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 9f89249eb..df78e72f7 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 @@ -1618,8 +1618,6 @@ describe('postgres entity integration', () => { ); // 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++) { @@ -1673,8 +1671,6 @@ describe('postgres entity integration', () => { ); // 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++) { @@ -1777,9 +1773,6 @@ describe('postgres entity integration', () => { 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) { @@ -1841,9 +1834,6 @@ describe('postgres entity integration', () => { 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(); @@ -1879,8 +1869,6 @@ describe('postgres entity integration', () => { const vc = new ViewerContext( createKnexIntegrationTestEntityCompanionProvider(knexInstance), ); - await PostgresTestEntity.dropPostgresTableAsync(knexInstance); - await PostgresTestEntity.createOrTruncatePostgresTableAsync(knexInstance); // Create test data with specific order await PostgresTestEntity.creator(vc).setField('name', 'Charlie').createAsync(); @@ -1910,8 +1898,6 @@ describe('postgres entity integration', () => { const vc = new ViewerContext( createKnexIntegrationTestEntityCompanionProvider(knexInstance), ); - await PostgresTestEntity.dropPostgresTableAsync(knexInstance); - await PostgresTestEntity.createOrTruncatePostgresTableAsync(knexInstance); // Enable pg_trgm extension for trigram similarity await knexInstance.raw('CREATE EXTENSION IF NOT EXISTS pg_trgm'); @@ -2091,15 +2077,56 @@ describe('postgres entity integration', () => { }); }); + it('returns empty page when cursor entity no longer exists', async () => { + const vc = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance)); + + // Create test entities + const names = ['Alice', 'Bob', 'Charlie', 'David', 'Eve']; + for (const name of names) { + await PostgresTestEntity.creator(vc).setField('name', name).createAsync(); + } + + // Get first page and capture cursor pointing to a specific entity + const firstPage = await PostgresTestEntity.knexLoader(vc).loadPageAsync({ + first: 2, + pagination: { + strategy: PaginationStrategy.STANDARD, + orderBy: [{ fieldName: 'name', order: OrderByOrdering.ASCENDING }], + }, + }); + + expect(firstPage.edges).toHaveLength(2); + const cursorEntityNode = firstPage.edges[1]!.node; // 'Bob' + const cursor = firstPage.pageInfo.endCursor!; + + // Delete the entity that the cursor refers to + await PostgresTestEntity.deleter(cursorEntityNode).deleteAsync(); + + // Paginate using the cursor of the now-deleted entity + const result = await PostgresTestEntity.knexLoader(vc).loadPageAsync({ + first: 10, + after: cursor, + pagination: { + strategy: PaginationStrategy.STANDARD, + orderBy: [{ fieldName: 'name', order: OrderByOrdering.ASCENDING }], + }, + }); + + expect(result.edges).toEqual([]); + expect(result.pageInfo).toEqual({ + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + endCursor: null, + }); + }); + describe(PaginationStrategy.ILIKE_SEARCH, () => { it('supports search with ILIKE strategy', async () => { const vc = new ViewerContext( createKnexIntegrationTestEntityCompanionProvider(knexInstance), ); - await PostgresTestEntity.dropPostgresTableAsync(knexInstance); - await PostgresTestEntity.createOrTruncatePostgresTableAsync(knexInstance); - // Create test data with searchable names const names = [ 'Alice Johnson', @@ -2196,8 +2223,6 @@ describe('postgres entity integration', () => { const vc = new ViewerContext( createKnexIntegrationTestEntityCompanionProvider(knexInstance), ); - await PostgresTestEntity.dropPostgresTableAsync(knexInstance); - await PostgresTestEntity.createOrTruncatePostgresTableAsync(knexInstance); // Create test data const names = ['Apple', 'Application', 'Apply', 'Banana', 'Cherry', 'Pineapple']; @@ -2388,9 +2413,6 @@ describe('postgres entity integration', () => { createKnexIntegrationTestEntityCompanionProvider(knexInstance), ); - await PostgresTestEntity.dropPostgresTableAsync(knexInstance); - await PostgresTestEntity.createOrTruncatePostgresTableAsync(knexInstance); - // Enable pg_trgm extension for trigram similarity await knexInstance.raw('CREATE EXTENSION IF NOT EXISTS pg_trgm'); @@ -2448,8 +2470,6 @@ describe('postgres entity integration', () => { const vc = new ViewerContext( createKnexIntegrationTestEntityCompanionProvider(knexInstance), ); - await PostgresTestEntity.dropPostgresTableAsync(knexInstance); - await PostgresTestEntity.createOrTruncatePostgresTableAsync(knexInstance); // Enable pg_trgm extension for trigram similarity await knexInstance.raw('CREATE EXTENSION IF NOT EXISTS pg_trgm'); @@ -2552,8 +2572,6 @@ describe('postgres entity integration', () => { const vc = new ViewerContext( createKnexIntegrationTestEntityCompanionProvider(knexInstance), ); - await PostgresTestEntity.dropPostgresTableAsync(knexInstance); - await PostgresTestEntity.createOrTruncatePostgresTableAsync(knexInstance); // Enable pg_trgm extension for trigram similarity await knexInstance.raw('CREATE EXTENSION IF NOT EXISTS pg_trgm'); @@ -2721,8 +2739,6 @@ describe('postgres entity integration', () => { const vc = new ViewerContext( createKnexIntegrationTestEntityCompanionProvider(knexInstance), ); - await PostgresTestEntity.dropPostgresTableAsync(knexInstance); - await PostgresTestEntity.createOrTruncatePostgresTableAsync(knexInstance); // Enable pg_trgm extension for trigram similarity await knexInstance.raw('CREATE EXTENSION IF NOT EXISTS pg_trgm'); diff --git a/packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts b/packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts index 4a826310d..b8549dca1 100644 --- a/packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts +++ b/packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts @@ -218,6 +218,15 @@ export class EntityKnexDataManager< /** * Load a page of objects using cursor-based pagination with unified pagination specification. * + * @remarks + * + * This method implements cursor-based pagination using the seek method for efficient pagination even on large datasets + * given appropriate indexes. Cursors are opaque and encode the necessary information to fetch the next page based on the + * specified pagination strategy (standard, ILIKE search, or trigram search). For this implementation in particular, + * the cursor encodes the ID of the last entity in the page to ensure correct pagination for all strategies, even in cases + * where multiple rows have the same value for all fields other than the ID. If the entity referenced by a cursor has been + * deleted, the load will return an empty page with `hasNextPage: false`. + * * @param queryContext - query context in which to perform the load * @param args - pagination arguments including pagination and first/after or last/before * @returns connection with edges containing field objects and page info @@ -477,6 +486,8 @@ export class EntityKnexDataManager< // We build a tuple comparison for fieldsToUseInPostgresTupleCursor 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. + // If the cursor entity has been deleted, the subquery returns no rows and the + // comparison evaluates to NULL, filtering out all results (empty page). const operator = direction === PaginationDirection.FORWARD ? '>' : '<'; const idField = getDatabaseFieldForEntityField( @@ -555,7 +566,9 @@ export class EntityKnexDataManager< decodedExternalCursorEntityID: TFields[TIDField], direction: PaginationDirection, ): SQLFragment { - // For TRIGRAM search, we compute the similarity values using a subquery, similar to normal cursor + // For TRIGRAM search, we compute the similarity values using a subquery, similar to normal cursor. + // If the cursor entity has been deleted, the subquery returns no rows and the + // comparison evaluates to NULL, filtering out all results (empty page). const operator = direction === PaginationDirection.FORWARD ? '<' : '>'; const idField = getDatabaseFieldForEntityField( this.entityConfiguration,