From cbd366a24632654135ad3294d77869b3b336dc9c Mon Sep 17 00:00:00 2001 From: Will Schurman Date: Sun, 1 Feb 2026 12:23:44 -0800 Subject: [PATCH] feat!: Add sql template and loader method --- .../src/StubPostgresDatabaseAdapter.ts | 11 + .../StubPostgresDatabaseAdapter-test.ts | 15 +- ...uthorizationResultBasedKnexEntityLoader.ts | 76 +- .../src/BasePostgresEntityDatabaseAdapter.ts | 57 ++ .../src/BaseSQLQueryBuilder.ts | 100 +++ .../src/EnforcingKnexEntityLoader.ts | 82 ++ .../src/PostgresEntityDatabaseAdapter.ts | 20 + .../src/SQLOperator.ts | 478 +++++++++++ .../PostgresEntityIntegration-test.ts | 479 +++++++++++ .../BasePostgresEntityDatabaseAdapter-test.ts | 12 + .../src/__tests__/SQLOperator-test.ts | 747 ++++++++++++++++++ .../fixtures/StubPostgresDatabaseAdapter.ts | 11 + .../entity-database-adapter-knex/src/index.ts | 2 + .../src/internal/EntityKnexDataManager.ts | 21 + .../src/metrics/IEntityMetricsAdapter.ts | 1 + 15 files changed, 2110 insertions(+), 2 deletions(-) create mode 100644 packages/entity-database-adapter-knex/src/BaseSQLQueryBuilder.ts create mode 100644 packages/entity-database-adapter-knex/src/SQLOperator.ts create mode 100644 packages/entity-database-adapter-knex/src/__tests__/SQLOperator-test.ts 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 44c6b5f04..b4b4c9d90 100644 --- a/packages/entity-database-adapter-knex-testing-utils/src/StubPostgresDatabaseAdapter.ts +++ b/packages/entity-database-adapter-knex-testing-utils/src/StubPostgresDatabaseAdapter.ts @@ -11,9 +11,11 @@ import { import { BasePostgresEntityDatabaseAdapter, OrderByOrdering, + SQLFragment, TableFieldMultiValueEqualityCondition, TableFieldSingleValueEqualityCondition, TableQuerySelectionModifiers, + TableQuerySelectionModifiersWithOrderByFragment, } from '@expo/entity-database-adapter-knex'; import invariant from 'invariant'; import { v7 as uuidv7 } from 'uuid'; @@ -184,6 +186,15 @@ export class StubPostgresDatabaseAdapter< throw new Error('Raw WHERE clauses not supported for StubDatabaseAdapter'); } + protected fetchManyBySQLFragmentInternalAsync( + _queryInterface: any, + _tableName: string, + _sqlFragment: SQLFragment, + _querySelectionModifiers: TableQuerySelectionModifiersWithOrderByFragment, + ): Promise { + throw new Error('SQL fragments not supported for StubDatabaseAdapter'); + } + private generateRandomID(): any { const idSchemaField = this.entityConfiguration2.schema.get(this.entityConfiguration2.idField); invariant( diff --git a/packages/entity-database-adapter-knex-testing-utils/src/__tests__/StubPostgresDatabaseAdapter-test.ts b/packages/entity-database-adapter-knex-testing-utils/src/__tests__/StubPostgresDatabaseAdapter-test.ts index 54d49b562..fae466095 100644 --- a/packages/entity-database-adapter-knex-testing-utils/src/__tests__/StubPostgresDatabaseAdapter-test.ts +++ b/packages/entity-database-adapter-knex-testing-utils/src/__tests__/StubPostgresDatabaseAdapter-test.ts @@ -5,7 +5,7 @@ import { SingleFieldHolder, SingleFieldValueHolder, } from '@expo/entity'; -import { OrderByOrdering } from '@expo/entity-database-adapter-knex'; +import { OrderByOrdering, sql } from '@expo/entity-database-adapter-knex'; import { describe, expect, it, jest } from '@jest/globals'; import { instance, mock } from 'ts-mockito'; import { validate, version } from 'uuid'; @@ -405,6 +405,19 @@ describe(StubPostgresDatabaseAdapter, () => { }); }); + describe('fetchManyBySQLFragmentAsync', () => { + it('throws because it is unsupported', async () => { + const queryContext = instance(mock(EntityQueryContext)); + const databaseAdapter = new StubPostgresDatabaseAdapter( + testEntityConfiguration, + new Map(), + ); + await expect( + databaseAdapter.fetchManyBySQLFragmentAsync(queryContext, sql``, {}), + ).rejects.toThrow(); + }); + }); + describe('insertAsync', () => { it('inserts a record', async () => { const queryContext = instance(mock(EntityQueryContext)); diff --git a/packages/entity-database-adapter-knex/src/AuthorizationResultBasedKnexEntityLoader.ts b/packages/entity-database-adapter-knex/src/AuthorizationResultBasedKnexEntityLoader.ts index 2f155fbea..0084313c0 100644 --- a/packages/entity-database-adapter-knex/src/AuthorizationResultBasedKnexEntityLoader.ts +++ b/packages/entity-database-adapter-knex/src/AuthorizationResultBasedKnexEntityLoader.ts @@ -12,8 +12,11 @@ import { FieldEqualityCondition, isSingleValueFieldEqualityCondition, QuerySelectionModifiers, + QuerySelectionModifiersWithOrderByFragment, QuerySelectionModifiersWithOrderByRaw, } from './BasePostgresEntityDatabaseAdapter'; +import { BaseSQLQueryBuilder } from './BaseSQLQueryBuilder'; +import { SQLFragment } from './SQLOperator'; import { EntityKnexDataManager } from './internal/EntityKnexDataManager'; /** @@ -40,7 +43,7 @@ export class AuthorizationResultBasedKnexEntityLoader< private readonly queryContext: EntityQueryContext, private readonly knexDataManager: EntityKnexDataManager, protected readonly metricsAdapter: IEntityMetricsAdapter, - public readonly constructionUtils: EntityConstructionUtils< + private readonly constructionUtils: EntityConstructionUtils< TFields, TIDField, TViewerContext, @@ -108,4 +111,75 @@ export class AuthorizationResultBasedKnexEntityLoader< ); return await this.constructionUtils.constructAndAuthorizeEntitiesArrayAsync(fieldObjects); } + + /** + * Authorization-result-based version of the EnforcingKnexEntityLoader method by the same name. + * @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, + modifiers: QuerySelectionModifiersWithOrderByFragment = {}, + ): AuthorizationResultBasedSQLQueryBuilder< + TFields, + TIDField, + TViewerContext, + TEntity, + TPrivacyPolicy, + TSelectedFields + > { + return new AuthorizationResultBasedSQLQueryBuilder( + this.knexDataManager, + this.constructionUtils, + this.queryContext, + fragment, + modifiers, + ); + } +} + +/** + * SQL query builder implementation for AuthorizationResultBasedKnexEntityLoader. + */ +export class AuthorizationResultBasedSQLQueryBuilder< + TFields extends Record, + TIDField extends keyof NonNullable>, + TViewerContext extends ViewerContext, + TEntity extends ReadonlyEntity, + TPrivacyPolicy extends EntityPrivacyPolicy< + TFields, + TIDField, + TViewerContext, + TEntity, + TSelectedFields + >, + TSelectedFields extends keyof TFields, +> extends BaseSQLQueryBuilder> { + constructor( + private readonly knexDataManager: EntityKnexDataManager, + private readonly constructionUtils: EntityConstructionUtils< + TFields, + TIDField, + TViewerContext, + TEntity, + TPrivacyPolicy, + TSelectedFields + >, + private readonly queryContext: EntityQueryContext, + sqlFragment: SQLFragment, + modifiers: QuerySelectionModifiersWithOrderByFragment, + ) { + super(sqlFragment, modifiers); + } + + /** + * Execute the query and return results. + */ + async executeInternalAsync(): Promise[]> { + const fieldObjects = await this.knexDataManager.loadManyBySQLFragmentAsync( + this.queryContext, + this.getSQLFragment(), + this.getModifiers(), + ); + return await this.constructionUtils.constructAndAuthorizeEntitiesArrayAsync(fieldObjects); + } } diff --git a/packages/entity-database-adapter-knex/src/BasePostgresEntityDatabaseAdapter.ts b/packages/entity-database-adapter-knex/src/BasePostgresEntityDatabaseAdapter.ts index 8cccea781..294d711db 100644 --- a/packages/entity-database-adapter-knex/src/BasePostgresEntityDatabaseAdapter.ts +++ b/packages/entity-database-adapter-knex/src/BasePostgresEntityDatabaseAdapter.ts @@ -6,6 +6,8 @@ import { } from '@expo/entity'; import { Knex } from 'knex'; +import { SQLFragment } from './SQLOperator'; + /** * Equality operand that is used for selecting entities with a field with a single value. */ @@ -112,6 +114,15 @@ export interface QuerySelectionModifiersWithOrderByRaw< orderByRaw?: string; } +export interface QuerySelectionModifiersWithOrderByFragment< + TFields extends Record, +> extends QuerySelectionModifiers { + /** + * Order the entities by a SQL fragment `ORDER BY` clause. + */ + orderByFragment?: SQLFragment; +} + export interface TableQuerySelectionModifiers { orderBy: | { @@ -125,6 +136,11 @@ export interface TableQuerySelectionModifiers { export interface TableQuerySelectionModifiersWithOrderByRaw extends TableQuerySelectionModifiers { orderByRaw: string | undefined; + orderByRawBindings?: readonly any[]; +} + +export interface TableQuerySelectionModifiersWithOrderByFragment extends TableQuerySelectionModifiers { + orderByFragment: SQLFragment | undefined; } export abstract class BasePostgresEntityDatabaseAdapter< @@ -218,6 +234,38 @@ export abstract class BasePostgresEntityDatabaseAdapter< querySelectionModifiers: TableQuerySelectionModifiersWithOrderByRaw, ): Promise; + /** + * Fetch many objects matching the SQL fragment. + * + * @param queryContext - query context with which to perform the fetch + * @param sqlFragment - SQLFragment for the WHERE clause of the query + * @param querySelectionModifiers - limit, offset, and orderByFragment for the query + * @returns array of objects matching the query + */ + async fetchManyBySQLFragmentAsync( + queryContext: EntityQueryContext, + sqlFragment: SQLFragment, + querySelectionModifiers: QuerySelectionModifiersWithOrderByFragment, + ): Promise[]> { + const results = await this.fetchManyBySQLFragmentInternalAsync( + queryContext.getQueryInterface(), + this.entityConfiguration.tableName, + sqlFragment, + this.convertToTableQueryModifiersWithOrderByFragment(querySelectionModifiers), + ); + + return results.map((result) => + transformDatabaseObjectToFields(this.entityConfiguration, this.fieldTransformerMap, result), + ); + } + + protected abstract fetchManyBySQLFragmentInternalAsync( + queryInterface: Knex, + tableName: string, + sqlFragment: SQLFragment, + querySelectionModifiers: TableQuerySelectionModifiersWithOrderByFragment, + ): Promise; + private convertToTableQueryModifiersWithOrderByRaw( querySelectionModifiers: QuerySelectionModifiersWithOrderByRaw, ): TableQuerySelectionModifiersWithOrderByRaw { @@ -227,6 +275,15 @@ export abstract class BasePostgresEntityDatabaseAdapter< }; } + private convertToTableQueryModifiersWithOrderByFragment( + querySelectionModifiers: QuerySelectionModifiersWithOrderByFragment, + ): TableQuerySelectionModifiersWithOrderByFragment { + return { + ...this.convertToTableQueryModifiers(querySelectionModifiers), + orderByFragment: querySelectionModifiers.orderByFragment, + }; + } + private convertToTableQueryModifiers( querySelectionModifiers: QuerySelectionModifiers, ): TableQuerySelectionModifiers { diff --git a/packages/entity-database-adapter-knex/src/BaseSQLQueryBuilder.ts b/packages/entity-database-adapter-knex/src/BaseSQLQueryBuilder.ts new file mode 100644 index 000000000..6830f8feb --- /dev/null +++ b/packages/entity-database-adapter-knex/src/BaseSQLQueryBuilder.ts @@ -0,0 +1,100 @@ +import { + OrderByOrdering, + QuerySelectionModifiersWithOrderByFragment, +} from './BasePostgresEntityDatabaseAdapter'; +import { SQLFragment } from './SQLOperator'; + +/** + * Base SQL query builder that provides common functionality for building SQL queries. + */ +export abstract class BaseSQLQueryBuilder, TResultType> { + private executed = false; + + constructor( + private readonly sqlFragment: SQLFragment, + private readonly modifiers: { + limit?: number; + offset?: number; + orderBy?: { fieldName: keyof TFields; order: OrderByOrdering }[]; + orderByFragment?: SQLFragment; + }, + ) {} + + /** + * Limit the number of results + */ + limit(n: number): this { + this.modifiers.limit = n; + return this; + } + + /** + * Skip a number of results + */ + offset(n: number): this { + this.modifiers.offset = n; + return this; + } + + /** + * Order by a field. Can be called multiple times to add multiple order bys. + */ + orderBy(fieldName: keyof TFields, order: OrderByOrdering = OrderByOrdering.ASCENDING): this { + this.modifiers.orderBy = [...(this.modifiers.orderBy ?? []), { fieldName, order }]; + return this; + } + + /** + * Order by a SQL fragment expression. + * Provides type-safe, parameterized ORDER BY clauses + * + * @example + * ```ts + * import { sql, raw } from '@expo/entity-database-adapter-knex'; + * + * // Safe parameterized ordering + * .orderBySQL(sql`CASE WHEN priority = ${1} THEN 0 ELSE 1 END, created_at DESC`) + * + * // Dynamic column ordering + * const sortColumn = 'name'; + * .orderBySQL(sql`${raw(sortColumn)} DESC NULLS LAST`) + * + * // Complex expressions + * .orderBySQL(sql`array_length(tags, 1) DESC, score * ${multiplier} ASC`) + * ``` + */ + orderBySQL(fragment: SQLFragment): this { + this.modifiers.orderByFragment = fragment; + return this; + } + + /** + * Get the current modifiers as QuerySelectionModifiersWithOrderByFragment + */ + protected getModifiers(): QuerySelectionModifiersWithOrderByFragment { + return this.modifiers; + } + + /** + * Get the SQL fragment + */ + protected getSQLFragment(): SQLFragment { + return this.sqlFragment; + } + + /** + * Execute the query and return results. + * Implementation depends on the specific loader type. + */ + public async executeAsync(): Promise { + if (this.executed) { + throw new Error( + 'Query has already been executed. Create a new query builder to execute again.', + ); + } + this.executed = true; + return await this.executeInternalAsync(); + } + + protected abstract executeInternalAsync(): Promise; +} diff --git a/packages/entity-database-adapter-knex/src/EnforcingKnexEntityLoader.ts b/packages/entity-database-adapter-knex/src/EnforcingKnexEntityLoader.ts index fb02c65a0..7005eb312 100644 --- a/packages/entity-database-adapter-knex/src/EnforcingKnexEntityLoader.ts +++ b/packages/entity-database-adapter-knex/src/EnforcingKnexEntityLoader.ts @@ -4,8 +4,11 @@ import { AuthorizationResultBasedKnexEntityLoader } from './AuthorizationResultB import { FieldEqualityCondition, QuerySelectionModifiers, + QuerySelectionModifiersWithOrderByFragment, QuerySelectionModifiersWithOrderByRaw, } from './BasePostgresEntityDatabaseAdapter'; +import { BaseSQLQueryBuilder } from './BaseSQLQueryBuilder'; +import { SQLFragment } from './SQLOperator'; /** * Enforcing knex entity loader for non-data-loader-based load methods. @@ -122,4 +125,83 @@ export class EnforcingKnexEntityLoader< ); return entityResults.map((result) => result.enforceValue()); } + + /** + * Load entities using a SQL query builder. When executed, all queries will enforce authorization and throw if not authorized. + * + * @example + * ```ts + * const entities = await ExampleEntity.loader(vc) + * .loadManyBySQL(sql`age >= ${18} AND status = ${'active'}`) + * .orderBy('createdAt', 'DESC') + * .limit(10) + * .executeAsync(); + * + * const { between, inArray } = SQLFragmentHelpers; + * const filtered = await ExampleEntity.loader(vc) + * .loadManyBySQL( + * sql`${between('age', 18, 65)} AND ${inArray('role', ['admin', 'moderator'])}` + * ) + * .executeAsync(); + * ``` + */ + loadManyBySQL( + fragment: SQLFragment, + modifiers: QuerySelectionModifiersWithOrderByFragment = {}, + ): EnforcingSQLQueryBuilder< + TFields, + TIDField, + TViewerContext, + TEntity, + TPrivacyPolicy, + TSelectedFields + > { + return new EnforcingSQLQueryBuilder(this.knexEntityLoader, fragment, modifiers); + } +} + +/** + * SQL query builder for EnforcingKnexEntityLoader. + * Provides a fluent API for building and executing SQL queries with enforced authorization. + */ +export class EnforcingSQLQueryBuilder< + TFields extends Record, + TIDField extends keyof NonNullable>, + TViewerContext extends ViewerContext, + TEntity extends ReadonlyEntity, + TPrivacyPolicy extends EntityPrivacyPolicy< + TFields, + TIDField, + TViewerContext, + TEntity, + TSelectedFields + >, + TSelectedFields extends keyof TFields, +> extends BaseSQLQueryBuilder { + constructor( + private readonly knexEntityLoader: AuthorizationResultBasedKnexEntityLoader< + TFields, + TIDField, + TViewerContext, + TEntity, + TPrivacyPolicy, + TSelectedFields + >, + sqlFragment: SQLFragment, + modifiers: QuerySelectionModifiersWithOrderByFragment, + ) { + super(sqlFragment, modifiers); + } + + /** + * Execute the query. + * @returns entities matching the query + * @throws EntityNotAuthorizedError if viewer is not authorized to view any entity + */ + async executeInternalAsync(): Promise { + const entityResults = await this.knexEntityLoader + .loadManyBySQL(this.getSQLFragment(), this.getModifiers()) + .executeAsync(); + return entityResults.map((result) => result.enforceValue()); + } } diff --git a/packages/entity-database-adapter-knex/src/PostgresEntityDatabaseAdapter.ts b/packages/entity-database-adapter-knex/src/PostgresEntityDatabaseAdapter.ts index d59f2055c..50d5218cf 100644 --- a/packages/entity-database-adapter-knex/src/PostgresEntityDatabaseAdapter.ts +++ b/packages/entity-database-adapter-knex/src/PostgresEntityDatabaseAdapter.ts @@ -6,9 +6,11 @@ import { TableFieldMultiValueEqualityCondition, TableFieldSingleValueEqualityCondition, TableQuerySelectionModifiers, + TableQuerySelectionModifiersWithOrderByFragment, TableQuerySelectionModifiersWithOrderByRaw, } from './BasePostgresEntityDatabaseAdapter'; import { JSONArrayField, MaybeJSONArrayField } from './EntityFields'; +import { SQLFragment } from './SQLOperator'; import { wrapNativePostgresCallAsync } from './errors/wrapNativePostgresCallAsync'; export class PostgresEntityDatabaseAdapter< @@ -185,6 +187,24 @@ export class PostgresEntityDatabaseAdapter< return await wrapNativePostgresCallAsync(() => query); } + protected async fetchManyBySQLFragmentInternalAsync( + queryInterface: Knex, + tableName: string, + sqlFragment: SQLFragment, + querySelectionModifiers: TableQuerySelectionModifiersWithOrderByFragment, + ): Promise { + let query = queryInterface + .select() + .from(tableName) + .whereRaw(sqlFragment.sql, sqlFragment.getKnexBindings()); + query = this.applyQueryModifiersToQuery(query, querySelectionModifiers); + const { orderByFragment } = querySelectionModifiers; + if (orderByFragment !== undefined) { + query = query.orderByRaw(orderByFragment.sql, orderByFragment.getKnexBindings()); + } + return await wrapNativePostgresCallAsync(() => query); + } + protected async insertInternalAsync( queryInterface: Knex, tableName: string, diff --git a/packages/entity-database-adapter-knex/src/SQLOperator.ts b/packages/entity-database-adapter-knex/src/SQLOperator.ts new file mode 100644 index 000000000..6ffcbf3e7 --- /dev/null +++ b/packages/entity-database-adapter-knex/src/SQLOperator.ts @@ -0,0 +1,478 @@ +/** + * Supported SQL value types that can be safely parameterized. + * This ensures type safety and prevents passing unsupported types to SQL queries. + */ +export type SupportedSQLValue = + | string + | number + | boolean + | null + | Date + | Buffer + | bigint + | undefined // Will be treated as NULL + | readonly SupportedSQLValue[] // For IN clauses and array types + | Readonly<{ [key: string]: unknown }>; // For JSON/JSONB columns + +/** + * Types of bindings that can be used in SQL queries. + */ +export type SQLBinding = + | { type: 'value'; value: SupportedSQLValue } + | { type: 'identifier'; name: string }; + +/** + * SQL Fragment class that safely handles parameterized queries. + */ +export class SQLFragment { + constructor( + public readonly sql: string, + 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. + */ + getKnexBindings(): readonly (string | SupportedSQLValue)[] { + return this.bindings.map((b) => { + if (b.type === 'identifier') { + return b.name; + } else { + return b.value; + } + }); + } + + /** + * Combine SQL fragments + */ + append(other: SQLFragment): SQLFragment { + return SQLFragment.join([this, other], ' '); + } + + /** + * Join multiple SQL fragments into a single fragment with a separator. + * @param fragments - Array of SQL fragments to join + * @param separator - Separator string (default: ', ') + */ + static join(fragments: SQLFragment[], separator = ', '): SQLFragment { + return new SQLFragment( + fragments.map((f) => f.sql).join(separator), + fragments.flatMap((f) => f.bindings), + ); + } + + /** + * Concatenate multiple SQL fragments with space separator. + * Useful for combining SQL clauses like WHERE, ORDER BY, etc. + * + * @example + * ```ts + * const where = sql`WHERE age > ${18}`; + * const orderBy = sql`ORDER BY name`; + * const query = SQLFragment.concat(sql`SELECT * FROM users`, where, orderBy); + * // Generates: "SELECT * FROM users WHERE age > ? ORDER BY name" + * ``` + */ + static concat(...fragments: SQLFragment[]): SQLFragment { + return SQLFragment.join(fragments, ' '); + } + + /** + * Get a debug representation of the query with values inline + * WARNING: This is for debugging only. Never execute the returned string directly. + */ + get toDebugString(): string { + let debugString = this.sql; + let bindingIndex = 0; + + // Replace ?? and ? placeholders with actual values for debugging + debugString = debugString.replace(/\?\?|\?/g, (match) => { + if (bindingIndex >= this.bindings.length) { + return match; + } + const binding = this.bindings[bindingIndex]; + if (!binding) { + return match; + } + bindingIndex++; + + if (match === '??' && binding.type === 'identifier') { + // For identifiers, show them quoted as they would appear + return `"${binding.name.replace(/"/g, '""')}"`; + } else if (match === '?' && binding.type === 'value') { + return SQLFragment.formatDebugValue(binding.value); + } else { + // Mismatch between placeholder type and binding type + return match; + } + }); + + return debugString; + } + + /** + * Format a value for debug output based on its type. + * Handles all SupportedSQLValue types. + */ + private static formatDebugValue(value: SupportedSQLValue): string { + // Handle null and undefined + if (value === null || value === undefined) { + return 'NULL'; + } + + // Handle primitives + if (typeof value === 'string') { + return `'${value.replace(/'/g, "''")}'`; + } + if (typeof value === 'number' || typeof value === 'bigint') { + return String(value); + } + if (typeof value === 'boolean') { + return value ? 'TRUE' : 'FALSE'; + } + + // Handle Date + if (value instanceof Date) { + return `'${value.toISOString()}'`; + } + + // Handle Buffer + if (Buffer.isBuffer(value)) { + return `'\\x${value.toString('hex')}'`; + } + + // Handle arrays (for IN clauses or array columns) + if (Array.isArray(value)) { + return `ARRAY[${value.map((v) => this.formatDebugValue(v)).join(', ')}]`; + } + + // Handle objects (for JSON/JSONB columns) + if (typeof value === 'object' && SQLFragment.isPlainObjectForDebug(value)) { + return `'${JSON.stringify(value).replace(/'/g, "''")}'::jsonb`; + } + + // Fallback (should never reach here with SupportedSQLValue but because this is used + // for debugging, there might be other values that we want to know about) + return `UnsupportedSQLValue[${String(value)}]`; + } + + private static isPlainObjectForDebug(obj: object): boolean { + const proto = Object.getPrototypeOf(obj); + // Ensure it doesn't have a custom prototype (like a class would) + if (proto === null) { + return true; // Created via Object.create(null) + } + // Check if constructor is the base Object function + return proto.constructor === Object; + } +} + +/** + * Helper for SQL identifiers (table/column names). + * Stores the raw identifier name to be escaped by Knex using ?? placeholder. + */ +export class SQLIdentifier { + constructor(public readonly name: string) {} +} + +/** + * Helper for raw SQL that should not be parameterized + * WARNING: Only use this with trusted input to avoid SQL injection + */ +export class SQLRaw { + constructor(public readonly rawSql: string) {} +} + +/** + * 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 + * identifier('users') // Will be escaped as "users" in PostgreSQL + * identifier('my"table') // Will be escaped as "my""table" in PostgreSQL + * identifier('column"; DROP TABLE users; --') // Will be safely escaped + * ``` + */ +export function identifier(name: string): SQLIdentifier { + return new SQLIdentifier(name); +} + +/** + * Insert raw SQL that will not be parameterized + * WARNING: This bypasses SQL injection protection. Only use with trusted input. + * + * @example + * ```ts + * // Dynamic column names + * const sortColumn = 'created_at'; + * const query = sql`ORDER BY ${raw(sortColumn)} DESC`; + * + * // Dynamic SQL expressions + * const query = sql`WHERE ${raw('EXTRACT(year FROM created_at)')} = ${2024}`; + * ``` + */ +export function raw(sqlString: string): SQLRaw { + return new SQLRaw(sqlString); +} + +/** + * Tagged template literal function for SQL queries + * + * @example + * ```ts + * const age = 18; + * const query = sql`age >= ${age} AND status = ${'active'}`; + * ``` + */ +export function sql( + strings: TemplateStringsArray, + ...values: readonly (SupportedSQLValue | SQLFragment | SQLIdentifier | SQLRaw)[] +): SQLFragment { + let sqlString = ''; + const bindings: SQLBinding[] = []; + + strings.forEach((string, i) => { + sqlString += string; + if (i < values.length) { + const value = values[i]; + + if (value instanceof SQLFragment) { + // Handle nested SQL fragments + sqlString += value.sql; + bindings.push(...value.bindings); + } else if (value instanceof SQLIdentifier) { + // Handle identifiers (table/column names) with ?? placeholder + sqlString += '??'; + bindings.push({ type: 'identifier', name: value.name }); + } else if (value instanceof SQLRaw) { + // 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 }))); + } else { + // Regular value binding + sqlString += '?'; + bindings.push({ type: 'value', value }); + } + } + }); + + return new SQLFragment(sqlString, bindings); +} + +/** + * Common SQL helper functions for building queries + */ +export const SQLFragmentHelpers = { + /** + * IN clause helper + * + * @example + * ```ts + * const query = SQLFragmentHelpers.inArray('status', ['active', 'pending']); + * // Generates: ?? IN (?, ?) with bindings ['status', 'active', 'pending'] + * ``` + */ + inArray(column: string, values: readonly T[]): 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[]}`; + }, + + /** + * NOT IN clause helper + */ + notInArray(column: string, values: readonly T[]): 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[]}`; + }, + + /** + * BETWEEN helper + * + * @example + * ```ts + * const query = SQLFragmentHelpers.between('age', 18, 65); + * // Generates: "age" BETWEEN ? AND ? with values [18, 65] + * ``` + */ + between(column: string, min: T, max: T): SQLFragment { + return sql`${identifier(column)} BETWEEN ${min} AND ${max}`; + }, + + /** + * NOT BETWEEN helper + */ + notBetween(column: string, min: T, max: T): SQLFragment { + return sql`${identifier(column)} NOT BETWEEN ${min} AND ${max}`; + }, + + /** + * LIKE helper with automatic escaping + * + * @example + * ```ts + * const query = SQLFragmentHelpers.like('name', '%John%'); + * // Generates: "name" LIKE ? with value '%John%' + * ``` + */ + like(column: string, pattern: string): SQLFragment { + return sql`${identifier(column)} LIKE ${pattern}`; + }, + + /** + * NOT LIKE helper + */ + notLike(column: string, pattern: string): SQLFragment { + return sql`${identifier(column)} NOT LIKE ${pattern}`; + }, + + /** + * ILIKE helper for case-insensitive matching + */ + ilike(column: string, pattern: string): SQLFragment { + return sql`${identifier(column)} ILIKE ${pattern}`; + }, + + /** + * NOT ILIKE helper for case-insensitive non-matching + */ + notIlike(column: string, pattern: string): SQLFragment { + return sql`${identifier(column)} NOT ILIKE ${pattern}`; + }, + + /** + * NULL check helper + */ + isNull(column: string): SQLFragment { + return sql`${identifier(column)} IS NULL`; + }, + + /** + * NOT NULL check helper + */ + isNotNull(column: string): SQLFragment { + return sql`${identifier(column)} IS NOT NULL`; + }, + + /** + * Single-equals-equality operator + */ + eq(column: string, value: SupportedSQLValue): SQLFragment { + if (value === null || value === undefined) { + return SQLFragmentHelpers.isNull(column); + } + return sql`${identifier(column)} = ${value}`; + }, + + /** + * Single-equals-inequality operator + */ + neq(column: string, value: SupportedSQLValue): SQLFragment { + if (value === null || value === undefined) { + return SQLFragmentHelpers.isNotNull(column); + } + return sql`${identifier(column)} != ${value}`; + }, + + /** + * Greater-than comparison operator + */ + gt(column: string, value: SupportedSQLValue): SQLFragment { + return sql`${identifier(column)} > ${value}`; + }, + + /** + * Greater-than-or-equal-to comparison operator + */ + gte(column: string, value: SupportedSQLValue): SQLFragment { + return sql`${identifier(column)} >= ${value}`; + }, + + /** + * Less-than comparison operator + */ + lt(column: string, value: SupportedSQLValue): SQLFragment { + return sql`${identifier(column)} < ${value}`; + }, + + /** + * Less-than-or-equal-to comparison operator + */ + lte(column: string, value: SupportedSQLValue): SQLFragment { + return sql`${identifier(column)} <= ${value}`; + }, + + /** + * JSON contains operator (\@\>) + */ + jsonContains(column: string, value: unknown): SQLFragment { + return sql`${identifier(column)} @> ${JSON.stringify(value)}::jsonb`; + }, + + /** + * JSON contained by operator (\<\@\) + */ + jsonContainedBy(column: string, value: unknown): SQLFragment { + return sql`${identifier(column)} <@ ${JSON.stringify(value)}::jsonb`; + }, + + /** + * JSON path extraction helper (-\>) + */ + jsonPath(column: string, path: string): SQLFragment { + return new SQLFragment(`"${column}"->'${path}'`, []); + }, + + /** + * JSON path text extraction helper (-\>\>) + */ + jsonPathText(column: string, path: string): SQLFragment { + return new SQLFragment(`"${column}"->>'${path}'`, []); + }, + + /** + * Logical AND of multiple fragments + */ + and(...conditions: SQLFragment[]): SQLFragment { + if (conditions.length === 0) { + return sql`1 = 1`; + } + return SQLFragment.join(conditions, ' AND '); + }, + + /** + * Logical OR of multiple fragments + */ + or(...conditions: SQLFragment[]): SQLFragment { + if (conditions.length === 0) { + return sql`1 = 0`; + } + return SQLFragment.join(conditions, ' OR '); + }, + + /** + * Logical NOT of a fragment + */ + not(condition: SQLFragment): SQLFragment { + return new SQLFragment('NOT (' + condition.sql + ')', condition.bindings); + }, + + /** + * Parentheses helper for grouping conditions + */ + group(condition: SQLFragment): SQLFragment { + return new SQLFragment('(' + condition.sql + ')', condition.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 ed49751a6..4200f30a8 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 { raw, sql, SQLFragment, SQLFragmentHelpers } from '../SQLOperator'; import { PostgresTestEntity } from '../__testfixtures__/PostgresTestEntity'; import { PostgresTriggerTestEntity } from '../__testfixtures__/PostgresTriggerTestEntity'; import { PostgresValidatorTestEntity } from '../__testfixtures__/PostgresValidatorTestEntity'; @@ -362,6 +363,484 @@ describe('postgres entity integration', () => { expect(results2.get(false)).toHaveLength(1); }); + describe('SQL operator loading with loadManyBySQL', () => { + it('supports basic SQL template literal queries', async () => { + const vc1 = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance)); + + await enforceAsyncResult( + PostgresTestEntity.creatorWithAuthorizationResults(vc1) + .setField('name', 'Alice') + .setField('hasACat', true) + .setField('hasADog', false) + .createAsync(), + ); + + await enforceAsyncResult( + PostgresTestEntity.creatorWithAuthorizationResults(vc1) + .setField('name', 'Bob') + .setField('hasACat', false) + .setField('hasADog', true) + .createAsync(), + ); + + await enforceAsyncResult( + PostgresTestEntity.creatorWithAuthorizationResults(vc1) + .setField('name', 'Charlie') + .setField('hasACat', true) + .setField('hasADog', true) + .createAsync(), + ); + + // Test basic SQL query with parameters + const catOwners = await PostgresTestEntity.knexLoader(vc1) + .loadManyBySQL(sql`has_a_cat = ${true}`) + .orderBy('name', OrderByOrdering.ASCENDING) + .executeAsync(); + + expect(catOwners).toHaveLength(2); + expect(catOwners[0]!.getField('name')).toBe('Alice'); + expect(catOwners[1]!.getField('name')).toBe('Charlie'); + + // Test with limit and offset + const limitedResults = await PostgresTestEntity.knexLoader(vc1) + .loadManyBySQL(sql`has_a_cat = ${true}`) + .orderBy('name', OrderByOrdering.ASCENDING) + .limit(1) + .offset(1) + .executeAsync(); + + expect(limitedResults).toHaveLength(1); + expect(limitedResults[0]!.getField('name')).toBe('Charlie'); + }); + + it('supports SQL helper functions', async () => { + const vc1 = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance)); + const { and, or, eq, neq, inArray } = SQLFragmentHelpers; + + await enforceAsyncResult( + PostgresTestEntity.creatorWithAuthorizationResults(vc1) + .setField('name', 'User1') + .setField('hasACat', true) + .setField('hasADog', false) + .createAsync(), + ); + + await enforceAsyncResult( + PostgresTestEntity.creatorWithAuthorizationResults(vc1) + .setField('name', 'User2') + .setField('hasACat', false) + .setField('hasADog', true) + .createAsync(), + ); + + await enforceAsyncResult( + PostgresTestEntity.creatorWithAuthorizationResults(vc1) + .setField('name', 'User3') + .setField('hasACat', true) + .setField('hasADog', true) + .createAsync(), + ); + + // Test AND condition + const bothPets = await PostgresTestEntity.knexLoader(vc1) + .loadManyBySQL(and(eq('has_a_cat', true), eq('has_a_dog', true))) + .executeAsync(); + + expect(bothPets).toHaveLength(1); + expect(bothPets[0]!.getField('name')).toBe('User3'); + + // Test OR condition + const eitherPet = await PostgresTestEntity.knexLoader(vc1) + .loadManyBySQL(or(eq('has_a_cat', false), eq('has_a_dog', false))) + .orderBy('name', OrderByOrdering.ASCENDING) + .executeAsync(); + + expect(eitherPet).toHaveLength(2); + expect(eitherPet[0]!.getField('name')).toBe('User1'); + expect(eitherPet[1]!.getField('name')).toBe('User2'); + + // Test IN array + const specificUsers = await PostgresTestEntity.knexLoader(vc1) + .loadManyBySQL(inArray('name', ['User1', 'User3'])) + .orderBy('name', OrderByOrdering.ASCENDING) + .executeAsync(); + + expect(specificUsers).toHaveLength(2); + expect(specificUsers[0]!.getField('name')).toBe('User1'); + expect(specificUsers[1]!.getField('name')).toBe('User3'); + + // 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'))) + .orderBy('name', OrderByOrdering.ASCENDING) + .executeAsync(); + + expect(complexQuery).toHaveLength(2); + expect(complexQuery[0]!.getField('name')).toBe('User1'); + expect(complexQuery[1]!.getField('name')).toBe('User3'); + }); + + it('supports executeFirstAsync', async () => { + const vc1 = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance)); + + await enforceAsyncResult( + PostgresTestEntity.creatorWithAuthorizationResults(vc1) + .setField('name', 'First') + .setField('hasACat', true) + .createAsync(), + ); + + await enforceAsyncResult( + PostgresTestEntity.creatorWithAuthorizationResults(vc1) + .setField('name', 'Second') + .setField('hasACat', true) + .createAsync(), + ); + + const firstCatOwnerLimit1 = await PostgresTestEntity.knexLoader(vc1) + .loadManyBySQL(sql`has_a_cat = ${true}`) + .orderBy('name', OrderByOrdering.ASCENDING) + .limit(1) + .executeAsync(); + + expect(firstCatOwnerLimit1).toHaveLength(1); + expect(firstCatOwnerLimit1[0]?.getField('name')).toBe('First'); + + // Test executeFirstAsync with no results + const noDogOwnerLimit1 = await PostgresTestEntity.knexLoader(vc1) + .loadManyBySQL(sql`has_a_dog = ${true}`) + .limit(1) + .executeAsync(); + + expect(noDogOwnerLimit1).toHaveLength(0); + }); + + it('supports authorization result-based loading', async () => { + const vc1 = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance)); + + await enforceAsyncResult( + PostgresTestEntity.creatorWithAuthorizationResults(vc1) + .setField('name', 'AuthTest1') + .setField('hasACat', true) + .createAsync(), + ); + + await enforceAsyncResult( + PostgresTestEntity.creatorWithAuthorizationResults(vc1) + .setField('name', 'AuthTest2') + .setField('hasACat', false) + .createAsync(), + ); + + // Test with authorization results + const results = await PostgresTestEntity.knexLoaderWithAuthorizationResults(vc1) + .loadManyBySQL(sql`name LIKE ${'AuthTest%'}`) + .orderBy('name', OrderByOrdering.ASCENDING) + .executeAsync(); + + expect(results).toHaveLength(2); + expect(results[0]!.ok).toBe(true); + expect(results[1]!.ok).toBe(true); + + if (results[0]!.ok) { + expect(results[0]!.value.getField('name')).toBe('AuthTest1'); + } + if (results[1]!.ok) { + expect(results[1]!.value.getField('name')).toBe('AuthTest2'); + } + + const firstResultLimit1 = await PostgresTestEntity.knexLoaderWithAuthorizationResults(vc1) + .loadManyBySQL(sql`has_a_cat = ${false}`) + .limit(1) + .executeAsync(); + + expect(firstResultLimit1).toHaveLength(1); + const firstResult = firstResultLimit1[0]; + expect(firstResult?.ok).toBe(true); + if (firstResult?.ok) { + expect(firstResult.value.getField('name')).toBe('AuthTest2'); + } + }); + + it('supports raw SQL for dynamic queries', async () => { + const vc1 = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance)); + + await enforceAsyncResult( + PostgresTestEntity.creatorWithAuthorizationResults(vc1) + .setField('name', 'RawTest1') + .setField('hasACat', true) + .setField('hasADog', false) + .createAsync(), + ); + await enforceAsyncResult( + PostgresTestEntity.creatorWithAuthorizationResults(vc1) + .setField('name', 'RawTest2') + .setField('hasACat', false) + .setField('hasADog', true) + .createAsync(), + ); + await enforceAsyncResult( + PostgresTestEntity.creatorWithAuthorizationResults(vc1) + .setField('name', 'RawTest3') + .setField('hasACat', true) + .setField('hasADog', true) + .createAsync(), + ); + + // Test raw SQL for dynamic column names with orderBySQL + const sortColumn = 'name'; + const rawResults = await PostgresTestEntity.knexLoader(vc1) + .loadManyBySQL(sql`${raw('name')} LIKE ${'RawTest%'}`) + .orderBySQL(sql`${raw(sortColumn)} DESC`) + .executeAsync(); + + expect(rawResults).toHaveLength(3); + expect(rawResults[0]!.getField('name')).toBe('RawTest3'); + expect(rawResults[1]!.getField('name')).toBe('RawTest2'); + expect(rawResults[2]!.getField('name')).toBe('RawTest1'); + + // Test complex ORDER BY with CASE statement + const priorityResults = await PostgresTestEntity.knexLoader(vc1) + .loadManyBySQL(sql`name LIKE ${'RawTest%'}`) + .orderBySQL( + sql`CASE + WHEN has_a_cat = true AND has_a_dog = true THEN 0 + WHEN has_a_cat = true THEN 1 + ELSE 2 + END, ${raw('name')} ASC`, + ) + .executeAsync(); + + expect(priorityResults).toHaveLength(3); + expect(priorityResults[0]!.getField('name')).toBe('RawTest3'); // has both + expect(priorityResults[1]!.getField('name')).toBe('RawTest1'); // has cat only + expect(priorityResults[2]!.getField('name')).toBe('RawTest2'); // has dog only + + // Test raw SQL with complex expressions - using CASE statement + const complexExpression = await PostgresTestEntity.knexLoader(vc1) + .loadManyBySQL( + sql`${raw('CASE WHEN has_a_cat THEN 1 ELSE 0 END')} + ${raw( + 'CASE WHEN has_a_dog THEN 1 ELSE 0 END', + )} >= 1 AND name LIKE ${'RawTest%'}`, + ) + .executeAsync(); + + expect(complexExpression).toHaveLength(3); + }); + + it('supports join helper for building complex queries', async () => { + const vc1 = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance)); + + await enforceAsyncResult( + PostgresTestEntity.creatorWithAuthorizationResults(vc1) + .setField('name', 'JoinTest1') + .setField('hasACat', true) + .setField('hasADog', false) + .createAsync(), + ); + await enforceAsyncResult( + PostgresTestEntity.creatorWithAuthorizationResults(vc1) + .setField('name', 'JoinTest2') + .setField('hasACat', true) + .setField('hasADog', true) + .createAsync(), + ); + + // Test join with OR conditions + const conditions = [ + sql`name = ${'JoinTest1'}`, + sql`(has_a_cat = ${true} AND has_a_dog = ${true})`, + ]; + const joinedResults = await PostgresTestEntity.knexLoader(vc1) + .loadManyBySQL(SQLFragment.join(conditions, ' OR ')) + .orderBy('name', OrderByOrdering.ASCENDING) + .executeAsync(); + + expect(joinedResults).toHaveLength(2); + expect(joinedResults[0]!.getField('name')).toBe('JoinTest1'); + expect(joinedResults[1]!.getField('name')).toBe('JoinTest2'); + }); + + it('provides debug text for SQL queries', async () => { + // Create a SQL fragment with various types of values + const fragment = sql`name = ${'TestUser'} AND has_a_cat = ${true} AND age > ${18} AND data = ${{ + key: 'value', + }} AND created_at > ${new Date('2024-01-01')}`; + + // Get the debug text + const debugText = fragment.toDebugString; + + // Verify the text contains properly formatted values + expect(debugText).toContain("'TestUser'"); + expect(debugText).toContain('TRUE'); + expect(debugText).toContain('18'); + expect(debugText).toContain('{"key":"value"}'); + expect(debugText).toContain('2024-01-01'); + + // Ensure it's still a valid query (though we wouldn't execute the text directly) + expect(debugText).toMatch( + /^name = .* AND has_a_cat = .* AND age > .* AND data = .* AND created_at > .*$/, + ); + }); + + it('supports orderBySQL for type-safe dynamic ordering', async () => { + const vc1 = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance)); + + // Create test entities with different combinations of fields + await enforceAsyncResult( + PostgresTestEntity.creatorWithAuthorizationResults(vc1) + .setField('name', 'OrderTest1') + .setField('hasACat', true) + .setField('hasADog', false) + .setField('stringArray', ['a', 'b', 'c']) + .createAsync(), + ); + await enforceAsyncResult( + PostgresTestEntity.creatorWithAuthorizationResults(vc1) + .setField('name', 'OrderTest2') + .setField('hasACat', false) + .setField('hasADog', true) + .setField('stringArray', ['x', 'y']) + .createAsync(), + ); + await enforceAsyncResult( + PostgresTestEntity.creatorWithAuthorizationResults(vc1) + .setField('name', 'OrderTest3') + .setField('hasACat', true) + .setField('hasADog', true) + .setField('stringArray', null) + .createAsync(), + ); + await enforceAsyncResult( + PostgresTestEntity.creatorWithAuthorizationResults(vc1) + .setField('name', 'OrderTest4') + .setField('hasACat', false) + .setField('hasADog', false) + .setField('stringArray', ['m']) + .createAsync(), + ); + + // Test 1: Simple orderBySQL with raw column + const simpleOrder = await PostgresTestEntity.knexLoader(vc1) + .loadManyBySQL(sql`name LIKE ${'OrderTest%'}`) + .orderBySQL(sql`${raw('name')} DESC`) + .executeAsync(); + + expect(simpleOrder).toHaveLength(4); + expect(simpleOrder[0]!.getField('name')).toBe('OrderTest4'); + expect(simpleOrder[1]!.getField('name')).toBe('OrderTest3'); + expect(simpleOrder[2]!.getField('name')).toBe('OrderTest2'); + expect(simpleOrder[3]!.getField('name')).toBe('OrderTest1'); + + // Test 2: Complex CASE statement ordering with parameterized values + const priority1 = 1; + const priority2 = 2; + const priority3 = 3; + const priority4 = 4; + const caseOrder = await PostgresTestEntity.knexLoader(vc1) + .loadManyBySQL(sql`name LIKE ${'OrderTest%'}`) + .orderBySQL( + sql`CASE + WHEN has_a_cat = true AND has_a_dog = true THEN ${priority1} + WHEN has_a_cat = true THEN ${priority2} + WHEN has_a_dog = true THEN ${priority3} + ELSE ${priority4} + END, ${raw('name')} ASC`, + ) + .executeAsync(); + + expect(caseOrder).toHaveLength(4); + expect(caseOrder[0]!.getField('name')).toBe('OrderTest3'); // Both pets = 1 + expect(caseOrder[1]!.getField('name')).toBe('OrderTest1'); // Cat only = 2 + expect(caseOrder[2]!.getField('name')).toBe('OrderTest2'); // Dog only = 3 + expect(caseOrder[3]!.getField('name')).toBe('OrderTest4'); // Neither = 4 + + // Test 3: Order by array length (PostgreSQL specific) + const arrayLengthOrder = await PostgresTestEntity.knexLoader(vc1) + .loadManyBySQL(sql`name LIKE ${'OrderTest%'}`) + .orderBySQL(sql`COALESCE(array_length(string_array, 1), 0) DESC, ${raw('name')} ASC`) + .executeAsync(); + + expect(arrayLengthOrder).toHaveLength(4); + expect(arrayLengthOrder[0]!.getField('name')).toBe('OrderTest1'); // 3 elements + expect(arrayLengthOrder[1]!.getField('name')).toBe('OrderTest2'); // 2 elements + expect(arrayLengthOrder[2]!.getField('name')).toBe('OrderTest4'); // 1 element + expect(arrayLengthOrder[3]!.getField('name')).toBe('OrderTest3'); // null = 0 + + // Test 4: Multiple orderBySQL calls (last one wins) + const multipleOrderBy = await PostgresTestEntity.knexLoader(vc1) + .loadManyBySQL(sql`name LIKE ${'OrderTest%'}`) + .orderBySQL(sql`${raw('name')} DESC`) // This will be overridden + .orderBySQL(sql`${raw('name')} ASC`) // This one wins + .executeAsync(); + + expect(multipleOrderBy).toHaveLength(4); + expect(multipleOrderBy[0]!.getField('name')).toBe('OrderTest1'); + expect(multipleOrderBy[3]!.getField('name')).toBe('OrderTest4'); + + // Test 5: Combining orderBySQL with limit and offset + const limitedOrder = await PostgresTestEntity.knexLoader(vc1) + .loadManyBySQL(sql`name LIKE ${'OrderTest%'}`) + .orderBySQL(sql`${raw('name')} ASC`) + .limit(2) + .offset(1) + .executeAsync(); + + expect(limitedOrder).toHaveLength(2); + expect(limitedOrder[0]!.getField('name')).toBe('OrderTest2'); + expect(limitedOrder[1]!.getField('name')).toBe('OrderTest3'); + + // Test 6: orderBySQL with NULLS FIRST/LAST + const nullsOrder = await PostgresTestEntity.knexLoader(vc1) + .loadManyBySQL(sql`name LIKE ${'OrderTest%'}`) + .orderBySQL(sql`string_array IS NULL, ${raw('name')} ASC`) + .executeAsync(); + + expect(nullsOrder).toHaveLength(4); + expect(nullsOrder[0]!.getField('stringArray')).not.toBeNull(); // false comes first (not null) + expect(nullsOrder[3]!.getField('stringArray')).toBeNull(); // true comes last (is null) + }); + + it('throws error on multiple executeAsync calls', async () => { + const vc1 = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance)); + + // Create a test entity + await PostgresTestEntity.creator(vc1) + .setField('name', 'MultiExecTest') + .setField('hasACat', true) + .createAsync(); + + // Create a query builder + const queryBuilder = PostgresTestEntity.knexLoader(vc1).loadManyBySQL( + sql`name = ${'MultiExecTest'}`, + ); + + // First execution should succeed + const firstResult = await queryBuilder.executeAsync(); + expect(firstResult).toHaveLength(1); + expect(firstResult[0]!.getField('name')).toBe('MultiExecTest'); + + // Second execution should throw + await expect(queryBuilder.executeAsync()).rejects.toThrow( + 'Query has already been executed. Create a new query builder to execute again.', + ); + + // Third execution should also throw + await expect(queryBuilder.executeAsync()).rejects.toThrow( + 'Query has already been executed. Create a new query builder to execute again.', + ); + + // A new query builder should work fine + const newQueryBuilder = PostgresTestEntity.knexLoader(vc1).loadManyBySQL( + sql`name = ${'MultiExecTest'}`, + ); + + const newResult = await newQueryBuilder.executeAsync(); + expect(newResult).toHaveLength(1); + expect(newResult[0]!.getField('name')).toBe('MultiExecTest'); + }); + }); + describe('conjunction field equality loading', () => { it('supports single fieldValue and multiple fieldValues', async () => { const vc1 = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance)); diff --git a/packages/entity-database-adapter-knex/src/__tests__/BasePostgresEntityDatabaseAdapter-test.ts b/packages/entity-database-adapter-knex/src/__tests__/BasePostgresEntityDatabaseAdapter-test.ts index 0ce770df5..2264ef2c9 100644 --- a/packages/entity-database-adapter-knex/src/__tests__/BasePostgresEntityDatabaseAdapter-test.ts +++ b/packages/entity-database-adapter-knex/src/__tests__/BasePostgresEntityDatabaseAdapter-test.ts @@ -18,6 +18,7 @@ class TestEntityDatabaseAdapter extends BasePostgresEntityDatabaseAdapter< private readonly updateResults: object[]; private readonly fetchEqualityConditionResults: object[]; private readonly fetchRawWhereResults: object[]; + private readonly fetchSQLFragmentResults: object[]; private readonly deleteCount: number; constructor({ @@ -26,6 +27,7 @@ class TestEntityDatabaseAdapter extends BasePostgresEntityDatabaseAdapter< updateResults = [], fetchEqualityConditionResults = [], fetchRawWhereResults = [], + fetchSQLFragmentResults = [], deleteCount = 0, }: { fetchResults?: object[]; @@ -33,6 +35,7 @@ class TestEntityDatabaseAdapter extends BasePostgresEntityDatabaseAdapter< updateResults?: object[]; fetchEqualityConditionResults?: object[]; fetchRawWhereResults?: object[]; + fetchSQLFragmentResults?: object[]; deleteCount?: number; }) { super(testEntityConfiguration); @@ -41,6 +44,7 @@ class TestEntityDatabaseAdapter extends BasePostgresEntityDatabaseAdapter< this.updateResults = updateResults; this.fetchEqualityConditionResults = fetchEqualityConditionResults; this.fetchRawWhereResults = fetchRawWhereResults; + this.fetchSQLFragmentResults = fetchSQLFragmentResults; this.deleteCount = deleteCount; } @@ -66,6 +70,14 @@ class TestEntityDatabaseAdapter extends BasePostgresEntityDatabaseAdapter< return this.fetchRawWhereResults; } + protected async fetchManyBySQLFragmentInternalAsync( + _queryInterface: any, + _tableName: string, + _sqlFragment: any, + ): Promise { + return this.fetchSQLFragmentResults; + } + protected async fetchManyByFieldEqualityConjunctionInternalAsync( _queryInterface: any, _tableName: string, diff --git a/packages/entity-database-adapter-knex/src/__tests__/SQLOperator-test.ts b/packages/entity-database-adapter-knex/src/__tests__/SQLOperator-test.ts new file mode 100644 index 000000000..6bdd6a38e --- /dev/null +++ b/packages/entity-database-adapter-knex/src/__tests__/SQLOperator-test.ts @@ -0,0 +1,747 @@ +import { describe, expect, it } from '@jest/globals'; + +import { + identifier, + raw, + sql, + SQLFragment, + SQLFragmentHelpers, + SQLIdentifier, +} from '../SQLOperator'; + +describe('SQLOperator', () => { + describe('sql template literal', () => { + it('handles basic parameterized queries', () => { + const age = 18; + const status = 'active'; + const fragment = sql`age >= ${age} AND status = ${status}`; + + expect(fragment.sql).toBe('age >= ? AND status = ?'); + expect(fragment.getKnexBindings()).toEqual([18, 'active']); + }); + + it('handles nested SQL fragments', () => { + const condition1 = sql`age >= ${18}`; + const condition2 = sql`status = ${'active'}`; + const combined = sql`${condition1} AND ${condition2}`; + + expect(combined.sql).toBe('age >= ? AND status = ?'); + expect(combined.getKnexBindings()).toEqual([18, 'active']); + }); + + it('handles SQL identifiers', () => { + const columnName = 'user_name'; + const fragment = sql`${identifier(columnName)} = ${'John'}`; + + expect(fragment.sql).toBe('?? = ?'); + expect(fragment.getKnexBindings()).toEqual(['user_name', 'John']); + }); + + it('handles arrays for IN clauses', () => { + const values = ['active', 'pending', 'approved']; + const fragment = sql`status IN ${values}`; + + expect(fragment.sql).toBe('status IN (?, ?, ?)'); + expect(fragment.getKnexBindings()).toEqual(['active', 'pending', 'approved']); + }); + + it('handles null values', () => { + const fragment = sql`field = ${null}`; + + expect(fragment.sql).toBe('field = ?'); + expect(fragment.getKnexBindings()).toEqual([null]); + }); + + it('handles empty strings', () => { + const fragment = sql`field = ${''}`; + + expect(fragment.sql).toBe('field = ?'); + expect(fragment.getKnexBindings()).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]); + }); + + 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]); + }); + + it('handles raw SQL', () => { + const columnName = 'created_at'; + const fragment = sql`ORDER BY ${raw(columnName)} DESC`; + + expect(fragment.sql).toBe('ORDER BY created_at DESC'); + expect(fragment.getKnexBindings()).toEqual([]); + }); + + it('handles complex raw SQL expressions', () => { + const fragment = sql`WHERE ${raw('EXTRACT(year FROM created_at)')} = ${2024}`; + + expect(fragment.sql).toBe('WHERE EXTRACT(year FROM created_at) = ?'); + expect(fragment.getKnexBindings()).toEqual([2024]); + }); + + it('combines raw SQL with regular parameters', () => { + const sortColumn = 'name'; + const fragment = sql`SELECT * FROM users WHERE age > ${18} ORDER BY ${raw(sortColumn)} ${raw('DESC')}`; + + expect(fragment.sql).toBe('SELECT * FROM users WHERE age > ? ORDER BY name DESC'); + expect(fragment.getKnexBindings()).toEqual([18]); + }); + }); + + describe(SQLFragment, () => { + describe(SQLFragment.prototype.append, () => { + it('appends fragments correctly', () => { + const fragment1 = new SQLFragment('age >= ?', [{ type: 'value', value: 18 }]); + const fragment2 = new SQLFragment('status = ?', [{ type: 'value', value: 'active' }]); + const combined = fragment1.append(fragment2); + + expect(combined.sql).toBe('age >= ? status = ?'); + expect(combined.getKnexBindings()).toEqual([18, 'active']); + }); + }); + + describe(SQLFragment.join, () => { + it('joins fragments with custom separator', () => { + const fragments = [ + new SQLFragment('name = ?', [{ type: 'value', value: 'Alice' }]), + new SQLFragment('age = ?', [{ type: 'value', value: 30 }]), + new SQLFragment('city = ?', [{ type: 'value', value: 'NYC' }]), + ]; + const joined = SQLFragment.join(fragments, ' AND '); + + expect(joined.sql).toBe('name = ? AND age = ? AND city = ?'); + expect(joined.getKnexBindings()).toEqual(['Alice', 30, 'NYC']); + }); + + it('handles empty array in join', () => { + const joined = SQLFragment.join([]); + + expect(joined.sql).toBe(''); + expect(joined.getKnexBindings()).toEqual([]); + }); + + it('joins SQL fragments with default separator', () => { + const columns = [sql`name`, sql`age`, sql`email`]; + const joined = SQLFragment.join(columns); + + expect(joined.sql).toBe('name, age, email'); + expect(joined.getKnexBindings()).toEqual([]); + }); + + it('joins SQL fragments with custom separator', () => { + const conditions = [sql`age > ${18}`, sql`status = ${'active'}`, sql`verified = ${true}`]; + const joined = SQLFragment.join(conditions, ' AND '); + + expect(joined.sql).toBe('age > ? AND status = ? AND verified = ?'); + expect(joined.getKnexBindings()).toEqual([18, 'active', true]); + }); + + it('handles single fragment', () => { + const single = [sql`name = ${'Alice'}`]; + const joined = SQLFragment.join(single); + + expect(joined.sql).toBe('name = ?'); + expect(joined.getKnexBindings()).toEqual(['Alice']); + }); + }); + + describe(SQLFragment.concat, () => { + it('concatenates fragments with space separator', () => { + const select = new SQLFragment('SELECT * FROM users', []); + const where = new SQLFragment('WHERE age > ?', [{ type: 'value', value: 18 }]); + const orderBy = new SQLFragment('ORDER BY name', []); + + const concatenated = SQLFragment.concat(select, where, orderBy); + + expect(concatenated.sql).toBe('SELECT * FROM users WHERE age > ? ORDER BY name'); + expect(concatenated.getKnexBindings()).toEqual([18]); + }); + + it('handles single fragment in concat', () => { + const fragment = new SQLFragment('SELECT * FROM users', []); + const concatenated = SQLFragment.concat(fragment); + + expect(concatenated.sql).toBe('SELECT * FROM users'); + expect(concatenated.getKnexBindings()).toEqual([]); + }); + + it('handles empty concat', () => { + const concatenated = SQLFragment.concat(); + + expect(concatenated.sql).toBe(''); + expect(concatenated.getKnexBindings()).toEqual([]); + }); + + it('supports dynamic query building with concat', () => { + // Build a query dynamically + const fragments: SQLFragment[] = [sql`SELECT * FROM products`]; + + // Conditionally add WHERE clause + const filters: SQLFragment[] = []; + filters.push(sql`price > ${100}`); + filters.push(sql`category = ${'electronics'}`); + + if (filters.length > 0) { + fragments.push(sql`WHERE ${SQLFragment.join(filters, ' AND ')}`); + } + + // Add ORDER BY + fragments.push(sql`ORDER BY created_at DESC`); + + // Add LIMIT + fragments.push(sql`LIMIT ${10}`); + + const query = SQLFragment.concat(...fragments); + + expect(query.sql).toBe( + 'SELECT * FROM products WHERE price > ? AND category = ? ORDER BY created_at DESC LIMIT ?', + ); + expect(query.getKnexBindings()).toEqual([100, 'electronics', 10]); + }); + }); + + describe('toDebugString', () => { + it('generates debug text with values inline', () => { + const fragment = new SQLFragment( + 'SELECT * FROM users WHERE name = ? AND age > ? AND active = ? AND created_at > ?', + [ + { type: 'value', value: 'Alice' }, + { type: 'value', value: 18 }, + { type: 'value', value: true }, + { type: 'value', value: new Date('2024-01-01') }, + ], + ); + + const text = fragment.toDebugString; + expect(text).toContain("'Alice'"); + expect(text).toContain('18'); + expect(text).toContain('TRUE'); + expect(text).toContain('2024-01-01'); + }); + + it('handles null and special characters in text', () => { + const fragment = new SQLFragment('name = ? AND email = ? AND data = ?', [ + { type: 'value', value: null }, + { type: 'value', value: "O'Reilly" }, + { type: 'value', value: { key: 'value' } }, + ]); + + const text = fragment.toDebugString; + expect(text).toContain('NULL'); + expect(text).toContain("O''Reilly"); // SQL escaped single quote + expect(text).toContain(`'{"key":"value"}'::jsonb`); + }); + + it('handles all SupportedSQLValue types in toDebugString', () => { + const fragment = new SQLFragment('INSERT INTO test VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', [ + { type: 'value', value: 'string' }, + { type: 'value', value: 123 }, + { type: 'value', value: true }, + { type: 'value', value: null }, + { type: 'value', value: undefined }, + { type: 'value', value: new Date('2024-01-01T00:00:00.000Z') }, + { type: 'value', value: Buffer.from('hello') }, + { type: 'value', value: BigInt(999) }, + { type: 'value', value: [1, 2, 3] }, + ]); + + const text = fragment.toDebugString; + expect(text).toBe( + "INSERT INTO test VALUES ('string', 123, TRUE, NULL, NULL, '2024-01-01T00:00:00.000Z', '\\x68656c6c6f', 999, ARRAY[1, 2, 3])", + ); + }); + + it('handles nested arrays in toDebugString', () => { + const fragment = new SQLFragment('SELECT * FROM test WHERE tags = ?', [ + { type: 'value', value: ['tag1', 'tag2', null] }, + ]); + + const text = fragment.toDebugString; + expect(text).toBe("SELECT * FROM test WHERE tags = ARRAY['tag1', 'tag2', NULL]"); + }); + + it('handles mismatched placeholders and values gracefully', () => { + const fragment = new SQLFragment('SELECT * FROM test WHERE field1 = ? AND field2 = ?', [ + { type: 'value', value: 'value1' }, + ]); + + const text = fragment.toDebugString; + expect(text).toBe("SELECT * FROM test WHERE field1 = 'value1' AND field2 = ?"); + }); + + it('handles non-SupportedSQLValue types gracefully', () => { + const fragment = new SQLFragment('SELECT * FROM test WHERE field = ? AND field2 = ?', [ + { type: 'value', value: new Error('wat') as any }, + { type: 'value', value: Object.create(null) }, + ]); + + const text = fragment.toDebugString; + expect(text).toBe( + `SELECT * FROM test WHERE field = UnsupportedSQLValue[Error: wat] AND field2 = '{}'::jsonb`, + ); + }); + + it('handles identifiers in toDebugString', () => { + const fragment = new SQLFragment('SELECT ?? FROM ?? WHERE ?? = ?', [ + { type: 'identifier', name: 'user_name' }, + { type: 'identifier', name: 'users' }, + { type: 'identifier', name: 'status' }, + { type: 'value', value: 'active' }, + ]); + + const text = fragment.toDebugString; + expect(text).toBe('SELECT "user_name" FROM "users" WHERE "status" = \'active\''); + }); + + it('handles undefined bindings gracefully', () => { + const fragment = new SQLFragment('SELECT * FROM users WHERE id = ? AND name = ?', [ + { type: 'value', value: 1 }, + undefined as any, // Simulate an edge case with undefined binding + ]); + + const text = fragment.toDebugString; + expect(text).toBe('SELECT * FROM users WHERE id = 1 AND name = ?'); + }); + + it('handles null bindings gracefully', () => { + const fragment = new SQLFragment('SELECT * FROM ?? WHERE ?? = ?', [ + { type: 'identifier', name: 'users' }, + null as any, // Simulate an edge case with null binding + { type: 'value', value: 'test' }, + ]); + + const text = fragment.toDebugString; + // When a binding is null, it leaves the placeholder unchanged and doesn't advance the index + expect(text).toBe('SELECT * FROM "users" WHERE ?? = ?'); + }); + + it('handles mismatch between identifier placeholder and value binding', () => { + const fragment = new SQLFragment('SELECT * FROM ?? WHERE id = ?', [ + { type: 'value', value: 'users' }, // Wrong type - should be identifier + { type: 'value', value: 1 }, + ]); + + const text = fragment.toDebugString; + // Mismatched binding type leaves the placeholder unchanged + expect(text).toBe('SELECT * FROM ?? WHERE id = 1'); + }); + + it('handles mismatch between value placeholder and identifier binding', () => { + const fragment = new SQLFragment('SELECT * FROM users WHERE ? = ?', [ + { type: 'identifier', name: 'status' }, // Wrong type - should be value + { type: 'value', value: 'active' }, + ]); + + const text = fragment.toDebugString; + // Mismatched binding type leaves the placeholder unchanged + expect(text).toBe("SELECT * FROM users WHERE ? = 'active'"); + }); + }); + }); + + describe(SQLIdentifier, () => { + it('stores raw identifier names', () => { + const id = identifier('user_name'); + expect(id.name).toBe('user_name'); + }); + + it('stores identifier with quotes unchanged', () => { + const id = identifier('table"name'); + expect(id.name).toBe('table"name'); + }); + + it('stores identifier with multiple quotes unchanged', () => { + const id = identifier('my"special"column'); + expect(id.name).toBe('my"special"column'); + }); + + it('stores potential SQL injection attempts unchanged', () => { + const id = identifier('col"; DROP TABLE users; --'); + expect(id.name).toBe('col"; DROP TABLE users; --'); + }); + + it('handles empty string identifier', () => { + const id = identifier(''); + expect(id.name).toBe(''); + }); + + it('handles identifier with only quotes', () => { + const id = identifier('"""'); + expect(id.name).toBe('"""'); + }); + + it('uses ?? placeholder in SQL fragments', () => { + 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']); + }); + + it('delegates escaping to Knex for SQL injection prevention', () => { + const maliciousName = 'id"; DELETE FROM users WHERE "1"="1'; + 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']); + }); + }); + + describe('SQLFragmentHelpers', () => { + describe(SQLFragmentHelpers.inArray, () => { + it('generates IN clause with values', () => { + const fragment = SQLFragmentHelpers.inArray('status', ['active', 'pending']); + + expect(fragment.sql).toBe('?? IN (?, ?)'); + expect(fragment.getKnexBindings()).toEqual(['status', 'active', 'pending']); + }); + + it('handles empty array', () => { + const fragment = SQLFragmentHelpers.inArray('status', []); + + expect(fragment.sql).toBe('1 = 0'); // Always false + expect(fragment.getKnexBindings()).toEqual([]); + }); + }); + + describe(SQLFragmentHelpers.notInArray, () => { + it('generates NOT IN clause with values', () => { + const fragment = SQLFragmentHelpers.notInArray('status', ['deleted', 'archived']); + + expect(fragment.sql).toBe('?? NOT IN (?, ?)'); + expect(fragment.getKnexBindings()).toEqual(['status', 'deleted', 'archived']); + }); + + it('handles empty array', () => { + const fragment = SQLFragmentHelpers.notInArray('status', []); + + expect(fragment.sql).toBe('1 = 1'); // Always true + expect(fragment.getKnexBindings()).toEqual([]); + }); + }); + + describe(SQLFragmentHelpers.between, () => { + it('generates BETWEEN clause with numbers', () => { + const fragment = SQLFragmentHelpers.between('age', 18, 65); + + expect(fragment.sql).toBe('?? BETWEEN ? AND ?'); + expect(fragment.getKnexBindings()).toEqual(['age', 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); + + expect(fragment.sql).toBe('?? BETWEEN ? AND ?'); + expect(fragment.getKnexBindings()).toEqual(['created_at', date1, date2]); + }); + + it('generates BETWEEN clause with strings', () => { + const fragment = SQLFragmentHelpers.between('name', 'A', 'Z'); + + expect(fragment.sql).toBe('?? BETWEEN ? AND ?'); + expect(fragment.getKnexBindings()).toEqual(['name', 'A', 'Z']); + }); + }); + + describe(SQLFragmentHelpers.notBetween, () => { + it('generates NOT BETWEEN clause with numbers', () => { + const fragment = SQLFragmentHelpers.notBetween('age', 18, 65); + + expect(fragment.sql).toBe('?? NOT BETWEEN ? AND ?'); + expect(fragment.getKnexBindings()).toEqual(['age', 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); + + expect(fragment.sql).toBe('?? NOT BETWEEN ? AND ?'); + expect(fragment.getKnexBindings()).toEqual(['created_at', date1, date2]); + }); + }); + + describe(SQLFragmentHelpers.like, () => { + it('generates LIKE clause', () => { + const fragment = SQLFragmentHelpers.like('name', '%John%'); + + expect(fragment.sql).toBe('?? LIKE ?'); + expect(fragment.getKnexBindings()).toEqual(['name', '%John%']); + }); + }); + + describe(SQLFragmentHelpers.notLike, () => { + it('generates NOT LIKE clause', () => { + const fragment = SQLFragmentHelpers.notLike('name', '%test%'); + + expect(fragment.sql).toBe('?? NOT LIKE ?'); + expect(fragment.getKnexBindings()).toEqual(['name', '%test%']); + }); + }); + + describe(SQLFragmentHelpers.ilike, () => { + it('generates ILIKE clause for case-insensitive matching', () => { + const fragment = SQLFragmentHelpers.ilike('email', '%@example.com'); + + expect(fragment.sql).toBe('?? ILIKE ?'); + expect(fragment.getKnexBindings()).toEqual(['email', '%@example.com']); + }); + }); + + describe(SQLFragmentHelpers.notIlike, () => { + it('generates NOT ILIKE clause for case-insensitive non-matching', () => { + const fragment = SQLFragmentHelpers.notIlike('email', '%@spam.com'); + + expect(fragment.sql).toBe('?? NOT ILIKE ?'); + expect(fragment.getKnexBindings()).toEqual(['email', '%@spam.com']); + }); + }); + + describe(SQLFragmentHelpers.isNull, () => { + it('generates IS NULL', () => { + const fragment = SQLFragmentHelpers.isNull('deleted_at'); + + expect(fragment.sql).toBe('?? IS NULL'); + expect(fragment.getKnexBindings()).toEqual(['deleted_at']); + }); + }); + + describe(SQLFragmentHelpers.isNotNull, () => { + it('generates IS NOT NULL', () => { + const fragment = SQLFragmentHelpers.isNotNull('email'); + + expect(fragment.sql).toBe('?? IS NOT NULL'); + expect(fragment.getKnexBindings()).toEqual(['email']); + }); + }); + + describe(SQLFragmentHelpers.eq, () => { + it('generates equality check', () => { + const fragment = SQLFragmentHelpers.eq('status', 'active'); + + expect(fragment.sql).toBe('?? = ?'); + expect(fragment.getKnexBindings()).toEqual(['status', 'active']); + }); + + it('handles null in equality check', () => { + const fragment = SQLFragmentHelpers.eq('field', null); + + expect(fragment.sql).toBe('?? IS NULL'); + expect(fragment.getKnexBindings()).toEqual(['field']); + }); + + it('handles undefined in equality check', () => { + const fragment = SQLFragmentHelpers.eq('field', undefined); + + expect(fragment.sql).toBe('?? IS NULL'); + expect(fragment.getKnexBindings()).toEqual(['field']); + }); + }); + + describe(SQLFragmentHelpers.neq, () => { + it('generates inequality check', () => { + const fragment = SQLFragmentHelpers.neq('status', 'deleted'); + + expect(fragment.sql).toBe('?? != ?'); + expect(fragment.getKnexBindings()).toEqual(['status', 'deleted']); + }); + + it('handles null in inequality check', () => { + const fragment = SQLFragmentHelpers.neq('field', null); + + expect(fragment.sql).toBe('?? IS NOT NULL'); + expect(fragment.getKnexBindings()).toEqual(['field']); + }); + + it('handles undefined in inequality check', () => { + const fragment = SQLFragmentHelpers.neq('field', undefined); + + expect(fragment.sql).toBe('?? IS NOT NULL'); + expect(fragment.getKnexBindings()).toEqual(['field']); + }); + }); + + describe(SQLFragmentHelpers.gt, () => { + it('generates greater than', () => { + const fragment = SQLFragmentHelpers.gt('age', 18); + + expect(fragment.sql).toBe('?? > ?'); + expect(fragment.getKnexBindings()).toEqual(['age', 18]); + }); + }); + + describe(SQLFragmentHelpers.gte, () => { + it('generates greater than or equal', () => { + const fragment = SQLFragmentHelpers.gte('age', 18); + + expect(fragment.sql).toBe('?? >= ?'); + expect(fragment.getKnexBindings()).toEqual(['age', 18]); + }); + }); + + describe(SQLFragmentHelpers.lt, () => { + it('generates less than', () => { + const fragment = SQLFragmentHelpers.lt('age', 65); + + expect(fragment.sql).toBe('?? < ?'); + expect(fragment.getKnexBindings()).toEqual(['age', 65]); + }); + }); + + describe(SQLFragmentHelpers.lte, () => { + it('generates less than or equal', () => { + const fragment = SQLFragmentHelpers.lte('age', 65); + + expect(fragment.sql).toBe('?? <= ?'); + expect(fragment.getKnexBindings()).toEqual(['age', 65]); + }); + }); + + describe(SQLFragmentHelpers.jsonContains, () => { + it('generates JSON contains', () => { + const fragment = SQLFragmentHelpers.jsonContains('metadata', { premium: true }); + + expect(fragment.sql).toBe('?? @> ?::jsonb'); + expect(fragment.getKnexBindings()).toEqual(['metadata', '{"premium":true}']); + }); + }); + + describe(SQLFragmentHelpers.jsonContainedBy, () => { + it('generates JSON contained by', () => { + const fragment = SQLFragmentHelpers.jsonContainedBy('settings', { + theme: 'dark', + lang: 'en', + }); + + expect(fragment.sql).toBe('?? <@ ?::jsonb'); + expect(fragment.getKnexBindings()).toEqual(['settings', '{"theme":"dark","lang":"en"}']); + }); + }); + + describe(SQLFragmentHelpers.jsonPath, () => { + it('generates JSON path access', () => { + const fragment = SQLFragmentHelpers.jsonPath('data', 'user'); + + expect(fragment.sql).toBe(`"data"->'user'`); + expect(fragment.getKnexBindings()).toEqual([]); + }); + }); + + describe(SQLFragmentHelpers.jsonPathText, () => { + it('generates JSON path text access', () => { + const fragment = SQLFragmentHelpers.jsonPathText('data', 'email'); + + expect(fragment.sql).toBe(`"data"->>'email'`); + expect(fragment.getKnexBindings()).toEqual([]); + }); + }); + + describe(SQLFragmentHelpers.and, () => { + it('combines conditions with AND', () => { + const cond1 = sql`age >= ${18}`; + const cond2 = sql`status = ${'active'}`; + const fragment = SQLFragmentHelpers.and(cond1, cond2); + + expect(fragment.sql).toBe('age >= ? AND status = ?'); + expect(fragment.getKnexBindings()).toEqual([18, 'active']); + }); + + it('handles single condition in AND', () => { + const cond = sql`age >= ${18}`; + const fragment = SQLFragmentHelpers.and(cond); + + expect(fragment.sql).toBe('age >= ?'); + expect(fragment.getKnexBindings()).toEqual([18]); + }); + + it('handles empty conditions in AND', () => { + const fragment = SQLFragmentHelpers.and(); + + expect(fragment.sql).toBe('1 = 1'); + expect(fragment.getKnexBindings()).toEqual([]); + }); + }); + + describe(SQLFragmentHelpers.or, () => { + it('combines conditions with OR', () => { + const cond1 = sql`status = ${'active'}`; + const cond2 = sql`status = ${'pending'}`; + const fragment = SQLFragmentHelpers.or(cond1, cond2); + + expect(fragment.sql).toBe('status = ? OR status = ?'); + expect(fragment.getKnexBindings()).toEqual(['active', 'pending']); + }); + + it('handles single condition in OR', () => { + const cond = sql`status = ${'active'}`; + const fragment = SQLFragmentHelpers.or(cond); + + expect(fragment.sql).toBe('status = ?'); + expect(fragment.getKnexBindings()).toEqual(['active']); + }); + + it('handles empty conditions in OR', () => { + const fragment = SQLFragmentHelpers.or(); + + expect(fragment.sql).toBe('1 = 0'); + expect(fragment.getKnexBindings()).toEqual([]); + }); + }); + + describe(SQLFragmentHelpers.not, () => { + it('negates conditions with NOT', () => { + const cond = sql`status = ${'deleted'}`; + const fragment = SQLFragmentHelpers.not(cond); + + expect(fragment.sql).toBe('NOT (status = ?)'); + expect(fragment.getKnexBindings()).toEqual(['deleted']); + }); + }); + + describe(SQLFragmentHelpers.group, () => { + it('groups conditions with parentheses', () => { + const cond = sql`age >= ${18} AND age <= ${65}`; + const fragment = SQLFragmentHelpers.group(cond); + + expect(fragment.sql).toBe('(age >= ? AND age <= ?)'); + expect(fragment.getKnexBindings()).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'), + ); + + expect(fragment.sql).toBe( + '?? BETWEEN ? AND ? AND (?? IN (?, ?) OR role = ?) AND ?? IS NOT NULL', + ); + expect(fragment.getKnexBindings()).toEqual([ + 'age', + 18, + 65, + 'status', + 'active', + 'premium', + 'admin', + 'email', + ]); + }); + }); + }); +}); 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 566125b51..77993664b 100644 --- a/packages/entity-database-adapter-knex/src/__tests__/fixtures/StubPostgresDatabaseAdapter.ts +++ b/packages/entity-database-adapter-knex/src/__tests__/fixtures/StubPostgresDatabaseAdapter.ts @@ -17,7 +17,9 @@ import { TableFieldMultiValueEqualityCondition, TableFieldSingleValueEqualityCondition, TableQuerySelectionModifiers, + TableQuerySelectionModifiersWithOrderByFragment, } from '../../BasePostgresEntityDatabaseAdapter'; +import { SQLFragment } from '../../SQLOperator'; export class StubPostgresDatabaseAdapter< TFields extends Record, @@ -185,6 +187,15 @@ export class StubPostgresDatabaseAdapter< throw new Error('Raw WHERE clauses not supported for StubDatabaseAdapter'); } + protected fetchManyBySQLFragmentInternalAsync( + _queryInterface: any, + _tableName: string, + _sqlFragment: SQLFragment, + _querySelectionModifiers: TableQuerySelectionModifiersWithOrderByFragment, + ): Promise { + throw new Error('SQL fragments not supported for StubDatabaseAdapter'); + } + private generateRandomID(): any { const idSchemaField = this.entityConfiguration2.schema.get(this.entityConfiguration2.idField); invariant( diff --git a/packages/entity-database-adapter-knex/src/index.ts b/packages/entity-database-adapter-knex/src/index.ts index 0b31d5dfa..8e5719ae6 100644 --- a/packages/entity-database-adapter-knex/src/index.ts +++ b/packages/entity-database-adapter-knex/src/index.ts @@ -6,6 +6,7 @@ export * from './AuthorizationResultBasedKnexEntityLoader'; export * from './BasePostgresEntityDatabaseAdapter'; +export * from './BaseSQLQueryBuilder'; export * from './EnforcingKnexEntityLoader'; export * from './EntityFields'; export * from './KnexEntityLoader'; @@ -13,6 +14,7 @@ export * from './KnexEntityLoaderFactory'; export * from './PostgresEntityDatabaseAdapter'; export * from './PostgresEntityDatabaseAdapterProvider'; export * from './PostgresEntityQueryContextProvider'; +export * from './SQLOperator'; export * from './ViewerScopedKnexEntityLoaderFactory'; export * from './errors/wrapNativePostgresCallAsync'; export * from './extensions/EntityCompanionExtensions'; diff --git a/packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts b/packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts index ae5c791eb..21cea0c27 100644 --- a/packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts +++ b/packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts @@ -9,8 +9,10 @@ import { BasePostgresEntityDatabaseAdapter, FieldEqualityCondition, QuerySelectionModifiers, + QuerySelectionModifiersWithOrderByFragment, QuerySelectionModifiersWithOrderByRaw, } from '../BasePostgresEntityDatabaseAdapter'; +import { SQLFragment } from '../SQLOperator'; /** * A knex data manager is responsible for handling non-dataloader-based @@ -85,4 +87,23 @@ export class EntityKnexDataManager< ), ); } + + async loadManyBySQLFragmentAsync( + queryContext: EntityQueryContext, + sqlFragment: SQLFragment, + querySelectionModifiers: QuerySelectionModifiersWithOrderByFragment, + ): Promise[]> { + return await timeAndLogLoadEventAsync( + this.metricsAdapter, + EntityMetricsLoadType.LOAD_MANY_SQL, + this.entityClassName, + queryContext, + )( + this.databaseAdapter.fetchManyBySQLFragmentAsync( + queryContext, + sqlFragment, + querySelectionModifiers, + ), + ); + } } diff --git a/packages/entity/src/metrics/IEntityMetricsAdapter.ts b/packages/entity/src/metrics/IEntityMetricsAdapter.ts index 71b270ed6..4d06bb079 100644 --- a/packages/entity/src/metrics/IEntityMetricsAdapter.ts +++ b/packages/entity/src/metrics/IEntityMetricsAdapter.ts @@ -8,6 +8,7 @@ export enum EntityMetricsLoadType { LOAD_MANY, LOAD_MANY_EQUALITY_CONJUNCTION, LOAD_MANY_RAW, + LOAD_MANY_SQL, } /**