From 262af3e89c5ec26b53fc5cfd7d2d0b8d2b80982d Mon Sep 17 00:00:00 2001 From: FTB_lag Date: Sat, 29 Aug 2026 04:15:35 +0300 Subject: [PATCH 1/3] fix(orm): append table for order by node (#2821) --- .../orm/src/client/executor/name-mapper.ts | 14 ++++- tests/regression/test/issue-2821.test.ts | 62 +++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 tests/regression/test/issue-2821.test.ts diff --git a/packages/orm/src/client/executor/name-mapper.ts b/packages/orm/src/client/executor/name-mapper.ts index b4ac42d9c..0093147e5 100644 --- a/packages/orm/src/client/executor/name-mapper.ts +++ b/packages/orm/src/client/executor/name-mapper.ts @@ -14,6 +14,7 @@ import { InsertQueryNode, type OperationNode, OperationNodeTransformer, + type OrderByItemNode, PrimitiveValueListNode, type QueryId, ReferenceNode, @@ -47,6 +48,7 @@ type Scope = { model?: string; alias?: OperationNode; namesMapped?: boolean; // true means fields referring to this scope have their names already mapped + orderBy?: boolean; }; type SelectionNodeChild = SimpleReferenceExpressionNode | AliasNode | SelectAllNode; @@ -185,6 +187,10 @@ export class QueryNameMapper extends OperationNodeTransformer { }; } + protected override transformOrderByItem(node: OrderByItemNode, queryId?: QueryId) { + return this.withScope({ orderBy: true }, () => super.transformOrderByItem(node, queryId)); + } + protected override transformReference(node: ReferenceNode, queryId?: QueryId) { if (!ColumnNode.is(node.column)) { return super.transformReference(node, queryId); @@ -192,7 +198,8 @@ export class QueryNameMapper extends OperationNodeTransformer { // resolve the reference to a field from outer scopes const scope = this.resolveFieldFromScopes(node.column.column.name, node.table?.table.identifier.name); - if (scope && !scope.namesMapped && scope.model) { + const inOrderBy = this.scopes.some((s) => s.orderBy); + if (scope?.model && (!scope.namesMapped || (inOrderBy && !node.table))) { // map column name and table name as needed const mappedFieldName = this.mapFieldName(scope.model, node.column.column.name); @@ -205,6 +212,11 @@ export class QueryNameMapper extends OperationNodeTransformer { // table name is resolved to a model, map the name as needed mappedTableName = this.mapTableName(scope.model); } + } else if (inOrderBy && scope.alias && IdentifierNode.is(scope.alias)) { + // inside "order by", qualify an otherwise-unqualified reference with its resolved + // table/alias, so it can't be shadowed by a same-named computed selection (e.g. a + // `CASE WHEN ... END AS field` produced for enum value mapping) + mappedTableName = scope.alias.name; } return ReferenceNode.create( ColumnNode.create(mappedFieldName), diff --git a/tests/regression/test/issue-2821.test.ts b/tests/regression/test/issue-2821.test.ts new file mode 100644 index 000000000..1bedeb33a --- /dev/null +++ b/tests/regression/test/issue-2821.test.ts @@ -0,0 +1,62 @@ +import { createTestClient } from '@zenstackhq/testtools'; +import { describe, expect, it } from 'vitest'; + +// https://github.com/zenstackhq/zenstack/issues/2821 +describe('Regression for issue #2821', () => { + it('supported enum array', async () => { + const schema = ` +enum OkStatus { + OK @map("ok") + NO @map("no") + + @@map("ok_status") +} + +model Post { + id Int @id + status OkStatus +} +`; + + const db = await createTestClient(schema, { usePrismaPush: true, provider: 'postgresql', debug: true }); + + await db.post.create({ data: { id: 1, status: 'NO' } }); + await db.post.create({ data: { id: 2, status: 'OK' } }); + await db.post.create({ data: { id: 3, status: 'NO' } }); + await db.post.create({ data: { id: 4, status: 'OK' } }); + + const ascVariant1 = await db.$qb.selectFrom('Post').select('status').orderBy('status', 'asc').execute(); + const ascVariant2 = await db.$qb.selectFrom('Post').select('status').orderBy('Post.status', 'asc').execute(); + const ascVariant3 = await db.$qb + .selectFrom('Post') + .select('status as otherName') + .orderBy('status', 'asc') + .execute(); + + expect(ascVariant1).toEqual([{ status: 'OK' }, { status: 'OK' }, { status: 'NO' }, { status: 'NO' }]); + expect(ascVariant2).toEqual([{ status: 'OK' }, { status: 'OK' }, { status: 'NO' }, { status: 'NO' }]); + expect(ascVariant3).toEqual([ + { otherName: 'OK' }, + { otherName: 'OK' }, + { otherName: 'NO' }, + { otherName: 'NO' }, + ]); + + const descVariant1 = await db.$qb.selectFrom('Post').select('status').orderBy('status', 'desc').execute(); + const descVariant2 = await db.$qb.selectFrom('Post').select('status').orderBy('Post.status', 'desc').execute(); + const descVariant3 = await db.$qb + .selectFrom('Post') + .select('status as otherName') + .orderBy('status', 'desc') + .execute(); + + expect(descVariant1).toEqual([{ status: 'NO' }, { status: 'NO' }, { status: 'OK' }, { status: 'OK' }]); + expect(descVariant2).toEqual([{ status: 'NO' }, { status: 'NO' }, { status: 'OK' }, { status: 'OK' }]); + expect(descVariant3).toEqual([ + { otherName: 'NO' }, + { otherName: 'NO' }, + { otherName: 'OK' }, + { otherName: 'OK' }, + ]); + }); +}); From 91c1c31fd8ab352814a71b6e05cdbf8737159999 Mon Sep 17 00:00:00 2001 From: FTB_lag Date: Sat, 29 Aug 2026 16:06:07 +0300 Subject: [PATCH 2/3] fix(orm): add qualifier for enum scope --- packages/orm/src/client/executor/name-mapper.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/orm/src/client/executor/name-mapper.ts b/packages/orm/src/client/executor/name-mapper.ts index 0093147e5..4280f7fa6 100644 --- a/packages/orm/src/client/executor/name-mapper.ts +++ b/packages/orm/src/client/executor/name-mapper.ts @@ -782,7 +782,10 @@ export class QueryNameMapper extends OperationNodeTransformer { private processEnumSelection(selection: SelectionNodeChild, fieldName: string) { const { alias, node } = stripAlias(selection); - const fieldScope = this.resolveFieldFromScopes(fieldName); + const fieldScope = this.resolveFieldFromScopes( + fieldName, + ReferenceNode.is(node) ? node.table?.table?.identifier.name : undefined, + ); if (!fieldScope || !fieldScope.model) { return selection; } From 5eee995f59afd354985e729f2d819e8df12635fe Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:11:07 +0800 Subject: [PATCH 3/3] fix(orm): qualify order-by refs only when shadowed by enum value mapping projection Rework the fix to post-process order-by items instead of threading an orderBy flag through the reference transform: an unqualified column reference is re-qualified with its resolved table/alias only when the enum-value-mapping CASE projection would actually shadow it (field not renamed by @map and enum has mapped values). This keeps ORDER BY SQL unchanged everywhere else, including deliberate ordering by user-authored output aliases. Co-Authored-By: Claude Fable 5 --- .../orm/src/client/executor/name-mapper.ts | 58 ++++++++++++++----- 1 file changed, 45 insertions(+), 13 deletions(-) diff --git a/packages/orm/src/client/executor/name-mapper.ts b/packages/orm/src/client/executor/name-mapper.ts index 4280f7fa6..a5891a7e4 100644 --- a/packages/orm/src/client/executor/name-mapper.ts +++ b/packages/orm/src/client/executor/name-mapper.ts @@ -48,7 +48,6 @@ type Scope = { model?: string; alias?: OperationNode; namesMapped?: boolean; // true means fields referring to this scope have their names already mapped - orderBy?: boolean; }; type SelectionNodeChild = SimpleReferenceExpressionNode | AliasNode | SelectAllNode; @@ -188,7 +187,49 @@ export class QueryNameMapper extends OperationNodeTransformer { } protected override transformOrderByItem(node: OrderByItemNode, queryId?: QueryId) { - return this.withScope({ orderBy: true }, () => super.transformOrderByItem(node, queryId)); + const result = super.transformOrderByItem(node, queryId); + return { ...result, orderBy: this.qualifyShadowedOrderByRef(result.orderBy) }; + } + + // When a column's enum type has `@map`-ed values, selecting it emits a computed + // `CASE ... END AS "column"` projection. In SQL, an unqualified `ORDER BY column` resolves + // to that output alias rather than the underlying column, silently switching the sort from + // native enum order to alphabetical order of the mapped-back labels. Re-qualify such + // references with their resolved table/alias so they keep pointing at the real column. + private qualifyShadowedOrderByRef(node: OperationNode): OperationNode { + let columnName: string | undefined; + if (ReferenceNode.is(node) && ColumnNode.is(node.column) && !node.table) { + columnName = node.column.column.name; + } else if (ColumnNode.is(node)) { + columnName = node.column.name; + } + if (!columnName) { + return node; + } + + const scope = this.resolveFieldFromScopes(columnName); + if (!scope?.model) { + return node; + } + + // we're inspecting a post-transform name: a renamed field's reference has already been + // rewritten to its column name, so if the resolved field's column differs from the name + // we're holding, the resolution is a name collision with an unrelated (renamed) field — + // qualifying based on it could point at the wrong table + if (this.mapFieldName(scope.model, columnName) !== columnName) { + return node; + } + + // and only when the enum-value mapping actually rewrites the projection + const fieldDef = getField(this.schema, scope.model, columnName); + const enumDef = fieldDef && getEnum(this.schema, fieldDef.type); + if (!enumDef || Object.keys(this.getEnumValueMapping(enumDef)).length === 0) { + return node; + } + + const tableName = + scope.alias && IdentifierNode.is(scope.alias) ? scope.alias.name : this.mapTableName(scope.model); + return ReferenceNode.create(ColumnNode.create(columnName), TableNode.create(tableName)); } protected override transformReference(node: ReferenceNode, queryId?: QueryId) { @@ -198,8 +239,7 @@ export class QueryNameMapper extends OperationNodeTransformer { // resolve the reference to a field from outer scopes const scope = this.resolveFieldFromScopes(node.column.column.name, node.table?.table.identifier.name); - const inOrderBy = this.scopes.some((s) => s.orderBy); - if (scope?.model && (!scope.namesMapped || (inOrderBy && !node.table))) { + if (scope && !scope.namesMapped && scope.model) { // map column name and table name as needed const mappedFieldName = this.mapFieldName(scope.model, node.column.column.name); @@ -212,11 +252,6 @@ export class QueryNameMapper extends OperationNodeTransformer { // table name is resolved to a model, map the name as needed mappedTableName = this.mapTableName(scope.model); } - } else if (inOrderBy && scope.alias && IdentifierNode.is(scope.alias)) { - // inside "order by", qualify an otherwise-unqualified reference with its resolved - // table/alias, so it can't be shadowed by a same-named computed selection (e.g. a - // `CASE WHEN ... END AS field` produced for enum value mapping) - mappedTableName = scope.alias.name; } return ReferenceNode.create( ColumnNode.create(mappedFieldName), @@ -782,10 +817,7 @@ export class QueryNameMapper extends OperationNodeTransformer { private processEnumSelection(selection: SelectionNodeChild, fieldName: string) { const { alias, node } = stripAlias(selection); - const fieldScope = this.resolveFieldFromScopes( - fieldName, - ReferenceNode.is(node) ? node.table?.table?.identifier.name : undefined, - ); + const fieldScope = this.resolveFieldFromScopes(fieldName); if (!fieldScope || !fieldScope.model) { return selection; }