Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 44 additions & 2 deletions packages/orm/src/client/executor/temp-alias-transformer.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,69 @@
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<string, string>();
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<T extends OperationNode>(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)) {
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);
}
}
7 changes: 3 additions & 4 deletions packages/orm/src/client/executor/zenstack-query-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -633,10 +633,9 @@ In such cases, ZenStack cannot reliably determine the IDs of the mutated entitie
}

private processTempAlias<Node extends RootOperationNode>(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) {
Expand Down
5 changes: 4 additions & 1 deletion packages/orm/src/client/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,8 +199,11 @@ export type ClientOptions<Schema extends SchemaDef> = QueryOptions<Schema> & {
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<Schema> extends true
Expand Down
161 changes: 161 additions & 0 deletions tests/regression/test/issue-2424.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading