Skip to content

Commit b43bb56

Browse files
committed
feat!: Add paginated loader to entity-database-adapter-knex
1 parent 56ec27d commit b43bb56

16 files changed

Lines changed: 1777 additions & 33 deletions

packages/entity-database-adapter-knex/src/AuthorizationResultBasedKnexEntityLoader.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
} from './BasePostgresEntityDatabaseAdapter';
1616
import { BaseSQLQueryBuilder } from './BaseSQLQueryBuilder';
1717
import { SQLFragment } from './SQLOperator';
18+
import type { Connection, PageInfo } from './internal/EntityKnexDataManager';
1819
import { EntityKnexDataManager } from './internal/EntityKnexDataManager';
1920

2021
export interface EntityLoaderOrderByClause<
@@ -75,6 +76,70 @@ export interface EntityLoaderQuerySelectionModifiersWithOrderByFragment<
7576
orderByFragment?: SQLFragment;
7677
}
7778

79+
/**
80+
* Base pagination arguments
81+
*/
82+
interface EntityLoaderBasePaginationArgs<
83+
TFields extends Record<string, any>,
84+
TSelectedFields extends keyof TFields,
85+
> {
86+
/**
87+
* SQLFragment representing the WHERE clause to filter the entities being paginated.
88+
*/
89+
where?: SQLFragment;
90+
91+
/**
92+
* Order the entities by specified columns and orders. If ID field is not included in the orderBy, it will be automatically included as the last orderBy field to ensure stable pagination.
93+
*/
94+
orderBy?: EntityLoaderOrderByClause<TFields, TSelectedFields>[];
95+
}
96+
97+
/**
98+
* Forward pagination arguments
99+
*/
100+
export interface EntityLoaderForwardPaginationArgs<
101+
TFields extends Record<string, any>,
102+
TSelectedFields extends keyof TFields,
103+
> extends EntityLoaderBasePaginationArgs<TFields, TSelectedFields> {
104+
/**
105+
* The number of entities to return starting from the entity after the cursor (for forward pagination). Must be a positive integer.
106+
*/
107+
first: number;
108+
109+
/**
110+
* The cursor to paginate after for forward pagination, typically an opaque string encoding of the values of the cursor fields of the last entity in the previous page. If not provided, pagination starts from the beginning of the result set.
111+
*/
112+
after?: string;
113+
}
114+
115+
/**
116+
* Backward pagination arguments
117+
*/
118+
export interface EntityLoaderBackwardPaginationArgs<
119+
TFields extends Record<string, any>,
120+
TSelectedFields extends keyof TFields,
121+
> extends EntityLoaderBasePaginationArgs<TFields, TSelectedFields> {
122+
/**
123+
* The number of entities to return starting from the entity before the cursor (for backward pagination). Must be a positive integer.
124+
*/
125+
last: number;
126+
127+
/**
128+
* The cursor to paginate before for backward pagination, typically an opaque string encoding of the values of the cursor fields of the first entity in the previous page. If not provided, pagination starts from the end of the result set.
129+
*/
130+
before?: string;
131+
}
132+
133+
/**
134+
* Load page pagination arguments, which can be either forward or backward pagination arguments.
135+
*/
136+
export type EntityLoaderLoadPageArgs<
137+
TFields extends Record<string, any>,
138+
TSelectedFields extends keyof TFields,
139+
> =
140+
| EntityLoaderForwardPaginationArgs<TFields, TSelectedFields>
141+
| EntityLoaderBackwardPaginationArgs<TFields, TSelectedFields>;
142+
78143
/**
79144
* Authorization-result-based knex entity loader for non-data-loader-based load methods.
80145
* All loads through this loader are results (or null for some loader methods), where an
@@ -200,6 +265,47 @@ export class AuthorizationResultBasedKnexEntityLoader<
200265
modifiers,
201266
);
202267
}
268+
269+
/**
270+
* Load a page of entities with Relay-style cursor pagination.
271+
* Only returns successfully authorized entities for cursor stability; failed authorization results are filtered out.
272+
*
273+
* @returns Connection with only successfully authorized entities
274+
*/
275+
async loadPageBySQLAsync(
276+
args: EntityLoaderLoadPageArgs<TFields, TSelectedFields>,
277+
): Promise<Connection<TEntity>> {
278+
const pageResult = await this.knexDataManager.loadPageBySQLFragmentAsync(
279+
this.queryContext,
280+
args,
281+
);
282+
283+
const edgeResults = await Promise.all(
284+
pageResult.edges.map(async (edge) => {
285+
const entityResult = await this.constructionUtils.constructAndAuthorizeEntityAsync(
286+
edge.node,
287+
);
288+
if (!entityResult.ok) {
289+
return null;
290+
}
291+
return {
292+
...edge,
293+
node: entityResult.value,
294+
};
295+
}),
296+
);
297+
const edges = edgeResults.filter((edge) => edge !== null);
298+
const pageInfo: PageInfo = {
299+
...pageResult.pageInfo,
300+
startCursor: edges[0]?.cursor ?? null,
301+
endCursor: edges[edges.length - 1]?.cursor ?? null,
302+
};
303+
304+
return {
305+
edges,
306+
pageInfo,
307+
};
308+
}
203309
}
204310

205311
/**

packages/entity-database-adapter-knex/src/EnforcingKnexEntityLoader.ts

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,23 @@
1-
import { EntityPrivacyPolicy, ReadonlyEntity, ViewerContext } from '@expo/entity';
1+
import {
2+
EntityConstructionUtils,
3+
EntityPrivacyPolicy,
4+
EntityQueryContext,
5+
IEntityMetricsAdapter,
6+
ReadonlyEntity,
7+
ViewerContext,
8+
} from '@expo/entity';
29

310
import {
411
AuthorizationResultBasedKnexEntityLoader,
12+
EntityLoaderLoadPageArgs,
513
EntityLoaderQuerySelectionModifiers,
614
EntityLoaderQuerySelectionModifiersWithOrderByFragment,
715
EntityLoaderQuerySelectionModifiersWithOrderByRaw,
816
} from './AuthorizationResultBasedKnexEntityLoader';
917
import { FieldEqualityCondition } from './BasePostgresEntityDatabaseAdapter';
1018
import { BaseSQLQueryBuilder } from './BaseSQLQueryBuilder';
1119
import { SQLFragment } from './SQLOperator';
20+
import type { Connection, EntityKnexDataManager } from './internal/EntityKnexDataManager';
1221

1322
/**
1423
* Enforcing knex entity loader for non-data-loader-based load methods.
@@ -37,6 +46,17 @@ export class EnforcingKnexEntityLoader<
3746
TPrivacyPolicy,
3847
TSelectedFields
3948
>,
49+
private readonly queryContext: EntityQueryContext,
50+
private readonly knexDataManager: EntityKnexDataManager<TFields, TIDField>,
51+
protected readonly metricsAdapter: IEntityMetricsAdapter,
52+
private readonly constructionUtils: EntityConstructionUtils<
53+
TFields,
54+
TIDField,
55+
TViewerContext,
56+
TEntity,
57+
TPrivacyPolicy,
58+
TSelectedFields
59+
>,
4060
) {}
4161

4262
/**
@@ -167,6 +187,59 @@ export class EnforcingKnexEntityLoader<
167187
> {
168188
return new EnforcingSQLQueryBuilder(this.knexEntityLoader, fragment, modifiers);
169189
}
190+
191+
/**
192+
* Load a page of entities with Relay-style cursor pagination.
193+
*
194+
* @param args - Pagination arguments with either first/after or last/before
195+
*
196+
* @example
197+
* ```typescript
198+
* // Forward pagination - get first 10 items
199+
* const users = await TestEntity.knexLoader(vc)
200+
* .loadPageBySQLAsync({
201+
* first: 10,
202+
* where: sql`age > ${18}`,
203+
* orderBy: 'created_at'
204+
* });
205+
*
206+
* // Backward pagination with cursor - get last 10 items before the cursor
207+
* const lastResults = await TestEntity.knexLoader(vc)
208+
* .loadPageBySQLAsync({
209+
* last: 10,
210+
* where: sql`status = ${'active'}`,
211+
* before: cursor,
212+
* });
213+
* ```
214+
*
215+
* @throws EntityNotAuthorizedError if viewer is not authorized to view any returned entity
216+
*/
217+
async loadPageBySQLAsync(
218+
args: EntityLoaderLoadPageArgs<TFields, TSelectedFields>,
219+
): Promise<Connection<TEntity>> {
220+
const pageResult = await this.knexDataManager.loadPageBySQLFragmentAsync(
221+
this.queryContext,
222+
args,
223+
);
224+
225+
const edges = await Promise.all(
226+
pageResult.edges.map(async (edge) => {
227+
const entityResult = await this.constructionUtils.constructAndAuthorizeEntityAsync(
228+
edge.node,
229+
);
230+
const entity = entityResult.enforceValue();
231+
return {
232+
...edge,
233+
node: entity,
234+
};
235+
}),
236+
);
237+
238+
return {
239+
edges,
240+
pageInfo: pageResult.pageInfo,
241+
};
242+
}
170243
}
171244

172245
/**

packages/entity-database-adapter-knex/src/KnexEntityLoader.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,10 @@ export class KnexEntityLoader<
5454
TPrivacyPolicy,
5555
TSelectedFields
5656
> {
57-
return new EnforcingKnexEntityLoader(this.withAuthorizationResults());
57+
return this.viewerContext
58+
.getViewerScopedEntityCompanionForClass(this.entityClass)
59+
.getKnexLoaderFactory()
60+
.forLoadEnforcing(this.queryContext, { previousValue: null, cascadingDeleteCause: null });
5861
}
5962

6063
/**

packages/entity-database-adapter-knex/src/KnexEntityLoaderFactory.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
import { EntityConstructionUtils } from '@expo/entity/src/EntityConstructionUtils';
1111

1212
import { AuthorizationResultBasedKnexEntityLoader } from './AuthorizationResultBasedKnexEntityLoader';
13+
import { EnforcingKnexEntityLoader } from './EnforcingKnexEntityLoader';
1314
import { EntityKnexDataManager } from './internal/EntityKnexDataManager';
1415

1516
/**
@@ -83,4 +84,47 @@ export class KnexEntityLoaderFactory<
8384
constructionUtils,
8485
);
8586
}
87+
88+
/**
89+
* Vend enforcing knex loader for loading an entity in a given query context.
90+
* @param viewerContext - viewer context of loading user
91+
* @param queryContext - query context in which to perform the load
92+
*/
93+
forLoadEnforcing(
94+
viewerContext: TViewerContext,
95+
queryContext: EntityQueryContext,
96+
privacyPolicyEvaluationContext: EntityPrivacyPolicyEvaluationContext<
97+
TFields,
98+
TIDField,
99+
TViewerContext,
100+
TEntity,
101+
TSelectedFields
102+
>,
103+
): EnforcingKnexEntityLoader<
104+
TFields,
105+
TIDField,
106+
TViewerContext,
107+
TEntity,
108+
TPrivacyPolicy,
109+
TSelectedFields
110+
> {
111+
const constructionUtils = new EntityConstructionUtils(
112+
viewerContext,
113+
queryContext,
114+
privacyPolicyEvaluationContext,
115+
this.entityCompanion.entityCompanionDefinition.entityConfiguration,
116+
this.entityCompanion.entityCompanionDefinition.entityClass,
117+
this.entityCompanion.entityCompanionDefinition.entitySelectedFields,
118+
this.entityCompanion.privacyPolicy,
119+
this.metricsAdapter,
120+
);
121+
122+
return new EnforcingKnexEntityLoader(
123+
this.forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext),
124+
queryContext,
125+
this.knexDataManager,
126+
this.metricsAdapter,
127+
constructionUtils,
128+
);
129+
}
86130
}

packages/entity-database-adapter-knex/src/ViewerScopedKnexEntityLoaderFactory.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
} from '@expo/entity';
88

99
import { AuthorizationResultBasedKnexEntityLoader } from './AuthorizationResultBasedKnexEntityLoader';
10+
import { EnforcingKnexEntityLoader } from './EnforcingKnexEntityLoader';
1011
import { KnexEntityLoaderFactory } from './KnexEntityLoaderFactory';
1112

1213
/**
@@ -61,4 +62,28 @@ export class ViewerScopedKnexEntityLoaderFactory<
6162
privacyPolicyEvaluationContext,
6263
);
6364
}
65+
66+
forLoadEnforcing(
67+
queryContext: EntityQueryContext,
68+
privacyPolicyEvaluationContext: EntityPrivacyPolicyEvaluationContext<
69+
TFields,
70+
TIDField,
71+
TViewerContext,
72+
TEntity,
73+
TSelectedFields
74+
>,
75+
): EnforcingKnexEntityLoader<
76+
TFields,
77+
TIDField,
78+
TViewerContext,
79+
TEntity,
80+
TPrivacyPolicy,
81+
TSelectedFields
82+
> {
83+
return this.knexEntityLoaderFactory.forLoadEnforcing(
84+
this.viewerContext,
85+
queryContext,
86+
privacyPolicyEvaluationContext,
87+
);
88+
}
6489
}

0 commit comments

Comments
 (0)