diff --git a/packages/orm/src/client/executor/name-mapper.ts b/packages/orm/src/client/executor/name-mapper.ts index b4ac42d9c..a5891a7e4 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, @@ -185,6 +186,52 @@ export class QueryNameMapper extends OperationNodeTransformer { }; } + protected override transformOrderByItem(node: OrderByItemNode, queryId?: 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) { if (!ColumnNode.is(node.column)) { return super.transformReference(node, queryId); 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' }, + ]); + }); +});