Skip to content

Commit 6a648ac

Browse files
fix(objectql)!: the in-process engine refuses a search-field override that names no field, as the doors and the shared declarations do (#28108)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DMwXCfG9Q3aTv4phHwhUDg4)_ Fixes #27732 Clause-②: no (narrowing) — the engine refuses the empty override the shared declaration already refuses (triage 6519918549) Metadata-protocol: none Skills-docs: updated here — `content/docs/protocol/objectql/query-syntax.mdx` (the search section) says the engine refuses an override that names no field, `400 VALIDATION_ERROR`, as at the ingress. `skills/**` needs no change: `git grep -n "searchFields\|search\.fields" -- skills` at this head finds only non-empty overrides (`skills/objectstack-query/SKILL.md` § Full-Text Search, its key tables at lines 25-26, its eval, and `skills/objectstack-data/SKILL.md:125`), and no skill emits or recommends an empty one. `content/docs/api/data-api.mdx` already states the door's refusal. ## 维护者速读 - **改了什么**:进程内引擎的 `find` / `findOne` / `count` / `aggregate` 遇到「一个字段都没写」的搜索字段覆盖(`searchFields: []`、对象形 `search: { query, fields: [] }`、或只有逗号和空格的字符串)时直接拒绝,答 `400 VALIDATION_ERROR`,与数据接口的门(#27484)和共享声明(#27683)一致;措辞取自 spec 声明本身,不另写一份。 - **为什么改**:门和声明都已拒收空覆盖,只有引擎把空列表当成「没覆盖」,于是插件、钩子、流程想缩小搜索范围却误传空列表时,会悄悄搜遍对象的全部默认搜索字段。一条规则:空覆盖被拒,永不读作未覆盖。 - **风险与回滚**:低。仓内非测试调用方 0 个会传空覆盖;不传或传 `null` 仍搜默认字段,写了字段名的覆盖照旧缩小。回滚即还原本 PR。 - **先看**:`packages/objectql/src/engine.ts` 的 `refuseSearchOverrideNamingNoField` 与 `expandSearchOnAst` 开头两行调用;`packages/objectql/src/engine-empty-search-fields-refusal.test.ts`。 - **两分钟验证**:`pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2 src/engine-empty-search-fields-refusal.test.ts`,预期 24/24 通过;在 `main` 上同文件 15 条失败(四个动词 × 三种空覆盖,加无搜索词、系统上下文、REST 对等)。 - **需要你做的**:无,席位落地。 ## What changes `ObjectQL.expandSearchOnAst` is the one search expander `find`, `findOne`, `count` and `aggregate` reach (the last two through `expandSearchOnOptions`). It now opens by judging the two override slots, before the expansion and so before any driver read: | Read | `main` | This head | |---|---|---| | `find/findOne/count/aggregate` with `search: 'harbour', searchFields: []` | the whole default search set scanned, no error | `400 VALIDATION_ERROR`, `param: 'searchFields'`, no driver read | | the same with `search: { query: 'harbour', fields: [] }` (item 1) | the whole default set | refused the same way, `param: 'search.fields'` | | the same with `searchFields: ' , '` (the `GET` transport spelling, which reaches the engine as sent) | the whole default set | refused, `param: 'searchFields'` | | `searchFields: []` with no search term | ignored | refused: the door judges it whether or not a term rides along, and so does the engine now | | a system-context caller | the whole default set | refused: the rule is about the shape, not the caller | | override absent, or `null` | default set | unchanged | | `searchFields: ['name']` / `search.fields: ['name']` | narrows | unchanged | - **The declarations' words, not a copy.** The message ends with the issue message of `FullTextSearchSchema.shape.fields.safeParse([])`, which is `SEARCH_FIELDS_NAMES_NO_FIELD` ("Name at least one field, or leave the key out to search the default search fields"), the same words `QuerySchema.searchFields` refuses with. The test asserts the two schemas agree. No spec change. - **The doors' answer.** `status: 400`, `code: 'VALIDATION_ERROR'`, the envelope `assertSearchFieldsAreSearchable` throws at the door for the same value, so `mapDataError` serves both callers the same status and code. The test pins it against `findData` on the same engine. - **What counts as naming no field** is the door's own derivation: a string is split on commas, trimmed and emptied of blanks; an array is taken as sent. Any other value keeps the expansion's existing handling (not this card). - **Both slots are judged.** The door reads `searchFields` and falls back to `search.fields`; the declarations refuse each on its own. The engine refuses each on its own too, so it never admits a value either declaration refuses. - **Shape.** The #27008 engine stage put its refusal in a sibling module. This one is a module-private function in `engine.ts` (+40/−1), because the claim's file surface names `engine.ts` and the one expander already sits there. - **One pin flipped by the narrowing, not a fix-to-green.** `engine-search-empty-set.test.ts` ("an explicit searchFields narrowing does not rescue it") listed `[]` among the overrides that still reach the empty-search-set refusal (`INVALID_FIELD`, `param: 'search'`). An empty override no longer reaches the expansion: it is refused first, `VALIDATION_ERROR`. The `[]` entry is dropped there, with a pointer to the new file; the three named overrides it keeps still pin the empty-set refusal. ### Item 2, the comma-string asymmetry: by design, not changed Triage 6519918549: "The comma string is the query-string transport encoding of the `GET` form; a JSON body carries the declared array. That is by design." `POST /data/:object/query` keeps refusing a string `searchFields` at the body schema, and the `GET` form keeps accepting the comma string. This PR widens nothing. ### Not here: the engine-side declarations (#28059) `EngineQueryOptionsSchema.searchFields`, `EngineAggregateOptionsSchema.searchFields` and `EngineCountOptionsSchema.searchFields` (`packages/spec/src/data/data-engine.zod.ts` lines 190, 514, 559) still declare `z.array(z.string())` with no minimum. Flipping them is a `packages/spec` edit, outside this claim's file surface, so the seat filed it as #28059 for the spec lane. Until it lands those three declarations admit `[]` while the engine refuses it; their `search` members already refuse `search.fields: []` through `FullTextSearchSchema`. ## Tests New `packages/objectql/src/engine-empty-search-fields-refusal.test.ts`, 24 cases, on a real `ObjectQL` over a driver double that evaluates `$or` / `$icontains` and records each read. `harbour` hits `name` on two rows and `city` on a third, so a narrowing is visible in the answer. - Per verb (`find`, `findOne`, `count`, `aggregate`): `searchFields: []`, `search: { query, fields: [] }` and `searchFields: ' , '` are each refused with `status` 400, `code` `VALIDATION_ERROR` and the named `param`, the declaration's words in the message, and zero driver reads. - Controls per verb: a named override narrows (`find` answers `a1, a2`, `count` 2, `aggregate` east 1 / west 1); an absent override searches the default set (`a1, a2, a3`, 3, east 2 / west 1). - An empty override with no search term is refused; a system-context read is refused; the REST door (`findData` with `searchFields: ''`) and the engine map to the same status and code; `searchFields: null` searches the default set. <details><summary>Reproduction on main, red/green, ablation</summary> Step 1 of the order, a probe on `origin/main` `bc496bf7` with this file's driver double (every row the same on all four verbs): ``` find searchFields: [] -> ["a1","a2","a3"] · driver where $or over name, city, region find search: {query, fields: []} -> ["a1","a2","a3"] · same find searchFields: ' , ' -> ["a1","a2","a3"] · same find (no override, control) -> ["a1","a2","a3"] · same findOne each empty override -> "a1", the default-set where count each empty override -> 3, the default-set where aggregate each empty override -> {"east":2,"west":1}, the default-set where ``` Red leg, the new file against the `main` engine: `Tests 15 failed | 9 passed (24)`, every refusal case "expected a refusal, got an answer"; the nine controls green. This head: `Tests 24 passed (24)`. Ablation, two legs, through `scripts/ablation-replace.mjs` (the test imports `./engine.js`, which vitest resolves to `src/`, so the mutation is on the resolved source): - Leg A, the `searchFields` call replaced by a marker: anchor 1 → 0, blob `b26bca3e` → `4903e59e`. `Tests 11 failed | 13 passed (24)`: the `searchFields: []` and comma-string cases on all four verbs, no-term, system-context and REST parity. Restored: blob == HEAD `b26bca3e`, `git diff HEAD` empty. - Leg B, the `search.fields` call replaced by a marker: anchor 1 → 0, blob `b26bca3e` → `3cef55e0`. `Tests 4 failed | 20 passed (24)`: the `search: { query, fields: [] }` case on all four verbs. Restored the same way. </details> ## Acceptance notes - **Caller census, at this head.** Non-test producers that hand the engine `searchFields`: the global search sweep (`protocol-record-reads.ts`, passes it only when the queryable set is narrower and skips an object left with none, so never empty); the REST export (`data-action-routes.ts`, keeps an empty list so `findData` refuses it, which it already did); `findData`'s stored-metadata narrowing (returns a non-empty list or refuses). None passes an empty override. - **In-process seam, noted.** `refuseOrNarrowStoredMetadataEvaluate` in `packages/runtime/src/stored-metadata-reader-seam.ts` runs `narrowStoredMetadataSearch` before the engine. For a stored-metadata object, an in-process `searchFields: []` with a search term is replaced there by the narrowed default set, so it reaches the engine non-empty and is not refused. That is the pre-existing "empty is absent" reading one layer up, on one object family, outside this file surface. `narrowStoredMetadataSearch`'s docblock ("an empty override is ABSENT to the engine") is stale after this PR. Carrier: seat. - **Not changed.** A non-empty override whose every name is outside the searchable set still falls back to the allowed set in process (`resolveSearchFields`'s tolerance; the doors refuse it `INVALID_FIELD`). That is the search twin of #27165's all-unknown projection and is not this card. ## Verification All on head `a43bf0a9` unless named. - **Derived gates.** `node scripts/pm/dispatch-gates.mjs --commands` (first stderr line names `objectstack-ai/objectstack` at the head) derived 93 families; each exit code recorded before any pipe. 93/93 exit 0. `--ran`: "93 derived, 93 run, 0 NOT-MEASURED, 0 UNRUN" (exit 0). A whole-tree `turbo run build` (exit 0) ran first, so no family hit a missing `dist/`. - **First pass, on `bdbb4799`.** 92/93: `check:query-options-erasure` exit 1, "test surface grew 236 → 240", the four `as any` bags in the new test's `run()` helper. Fixed in `a43bf0a9` by naming the bypassed contract (`as unknown as EngineQueryOptions` / `EngineCountOptions` / `EngineAggregateOptions`, the form the gate asks for); the ratchet reads 236, at the ceiling. The second pass above is the full 93 again. - **`pnpm lint`** (`eslint . --no-inline-config`): exit 0 on `a43bf0a9`. - **`@objectstack/objectql`.** `typecheck` (tsc plus `check:test-typecheck`) exit 0 on `a43bf0a9`. `test` under the verify lock on `bdbb4799`: `Test Files 494 passed (494)`, `Tests 9616 passed (9616)`. The only later change is the four type assertions in the new test file; that file and `engine-search-empty-set.test.ts` re-ran on `a43bf0a9`: 35/35. - **Dogfood battery** (`pnpm --filter @objectstack/dogfood test` under the lock, after the whole-tree build) on `bdbb4799`: VERDICT command-exit 1, `Test Files 1 failed | 357 passed | 3 skipped (361)`, `Tests 1 failed | 2880 passed | 20 skipped (2901)`. The one red is main-red #28082, pre-existing, not this diff: `test/api-action-door-forward.dogfood.test.ts:178` still expects `sys_metadata_activation` in the `ACTION_DISABLED` message that PR #27964 rewrote; it is the same on `origin/main` `6b125a7a`. `a43bf0a9` changes only type assertions in a unit test file, which the dogfood battery does not import. - **Not measured here:** CI's own lanes (Test Core with `test:repo`, the type-check lanes, Build Docs); this PR's check-runs answer them. <details><summary>Serial</summary> - PR #27964 (#27842) merged at 10:54Z as `3e0fd7a8`; `origin/main` was merged into this branch afterwards (`bdbb4799`, no conflict). `git merge-base --is-ancestor 3e0fd7a HEAD` exit 0 (control `bc496bf7`: exit 0); `git merge-tree --write-tree origin/main HEAD` exit 0. - #25522 (seat 2's engine cuts): the last comment (6513988645, 03:08Z) released cut 20 back to `pm:queue`; no live cut claim, and no open PR touches the search region. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_016WV4cyzHgmAWpXVdUctRKU)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 121d1a5 commit 6a648ac

5 files changed

Lines changed: 288 additions & 3 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
fix(objectql)!: the in-process engine refuses a search-field override that names no field, as the Data API doors and the shared declarations do (#27732)
6+
7+
Clause-②: no (narrowing) — the in-process engine (`find`, `findOne`, `count`, `aggregate`) refuses a `searchFields` or object-form `search.fields` that names no field (an empty list, or a comma string with no name in it), which it read as absent before. The doors already refuse it (#27484) and `QuerySchema.searchFields` / `FullTextSearchSchema.fields` declare the refusal (#27683). Nothing is widened.
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no export or type changes: an engine option value the shared declarations already refuse is now refused in process too. The refusal is the doors' VALIDATION_ERROR / 400, so there is nothing for a migration to rewrite and no tombstone. -->
11+
12+
**BREAKING** accept-set narrowing at the engine's search override (`@objectstack/objectql`).
13+
14+
- **Before.** `engine.find(obj, { search: 'acme', searchFields: [] })`, the same on `findOne` / `count` / `aggregate`, and `search: { query: 'acme', fields: [] }` scanned the object's whole default search set: an empty override read as no override. The caller wrote the key to narrow and was not told.
15+
- **Now.** Each is refused `400 VALIDATION_ERROR` (`param: 'searchFields'` or `'search.fields'`) before any driver read, whether or not a search term rides along, and for a system-context caller too. The message carries the declaration's own words ("Name at least one field, or leave the key out to search the default search fields"), and `mapDataError` serves it the status and code the REST door serves.
16+
- **Unchanged.** An absent (or `null`) override searches the default set. A named override narrows as before, and a name outside the searchable set is still intersected away in process (refused at the doors).
17+
18+
**The remedy.** Leave the key out to search the default fields, or name at least one field.

‎content/docs/protocol/objectql/query-syntax.mdx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1194,7 +1194,9 @@ the tolerant intersection, but not a search with nothing to scan: when no
11941194
`searchableFields` entry names a stored field and the auto-default finds none
11951195
(`internal: true` fields never count), the engine itself refuses a non-blank `search`
11961196
with `400 INVALID_FIELD` (`param: 'search'`) on `find`, `findOne`, `count` and
1197-
`aggregate` alike, rather than answering every row. Multiple whitespace-separated terms are AND-ed and
1197+
`aggregate` alike, rather than answering every row. An override that names no field (`searchFields: []`,
1198+
`search.fields: []`, or a comma string with no name in it) is refused there too, `400 VALIDATION_ERROR`, as
1199+
at the ingress: leave the key out to search the default set. Multiple whitespace-separated terms are AND-ed and
11981200
fields are OR-ed. Case sensitivity comes from the operator the expansion emits, not
11991201
from the expansion: it emits `$icontains`, which ignores **ASCII case** by the
12001202
rule in [Case Sensitivity](#case-sensitivity) above. So a user typing `acme` does
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#27732] The in-process engine refuses a search-field override that names no
5+
* field, as the Data API doors (#27484) and the shared declarations (#27683)
6+
* do.
7+
*
8+
* `searchFields` and the object-form `search.fields` narrow which columns a
9+
* `search` scans; absent means "scan the object's default search fields". The
10+
* door refuses a value naming no field with `400 VALIDATION_ERROR`
11+
* (`assertSearchFieldsAreSearchable`, `@objectstack/metadata-protocol`), and
12+
* `QuerySchema.searchFields` / `FullTextSearchSchema.fields` refuse it too. The
13+
* engine never parses its options with those schemas, and `resolveSearchFields`
14+
* read an empty override as absent, so `find` / `findOne` / `count` /
15+
* `aggregate` scanned the whole default set: a caller who wrote the override
16+
* meant to narrow, and got the widest search instead, untold.
17+
*
18+
* Driven against a REAL {@link ObjectQL} engine over a driver double that
19+
* evaluates `$or` / `$icontains` and records each read, so "refused before any
20+
* driver read" is measured rather than assumed. `harbour` hits `name` on two
21+
* rows and `city` on a third, so a narrowing is observable.
22+
*/
23+
24+
import { describe, it, expect, beforeEach } from 'vitest';
25+
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
26+
import { QuerySchema, FullTextSearchSchema } from '@objectstack/spec/data';
27+
import type { EngineQueryOptions, EngineCountOptions, EngineAggregateOptions } from '@objectstack/spec/data';
28+
import { mapDataError } from '@objectstack/types';
29+
import { ObjectQL } from './engine.js';
30+
31+
const account = {
32+
name: 'acct_27732',
33+
label: 'Account',
34+
fields: {
35+
id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true },
36+
name: { name: 'name', label: 'Name', type: 'text' as const },
37+
city: { name: 'city', label: 'City', type: 'text' as const },
38+
region: { name: 'region', label: 'Region', type: 'text' as const },
39+
},
40+
};
41+
42+
const ROWS: ReadonlyArray<Record<string, unknown>> = [
43+
{ id: 'a1', name: 'Harbour Freight', city: 'Boston', region: 'east' },
44+
{ id: 'a2', name: 'Harbour Lights', city: 'Seattle', region: 'west' },
45+
{ id: 'a3', name: 'Northgate Mills', city: 'Harbourton', region: 'east' },
46+
{ id: 'a4', name: 'Riverside Co', city: 'Portland', region: 'west' },
47+
];
48+
49+
/** The shared declarations' own refusal words, read off the published schemas. */
50+
const declaredRefusal = (): string => {
51+
const parsed = (QuerySchema as any).shape.searchFields.safeParse([]);
52+
expect(parsed.success).toBe(false);
53+
const nested = FullTextSearchSchema.shape.fields.safeParse([]);
54+
expect(nested.success).toBe(false);
55+
expect(nested.error!.issues[0].message).toBe(parsed.error!.issues[0].message);
56+
return parsed.error!.issues[0].message;
57+
};
58+
59+
function matches(row: Record<string, unknown>, where: unknown): boolean {
60+
if (!where || typeof where !== 'object') return true;
61+
for (const [k, v] of Object.entries(where as Record<string, unknown>)) {
62+
if (k === '$and') { if (!(v as unknown[]).every((w) => matches(row, w))) return false; continue; }
63+
if (k === '$or') { if (!(v as unknown[]).some((w) => matches(row, w))) return false; continue; }
64+
if (k.startsWith('$')) throw new Error(`driver double does not implement the '${k}' combinator`);
65+
if (v && typeof v === 'object' && '$icontains' in (v as any)) {
66+
if (!String(row[k] ?? '').toLowerCase().includes(String((v as any).$icontains).toLowerCase())) return false;
67+
continue;
68+
}
69+
const expected = v && typeof v === 'object' && '$eq' in (v as any) ? (v as any).$eq : v;
70+
if (row[k] !== expected) return false;
71+
}
72+
return true;
73+
}
74+
75+
function makeDriver() {
76+
const reads: Array<{ via: string; where: unknown }> = [];
77+
const driver: any = {
78+
name: 'memory',
79+
version: '0.0.0',
80+
supports: {},
81+
async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; },
82+
async find(_o: string, ast: any) {
83+
reads.push({ via: 'find', where: ast?.where });
84+
const matched = ROWS.filter((r) => matches(r, ast?.where));
85+
const page = typeof ast?.limit === 'number' ? matched.slice(0, ast.limit) : matched;
86+
return page.map((r) => ({ ...r }));
87+
},
88+
async findOne(_o: string, ast: any) {
89+
reads.push({ via: 'findOne', where: ast?.where });
90+
const hit = ROWS.find((r) => matches(r, ast?.where));
91+
return hit ? { ...hit } : null;
92+
},
93+
async count(_o: string, ast: any) {
94+
reads.push({ via: 'count', where: ast?.where });
95+
return ROWS.filter((r) => matches(r, ast?.where)).length;
96+
},
97+
async create(_o: string, d: any) { return d; },
98+
async update(_o: string, _id: string, d: any) { return d; },
99+
async delete() { return true; },
100+
async bulkCreate(_o: string, r: any[]) { return r; },
101+
async bulkUpdate() { return []; }, async bulkDelete() {},
102+
async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; },
103+
async commit() {}, async rollback() {},
104+
};
105+
return { driver, reads };
106+
}
107+
108+
/** The refusal as the REST door would answer it: `mapDataError`'s status and code. */
109+
async function wireAnswer(read: Promise<unknown>) {
110+
const err = await read.then(
111+
() => { throw new Error('expected a refusal, got an answer'); },
112+
(e: unknown) => e,
113+
);
114+
const mapped = mapDataError(err);
115+
return { status: mapped.status, code: mapped.body.code, message: (err as Error).message, param: (err as any).param };
116+
}
117+
118+
const COUNT = [{ function: 'count' as const, alias: 'n' }];
119+
120+
type Verb = 'find' | 'findOne' | 'count' | 'aggregate';
121+
const VERBS: readonly Verb[] = ['find', 'findOne', 'count', 'aggregate'];
122+
123+
/**
124+
* Run one verb with the search keys in `bag`. Deliberately off-contract: the
125+
* bags carry the comma-string and `null` spellings the engine-side declaration
126+
* does not admit, as the wire path hands them over.
127+
*/
128+
function run(engine: ObjectQL, verb: Verb, bag: Record<string, unknown>): Promise<unknown> {
129+
switch (verb) {
130+
case 'find': return engine.find(account.name, bag as unknown as EngineQueryOptions);
131+
case 'findOne': return engine.findOne(account.name, bag as unknown as EngineQueryOptions);
132+
case 'count': return engine.count(account.name, bag as unknown as EngineCountOptions);
133+
case 'aggregate': return engine.aggregate(account.name, { groupBy: ['region'], aggregations: COUNT, ...bag } as unknown as EngineAggregateOptions);
134+
}
135+
}
136+
137+
/** The ids a verb's answer covers, so a narrowing is read off the result. */
138+
async function answered(engine: ObjectQL, verb: Verb, bag: Record<string, unknown>): Promise<unknown> {
139+
const out: any = await run(engine, verb, bag);
140+
if (verb === 'find') return (out as any[]).map((r) => r.id).sort();
141+
if (verb === 'findOne') return out?.id ?? null;
142+
if (verb === 'count') return out;
143+
return Object.fromEntries((out as any[]).map((r) => [r.region, r.n]));
144+
}
145+
146+
/** What each verb answers for `harbour` over the DEFAULT set, and narrowed to `name`. */
147+
const DEFAULT_ANSWER: Record<Verb, unknown> = {
148+
find: ['a1', 'a2', 'a3'], findOne: 'a1', count: 3, aggregate: { east: 2, west: 1 },
149+
};
150+
const NAME_ONLY_ANSWER: Record<Verb, unknown> = {
151+
find: ['a1', 'a2'], findOne: 'a1', count: 2, aggregate: { east: 1, west: 1 },
152+
};
153+
154+
describe('[#27732] the in-process engine refuses a search-field override naming no field', () => {
155+
let engine: ObjectQL;
156+
let reads: Array<{ via: string; where: unknown }>;
157+
158+
beforeEach(async () => {
159+
engine = new ObjectQL();
160+
const made = makeDriver();
161+
reads = made.reads;
162+
engine.registerDriver(made.driver, true);
163+
await engine.init();
164+
engine.registry.registerObject(account, 'test-package');
165+
});
166+
167+
describe.each(VERBS)('%s', (verb) => {
168+
it('searchFields: [] is refused 400 VALIDATION_ERROR in the declarations\' words, before any driver read', async () => {
169+
const answer = await wireAnswer(run(engine, verb, { search: 'harbour', searchFields: [] }));
170+
expect(answer).toMatchObject({ status: 400, code: 'VALIDATION_ERROR', param: 'searchFields' });
171+
expect(answer.message).toContain(declaredRefusal());
172+
expect(reads).toEqual([]);
173+
});
174+
175+
it('search: { query, fields: [] } is refused the same way (item 1, the search.fields coupling)', async () => {
176+
const answer = await wireAnswer(run(engine, verb, { search: { query: 'harbour', fields: [] } }));
177+
expect(answer).toMatchObject({ status: 400, code: 'VALIDATION_ERROR', param: 'search.fields' });
178+
expect(answer.message).toContain(declaredRefusal());
179+
expect(reads).toEqual([]);
180+
});
181+
182+
it('a comma string naming no field (the GET transport form) is refused the same way', async () => {
183+
const answer = await wireAnswer(run(engine, verb, { search: 'harbour', searchFields: ' , ' }));
184+
expect(answer).toMatchObject({ status: 400, code: 'VALIDATION_ERROR', param: 'searchFields' });
185+
expect(reads).toEqual([]);
186+
});
187+
188+
it('CONTROL: a named override still narrows', async () => {
189+
expect(await answered(engine, verb, { search: 'harbour', searchFields: ['name'] })).toEqual(NAME_ONLY_ANSWER[verb]);
190+
expect(await answered(engine, verb, { search: { query: 'harbour', fields: ['name'] } })).toEqual(NAME_ONLY_ANSWER[verb]);
191+
});
192+
193+
it('CONTROL: an absent override still searches the default set', async () => {
194+
expect(await answered(engine, verb, { search: 'harbour' })).toEqual(DEFAULT_ANSWER[verb]);
195+
expect(await answered(engine, verb, { search: { query: 'harbour' } })).toEqual(DEFAULT_ANSWER[verb]);
196+
});
197+
});
198+
199+
it('an empty override is refused whether or not a search term rode along, as the door judges it', async () => {
200+
const answer = await wireAnswer(engine.find(account.name, { searchFields: [] }));
201+
expect(answer).toMatchObject({ status: 400, code: 'VALIDATION_ERROR', param: 'searchFields' });
202+
expect(reads).toEqual([]);
203+
});
204+
205+
it('a system-context read is refused too: the rule is about the shape, not the caller', async () => {
206+
const answer = await wireAnswer(engine.find(account.name, { search: 'harbour', searchFields: [], context: { isSystem: true } }));
207+
expect(answer).toMatchObject({ status: 400, code: 'VALIDATION_ERROR' });
208+
expect(reads).toEqual([]);
209+
});
210+
211+
it('the REST door answers the same status and code for the same override', async () => {
212+
const protocol = new ObjectStackProtocolImplementation(engine);
213+
const door = await wireAnswer(protocol.findData({ object: account.name, query: { search: 'harbour', searchFields: '' } as any }));
214+
const inProcess = await wireAnswer(engine.find(account.name, { search: 'harbour', searchFields: [] }));
215+
expect({ status: inProcess.status, code: inProcess.code }).toEqual({ status: door.status, code: door.code });
216+
expect(door).toMatchObject({ status: 400, code: 'VALIDATION_ERROR' });
217+
expect(reads).toEqual([]);
218+
});
219+
220+
it('CONTROL: a null override is a withdrawal and searches the default set', async () => {
221+
expect(await answered(engine, 'find', { search: 'harbour', searchFields: null })).toEqual(DEFAULT_ANSWER.find);
222+
});
223+
});

‎packages/objectql/src/engine-search-empty-set.test.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,10 @@ describe('[#24284] a search over an empty resolved search set is refused', () =>
138138
});
139139

140140
it('an explicit searchFields narrowing does not rescue it — the allowed set is empty, so the fallback is too', async () => {
141-
for (const searchFields of [['title'], ['book'], ['nope'], []]) {
141+
// [#27732] An empty override is no longer a narrowing to rescue: it
142+
// names no field, and is refused before the expansion
143+
// (engine-empty-search-fields-refusal.test.ts).
144+
for (const searchFields of [['title'], ['book'], ['nope']]) {
142145
const err: any = await engine.find('lib_loan', { search: 'Dune', searchFields }).then(() => null, (e) => e);
143146
expect(err, `searchFields ${JSON.stringify(searchFields)}`).toMatchObject(REFUSAL);
144147
}

‎packages/objectql/src/engine.ts‎

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { QueryAST, QueryInput, HookContext, ServiceObject } from '@objectstack/s
55
// [commit 74155c735] The defaulting node schema `fillQueryAstDefaults` runs author input
66
// through — the declared `.default()` stays in `packages/spec`, the engine
77
// only invokes it.
8-
import { SortNodeSchema } from '@objectstack/spec/data';
8+
import { SortNodeSchema, FullTextSearchSchema } from '@objectstack/spec/data';
99
import {
1010
EngineQueryOptions,
1111
DataEngineInsertOptions,
@@ -735,6 +735,40 @@ const ENGINE_AGGREGATE_OPTION_KEYS: ReadonlySet<string> = new Set([
735735
'search', 'searchFields',
736736
]);
737737

738+
/**
739+
* [#27732] A search-field override — `searchFields`, or the object-form
740+
* `search.fields` — that names no field is refused `400 VALIDATION_ERROR`:
741+
* an empty list, or a comma string with no name in it (the `GET` door's
742+
* transport spelling, which reaches the engine as sent). Absent (or `null`)
743+
* still searches the object's default search fields.
744+
*
745+
* `resolveSearchFields` reads an empty override as absent, so the search
746+
* widened to the whole default set: the caller wrote the key to narrow and was
747+
* not told. The door refuses this set (`assertSearchFieldsAreSearchable`,
748+
* #27484) and `QuerySchema.searchFields` / `FullTextSearchSchema.fields`
749+
* declare the same refusal (#27683); the words are read off that declaration
750+
* rather than copied, and the status and code are the door's, so
751+
* `mapDataError` answers an in-process caller and a REST caller alike. Any
752+
* other value is left to the expansion's existing tolerance.
753+
*/
754+
function refuseSearchOverrideNamingNoField(object: string, requested: unknown, param: string): void {
755+
const names = typeof requested === 'string'
756+
? requested.split(',').map((s) => s.trim()).filter(Boolean)
757+
: Array.isArray(requested) ? requested : undefined;
758+
if (names === undefined || names.length > 0) return;
759+
const declared = FullTextSearchSchema.shape.fields.safeParse([]).error?.issues[0]?.message;
760+
const err: any = new Error(
761+
`ObjectQL search on '${object}' names no field in '${param}': got ${JSON.stringify(requested)}. The query was not run: `
762+
+ "an empty search-field list was read as no narrowing, which scans the object's default search fields. "
763+
+ `${declared}.`,
764+
);
765+
err.status = 400;
766+
err.code = 'VALIDATION_ERROR';
767+
err.param = param;
768+
err.object = object;
769+
throw err;
770+
}
771+
738772
/**
739773
* Rows per page for the autonumber seeding scan (#6249). This is a PAGE size,
740774
* not a cap: the walk pages until the scope is exhausted. The number is the one
@@ -13397,6 +13431,11 @@ export class ObjectQL implements IObjectQLEngine {
1339713431
// unknown option — rejected at the entry point, not silently dropped
1339813432
// (#4371).
1339913433
const raw = (ast as any).search;
13434+
// [#27732] An override naming no field is refused, never read as absent.
13435+
// Judged before the expansion and so before any driver read, whether or
13436+
// not a search term rode along, on both slots, as the door does.
13437+
refuseSearchOverrideNamingNoField(ast.object, (ast as any).searchFields, 'searchFields');
13438+
if (raw !== null && typeof raw === 'object') refuseSearchOverrideNamingNoField(ast.object, raw.fields, 'search.fields');
1340013439
if (raw != null && schema?.fields) {
1340113440
const requestedFields = (ast as any).searchFields
1340213441
?? (typeof raw === 'object' ? raw?.fields : undefined);

0 commit comments

Comments
 (0)