Skip to content

Commit 1997cf3

Browse files
ymc9claude
andcommitted
fix(orm): coalesce NULL → '' in single-field _ftsRelevance ORDER BY
`to_tsvector(NULL)` returns NULL and `ts_rank(NULL, ...)` therefore returns NULL — under Postgres's default `NULLS FIRST` for `ORDER BY DESC` this would surface NULL-valued rows ahead of any matching ones, an asymmetry with the multi-field path where `concat_ws(' ', ...)` already skips NULLs and yields a 0.0 rank. Coalescing the field to `''` aligns the two paths. Adds `subtitle: String? @fullText` to the test fixture and a regression test that orders a NULL-subtitle row against a matching one — without the fix, the NULL row ranks first under DESC. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d0dd954 commit 1997cf3

4 files changed

Lines changed: 46 additions & 10 deletions

File tree

packages/orm/src/client/crud/dialects/postgresql.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -707,11 +707,15 @@ export class PostgresCrudDialect<Schema extends SchemaDef> extends LateralJoinDi
707707
const q = sql.val(search);
708708

709709
// Document expression: a single field, or `concat_ws` of all fields when
710-
// multi-field. `concat_ws` skips NULL arguments natively. Multi-field uses
711-
// a single ts_rank over the combined document (matches Prisma; ensures
712-
// AND queries match terms spread across fields).
710+
// multi-field. The single-field path coalesces NULL → '' so `ts_rank`
711+
// returns 0.0 (not NULL) for NULL-valued rows, matching the null-skipping
712+
// behavior `concat_ws` already provides on the multi-field path.
713+
// Multi-field uses a single ts_rank over the combined document (matches
714+
// Prisma; ensures AND queries match terms spread across fields).
713715
const document =
714-
fieldRefs.length === 1 ? fieldRefs[0]! : sql`concat_ws(' ', ${sql.join(fieldRefs)})`;
716+
fieldRefs.length === 1
717+
? sql`coalesce(${fieldRefs[0]!}, '')`
718+
: sql`concat_ws(' ', ${sql.join(fieldRefs)})`;
715719

716720
if (config === undefined) {
717721
// No regconfig — Postgres uses default_text_search_config.

tests/e2e/orm/client-api/full-text-search.test.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,28 @@ describe.skipIf(provider !== 'postgresql')('Full-text search tests', () => {
190190
expect(results[0]!.title).toBe('A cat and a dog');
191191
});
192192

193+
it('single-field _ftsRelevance on a nullable @fullText field tolerates NULL rows', async () => {
194+
// `subtitle` is `String? @fullText`. A row whose subtitle is NULL must
195+
// not break the orderBy expression — `to_tsvector(NULL)` returns NULL
196+
// and `ts_rank(NULL, ...)` returns NULL, which would otherwise place
197+
// those rows at the front under ASC. The single-field path coalesces
198+
// NULL → '' so `ts_rank` returns 0.0 instead, matching how the
199+
// multi-field `concat_ws` path already handles NULL inputs.
200+
const created = await Promise.all([
201+
client.article.create({ data: { title: 't1', body: 'b1', subtitle: 'cat' } }),
202+
client.article.create({ data: { title: 't2', body: 'b2', subtitle: null } }),
203+
]);
204+
const ids = created.map((r) => r.id);
205+
const results = await client.article.findMany({
206+
where: { id: { in: ids } },
207+
orderBy: [
208+
{ _ftsRelevance: { fields: ['subtitle'], search: 'cat', sort: 'desc' } },
209+
{ id: 'asc' },
210+
],
211+
});
212+
expect(results.map((r) => r.subtitle)).toEqual(['cat', null]);
213+
});
214+
193215
it('orderBy with config option', async () => {
194216
const results = await client.article.findMany({
195217
where: { body: { fts: { search: 'run', config: 'english' } } },
@@ -338,14 +360,16 @@ describe.skipIf(provider !== 'postgresql')('Full-text search tests', () => {
338360
it('rejects _ftsRelevance on a non-@fullText field', async () => {
339361
// `_ftsRelevance.fields` is typed as an enum of `@fullText` field names
340362
// only — `notes` is rejected with a precise enum-mismatch error that
341-
// also confirms the enum lists exactly `title` and `body`.
363+
// also confirms the enum lists exactly the three `@fullText` fields.
342364
await expect(
343365
client.article.findMany({
344366
orderBy: {
345367
_ftsRelevance: { fields: ['notes' as any], search: 'foo', sort: 'desc' },
346368
} as any,
347369
}),
348-
).rejects.toThrow(/expected one of "title"\|"body"\s*at\s*"orderBy\._ftsRelevance\.fields/i);
370+
).rejects.toThrow(
371+
/expected one of "title"\|"body"\|"subtitle"\s*at\s*"orderBy\._ftsRelevance\.fields/i,
372+
);
349373
});
350374

351375
// ---------------------------------------------------------------

tests/e2e/orm/schemas/full-text-search/schema.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,13 @@ export class SchemaType implements SchemaDef {
3333
fullText: true,
3434
attributes: [{ name: "@fullText" }] as readonly AttributeApplication[]
3535
},
36+
subtitle: {
37+
name: "subtitle",
38+
type: "String",
39+
optional: true,
40+
fullText: true,
41+
attributes: [{ name: "@fullText" }] as readonly AttributeApplication[]
42+
},
3643
notes: {
3744
name: "notes",
3845
type: "String",

tests/e2e/orm/schemas/full-text-search/schema.zmodel

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ datasource db {
44
}
55

66
model Article {
7-
id Int @id @default(autoincrement())
8-
title String @fullText
9-
body String @fullText
10-
notes String? // not full-text-searchable
7+
id Int @id @default(autoincrement())
8+
title String @fullText
9+
body String @fullText
10+
subtitle String? @fullText
11+
notes String? // not full-text-searchable
1112
}

0 commit comments

Comments
 (0)