Repository navigation
Commit 4df101c
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
- .changeset
- packages
- objectql/src
- spec
- api-surface
- export-origins
- src/contracts
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
| 309 | + | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
| 313 | + | |
| 314 | + | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
| 323 | + | |
| 324 | + | |
| 325 | + | |
| 326 | + | |
| 327 | + | |
0 commit comments