diff --git a/packages/entity-database-adapter-knex/src/AuthorizationResultBasedKnexEntityLoader.ts b/packages/entity-database-adapter-knex/src/AuthorizationResultBasedKnexEntityLoader.ts index 35f7b6c91..0e52532d4 100644 --- a/packages/entity-database-adapter-knex/src/AuthorizationResultBasedKnexEntityLoader.ts +++ b/packages/entity-database-adapter-knex/src/AuthorizationResultBasedKnexEntityLoader.ts @@ -14,6 +14,7 @@ import { OrderByOrdering, } from './BasePostgresEntityDatabaseAdapter'; import { BaseSQLQueryBuilder } from './BaseSQLQueryBuilder'; +import { PaginationStrategy } from './PaginationStrategy'; import { SQLFragment } from './SQLOperator'; import type { Connection, PageInfo } from './internal/EntityKnexDataManager'; import { EntityKnexDataManager } from './internal/EntityKnexDataManager'; @@ -76,10 +77,94 @@ export interface EntityLoaderQuerySelectionModifiersWithOrderByFragment< orderByFragment?: SQLFragment; } +interface SearchSpecificationBase< + TFields extends Record, + TSelectedFields extends keyof TFields, +> { + /** + * The search term to search for. Must be a non-empty string. + */ + term: string; + + /** + * The fields to search within. Must be a non-empty array. + */ + fields: TSelectedFields[]; +} + +interface ILikeSearchSpecification< + TFields extends Record, + TSelectedFields extends keyof TFields, +> extends SearchSpecificationBase { + /** + * Case-insensitive pattern matching search using SQL ILIKE operator. + * Results are ordered by the fields being searched within in the order specified, then by ID for tie-breaking and stable pagination. + */ + strategy: PaginationStrategy.ILIKE_SEARCH; +} + +interface TrigramSearchSpecification< + TFields extends Record, + TSelectedFields extends keyof TFields, +> extends SearchSpecificationBase { + /** + * Similarity search using PostgreSQL trigram similarity. Results are ordered by exact match priority, then by similarity score, then by specified extra order by fields if provided, then by ID for tie-breaking and stable pagination. + * Note that trigram similarity search can be significantly slower than ILIKE search, especially on large datasets without appropriate indexes, and results may not be as relevant as more advanced full-text search solutions. + * It is recommended to use this strategy only when ILIKE search does not meet the application's needs and to ensure appropriate database indexing for performance. + */ + strategy: PaginationStrategy.TRIGRAM_SEARCH; + + /** + * Similarity threshold for trigram matching. + * Must be between 0 and 1, where: + * - 0 matches everything + * - 1 requires exact match + * + * Recommended threshold values: + * - 0.3: Loose matching, allows more variation (default PostgreSQL similarity threshold) + * - 0.4-0.5: Moderate matching, good balance for most use cases + * - 0.6+: Strict matching, requires high similarity + */ + threshold: number; + + /** + * Optional additional fields to order by after similarity score and before ID for tie-breaking. + * These fields are independent of search fields and can be used to provide meaningful + * ordering when multiple results have the same similarity score. + */ + extraOrderByFields?: TSelectedFields[]; +} + +interface StandardPaginationSpecification< + TFields extends Record, + TSelectedFields extends keyof TFields, +> { + /** + * Standard pagination without search. Results are ordered by the specified orderBy fields. + */ + strategy: PaginationStrategy.STANDARD; + + /** + * Order the entities by specified columns and orders. If the ID field is not included, it will be automatically added for stable pagination. + */ + orderBy: EntityLoaderOrderByClause[]; +} + /** - * Base pagination arguments + * Pagination specification for SQL-based pagination (with or without search). */ -interface EntityLoaderBasePaginationArgs< +export type PaginationSpecification< + TFields extends Record, + TSelectedFields extends keyof TFields, +> = + | StandardPaginationSpecification + | ILikeSearchSpecification + | TrigramSearchSpecification; + +/** + * Base unified pagination arguments + */ +interface EntityLoaderBaseUnifiedPaginationArgs< TFields extends Record, TSelectedFields extends keyof TFields, > { @@ -89,56 +174,56 @@ interface EntityLoaderBasePaginationArgs< 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. + * Pagination specification determining how to order and paginate results. */ - orderBy?: EntityLoaderOrderByClause[]; + pagination: PaginationSpecification; } /** - * Forward pagination arguments + * Forward unified pagination arguments */ -export interface EntityLoaderForwardPaginationArgs< +export interface EntityLoaderForwardUnifiedPaginationArgs< TFields extends Record, TSelectedFields extends keyof TFields, -> extends EntityLoaderBasePaginationArgs { +> extends EntityLoaderBaseUnifiedPaginationArgs { /** - * The number of entities to return starting from the entity after the cursor (for forward pagination). Must be a positive integer. + * The number of entities to return starting from the entity after the cursor. 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. + * The cursor to paginate after for forward pagination. */ after?: string; } /** - * Backward pagination arguments + * Backward unified pagination arguments */ -export interface EntityLoaderBackwardPaginationArgs< +export interface EntityLoaderBackwardUnifiedPaginationArgs< TFields extends Record, TSelectedFields extends keyof TFields, -> extends EntityLoaderBasePaginationArgs { +> extends EntityLoaderBaseUnifiedPaginationArgs { /** - * The number of entities to return starting from the entity before the cursor (for backward pagination). Must be a positive integer. + * The number of entities to return starting from the entity before the cursor. 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. + * The cursor to paginate before for backward pagination. */ before?: string; } /** - * Load page pagination arguments, which can be either forward or backward pagination arguments. + * Load page pagination arguments, which can be either forward or backward unified pagination arguments. */ export type EntityLoaderLoadPageArgs< TFields extends Record, TSelectedFields extends keyof TFields, > = - | EntityLoaderForwardPaginationArgs - | EntityLoaderBackwardPaginationArgs; + | EntityLoaderForwardUnifiedPaginationArgs + | EntityLoaderBackwardUnifiedPaginationArgs; /** * Authorization-result-based knex entity loader for non-data-loader-based load methods. @@ -267,18 +352,15 @@ export class AuthorizationResultBasedKnexEntityLoader< } /** - * Load a page of entities with Relay-style cursor pagination. + * Load a page of entities with Relay-style cursor pagination using a unified pagination specification. * Only returns successfully authorized entities for cursor stability; failed authorization results are filtered out. * * @returns Connection with only successfully authorized entities */ - async loadPageBySQLAsync( + async loadPageAsync( args: EntityLoaderLoadPageArgs, ): Promise> { - const pageResult = await this.knexDataManager.loadPageBySQLFragmentAsync( - this.queryContext, - args, - ); + const pageResult = await this.knexDataManager.loadPageAsync(this.queryContext, args); const edgeResults = await Promise.all( pageResult.edges.map(async (edge) => { diff --git a/packages/entity-database-adapter-knex/src/EnforcingKnexEntityLoader.ts b/packages/entity-database-adapter-knex/src/EnforcingKnexEntityLoader.ts index 9f3c0fa63..141b78718 100644 --- a/packages/entity-database-adapter-knex/src/EnforcingKnexEntityLoader.ts +++ b/packages/entity-database-adapter-knex/src/EnforcingKnexEntityLoader.ts @@ -191,37 +191,14 @@ export class EnforcingKnexEntityLoader< /** * 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, - * }); - * ``` - * + * @param args - Pagination arguments with pagination and either first/after or last/before + * @returns a page of entities matching the pagination arguments * @throws EntityNotAuthorizedError if viewer is not authorized to view any returned entity */ - async loadPageBySQLAsync( + async loadPageAsync( args: EntityLoaderLoadPageArgs, ): Promise> { - const pageResult = await this.knexDataManager.loadPageBySQLFragmentAsync( - this.queryContext, - args, - ); - + const pageResult = await this.knexDataManager.loadPageAsync(this.queryContext, args); const edges = await Promise.all( pageResult.edges.map(async (edge) => { const entityResult = await this.constructionUtils.constructAndAuthorizeEntityAsync( diff --git a/packages/entity-database-adapter-knex/src/PaginationStrategy.ts b/packages/entity-database-adapter-knex/src/PaginationStrategy.ts new file mode 100644 index 000000000..3791aeddc --- /dev/null +++ b/packages/entity-database-adapter-knex/src/PaginationStrategy.ts @@ -0,0 +1,32 @@ +/** + * Search strategy for SQL-based pagination. + */ +export enum PaginationStrategy { + /** + * Standard pagination with ORDER BY. Results are ordered by the specified orderBy fields, with ID field automatically included for stable pagination if not already present. + */ + STANDARD = 'standard', + + /** + * Case-insensitive pattern matching search using SQL ILIKE operator. + * Results are ordered by the fields being searched within in the order specified, then by ID for tie-breaking and stable pagination. + */ + ILIKE_SEARCH = 'ilike-search', + + /** + * Similarity search using PostgreSQL trigram similarity. Results are ordered by exact match priority, then by similarity score, then by specified extra order by fields if provided, then by ID for tie-breaking and stable pagination. + * + * Performance considerations: + * - Trigram search can be significantly slower than ILIKE search, especially on large datasets without appropriate indexes. + * - Consider using ILIKE search for smaller datasets or when exact substring matching is sufficient + * - For larger datasets, ensure proper indexing or consider dedicated full-text search solutions. + * - For optimal performance, create GIN or GIST indexes on searchable columns: + * ```sql + * CREATE EXTENSION IF NOT EXISTS pg_trgm; + * CREATE INDEX idx_table_field_trigram ON table_name USING gin(field_name gin_trgm_ops); + * -- Or for multiple columns: + * CREATE INDEX idx_table_search ON table_name USING gin((field1 || ' ' || field2) gin_trgm_ops); + * ``` + */ + TRIGRAM_SEARCH = 'trigram', +} diff --git a/packages/entity-database-adapter-knex/src/PostgresEntityDatabaseAdapter.ts b/packages/entity-database-adapter-knex/src/PostgresEntityDatabaseAdapter.ts index 126837c2b..72ac639b7 100644 --- a/packages/entity-database-adapter-knex/src/PostgresEntityDatabaseAdapter.ts +++ b/packages/entity-database-adapter-knex/src/PostgresEntityDatabaseAdapter.ts @@ -110,14 +110,19 @@ export class PostgresEntityDatabaseAdapter< query: Knex.QueryBuilder, querySelectionModifiers: TableQuerySelectionModifiersWithOrderByRaw, ): Knex.QueryBuilder { - let ret = this.applyQueryModifiersToQuery(query, querySelectionModifiers); - const { orderByRaw } = querySelectionModifiers; + + // orderByRaw takes precedence over orderBy - they are mutually exclusive if (orderByRaw !== undefined) { - ret = ret.orderByRaw(orderByRaw); + // Apply only orderByRaw (offset/limit still applied, but not orderBy) + return this.applyQueryModifiersToQuery(query, { + ...querySelectionModifiers, + orderBy: undefined, // Explicitly exclude orderBy when orderByRaw is present + }).orderByRaw(orderByRaw); + } else { + // Apply regular orderBy (and offset/limit) + return this.applyQueryModifiersToQuery(query, querySelectionModifiers); } - - return ret; } private applyQueryModifiersToQuery( @@ -216,10 +221,19 @@ export class PostgresEntityDatabaseAdapter< .select() .from(tableName) .whereRaw(sqlFragment.sql, sqlFragment.getKnexBindings()); - query = this.applyQueryModifiersToQuery(query, querySelectionModifiers); + + // Apply order by modifiers + // orderByFragment takes precedence over orderBy - they are mutually exclusive const { orderByFragment } = querySelectionModifiers; if (orderByFragment !== undefined) { - query = query.orderByRaw(orderByFragment.sql, orderByFragment.getKnexBindings()); + // Apply only orderByFragment (offset/limit still applied, but not orderBy) + query = this.applyQueryModifiersToQuery(query, { + ...querySelectionModifiers, + orderBy: undefined, // Explicitly exclude orderBy when orderByFragment is present + }).orderByRaw(orderByFragment.sql, orderByFragment.getKnexBindings()); + } else { + // Apply regular orderBy (and offset/limit) + query = this.applyQueryModifiersToQuery(query, querySelectionModifiers); } return await wrapNativePostgresCallAsync(() => query); } 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 7848f5463..62ff0487e 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 @@ -11,6 +11,7 @@ import nullthrows from 'nullthrows'; import { setTimeout } from 'timers/promises'; import { OrderByOrdering } from '../BasePostgresEntityDatabaseAdapter'; +import { PaginationStrategy } from '../PaginationStrategy'; import { raw, sql, SQLFragment, SQLFragmentHelpers } from '../SQLOperator'; import { PostgresTestEntity } from '../__testfixtures__/PostgresTestEntity'; import { PostgresTriggerTestEntity } from '../__testfixtures__/PostgresTriggerTestEntity'; @@ -1359,417 +1360,1468 @@ describe('postgres entity integration', () => { }); }); - describe('pagination with loadPageBySQLAsync', () => { - beforeEach(async () => { - const vc = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance)); + describe('pagination with loadPageAsync', () => { + describe(PaginationStrategy.STANDARD, () => { + describe('with standard test data', () => { + 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(); - } - }); + // 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)); + 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 }], - }); + // Get first page + const firstPage = await PostgresTestEntity.knexLoader(vc).loadPageAsync({ + first: 3, + pagination: { + strategy: PaginationStrategy.STANDARD, + 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(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).loadPageAsync({ + first: 3, + after: firstPage.pageInfo.endCursor!, + pagination: { + strategy: PaginationStrategy.STANDARD, + 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); - }); + 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)); + 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 }], - }); + // Get last page + const lastPage = await PostgresTestEntity.knexLoader(vc).loadPageAsync({ + last: 3, + pagination: { + strategy: PaginationStrategy.STANDARD, + 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(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).loadPageAsync({ + last: 3, + before: lastPage.pageInfo.startCursor!, + pagination: { + strategy: PaginationStrategy.STANDARD, + 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); - }); + 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)); + 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 }], - }); + // Query only entities with cats + const page = await PostgresTestEntity.knexLoader(vc).loadPageAsync({ + first: 2, + where: sql`has_a_cat = ${true}`, + pagination: { + strategy: PaginationStrategy.STANDARD, + 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(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).loadPageAsync({ + first: 2, + after: page.pageInfo.endCursor!, + where: sql`has_a_cat = ${true}`, + pagination: { + strategy: PaginationStrategy.STANDARD, + 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); - }); + 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)); + 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 }, - ], - }); + const page = await PostgresTestEntity.knexLoader(vc).loadPageAsync({ + first: 4, + pagination: { + strategy: PaginationStrategy.STANDARD, + orderBy: [ + { fieldName: 'hasACat', order: OrderByOrdering.DESCENDING }, // true comes before false + { fieldName: 'name', order: OrderByOrdering.ASCENDING }, + { fieldName: 'id', 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'); - }); + // 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)); + it('handles empty results correctly', async () => { + const vc = new ViewerContext( + createKnexIntegrationTestEntityCompanionProvider(knexInstance), + ); + + const page = await PostgresTestEntity.knexLoader(vc).loadPageAsync({ + first: 10, + where: sql`name = ${'NonexistentName'}`, + pagination: { + strategy: PaginationStrategy.STANDARD, + orderBy: [], + }, + }); + + 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).loadPageAsync({ + first: 3, + pagination: { + strategy: PaginationStrategy.STANDARD, + 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).loadPageAsync({ + first: 2, + after: page.edges[1]!.cursor, + pagination: { + strategy: PaginationStrategy.STANDARD, + 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).loadPageAsync({ + first: 3, + pagination: { + strategy: PaginationStrategy.STANDARD, + orderBy: [{ fieldName: 'dateField', order: OrderByOrdering.ASCENDING }], + }, + }); + + expect(page.edges).toHaveLength(3); + + // Navigate using cursor + const nextPage = await PostgresTestEntity.knexLoader(vc).loadPageAsync({ + first: 3, + after: page.pageInfo.endCursor!, + pagination: { + strategy: PaginationStrategy.STANDARD, + orderBy: [{ fieldName: 'dateField', order: OrderByOrdering.ASCENDING }], + }, + }); - const page = await PostgresTestEntity.knexLoader(vc).loadPageBySQLAsync({ - first: 10, - where: sql`name = ${'NonexistentName'}`, - orderBy: [{ fieldName: 'name', order: OrderByOrdering.ASCENDING }], + expect(nextPage.edges).toHaveLength(3); + expect(nextPage.pageInfo.hasNextPage).toBe(true); + }); }); - 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('performs backward pagination with descending order', async () => { + const vc = new ViewerContext( + createKnexIntegrationTestEntityCompanionProvider(knexInstance), + ); - it('includes cursors for each edge', 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 page = await PostgresTestEntity.knexLoader(vc).loadPageBySQLAsync({ - first: 3, - orderBy: [{ fieldName: 'name', order: OrderByOrdering.ASCENDING }], + 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).loadPageAsync({ + last: 3, + pagination: { + strategy: PaginationStrategy.STANDARD, + 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).loadPageAsync({ + first: 3, + pagination: { + strategy: PaginationStrategy.STANDARD, + 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); }); - // Each edge should have a cursor - expect(page.edges[0]?.cursor).toBeTruthy(); - expect(page.edges[1]?.cursor).toBeTruthy(); - expect(page.edges[2]?.cursor).toBeTruthy(); + 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).loadPageAsync({ + first: 3, + pagination: { + strategy: PaginationStrategy.STANDARD, + orderBy: [], + }, + }); + + expect(firstPage.edges).toHaveLength(3); + + // Get second page + const secondPage = await PostgresTestEntity.knexLoader(vc).loadPageAsync({ + first: 3, + after: firstPage.pageInfo.endCursor!, + pagination: { + strategy: PaginationStrategy.STANDARD, + orderBy: [], + }, + }); + + 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).loadPageAsync({ + first: 3, + pagination: { + strategy: PaginationStrategy.STANDARD, + orderBy: [], + }, + }); - // 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(pageWithExplicitId.edges).toHaveLength(3); }); - expect(nextPage.edges).toHaveLength(2); - expect(nextPage.edges[0]?.node.getField('name')).toBe('Charlie'); - expect(nextPage.edges[1]?.node.getField('name')).toBe('David'); - }); + it('throws error for invalid cursor format', async () => { + const vc = new ViewerContext( + createKnexIntegrationTestEntityCompanionProvider(knexInstance), + ); - it('derives postgres cursor fields from orderBy', async () => { - const vc = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance)); + // Try with completely invalid cursor + await expect( + PostgresTestEntity.knexLoader(vc).loadPageAsync({ + first: 10, + after: 'not-a-valid-cursor', + pagination: { + strategy: PaginationStrategy.STANDARD, + orderBy: [], + }, + }), + ).rejects.toThrow('Failed to decode cursor'); - const page = await PostgresTestEntity.knexLoader(vc).loadPageBySQLAsync({ - first: 3, - orderBy: [{ fieldName: 'dateField', order: OrderByOrdering.ASCENDING }], + // Try with valid base64 but invalid JSON + const invalidJsonCursor = Buffer.from('not json').toString('base64url'); + await expect( + PostgresTestEntity.knexLoader(vc).loadPageAsync({ + first: 10, + after: invalidJsonCursor, + pagination: { + strategy: PaginationStrategy.STANDARD, + orderBy: [], + }, + }), + ).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).loadPageAsync({ + first: 10, + after: missingFieldsCursor, + pagination: { + strategy: PaginationStrategy.STANDARD, + orderBy: [], + }, + }), + ).rejects.toThrow("Cursor is missing required 'id' field."); }); - expect(page.edges).toHaveLength(3); + 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).loadPageAsync({ + first: 4, + pagination: { + strategy: PaginationStrategy.STANDARD, + 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).loadPageAsync({ + first: 4, + after: pageEnforced.pageInfo.endCursor!, + pagination: { + strategy: PaginationStrategy.STANDARD, + 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 loadPageWithSearchAsync with knexLoaderWithAuthorizationResults + // returns entities directly, not Result objects (unlike loadManyBySQL) + const pageWithAuth = await PostgresTestEntity.knexLoaderWithAuthorizationResults( + vc, + ).loadPageAsync({ + first: 3, + pagination: { + strategy: PaginationStrategy.STANDARD, + orderBy: [{ fieldName: 'name', order: OrderByOrdering.ASCENDING }], + }, + }); - // Navigate using cursor - const nextPage = await PostgresTestEntity.knexLoader(vc).loadPageBySQLAsync({ - first: 3, - after: page.pageInfo.endCursor!, - orderBy: [{ fieldName: 'dateField', 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'); }); - expect(nextPage.edges).toHaveLength(3); - expect(nextPage.pageInfo.hasNextPage).toBe(true); - }); + it('correctly handles hasMore flag when filtering unauthorized entities', async () => { + const vc = new ViewerContext( + createKnexIntegrationTestEntityCompanionProvider(knexInstance), + ); - it('performs backward pagination with descending order', async () => { - const vc = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance)); + await PostgresTestEntity.dropPostgresTableAsync(knexInstance); + await PostgresTestEntity.createOrTruncatePostgresTableAsync(knexInstance); - // Create test data with names that sort in a specific order - 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(); + } - 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); - } + // Load with limit 5 - should have hasNextPage=true + const page1 = await PostgresTestEntity.knexLoader(vc).loadPageAsync({ + first: 5, + pagination: { + strategy: PaginationStrategy.STANDARD, + orderBy: [], + }, + }); - // 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 }], + expect(page1.edges).toHaveLength(5); + expect(page1.pageInfo.hasNextPage).toBe(true); + + // Load the last entity + const page2 = await PostgresTestEntity.knexLoader(vc).loadPageAsync({ + first: 5, + after: page1.pageInfo.endCursor!, + pagination: { + strategy: PaginationStrategy.STANDARD, + orderBy: [], + }, + }); + + expect(page2.edges).toHaveLength(1); + expect(page2.pageInfo.hasNextPage).toBe(false); }); - // 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 }], + it('orderByFragment takes precedence over orderBy when both are specified', async () => { + 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(); + await PostgresTestEntity.creator(vc).setField('name', 'Alice').createAsync(); + await PostgresTestEntity.creator(vc).setField('name', 'Bob').createAsync(); + await PostgresTestEntity.creator(vc).setField('name', 'David').createAsync(); + + // Test that orderByFragment overrides orderBy completely + // orderBy would sort by name ascending (Alice, Bob, Charlie, David) + // orderByFragment will sort by name descending (David, Charlie, Bob, Alice) + const results = await PostgresTestEntity.knexLoader(vc) + .loadManyBySQL(sql`1 = 1`, { + orderBy: [{ fieldName: 'name', order: OrderByOrdering.ASCENDING }], + orderByFragment: sql`name DESC`, + }) + .executeAsync(); + + // Should be in descending order due to orderByFragment taking precedence + expect(results).toHaveLength(4); + expect(results[0]?.getField('name')).toBe('David'); + expect(results[1]?.getField('name')).toBe('Charlie'); + expect(results[2]?.getField('name')).toBe('Bob'); + expect(results[3]?.getField('name')).toBe('Alice'); }); - // 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('performs paginated search with both loader types', async () => { + 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'); + + // Create test data with searchable names and mixed attributes + const testData = [ + { name: 'Alice Johnson', hasACat: true, hasADog: false }, + { name: 'Bob Smith', hasACat: false, hasADog: true }, + { name: 'Charlie Johnson', hasACat: true, hasADog: false }, + { name: 'David Smith', hasACat: false, hasADog: false }, + { name: 'Eve Thompson', hasACat: true, hasADog: true }, + { name: 'Frank Johnson', hasACat: false, hasADog: true }, + ]; + + for (const data of testData) { + await PostgresTestEntity.creator(vc) + .setField('name', data.name) + .setField('hasACat', data.hasACat) + .setField('hasADog', data.hasADog) + .createAsync(); + } - it('always includes ID field in orderBy for stability', async () => { - const vc = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance)); + // Test 1: Regular loader with ILIKE search + const iLikeSearchRegular = await PostgresTestEntity.knexLoader(vc).loadPageAsync({ + first: 2, + pagination: { + strategy: PaginationStrategy.ILIKE_SEARCH, + term: 'Johnson', + fields: ['name'], + }, + }); - // Create entities with duplicate values to test stability - await PostgresTestEntity.dropPostgresTableAsync(knexInstance); - await PostgresTestEntity.createOrTruncatePostgresTableAsync(knexInstance); + expect(iLikeSearchRegular.edges).toHaveLength(2); + expect(iLikeSearchRegular.edges[0]?.node.getField('name')).toBe('Alice Johnson'); + expect(iLikeSearchRegular.edges[1]?.node.getField('name')).toBe('Charlie Johnson'); + expect(iLikeSearchRegular.pageInfo.hasNextPage).toBe(true); + + // Test 2: Authorization result loader with same ILIKE search + const iLikeSearchAuth = await PostgresTestEntity.knexLoaderWithAuthorizationResults( + vc, + ).loadPageAsync({ + first: 2, + pagination: { + strategy: PaginationStrategy.ILIKE_SEARCH, + term: 'Johnson', + fields: ['name'], + }, + }); - 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); - } + expect(iLikeSearchAuth.edges).toHaveLength(2); + // Authorization loader returns entities directly, not Result objects + expect(iLikeSearchAuth.edges[0]?.node.getField('name')).toBe('Alice Johnson'); + expect(iLikeSearchAuth.edges[1]?.node.getField('name')).toBe('Charlie Johnson'); + expect(iLikeSearchAuth.pageInfo.hasNextPage).toBe(true); + + // Test 3: Regular loader with TRIGRAM search + const trigramSearchRegular = await PostgresTestEntity.knexLoader(vc).loadPageAsync({ + first: 3, + pagination: { + strategy: PaginationStrategy.TRIGRAM_SEARCH, + term: 'Jonson', // Intentional misspelling to test similarity + fields: ['name'], + threshold: 0.2, + }, + }); - // 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 }], - }); + // Should find Johnson names due to similarity + expect(trigramSearchRegular.edges.length).toBeGreaterThan(0); + const foundNames = trigramSearchRegular.edges.map((e) => e.node.getField('name')); + expect(foundNames).toContain('Alice Johnson'); + expect(foundNames).toContain('Charlie Johnson'); + expect(foundNames).toContain('Frank Johnson'); + + // Test 4: Authorization result loader with TRIGRAM search + const trigramSearchAuth = await PostgresTestEntity.knexLoaderWithAuthorizationResults( + vc, + ).loadPageAsync({ + first: 3, + pagination: { + strategy: PaginationStrategy.TRIGRAM_SEARCH, + term: 'Jonson', // Intentional misspelling + fields: ['name'], + threshold: 0.2, + }, + }); - expect(firstPage.edges).toHaveLength(3); + expect(trigramSearchAuth.edges.length).toBeGreaterThan(0); + const foundNamesAuth = trigramSearchAuth.edges.map((e) => e.node.getField('name')); + expect(foundNamesAuth).toContain('Alice Johnson'); + expect(foundNamesAuth).toContain('Charlie Johnson'); + expect(foundNamesAuth).toContain('Frank Johnson'); + + // Test 5: Test pagination with cursor for both loader types + const firstPageRegular = await PostgresTestEntity.knexLoader(vc).loadPageAsync({ + first: 1, + pagination: { + strategy: PaginationStrategy.ILIKE_SEARCH, + term: 'Smith', + fields: ['name'], + }, + }); - // Get second page - const secondPage = await PostgresTestEntity.knexLoader(vc).loadPageBySQLAsync({ - first: 3, - after: firstPage.pageInfo.endCursor!, - orderBy: [{ fieldName: 'name', order: OrderByOrdering.ASCENDING }], - }); + expect(firstPageRegular.edges).toHaveLength(1); + expect(firstPageRegular.edges[0]?.node.getField('name')).toBe('Bob Smith'); - expect(secondPage.edges).toHaveLength(3); + const secondPageRegular = await PostgresTestEntity.knexLoader(vc).loadPageAsync({ + first: 1, + after: firstPageRegular.pageInfo.endCursor!, + pagination: { + strategy: PaginationStrategy.ILIKE_SEARCH, + term: 'Smith', + fields: ['name'], + }, + }); - // 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); + expect(secondPageRegular.edges).toHaveLength(1); + expect(secondPageRegular.edges[0]?.node.getField('name')).toBe('David Smith'); - // 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 }, - ], - }); + // Test 6: Combine search with WHERE filter for both loaders + const filteredSearchRegular = await PostgresTestEntity.knexLoader(vc).loadPageAsync({ + first: 10, + where: sql`has_a_cat = ${true}`, + pagination: { + strategy: PaginationStrategy.ILIKE_SEARCH, + term: 'Johnson', + fields: ['name'], + }, + }); + + // Only Alice Johnson and Charlie Johnson have cats + expect(filteredSearchRegular.edges).toHaveLength(2); + expect(filteredSearchRegular.edges[0]?.node.getField('name')).toBe('Alice Johnson'); + expect(filteredSearchRegular.edges[0]?.node.getField('hasACat')).toBe(true); + expect(filteredSearchRegular.edges[1]?.node.getField('name')).toBe('Charlie Johnson'); + expect(filteredSearchRegular.edges[1]?.node.getField('hasACat')).toBe(true); + + const filteredSearchAuth = await PostgresTestEntity.knexLoaderWithAuthorizationResults( + vc, + ).loadPageAsync({ + first: 10, + where: sql`has_a_cat = ${true}`, + pagination: { + strategy: PaginationStrategy.ILIKE_SEARCH, + term: 'Johnson', + fields: ['name'], + }, + }); + + expect(filteredSearchAuth.edges).toHaveLength(2); + expect(filteredSearchAuth.edges[0]?.node.getField('name')).toBe('Alice Johnson'); + expect(filteredSearchAuth.edges[1]?.node.getField('name')).toBe('Charlie Johnson'); + + // Test 7: Test with both loader types + const withRegular = await PostgresTestEntity.knexLoader(vc).loadPageAsync({ + first: 1, + pagination: { + strategy: PaginationStrategy.ILIKE_SEARCH, + term: 'Johnson', + fields: ['name'], + }, + }); + + expect(withRegular.edges).toHaveLength(1); + + const withAuth = await PostgresTestEntity.knexLoaderWithAuthorizationResults( + vc, + ).loadPageAsync({ + first: 1, + pagination: { + strategy: PaginationStrategy.ILIKE_SEARCH, + term: 'Johnson', + fields: ['name'], + }, + }); - expect(pageWithExplicitId.edges).toHaveLength(3); + expect(withAuth.edges).toHaveLength(1); + }); }); - it('throws error for invalid cursor format', async () => { - const vc = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance)); + describe(PaginationStrategy.ILIKE_SEARCH, () => { + it('supports search with ILIKE strategy', async () => { + const vc = new ViewerContext( + createKnexIntegrationTestEntityCompanionProvider(knexInstance), + ); - // Try with completely invalid cursor - await expect( - PostgresTestEntity.knexLoader(vc).loadPageBySQLAsync({ + await PostgresTestEntity.dropPostgresTableAsync(knexInstance); + await PostgresTestEntity.createOrTruncatePostgresTableAsync(knexInstance); + + // Create test data with searchable names + const names = [ + 'Alice Johnson', + 'Bob Smith', + 'Charlie Brown', + 'David Smith', + 'Eve Johnson', + 'Frank Miller', + ]; + for (let i = 0; i < names.length; i++) { + await PostgresTestEntity.creator(vc) + .setField('name', names[i]!) + .setField('hasACat', i % 2 === 0) + .createAsync(); + } + + // Search for names containing "Johnson" + const searchResults = await PostgresTestEntity.knexLoader(vc).loadPageAsync({ first: 10, - after: 'not-a-valid-cursor', - orderBy: [{ fieldName: 'name', order: OrderByOrdering.ASCENDING }], - }), - ).rejects.toThrow('Failed to decode cursor'); + pagination: { + strategy: PaginationStrategy.ILIKE_SEARCH, + term: 'Johnson', + fields: ['name'], + }, + }); - // Try with valid base64 but invalid JSON - const invalidJsonCursor = Buffer.from('not json').toString('base64url'); - await expect( - PostgresTestEntity.knexLoader(vc).loadPageBySQLAsync({ + expect(searchResults.edges).toHaveLength(2); + expect(searchResults.edges[0]?.node.getField('name')).toBe('Alice Johnson'); + expect(searchResults.edges[1]?.node.getField('name')).toBe('Eve Johnson'); + + // Search for names containing "Smith" with pagination + const smithPage1 = await PostgresTestEntity.knexLoader(vc).loadPageAsync({ + first: 1, + pagination: { + strategy: PaginationStrategy.ILIKE_SEARCH, + term: 'Smith', + fields: ['name'], + }, + }); + + expect(smithPage1.edges).toHaveLength(1); + expect(smithPage1.edges[0]?.node.getField('name')).toBe('Bob Smith'); + expect(smithPage1.pageInfo.hasNextPage).toBe(true); + + // Get next page + const smithPage2 = await PostgresTestEntity.knexLoader(vc).loadPageAsync({ + first: 1, + after: smithPage1.pageInfo.endCursor!, + pagination: { + strategy: PaginationStrategy.ILIKE_SEARCH, + term: 'Smith', + fields: ['name'], + }, + }); + + expect(smithPage2.edges).toHaveLength(1); + expect(smithPage2.edges[0]?.node.getField('name')).toBe('David Smith'); + expect(smithPage2.pageInfo.hasNextPage).toBe(false); + + // Test partial match (case insensitive) + const partialMatch = await PostgresTestEntity.knexLoader(vc).loadPageAsync({ first: 10, - after: invalidJsonCursor, - orderBy: [{ fieldName: 'name', order: OrderByOrdering.ASCENDING }], - }), - ).rejects.toThrow('Failed to decode cursor'); + pagination: { + strategy: PaginationStrategy.ILIKE_SEARCH, + term: 'john', + fields: ['name'], + }, + }); - // Try with valid JSON but missing required fields - const missingFieldsCursor = Buffer.from(JSON.stringify({ some: 'field' })).toString( - 'base64url', - ); - await expect( - PostgresTestEntity.knexLoader(vc).loadPageBySQLAsync({ + expect(partialMatch.edges).toHaveLength(2); + expect(partialMatch.edges[0]?.node.getField('name')).toBe('Alice Johnson'); + expect(partialMatch.edges[1]?.node.getField('name')).toBe('Eve Johnson'); + + // Test search with WHERE clause + const combinedFilter = await PostgresTestEntity.knexLoader(vc).loadPageAsync({ first: 10, - after: missingFieldsCursor, - orderBy: [{ fieldName: 'name', order: OrderByOrdering.ASCENDING }], - }), - ).rejects.toThrow("Cursor is missing required 'id' field."); - }); + where: sql`has_a_cat = ${true}`, + pagination: { + strategy: PaginationStrategy.ILIKE_SEARCH, + term: 'Johnson', + fields: ['name'], + }, + }); - it('performs pagination with both loader types', async () => { - const vc = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance)); + // Both Alice Johnson (index 0) and Eve Johnson (index 4) have cats + expect(combinedFilter.edges).toHaveLength(2); + expect(combinedFilter.edges[0]?.node.getField('name')).toBe('Alice Johnson'); + expect(combinedFilter.edges[0]?.node.getField('hasACat')).toBe(true); + expect(combinedFilter.edges[1]?.node.getField('name')).toBe('Eve Johnson'); + expect(combinedFilter.edges[1]?.node.getField('hasACat')).toBe(true); + }); - await PostgresTestEntity.dropPostgresTableAsync(knexInstance); - await PostgresTestEntity.createOrTruncatePostgresTableAsync(knexInstance); + it('search with ILIKE strategy works with forward and backward pagination', 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(); - } + // Create test data + const names = ['Apple', 'Application', 'Apply', 'Banana', 'Cherry', 'Pineapple']; + for (const name of names) { + await PostgresTestEntity.creator(vc).setField('name', name).createAsync(); + } + + // Forward pagination with ILIKE search + const forwardPage = await PostgresTestEntity.knexLoader(vc).loadPageAsync({ + first: 2, + pagination: { + strategy: PaginationStrategy.ILIKE_SEARCH, + term: 'app', + fields: ['name'], + }, + }); + + expect(forwardPage.edges).toHaveLength(2); + const forwardNames = forwardPage.edges.map((e) => e.node.getField('name')); + // Should match Apple, Application, Apply, Pineapple (case-insensitive) + forwardNames.forEach((name) => { + expect(name?.toLowerCase()).toContain('app'); + }); + + // Backward pagination with ILIKE search + const backwardPage = await PostgresTestEntity.knexLoader(vc).loadPageAsync({ + last: 2, + pagination: { + strategy: PaginationStrategy.ILIKE_SEARCH, + term: 'app', + fields: ['name'], + }, + }); + + expect(backwardPage.edges).toHaveLength(2); + const backwardNames = backwardPage.edges.map((e) => e.node.getField('name')); + backwardNames.forEach((name) => { + expect(name?.toLowerCase()).toContain('app'); + }); + + // Verify complete coverage with cursors + const allResults: string[] = []; + let cursor: string | undefined; + let hasNext = true; + + while (hasNext) { + const page = await PostgresTestEntity.knexLoader(vc).loadPageAsync({ + first: 10, + ...(cursor && { after: cursor }), + pagination: { + strategy: PaginationStrategy.ILIKE_SEARCH, + term: 'app', + fields: ['name'], + }, + }); + + allResults.push( + ...page.edges + .map((e) => e.node.getField('name')) + .filter((n): n is string => n !== null), + ); + cursor = page.pageInfo.endCursor ?? undefined; + hasNext = page.pageInfo.hasNextPage; + } - // Test with enforcing loader (standard pagination) - const pageEnforced = await PostgresTestEntity.knexLoader(vc).loadPageBySQLAsync({ - first: 4, - orderBy: [{ fieldName: 'name', order: OrderByOrdering.ASCENDING }], + // Should find all names containing 'app' (case-insensitive) + expect(allResults).toContain('Apple'); + expect(allResults).toContain('Application'); + expect(allResults).toContain('Apply'); + expect(allResults).toContain('Pineapple'); + expect(allResults).not.toContain('Banana'); + expect(allResults).not.toContain('Cherry'); }); - // 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 }], + it('verifies ILIKE search cursor pagination works correctly with ORDER BY', async () => { + const vc = new ViewerContext( + createKnexIntegrationTestEntityCompanionProvider(knexInstance), + ); + + // Create test data with many matching records + const testNames = []; + for (let i = 0; i < 20; i++) { + testNames.push(`Test${i.toString().padStart(2, '0')}_Pattern`); + } + + // Shuffle the array to create in random order + const shuffled = [...testNames].sort(() => Math.random() - 0.5); + + // Create entities in shuffled order + const createdEntities = []; + for (const name of shuffled) { + const entity = await PostgresTestEntity.creator(vc) + .setField('name', name) + .setField('hasACat', Math.random() > 0.5) + .createAsync(); + createdEntities.push(entity); + } + + // Paginate through all results with ILIKE search, collecting all names + const allNames: string[] = []; + let cursor: string | undefined; + let pageCount = 0; + const pageSize = 3; + + while (true) { + const page = await PostgresTestEntity.knexLoader(vc).loadPageAsync({ + first: pageSize, + ...(cursor && { after: cursor }), + pagination: { + strategy: PaginationStrategy.ILIKE_SEARCH, + term: 'Pattern', + fields: ['name'], + }, + }); + + pageCount++; + allNames.push( + ...page.edges + .map((e) => e.node.getField('name')) + .filter((n): n is string => n !== null), + ); + + if (!page.pageInfo.hasNextPage || pageCount > 10) { + break; + } + cursor = page.pageInfo.endCursor!; + } + + // With proper ORDER BY, we should get all matching records + expect(allNames.length).toBe(20); + + // Check that we got all unique names (no duplicates) + const uniqueNames = new Set(allNames); + expect(uniqueNames.size).toBe(20); + + // Verify all expected names are present + const sortedTestNames = [...testNames].sort(); + const sortedAllNames = [...allNames].sort(); + expect(sortedAllNames).toEqual(sortedTestNames); + + // Test backward pagination + const backwardNames: string[] = []; + let backCursor: string | undefined; + pageCount = 0; + + while (true) { + const page = await PostgresTestEntity.knexLoader(vc).loadPageAsync({ + last: pageSize, + ...(backCursor && { before: backCursor }), + pagination: { + strategy: PaginationStrategy.ILIKE_SEARCH, + term: 'Pattern', + fields: ['name'], + }, + }); + + pageCount++; + backwardNames.unshift( + ...page.edges + .map((e) => e.node.getField('name')) + .filter((n): n is string => n !== null), + ); + + if (!page.pageInfo.hasPreviousPage || pageCount > 10) { + break; + } + backCursor = page.pageInfo.startCursor!; + } + + // Backward pagination should also return all records + expect(backwardNames.length).toBe(20); + expect(new Set(backwardNames).size).toBe(20); + + // With ORDER BY (search fields + ID), the ordering is deterministic + // Forward and backward should produce the same order since we're ordering by name ASC/DESC + ID + expect(allNames).toEqual(backwardNames); + + // Verify the ordering follows the search fields (name in this case) + // Since we order by name ASC for forward pagination, names should be sorted + const expectedOrder = [...testNames].sort(); + expect(allNames).toEqual(expectedOrder); }); + }); + + describe(PaginationStrategy.TRIGRAM_SEARCH, () => { + it('supports trigram similarity search', async () => { + 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'); + + // Create test data with similar names + const names = ['Johnson', 'Jonson', 'Johnsen', 'Smith', 'Smyth', 'Schmidt']; + for (let i = 0; i < names.length; i++) { + await PostgresTestEntity.creator(vc) + .setField('name', names[i]!) + .setField('hasACat', i < 3) // First 3 have cats + .createAsync(); + } + + // Search for similar names to "Johnson" using trigram + const trigramSearch = await PostgresTestEntity.knexLoader(vc).loadPageAsync({ + first: 10, + pagination: { + strategy: PaginationStrategy.TRIGRAM_SEARCH, + term: 'Johnson', + fields: ['name'], + threshold: 0.3, // Similarity threshold + }, + }); + + // Should find exact match and similar names, ordered by relevance + expect(trigramSearch.edges.length).toBeGreaterThan(0); + // Exact match should come first due to ILIKE matching + expect(trigramSearch.edges[0]?.node.getField('name')).toBe('Johnson'); + + // The similar names (Jonson, Johnsen) should also be included + const foundNames = trigramSearch.edges.map((e) => e.node.getField('name')); + expect(foundNames).toContain('Jonson'); + expect(foundNames).toContain('Johnsen'); + + // Test combining with WHERE clause + const filteredTrigram = await PostgresTestEntity.knexLoader(vc).loadPageAsync({ + first: 10, + where: sql`has_a_cat = ${true}`, + pagination: { + strategy: PaginationStrategy.TRIGRAM_SEARCH, + term: 'Johnson', + fields: ['name'], + threshold: 0.3, + }, + }); - 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 }], + // Only the Johnson-like names with cats + expect(filteredTrigram.edges.length).toBeGreaterThan(0); + expect(filteredTrigram.edges.length).toBeLessThanOrEqual(3); + filteredTrigram.edges.forEach((edge) => { + expect(edge.node.getField('hasACat')).toBe(true); + }); }); - 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('supports trigram search with cursor pagination', async () => { + 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'); + + // Create test data with similar names + const names = [ + 'Johnson', + 'Jonson', + 'Johnsen', + 'Johnston', + 'Johan', + 'Smith', + 'Smyth', + 'Schmidt', + 'Smithers', + 'Smythe', + ]; + for (let i = 0; i < names.length; i++) { + await PostgresTestEntity.creator(vc) + .setField('name', names[i]!) + .setField('hasACat', i < 5) // First 5 have cats (Johnson-like names) + .createAsync(); + } - it('correctly handles hasMore flag when filtering unauthorized entities', async () => { - const vc = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance)); + // First page with trigram search (no cursor) + const firstPage = await PostgresTestEntity.knexLoader(vc).loadPageAsync({ + first: 3, + pagination: { + strategy: PaginationStrategy.TRIGRAM_SEARCH, + term: 'Johnson', + fields: ['name'], + threshold: 0.3, + }, + }); - await PostgresTestEntity.dropPostgresTableAsync(knexInstance); - await PostgresTestEntity.createOrTruncatePostgresTableAsync(knexInstance); + // Should have results ordered by relevance + expect(firstPage.edges.length).toBeGreaterThan(0); + expect(firstPage.edges[0]?.node.getField('name')).toBe('Johnson'); // Exact match first + const firstPageCursor = firstPage.pageInfo.endCursor; + expect(firstPageCursor).not.toBeNull(); + + // Second page with cursor + // Note: For trigram search with cursor, we use regular orderBy instead of custom order + // so results might not be in perfect similarity order, but should still be filtered + const secondPage = await PostgresTestEntity.knexLoader(vc).loadPageAsync({ + first: 3, + after: firstPageCursor!, + pagination: { + strategy: PaginationStrategy.TRIGRAM_SEARCH, + term: 'Johnson', + fields: ['name'], + threshold: 0.3, + }, + }); - // Create exactly 6 entities - for (let i = 1; i <= 6; i++) { - await PostgresTestEntity.creator(vc).setField('name', `Entity${i}`).createAsync(); - } + // Should have results (might be empty if first page had all results) + expect(secondPage.edges.length).toBeGreaterThanOrEqual(0); + + // The key test is that the query runs successfully with the searchOrderByFragment + // being passed through the parallel query path + + // Test backward pagination with cursor + const lastPage = await PostgresTestEntity.knexLoader(vc).loadPageAsync({ + last: 2, + before: firstPageCursor!, + pagination: { + strategy: PaginationStrategy.TRIGRAM_SEARCH, + term: 'Johnson', + fields: ['name'], + threshold: 0.3, + }, + }); - // Load with limit 5 - should have hasNextPage=true - const page1 = await PostgresTestEntity.knexLoader(vc).loadPageBySQLAsync({ - first: 5, - orderBy: [{ fieldName: 'name', order: OrderByOrdering.ASCENDING }], + // Should have results before the cursor + expect(lastPage.edges.length).toBeGreaterThanOrEqual(0); + + // Test with WHERE clause, cursor, and search + const firstEdgeCursor = firstPage.edges[0]?.cursor; + expect(firstEdgeCursor).toBeDefined(); + const filteredWithCursor = await PostgresTestEntity.knexLoader(vc).loadPageAsync({ + first: 2, + after: firstEdgeCursor!, + where: sql`has_a_cat = ${true}`, + pagination: { + strategy: PaginationStrategy.TRIGRAM_SEARCH, + term: 'Johnson', + fields: ['name'], + threshold: 0.3, + }, + }); + + // Should have filtered results with correct + expect(filteredWithCursor.edges.length).toBeGreaterThanOrEqual(0); + filteredWithCursor.edges.forEach((edge) => { + expect(edge.node.getField('hasACat')).toBe(true); + }); }); - expect(page1.edges).toHaveLength(5); - expect(page1.pageInfo.hasNextPage).toBe(true); + it('correctly orders trigram search results for forward and backward pagination', async () => { + 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'); + + // Create test data with similar names and unique IDs for stable ordering + const testData = [ + { name: 'Johnson', hasACat: true }, // Exact match, should be first + { name: 'Jonson', hasACat: true }, // High similarity + { name: 'Johnsen', hasACat: true }, // High similarity + { name: 'Johnston', hasACat: true }, // Medium similarity + { name: 'Johan', hasACat: true }, // Lower similarity + { name: 'John', hasACat: false }, // Lower similarity + { name: 'Smith', hasACat: false }, // No similarity + { name: 'Williams', hasACat: false }, // No similarity + ]; + + const createdEntities = []; + for (const data of testData) { + const entity = await PostgresTestEntity.creator(vc) + .setField('name', data.name) + .setField('hasACat', data.hasACat) + .createAsync(); + createdEntities.push(entity); + } + + // Test 1: Forward pagination (first) + const firstPageForward = await PostgresTestEntity.knexLoader(vc).loadPageAsync({ + first: 4, + pagination: { + strategy: PaginationStrategy.TRIGRAM_SEARCH, + term: 'Johnson', + fields: ['name'], + extraOrderByFields: ['createdAt'], // Ensure stable ordering for similar scores since ID is uuidv4 + threshold: 0.2, + }, + }); + + // Johnson should be first (exact match), followed by high similarity matches + expect(firstPageForward.edges.length).toBeGreaterThan(0); + expect(firstPageForward.edges[0]?.node.getField('name')).toBe('Johnson'); + + // All results should match the search term + const forwardNames = firstPageForward.edges.map((e) => e.node.getField('name')); + expect(forwardNames).not.toContain('Smith'); + expect(forwardNames).not.toContain('Williams'); + + // Test 2: Backward pagination (last) + const lastPageBackward = await PostgresTestEntity.knexLoader(vc).loadPageAsync({ + last: 4, + pagination: { + strategy: PaginationStrategy.TRIGRAM_SEARCH, + term: 'Johnson', + fields: ['name'], + extraOrderByFields: ['createdAt'], // Ensure stable ordering for similar scores since ID is uuidv4 + threshold: 0.2, + }, + }); + + // Results should be in the same order (not reversed) after internal processing + expect(lastPageBackward.edges.length).toBeGreaterThan(0); + const backwardNames = lastPageBackward.edges.map((e) => e.node.getField('name')); + + // Should not include non-matching names + expect(backwardNames).not.toContain('Smith'); + expect(backwardNames).not.toContain('Williams'); + + // Test 3: Test cursor pagination with trigram search + // With the improved implementation, TRIGRAM cursor pagination now preserves + // similarity-based ordering by computing similarity scores dynamically via subquery + const firstPageForwardCursor = await PostgresTestEntity.knexLoader(vc).loadPageAsync({ + first: 3, + pagination: { + strategy: PaginationStrategy.TRIGRAM_SEARCH, + term: 'Johnson', + fields: ['name'], + extraOrderByFields: ['createdAt'], // Ensure stable ordering for similar scores since ID is uuidv4 + threshold: 0.2, + }, + }); + + expect(firstPageForwardCursor.edges.length).toBeGreaterThan(0); + const firstPageForwardCursorData = firstPageForwardCursor.edges.map((e) => ({ + name: e.node.getField('name'), + id: e.node.getID(), + createdAt: e.node.getField('createdAt'), + })); + const firstPageForwardCursorIDs = firstPageForwardCursorData.map((d) => d.id); + + const secondPageForwardCursor = await PostgresTestEntity.knexLoader(vc).loadPageAsync({ + first: 3, + after: firstPageForwardCursor.pageInfo.endCursor!, + pagination: { + strategy: PaginationStrategy.TRIGRAM_SEARCH, + term: 'Johnson', + fields: ['name'], + extraOrderByFields: ['createdAt'], // Ensure stable ordering for similar scores since ID is uuidv4 + threshold: 0.2, + }, + }); + + const secondPageForwardCursorData = secondPageForwardCursor.edges.map((e) => ({ + name: e.node.getField('name'), + id: e.node.getID(), + createdAt: e.node.getField('createdAt'), + })); + const secondPageForwardCursorIDs = secondPageForwardCursorData.map((d) => d.id); + expect(secondPageForwardCursorIDs.length).toBeGreaterThan(0); + + // With the new subquery-based cursor implementation, there should be no overlap + // between pages as ordering is perfectly preserved + const overlapForwardCursor = firstPageForwardCursorIDs.filter((id) => + secondPageForwardCursorIDs.includes(id), + ); + expect(overlapForwardCursor).toHaveLength(0); + + // Test 4: test backward cursor pagination with trigram search + const firstPageBackwardCursor = await PostgresTestEntity.knexLoader(vc).loadPageAsync({ + last: 3, + pagination: { + strategy: PaginationStrategy.TRIGRAM_SEARCH, + term: 'Johnson', + fields: ['name'], + extraOrderByFields: ['createdAt'], // Ensure stable ordering for similar scores since ID is uuidv4 + threshold: 0.2, + }, + }); + + const firstPageBackwardCursorData = firstPageBackwardCursor.edges.map((e) => ({ + name: e.node.getField('name'), + id: e.node.getID(), + createdAt: e.node.getField('createdAt'), + })); + const firstPageBackwardIDs = firstPageBackwardCursorData.map((d) => d.id); + expect(firstPageBackwardIDs.length).toBeGreaterThan(0); + + const secondPageBackwardCursor = await PostgresTestEntity.knexLoader(vc).loadPageAsync({ + last: 3, + before: firstPageBackwardCursor.pageInfo.startCursor!, + pagination: { + strategy: PaginationStrategy.TRIGRAM_SEARCH, + term: 'Johnson', + fields: ['name'], + extraOrderByFields: ['createdAt'], // Ensure stable ordering for similar scores since ID is uuidv4 + threshold: 0.2, + }, + }); - // Load the last entity - const page2 = await PostgresTestEntity.knexLoader(vc).loadPageBySQLAsync({ - first: 5, - after: page1.pageInfo.endCursor!, - orderBy: [{ fieldName: 'name', order: OrderByOrdering.ASCENDING }], + const secondPageBackwardCursorData = secondPageBackwardCursor.edges.map((e) => ({ + name: e.node.getField('name'), + id: e.node.getID(), + createdAt: e.node.getField('createdAt'), + })); + const secondPageBackwardIDs = secondPageBackwardCursorData.map((d) => d.id); + expect(secondPageBackwardIDs.length).toBeGreaterThan(0); + + // With the new subquery-based cursor implementation, there should be no overlap + // between pages as ordering is perfectly preserved + const overlapBackwardCursor = firstPageBackwardIDs.filter((id) => + secondPageBackwardIDs.includes(id), + ); + expect(overlapBackwardCursor).toHaveLength(0); }); - expect(page2.edges).toHaveLength(1); - expect(page2.pageInfo.hasNextPage).toBe(false); + it('supports extraOrderByFields with TRIGRAM search for stable cursor pagination', async () => { + 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'); + + // Create test data with similar names and different hasACat values + const testData = [ + { name: 'Johnson', hasACat: true }, // Exact match + { name: 'Jonson', hasACat: false }, // High similarity + { name: 'Johnsen', hasACat: true }, // High similarity + { name: 'Johnston', hasACat: false }, // Medium similarity + { name: 'Johan', hasACat: true }, // Lower similarity + { name: 'John', hasACat: false }, // Lower similarity + { name: 'Johnny', hasACat: true }, // Lower similarity + { name: 'Smith', hasACat: false }, // No match + ]; + + for (const data of testData) { + await PostgresTestEntity.creator(vc) + .setField('name', data.name) + .setField('hasACat', data.hasACat) + .createAsync(); + } + + // Test TRIGRAM search with extraOrderByFields for stable pagination + const firstPage = await PostgresTestEntity.knexLoader(vc).loadPageAsync({ + first: 3, + pagination: { + strategy: PaginationStrategy.TRIGRAM_SEARCH, + term: 'Johnson', + fields: ['name'], + threshold: 0.2, + extraOrderByFields: ['hasACat'], // Add extra stable ordering + }, + }); + + expect(firstPage.edges.length).toBeGreaterThan(0); + expect(firstPage.edges[0]?.node.getField('name')).toBe('Johnson'); // Exact match first + + const firstPageCursor = firstPage.pageInfo.endCursor; + expect(firstPageCursor).not.toBeNull(); + + // Get second page using cursor + // With extraOrderByFields, cursor includes hasACat field which provides more stable pagination + const secondPage = await PostgresTestEntity.knexLoader(vc).loadPageAsync({ + first: 3, + after: firstPageCursor!, + pagination: { + strategy: PaginationStrategy.TRIGRAM_SEARCH, + term: 'Johnson', + fields: ['name'], + threshold: 0.2, + extraOrderByFields: ['hasACat'], + }, + }); + + // Store first page names for comparison + const firstPageNames = firstPage.edges.map((e) => e.node.getField('name')); + const secondPageNames = secondPage.edges.map((e) => e.node.getField('name')); + + // Verify no overlap between pages (ordering is preserved) + const overlap = firstPageNames.filter((name) => secondPageNames.includes(name)); + expect(overlap).toHaveLength(0); + + // Test backward pagination with extraOrderByFields + const lastPage = await PostgresTestEntity.knexLoader(vc).loadPageAsync({ + last: 2, + pagination: { + strategy: PaginationStrategy.TRIGRAM_SEARCH, + term: 'Johnson', + fields: ['name'], + threshold: 0.2, + extraOrderByFields: ['hasACat'], + }, + }); + + expect(lastPage.edges.length).toBeGreaterThan(0); + + // Test that extraOrderByFields provides consistent ordering + // Get all results in one go for comparison + const allResultsPage = await PostgresTestEntity.knexLoader(vc).loadPageAsync({ + first: 10, + pagination: { + strategy: PaginationStrategy.TRIGRAM_SEARCH, + term: 'Johnson', + fields: ['name'], + threshold: 0.2, + extraOrderByFields: ['hasACat'], + }, + }); + + // Verify results are ordered by: exact match first, then similarity, then hasACat, then id + const allNames = allResultsPage.edges.map((e) => ({ + name: e.node.getField('name'), + hasACat: e.node.getField('hasACat'), + })); + + // Johnson (exact match) should be first + expect(allNames[0]?.name).toBe('Johnson'); + }); }); }); }); diff --git a/packages/entity-database-adapter-knex/src/__testfixtures__/PostgresTestEntity.ts b/packages/entity-database-adapter-knex/src/__testfixtures__/PostgresTestEntity.ts index 9f94ed399..ca7aaad81 100644 --- a/packages/entity-database-adapter-knex/src/__testfixtures__/PostgresTestEntity.ts +++ b/packages/entity-database-adapter-knex/src/__testfixtures__/PostgresTestEntity.ts @@ -31,6 +31,7 @@ type PostgresTestEntityFields = { maybeJsonArrayField: string[] | { hello: string } | null; bigintField: string | null; binaryField: Buffer | null; + createdAt: Date; }; export class PostgresTestEntity extends Entity { @@ -64,6 +65,7 @@ export class PostgresTestEntity extends Entity { }); }); - describe('loads entities with loadPageBySQLAsync', () => { + describe('loads entities with loadPageAsync', () => { it('returns paginated entities with forward pagination', async () => { const privacyPolicy = new TestEntityPrivacyPolicy(); const spiedPrivacyPolicy = spy(privacyPolicy); @@ -460,7 +461,7 @@ describe(AuthorizationResultBasedKnexEntityLoader, () => { const id1 = uuidv4(); const id2 = uuidv4(); - when(knexDataManagerMock.loadPageBySQLFragmentAsync(queryContext, anything())).thenResolve({ + when(knexDataManagerMock.loadPageAsync(queryContext, anything())).thenResolve({ edges: [ { cursor: 'cursor1', @@ -511,10 +512,13 @@ describe(AuthorizationResultBasedKnexEntityLoader, () => { constructionUtils, ); - const connection = await knexEntityLoader.loadPageBySQLAsync({ + const connection = await knexEntityLoader.loadPageAsync({ first: 10, where: sql`intField > ${0}`, - orderBy: [{ fieldName: 'intField', order: OrderByOrdering.ASCENDING }], + pagination: { + strategy: PaginationStrategy.STANDARD, + orderBy: [{ fieldName: 'intField', order: OrderByOrdering.ASCENDING }], + }, }); expect(connection.edges).toHaveLength(2); @@ -564,7 +568,7 @@ describe(AuthorizationResultBasedKnexEntityLoader, () => { const id1 = uuidv4(); const id2 = uuidv4(); const id3 = uuidv4(); - when(knexDataManagerMock.loadPageBySQLFragmentAsync(queryContext, anything())).thenResolve({ + when(knexDataManagerMock.loadPageAsync(queryContext, anything())).thenResolve({ edges: [ { cursor: 'cursor1', @@ -623,10 +627,13 @@ describe(AuthorizationResultBasedKnexEntityLoader, () => { constructionUtils, ); - const connection = await knexEntityLoader.loadPageBySQLAsync({ + const connection = await knexEntityLoader.loadPageAsync({ first: 10, where: sql`score > ${0}`, - orderBy: [{ fieldName: 'createdAt', order: OrderByOrdering.ASCENDING }], + pagination: { + strategy: PaginationStrategy.STANDARD, + orderBy: [{ fieldName: 'createdAt', order: OrderByOrdering.ASCENDING }], + }, }); // Should only have 2 entities (unauthorized one filtered out) @@ -666,7 +673,7 @@ describe(AuthorizationResultBasedKnexEntityLoader, () => { const knexDataManagerMock = mock>(EntityKnexDataManager); - when(knexDataManagerMock.loadPageBySQLFragmentAsync(queryContext, anything())).thenResolve({ + when(knexDataManagerMock.loadPageAsync(queryContext, anything())).thenResolve({ edges: [ { cursor: 'cursor5', @@ -706,10 +713,13 @@ describe(AuthorizationResultBasedKnexEntityLoader, () => { constructionUtils, ); - const connection = await knexEntityLoader.loadPageBySQLAsync({ + const connection = await knexEntityLoader.loadPageAsync({ last: 5, before: 'someCursor', - orderBy: [{ fieldName: 'intField', order: OrderByOrdering.ASCENDING }], + pagination: { + strategy: PaginationStrategy.STANDARD, + orderBy: [{ fieldName: 'intField', order: OrderByOrdering.ASCENDING }], + }, }); expect(connection.edges).toHaveLength(1); @@ -717,4 +727,441 @@ describe(AuthorizationResultBasedKnexEntityLoader, () => { expect(connection.pageInfo.hasNextPage).toBe(false); }); }); + + describe('loads entities with loadPageAsync (search)', () => { + it('performs ILIKE search and filters unauthorized entities', 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(); + + // Mock data manager to return 3 entities from search + when(knexDataManagerMock.loadPageAsync(queryContext, anything())).thenResolve({ + edges: [ + { + cursor: 'cursor1', + node: { + id: id1, + name: 'Alice Johnson', + status: 'active', + createdAt: new Date(), + score: 1, + }, + }, + { + cursor: 'cursor2', + node: { + id: id2, + name: 'Bob Johnson', + status: 'unauthorized', // This will fail authorization per TestPaginationPrivacyPolicy + createdAt: new Date(), + score: 2, + }, + }, + { + cursor: 'cursor3', + node: { + id: id3, + name: 'Charlie Johnson', + status: 'active', + createdAt: new Date(), + score: 3, + }, + }, + ], + 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.loadPageAsync({ + first: 10, + pagination: { + strategy: PaginationStrategy.ILIKE_SEARCH, + term: 'Johnson', + fields: ['name'], + }, + }); + + // Should only have 2 edges (Bob was filtered out) + expect(connection.edges).toHaveLength(2); + expect(connection.edges[0]?.node.getField('id')).toBe(id1); + expect(connection.edges[0]?.node.getField('name')).toBe('Alice Johnson'); + expect(connection.edges[1]?.node.getField('id')).toBe(id3); + expect(connection.edges[1]?.node.getField('name')).toBe('Charlie Johnson'); + + // Cursors should be updated to reflect only authorized entities + expect(connection.pageInfo.startCursor).toBe('cursor1'); + expect(connection.pageInfo.endCursor).toBe('cursor3'); + }); + + it('performs TRIGRAM search with cursor pagination', 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(); + + // Mock first page of TRIGRAM search results + when(knexDataManagerMock.loadPageAsync(queryContext, anything())).thenResolve({ + edges: [ + { + cursor: 'cursor1', + node: { + id: id1, + name: 'Johnson', // Exact match + status: 'active', + createdAt: new Date(), + score: 1, + }, + }, + { + cursor: 'cursor2', + node: { + id: id2, + name: 'Jonson', // Similar match + status: 'active', + createdAt: new Date(), + score: 2, + }, + }, + ], + pageInfo: { + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'cursor1', + endCursor: 'cursor2', + }, + }); + + 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.loadPageAsync({ + first: 2, + after: 'someCursor', + pagination: { + strategy: PaginationStrategy.TRIGRAM_SEARCH, + term: 'Johnson', + fields: ['name'], + threshold: 0.3, + extraOrderByFields: ['score'], + }, + }); + + expect(connection.edges).toHaveLength(2); + expect(connection.edges[0]?.node.getField('name')).toBe('Johnson'); + expect(connection.edges[1]?.node.getField('name')).toBe('Jonson'); + expect(connection.pageInfo.hasNextPage).toBe(true); + }); + + it('handles backward pagination with search', 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(); + + // Mock backward pagination results + when(knexDataManagerMock.loadPageAsync(queryContext, anything())).thenResolve({ + edges: [ + { + cursor: 'cursor1', + node: { + id: id1, + name: 'Charlie Smith', + status: 'active', + createdAt: new Date(), + score: 3, + }, + }, + { + cursor: 'cursor2', + node: { + id: id2, + name: 'David Smith', + status: 'active', + createdAt: new Date(), + score: 4, + }, + }, + ], + pageInfo: { + hasNextPage: false, + hasPreviousPage: true, + startCursor: 'cursor1', + endCursor: 'cursor2', + }, + }); + + 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.loadPageAsync({ + last: 2, + before: 'someCursor', + pagination: { + strategy: PaginationStrategy.ILIKE_SEARCH, + term: 'Smith', + fields: ['name'], + }, + }); + + expect(connection.edges).toHaveLength(2); + expect(connection.pageInfo.hasPreviousPage).toBe(true); + expect(connection.pageInfo.hasNextPage).toBe(false); + }); + + it('handles empty search results', 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); + + // Mock empty search results + when(knexDataManagerMock.loadPageAsync(queryContext, anything())).thenResolve({ + edges: [], + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + endCursor: null, + }, + }); + + 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.loadPageAsync({ + first: 10, + pagination: { + strategy: PaginationStrategy.ILIKE_SEARCH, + term: 'NonexistentTerm', + fields: ['name'], + }, + }); + + expect(connection.edges).toHaveLength(0); + expect(connection.pageInfo.startCursor).toBeNull(); + expect(connection.pageInfo.endCursor).toBeNull(); + }); + + it('handles all entities failing 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(); + + // Mock search results + when(knexDataManagerMock.loadPageAsync(queryContext, anything())).thenResolve({ + edges: [ + { + cursor: 'cursor1', + node: { + id: id1, + name: 'Alice', + status: 'unauthorized', // This will fail authorization + createdAt: new Date(), + score: 1, + }, + }, + { + cursor: 'cursor2', + node: { + id: id2, + name: 'Bob', + status: 'unauthorized', // This will fail authorization + createdAt: new Date(), + score: 2, + }, + }, + ], + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + startCursor: 'cursor1', + endCursor: 'cursor2', + }, + }); + + 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.loadPageAsync({ + first: 10, + pagination: { + strategy: PaginationStrategy.ILIKE_SEARCH, + term: 'test', + fields: ['name'], + }, + }); + + // All entities filtered out due to failed authorization + expect(connection.edges).toHaveLength(0); + expect(connection.pageInfo.startCursor).toBeNull(); + expect(connection.pageInfo.endCursor).toBeNull(); + }); + }); }); 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 2921ac6f6..92562e49f 100644 --- a/packages/entity-database-adapter-knex/src/__tests__/EnforcingKnexEntityLoader-test.ts +++ b/packages/entity-database-adapter-knex/src/__tests__/EnforcingKnexEntityLoader-test.ts @@ -8,6 +8,7 @@ import { AuthorizationResultBasedSQLQueryBuilder, } from '../AuthorizationResultBasedKnexEntityLoader'; import { EnforcingKnexEntityLoader } from '../EnforcingKnexEntityLoader'; +import { PaginationStrategy } from '../PaginationStrategy'; import { sql } from '../SQLOperator'; import { EntityKnexDataManager } from '../internal/EntityKnexDataManager'; @@ -248,7 +249,7 @@ describe(EnforcingKnexEntityLoader, () => { }); }); - describe('loadPageBySQLAsync', () => { + describe('loadPageAsync', () => { it('throws when result is unsuccessful', async () => { const queryContext = instance(mock(EntityQueryContext)); const knexDataManagerMock = mock>(EntityKnexDataManager); @@ -257,7 +258,7 @@ describe(EnforcingKnexEntityLoader, () => { const rejection = new Error('Entity not authorized'); // Mock the data manager to return a connection with field objects - when(knexDataManagerMock.loadPageBySQLFragmentAsync(anything(), anything())).thenResolve({ + when(knexDataManagerMock.loadPageAsync(anything(), anything())).thenResolve({ edges: [ { cursor: 'cursor1', @@ -286,9 +287,12 @@ describe(EnforcingKnexEntityLoader, () => { ); await expect( - enforcingKnexEntityLoader.loadPageBySQLAsync({ + enforcingKnexEntityLoader.loadPageAsync({ first: 10, - orderBy: [], + pagination: { + strategy: PaginationStrategy.STANDARD, + orderBy: [], + }, }), ).rejects.toThrow(rejection); }); @@ -301,7 +305,7 @@ describe(EnforcingKnexEntityLoader, () => { const entity1 = { id: '1', name: 'Entity 1', getID: () => '1' }; const entity2 = { id: '2', name: 'Entity 2', getID: () => '2' }; - when(knexDataManagerMock.loadPageBySQLFragmentAsync(anything(), anything())).thenResolve({ + when(knexDataManagerMock.loadPageAsync(anything(), anything())).thenResolve({ edges: [ { cursor: 'cursor1', @@ -339,9 +343,12 @@ describe(EnforcingKnexEntityLoader, () => { instance(constructionUtilsMock), ); - const connection = await enforcingKnexEntityLoader.loadPageBySQLAsync({ + const connection = await enforcingKnexEntityLoader.loadPageAsync({ first: 10, - orderBy: [], + pagination: { + strategy: PaginationStrategy.STANDARD, + orderBy: [], + }, }); expect(connection.edges).toHaveLength(2); diff --git a/packages/entity-database-adapter-knex/src/index.ts b/packages/entity-database-adapter-knex/src/index.ts index cd822cfbe..df336fddc 100644 --- a/packages/entity-database-adapter-knex/src/index.ts +++ b/packages/entity-database-adapter-knex/src/index.ts @@ -11,6 +11,7 @@ export * from './EnforcingKnexEntityLoader'; export * from './EntityFields'; export * from './KnexEntityLoader'; export * from './KnexEntityLoaderFactory'; +export * from './PaginationStrategy'; export * from './PostgresEntityDatabaseAdapter'; export * from './PostgresEntityDatabaseAdapterProvider'; export * from './PostgresEntityQueryContextProvider'; diff --git a/packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts b/packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts index f732247d6..eddecc5d9 100644 --- a/packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts +++ b/packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts @@ -18,42 +18,63 @@ import { PostgresQuerySelectionModifiersWithOrderByFragment, PostgresQuerySelectionModifiersWithOrderByRaw, } from '../BasePostgresEntityDatabaseAdapter'; -import { SQLFragment, identifier, raw, sql } from '../SQLOperator'; +import { PaginationStrategy } from '../PaginationStrategy'; +import { SQLFragment, SQLFragmentHelpers, identifier, raw, sql } from '../SQLOperator'; -/** - * Base pagination arguments - */ -interface BasePaginationArgs> { +interface DataManagerStandardSpecification> { + strategy: PaginationStrategy.STANDARD; + orderBy: PostgresOrderByClause[]; +} + +interface DataManagerSearchSpecificationBase> { + term: string; + fields: (keyof TFields)[]; +} + +interface DataManagerILikeSearchSpecification< + TFields extends Record, +> extends DataManagerSearchSpecificationBase { + strategy: PaginationStrategy.ILIKE_SEARCH; +} + +interface DataManagerTrigramSearchSpecification< + TFields extends Record, +> extends DataManagerSearchSpecificationBase { + strategy: PaginationStrategy.TRIGRAM_SEARCH; + threshold: number; + extraOrderByFields?: (keyof TFields)[]; +} + +type DataManagerSearchSpecification> = + | DataManagerILikeSearchSpecification + | DataManagerTrigramSearchSpecification; + +type DataManagerPaginationSpecification> = + | DataManagerStandardSpecification + | DataManagerSearchSpecification; + +interface BaseUnifiedPaginationArgs> { where?: SQLFragment; - orderBy?: PostgresOrderByClause[]; + pagination: DataManagerPaginationSpecification; } -/** - * Forward pagination arguments - */ -export interface ForwardPaginationArgs< +interface ForwardUnifiedPaginationArgs< TFields extends Record, -> extends BasePaginationArgs { +> extends BaseUnifiedPaginationArgs { first: number; after?: string; } -/** - * Backward pagination arguments - */ -export interface BackwardPaginationArgs< +interface BackwardUnifiedPaginationArgs< TFields extends Record, -> extends BasePaginationArgs { +> extends BaseUnifiedPaginationArgs { last: number; before?: string; } -/** - * Combined pagination arguments using discriminated union - */ -export type LoadPageArgs> = - | ForwardPaginationArgs - | BackwardPaginationArgs; +type LoadPageArgs> = + | ForwardUnifiedPaginationArgs + | BackwardUnifiedPaginationArgs; /** * Edge in a connection @@ -81,6 +102,25 @@ export interface Connection { pageInfo: PageInfo; } +enum PaginationDirection { + FORWARD = 'forward', + BACKWARD = 'backward', +} + +const CURSOR_ROW_TABLE_ALIAS = 'cursor_row'; + +interface PaginationProvider, TIDField extends keyof TFields> { + whereClause: SQLFragment | undefined; + buildOrderBy: (direction: PaginationDirection) => { + clauses?: PostgresOrderByClause[]; + fragment?: SQLFragment | undefined; + }; + buildCursorCondition: ( + decodedCursorId: TFields[TIDField], + direction: PaginationDirection, + ) => SQLFragment; +} + /** * A knex data manager is responsible for handling non-dataloader-based * database operations. @@ -176,21 +216,116 @@ export class EntityKnexDataManager< } /** - * Load a page of objects using cursor-based pagination. + * Load a page of objects using cursor-based pagination with unified pagination specification. * * @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 + * @param args - pagination arguments including pagination and first/after or last/before * @returns connection with edges containing field objects and page info */ - async loadPageBySQLFragmentAsync( + async loadPageAsync( + queryContext: EntityQueryContext, + args: LoadPageArgs, + ): Promise>> { + const { where, pagination } = args; + + if (pagination.strategy === PaginationStrategy.STANDARD) { + // Standard pagination + const idField = this.entityConfiguration.idField; + const augmentedOrderByClauses = this.augmentOrderByIfNecessary(pagination.orderBy, idField); + + const fieldsToUseInPostgresTupleCursor = augmentedOrderByClauses.map( + (order) => order.fieldName, + ); + + // Create strategy for regular pagination + const strategy: PaginationProvider = { + whereClause: where, + buildOrderBy: (direction) => { + // For backward pagination, we flip the ORDER BY direction to fetch records + // in reverse order. This allows us to use a simple "less than" cursor comparison + // instead of complex SQL. We'll reverse the results array later to restore + // the original requested order. + // Example: If user wants last 3 items ordered by name ASC, we: + // 1. Flip to name DESC to get the last items first + // 2. Apply cursor with < comparison + // 3. Reverse the final array to present items in name ASC order + const clauses = + direction === PaginationDirection.FORWARD + ? augmentedOrderByClauses + : augmentedOrderByClauses.map((clause) => ({ + fieldName: clause.fieldName, + order: + clause.order === OrderByOrdering.ASCENDING + ? OrderByOrdering.DESCENDING + : OrderByOrdering.ASCENDING, + })); + return { clauses }; + }, + buildCursorCondition: (decodedCursorId, direction) => + this.buildCursorCondition(decodedCursorId, fieldsToUseInPostgresTupleCursor, direction), + }; + + return await this.loadPageInternalAsync(queryContext, args, strategy); + } else { + // Search pagination (ILIKE or TRIGRAM) + const search = pagination; + + // Validate search parameters + assert(search.term.length > 0, 'Search term must be a non-empty string'); + assert(search.fields.length > 0, 'Search fields must be a non-empty array'); + + const direction = + 'first' in args ? PaginationDirection.FORWARD : PaginationDirection.BACKWARD; + const { searchWhere, searchOrderByFragment } = this.buildSearchConditionAndOrderBy( + search, + direction, + ); + + // Combine WHERE conditions: base where + search where + const whereClause = + where && searchWhere ? SQLFragmentHelpers.and(where, searchWhere) : (where ?? searchWhere); + + const fieldsToUseInPostgresTupleCursor = + search.strategy === PaginationStrategy.TRIGRAM_SEARCH + ? // For trigram search, cursor includes extra order by fields (if specified) + ID to ensure stable ordering that matches ORDER BY clause + [...(search.extraOrderByFields ?? []), this.entityConfiguration.idField] + : // For ILIKE search, cursor includes search fields + ID to ensure stable ordering that matches ORDER BY clause + [...search.fields, this.entityConfiguration.idField]; + + // Create strategy for search pagination + const strategy: PaginationProvider = { + whereClause, + buildOrderBy: () => { + return { fragment: searchOrderByFragment }; + }, + buildCursorCondition: (decodedCursorId, direction) => + search.strategy === PaginationStrategy.TRIGRAM_SEARCH + ? this.buildTrigramCursorCondition(search, decodedCursorId, direction) + : this.buildCursorCondition( + decodedCursorId, + fieldsToUseInPostgresTupleCursor, + direction, + ), + }; + + return await this.loadPageInternalAsync(queryContext, args, strategy); + } + } + + /** + * Internal method for loading a page with cursor-based pagination. + * Shared logic for both regular and search pagination. + */ + private async loadPageInternalAsync( queryContext: EntityQueryContext, args: LoadPageArgs, + paginationProvider: PaginationProvider, ): Promise>> { const idField = this.entityConfiguration.idField; // Validate pagination arguments - if ('first' in args) { + const isForward = 'first' in args; + if (isForward) { assert( Number.isInteger(args.first) && args.first > 0, 'first must be an integer greater than 0', @@ -202,65 +337,47 @@ export class EntityKnexDataManager< ); } - const isForward = 'first' in args; - const { where, orderBy } = args; + const direction = isForward ? PaginationDirection.FORWARD : PaginationDirection.BACKWARD; + const limit = isForward ? args.first : args.last; + const cursor = isForward ? args.after : args.before; - let limit: number; - let cursor: string | undefined; - if (isForward) { - limit = args.first; - cursor = args.after; - } else { - limit = args.last; - cursor = args.before; - } + // Decode cursor + const decodedExternalCursorEntityID = cursor ? this.decodeOpaqueCursor(cursor) : null; - // Augment orderBy with ID field for stability - const orderByClauses = this.augmentOrderByIfNecessary(orderBy, idField); + // Build WHERE clause with cursor condition + const baseWhere = paginationProvider.whereClause; + const cursorCondition = decodedExternalCursorEntityID + ? paginationProvider.buildCursorCondition(decodedExternalCursorEntityID, direction) + : null; - // Build cursor fields from orderBy + id for stability - const fieldsToUseInPostgresCursor = orderByClauses.map((order) => order.fieldName); + const whereClause = this.combineWhereConditions(baseWhere, cursorCondition); - // Decode cursor - const decodedExternalCursorEntityID = cursor ? this.decodeOpaqueCursor(cursor) : null; + // Get ordering from strategy + const { clauses: orderByClauses, fragment: orderByFragment } = + paginationProvider.buildOrderBy(direction); - // Build WHERE clause with cursor condition for keyset pagination - const whereClause = this.buildWhereClause({ - ...(where && { where }), - decodedExternalCursorEntityID, - fieldsToUseInPostgresCursor, - direction: isForward ? 'forward' : 'backward', - }); + // Determine query modifiers + const queryModifiers: PostgresQuerySelectionModifiersWithOrderByFragment = { + ...(orderByFragment !== undefined && { orderByFragment }), + ...(orderByClauses !== undefined && { orderBy: orderByClauses }), + limit: limit + 1, // Fetch data with limit + 1 to check for more pages + }; - // 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, - }), - ); + )(this.databaseAdapter.fetchManyBySQLFragmentAsync(queryContext, whereClause, queryModifiers)); // Process results const hasMore = fieldObjects.length > limit; const pageFieldObjects = hasMore ? fieldObjects.slice(0, limit) : [...fieldObjects]; - if (!isForward) { + if (direction === PaginationDirection.BACKWARD) { + // Restore the original requested order by reversing the results. + // We fetched with flipped ORDER BY for efficient cursor comparison, + // so now we reverse to match the order the user expects. pageFieldObjects.reverse(); } @@ -271,8 +388,8 @@ export class EntityKnexDataManager< })); const pageInfo: PageInfo = { - hasNextPage: isForward ? hasMore : false, - hasPreviousPage: !isForward ? hasMore : false, + hasNextPage: direction === PaginationDirection.FORWARD ? hasMore : false, + hasPreviousPage: direction === PaginationDirection.BACKWARD ? hasMore : false, startCursor: edges[0]?.cursor ?? null, endCursor: edges[edges.length - 1]?.cursor ?? null, }; @@ -283,6 +400,23 @@ export class EntityKnexDataManager< }; } + private combineWhereConditions( + baseWhere: SQLFragment | undefined, + cursorCondition: SQLFragment | null, + ): SQLFragment { + const conditions = [baseWhere, cursorCondition].filter((it) => !!it); + if (conditions.length === 0) { + return sql`1 = 1`; + } + if (conditions.length === 1) { + return conditions[0]!; + } + // Wrap baseWhere in parens if combining with cursor condition + // We know we have exactly 2 conditions at this point + const [first, second] = conditions; + return sql`(${first}) AND ${second}`; + } + private augmentOrderByIfNecessary( orderBy: PostgresOrderByClause[] | undefined, idField: TIDField, @@ -322,38 +456,15 @@ export class EntityKnexDataManager< 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: '<' | '>', + fieldsToUseInPostgresTupleCursor: readonly (keyof TFields)[], + direction: PaginationDirection, ): SQLFragment { - // We build a tuple comparison for fieldsToUseInPostgresCursor fields of the + // 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. + const operator = direction === PaginationDirection.FORWARD ? '>' : '<'; const idField = getDatabaseFieldForEntityField( this.entityConfiguration, @@ -361,7 +472,7 @@ export class EntityKnexDataManager< ); const tableName = this.entityConfiguration.tableName; - const postgresCursorFieldIdentifiers = fieldsToUseInPostgresCursor.map((f) => { + const postgresCursorFieldIdentifiers = fieldsToUseInPostgresTupleCursor.map((f) => { const dbField = getDatabaseFieldForEntityField(this.entityConfiguration, f); return sql`${identifier(dbField)}`; }); @@ -370,17 +481,240 @@ export class EntityKnexDataManager< const leftSide = SQLFragment.join(postgresCursorFieldIdentifiers, ', '); // Build right side using subquery to get computed values for cursor entity - const postgresCursorRowFieldIdentifiers = fieldsToUseInPostgresCursor.map((f) => { + const postgresCursorRowFieldIdentifiers = fieldsToUseInPostgresTupleCursor.map((f) => { const dbField = getDatabaseFieldForEntityField(this.entityConfiguration, f); - return sql`cursor_row.${identifier(dbField)}`; + return sql`${raw(CURSOR_ROW_TABLE_ALIAS)}.${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} + FROM ${identifier(tableName)} AS ${raw(CURSOR_ROW_TABLE_ALIAS)} + WHERE ${raw(CURSOR_ROW_TABLE_ALIAS)}.${identifier(idField)} = ${decodedExternalCursorEntityID} `; return sql`(${leftSide}) ${raw(operator)} (${rightSideSubquery})`; } + + private buildILikeConditions( + search: DataManagerSearchSpecification, + tableAlias?: typeof CURSOR_ROW_TABLE_ALIAS, + ): SQLFragment[] { + return search.fields.map((field) => { + const dbField = getDatabaseFieldForEntityField(this.entityConfiguration, field); + const fieldIdentifier = tableAlias + ? sql`${raw(tableAlias)}.${identifier(dbField)}` + : sql`${identifier(dbField)}`; + return sql`${fieldIdentifier} ILIKE ${'%' + EntityKnexDataManager.escapeILikePattern(search.term) + '%'}`; + }); + } + + private buildTrigramSimilarityExpressions( + search: DataManagerSearchSpecification, + tableAlias?: typeof CURSOR_ROW_TABLE_ALIAS, + ): SQLFragment[] { + return search.fields.map((field) => { + const dbField = getDatabaseFieldForEntityField(this.entityConfiguration, field); + const fieldIdentifier = tableAlias + ? sql`${raw(tableAlias)}.${identifier(dbField)}` + : sql`${identifier(dbField)}`; + return sql`similarity(${fieldIdentifier}, ${search.term})`; + }); + } + + private buildTrigramExactMatchCaseExpression( + search: DataManagerSearchSpecification, + tableAlias?: typeof CURSOR_ROW_TABLE_ALIAS, + ): SQLFragment { + const ilikeConditions = this.buildILikeConditions(search, tableAlias); + return sql`CASE WHEN ${SQLFragment.join(ilikeConditions, ' OR ')} THEN 1 ELSE 0 END`; + } + + private buildTrigramSimilarityGreatestExpression( + search: DataManagerSearchSpecification, + tableAlias?: typeof CURSOR_ROW_TABLE_ALIAS, + ): SQLFragment { + const similarityExprs = this.buildTrigramSimilarityExpressions(search, tableAlias); + return sql`GREATEST(${SQLFragment.join(similarityExprs, ', ')})`; + } + + private buildTrigramCursorCondition( + search: DataManagerTrigramSearchSpecification, + decodedExternalCursorEntityID: TFields[TIDField], + direction: PaginationDirection, + ): SQLFragment { + // For TRIGRAM search, we compute the similarity values using a subquery, similar to normal cursor + const operator = direction === PaginationDirection.FORWARD ? '<' : '>'; + const idField = getDatabaseFieldForEntityField( + this.entityConfiguration, + this.entityConfiguration.idField, + ); + + const exactMatchExpr = this.buildTrigramExactMatchCaseExpression(search); + const similarityExpr = this.buildTrigramSimilarityGreatestExpression(search); + + // Build extra order by fields + const extraOrderByFields = search.extraOrderByFields; + const extraFields = + extraOrderByFields?.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( + [exactMatchExpr, similarityExpr, ...extraFields, sql`${identifier(idField)}`], + ', ', + ); + + // Build right side using subquery to get computed values for cursor entity + // We need to rebuild the same expressions for the cursor row + + const cursorExactMatchExpr = this.buildTrigramExactMatchCaseExpression( + search, + CURSOR_ROW_TABLE_ALIAS, + ); + const cursorSimilarityExpr = this.buildTrigramSimilarityGreatestExpression( + search, + CURSOR_ROW_TABLE_ALIAS, + ); + + const cursorExtraFields = + extraOrderByFields?.map((f) => { + const dbField = getDatabaseFieldForEntityField(this.entityConfiguration, f); + return sql`${raw(CURSOR_ROW_TABLE_ALIAS)}.${identifier(dbField)}`; + }) ?? []; + + // Build SELECT fields for subquery + const selectFields = [ + cursorExactMatchExpr, + cursorSimilarityExpr, + ...cursorExtraFields, + sql`${raw(CURSOR_ROW_TABLE_ALIAS)}.${identifier(idField)}`, + ]; + + const rightSideSubquery = sql` + SELECT ${SQLFragment.join(selectFields, ', ')} + FROM ${identifier(this.entityConfiguration.tableName)} AS ${raw(CURSOR_ROW_TABLE_ALIAS)} + WHERE ${raw(CURSOR_ROW_TABLE_ALIAS)}.${identifier(idField)} = ${decodedExternalCursorEntityID} + `; + + return sql`(${leftSide}) ${raw(operator)} (${rightSideSubquery})`; + } + + private buildSearchConditionAndOrderBy( + search: DataManagerSearchSpecification, + direction: PaginationDirection, + ): { + searchWhere: SQLFragment; + searchOrderByFragment: SQLFragment | undefined; + } { + switch (search.strategy) { + case PaginationStrategy.ILIKE_SEARCH: { + const conditions = this.buildILikeConditions(search); + + // Order by search fields + ID to match cursor fields + const orderByFields = [...search.fields, this.entityConfiguration.idField].map((field) => { + const dbField = getDatabaseFieldForEntityField(this.entityConfiguration, field); + return sql`${identifier(dbField)} ${raw( + direction === PaginationDirection.FORWARD ? 'ASC' : 'DESC', + )}`; + }); + + return { + searchWhere: conditions.length > 0 ? SQLFragment.join(conditions, ' OR ') : sql`1 = 0`, + searchOrderByFragment: SQLFragment.join(orderByFields, ', '), + }; + } + + case PaginationStrategy.TRIGRAM_SEARCH: { + // PostgreSQL trigram similarity + const ilikeConditions = this.buildILikeConditions(search); + const similarityExprs = this.buildTrigramSimilarityExpressions(search); + + assert( + search.threshold >= 0 && search.threshold <= 1, + `Trigram similarity threshold must be between 0 and 1, got ${search.threshold}`, + ); + + const conditions = similarityExprs.map((expr) => sql`${expr} > ${search.threshold}`); + + // Combine exact matches (ILIKE) with similarity + const allConditions = [...ilikeConditions, ...conditions]; + + // Build ORDER BY components + const exactMatchPriority = this.buildTrigramExactMatchPriority(ilikeConditions, direction); + const similarityRanking = this.buildTrigramSimilarityRanking(search, direction); + const tieBreakers = this.buildTrigramTieBreakers(search, direction); + + return { + searchWhere: SQLFragment.join(allConditions, ' OR '), + // For trigram search, order by relevance with direction-aware ordering + // 1. Exact matches first (ILIKE) + // 2. Then by similarity score + // 3. Then by extra fields and ID field for stability + searchOrderByFragment: SQLFragment.join( + [exactMatchPriority, similarityRanking, tieBreakers], + ', ', + ), + }; + } + } + } + + /** + * Builds the exact match priority component of the ORDER BY clause for trigram search. + * Exact matches (via ILIKE) are prioritized over similarity matches. + */ + private buildTrigramExactMatchPriority( + ilikeConditions: SQLFragment[], + direction: PaginationDirection, + ): SQLFragment { + const sortOrder = direction === PaginationDirection.FORWARD ? 'DESC' : 'ASC'; + const exactMatchCondition = SQLFragment.join(ilikeConditions, ' OR '); + return sql`CASE WHEN ${exactMatchCondition} THEN 1 ELSE 0 END ${raw(sortOrder)}`; + } + + /** + * Builds the similarity score ranking component of the ORDER BY clause. + * Uses the highest similarity score across all search fields. + */ + private buildTrigramSimilarityRanking( + search: DataManagerSearchSpecification, + direction: PaginationDirection, + ): SQLFragment { + const sortOrder = direction === PaginationDirection.FORWARD ? 'DESC' : 'ASC'; + const similarityGreatestExpr = this.buildTrigramSimilarityGreatestExpression(search); + return sql`${similarityGreatestExpr} ${raw(sortOrder)}`; + } + + /** + * Builds the tie-breaker fields component of the ORDER BY clause. + * Includes extra order-by fields (if specified) and always includes the ID field for stability. + */ + private buildTrigramTieBreakers( + search: DataManagerTrigramSearchSpecification, + direction: PaginationDirection, + ): SQLFragment { + const idField = getDatabaseFieldForEntityField( + this.entityConfiguration, + this.entityConfiguration.idField, + ); + + const extraOrderByFields = search.extraOrderByFields?.map((field) => + getDatabaseFieldForEntityField(this.entityConfiguration, field), + ); + + const sortOrder = direction === PaginationDirection.FORWARD ? 'DESC' : 'ASC'; + + const allTieBreakerFields = [...(extraOrderByFields ?? []), idField]; + const tieBreakerClauses = allTieBreakerFields.map( + (field) => sql`${identifier(field)} ${raw(sortOrder)}`, + ); + + return SQLFragment.join(tieBreakerClauses, ', '); + } + + private static escapeILikePattern(term: string): string { + return term.replace(/[%_\\]/g, '\\$&'); + } }