From bc2269fe5a837f41f9b5d9f0781c78b049abfc8f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 15:29:24 +0000 Subject: [PATCH 1/2] 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 --- .../29394-search-skips-non-text-columns.md | 12 + .../src/engine-search-non-text-column.test.ts | 215 ++++++++++++++++++ packages/objectql/src/engine.ts | 6 + packages/objectql/src/search-filter.ts | 41 ++++ .../data-search-non-text-column-door.test.ts | 166 ++++++++++++++ 5 files changed, 440 insertions(+) create mode 100644 .changeset/29394-search-skips-non-text-columns.md create mode 100644 packages/objectql/src/engine-search-non-text-column.test.ts create mode 100644 packages/rest/src/data-search-non-text-column-door.test.ts diff --git a/.changeset/29394-search-skips-non-text-columns.md b/.changeset/29394-search-skips-non-text-columns.md new file mode 100644 index 00000000000..a37a550a66c --- /dev/null +++ b/.changeset/29394-search-skips-non-text-columns.md @@ -0,0 +1,12 @@ +--- +'@objectstack/objectql': patch +--- + +fix(objectql): `$search` no longer aims a text match at a column that can never hold text (number, boolean, date and time, json) + +Clause-②: no +Metadata-protocol: none + +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`). + +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. diff --git a/packages/objectql/src/engine-search-non-text-column.test.ts b/packages/objectql/src/engine-search-non-text-column.test.ts new file mode 100644 index 00000000000..f28a7e3f57e --- /dev/null +++ b/packages/objectql/src/engine-search-non-text-column.test.ts @@ -0,0 +1,215 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#29394] The `$search` expansion emits no text clause over a column whose + * DECLARED type can never hold a string — the #15661 door's own verdict + * (`textOperatorDoorVerdict`, `@objectstack/spec/data`), read, never re-listed. + * + * The door judges the caller's `where` before `expandSearchOnAst` runs, so the + * expansion's `$icontains` never met it: a declared `searchableFields` naming a + * `number` / `boolean` / `date` / `json` column (and, once `searchable: true` + * joins the auto set, a flagged one) sent the driver exactly the predicate the + * door refuses when a caller writes it. Beneath the door each backend answered + * that predicate its own way: the SQL family refuses it on a JSON column + * (`INVALID_FILTER`, so the whole search fails) and type-gates it to no-match + * on a numeric / boolean / temporal one, while a value-keyed evaluator matches + * the value's text rendering (`42` hits `'4'`, a date hits `'2026'`). + * + * The answer chosen is SKIP: such a column contributes no clause. A search + * term is text, and a column that can never hold text has nothing for it to + * match, on any backend — the same declared no-match the SQL faces compile. + * The resolved search set is unchanged, so every refusal judged on it (#24284 + * empty set, #28120 override) answers as before, and a set holding ONLY such + * columns answers no rows: each term's `$or` has no arm, which is FALSE + * (`FILTER_LOGIC_CASES`, "empty $or is FALSE"). Not an error, not every row. + * + * Dialect-neutral: the pin is on the AST the driver receives. The real-driver + * half is `packages/rest/src/data-search-non-text-column-door.test.ts`. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { + TEXT_OPERATOR_DOOR_FIXTURE, + TEXT_OPERATOR_DOOR_FIXTURE_OBJECT, + textOperatorDoorVerdict, +} from '@objectstack/spec/data'; +import { ObjectQL } from './engine.js'; +import { findTextOperatorOverNonTextField } from './text-operator-declared-type-door.js'; + +type Row = Record; + +const OBJECT = 'search_non_text_29394'; +const ONLY_NON_TEXT = 'search_only_non_text_29394'; + +const FIELDS = { + name: { type: 'text' }, + qty: { type: 'number' }, + born: { type: 'date' }, + active: { type: 'boolean' }, + meta: { type: 'json' }, +} as const; + +const ROWS: Row[] = [ + { id: 'r1', name: 'qq9-alpha', qty: 42, born: '2026-01-05', active: true, meta: { k: 'qq9' } }, + { id: 'r2', name: 'qq9-beta', qty: 7, born: '2025-03-01', active: false, meta: { k: 'x' } }, + { id: 'r3', name: 'other', qty: 420, born: '2026-02-02', active: true, meta: { k: 'qq9' } }, +]; + +/** The keys every clause of an emitted search `$or` names — the fields the search scanned. */ +function scannedFields(where: unknown): string[] { + const out = new Set(); + const walk = (node: unknown): void => { + if (!node || typeof node !== 'object' || Array.isArray(node)) return; + for (const [k, v] of Object.entries(node as Row)) { + if (k === '$and' || k === '$or') { for (const arm of v as unknown[]) walk(arm); continue; } + if (!k.startsWith('$')) out.add(k); + } + }; + walk(where); + return [...out].sort(); +} + +/** + * A value-keyed evaluator, the shape of the in-memory faces: a text operator + * reads the stored value's text rendering. That is exactly how a search term + * matched `42` or a date on such a face before the skip. + */ +const MATCHERS: Record boolean> = { + $icontains: (value, operand) => value != null && String(value).toLowerCase().includes(String(operand).toLowerCase()), +}; + +function matches(row: Row, where: unknown): boolean { + if (!where || typeof where !== 'object') return true; + for (const [k, v] of Object.entries(where as Row)) { + if (k === '$and') { if (!(v as unknown[]).every((w) => matches(row, w))) return false; continue; } + if (k === '$or') { if (!(v as unknown[]).some((w) => matches(row, w))) return false; continue; } + if (k.startsWith('$')) throw new Error(`matcher: unimplemented key ${k}`); + if (v && typeof v === 'object' && !Array.isArray(v)) { + for (const [op, operand] of Object.entries(v as Row)) { + const m = MATCHERS[op]; + if (!m) throw new Error(`matcher: unimplemented operator ${op}`); + if (!m(row[k], operand)) return false; + } + continue; + } + if (row[k] !== v) return false; + } + return true; +} + +function makeDriver() { + const reads: Array<{ object: string; where: unknown }> = []; + const read = (object: string, ast: any) => { + reads.push({ object, where: ast?.where }); + const rows = (object === OBJECT || object === ONLY_NON_TEXT ? ROWS : []).filter((r) => matches(r, ast?.where)); + return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; + }; + const driver: any = { + name: 'mem', version: '0.0.0', supports: {}, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, + async find(object: string, ast: any) { return read(object, ast).map((r) => ({ ...r })); }, + async findOne(object: string, ast: any) { return read(object, ast)[0] ?? null; }, + async count(object: string, ast: any) { return read(object, ast).length; }, + async create(_o: string, d: any) { return d; }, + async update(_o: string, _id: string, d: any) { return d; }, + async delete() { return true; }, + async deleteMany() { return 0; }, + async bulkCreate(_o: string, r: any[]) { return r; }, + async bulkUpdate() { return []; }, async bulkDelete() {}, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return { driver, reads }; +} + +describe('[#29394] $search emits no text clause over a never-string-capable column', () => { + let engine: ObjectQL; + let reads: ReturnType['reads']; + const lastWhere = (object = OBJECT) => [...reads].reverse().find((r) => r.object === object)?.where; + + beforeEach(async () => { + const made = makeDriver(); + reads = made.reads; + engine = new ObjectQL(); + engine.registerDriver(made.driver, true); + await engine.init(); + const registry = engine.registry as any; + registry.registerObject({ + name: OBJECT, + searchableFields: ['name', 'qty', 'born', 'active', 'meta'], + fields: FIELDS, + }, 'test'); + registry.registerObject({ + name: ONLY_NON_TEXT, + searchableFields: ['qty', 'born', 'active', 'meta'], + fields: FIELDS, + }, 'test'); + registry.registerObject({ + ...TEXT_OPERATOR_DOOR_FIXTURE, + // Every fixture field the declared branch keeps: one per FieldType member. + searchableFields: Object.keys(TEXT_OPERATOR_DOOR_FIXTURE.fields).filter((f) => f !== 'id'), + }, 'test'); + }); + + it('the declared set over every FieldType: the emitted search is one the #15661 door passes', async () => { + await engine.find(TEXT_OPERATOR_DOOR_FIXTURE_OBJECT, { search: 'zz', context: { isSystem: true } }); + const where = lastWhere(TEXT_OPERATOR_DOOR_FIXTURE_OBJECT); + // The door's own walk, over the expansion's output: no text operator aimed + // at a column the door would refuse it on. + expect(findTextOperatorOverNonTextField(TEXT_OPERATOR_DOOR_FIXTURE, where)).toBeNull(); + const scanned = scannedFields(where); + const fields = TEXT_OPERATOR_DOOR_FIXTURE.fields as Record; + for (const f of scanned) expect(textOperatorDoorVerdict(fields[f]!), f).not.toBe('door-refusal'); + // Control: the string-capable columns are still scanned (text, an option + // code, a reference id, a multi-valued option field). + for (const f of ['f_text', 'f_select', 'f_lookup', 'f_tags']) expect(scanned, f).toContain(f); + }); + + it('a mixed declared set scans only the text column: number, date, boolean and json contribute no clause', async () => { + const rows = await engine.find(OBJECT, { search: 'qq9' }); + expect(scannedFields(lastWhere())).toEqual(['name']); + expect(rows.map((r) => r.id)).toEqual(['r1', 'r2']); + }); + + it('a term matching only a non-text value\'s text rendering matches nothing — 42, 2026, true', async () => { + for (const term of ['42', '2026', 'true']) { + expect(await engine.find(OBJECT, { search: term }), term).toEqual([]); + expect(scannedFields(lastWhere()), term).toEqual(['name']); + } + }); + + it('multi-term: every term\'s $or carries only the text column', async () => { + const rows = await engine.find(OBJECT, { search: 'qq9 alpha' }); + expect(rows.map((r) => r.id)).toEqual(['r1']); + expect(lastWhere()).toEqual({ $and: [ + { $or: [{ name: { $icontains: 'qq9' } }] }, + { $or: [{ name: { $icontains: 'alpha' } }] }, + ] }); + }); + + it('a set of only non-text columns answers NO rows — the driver is asked, nothing is refused, not every row', async () => { + for (const term of ['qq9', '42', '2026']) { + const before = reads.length; + expect(await engine.find(ONLY_NON_TEXT, { search: term }), term).toEqual([]); + expect(await engine.count(ONLY_NON_TEXT, { search: term }), term).toBe(0); + expect(reads.length - before, 'the driver answered the read').toBe(2); + expect(lastWhere(ONLY_NON_TEXT), term).toEqual({ $or: [] }); + } + // A blank term is still not a search: every row. + expect(await engine.find(ONLY_NON_TEXT, { search: ' ' })).toHaveLength(3); + }); + + it('an override naming a declared non-text column is honoured as that column: no rows, no refusal', async () => { + expect(await engine.find(OBJECT, { search: '42', searchFields: ['qty'] })).toEqual([]); + expect(lastWhere()).toEqual({ $or: [] }); + // Control: naming the text column beside it still matches through it. + const rows = await engine.find(OBJECT, { search: 'qq9', searchFields: ['qty', 'name'] }); + expect(rows.map((r) => r.id)).toEqual(['r1', 'r2']); + expect(scannedFields(lastWhere())).toEqual(['name']); + }); + + it('control: an override outside the declared set is still refused, judged on the unchanged set', async () => { + await expect(engine.find(OBJECT, { search: 'qq9', searchFields: ['nope'] })) + .rejects.toMatchObject({ status: 400, code: 'INVALID_FIELD', param: 'searchFields', field: 'nope' }); + }); +}); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 93b1fa9ba76..e5952e1a036 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -14200,6 +14200,12 @@ export class ObjectQL implements IObjectQLEngine { * allowed set, never widened. All drivers already execute `$or`/`$icontains`, * so this needs no driver changes. * + * [#29394] A resolved field whose declared type can never hold a string (the + * #15661 door's verdict) gets no clause — `expandSearchToFilter` skips it — + * because that door judges only the caller's `where`, above, and never this + * expansion. The set itself is not narrowed, so the refusals below judge it + * as before, and a set of only such fields answers no rows. + * * The keys are deleted whether or not anything expanded — leaving them on * would hand the driver a key it does not read, which is the same silent drop * one layer down. diff --git a/packages/objectql/src/search-filter.ts b/packages/objectql/src/search-filter.ts index 5fca5421651..0c1d38a41cb 100644 --- a/packages/objectql/src/search-filter.ts +++ b/packages/objectql/src/search-filter.ts @@ -66,12 +66,33 @@ * or match an option label, to hit a multi-valued field. The declaration is * read from the field map the engine already passes in (`fields`), whose * entries are the object's full field definitions — `multiple` included. + * + * [#29394] A field whose DECLARED type can never hold a string — the #15661 + * text-operator door's `door-refusal` verdict (`textOperatorDoorVerdict`: + * numeric, boolean, calendar-date, instant, clock-time and structured-JSON + * types, plus a `formula` whose readable `returnType` names one) — contributes + * NO clause. That door judges only the caller's own `where`, before this + * expansion runs, so the `$icontains` emitted here used to reach the driver + * unjudged and answer per backend: every SQL face and `driver-memory` refuse a + * text operator on a JSON column, so one `json` field in a declared + * `searchableFields` failed EVERY search on the object with `INVALID_FILTER`; + * the SQL faces type-gate a numeric / boolean / temporal column to no-match, + * while `driver-memory` matched a date stored as text by ISO substring. A + * search term is text, and such a column has no text for it to match, so the + * answer is the declared no-match on every backend — reached here, by skipping, + * rather than by a cast whose rendering differs per dialect. The resolved set + * is NOT narrowed: `resolveSearchFields` still answers it whole, so the + * refusals judged on it (#24284, #28120) and the `$searchFields` door are + * unmoved, and a set holding only such fields expands to an `$or` with no arm, + * which is FALSE (`FILTER_LOGIC_CASES`): no rows, never an error, never every + * row. ⛔ No second type list: the verdict is the spec's. */ import { isMultiValueField, resolveSearchFields, SEARCHABLE_ENUM_TYPES, + textOperatorDoorVerdict, type SearchFieldMeta, type SearchFieldResolutionOptions, } from '@objectstack/spec/data'; @@ -152,7 +173,23 @@ function isMultiValuedSearchField(meta: SearchFieldMeta): boolean { return typeof type === 'string' && isMultiValueField({ type, multiple: multiple === true }); } +/** + * [#29394] Can this field's DECLARED type never hold a string? The #15661 + * door's own verdict, over the field definition the engine handed in (its + * `returnType` read for a `formula`). An unreadable `type` is not judged, the + * door's rule too: unresolvable is not wrong. + */ +function neverHoldsText(meta: SearchFieldMeta): boolean { + const { type, returnType } = meta as SearchFieldMeta & { returnType?: unknown }; + if (typeof type !== 'string') return false; + const declared = typeof returnType === 'string' ? { type, returnType } : { type }; + return textOperatorDoorVerdict(declared) === 'door-refusal'; +} + function fieldClausesForTerm(field: string, term: string, meta: SearchFieldMeta): any[] { + // [#29394] No text clause over a column that can never hold text — see the + // module header. + if (neverHoldsText(meta)) return []; // [#21009] Membership on a multi-valued field — see the module header. if (isMultiValuedSearchField(meta)) { const values = optionValuesMatching(meta, term).filter((v) => v !== null && v !== undefined); @@ -248,6 +285,10 @@ function withholdUnqueryable( * non-throwing; the judgement lives at the call site every verb reaches. * [#25672] A set that `queryableFields` withheld down to nothing is this * case too. + * + * [#29394] A NON-empty set whose every field can never hold text is not the + * second case: each term's `$or` is emitted with no arm, FALSE, so the search + * answers no rows rather than being refused. */ export function expandSearchToFilter(raw: unknown, opts: ExpandSearchOptions): any | null { const { query, fields: requested } = normalizeSearch(raw); diff --git a/packages/rest/src/data-search-non-text-column-door.test.ts b/packages/rest/src/data-search-non-text-column-door.test.ts new file mode 100644 index 00000000000..897d607fe28 --- /dev/null +++ b/packages/rest/src/data-search-non-text-column-door.test.ts @@ -0,0 +1,166 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#29394] `$search` over an object whose declared `searchableFields` holds a + * column that can never hold a string (`number`, `date`, `boolean`, `json`) + * — on the Data API door, over a real `SqlDriver` (SQLite), the harness of + * `data-search-fields-credential-door.test.ts`. + * + * Before: the engine's expansion emitted `$icontains` for every resolved + * field. The #15661 declared-type door, which refuses a text operator over + * such a column, judges only the caller's own `where`, so these clauses went + * straight to the driver. The SQL family refuses a text operator on a JSON + * column, so ONE `json` entry in the declared set failed EVERY search on the + * object, however the term read, with a refusal about a predicate the caller + * never wrote. (The numeric, boolean and temporal clauses were type-gated to + * no-match beneath the door; a value-keyed face matched their text rendering + * instead.) + * + * After: such a column contributes no clause (the door's own verdict, + * `textOperatorDoorVerdict`). The text column still matches; a set of only + * such columns answers no rows, 200, never an error and never every row. + * + * The dialect-neutral pin on the emitted AST is objectql's + * `engine-search-non-text-column.test.ts`. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { RestServer } from './rest-server'; +import { installDefaultOrganization } from './default-organization.testkit.js'; + +const MIXED = 'rest_search_non_text_mixed_29394'; +const ONLY_NON_TEXT = 'rest_search_non_text_only_29394'; + +const FIELDS = { + name: { name: 'name', type: 'text' as const }, + qty: { name: 'qty', type: 'number' as const }, + born: { name: 'born', type: 'date' as const }, + active: { name: 'active', type: 'boolean' as const }, + meta: { name: 'meta', type: 'json' as const }, +}; + +const ROWS = [ + { id: 'r1', name: 'qq9-alpha', qty: 42, born: '2026-01-05', active: true, meta: { k: 'qq9' } }, + { id: 'r2', name: 'qq9-beta', qty: 7, born: '2025-03-01', active: false, meta: { k: 'x' } }, + { id: 'r3', name: 'other', qty: 420, born: '2026-02-02', active: true, meta: { k: 'qq9' } }, +]; + +function createMockServer() { + const noop = () => {}; + return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} }; +} + +function makeRes() { + const res: any = { + write: () => true, end: () => {}, + header: () => res, + status: (code: number) => { res._status = code; return res; }, + json: (body: any) => { res._json = body; return res; }, + }; + return res; +} + +describe('[#29394] $search over a declared non-text column on the Data API door — sqlite', () => { + let engine: ObjectQL; + let list: (object: string, query: Record) => Promise<{ status: number; body: any }>; + let query: (object: string, body: Record) => Promise<{ status: number; body: any }>; + + beforeAll(async () => { + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true } as any); + engine = new ObjectQL(); + engine.registerDriver(driver, true); + await engine.init(); + engine.registry.registerObject({ + name: MIXED, label: 'Mixed 29394', + searchableFields: ['name', 'qty', 'born', 'active', 'meta'], + fields: FIELDS, + } as any); + engine.registry.registerObject({ + name: ONLY_NON_TEXT, label: 'Only non-text 29394', + searchableFields: ['qty', 'born', 'active', 'meta'], + fields: FIELDS, + } as any); + await engine.syncSchemas(); + // A real `single` install's Default Organization (ADR-0131 D9, D1). + await installDefaultOrganization(engine); + for (const object of [MIXED, ONLY_NON_TEXT]) { + for (const row of ROWS) await engine.insert(object, { ...row } as any); + } + + const protocol = new ObjectStackProtocolImplementation(engine as any); + const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); + const getRoute = rest.getRoutes().find((r: any) => r.method === 'GET' && r.path === '/api/v1/data/:object'); + expect(getRoute, 'GET /api/v1/data/:object').toBeDefined(); + list = async (object, q) => { + const res = makeRes(); + // What a querystring carries: strings. + await getRoute!.handler({ params: { object }, query: { $orderby: 'name', ...q }, headers: {} } as any, res); + return { status: res._status ?? 200, body: res._json }; + }; + const postRoute = rest.getRoutes().find((r: any) => r.method === 'POST' && r.path === '/api/v1/data/:object/query'); + expect(postRoute, 'POST /api/v1/data/:object/query').toBeDefined(); + query = async (object, body) => { + const res = makeRes(); + // What the wire carries: JSON. + await postRoute!.handler({ params: { object }, body: JSON.parse(JSON.stringify(body)), query: {}, headers: {} } as any, res); + return { status: res._status ?? 200, body: res._json }; + }; + }); + + afterAll(async () => { + try { await engine?.destroy(); } catch { /* noop */ } + }); + + const ids = (body: any) => (body?.records ?? []).map((r: any) => r.id); + + it('the card\'s shape: a term in the text column matches through it — 200, the two rows, GET and POST', async () => { + const got = await list(MIXED, { $search: 'qq9' }); + expect(got.status, JSON.stringify(got.body)).toBe(200); + expect(ids(got.body)).toEqual(['r1', 'r2']); + + const posted = await query(MIXED, { search: 'qq9' }); + expect(posted.status, JSON.stringify(posted.body)).toBe(200); + expect(ids(posted.body).sort()).toEqual(['r1', 'r2']); + }); + + it('a term found only in a non-text value\'s rendering matches nothing — 200, no rows', async () => { + for (const term of ['42', '2026', 'true']) { + const got = await list(MIXED, { $search: term }); + expect(got.status, `${term}: ${JSON.stringify(got.body)}`).toBe(200); + expect(ids(got.body), term).toEqual([]); + } + }); + + it('a set of only non-text columns answers no rows — 200, not an error, not every row', async () => { + for (const term of ['qq9', '42']) { + const got = await list(ONLY_NON_TEXT, { $search: term }); + expect(got.status, `${term}: ${JSON.stringify(got.body)}`).toBe(200); + expect(ids(got.body), term).toEqual([]); + } + // Control: no search at all is every row. + const all = await list(ONLY_NON_TEXT, {}); + expect(all.status, JSON.stringify(all.body)).toBe(200); + expect(ids(all.body)).toHaveLength(3); + }); + + it('$searchFields naming a declared non-text column is accepted and matches nothing; beside the text column it matches through it', async () => { + const narrowed = await list(MIXED, { $search: 'qq9', $searchFields: 'meta' }); + expect(narrowed.status, JSON.stringify(narrowed.body)).toBe(200); + expect(ids(narrowed.body)).toEqual([]); + + const both = await list(MIXED, { $search: 'qq9', $searchFields: 'meta,name' }); + expect(both.status, JSON.stringify(both.body)).toBe(200); + expect(ids(both.body)).toEqual(['r1', 'r2']); + }); + + it('control: the caller\'s own text operator on a non-text column is still refused by the #15661 door', async () => { + const posted = await query(MIXED, { where: { qty: { $contains: '4' } } }); + expect(posted.status, JSON.stringify(posted.body)).toBe(400); + expect(posted.body.code, JSON.stringify(posted.body)).toBe('INVALID_FILTER'); + }); +}); From 18f7a010436b8d069ccf78e579e05691fecd85ae Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 17:21:02 +0000 Subject: [PATCH 2/2] test(objectql): pin the $search skip on the auto branch (searchable: true) #29385 landed: with no searchableFields, a field marked searchable: true joins the auto set of any type. A flagged number / date / json field is admitted to the set and still gets no text clause; the text control is scanned, and naming a flagged field is accepted and matches nothing. On the engine (AST) and the SQLite Data API door. Claude-Session: https://claude.ai/code/session_01ADHkmRnyoAJD6oToQ2QZUK Co-authored-by: Claude --- .../src/engine-search-non-text-column.test.ts | 29 ++++++++++++++++- .../data-search-non-text-column-door.test.ts | 32 +++++++++++++++++-- 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/packages/objectql/src/engine-search-non-text-column.test.ts b/packages/objectql/src/engine-search-non-text-column.test.ts index f28a7e3f57e..18297925fa2 100644 --- a/packages/objectql/src/engine-search-non-text-column.test.ts +++ b/packages/objectql/src/engine-search-non-text-column.test.ts @@ -40,6 +40,7 @@ type Row = Record; const OBJECT = 'search_non_text_29394'; const ONLY_NON_TEXT = 'search_only_non_text_29394'; +const AUTO_FLAGGED = 'search_auto_flagged_29394'; const FIELDS = { name: { type: 'text' }, @@ -101,7 +102,7 @@ function makeDriver() { const reads: Array<{ object: string; where: unknown }> = []; const read = (object: string, ast: any) => { reads.push({ object, where: ast?.where }); - const rows = (object === OBJECT || object === ONLY_NON_TEXT ? ROWS : []).filter((r) => matches(r, ast?.where)); + const rows = ([OBJECT, ONLY_NON_TEXT, AUTO_FLAGGED].includes(object) ? ROWS : []).filter((r) => matches(r, ast?.where)); return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; }; const driver: any = { @@ -144,6 +145,19 @@ describe('[#29394] $search emits no text clause over a never-string-capable colu searchableFields: ['qty', 'born', 'active', 'meta'], fields: FIELDS, }, 'test'); + // The AUTO branch (ruling 6573569376 letter A): no `searchableFields`, so + // every field marked `searchable: true` joins the set, of any type. The + // resolver admits the flagged number / date / json fields; the expansion + // must still emit no text clause for them. + registry.registerObject({ + name: AUTO_FLAGGED, + fields: { + name: { type: 'text' }, + qty: { type: 'number', searchable: true }, + born: { type: 'date', searchable: true }, + meta: { type: 'json', searchable: true }, + }, + }, 'test'); registry.registerObject({ ...TEXT_OPERATOR_DOOR_FIXTURE, // Every fixture field the declared branch keeps: one per FieldType member. @@ -208,6 +222,19 @@ describe('[#29394] $search emits no text clause over a never-string-capable colu expect(scannedFields(lastWhere())).toEqual(['name']); }); + it('the auto branch: flagged searchable: true number / date / json fields get no clause; the text control is still scanned', async () => { + const rows = await engine.find(AUTO_FLAGGED, { search: 'qq9' }); + expect(scannedFields(lastWhere(AUTO_FLAGGED))).toEqual(['name']); + expect(rows.map((r) => r.id)).toEqual(['r1', 'r2']); + for (const term of ['42', '2026']) { + expect(await engine.find(AUTO_FLAGGED, { search: term }), term).toEqual([]); + } + // The flag still admits them into the set: naming one is honoured, not + // refused, and answers no rows. + expect(await engine.find(AUTO_FLAGGED, { search: '42', searchFields: ['qty'] })).toEqual([]); + expect(lastWhere(AUTO_FLAGGED)).toEqual({ $or: [] }); + }); + it('control: an override outside the declared set is still refused, judged on the unchanged set', async () => { await expect(engine.find(OBJECT, { search: 'qq9', searchFields: ['nope'] })) .rejects.toMatchObject({ status: 400, code: 'INVALID_FIELD', param: 'searchFields', field: 'nope' }); diff --git a/packages/rest/src/data-search-non-text-column-door.test.ts b/packages/rest/src/data-search-non-text-column-door.test.ts index 897d607fe28..a654fd5f80c 100644 --- a/packages/rest/src/data-search-non-text-column-door.test.ts +++ b/packages/rest/src/data-search-non-text-column-door.test.ts @@ -33,6 +33,7 @@ import { installDefaultOrganization } from './default-organization.testkit.js'; const MIXED = 'rest_search_non_text_mixed_29394'; const ONLY_NON_TEXT = 'rest_search_non_text_only_29394'; +const AUTO_FLAGGED = 'rest_search_non_text_auto_29394'; const FIELDS = { name: { name: 'name', type: 'text' as const }, @@ -83,11 +84,25 @@ describe('[#29394] $search over a declared non-text column on the Data API door searchableFields: ['qty', 'born', 'active', 'meta'], fields: FIELDS, } as any); + // The auto branch: no `searchableFields`, flagged non-text fields join the + // set (ruling 6573569376 letter A) and must still contribute no clause. + engine.registry.registerObject({ + name: AUTO_FLAGGED, label: 'Auto flagged 29394', + fields: { + name: FIELDS.name, + qty: { ...FIELDS.qty, searchable: true }, + born: { ...FIELDS.born, searchable: true }, + meta: { ...FIELDS.meta, searchable: true }, + }, + } as any); await engine.syncSchemas(); // A real `single` install's Default Organization (ADR-0131 D9, D1). await installDefaultOrganization(engine); - for (const object of [MIXED, ONLY_NON_TEXT]) { - for (const row of ROWS) await engine.insert(object, { ...row } as any); + for (const object of [MIXED, ONLY_NON_TEXT, AUTO_FLAGGED]) { + for (const row of ROWS) { + const { active: _active, ...rest } = row; + await engine.insert(object, (object === AUTO_FLAGGED ? rest : { ...row }) as any); + } } const protocol = new ObjectStackProtocolImplementation(engine as any); @@ -158,6 +173,19 @@ describe('[#29394] $search over a declared non-text column on the Data API door expect(ids(both.body)).toEqual(['r1', 'r2']); }); + it('the auto branch: flagged searchable: true number / date / json fields add no clause — the text column matches, 200', async () => { + const got = await list(AUTO_FLAGGED, { $search: 'qq9' }); + expect(got.status, JSON.stringify(got.body)).toBe(200); + expect(ids(got.body)).toEqual(['r1', 'r2']); + const rendered = await list(AUTO_FLAGGED, { $search: '2026' }); + expect(rendered.status, JSON.stringify(rendered.body)).toBe(200); + expect(ids(rendered.body)).toEqual([]); + // The flag admits the field at the ingress door: naming it is accepted, and matches nothing. + const named = await list(AUTO_FLAGGED, { $search: 'qq9', $searchFields: 'meta' }); + expect(named.status, JSON.stringify(named.body)).toBe(200); + expect(ids(named.body)).toEqual([]); + }); + it('control: the caller\'s own text operator on a non-text column is still refused by the #15661 door', async () => { const posted = await query(MIXED, { where: { qty: { $contains: '4' } } }); expect(posted.status, JSON.stringify(posted.body)).toBe(400);