Skip to content

Commit b7ccc5e

Browse files
committed
fix: explicitly document behavior when cursor entity no longer exists
1 parent a83c7cd commit b7ccc5e

2 files changed

Lines changed: 59 additions & 1 deletion

File tree

packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,15 @@ export class EntityKnexDataManager<
218218
/**
219219
* Load a page of objects using cursor-based pagination with unified pagination specification.
220220
*
221+
* @remarks
222+
*
223+
* This method implements cursor-based pagination using the seek method for efficient pagination even on large datasets
224+
* given appropriate indexes. Cursors are opaque and encode the necessary information to fetch the next page based on the
225+
* specified pagination strategy (standard, ILIKE search, or trigram search). For this implementation in particular,
226+
* the cursor encodes the ID of the last entity in the page to ensure correct pagination for all strategies, even in cases
227+
* where multiple rows have the same value for all fields other than the ID. If the entity referenced by a cursor has been
228+
* deleted, the load will return an empty page with `hasNextPage: false`.
229+
*
221230
* @param queryContext - query context in which to perform the load
222231
* @param args - pagination arguments including pagination and first/after or last/before
223232
* @returns connection with edges containing field objects and page info
@@ -477,6 +486,8 @@ export class EntityKnexDataManager<
477486
// We build a tuple comparison for fieldsToUseInPostgresTupleCursor fields of the
478487
// entity identified by the external cursor to ensure correct pagination behavior
479488
// even in cases where multiple rows have the same value all fields other than id.
489+
// If the cursor entity has been deleted, the subquery returns no rows and the
490+
// comparison evaluates to NULL, filtering out all results (empty page).
480491
const operator = direction === PaginationDirection.FORWARD ? '>' : '<';
481492

482493
const idField = getDatabaseFieldForEntityField(
@@ -555,7 +566,9 @@ export class EntityKnexDataManager<
555566
decodedExternalCursorEntityID: TFields[TIDField],
556567
direction: PaginationDirection,
557568
): SQLFragment {
558-
// For TRIGRAM search, we compute the similarity values using a subquery, similar to normal cursor
569+
// For TRIGRAM search, we compute the similarity values using a subquery, similar to normal cursor.
570+
// If the cursor entity has been deleted, the subquery returns no rows and the
571+
// comparison evaluates to NULL, filtering out all results (empty page).
559572
const operator = direction === PaginationDirection.FORWARD ? '<' : '>';
560573
const idField = getDatabaseFieldForEntityField(
561574
this.entityConfiguration,

packages/entity-database-adapter-knex/src/internal/__tests__/EntityKnexDataManager-test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,5 +374,50 @@ describe(EntityKnexDataManager, () => {
374374
expect(result.edges).toEqual([]);
375375
});
376376
});
377+
378+
describe('deleted cursor entity', () => {
379+
it('should return empty page when cursor entity no longer exists', async () => {
380+
// When the cursor row is deleted, the cursor subquery returns no rows,
381+
// causing the tuple comparison to evaluate to NULL. This filters out all rows,
382+
// resulting in an empty page. This is the intended behavior for GraphQL relay
383+
// pagination where throwing would fail the entire query.
384+
const queryContext = instance(mock<EntityQueryContext>());
385+
const databaseAdapterMock = mock<
386+
PostgresEntityDatabaseAdapter<TestFields, 'customIdField'>
387+
>(PostgresEntityDatabaseAdapter);
388+
389+
when(databaseAdapterMock.paginationMaxPageSize).thenReturn(undefined);
390+
// The main query returns empty because the cursor subquery resolves to NULL
391+
when(
392+
databaseAdapterMock.fetchManyBySQLFragmentAsync(queryContext, anything(), anything()),
393+
).thenResolve([]);
394+
395+
const entityDataManager = new EntityKnexDataManager(
396+
testEntityConfiguration,
397+
instance(databaseAdapterMock),
398+
new NoOpEntityMetricsAdapter(),
399+
TestEntity.name,
400+
);
401+
402+
const cursor = Buffer.from(JSON.stringify({ id: 'deleted-id' })).toString('base64url');
403+
404+
const result = await entityDataManager.loadPageAsync(queryContext, {
405+
first: 10,
406+
after: cursor,
407+
pagination: {
408+
strategy: PaginationStrategy.STANDARD,
409+
orderBy: [{ fieldName: 'customIdField', order: OrderByOrdering.ASCENDING }],
410+
},
411+
});
412+
413+
expect(result.edges).toEqual([]);
414+
expect(result.pageInfo).toEqual({
415+
hasNextPage: false,
416+
hasPreviousPage: false,
417+
startCursor: null,
418+
endCursor: null,
419+
});
420+
});
421+
});
377422
});
378423
});

0 commit comments

Comments
 (0)