Skip to content

Commit bc2269f

Browse files
committed
fix(objectql): $search emits no text clause over a never-string-capable column
The #15661 text-operator door judges only the caller's own where, before expandSearchOnAst runs, so the expansion's $icontains over a declared number / boolean / date / json search field reached the driver unjudged. One json field in a declared searchableFields failed every search on the object with 400 INVALID_FILTER (PostgreSQL, SQLite, memory alike), and driver-memory matched a date by ISO substring where the SQL faces answer no-match. fieldClausesForTerm now skips a field whose declared type gets the door's own door-refusal verdict (textOperatorDoorVerdict, read from the spec, no second list). The resolved set is unchanged, so the #24284 / #28120 refusals and the $searchFields door are unmoved; a set of only such fields expands to an $or with no arm, FALSE: no rows, never an error. Claude-Session: https://claude.ai/code/session_01ADHkmRnyoAJD6oToQ2QZUK Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7b1ae33 commit bc2269f

5 files changed

Lines changed: 440 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: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
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+
44+
const FIELDS = {
45+
name: { type: 'text' },
46+
qty: { type: 'number' },
47+
born: { type: 'date' },
48+
active: { type: 'boolean' },
49+
meta: { type: 'json' },
50+
} as const;
51+
52+
const ROWS: Row[] = [
53+
{ id: 'r1', name: 'qq9-alpha', qty: 42, born: '2026-01-05', active: true, meta: { k: 'qq9' } },
54+
{ id: 'r2', name: 'qq9-beta', qty: 7, born: '2025-03-01', active: false, meta: { k: 'x' } },
55+
{ id: 'r3', name: 'other', qty: 420, born: '2026-02-02', active: true, meta: { k: 'qq9' } },
56+
];
57+
58+
/** The keys every clause of an emitted search `$or` names — the fields the search scanned. */
59+
function scannedFields(where: unknown): string[] {
60+
const out = new Set<string>();
61+
const walk = (node: unknown): void => {
62+
if (!node || typeof node !== 'object' || Array.isArray(node)) return;
63+
for (const [k, v] of Object.entries(node as Row)) {
64+
if (k === '$and' || k === '$or') { for (const arm of v as unknown[]) walk(arm); continue; }
65+
if (!k.startsWith('$')) out.add(k);
66+
}
67+
};
68+
walk(where);
69+
return [...out].sort();
70+
}
71+
72+
/**
73+
* A value-keyed evaluator, the shape of the in-memory faces: a text operator
74+
* reads the stored value's text rendering. That is exactly how a search term
75+
* matched `42` or a date on such a face before the skip.
76+
*/
77+
const MATCHERS: Record<string, (value: unknown, operand: unknown) => boolean> = {
78+
$icontains: (value, operand) => value != null && String(value).toLowerCase().includes(String(operand).toLowerCase()),
79+
};
80+
81+
function matches(row: Row, where: unknown): boolean {
82+
if (!where || typeof where !== 'object') return true;
83+
for (const [k, v] of Object.entries(where as Row)) {
84+
if (k === '$and') { if (!(v as unknown[]).every((w) => matches(row, w))) return false; continue; }
85+
if (k === '$or') { if (!(v as unknown[]).some((w) => matches(row, w))) return false; continue; }
86+
if (k.startsWith('$')) throw new Error(`matcher: unimplemented key ${k}`);
87+
if (v && typeof v === 'object' && !Array.isArray(v)) {
88+
for (const [op, operand] of Object.entries(v as Row)) {
89+
const m = MATCHERS[op];
90+
if (!m) throw new Error(`matcher: unimplemented operator ${op}`);
91+
if (!m(row[k], operand)) return false;
92+
}
93+
continue;
94+
}
95+
if (row[k] !== v) return false;
96+
}
97+
return true;
98+
}
99+
100+
function makeDriver() {
101+
const reads: Array<{ object: string; where: unknown }> = [];
102+
const read = (object: string, ast: any) => {
103+
reads.push({ object, where: ast?.where });
104+
const rows = (object === OBJECT || object === ONLY_NON_TEXT ? ROWS : []).filter((r) => matches(r, ast?.where));
105+
return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows;
106+
};
107+
const driver: any = {
108+
name: 'mem', version: '0.0.0', supports: {},
109+
async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; },
110+
async find(object: string, ast: any) { return read(object, ast).map((r) => ({ ...r })); },
111+
async findOne(object: string, ast: any) { return read(object, ast)[0] ?? null; },
112+
async count(object: string, ast: any) { return read(object, ast).length; },
113+
async create(_o: string, d: any) { return d; },
114+
async update(_o: string, _id: string, d: any) { return d; },
115+
async delete() { return true; },
116+
async deleteMany() { return 0; },
117+
async bulkCreate(_o: string, r: any[]) { return r; },
118+
async bulkUpdate() { return []; }, async bulkDelete() {},
119+
async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; },
120+
async commit() {}, async rollback() {},
121+
};
122+
return { driver, reads };
123+
}
124+
125+
describe('[#29394] $search emits no text clause over a never-string-capable column', () => {
126+
let engine: ObjectQL;
127+
let reads: ReturnType<typeof makeDriver>['reads'];
128+
const lastWhere = (object = OBJECT) => [...reads].reverse().find((r) => r.object === object)?.where;
129+
130+
beforeEach(async () => {
131+
const made = makeDriver();
132+
reads = made.reads;
133+
engine = new ObjectQL();
134+
engine.registerDriver(made.driver, true);
135+
await engine.init();
136+
const registry = engine.registry as any;
137+
registry.registerObject({
138+
name: OBJECT,
139+
searchableFields: ['name', 'qty', 'born', 'active', 'meta'],
140+
fields: FIELDS,
141+
}, 'test');
142+
registry.registerObject({
143+
name: ONLY_NON_TEXT,
144+
searchableFields: ['qty', 'born', 'active', 'meta'],
145+
fields: FIELDS,
146+
}, 'test');
147+
registry.registerObject({
148+
...TEXT_OPERATOR_DOOR_FIXTURE,
149+
// Every fixture field the declared branch keeps: one per FieldType member.
150+
searchableFields: Object.keys(TEXT_OPERATOR_DOOR_FIXTURE.fields).filter((f) => f !== 'id'),
151+
}, 'test');
152+
});
153+
154+
it('the declared set over every FieldType: the emitted search is one the #15661 door passes', async () => {
155+
await engine.find(TEXT_OPERATOR_DOOR_FIXTURE_OBJECT, { search: 'zz', context: { isSystem: true } });
156+
const where = lastWhere(TEXT_OPERATOR_DOOR_FIXTURE_OBJECT);
157+
// The door's own walk, over the expansion's output: no text operator aimed
158+
// at a column the door would refuse it on.
159+
expect(findTextOperatorOverNonTextField(TEXT_OPERATOR_DOOR_FIXTURE, where)).toBeNull();
160+
const scanned = scannedFields(where);
161+
const fields = TEXT_OPERATOR_DOOR_FIXTURE.fields as Record<string, { type: string; returnType?: string }>;
162+
for (const f of scanned) expect(textOperatorDoorVerdict(fields[f]!), f).not.toBe('door-refusal');
163+
// Control: the string-capable columns are still scanned (text, an option
164+
// code, a reference id, a multi-valued option field).
165+
for (const f of ['f_text', 'f_select', 'f_lookup', 'f_tags']) expect(scanned, f).toContain(f);
166+
});
167+
168+
it('a mixed declared set scans only the text column: number, date, boolean and json contribute no clause', async () => {
169+
const rows = await engine.find(OBJECT, { search: 'qq9' });
170+
expect(scannedFields(lastWhere())).toEqual(['name']);
171+
expect(rows.map((r) => r.id)).toEqual(['r1', 'r2']);
172+
});
173+
174+
it('a term matching only a non-text value\'s text rendering matches nothing — 42, 2026, true', async () => {
175+
for (const term of ['42', '2026', 'true']) {
176+
expect(await engine.find(OBJECT, { search: term }), term).toEqual([]);
177+
expect(scannedFields(lastWhere()), term).toEqual(['name']);
178+
}
179+
});
180+
181+
it('multi-term: every term\'s $or carries only the text column', async () => {
182+
const rows = await engine.find(OBJECT, { search: 'qq9 alpha' });
183+
expect(rows.map((r) => r.id)).toEqual(['r1']);
184+
expect(lastWhere()).toEqual({ $and: [
185+
{ $or: [{ name: { $icontains: 'qq9' } }] },
186+
{ $or: [{ name: { $icontains: 'alpha' } }] },
187+
] });
188+
});
189+
190+
it('a set of only non-text columns answers NO rows — the driver is asked, nothing is refused, not every row', async () => {
191+
for (const term of ['qq9', '42', '2026']) {
192+
const before = reads.length;
193+
expect(await engine.find(ONLY_NON_TEXT, { search: term }), term).toEqual([]);
194+
expect(await engine.count(ONLY_NON_TEXT, { search: term }), term).toBe(0);
195+
expect(reads.length - before, 'the driver answered the read').toBe(2);
196+
expect(lastWhere(ONLY_NON_TEXT), term).toEqual({ $or: [] });
197+
}
198+
// A blank term is still not a search: every row.
199+
expect(await engine.find(ONLY_NON_TEXT, { search: ' ' })).toHaveLength(3);
200+
});
201+
202+
it('an override naming a declared non-text column is honoured as that column: no rows, no refusal', async () => {
203+
expect(await engine.find(OBJECT, { search: '42', searchFields: ['qty'] })).toEqual([]);
204+
expect(lastWhere()).toEqual({ $or: [] });
205+
// Control: naming the text column beside it still matches through it.
206+
const rows = await engine.find(OBJECT, { search: 'qq9', searchFields: ['qty', 'name'] });
207+
expect(rows.map((r) => r.id)).toEqual(['r1', 'r2']);
208+
expect(scannedFields(lastWhere())).toEqual(['name']);
209+
});
210+
211+
it('control: an override outside the declared set is still refused, judged on the unchanged set', async () => {
212+
await expect(engine.find(OBJECT, { search: 'qq9', searchFields: ['nope'] }))
213+
.rejects.toMatchObject({ status: 400, code: 'INVALID_FIELD', param: 'searchFields', field: 'nope' });
214+
});
215+
});

‎packages/objectql/src/engine.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14200,6 +14200,12 @@ export class ObjectQL implements IObjectQLEngine {
1420014200
* allowed set, never widened. All drivers already execute `$or`/`$icontains`,
1420114201
* so this needs no driver changes.
1420214202
*
14203+
* [#29394] A resolved field whose declared type can never hold a string (the
14204+
* #15661 door's verdict) gets no clause — `expandSearchToFilter` skips it —
14205+
* because that door judges only the caller's `where`, above, and never this
14206+
* expansion. The set itself is not narrowed, so the refusals below judge it
14207+
* as before, and a set of only such fields answers no rows.
14208+
*
1420314209
* The keys are deleted whether or not anything expanded — leaving them on
1420414210
* would hand the driver a key it does not read, which is the same silent drop
1420514211
* 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)