Skip to content

Commit d22709f

Browse files
ymc9claudeclaude[bot]
authored
fix(orm): prepend DISTINCT ON fields to ORDER BY for PostgreSQL compatibility (#2562)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> Co-authored-by: Yiming Cao <ymc9@users.noreply.github.com>
1 parent df9b35e commit d22709f

File tree

2 files changed

+96
-6
lines changed

2 files changed

+96
-6
lines changed

packages/orm/src/client/crud/dialects/base-dialect.ts

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -138,21 +138,42 @@ export abstract class BaseCrudDialect<Schema extends SchemaDef> {
138138
}
139139
result = this.buildSkipTake(result, skip, take);
140140

141-
// orderBy
142-
result = this.buildOrderBy(result, model, modelAlias, args.orderBy, negateOrderBy, take);
143-
144141
// distinct
142+
let distinctFields: string[] = [];
145143
if ('distinct' in args && (args as any).distinct) {
146-
const distinct = ensureArray((args as any).distinct) as string[];
144+
distinctFields = ensureArray((args as any).distinct) as string[];
147145
if (this.supportsDistinctOn) {
148-
result = result.distinctOn(distinct.map((f) => this.eb.ref(`${modelAlias}.${f}`)));
146+
result = result.distinctOn(distinctFields.map((f) => this.eb.ref(`${modelAlias}.${f}`)));
149147
} else {
150148
throw createNotSupportedError(`"distinct" is not supported by "${this.schema.provider.type}" provider`);
151149
}
152150
}
153151

152+
// orderBy
153+
// Some dialects (e.g., postgres) requires DISTINCT ON expressions to match the leftmost ORDER BY expressions.
154+
// Prepend distinct fields only when the user-supplied orderBy doesn't already satisfy this.
155+
let effectiveOrderBy = args.orderBy;
156+
if (distinctFields.length > 0 && this.supportsDistinctOn) {
157+
const existingOrderBy = enumerate(args.orderBy).filter((o) => Object.keys(o as object).length > 0);
158+
const alreadySatisfied = distinctFields.every(
159+
(f, i) => i < existingOrderBy.length && Object.keys(existingOrderBy[i] as object)[0] === f,
160+
);
161+
if (existingOrderBy.length > 0 && !alreadySatisfied) {
162+
const prependedOrderBy = distinctFields.map((f) => ({ [f]: 'asc' })) as any[];
163+
effectiveOrderBy = [...prependedOrderBy, ...existingOrderBy];
164+
}
165+
}
166+
result = this.buildOrderBy(result, model, modelAlias, effectiveOrderBy, negateOrderBy, take);
167+
154168
if (args.cursor) {
155-
result = this.buildCursorFilter(model, result, args.cursor, args.orderBy, negateOrderBy, modelAlias);
169+
result = this.buildCursorFilter(
170+
model,
171+
result,
172+
args.cursor,
173+
effectiveOrderBy as OrArray<Record<string, SortOrder>> | undefined,
174+
negateOrderBy,
175+
modelAlias,
176+
);
156177
}
157178
return result;
158179
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { createTestClient } from '@zenstackhq/testtools';
2+
import { describe, expect, it } from 'vitest';
3+
4+
// https://github.com/zenstackhq/zenstack/issues/2529
5+
describe('Regression for issue #2529', () => {
6+
async function setup() {
7+
const db = await createTestClient(
8+
`
9+
model Post {
10+
id Int @id @default(autoincrement())
11+
title String
12+
createdAt DateTime @default(now())
13+
}
14+
`,
15+
{ provider: 'postgresql' },
16+
);
17+
await db.post.create({ data: { title: 'A' } });
18+
await db.post.create({ data: { title: 'A' } });
19+
await db.post.create({ data: { title: 'B' } });
20+
return db;
21+
}
22+
23+
it('distinct only without orderBy', async () => {
24+
const db = await setup();
25+
26+
const result = await db.post.findMany({ distinct: ['title'] });
27+
28+
expect(result).toHaveLength(2);
29+
const titles = result.map((p: any) => p.title).sort();
30+
expect(titles).toEqual(['A', 'B']);
31+
});
32+
33+
it('orderBy only without distinct', async () => {
34+
const db = await setup();
35+
36+
const result = await db.post.findMany({ orderBy: { title: 'desc' } });
37+
38+
expect(result).toHaveLength(3);
39+
expect(result.map((p: any) => p.title)).toEqual(['B', 'A', 'A']);
40+
});
41+
42+
it('prepends the distinct field to orderBy when user-supplied orderBy does not start with it', async () => {
43+
const db = await setup();
44+
45+
const result = await db.post.findMany({
46+
distinct: ['title'],
47+
orderBy: { createdAt: 'desc' },
48+
});
49+
50+
expect(result).toHaveLength(2);
51+
const titles = result.map((p: any) => p.title).sort();
52+
expect(titles).toEqual(['A', 'B']);
53+
});
54+
55+
it('does not double-prepend when user-supplied orderBy already starts with the distinct field', async () => {
56+
const db = await setup();
57+
58+
// User already satisfies pg's requirement: ORDER BY "title" DESC, "createdAt" DESC
59+
// The distinct field must NOT be prepended again, which would change sort semantics.
60+
const result = await db.post.findMany({
61+
distinct: ['title'],
62+
orderBy: [{ title: 'desc' }, { createdAt: 'desc' }],
63+
});
64+
65+
expect(result).toHaveLength(2);
66+
// With ORDER BY title DESC, we expect 'B' before 'A'
67+
expect(result.map((p: any) => p.title)).toEqual(['B', 'A']);
68+
});
69+
});

0 commit comments

Comments
 (0)