Skip to content

Commit 04740b7

Browse files
refactor(objectql): move three query-lowering bodies out of engine.ts into engine-query-lowering.ts (#25522 cut 20) (#27851)
domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F <!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuPMZ8BHWtPf6nBTp51qSM64)_ Part of #25522 Clause-②: no Metadata-protocol: none Skills-docs: none — a move-only split of a private engine body; no skill or docs page names these members Ruling-ref: 6348891160 + 6441032442 ## 维护者速读 - **改了什么**:把 `engine.ts`「查询条件下沉」区里 3 个方法体(`where`/`having` 令牌解析 `resolveWhereTokens`、跨对象关系条件改写成 `$in` 的 `lowerRelationConditions`、搜索可查询字段的取得 `resolveSearchQueryableFields`)原样搬进新文件 `engine-query-lowering.ts`;类上的方法签名、注释、位置不变,方法体换成转交。与留下代码共用的 2 个顶层 helper(`resolveThenLowerWhere`、`relationSitesOf`)原地加 `export`、由新文件导入(不复制)。`engine.ts` 22,170 → 22,131 行。 - **为什么改**:#25522 按缝拆分 `engine.ts` 的第 20 刀。在最新 `main` 上把类的 268 个成员全部重新测价:大块方法体几乎都被系列限制锁住(私有静态、推断的公开返回类型、`engine.ts#X` 外部指针、写入普查、查询选项擦除基线),能整块搬、付得起 −30 行以上的只剩这一区,净 −39 行。 - **风险与回滚**:纯搬移,无行为变化。公开 `.d.ts` 六个文件里四个逐字节相同;`index.d.ts`/`.d.mts` 因两个 helper 原地 `export` 引起顶层声明重排(裁决 `6441032442` B 情形),经语句级归一化后完全相等(语句多重集、声明面摘要、导入导出名集合都相等,附阳性对照)。运行期导出名单与函数名相同,外部包加载顺序不变,晚绑定探针新旧逐行一致。回滚 = revert 本 PR。 - **先看**:`packages/objectql/src/engine-query-lowering.ts`(3 个函数与 `QueryLoweringHost`,其中用 symbol 键 getter 传安全服务槽是本系列新出现的宿主形状)、`packages/objectql/src/engine.ts` 里 3 个转交桩、新导入行与 2 处原地 `export`。 - **两分钟验证**:`pnpm --filter @objectstack/objectql exec vitest run --project local --maxWorkers=2 src/engine-nested-relation-lowering.test.ts src/relation-condition-prescription-cap.test.ts src/engine-search-queryable-fields.test.ts`,应见 3 个文件、38 个测试全部通过。 - **需要你做的**:无,席位落地。 Cut 20 of the `packages/objectql/src/engine.ts` split (#24586 ruling A, recorded in `6270097840`), under the claim `6512318712` (branch amendment `6512348857`). Three bodies of the query-lowering area move unedited into a new module, `packages/objectql/src/engine-query-lowering.ts`, behind `QueryLoweringHost`. No behaviour changes. #25522 stays open. Branch `claude/project-thread-n9w3bf` (the card thread's harness-pinned branch), cut from `origin/main` at `fec60e00`, with `origin/main` `0a4b0fb223` merged in (clean). `origin/main` has since moved to `637cfc6736`; that range touches `registry.ts`, two ADRs and `liveness/datasource.*`, none of this diff's paths, none of them names a moved body, and `git merge-tree` of this head with it is clean. ## What changes ### Why this area Re-priced on fresh `origin/main` (`engine.ts` 22,170 lines, blob `543fe0beb94c`): every one of the class's 268 members was screened for the series limits and priced by the net-line rule (body lines removed less the stub and host the move costs). What is left that is both free and large is little: | candidate area | net | why not / why | |---|---|---| | **query lowering** (`resolveWhereTokens`, `lowerRelationConditions`, `resolveSearchQueryableFields`) | **−39** | **chosen**: all three free, private, declared return types, contiguous | | write support (`staticReadonlyCreateStrip` and neighbours) | about −35 | free, but scattered over several sections, so the cut would not read as one seam | | hook dispatch remainder (`dispatchUnscopedMultiWriteHooks`) | −30 | one body; does not pay more than −30 | The large bodies are locked: private statics (`expandRelatedRecords`, `walkReferencingRelations`), an inferred public return (`registerApp`), `engine.ts#X` liveness pointers (`registerMetadataCollections`, `buildSummaryIndex`, `expandSearchOnAst`), `docs/adr` pointers (`applyFieldDefaults`, `executeWithMiddleware`, `transaction`), query-options erasure sites (`find`, `findOne`, `seedAutonumber`) and `this.update` write sites under the tenant-audit census (`recordObservedDeviation`, `retractCreationAttestation`, `recomputeSummaries`). Inside the chosen area: | member | body lines | disposition | |---|---|---| | `resolveWhereTokens` | 20 | **moves**, −13 | | `resolveRelateThenLowerWhere` | 5 | stays: pays +2 | | `lowerRelationConditions` | 27 | **moves**, −18 | | `withResolvedWhere` | 5 | stays: pays 0 | | `expandSearchOnAst` | 84 | stays: `docs/qa` and `packages/spec/liveness` name `engine.ts#expandSearchOnAst` | | `resolveSearchQueryableFields` | 14 | **moves**, −12 | | `expandSearchOnOptions` | 6 | stays: pays +1 | | `fillQueryAstDefaults` | 14 | stays: a `packages/spec/liveness` pointer names it | The three bodies price at −43 in all; the module's import line and the import-list edits in `engine.ts` bring the file to −39. The two-body variant (without `resolveSearchQueryableFields`) measured −29 to −31, so the third body was added to clear −30. ### The shape - The class keeps all three methods with name, modifiers, signature, docblock and position. A body becomes one host literal typed `ThisParameterType<typeof fn>` with exactly the `QueryLoweringHost` members it reads, plus a `.call`, marked `[#25522 cut 20]`, as cuts 8 and 10 to 19 did; the generic `lowerRelationConditions<W>` is called as `(lowerRelationConditions<W>).call(host, …)`. - Fields go as getters over late-bound closures (`_registry`, `relatedSchemaOf`); methods as late-bound closures (`resolveRelateThenLowerWhere`, `find`), so a double set on the engine after construction is still the one a call reaches. - **New in the series: a symbol-keyed host member.** `resolveSearchQueryableFields` reads the engine's security-service source through the module-level symbol `SECURITY_SERVICE_SOURCE_SLOT`. The host carries it as `get [SECURITY_SERVICE_SOURCE_SLOT]()`, typed `unknown` on `QueryLoweringHost` (the body already narrows it), and the stub reads it as `(this as unknown as Record<symbol, unknown>)[SECURITY_SERVICE_SOURCE_SLOT]`. No moved body assigns a field, so there is no setter. - **Series limits.** All three are private with declared return types, so each `.d.ts` line stays `private NAME;`. No moved body reads a private static, has a `this.FIELD.delete/insert/update(` site, calls `this.insert` / `this.update` / `this.delete`, is a query-options erasure site, carries an `engine.ts#NAME` pointer, or holds a system-context census row or a reader-test `via` site. - **Helpers, under the cut-14 seat ruling `6432376812`.** `resolveThenLowerWhere` and `relationSitesOf` are read by moved and staying bodies alike; each gains `export` in place, byte for byte otherwise, and the module imports them (and `SECURITY_SERVICE_SOURCE_SLOT`, already exported) from `./engine.js`. Nothing re-exports any of them from the package entry. - **Imports.** `assertHavingResolvedTemporalTokensInRange`, `relationFilterCapError` and the `RelationReplacement` type were read in `engine.ts` only by moved bodies, so they leave `engine.ts`'s import lists (both modules are still imported there for other names). `RELATION_FILTER_ID_CAP` becomes a type-only specifier in `engine.ts`, since its only remaining reader there is a `{@link}` in a docblock that stays. - **Load order (measured).** `engine.ts` imports the module right after `./having-filter.js`, after every module the new one imports, so no module is evaluated earlier than before. On the head bundles the `// src/...` region order equals the base's with one line added, `// src/engine-query-lowering.ts`, after `engine-validate.ts` (`index.mjs` 121 to 122 regions, `index.js` 122 to 123, `core.mjs` 110 to 111, `core.js` 111 to 112). The external `import` statements (158 in `index.mjs`, 140 in `core.mjs`) and `require(` calls are identical and in the same order at base and head. ### Net lines `engine.ts` goes from 22,170 to 22,131 lines, **net −39** (28 added, 67 removed). The module is 178 lines. The diff against `origin/main` is 273 changed lines over 2 files (+206 / −67). ### Readers of the moved lines - No `engine.ts#resolveWhereTokens|lowerRelationConditions|resolveSearchQueryableFields|resolveThenLowerWhere|relationSitesOf` pointer exists anywhere outside `packages/objectql/src` (`git grep` on `origin/main` `637cfc6736`: 0 hits), so no reader moves. - `federated-injected-column-readers.test.ts` keys name none of the moved bodies; the test passes. `check-system-context-census`: OK, register unchanged. `check-tenant-audit-census`: census JSON equal at base and head. - **Read-seam census.** `check-durability-degradation-log-level --list` at base and head: identical once line numbers are masked (167 lines). - **Decision-letter count.** `check-adr-anchors`: **1,921** decision-letter citations at base and at head. The moved bodies carry no `[ADR-NNNN Dn]` letter. ## Why #25522 splits `engine.ts` one seam at a time so that a change to one area reads, reviews and conflicts as that area alone. This is the largest free, contiguous seam left under the current series limits. Nothing about what the engine does changes. ## Tests No test is added and none is edited: the move is proved by identity, not by new behaviour. <details><summary>Public <code>.d.ts</code>: ruling <code>6441032442</code> letter B (top-level reorder), with a positive control</summary> Built `@objectstack/objectql` at base `0a4b0fb223` and at the merged head `0014e7332`. Four declaration files are raw byte-identical; `index.d.ts` and `index.d.mts` differ by 180 raw-diff lines each. Measured per variant: exporting either `resolveThenLowerWhere` or `relationSitesOf` alone moves the `AggregatedColumnClass` and `RELATION_FILTER_ID_CAP` declarations and reorders one import's specifiers, which is the case letter B allows when the statement-level normaliser closes it. | file | raw sha256 base → head | raw-diff lines | statements (only base / only head) | normalised sha256 | declared surface | specifier sets | |---|---|---|---|---|---|---| | `core.d.ts` | `61512fd9…` = | 0 | 14 / 14 (0 / 0) | `3a827f7f…` EQUAL | `0224b74e351a` (1) EQUAL | import 10, export 85 EQUAL | | `core.d.mts` | `1753655e…` = | 0 | 14 / 14 (0 / 0) | `9c348846…` EQUAL | `0224b74e351a` (1) EQUAL | import 10, export 85 EQUAL | | `index.d.ts` | `1f93ea9f…` → `17310099…` | 180 | 137 / 137 (0 / 0) | `8369d957…` EQUAL | `5ad12aeac594` (118) EQUAL | import 26, export 300 EQUAL | | `index.d.mts` | `b28038a7…` → `576cb7ad…` | 180 | 137 / 137 (0 / 0) | `aa35d3ef…` EQUAL | `5ad12aeac594` (118) EQUAL | import 26, export 300 EQUAL | | `util-BMx8ShtN.d.ts` | `75d16412…` = | 0 | 187 / 187 (0 / 0) | `17b34879…` EQUAL | `38c72db70306` (175) EQUAL | import 55, export 149 EQUAL | | `util-BMx8ShtN.d.mts` | `75d16412…` = | 0 | 187 / 187 (0 / 0) | `17b34879…` EQUAL | `38c72db70306` (175) EQUAL | import 55, export 149 EQUAL | - No chunk is renamed (`util-BMx8ShtN` at both). - **Normaliser disclosure.** The ruling's own `dtsnorm.mjs` lives in another project's files and was not available in this container, so `dtsnorm-cut20.mjs` re-implements ruling `6441032442` item 1(b) from the ruling's text (statement multiset, declared-surface digest sorted per top-level entry, import/export specifier sets). Its sha256 is `2dd168976943dd62df853a20dd2e06fc41afea76aef34cb2d4e7fb54af8fa5c0`; the positive control below shows it does see a surface change. - **Positive control.** On `66d694e25` (whose declaration files are byte-identical to the merged head's), `node scripts/ablation-replace.mjs` dropped `private` from the `lowerRelationConditions` stub and rebuilt. The chunk is renamed `util-ljRTZG5W`; its normalised sha256 reads `4ca41d40…` against `17b34879…` and its declared surface `21b34ba6d9a8` against `38c72db70306`. The restore leg put the `engine.ts` blob back to `fd1d10a8237a` (equal to `HEAD`), and the rebuild that followed compared equal to the head build. - The run-time export lists are equal: `index` 194 names and `core` 54, ESM and CJS, and a digest over every export's name and function `.name` is `c5c2f41286d9` at base and head, so no bundler same-name rename happened. - (1e) After the head build, the full `turbo run build` (116 of 116 tasks, the dependents' DTS builds included) and the `@objectstack/core` and `@objectstack/objectql` typechecks ran: exit 0 each. (P2) `packages/objectql/package.json` `exports` names `./dist/index.*` and `./dist/core.*` only; (P3) `git grep -n -E 'objectql/dist' -- scripts package.json`: 0 hits. </details> <details><summary>AST compare (move-only)</summary> A one-off script (TypeScript compiler API; leaf tokens) on base `engine.ts` against head `engine.ts` plus the module: - The three moved bodies are **SAME** (parameters, return type, `async`, body tokens, and every body line equal after one level of indent): `resolveWhereTokens` 173 tokens / 20 lines, `lowerRelationConditions` 282 / 27, `resolveSearchQueryableFields` 135 / 14. Negative controls: one changed token in a module copy reads DIFF, and a comment-only edit fails the line rule. - `class ObjectQL`: 333 of 333 members kept, in the same order; 330 byte-identical including leading trivia, and the 3 stubs keep docblock, modifiers and signature identical. - Top level: 237 of 242 base statements byte-identical. The other 5: the two import lists that lose specifiers, `resolveThenLowerWhere` and `relationSitesOf` each with `export` added and otherwise byte-identical, and the class. The head adds one statement: the module import. </details> <details><summary>Late-binding probe (base and head <code>dist</code>)</summary> `zz-probe-cut20.mjs` (a one-off, not committed) loads the built `ObjectQL` from each `dist/` and drives the three bodies with doubles set on the instance after construction: `lowerRelationConditions` on a nested relation condition, `null`, a plain condition, the 1,000-id cap (it throws `INVALID_FILTER`) and with `find` replaced a second time; `resolveWhereTokens` on `where`, with `resolveRelateThenLowerWhere` replaced (the spy sees the call), on `having`, and on an empty AST; `resolveSearchQueryableFields` with no context, with no slot, with the security-service slot set after construction, replaced, throwing, and set to a non-function. The 27 output lines are identical on base and head (the construction log lines, which carry timestamps, set aside); stderr is empty on both. </details> <details><summary>Suites</summary> Under `scripts/pm/os-verify-lock.sh`: - Full `turbo run build`: 116 of 116 tasks, exit 0. - `typecheck` of `@objectstack/objectql` and `@objectstack/core`: exit 0 each. - `@objectstack/objectql` vitest `--project local` on the merged head: 491 files, 9,252 tests passed, exit 0 (`engine-nested-relation-lowering.test.ts`, `relation-condition-prescription-cap.test.ts`, `engine-search-queryable-fields.test.ts`, `engine-nested-object-door.test.ts`, `query-expression-conformance.test.ts` and `federated-injected-column-readers.test.ts` among them). The three files in the two-minute check: 38 passed. - `test:repo` projects for `objectql` (1 of 5) and `core` (6 of 94) that read this package ran green locally; `Test Core` runs the rest in CI. </details> ## Verification The gate list is `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on `0014e7332`, each run with its exit code captured before any pipe, then reconciled with `--ran` (each line `CMD :: exit N`). - **Derived gates: 48 of 48 run, exit 0 each; `--ran`: 48 derived, 48 run, 0 NOT-MEASURED, 0 UNRUN** ("a DERIVED zero — all 48 recorded an exit code and none of them is 3"). `check:dual-build-cjs-loads` ran after the full build. - **Hand gates, exit 0 each:** `pnpm check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity`, `check:engine-double-contract`, `check:tenant-chokepoint`, `check:adr-anchors`, `check:query-options-erasure`; `@objectstack/spec` `check:api-surface`, `check:generated`, `check:liveness`; `node scripts/check-tenant-audit-census.mjs`, `check-adr-symbol-anchors.mjs`, `check-platform-checklist.mjs --changed-from origin/main`, `check-commit-card-trailers.mjs --range origin/main..HEAD`, `check-system-context-census.mjs`, `check-nul-bytes.mjs`; and eslint as `pnpm lint` runs it (`--no-inline-config`) on `packages/objectql`. ## Acceptance notes - **Serial order.** Nothing was edited before the claim `6512318712` and its amendment `6512348857` named this branch. - **Overlap.** All 13 open PRs were fetched just before opening this (`git diff --name-only origin/main...pr/N`): none touches `packages/objectql/src/engine*`. `git merge-tree` of this head with `origin/main` `637cfc6736` is clean. - **New host shape to look at.** The symbol-keyed getter for `SECURITY_SERVICE_SOURCE_SLOT` is the first host member in the series keyed by a symbol rather than a name; it is the one place this cut goes beyond cuts 8 to 19's shape. - **What stays, for the next cut.** `expandSearchOnAst` (84 body lines) and `fillQueryAstDefaults` stay for their liveness pointers; `resolveRelateThenLowerWhere`, `withResolvedWhere` and `expandSearchOnOptions` do not pay for a stub. After this cut, no free contiguous area in `engine.ts` pays more than −30 under the current series limits. - **Left as is.** A docblock in `engine.ts` still says `{@link RELATION_FILTER_ID_CAP}`; it is kept byte-identical with the specifier type-only. The moved bodies keep their indentation minus one level, as moved, not re-formatted. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (Not touched.) - [x] 2. Every name I added uses the casing for its kind (`QueryLoweringHost`, `engine-query-lowering.ts`). - [x] 3. No formatter ran; `pnpm lint` passes on the touched package. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. No publish-visible change, so `skip-changeset` (ruling `6441032442` item 4: no changeset for a reorder); both declaration lines are filled in above. - [x] 6. `packages/spec` not touched. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English, apart from the maintainer quick-read block the project asks for. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. `skip-changeset`: move-only inside `@objectstack/objectql`, with the published declarations equal under the ruling's normaliser and no behaviour change, so there is nothing for a release note to say. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L6bhgWZYhVkTy8Yp9oDD11 --- _Generated by [Claude Code](https://claude.ai/code/session_01L6bhgWZYhVkTy8Yp9oDD11)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent b13a1ed commit 04740b7

2 files changed

Lines changed: 206 additions & 67 deletions

File tree

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25522 cut 20] Three bodies of the `ObjectQL` query-lowering stage, the
5+
* head of the "Data Access Methods" section: `resolveWhereTokens` (every read
6+
* verb's filter-position stage: resolve the placeholders, then lower),
7+
* `lowerRelationConditions` (the nested-relation step, which reads the related
8+
* object as the caller) and `resolveSearchQueryableFields` (the fields a
9+
* `search` may scan for the caller), moved out of `engine.ts`.
10+
*
11+
* A MOVE, not a redesign. Each exported function below is the body of the
12+
* `ObjectQL` method of the same name as it stood, re-indented one level and
13+
* otherwise unedited. Each method's docblock stays on the method in
14+
* `engine.ts`, which is still the contract.
15+
*
16+
* ## How the moved bodies reach the class
17+
*
18+
* The shape #23050 set for `rest-server.ts`, applied as
19+
* `engine-count-aggregate.ts` applies it. The class keeps each method with
20+
* name, modifiers, signature, docblock and position; its body becomes one
21+
* literal of exactly the {@link QueryLoweringHost} members it reads, plus a
22+
* `.call`. The fields read (`_registry`, `relatedSchemaOf`) go as GETTERS, so
23+
* each read happens when the body makes it, as it did; no body assigns a
24+
* field. The methods called (`resolveRelateThenLowerWhere`, `find`) go as
25+
* late-bound closures over the instance, so a double or spy set on the engine
26+
* is still the one a call reaches.
27+
*
28+
* `resolveSearchQueryableFields` reads one more member: the engine's
29+
* {@link SECURITY_SERVICE_SOURCE_SLOT}, a symbol-keyed slot the kernel sets on
30+
* the instance and the class does not declare. It goes as a getter too, keyed
31+
* by the same symbol, so the body still reads the slot at use and never a
32+
* copy; its type is `unknown`, the type the body reads it as.
33+
*
34+
* The rest of the stage stays in the class: `resolveRelateThenLowerWhere`,
35+
* `withResolvedWhere` and `expandSearchOnOptions` are bodies no longer than
36+
* the host literal that would replace them, and `expandSearchOnAst` and
37+
* `fillQueryAstDefaults` are named as `engine.ts#…` producers in
38+
* `packages/spec/liveness/` (the first by `docs/qa/` too).
39+
*
40+
* ## Module helpers
41+
*
42+
* `resolveWhereTokens` reads `resolveThenLowerWhere` and
43+
* `lowerRelationConditions` reads `relationSitesOf`; `aggregate` and the
44+
* `where` admission judge read them too. One home each: they stay declared in
45+
* `engine.ts`, exported in place, and are imported here, never copied.
46+
* `SECURITY_SERVICE_SOURCE_SLOT` was already exported. Nothing re-exports any
47+
* of them from the package entry, so the published surface does not move.
48+
*
49+
* That import is a run-time module cycle (`engine.ts` imports this module,
50+
* this module imports `engine.ts`). Nothing here runs at module evaluation,
51+
* only declarations, and the helpers are read only when a body runs, after
52+
* both modules have evaluated. `engine.ts` imports this module after every
53+
* module this one imports, so the evaluation order of every module is what it
54+
* was. The published build bundles both files into one, where the cycle does
55+
* not exist.
56+
*/
57+
58+
import type { FilterCondition, FilterLoweringOptions, QueryAST } from '@objectstack/spec/data';
59+
import type { ExecutionContext } from '@objectstack/spec/kernel';
60+
import type { ISecurityService } from '@objectstack/spec/contracts';
61+
import { assertHavingResolvedTemporalTokensInRange } from './temporal-comparand-door.js';
62+
import { mapRelationConditions } from './number-comparand-declared-type-door.js';
63+
import {
64+
lowerRelationSite,
65+
RELATION_FILTER_ID_CAP,
66+
relationFilterCapError,
67+
type RelationReplacement,
68+
} from './relation-filter-lowering.js';
69+
import { aggregatedRowColumnClasses } from './having-filter.js';
70+
import { relationSitesOf, resolveThenLowerWhere, SECURITY_SERVICE_SOURCE_SLOT } from './engine.js';
71+
import type { ObjectQL } from './engine.js';
72+
73+
/**
74+
* The `ObjectQL` members the functions below read — every one of them, and no
75+
* other; each function's `this` is the `Pick` of the members IT reads. Each
76+
* declared member is typed AS the class member (an indexed access on the
77+
* class), so a change to a member's type on the class is a change here too,
78+
* and naming a member the class does not have is a compile error. The one
79+
* exception is the security-service slot, which the class does not declare
80+
* (module header). See the module header for which members are getters.
81+
*/
82+
export interface QueryLoweringHost {
83+
readonly _registry: ObjectQL['_registry'];
84+
readonly relatedSchemaOf: ObjectQL['relatedSchemaOf'];
85+
readonly [SECURITY_SERVICE_SOURCE_SLOT]: unknown;
86+
resolveRelateThenLowerWhere: ObjectQL['resolveRelateThenLowerWhere'];
87+
find: ObjectQL['find'];
88+
}
89+
90+
/** The body of `ObjectQL#resolveWhereTokens`; the method's docblock there is its contract. */
91+
export async function resolveWhereTokens(
92+
this: Pick<QueryLoweringHost, '_registry' | 'resolveRelateThenLowerWhere'>,
93+
ast: QueryAST | undefined,
94+
execCtx: ExecutionContext | undefined,
95+
lowering: FilterLoweringOptions,
96+
position: 'where' | 'having' = 'where',
97+
operation = 'find',
98+
): Promise<void> {
99+
if (!ast || ast[position] == null) return;
100+
// [#20157] Through the stage function the judge also calls.
101+
if (position === 'having') {
102+
// [#20844] The year range of a resolved date macro, by each aggregated
103+
// column's class — the reading the `having` temporal door takes. Built
104+
// only when something resolved.
105+
ast[position] = resolveThenLowerWhere(ast[position], execCtx, lowering, (written, resolved) => {
106+
const fields = (this._registry.getObject(ast.object) as { fields?: Record<string, unknown> } | undefined)?.fields;
107+
assertHavingResolvedTemporalTokensInRange(
108+
ast.object, written, resolved, aggregatedRowColumnClasses(ast.groupBy, ast.aggregations, fields), ast,
109+
);
110+
});
111+
return;
112+
}
113+
// [#20802] `where` serves the nested-relation form: resolve, then lower
114+
// each relation condition (a read of the related object, as the caller),
115+
// then the shared lowering — {@link resolveRelateThenLowerWhere}.
116+
ast[position] = await this.resolveRelateThenLowerWhere(
117+
ast.object, operation, ast[position], execCtx, lowering,
118+
);
119+
}
120+
121+
/** The body of `ObjectQL#lowerRelationConditions`; the method's docblock there is its contract. */
122+
export async function lowerRelationConditions<W>(
123+
this: Pick<QueryLoweringHost, '_registry' | 'relatedSchemaOf' | 'find'>,
124+
object: string,
125+
operation: string,
126+
where: W,
127+
execCtx: ExecutionContext | undefined,
128+
): Promise<W> {
129+
if (where == null) return where;
130+
const schema = this._registry.getObject(object);
131+
const sites = relationSitesOf(object, operation, where, schema, this.relatedSchemaOf);
132+
if (sites.length === 0) return where;
133+
const context = `${operation}('${object}')`;
134+
const replacements: RelationReplacement[] = [];
135+
for (const site of sites) {
136+
const rows = await this.find(site.target, {
137+
where: site.condition as FilterCondition,
138+
fields: ['id'],
139+
limit: RELATION_FILTER_ID_CAP + 1,
140+
...(execCtx ? { context: execCtx } : {}),
141+
});
142+
const matched = Array.isArray(rows) ? rows : [];
143+
if (matched.length > RELATION_FILTER_ID_CAP) {
144+
throw relationFilterCapError(site, context, RELATION_FILTER_ID_CAP);
145+
}
146+
replacements.push(lowerRelationSite(
147+
site,
148+
matched.map((row) => (row as { id?: unknown } | null)?.id).filter((id) => id !== undefined && id !== null),
149+
));
150+
}
151+
let next = 0;
152+
return mapRelationConditions(object, operation, schema, where, {
153+
schemaOf: this.relatedSchemaOf,
154+
replace: () => replacements[next++],
155+
});
156+
}
157+
158+
/** The body of `ObjectQL#resolveSearchQueryableFields`; the method's docblock there is its contract. */
159+
export async function resolveSearchQueryableFields(
160+
this: Pick<QueryLoweringHost, typeof SECURITY_SERVICE_SOURCE_SLOT>,
161+
object: string,
162+
context: ExecutionContext | undefined,
163+
): Promise<ReadonlySet<string> | undefined> {
164+
if (context === undefined) return undefined;
165+
// Read from the engine slot at use (see SECURITY_SERVICE_SOURCE_SLOT), never cached.
166+
const source = (this as unknown as Record<symbol, unknown>)[SECURITY_SERVICE_SOURCE_SLOT];
167+
if (typeof source !== 'function') return undefined;
168+
let svc: Partial<Pick<ISecurityService, 'getQueryableFields'>> | undefined;
169+
try {
170+
svc = source() ?? undefined;
171+
} catch {
172+
// The in-repo kernels throw on a name nothing registered: no service.
173+
return undefined;
174+
}
175+
if (typeof svc?.getQueryableFields !== 'function') return undefined;
176+
const queryable = await svc.getQueryableFields(object, context as any);
177+
return queryable === undefined ? undefined : new Set(queryable);
178+
}

‎packages/objectql/src/engine.ts‎

Lines changed: 28 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,6 @@ import {
6363
} from './filter-comparand-shape.js';
6464
import type { ISecurityService } from '@objectstack/spec/contracts';
6565
import {
66-
assertHavingResolvedTemporalTokensInRange,
6766
assertHavingTemporalComparandsInterpretable,
6867
assertResolvedTemporalTokensInRange,
6968
assertTemporalComparandsInterpretable,
@@ -77,10 +76,8 @@ import {
7776
} from './number-comparand-declared-type-door.js';
7877
import {
7978
lowerRelationSite,
80-
RELATION_FILTER_ID_CAP,
81-
relationFilterCapError,
79+
type RELATION_FILTER_ID_CAP,
8280
type RelationFilterSite,
83-
type RelationReplacement,
8481
} from './relation-filter-lowering.js';
8582
// Seek pagination for the walks that must read EVERY row — the autonumber seed
8683
// scan is one (#6249). Shared with `summary-backfill` rather than re-rolled:
@@ -439,6 +436,9 @@ import {
439436
assertHavingIsEvaluable,
440437
assertHavingIsFilterCondition,
441438
} from './having-filter.js';
439+
// [#25522 cut 20] The `where`-lowering bodies (`QueryLoweringHost`), imported
440+
// after every module they import, so module evaluation order is unchanged.
441+
import { lowerRelationConditions, resolveSearchQueryableFields, resolveWhereTokens } from './engine-query-lowering.js';
442442
import {
443443
auditDanglingReferences,
444444
type AuditableObject,
@@ -1414,7 +1414,7 @@ function whereResolvedTokenJudge(
14141414
*
14151415
* Returns the input by reference when nothing resolved and nothing lowered.
14161416
*/
1417-
function resolveThenLowerWhere<W>(
1417+
export function resolveThenLowerWhere<W>(
14181418
where: W,
14191419
context: Parameters<typeof filterTokenContextFrom>[0],
14201420
lowering: FilterLoweringOptions,
@@ -1564,7 +1564,7 @@ function judgeWhereAdmission(
15641564
* recorded and kept as written, so nothing is rewritten). Empty for a filter
15651565
* with none, and for a registry-less host (no field map, no relation column).
15661566
*/
1567-
function relationSitesOf(
1567+
export function relationSitesOf(
15681568
object: string,
15691569
operation: string,
15701570
where: unknown,
@@ -13224,26 +13224,13 @@ export class ObjectQL implements IObjectQLEngine {
1322413224
position: 'where' | 'having' = 'where',
1322513225
operation = 'find',
1322613226
): Promise<void> {
13227-
if (!ast || ast[position] == null) return;
13228-
// [#20157] Through the stage function the judge also calls.
13229-
if (position === 'having') {
13230-
// [#20844] The year range of a resolved date macro, by each aggregated
13231-
// column's class — the reading the `having` temporal door takes. Built
13232-
// only when something resolved.
13233-
ast[position] = resolveThenLowerWhere(ast[position], execCtx, lowering, (written, resolved) => {
13234-
const fields = (this._registry.getObject(ast.object) as { fields?: Record<string, unknown> } | undefined)?.fields;
13235-
assertHavingResolvedTemporalTokensInRange(
13236-
ast.object, written, resolved, aggregatedRowColumnClasses(ast.groupBy, ast.aggregations, fields), ast,
13237-
);
13238-
});
13239-
return;
13240-
}
13241-
// [#20802] `where` serves the nested-relation form: resolve, then lower
13242-
// each relation condition (a read of the related object, as the caller),
13243-
// then the shared lowering — {@link resolveRelateThenLowerWhere}.
13244-
ast[position] = await this.resolveRelateThenLowerWhere(
13245-
ast.object, operation, ast[position], execCtx, lowering,
13246-
);
13227+
// [#25522 cut 20] The body moved to `engine-query-lowering.ts` (see `QueryLoweringHost`).
13228+
const readRegistry = () => this._registry;
13229+
const host: ThisParameterType<typeof resolveWhereTokens> = {
13230+
get _registry() { return readRegistry(); },
13231+
resolveRelateThenLowerWhere: (...a) => this.resolveRelateThenLowerWhere(...a),
13232+
};
13233+
return resolveWhereTokens.call(host, ast, execCtx, lowering, position, operation);
1324713234
}
1324813235

1324913236
/**
@@ -13308,33 +13295,15 @@ export class ObjectQL implements IObjectQLEngine {
1330813295
where: W,
1330913296
execCtx: ExecutionContext | undefined,
1331013297
): Promise<W> {
13311-
if (where == null) return where;
13312-
const schema = this._registry.getObject(object);
13313-
const sites = relationSitesOf(object, operation, where, schema, this.relatedSchemaOf);
13314-
if (sites.length === 0) return where;
13315-
const context = `${operation}('${object}')`;
13316-
const replacements: RelationReplacement[] = [];
13317-
for (const site of sites) {
13318-
const rows = await this.find(site.target, {
13319-
where: site.condition as FilterCondition,
13320-
fields: ['id'],
13321-
limit: RELATION_FILTER_ID_CAP + 1,
13322-
...(execCtx ? { context: execCtx } : {}),
13323-
});
13324-
const matched = Array.isArray(rows) ? rows : [];
13325-
if (matched.length > RELATION_FILTER_ID_CAP) {
13326-
throw relationFilterCapError(site, context, RELATION_FILTER_ID_CAP);
13327-
}
13328-
replacements.push(lowerRelationSite(
13329-
site,
13330-
matched.map((row) => (row as { id?: unknown } | null)?.id).filter((id) => id !== undefined && id !== null),
13331-
));
13332-
}
13333-
let next = 0;
13334-
return mapRelationConditions(object, operation, schema, where, {
13335-
schemaOf: this.relatedSchemaOf,
13336-
replace: () => replacements[next++],
13337-
});
13298+
// [#25522 cut 20] The body moved to `engine-query-lowering.ts` (see `QueryLoweringHost`).
13299+
const readRegistry = () => this._registry;
13300+
const readRelatedSchemaOf = () => this.relatedSchemaOf;
13301+
const host: ThisParameterType<typeof lowerRelationConditions> = {
13302+
get _registry() { return readRegistry(); },
13303+
get relatedSchemaOf() { return readRelatedSchemaOf(); },
13304+
find: (...a) => this.find(...a),
13305+
};
13306+
return (lowerRelationConditions<W>).call(host, object, operation, where, execCtx);
1333813307
}
1333913308

1334013309
/**
@@ -13497,20 +13466,12 @@ export class ObjectQL implements IObjectQLEngine {
1349713466
object: string,
1349813467
context: ExecutionContext | undefined,
1349913468
): Promise<ReadonlySet<string> | undefined> {
13500-
if (context === undefined) return undefined;
13501-
// Read from the engine slot at use (see SECURITY_SERVICE_SOURCE_SLOT), never cached.
13502-
const source = (this as unknown as Record<symbol, unknown>)[SECURITY_SERVICE_SOURCE_SLOT];
13503-
if (typeof source !== 'function') return undefined;
13504-
let svc: Partial<Pick<ISecurityService, 'getQueryableFields'>> | undefined;
13505-
try {
13506-
svc = source() ?? undefined;
13507-
} catch {
13508-
// The in-repo kernels throw on a name nothing registered: no service.
13509-
return undefined;
13510-
}
13511-
if (typeof svc?.getQueryableFields !== 'function') return undefined;
13512-
const queryable = await svc.getQueryableFields(object, context as any);
13513-
return queryable === undefined ? undefined : new Set(queryable);
13469+
// [#25522 cut 20] The body moved to `engine-query-lowering.ts` (see `QueryLoweringHost`).
13470+
const readSecurityServiceSourceSlot = () => (this as unknown as Record<symbol, unknown>)[SECURITY_SERVICE_SOURCE_SLOT];
13471+
const host: ThisParameterType<typeof resolveSearchQueryableFields> = {
13472+
get [SECURITY_SERVICE_SOURCE_SLOT]() { return readSecurityServiceSourceSlot(); },
13473+
};
13474+
return resolveSearchQueryableFields.call(host, object, context);
1351413475
}
1351513476

1351613477
/**

0 commit comments

Comments
 (0)