Skip to content

Commit 03797bd

Browse files
fix(objectql): $search emits no text clause over a column that can never hold text (#29502)
domain:server#2 · os-steve · session_01ADHkmQxwoGb5fSnrFkpxmt <!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi91zfUQtVgYdNNtTEQp3VSvj)_ Fixes #29394 Clause-②: no Metadata-protocol: none Clause-② basis (contract review 6583544380): nothing the contract refuses is newly accepted. The declared-branch contract, in the `resolveSearchFieldResolution` docblock of `packages/spec/src/data/search-fields.ts`, admits a declared `json` column as the author's explicit choice ("executed by the engine as a `$contains` over the stored JSON text"). #4254 puts the loudness for the search set at the ingress, which judges the same set before and after this change. Ruling 6573569376 letter A admits a field marked `searchable: true` "of any type". The `400 INVALID_FILTER` that a json field caused was a driver mis-refusal of a declared, admitted request, so turning it into a 200 is not a widening. ## 维护者速读 - **改了什么**:`$search` 展开成"每个可搜索字段一个模糊文本匹配"时,不再为 number / boolean / 日期时间 / json 这类永远存不了文本的字段生成匹配;文本、选项、关联、文件字段照旧。 - **为什么改**:对象的 `searchableFields` 里只要有一个 json 字段,该对象上的**每一次**搜索都会 400 失败(实测 PostgreSQL、SQLite、内存驱动都一样);内存驱动还会按日期的文本命中(搜 `2026` 搜到 2026 年的日期),和 SQL 驱动不一致。 - **风险与回滚**:不拒绝任何请求;只由非文本字段组成的搜索集合现在返回 0 行(不报错,也不返回全部)。数值/日期在 SQL 驱动上本来就匹配不到,结果不变。回滚即 revert 本 PR。 - **先看**:① `packages/objectql/src/search-filter.ts` 的 `neverHoldsText` 与 `fieldClausesForTerm` 第一行;② `packages/rest/src/data-search-non-text-column-door.test.ts`(真实 SQLite 驱动)。 - **两分钟验证**:在已构建的工作区跑 `pnpm --filter @objectstack/rest exec vitest run src/data-search-non-text-column-door.test.ts`,预期 6/6 通过:`$search=qq9` 返回 200 和两行,不再是 400 INVALID_FILTER。 - **需要你做的**:无,席位落地 ## What changes - **`packages/objectql/src/search-filter.ts`.** `fieldClausesForTerm` returns no clause for a field whose declared type gets the #15661 door's `door-refusal` verdict. The verdict is `textOperatorDoorVerdict` from `@objectstack/spec/data`, read and not re-listed: the numeric, boolean, calendar-date, instant, clock-time and structured-JSON classes, plus a `formula` whose readable `returnType` names one. The module header records why. - **Skip, not cast.** A search term is text, and such a column has no text for it to match. Skipping gives the declared no-match on every backend. A cast would make the term match a value's text rendering, which differs by dialect (a date's ISO text, a number's formatting), and #15683 already ruled the SQLite ISO-text match out as a contract. - **The resolved search set is not narrowed.** `resolveSearchFields` still answers it whole, so the #24284 empty-set refusal, the #28120 override refusal and the `$searchFields` ingress door judge exactly the set they judged before. A set holding ONLY such fields expands to an `$or` with no arm for each term. That is FALSE (`FILTER_LOGIC_CASES`, "empty $or is FALSE"), so the search answers no rows, with 200, and is neither an error nor every row. A `$searchFields` naming a declared non-text column is still accepted and matches nothing through it. - **`packages/objectql/src/engine.ts`.** A comment in the `expandSearchOnAst` docblock points at the skip. No code changes there. - **Changeset.** `@objectstack/objectql` patch, with the claim's two lines at line start. - **Both branches, both pinned.** The skip sits after resolution, so it covers the declared `searchableFields` branch and the auto branch. #29385 has merged (`searchable: true` joins the auto set, of any type), and both are pinned. A flagged `number` / `date` / `json` field is admitted to the set and gets no clause, and its text control is still scanned. ## Tests - `packages/objectql/src/engine-search-non-text-column.test.ts` (new, dialect-neutral pin on the AST the driver receives). It declares a search set over one field per `FieldType` (the spec's `TEXT_OPERATOR_DOOR_FIXTURE`) and asserts that the door's own walk (`findTextOperatorOverNonTextField`) finds nothing in the emitted search. Its CONTROL is that `f_text`, `f_select`, `f_lookup` and `f_tags` are still scanned. Further cases: a mixed `name` / `qty` / `born` / `active` / `meta` set scans only `name`. The terms `42`, `2026` and `true` match nothing. A multi-term search carries only `name` in each term's `$or`. A set of only non-text fields reaches the driver as `{ $or: [] }` and answers 0 rows on `find` and `count`, and a blank term still answers every row. A `searchFields` naming `qty` answers no rows with no refusal, and `qty,name` matches through `name`. CONTROL: an override outside the set is still `400 INVALID_FIELD`. **Auto branch:** with no `searchableFields`, flagged `searchable: true` `number` / `date` / `json` fields get no clause, the unflagged text `name` is still scanned (rows r1 and r2), and naming a flagged field answers `{ $or: [] }`, no rows, with no refusal. - `packages/rest/src/data-search-non-text-column-door.test.ts` (new) runs the Data API door, GET and POST, over a real `SqlDriver` (SQLite), on the harness of `data-search-fields-credential-door.test.ts`. It covers the auto branch too: a flagged `number` / `date` / `json` field with no `searchableFields` answers 200 through the text column, and `$searchFields` naming the flagged json field is accepted and matches nothing. CONTROL: the caller's own `{ qty: { $contains: '4' } }` is still `400 INVALID_FILTER` from the #15661 door. - No forced test edits. ## Acceptance notes 1. **The card's premise, measured.** The card read a Postgres type error (SQLSTATE 42883) for number / boolean / date columns. On a live PostgreSQL 16 that does not happen on this base. `driver-sql`'s compile-time type gate (#14079 / #15683) already turns those clauses into no-match. The real failure is `json`: the SQL family and `driver-memory` both refuse a text operator on a JSON column, so one `json` field in the set fails every search on the object. The other divergence is `driver-memory`, which matched a date stored as text by ISO substring. Readings are below. 2. **Postgres harness.** The repo's live-dialect matrix (`OS_TEST_POSTGRES_URL`) is wired in CI only for driver-sql and metadata-protocol, and an engine-level live cell would need the isolation scaffolding of `cascade-delete-multivalue-lookup-real-driver.integration.test.ts`. So the committed pins are the dialect-neutral AST pin and the real SQLite door. The Postgres readings come from a one-off, uncommitted engine plus `SqlDriver` script run against a local PostgreSQL 16 in this container. 3. **The 400 that becomes a 200.** A search that answered `400 INVALID_FILTER` (a json field in the set) now answers 200. No request is refused that was accepted. The 400 was a driver refusal of a predicate the caller never wrote, so the claim's `Clause-②: no` is kept. The seat may re-grade it. ## Verification Head `18f7a010436b8d069ccf78e579e05691fecd85ae`, a clean merge with `origin/main` at `c88467d4` (it includes #29385). Round 2 (contract review 6583544380) is test-only: it adds the auto-branch pins and changes no source. <details> <summary>Red first on main (7b1ae33), then green on the fix commit bc2269f (the head's full suites are below)</summary> ```text main objectql engine-search-non-text-column.test.ts Tests 6 failed | 1 passed (7) (the passing one is the override-refusal control) main rest data-search-non-text-column-door.test.ts Tests 4 failed | 1 passed (5) 400 INVALID_FILTER "...aims a scalar comparison or text operator at a multi-value or JSON field..."; the #15661 control passed bc2269f objectql (10 search / door files) Test Files 10 passed (10) · Tests 198 passed bc2269f rest data-search-non-text-column-door.test.ts Tests 5 passed (5) round 2 objectql engine-search-non-text-column.test.ts Tests 8 passed (8) (+1: auto branch) round 2 rest data-search-non-text-column-door.test.ts Tests 6 passed (6) (+1: auto branch) ``` </details> <details> <summary>Live readings: engine + SqlDriver on PostgreSQL 16 and SQLite, and the in-memory driver (one-off script, not committed)</summary> Rows: `r1` has name `qq9-alpha`, qty 42, born 2026-01-05, active true and meta `{k:'qq9'}`; `r2` has name `other`. Each object declares `searchableFields: ['name', X]`. ```text before (skip removed, rebuilt) after (head) pg name+json qq9 400 INVALID_FILTER 1 row [r1] pg name+json 42 400 INVALID_FILTER 0 rows pg all five qq9 400 INVALID_FILTER 1 row [r1] pg name+qty qq9 1 row [r1] (no 42883: type-gated) 1 row [r1] pg name+born 2026 0 rows 0 rows sqlite name+json qq9 400 INVALID_FILTER 1 row [r1] sqlite all five qq9 400 INVALID_FILTER 1 row [r1] memory name+json qq9 400 INVALID_FILTER 1 row [r1] memory name+born 2026 1 row [r1] (ISO substring of born) 0 rows any only qty/born/active 42 / 2026 0 rows 0 rows ``` The `before` column for pg and sqlite was taken under the ablation build below, because the first main run's SQL inserts were refused for a missing organization row and so held no rows. The 400 cells read the same with rows absent. The memory `before` cells come from the first main run, where the memory inserts landed. </details> <details> <summary>Ablation: the skip removed, rebuilt, proven in dist, then restored</summary> `scripts/ablation-replace.mjs --hold` replaced `if (neverHoldsText(meta)) return [];` with a guard that never fires, carrying the string marker `ABLATION_29394_SKIP_OFF` (anchor 1 → 0, blob `0c1d38a4` → `8ede2261`). After `pnpm --filter @objectstack/objectql build`, `ablation-dist-preflight.mjs` found the marker in 4 built files. ```text round 1 objectql engine-search-non-text-column.test.ts Tests 6 failed | 1 passed (7) round 1 rest data-search-non-text-column-door.test.ts Tests 4 failed | 1 passed (5) round 2 objectql engine-search-non-text-column.test.ts Tests 7 failed | 1 passed (8) (the auto-branch pin reds too) round 2 rest data-search-non-text-column-door.test.ts Tests 5 failed | 1 passed (6) (the auto-branch pin reds too) ``` Every control stayed green in both rounds. Restore (each round): the blob after restore equals HEAD (`0c1d38a4`) and `git diff HEAD` is empty. After a rebuild, `--absent` found the marker absent from all 14 built files and the tree clean. </details> <details> <summary>Suites, typecheck, lint and derived gates on the head 18f7a01</summary> ```text turbo run build --filter=./packages/** (after merging main) exit 0 @objectstack/objectql test Test Files 535 passed (535) · Tests 10399 passed (10399) @objectstack/objectql typecheck exit 0 @objectstack/rest search files (9: data-*search* incl. data-search-field-searchable-flag-door, discovery-search, list-search-total, meta-object-search-companion, search-clone) Test Files 9 passed (9) · Tests 54 passed | 22 skipped @objectstack/rest typecheck exit 0 eslint --no-inline-config packages/objectql packages/rest (round 1) and the two edited test files (round 2) exit 0 dispatch-gates --commands (63 derived) then --ran: ✓ dispatch-gates --ran: 63 derived famil(ies) accounted for — 63 run, 0 NOT-MEASURED (a DERIVED zero — all 63 recorded an exit code and none of them is 3) ``` </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01ADHkmRnyoAJD6oToQ2QZUK)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 32907ae commit 03797bd

5 files changed

Lines changed: 495 additions & 0 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
fix(objectql): `$search` no longer aims a text match at a column that can never hold text (number, boolean, date and time, json)
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
A `$search` is expanded by the engine into one case-insensitive text match per field of the object's search set. When that set held a `number`, `currency`, `boolean`, `date`, `datetime`, `time` or `json` field (an object's `searchableFields` naming one), the engine still emitted a text match for it, and the field-type check that refuses the same match when a caller writes it never saw it. What happened next depended on the driver. One `json` field in the set made every search on the object fail with `400 INVALID_FILTER`, on PostgreSQL, SQLite and the in-memory driver alike. A numeric, boolean or temporal field matched nothing on the SQL drivers, while the in-memory driver matched a date by its text (a search for `2026` found a record dated `2026-01-05`).
11+
12+
Now such a field adds no match to the search. The set is read from the same field-type verdict the filter check uses (`textOperatorDoorVerdict`), not from a new list. Text, option, reference and file fields are searched as before. A search set made only of such fields answers no rows (200, not an error and not every row), and a `$searchFields` naming one is still accepted and matches nothing through it. The search set itself, and every refusal judged on it, is unchanged. To find records by a number or a date, filter with `$eq` or a range (`$gte` / `$lt`) on that field.
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#29394] The `$search` expansion emits no text clause over a column whose
5+
* DECLARED type can never hold a string — the #15661 door's own verdict
6+
* (`textOperatorDoorVerdict`, `@objectstack/spec/data`), read, never re-listed.
7+
*
8+
* The door judges the caller's `where` before `expandSearchOnAst` runs, so the
9+
* expansion's `$icontains` never met it: a declared `searchableFields` naming a
10+
* `number` / `boolean` / `date` / `json` column (and, once `searchable: true`
11+
* joins the auto set, a flagged one) sent the driver exactly the predicate the
12+
* door refuses when a caller writes it. Beneath the door each backend answered
13+
* that predicate its own way: the SQL family refuses it on a JSON column
14+
* (`INVALID_FILTER`, so the whole search fails) and type-gates it to no-match
15+
* on a numeric / boolean / temporal one, while a value-keyed evaluator matches
16+
* the value's text rendering (`42` hits `'4'`, a date hits `'2026'`).
17+
*
18+
* The answer chosen is SKIP: such a column contributes no clause. A search
19+
* term is text, and a column that can never hold text has nothing for it to
20+
* match, on any backend — the same declared no-match the SQL faces compile.
21+
* The resolved search set is unchanged, so every refusal judged on it (#24284
22+
* empty set, #28120 override) answers as before, and a set holding ONLY such
23+
* columns answers no rows: each term's `$or` has no arm, which is FALSE
24+
* (`FILTER_LOGIC_CASES`, "empty $or is FALSE"). Not an error, not every row.
25+
*
26+
* Dialect-neutral: the pin is on the AST the driver receives. The real-driver
27+
* half is `packages/rest/src/data-search-non-text-column-door.test.ts`.
28+
*/
29+
30+
import { describe, it, expect, beforeEach } from 'vitest';
31+
import {
32+
TEXT_OPERATOR_DOOR_FIXTURE,
33+
TEXT_OPERATOR_DOOR_FIXTURE_OBJECT,
34+
textOperatorDoorVerdict,
35+
} from '@objectstack/spec/data';
36+
import { ObjectQL } from './engine.js';
37+
import { findTextOperatorOverNonTextField } from './text-operator-declared-type-door.js';
38+
39+
type Row = Record<string, unknown>;
40+
41+
const OBJECT = 'search_non_text_29394';
42+
const ONLY_NON_TEXT = 'search_only_non_text_29394';
43+
const AUTO_FLAGGED = 'search_auto_flagged_29394';
44+
45+
const FIELDS = {
46+
name: { type: 'text' },
47+
qty: { type: 'number' },
48+
born: { type: 'date' },
49+
active: { type: 'boolean' },
50+
meta: { type: 'json' },
51+
} as const;
52+
53+
const ROWS: Row[] = [
54+
{ id: 'r1', name: 'qq9-alpha', qty: 42, born: '2026-01-05', active: true, meta: { k: 'qq9' } },
55+
{ id: 'r2', name: 'qq9-beta', qty: 7, born: '2025-03-01', active: false, meta: { k: 'x' } },
56+
{ id: 'r3', name: 'other', qty: 420, born: '2026-02-02', active: true, meta: { k: 'qq9' } },
57+
];
58+
59+
/** The keys every clause of an emitted search `$or` names — the fields the search scanned. */
60+
function scannedFields(where: unknown): string[] {
61+
const out = new Set<string>();
62+
const walk = (node: unknown): void => {
63+
if (!node || typeof node !== 'object' || Array.isArray(node)) return;
64+
for (const [k, v] of Object.entries(node as Row)) {
65+
if (k === '$and' || k === '$or') { for (const arm of v as unknown[]) walk(arm); continue; }
66+
if (!k.startsWith('$')) out.add(k);
67+
}
68+
};
69+
walk(where);
70+
return [...out].sort();
71+
}
72+
73+
/**
74+
* A value-keyed evaluator, the shape of the in-memory faces: a text operator
75+
* reads the stored value's text rendering. That is exactly how a search term
76+
* matched `42` or a date on such a face before the skip.
77+
*/
78+
const MATCHERS: Record<string, (value: unknown, operand: unknown) => boolean> = {
79+
$icontains: (value, operand) => value != null && String(value).toLowerCase().includes(String(operand).toLowerCase()),
80+
};
81+
82+
function matches(row: Row, where: unknown): boolean {
83+
if (!where || typeof where !== 'object') return true;
84+
for (const [k, v] of Object.entries(where as Row)) {
85+
if (k === '$and') { if (!(v as unknown[]).every((w) => matches(row, w))) return false; continue; }
86+
if (k === '$or') { if (!(v as unknown[]).some((w) => matches(row, w))) return false; continue; }
87+
if (k.startsWith('$')) throw new Error(`matcher: unimplemented key ${k}`);
88+
if (v && typeof v === 'object' && !Array.isArray(v)) {
89+
for (const [op, operand] of Object.entries(v as Row)) {
90+
const m = MATCHERS[op];
91+
if (!m) throw new Error(`matcher: unimplemented operator ${op}`);
92+
if (!m(row[k], operand)) return false;
93+
}
94+
continue;
95+
}
96+
if (row[k] !== v) return false;
97+
}
98+
return true;
99+
}
100+
101+
function makeDriver() {
102+
const reads: Array<{ object: string; where: unknown }> = [];
103+
const read = (object: string, ast: any) => {
104+
reads.push({ object, where: ast?.where });
105+
const rows = ([OBJECT, ONLY_NON_TEXT, AUTO_FLAGGED].includes(object) ? ROWS : []).filter((r) => matches(r, ast?.where));
106+
return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows;
107+
};
108+
const driver: any = {
109+
name: 'mem', version: '0.0.0', supports: {},
110+
async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; },
111+
async find(object: string, ast: any) { return read(object, ast).map((r) => ({ ...r })); },
112+
async findOne(object: string, ast: any) { return read(object, ast)[0] ?? null; },
113+
async count(object: string, ast: any) { return read(object, ast).length; },
114+
async create(_o: string, d: any) { return d; },
115+
async update(_o: string, _id: string, d: any) { return d; },
116+
async delete() { return true; },
117+
async deleteMany() { return 0; },
118+
async bulkCreate(_o: string, r: any[]) { return r; },
119+
async bulkUpdate() { return []; }, async bulkDelete() {},
120+
async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; },
121+
async commit() {}, async rollback() {},
122+
};
123+
return { driver, reads };
124+
}
125+
126+
describe('[#29394] $search emits no text clause over a never-string-capable column', () => {
127+
let engine: ObjectQL;
128+
let reads: ReturnType<typeof makeDriver>['reads'];
129+
const lastWhere = (object = OBJECT) => [...reads].reverse().find((r) => r.object === object)?.where;
130+
131+
beforeEach(async () => {
132+
const made = makeDriver();
133+
reads = made.reads;
134+
engine = new ObjectQL();
135+
engine.registerDriver(made.driver, true);
136+
await engine.init();
137+
const registry = engine.registry as any;
138+
registry.registerObject({
139+
name: OBJECT,
140+
searchableFields: ['name', 'qty', 'born', 'active', 'meta'],
141+
fields: FIELDS,
142+
}, 'test');
143+
registry.registerObject({
144+
name: ONLY_NON_TEXT,
145+
searchableFields: ['qty', 'born', 'active', 'meta'],
146+
fields: FIELDS,
147+
}, 'test');
148+
// The AUTO branch (ruling 6573569376 letter A): no `searchableFields`, so
149+
// every field marked `searchable: true` joins the set, of any type. The
150+
// resolver admits the flagged number / date / json fields; the expansion
151+
// must still emit no text clause for them.
152+
registry.registerObject({
153+
name: AUTO_FLAGGED,
154+
fields: {
155+
name: { type: 'text' },
156+
qty: { type: 'number', searchable: true },
157+
born: { type: 'date', searchable: true },
158+
meta: { type: 'json', searchable: true },
159+
},
160+
}, 'test');
161+
registry.registerObject({
162+
...TEXT_OPERATOR_DOOR_FIXTURE,
163+
// Every fixture field the declared branch keeps: one per FieldType member.
164+
searchableFields: Object.keys(TEXT_OPERATOR_DOOR_FIXTURE.fields).filter((f) => f !== 'id'),
165+
}, 'test');
166+
});
167+
168+
it('the declared set over every FieldType: the emitted search is one the #15661 door passes', async () => {
169+
await engine.find(TEXT_OPERATOR_DOOR_FIXTURE_OBJECT, { search: 'zz', context: { isSystem: true } });
170+
const where = lastWhere(TEXT_OPERATOR_DOOR_FIXTURE_OBJECT);
171+
// The door's own walk, over the expansion's output: no text operator aimed
172+
// at a column the door would refuse it on.
173+
expect(findTextOperatorOverNonTextField(TEXT_OPERATOR_DOOR_FIXTURE, where)).toBeNull();
174+
const scanned = scannedFields(where);
175+
const fields = TEXT_OPERATOR_DOOR_FIXTURE.fields as Record<string, { type: string; returnType?: string }>;
176+
for (const f of scanned) expect(textOperatorDoorVerdict(fields[f]!), f).not.toBe('door-refusal');
177+
// Control: the string-capable columns are still scanned (text, an option
178+
// code, a reference id, a multi-valued option field).
179+
for (const f of ['f_text', 'f_select', 'f_lookup', 'f_tags']) expect(scanned, f).toContain(f);
180+
});
181+
182+
it('a mixed declared set scans only the text column: number, date, boolean and json contribute no clause', async () => {
183+
const rows = await engine.find(OBJECT, { search: 'qq9' });
184+
expect(scannedFields(lastWhere())).toEqual(['name']);
185+
expect(rows.map((r) => r.id)).toEqual(['r1', 'r2']);
186+
});
187+
188+
it('a term matching only a non-text value\'s text rendering matches nothing — 42, 2026, true', async () => {
189+
for (const term of ['42', '2026', 'true']) {
190+
expect(await engine.find(OBJECT, { search: term }), term).toEqual([]);
191+
expect(scannedFields(lastWhere()), term).toEqual(['name']);
192+
}
193+
});
194+
195+
it('multi-term: every term\'s $or carries only the text column', async () => {
196+
const rows = await engine.find(OBJECT, { search: 'qq9 alpha' });
197+
expect(rows.map((r) => r.id)).toEqual(['r1']);
198+
expect(lastWhere()).toEqual({ $and: [
199+
{ $or: [{ name: { $icontains: 'qq9' } }] },
200+
{ $or: [{ name: { $icontains: 'alpha' } }] },
201+
] });
202+
});
203+
204+
it('a set of only non-text columns answers NO rows — the driver is asked, nothing is refused, not every row', async () => {
205+
for (const term of ['qq9', '42', '2026']) {
206+
const before = reads.length;
207+
expect(await engine.find(ONLY_NON_TEXT, { search: term }), term).toEqual([]);
208+
expect(await engine.count(ONLY_NON_TEXT, { search: term }), term).toBe(0);
209+
expect(reads.length - before, 'the driver answered the read').toBe(2);
210+
expect(lastWhere(ONLY_NON_TEXT), term).toEqual({ $or: [] });
211+
}
212+
// A blank term is still not a search: every row.
213+
expect(await engine.find(ONLY_NON_TEXT, { search: ' ' })).toHaveLength(3);
214+
});
215+
216+
it('an override naming a declared non-text column is honoured as that column: no rows, no refusal', async () => {
217+
expect(await engine.find(OBJECT, { search: '42', searchFields: ['qty'] })).toEqual([]);
218+
expect(lastWhere()).toEqual({ $or: [] });
219+
// Control: naming the text column beside it still matches through it.
220+
const rows = await engine.find(OBJECT, { search: 'qq9', searchFields: ['qty', 'name'] });
221+
expect(rows.map((r) => r.id)).toEqual(['r1', 'r2']);
222+
expect(scannedFields(lastWhere())).toEqual(['name']);
223+
});
224+
225+
it('the auto branch: flagged searchable: true number / date / json fields get no clause; the text control is still scanned', async () => {
226+
const rows = await engine.find(AUTO_FLAGGED, { search: 'qq9' });
227+
expect(scannedFields(lastWhere(AUTO_FLAGGED))).toEqual(['name']);
228+
expect(rows.map((r) => r.id)).toEqual(['r1', 'r2']);
229+
for (const term of ['42', '2026']) {
230+
expect(await engine.find(AUTO_FLAGGED, { search: term }), term).toEqual([]);
231+
}
232+
// The flag still admits them into the set: naming one is honoured, not
233+
// refused, and answers no rows.
234+
expect(await engine.find(AUTO_FLAGGED, { search: '42', searchFields: ['qty'] })).toEqual([]);
235+
expect(lastWhere(AUTO_FLAGGED)).toEqual({ $or: [] });
236+
});
237+
238+
it('control: an override outside the declared set is still refused, judged on the unchanged set', async () => {
239+
await expect(engine.find(OBJECT, { search: 'qq9', searchFields: ['nope'] }))
240+
.rejects.toMatchObject({ status: 400, code: 'INVALID_FIELD', param: 'searchFields', field: 'nope' });
241+
});
242+
});

‎packages/objectql/src/engine.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14213,6 +14213,12 @@ export class ObjectQL implements IObjectQLEngine {
1421314213
* allowed set, never widened. All drivers already execute `$or`/`$icontains`,
1421414214
* so this needs no driver changes.
1421514215
*
14216+
* [#29394] A resolved field whose declared type can never hold a string (the
14217+
* #15661 door's verdict) gets no clause — `expandSearchToFilter` skips it —
14218+
* because that door judges only the caller's `where`, above, and never this
14219+
* expansion. The set itself is not narrowed, so the refusals below judge it
14220+
* as before, and a set of only such fields answers no rows.
14221+
*
1421614222
* The keys are deleted whether or not anything expanded — leaving them on
1421714223
* would hand the driver a key it does not read, which is the same silent drop
1421814224
* one layer down.

‎packages/objectql/src/search-filter.ts‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,12 +66,33 @@
6666
* or match an option label, to hit a multi-valued field. The declaration is
6767
* read from the field map the engine already passes in (`fields`), whose
6868
* entries are the object's full field definitions — `multiple` included.
69+
*
70+
* [#29394] A field whose DECLARED type can never hold a string — the #15661
71+
* text-operator door's `door-refusal` verdict (`textOperatorDoorVerdict`:
72+
* numeric, boolean, calendar-date, instant, clock-time and structured-JSON
73+
* types, plus a `formula` whose readable `returnType` names one) — contributes
74+
* NO clause. That door judges only the caller's own `where`, before this
75+
* expansion runs, so the `$icontains` emitted here used to reach the driver
76+
* unjudged and answer per backend: every SQL face and `driver-memory` refuse a
77+
* text operator on a JSON column, so one `json` field in a declared
78+
* `searchableFields` failed EVERY search on the object with `INVALID_FILTER`;
79+
* the SQL faces type-gate a numeric / boolean / temporal column to no-match,
80+
* while `driver-memory` matched a date stored as text by ISO substring. A
81+
* search term is text, and such a column has no text for it to match, so the
82+
* answer is the declared no-match on every backend — reached here, by skipping,
83+
* rather than by a cast whose rendering differs per dialect. The resolved set
84+
* is NOT narrowed: `resolveSearchFields` still answers it whole, so the
85+
* refusals judged on it (#24284, #28120) and the `$searchFields` door are
86+
* unmoved, and a set holding only such fields expands to an `$or` with no arm,
87+
* which is FALSE (`FILTER_LOGIC_CASES`): no rows, never an error, never every
88+
* row. ⛔ No second type list: the verdict is the spec's.
6989
*/
7090

7191
import {
7292
isMultiValueField,
7393
resolveSearchFields,
7494
SEARCHABLE_ENUM_TYPES,
95+
textOperatorDoorVerdict,
7596
type SearchFieldMeta,
7697
type SearchFieldResolutionOptions,
7798
} from '@objectstack/spec/data';
@@ -152,7 +173,23 @@ function isMultiValuedSearchField(meta: SearchFieldMeta): boolean {
152173
return typeof type === 'string' && isMultiValueField({ type, multiple: multiple === true });
153174
}
154175

176+
/**
177+
* [#29394] Can this field's DECLARED type never hold a string? The #15661
178+
* door's own verdict, over the field definition the engine handed in (its
179+
* `returnType` read for a `formula`). An unreadable `type` is not judged, the
180+
* door's rule too: unresolvable is not wrong.
181+
*/
182+
function neverHoldsText(meta: SearchFieldMeta): boolean {
183+
const { type, returnType } = meta as SearchFieldMeta & { returnType?: unknown };
184+
if (typeof type !== 'string') return false;
185+
const declared = typeof returnType === 'string' ? { type, returnType } : { type };
186+
return textOperatorDoorVerdict(declared) === 'door-refusal';
187+
}
188+
155189
function fieldClausesForTerm(field: string, term: string, meta: SearchFieldMeta): any[] {
190+
// [#29394] No text clause over a column that can never hold text — see the
191+
// module header.
192+
if (neverHoldsText(meta)) return [];
156193
// [#21009] Membership on a multi-valued field — see the module header.
157194
if (isMultiValuedSearchField(meta)) {
158195
const values = optionValuesMatching(meta, term).filter((v) => v !== null && v !== undefined);
@@ -248,6 +285,10 @@ function withholdUnqueryable(
248285
* non-throwing; the judgement lives at the call site every verb reaches.
249286
* [#25672] A set that `queryableFields` withheld down to nothing is this
250287
* case too.
288+
*
289+
* [#29394] A NON-empty set whose every field can never hold text is not the
290+
* second case: each term's `$or` is emitted with no arm, FALSE, so the search
291+
* answers no rows rather than being refused.
251292
*/
252293
export function expandSearchToFilter(raw: unknown, opts: ExpandSearchOptions): any | null {
253294
const { query, fields: requested } = normalizeSearch(raw);

0 commit comments

Comments
 (0)