Repository navigation
Commit ee9a8d6
fix(objectql)!: refuse a projection whose every column is unknown, as an empty one is refused (#27541)
domain:server#1 · os-sales · session_01L6bhgXcN9HaZYSEUdJWEjw
<!-- claude-projects-attribution: {"github_login":"os-sales"} -->
_Requested by **Sales** · [project
thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuVpEmthvkRbuHGjC6oSQZvd)_
Fixes #27165
Clause-②: no (narrowing)
Metadata-protocol: none
Skills-docs: updated here — content/docs/data-modeling/queries.mdx (the
unknown-field Callout under Field Selection says a top-level projection
whose every entry is unknown, object-form entries included, is refused
400 VALIDATION_ERROR by engine.find() / findOne(); a mix still drops the
unknown ones), prose-only commit e354975; skills/** teaches fields only
as a list of real columns and needs no change.
## 维护者速读
- **改了什么**:引擎进程内读入口 `ObjectQL.find` / `findOne` 遇到投影里每一列都是对象上不存在的列(如
`fields: ['nope']`)时,不再回退为读全部列,而是按空投影拒绝,答 `400 VALIDATION_ERROR`,与
#27008 对 `fields: []` 的拒绝是同一个函数、同一形状,消息里点名所请求的列。
- **为什么改**:#7589 的容忍会丢掉未知列,丢光后引擎回退 `*`,于是"更窄的请求被更宽地回答"(含
owner、组织、审计列);REST 门早已拒绝同一请求。分诊裁定:过滤后为空的投影就是空投影,用 #27008 的拒绝。
- **风险与回滚**:低。仓内非测试调用方普查 0 个依赖该回退(见 Acceptance notes);已知列与未知列混合时仍保留
#7589 容忍;不传 `fields` 仍读全部列。回滚即 revert。
- **先看**:`packages/objectql/src/engine.ts` 中 `find` / `findOne`
未知列过滤处的两行;`packages/objectql/src/empty-projection-door.ts`(拒绝本体,新增命名所请求列的分支);`packages/objectql/src/engine-empty-projection-refusal.test.ts`
末尾的 `[#27165]` 段。
- **两分钟验证**:`pnpm --filter @objectstack/objectql exec vitest run
--maxWorkers=2 src/engine-empty-projection-refusal.test.ts`,预期 13/13
通过;在 `main` 上同文件 2 条失败(`find` / `findOne` 只含未知列)。
- **需要你做的**:无,席位落地。
## What changes
`find` and `findOne` drop a requested plain column the object does not
have (the #7589 tolerance). When that left nothing, both ended with
`ast.fields = filtered.length > 0 ? filtered : undefined`, so the driver
got no projection and answered every column. Now the emptied case calls
`refuseProjectionOfUnknownColumns`, a thin export over #27008's own
`refuseEmptyProjection` in `empty-projection-door.ts`. No second
refusal: same function, same `status: 400`, same `code:
'VALIDATION_ERROR'`, same declaration words (`Name at least one field`).
The only addition is that `got` names what was asked for and says why it
emptied.
| Read (registered object) | `main` | This head |
|---|---|---|
| `find(obj, { fields: ['nope'] })` | every column, no error | `400
VALIDATION_ERROR`, no driver read |
| `findOne(obj, { where, fields: ['nope'] })` | every column | refused
the same way |
| `fields: ['nope', 'title']` | `{ title }` | unchanged (#7589 tolerance
kept) |
| `fields: []` | refused (#27008) | unchanged |
| `fields` absent or `null` | every column | unchanged |
| object with no registered field map | not judged | unchanged |
`engine.ts` changes 4 lines in each of the two filter sites plus the
import. The delete-cascade region (#27391, now landed) is not touched.
Open drafts #27339 and #27477 edit `engine.ts` near its imports, and
#27477 also edits near the two filter sites, so whichever lands later
merges `main`.
## Why
Card #27165, triage `6466016076`: "a projection that is empty **after**
dropping unknown columns is an empty projection, so it is refused with
#27008's refusal (one rule: an empty projection is refused, never read
as every column). The #7589 tolerance stays for a mix of known and
unknown columns. This narrows behaviour, and the in-repo census of
callers comes first."
## Tests
- `packages/objectql/src/engine-empty-projection-refusal.test.ts`: a new
`[#27165]` block on the same real `ObjectQL` and exact-projecting driver
double as #27008's cases. It checks `find` with `['nope','nada']` and
`findOne` with `['nope']`. Each is refused with `mapDataError` status
400 and code `VALIDATION_ERROR`, the declaration's words, the requested
columns named in `got`, and zero driver reads. The control is
`['nope','title']`, which still reads `{ title }` on both verbs.
- Two existing pins of the old fallback now expect the refusal:
- `query-expression-conformance.test.ts` `[#7589 GUARD] a projection of
ONLY unknown plain columns still falls back to every field`, which said
it was pinned "so a future tighten cannot ride in … without meeting its
own ruling". Triage's answer on this card is that ruling.
- `unprojected-read-declared-fields-conformance.test.ts` `door: an
explicit projection naming ONLY a retired column`, three driver shapes.
That request is still never served the retired column: it is now
refused.
<details><summary>Red on main, green on this head, ablation</summary>
Red leg: the new block against the unfixed engine (commit `89938406`,
tests only):
```
× find with only unknown columns is refused 400 VALIDATION_ERROR, naming them, before any driver read
× findOne with only unknown columns is refused the same way, before any driver read
Tests 2 failed | 11 passed (13) — "expected a refusal, got an answer"
```
Green: `Tests 13 passed (13)`.
Ablation through `scripts/ablation-replace.mjs`, one leg per site. The
test imports `./engine.js` from source, so the mutation is on source.
Each leg puts the old line `ast.fields = filtered.length > 0 ? filtered
: undefined;` back in place of the refusal and the assignment after it:
- `find` leg: anchor 1 → 0, blob `7697ee2b` → `8beef8e9`. `Tests 1
failed | 12 passed (13)`: the `find` case. Restored: blob == HEAD
(`7697ee2b`), `git diff HEAD` empty.
- `findOne` leg: anchor 1 → 0, blob `7697ee2b` → `56a22aca`. `Tests 1
failed | 12 passed (13)`: the `findOne` case. Restored: blob == HEAD,
`git diff HEAD` empty.
</details>
<details><summary>Probe: the REST door and the engine on the same
request</summary>
```
door findData select ['nope'] → 400 INVALID_FIELD "Unknown field 'nope' on object 'task_27008'. …"
engine find fields ['nope'] → 400 VALIDATION_ERROR "ObjectQL.find('task_27008') names no field in 'fields': got ["nope"], and none of them is a field of 'task_27008' (an unknown column is dropped from a projection). …"
engine findOne fields ['nope'] → 400 VALIDATION_ERROR (same sentence, findOne)
engine find fields ['nope','title'] → [{ title: 'Ship' }]
engine find expand { project_id: { fields: ['nope'] } } → project_id: {} (sub-read asked for ['id'] only)
```
</details>
## Acceptance notes
- **Caller census (in-repo, before the code).** A call-text scan of
every `.find(` / `.findOne(` call whose arguments mention `fields`,
across non-test sources in `packages`, `examples`, `apps`, `ui` and
`skills`, found 278 calls.
- 166 name `id` in a literal list. `id` is always known, so the
projection can never empty.
- Of the other 112, every literal or constant projection names columns
its object declares, checked by hand where the name was not obvious
(`RECIPIENT_LOCALE_FIELD` = `locale` on `sys_user`, `PARENT_COLUMNS` on
the audit gate objects).
- The computed ones either prepend `id` (`dangling-reference-audit`,
`backfill-file-references`, pinyin `companion-projection`, the
plugin-security report scans, `mail-read-visibility`, sharing-rule
`findMatchingRows`) or are `IDataDriver` calls, not engine calls
(`secret-reference-union`, the `summaryKeyColumns` driver reads in
`engine.ts`).
- `ui/` and `client-react` reach the engine over REST, where the door
already refuses unknown fields.
- The better-auth adapter passes better-auth's own `select`, which names
model fields mapped to declared columns. better-auth 1.7.12 core passes
no `select` literal.
- The flow `get_record` node passes its authored `fields`. Its `findOne`
arm always adds `id` (`projectionNamingId`). Its `find` arm (limit > 1)
forwards `fields` as authored. The only in-repo flow with `fields` is
`recalc-estimate`, with `['start_date','end_date']` on `showcase_task`,
both declared.
- Verdict: no shipped in-repo caller relies on the fallback. The scan
cannot see an options object built away from the call. The objectql
suite and the dogfood battery are the runtime half of the census.
Callers outside this repo (cloud) were not censused.
- **Code, measured against the card body.** The card body says REST
answers `400 INVALID_FIELD`. The ruling chose #27008's refusal, so the
in-process answer is `400 VALIDATION_ERROR`. Both are 400, and the codes
differ. A host mapping engine errors through `mapDataError` serves both
400s. Whether the two doors should share one code for this request is
not decided here.
- **`expand` needs no new arm.** An `expand` entry's sub-read always
names `id` (#7537), which is never dropped. A nested projection of only
unknown columns therefore reads `{ id }`, strips it, and expands to
`{}`. That is not every column, and it is unchanged by this PR.
- **Non-string entries.** A projection made only of non-string entries
(the retired field-node object form) also empties under the filter, so
it is now refused rather than read as every column. A mixed one keeps
its string columns, as before.
- `Clause-②: no (narrowing)`: a shape that read before is refused, and
nothing is widened. `Metadata-protocol: none`: no authored key changes,
and no save or publish door refuses anything new. Because of the
narrowing, this PR owes a `CONTRACT_REVIEW_TIER` contract review before
the queue.
## Verification
Everything here was run at head `68ff2394`, after merging `origin/main`
`f57eddcf`, unless a line names another head.
<details><summary>Local runs</summary>
- `pnpm --filter '@objectstack/dogfood^...' build` (the objectql closure
included), then `pnpm --filter @objectstack/objectql typecheck` and
`test`, then `pnpm --filter @objectstack/dogfood test`, all in one
locked run: VERDICT command-exit 0. objectql: Test Files 483 passed
(483), Tests 9091 passed (9091). Full dogfood, owed by the narrowing
arm: Test Files 348 passed, 3 skipped (351); Tests 2806 passed, 20
skipped (2826).
- An earlier dogfood run at `a04167c7` had 4 tests time out at 5000 ms
(`packaged-flow-write-door-parity`, `rest-route-generation-tombstones`
tsc cases) while the box was loaded. Re-run alone, the same two files
passed 15/15. At this head, all of them pass in the full run above.
- `pnpm exec eslint packages/objectql --no-inline-config`: exit 0.
- `check-commit-card-trailers` (pre-push) passed on every push.
</details>
<details><summary>Derived gates</summary>
`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 63 commands for the 6 changed paths,
and every one was run at this head. `--ran` reconciliation: "63 derived
famil(ies) accounted for — 63 run, 0 NOT-MEASURED (a DERIVED zero — all
63 recorded an exit code and none of them is 3)". Every one exited 0.
Two needed a rerun:
- `check:dts-closure` first ran while this worktree's own closure build
was rewriting `trigger-schedule/dist`. Rerun after the build: 219/219
declaration files present, exit 0.
- `check:dual-build-cjs-loads` first answered PREREQUISITE NOT MET (exit
3, unbuilt packages). Rerun after `check:type-check-debt` built every
package under the lock: exit 0.
`check:type-check-debt` ran under the verify lock: VERDICT command-exit
0.
</details>
## 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.
- [x] 3. No formatter ran; `pnpm lint` passes (scoped to
`packages/objectql`; the repo-wide run is CI's).
- [x] 4. One issue, one branch off an up-to-date `main`, one pull
request.
- [x] 5. A change that publishes carries a changeset, and both
declaration lines are filled in here and in the changeset.
- [x] 6. `packages/spec` touched: its generated artifacts are
regenerated and committed. (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.
- [x] The *Before Submitting* list in CONTRIBUTING.md is done.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01L6bhgXcN9HaZYSEUdJWEjw)_
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01L6bhgXcN9HaZYSEUdJWEjw
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent ad3a640 commit ee9a8d6
7 files changed
Lines changed: 125 additions & 21 deletions
File tree
- .changeset
- content/docs/data-modeling
- packages/objectql/src
| 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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
322 | 322 | | |
323 | 323 | | |
324 | 324 | | |
| 325 | + | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
325 | 329 | | |
326 | 330 | | |
327 | 331 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
36 | 36 | | |
37 | 37 | | |
38 | 38 | | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
39 | 49 | | |
40 | 50 | | |
41 | 51 | | |
| |||
48 | 58 | | |
49 | 59 | | |
50 | 60 | | |
51 | | - | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
52 | 70 | | |
53 | | - | |
| 71 | + | |
54 | 72 | | |
55 | 73 | | |
56 | 74 | | |
| |||
93 | 111 | | |
94 | 112 | | |
95 | 113 | | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
216 | 216 | | |
217 | 217 | | |
218 | 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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
302 | 302 | | |
303 | 303 | | |
304 | 304 | | |
305 | | - | |
| 305 | + | |
306 | 306 | | |
307 | 307 | | |
308 | 308 | | |
| |||
13634 | 13634 | | |
13635 | 13635 | | |
13636 | 13636 | | |
13637 | | - | |
13638 | | - | |
13639 | | - | |
13640 | | - | |
| 13637 | + | |
| 13638 | + | |
| 13639 | + | |
| 13640 | + | |
13641 | 13641 | | |
13642 | 13642 | | |
13643 | 13643 | | |
| |||
13940 | 13940 | | |
13941 | 13941 | | |
13942 | 13942 | | |
13943 | | - | |
| 13943 | + | |
| 13944 | + | |
| 13945 | + | |
13944 | 13946 | | |
13945 | 13947 | | |
13946 | 13948 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1783 | 1783 | | |
1784 | 1784 | | |
1785 | 1785 | | |
1786 | | - | |
1787 | | - | |
1788 | | - | |
1789 | | - | |
1790 | | - | |
1791 | | - | |
1792 | | - | |
1793 | | - | |
| 1786 | + | |
| 1787 | + | |
| 1788 | + | |
| 1789 | + | |
| 1790 | + | |
| 1791 | + | |
| 1792 | + | |
| 1793 | + | |
| 1794 | + | |
| 1795 | + | |
| 1796 | + | |
1794 | 1797 | | |
1795 | 1798 | | |
1796 | 1799 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
203 | 203 | | |
204 | 204 | | |
205 | 205 | | |
206 | | - | |
207 | | - | |
208 | | - | |
209 | | - | |
210 | | - | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
211 | 211 | | |
212 | 212 | | |
213 | 213 | | |
| |||
0 commit comments