From 7f45f6016a3b0e6276ad4bb9312a83e29243c667 Mon Sep 17 00:00:00 2001 From: Will Schurman Date: Thu, 5 Feb 2026 09:40:59 -0800 Subject: [PATCH] feat!: Add paginated loader to entity-database-adapter-knex --- ...uthorizationResultBasedKnexEntityLoader.ts | 106 +++++ .../src/EnforcingKnexEntityLoader.ts | 75 ++- .../src/KnexEntityLoader.ts | 5 +- .../src/KnexEntityLoaderFactory.ts | 44 ++ .../ViewerScopedKnexEntityLoaderFactory.ts | 25 + .../PostgresEntityIntegration-test.ts | 414 ++++++++++++++++ ...izationResultBasedKnexEntityLoader-test.ts | 444 ++++++++++++++++++ .../EnforcingKnexEntityLoader-test.ts | 281 +++++++++-- .../fixtures/TestPaginationEntity.ts | 102 ++++ .../EntityTableDataCoordinatorExtensions.ts | 1 + .../src/internal/EntityKnexDataManager.ts | 279 ++++++++++- .../__tests__/EntityKnexDataManager-test.ts | 9 +- .../src/errors/EntityDatabaseAdapterError.ts | 14 + packages/entity/src/errors/EntityError.ts | 1 + .../EntityDatabaseAdapterError-test.ts | 9 + .../src/metrics/IEntityMetricsAdapter.ts | 1 + 16 files changed, 1777 insertions(+), 33 deletions(-) create mode 100644 packages/entity-database-adapter-knex/src/__tests__/fixtures/TestPaginationEntity.ts diff --git a/packages/entity-database-adapter-knex/src/AuthorizationResultBasedKnexEntityLoader.ts b/packages/entity-database-adapter-knex/src/AuthorizationResultBasedKnexEntityLoader.ts index 6e0bfe60d..35f7b6c91 100644 --- a/packages/entity-database-adapter-knex/src/AuthorizationResultBasedKnexEntityLoader.ts +++ b/packages/entity-database-adapter-knex/src/AuthorizationResultBasedKnexEntityLoader.ts @@ -15,6 +15,7 @@ import { } from './BasePostgresEntityDatabaseAdapter'; import { BaseSQLQueryBuilder } from './BaseSQLQueryBuilder'; import { SQLFragment } from './SQLOperator'; +import type { Connection, PageInfo } from './internal/EntityKnexDataManager'; import { EntityKnexDataManager } from './internal/EntityKnexDataManager'; export interface EntityLoaderOrderByClause< @@ -75,6 +76,70 @@ export interface EntityLoaderQuerySelectionModifiersWithOrderByFragment< orderByFragment?: SQLFragment; } +/** + * Base pagination arguments + */ +interface EntityLoaderBasePaginationArgs< + TFields extends Record, + TSelectedFields extends keyof TFields, +> { + /** + * SQLFragment representing the WHERE clause to filter the entities being paginated. + */ + where?: SQLFragment; + + /** + * Order the entities by specified columns and orders. If the ID field is not included in the orderBy, it will be automatically included as the last orderBy field to ensure stable pagination. + */ + orderBy?: EntityLoaderOrderByClause[]; +} + +/** + * Forward pagination arguments + */ +export interface EntityLoaderForwardPaginationArgs< + TFields extends Record, + TSelectedFields extends keyof TFields, +> extends EntityLoaderBasePaginationArgs { + /** + * The number of entities to return starting from the entity after the cursor (for forward pagination). Must be a positive integer. + */ + first: number; + + /** + * 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. + */ + after?: string; +} + +/** + * Backward pagination arguments + */ +export interface EntityLoaderBackwardPaginationArgs< + TFields extends Record, + TSelectedFields extends keyof TFields, +> extends EntityLoaderBasePaginationArgs { + /** + * The number of entities to return starting from the entity before the cursor (for backward pagination). Must be a positive integer. + */ + last: number; + + /** + * 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. + */ + before?: string; +} + +/** + * Load page pagination arguments, which can be either forward or backward pagination arguments. + */ +export type EntityLoaderLoadPageArgs< + TFields extends Record, + TSelectedFields extends keyof TFields, +> = + | EntityLoaderForwardPaginationArgs + | EntityLoaderBackwardPaginationArgs; + /** * Authorization-result-based knex entity loader for non-data-loader-based load methods. * All loads through this loader are results (or null for some loader methods), where an @@ -200,6 +265,47 @@ export class AuthorizationResultBasedKnexEntityLoader< modifiers, ); } + + /** + * Load a page of entities with Relay-style cursor pagination. + * Only returns successfully authorized entities for cursor stability; failed authorization results are filtered out. + * + * @returns Connection with only successfully authorized entities + */ + async loadPageBySQLAsync( + args: EntityLoaderLoadPageArgs, + ): Promise> { + const pageResult = await this.knexDataManager.loadPageBySQLFragmentAsync( + this.queryContext, + args, + ); + + const edgeResults = await Promise.all( + pageResult.edges.map(async (edge) => { + const entityResult = await this.constructionUtils.constructAndAuthorizeEntityAsync( + edge.node, + ); + if (!entityResult.ok) { + return null; + } + return { + ...edge, + node: entityResult.value, + }; + }), + ); + const edges = edgeResults.filter((edge) => edge !== null); + const pageInfo: PageInfo = { + ...pageResult.pageInfo, + startCursor: edges[0]?.cursor ?? null, + endCursor: edges[edges.length - 1]?.cursor ?? null, + }; + + return { + edges, + pageInfo, + }; + } } /** diff --git a/packages/entity-database-adapter-knex/src/EnforcingKnexEntityLoader.ts b/packages/entity-database-adapter-knex/src/EnforcingKnexEntityLoader.ts index eb91ed17c..9f3c0fa63 100644 --- a/packages/entity-database-adapter-knex/src/EnforcingKnexEntityLoader.ts +++ b/packages/entity-database-adapter-knex/src/EnforcingKnexEntityLoader.ts @@ -1,7 +1,15 @@ -import { EntityPrivacyPolicy, ReadonlyEntity, ViewerContext } from '@expo/entity'; +import { + EntityConstructionUtils, + EntityPrivacyPolicy, + EntityQueryContext, + IEntityMetricsAdapter, + ReadonlyEntity, + ViewerContext, +} from '@expo/entity'; import { AuthorizationResultBasedKnexEntityLoader, + EntityLoaderLoadPageArgs, EntityLoaderQuerySelectionModifiers, EntityLoaderQuerySelectionModifiersWithOrderByFragment, EntityLoaderQuerySelectionModifiersWithOrderByRaw, @@ -9,6 +17,7 @@ import { import { FieldEqualityCondition } from './BasePostgresEntityDatabaseAdapter'; import { BaseSQLQueryBuilder } from './BaseSQLQueryBuilder'; import { SQLFragment } from './SQLOperator'; +import type { Connection, EntityKnexDataManager } from './internal/EntityKnexDataManager'; /** * Enforcing knex entity loader for non-data-loader-based load methods. @@ -37,6 +46,17 @@ export class EnforcingKnexEntityLoader< TPrivacyPolicy, TSelectedFields >, + private readonly queryContext: EntityQueryContext, + private readonly knexDataManager: EntityKnexDataManager, + protected readonly metricsAdapter: IEntityMetricsAdapter, + private readonly constructionUtils: EntityConstructionUtils< + TFields, + TIDField, + TViewerContext, + TEntity, + TPrivacyPolicy, + TSelectedFields + >, ) {} /** @@ -167,6 +187,59 @@ export class EnforcingKnexEntityLoader< > { return new EnforcingSQLQueryBuilder(this.knexEntityLoader, fragment, modifiers); } + + /** + * Load a page of entities with Relay-style cursor pagination. + * + * @param args - Pagination arguments with either first/after or last/before + * + * @example + * ```typescript + * // Forward pagination - get first 10 items + * const users = await TestEntity.knexLoader(vc) + * .loadPageBySQLAsync({ + * first: 10, + * where: sql`age > ${18}`, + * orderBy: 'created_at' + * }); + * + * // Backward pagination with cursor - get last 10 items before the cursor + * const lastResults = await TestEntity.knexLoader(vc) + * .loadPageBySQLAsync({ + * last: 10, + * where: sql`status = ${'active'}`, + * before: cursor, + * }); + * ``` + * + * @throws EntityNotAuthorizedError if viewer is not authorized to view any returned entity + */ + async loadPageBySQLAsync( + args: EntityLoaderLoadPageArgs, + ): Promise> { + const pageResult = await this.knexDataManager.loadPageBySQLFragmentAsync( + this.queryContext, + args, + ); + + const edges = await Promise.all( + pageResult.edges.map(async (edge) => { + const entityResult = await this.constructionUtils.constructAndAuthorizeEntityAsync( + edge.node, + ); + const entity = entityResult.enforceValue(); + return { + ...edge, + node: entity, + }; + }), + ); + + return { + edges, + pageInfo: pageResult.pageInfo, + }; + } } /** diff --git a/packages/entity-database-adapter-knex/src/KnexEntityLoader.ts b/packages/entity-database-adapter-knex/src/KnexEntityLoader.ts index f7ca901b9..e77a9e7ed 100644 --- a/packages/entity-database-adapter-knex/src/KnexEntityLoader.ts +++ b/packages/entity-database-adapter-knex/src/KnexEntityLoader.ts @@ -54,7 +54,10 @@ export class KnexEntityLoader< TPrivacyPolicy, TSelectedFields > { - return new EnforcingKnexEntityLoader(this.withAuthorizationResults()); + return this.viewerContext + .getViewerScopedEntityCompanionForClass(this.entityClass) + .getKnexLoaderFactory() + .forLoadEnforcing(this.queryContext, { previousValue: null, cascadingDeleteCause: null }); } /** diff --git a/packages/entity-database-adapter-knex/src/KnexEntityLoaderFactory.ts b/packages/entity-database-adapter-knex/src/KnexEntityLoaderFactory.ts index 1e58920a0..8d0890a83 100644 --- a/packages/entity-database-adapter-knex/src/KnexEntityLoaderFactory.ts +++ b/packages/entity-database-adapter-knex/src/KnexEntityLoaderFactory.ts @@ -10,6 +10,7 @@ import { import { EntityConstructionUtils } from '@expo/entity/src/EntityConstructionUtils'; import { AuthorizationResultBasedKnexEntityLoader } from './AuthorizationResultBasedKnexEntityLoader'; +import { EnforcingKnexEntityLoader } from './EnforcingKnexEntityLoader'; import { EntityKnexDataManager } from './internal/EntityKnexDataManager'; /** @@ -83,4 +84,47 @@ export class KnexEntityLoaderFactory< constructionUtils, ); } + + /** + * Vend enforcing knex loader for loading an entity in a given query context. + * @param viewerContext - viewer context of loading user + * @param queryContext - query context in which to perform the load + */ + forLoadEnforcing( + viewerContext: TViewerContext, + queryContext: EntityQueryContext, + privacyPolicyEvaluationContext: EntityPrivacyPolicyEvaluationContext< + TFields, + TIDField, + TViewerContext, + TEntity, + TSelectedFields + >, + ): EnforcingKnexEntityLoader< + TFields, + TIDField, + TViewerContext, + TEntity, + TPrivacyPolicy, + TSelectedFields + > { + const constructionUtils = new EntityConstructionUtils( + viewerContext, + queryContext, + privacyPolicyEvaluationContext, + this.entityCompanion.entityCompanionDefinition.entityConfiguration, + this.entityCompanion.entityCompanionDefinition.entityClass, + this.entityCompanion.entityCompanionDefinition.entitySelectedFields, + this.entityCompanion.privacyPolicy, + this.metricsAdapter, + ); + + return new EnforcingKnexEntityLoader( + this.forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext), + queryContext, + this.knexDataManager, + this.metricsAdapter, + constructionUtils, + ); + } } diff --git a/packages/entity-database-adapter-knex/src/ViewerScopedKnexEntityLoaderFactory.ts b/packages/entity-database-adapter-knex/src/ViewerScopedKnexEntityLoaderFactory.ts index 32a60cbf4..3e47162f5 100644 --- a/packages/entity-database-adapter-knex/src/ViewerScopedKnexEntityLoaderFactory.ts +++ b/packages/entity-database-adapter-knex/src/ViewerScopedKnexEntityLoaderFactory.ts @@ -7,6 +7,7 @@ import { } from '@expo/entity'; import { AuthorizationResultBasedKnexEntityLoader } from './AuthorizationResultBasedKnexEntityLoader'; +import { EnforcingKnexEntityLoader } from './EnforcingKnexEntityLoader'; import { KnexEntityLoaderFactory } from './KnexEntityLoaderFactory'; /** @@ -61,4 +62,28 @@ export class ViewerScopedKnexEntityLoaderFactory< privacyPolicyEvaluationContext, ); } + + forLoadEnforcing( + queryContext: EntityQueryContext, + privacyPolicyEvaluationContext: EntityPrivacyPolicyEvaluationContext< + TFields, + TIDField, + TViewerContext, + TEntity, + TSelectedFields + >, + ): EnforcingKnexEntityLoader< + TFields, + TIDField, + TViewerContext, + TEntity, + TPrivacyPolicy, + TSelectedFields + > { + return this.knexEntityLoaderFactory.forLoadEnforcing( + this.viewerContext, + queryContext, + privacyPolicyEvaluationContext, + ); + } } diff --git a/packages/entity-database-adapter-knex/src/__integration-tests__/PostgresEntityIntegration-test.ts b/packages/entity-database-adapter-knex/src/__integration-tests__/PostgresEntityIntegration-test.ts index 6c0df0856..7848f5463 100644 --- a/packages/entity-database-adapter-knex/src/__integration-tests__/PostgresEntityIntegration-test.ts +++ b/packages/entity-database-adapter-knex/src/__integration-tests__/PostgresEntityIntegration-test.ts @@ -1358,4 +1358,418 @@ describe('postgres entity integration', () => { expect(postCommitCallCount).toBe(2); }); }); + + describe('pagination with loadPageBySQLAsync', () => { + beforeEach(async () => { + const vc = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance)); + + // Create test data with predictable values + const names = ['Alice', 'Bob', 'Charlie', 'David', 'Eve', 'Frank', 'Grace', 'Henry']; + for (let i = 0; i < names.length; i++) { + await PostgresTestEntity.creator(vc) + .setField('name', names[i]!) + .setField('hasACat', i % 2 === 0) + .setField('hasADog', i % 3 === 0) + .setField('dateField', new Date(2024, 0, i + 1)) + .createAsync(); + } + }); + + it('performs forward pagination with first/after', async () => { + const vc = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance)); + + // Get first page + const firstPage = await PostgresTestEntity.knexLoader(vc).loadPageBySQLAsync({ + first: 3, + orderBy: [{ fieldName: 'name', order: OrderByOrdering.ASCENDING }], + }); + + expect(firstPage.edges).toHaveLength(3); + expect(firstPage.edges[0]?.node.getField('name')).toBe('Alice'); + expect(firstPage.edges[1]?.node.getField('name')).toBe('Bob'); + expect(firstPage.edges[2]?.node.getField('name')).toBe('Charlie'); + expect(firstPage.pageInfo.hasNextPage).toBe(true); + expect(firstPage.pageInfo.hasPreviousPage).toBe(false); + + // Get second page using cursor + const secondPage = await PostgresTestEntity.knexLoader(vc).loadPageBySQLAsync({ + first: 3, + after: firstPage.pageInfo.endCursor!, + orderBy: [{ fieldName: 'name', order: OrderByOrdering.ASCENDING }], + }); + + expect(secondPage.edges).toHaveLength(3); + expect(secondPage.edges[0]?.node.getField('name')).toBe('David'); + expect(secondPage.edges[1]?.node.getField('name')).toBe('Eve'); + expect(secondPage.edges[2]?.node.getField('name')).toBe('Frank'); + expect(secondPage.pageInfo.hasNextPage).toBe(true); + expect(secondPage.pageInfo.hasPreviousPage).toBe(false); + }); + + it('performs backward pagination with last/before', async () => { + const vc = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance)); + + // Get last page + const lastPage = await PostgresTestEntity.knexLoader(vc).loadPageBySQLAsync({ + last: 3, + orderBy: [{ fieldName: 'name', order: OrderByOrdering.ASCENDING }], + }); + + expect(lastPage.edges).toHaveLength(3); + expect(lastPage.edges[0]?.node.getField('name')).toBe('Frank'); + expect(lastPage.edges[1]?.node.getField('name')).toBe('Grace'); + expect(lastPage.edges[2]?.node.getField('name')).toBe('Henry'); + expect(lastPage.pageInfo.hasNextPage).toBe(false); + expect(lastPage.pageInfo.hasPreviousPage).toBe(true); + + // Get previous page using cursor + const previousPage = await PostgresTestEntity.knexLoader(vc).loadPageBySQLAsync({ + last: 3, + before: lastPage.pageInfo.startCursor!, + orderBy: [{ fieldName: 'name', order: OrderByOrdering.ASCENDING }], + }); + + expect(previousPage.edges).toHaveLength(3); + expect(previousPage.edges[0]?.node.getField('name')).toBe('Charlie'); + expect(previousPage.edges[1]?.node.getField('name')).toBe('David'); + expect(previousPage.edges[2]?.node.getField('name')).toBe('Eve'); + expect(previousPage.pageInfo.hasNextPage).toBe(false); + expect(previousPage.pageInfo.hasPreviousPage).toBe(true); + }); + + it('supports pagination with SQL where conditions', async () => { + const vc = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance)); + + // Query only entities with cats + const page = await PostgresTestEntity.knexLoader(vc).loadPageBySQLAsync({ + first: 2, + where: sql`has_a_cat = ${true}`, + orderBy: [{ fieldName: 'name', order: OrderByOrdering.ASCENDING }], + }); + + expect(page.edges).toHaveLength(2); + expect(page.edges[0]?.node.getField('name')).toBe('Alice'); + expect(page.edges[0]?.node.getField('hasACat')).toBe(true); + expect(page.edges[1]?.node.getField('name')).toBe('Charlie'); + expect(page.edges[1]?.node.getField('hasACat')).toBe(true); + expect(page.pageInfo.hasNextPage).toBe(true); + + // Get next page with same where condition + const nextPage = await PostgresTestEntity.knexLoader(vc).loadPageBySQLAsync({ + first: 2, + after: page.pageInfo.endCursor!, + where: sql`has_a_cat = ${true}`, + orderBy: [{ fieldName: 'name', order: OrderByOrdering.ASCENDING }], + }); + + expect(nextPage.edges).toHaveLength(2); + expect(nextPage.edges[0]?.node.getField('name')).toBe('Eve'); + expect(nextPage.edges[0]?.node.getField('hasACat')).toBe(true); + expect(nextPage.edges[1]?.node.getField('name')).toBe('Grace'); + expect(nextPage.edges[1]?.node.getField('hasACat')).toBe(true); + expect(nextPage.pageInfo.hasNextPage).toBe(false); + }); + + it('supports pagination with multiple orderBy fields', async () => { + const vc = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance)); + + const page = await PostgresTestEntity.knexLoader(vc).loadPageBySQLAsync({ + first: 4, + orderBy: [ + { fieldName: 'hasACat', order: OrderByOrdering.DESCENDING }, + { fieldName: 'name', order: OrderByOrdering.ASCENDING }, + ], + }); + + // Entities with cats (true) come first, then sorted by name + expect(page.edges).toHaveLength(4); + expect(page.edges[0]?.node.getField('hasACat')).toBe(true); + expect(page.edges[0]?.node.getField('name')).toBe('Alice'); + expect(page.edges[1]?.node.getField('hasACat')).toBe(true); + expect(page.edges[1]?.node.getField('name')).toBe('Charlie'); + expect(page.edges[2]?.node.getField('hasACat')).toBe(true); + expect(page.edges[2]?.node.getField('name')).toBe('Eve'); + expect(page.edges[3]?.node.getField('hasACat')).toBe(true); + expect(page.edges[3]?.node.getField('name')).toBe('Grace'); + }); + + it('handles empty results correctly', async () => { + const vc = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance)); + + const page = await PostgresTestEntity.knexLoader(vc).loadPageBySQLAsync({ + first: 10, + where: sql`name = ${'NonexistentName'}`, + orderBy: [{ fieldName: 'name', order: OrderByOrdering.ASCENDING }], + }); + + expect(page.edges).toHaveLength(0); + expect(page.pageInfo.hasNextPage).toBe(false); + expect(page.pageInfo.hasPreviousPage).toBe(false); + expect(page.pageInfo.startCursor).toBeNull(); + expect(page.pageInfo.endCursor).toBeNull(); + }); + + it('includes cursors for each edge', async () => { + const vc = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance)); + + const page = await PostgresTestEntity.knexLoader(vc).loadPageBySQLAsync({ + first: 3, + orderBy: [{ fieldName: 'name', order: OrderByOrdering.ASCENDING }], + }); + + // Each edge should have a cursor + expect(page.edges[0]?.cursor).toBeTruthy(); + expect(page.edges[1]?.cursor).toBeTruthy(); + expect(page.edges[2]?.cursor).toBeTruthy(); + + // Start from middle item + const nextPage = await PostgresTestEntity.knexLoader(vc).loadPageBySQLAsync({ + first: 2, + after: page.edges[1]!.cursor, + orderBy: [{ fieldName: 'name', order: OrderByOrdering.ASCENDING }], + }); + + expect(nextPage.edges).toHaveLength(2); + expect(nextPage.edges[0]?.node.getField('name')).toBe('Charlie'); + expect(nextPage.edges[1]?.node.getField('name')).toBe('David'); + }); + + it('derives postgres cursor fields from orderBy', async () => { + const vc = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance)); + + const page = await PostgresTestEntity.knexLoader(vc).loadPageBySQLAsync({ + first: 3, + orderBy: [{ fieldName: 'dateField', order: OrderByOrdering.ASCENDING }], + }); + + expect(page.edges).toHaveLength(3); + + // Navigate using cursor + const nextPage = await PostgresTestEntity.knexLoader(vc).loadPageBySQLAsync({ + first: 3, + after: page.pageInfo.endCursor!, + orderBy: [{ fieldName: 'dateField', order: OrderByOrdering.ASCENDING }], + }); + + expect(nextPage.edges).toHaveLength(3); + expect(nextPage.pageInfo.hasNextPage).toBe(true); + }); + + it('performs backward pagination with descending order', async () => { + const vc = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance)); + + // Create test data with names that sort in a specific order + await PostgresTestEntity.dropPostgresTableAsync(knexInstance); + await PostgresTestEntity.createOrTruncatePostgresTableAsync(knexInstance); + + const entities = []; + for (let i = 1; i <= 5; i++) { + const entity = await PostgresTestEntity.creator(vc) + .setField('name', `Z_Item_${i}`) // Z_Item_1, Z_Item_2, Z_Item_3, Z_Item_4, Z_Item_5 + .createAsync(); + entities.push(entity); + } + + // Test backward pagination with DESCENDING order + // This internally flips DESCENDING to ASCENDING for the query + const page = await PostgresTestEntity.knexLoader(vc).loadPageBySQLAsync({ + last: 3, + orderBy: [{ fieldName: 'name', order: OrderByOrdering.DESCENDING }], + }); + + // With `last: 3` and DESCENDING order, we get the last 3 items when sorted descending + // Sorted descending: Z_Item_5, Z_Item_4, Z_Item_3, Z_Item_2, Z_Item_1 + // Last 3: Z_Item_3, Z_Item_2, Z_Item_1 + expect(page.edges).toHaveLength(3); + expect(page.edges[0]?.node.getField('name')).toBe('Z_Item_3'); + expect(page.edges[1]?.node.getField('name')).toBe('Z_Item_2'); + expect(page.edges[2]?.node.getField('name')).toBe('Z_Item_1'); + expect(page.pageInfo.hasPreviousPage).toBe(true); + expect(page.pageInfo.hasNextPage).toBe(false); + + // Verify the order is maintained correctly with forward pagination too + const forwardPage = await PostgresTestEntity.knexLoader(vc).loadPageBySQLAsync({ + first: 3, + orderBy: [{ fieldName: 'name', order: OrderByOrdering.DESCENDING }], + }); + + // First 3 in descending order + expect(forwardPage.edges).toHaveLength(3); + expect(forwardPage.edges[0]?.node.getField('name')).toBe('Z_Item_5'); + expect(forwardPage.edges[1]?.node.getField('name')).toBe('Z_Item_4'); + expect(forwardPage.edges[2]?.node.getField('name')).toBe('Z_Item_3'); + expect(forwardPage.pageInfo.hasNextPage).toBe(true); + expect(forwardPage.pageInfo.hasPreviousPage).toBe(false); + }); + + it('always includes ID field in orderBy for stability', async () => { + const vc = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance)); + + // Create entities with duplicate values to test stability + await PostgresTestEntity.dropPostgresTableAsync(knexInstance); + await PostgresTestEntity.createOrTruncatePostgresTableAsync(knexInstance); + + const entities = []; + for (let i = 1; i <= 6; i++) { + const entity = await PostgresTestEntity.creator(vc) + .setField('name', `Test${Math.floor((i - 1) / 2)}`) // Creates duplicates: Test0, Test0, Test1, Test1, Test2, Test2 + .setField('hasACat', i % 2 === 0) + .createAsync(); + entities.push(entity); + } + + // Pagination with only name in orderBy - ID should be added automatically for stability + const firstPage = await PostgresTestEntity.knexLoader(vc).loadPageBySQLAsync({ + first: 3, + orderBy: [{ fieldName: 'name', order: OrderByOrdering.ASCENDING }], + }); + + expect(firstPage.edges).toHaveLength(3); + + // Get second page + const secondPage = await PostgresTestEntity.knexLoader(vc).loadPageBySQLAsync({ + first: 3, + after: firstPage.pageInfo.endCursor!, + orderBy: [{ fieldName: 'name', order: OrderByOrdering.ASCENDING }], + }); + + expect(secondPage.edges).toHaveLength(3); + + // Ensure no overlap between pages (stability check) + const firstPageIds = firstPage.edges.map((e) => e.node.getID()); + const secondPageIds = secondPage.edges.map((e) => e.node.getID()); + const intersection = firstPageIds.filter((id) => secondPageIds.includes(id)); + expect(intersection).toHaveLength(0); + + // Test with explicit ID in orderBy (shouldn't duplicate) + const pageWithExplicitId = await PostgresTestEntity.knexLoader(vc).loadPageBySQLAsync({ + first: 3, + orderBy: [ + { fieldName: 'name', order: OrderByOrdering.ASCENDING }, + { fieldName: 'id', order: OrderByOrdering.ASCENDING }, + ], + }); + + expect(pageWithExplicitId.edges).toHaveLength(3); + }); + + it('throws error for invalid cursor format', async () => { + const vc = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance)); + + // Try with completely invalid cursor + await expect( + PostgresTestEntity.knexLoader(vc).loadPageBySQLAsync({ + first: 10, + after: 'not-a-valid-cursor', + orderBy: [{ fieldName: 'name', order: OrderByOrdering.ASCENDING }], + }), + ).rejects.toThrow('Failed to decode cursor'); + + // Try with valid base64 but invalid JSON + const invalidJsonCursor = Buffer.from('not json').toString('base64url'); + await expect( + PostgresTestEntity.knexLoader(vc).loadPageBySQLAsync({ + first: 10, + after: invalidJsonCursor, + orderBy: [{ fieldName: 'name', order: OrderByOrdering.ASCENDING }], + }), + ).rejects.toThrow('Failed to decode cursor'); + + // Try with valid JSON but missing required fields + const missingFieldsCursor = Buffer.from(JSON.stringify({ some: 'field' })).toString( + 'base64url', + ); + await expect( + PostgresTestEntity.knexLoader(vc).loadPageBySQLAsync({ + first: 10, + after: missingFieldsCursor, + orderBy: [{ fieldName: 'name', order: OrderByOrdering.ASCENDING }], + }), + ).rejects.toThrow("Cursor is missing required 'id' field."); + }); + + it('performs pagination with both loader types', async () => { + const vc = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance)); + + await PostgresTestEntity.dropPostgresTableAsync(knexInstance); + await PostgresTestEntity.createOrTruncatePostgresTableAsync(knexInstance); + + // Create entities with different names + const names = ['Alice', 'Bob', 'Charlie', 'David', 'Eve', 'Frank']; + for (const name of names) { + await PostgresTestEntity.creator(vc).setField('name', name).createAsync(); + } + + // Test with enforcing loader (standard pagination) + const pageEnforced = await PostgresTestEntity.knexLoader(vc).loadPageBySQLAsync({ + first: 4, + orderBy: [{ fieldName: 'name', order: OrderByOrdering.ASCENDING }], + }); + + // Should return entities directly + expect(pageEnforced.edges).toHaveLength(4); + expect(pageEnforced.edges[0]?.node.getField('name')).toBe('Alice'); + expect(pageEnforced.edges[1]?.node.getField('name')).toBe('Bob'); + expect(pageEnforced.edges[2]?.node.getField('name')).toBe('Charlie'); + expect(pageEnforced.edges[3]?.node.getField('name')).toBe('David'); + + // Test pagination continues correctly + const secondPage = await PostgresTestEntity.knexLoader(vc).loadPageBySQLAsync({ + first: 4, + after: pageEnforced.pageInfo.endCursor!, + orderBy: [{ fieldName: 'name', order: OrderByOrdering.ASCENDING }], + }); + + expect(secondPage.edges).toHaveLength(2); // Only 2 entities left + expect(secondPage.edges[0]?.node.getField('name')).toBe('Eve'); + expect(secondPage.edges[1]?.node.getField('name')).toBe('Frank'); + + // Test with authorization result-based loader + // Note: Currently loadPageBySQLAsync with knexLoaderWithAuthorizationResults + // returns entities directly, not Result objects (unlike loadManyBySQL) + const pageWithAuth = await PostgresTestEntity.knexLoaderWithAuthorizationResults( + vc, + ).loadPageBySQLAsync({ + first: 3, + orderBy: [{ fieldName: 'name', order: OrderByOrdering.ASCENDING }], + }); + + expect(pageWithAuth.edges).toHaveLength(3); + // These are entities, not Result objects in the current implementation + expect(pageWithAuth.edges[0]?.node.getField('name')).toBe('Alice'); + expect(pageWithAuth.edges[1]?.node.getField('name')).toBe('Bob'); + expect(pageWithAuth.edges[2]?.node.getField('name')).toBe('Charlie'); + }); + + it('correctly handles hasMore flag when filtering unauthorized entities', async () => { + const vc = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance)); + + await PostgresTestEntity.dropPostgresTableAsync(knexInstance); + await PostgresTestEntity.createOrTruncatePostgresTableAsync(knexInstance); + + // Create exactly 6 entities + for (let i = 1; i <= 6; i++) { + await PostgresTestEntity.creator(vc).setField('name', `Entity${i}`).createAsync(); + } + + // Load with limit 5 - should have hasNextPage=true + const page1 = await PostgresTestEntity.knexLoader(vc).loadPageBySQLAsync({ + first: 5, + orderBy: [{ fieldName: 'name', order: OrderByOrdering.ASCENDING }], + }); + + expect(page1.edges).toHaveLength(5); + expect(page1.pageInfo.hasNextPage).toBe(true); + + // Load the last entity + const page2 = await PostgresTestEntity.knexLoader(vc).loadPageBySQLAsync({ + first: 5, + after: page1.pageInfo.endCursor!, + orderBy: [{ fieldName: 'name', order: OrderByOrdering.ASCENDING }], + }); + + expect(page2.edges).toHaveLength(1); + expect(page2.pageInfo.hasNextPage).toBe(false); + }); + }); }); diff --git a/packages/entity-database-adapter-knex/src/__tests__/AuthorizationResultBasedKnexEntityLoader-test.ts b/packages/entity-database-adapter-knex/src/__tests__/AuthorizationResultBasedKnexEntityLoader-test.ts index 1fe2cf3ff..171f75ede 100644 --- a/packages/entity-database-adapter-knex/src/__tests__/AuthorizationResultBasedKnexEntityLoader-test.ts +++ b/packages/entity-database-adapter-knex/src/__tests__/AuthorizationResultBasedKnexEntityLoader-test.ts @@ -12,12 +12,19 @@ import { v4 as uuidv4 } from 'uuid'; import { AuthorizationResultBasedKnexEntityLoader } from '../AuthorizationResultBasedKnexEntityLoader'; import { OrderByOrdering } from '../BasePostgresEntityDatabaseAdapter'; +import { sql } from '../SQLOperator'; import { TestEntity, testEntityConfiguration, TestEntityPrivacyPolicy, TestFields, } from './fixtures/TestEntity'; +import { + TestPaginationEntity, + testPaginationEntityConfiguration, + TestPaginationPrivacyPolicy, + TestPaginationFields, +} from './fixtures/TestPaginationEntity'; import { EntityKnexDataManager } from '../internal/EntityKnexDataManager'; describe(AuthorizationResultBasedKnexEntityLoader, () => { @@ -273,4 +280,441 @@ describe(AuthorizationResultBasedKnexEntityLoader, () => { ), ).once(); }); + + describe('loads entities with loadManyBySQL', () => { + it('returns entities with authorization results', async () => { + const privacyPolicy = new TestEntityPrivacyPolicy(); + const spiedPrivacyPolicy = spy(privacyPolicy); + const viewerContext = instance(mock(ViewerContext)); + const privacyPolicyEvaluationContext = + instance( + mock< + EntityPrivacyPolicyEvaluationContext< + TestFields, + 'customIdField', + ViewerContext, + TestEntity + > + >(), + ); + const metricsAdapter = instance(mock()); + const queryContext = instance(mock()); + + const knexDataManagerMock = + mock>(EntityKnexDataManager); + + const id1 = uuidv4(); + const id2 = uuidv4(); + when( + knexDataManagerMock.loadManyBySQLFragmentAsync(queryContext, anything(), anything()), + ).thenResolve([ + { + customIdField: id1, + stringField: 'test1', + intField: 1, + testIndexedField: '1', + dateField: new Date(), + nullableField: null, + }, + { + customIdField: id2, + stringField: 'test2', + intField: 2, + testIndexedField: '2', + dateField: new Date(), + nullableField: null, + }, + ]); + + const constructionUtils = new EntityConstructionUtils( + viewerContext, + queryContext, + privacyPolicyEvaluationContext, + testEntityConfiguration, + TestEntity, + /* entitySelectedFields */ undefined, + privacyPolicy, + metricsAdapter, + ); + + const knexEntityLoader = new AuthorizationResultBasedKnexEntityLoader( + queryContext, + instance(knexDataManagerMock), + metricsAdapter, + constructionUtils, + ); + + const queryBuilder = knexEntityLoader.loadManyBySQL(sql`intField > ${0}`); + const results = await queryBuilder.executeAsync(); + + expect(results).toHaveLength(2); + expect(results[0]!.ok).toBe(true); + expect(results[1]!.ok).toBe(true); + + const entity1 = results[0]!.enforceValue(); + const entity2 = results[1]!.enforceValue(); + expect(entity1.getField('stringField')).toEqual('test1'); + expect(entity2.getField('stringField')).toEqual('test2'); + + verify( + spiedPrivacyPolicy.authorizeReadAsync( + viewerContext, + queryContext, + privacyPolicyEvaluationContext, + anyOfClass(TestEntity), + anything(), + ), + ).twice(); + }); + + it('supports chaining query builder methods', async () => { + const privacyPolicy = new TestEntityPrivacyPolicy(); + const viewerContext = instance(mock(ViewerContext)); + const privacyPolicyEvaluationContext = + instance( + mock< + EntityPrivacyPolicyEvaluationContext< + TestFields, + 'customIdField', + ViewerContext, + TestEntity + > + >(), + ); + const metricsAdapter = instance(mock()); + const queryContext = instance(mock()); + + const knexDataManagerMock = + mock>(EntityKnexDataManager); + + when( + knexDataManagerMock.loadManyBySQLFragmentAsync(queryContext, anything(), anything()), + ).thenCall(async (_context, _fragment, modifiers) => { + // Verify the modifiers are passed correctly + expect(modifiers?.limit).toEqual(5); + expect(modifiers?.orderBy).toEqual([ + { fieldName: 'intField', order: OrderByOrdering.DESCENDING }, + ]); + return [ + { + customIdField: uuidv4(), + stringField: 'result', + intField: 10, + testIndexedField: '1', + dateField: new Date(), + nullableField: null, + }, + ]; + }); + + const constructionUtils = new EntityConstructionUtils( + viewerContext, + queryContext, + privacyPolicyEvaluationContext, + testEntityConfiguration, + TestEntity, + /* entitySelectedFields */ undefined, + privacyPolicy, + metricsAdapter, + ); + + const knexEntityLoader = new AuthorizationResultBasedKnexEntityLoader( + queryContext, + instance(knexDataManagerMock), + metricsAdapter, + constructionUtils, + ); + + const results = await knexEntityLoader + .loadManyBySQL(sql`status = ${'active'}`) + .orderBy('intField', OrderByOrdering.DESCENDING) + .limit(5) + .executeAsync(); + + expect(results).toHaveLength(1); + expect(results[0]!.ok).toBe(true); + }); + }); + + describe('loads entities with loadPageBySQLAsync', () => { + it('returns paginated entities with forward pagination', async () => { + const privacyPolicy = new TestEntityPrivacyPolicy(); + const spiedPrivacyPolicy = spy(privacyPolicy); + const viewerContext = instance(mock(ViewerContext)); + const privacyPolicyEvaluationContext = + instance( + mock< + EntityPrivacyPolicyEvaluationContext< + TestFields, + 'customIdField', + ViewerContext, + TestEntity + > + >(), + ); + const metricsAdapter = instance(mock()); + const queryContext = instance(mock()); + + const knexDataManagerMock = + mock>(EntityKnexDataManager); + + const id1 = uuidv4(); + const id2 = uuidv4(); + when(knexDataManagerMock.loadPageBySQLFragmentAsync(queryContext, anything())).thenResolve({ + edges: [ + { + cursor: 'cursor1', + node: { + customIdField: id1, + stringField: 'page1', + intField: 1, + testIndexedField: '1', + dateField: new Date(), + nullableField: null, + }, + }, + { + cursor: 'cursor2', + node: { + customIdField: id2, + stringField: 'page2', + intField: 2, + testIndexedField: '2', + dateField: new Date(), + nullableField: null, + }, + }, + ], + pageInfo: { + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor1', + endCursor: 'cursor2', + }, + }); + + const constructionUtils = new EntityConstructionUtils( + viewerContext, + queryContext, + privacyPolicyEvaluationContext, + testEntityConfiguration, + TestEntity, + /* entitySelectedFields */ undefined, + privacyPolicy, + metricsAdapter, + ); + + const knexEntityLoader = new AuthorizationResultBasedKnexEntityLoader( + queryContext, + instance(knexDataManagerMock), + metricsAdapter, + constructionUtils, + ); + + const connection = await knexEntityLoader.loadPageBySQLAsync({ + first: 10, + where: sql`intField > ${0}`, + orderBy: [{ fieldName: 'intField', order: OrderByOrdering.ASCENDING }], + }); + + expect(connection.edges).toHaveLength(2); + expect(connection.edges[0]!.cursor).toEqual('cursor1'); + expect(connection.edges[0]!.node.getField('stringField')).toEqual('page1'); + expect(connection.edges[1]!.cursor).toEqual('cursor2'); + expect(connection.edges[1]!.node.getField('stringField')).toEqual('page2'); + + expect(connection.pageInfo).toEqual({ + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor1', + endCursor: 'cursor2', + }); + + verify( + spiedPrivacyPolicy.authorizeReadAsync( + viewerContext, + queryContext, + privacyPolicyEvaluationContext, + anyOfClass(TestEntity), + anything(), + ), + ).twice(); + }); + + it('filters out entities that fail authorization', async () => { + const privacyPolicy = new TestPaginationPrivacyPolicy(); + const viewerContext = instance(mock(ViewerContext)); + const privacyPolicyEvaluationContext = + instance( + mock< + EntityPrivacyPolicyEvaluationContext< + TestPaginationFields, + 'id', + ViewerContext, + TestPaginationEntity + > + >(), + ); + const metricsAdapter = instance(mock()); + const queryContext = instance(mock()); + + const knexDataManagerMock = + mock>(EntityKnexDataManager); + + const id1 = uuidv4(); + const id2 = uuidv4(); + const id3 = uuidv4(); + when(knexDataManagerMock.loadPageBySQLFragmentAsync(queryContext, anything())).thenResolve({ + edges: [ + { + cursor: 'cursor1', + node: { + id: id1, + name: 'Entity 1', + status: 'active', + createdAt: new Date('2024-01-01'), + score: 100, + }, + }, + { + cursor: 'cursor2', + node: { + id: id2, + name: 'Entity 2', + status: 'unauthorized', // This will fail authorization + createdAt: new Date('2024-01-02'), + score: 200, + }, + }, + { + cursor: 'cursor3', + node: { + id: id3, + name: 'Entity 3', + status: 'active', + createdAt: new Date('2024-01-03'), + score: 300, + }, + }, + ], + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + startCursor: 'cursor1', + endCursor: 'cursor3', + }, + }); + + const constructionUtils = new EntityConstructionUtils( + viewerContext, + queryContext, + privacyPolicyEvaluationContext, + testPaginationEntityConfiguration, + TestPaginationEntity, + /* entitySelectedFields */ undefined, + privacyPolicy, + metricsAdapter, + ); + + const knexEntityLoader = new AuthorizationResultBasedKnexEntityLoader( + queryContext, + instance(knexDataManagerMock), + metricsAdapter, + constructionUtils, + ); + + const connection = await knexEntityLoader.loadPageBySQLAsync({ + first: 10, + where: sql`score > ${0}`, + orderBy: [{ fieldName: 'createdAt', order: OrderByOrdering.ASCENDING }], + }); + + // Should only have 2 entities (unauthorized one filtered out) + expect(connection.edges).toHaveLength(2); + expect(connection.edges[0]!.node.getField('name')).toEqual('Entity 1'); + expect(connection.edges[1]!.node.getField('name')).toEqual('Entity 3'); + + // Cursors should be maintained from successful entities only + expect(connection.edges[0]!.cursor).toEqual('cursor1'); + expect(connection.edges[1]!.cursor).toEqual('cursor3'); + + expect(connection.pageInfo).toEqual({ + hasNextPage: false, + hasPreviousPage: false, + startCursor: 'cursor1', + endCursor: 'cursor3', + }); + }); + + it('supports backward pagination with last/before', async () => { + const privacyPolicy = new TestEntityPrivacyPolicy(); + const viewerContext = instance(mock(ViewerContext)); + const privacyPolicyEvaluationContext = + instance( + mock< + EntityPrivacyPolicyEvaluationContext< + TestFields, + 'customIdField', + ViewerContext, + TestEntity + > + >(), + ); + const metricsAdapter = instance(mock()); + const queryContext = instance(mock()); + + const knexDataManagerMock = + mock>(EntityKnexDataManager); + + when(knexDataManagerMock.loadPageBySQLFragmentAsync(queryContext, anything())).thenResolve({ + edges: [ + { + cursor: 'cursor5', + node: { + customIdField: uuidv4(), + stringField: 'item5', + intField: 5, + testIndexedField: '5', + dateField: new Date(), + nullableField: null, + }, + }, + ], + pageInfo: { + hasNextPage: false, + hasPreviousPage: true, + startCursor: 'cursor5', + endCursor: 'cursor5', + }, + }); + + const constructionUtils = new EntityConstructionUtils( + viewerContext, + queryContext, + privacyPolicyEvaluationContext, + testEntityConfiguration, + TestEntity, + /* entitySelectedFields */ undefined, + privacyPolicy, + metricsAdapter, + ); + + const knexEntityLoader = new AuthorizationResultBasedKnexEntityLoader( + queryContext, + instance(knexDataManagerMock), + metricsAdapter, + constructionUtils, + ); + + const connection = await knexEntityLoader.loadPageBySQLAsync({ + last: 5, + before: 'someCursor', + orderBy: [{ fieldName: 'intField', order: OrderByOrdering.ASCENDING }], + }); + + expect(connection.edges).toHaveLength(1); + expect(connection.pageInfo.hasPreviousPage).toBe(true); + expect(connection.pageInfo.hasNextPage).toBe(false); + }); + }); }); diff --git a/packages/entity-database-adapter-knex/src/__tests__/EnforcingKnexEntityLoader-test.ts b/packages/entity-database-adapter-knex/src/__tests__/EnforcingKnexEntityLoader-test.ts index fa9b745b6..2921ac6f6 100644 --- a/packages/entity-database-adapter-knex/src/__tests__/EnforcingKnexEntityLoader-test.ts +++ b/packages/entity-database-adapter-knex/src/__tests__/EnforcingKnexEntityLoader-test.ts @@ -1,16 +1,22 @@ +import { EntityConstructionUtils, EntityQueryContext, IEntityMetricsAdapter } from '@expo/entity'; import { result } from '@expo/results'; import { describe, expect, it } from '@jest/globals'; import { anything, instance, mock, when } from 'ts-mockito'; -import { AuthorizationResultBasedKnexEntityLoader } from '../AuthorizationResultBasedKnexEntityLoader'; +import { + AuthorizationResultBasedKnexEntityLoader, + AuthorizationResultBasedSQLQueryBuilder, +} from '../AuthorizationResultBasedKnexEntityLoader'; import { EnforcingKnexEntityLoader } from '../EnforcingKnexEntityLoader'; +import { sql } from '../SQLOperator'; +import { EntityKnexDataManager } from '../internal/EntityKnexDataManager'; describe(EnforcingKnexEntityLoader, () => { describe('loadFirstByFieldEqualityConjunction', () => { it('throws when result is unsuccessful', async () => { - const nonEnforcingKnexEntityLoaderMock = mock< - AuthorizationResultBasedKnexEntityLoader - >(AuthorizationResultBasedKnexEntityLoader); + const nonEnforcingKnexEntityLoaderMock = mock( + AuthorizationResultBasedKnexEntityLoader, + ); const rejection = new Error(); when( nonEnforcingKnexEntityLoaderMock.loadFirstByFieldEqualityConjunctionAsync( @@ -19,16 +25,22 @@ describe(EnforcingKnexEntityLoader, () => { ), ).thenResolve(result(rejection)); const nonEnforcingKnexEntityLoader = instance(nonEnforcingKnexEntityLoaderMock); - const enforcingKnexEntityLoader = new EnforcingKnexEntityLoader(nonEnforcingKnexEntityLoader); + const enforcingKnexEntityLoader = new EnforcingKnexEntityLoader( + nonEnforcingKnexEntityLoader, + instance(mock(EntityQueryContext)), + instance(mock(EntityKnexDataManager)), + instance(mock()), + instance(mock(EntityConstructionUtils)), + ); await expect( enforcingKnexEntityLoader.loadFirstByFieldEqualityConjunctionAsync(anything(), anything()), ).rejects.toThrow(rejection); }); it('returns value when result is successful', async () => { - const nonEnforcingKnexEntityLoaderMock = mock< - AuthorizationResultBasedKnexEntityLoader - >(AuthorizationResultBasedKnexEntityLoader); + const nonEnforcingKnexEntityLoaderMock = mock( + AuthorizationResultBasedKnexEntityLoader, + ); const resolved = {}; when( nonEnforcingKnexEntityLoaderMock.loadFirstByFieldEqualityConjunctionAsync( @@ -37,16 +49,22 @@ describe(EnforcingKnexEntityLoader, () => { ), ).thenResolve(result(resolved)); const nonEnforcingKnexEntityLoader = instance(nonEnforcingKnexEntityLoaderMock); - const enforcingKnexEntityLoader = new EnforcingKnexEntityLoader(nonEnforcingKnexEntityLoader); + const enforcingKnexEntityLoader = new EnforcingKnexEntityLoader( + nonEnforcingKnexEntityLoader, + instance(mock(EntityQueryContext)), + instance(mock(EntityKnexDataManager)), + instance(mock()), + instance(mock(EntityConstructionUtils)), + ); await expect( enforcingKnexEntityLoader.loadFirstByFieldEqualityConjunctionAsync(anything(), anything()), ).resolves.toEqual(resolved); }); it('returns null when the query is successful but no rows match', async () => { - const nonEnforcingKnexEntityLoaderMock = mock< - AuthorizationResultBasedKnexEntityLoader - >(AuthorizationResultBasedKnexEntityLoader); + const nonEnforcingKnexEntityLoaderMock = mock( + AuthorizationResultBasedKnexEntityLoader, + ); when( nonEnforcingKnexEntityLoaderMock.loadFirstByFieldEqualityConjunctionAsync( anything(), @@ -54,7 +72,13 @@ describe(EnforcingKnexEntityLoader, () => { ), ).thenResolve(null); const nonEnforcingKnexEntityLoader = instance(nonEnforcingKnexEntityLoaderMock); - const enforcingKnexEntityLoader = new EnforcingKnexEntityLoader(nonEnforcingKnexEntityLoader); + const enforcingKnexEntityLoader = new EnforcingKnexEntityLoader( + nonEnforcingKnexEntityLoader, + instance(mock(EntityQueryContext)), + instance(mock(EntityKnexDataManager)), + instance(mock()), + instance(mock(EntityConstructionUtils)), + ); await expect( enforcingKnexEntityLoader.loadFirstByFieldEqualityConjunctionAsync(anything(), anything()), ).resolves.toBeNull(); @@ -63,9 +87,9 @@ describe(EnforcingKnexEntityLoader, () => { describe('loadManyByFieldEqualityConjunction', () => { it('throws when result is unsuccessful', async () => { - const nonEnforcingKnexEntityLoaderMock = mock< - AuthorizationResultBasedKnexEntityLoader - >(AuthorizationResultBasedKnexEntityLoader); + const nonEnforcingKnexEntityLoaderMock = mock( + AuthorizationResultBasedKnexEntityLoader, + ); const rejection = new Error(); when( nonEnforcingKnexEntityLoaderMock.loadManyByFieldEqualityConjunctionAsync( @@ -74,16 +98,22 @@ describe(EnforcingKnexEntityLoader, () => { ), ).thenResolve([result(rejection)]); const nonEnforcingKnexEntityLoader = instance(nonEnforcingKnexEntityLoaderMock); - const enforcingKnexEntityLoader = new EnforcingKnexEntityLoader(nonEnforcingKnexEntityLoader); + const enforcingKnexEntityLoader = new EnforcingKnexEntityLoader( + nonEnforcingKnexEntityLoader, + instance(mock(EntityQueryContext)), + instance(mock(EntityKnexDataManager)), + instance(mock()), + instance(mock(EntityConstructionUtils)), + ); await expect( enforcingKnexEntityLoader.loadManyByFieldEqualityConjunctionAsync(anything(), anything()), ).rejects.toThrow(rejection); }); it('returns value when result is successful', async () => { - const nonEnforcingKnexEntityLoaderMock = mock< - AuthorizationResultBasedKnexEntityLoader - >(AuthorizationResultBasedKnexEntityLoader); + const nonEnforcingKnexEntityLoaderMock = mock( + AuthorizationResultBasedKnexEntityLoader, + ); const resolved = {}; when( nonEnforcingKnexEntityLoaderMock.loadManyByFieldEqualityConjunctionAsync( @@ -92,7 +122,13 @@ describe(EnforcingKnexEntityLoader, () => { ), ).thenResolve([result(resolved)]); const nonEnforcingKnexEntityLoader = instance(nonEnforcingKnexEntityLoaderMock); - const enforcingKnexEntityLoader = new EnforcingKnexEntityLoader(nonEnforcingKnexEntityLoader); + const enforcingKnexEntityLoader = new EnforcingKnexEntityLoader( + nonEnforcingKnexEntityLoader, + instance(mock(EntityQueryContext)), + instance(mock(EntityKnexDataManager)), + instance(mock()), + instance(mock(EntityConstructionUtils)), + ); await expect( enforcingKnexEntityLoader.loadManyByFieldEqualityConjunctionAsync(anything(), anything()), ).resolves.toEqual([resolved]); @@ -101,9 +137,9 @@ describe(EnforcingKnexEntityLoader, () => { describe('loadManyByRawWhereClause', () => { it('throws when result is unsuccessful', async () => { - const nonEnforcingKnexEntityLoaderMock = mock< - AuthorizationResultBasedKnexEntityLoader - >(AuthorizationResultBasedKnexEntityLoader); + const nonEnforcingKnexEntityLoaderMock = mock( + AuthorizationResultBasedKnexEntityLoader, + ); const rejection = new Error(); when( nonEnforcingKnexEntityLoaderMock.loadManyByRawWhereClauseAsync( @@ -113,16 +149,22 @@ describe(EnforcingKnexEntityLoader, () => { ), ).thenResolve([result(rejection)]); const nonEnforcingKnexEntityLoader = instance(nonEnforcingKnexEntityLoaderMock); - const enforcingKnexEntityLoader = new EnforcingKnexEntityLoader(nonEnforcingKnexEntityLoader); + const enforcingKnexEntityLoader = new EnforcingKnexEntityLoader( + nonEnforcingKnexEntityLoader, + instance(mock(EntityQueryContext)), + instance(mock(EntityKnexDataManager)), + instance(mock()), + instance(mock(EntityConstructionUtils)), + ); await expect( enforcingKnexEntityLoader.loadManyByRawWhereClauseAsync(anything(), anything(), anything()), ).rejects.toThrow(rejection); }); it('returns value when result is successful', async () => { - const nonEnforcingKnexEntityLoaderMock = mock< - AuthorizationResultBasedKnexEntityLoader - >(AuthorizationResultBasedKnexEntityLoader); + const nonEnforcingKnexEntityLoaderMock = mock( + AuthorizationResultBasedKnexEntityLoader, + ); const resolved = {}; when( nonEnforcingKnexEntityLoaderMock.loadManyByRawWhereClauseAsync( @@ -132,13 +174,194 @@ describe(EnforcingKnexEntityLoader, () => { ), ).thenResolve([result(resolved)]); const nonEnforcingKnexEntityLoader = instance(nonEnforcingKnexEntityLoaderMock); - const enforcingKnexEntityLoader = new EnforcingKnexEntityLoader(nonEnforcingKnexEntityLoader); + const enforcingKnexEntityLoader = new EnforcingKnexEntityLoader( + nonEnforcingKnexEntityLoader, + instance(mock(EntityQueryContext)), + instance(mock(EntityKnexDataManager)), + instance(mock()), + instance(mock(EntityConstructionUtils)), + ); await expect( enforcingKnexEntityLoader.loadManyByRawWhereClauseAsync(anything(), anything(), anything()), ).resolves.toEqual([resolved]); }); }); + describe('loadManyBySQL', () => { + it('throws when result is unsuccessful', async () => { + const nonEnforcingKnexEntityLoaderMock = mock( + AuthorizationResultBasedKnexEntityLoader, + ); + const rejection = new Error('Authorization failed'); + + const queryBuilderMock = mock( + AuthorizationResultBasedSQLQueryBuilder, + ); + when(queryBuilderMock.executeAsync()).thenResolve([result(rejection)]); + const queryBuilder = instance(queryBuilderMock); + + when(nonEnforcingKnexEntityLoaderMock.loadManyBySQL(anything(), anything())).thenReturn( + queryBuilder, + ); + + const nonEnforcingKnexEntityLoader = instance(nonEnforcingKnexEntityLoaderMock); + const enforcingKnexEntityLoader = new EnforcingKnexEntityLoader( + nonEnforcingKnexEntityLoader, + instance(mock(EntityQueryContext)), + instance(mock(EntityKnexDataManager)), + instance(mock()), + instance(mock(EntityConstructionUtils)), + ); + + const enforcingQueryBuilder = enforcingKnexEntityLoader.loadManyBySQL(sql`1=1`); + await expect(enforcingQueryBuilder.executeAsync()).rejects.toThrow(rejection); + }); + + it('returns value when result is successful', async () => { + const nonEnforcingKnexEntityLoaderMock = mock( + AuthorizationResultBasedKnexEntityLoader, + ); + const entity1 = { id: '1', name: 'Entity 1' }; + const entity2 = { id: '2', name: 'Entity 2' }; + + const queryBuilderMock = mock( + AuthorizationResultBasedSQLQueryBuilder, + ); + when(queryBuilderMock.executeAsync()).thenResolve([result(entity1), result(entity2)]); + const queryBuilder = instance(queryBuilderMock); + + when(nonEnforcingKnexEntityLoaderMock.loadManyBySQL(anything(), anything())).thenReturn( + queryBuilder, + ); + + const nonEnforcingKnexEntityLoader = instance(nonEnforcingKnexEntityLoaderMock); + const enforcingKnexEntityLoader = new EnforcingKnexEntityLoader( + nonEnforcingKnexEntityLoader, + instance(mock(EntityQueryContext)), + instance(mock(EntityKnexDataManager)), + instance(mock()), + instance(mock(EntityConstructionUtils)), + ); + + const enforcingQueryBuilder = enforcingKnexEntityLoader.loadManyBySQL(sql`1=1`); + await expect(enforcingQueryBuilder.executeAsync()).resolves.toEqual([entity1, entity2]); + }); + }); + + describe('loadPageBySQLAsync', () => { + it('throws when result is unsuccessful', async () => { + const queryContext = instance(mock(EntityQueryContext)); + const knexDataManagerMock = mock>(EntityKnexDataManager); + const constructionUtilsMock = + mock>(EntityConstructionUtils); + const rejection = new Error('Entity not authorized'); + + // Mock the data manager to return a connection with field objects + when(knexDataManagerMock.loadPageBySQLFragmentAsync(anything(), anything())).thenResolve({ + edges: [ + { + cursor: 'cursor1', + node: { id: '1', name: 'Entity 1' }, + }, + ], + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + startCursor: 'cursor1', + endCursor: 'cursor1', + }, + }); + + // Mock constructionUtils to throw when constructing entities + when(constructionUtilsMock.constructAndAuthorizeEntityAsync(anything())).thenResolve( + result(rejection), + ); + + const enforcingKnexEntityLoader = new EnforcingKnexEntityLoader( + instance(mock(AuthorizationResultBasedKnexEntityLoader)), + queryContext, + instance(knexDataManagerMock), + instance(mock()), + instance(constructionUtilsMock), + ); + + await expect( + enforcingKnexEntityLoader.loadPageBySQLAsync({ + first: 10, + orderBy: [], + }), + ).rejects.toThrow(rejection); + }); + + it('returns value when result is successful', async () => { + const queryContext = instance(mock(EntityQueryContext)); + const knexDataManagerMock = mock>(EntityKnexDataManager); + const constructionUtilsMock = + mock>(EntityConstructionUtils); + const entity1 = { id: '1', name: 'Entity 1', getID: () => '1' }; + const entity2 = { id: '2', name: 'Entity 2', getID: () => '2' }; + + when(knexDataManagerMock.loadPageBySQLFragmentAsync(anything(), anything())).thenResolve({ + edges: [ + { + cursor: 'cursor1', + node: { id: '1', name: 'Entity 1' }, + }, + { + cursor: 'cursor2', + node: { id: '2', name: 'Entity 2' }, + }, + ], + pageInfo: { + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor1', + endCursor: 'cursor2', + }, + }); + + when(constructionUtilsMock.constructAndAuthorizeEntityAsync(anything())).thenCall( + async (fieldObject: any) => { + if (fieldObject.id === '1') { + return result(entity1); + } else if (fieldObject.id === '2') { + return result(entity2); + } + throw new Error('Unexpected field object'); + }, + ); + + const enforcingKnexEntityLoader = new EnforcingKnexEntityLoader( + instance(mock(AuthorizationResultBasedKnexEntityLoader)), + queryContext, + instance(knexDataManagerMock), + instance(mock()), + instance(constructionUtilsMock), + ); + + const connection = await enforcingKnexEntityLoader.loadPageBySQLAsync({ + first: 10, + orderBy: [], + }); + + expect(connection.edges).toHaveLength(2); + expect(connection.edges[0]).toEqual({ + cursor: 'cursor1', + node: entity1, + }); + expect(connection.edges[1]).toEqual({ + cursor: 'cursor2', + node: entity2, + }); + expect(connection.pageInfo).toEqual({ + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor1', + endCursor: 'cursor2', + }); + }); + }); + it('has the same method names as AuthorizationResultBasedKnexEntityLoader', () => { const enforcingKnexLoaderProperties = Object.getOwnPropertyNames( EnforcingKnexEntityLoader.prototype, diff --git a/packages/entity-database-adapter-knex/src/__tests__/fixtures/TestPaginationEntity.ts b/packages/entity-database-adapter-knex/src/__tests__/fixtures/TestPaginationEntity.ts new file mode 100644 index 000000000..213c54421 --- /dev/null +++ b/packages/entity-database-adapter-knex/src/__tests__/fixtures/TestPaginationEntity.ts @@ -0,0 +1,102 @@ +import { + Entity, + EntityCompanionDefinition, + EntityConfiguration, + EntityPrivacyPolicy, + ViewerContext, + EntityPrivacyPolicyEvaluationContext, + EntityQueryContext, + UUIDField, + StringField, + DateField, + IntField, + RuleEvaluationResult, +} from '@expo/entity'; + +export interface TestPaginationFields { + id: string; + name: string; + status: string; + createdAt: Date; + score: number; +} + +export const testPaginationEntityConfiguration = new EntityConfiguration< + TestPaginationFields, + 'id' +>({ + idField: 'id', + tableName: 'test_pagination_entities', + schema: { + id: new UUIDField({ + columnName: 'id', + cache: true, + }), + name: new StringField({ + columnName: 'name', + }), + status: new StringField({ + columnName: 'status', + }), + createdAt: new DateField({ + columnName: 'created_at', + }), + score: new IntField({ + columnName: 'score', + }), + }, + databaseAdapterFlavor: 'postgres', + cacheAdapterFlavor: 'redis', +}); + +/** + * Privacy policy that conditionally fails authorization based on the 'status' field. + * Entities with status 'unauthorized' will fail authorization. + */ +export class TestPaginationPrivacyPolicy extends EntityPrivacyPolicy< + TestPaginationFields, + 'id', + ViewerContext, + TestPaginationEntity +> { + protected override readonly readRules = [ + { + async evaluateAsync( + _viewerContext: ViewerContext, + _queryContext: EntityQueryContext, + _evaluationContext: EntityPrivacyPolicyEvaluationContext< + TestPaginationFields, + 'id', + ViewerContext, + TestPaginationEntity + >, + entity: TestPaginationEntity, + ): Promise { + // Fail authorization for entities with status 'unauthorized' + return entity.getField('status') === 'unauthorized' + ? RuleEvaluationResult.DENY + : RuleEvaluationResult.ALLOW; + }, + }, + ]; + + protected override readonly createRules = []; + protected override readonly updateRules = []; + protected override readonly deleteRules = []; +} + +export class TestPaginationEntity extends Entity { + static defineCompanionDefinition(): EntityCompanionDefinition< + TestPaginationFields, + 'id', + ViewerContext, + TestPaginationEntity, + TestPaginationPrivacyPolicy + > { + return { + entityClass: TestPaginationEntity, + entityConfiguration: testPaginationEntityConfiguration, + privacyPolicyClass: TestPaginationPrivacyPolicy, + }; + } +} diff --git a/packages/entity-database-adapter-knex/src/extensions/EntityTableDataCoordinatorExtensions.ts b/packages/entity-database-adapter-knex/src/extensions/EntityTableDataCoordinatorExtensions.ts index 3cb007a6a..156a60a17 100644 --- a/packages/entity-database-adapter-knex/src/extensions/EntityTableDataCoordinatorExtensions.ts +++ b/packages/entity-database-adapter-knex/src/extensions/EntityTableDataCoordinatorExtensions.ts @@ -35,6 +35,7 @@ export function installEntityTableDataCoordinatorExtensions(): void { TIDField extends keyof TFields, >(this: EntityTableDataCoordinator): EntityKnexDataManager { return (this[KNEX_DATA_MANAGER] ??= new EntityKnexDataManager( + this.entityConfiguration, requireBasePostgresAdapter(this.databaseAdapter), this.metricsAdapter, this.entityClassName, diff --git a/packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts b/packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts index ca73356aa..f732247d6 100644 --- a/packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts +++ b/packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts @@ -3,16 +3,83 @@ import { timeAndLogLoadEventAsync, EntityMetricsLoadType, IEntityMetricsAdapter, + EntityConfiguration, + getDatabaseFieldForEntityField, + EntityDatabaseAdapterPaginationCursorInvalidError, } from '@expo/entity'; +import assert from 'assert'; import { BasePostgresEntityDatabaseAdapter, FieldEqualityCondition, + OrderByOrdering, + PostgresOrderByClause, PostgresQuerySelectionModifiers, PostgresQuerySelectionModifiersWithOrderByFragment, PostgresQuerySelectionModifiersWithOrderByRaw, } from '../BasePostgresEntityDatabaseAdapter'; -import { SQLFragment } from '../SQLOperator'; +import { SQLFragment, identifier, raw, sql } from '../SQLOperator'; + +/** + * Base pagination arguments + */ +interface BasePaginationArgs> { + where?: SQLFragment; + orderBy?: PostgresOrderByClause[]; +} + +/** + * Forward pagination arguments + */ +export interface ForwardPaginationArgs< + TFields extends Record, +> extends BasePaginationArgs { + first: number; + after?: string; +} + +/** + * Backward pagination arguments + */ +export interface BackwardPaginationArgs< + TFields extends Record, +> extends BasePaginationArgs { + last: number; + before?: string; +} + +/** + * Combined pagination arguments using discriminated union + */ +export type LoadPageArgs> = + | ForwardPaginationArgs + | BackwardPaginationArgs; + +/** + * Edge in a connection + */ +export interface Edge { + cursor: string; + node: TNode; +} + +/** + * Page information for pagination + */ +export interface PageInfo { + hasNextPage: boolean; + hasPreviousPage: boolean; + startCursor: string | null; + endCursor: string | null; +} + +/** + * Relay-style Connection type + */ +export interface Connection { + edges: Edge[]; + pageInfo: PageInfo; +} /** * A knex data manager is responsible for handling non-dataloader-based @@ -25,6 +92,7 @@ export class EntityKnexDataManager< TIDField extends keyof TFields, > { constructor( + private readonly entityConfiguration: EntityConfiguration, private readonly databaseAdapter: BasePostgresEntityDatabaseAdapter, private readonly metricsAdapter: IEntityMetricsAdapter, private readonly entityClassName: string, @@ -106,4 +174,213 @@ export class EntityKnexDataManager< ), ); } + + /** + * Load a page of objects using cursor-based pagination. + * + * @param queryContext - query context in which to perform the load + * @param args - pagination arguments including first/after or last/before + * @param idField - the ID field name for the entity + * @returns connection with edges containing field objects and page info + */ + async loadPageBySQLFragmentAsync( + queryContext: EntityQueryContext, + args: LoadPageArgs, + ): Promise>> { + const idField = this.entityConfiguration.idField; + + // Validate pagination arguments + if ('first' in args) { + assert( + Number.isInteger(args.first) && args.first > 0, + 'first must be an integer greater than 0', + ); + } else { + assert( + Number.isInteger(args.last) && args.last > 0, + 'last must be an integer greater than 0', + ); + } + + const isForward = 'first' in args; + const { where, orderBy } = args; + + let limit: number; + let cursor: string | undefined; + if (isForward) { + limit = args.first; + cursor = args.after; + } else { + limit = args.last; + cursor = args.before; + } + + // Augment orderBy with ID field for stability + const orderByClauses = this.augmentOrderByIfNecessary(orderBy, idField); + + // Build cursor fields from orderBy + id for stability + const fieldsToUseInPostgresCursor = orderByClauses.map((order) => order.fieldName); + + // Decode cursor + const decodedExternalCursorEntityID = cursor ? this.decodeOpaqueCursor(cursor) : null; + + // Build WHERE clause with cursor condition for keyset pagination + const whereClause = this.buildWhereClause({ + ...(where && { where }), + decodedExternalCursorEntityID, + fieldsToUseInPostgresCursor, + direction: isForward ? 'forward' : 'backward', + }); + + // Adjust order for backward pagination + const finalOrderByClauses = isForward + ? orderByClauses + : orderByClauses.map((clause) => ({ + fieldName: clause.fieldName, + order: + clause.order === OrderByOrdering.ASCENDING + ? OrderByOrdering.DESCENDING + : OrderByOrdering.ASCENDING, + })); + + // Fetch data with limit + 1 to check for more pages + const fieldObjects = await timeAndLogLoadEventAsync( + this.metricsAdapter, + EntityMetricsLoadType.LOAD_PAGE, + this.entityClassName, + queryContext, + )( + this.databaseAdapter.fetchManyBySQLFragmentAsync(queryContext, whereClause, { + orderBy: finalOrderByClauses, + limit: limit + 1, + }), + ); + + // Process results + const hasMore = fieldObjects.length > limit; + const pageFieldObjects = hasMore ? fieldObjects.slice(0, limit) : [...fieldObjects]; + + if (!isForward) { + pageFieldObjects.reverse(); + } + + // Build edges with cursors + const edges = pageFieldObjects.map((fieldObject) => ({ + node: fieldObject, + cursor: this.encodeOpaqueCursor(fieldObject[idField]), + })); + + const pageInfo: PageInfo = { + hasNextPage: isForward ? hasMore : false, + hasPreviousPage: !isForward ? hasMore : false, + startCursor: edges[0]?.cursor ?? null, + endCursor: edges[edges.length - 1]?.cursor ?? null, + }; + + return { + edges, + pageInfo, + }; + } + + private augmentOrderByIfNecessary( + orderBy: PostgresOrderByClause[] | undefined, + idField: TIDField, + ): PostgresOrderByClause[] { + const clauses = orderBy ?? []; + + // Always ensure ID is included for stability and cursor correctness + const hasId = clauses.some((spec) => spec.fieldName === idField); + if (!hasId) { + return [...clauses, { fieldName: idField, order: OrderByOrdering.ASCENDING }]; + } + return clauses; + } + + private encodeOpaqueCursor(idField: TFields[TIDField]): string { + return Buffer.from(JSON.stringify({ id: idField })).toString('base64url'); + } + + private decodeOpaqueCursor(cursor: string): TFields[TIDField] { + let parsedCursor: any; + try { + const decoded = Buffer.from(cursor, 'base64url').toString(); + parsedCursor = JSON.parse(decoded); + } catch (e) { + throw new EntityDatabaseAdapterPaginationCursorInvalidError( + `Failed to decode cursor`, + e instanceof Error ? e : undefined, + ); + } + + if (!('id' in parsedCursor)) { + throw new EntityDatabaseAdapterPaginationCursorInvalidError( + `Cursor is missing required 'id' field. Parsed cursor: ${JSON.stringify(parsedCursor)}`, + ); + } + + return parsedCursor.id; + } + + private buildWhereClause(options: { + where?: SQLFragment; + decodedExternalCursorEntityID: TFields[TIDField] | null; + fieldsToUseInPostgresCursor: readonly (keyof TFields)[]; + direction: 'forward' | 'backward'; + }): SQLFragment { + const { where, decodedExternalCursorEntityID, fieldsToUseInPostgresCursor, direction } = + options; + + const cursorCondition = decodedExternalCursorEntityID + ? this.buildCursorCondition( + decodedExternalCursorEntityID, + fieldsToUseInPostgresCursor, + direction === 'forward' ? '>' : '<', + ) + : null; + + // Combine conditions + const conditions = [where, cursorCondition].filter((it) => !!it); + + // Return combined WHERE clause or "1 = 1" if no conditions + return conditions.length > 0 ? SQLFragment.join(conditions, ' AND ') : sql`1 = 1`; + } + + private buildCursorCondition( + decodedExternalCursorEntityID: TFields[TIDField], + fieldsToUseInPostgresCursor: readonly (keyof TFields)[], + operator: '<' | '>', + ): SQLFragment { + // We build a tuple comparison for fieldsToUseInPostgresCursor fields of the + // entity identified by the external cursor to ensure correct pagination behavior + // even in cases where multiple rows have the same value all fields other than id. + + const idField = getDatabaseFieldForEntityField( + this.entityConfiguration, + this.entityConfiguration.idField, + ); + const tableName = this.entityConfiguration.tableName; + + const postgresCursorFieldIdentifiers = fieldsToUseInPostgresCursor.map((f) => { + const dbField = getDatabaseFieldForEntityField(this.entityConfiguration, f); + return sql`${identifier(dbField)}`; + }); + + // Build left side of comparison (current row's computed values) + const leftSide = SQLFragment.join(postgresCursorFieldIdentifiers, ', '); + + // Build right side using subquery to get computed values for cursor entity + const postgresCursorRowFieldIdentifiers = fieldsToUseInPostgresCursor.map((f) => { + const dbField = getDatabaseFieldForEntityField(this.entityConfiguration, f); + return sql`cursor_row.${identifier(dbField)}`; + }); + + // Build SELECT fields for subquery + const rightSideSubquery = sql` + SELECT ${SQLFragment.join(postgresCursorRowFieldIdentifiers, ', ')} + FROM ${identifier(tableName)} AS cursor_row + WHERE cursor_row.${identifier(idField)} = ${decodedExternalCursorEntityID} + `; + return sql`(${leftSide}) ${raw(operator)} (${rightSideSubquery})`; + } } diff --git a/packages/entity-database-adapter-knex/src/internal/__tests__/EntityKnexDataManager-test.ts b/packages/entity-database-adapter-knex/src/internal/__tests__/EntityKnexDataManager-test.ts index 704451987..840dc0ded 100644 --- a/packages/entity-database-adapter-knex/src/internal/__tests__/EntityKnexDataManager-test.ts +++ b/packages/entity-database-adapter-knex/src/internal/__tests__/EntityKnexDataManager-test.ts @@ -19,7 +19,11 @@ import { } from 'ts-mockito'; import { PostgresEntityDatabaseAdapter } from '../../PostgresEntityDatabaseAdapter'; -import { TestEntity, TestFields } from '../../__tests__/fixtures/TestEntity'; +import { + TestEntity, + TestFields, + testEntityConfiguration, +} from '../../__tests__/fixtures/TestEntity'; import { EntityKnexDataManager } from '../EntityKnexDataManager'; describe(EntityKnexDataManager, () => { @@ -53,6 +57,7 @@ describe(EntityKnexDataManager, () => { }, ]); const entityDataManager = new EntityKnexDataManager( + testEntityConfiguration, instance(databaseAdapterMock), new NoOpEntityMetricsAdapter(), TestEntity.name, @@ -120,6 +125,7 @@ describe(EntityKnexDataManager, () => { ).thenResolve([]); const entityDataManager = new EntityKnexDataManager( + testEntityConfiguration, instance(databaseAdapterMock), metricsAdapter, TestEntity.name, @@ -199,6 +205,7 @@ describe(EntityKnexDataManager, () => { ).thenResolve([]); const entityDataManager = new EntityKnexDataManager( + testEntityConfiguration, instance(databaseAdapterMock), metricsAdapter, TestEntity.name, diff --git a/packages/entity/src/errors/EntityDatabaseAdapterError.ts b/packages/entity/src/errors/EntityDatabaseAdapterError.ts index 755ed6d5f..03a0e3319 100644 --- a/packages/entity/src/errors/EntityDatabaseAdapterError.ts +++ b/packages/entity/src/errors/EntityDatabaseAdapterError.ts @@ -231,3 +231,17 @@ export class EntityDatabaseAdapterExcessiveDeleteResultError extends EntityDatab return EntityErrorCode.ERR_ENTITY_DATABASE_ADAPTER_EXCESSIVE_DELETE_RESULT; } } + +export class EntityDatabaseAdapterPaginationCursorInvalidError extends EntityDatabaseAdapterError { + static { + this.prototype.name = 'EntityDatabaseAdapterPaginationCursorError'; + } + + get state(): EntityErrorState.PERMANENT { + return EntityErrorState.PERMANENT; + } + + get code(): EntityErrorCode.ERR_ENTITY_DATABASE_ADAPTER_PAGINATION_CURSOR_INVALID { + return EntityErrorCode.ERR_ENTITY_DATABASE_ADAPTER_PAGINATION_CURSOR_INVALID; + } +} diff --git a/packages/entity/src/errors/EntityError.ts b/packages/entity/src/errors/EntityError.ts index 0ddcd50cd..24a8d45e0 100644 --- a/packages/entity/src/errors/EntityError.ts +++ b/packages/entity/src/errors/EntityError.ts @@ -27,6 +27,7 @@ export enum EntityErrorCode { ERR_ENTITY_DATABASE_ADAPTER_EMPTY_UPDATE_RESULT = 'ERR_ENTITY_DATABASE_ADAPTER_EMPTY_UPDATE_RESULT', ERR_ENTITY_DATABASE_ADAPTER_EXCESSIVE_DELETE_RESULT = 'ERR_ENTITY_DATABASE_ADAPTER_EXCESSIVE_DELETE_RESULT', ERR_ENTITY_CACHE_ADAPTER_TRANSIENT = 'ERR_ENTITY_CACHE_ADAPTER_TRANSIENT', + ERR_ENTITY_DATABASE_ADAPTER_PAGINATION_CURSOR_INVALID = 'ERR_ENTITY_DATABASE_ADAPTER_PAGINATION_CURSOR_INVALID', } /** diff --git a/packages/entity/src/errors/__tests__/EntityDatabaseAdapterError-test.ts b/packages/entity/src/errors/__tests__/EntityDatabaseAdapterError-test.ts index 8e6370e82..5d6592ed2 100644 --- a/packages/entity/src/errors/__tests__/EntityDatabaseAdapterError-test.ts +++ b/packages/entity/src/errors/__tests__/EntityDatabaseAdapterError-test.ts @@ -11,6 +11,7 @@ import { EntityDatabaseAdapterExclusionConstraintError, EntityDatabaseAdapterForeignKeyConstraintError, EntityDatabaseAdapterNotNullConstraintError, + EntityDatabaseAdapterPaginationCursorInvalidError, EntityDatabaseAdapterTransientError, EntityDatabaseAdapterUniqueConstraintError, EntityDatabaseAdapterUnknownError, @@ -82,5 +83,13 @@ describe(EntityDatabaseAdapterError, () => { expect(excessiveDeleteError.code).toBe( EntityErrorCode.ERR_ENTITY_DATABASE_ADAPTER_EXCESSIVE_DELETE_RESULT, ); + + const paginationCursorInvalidError = new EntityDatabaseAdapterPaginationCursorInvalidError( + 'test', + ); + expect(paginationCursorInvalidError.state).toBe(EntityErrorState.PERMANENT); + expect(paginationCursorInvalidError.code).toBe( + EntityErrorCode.ERR_ENTITY_DATABASE_ADAPTER_PAGINATION_CURSOR_INVALID, + ); }); }); diff --git a/packages/entity/src/metrics/IEntityMetricsAdapter.ts b/packages/entity/src/metrics/IEntityMetricsAdapter.ts index a75b440a7..7f97b4f92 100644 --- a/packages/entity/src/metrics/IEntityMetricsAdapter.ts +++ b/packages/entity/src/metrics/IEntityMetricsAdapter.ts @@ -10,6 +10,7 @@ export enum EntityMetricsLoadType { LOAD_MANY_RAW, LOAD_MANY_SQL, LOAD_ONE, + LOAD_PAGE, } /**