diff --git a/packages/orm/src/client/executor/temp-alias-transformer.ts b/packages/orm/src/client/executor/temp-alias-transformer.ts index 1a66a260a..fc742b23a 100644 --- a/packages/orm/src/client/executor/temp-alias-transformer.ts +++ b/packages/orm/src/client/executor/temp-alias-transformer.ts @@ -1,12 +1,37 @@ import { IdentifierNode, OperationNodeTransformer, type OperationNode, type QueryId } from 'kysely'; import { TEMP_ALIAS_PREFIX } from '../query-utils'; +type TempAliasTransformerMode = 'alwaysCompact' | 'compactLongNames'; + +type TempAliasTransformerOptions = { + mode?: TempAliasTransformerMode; + maxIdentifierLength?: number; +}; + /** * 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(); + private readonly textEncoder = new TextEncoder(); + private readonly mode: TempAliasTransformerMode; + private readonly maxIdentifierLength: number; + + constructor(options: TempAliasTransformerOptions = {}) { + super(); + this.mode = options.mode ?? 'alwaysCompact'; + // PostgreSQL limits identifier length to 63 bytes and silently truncates overlong aliases. + const maxIdentifierLength = options.maxIdentifierLength ?? 63; + if ( + !Number.isFinite(maxIdentifierLength) || + !Number.isInteger(maxIdentifierLength) || + maxIdentifierLength <= 0 + ) { + throw new RangeError('maxIdentifierLength must be a positive integer'); + } + this.maxIdentifierLength = maxIdentifierLength; + } run(node: T): T { this.aliasMap.clear(); @@ -14,14 +39,31 @@ export class TempAliasTransformer extends OperationNodeTransformer { } protected override transformIdentifier(node: IdentifierNode, queryId?: QueryId): IdentifierNode { - if (node.name.startsWith(TEMP_ALIAS_PREFIX)) { + if (!node.name.startsWith(TEMP_ALIAS_PREFIX)) { + return super.transformIdentifier(node, queryId); + } + + let shouldCompact = false; + if (this.mode === 'alwaysCompact') { + shouldCompact = true; + } else { + // check if the alias name exceeds the max identifier length, and + // if so, compact it + const aliasByteLength = this.textEncoder.encode(node.name).length; + if (aliasByteLength > this.maxIdentifierLength) { + shouldCompact = true; + } + } + + if (shouldCompact) { 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); + } else { + return super.transformIdentifier(node, queryId); } - 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 b0aab7627..52afde140 100644 --- a/packages/orm/src/client/executor/zenstack-query-executor.ts +++ b/packages/orm/src/client/executor/zenstack-query-executor.ts @@ -633,10 +633,9 @@ In such cases, ZenStack cannot reliably determine the IDs of the mutated entitie } private processTempAlias(query: Node): Node { - if (this.options.useCompactAliasNames === false) { - return query; - } - return new TempAliasTransformer().run(query); + return new TempAliasTransformer({ + mode: this.options.useCompactAliasNames === false ? 'compactLongNames' : 'alwaysCompact', + }).run(query); } private createClientForConnection(connection: DatabaseConnection, inTx: boolean) { diff --git a/packages/orm/src/client/options.ts b/packages/orm/src/client/options.ts index 4601598b2..3e3f6c57a 100644 --- a/packages/orm/src/client/options.ts +++ b/packages/orm/src/client/options.ts @@ -199,8 +199,11 @@ export type ClientOptions = QueryOptions & { validateInput?: boolean; /** - * Whether to use compact alias names (e.g., "$t1", "$t2") when transforming ORM queries to SQL. + * Whether to use compact alias names (e.g., "$$t1", "$$t2") when transforming ORM queries to SQL. * Defaults to `true`. + * + * When set to `false`, original aliases are kept unless temporary aliases become too long for + * safe SQL identifier handling, in which case compact aliases are used as a fallback. */ useCompactAliasNames?: boolean; } & (HasComputedFields extends true diff --git a/tests/regression/test/issue-2424.test.ts b/tests/regression/test/issue-2424.test.ts new file mode 100644 index 000000000..0bfd3e4da --- /dev/null +++ b/tests/regression/test/issue-2424.test.ts @@ -0,0 +1,161 @@ +import { createPolicyTestClient } from '@zenstackhq/testtools'; +import { describe, expect, it } from 'vitest'; + +describe('Regression for issue #2424', () => { + it('deep nested include with PolicyPlugin works with non-compact alias mode', async () => { + const db = await createPolicyTestClient( + ` +model Store { + id String @id + customerOrders CustomerOrder[] + productCatalogItems ProductCatalogItem[] + @@allow('all', true) +} + +model CustomerOrder { + id String @id + storeId String + store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) + customerOrderPaymentSummary CustomerOrderPaymentSummary[] + @@allow('all', true) +} + +model CustomerOrderPaymentSummary { + id String @id + customerOrderId String + customerOrder CustomerOrder @relation(fields: [customerOrderId], references: [id], onDelete: Cascade) + customerOrderPaymentSummaryLine CustomerOrderPaymentSummaryLine[] + @@allow('all', true) +} + +model PaymentTransaction { + id String @id + customerOrderPaymentSummaryLine CustomerOrderPaymentSummaryLine[] + paymentTransactionLineItem PaymentTransactionLineItem[] + @@allow('all', true) +} + +model CustomerOrderPaymentSummaryLine { + customerOrderPaymentSummaryId String + lineIndex Int + paymentTransactionId String + customerOrderPaymentSummary CustomerOrderPaymentSummary @relation(fields: [customerOrderPaymentSummaryId], references: [id], onDelete: Cascade) + paymentTransaction PaymentTransaction @relation(fields: [paymentTransactionId], references: [id], onDelete: Cascade) + @@id([customerOrderPaymentSummaryId, lineIndex]) + @@allow('all', true) +} + +model ProductCatalogItem { + storeId String + sku String + store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) + paymentTransactionLineItem PaymentTransactionLineItem[] + @@id([storeId, sku]) + @@allow('all', true) +} + +model InventoryReservation { + id String @id + paymentTransactionLineItem PaymentTransactionLineItem[] + @@allow('all', true) +} + +model PaymentTransactionLineItem { + paymentTransactionId String + lineNumber Int + storeId String + productSku String + inventoryReservationId String? + paymentTransaction PaymentTransaction @relation(fields: [paymentTransactionId], references: [id], onDelete: Cascade) + productCatalogItem ProductCatalogItem @relation(fields: [storeId, productSku], references: [storeId, sku]) + inventoryReservation InventoryReservation? @relation(fields: [inventoryReservationId], references: [id], onDelete: SetNull) + @@id([paymentTransactionId, lineNumber]) + @@allow('all', true) +} + `, + { provider: 'postgresql', useCompactAliasNames: false }, + ); + + const rawDb = db.$unuseAll(); + + await rawDb.store.create({ data: { id: 'store_1' } }); + await rawDb.customerOrder.create({ data: { id: 'order_1', storeId: 'store_1' } }); + await rawDb.customerOrderPaymentSummary.create({ data: { id: 'summary_1', customerOrderId: 'order_1' } }); + await rawDb.paymentTransaction.create({ data: { id: 'payment_1' } }); + await rawDb.customerOrderPaymentSummaryLine.create({ + data: { + customerOrderPaymentSummaryId: 'summary_1', + lineIndex: 0, + paymentTransactionId: 'payment_1', + }, + }); + await rawDb.productCatalogItem.create({ data: { storeId: 'store_1', sku: 'sku_1' } }); + await rawDb.inventoryReservation.create({ data: { id: 'reservation_1' } }); + await rawDb.paymentTransactionLineItem.create({ + data: { + paymentTransactionId: 'payment_1', + lineNumber: 0, + storeId: 'store_1', + productSku: 'sku_1', + inventoryReservationId: 'reservation_1', + }, + }); + + const result = await db.customerOrderPaymentSummary.findUnique({ + where: { id: 'summary_1' }, + include: { + customerOrder: true, + customerOrderPaymentSummaryLine: { + include: { + paymentTransaction: { + include: { + paymentTransactionLineItem: { + include: { + productCatalogItem: true, + inventoryReservation: true, + }, + }, + }, + }, + }, + }, + }, + }); + + expect(result).toMatchObject({ + id: 'summary_1', + customerOrder: { + id: 'order_1', + storeId: 'store_1', + }, + customerOrderPaymentSummaryLine: [ + { + customerOrderPaymentSummaryId: 'summary_1', + lineIndex: 0, + paymentTransactionId: 'payment_1', + paymentTransaction: { + id: 'payment_1', + paymentTransactionLineItem: [ + { + paymentTransactionId: 'payment_1', + lineNumber: 0, + storeId: 'store_1', + productSku: 'sku_1', + inventoryReservationId: 'reservation_1', + productCatalogItem: { + storeId: 'store_1', + sku: 'sku_1', + }, + inventoryReservation: { + id: 'reservation_1', + }, + }, + ], + }, + }, + ], + }); + + await db.$disconnect(); + }); +});