From 79dad3114349750934c6c70e31aa6196d435460e Mon Sep 17 00:00:00 2001 From: Will Schurman Date: Thu, 26 Feb 2026 19:39:09 -0800 Subject: [PATCH] feat: add entityField SQL helper --- .../src/StubPostgresDatabaseAdapter.ts | 12 +- ...uthorizationResultBasedKnexEntityLoader.ts | 19 +- .../src/BasePostgresEntityDatabaseAdapter.ts | 24 +- .../src/BaseSQLQueryBuilder.ts | 6 +- .../src/EnforcingKnexEntityLoader.ts | 4 +- .../src/PostgresEntityDatabaseAdapter.ts | 28 +- .../src/SQLOperator.ts | 256 ++++++++++----- .../PostgresEntityIntegration-test.ts | 64 +++- .../src/__tests__/SQLOperator-test.ts | 302 ++++++++++++------ .../fixtures/StubPostgresDatabaseAdapter.ts | 12 +- .../src/internal/EntityKnexDataManager.ts | 56 ++-- 11 files changed, 536 insertions(+), 247 deletions(-) diff --git a/packages/entity-database-adapter-knex-testing-utils/src/StubPostgresDatabaseAdapter.ts b/packages/entity-database-adapter-knex-testing-utils/src/StubPostgresDatabaseAdapter.ts index d2ab4923b..85899ed2a 100644 --- a/packages/entity-database-adapter-knex-testing-utils/src/StubPostgresDatabaseAdapter.ts +++ b/packages/entity-database-adapter-knex-testing-utils/src/StubPostgresDatabaseAdapter.ts @@ -105,8 +105,8 @@ export class StubPostgresDatabaseAdapter< return results[0] ?? null; } - private static compareByOrderBys( - orderBys: TableOrderByClause[], + private static compareByOrderBys>( + orderBys: TableOrderByClause[], objectA: { [key: string]: any }, objectB: { [key: string]: any }, ): 0 | 1 | -1 { @@ -160,7 +160,7 @@ export class StubPostgresDatabaseAdapter< tableName: string, tableFieldSingleValueEqualityOperands: TableFieldSingleValueEqualityCondition[], tableFieldMultiValueEqualityOperands: TableFieldMultiValueEqualityCondition[], - querySelectionModifiers: TableQuerySelectionModifiers, + querySelectionModifiers: TableQuerySelectionModifiers, ): Promise { let filteredObjects = this.getObjectCollectionForTable(tableName); for (const { tableField, tableValue } of tableFieldSingleValueEqualityOperands) { @@ -196,7 +196,7 @@ export class StubPostgresDatabaseAdapter< _tableName: string, _rawWhereClause: string, _bindings: object | any[], - _querySelectionModifiers: TableQuerySelectionModifiers, + _querySelectionModifiers: TableQuerySelectionModifiers, ): Promise { throw new Error('Raw WHERE clauses not supported for StubDatabaseAdapter'); } @@ -204,8 +204,8 @@ export class StubPostgresDatabaseAdapter< protected fetchManyBySQLFragmentInternalAsync( _queryInterface: any, _tableName: string, - _sqlFragment: SQLFragment, - _querySelectionModifiers: TableQuerySelectionModifiers, + _sqlFragment: SQLFragment, + _querySelectionModifiers: TableQuerySelectionModifiers, ): Promise { throw new Error('SQL fragments not supported for StubDatabaseAdapter'); } diff --git a/packages/entity-database-adapter-knex/src/AuthorizationResultBasedKnexEntityLoader.ts b/packages/entity-database-adapter-knex/src/AuthorizationResultBasedKnexEntityLoader.ts index 01996fb74..4c4176b80 100644 --- a/packages/entity-database-adapter-knex/src/AuthorizationResultBasedKnexEntityLoader.ts +++ b/packages/entity-database-adapter-knex/src/AuthorizationResultBasedKnexEntityLoader.ts @@ -43,12 +43,15 @@ export type EntityLoaderFieldNameOrderByClause< fieldName: TSelectedFields; }; -export type EntityLoaderFieldFragmentOrderByClause = EntityLoaderBaseOrderByClause & { +export type EntityLoaderFieldFragmentOrderByClause< + TFields extends Record, + TSelectedFields extends keyof TFields = keyof TFields, +> = EntityLoaderBaseOrderByClause & { /** * The SQL fragment to order by, which can reference selected fields. Example: `COALESCE(NULLIF(display_name, ''), split_part(full_name, '/', 2))`. * May not contain ASC or DESC, as ordering direction is controlled by the `order` property. */ - fieldFragment: SQLFragment; + fieldFragment: SQLFragment>; }; export type EntityLoaderOrderByClause< @@ -56,7 +59,7 @@ export type EntityLoaderOrderByClause< TSelectedFields extends keyof TFields = keyof TFields, > = | EntityLoaderFieldNameOrderByClause - | EntityLoaderFieldFragmentOrderByClause; + | EntityLoaderFieldFragmentOrderByClause; /** * SQL modifiers that only affect the selection but not the projection. @@ -88,7 +91,7 @@ export interface EntityLoaderQuerySelectionModifiers< export type EntityLoaderFieldNameConstructorFn< TFields extends Record, TSelectedFields extends keyof TFields = keyof TFields, -> = (fieldName: TSelectedFields) => SQLFragment; +> = (fieldName: TSelectedFields) => SQLFragment; /** * Specification for a search field that is a manually constructed SQLFragment. Useful for complex search fields that require @@ -122,7 +125,7 @@ export type EntityLoaderSearchFieldSQLFragmentFnSpecification< * Helper function to get a SQLFragment for a given field name, which should be used to construct the final SQLFragment for the search field. */ getFragmentForFieldName: EntityLoaderFieldNameConstructorFn, - ) => SQLFragment; + ) => SQLFragment>; }; /** @@ -240,7 +243,7 @@ interface EntityLoaderBaseUnifiedPaginationArgs< /** * SQLFragment representing the WHERE clause to filter the entities being paginated. */ - where?: SQLFragment; + where?: SQLFragment>; /** * Pagination specification determining how to order and paginate results. @@ -421,7 +424,7 @@ export class AuthorizationResultBasedKnexEntityLoader< * @returns SQL query builder for building and executing SQL queries that when executed returns entity results where result error can be UnauthorizedError. */ loadManyBySQL( - fragment: SQLFragment, + fragment: SQLFragment>, modifiers: EntityLoaderQuerySelectionModifiers = {}, ): AuthorizationResultBasedSQLQueryBuilder< TFields, @@ -515,7 +518,7 @@ export class AuthorizationResultBasedSQLQueryBuilder< TSelectedFields >, private readonly queryContext: EntityQueryContext, - sqlFragment: SQLFragment, + sqlFragment: SQLFragment>, modifiers: EntityLoaderQuerySelectionModifiers, ) { super(sqlFragment, modifiers); diff --git a/packages/entity-database-adapter-knex/src/BasePostgresEntityDatabaseAdapter.ts b/packages/entity-database-adapter-knex/src/BasePostgresEntityDatabaseAdapter.ts index 82a882550..e17bbb077 100644 --- a/packages/entity-database-adapter-knex/src/BasePostgresEntityDatabaseAdapter.ts +++ b/packages/entity-database-adapter-knex/src/BasePostgresEntityDatabaseAdapter.ts @@ -102,7 +102,7 @@ export type PostgresOrderByClause> = /** * A raw SQL fragment to order by. May not contain ASC or DESC, as ordering direction is determined by the `order` property. */ - fieldFragment: SQLFragment; + fieldFragment: SQLFragment; /** * The OrderByOrdering to order by. @@ -136,20 +136,20 @@ export interface PostgresQuerySelectionModifiers> = | { columnName: string; order: OrderByOrdering; nulls: NullsOrdering | undefined; } | { - columnFragment: SQLFragment; + columnFragment: SQLFragment; order: OrderByOrdering; nulls: NullsOrdering | undefined; }; -export interface TableQuerySelectionModifiers { - orderBy: TableOrderByClause[] | undefined; +export interface TableQuerySelectionModifiers> { + orderBy: TableOrderByClause[] | undefined; offset: number | undefined; limit: number | undefined; } @@ -213,7 +213,7 @@ export abstract class BasePostgresEntityDatabaseAdapter< tableName: string, tableFieldSingleValueEqualityOperands: TableFieldSingleValueEqualityCondition[], tableFieldMultiValueEqualityOperands: TableFieldMultiValueEqualityCondition[], - querySelectionModifiers: TableQuerySelectionModifiers, + querySelectionModifiers: TableQuerySelectionModifiers, ): Promise; /** @@ -249,7 +249,7 @@ export abstract class BasePostgresEntityDatabaseAdapter< tableName: string, rawWhereClause: string, bindings: object | any[], - querySelectionModifiers: TableQuerySelectionModifiers, + querySelectionModifiers: TableQuerySelectionModifiers, ): Promise; /** @@ -262,7 +262,7 @@ export abstract class BasePostgresEntityDatabaseAdapter< */ async fetchManyBySQLFragmentAsync( queryContext: EntityQueryContext, - sqlFragment: SQLFragment, + sqlFragment: SQLFragment, querySelectionModifiers: PostgresQuerySelectionModifiers, ): Promise[]> { const results = await this.fetchManyBySQLFragmentInternalAsync( @@ -280,18 +280,18 @@ export abstract class BasePostgresEntityDatabaseAdapter< protected abstract fetchManyBySQLFragmentInternalAsync( queryInterface: Knex, tableName: string, - sqlFragment: SQLFragment, - querySelectionModifiers: TableQuerySelectionModifiers, + sqlFragment: SQLFragment, + querySelectionModifiers: TableQuerySelectionModifiers, ): Promise; private convertToTableQueryModifiers( querySelectionModifiers: PostgresQuerySelectionModifiers, - ): TableQuerySelectionModifiers { + ): TableQuerySelectionModifiers { const orderBy = querySelectionModifiers.orderBy; return { orderBy: orderBy !== undefined - ? orderBy.map((orderBySpecification): TableOrderByClause => { + ? orderBy.map((orderBySpecification): TableOrderByClause => { if ('fieldName' in orderBySpecification) { return { columnName: getDatabaseFieldForEntityField( diff --git a/packages/entity-database-adapter-knex/src/BaseSQLQueryBuilder.ts b/packages/entity-database-adapter-knex/src/BaseSQLQueryBuilder.ts index ba3f48d6d..92190cfb9 100644 --- a/packages/entity-database-adapter-knex/src/BaseSQLQueryBuilder.ts +++ b/packages/entity-database-adapter-knex/src/BaseSQLQueryBuilder.ts @@ -16,7 +16,7 @@ export abstract class BaseSQLQueryBuilder< private executed = false; constructor( - private readonly sqlFragment: SQLFragment, + private readonly sqlFragment: SQLFragment>, private readonly modifiers: { limit?: number; offset?: number; @@ -71,7 +71,7 @@ export abstract class BaseSQLQueryBuilder< * @param order - The ordering direction (ascending or descending). Defaults to ascending. */ orderBySQL( - fragment: SQLFragment, + fragment: SQLFragment>, order: OrderByOrdering = OrderByOrdering.ASCENDING, nulls: NullsOrdering | undefined = undefined, ): this { @@ -92,7 +92,7 @@ export abstract class BaseSQLQueryBuilder< /** * Get the SQL fragment */ - protected getSQLFragment(): SQLFragment { + protected getSQLFragment(): SQLFragment> { return this.sqlFragment; } diff --git a/packages/entity-database-adapter-knex/src/EnforcingKnexEntityLoader.ts b/packages/entity-database-adapter-knex/src/EnforcingKnexEntityLoader.ts index 90c8a03e9..52751193b 100644 --- a/packages/entity-database-adapter-knex/src/EnforcingKnexEntityLoader.ts +++ b/packages/entity-database-adapter-knex/src/EnforcingKnexEntityLoader.ts @@ -168,7 +168,7 @@ export class EnforcingKnexEntityLoader< * ``` */ loadManyBySQL( - fragment: SQLFragment, + fragment: SQLFragment>, modifiers: EntityLoaderQuerySelectionModifiers = {}, ): EnforcingSQLQueryBuilder< TFields, @@ -251,7 +251,7 @@ export class EnforcingSQLQueryBuilder< TPrivacyPolicy, TSelectedFields >, - sqlFragment: SQLFragment, + sqlFragment: SQLFragment>, modifiers: EntityLoaderQuerySelectionModifiers, ) { super(sqlFragment, modifiers); diff --git a/packages/entity-database-adapter-knex/src/PostgresEntityDatabaseAdapter.ts b/packages/entity-database-adapter-knex/src/PostgresEntityDatabaseAdapter.ts index 8317bb140..454e4a77c 100644 --- a/packages/entity-database-adapter-knex/src/PostgresEntityDatabaseAdapter.ts +++ b/packages/entity-database-adapter-knex/src/PostgresEntityDatabaseAdapter.ts @@ -1,4 +1,9 @@ -import { EntityConfiguration, FieldTransformer, FieldTransformerMap } from '@expo/entity'; +import { + EntityConfiguration, + FieldTransformer, + FieldTransformerMap, + getDatabaseFieldForEntityField, +} from '@expo/entity'; import { Knex } from 'knex'; import { @@ -119,7 +124,7 @@ export class PostgresEntityDatabaseAdapter< private applyQueryModifiersToQuery( query: Knex.QueryBuilder, - querySelectionModifiers: TableQuerySelectionModifiers, + querySelectionModifiers: TableQuerySelectionModifiers, ): Knex.QueryBuilder { const { orderBy, offset, limit } = querySelectionModifiers; @@ -139,7 +144,9 @@ export class PostgresEntityDatabaseAdapter< : ''; ret = ret.orderByRaw( `(${orderBySpecification.columnFragment.sql}) ${orderBySpecification.order}${nullsSuffix}`, - orderBySpecification.columnFragment.getKnexBindings(), + orderBySpecification.columnFragment.getKnexBindings((fieldName) => + getDatabaseFieldForEntityField(this.entityConfiguration, fieldName), + ), ); } } @@ -161,7 +168,7 @@ export class PostgresEntityDatabaseAdapter< tableName: string, tableFieldSingleValueEqualityOperands: TableFieldSingleValueEqualityCondition[], tableFieldMultiValueEqualityOperands: TableFieldMultiValueEqualityCondition[], - querySelectionModifiers: TableQuerySelectionModifiers, + querySelectionModifiers: TableQuerySelectionModifiers, ): Promise { let query = queryInterface.select().from(tableName); @@ -207,7 +214,7 @@ export class PostgresEntityDatabaseAdapter< tableName: string, rawWhereClause: string, bindings: object | any[], - querySelectionModifiers: TableQuerySelectionModifiers, + querySelectionModifiers: TableQuerySelectionModifiers, ): Promise { let query = queryInterface.select().from(tableName).whereRaw(rawWhereClause, bindings); query = this.applyQueryModifiersToQuery(query, querySelectionModifiers); @@ -217,13 +224,18 @@ export class PostgresEntityDatabaseAdapter< protected async fetchManyBySQLFragmentInternalAsync( queryInterface: Knex, tableName: string, - sqlFragment: SQLFragment, - querySelectionModifiers: TableQuerySelectionModifiers, + sqlFragment: SQLFragment, + querySelectionModifiers: TableQuerySelectionModifiers, ): Promise { let query = queryInterface .select() .from(tableName) - .whereRaw(sqlFragment.sql, sqlFragment.getKnexBindings()); + .whereRaw( + sqlFragment.sql, + sqlFragment.getKnexBindings((fieldName) => + getDatabaseFieldForEntityField(this.entityConfiguration, fieldName), + ), + ); query = this.applyQueryModifiersToQuery(query, querySelectionModifiers); return await wrapNativePostgresCallAsync(() => query); } diff --git a/packages/entity-database-adapter-knex/src/SQLOperator.ts b/packages/entity-database-adapter-knex/src/SQLOperator.ts index e30045a2c..0936e5c09 100644 --- a/packages/entity-database-adapter-knex/src/SQLOperator.ts +++ b/packages/entity-database-adapter-knex/src/SQLOperator.ts @@ -17,29 +17,37 @@ export type SupportedSQLValue = /** * Types of bindings that can be used in SQL queries. */ -export type SQLBinding = +export type SQLBinding> = | { type: 'value'; value: SupportedSQLValue } - | { type: 'identifier'; name: string }; + | { type: 'identifier'; name: string } + | { type: 'entityField'; fieldName: keyof TFields }; /** * SQL Fragment class that safely handles parameterized queries. */ -export class SQLFragment { +export class SQLFragment> { constructor( public readonly sql: string, - public readonly bindings: readonly SQLBinding[], + public readonly bindings: readonly SQLBinding[], ) {} /** * Get bindings in the format expected by Knex. * Knex expects a flat array where both identifiers and values are mixed in order. + * + * @param getColumnForField - function that resolves an entity field name to its database column name */ - getKnexBindings(): readonly (string | SupportedSQLValue)[] { + getKnexBindings( + getColumnForField: (fieldName: keyof TFields) => string, + ): readonly (string | SupportedSQLValue)[] { return this.bindings.map((b) => { - if (b.type === 'identifier') { - return b.name; - } else { - return b.value; + switch (b.type) { + case 'entityField': + return getColumnForField(b.fieldName); + case 'identifier': + return b.name; + case 'value': + return b.value; } }); } @@ -47,7 +55,7 @@ export class SQLFragment { /** * Combine SQL fragments */ - append(other: SQLFragment): SQLFragment { + append(other: SQLFragment): SQLFragment { return joinSQLFragments([this, other], ' '); } @@ -58,7 +66,9 @@ export class SQLFragment { * @param fragments - Array of SQL fragments to join * @returns - A new SQLFragment with the fragments joined by a comma and space */ - static joinWithCommaSeparator(...fragments: readonly SQLFragment[]): SQLFragment { + static joinWithCommaSeparator>( + ...fragments: readonly SQLFragment[] + ): SQLFragment { return joinSQLFragments(fragments, ', '); } @@ -74,7 +84,9 @@ export class SQLFragment { * // Generates: "SELECT * FROM users WHERE age > ? ORDER BY name" * ``` */ - static concat(...fragments: readonly SQLFragment[]): SQLFragment { + static concat>( + ...fragments: readonly SQLFragment[] + ): SQLFragment { return joinSQLFragments(fragments, ' '); } @@ -100,6 +112,9 @@ export class SQLFragment { if (match === '??' && binding.type === 'identifier') { // For identifiers, show them quoted as they would appear return `"${binding.name.replace(/"/g, '""')}"`; + } else if (match === '??' && binding.type === 'entityField') { + // For entity fields, show the entity field name as the identifier for debugging + return `"${binding.fieldName.toString()}"`; } else if (match === '?' && binding.type === 'value') { return SQLFragment.formatDebugValue(binding.value); } else { @@ -176,6 +191,14 @@ export class SQLIdentifier { constructor(public readonly name: string) {} } +/** + * Helper for referencing entity fields that can be used in SQL queries. This allows for type-safe references to fields of an entity + * and does automatic translation to DB field names. + */ +export class SQLEntityField> { + constructor(public readonly fieldName: keyof TFields) {} +} + /** * Helper for raw SQL that should not be parameterized * WARNING: Only use this with trusted input to avoid SQL injection @@ -186,7 +209,6 @@ export class SQLUnsafeRaw { /** * Create a SQL identifier (table/column name) that will be escaped by Knex using ??. - * The escaping is delegated to Knex which will handle it based on the database type. * * @example * ```ts @@ -199,6 +221,18 @@ export function identifier(name: string): SQLIdentifier { return new SQLIdentifier(name); } +/** + * Create a reference to an entity field that can be used in SQL queries. This allows for type-safe references to fields of an entity + * and does automatic translation to DB field names and will be escaped by Knex using ??. + * + * @param fieldName - The entity field name to reference. + */ +export function entityField>( + fieldName: keyof TFields, +): SQLEntityField { + return new SQLEntityField(fieldName); +} + /** * Insert raw SQL that will not be parameterized * WARNING: This bypasses SQL injection protection. Only use with trusted input. @@ -226,12 +260,18 @@ export function unsafeRaw(sqlString: string): SQLUnsafeRaw { * const query = sql`age >= ${age} AND status = ${'active'}`; * ``` */ -export function sql( +export function sql>( strings: TemplateStringsArray, - ...values: readonly (SupportedSQLValue | SQLFragment | SQLIdentifier | SQLUnsafeRaw)[] -): SQLFragment { + ...values: readonly ( + | SupportedSQLValue + | SQLFragment + | SQLIdentifier + | SQLUnsafeRaw + | SQLEntityField + )[] +): SQLFragment { let sqlString = ''; - const bindings: SQLBinding[] = []; + const bindings: SQLBinding[] = []; strings.forEach((string, i) => { sqlString += string; @@ -246,13 +286,17 @@ export function sql( // Handle identifiers (table/column names) with ?? placeholder sqlString += '??'; bindings.push({ type: 'identifier', name: value.name }); + } else if (value instanceof SQLEntityField) { + // Handle entity field references by treating them as identifiers + sqlString += '??'; + bindings.push({ type: 'entityField', fieldName: value.fieldName }); } else if (value instanceof SQLUnsafeRaw) { // Handle raw SQL (WARNING: no parameterization) sqlString += value.rawSql; } else if (Array.isArray(value)) { // Handle IN clauses sqlString += `(${value.map(() => '?').join(', ')})`; - bindings.push(...value.map((v) => ({ type: 'value' as const, value: v }))); + bindings.push(...value.map((v): SQLBinding => ({ type: 'value', value: v }))); } else { // Regular value binding sqlString += '?'; @@ -264,6 +308,10 @@ export function sql( return new SQLFragment(sqlString, bindings); } +type PickSupportedSQLValueKeys = { + [K in keyof T]: T[K] extends SupportedSQLValue ? K : never; +}[keyof T]; + /** * Common SQL helper functions for building queries */ @@ -273,28 +321,33 @@ export const SQLFragmentHelpers = { * * @example * ```ts - * const query = SQLFragmentHelpers.inArray('status', ['active', 'pending']); - * // Generates: ?? IN (?, ?) with bindings ['status', 'active', 'pending'] + * const query = SQLFragmentHelpers.inArray('status', ['active', 'pending']); + * // Generates: ?? IN (?, ?) with entityField binding for 'status' and value bindings * ``` */ - inArray(column: string, values: readonly T[]): SQLFragment { + inArray, N extends PickSupportedSQLValueKeys>( + fieldName: N, + values: readonly TFields[N][], + ): SQLFragment { if (values.length === 0) { // Handle empty array case - always false return sql`1 = 0`; } - // The array is already correctly typed, just needs to be seen as SupportedSQLValue for the template - return sql`${identifier(column)} IN ${values as readonly SupportedSQLValue[]}`; + return sql`${entityField(fieldName)} IN ${values}`; }, /** * NOT IN clause helper */ - notInArray(column: string, values: readonly T[]): SQLFragment { + notInArray, N extends PickSupportedSQLValueKeys>( + fieldName: N, + values: readonly TFields[N][], + ): SQLFragment { if (values.length === 0) { // Handle empty array case - always true return sql`1 = 1`; } - return sql`${identifier(column)} NOT IN ${values as readonly SupportedSQLValue[]}`; + return sql`${entityField(fieldName)} NOT IN ${values}`; }, /** @@ -302,19 +355,27 @@ export const SQLFragmentHelpers = { * * @example * ```ts - * const query = SQLFragmentHelpers.between('age', 18, 65); - * // Generates: "age" BETWEEN ? AND ? with values [18, 65] + * const query = SQLFragmentHelpers.between('age', 18, 65); + * // Generates: ?? BETWEEN ? AND ? with entityField binding for 'age' and value bindings * ``` */ - between(column: string, min: T, max: T): SQLFragment { - return sql`${identifier(column)} BETWEEN ${min} AND ${max}`; + between, N extends PickSupportedSQLValueKeys>( + fieldName: N, + min: TFields[N], + max: TFields[N], + ): SQLFragment { + return sql`${entityField(fieldName)} BETWEEN ${min} AND ${max}`; }, /** * NOT BETWEEN helper */ - notBetween(column: string, min: T, max: T): SQLFragment { - return sql`${identifier(column)} NOT BETWEEN ${min} AND ${max}`; + notBetween, N extends PickSupportedSQLValueKeys>( + fieldName: N, + min: TFields[N], + max: TFields[N], + ): SQLFragment { + return sql`${entityField(fieldName)} NOT BETWEEN ${min} AND ${max}`; }, /** @@ -322,129 +383,173 @@ export const SQLFragmentHelpers = { * * @example * ```ts - * const query = SQLFragmentHelpers.like('name', '%John%'); - * // Generates: "name" LIKE ? with value '%John%' + * const query = SQLFragmentHelpers.like('name', '%John%'); + * // Generates: ?? LIKE ? with entityField binding for 'name' and value binding * ``` */ - like(column: string, pattern: string): SQLFragment { - return sql`${identifier(column)} LIKE ${pattern}`; + like>( + fieldName: keyof TFields, + pattern: string, + ): SQLFragment { + return sql`${entityField(fieldName)} LIKE ${pattern}`; }, /** * NOT LIKE helper */ - notLike(column: string, pattern: string): SQLFragment { - return sql`${identifier(column)} NOT LIKE ${pattern}`; + notLike>( + fieldName: keyof TFields, + pattern: string, + ): SQLFragment { + return sql`${entityField(fieldName)} NOT LIKE ${pattern}`; }, /** * ILIKE helper for case-insensitive matching */ - ilike(column: string, pattern: string): SQLFragment { - return sql`${identifier(column)} ILIKE ${pattern}`; + ilike>( + fieldName: keyof TFields, + pattern: string, + ): SQLFragment { + return sql`${entityField(fieldName)} ILIKE ${pattern}`; }, /** * NOT ILIKE helper for case-insensitive non-matching */ - notIlike(column: string, pattern: string): SQLFragment { - return sql`${identifier(column)} NOT ILIKE ${pattern}`; + notIlike>( + fieldName: keyof TFields, + pattern: string, + ): SQLFragment { + return sql`${entityField(fieldName)} NOT ILIKE ${pattern}`; }, /** * NULL check helper */ - isNull(column: string): SQLFragment { - return sql`${identifier(column)} IS NULL`; + isNull>(fieldName: keyof TFields): SQLFragment { + return sql`${entityField(fieldName)} IS NULL`; }, /** * NOT NULL check helper */ - isNotNull(column: string): SQLFragment { - return sql`${identifier(column)} IS NOT NULL`; + isNotNull>(fieldName: keyof TFields): SQLFragment { + return sql`${entityField(fieldName)} IS NOT NULL`; }, /** * Single-equals-equality operator */ - eq(column: string, value: SupportedSQLValue): SQLFragment { + eq, N extends PickSupportedSQLValueKeys>( + fieldName: N, + value: TFields[N], + ): SQLFragment { if (value === null || value === undefined) { - return SQLFragmentHelpers.isNull(column); + return SQLFragmentHelpers.isNull(fieldName); } - return sql`${identifier(column)} = ${value}`; + return sql`${entityField(fieldName)} = ${value}`; }, /** * Single-equals-inequality operator */ - neq(column: string, value: SupportedSQLValue): SQLFragment { + neq, N extends PickSupportedSQLValueKeys>( + fieldName: N, + value: TFields[N], + ): SQLFragment { if (value === null || value === undefined) { - return SQLFragmentHelpers.isNotNull(column); + return SQLFragmentHelpers.isNotNull(fieldName); } - return sql`${identifier(column)} != ${value}`; + return sql`${entityField(fieldName)} != ${value}`; }, /** * Greater-than comparison operator */ - gt(column: string, value: SupportedSQLValue): SQLFragment { - return sql`${identifier(column)} > ${value}`; + gt, N extends PickSupportedSQLValueKeys>( + fieldName: N, + value: TFields[N], + ): SQLFragment { + return sql`${entityField(fieldName)} > ${value}`; }, /** * Greater-than-or-equal-to comparison operator */ - gte(column: string, value: SupportedSQLValue): SQLFragment { - return sql`${identifier(column)} >= ${value}`; + gte, N extends PickSupportedSQLValueKeys>( + fieldName: N, + value: TFields[N], + ): SQLFragment { + return sql`${entityField(fieldName)} >= ${value}`; }, /** * Less-than comparison operator */ - lt(column: string, value: SupportedSQLValue): SQLFragment { - return sql`${identifier(column)} < ${value}`; + lt, N extends PickSupportedSQLValueKeys>( + fieldName: N, + value: TFields[N], + ): SQLFragment { + return sql`${entityField(fieldName)} < ${value}`; }, /** * Less-than-or-equal-to comparison operator */ - lte(column: string, value: SupportedSQLValue): SQLFragment { - return sql`${identifier(column)} <= ${value}`; + lte, N extends PickSupportedSQLValueKeys>( + fieldName: N, + value: TFields[N], + ): SQLFragment { + return sql`${entityField(fieldName)} <= ${value}`; }, /** * JSON contains operator (\@\>) */ - jsonContains(column: string, value: unknown): SQLFragment { - return sql`${identifier(column)} @> ${JSON.stringify(value)}::jsonb`; + jsonContains>( + fieldName: keyof TFields, + value: unknown, + ): SQLFragment { + return sql`${entityField(fieldName)} @> ${JSON.stringify(value)}::jsonb`; }, /** * JSON contained by operator (\<\@\) */ - jsonContainedBy(column: string, value: unknown): SQLFragment { - return sql`${identifier(column)} <@ ${JSON.stringify(value)}::jsonb`; + jsonContainedBy>( + fieldName: keyof TFields, + value: unknown, + ): SQLFragment { + return sql`${entityField(fieldName)} <@ ${JSON.stringify(value)}::jsonb`; }, /** * JSON path extraction helper (-\>) */ - jsonPath(column: string, path: string): SQLFragment { - return sql`${identifier(column)}->${path}`; + jsonPath>( + fieldName: keyof TFields, + path: string, + ): SQLFragment { + return sql`${entityField(fieldName)}->${path}`; }, /** * JSON path text extraction helper (-\>\>) */ - jsonPathText(column: string, path: string): SQLFragment { - return sql`${identifier(column)}->>${path}`; + jsonPathText>( + fieldName: keyof TFields, + path: string, + ): SQLFragment { + return sql`${entityField(fieldName)}->>${path}`; }, /** * Logical AND of multiple fragments */ - and(...conditions: readonly SQLFragment[]): SQLFragment { + and>( + ...conditions: readonly SQLFragment[] + ): SQLFragment { if (conditions.length === 0) { return sql`1 = 1`; } @@ -457,7 +562,9 @@ export const SQLFragmentHelpers = { /** * Logical OR of multiple fragments */ - or(...conditions: readonly SQLFragment[]): SQLFragment { + or>( + ...conditions: readonly SQLFragment[] + ): SQLFragment { if (conditions.length === 0) { return sql`1 = 0`; } @@ -470,20 +577,25 @@ export const SQLFragmentHelpers = { /** * Logical NOT of a fragment */ - not(condition: SQLFragment): SQLFragment { + not>(condition: SQLFragment): SQLFragment { return new SQLFragment('NOT (' + condition.sql + ')', condition.bindings); }, /** * Parentheses helper for grouping conditions */ - group(condition: SQLFragment): SQLFragment { + group>( + condition: SQLFragment, + ): SQLFragment { return new SQLFragment('(' + condition.sql + ')', condition.bindings); }, }; // Internal helper function to join SQL fragments with a specified separator -function joinSQLFragments(fragments: readonly SQLFragment[], separator: string): SQLFragment { +function joinSQLFragments>( + fragments: readonly SQLFragment[], + separator: string, +): SQLFragment { return new SQLFragment( fragments.map((f) => f.sql).join(separator), fragments.flatMap((f) => f.bindings), 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 f94052780..4e4c9f40e 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 @@ -13,7 +13,7 @@ import { setTimeout } from 'timers/promises'; import { PaginationSpecification } from '../AuthorizationResultBasedKnexEntityLoader'; import { NullsOrdering, OrderByOrdering } from '../BasePostgresEntityDatabaseAdapter'; import { PaginationStrategy } from '../PaginationStrategy'; -import { unsafeRaw, sql, SQLFragmentHelpers } from '../SQLOperator'; +import { entityField, unsafeRaw, sql, SQLFragmentHelpers, SQLFragment } from '../SQLOperator'; import { PostgresTestEntity, PostgresTestEntityFields, @@ -484,7 +484,7 @@ describe('postgres entity integration', () => { // Test AND condition const bothPets = await PostgresTestEntity.knexLoader(vc1) - .loadManyBySQL(and(eq('has_a_cat', true), eq('has_a_dog', true))) + .loadManyBySQL(and(eq('hasACat', true), eq('hasADog', true))) .executeAsync(); expect(bothPets).toHaveLength(1); @@ -492,7 +492,7 @@ describe('postgres entity integration', () => { // Test OR condition const eitherPet = await PostgresTestEntity.knexLoader(vc1) - .loadManyBySQL(or(eq('has_a_cat', false), eq('has_a_dog', false))) + .loadManyBySQL(or(eq('hasACat', false), eq('hasADog', false))) .orderBy('name', OrderByOrdering.ASCENDING) .executeAsync(); @@ -512,7 +512,7 @@ describe('postgres entity integration', () => { // Test complex condition const complexQuery = await PostgresTestEntity.knexLoader(vc1) - .loadManyBySQL(and(or(eq('has_a_cat', true), eq('has_a_dog', true)), neq('name', 'User2'))) + .loadManyBySQL(and(or(eq('hasACat', true), eq('hasADog', true)), neq('name', 'User2'))) .orderBy('name', OrderByOrdering.ASCENDING) .executeAsync(); @@ -521,6 +521,54 @@ describe('postgres entity integration', () => { expect(complexQuery[1]!.getField('name')).toBe('User3'); }); + it('supports entityField for entity-to-DB field name translation', async () => { + const vc1 = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance)); + + await enforceAsyncResult( + PostgresTestEntity.creatorWithAuthorizationResults(vc1) + .setField('name', 'EntityFieldUser1') + .setField('hasACat', true) + .setField('hasADog', false) + .createAsync(), + ); + + await enforceAsyncResult( + PostgresTestEntity.creatorWithAuthorizationResults(vc1) + .setField('name', 'EntityFieldUser2') + .setField('hasACat', false) + .setField('hasADog', true) + .createAsync(), + ); + + await enforceAsyncResult( + PostgresTestEntity.creatorWithAuthorizationResults(vc1) + .setField('name', 'EntityFieldUser3') + .setField('hasACat', true) + .setField('hasADog', true) + .createAsync(), + ); + + // Use entityField to reference fields by entity name instead of DB column name + const catOwners = await PostgresTestEntity.knexLoader(vc1) + .loadManyBySQL(sql`${entityField('hasACat')} = ${true}`) + .orderBy('name', OrderByOrdering.ASCENDING) + .executeAsync(); + + expect(catOwners).toHaveLength(2); + expect(catOwners[0]!.getField('name')).toBe('EntityFieldUser1'); + expect(catOwners[1]!.getField('name')).toBe('EntityFieldUser3'); + + // Combine entityField with other SQL constructs + const bothPets = await PostgresTestEntity.knexLoader(vc1) + .loadManyBySQL( + sql`${entityField('hasACat')} = ${true} AND ${entityField('hasADog')} = ${true}`, + ) + .executeAsync(); + + expect(bothPets).toHaveLength(1); + expect(bothPets[0]!.getField('name')).toBe('EntityFieldUser3'); + }); + it('supports executeFirstAsync', async () => { const vc1 = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance)); @@ -690,12 +738,12 @@ describe('postgres entity integration', () => { ); // Test join with OR conditions - const conditions = [ + const conditions: SQLFragment[] = [ sql`name = ${'JoinTest1'}`, sql`(has_a_cat = ${true} AND has_a_dog = ${true})`, ]; const joinedResults = await PostgresTestEntity.knexLoader(vc1) - .loadManyBySQL(SQLFragmentHelpers.or(...conditions)) + .loadManyBySQL(SQLFragmentHelpers.or(...conditions)) .orderBy('name', OrderByOrdering.ASCENDING) .executeAsync(); @@ -1191,11 +1239,11 @@ describe('postgres entity integration', () => { ).loadManyByRawWhereClauseAsync('has_a_dog = ?', [true], { orderBy: [ { - fieldFragment: sql`has_a_dog`, + fieldFragment: sql`${entityField('hasADog')}`, order: OrderByOrdering.ASCENDING, }, { - fieldFragment: sql`name`, + fieldFragment: sql`${entityField('name')}`, order: OrderByOrdering.DESCENDING, }, ], diff --git a/packages/entity-database-adapter-knex/src/__tests__/SQLOperator-test.ts b/packages/entity-database-adapter-knex/src/__tests__/SQLOperator-test.ts index 0709522f1..605377c45 100644 --- a/packages/entity-database-adapter-knex/src/__tests__/SQLOperator-test.ts +++ b/packages/entity-database-adapter-knex/src/__tests__/SQLOperator-test.ts @@ -1,13 +1,20 @@ +import { getDatabaseFieldForEntityField } from '@expo/entity'; import { describe, expect, it } from '@jest/globals'; import { + entityField, identifier, unsafeRaw, sql, + SQLEntityField, SQLFragment, SQLFragmentHelpers, SQLIdentifier, } from '../SQLOperator'; +import { TestFields, testEntityConfiguration } from './fixtures/TestEntity'; + +const getColumnForField = (fieldName: string): string => + getDatabaseFieldForEntityField(testEntityConfiguration, fieldName as keyof TestFields); describe('SQLOperator', () => { describe('sql template literal', () => { @@ -17,7 +24,7 @@ describe('SQLOperator', () => { const fragment = sql`age >= ${age} AND status = ${status}`; expect(fragment.sql).toBe('age >= ? AND status = ?'); - expect(fragment.getKnexBindings()).toEqual([18, 'active']); + expect(fragment.getKnexBindings(getColumnForField)).toEqual([18, 'active']); }); it('handles nested SQL fragments', () => { @@ -26,7 +33,7 @@ describe('SQLOperator', () => { const combined = sql`${condition1} AND ${condition2}`; expect(combined.sql).toBe('age >= ? AND status = ?'); - expect(combined.getKnexBindings()).toEqual([18, 'active']); + expect(combined.getKnexBindings(getColumnForField)).toEqual([18, 'active']); }); it('handles SQL identifiers', () => { @@ -34,7 +41,7 @@ describe('SQLOperator', () => { const fragment = sql`${identifier(columnName)} = ${'John'}`; expect(fragment.sql).toBe('?? = ?'); - expect(fragment.getKnexBindings()).toEqual(['user_name', 'John']); + expect(fragment.getKnexBindings(getColumnForField)).toEqual(['user_name', 'John']); }); it('handles arrays for IN clauses', () => { @@ -42,35 +49,39 @@ describe('SQLOperator', () => { const fragment = sql`status IN ${values}`; expect(fragment.sql).toBe('status IN (?, ?, ?)'); - expect(fragment.getKnexBindings()).toEqual(['active', 'pending', 'approved']); + expect(fragment.getKnexBindings(getColumnForField)).toEqual([ + 'active', + 'pending', + 'approved', + ]); }); it('handles null values', () => { const fragment = sql`field = ${null}`; expect(fragment.sql).toBe('field = ?'); - expect(fragment.getKnexBindings()).toEqual([null]); + expect(fragment.getKnexBindings(getColumnForField)).toEqual([null]); }); it('handles empty strings', () => { const fragment = sql`field = ${''}`; expect(fragment.sql).toBe('field = ?'); - expect(fragment.getKnexBindings()).toEqual(['']); + expect(fragment.getKnexBindings(getColumnForField)).toEqual(['']); }); it('handles numbers including zero', () => { const fragment = sql`count = ${0} OR count = ${42}`; expect(fragment.sql).toBe('count = ? OR count = ?'); - expect(fragment.getKnexBindings()).toEqual([0, 42]); + expect(fragment.getKnexBindings(getColumnForField)).toEqual([0, 42]); }); it('handles boolean values', () => { const fragment = sql`active = ${true} AND deleted = ${false}`; expect(fragment.sql).toBe('active = ? AND deleted = ?'); - expect(fragment.getKnexBindings()).toEqual([true, false]); + expect(fragment.getKnexBindings(getColumnForField)).toEqual([true, false]); }); it('handles raw SQL', () => { @@ -78,14 +89,14 @@ describe('SQLOperator', () => { const fragment = sql`ORDER BY ${unsafeRaw(columnName)} DESC`; expect(fragment.sql).toBe('ORDER BY created_at DESC'); - expect(fragment.getKnexBindings()).toEqual([]); + expect(fragment.getKnexBindings(getColumnForField)).toEqual([]); }); it('handles complex raw SQL expressions', () => { const fragment = sql`WHERE ${unsafeRaw('EXTRACT(year FROM created_at)')} = ${2024}`; expect(fragment.sql).toBe('WHERE EXTRACT(year FROM created_at) = ?'); - expect(fragment.getKnexBindings()).toEqual([2024]); + expect(fragment.getKnexBindings(getColumnForField)).toEqual([2024]); }); it('combines raw SQL with regular parameters', () => { @@ -93,7 +104,7 @@ describe('SQLOperator', () => { const fragment = sql`SELECT * FROM users WHERE age > ${18} ORDER BY ${unsafeRaw(sortColumn)} ${unsafeRaw('DESC')}`; expect(fragment.sql).toBe('SELECT * FROM users WHERE age > ? ORDER BY name DESC'); - expect(fragment.getKnexBindings()).toEqual([18]); + expect(fragment.getKnexBindings(getColumnForField)).toEqual([18]); }); }); @@ -105,7 +116,7 @@ describe('SQLOperator', () => { const combined = fragment1.append(fragment2); expect(combined.sql).toBe('age >= ? status = ?'); - expect(combined.getKnexBindings()).toEqual([18, 'active']); + expect(combined.getKnexBindings(getColumnForField)).toEqual([18, 'active']); }); }); @@ -114,7 +125,7 @@ describe('SQLOperator', () => { const joined = SQLFragment.joinWithCommaSeparator(); expect(joined.sql).toBe(''); - expect(joined.getKnexBindings()).toEqual([]); + expect(joined.getKnexBindings(getColumnForField)).toEqual([]); }); it('joins SQL fragments with comma', () => { @@ -122,7 +133,7 @@ describe('SQLOperator', () => { const joined = SQLFragment.joinWithCommaSeparator(...columns); expect(joined.sql).toBe('name, age, email'); - expect(joined.getKnexBindings()).toEqual([]); + expect(joined.getKnexBindings(getColumnForField)).toEqual([]); }); it('handles single fragment', () => { @@ -130,7 +141,7 @@ describe('SQLOperator', () => { const joined = SQLFragment.joinWithCommaSeparator(...single); expect(joined.sql).toBe('name = ?'); - expect(joined.getKnexBindings()).toEqual(['Alice']); + expect(joined.getKnexBindings(getColumnForField)).toEqual(['Alice']); }); }); @@ -143,7 +154,7 @@ describe('SQLOperator', () => { const concatenated = SQLFragment.concat(select, where, orderBy); expect(concatenated.sql).toBe('SELECT * FROM users WHERE age > ? ORDER BY name'); - expect(concatenated.getKnexBindings()).toEqual([18]); + expect(concatenated.getKnexBindings(getColumnForField)).toEqual([18]); }); it('handles single fragment in concat', () => { @@ -151,22 +162,22 @@ describe('SQLOperator', () => { const concatenated = SQLFragment.concat(fragment); expect(concatenated.sql).toBe('SELECT * FROM users'); - expect(concatenated.getKnexBindings()).toEqual([]); + expect(concatenated.getKnexBindings(getColumnForField)).toEqual([]); }); it('handles empty concat', () => { const concatenated = SQLFragment.concat(); expect(concatenated.sql).toBe(''); - expect(concatenated.getKnexBindings()).toEqual([]); + expect(concatenated.getKnexBindings(getColumnForField)).toEqual([]); }); it('supports dynamic query building with concat', () => { // Build a query dynamically - const fragments: SQLFragment[] = [sql`SELECT * FROM products`]; + const fragments: SQLFragment>[] = [sql`SELECT * FROM products`]; // Conditionally add WHERE clause - const filters: SQLFragment[] = []; + const filters: SQLFragment>[] = []; filters.push(sql`price > ${100}`); filters.push(sql`category = ${'electronics'}`); @@ -185,7 +196,7 @@ describe('SQLOperator', () => { expect(query.sql).toBe( 'SELECT * FROM products WHERE (price > ?) AND (category = ?) ORDER BY created_at DESC LIMIT ?', ); - expect(query.getKnexBindings()).toEqual([100, 'electronics', 10]); + expect(query.getKnexBindings(getColumnForField)).toEqual([100, 'electronics', 10]); }); }); @@ -282,6 +293,18 @@ describe('SQLOperator', () => { expect(text).toBe('SELECT "user_name" FROM "users" WHERE "status" = \'active\''); }); + it('handles entity fields in getDebugString', () => { + const fragment = new SQLFragment('SELECT ?? FROM ?? WHERE ?? = ?', [ + { type: 'entityField', fieldName: 'string_field' }, + { type: 'identifier', name: 'test' }, + { type: 'entityField', fieldName: 'number_field' }, + { type: 'value', value: 42 }, + ]); + + const text = fragment.getDebugString(); + expect(text).toBe('SELECT "string_field" FROM "test" WHERE "number_field" = 42'); + }); + it('handles undefined bindings gracefully', () => { const fragment = new SQLFragment('SELECT * FROM users WHERE id = ? AND name = ?', [ { type: 'value', value: 1 }, @@ -363,7 +386,7 @@ describe('SQLOperator', () => { const columnName = 'user"data'; const fragment = sql`SELECT ${identifier(columnName)} FROM users`; expect(fragment.sql).toBe('SELECT ?? FROM users'); - expect(fragment.getKnexBindings()).toEqual(['user"data']); + expect(fragment.getKnexBindings(getColumnForField)).toEqual(['user"data']); }); it('delegates escaping to Knex for SQL injection prevention', () => { @@ -371,258 +394,336 @@ describe('SQLOperator', () => { const fragment = sql`SELECT * FROM ${identifier(maliciousName)}`; // The identifier is passed as a binding to Knex which will escape it expect(fragment.sql).toBe('SELECT * FROM ??'); - expect(fragment.getKnexBindings()).toEqual(['id"; DELETE FROM users WHERE "1"="1']); + expect(fragment.getKnexBindings(getColumnForField)).toEqual([ + 'id"; DELETE FROM users WHERE "1"="1', + ]); + }); + }); + + describe(SQLEntityField, () => { + it('stores the entity field name', () => { + const field = entityField('stringField'); + expect(field.fieldName).toBe('stringField'); + }); + + it('uses ?? placeholder in SQL fragments', () => { + const fragment = sql`SELECT ${entityField('stringField')} FROM users`; + + expect(fragment.sql).toBe('SELECT ?? FROM users'); + expect(fragment.getKnexBindings(getColumnForField)).toEqual(['string_field']); + }); + + it('translates entity field name to database column name via getKnexBindings', () => { + const fragment = sql`WHERE ${entityField('intField')} = ${42}`; + + expect(fragment.sql).toBe('WHERE ?? = ?'); + expect(fragment.getKnexBindings(getColumnForField)).toEqual(['number_field', 42]); + }); + + it('translates the id field correctly', () => { + const fragment = sql`WHERE ${entityField('customIdField')} = ${'some-id'}`; + + expect(fragment.sql).toBe('WHERE ?? = ?'); + expect(fragment.getKnexBindings(getColumnForField)).toEqual(['custom_id', 'some-id']); + }); + + it('works alongside identifiers and values', () => { + const fragment = sql`SELECT ${identifier('table_name')}.${entityField('stringField')} WHERE ${entityField('intField')} > ${10}`; + + expect(fragment.sql).toBe('SELECT ??.?? WHERE ?? > ?'); + expect(fragment.getKnexBindings(getColumnForField)).toEqual([ + 'table_name', + 'string_field', + 'number_field', + 10, + ]); + }); + + it('works with multiple entity fields', () => { + const fragment = sql`SELECT ${entityField('stringField')}, ${entityField('intField')}, ${entityField('dateField')} FROM test`; + + expect(fragment.sql).toBe('SELECT ??, ??, ?? FROM test'); + expect(fragment.getKnexBindings(getColumnForField)).toEqual([ + 'string_field', + 'number_field', + 'date_field', + ]); + }); + + it('works in nested SQL fragments', () => { + const inner = sql`${entityField('stringField')} = ${'hello'}`; + const outer = sql`SELECT * FROM test WHERE ${inner}`; + + expect(outer.sql).toBe('SELECT * FROM test WHERE ?? = ?'); + expect(outer.getKnexBindings(getColumnForField)).toEqual(['string_field', 'hello']); }); }); describe('SQLFragmentHelpers', () => { describe(SQLFragmentHelpers.inArray, () => { it('generates IN clause with values', () => { - const fragment = SQLFragmentHelpers.inArray('status', ['active', 'pending']); + const fragment = SQLFragmentHelpers.inArray('stringField', ['active', 'pending']); expect(fragment.sql).toBe('?? IN (?, ?)'); - expect(fragment.getKnexBindings()).toEqual(['status', 'active', 'pending']); + expect(fragment.getKnexBindings(getColumnForField)).toEqual([ + 'string_field', + 'active', + 'pending', + ]); }); it('handles empty array', () => { - const fragment = SQLFragmentHelpers.inArray('status', []); + const fragment = SQLFragmentHelpers.inArray('stringField', []); expect(fragment.sql).toBe('1 = 0'); // Always false - expect(fragment.getKnexBindings()).toEqual([]); + expect(fragment.getKnexBindings(getColumnForField)).toEqual([]); }); }); describe(SQLFragmentHelpers.notInArray, () => { it('generates NOT IN clause with values', () => { - const fragment = SQLFragmentHelpers.notInArray('status', ['deleted', 'archived']); + const fragment = SQLFragmentHelpers.notInArray('stringField', ['deleted', 'archived']); expect(fragment.sql).toBe('?? NOT IN (?, ?)'); - expect(fragment.getKnexBindings()).toEqual(['status', 'deleted', 'archived']); + expect(fragment.getKnexBindings(getColumnForField)).toEqual([ + 'string_field', + 'deleted', + 'archived', + ]); }); it('handles empty array', () => { - const fragment = SQLFragmentHelpers.notInArray('status', []); + const fragment = SQLFragmentHelpers.notInArray('stringField', []); expect(fragment.sql).toBe('1 = 1'); // Always true - expect(fragment.getKnexBindings()).toEqual([]); + expect(fragment.getKnexBindings(getColumnForField)).toEqual([]); }); }); describe(SQLFragmentHelpers.between, () => { it('generates BETWEEN clause with numbers', () => { - const fragment = SQLFragmentHelpers.between('age', 18, 65); + const fragment = SQLFragmentHelpers.between('intField', 18, 65); expect(fragment.sql).toBe('?? BETWEEN ? AND ?'); - expect(fragment.getKnexBindings()).toEqual(['age', 18, 65]); + expect(fragment.getKnexBindings(getColumnForField)).toEqual(['number_field', 18, 65]); }); it('generates BETWEEN clause with dates', () => { const date1 = new Date('2024-01-01'); const date2 = new Date('2024-12-31'); - const fragment = SQLFragmentHelpers.between('created_at', date1, date2); + const fragment = SQLFragmentHelpers.between('dateField', date1, date2); expect(fragment.sql).toBe('?? BETWEEN ? AND ?'); - expect(fragment.getKnexBindings()).toEqual(['created_at', date1, date2]); + expect(fragment.getKnexBindings(getColumnForField)).toEqual(['date_field', date1, date2]); }); it('generates BETWEEN clause with strings', () => { - const fragment = SQLFragmentHelpers.between('name', 'A', 'Z'); + const fragment = SQLFragmentHelpers.between('stringField', 'A', 'Z'); expect(fragment.sql).toBe('?? BETWEEN ? AND ?'); - expect(fragment.getKnexBindings()).toEqual(['name', 'A', 'Z']); + expect(fragment.getKnexBindings(getColumnForField)).toEqual(['string_field', 'A', 'Z']); }); }); describe(SQLFragmentHelpers.notBetween, () => { it('generates NOT BETWEEN clause with numbers', () => { - const fragment = SQLFragmentHelpers.notBetween('age', 18, 65); + const fragment = SQLFragmentHelpers.notBetween('intField', 18, 65); expect(fragment.sql).toBe('?? NOT BETWEEN ? AND ?'); - expect(fragment.getKnexBindings()).toEqual(['age', 18, 65]); + expect(fragment.getKnexBindings(getColumnForField)).toEqual(['number_field', 18, 65]); }); it('generates NOT BETWEEN clause with dates', () => { const date1 = new Date('2024-01-01'); const date2 = new Date('2024-12-31'); - const fragment = SQLFragmentHelpers.notBetween('created_at', date1, date2); + const fragment = SQLFragmentHelpers.notBetween('dateField', date1, date2); expect(fragment.sql).toBe('?? NOT BETWEEN ? AND ?'); - expect(fragment.getKnexBindings()).toEqual(['created_at', date1, date2]); + expect(fragment.getKnexBindings(getColumnForField)).toEqual(['date_field', date1, date2]); }); }); describe(SQLFragmentHelpers.like, () => { it('generates LIKE clause', () => { - const fragment = SQLFragmentHelpers.like('name', '%John%'); + const fragment = SQLFragmentHelpers.like('stringField', '%John%'); expect(fragment.sql).toBe('?? LIKE ?'); - expect(fragment.getKnexBindings()).toEqual(['name', '%John%']); + expect(fragment.getKnexBindings(getColumnForField)).toEqual(['string_field', '%John%']); }); }); describe(SQLFragmentHelpers.notLike, () => { it('generates NOT LIKE clause', () => { - const fragment = SQLFragmentHelpers.notLike('name', '%test%'); + const fragment = SQLFragmentHelpers.notLike('stringField', '%test%'); expect(fragment.sql).toBe('?? NOT LIKE ?'); - expect(fragment.getKnexBindings()).toEqual(['name', '%test%']); + expect(fragment.getKnexBindings(getColumnForField)).toEqual(['string_field', '%test%']); }); }); describe(SQLFragmentHelpers.ilike, () => { it('generates ILIKE clause for case-insensitive matching', () => { - const fragment = SQLFragmentHelpers.ilike('email', '%@example.com'); + const fragment = SQLFragmentHelpers.ilike('testIndexedField', '%@example.com'); expect(fragment.sql).toBe('?? ILIKE ?'); - expect(fragment.getKnexBindings()).toEqual(['email', '%@example.com']); + expect(fragment.getKnexBindings(getColumnForField)).toEqual([ + 'test_index', + '%@example.com', + ]); }); }); describe(SQLFragmentHelpers.notIlike, () => { it('generates NOT ILIKE clause for case-insensitive non-matching', () => { - const fragment = SQLFragmentHelpers.notIlike('email', '%@spam.com'); + const fragment = SQLFragmentHelpers.notIlike('testIndexedField', '%@spam.com'); expect(fragment.sql).toBe('?? NOT ILIKE ?'); - expect(fragment.getKnexBindings()).toEqual(['email', '%@spam.com']); + expect(fragment.getKnexBindings(getColumnForField)).toEqual(['test_index', '%@spam.com']); }); }); describe(SQLFragmentHelpers.isNull, () => { it('generates IS NULL', () => { - const fragment = SQLFragmentHelpers.isNull('deleted_at'); + const fragment = SQLFragmentHelpers.isNull('nullableField'); expect(fragment.sql).toBe('?? IS NULL'); - expect(fragment.getKnexBindings()).toEqual(['deleted_at']); + expect(fragment.getKnexBindings(getColumnForField)).toEqual(['nullable_field']); }); }); describe(SQLFragmentHelpers.isNotNull, () => { it('generates IS NOT NULL', () => { - const fragment = SQLFragmentHelpers.isNotNull('email'); + const fragment = SQLFragmentHelpers.isNotNull('testIndexedField'); expect(fragment.sql).toBe('?? IS NOT NULL'); - expect(fragment.getKnexBindings()).toEqual(['email']); + expect(fragment.getKnexBindings(getColumnForField)).toEqual(['test_index']); }); }); describe(SQLFragmentHelpers.eq, () => { it('generates equality check', () => { - const fragment = SQLFragmentHelpers.eq('status', 'active'); + const fragment = SQLFragmentHelpers.eq('stringField', 'active'); expect(fragment.sql).toBe('?? = ?'); - expect(fragment.getKnexBindings()).toEqual(['status', 'active']); + expect(fragment.getKnexBindings(getColumnForField)).toEqual(['string_field', 'active']); }); it('handles null in equality check', () => { - const fragment = SQLFragmentHelpers.eq('field', null); + const fragment = SQLFragmentHelpers.eq('nullableField', null); expect(fragment.sql).toBe('?? IS NULL'); - expect(fragment.getKnexBindings()).toEqual(['field']); + expect(fragment.getKnexBindings(getColumnForField)).toEqual(['nullable_field']); }); it('handles undefined in equality check', () => { - const fragment = SQLFragmentHelpers.eq('field', undefined); + const fragment = SQLFragmentHelpers.eq('nullableField', undefined); expect(fragment.sql).toBe('?? IS NULL'); - expect(fragment.getKnexBindings()).toEqual(['field']); + expect(fragment.getKnexBindings(getColumnForField)).toEqual(['nullable_field']); }); }); describe(SQLFragmentHelpers.neq, () => { it('generates inequality check', () => { - const fragment = SQLFragmentHelpers.neq('status', 'deleted'); + const fragment = SQLFragmentHelpers.neq('stringField', 'deleted'); expect(fragment.sql).toBe('?? != ?'); - expect(fragment.getKnexBindings()).toEqual(['status', 'deleted']); + expect(fragment.getKnexBindings(getColumnForField)).toEqual(['string_field', 'deleted']); }); it('handles null in inequality check', () => { - const fragment = SQLFragmentHelpers.neq('field', null); + const fragment = SQLFragmentHelpers.neq('nullableField', null); expect(fragment.sql).toBe('?? IS NOT NULL'); - expect(fragment.getKnexBindings()).toEqual(['field']); + expect(fragment.getKnexBindings(getColumnForField)).toEqual(['nullable_field']); }); it('handles undefined in inequality check', () => { - const fragment = SQLFragmentHelpers.neq('field', undefined); + const fragment = SQLFragmentHelpers.neq('nullableField', undefined); expect(fragment.sql).toBe('?? IS NOT NULL'); - expect(fragment.getKnexBindings()).toEqual(['field']); + expect(fragment.getKnexBindings(getColumnForField)).toEqual(['nullable_field']); }); }); describe(SQLFragmentHelpers.gt, () => { it('generates greater than', () => { - const fragment = SQLFragmentHelpers.gt('age', 18); + const fragment = SQLFragmentHelpers.gt('intField', 18); expect(fragment.sql).toBe('?? > ?'); - expect(fragment.getKnexBindings()).toEqual(['age', 18]); + expect(fragment.getKnexBindings(getColumnForField)).toEqual(['number_field', 18]); }); }); describe(SQLFragmentHelpers.gte, () => { it('generates greater than or equal', () => { - const fragment = SQLFragmentHelpers.gte('age', 18); + const fragment = SQLFragmentHelpers.gte('intField', 18); expect(fragment.sql).toBe('?? >= ?'); - expect(fragment.getKnexBindings()).toEqual(['age', 18]); + expect(fragment.getKnexBindings(getColumnForField)).toEqual(['number_field', 18]); }); }); describe(SQLFragmentHelpers.lt, () => { it('generates less than', () => { - const fragment = SQLFragmentHelpers.lt('age', 65); + const fragment = SQLFragmentHelpers.lt('intField', 65); expect(fragment.sql).toBe('?? < ?'); - expect(fragment.getKnexBindings()).toEqual(['age', 65]); + expect(fragment.getKnexBindings(getColumnForField)).toEqual(['number_field', 65]); }); }); describe(SQLFragmentHelpers.lte, () => { it('generates less than or equal', () => { - const fragment = SQLFragmentHelpers.lte('age', 65); + const fragment = SQLFragmentHelpers.lte('intField', 65); expect(fragment.sql).toBe('?? <= ?'); - expect(fragment.getKnexBindings()).toEqual(['age', 65]); + expect(fragment.getKnexBindings(getColumnForField)).toEqual(['number_field', 65]); }); }); describe(SQLFragmentHelpers.jsonContains, () => { it('generates JSON contains', () => { - const fragment = SQLFragmentHelpers.jsonContains('metadata', { premium: true }); + const fragment = SQLFragmentHelpers.jsonContains('stringField', { premium: true }); expect(fragment.sql).toBe('?? @> ?::jsonb'); - expect(fragment.getKnexBindings()).toEqual(['metadata', '{"premium":true}']); + expect(fragment.getKnexBindings(getColumnForField)).toEqual([ + 'string_field', + '{"premium":true}', + ]); }); }); describe(SQLFragmentHelpers.jsonContainedBy, () => { it('generates JSON contained by', () => { - const fragment = SQLFragmentHelpers.jsonContainedBy('settings', { + const fragment = SQLFragmentHelpers.jsonContainedBy('stringField', { theme: 'dark', lang: 'en', }); expect(fragment.sql).toBe('?? <@ ?::jsonb'); - expect(fragment.getKnexBindings()).toEqual(['settings', '{"theme":"dark","lang":"en"}']); + expect(fragment.getKnexBindings(getColumnForField)).toEqual([ + 'string_field', + '{"theme":"dark","lang":"en"}', + ]); }); }); describe(SQLFragmentHelpers.jsonPath, () => { it('generates JSON path access', () => { - const fragment = SQLFragmentHelpers.jsonPath('data', 'user'); + const fragment = SQLFragmentHelpers.jsonPath('stringField', 'user'); expect(fragment.sql).toBe(`??->?`); - expect(fragment.getKnexBindings()).toEqual(['data', 'user']); + expect(fragment.getKnexBindings(getColumnForField)).toEqual(['string_field', 'user']); }); }); describe(SQLFragmentHelpers.jsonPathText, () => { it('generates JSON path text access', () => { - const fragment = SQLFragmentHelpers.jsonPathText('data', 'email'); + const fragment = SQLFragmentHelpers.jsonPathText('stringField', 'email'); expect(fragment.sql).toBe(`??->>?`); - expect(fragment.getKnexBindings()).toEqual(['data', 'email']); + expect(fragment.getKnexBindings(getColumnForField)).toEqual(['string_field', 'email']); }); }); @@ -633,7 +734,7 @@ describe('SQLOperator', () => { const fragment = SQLFragmentHelpers.and(cond1, cond2); expect(fragment.sql).toBe('(age >= ?) AND (status = ?)'); - expect(fragment.getKnexBindings()).toEqual([18, 'active']); + expect(fragment.getKnexBindings(getColumnForField)).toEqual([18, 'active']); }); it('handles single condition in AND', () => { @@ -641,14 +742,14 @@ describe('SQLOperator', () => { const fragment = SQLFragmentHelpers.and(cond); expect(fragment.sql).toBe('(age >= ?)'); - expect(fragment.getKnexBindings()).toEqual([18]); + expect(fragment.getKnexBindings(getColumnForField)).toEqual([18]); }); it('handles empty conditions in AND', () => { const fragment = SQLFragmentHelpers.and(); expect(fragment.sql).toBe('1 = 1'); - expect(fragment.getKnexBindings()).toEqual([]); + expect(fragment.getKnexBindings(getColumnForField)).toEqual([]); }); }); @@ -659,7 +760,7 @@ describe('SQLOperator', () => { const fragment = SQLFragmentHelpers.or(cond1, cond2); expect(fragment.sql).toBe('(status = ?) OR (status = ?)'); - expect(fragment.getKnexBindings()).toEqual(['active', 'pending']); + expect(fragment.getKnexBindings(getColumnForField)).toEqual(['active', 'pending']); }); it('handles single condition in OR', () => { @@ -667,14 +768,14 @@ describe('SQLOperator', () => { const fragment = SQLFragmentHelpers.or(cond); expect(fragment.sql).toBe('(status = ?)'); - expect(fragment.getKnexBindings()).toEqual(['active']); + expect(fragment.getKnexBindings(getColumnForField)).toEqual(['active']); }); it('handles empty conditions in OR', () => { const fragment = SQLFragmentHelpers.or(); expect(fragment.sql).toBe('1 = 0'); - expect(fragment.getKnexBindings()).toEqual([]); + expect(fragment.getKnexBindings(getColumnForField)).toEqual([]); }); }); @@ -684,7 +785,7 @@ describe('SQLOperator', () => { const fragment = SQLFragmentHelpers.not(cond); expect(fragment.sql).toBe('NOT (status = ?)'); - expect(fragment.getKnexBindings()).toEqual(['deleted']); + expect(fragment.getKnexBindings(getColumnForField)).toEqual(['deleted']); }); }); @@ -694,32 +795,35 @@ describe('SQLOperator', () => { const fragment = SQLFragmentHelpers.group(cond); expect(fragment.sql).toBe('(age >= ? AND age <= ?)'); - expect(fragment.getKnexBindings()).toEqual([18, 65]); + expect(fragment.getKnexBindings(getColumnForField)).toEqual([18, 65]); }); }); describe('complex combinations', () => { it('builds complex queries with multiple helpers', () => { - const { and, or, between, inArray, isNotNull, group } = SQLFragmentHelpers; - - const fragment = and( - between('age', 18, 65), - group(or(inArray('status', ['active', 'premium']), sql`role = ${'admin'}`)), - isNotNull('email'), + const fragment = SQLFragmentHelpers.and( + SQLFragmentHelpers.between('intField', 18, 65), + SQLFragmentHelpers.group( + SQLFragmentHelpers.or( + SQLFragmentHelpers.inArray('stringField', ['active', 'premium']), + sql`role = ${'admin'}`, + ), + ), + SQLFragmentHelpers.isNotNull('testIndexedField'), ); expect(fragment.sql).toBe( '(?? BETWEEN ? AND ?) AND (((?? IN (?, ?)) OR (role = ?))) AND (?? IS NOT NULL)', ); - expect(fragment.getKnexBindings()).toEqual([ - 'age', + expect(fragment.getKnexBindings(getColumnForField)).toEqual([ + 'number_field', 18, 65, - 'status', + 'string_field', 'active', 'premium', 'admin', - 'email', + 'test_index', ]); }); }); diff --git a/packages/entity-database-adapter-knex/src/__tests__/fixtures/StubPostgresDatabaseAdapter.ts b/packages/entity-database-adapter-knex/src/__tests__/fixtures/StubPostgresDatabaseAdapter.ts index 8b87a4f82..e47697e81 100644 --- a/packages/entity-database-adapter-knex/src/__tests__/fixtures/StubPostgresDatabaseAdapter.ts +++ b/packages/entity-database-adapter-knex/src/__tests__/fixtures/StubPostgresDatabaseAdapter.ts @@ -106,8 +106,8 @@ export class StubPostgresDatabaseAdapter< return results[0] ?? null; } - private static compareByOrderBys( - orderBys: TableOrderByClause[], + private static compareByOrderBys>( + orderBys: TableOrderByClause[], objectA: { [key: string]: any }, objectB: { [key: string]: any }, ): 0 | 1 | -1 { @@ -161,7 +161,7 @@ export class StubPostgresDatabaseAdapter< tableName: string, tableFieldSingleValueEqualityOperands: TableFieldSingleValueEqualityCondition[], tableFieldMultiValueEqualityOperands: TableFieldMultiValueEqualityCondition[], - querySelectionModifiers: TableQuerySelectionModifiers, + querySelectionModifiers: TableQuerySelectionModifiers, ): Promise { let filteredObjects = this.getObjectCollectionForTable(tableName); for (const { tableField, tableValue } of tableFieldSingleValueEqualityOperands) { @@ -197,7 +197,7 @@ export class StubPostgresDatabaseAdapter< _tableName: string, _rawWhereClause: string, _bindings: object | any[], - _querySelectionModifiers: TableQuerySelectionModifiers, + _querySelectionModifiers: TableQuerySelectionModifiers, ): Promise { throw new Error('Raw WHERE clauses not supported for StubDatabaseAdapter'); } @@ -205,8 +205,8 @@ export class StubPostgresDatabaseAdapter< protected fetchManyBySQLFragmentInternalAsync( _queryInterface: any, _tableName: string, - _sqlFragment: SQLFragment, - _querySelectionModifiers: TableQuerySelectionModifiers, + _sqlFragment: SQLFragment, + _querySelectionModifiers: TableQuerySelectionModifiers, ): Promise { throw new Error('SQL fragments not supported for StubDatabaseAdapter'); } diff --git a/packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts b/packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts index 91e6d20dd..2841e99af 100644 --- a/packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts +++ b/packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts @@ -28,16 +28,19 @@ interface DataManagerStandardSpecification> type DataManagerFieldNameConstructorFn> = ( fieldName: keyof TFields, -) => SQLFragment; +) => SQLFragment; type DataManagerSearchFieldSQLFragmentFnSpecification> = { fieldConstructor: ( getFragmentForFieldName: DataManagerFieldNameConstructorFn, - ) => SQLFragment; + ) => SQLFragment; }; function isDataManagerSearchFieldSQLFragmentFnSpecification>( - obj: keyof TFields | SQLFragment | DataManagerSearchFieldSQLFragmentFnSpecification, + obj: + | keyof TFields + | SQLFragment + | DataManagerSearchFieldSQLFragmentFnSpecification, ): obj is DataManagerSearchFieldSQLFragmentFnSpecification { return typeof obj === 'object' && obj !== null && 'fieldConstructor' in obj; } @@ -74,7 +77,7 @@ type DataManagerPaginationSpecification> = | DataManagerSearchSpecification; interface BaseUnifiedPaginationArgs> { - where?: SQLFragment; + where?: SQLFragment; pagination: DataManagerPaginationSpecification; } @@ -82,14 +85,18 @@ interface ForwardUnifiedPaginationArgs< TFields extends Record, > extends BaseUnifiedPaginationArgs { first: number; + last?: never; + before?: never; after?: string; } interface BackwardUnifiedPaginationArgs< TFields extends Record, > extends BaseUnifiedPaginationArgs { + first?: never; last: number; before?: string; + after?: never; } type LoadPageArgs> = @@ -130,13 +137,13 @@ enum PaginationDirection { const CURSOR_ROW_TABLE_ALIAS = 'cursor_row'; interface PaginationProvider, TIDField extends keyof TFields> { - whereClause: SQLFragment | undefined; + whereClause: SQLFragment | undefined; buildOrderBy: (direction: PaginationDirection) => readonly PostgresOrderByClause[]; buildCursorCondition: ( decodedCursorId: TFields[TIDField], direction: PaginationDirection, orderByClauses: readonly PostgresOrderByClause[], - ) => SQLFragment; + ) => SQLFragment; } /** @@ -220,7 +227,7 @@ export class EntityKnexDataManager< async loadManyBySQLFragmentAsync( queryContext: EntityQueryContext, - sqlFragment: SQLFragment, + sqlFragment: SQLFragment, querySelectionModifiers: PostgresQuerySelectionModifiers, ): Promise[]> { EntityKnexDataManager.validateOrderByClauses(querySelectionModifiers.orderBy); @@ -269,7 +276,7 @@ export class EntityKnexDataManager< const idField = this.entityConfiguration.idField; const augmentedOrderByClauses = this.augmentOrderByIfNecessary(pagination.orderBy, idField); - const fieldsToUseInPostgresTupleCursor: readonly (keyof TFields | SQLFragment)[] = + const fieldsToUseInPostgresTupleCursor: readonly (keyof TFields | SQLFragment)[] = augmentedOrderByClauses.map((order) => 'fieldName' in order ? order.fieldName : order.fieldFragment, ); @@ -384,7 +391,7 @@ export class EntityKnexDataManager< // Validate pagination arguments const maxPageSize = this.databaseAdapter.paginationMaxPageSize; - const isForward = 'first' in args; + const isForward = 'first' in args && args.first !== undefined; if (isForward) { assert( Number.isInteger(args.first) && args.first > 0, @@ -475,9 +482,9 @@ export class EntityKnexDataManager< } private combineWhereConditions( - baseWhere: SQLFragment | undefined, - cursorCondition: SQLFragment | null, - ): SQLFragment { + baseWhere: SQLFragment | undefined, + cursorCondition: SQLFragment | null, + ): SQLFragment { const conditions = [baseWhere, cursorCondition].filter((it) => !!it); if (conditions.length === 0) { return sql`1 = 1`; @@ -575,9 +582,12 @@ export class EntityKnexDataManager< } private resolveSearchFieldToSQLFragment( - field: keyof TFields | SQLFragment | DataManagerSearchFieldSQLFragmentFnSpecification, + field: + | keyof TFields + | SQLFragment + | DataManagerSearchFieldSQLFragmentFnSpecification, tableAlias?: typeof CURSOR_ROW_TABLE_ALIAS, - ): SQLFragment { + ): SQLFragment { if (field instanceof SQLFragment) { return field; } @@ -601,11 +611,11 @@ export class EntityKnexDataManager< decodedExternalCursorEntityID: TFields[TIDField], fieldsToUseInPostgresTupleCursor: readonly ( | keyof TFields - | SQLFragment + | SQLFragment | DataManagerSearchFieldSQLFragmentFnSpecification )[], effectiveOrdering: OrderByOrdering, - ): SQLFragment { + ): SQLFragment { // 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. @@ -645,7 +655,7 @@ export class EntityKnexDataManager< private buildILikeConditions( search: DataManagerSearchSpecification, tableAlias?: typeof CURSOR_ROW_TABLE_ALIAS, - ): readonly SQLFragment[] { + ): readonly SQLFragment[] { return search.fields.map((field) => { const fieldFragment = this.resolveSearchFieldToSQLFragment(field, tableAlias); return sql`${fieldFragment} ILIKE ${'%' + EntityKnexDataManager.escapeILikePattern(search.term) + '%'}`; @@ -655,7 +665,7 @@ export class EntityKnexDataManager< private buildTrigramSimilarityExpressions( search: DataManagerSearchSpecification, tableAlias?: typeof CURSOR_ROW_TABLE_ALIAS, - ): readonly SQLFragment[] { + ): readonly SQLFragment[] { return search.fields.map((field) => { const fieldFragment = this.resolveSearchFieldToSQLFragment(field, tableAlias); return sql`similarity(${fieldFragment}, ${search.term})`; @@ -665,7 +675,7 @@ export class EntityKnexDataManager< private buildTrigramExactMatchCaseExpression( search: DataManagerSearchSpecification, tableAlias?: typeof CURSOR_ROW_TABLE_ALIAS, - ): SQLFragment { + ): SQLFragment { const ilikeConditions = this.buildILikeConditions(search, tableAlias); return sql`CASE WHEN ${SQLFragmentHelpers.or(...ilikeConditions)} THEN 1 ELSE 0 END`; } @@ -673,7 +683,7 @@ export class EntityKnexDataManager< private buildTrigramSimilarityGreatestExpression( search: DataManagerSearchSpecification, tableAlias?: typeof CURSOR_ROW_TABLE_ALIAS, - ): SQLFragment { + ): SQLFragment { const similarityExprs = this.buildTrigramSimilarityExpressions(search, tableAlias); return sql`GREATEST(${SQLFragment.joinWithCommaSeparator(...similarityExprs)})`; } @@ -682,7 +692,7 @@ export class EntityKnexDataManager< search: DataManagerTrigramSearchSpecification, decodedExternalCursorEntityID: TFields[TIDField], direction: PaginationDirection, - ): SQLFragment { + ): SQLFragment { // For TRIGRAM search, we compute the similarity values using a subquery, similar to normal cursor. // If the cursor entity has been deleted, the subquery returns no rows and the // comparison evaluates to NULL, filtering out all results (empty page). @@ -726,7 +736,7 @@ export class EntityKnexDataManager< ) ?? []; // Build SELECT fields for subquery - const selectFields = [ + const selectFields: SQLFragment[] = [ cursorExactMatchExpr, cursorSimilarityExpr, ...cursorExtraFields, @@ -743,7 +753,7 @@ export class EntityKnexDataManager< } private buildSearchConditionAndOrderBy(search: DataManagerSearchSpecification): { - searchWhere: SQLFragment; + searchWhere: SQLFragment; searchOrderByClauses: readonly DistributiveOmit, 'nulls'>[]; } { switch (search.strategy) {