Skip to content

Commit 4df101c

Browse files
feat(spec,objectql): IObjectQLEngine.judgeFilter — judge a where through the engine's own admission, without executing it (#20213)
Fixes #20157 Clause-②: yes ## What this adds `IObjectQLEngine` gains one OPTIONAL member, `judgeFilter(objectName, where, { operation?, context? })`, and `ObjectQL` implements it. It answers 「can this filter run against this object」 without running anything. The answer is `{ ok: true }`, or `{ ok: false, code, status, message }` with the diagnostic execution raises for the same filter. This implements ruling C on #19995 (batch #225 item 3, maintainer 「同意」), as the card records it. Two consumers wait on this card with `Blocked-by: #20157`: the analytics read-scope pre-judge (#19995) and authoring-time RLS policy admission (#20158). Neither consumer is wired in this PR. ## Premise measured first: admission needs no driver and no data The card's premise, verbatim: "the admission pipeline can run without a driver or data; if a door needs either, stop and report the fork." It holds. I read each door on the tree this branch starts from (`d7c024133`, which includes `cfe2387a3`): | door (in execution order) | reads | |:--|:--| | shape gate (`isWhereFilterObject`) in `lowerWhereFilterArray` | the `where` value | | `assertListComparandShapes` (spec face) | the `where` value | | `assertFilterIsMaterializable`, dotted-path verdict then virtual-field verdict | `where` + the registry's field map | | `assertTextOperatorTargetsAreStringCapable` | `where` + field map | | `assertTemporalComparandsInterpretable` | `where` + field map (+ core's value predicate) | | `normalizeFilterComparandTypes` (spec comparand-type door) | the `where` value | | array form: `isFilterAST` → `parseFilterAST`, then the three field-map doors on the lowered condition | `where` + field map | | placeholder resolver (`resolveFilterTokens`, `FILTER_TOKEN_UNKNOWN` / `FILTER_TOKEN_UNRESOLVED`) | `where` + the execution context (user id, tenant id, timezone) + the clock | No door reads a driver, a hook, the middleware chain or a row. The object name is looked up in the registry (`resolveObjectName`), as on every verb. ## One pipeline, the same on every verb, and the judge calls it I checked the dispatch's assumption that `where` admission is one ordered sequence against all six verbs that take a `where`. It holds. Every verb runs the same two stages in the same order: 1. `lowerWhereFilterArray(object, operation, bag, schema)`: every door in the table above except the resolver. 2. Placeholder expansion. What differs by verb sits around those stages and judges something other than `where`: - option-key folding and refusal; - `getDriver` (before stage 1 on `update` / `delete`, between the stages on the reads); - the `orderBy` and projection doors on `find`; - the per-aggregation and `having` doors on `aggregate`. The consumers need the read path (the analytics scope runs through `aggregate`). The stage sequence is the same on every verb, so there is nothing verb-specific to choose. The refactor is small. Stage 2 had two spellings of one expression, in `ObjectQL.resolveWhereTokens` (reads) and `ObjectQL.withResolvedWhere` (writes). Both now call a module function, `resolveWhereFilterTokens`. `judgeWhereAdmission` calls `lowerWhereFilterArray` and then `resolveWhereFilterTokens`, the same two functions execution calls. The judge carries no second copy of any walk. A thrown door diagnostic (string `code` + numeric `status`, the ADR-0112 envelope) becomes the returned refusal. Any other throw is re-thrown, because it is a fault, not a verdict. `ObjectQL.aggregate`'s per-aggregation filter loop is not touched. Execution call sites are not reordered. ## Contract choices for the contract review The ruling leaves the name and the exact signature to the seat and the review. Each of these is open to change there. - **Name:** `judgeFilter`. - **Synchronous.** The verdict is a value. A door that needs I/O cannot join it without a contract change, so "stops before any driver call" is also a property of the type. - **`where: EngineQueryOptions['where']`.** This is the type `find` accepts. The analytics scope type (`FilterCondition`) assigns to it without a cast (pinned in spec). - **`operation?`:** one of the six verbs, default `'find'`. Engine refusals name their verb (`aggregate('deal'): …`), so without this option the "same message" claim would hold for `find` only. It changes the message's opening words, never the verdict. - **`context?: BaseEngineOptions['context']`, and placeholders are expanded against it.** The ruling does not spell this out. What I measured: #19995's consumer already calls `assertReadScopePlaceholdersResolvable(scope, objectName, ctx.context)` at both merges (`withReadScope`, `resolveFkAttr`). That call runs the engine's own resolver against the context it forwards to `executeAggregate`. So the judge expands placeholders against the supplied context, as execution does. A placeholder the context cannot answer is refused `FILTER_TOKEN_UNRESOLVED` / 400, never read as `null`; this is pinned. - **Return type:** `{ ok: true } | { ok: false; code: string; status: number; message: string }`. - **Not redacted.** The message is the door's own text. A caller judging a policy withholds it itself (the #5367 ruling as recorded in `read-scope-sql.ts`). - **OPTIONAL, as ruled.** The interface header said members beyond `IDataEngine` are required. I amended it to name this one exception and its reason. The header's evidence bar ("declared where a cross-package consumer already calls it") is met here by ruling and not yet by a call site. The docblock says so. **Exports:** `@objectstack/objectql`'s `exports` are unchanged (`.` and `./core`). The judge is reached through the engine instance, typed `IObjectQLEngine`, like `getSchema`, so no package entry is needed. `@objectstack/spec/contracts` gains two type exports, `EngineFilterJudgement` and `EngineFilterJudgementOptions`. `api-surface/` and `export-origins/` are regenerated, not hand-edited. ## Pins `packages/objectql/src/engine-judge-filter.test.ts` has 43 tests on a real `ObjectQL` with a recording driver: - **Per class, per verb (30 tests).** The five classes are a text operator over a non-text field, an uninterpretable temporal comparand, an unknown filter placeholder, a filter on a virtual (formula) field, and a dotted path through a lookup. The six verbs are `find`, `findOne`, `count`, `aggregate`, `update` (multi) and `delete` (multi). For each pair, `judgeFilter(…, { operation: verb })` and that verb's execution agree on `code`, `status` and the full `message`, and both equal the expected envelope: `INVALID_FILTER` / 400, `FILTER_TOKEN_UNKNOWN` / 400 or `INVALID_FIELD` / 400. - The array-sugar form and a non-object `where` give the same diagnostic as execution. The default operation is `find`. - A runnable filter returns `{ ok: true }`. As a positive control, execution of that filter reaches the driver. - **Nothing executes.** During judging the driver spy records zero calls, `getDriver` is never called, and neither a `beforeFind` hook nor a middleware runs. Each assertion has a positive control on execution. - **Order.** With a door defect and an unknown placeholder together, both sides answer the door's diagnostic. With a virtual field and a text operator together, both answer `INVALID_FIELD` (the materializable door runs before the text-operator door). - **Placeholders.** `{current_user_id}` with no context gives `FILTER_TOKEN_UNRESOLVED` on both sides. With `{ userId }` the judge answers ok, and execution sends the expanded value to the driver. - **An object the registry does not know.** The field-map doors answer nothing, and the list-comparand gate still refuses, as at execution. `packages/spec/src/contracts/objectql-engine.test.ts` adds 5 type pins. The member is optional and synchronous. The ruled consumer's argument types assign with no cast. The refusal carries exactly `code` / `status` / `message`. The operation union is the six verbs. ## Proof that the pins can fail The mutations went through `scripts/ablation-replace.mjs`, from committed state. Each mutation was proven on disk (anchor count 1 → 0, blob changed) and each restore was proven (blob equals HEAD, `git diff HEAD` empty). | mutation in `judgeWhereAdmission` | expected | observed | |:--|:--|:--| | delete the stage-2 call (`resolveWhereFilterTokens(admitted.where, context);`) | red on the placeholder pins | 7 failed / 36 passed: the unknown-placeholder class on all 6 verbs and the unresolved-context pin | | replace stage 1 with `const admitted = { where };` | red on every door pin | 30 failed / 13 passed | **Reverse type verification.** In the objectql test I changed `{ operation: 'find' }` to `{ operation: 'insert' }`. `tsc -p tsconfig.test.json` then reported `TS2322: Type '"insert"' is not assignable to type '"update" | "delete" | "count" | "find" | "findOne" | "aggregate" | undefined'`, so the test reads the rebuilt spec `dist` declaration. Control leg: both new test files compile with 0 errors under their packages' `tsconfig.test.json`. ## Readings The gate union and the new pins were run at head `c797375ab`. The consumer suites were run at `30dadfba7`. The only change between the two heads is the wording of one docblock in `packages/spec/src/contracts/objectql-engine.ts`. - **New pins at `c797375ab`:** objectql `engine-judge-filter.test.ts` 43/43, spec `objectql-engine.test.ts` 12/12. - **Full suites of the two changed packages at `30dadfba7`:** objectql 317 files / 5671 tests passed, with every existing door test unmodified (`engine-text-operator-declared-type-door`, `engine-temporal-comparand-door`, `engine-filter-tokens`, `engine-where-shape-refusal`, `engine-comparand-type-door`, `engine-filter-array-lowering`, …). Spec: 541 files / 15901 passed, 2 todo. - **Consumers of `IObjectQLEngine` at `30dadfba7` (downstream direction: the contract's importers), one reading per package:** - service-analytics 128/3017; - plugin-security 136/2740; - metadata-protocol 189 files passed, 3 skipped / 2700 tests passed, 19 skipped; - core 53/1358; - rest 197/3339, 1 skipped; - runtime 279/3906, 1 skipped; - platform-objects 55/911; - plugin-approvals 51/791; - plugin-pinyin-search 2/21; - plugin-sharing 37/913; - trigger-record-change 10/101; - service-datasource 34/693; - plugin-hono-server 27/324; - plugin-auth 114/2440; - cloud-connection 30/397; - cli `--project unit` 225/3192 (the integration tier is declared to CI: this diff touches no spawn entry and no integration file); - dogfood: `typecheck` green, plus its five `IObjectQLEngine` importers 5/38. The full dogfood suite is left to CI's Dogfood Regression Gate. - **Typecheck:** `@objectstack/spec`, `@objectstack/objectql`, service-analytics, plugin-security, metadata-protocol, core, rest and runtime are all green. - **Gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 89 commands. All 89 were run at `c797375ab` and exited 0. `--ran` reconciles 89 run, 0 NOT-MEASURED, 0 UNRUN. One of them, `check:type-check-debt`, needed a second run. Its first run rebuilds the package closure itself, and my runner's 280 s timeout killed that rebuild partway through. I rebuilt the closure under the verify lock and re-ran the gate: exit 0, 4 ledger entries at their numbers. - **Lint, narrowed to the diff.** eslint over the 4 changed `.ts` files: 0 errors, 0 warnings. The population comes from eslint's own config: `--print-config` returns a config for each of the 4, so none is ignored. The file count (4) is read from the `--format json` output. Invariance: the config sets no `parserOptions.project`, so no rule is type-aware, and this diff cannot move the verdict of any file it does not touch. The full `pnpm lint` is CI's. ## Acceptance notes - **The judge's boundary is the engine's own admission.** Refusals below it are not judged: driver refusals (for example `driver-sql`'s declared-referent check on a `{ $field }` in `where`), hooks, and the predicates middleware composes after admission. The docblock says so. All fifteen classes named on #19995 sit inside that boundary. The four ruling C adds are pinned here. The eleven withheld at the merge boundary are covered by code reading, not by a pin in this PR: `assertReadScopeComparandsRunnable` calls `assertListComparandShapes` and `normalizeFilterComparandTypes`, both stage-1 doors, and the placeholder class is stage 2. The five classes pinned here are the card's list. - The branch starts from `d7c024133` and is behind `origin/main` by commits that touch none of these files. CI's merge ref tests the combination. - No `skip-changeset`. The changeset is `minor` for `@objectstack/spec` and `@objectstack/objectql` and carries `Clause-②: yes`. --- _Generated by [Claude Code](https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3875ae6 commit 4df101c

7 files changed

Lines changed: 626 additions & 3 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/objectql": minor
4+
---
5+
6+
`IObjectQLEngine` gains an optional judge-only member, `judgeFilter(objectName, where, { operation?, context? })`, and `ObjectQL` implements it (#20157, #19995 ruling C). It answers "can this filter run against this object?" without running anything: `{ ok: true }`, or `{ ok: false, code, status, message }` with the same diagnostic execution would raise.
7+
8+
- **The engine's own admission, not a copy.** The judge calls the two stage functions every verb that takes a `where` already runs, in their order. First the lowering doors: the shape gate, the list-comparand shape, the virtual-field and dotted-path refusals, the text operator over a non-text field, the uninterpretable temporal comparand and the comparand-type door. Then the filter-placeholder resolver. A new door on that pipeline is judged the day it lands.
9+
- **Nothing executes.** No driver is resolved or called, and no hook or middleware runs. The member is synchronous, so a door that needs I/O cannot join it without a contract change. Driver-level refusals and the predicates middleware composes later (RLS, sharing, tenant scope) are not judged.
10+
- **Placeholders resolve against `context`**, exactly as execution resolves them. A context placeholder the context cannot answer (`{current_user_id}` with no user) is refused with `FILTER_TOKEN_UNRESOLVED`, never resolved to `null`.
11+
- **`operation`** (default `'find'`) names the verb the caller will run, so the message carries that verb's prefix. The verdict is the same on every verb.
12+
- **The message is not redacted.** It names fields, operators and comparands. A caller judging a filter it must not disclose, such as a read-scope policy, withholds the message itself.
13+
14+
Optional by the ruling. A caller probes for it (`typeof ql.judgeFilter === 'function'`) and keeps its current behaviour on an engine without it. Existing engine doubles and foreign engines need no change. Execution is unchanged for every CRUD caller: same diagnostics, same order.
15+
16+
Clause-②: yes
Lines changed: 327 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,327 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#20157] `ObjectQL.judgeFilter`, the engine's judge-only filter admission
5+
* (`IObjectQLEngine.judgeFilter`, #19995 ruling C).
6+
*
7+
* What each block pins:
8+
*
9+
* - **Per class.** For each class #19995 lists (a text operator over a
10+
* non-text field, an uninterpretable temporal comparand, an unknown filter
11+
* placeholder, a filter on a virtual field, a dotted path through a lookup),
12+
* the judge returns the door's diagnostic, AND execution raises the same one.
13+
* Same `code`, `status` and `message`, on every verb that takes a `where`,
14+
* with `operation` naming the verb.
15+
* - **Ok.** A runnable filter returns `{ ok: true }`.
16+
* - **Nothing executes.** A driver spy sees no call, no driver is resolved,
17+
* and no hook or middleware runs. Each has a positive control: execution
18+
* drives the same spy.
19+
* - **Order.** A filter with two defects gets the diagnostic execution gives.
20+
* The door stage runs before the placeholder stage, and the doors inside it
21+
* keep their order.
22+
* - **Placeholders.** They resolve against the supplied context. One the
23+
* context cannot answer is refused, never resolved to `null`.
24+
*
25+
* Every refusal assertion reads `code` and `status` (the ADR-0112 envelope),
26+
* never a bare `toThrow()`.
27+
*/
28+
29+
import { describe, it, expect, beforeEach, vi } from 'vitest';
30+
import type { EngineFilterJudgement, EngineFilterJudgementOptions } from '@objectstack/spec/contracts';
31+
import type { EngineQueryOptions } from '@objectstack/spec/data';
32+
import type { ExecutionContext } from '@objectstack/spec/kernel';
33+
import { ObjectQL } from './engine.js';
34+
35+
/** The `where` type every verb (and the judge) accepts. */
36+
type Where = EngineQueryOptions['where'];
37+
38+
const DEAL = 'judge_deal';
39+
const ACCOUNT = 'judge_account';
40+
41+
const ACCOUNT_SCHEMA = {
42+
name: ACCOUNT,
43+
label: 'Judge account',
44+
fields: {
45+
id: { name: 'id', type: 'text' },
46+
name: { name: 'name', type: 'text' },
47+
},
48+
};
49+
50+
const DEAL_SCHEMA = {
51+
name: DEAL,
52+
label: 'Judge deal',
53+
fields: {
54+
id: { name: 'id', type: 'text' },
55+
title: { name: 'title', type: 'text' },
56+
owner_id: { name: 'owner_id', type: 'text' },
57+
amount: { name: 'amount', type: 'number' },
58+
closes_on: { name: 'closes_on', type: 'date' },
59+
// A virtual field: no driver materialises a column for it.
60+
is_open: { name: 'is_open', type: 'formula', expression: '1', returnType: 'boolean' },
61+
account: { name: 'account', type: 'lookup', reference: ACCOUNT },
62+
},
63+
};
64+
65+
type Verb = NonNullable<EngineFilterJudgementOptions['operation']>;
66+
const VERBS: readonly Verb[] = ['find', 'findOne', 'count', 'aggregate', 'update', 'delete'];
67+
68+
/** Run `where` through one verb's EXECUTION on `object`. */
69+
function execute(engine: ObjectQL, verb: Verb, object: string, where: Where, context?: ExecutionContext) {
70+
switch (verb) {
71+
case 'find': return engine.find(object, { where }, { context });
72+
case 'findOne': return engine.findOne(object, { where }, { context });
73+
case 'count': return engine.count(object, { where }, { context });
74+
case 'aggregate':
75+
return engine.aggregate(object, { where, aggregations: [{ function: 'count', alias: 'n' }] }, { context });
76+
case 'update': return engine.update(object, { title: 'x' }, { where, multi: true, context });
77+
case 'delete': return engine.delete(object, { where, multi: true, context });
78+
}
79+
}
80+
81+
type Thrown = Error & { code?: string; status?: number };
82+
83+
async function refusalOf(p: Promise<unknown>): Promise<Thrown | null> {
84+
return p.then(() => null, (e: unknown) => e as Thrown);
85+
}
86+
87+
/** A recording driver: every method call lands in `calls`, by method name. */
88+
function makeRecordingDriver() {
89+
const calls: string[] = [];
90+
const reads: Array<{ method: string; ast: any }> = [];
91+
const record = <A extends unknown[], R>(method: string, impl: (...args: A) => R) =>
92+
(...args: A): R => { calls.push(method); return impl(...args); };
93+
const driver: any = {
94+
name: 'judge-recording', version: '0.0.0', supports: {},
95+
connect: record('connect', async () => {}),
96+
disconnect: record('disconnect', async () => {}),
97+
checkHealth: record('checkHealth', async () => true),
98+
execute: record('execute', async () => null),
99+
find: record('find', async (_o: string, ast: any) => { reads.push({ method: 'find', ast }); return []; }),
100+
findOne: record('findOne', async (_o: string, ast: any) => { reads.push({ method: 'findOne', ast }); return null; }),
101+
count: record('count', async (_o: string, ast: any) => { reads.push({ method: 'count', ast }); return 0; }),
102+
aggregate: record('aggregate', async (_o: string, ast: any) => { reads.push({ method: 'aggregate', ast }); return []; }),
103+
create: record('create', async (_o: string, d: any) => d),
104+
update: record('update', async (_o: string, id: any, d: any) => ({ id, ...d })),
105+
updateMany: record('updateMany', async () => 0),
106+
delete: record('delete', async () => true),
107+
deleteMany: record('deleteMany', async () => 0),
108+
bulkCreate: record('bulkCreate', async (_o: string, rows: any[]) => rows),
109+
beginTransaction: record('beginTransaction', async () => ({})),
110+
commit: record('commit', async () => {}),
111+
rollback: record('rollback', async () => {}),
112+
};
113+
return { driver, calls, reads };
114+
}
115+
116+
/**
117+
* The five classes the ruling names, each with the envelope its door raises.
118+
* `where` is a factory so no case can edit a filter another case judges.
119+
*/
120+
const CLASSES: ReadonlyArray<{ name: string; where: () => Where; code: string; status: number; mentions: string }> = [
121+
{
122+
name: 'a text operator over a non-text field',
123+
where: () => ({ amount: { $contains: '5' } }),
124+
code: 'INVALID_FILTER', status: 400, mentions: "'amount'",
125+
},
126+
{
127+
name: 'an uninterpretable temporal comparand',
128+
where: () => ({ closes_on: { $gt: 'not-a-date' } }),
129+
code: 'INVALID_FILTER', status: 400, mentions: "'closes_on'",
130+
},
131+
{
132+
name: 'an unknown filter placeholder',
133+
where: () => ({ owner_id: '{bogus_token}' }),
134+
code: 'FILTER_TOKEN_UNKNOWN', status: 400, mentions: '{bogus_token}',
135+
},
136+
{
137+
name: 'a filter on a virtual field',
138+
where: () => ({ is_open: true }),
139+
code: 'INVALID_FIELD', status: 400, mentions: "'is_open'",
140+
},
141+
{
142+
name: 'a dotted path through a lookup',
143+
where: () => ({ 'account.name': 'Acme' }),
144+
code: 'INVALID_FIELD', status: 400, mentions: "'account.name'",
145+
},
146+
];
147+
148+
function refused(verdict: EngineFilterJudgement): Extract<EngineFilterJudgement, { ok: false }> {
149+
if (verdict.ok) throw new Error(`expected a refusal, the judge answered ok`);
150+
return verdict;
151+
}
152+
153+
describe('[#20157] ObjectQL.judgeFilter: judge a where without executing it', () => {
154+
let engine: ObjectQL;
155+
let calls: string[];
156+
let reads: Array<{ method: string; ast: any }>;
157+
158+
beforeEach(async () => {
159+
const rec = makeRecordingDriver();
160+
calls = rec.calls;
161+
reads = rec.reads;
162+
engine = new ObjectQL();
163+
engine.registerDriver(rec.driver, true);
164+
await engine.init();
165+
engine.registry.registerObject(ACCOUNT_SCHEMA as any, 'test');
166+
engine.registry.registerObject(DEAL_SCHEMA as any, 'test');
167+
// Boot traffic (connect, schema sync) is not the judge's; start clean.
168+
calls.length = 0;
169+
reads.length = 0;
170+
});
171+
172+
describe('per class: the judge returns the diagnostic execution raises', () => {
173+
for (const c of CLASSES) {
174+
for (const verb of VERBS) {
175+
it(`${c.name}, on ${verb}`, async () => {
176+
const verdict = refused(engine.judgeFilter(DEAL, c.where(), { operation: verb }));
177+
expect(calls).toEqual([]);
178+
179+
const thrown = await refusalOf(execute(engine, verb, DEAL, c.where()));
180+
expect(thrown).not.toBeNull();
181+
// The envelope, on both sides.
182+
expect({ code: thrown!.code, status: thrown!.status }).toEqual({ code: c.code, status: c.status });
183+
expect({ code: verdict.code, status: verdict.status }).toEqual({ code: c.code, status: c.status });
184+
// The same diagnostic, byte for byte, including the verb prefix.
185+
expect(verdict.message).toBe(thrown!.message);
186+
expect(verdict.message).toContain(c.mentions);
187+
});
188+
}
189+
}
190+
191+
it('the default operation is find', () => {
192+
const bare = refused(engine.judgeFilter(DEAL, { amount: { $contains: '5' } }));
193+
const asFind = refused(engine.judgeFilter(DEAL, { amount: { $contains: '5' } }, { operation: 'find' }));
194+
expect(bare).toEqual(asFind);
195+
expect(bare.message.startsWith(`find('${DEAL}')`)).toBe(true);
196+
});
197+
198+
it('the filter-array sugar is judged through the same lowering execution runs', async () => {
199+
// Input-only sugar: off the `where` type on purpose, as a caller holding
200+
// a `FilterArray` passes it.
201+
const where = [['amount', 'contains', '5']] as unknown as Where;
202+
const verdict = refused(engine.judgeFilter(DEAL, where));
203+
const thrown = await refusalOf(engine.find(DEAL, { where }));
204+
expect({ code: verdict.code, status: verdict.status }).toEqual({ code: 'INVALID_FILTER', status: 400 });
205+
expect({ code: thrown!.code, status: thrown!.status }).toEqual({ code: 'INVALID_FILTER', status: 400 });
206+
expect(verdict.message).toBe(thrown!.message);
207+
});
208+
209+
it('a where that is not a filter object gets the shape gate\'s diagnostic', async () => {
210+
// Off-contract on purpose: the shape gate is what refuses it.
211+
const where = 'status = open' as unknown as Where;
212+
const verdict = refused(engine.judgeFilter(DEAL, where));
213+
const thrown = await refusalOf(engine.find(DEAL, { where }));
214+
expect({ code: verdict.code, status: verdict.status }).toEqual({ code: 'INVALID_FILTER', status: 400 });
215+
expect(verdict.message).toBe(thrown!.message);
216+
});
217+
});
218+
219+
describe('a runnable filter', () => {
220+
it('returns ok, and execution admits it and reaches the driver', async () => {
221+
const where = { title: 'Acme', amount: { $gt: 5 }, closes_on: { $gte: '{30_days_ago}' } };
222+
expect(engine.judgeFilter(DEAL, where)).toEqual({ ok: true });
223+
expect(calls).toEqual([]);
224+
225+
// Positive control: the same filter executes, so the spy is live.
226+
await engine.find(DEAL, { where });
227+
expect(calls).toContain('find');
228+
});
229+
230+
it('an absent, null or empty where is ok', () => {
231+
expect(engine.judgeFilter(DEAL, undefined)).toEqual({ ok: true });
232+
expect(engine.judgeFilter(DEAL, null as unknown as Where)).toEqual({ ok: true });
233+
expect(engine.judgeFilter(DEAL, {})).toEqual({ ok: true });
234+
expect(engine.judgeFilter(DEAL, [] as unknown as Where)).toEqual({ ok: true });
235+
});
236+
237+
it('leaves the caller\'s filter untouched', () => {
238+
const where = { owner_id: '{current_user_id}', closes_on: { $gte: '{30_days_ago}' } };
239+
const before = JSON.stringify(where);
240+
expect(engine.judgeFilter(DEAL, where, { context: { userId: 'u_1' } })).toEqual({ ok: true });
241+
expect(JSON.stringify(where)).toBe(before);
242+
});
243+
});
244+
245+
describe('nothing executes', () => {
246+
it('no driver method is called and no driver is resolved, for a refusal or an ok', async () => {
247+
const getDriver = vi.spyOn(engine as any, 'getDriver');
248+
for (const c of CLASSES) engine.judgeFilter(DEAL, c.where());
249+
engine.judgeFilter(DEAL, { title: 'Acme' });
250+
expect(calls).toEqual([]);
251+
expect(getDriver).not.toHaveBeenCalled();
252+
253+
// Positive control: execution resolves the driver through the same spy.
254+
await engine.find(DEAL, { where: { title: 'Acme' } });
255+
expect(getDriver).toHaveBeenCalled();
256+
});
257+
258+
it('no hook and no middleware runs', async () => {
259+
const hook = vi.fn();
260+
const middleware = vi.fn(async (_opCtx: unknown, next: () => Promise<void>) => { await next(); });
261+
engine.registerHook('beforeFind', hook, { object: DEAL });
262+
engine.registerMiddleware(middleware);
263+
264+
engine.judgeFilter(DEAL, { title: 'Acme' });
265+
engine.judgeFilter(DEAL, { is_open: true });
266+
expect(hook).not.toHaveBeenCalled();
267+
expect(middleware).not.toHaveBeenCalled();
268+
269+
// Positive control: execution runs both.
270+
await engine.find(DEAL, { where: { title: 'Acme' } });
271+
expect(hook).toHaveBeenCalled();
272+
expect(middleware).toHaveBeenCalled();
273+
});
274+
});
275+
276+
describe('order: the diagnostic execution gives when a filter has two defects', () => {
277+
it('the door stage runs before the placeholder stage', async () => {
278+
const where = (): Where => ({ owner_id: '{bogus_token}', amount: { $contains: '5' } });
279+
const verdict = refused(engine.judgeFilter(DEAL, where()));
280+
const thrown = await refusalOf(engine.find(DEAL, { where: where() }));
281+
expect(verdict.code).toBe('INVALID_FILTER');
282+
expect({ code: thrown!.code, status: thrown!.status }).toEqual({ code: 'INVALID_FILTER', status: 400 });
283+
expect(verdict.message).toBe(thrown!.message);
284+
});
285+
286+
it('inside the door stage, the materializable door answers before the text-operator door', async () => {
287+
const where = (): Where => ({ amount: { $contains: '5' }, is_open: true });
288+
const verdict = refused(engine.judgeFilter(DEAL, where()));
289+
const thrown = await refusalOf(engine.find(DEAL, { where: where() }));
290+
expect(verdict.code).toBe('INVALID_FIELD');
291+
expect({ code: thrown!.code, status: thrown!.status }).toEqual({ code: 'INVALID_FIELD', status: 400 });
292+
expect(verdict.message).toBe(thrown!.message);
293+
});
294+
});
295+
296+
describe('placeholders resolve against the supplied context, never to null', () => {
297+
it('a context placeholder with no context is refused, as execution refuses it', async () => {
298+
const where = { owner_id: '{current_user_id}' };
299+
const verdict = refused(engine.judgeFilter(DEAL, where));
300+
const thrown = await refusalOf(engine.find(DEAL, { where }));
301+
expect({ code: verdict.code, status: verdict.status }).toEqual({ code: 'FILTER_TOKEN_UNRESOLVED', status: 400 });
302+
expect({ code: thrown!.code, status: thrown!.status }).toEqual({ code: 'FILTER_TOKEN_UNRESOLVED', status: 400 });
303+
expect(verdict.message).toBe(thrown!.message);
304+
});
305+
306+
it('the same placeholder with the context execution would get is ok, and execution sends the resolved value', async () => {
307+
const where = { owner_id: '{current_user_id}' };
308+
expect(engine.judgeFilter(DEAL, where, { context: { userId: 'u_1' } })).toEqual({ ok: true });
309+
await engine.find(DEAL, { where }, { context: { userId: 'u_1' } });
310+
expect(reads.at(-1)?.ast.where).toEqual({ owner_id: 'u_1' });
311+
});
312+
});
313+
314+
describe('an object the registry does not know', () => {
315+
it('the field-map doors answer nothing and the schema-free doors still judge, as at execution', async () => {
316+
// A virtual-field verdict needs the field map; with none, it is ok.
317+
expect(engine.judgeFilter('judge_unregistered', { is_open: true })).toEqual({ ok: true });
318+
// The list-comparand shape gate needs no field map.
319+
const where = (): Where => ({ status: { $in: 'open' } });
320+
const verdict = refused(engine.judgeFilter('judge_unregistered', where()));
321+
const thrown = await refusalOf(engine.find('judge_unregistered', { where: where() }));
322+
expect({ code: verdict.code, status: verdict.status }).toEqual({ code: 'INVALID_FILTER', status: 400 });
323+
expect({ code: thrown!.code, status: thrown!.status }).toEqual({ code: 'INVALID_FILTER', status: 400 });
324+
expect(verdict.message).toBe(thrown!.message);
325+
});
326+
});
327+
});

0 commit comments

Comments
 (0)