From 7fb2cb723c72c17c8ca6486881b78a89fc43fb1d Mon Sep 17 00:00:00 2001 From: Pedro Del Moral Lopez Date: Fri, 5 Jun 2026 12:04:56 -0400 Subject: [PATCH] refactor: extract ORM query compilers --- .changeset/where-compiler-extraction.md | 5 + .../query/compiler/HydrationPlanCompiler.ts | 222 ++++++ .../query/compiler/PrefetchQueryCompiler.ts | 215 ++++++ .../orm/src/query/compiler/QueryCompiler.ts | 687 ++---------------- .../orm/src/query/compiler/WhereCompiler.ts | 289 ++++++++ .../tests/HydrationPlanCompiler.test.ts | 311 ++++++++ .../tests/PrefetchQueryCompiler.test.ts | 354 +++++++++ .../compiler/tests/QueryCompiler.test.ts | 588 +-------------- .../compiler/tests/WhereCompiler.test.ts | 460 ++++++++++++ 9 files changed, 1899 insertions(+), 1232 deletions(-) create mode 100644 .changeset/where-compiler-extraction.md create mode 100644 packages/orm/src/query/compiler/HydrationPlanCompiler.ts create mode 100644 packages/orm/src/query/compiler/PrefetchQueryCompiler.ts create mode 100644 packages/orm/src/query/compiler/WhereCompiler.ts create mode 100644 packages/orm/src/query/compiler/tests/HydrationPlanCompiler.test.ts create mode 100644 packages/orm/src/query/compiler/tests/PrefetchQueryCompiler.test.ts create mode 100644 packages/orm/src/query/compiler/tests/WhereCompiler.test.ts diff --git a/.changeset/where-compiler-extraction.md b/.changeset/where-compiler-extraction.md new file mode 100644 index 00000000..036d5955 --- /dev/null +++ b/.changeset/where-compiler-extraction.md @@ -0,0 +1,5 @@ +--- +"@danceroutine/tango-orm": patch +--- + +Existing `QuerySet` filter, exclude, relation hydration, and prefetch SQL outputs remain unchanged while predicate, hydration-plan, and prefetch rendering move into internal compiler components. diff --git a/packages/orm/src/query/compiler/HydrationPlanCompiler.ts b/packages/orm/src/query/compiler/HydrationPlanCompiler.ts new file mode 100644 index 00000000..9b08aea1 --- /dev/null +++ b/packages/orm/src/query/compiler/HydrationPlanCompiler.ts @@ -0,0 +1,222 @@ +import { OrmSqlSafetyAdapter } from '../../validation'; +import { InternalSqlValidationPlanKind as SqlPlanKind } from '../../validation/internal/InternalSqlValidationPlanKind'; +import type { CompiledHydrationNode, CompiledJoinHydrationDescriptor } from '../domain/CompiledQuery'; +import { InternalRelationHydrationLoadMode } from '../domain/RelationMeta'; +import type { TableMeta } from '../domain/TableMeta'; +import type { QueryHydrationPlanNode, QueryHydrationPlanRoot } from '../planning'; + +const sqlSafetyAdapter = new OrmSqlSafetyAdapter(); + +type JoinCollection = { + selects: string[]; + joins: string[]; +}; + +export type CompiledHydrationArtifacts = { + joinNodes: CompiledHydrationNode[]; + prefetchNodes: CompiledHydrationNode[]; + hiddenRootAliases: string[]; + rootJoinSelects: string[]; + rootJoinSql: string[]; + rootHiddenSelects: string[]; +}; + +/** + * Compiles planned relation hydration paths into root-query join artifacts and + * recursive hydration nodes. + */ +export class HydrationPlanCompiler { + constructor(private readonly meta: TableMeta) {} + + compile( + plan: QueryHydrationPlanRoot, + options: { + rootTable: string; + rootSelectedFields?: readonly string[]; + } + ): CompiledHydrationArtifacts { + const joinCollection: JoinCollection = { selects: [], joins: [] }; + const hiddenRootAliases: string[] = []; + + const joinNodes = plan.joinNodes.map((node) => + this.compileHydrationNode(node, { + rootTable: options.rootTable, + ownerMeta: this.meta, + ownerAlias: options.rootTable, + collectRootJoins: true, + rootSelectedFields: options.rootSelectedFields, + hiddenRootAliases, + joinCollection, + }) + ); + const prefetchNodes = plan.prefetchNodes.map((node) => + this.compileHydrationNode(node, { + rootTable: options.rootTable, + ownerMeta: this.meta, + ownerAlias: options.rootTable, + collectRootJoins: false, + rootSelectedFields: options.rootSelectedFields, + hiddenRootAliases, + joinCollection, + }) + ); + + return { + joinNodes, + prefetchNodes, + hiddenRootAliases: [...new Set(hiddenRootAliases)], + rootJoinSelects: joinCollection.selects, + rootJoinSql: joinCollection.joins, + rootHiddenSelects: this.buildRootHiddenSelects(prefetchNodes, options.rootTable), + }; + } + + private compileHydrationNode( + node: QueryHydrationPlanNode, + context: { + rootTable: string; + ownerMeta: TableMeta; + ownerAlias: string; + collectRootJoins: boolean; + rootSelectedFields?: readonly string[]; + hiddenRootAliases: string[]; + joinCollection: JoinCollection; + } + ): CompiledHydrationNode { + const validatedRelation = this.validateHydrationRelation(context.ownerMeta, node.relationName); + const targetColumns = validatedRelation.targetColumns; + const targetMeta = node.relationEdge.targetMeta; + if (!targetMeta) { + throw new Error(`Relation path '${node.relationPath}' is missing target metadata.`); + } + const compiledJoinChildren = node.joinChildren.map((child) => + this.compileHydrationNode(child, { + ...context, + ownerMeta: targetMeta, + ownerAlias: this.buildJoinAlias(node.relationPath), + collectRootJoins: context.collectRootJoins, + }) + ); + const compiledPrefetchChildren = node.prefetchChildren.map((child) => + this.compileHydrationNode(child, { + ...context, + ownerMeta: targetMeta, + ownerAlias: this.buildJoinAlias(node.relationPath), + collectRootJoins: false, + }) + ); + + let joinDescriptor: CompiledJoinHydrationDescriptor | undefined; + if (node.loadMode === InternalRelationHydrationLoadMode.JOIN) { + joinDescriptor = { + alias: this.buildJoinAlias(node.relationPath), + columns: Object.fromEntries( + Object.keys(targetColumns).map((column) => [ + column, + this.buildHydrationColumnAlias(node.relationPath, column), + ]) + ), + }; + + if (context.collectRootJoins) { + context.joinCollection.joins.push( + `LEFT JOIN ${validatedRelation.table} ${joinDescriptor.alias} ON ${joinDescriptor.alias}.${validatedRelation.targetKey} = ${context.ownerAlias}.${validatedRelation.sourceKey}` + ); + context.joinCollection.selects.push( + ...Object.entries(joinDescriptor.columns).map( + ([column, alias]) => `${joinDescriptor!.alias}.${column} AS ${alias}` + ) + ); + } + } + + const ownerSourceAccessor = + node.loadMode === InternalRelationHydrationLoadMode.PREFETCH && + context.collectRootJoins === false && + context.ownerAlias === context.rootTable && + context.rootSelectedFields?.length && + !context.rootSelectedFields.includes(validatedRelation.sourceKey) + ? this.buildPrefetchSourceAlias(node.relationPath, validatedRelation.sourceKey) + : validatedRelation.sourceKey; + + if ( + node.loadMode === InternalRelationHydrationLoadMode.PREFETCH && + ownerSourceAccessor !== validatedRelation.sourceKey + ) { + context.hiddenRootAliases.push(ownerSourceAccessor); + } + + return { + nodeId: node.nodeId, + relationName: node.relationName, + relationPath: node.relationPath, + ownerModelKey: node.ownerModelKey, + targetModelKey: node.targetModelKey, + loadMode: node.loadMode, + cardinality: node.cardinality, + sourceKey: validatedRelation.sourceKey, + ownerSourceAccessor, + targetKey: validatedRelation.targetKey, + targetTable: validatedRelation.table, + targetPrimaryKey: node.relationEdge.targetPrimaryKey, + throughTable: node.relationEdge.throughTable, + throughSourceKey: node.relationEdge.throughSourceKey, + throughTargetKey: node.relationEdge.throughTargetKey, + throughSourceColumnType: node.relationEdge.throughSourceColumnType, + throughTargetColumnType: node.relationEdge.throughTargetColumnType, + targetColumns, + provenance: node.provenance, + joinChildren: compiledJoinChildren, + prefetchChildren: compiledPrefetchChildren, + join: joinDescriptor, + }; + } + + private validateHydrationRelation( + ownerMeta: TableMeta, + relationName: string + ): NonNullable[string] { + return sqlSafetyAdapter.validate({ + kind: SqlPlanKind.SELECT, + meta: ownerMeta, + relationNames: [relationName], + }).relations[relationName]!; + } + + private buildRootHiddenSelects(nodes: readonly CompiledHydrationNode[], table: string): string[] { + return nodes.flatMap((node) => { + const select = + node.ownerSourceAccessor === node.sourceKey + ? [] + : [`${table}.${node.sourceKey} AS ${node.ownerSourceAccessor}`]; + return [...select, ...this.buildRootHiddenSelects(node.prefetchChildren, table)]; + }); + } + + private buildJoinAlias(relationPath: string): string { + return this.assertInternalAliasDoesNotCollide(`__tango_join_${this.sanitizeRelationPath(relationPath)}`); + } + + private buildHydrationColumnAlias(relationPath: string, column: string): string { + return this.assertInternalAliasDoesNotCollide( + `__tango_hydrate_${this.sanitizeRelationPath(relationPath)}_${column}` + ); + } + + private buildPrefetchSourceAlias(relationPath: string, sourceKey: string): string { + return this.assertInternalAliasDoesNotCollide( + `__tango_prefetch_${this.sanitizeRelationPath(relationPath)}_${sourceKey}` + ); + } + + private sanitizeRelationPath(relationPath: string): string { + return relationPath.replace(/[^a-zA-Z0-9]+/gu, '_'); + } + + private assertInternalAliasDoesNotCollide(alias: string): string { + if (alias in this.meta.columns) { + throw new Error(`Internal query alias '${alias}' collides with a field on table '${this.meta.table}'.`); + } + return alias; + } +} diff --git a/packages/orm/src/query/compiler/PrefetchQueryCompiler.ts b/packages/orm/src/query/compiler/PrefetchQueryCompiler.ts new file mode 100644 index 00000000..2824878c --- /dev/null +++ b/packages/orm/src/query/compiler/PrefetchQueryCompiler.ts @@ -0,0 +1,215 @@ +import { isError } from '@danceroutine/tango-core'; +import type { Adapter, SqlPlaceholders } from '../../connection/adapters/Adapter'; +import { OrmSqlSafetyAdapter } from '../../validation'; +import { InternalSqlValidationPlanKind as SqlPlanKind } from '../../validation/internal/InternalSqlValidationPlanKind'; +import type { CompiledHydrationNode, CompiledPrefetchQuery } from '../domain/CompiledQuery'; +import { InternalPrefetchQueryKind } from '../domain/internal/InternalPrefetchQueryKind'; +import type { TableMeta } from '../domain/TableMeta'; + +const sqlSafetyAdapter = new OrmSqlSafetyAdapter(); + +type JoinCollection = { + selects: string[]; + joins: string[]; +}; + +/** + * Compiles follow-up SQL for prefetch-backed relation hydration. + */ +export class PrefetchQueryCompiler { + private readonly placeholders: SqlPlaceholders; + + constructor( + private readonly meta: TableMeta, + adapter: Adapter + ) { + this.placeholders = adapter.placeholders; + } + + compilePrefetch(node: CompiledHydrationNode, sourceValues: readonly (string | number)[]): CompiledPrefetchQuery { + if (node.throughTable && node.throughSourceKey && node.throughTargetKey) { + return this.compileManyToManyPrefetch(node, sourceValues); + } + + const placeholders = this.placeholders.list(sourceValues.length); + const validatedTarget = this.validatePrefetchTarget(node); + const baseAlias = this.buildPrefetchBaseAlias(node.relationPath); + const joinCollection: JoinCollection = { selects: [], joins: [] }; + + for (const joinChild of node.joinChildren) { + this.collectNestedJoinSql(joinChild, baseAlias, validatedTarget.columns, joinCollection); + } + + const baseSelects = Object.keys(validatedTarget.columns).map((column) => `${baseAlias}.${column} AS ${column}`); + return { + kind: InternalPrefetchQueryKind.DIRECT, + sql: `SELECT ${[...baseSelects, ...joinCollection.selects].join(', ')} FROM ${validatedTarget.table} ${baseAlias}${joinCollection.joins.length ? ` ${joinCollection.joins.join(' ')}` : ''} WHERE ${baseAlias}.${validatedTarget.targetKey} IN (${placeholders}) ORDER BY ${baseAlias}.${validatedTarget.targetKey} ASC, ${baseAlias}.${validatedTarget.primaryKey} ASC`, + params: sourceValues, + targetKey: validatedTarget.targetKey, + targetColumns: validatedTarget.columns, + }; + } + + compileManyToManyTargets( + node: CompiledHydrationNode, + targetIds: readonly (string | number)[] + ): { sql: string; params: readonly unknown[] } { + const placeholders = this.placeholders.list(targetIds.length); + const validatedTarget = this.validatePrefetchTarget(node); + const baseAlias = this.buildPrefetchBaseAlias(node.relationPath); + const joinCollection: JoinCollection = { selects: [], joins: [] }; + + for (const joinChild of node.joinChildren) { + this.collectNestedJoinSql(joinChild, baseAlias, validatedTarget.columns, joinCollection); + } + + const baseSelects = Object.keys(validatedTarget.columns).map((column) => `${baseAlias}.${column} AS ${column}`); + return { + sql: `SELECT ${[...baseSelects, ...joinCollection.selects].join(', ')} FROM ${validatedTarget.table} ${baseAlias}${joinCollection.joins.length ? ` ${joinCollection.joins.join(' ')}` : ''} WHERE ${baseAlias}.${validatedTarget.primaryKey} IN (${placeholders}) ORDER BY ${baseAlias}.${validatedTarget.primaryKey} ASC`, + params: targetIds, + }; + } + + private compileManyToManyPrefetch( + node: CompiledHydrationNode, + sourceValues: readonly (string | number)[] + ): CompiledPrefetchQuery { + const placeholders = this.placeholders.list(sourceValues.length); + const throughValidated = sqlSafetyAdapter.validate({ + kind: SqlPlanKind.SELECT, + meta: { + table: node.throughTable!, + pk: node.throughSourceKey!, + columns: { + [node.throughSourceKey!]: node.throughSourceColumnType ?? 'int', + [node.throughTargetKey!]: node.throughTargetColumnType ?? 'int', + }, + }, + filterKeys: [node.throughSourceKey!, node.throughTargetKey!], + relationNames: [], + }); + const ownerAlias = this.validateInternalAlias('__tango_m2m_owner'); + const targetAlias = this.validateInternalAlias('__tango_m2m_target'); + const throughSourceColumn = throughValidated.filterKeys[node.throughSourceKey!]!.field; + const throughTargetColumn = throughValidated.filterKeys[node.throughTargetKey!]!.field; + return { + kind: InternalPrefetchQueryKind.MANY_TO_MANY, + throughSql: `SELECT ${throughValidated.meta.table}.${throughSourceColumn} AS ${ownerAlias}, ${throughValidated.meta.table}.${throughTargetColumn} AS ${targetAlias} FROM ${throughValidated.meta.table} WHERE ${throughValidated.meta.table}.${throughSourceColumn} IN (${placeholders}) ORDER BY ${throughValidated.meta.table}.${throughSourceColumn} ASC, ${throughValidated.meta.table}.${throughTargetColumn} ASC`, + throughParams: sourceValues, + ownerAlias, + targetAlias, + targetTable: node.targetTable, + targetPrimaryKey: node.targetPrimaryKey, + targetColumns: node.targetColumns, + }; + } + + private validatePrefetchTarget(node: CompiledHydrationNode): { + table: string; + primaryKey: string; + targetKey: string; + columns: Record; + } { + try { + const validated = sqlSafetyAdapter.validate({ + kind: SqlPlanKind.SELECT, + meta: { + table: node.targetTable, + pk: node.targetPrimaryKey, + columns: node.targetColumns, + }, + filterKeys: [node.targetKey], + }); + + return { + table: validated.meta.table, + primaryKey: validated.meta.pk, + targetKey: validated.filterKeys[node.targetKey]!.field, + columns: validated.meta.columns, + }; + } catch (error) { + const message = isError(error) ? error.message : String(error); + throw new Error(`Compiled prefetch query failed validation: ${message}`, { cause: error }); + } + } + + private collectNestedJoinSql( + node: CompiledHydrationNode, + ownerAlias: string, + ownerColumns: Record, + collection: JoinCollection + ): void { + if (!node.join) { + return; + } + + const validatedTarget = this.validatePrefetchJoinTarget(node, ownerColumns); + const validatedJoinAlias = this.validateInternalAlias(node.join.alias); + const validatedJoinColumns = Object.fromEntries( + Object.entries(node.join.columns).map(([column, alias]) => { + if (!(column in validatedTarget.columns)) { + throw new Error( + `Compiled prefetch query failed validation: unknown nested join column '${column}'.` + ); + } + return [column, this.validateInternalAlias(alias)]; + }) + ); + + collection.joins.push( + `LEFT JOIN ${validatedTarget.table} ${validatedJoinAlias} ON ${validatedJoinAlias}.${validatedTarget.targetKey} = ${ownerAlias}.${node.sourceKey}` + ); + collection.selects.push( + ...Object.entries(validatedJoinColumns).map( + ([column, alias]) => `${validatedJoinAlias}.${column} AS ${alias}` + ) + ); + + for (const child of node.joinChildren) { + this.collectNestedJoinSql(child, validatedJoinAlias, validatedTarget.columns, collection); + } + } + + private validatePrefetchJoinTarget( + node: CompiledHydrationNode, + ownerColumns: Record + ): { + table: string; + primaryKey: string; + targetKey: string; + columns: Record; + } { + if (!(node.sourceKey in ownerColumns)) { + throw new Error( + `Compiled prefetch query failed validation: unknown owner column '${node.sourceKey}' for nested join.` + ); + } + + return this.validatePrefetchTarget(node); + } + + private validateInternalAlias(alias: string): string { + if (!/^__tango_[A-Za-z0-9_]+$/u.test(alias)) { + throw new Error(`Compiled prefetch query failed validation: invalid internal alias '${alias}'.`); + } + + return alias; + } + + private buildPrefetchBaseAlias(relationPath: string): string { + return this.assertInternalAliasDoesNotCollide( + `__tango_prefetch_base_${this.sanitizeRelationPath(relationPath)}` + ); + } + + private sanitizeRelationPath(relationPath: string): string { + return relationPath.replace(/[^a-zA-Z0-9]+/gu, '_'); + } + + private assertInternalAliasDoesNotCollide(alias: string): string { + if (alias in this.meta.columns) { + throw new Error(`Internal query alias '${alias}' collides with a field on table '${this.meta.table}'.`); + } + return alias; + } +} diff --git a/packages/orm/src/query/compiler/QueryCompiler.ts b/packages/orm/src/query/compiler/QueryCompiler.ts index 6b6f3bfc..bfe877b1 100644 --- a/packages/orm/src/query/compiler/QueryCompiler.ts +++ b/packages/orm/src/query/compiler/QueryCompiler.ts @@ -1,41 +1,19 @@ -import { isError } from '@danceroutine/tango-core'; -import type { LookupType } from '../domain/LookupType'; import type { QuerySetState } from '../domain/QuerySetState'; import type { TableMeta } from '../domain/TableMeta'; -import type { QNode } from '../domain/QNode'; -import type { - CompiledHydrationNode, - CompiledHydrationPlanRoot, - CompiledJoinHydrationDescriptor, - CompiledPrefetchQuery, - CompiledQuery, -} from '../domain/CompiledQuery'; -import type { WhereClause } from '../domain/WhereClause'; -import type { FilterInput } from '../domain/FilterInput'; -import type { Adapter, SqlPlaceholders } from '../../connection/adapters/Adapter'; -import { InternalRelationHydrationLoadMode } from '../domain/RelationMeta'; +import type { CompiledHydrationNode, CompiledPrefetchQuery, CompiledQuery } from '../domain/CompiledQuery'; +import type { Adapter } from '../../connection/adapters/Adapter'; import { InternalDialect } from '../domain/internal/InternalDialect'; -import { InternalPrefetchQueryKind } from '../domain/internal/InternalPrefetchQueryKind'; import { InternalQNodeType } from '../domain/internal/InternalQNodeType'; -import { InternalLookupType } from '../domain/internal/InternalLookupType'; import { InternalSqlValidationPlanKind as SqlPlanKind } from '../../validation/internal/InternalSqlValidationPlanKind'; -import { InternalValidatedFilterDescriptorKind } from '../../validation/internal/InternalValidatedFilterDescriptorKind'; import { OrmSqlSafetyAdapter } from '../../validation'; -import type { - ValidatedFilterDescriptor, - ValidatedRelationMeta, - ValidatedSelectSqlPlan, -} from '../../validation/SQLValidationEngine'; +import type { ValidatedSelectSqlPlan } from '../../validation/SQLValidationEngine'; import { QueryPlanner } from '../planning'; -import type { QueryHydrationPlanNode } from '../planning'; +import { HydrationPlanCompiler } from './HydrationPlanCompiler'; +import { PrefetchQueryCompiler } from './PrefetchQueryCompiler'; +import { WhereCompiler } from './WhereCompiler'; const sqlSafetyAdapter = new OrmSqlSafetyAdapter(); -type JoinCollection = { - selects: string[]; - joins: string[]; -}; - /** * Compiles immutable `QuerySet` state into parameterized SQL and recursive * hydration execution artifacts. @@ -44,13 +22,17 @@ export class QueryCompiler { static readonly BRAND = 'tango.orm.query_compiler' as const; readonly __tangoBrand: typeof QueryCompiler.BRAND = QueryCompiler.BRAND; - private readonly placeholders: SqlPlaceholders; + private readonly whereCompiler: WhereCompiler; + private readonly prefetchCompiler: PrefetchQueryCompiler; + private readonly hydrationCompiler: HydrationPlanCompiler; constructor( private meta: TableMeta, private adapter: Adapter ) { - this.placeholders = adapter.placeholders; + this.whereCompiler = new WhereCompiler(meta, adapter); + this.prefetchCompiler = new PrefetchQueryCompiler(meta, adapter); + this.hydrationCompiler = new HydrationPlanCompiler(meta); } static isQueryCompiler(value: unknown): value is QueryCompiler { @@ -67,7 +49,7 @@ export class QueryCompiler { kind: SqlPlanKind.SELECT, meta: this.meta, selectFields: state.select?.map(String), - filterKeys: this.collectStateFilterKeys(state), + filterKeys: this.whereCompiler.collectStateFilterKeys(state), orderFields: state.order?.map((order) => String(order.by)), relationNames: [], }); @@ -76,7 +58,7 @@ export class QueryCompiler { const params: unknown[] = []; if (state.q) { - const result = this.compileQNode(state.q, params.length + 1, validatedPlan.filterKeys); + const result = this.whereCompiler.compileNode(state.q, params.length + 1, validatedPlan.filterKeys); if (result.sql) { whereParts.push(result.sql); params.push(...result.params); @@ -84,7 +66,7 @@ export class QueryCompiler { } state.excludes?.forEach((exclude) => { - const result = this.compileQNode( + const result = this.whereCompiler.compileNode( { kind: InternalQNodeType.NOT, node: exclude }, params.length + 1, validatedPlan.filterKeys @@ -98,48 +80,27 @@ export class QueryCompiler { const baseSelects = state.select?.length ? state.select.map((field) => validatedPlan.selectFields[String(field)]!) : [`${table}.*`]; - const joinCollection: JoinCollection = { selects: [], joins: [] }; - const hiddenRootAliases: string[] = []; - - const compiledJoinNodes = hydrationPlan.joinNodes.map((node) => - this.compileHydrationNode(node, { - rootTable: table, - ownerMeta: this.meta, - ownerAlias: table, - collectRootJoins: true, - rootSelectedFields: state.select?.map(String) ?? undefined, - hiddenRootAliases, - joinCollection, - }) - ); - const compiledPrefetchNodes = hydrationPlan.prefetchNodes.map((node) => - this.compileHydrationNode(node, { - rootTable: table, - ownerMeta: this.meta, - ownerAlias: table, - collectRootJoins: false, - rootSelectedFields: state.select?.map(String) ?? undefined, - hiddenRootAliases, - joinCollection, - }) - ); + const compiledHydration = this.hydrationCompiler.compile(hydrationPlan, { + rootTable: table, + rootSelectedFields: state.select?.map(String) ?? undefined, + }); const select = [ ...baseSelects, - ...joinCollection.selects, - ...this.buildRootHiddenSelects(compiledPrefetchNodes, table), + ...compiledHydration.rootJoinSelects, + ...compiledHydration.rootHiddenSelects, ].join(', '); const whereSQL = whereParts.length ? ` WHERE ${whereParts.join(' AND ')}` : ''; const { orderSQL, limitSQL, offsetSQL } = this.buildQueryWindowSuffix(state, validatedPlan, table); - const sql = `SELECT ${select} FROM ${table}${joinCollection.joins.length ? ` ${joinCollection.joins.join(' ')}` : ''}${whereSQL}${orderSQL}${limitSQL}${offsetSQL}`; + const sql = `SELECT ${select} FROM ${table}${compiledHydration.rootJoinSql.length ? ` ${compiledHydration.rootJoinSql.join(' ')}` : ''}${whereSQL}${orderSQL}${limitSQL}${offsetSQL}`; - const compiledHydrationPlan: CompiledHydrationPlanRoot | undefined = - compiledJoinNodes.length > 0 || compiledPrefetchNodes.length > 0 + const compiledHydrationPlan = + compiledHydration.joinNodes.length > 0 || compiledHydration.prefetchNodes.length > 0 ? { requestedPaths: hydrationPlan.requestedPaths, - hiddenRootAliases: [...new Set(hiddenRootAliases)], - joinNodes: compiledJoinNodes, - prefetchNodes: compiledPrefetchNodes, + hiddenRootAliases: compiledHydration.hiddenRootAliases, + joinNodes: compiledHydration.joinNodes, + prefetchNodes: compiledHydration.prefetchNodes, } : undefined; @@ -155,7 +116,7 @@ export class QueryCompiler { kind: SqlPlanKind.SELECT, meta: this.meta, selectFields: state.select?.map(String), - filterKeys: this.collectStateFilterKeys(state), + filterKeys: this.whereCompiler.collectStateFilterKeys(state), orderFields: state.order?.map((order) => String(order.by)), relationNames: [], }); @@ -164,7 +125,7 @@ export class QueryCompiler { const params: unknown[] = []; if (state.q) { - const result = this.compileQNode(state.q, params.length + 1, validatedPlan.filterKeys); + const result = this.whereCompiler.compileNode(state.q, params.length + 1, validatedPlan.filterKeys); if (result.sql) { whereParts.push(result.sql); params.push(...result.params); @@ -172,7 +133,7 @@ export class QueryCompiler { } state.excludes?.forEach((exclude) => { - const result = this.compileQNode( + const result = this.whereCompiler.compileNode( { kind: InternalQNodeType.NOT, node: exclude }, params.length + 1, validatedPlan.filterKeys @@ -201,47 +162,14 @@ export class QueryCompiler { } compilePrefetch(node: CompiledHydrationNode, sourceValues: readonly (string | number)[]): CompiledPrefetchQuery { - if (node.throughTable && node.throughSourceKey && node.throughTargetKey) { - return this.compileManyToManyPrefetch(node, sourceValues); - } - - const placeholders = this.placeholders.list(sourceValues.length); - const validatedTarget = this.validatePrefetchTarget(node); - const baseAlias = this.buildPrefetchBaseAlias(node.relationPath); - const joinCollection: JoinCollection = { selects: [], joins: [] }; - - for (const joinChild of node.joinChildren) { - this.collectNestedJoinSql(joinChild, baseAlias, validatedTarget.columns, joinCollection); - } - - const baseSelects = Object.keys(validatedTarget.columns).map((column) => `${baseAlias}.${column} AS ${column}`); - return { - kind: InternalPrefetchQueryKind.DIRECT, - sql: `SELECT ${[...baseSelects, ...joinCollection.selects].join(', ')} FROM ${validatedTarget.table} ${baseAlias}${joinCollection.joins.length ? ` ${joinCollection.joins.join(' ')}` : ''} WHERE ${baseAlias}.${validatedTarget.targetKey} IN (${placeholders}) ORDER BY ${baseAlias}.${validatedTarget.targetKey} ASC, ${baseAlias}.${validatedTarget.primaryKey} ASC`, - params: sourceValues, - targetKey: validatedTarget.targetKey, - targetColumns: validatedTarget.columns, - }; + return this.prefetchCompiler.compilePrefetch(node, sourceValues); } compileManyToManyTargets( node: CompiledHydrationNode, targetIds: readonly (string | number)[] ): { sql: string; params: readonly unknown[] } { - const placeholders = this.placeholders.list(targetIds.length); - const validatedTarget = this.validatePrefetchTarget(node); - const baseAlias = this.buildPrefetchBaseAlias(node.relationPath); - const joinCollection: JoinCollection = { selects: [], joins: [] }; - - for (const joinChild of node.joinChildren) { - this.collectNestedJoinSql(joinChild, baseAlias, validatedTarget.columns, joinCollection); - } - - const baseSelects = Object.keys(validatedTarget.columns).map((column) => `${baseAlias}.${column} AS ${column}`); - return { - sql: `SELECT ${[...baseSelects, ...joinCollection.selects].join(', ')} FROM ${validatedTarget.table} ${baseAlias}${joinCollection.joins.length ? ` ${joinCollection.joins.join(' ')}` : ''} WHERE ${baseAlias}.${validatedTarget.primaryKey} IN (${placeholders}) ORDER BY ${baseAlias}.${validatedTarget.primaryKey} ASC`, - params: targetIds, - }; + return this.prefetchCompiler.compileManyToManyTargets(node, targetIds); } private buildQueryWindowSuffix( @@ -258,549 +186,16 @@ export class QueryCompiler { : `${table}.${validatedPlan.meta.pk} ASC` }`; const hasOffset = state.offset !== undefined; - const limitSQL = options?.existsProbe - ? state.limit === 0 - ? ' LIMIT 0' - : ' LIMIT 1' - : state.limit !== undefined - ? ` LIMIT ${state.limit}` - : hasOffset && this.adapter.dialect === InternalDialect.SQLITE - ? ' LIMIT -1' - : ''; - const offsetSQL = state.offset !== undefined ? ` OFFSET ${state.offset}` : ''; - return { orderSQL, limitSQL, offsetSQL }; - } - - private compileManyToManyPrefetch( - node: CompiledHydrationNode, - sourceValues: readonly (string | number)[] - ): CompiledPrefetchQuery { - const placeholders = this.placeholders.list(sourceValues.length); - const throughValidated = sqlSafetyAdapter.validate({ - kind: SqlPlanKind.SELECT, - meta: { - table: node.throughTable!, - pk: node.throughSourceKey!, - columns: { - [node.throughSourceKey!]: node.throughSourceColumnType ?? 'int', - [node.throughTargetKey!]: node.throughTargetColumnType ?? 'int', - }, - }, - filterKeys: [node.throughSourceKey!, node.throughTargetKey!], - relationNames: [], - }); - const ownerAlias = this.validateInternalAlias('__tango_m2m_owner'); - const targetAlias = this.validateInternalAlias('__tango_m2m_target'); - const throughSourceColumn = throughValidated.filterKeys[node.throughSourceKey!]!.field; - const throughTargetColumn = throughValidated.filterKeys[node.throughTargetKey!]!.field; - return { - kind: InternalPrefetchQueryKind.MANY_TO_MANY, - throughSql: `SELECT ${throughValidated.meta.table}.${throughSourceColumn} AS ${ownerAlias}, ${throughValidated.meta.table}.${throughTargetColumn} AS ${targetAlias} FROM ${throughValidated.meta.table} WHERE ${throughValidated.meta.table}.${throughSourceColumn} IN (${placeholders}) ORDER BY ${throughValidated.meta.table}.${throughSourceColumn} ASC, ${throughValidated.meta.table}.${throughTargetColumn} ASC`, - throughParams: sourceValues, - ownerAlias, - targetAlias, - targetTable: node.targetTable, - targetPrimaryKey: node.targetPrimaryKey, - targetColumns: node.targetColumns, - }; - } - - private compileHydrationNode( - node: QueryHydrationPlanNode, - context: { - rootTable: string; - ownerMeta: TableMeta; - ownerAlias: string; - collectRootJoins: boolean; - rootSelectedFields?: readonly string[]; - hiddenRootAliases: string[]; - joinCollection: JoinCollection; - } - ): CompiledHydrationNode { - const validatedRelation = this.validateHydrationRelation(context.ownerMeta, node.relationName); - const targetColumns = validatedRelation.targetColumns; - const targetMeta = node.relationEdge.targetMeta; - if (!targetMeta) { - throw new Error(`Relation path '${node.relationPath}' is missing target metadata.`); - } - const compiledJoinChildren = node.joinChildren.map((child) => - this.compileHydrationNode(child, { - ...context, - ownerMeta: targetMeta, - ownerAlias: this.buildJoinAlias(node.relationPath), - collectRootJoins: context.collectRootJoins, - }) - ); - const compiledPrefetchChildren = node.prefetchChildren.map((child) => - this.compileHydrationNode(child, { - ...context, - ownerMeta: targetMeta, - ownerAlias: this.buildJoinAlias(node.relationPath), - collectRootJoins: false, - }) - ); - - let joinDescriptor: CompiledJoinHydrationDescriptor | undefined; - if (node.loadMode === InternalRelationHydrationLoadMode.JOIN) { - joinDescriptor = { - alias: this.buildJoinAlias(node.relationPath), - columns: Object.fromEntries( - Object.keys(targetColumns).map((column) => [ - column, - this.buildHydrationColumnAlias(node.relationPath, column), - ]) - ), - }; - - if (context.collectRootJoins) { - context.joinCollection.joins.push( - `LEFT JOIN ${validatedRelation.table} ${joinDescriptor.alias} ON ${joinDescriptor.alias}.${validatedRelation.targetKey} = ${context.ownerAlias}.${validatedRelation.sourceKey}` - ); - context.joinCollection.selects.push( - ...Object.entries(joinDescriptor.columns).map( - ([column, alias]) => `${joinDescriptor!.alias}.${column} AS ${alias}` - ) - ); - } - } - - const ownerSourceAccessor = - node.loadMode === InternalRelationHydrationLoadMode.PREFETCH && - context.collectRootJoins === false && - context.ownerAlias === context.rootTable && - context.rootSelectedFields?.length && - !context.rootSelectedFields.includes(validatedRelation.sourceKey) - ? this.buildPrefetchSourceAlias(node.relationPath, validatedRelation.sourceKey) - : validatedRelation.sourceKey; - - if ( - node.loadMode === InternalRelationHydrationLoadMode.PREFETCH && - ownerSourceAccessor !== validatedRelation.sourceKey - ) { - context.hiddenRootAliases.push(ownerSourceAccessor); - } - - return { - nodeId: node.nodeId, - relationName: node.relationName, - relationPath: node.relationPath, - ownerModelKey: node.ownerModelKey, - targetModelKey: node.targetModelKey, - loadMode: node.loadMode, - cardinality: node.cardinality, - sourceKey: validatedRelation.sourceKey, - ownerSourceAccessor, - targetKey: validatedRelation.targetKey, - targetTable: validatedRelation.table, - targetPrimaryKey: node.relationEdge.targetPrimaryKey, - throughTable: node.relationEdge.throughTable, - throughSourceKey: node.relationEdge.throughSourceKey, - throughTargetKey: node.relationEdge.throughTargetKey, - throughSourceColumnType: node.relationEdge.throughSourceColumnType, - throughTargetColumnType: node.relationEdge.throughTargetColumnType, - targetColumns, - provenance: node.provenance, - joinChildren: compiledJoinChildren, - prefetchChildren: compiledPrefetchChildren, - join: joinDescriptor, - }; - } - - private validateHydrationRelation( - ownerMeta: TableMeta, - relationName: string - ): NonNullable[string] { - return sqlSafetyAdapter.validate({ - kind: SqlPlanKind.SELECT, - meta: ownerMeta, - relationNames: [relationName], - }).relations[relationName]!; - } - - private buildRootHiddenSelects(nodes: readonly CompiledHydrationNode[], table: string): string[] { - return nodes.flatMap((node) => { - const select = - node.ownerSourceAccessor !== node.sourceKey - ? [`${table}.${node.sourceKey} AS ${node.ownerSourceAccessor}`] - : []; - return [...select, ...this.buildRootHiddenSelects(node.prefetchChildren, table)]; - }); - } - - private validatePrefetchTarget(node: CompiledHydrationNode): { - table: string; - primaryKey: string; - targetKey: string; - columns: Record; - } { - try { - const validated = sqlSafetyAdapter.validate({ - kind: SqlPlanKind.SELECT, - meta: { - table: node.targetTable, - pk: node.targetPrimaryKey, - columns: node.targetColumns, - }, - filterKeys: [node.targetKey], - }); - - return { - table: validated.meta.table, - primaryKey: validated.meta.pk, - targetKey: validated.filterKeys[node.targetKey]!.field, - columns: validated.meta.columns, - }; - } catch (error) { - const message = isError(error) ? error.message : String(error); - throw new Error(`Compiled prefetch query failed validation: ${message}`, { cause: error }); - } - } - - private collectNestedJoinSql( - node: CompiledHydrationNode, - ownerAlias: string, - ownerColumns: Record, - collection: JoinCollection - ): void { - if (!node.join) { - return; - } - - const validatedTarget = this.validatePrefetchJoinTarget(node, ownerColumns); - const validatedJoinAlias = this.validateInternalAlias(node.join.alias); - const validatedJoinColumns = Object.fromEntries( - Object.entries(node.join.columns).map(([column, alias]) => { - if (!(column in validatedTarget.columns)) { - throw new Error( - `Compiled prefetch query failed validation: unknown nested join column '${column}'.` - ); - } - return [column, this.validateInternalAlias(alias)]; - }) - ); - - collection.joins.push( - `LEFT JOIN ${validatedTarget.table} ${validatedJoinAlias} ON ${validatedJoinAlias}.${validatedTarget.targetKey} = ${ownerAlias}.${node.sourceKey}` - ); - collection.selects.push( - ...Object.entries(validatedJoinColumns).map( - ([column, alias]) => `${validatedJoinAlias}.${column} AS ${alias}` - ) - ); - - for (const child of node.joinChildren) { - this.collectNestedJoinSql(child, validatedJoinAlias, validatedTarget.columns, collection); - } - } - - private validatePrefetchJoinTarget( - node: CompiledHydrationNode, - ownerColumns: Record - ): { - table: string; - primaryKey: string; - targetKey: string; - columns: Record; - } { - if (!(node.sourceKey in ownerColumns)) { - throw new Error( - `Compiled prefetch query failed validation: unknown owner column '${node.sourceKey}' for nested join.` - ); - } - - return this.validatePrefetchTarget(node); - } - - private validateInternalAlias(alias: string): string { - if (!/^__tango_[A-Za-z0-9_]+$/.test(alias)) { - throw new Error(`Compiled prefetch query failed validation: invalid internal alias '${alias}'.`); - } - - return alias; - } - - private buildJoinAlias(relationPath: string): string { - return this.assertInternalAliasDoesNotCollide(`__tango_join_${this.sanitizeRelationPath(relationPath)}`); - } - - private buildPrefetchBaseAlias(relationPath: string): string { - return this.assertInternalAliasDoesNotCollide( - `__tango_prefetch_base_${this.sanitizeRelationPath(relationPath)}` - ); - } - - private buildHydrationColumnAlias(relationPath: string, column: string): string { - return this.assertInternalAliasDoesNotCollide( - `__tango_hydrate_${this.sanitizeRelationPath(relationPath)}_${column}` - ); - } - - private buildPrefetchSourceAlias(relationPath: string, sourceKey: string): string { - return this.assertInternalAliasDoesNotCollide( - `__tango_prefetch_${this.sanitizeRelationPath(relationPath)}_${sourceKey}` - ); - } - - private buildFilterAlias(relationPath: string, suffix: string): string { - return this.assertInternalAliasDoesNotCollide( - `__tango_filter_${this.sanitizeRelationPath(relationPath)}_${suffix}` - ); - } - - private sanitizeRelationPath(relationPath: string): string { - return relationPath.replace(/[^a-zA-Z0-9]+/g, '_'); - } - - private assertInternalAliasDoesNotCollide(alias: string): string { - if (alias in this.meta.columns) { - throw new Error(`Internal query alias '${alias}' collides with a field on table '${this.meta.table}'.`); + let limitSQL: string; + if (options?.existsProbe) { + limitSQL = state.limit === 0 ? ' LIMIT 0' : ' LIMIT 1'; + } else if (state.limit === undefined) { + limitSQL = hasOffset && this.adapter.dialect === InternalDialect.SQLITE ? ' LIMIT -1' : ''; + } else { + limitSQL = ` LIMIT ${state.limit}`; } - return alias; - } - - private compileQNode( - node: QNode, - paramIndex: number, - filterKeys: Record - ): WhereClause { - switch (node.kind) { - case InternalQNodeType.ATOM: - return this.compileAtom(node.where || {}, paramIndex, filterKeys); - case InternalQNodeType.AND: - return this.compileAnd(node.nodes || [], paramIndex, filterKeys); - case InternalQNodeType.OR: - return this.compileOr(node.nodes || [], paramIndex, filterKeys); - case InternalQNodeType.NOT: - return this.compileNot(node.node!, paramIndex, filterKeys); - default: - return { sql: '', params: [] }; - } - } - - private compileAtom( - where: FilterInput, - paramIndex: number, - filterKeys: Record - ): WhereClause { - const entries = Object.entries(where).filter(([, value]) => value !== undefined); - const { parts, params } = entries.reduce<{ parts: string[]; params: unknown[] }>( - (accumulator, [key, value]) => { - const descriptor = filterKeys[String(key)]!; - const idx = paramIndex + accumulator.params.length; - const clause = - descriptor.kind === InternalValidatedFilterDescriptorKind.COLUMN - ? this.lookupToSQL(descriptor.qualifiedColumn, descriptor.lookup, value, idx) - : this.compileRelationFilter(descriptor, value, idx); - accumulator.parts.push(clause.sql); - accumulator.params.push(...clause.params); - return accumulator; - }, - { parts: [], params: [] } - ); - - return { - sql: parts.length ? `(${parts.join(' AND ')})` : '', - params, - }; - } - - private compileAnd( - nodes: QNode[], - paramIndex: number, - filterKeys: Record - ): WhereClause { - const { parts, params } = nodes.reduce<{ parts: string[]; params: unknown[] }>( - (accumulator, node) => { - const result = this.compileQNode(node, paramIndex + accumulator.params.length, filterKeys); - if (result.sql) { - accumulator.parts.push(result.sql); - accumulator.params.push(...result.params); - } - return accumulator; - }, - { parts: [], params: [] } - ); - - return { - sql: parts.length ? `(${parts.join(' AND ')})` : '', - params, - }; - } - - private compileOr( - nodes: QNode[], - paramIndex: number, - filterKeys: Record - ): WhereClause { - const { parts, params } = nodes.reduce<{ parts: string[]; params: unknown[] }>( - (accumulator, node) => { - const result = this.compileQNode(node, paramIndex + accumulator.params.length, filterKeys); - if (result.sql) { - accumulator.parts.push(result.sql); - accumulator.params.push(...result.params); - } - return accumulator; - }, - { parts: [], params: [] } - ); - - return { - sql: parts.length ? `(${parts.join(' OR ')})` : '', - params, - }; - } - - private compileNot( - node: QNode, - paramIndex: number, - filterKeys: Record - ): WhereClause { - const result = this.compileQNode(node, paramIndex, filterKeys); - if (!result.sql) { - return { sql: '', params: [] }; - } - - return { - sql: `(NOT ${result.sql})`, - params: result.params, - }; - } - - private compileRelationFilter( - descriptor: Extract, - value: unknown, - paramIndex: number - ): WhereClause { - return this.buildRelationFilterExists( - this.meta.table, - descriptor.relationChain, - descriptor.terminalColumn, - descriptor.lookup, - value, - paramIndex, - descriptor.relationPath - ); - } - - private buildRelationFilterExists( - ownerAlias: string, - relationChain: readonly ValidatedRelationMeta[], - terminalColumn: string, - lookup: LookupType, - value: unknown, - paramIndex: number, - relationPath: string - ): WhereClause { - const [relation, ...rest] = relationChain; - if (!relation) { - throw new Error(`Cannot compile empty relation filter path '${relationPath}'.`); - } - - const targetAlias = this.buildFilterAlias(relationPath, `target_${relation.alias}_${rest.length}`); - const targetPredicate = - rest.length === 0 - ? this.lookupToSQL(`${targetAlias}.${terminalColumn}`, lookup, value, paramIndex) - : this.buildRelationFilterExists( - targetAlias, - rest, - terminalColumn, - lookup, - value, - paramIndex, - relationPath - ); - - if (relation.throughTable && relation.throughSourceKey && relation.throughTargetKey) { - const throughAlias = this.buildFilterAlias(relationPath, `through_${relation.alias}_${rest.length}`); - return { - sql: `EXISTS (SELECT 1 FROM ${relation.throughTable} ${throughAlias} INNER JOIN ${relation.table} ${targetAlias} ON ${targetAlias}.${relation.targetKey} = ${throughAlias}.${relation.throughTargetKey} WHERE ${throughAlias}.${relation.throughSourceKey} = ${ownerAlias}.${relation.sourceKey} AND ${targetPredicate.sql})`, - params: targetPredicate.params, - }; - } - - return { - sql: `EXISTS (SELECT 1 FROM ${relation.table} ${targetAlias} WHERE ${targetAlias}.${relation.targetKey} = ${ownerAlias}.${relation.sourceKey} AND ${targetPredicate.sql})`, - params: targetPredicate.params, - }; - } - - private lookupToSQL(col: string, lookup: LookupType, value: unknown, paramIndex: number): WhereClause { - const placeholder = this.placeholders.at(paramIndex); - const normalized = this.normalizeParam(value); - - switch (lookup) { - case InternalLookupType.EXACT: - if (value === null) { - return { sql: `${col} IS NULL`, params: [] }; - } - return { sql: `${col} = ${placeholder}`, params: [normalized] }; - case InternalLookupType.LT: - return { sql: `${col} < ${placeholder}`, params: [normalized] }; - case InternalLookupType.LTE: - return { sql: `${col} <= ${placeholder}`, params: [normalized] }; - case InternalLookupType.GT: - return { sql: `${col} > ${placeholder}`, params: [normalized] }; - case InternalLookupType.GTE: - return { sql: `${col} >= ${placeholder}`, params: [normalized] }; - case InternalLookupType.IN: { - const entries = (Array.isArray(value) ? value : [value]).map((entry) => this.normalizeParam(entry)); - if (entries.length === 0) { - return { sql: '1=0', params: [] }; - } - const placeholders = this.placeholders.listFromOffset(entries.length, paramIndex - 1); - return { sql: `${col} IN (${placeholders})`, params: entries }; - } - case InternalLookupType.ISNULL: - return { sql: value ? `${col} IS NULL` : `${col} IS NOT NULL`, params: [] }; - case InternalLookupType.CONTAINS: - return { sql: `${col} LIKE ${placeholder}`, params: [`%${value}%`] }; - case InternalLookupType.ICONTAINS: { - const lowerCol = this.adapter.dialect === InternalDialect.POSTGRES ? `LOWER(${col})` : `${col}`; - return { sql: `${lowerCol} LIKE ${placeholder}`, params: [`%${String(value).toLowerCase()}%`] }; - } - case InternalLookupType.STARTSWITH: - return { sql: `${col} LIKE ${placeholder}`, params: [`${value}%`] }; - case InternalLookupType.ISTARTSWITH: { - const lowerCol = this.adapter.dialect === InternalDialect.POSTGRES ? `LOWER(${col})` : `${col}`; - return { sql: `${lowerCol} LIKE ${placeholder}`, params: [`${String(value).toLowerCase()}%`] }; - } - case InternalLookupType.ENDSWITH: - return { sql: `${col} LIKE ${placeholder}`, params: [`%${value}`] }; - case InternalLookupType.IENDSWITH: { - const lowerCol = this.adapter.dialect === InternalDialect.POSTGRES ? `LOWER(${col})` : `${col}`; - return { sql: `${lowerCol} LIKE ${placeholder}`, params: [`%${String(value).toLowerCase()}`] }; - } - default: - throw new Error(`Unknown lookup: ${lookup}`); - } - } - - private normalizeParam(value: unknown): unknown { - if (this.adapter.dialect === InternalDialect.SQLITE && typeof value === 'boolean') { - return value ? 1 : 0; - } - return value; - } - - private collectStateFilterKeys(state: QuerySetState): string[] { - const filterKeys = new Set(); - if (state.q) { - this.collectNodeFilterKeys(state.q, filterKeys); - } - - state.excludes?.forEach((exclude) => this.collectNodeFilterKeys(exclude, filterKeys)); - return [...filterKeys]; - } - - private collectNodeFilterKeys( - node: QNode, - filterKeys: Set - ): void { - Object.keys(node.where ?? {}).forEach((key) => filterKeys.add(key)); - node.nodes?.forEach((child) => this.collectNodeFilterKeys(child, filterKeys)); - if (node.node) { - this.collectNodeFilterKeys(node.node, filterKeys); - } + const offsetSQL = hasOffset ? ` OFFSET ${state.offset}` : ''; + return { orderSQL, limitSQL, offsetSQL }; } } diff --git a/packages/orm/src/query/compiler/WhereCompiler.ts b/packages/orm/src/query/compiler/WhereCompiler.ts new file mode 100644 index 00000000..5c5d9567 --- /dev/null +++ b/packages/orm/src/query/compiler/WhereCompiler.ts @@ -0,0 +1,289 @@ +import type { Adapter, SqlPlaceholders } from '../../connection/adapters/Adapter'; +import type { LookupType } from '../domain/LookupType'; +import type { FilterInput } from '../domain/FilterInput'; +import type { QNode } from '../domain/QNode'; +import type { QuerySetState } from '../domain/QuerySetState'; +import type { TableMeta } from '../domain/TableMeta'; +import type { WhereClause } from '../domain/WhereClause'; +import { InternalDialect } from '../domain/internal/InternalDialect'; +import { InternalLookupType } from '../domain/internal/InternalLookupType'; +import { InternalQNodeType } from '../domain/internal/InternalQNodeType'; +import { InternalValidatedFilterDescriptorKind } from '../../validation/internal/InternalValidatedFilterDescriptorKind'; +import type { ValidatedFilterDescriptor, ValidatedRelationMeta } from '../../validation/SQLValidationEngine'; + +/** + * Compiles validated `QNode` predicate trees into parameterized WHERE fragments. + */ +export class WhereCompiler { + private readonly placeholders: SqlPlaceholders; + + constructor( + private readonly meta: TableMeta, + private readonly adapter: Adapter + ) { + this.placeholders = adapter.placeholders; + } + + collectStateFilterKeys(state: QuerySetState): string[] { + const filterKeys = new Set(); + if (state.q) { + this.collectNodeFilterKeys(state.q, filterKeys); + } + + state.excludes?.forEach((exclude) => this.collectNodeFilterKeys(exclude, filterKeys)); + return [...filterKeys]; + } + + compileNode( + node: QNode, + paramIndex: number, + filterKeys: Record + ): WhereClause { + switch (node.kind) { + case InternalQNodeType.ATOM: + return this.compileAtom(node.where || {}, paramIndex, filterKeys); + case InternalQNodeType.AND: + return this.compileAnd(node.nodes || [], paramIndex, filterKeys); + case InternalQNodeType.OR: + return this.compileOr(node.nodes || [], paramIndex, filterKeys); + case InternalQNodeType.NOT: + return this.compileNot(node.node!, paramIndex, filterKeys); + default: + return { sql: '', params: [] }; + } + } + + private compileAtom( + where: FilterInput, + paramIndex: number, + filterKeys: Record + ): WhereClause { + const entries = Object.entries(where).filter(([, value]) => value !== undefined); + + const { parts, params } = entries.reduce<{ parts: string[]; params: unknown[] }>( + (accumulator, [key, value]) => { + const descriptor = filterKeys[String(key)]!; + const idx = paramIndex + accumulator.params.length; + const clause = + descriptor.kind === InternalValidatedFilterDescriptorKind.COLUMN + ? this.lookupToSQL(descriptor.qualifiedColumn, descriptor.lookup, value, idx) + : this.compileRelationFilter(descriptor, value, idx); + accumulator.parts.push(clause.sql); + accumulator.params.push(...clause.params); + return accumulator; + }, + { parts: [], params: [] } + ); + + return { + sql: parts.length ? `(${parts.join(' AND ')})` : '', + params, + }; + } + + private compileAnd( + nodes: QNode[], + paramIndex: number, + filterKeys: Record + ): WhereClause { + const { parts, params } = nodes.reduce<{ parts: string[]; params: unknown[] }>( + (accumulator, node) => { + const result = this.compileNode(node, paramIndex + accumulator.params.length, filterKeys); + if (result.sql) { + accumulator.parts.push(result.sql); + accumulator.params.push(...result.params); + } + return accumulator; + }, + { parts: [], params: [] } + ); + + return { + sql: parts.length ? `(${parts.join(' AND ')})` : '', + params, + }; + } + + private compileOr( + nodes: QNode[], + paramIndex: number, + filterKeys: Record + ): WhereClause { + const { parts, params } = nodes.reduce<{ parts: string[]; params: unknown[] }>( + (accumulator, node) => { + const result = this.compileNode(node, paramIndex + accumulator.params.length, filterKeys); + if (result.sql) { + accumulator.parts.push(result.sql); + accumulator.params.push(...result.params); + } + return accumulator; + }, + { parts: [], params: [] } + ); + + return { + sql: parts.length ? `(${parts.join(' OR ')})` : '', + params, + }; + } + + private compileNot( + node: QNode, + paramIndex: number, + filterKeys: Record + ): WhereClause { + const result = this.compileNode(node, paramIndex, filterKeys); + if (!result.sql) { + return { sql: '', params: [] }; + } + + return { + sql: `(NOT ${result.sql})`, + params: result.params, + }; + } + + private compileRelationFilter( + descriptor: Extract, + value: unknown, + paramIndex: number + ): WhereClause { + return this.buildRelationFilterExists( + this.meta.table, + descriptor.relationChain, + descriptor.terminalColumn, + descriptor.lookup, + value, + paramIndex, + descriptor.relationPath + ); + } + + private buildRelationFilterExists( + ownerAlias: string, + relationChain: readonly ValidatedRelationMeta[], + terminalColumn: string, + lookup: LookupType, + value: unknown, + paramIndex: number, + relationPath: string + ): WhereClause { + const [relation, ...rest] = relationChain; + if (!relation) { + throw new Error(`Cannot compile empty relation filter path '${relationPath}'.`); + } + + const targetAlias = this.buildFilterAlias(relationPath, `target_${relation.alias}_${rest.length}`); + const targetPredicate = + rest.length === 0 + ? this.lookupToSQL(`${targetAlias}.${terminalColumn}`, lookup, value, paramIndex) + : this.buildRelationFilterExists( + targetAlias, + rest, + terminalColumn, + lookup, + value, + paramIndex, + relationPath + ); + + if (relation.throughTable && relation.throughSourceKey && relation.throughTargetKey) { + const throughAlias = this.buildFilterAlias(relationPath, `through_${relation.alias}_${rest.length}`); + return { + sql: `EXISTS (SELECT 1 FROM ${relation.throughTable} ${throughAlias} INNER JOIN ${relation.table} ${targetAlias} ON ${targetAlias}.${relation.targetKey} = ${throughAlias}.${relation.throughTargetKey} WHERE ${throughAlias}.${relation.throughSourceKey} = ${ownerAlias}.${relation.sourceKey} AND ${targetPredicate.sql})`, + params: targetPredicate.params, + }; + } + + return { + sql: `EXISTS (SELECT 1 FROM ${relation.table} ${targetAlias} WHERE ${targetAlias}.${relation.targetKey} = ${ownerAlias}.${relation.sourceKey} AND ${targetPredicate.sql})`, + params: targetPredicate.params, + }; + } + + private lookupToSQL(col: string, lookup: LookupType, value: unknown, paramIndex: number): WhereClause { + const placeholder = this.placeholders.at(paramIndex); + const normalized = this.normalizeParam(value); + + switch (lookup) { + case InternalLookupType.EXACT: + if (value === null) { + return { sql: `${col} IS NULL`, params: [] }; + } + return { sql: `${col} = ${placeholder}`, params: [normalized] }; + case InternalLookupType.LT: + return { sql: `${col} < ${placeholder}`, params: [normalized] }; + case InternalLookupType.LTE: + return { sql: `${col} <= ${placeholder}`, params: [normalized] }; + case InternalLookupType.GT: + return { sql: `${col} > ${placeholder}`, params: [normalized] }; + case InternalLookupType.GTE: + return { sql: `${col} >= ${placeholder}`, params: [normalized] }; + case InternalLookupType.IN: { + const entries = (Array.isArray(value) ? value : [value]).map((entry) => this.normalizeParam(entry)); + if (entries.length === 0) { + return { sql: '1=0', params: [] }; + } + const placeholders = this.placeholders.listFromOffset(entries.length, paramIndex - 1); + return { sql: `${col} IN (${placeholders})`, params: entries }; + } + case InternalLookupType.ISNULL: + return { sql: value ? `${col} IS NULL` : `${col} IS NOT NULL`, params: [] }; + case InternalLookupType.CONTAINS: + return { sql: `${col} LIKE ${placeholder}`, params: [`%${value}%`] }; + case InternalLookupType.ICONTAINS: { + const lowerCol = this.adapter.dialect === InternalDialect.POSTGRES ? `LOWER(${col})` : `${col}`; + return { sql: `${lowerCol} LIKE ${placeholder}`, params: [`%${String(value).toLowerCase()}%`] }; + } + case InternalLookupType.STARTSWITH: + return { sql: `${col} LIKE ${placeholder}`, params: [`${value}%`] }; + case InternalLookupType.ISTARTSWITH: { + const lowerCol = this.adapter.dialect === InternalDialect.POSTGRES ? `LOWER(${col})` : `${col}`; + return { sql: `${lowerCol} LIKE ${placeholder}`, params: [`${String(value).toLowerCase()}%`] }; + } + case InternalLookupType.ENDSWITH: + return { sql: `${col} LIKE ${placeholder}`, params: [`%${value}`] }; + case InternalLookupType.IENDSWITH: { + const lowerCol = this.adapter.dialect === InternalDialect.POSTGRES ? `LOWER(${col})` : `${col}`; + return { sql: `${lowerCol} LIKE ${placeholder}`, params: [`%${String(value).toLowerCase()}`] }; + } + default: + throw new Error(`Unknown lookup: ${lookup}`); + } + } + + private normalizeParam(value: unknown): unknown { + if (this.adapter.dialect === InternalDialect.SQLITE && typeof value === 'boolean') { + return value ? 1 : 0; + } + return value; + } + + private buildFilterAlias(relationPath: string, suffix: string): string { + return this.assertInternalAliasDoesNotCollide( + `__tango_filter_${this.sanitizeRelationPath(relationPath)}_${suffix}` + ); + } + + private sanitizeRelationPath(relationPath: string): string { + return relationPath.replace(/[^a-zA-Z0-9]+/gu, '_'); + } + + private assertInternalAliasDoesNotCollide(alias: string): string { + if (alias in this.meta.columns) { + throw new Error(`Internal query alias '${alias}' collides with a field on table '${this.meta.table}'.`); + } + return alias; + } + + private collectNodeFilterKeys( + node: QNode, + filterKeys: Set + ): void { + Object.keys(node.where ?? {}).forEach((key) => filterKeys.add(key)); + node.nodes?.forEach((child) => this.collectNodeFilterKeys(child, filterKeys)); + if (node.node) { + this.collectNodeFilterKeys(node.node, filterKeys); + } + } +} diff --git a/packages/orm/src/query/compiler/tests/HydrationPlanCompiler.test.ts b/packages/orm/src/query/compiler/tests/HydrationPlanCompiler.test.ts new file mode 100644 index 00000000..09f78d80 --- /dev/null +++ b/packages/orm/src/query/compiler/tests/HydrationPlanCompiler.test.ts @@ -0,0 +1,311 @@ +import { describe, expect, it } from 'vitest'; +import { aRelationMeta } from '@danceroutine/tango-testing'; +import { HydrationPlanCompiler } from '../HydrationPlanCompiler'; +import { InternalRelationKind } from '../../domain/internal/InternalRelationKind'; +import { InternalRelationHydrationLoadMode } from '../../domain/RelationMeta'; +import type { TableMeta } from '../../domain/TableMeta'; +import type { QueryHydrationPlanNode, QueryHydrationPlanRoot } from '../../planning'; + +const mockMeta: TableMeta = { + table: 'users', + pk: 'id', + columns: { + id: 'int', + organization_id: 'int', + email: 'text', + name: 'text', + }, +}; + +function aHydrationNode(overrides: Partial = {}): QueryHydrationPlanNode { + const relationEdge = aRelationMeta({ + kind: InternalRelationKind.BELONGS_TO, + table: 'organizations', + alias: 'organization', + sourceKey: 'organization_id', + targetKey: 'id', + targetColumns: { id: 'int', name: 'text' }, + }); + + return { + nodeId: 'organization', + relationName: 'organization', + relationPath: 'organization', + ownerModelKey: 'tests/User', + relationEdge, + targetModelKey: 'tests/Organization', + loadMode: InternalRelationHydrationLoadMode.JOIN, + cardinality: relationEdge.cardinality, + provenance: ['organization'], + joinChildren: [], + prefetchChildren: [], + ...overrides, + }; +} + +function aHydrationPlan(overrides: Partial = {}): QueryHydrationPlanRoot { + return { + joinNodes: [], + prefetchNodes: [], + requestedPaths: [], + ...overrides, + }; +} + +describe(HydrationPlanCompiler, () => { + it('prepares root join hydration for selected relation paths', () => { + const organizationRelation = aRelationMeta({ + kind: InternalRelationKind.BELONGS_TO, + table: 'organizations', + alias: 'organization', + sourceKey: 'organization_id', + targetKey: 'id', + targetColumns: { id: 'int', name: 'text' }, + }); + const compiler = new HydrationPlanCompiler({ + ...mockMeta, + relations: { + organization: organizationRelation, + }, + }); + + const compiled = compiler.compile( + aHydrationPlan({ + joinNodes: [ + aHydrationNode({ + relationEdge: organizationRelation, + }), + ], + requestedPaths: ['organization'], + }), + { rootTable: 'users' } + ); + + expect(compiled.rootJoinSql).toEqual([ + 'LEFT JOIN organizations __tango_join_organization ON __tango_join_organization.id = users.organization_id', + ]); + expect(compiled.rootJoinSelects).toEqual([ + '__tango_join_organization.id AS __tango_hydrate_organization_id', + '__tango_join_organization.name AS __tango_hydrate_organization_name', + ]); + expect(compiled.joinNodes[0]?.join).toEqual({ + alias: '__tango_join_organization', + columns: { + id: '__tango_hydrate_organization_id', + name: '__tango_hydrate_organization_name', + }, + }); + expect(compiled.prefetchNodes).toEqual([]); + }); + + it('adds hidden root selections when projected prefetch source fields are missing', () => { + const postsRelation = aRelationMeta({ + kind: InternalRelationKind.HAS_MANY, + table: 'posts', + alias: 'posts', + sourceKey: 'organization_id', + targetKey: 'organization_id', + targetColumns: { id: 'int', organization_id: 'int', title: 'text' }, + }); + const compiler = new HydrationPlanCompiler({ + ...mockMeta, + relations: { + posts: postsRelation, + }, + }); + + const compiled = compiler.compile( + aHydrationPlan({ + prefetchNodes: [ + aHydrationNode({ + nodeId: 'posts', + relationName: 'posts', + relationPath: 'posts', + relationEdge: postsRelation, + targetModelKey: 'tests/Post', + loadMode: InternalRelationHydrationLoadMode.PREFETCH, + cardinality: postsRelation.cardinality, + provenance: ['posts'], + }), + ], + requestedPaths: ['posts'], + }), + { rootTable: 'users', rootSelectedFields: ['id'] } + ); + + expect(compiled.hiddenRootAliases).toEqual(['__tango_prefetch_posts_organization_id']); + expect(compiled.rootHiddenSelects).toEqual(['users.organization_id AS __tango_prefetch_posts_organization_id']); + expect(compiled.prefetchNodes[0]?.ownerSourceAccessor).toBe('__tango_prefetch_posts_organization_id'); + }); + + it('uses selected root fields directly for prefetch source accessors', () => { + const postsRelation = aRelationMeta({ + kind: InternalRelationKind.HAS_MANY, + table: 'posts', + alias: 'posts', + sourceKey: 'organization_id', + targetKey: 'organization_id', + targetColumns: { id: 'int', organization_id: 'int', title: 'text' }, + }); + const compiler = new HydrationPlanCompiler({ + ...mockMeta, + relations: { + posts: postsRelation, + }, + }); + + const compiled = compiler.compile( + aHydrationPlan({ + prefetchNodes: [ + aHydrationNode({ + nodeId: 'posts', + relationName: 'posts', + relationPath: 'posts', + relationEdge: postsRelation, + targetModelKey: 'tests/Post', + loadMode: InternalRelationHydrationLoadMode.PREFETCH, + cardinality: postsRelation.cardinality, + provenance: ['posts'], + }), + ], + requestedPaths: ['posts'], + }), + { rootTable: 'users', rootSelectedFields: ['organization_id'] } + ); + + expect(compiled.hiddenRootAliases).toEqual([]); + expect(compiled.rootHiddenSelects).toEqual([]); + expect(compiled.prefetchNodes[0]?.ownerSourceAccessor).toBe('organization_id'); + }); + + it('prepares nested prefetch descendants under join hydration paths', () => { + const postsRelation = aRelationMeta({ + kind: InternalRelationKind.HAS_MANY, + table: 'posts', + alias: 'posts', + sourceKey: 'id', + targetKey: 'organization_id', + targetColumns: { id: 'int', organization_id: 'int', title: 'text' }, + }); + const organizationRelation = aRelationMeta({ + kind: InternalRelationKind.BELONGS_TO, + table: 'organizations', + alias: 'organization', + sourceKey: 'organization_id', + targetKey: 'id', + targetColumns: { id: 'int', name: 'text' }, + targetMeta: { + table: 'organizations', + pk: 'id', + columns: { id: 'int', name: 'text' }, + relations: { + posts: postsRelation, + }, + }, + }); + const compiler = new HydrationPlanCompiler({ + ...mockMeta, + relations: { + organization: organizationRelation, + }, + }); + + const compiled = compiler.compile( + aHydrationPlan({ + joinNodes: [ + aHydrationNode({ + relationEdge: organizationRelation, + prefetchChildren: [ + aHydrationNode({ + nodeId: 'organization__posts', + relationName: 'posts', + relationPath: 'organization__posts', + relationEdge: postsRelation, + targetModelKey: 'tests/Post', + loadMode: InternalRelationHydrationLoadMode.PREFETCH, + cardinality: postsRelation.cardinality, + provenance: ['organization__posts'], + }), + ], + }), + ], + requestedPaths: ['organization__posts'], + }), + { rootTable: 'users' } + ); + + const posts = compiled.joinNodes[0]?.prefetchChildren[0]; + expect(posts?.relationPath).toBe('organization__posts'); + expect(posts?.ownerSourceAccessor).toBe('id'); + expect(compiled.rootHiddenSelects).toEqual([]); + }); + + it('rejects relation paths that no longer carry target metadata', () => { + const organizationRelation = aRelationMeta({ + kind: InternalRelationKind.BELONGS_TO, + table: 'organizations', + alias: 'organization', + sourceKey: 'organization_id', + targetKey: 'id', + targetColumns: { id: 'int' }, + }); + const compiler = new HydrationPlanCompiler({ + ...mockMeta, + relations: { + organization: organizationRelation, + }, + }); + + expect(() => + compiler.compile( + aHydrationPlan({ + joinNodes: [ + aHydrationNode({ + relationEdge: { + ...organizationRelation, + targetMeta: undefined, + }, + }), + ], + requestedPaths: ['organization'], + }), + { rootTable: 'users' } + ) + ).toThrow(/missing target metadata/iu); + }); + + it('rejects internal hydration aliases that collide with model fields', () => { + const organizationRelation = aRelationMeta({ + kind: InternalRelationKind.BELONGS_TO, + table: 'organizations', + alias: 'organization', + sourceKey: 'organization_id', + targetKey: 'id', + targetColumns: { id: 'int' }, + }); + const compiler = new HydrationPlanCompiler({ + ...mockMeta, + columns: { + ...mockMeta.columns, + __tango_hydrate_organization_id: 'text', + }, + relations: { + organization: organizationRelation, + }, + }); + + expect(() => + compiler.compile( + aHydrationPlan({ + joinNodes: [ + aHydrationNode({ + relationEdge: organizationRelation, + }), + ], + requestedPaths: ['organization'], + }), + { rootTable: 'users' } + ) + ).toThrow(/internal query alias/iu); + }); +}); diff --git a/packages/orm/src/query/compiler/tests/PrefetchQueryCompiler.test.ts b/packages/orm/src/query/compiler/tests/PrefetchQueryCompiler.test.ts new file mode 100644 index 00000000..39b53a99 --- /dev/null +++ b/packages/orm/src/query/compiler/tests/PrefetchQueryCompiler.test.ts @@ -0,0 +1,354 @@ +import { describe, expect, it } from 'vitest'; +import { anAdapter } from '@danceroutine/tango-testing'; +import { PrefetchQueryCompiler } from '../PrefetchQueryCompiler'; +import type { CompiledHydrationNode } from '../../domain/CompiledQuery'; +import { InternalPrefetchQueryKind } from '../../domain/internal/InternalPrefetchQueryKind'; +import type { TableMeta } from '../../domain/TableMeta'; + +const mockMeta: TableMeta = { + table: 'users', + pk: 'id', + columns: { + id: 'int', + organization_id: 'int', + email: 'text', + name: 'text', + age: 'int', + isActive: 'bool', + }, +}; + +const postgresAdapter = anAdapter({ dialect: 'postgres' }); +const sqliteAdapter = anAdapter({ dialect: 'sqlite' }); + +function compiledPrefetchNode(overrides: Partial = {}): CompiledHydrationNode { + return { + nodeId: 'posts', + relationName: 'posts', + relationPath: 'posts', + ownerModelKey: 'tests/User', + targetModelKey: 'tests/Post', + loadMode: 'prefetch', + cardinality: 'many', + sourceKey: 'id', + ownerSourceAccessor: 'id', + targetKey: 'author_id', + targetTable: 'posts', + targetPrimaryKey: 'id', + targetColumns: { id: 'int', author_id: 'int', title: 'text' }, + provenance: ['posts'], + joinChildren: [], + prefetchChildren: [], + ...overrides, + }; +} + +describe(PrefetchQueryCompiler, () => { + it('compiles validated direct prefetch follow-up queries', () => { + const compiler = new PrefetchQueryCompiler(mockMeta, postgresAdapter); + + expect(compiler.compilePrefetch(compiledPrefetchNode(), [1, 2])).toEqual({ + kind: InternalPrefetchQueryKind.DIRECT, + sql: 'SELECT __tango_prefetch_base_posts.id AS id, __tango_prefetch_base_posts.author_id AS author_id, __tango_prefetch_base_posts.title AS title FROM posts __tango_prefetch_base_posts WHERE __tango_prefetch_base_posts.author_id IN ($1, $2) ORDER BY __tango_prefetch_base_posts.author_id ASC, __tango_prefetch_base_posts.id ASC', + params: [1, 2], + targetKey: 'author_id', + targetColumns: { id: 'int', author_id: 'int', title: 'text' }, + }); + }); + + it('splits many-to-many prefetch into a through-table read and target primary-key read', () => { + const compiler = new PrefetchQueryCompiler(mockMeta, postgresAdapter); + const compiled = compiler.compilePrefetch( + compiledPrefetchNode({ + targetTable: 'tags', + targetPrimaryKey: 'id', + targetColumns: { id: 'int', name: 'text' }, + throughTable: 'post_tags', + throughSourceKey: 'post_id', + throughTargetKey: 'tag_id', + }), + [1, 2] + ); + + expect(compiled.kind).toBe(InternalPrefetchQueryKind.MANY_TO_MANY); + if (compiled.kind !== InternalPrefetchQueryKind.MANY_TO_MANY) { + throw new Error('Expected many-to-many compilation'); + } + expect(compiled.throughSql).toContain('FROM post_tags'); + expect(compiled.throughSql).toContain('ORDER BY post_tags.post_id ASC'); + + const targets = compiler.compileManyToManyTargets( + compiledPrefetchNode({ + targetTable: compiled.targetTable, + targetPrimaryKey: compiled.targetPrimaryKey, + targetColumns: compiled.targetColumns, + targetKey: compiled.targetPrimaryKey, + }), + [10, 11] + ); + expect(targets.sql).toContain('FROM tags'); + expect(targets.sql).toContain('WHERE __tango_prefetch_base_posts.id IN ($1, $2)'); + }); + + describe.each([ + { dialect: 'postgres' as const, adapter: postgresAdapter, expectedPlaceholders: 'IN ($1, $2)' }, + { dialect: 'sqlite' as const, adapter: sqliteAdapter, expectedPlaceholders: 'IN (?, ?)' }, + ])('uses $dialect placeholders for many-to-many prefetch queries', ({ adapter, expectedPlaceholders }) => { + it('scopes the through-table read to owner ids', () => { + const compiler = new PrefetchQueryCompiler(mockMeta, adapter); + const compiled = compiler.compilePrefetch( + compiledPrefetchNode({ + targetTable: 'tags', + targetPrimaryKey: 'id', + targetColumns: { id: 'int', name: 'text' }, + throughTable: 'post_tags', + throughSourceKey: 'post_id', + throughTargetKey: 'tag_id', + }), + [1, 2] + ); + + expect(compiled.kind).toBe(InternalPrefetchQueryKind.MANY_TO_MANY); + if (compiled.kind !== InternalPrefetchQueryKind.MANY_TO_MANY) { + throw new Error('Expected many-to-many compilation'); + } + expect(compiled.throughSql).toContain(expectedPlaceholders); + }); + + it('scopes the follow-up target read to resolved target ids', () => { + const compiler = new PrefetchQueryCompiler(mockMeta, adapter); + const targets = compiler.compileManyToManyTargets( + compiledPrefetchNode({ + targetTable: 'tags', + targetPrimaryKey: 'id', + targetKey: 'id', + targetColumns: { id: 'int', name: 'text' }, + }), + [10, 11] + ); + + expect(targets.sql).toContain(expectedPlaceholders); + }); + }); + + it('includes nested joins when compiling direct and many-to-many target queries', () => { + const compiler = new PrefetchQueryCompiler(mockMeta, postgresAdapter); + const node = compiledPrefetchNode({ + targetTable: 'posts', + targetPrimaryKey: 'id', + targetKey: 'id', + targetColumns: { id: 'int', author_id: 'int', title: 'text' }, + joinChildren: [ + compiledPrefetchNode({ + relationName: 'author', + relationPath: 'posts__author', + loadMode: 'join', + cardinality: 'single', + sourceKey: 'author_id', + targetKey: 'id', + targetTable: 'authors', + targetPrimaryKey: 'id', + targetModelKey: 'tests/Author', + targetColumns: { id: 'int', team_id: 'int', name: 'text' }, + join: { + alias: '__tango_join_posts_author', + columns: { + id: '__tango_hydrate_posts_author_id', + name: '__tango_hydrate_posts_author_name', + }, + }, + joinChildren: [ + compiledPrefetchNode({ + relationName: 'team', + relationPath: 'posts__author__team', + loadMode: 'join', + cardinality: 'single', + sourceKey: 'team_id', + targetKey: 'id', + targetTable: 'teams', + targetPrimaryKey: 'id', + targetModelKey: 'tests/Team', + targetColumns: { id: 'int', name: 'text' }, + join: { + alias: '__tango_join_posts_author_team', + columns: { + id: '__tango_hydrate_posts_author_team_id', + name: '__tango_hydrate_posts_author_team_name', + }, + }, + }), + ], + }), + ], + }); + + const direct = compiler.compilePrefetch(node, [1]); + const targets = compiler.compileManyToManyTargets(node, [1]); + + if (direct.kind !== InternalPrefetchQueryKind.DIRECT) { + throw new Error('Expected direct compilation'); + } + expect(direct.sql).toContain('LEFT JOIN authors __tango_join_posts_author'); + expect(direct.sql).toContain('LEFT JOIN teams __tango_join_posts_author_team'); + expect(targets.sql).toContain('LEFT JOIN authors __tango_join_posts_author'); + expect(targets.sql).toContain('LEFT JOIN teams __tango_join_posts_author_team'); + }); + + it('ignores nested join SQL collection for nodes without join descriptors', () => { + const compiler = new PrefetchQueryCompiler(mockMeta, postgresAdapter); + const result = compiler.compilePrefetch( + compiledPrefetchNode({ + joinChildren: [compiledPrefetchNode()], + }), + [1] + ); + + if (result.kind !== InternalPrefetchQueryKind.DIRECT) { + throw new Error('Expected direct compilation'); + } + expect(result.sql).not.toContain('LEFT JOIN'); + }); + + it('rejects prefetch follow-up queries when compiled metadata fails validation', () => { + const compiler = new PrefetchQueryCompiler(mockMeta, sqliteAdapter); + + expect(() => + compiler.compilePrefetch( + compiledPrefetchNode({ + targetTable: 'posts; DROP TABLE users;', + targetColumns: { id: 'int', author_id: 'int' }, + }), + [1] + ) + ).toThrow(/failed validation/iu); + }); + + it('surfaces non-Error validation failures from compiled prefetch metadata', () => { + const compiler = new PrefetchQueryCompiler(mockMeta, postgresAdapter); + const nonError = { + [Symbol.toPrimitive]() { + return 'boom'; + }, + }; + + expect(() => + compiler.compilePrefetch( + Object.defineProperty(compiledPrefetchNode(), 'targetTable', { + get() { + // oxlint-disable-next-line no-throw-literal -- Exercises non-Error metadata getter failures. + throw nonError; + }, + }), + [1] + ) + ).toThrow(/boom/u); + }); + + it('rejects nested prefetch join metadata when a compiled child node is mutated', () => { + const compiler = new PrefetchQueryCompiler(mockMeta, postgresAdapter); + + expect(() => + compiler.compilePrefetch( + compiledPrefetchNode({ + joinChildren: [ + compiledPrefetchNode({ + relationName: 'author', + relationPath: 'posts__author', + loadMode: 'join', + cardinality: 'single', + sourceKey: 'author_id', + targetKey: 'id', + targetTable: 'authors', + targetModelKey: 'tests/Author', + targetColumns: { id: 'int', name: 'text' }, + join: { + alias: '__tango_join_posts_author; DROP TABLE users;', + columns: { + id: '__tango_hydrate_posts_author_id', + name: '__tango_hydrate_posts_author_name', + }, + }, + }), + ], + }), + [1] + ) + ).toThrow(/failed validation/iu); + }); + + it('rejects nested prefetch joins whose owner column is not present on the parent target', () => { + const compiler = new PrefetchQueryCompiler(mockMeta, postgresAdapter); + + expect(() => + compiler.compilePrefetch( + compiledPrefetchNode({ + joinChildren: [ + compiledPrefetchNode({ + relationName: 'author', + relationPath: 'posts__author', + loadMode: 'join', + cardinality: 'single', + sourceKey: 'missing_owner_column', + targetKey: 'id', + targetTable: 'authors', + targetColumns: { id: 'int', name: 'text' }, + join: { + alias: '__tango_join_posts_author', + columns: { + id: '__tango_hydrate_posts_author_id', + name: '__tango_hydrate_posts_author_name', + }, + }, + }), + ], + }), + [1] + ) + ).toThrow(/unknown owner column/iu); + }); + + it('rejects nested prefetch joins whose projected columns are not present on the target', () => { + const compiler = new PrefetchQueryCompiler(mockMeta, postgresAdapter); + + expect(() => + compiler.compilePrefetch( + compiledPrefetchNode({ + joinChildren: [ + compiledPrefetchNode({ + relationName: 'author', + relationPath: 'posts__author', + loadMode: 'join', + cardinality: 'single', + sourceKey: 'author_id', + targetKey: 'id', + targetTable: 'authors', + targetColumns: { id: 'int', name: 'text' }, + join: { + alias: '__tango_join_posts_author', + columns: { + missing: '__tango_hydrate_posts_author_missing', + }, + }, + }), + ], + }), + [1] + ) + ).toThrow(/unknown nested join column/iu); + }); + + it('rejects generated prefetch aliases that collide with model fields', () => { + const compiler = new PrefetchQueryCompiler( + { + ...mockMeta, + columns: { + ...mockMeta.columns, + __tango_prefetch_base_posts: 'text', + }, + }, + postgresAdapter + ); + + expect(() => compiler.compilePrefetch(compiledPrefetchNode(), [1])).toThrow(/internal query alias/iu); + }); +}); diff --git a/packages/orm/src/query/compiler/tests/QueryCompiler.test.ts b/packages/orm/src/query/compiler/tests/QueryCompiler.test.ts index aab89c30..716159b7 100644 --- a/packages/orm/src/query/compiler/tests/QueryCompiler.test.ts +++ b/packages/orm/src/query/compiler/tests/QueryCompiler.test.ts @@ -1,11 +1,8 @@ import { describe, it, expect } from 'vitest'; import { anAdapter, aRelationMeta } from '@danceroutine/tango-testing'; import { QueryCompiler } from '../QueryCompiler'; -import { Q } from '../..'; -import type { QNode } from '../../domain/QNode'; import type { TableMeta } from '../../domain/TableMeta'; import type { CompiledHydrationNode } from '../../domain/CompiledQuery'; -import type { QueryHydrationPlanNode } from '../../planning'; import { sqlInjectionRejectCases, sqlInjectionValueCases, @@ -232,23 +229,6 @@ describe(QueryCompiler, () => { expect(targets.sql).toContain('LEFT JOIN authors __tango_join_posts_author'); }); - it('throws when relation-filter compilation is asked to traverse an empty relation chain', () => { - const compiler = new QueryCompiler(mockMeta, postgresAdapter) as unknown as { - buildRelationFilterExists: ( - ownerAlias: string, - relationChain: readonly unknown[], - terminalColumn: string, - lookup: 'exact', - value: unknown, - paramIndex: number, - relationPath: string - ) => unknown; - }; - - expect(() => - compiler.buildRelationFilterExists('users', [], 'name', 'exact', 'pedro', 1, 'organization') - ).toThrow(/cannot compile empty relation filter path/i); - }); it('identifies matching instances', () => { const compiler = new QueryCompiler(mockMeta, postgresAdapter); expect(QueryCompiler.isQueryCompiler(compiler)).toBe(true); @@ -267,333 +247,6 @@ describe(QueryCompiler, () => { expect(result.params).toEqual(['test@example.com']); }); - it('compiles null value as IS NULL', () => { - const state = { - q: { kind: 'atom' as const, where: { email: null } }, - }; - - const result = new QueryCompiler(mockMeta, postgresAdapter).compile(state); - - expect(result.sql).toContain('users.email IS NULL'); - expect(result.params).toEqual([]); - }); - - it('compiles single-valued relation-path filters through correlated EXISTS clauses', () => { - const compiler = new QueryCompiler( - { - ...mockMeta, - relations: { - organization: aRelationMeta({ - kind: InternalRelationKind.BELONGS_TO, - table: 'organizations', - sourceKey: 'organization_id', - targetKey: 'id', - targetPrimaryKey: 'id', - targetColumns: { id: 'int', name: 'text' }, - alias: 'organizations', - targetMeta: { - table: 'organizations', - pk: 'id', - columns: { id: 'int', name: 'text' }, - }, - }), - }, - }, - postgresAdapter - ); - - const result = compiler.compile({ - q: { kind: 'atom', where: { organization__name__icontains: 'dance' } }, - }); - - expect(result.sql).toContain('EXISTS (SELECT 1 FROM organizations'); - expect(result.sql).toContain('LOWER(__tango_filter_organization_target_organizations_0.name) LIKE $1'); - expect(result.params).toEqual(['%dance%']); - }); - - it('compiles nested single-valued relation-path filters through chained correlated EXISTS clauses', () => { - const compiler = new QueryCompiler( - { - ...mockMeta, - relations: { - organization: aRelationMeta({ - kind: InternalRelationKind.BELONGS_TO, - table: 'organizations', - sourceKey: 'organization_id', - targetKey: 'id', - targetPrimaryKey: 'id', - targetColumns: { id: 'int', parent_id: 'int', name: 'text' }, - alias: 'organizations', - targetMeta: { - table: 'organizations', - pk: 'id', - columns: { id: 'int', parent_id: 'int', name: 'text' }, - relations: { - parent: aRelationMeta({ - kind: InternalRelationKind.BELONGS_TO, - table: 'organizations', - sourceKey: 'parent_id', - targetKey: 'id', - targetPrimaryKey: 'id', - targetColumns: { id: 'int', name: 'text' }, - alias: 'parent', - targetMeta: { - table: 'organizations', - pk: 'id', - columns: { id: 'int', name: 'text' }, - }, - }), - }, - }, - }), - }, - }, - postgresAdapter - ); - - const result = compiler.compile({ - q: { kind: 'atom', where: { organization__parent__name__icontains: 'core' } }, - }); - - expect(result.sql).toContain( - 'EXISTS (SELECT 1 FROM organizations __tango_filter_organization_parent_target_organizations_1' - ); - expect(result.sql).toContain( - 'EXISTS (SELECT 1 FROM organizations __tango_filter_organization_parent_target_parent_0' - ); - expect(result.sql).toContain('LOWER(__tango_filter_organization_parent_target_parent_0.name) LIKE $1'); - expect(result.params).toEqual(['%core%']); - }); - - it('compiles many-to-many relation-path filters through correlated EXISTS clauses', () => { - const compiler = new QueryCompiler( - { - table: 'posts', - pk: 'id', - columns: { - id: 'int', - title: 'text', - }, - relations: { - tags: { - kind: InternalRelationKind.MANY_TO_MANY, - edgeId: 'posts:tags', - sourceModelKey: 'tests/Post', - targetModelKey: 'tests/Tag', - cardinality: 'many', - capabilities: { - queryable: true, - hydratable: true, - joinable: false, - prefetchable: true, - }, - table: 'tags', - sourceKey: 'id', - targetKey: 'id', - throughTable: 'post_tags', - throughSourceKey: 'post_id', - throughTargetKey: 'tag_id', - targetPrimaryKey: 'id', - targetColumns: { id: 'int', slug: 'text' }, - alias: 'tags', - targetMeta: { - table: 'tags', - pk: 'id', - columns: { id: 'int', slug: 'text' }, - }, - }, - }, - }, - postgresAdapter - ); - - const result = compiler.compile({ - q: { kind: 'atom', where: { tags__slug: 'tango' } }, - }); - - expect(result.sql).toContain('EXISTS (SELECT 1 FROM post_tags'); - expect(result.sql).toContain('INNER JOIN tags'); - expect(result.sql).toContain('__tango_filter_tags_target_tags_0.slug = $1'); - expect(result.params).toEqual(['tango']); - }); - - it('compiles lt lookup', () => { - const state = { - q: { kind: 'atom' as const, where: { age__lt: 30 } }, - }; - - const result = new QueryCompiler(mockMeta, postgresAdapter).compile(state); - - expect(result.sql).toContain('users.age < $1'); - expect(result.params).toEqual([30]); - }); - - it('compiles gte lookup', () => { - const state = { - q: { kind: 'atom' as const, where: { age__gte: 18 } }, - }; - - const result = new QueryCompiler(mockMeta, postgresAdapter).compile(state); - - expect(result.sql).toContain('users.age >= $1'); - expect(result.params).toEqual([18]); - }); - - it('compiles lte and gt lookups', () => { - const lteState = { - q: { kind: 'atom' as const, where: { age__lte: 65 } }, - }; - const gtState = { - q: { kind: 'atom' as const, where: { age__gt: 21 } }, - }; - - const lte = new QueryCompiler(mockMeta, postgresAdapter).compile(lteState); - const gt = new QueryCompiler(mockMeta, postgresAdapter).compile(gtState); - - expect(lte.sql).toContain('users.age <= $1'); - expect(gt.sql).toContain('users.age > $1'); - }); - - it('compiles in lookup', () => { - const state = { - q: { kind: 'atom' as const, where: { id__in: [1, 2, 3] } }, - }; - - const result = new QueryCompiler(mockMeta, postgresAdapter).compile(state); - - expect(result.sql).toContain('users.id IN ($1, $2, $3)'); - expect(result.params).toEqual([1, 2, 3]); - }); - - it('compiles empty in as always false', () => { - const state = { - q: { kind: 'atom' as const, where: { id__in: [] } }, - }; - - const result = new QueryCompiler(mockMeta, postgresAdapter).compile(state); - - expect(result.sql).toContain('1=0'); - }); - - it('compiles isnull lookup', () => { - const state = { - q: { kind: 'atom' as const, where: { email__isnull: true } }, - }; - - const result = new QueryCompiler(mockMeta, postgresAdapter).compile(state); - - expect(result.sql).toContain('users.email IS NULL'); - }); - - it('compiles isnull false', () => { - const state = { - q: { kind: 'atom' as const, where: { email__isnull: false } }, - }; - - const result = new QueryCompiler(mockMeta, postgresAdapter).compile(state); - - expect(result.sql).toContain('users.email IS NOT NULL'); - }); - - it('compiles contains lookup', () => { - const state = { - q: { kind: 'atom' as const, where: { name__contains: 'John' } }, - }; - - const result = new QueryCompiler(mockMeta, postgresAdapter).compile(state); - - expect(result.sql).toContain('users.name LIKE $1'); - expect(result.params).toEqual(['%John%']); - }); - - it('compiles icontains lookup', () => { - const state = { - q: { kind: 'atom' as const, where: { name__icontains: 'JOHN' } }, - }; - - const result = new QueryCompiler(mockMeta, postgresAdapter).compile(state); - - expect(result.sql).toContain('LOWER(users.name) LIKE $1'); - expect(result.params).toEqual(['%john%']); - }); - - it('compiles startswith lookup', () => { - const state = { - q: { kind: 'atom' as const, where: { email__startswith: 'admin' } }, - }; - - const result = new QueryCompiler(mockMeta, postgresAdapter).compile(state); - - expect(result.sql).toContain('users.email LIKE $1'); - expect(result.params).toEqual(['admin%']); - }); - - it('compiles endswith lookup', () => { - const state = { - q: { kind: 'atom' as const, where: { email__endswith: '.com' } }, - }; - - const result = new QueryCompiler(mockMeta, postgresAdapter).compile(state); - - expect(result.sql).toContain('users.email LIKE $1'); - expect(result.params).toEqual(['%.com']); - }); - - it('compiles case-insensitive startswith and endswith lookups', () => { - const startsWithState = { - q: { kind: 'atom' as const, where: { email__istartswith: 'ADMIN' } }, - }; - const endsWithState = { - q: { kind: 'atom' as const, where: { email__iendswith: '.COM' } }, - }; - - const startsWith = new QueryCompiler(mockMeta, postgresAdapter).compile(startsWithState); - const endsWith = new QueryCompiler(mockMeta, postgresAdapter).compile(endsWithState); - - expect(startsWith.sql).toContain('LOWER(users.email) LIKE $1'); - expect(startsWith.params).toEqual(['admin%']); - expect(endsWith.sql).toContain('LOWER(users.email) LIKE $1'); - expect(endsWith.params).toEqual(['%.com']); - }); - - it('compiles AND node', () => { - const state = { - q: Q.and({ email: 'test@example.com' }, { age__gte: 18 }), - }; - - const result = new QueryCompiler(mockMeta, postgresAdapter).compile(state); - - expect(result.sql).toContain('users.email = $1'); - expect(result.sql).toContain('users.age >= $2'); - expect(result.sql).toContain('AND'); - expect(result.params).toEqual(['test@example.com', 18]); - }); - - it('compiles OR node', () => { - const state = { - q: Q.or({ email: 'test@example.com' }, { email: 'admin@example.com' }), - }; - - const result = new QueryCompiler(mockMeta, postgresAdapter).compile(state); - - expect(result.sql).toContain('users.email = $1'); - expect(result.sql).toContain('users.email = $2'); - expect(result.sql).toContain('OR'); - expect(result.params).toEqual(['test@example.com', 'admin@example.com']); - }); - - it('compiles NOT node', () => { - const state = { - q: Q.not({ email: 'test@example.com' }), - }; - - const result = new QueryCompiler(mockMeta, postgresAdapter).compile(state); - - expect(result.sql).toContain('NOT'); - expect(result.sql).toContain('users.email = $1'); - expect(result.params).toEqual(['test@example.com']); - }); - it('compiles order by', () => { const state = { order: [ @@ -712,27 +365,6 @@ describe(QueryCompiler, () => { }); describe('SQLite', () => { - it('uses SQLite placeholders', () => { - const state = { - q: { kind: 'atom' as const, where: { email: 'test@example.com' } }, - }; - - const result = new QueryCompiler(mockMeta, sqliteAdapter).compile(state); - - expect(result.sql).toContain('users.email = ?'); - expect(result.params).toEqual(['test@example.com']); - }); - - it('uses SQLite placeholders for IN lookups', () => { - const state = { - q: { kind: 'atom' as const, where: { id__in: [1, 2] } }, - }; - - const result = new QueryCompiler(mockMeta, sqliteAdapter).compile(state); - expect(result.sql).toContain('users.id IN (?, ?)'); - expect(result.params).toEqual([1, 2]); - }); - it('uses SQLite unlimited limit syntax when offset is set without a limit', () => { const state = { offset: 0, @@ -754,42 +386,6 @@ describe(QueryCompiler, () => { expect(result.sql).toContain('LIMIT 10 OFFSET 0'); }); - it('normalizes sqlite booleans and supports non-array IN values', () => { - const result = new QueryCompiler(mockMeta, sqliteAdapter).compile({ - q: Q.and({ isActive: true }, { id__in: 9 }), - }); - - expect(result.sql).toContain('users.isActive = ?'); - expect(result.sql).toContain('users.id IN (?)'); - expect(result.params).toEqual([1, 9]); - }); - - it('uses sqlite non-lowercase columns for case-insensitive lookups', () => { - const contains = new QueryCompiler(mockMeta, sqliteAdapter).compile({ - q: { kind: 'atom', where: { email__icontains: 'ADMIN' } }, - }); - const startsWith = new QueryCompiler(mockMeta, sqliteAdapter).compile({ - q: { kind: 'atom', where: { email__istartswith: 'ADMIN' } }, - }); - const endsWith = new QueryCompiler(mockMeta, sqliteAdapter).compile({ - q: { kind: 'atom', where: { email__iendswith: '.COM' } }, - }); - - expect(contains.sql).toContain('users.email LIKE ?'); - expect(contains.params).toEqual(['%admin%']); - expect(startsWith.sql).toContain('users.email LIKE ?'); - expect(startsWith.params).toEqual(['admin%']); - expect(endsWith.sql).toContain('users.email LIKE ?'); - expect(endsWith.params).toEqual(['%.com']); - }); - - it('normalizes sqlite false booleans to zero', () => { - const result = new QueryCompiler(mockMeta, sqliteAdapter).compile({ - q: { kind: 'atom', where: { isActive: false } }, - }); - expect(result.params).toEqual([0]); - }); - it.each(sqlInjectionValueCases)('$id keeps $category payloads parameterized in sqlite filters', (testCase) => { const result = new QueryCompiler(mockMeta, sqliteAdapter).compile({ q: { kind: 'atom', where: { email: testCase.payload } }, @@ -827,7 +423,7 @@ describe(QueryCompiler, () => { ); const result = compiler.compile({ - q: Q.and({ email: 'test@example.com' }, { age__gte: 18 }), + q: { kind: 'atom', where: { email: 'test@example.com' } }, excludes: [{ kind: 'atom', where: { name__contains: 'spam' } }], selectRelated: ['organization'], select: ['id'], @@ -839,7 +435,7 @@ describe(QueryCompiler, () => { ); expect(result.sql).toContain('WHERE'); expect(result.sql).toContain('NOT'); - expect(result.params).toEqual(['test@example.com', 18, '%spam%']); + expect(result.params).toEqual(['test@example.com', '%spam%']); }); it('rejects relation names used with the wrong eager-loading method', () => { @@ -1082,85 +678,6 @@ describe(QueryCompiler, () => { ).toThrow(/failed validation/i); }); - it('rejects compiled hydration nodes that no longer carry target metadata', () => { - const compiler = new QueryCompiler(mockMeta, postgresAdapter) as unknown as { - compileHydrationNode: ( - node: QueryHydrationPlanNode, - context: { - rootTable: string; - ownerMeta: TableMeta; - ownerAlias: string; - collectRootJoins: boolean; - hiddenRootAliases: string[]; - joinCollection: { selects: string[]; joins: string[] }; - } - ) => unknown; - }; - - expect(() => - compiler.compileHydrationNode( - { - nodeId: 'organization', - relationName: 'organization', - relationPath: 'organization', - ownerModelKey: 'tests/User', - relationEdge: { - ...aRelationMeta({ - kind: InternalRelationKind.BELONGS_TO, - table: 'organizations', - alias: 'organization', - sourceKey: 'organization_id', - targetKey: 'id', - targetColumns: { id: 'int' }, - }), - targetMeta: undefined, - }, - targetModelKey: 'tests/Organization', - loadMode: 'join', - cardinality: 'single', - provenance: ['organization'], - joinChildren: [], - prefetchChildren: [], - }, - { - rootTable: 'users', - ownerMeta: { - ...mockMeta, - relations: { - organization: aRelationMeta({ - kind: InternalRelationKind.BELONGS_TO, - table: 'organizations', - alias: 'organization', - sourceKey: 'organization_id', - targetKey: 'id', - targetColumns: { id: 'int' }, - }), - }, - }, - ownerAlias: 'users', - collectRootJoins: true, - hiddenRootAliases: [], - joinCollection: { selects: [], joins: [] }, - } - ) - ).toThrow(/missing target metadata/i); - }); - - it('ignores nested join SQL collection for nodes without join descriptors', () => { - const compiler = new QueryCompiler(mockMeta, postgresAdapter) as unknown as { - collectNestedJoinSql: ( - node: CompiledHydrationNode, - ownerAlias: string, - collection: { selects: string[]; joins: string[] } - ) => void; - }; - const collection = { selects: [], joins: [] }; - - compiler.collectNestedJoinSql(compiledPrefetchNode(), 'users', collection); - - expect(collection).toEqual({ selects: [], joins: [] }); - }); - it('rejects nested prefetch joins whose owner column is not present on the parent target', () => { const compiler = new QueryCompiler(mockMeta, postgresAdapter); @@ -1222,22 +739,6 @@ describe(QueryCompiler, () => { ).toThrow(/unknown nested join column/i); }); - it('surfaces non-Error validation failures from compiled prefetch metadata', () => { - const compiler = new QueryCompiler(mockMeta, postgresAdapter) as unknown as { - validatePrefetchTarget: (node: CompiledHydrationNode) => unknown; - }; - - expect(() => - compiler.validatePrefetchTarget( - Object.defineProperty(compiledPrefetchNode(), 'targetTable', { - get() { - throw 'boom'; - }, - }) - ) - ).toThrow(/boom/); - }); - it('ignores empty excludes that produce no SQL', () => { const compiler = new QueryCompiler(mockMeta, postgresAdapter); const result = compiler.compile({ @@ -1255,91 +756,6 @@ describe(QueryCompiler, () => { ); }); - it('defensively rejects unsupported lookup values during SQL rendering', () => { - const compiler = new QueryCompiler(mockMeta, postgresAdapter) as unknown as { - lookupToSQL: (column: string, lookup: string, value: unknown, paramIndex: number) => unknown; - }; - - expect(() => compiler.lookupToSQL('users.email', 'wat', 'payload', 1)).toThrow('Unknown lookup: wat'); - }); - - it('ignores q-nodes with unknown kind', () => { - const compiler = new QueryCompiler(mockMeta, postgresAdapter); - const result = compiler.compile({ - q: { - kind: 'unknown_kind' as unknown as 'atom', - } as unknown as Parameters[0]['q'], - }); - - expect(result.sql).toContain('SELECT users.* FROM users'); - expect(result.params).toEqual([]); - }); - - it('returns empty sql for NOT nodes that compile to empty clauses', () => { - const compiler = new QueryCompiler(mockMeta, postgresAdapter); - const result = compiler.compile({ - q: Q.not({ email: undefined }), - }); - - expect(result.sql).toContain('SELECT users.* FROM users'); - expect(result.sql).not.toContain('WHERE'); - }); - - it('drops empty AND/OR children from compiled predicates', () => { - const compiler = new QueryCompiler(mockMeta, postgresAdapter); - const andResult = compiler.compile({ - q: { - kind: 'and', - nodes: [ - { kind: 'atom', where: { email: undefined } }, - { kind: 'atom', where: { id: 1 } }, - ] as QNode[], - }, - }); - const orResult = compiler.compile({ - q: { - kind: 'or', - nodes: [ - { kind: 'atom', where: { email: undefined } }, - { kind: 'atom', where: { id: 2 } }, - ] as QNode[], - }, - }); - - expect(andResult.sql).toContain('users.id = $1'); - expect(andResult.params).toEqual([1]); - expect(orResult.sql).toContain('users.id = $1'); - expect(orResult.params).toEqual([2]); - }); - - it('returns empty predicates for fully empty AND/OR nodes', () => { - const compiler = new QueryCompiler(mockMeta, postgresAdapter); - const andResult = compiler.compile({ - q: { kind: 'and', nodes: [{ kind: 'atom', where: { email: undefined } }] }, - }); - const orResult = compiler.compile({ - q: { kind: 'or', nodes: [{ kind: 'atom', where: { email: undefined } }] }, - }); - - expect(andResult.sql).not.toContain('WHERE'); - expect(orResult.sql).not.toContain('WHERE'); - }); - - it('handles AND/OR nodes without nodes arrays', () => { - const compiler = new QueryCompiler(mockMeta, postgresAdapter); - const andResult = compiler.compile({ q: { kind: 'and' } }); - const orResult = compiler.compile({ q: { kind: 'or' } }); - expect(andResult.sql).not.toContain('WHERE'); - expect(orResult.sql).not.toContain('WHERE'); - }); - - it('handles ATOM nodes without where objects', () => { - const compiler = new QueryCompiler(mockMeta, postgresAdapter); - const result = compiler.compile({ q: { kind: 'atom' } }); - expect(result.sql).not.toContain('WHERE'); - expect(result.params).toEqual([]); - }); - it('rejects suspicious identifiers before emitting SQL', () => { const compiler = new QueryCompiler( { diff --git a/packages/orm/src/query/compiler/tests/WhereCompiler.test.ts b/packages/orm/src/query/compiler/tests/WhereCompiler.test.ts new file mode 100644 index 00000000..753fc043 --- /dev/null +++ b/packages/orm/src/query/compiler/tests/WhereCompiler.test.ts @@ -0,0 +1,460 @@ +import { describe, expect, it } from 'vitest'; +import { anAdapter, aRelationMeta } from '@danceroutine/tango-testing'; +import { Q } from '../..'; +import { WhereCompiler } from '../WhereCompiler'; +import type { Adapter } from '../../../connection'; +import { OrmSqlSafetyAdapter } from '../../../validation'; +import { InternalSqlValidationPlanKind as SqlPlanKind } from '../../../validation/internal/InternalSqlValidationPlanKind'; +import { InternalValidatedFilterDescriptorKind } from '../../../validation/internal/InternalValidatedFilterDescriptorKind'; +import type { ValidatedFilterDescriptor } from '../../../validation/SQLValidationEngine'; +import type { QNode } from '../../domain/QNode'; +import type { TableMeta } from '../../domain/TableMeta'; +import { InternalRelationKind } from '../../domain/internal/InternalRelationKind'; + +type UserModel = { + id: number; + email: string; + name: string; + age: number; + isActive: boolean; +}; + +const mockMeta: TableMeta = { + table: 'users', + pk: 'id', + columns: { + id: 'int', + organization_id: 'int', + email: 'text', + name: 'text', + age: 'int', + isActive: 'bool', + }, +}; + +const postgresAdapter = anAdapter({ dialect: 'postgres' }); +const sqliteAdapter = anAdapter({ dialect: 'sqlite' }); +const sqlSafetyAdapter = new OrmSqlSafetyAdapter(); + +function compileWhere( + q: QNode, + options: { meta?: TableMeta; adapter?: Adapter; paramIndex?: number } = {} +): { sql: string; params: readonly unknown[] } { + const meta = options.meta ?? mockMeta; + const adapter = options.adapter ?? postgresAdapter; + const compiler = new WhereCompiler(meta, adapter); + const validatedPlan = sqlSafetyAdapter.validate({ + kind: SqlPlanKind.SELECT, + meta, + filterKeys: compiler.collectStateFilterKeys({ q }), + relationNames: [], + }); + + return compiler.compileNode(q, options.paramIndex ?? 1, validatedPlan.filterKeys); +} + +describe(WhereCompiler, () => { + it('collects filter keys from root predicates and excludes', () => { + const compiler = new WhereCompiler(mockMeta, postgresAdapter); + + expect( + compiler.collectStateFilterKeys({ + q: Q.and({ email: 'test@example.com' }, { age__gte: 18 }), + excludes: [Q.not({ name__contains: 'bot' })], + }) + ).toEqual(['email', 'age__gte', 'name__contains']); + }); + + it('compiles exact filters', () => { + const result = compileWhere({ kind: 'atom', where: { email: 'test@example.com' } }); + + expect(result.sql).toContain('users.email = $1'); + expect(result.params).toEqual(['test@example.com']); + }); + + it('compiles null values as IS NULL', () => { + const result = compileWhere({ kind: 'atom', where: { email: null } }); + + expect(result.sql).toContain('users.email IS NULL'); + expect(result.params).toEqual([]); + }); + + it('compiles comparison lookups', () => { + const result = compileWhere( + Q.and({ age__lt: 30 }, { age__lte: 65 }, { age__gt: 21 }, { age__gte: 18 }) + ); + + expect(result.sql).toContain('users.age < $1'); + expect(result.sql).toContain('users.age <= $2'); + expect(result.sql).toContain('users.age > $3'); + expect(result.sql).toContain('users.age >= $4'); + expect(result.params).toEqual([30, 65, 21, 18]); + }); + + it('compiles IN lookups and treats empty IN lookups as always false', () => { + const populated = compileWhere({ kind: 'atom', where: { id__in: [1, 2, 3] } }); + const empty = compileWhere({ kind: 'atom', where: { id__in: [] } }); + + expect(populated.sql).toContain('users.id IN ($1, $2, $3)'); + expect(populated.params).toEqual([1, 2, 3]); + expect(empty.sql).toContain('1=0'); + expect(empty.params).toEqual([]); + }); + + it('compiles IS NULL lookups', () => { + const isNull = compileWhere({ kind: 'atom', where: { email__isnull: true } }); + const isNotNull = compileWhere({ kind: 'atom', where: { email__isnull: false } }); + + expect(isNull.sql).toContain('users.email IS NULL'); + expect(isNotNull.sql).toContain('users.email IS NOT NULL'); + }); + + it('compiles string lookups', () => { + const contains = compileWhere({ kind: 'atom', where: { name__contains: 'John' } }); + const startsWith = compileWhere({ kind: 'atom', where: { email__startswith: 'admin' } }); + const endsWith = compileWhere({ kind: 'atom', where: { email__endswith: '.com' } }); + + expect(contains.sql).toContain('users.name LIKE $1'); + expect(contains.params).toEqual(['%John%']); + expect(startsWith.sql).toContain('users.email LIKE $1'); + expect(startsWith.params).toEqual(['admin%']); + expect(endsWith.sql).toContain('users.email LIKE $1'); + expect(endsWith.params).toEqual(['%.com']); + }); + + it('compiles case-insensitive string lookups for PostgreSQL', () => { + const contains = compileWhere({ kind: 'atom', where: { name__icontains: 'JOHN' } }); + const startsWith = compileWhere({ kind: 'atom', where: { email__istartswith: 'ADMIN' } }); + const endsWith = compileWhere({ kind: 'atom', where: { email__iendswith: '.COM' } }); + + expect(contains.sql).toContain('LOWER(users.name) LIKE $1'); + expect(contains.params).toEqual(['%john%']); + expect(startsWith.sql).toContain('LOWER(users.email) LIKE $1'); + expect(startsWith.params).toEqual(['admin%']); + expect(endsWith.sql).toContain('LOWER(users.email) LIKE $1'); + expect(endsWith.params).toEqual(['%.com']); + }); + + it('compiles AND, OR, and NOT nodes', () => { + const andResult = compileWhere(Q.and({ email: 'test@example.com' }, { age__gte: 18 })); + const orResult = compileWhere(Q.or({ email: 'test@example.com' }, { email: 'admin@example.com' })); + const notResult = compileWhere(Q.not({ email: 'test@example.com' })); + + expect(andResult.sql).toContain('users.email = $1'); + expect(andResult.sql).toContain('users.age >= $2'); + expect(andResult.sql).toContain('AND'); + expect(andResult.params).toEqual(['test@example.com', 18]); + expect(orResult.sql).toContain('users.email = $1'); + expect(orResult.sql).toContain('users.email = $2'); + expect(orResult.sql).toContain('OR'); + expect(orResult.params).toEqual(['test@example.com', 'admin@example.com']); + expect(notResult.sql).toContain('NOT'); + expect(notResult.sql).toContain('users.email = $1'); + expect(notResult.params).toEqual(['test@example.com']); + }); + + it('elides empty predicates', () => { + const compiler = new WhereCompiler(mockMeta, postgresAdapter); + const validatedPlan = sqlSafetyAdapter.validate({ + kind: SqlPlanKind.SELECT, + meta: mockMeta, + filterKeys: ['email', 'id'], + relationNames: [], + }); + const notResult = compiler.compileNode(Q.not({ email: undefined }), 1, validatedPlan.filterKeys); + const andResult = compiler.compileNode( + { + kind: 'and', + nodes: [ + { kind: 'atom', where: { email: undefined } }, + { kind: 'atom', where: { id: 1 } }, + ], + }, + 1, + validatedPlan.filterKeys + ); + const orResult = compiler.compileNode( + { + kind: 'or', + nodes: [ + { kind: 'atom', where: { email: undefined } }, + { kind: 'atom', where: { id: 2 } }, + ], + }, + 1, + validatedPlan.filterKeys + ); + const emptyAnd = compiler.compileNode( + { kind: 'and', nodes: [{ kind: 'atom', where: { email: undefined } }] }, + 1, + validatedPlan.filterKeys + ); + const emptyOr = compiler.compileNode( + { kind: 'or', nodes: [{ kind: 'atom', where: { email: undefined } }] }, + 1, + validatedPlan.filterKeys + ); + + expect(notResult.sql).toBe(''); + expect(andResult.sql).toContain('users.id = $1'); + expect(andResult.params).toEqual([1]); + expect(orResult.sql).toContain('users.id = $1'); + expect(orResult.params).toEqual([2]); + expect(emptyAnd.sql).toBe(''); + expect(emptyOr.sql).toBe(''); + }); + + it('handles missing predicate payload arrays and objects as empty predicates', () => { + const compiler = new WhereCompiler(mockMeta, postgresAdapter); + + expect(compiler.compileNode({ kind: 'and' }, 1, {}).sql).toBe(''); + expect(compiler.compileNode({ kind: 'or' }, 1, {}).sql).toBe(''); + expect(compiler.compileNode({ kind: 'atom' }, 1, {}).sql).toBe(''); + }); + + it('ignores q-nodes with unknown kinds', () => { + const result = compileWhere({ + kind: 'unknown_kind' as unknown as 'atom', + }); + + expect(result).toEqual({ sql: '', params: [] }); + }); + + it('defensively rejects unsupported lookup values during SQL rendering', () => { + const compiler = new WhereCompiler(mockMeta, postgresAdapter); + const filterKeys = { + email__wat: { + kind: InternalValidatedFilterDescriptorKind.COLUMN, + rawKey: 'email__wat', + field: 'email', + lookup: 'wat', + qualifiedColumn: 'users.email', + }, + } as unknown as Record; + + expect(() => compiler.compileNode({ kind: 'atom', where: { email__wat: 'payload' } }, 1, filterKeys)).toThrow( + 'Unknown lookup: wat' + ); + }); + + it('uses SQLite placeholders and normalized boolean parameters', () => { + const result = compileWhere(Q.and({ isActive: true }, { id__in: 9 }), { + adapter: sqliteAdapter, + }); + const falseResult = compileWhere( + { kind: 'atom', where: { isActive: false } }, + { + adapter: sqliteAdapter, + } + ); + + expect(result.sql).toContain('users.isActive = ?'); + expect(result.sql).toContain('users.id IN (?)'); + expect(result.params).toEqual([1, 9]); + expect(falseResult.params).toEqual([0]); + }); + + it('uses SQLite column casing for case-insensitive lookups', () => { + const contains = compileWhere( + { kind: 'atom', where: { email__icontains: 'ADMIN' } }, + { + adapter: sqliteAdapter, + } + ); + const startsWith = compileWhere( + { kind: 'atom', where: { email__istartswith: 'ADMIN' } }, + { + adapter: sqliteAdapter, + } + ); + const endsWith = compileWhere( + { kind: 'atom', where: { email__iendswith: '.COM' } }, + { + adapter: sqliteAdapter, + } + ); + + expect(contains.sql).toContain('users.email LIKE ?'); + expect(contains.params).toEqual(['%admin%']); + expect(startsWith.sql).toContain('users.email LIKE ?'); + expect(startsWith.params).toEqual(['admin%']); + expect(endsWith.sql).toContain('users.email LIKE ?'); + expect(endsWith.params).toEqual(['%.com']); + }); + + it('compiles single-valued relation-path filters through correlated EXISTS clauses', () => { + const meta: TableMeta = { + ...mockMeta, + relations: { + organization: aRelationMeta({ + kind: InternalRelationKind.BELONGS_TO, + table: 'organizations', + sourceKey: 'organization_id', + targetKey: 'id', + targetPrimaryKey: 'id', + targetColumns: { id: 'int', name: 'text' }, + alias: 'organizations', + targetMeta: { + table: 'organizations', + pk: 'id', + columns: { id: 'int', name: 'text' }, + }, + }), + }, + }; + + const result = compileWhere({ kind: 'atom', where: { organization__name__icontains: 'dance' } }, { meta }); + + expect(result.sql).toContain('EXISTS (SELECT 1 FROM organizations'); + expect(result.sql).toContain('LOWER(__tango_filter_organization_target_organizations_0.name) LIKE $1'); + expect(result.params).toEqual(['%dance%']); + }); + + it('rejects relation-filter aliases that collide with model fields', () => { + const meta: TableMeta = { + ...mockMeta, + columns: { + ...mockMeta.columns, + __tango_filter_organization_target_organizations_0: 'text', + }, + relations: { + organization: aRelationMeta({ + kind: InternalRelationKind.BELONGS_TO, + table: 'organizations', + sourceKey: 'organization_id', + targetKey: 'id', + targetPrimaryKey: 'id', + targetColumns: { id: 'int', name: 'text' }, + alias: 'organizations', + targetMeta: { + table: 'organizations', + pk: 'id', + columns: { id: 'int', name: 'text' }, + }, + }), + }, + }; + + expect(() => compileWhere({ kind: 'atom', where: { organization__name: 'dance' } }, { meta })).toThrow( + /internal query alias/i + ); + }); + + it('compiles nested single-valued relation-path filters through chained correlated EXISTS clauses', () => { + const meta: TableMeta = { + ...mockMeta, + relations: { + organization: aRelationMeta({ + kind: InternalRelationKind.BELONGS_TO, + table: 'organizations', + sourceKey: 'organization_id', + targetKey: 'id', + targetPrimaryKey: 'id', + targetColumns: { id: 'int', parent_id: 'int', name: 'text' }, + alias: 'organizations', + targetMeta: { + table: 'organizations', + pk: 'id', + columns: { id: 'int', parent_id: 'int', name: 'text' }, + relations: { + parent: aRelationMeta({ + kind: InternalRelationKind.BELONGS_TO, + table: 'organizations', + sourceKey: 'parent_id', + targetKey: 'id', + targetPrimaryKey: 'id', + targetColumns: { id: 'int', name: 'text' }, + alias: 'parent', + targetMeta: { + table: 'organizations', + pk: 'id', + columns: { id: 'int', name: 'text' }, + }, + }), + }, + }, + }), + }, + }; + + const result = compileWhere( + { kind: 'atom', where: { organization__parent__name__icontains: 'core' } }, + { meta } + ); + + expect(result.sql).toContain( + 'EXISTS (SELECT 1 FROM organizations __tango_filter_organization_parent_target_organizations_1' + ); + expect(result.sql).toContain( + 'EXISTS (SELECT 1 FROM organizations __tango_filter_organization_parent_target_parent_0' + ); + expect(result.sql).toContain('LOWER(__tango_filter_organization_parent_target_parent_0.name) LIKE $1'); + expect(result.params).toEqual(['%core%']); + }); + + it('compiles many-to-many relation-path filters through correlated EXISTS clauses', () => { + const meta: TableMeta = { + table: 'posts', + pk: 'id', + columns: { + id: 'int', + title: 'text', + }, + relations: { + tags: { + kind: InternalRelationKind.MANY_TO_MANY, + edgeId: 'posts:tags', + sourceModelKey: 'tests/Post', + targetModelKey: 'tests/Tag', + cardinality: 'many', + capabilities: { + queryable: true, + hydratable: true, + joinable: false, + prefetchable: true, + }, + table: 'tags', + sourceKey: 'id', + targetKey: 'id', + throughTable: 'post_tags', + throughSourceKey: 'post_id', + throughTargetKey: 'tag_id', + targetPrimaryKey: 'id', + targetColumns: { id: 'int', slug: 'text' }, + alias: 'tags', + targetMeta: { + table: 'tags', + pk: 'id', + columns: { id: 'int', slug: 'text' }, + }, + }, + }, + }; + + const result = compileWhere({ kind: 'atom', where: { tags__slug: 'tango' } }, { meta }); + + expect(result.sql).toContain('EXISTS (SELECT 1 FROM post_tags'); + expect(result.sql).toContain('INNER JOIN tags'); + expect(result.sql).toContain('__tango_filter_tags_target_tags_0.slug = $1'); + expect(result.params).toEqual(['tango']); + }); + + it('throws when relation-filter compilation is asked to traverse an empty relation chain', () => { + const compiler = new WhereCompiler(mockMeta, postgresAdapter); + const filterKeys = { + organization__name: { + kind: InternalValidatedFilterDescriptorKind.RELATION, + rawKey: 'organization__name', + field: 'name', + lookup: 'exact', + relationPath: 'organization', + relationChain: [], + terminalColumn: 'name', + }, + } as unknown as Record; + + expect(() => + compiler.compileNode({ kind: 'atom', where: { organization__name: 'pedro' } }, 1, filterKeys) + ).toThrow(/cannot compile empty relation filter path/i); + }); +});