Skip to content

Commit 33e8e6f

Browse files
committed
feat!: Add paginated loader to entity-database-adapter-knex
1 parent 64712a7 commit 33e8e6f

16 files changed

Lines changed: 1771 additions & 33 deletions

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

Lines changed: 102 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,50 @@ 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+
where?: SQLFragment;
87+
orderBy?: EntityLoaderOrderByClause<TFields, TSelectedFields>[];
88+
cursorFields?: readonly (keyof TFields)[];
89+
}
90+
91+
/**
92+
* Forward pagination arguments
93+
*/
94+
export interface EntityLoaderForwardPaginationArgs<
95+
TFields extends Record<string, any>,
96+
TSelectedFields extends keyof TFields,
97+
> extends EntityLoaderBasePaginationArgs<TFields, TSelectedFields> {
98+
first: number;
99+
after?: string;
100+
}
101+
102+
/**
103+
* Backward pagination arguments
104+
*/
105+
export interface EntityLoaderBackwardPaginationArgs<
106+
TFields extends Record<string, any>,
107+
TSelectedFields extends keyof TFields,
108+
> extends EntityLoaderBasePaginationArgs<TFields, TSelectedFields> {
109+
last: number;
110+
before?: string;
111+
}
112+
113+
/**
114+
* Combined pagination arguments using discriminated union
115+
*/
116+
export type EntityLoaderLoadPageArgs<
117+
TFields extends Record<string, any>,
118+
TSelectedFields extends keyof TFields,
119+
> =
120+
| EntityLoaderForwardPaginationArgs<TFields, TSelectedFields>
121+
| EntityLoaderBackwardPaginationArgs<TFields, TSelectedFields>;
122+
78123
/**
79124
* Authorization-result-based knex entity loader for non-data-loader-based load methods.
80125
* All loads through this loader are results (or null for some loader methods), where an
@@ -200,6 +245,63 @@ export class AuthorizationResultBasedKnexEntityLoader<
200245
modifiers,
201246
);
202247
}
248+
249+
/**
250+
* Load a page of entities with Relay-style cursor pagination.
251+
* Only returns successfully authorized entities for cursor stability; failed authorization results are filtered out.
252+
*
253+
* @returns Connection with only successfully authorized entities
254+
*
255+
* @example
256+
* ```typescript
257+
* // Forward pagination - get first 10 items
258+
* const results = await TestEntity.knexLoader(vc)
259+
* .loadPageBySQLAsync({
260+
* first: 10,
261+
* where: sql`age > ${18}`,
262+
* orderBy: [{ fieldName: 'created_at', order: OrderByOrdering.ASCENDING }]
263+
* });
264+
*
265+
* // All returned entities are successfully authorized
266+
* for (const edge of results.edges) {
267+
* console.log(edge.node.getID());
268+
* }
269+
* ```
270+
*/
271+
async loadPageBySQLAsync(
272+
args: EntityLoaderLoadPageArgs<TFields, TSelectedFields>,
273+
): Promise<Connection<TEntity>> {
274+
const pageResult = await this.knexDataManager.loadPageBySQLFragmentAsync(
275+
this.queryContext,
276+
args,
277+
);
278+
279+
const edgeResults = await Promise.all(
280+
pageResult.edges.map(async (edge) => {
281+
const entityResult = await this.constructionUtils.constructAndAuthorizeEntityAsync(
282+
edge.node,
283+
);
284+
if (!entityResult.ok) {
285+
return null;
286+
}
287+
return {
288+
...edge,
289+
node: entityResult.value,
290+
};
291+
}),
292+
);
293+
const edges = edgeResults.filter((edge) => edge !== null);
294+
const pageInfo: PageInfo = {
295+
...pageResult.pageInfo,
296+
startCursor: edges[0]?.cursor ?? null,
297+
endCursor: edges[edges.length - 1]?.cursor ?? null,
298+
};
299+
300+
return {
301+
edges,
302+
pageInfo,
303+
};
304+
}
203305
}
204306

205307
/**

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+
* Query entities with cursor-based 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 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)