Skip to content

Commit 8208900

Browse files
pkudinovymc9
andauthored
fix(orm): fallback to compact temp aliases for overlong names (#2425)
Co-authored-by: ymc9 <104139426+ymc9@users.noreply.github.com>
1 parent 75d77de commit 8208900

File tree

4 files changed

+212
-7
lines changed

4 files changed

+212
-7
lines changed
Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,69 @@
11
import { IdentifierNode, OperationNodeTransformer, type OperationNode, type QueryId } from 'kysely';
22
import { TEMP_ALIAS_PREFIX } from '../query-utils';
33

4+
type TempAliasTransformerMode = 'alwaysCompact' | 'compactLongNames';
5+
6+
type TempAliasTransformerOptions = {
7+
mode?: TempAliasTransformerMode;
8+
maxIdentifierLength?: number;
9+
};
10+
411
/**
512
* Kysely node transformer that replaces temporary aliases created during query construction with
613
* shorter names while ensuring the same temp alias gets replaced with the same name.
714
*/
815
export class TempAliasTransformer extends OperationNodeTransformer {
916
private aliasMap = new Map<string, string>();
17+
private readonly textEncoder = new TextEncoder();
18+
private readonly mode: TempAliasTransformerMode;
19+
private readonly maxIdentifierLength: number;
20+
21+
constructor(options: TempAliasTransformerOptions = {}) {
22+
super();
23+
this.mode = options.mode ?? 'alwaysCompact';
24+
// PostgreSQL limits identifier length to 63 bytes and silently truncates overlong aliases.
25+
const maxIdentifierLength = options.maxIdentifierLength ?? 63;
26+
if (
27+
!Number.isFinite(maxIdentifierLength) ||
28+
!Number.isInteger(maxIdentifierLength) ||
29+
maxIdentifierLength <= 0
30+
) {
31+
throw new RangeError('maxIdentifierLength must be a positive integer');
32+
}
33+
this.maxIdentifierLength = maxIdentifierLength;
34+
}
1035

1136
run<T extends OperationNode>(node: T): T {
1237
this.aliasMap.clear();
1338
return this.transformNode(node);
1439
}
1540

1641
protected override transformIdentifier(node: IdentifierNode, queryId?: QueryId): IdentifierNode {
17-
if (node.name.startsWith(TEMP_ALIAS_PREFIX)) {
42+
if (!node.name.startsWith(TEMP_ALIAS_PREFIX)) {
43+
return super.transformIdentifier(node, queryId);
44+
}
45+
46+
let shouldCompact = false;
47+
if (this.mode === 'alwaysCompact') {
48+
shouldCompact = true;
49+
} else {
50+
// check if the alias name exceeds the max identifier length, and
51+
// if so, compact it
52+
const aliasByteLength = this.textEncoder.encode(node.name).length;
53+
if (aliasByteLength > this.maxIdentifierLength) {
54+
shouldCompact = true;
55+
}
56+
}
57+
58+
if (shouldCompact) {
1859
let mapped = this.aliasMap.get(node.name);
1960
if (!mapped) {
2061
mapped = `$$t${this.aliasMap.size + 1}`;
2162
this.aliasMap.set(node.name, mapped);
2263
}
2364
return IdentifierNode.create(mapped);
65+
} else {
66+
return super.transformIdentifier(node, queryId);
2467
}
25-
return super.transformIdentifier(node, queryId);
2668
}
2769
}

packages/orm/src/client/executor/zenstack-query-executor.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -633,10 +633,9 @@ In such cases, ZenStack cannot reliably determine the IDs of the mutated entitie
633633
}
634634

635635
private processTempAlias<Node extends RootOperationNode>(query: Node): Node {
636-
if (this.options.useCompactAliasNames === false) {
637-
return query;
638-
}
639-
return new TempAliasTransformer().run(query);
636+
return new TempAliasTransformer({
637+
mode: this.options.useCompactAliasNames === false ? 'compactLongNames' : 'alwaysCompact',
638+
}).run(query);
640639
}
641640

642641
private createClientForConnection(connection: DatabaseConnection, inTx: boolean) {

packages/orm/src/client/options.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,8 +199,11 @@ export type ClientOptions<Schema extends SchemaDef> = QueryOptions<Schema> & {
199199
validateInput?: boolean;
200200

201201
/**
202-
* Whether to use compact alias names (e.g., "$t1", "$t2") when transforming ORM queries to SQL.
202+
* Whether to use compact alias names (e.g., "$$t1", "$$t2") when transforming ORM queries to SQL.
203203
* Defaults to `true`.
204+
*
205+
* When set to `false`, original aliases are kept unless temporary aliases become too long for
206+
* safe SQL identifier handling, in which case compact aliases are used as a fallback.
204207
*/
205208
useCompactAliasNames?: boolean;
206209

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import { createPolicyTestClient } from '@zenstackhq/testtools';
2+
import { describe, expect, it } from 'vitest';
3+
4+
describe('Regression for issue #2424', () => {
5+
it('deep nested include with PolicyPlugin works with non-compact alias mode', async () => {
6+
const db = await createPolicyTestClient(
7+
`
8+
model Store {
9+
id String @id
10+
customerOrders CustomerOrder[]
11+
productCatalogItems ProductCatalogItem[]
12+
@@allow('all', true)
13+
}
14+
15+
model CustomerOrder {
16+
id String @id
17+
storeId String
18+
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
19+
customerOrderPaymentSummary CustomerOrderPaymentSummary[]
20+
@@allow('all', true)
21+
}
22+
23+
model CustomerOrderPaymentSummary {
24+
id String @id
25+
customerOrderId String
26+
customerOrder CustomerOrder @relation(fields: [customerOrderId], references: [id], onDelete: Cascade)
27+
customerOrderPaymentSummaryLine CustomerOrderPaymentSummaryLine[]
28+
@@allow('all', true)
29+
}
30+
31+
model PaymentTransaction {
32+
id String @id
33+
customerOrderPaymentSummaryLine CustomerOrderPaymentSummaryLine[]
34+
paymentTransactionLineItem PaymentTransactionLineItem[]
35+
@@allow('all', true)
36+
}
37+
38+
model CustomerOrderPaymentSummaryLine {
39+
customerOrderPaymentSummaryId String
40+
lineIndex Int
41+
paymentTransactionId String
42+
customerOrderPaymentSummary CustomerOrderPaymentSummary @relation(fields: [customerOrderPaymentSummaryId], references: [id], onDelete: Cascade)
43+
paymentTransaction PaymentTransaction @relation(fields: [paymentTransactionId], references: [id], onDelete: Cascade)
44+
@@id([customerOrderPaymentSummaryId, lineIndex])
45+
@@allow('all', true)
46+
}
47+
48+
model ProductCatalogItem {
49+
storeId String
50+
sku String
51+
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
52+
paymentTransactionLineItem PaymentTransactionLineItem[]
53+
@@id([storeId, sku])
54+
@@allow('all', true)
55+
}
56+
57+
model InventoryReservation {
58+
id String @id
59+
paymentTransactionLineItem PaymentTransactionLineItem[]
60+
@@allow('all', true)
61+
}
62+
63+
model PaymentTransactionLineItem {
64+
paymentTransactionId String
65+
lineNumber Int
66+
storeId String
67+
productSku String
68+
inventoryReservationId String?
69+
paymentTransaction PaymentTransaction @relation(fields: [paymentTransactionId], references: [id], onDelete: Cascade)
70+
productCatalogItem ProductCatalogItem @relation(fields: [storeId, productSku], references: [storeId, sku])
71+
inventoryReservation InventoryReservation? @relation(fields: [inventoryReservationId], references: [id], onDelete: SetNull)
72+
@@id([paymentTransactionId, lineNumber])
73+
@@allow('all', true)
74+
}
75+
`,
76+
{ provider: 'postgresql', useCompactAliasNames: false },
77+
);
78+
79+
const rawDb = db.$unuseAll();
80+
81+
await rawDb.store.create({ data: { id: 'store_1' } });
82+
await rawDb.customerOrder.create({ data: { id: 'order_1', storeId: 'store_1' } });
83+
await rawDb.customerOrderPaymentSummary.create({ data: { id: 'summary_1', customerOrderId: 'order_1' } });
84+
await rawDb.paymentTransaction.create({ data: { id: 'payment_1' } });
85+
await rawDb.customerOrderPaymentSummaryLine.create({
86+
data: {
87+
customerOrderPaymentSummaryId: 'summary_1',
88+
lineIndex: 0,
89+
paymentTransactionId: 'payment_1',
90+
},
91+
});
92+
await rawDb.productCatalogItem.create({ data: { storeId: 'store_1', sku: 'sku_1' } });
93+
await rawDb.inventoryReservation.create({ data: { id: 'reservation_1' } });
94+
await rawDb.paymentTransactionLineItem.create({
95+
data: {
96+
paymentTransactionId: 'payment_1',
97+
lineNumber: 0,
98+
storeId: 'store_1',
99+
productSku: 'sku_1',
100+
inventoryReservationId: 'reservation_1',
101+
},
102+
});
103+
104+
const result = await db.customerOrderPaymentSummary.findUnique({
105+
where: { id: 'summary_1' },
106+
include: {
107+
customerOrder: true,
108+
customerOrderPaymentSummaryLine: {
109+
include: {
110+
paymentTransaction: {
111+
include: {
112+
paymentTransactionLineItem: {
113+
include: {
114+
productCatalogItem: true,
115+
inventoryReservation: true,
116+
},
117+
},
118+
},
119+
},
120+
},
121+
},
122+
},
123+
});
124+
125+
expect(result).toMatchObject({
126+
id: 'summary_1',
127+
customerOrder: {
128+
id: 'order_1',
129+
storeId: 'store_1',
130+
},
131+
customerOrderPaymentSummaryLine: [
132+
{
133+
customerOrderPaymentSummaryId: 'summary_1',
134+
lineIndex: 0,
135+
paymentTransactionId: 'payment_1',
136+
paymentTransaction: {
137+
id: 'payment_1',
138+
paymentTransactionLineItem: [
139+
{
140+
paymentTransactionId: 'payment_1',
141+
lineNumber: 0,
142+
storeId: 'store_1',
143+
productSku: 'sku_1',
144+
inventoryReservationId: 'reservation_1',
145+
productCatalogItem: {
146+
storeId: 'store_1',
147+
sku: 'sku_1',
148+
},
149+
inventoryReservation: {
150+
id: 'reservation_1',
151+
},
152+
},
153+
],
154+
},
155+
},
156+
],
157+
});
158+
159+
await db.$disconnect();
160+
});
161+
});

0 commit comments

Comments
 (0)