diff --git a/CLAUDE.md b/CLAUDE.md index 6f74cd60a..04e18dee4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,6 +20,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ### Testing - E2E tests are in `tests/e2e/` directory +- Regression tests for GitHub issues go in `tests/regression/test/` as `issue-{number}.test.ts` ### ZenStack CLI Commands diff --git a/packages/orm/src/client/crud/dialects/base-dialect.ts b/packages/orm/src/client/crud/dialects/base-dialect.ts index 0f204817f..3ab2d4979 100644 --- a/packages/orm/src/client/crud/dialects/base-dialect.ts +++ b/packages/orm/src/client/crud/dialects/base-dialect.ts @@ -34,6 +34,7 @@ import { requireIdFields, requireModel, requireTypeDef, + tmpAlias, } from '../../query-utils'; export abstract class BaseCrudDialect { @@ -298,7 +299,7 @@ export abstract class BaseCrudDialect { } } - const joinAlias = `${modelAlias}$${field}`; + const joinAlias = tmpAlias(`${modelAlias}$${field}`); const joinPairs = buildJoinPairs( this.schema, model, @@ -307,7 +308,7 @@ export abstract class BaseCrudDialect { field, joinAlias, ); - const filterResultField = `${field}$filter`; + const filterResultField = tmpAlias(`${field}$flt`); const joinSelect = this.eb .selectFrom(`${fieldDef.type} as ${joinAlias}`) @@ -383,7 +384,7 @@ export abstract class BaseCrudDialect { // evaluating the filter involves creating an inner select, // give it an alias to avoid conflict - const relationFilterSelectAlias = `${modelAlias}$${field}$filter`; + const relationFilterSelectAlias = tmpAlias(`${modelAlias}$${field}$flt`); const buildPkFkWhereRefs = (eb: ExpressionBuilder) => { const m2m = getManyToManyRelation(this.schema, model, field); @@ -1083,7 +1084,7 @@ export abstract class BaseCrudDialect { ); const sort = this.negateSort(value._count, negated); result = result.orderBy((eb) => { - const subQueryAlias = `${modelAlias}$orderBy$${field}$count`; + const subQueryAlias = tmpAlias(`${modelAlias}$ob$${field}$ct`); let subQuery = this.buildSelectModel(relationModel, subQueryAlias); const joinPairs = buildJoinPairs(this.schema, model, modelAlias, field, subQueryAlias); subQuery = subQuery.where(() => @@ -1099,7 +1100,7 @@ export abstract class BaseCrudDialect { } } else { // order by to-one relation - const joinAlias = `${modelAlias}$orderBy$${index}`; + const joinAlias = tmpAlias(`${modelAlias}$ob$${index}`); result = result.leftJoin(`${relationModel} as ${joinAlias}`, (join) => { const joinPairs = buildJoinPairs(this.schema, model, modelAlias, field, joinAlias); return join.on((eb) => diff --git a/packages/orm/src/client/crud/dialects/lateral-join-dialect-base.ts b/packages/orm/src/client/crud/dialects/lateral-join-dialect-base.ts index 65f2d9cec..94f29b20a 100644 --- a/packages/orm/src/client/crud/dialects/lateral-join-dialect-base.ts +++ b/packages/orm/src/client/crud/dialects/lateral-join-dialect-base.ts @@ -11,6 +11,7 @@ import { requireField, requireIdFields, requireModel, + tmpAlias, } from '../../query-utils'; import { BaseCrudDialect } from './base-dialect'; @@ -31,7 +32,7 @@ export abstract class LateralJoinDialectBase extends B parentAlias: string, payload: true | FindArgs, any, true>, ): SelectQueryBuilder { - const relationResultName = `${parentAlias}$${relationField}`; + const relationResultName = tmpAlias(`${parentAlias}$${relationField}`); const joinedQuery = this.buildRelationJSON( model, query, @@ -56,7 +57,7 @@ export abstract class LateralJoinDialectBase extends B return qb.leftJoinLateral( (eb) => { - const relationSelectName = `${resultName}$sub`; + const relationSelectName = tmpAlias(`${resultName}$sub`); const relationModelDef = requireModel(this.schema, relationModel); let tbl: SelectQueryBuilder; diff --git a/packages/orm/src/client/crud/dialects/mysql.ts b/packages/orm/src/client/crud/dialects/mysql.ts index 45e4aaea1..e9ca2e4aa 100644 --- a/packages/orm/src/client/crud/dialects/mysql.ts +++ b/packages/orm/src/client/crud/dialects/mysql.ts @@ -306,7 +306,7 @@ export class MySqlCrudDialect extends LateralJoinDiale return this.eb.exists( this.eb .selectFrom(sql`JSON_TABLE(${receiver}, '$[*]' COLUMNS(value JSON PATH '$'))`.as('$items')) - .select(this.eb.lit(1).as('$t')) + .select(this.eb.lit(1).as('_')) .where(buildFilter(this.eb.ref('$items.value'))), ); } diff --git a/packages/orm/src/client/crud/dialects/postgresql.ts b/packages/orm/src/client/crud/dialects/postgresql.ts index 62b19d3d6..5f962dbb5 100644 --- a/packages/orm/src/client/crud/dialects/postgresql.ts +++ b/packages/orm/src/client/crud/dialects/postgresql.ts @@ -352,7 +352,7 @@ export class PostgresCrudDialect extends LateralJoinDi return this.eb.exists( this.eb .selectFrom(this.eb.fn('jsonb_array_elements', [receiver]).as('$items')) - .select(this.eb.lit(1).as('$t')) + .select(this.eb.lit(1).as('_')) .where(buildFilter(this.eb.ref('$items.value'))), ); } diff --git a/packages/orm/src/client/crud/dialects/sqlite.ts b/packages/orm/src/client/crud/dialects/sqlite.ts index 93d4f547d..95e2910f8 100644 --- a/packages/orm/src/client/crud/dialects/sqlite.ts +++ b/packages/orm/src/client/crud/dialects/sqlite.ts @@ -25,6 +25,7 @@ import { requireField, requireIdFields, requireModel, + tmpAlias, } from '../../query-utils'; import { BaseCrudDialect } from './base-dialect'; @@ -201,7 +202,7 @@ export class SqliteCrudDialect extends BaseCrudDialect const relationModel = relationFieldDef.type as GetModels; const relationModelDef = requireModel(this.schema, relationModel); - const subQueryName = `${parentAlias}$${relationField}`; + const subQueryName = tmpAlias(`${parentAlias}$${relationField}`); let tbl: SelectQueryBuilder; if (this.canJoinWithoutNestedSelect(relationModelDef, payload)) { @@ -214,7 +215,7 @@ export class SqliteCrudDialect extends BaseCrudDialect // need to make a nested select on relation model tbl = eb.selectFrom(() => { // nested query name - const selectModelAlias = `${parentAlias}$${relationField}$sub`; + const selectModelAlias = tmpAlias(`${parentAlias}$${relationField}$sub`); // select all fields let selectModelQuery = this.buildModelSelect(relationModel, selectModelAlias, payload, true); @@ -268,7 +269,7 @@ export class SqliteCrudDialect extends BaseCrudDialect const subJson = this.buildCountJson( relationModel, eb, - `${parentAlias}$${relationField}`, + tmpAlias(`${parentAlias}$${relationField}`), value, ); return [sql.lit(field), subJson]; @@ -279,7 +280,7 @@ export class SqliteCrudDialect extends BaseCrudDialect relationModel, eb, field, - `${parentAlias}$${relationField}`, + tmpAlias(`${parentAlias}$${relationField}`), value, ); return [sql.lit(field), subJson]; @@ -305,7 +306,7 @@ export class SqliteCrudDialect extends BaseCrudDialect relationModel, eb, field, - `${parentAlias}$${relationField}`, + tmpAlias(`${parentAlias}$${relationField}`), value, ); return [sql.lit(field), subJson]; @@ -440,7 +441,7 @@ export class SqliteCrudDialect extends BaseCrudDialect return this.eb.exists( this.eb .selectFrom(this.eb.fn('json_each', [receiver]).as('$items')) - .select(this.eb.lit(1).as('$t')) + .select(this.eb.lit(1).as('_')) .where(buildFilter(this.eb.ref('$items.value'))), ); } diff --git a/packages/orm/src/client/crud/operations/base.ts b/packages/orm/src/client/crud/operations/base.ts index 0314bcf95..e38ca4369 100644 --- a/packages/orm/src/client/crud/operations/base.ts +++ b/packages/orm/src/client/crud/operations/base.ts @@ -260,23 +260,23 @@ export abstract class BaseOperationHandler { .exists( this.dialect .buildSelectModel(model, model) - .select(sql.lit(1).as('$t')) + .select(sql.lit(1).as('_')) .where(() => this.dialect.buildFilter(model, model, filter)), ) - .as('exists'), + .as('$exists'), ) .modifyEnd(this.makeContextComment({ model, operation: 'read' })); - let result: { exists: number | boolean }[] = []; + let result: { $exists: number | boolean }[] = []; const compiled = kysely.getExecutor().compileQuery(query.toOperationNode(), createQueryId()); try { const r = await kysely.getExecutor().executeQuery(compiled); - result = r.rows as { exists: number | boolean }[]; + result = r.rows as { $exists: number | boolean }[]; } catch (err) { throw createDBQueryError(`Failed to execute query: ${err}`, err, compiled.sql, compiled.parameters); } - return !!result[0]?.exists; + return !!result[0]?.$exists; } protected async read( diff --git a/packages/orm/src/client/executor/temp-alias-transformer.ts b/packages/orm/src/client/executor/temp-alias-transformer.ts new file mode 100644 index 000000000..1a66a260a --- /dev/null +++ b/packages/orm/src/client/executor/temp-alias-transformer.ts @@ -0,0 +1,27 @@ +import { IdentifierNode, OperationNodeTransformer, type OperationNode, type QueryId } from 'kysely'; +import { TEMP_ALIAS_PREFIX } from '../query-utils'; + +/** + * Kysely node transformer that replaces temporary aliases created during query construction with + * shorter names while ensuring the same temp alias gets replaced with the same name. + */ +export class TempAliasTransformer extends OperationNodeTransformer { + private aliasMap = new Map(); + + run(node: T): T { + this.aliasMap.clear(); + return this.transformNode(node); + } + + protected override transformIdentifier(node: IdentifierNode, queryId?: QueryId): IdentifierNode { + if (node.name.startsWith(TEMP_ALIAS_PREFIX)) { + let mapped = this.aliasMap.get(node.name); + if (!mapped) { + mapped = `$$t${this.aliasMap.size + 1}`; + this.aliasMap.set(node.name, mapped); + } + return IdentifierNode.create(mapped); + } + return super.transformIdentifier(node, queryId); + } +} diff --git a/packages/orm/src/client/executor/zenstack-query-executor.ts b/packages/orm/src/client/executor/zenstack-query-executor.ts index 4fa8b1896..b0aab7627 100644 --- a/packages/orm/src/client/executor/zenstack-query-executor.ts +++ b/packages/orm/src/client/executor/zenstack-query-executor.ts @@ -39,6 +39,7 @@ import { createDBQueryError, createInternalError, ORMError } from '../errors'; import type { AfterEntityMutationCallback, OnKyselyQueryCallback } from '../plugin'; import { requireIdFields, stripAlias } from '../query-utils'; import { QueryNameMapper } from './name-mapper'; +import { TempAliasTransformer } from './temp-alias-transformer'; import type { ZenStackDriver } from './zenstack-driver'; type MutationQueryNode = InsertQueryNode | UpdateQueryNode | DeleteQueryNode; @@ -620,10 +621,24 @@ In such cases, ZenStack cannot reliably determine the IDs of the mutated entitie }) as string; } + private processQueryNode(query: Node): Node { + let result = query; + result = this.processNameMapping(result); + result = this.processTempAlias(result); + return result; + } + private processNameMapping(query: Node): Node { return this.nameMapper?.transformNode(query) ?? query; } + private processTempAlias(query: Node): Node { + if (this.options.useCompactAliasNames === false) { + return query; + } + return new TempAliasTransformer().run(query); + } + private createClientForConnection(connection: DatabaseConnection, inTx: boolean) { const innerExecutor = this.withConnectionProvider(new SingleConnectionProvider(connection)); innerExecutor.suppressMutationHooks = true; @@ -650,8 +665,8 @@ In such cases, ZenStack cannot reliably determine the IDs of the mutated entitie queryId?: QueryId, parameters?: readonly unknown[], ) { - // no need to handle mutation hooks, just proceed - const finalQuery = this.processNameMapping(query); + // run query node processors: name mapping, temp alias renaming, etc. + const finalQuery = this.processQueryNode(query); // inherit the original queryId let compiledQuery = this.compileQuery(finalQuery, queryId ?? createQueryId()); diff --git a/packages/orm/src/client/options.ts b/packages/orm/src/client/options.ts index 2061ebafa..4601598b2 100644 --- a/packages/orm/src/client/options.ts +++ b/packages/orm/src/client/options.ts @@ -197,6 +197,12 @@ export type ClientOptions = QueryOptions & { * `@@validate`, etc. Defaults to `true`. */ validateInput?: boolean; + + /** + * Whether to use compact alias names (e.g., "$t1", "$t2") when transforming ORM queries to SQL. + * Defaults to `true`. + */ + useCompactAliasNames?: boolean; } & (HasComputedFields extends true ? { /** diff --git a/packages/orm/src/client/query-utils.ts b/packages/orm/src/client/query-utils.ts index fb9c39bea..0ea7ea58a 100644 --- a/packages/orm/src/client/query-utils.ts +++ b/packages/orm/src/client/query-utils.ts @@ -427,3 +427,16 @@ export function extractFieldName(node: OperationNode) { return undefined; } } + +export const TEMP_ALIAS_PREFIX = '$$_'; + +/** + * Create an alias name for a temporary table or column name. + */ +export function tmpAlias(name: string) { + if (!name.startsWith(TEMP_ALIAS_PREFIX)) { + return `${TEMP_ALIAS_PREFIX}${name}`; + } else { + return name; + } +} diff --git a/packages/plugins/policy/src/expression-transformer.ts b/packages/plugins/policy/src/expression-transformer.ts index 3b31bd05c..2a237c5cb 100644 --- a/packages/plugins/policy/src/expression-transformer.ts +++ b/packages/plugins/policy/src/expression-transformer.ts @@ -403,7 +403,7 @@ export class ExpressionTransformer { return this.transform(expr.left, { ...context, - memberSelect: SelectionNode.create(AliasNode.create(predicateResult, IdentifierNode.create('$t'))), + memberSelect: SelectionNode.create(AliasNode.create(predicateResult, IdentifierNode.create('_'))), memberFilter: predicateFilter, }); } @@ -776,7 +776,7 @@ export class ExpressionTransformer { return { ...receiver, - selections: [SelectionNode.create(AliasNode.create(currNode!, IdentifierNode.create('$t')))], + selections: [SelectionNode.create(AliasNode.create(currNode!, IdentifierNode.create('_')))], }; } diff --git a/packages/plugins/policy/src/policy-handler.ts b/packages/plugins/policy/src/policy-handler.ts index 25545cfc8..aa46be502 100644 --- a/packages/plugins/policy/src/policy-handler.ts +++ b/packages/plugins/policy/src/policy-handler.ts @@ -825,13 +825,13 @@ export class PolicyHandler extends OperationNodeTransf const queryA = eb .selectFrom(m2m.firstModel) .where(eb(eb.ref(`${m2m.firstModel}.${m2m.firstIdField}`), '=', aValue)) - .select(() => new ExpressionWrapper(filterA).as('$t')); + .select(() => new ExpressionWrapper(filterA).as('_')); const filterB = this.buildPolicyFilter(m2m.secondModel, undefined, 'update'); const queryB = eb .selectFrom(m2m.secondModel) .where(eb(eb.ref(`${m2m.secondModel}.${m2m.secondIdField}`), '=', bValue)) - .select(() => new ExpressionWrapper(filterB).as('$t')); + .select(() => new ExpressionWrapper(filterB).as('_')); // select both conditions in one query const queryNode: SelectQueryNode = { diff --git a/tests/regression/test/issue-2378.test.ts b/tests/regression/test/issue-2378.test.ts new file mode 100644 index 000000000..2bafd992f --- /dev/null +++ b/tests/regression/test/issue-2378.test.ts @@ -0,0 +1,104 @@ +import { createTestClient } from '@zenstackhq/testtools'; +import { describe, expect, it } from 'vitest'; + +describe('Regression for issue #2378', () => { + it('deep nested include should not generate alias names exceeding 63 bytes', async () => { + const db = await createTestClient( + ` +model RepositoryCases { + id Int @id @default(autoincrement()) + templateId Int + template Templates @relation(fields: [templateId], references: [id]) +} + +model Templates { + id Int @id @default(autoincrement()) + cases RepositoryCases[] + caseFields TemplateCaseAssignment[] +} + +model TemplateCaseAssignment { + id Int @id @default(autoincrement()) + templateId Int + template Templates @relation(fields: [templateId], references: [id]) + caseFieldId Int + caseField CaseFields @relation(fields: [caseFieldId], references: [id]) +} + +model CaseFields { + id Int @id @default(autoincrement()) + assignments TemplateCaseAssignment[] + fieldOptions CaseFieldAssignment[] +} + +model CaseFieldAssignment { + id Int @id @default(autoincrement()) + caseFieldId Int + caseField CaseFields @relation(fields: [caseFieldId], references: [id]) + fieldOptionId Int + fieldOption FieldOptions @relation(fields: [fieldOptionId], references: [id]) +} + +model FieldOptions { + id Int @id @default(autoincrement()) + value String + assignments CaseFieldAssignment[] +} + `, + ); + + // seed data: RepositoryCases -> Templates -> TemplateCaseAssignment -> CaseFields -> CaseFieldAssignment -> FieldOptions + await db.repositoryCases.create({ + data: { + template: { + create: { + caseFields: { + create: { + caseField: { + create: { + fieldOptions: { + create: { + fieldOption: { + create: { value: 'option1' }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }); + + // 5-level deep include that previously generated aliases exceeding 63 bytes + const result = await db.repositoryCases.findFirst({ + where: { id: 1 }, + include: { + template: { + include: { + caseFields: { + include: { + caseField: { + include: { + fieldOptions: { + include: { + fieldOption: true, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }); + + expect(result).toBeTruthy(); + expect(result.template.caseFields).toHaveLength(1); + expect(result.template.caseFields[0].caseField.fieldOptions).toHaveLength(1); + expect(result.template.caseFields[0].caseField.fieldOptions[0].fieldOption.value).toBe('option1'); + }); +});