Skip to content

Commit ee9a8d6

Browse files
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
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
fix(objectql)!: the in-process engine refuses a projection whose every column is unknown, as it refuses an empty one (#27165)
6+
7+
Clause-②: no (narrowing) — `ObjectQL.find` / `findOne` refuse a `fields` list naming only columns the object does not have, which they read before as every column. Nothing is widened.
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no export or type changes: the engine already refused an empty projection, and a projection that its unknown-column tolerance empties is now judged the same way. There is nothing for a migration to rewrite and no tombstone. -->
11+
12+
**BREAKING** accept-set narrowing at the in-process read entry (`@objectstack/objectql`).
13+
14+
- **Before.** `find` / `findOne` drop a requested plain column the object does not have. When that left nothing, they fell back to every column: `engine.find(object, { fields: ['nope'] })` answered every column, owner, organization and audit columns included, and the caller was not told. The REST door refuses the same request.
15+
- **Now.** That case is an empty projection and gets the empty projection's refusal, before any driver read: `status: 400`, `code: 'VALIDATION_ERROR'`, the declaration's words (`Name at least one field`), and the message names the columns that were asked for.
16+
- **Unchanged.** A mix of known and unknown columns still projects the known ones and drops the rest. An absent or `null` `fields` still reads every column. A host whose object has no registered field map is not judged.
17+
- **Who sees it.** A plugin, hook, flow or other in-process caller whose projection names only columns the object lacks, for example a typo or a column from another object. No in-repo caller does.
18+
19+
**The remedy.** Name at least one column the object has, or leave `fields` out to read every column.

‎content/docs/data-modeling/queries.mdx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,10 @@ This object form of a field node isn't wired up for top-level `fields` projectio
322322
object's schema is registered, the engine's unknown-field filter compares each entry against
323323
the schema's field names via `String(f)`, so an object entry never matches and is silently
324324
dropped from the projection — the aliased field is simply missing from results, no error.
325+
That holds only while some other entry is known: when **every** top-level `fields` entry is
326+
unknown (object-form entries included), `engine.find()` / `findOne()` refuse the read with
327+
`400 VALIDATION_ERROR` naming the requested columns, instead of reading every column. A mix of
328+
known and unknown entries still drops the unknown ones and projects the rest.
325329

326330
Dotted relationship paths (`'owner.name'`) fare no better: the unknown-field filter validates
327331
only the **head** segment and keeps the path, but there is no populate step anywhere in the

‎packages/objectql/src/empty-projection-door.ts‎

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,16 @@
3636
* the bare foreign key when a sub-read throws, so the refusal would reach the
3737
* caller as a silently unexpanded field. Judged here, before the parent read,
3838
* it reaches the caller as a refusal.
39+
*
40+
* ## A projection emptied by the unknown-column tolerance
41+
*
42+
* [#27165] `find` / `findOne` drop a requested plain column the object does not
43+
* have (the #7589 tolerance). When that leaves nothing, the projection is
44+
* empty, and it gets this same refusal, naming what was asked for. Before, the
45+
* engine fell back to every column there, so `fields: ['nope']` answered wider
46+
* than any named projection. A mix of known and unknown columns keeps the
47+
* tolerance. An `expand` entry needs no judgement of its own here: its
48+
* sub-read always names `id`, which is never dropped.
3949
*/
4050

4151
import { EngineQueryOptionsSchema } from '@objectstack/spec/data';
@@ -48,9 +58,17 @@ function projectionNamesNoField(): string | undefined {
4858
return declaredRefusal;
4959
}
5060

51-
function refuseEmptyProjection(object: string, operation: 'find' | 'findOne', param: string): never {
61+
function refuseEmptyProjection(
62+
object: string,
63+
operation: 'find' | 'findOne',
64+
param: string,
65+
requested: readonly unknown[] = [],
66+
): never {
67+
const dropped = requested.length > 0
68+
? `, and none of them is a field of '${object}' (an unknown column is dropped from a projection)`
69+
: '';
5270
const err: any = new Error(
53-
`ObjectQL.${operation}('${object}') names no field in ${param}: got []. The query was not run: `
71+
`ObjectQL.${operation}('${object}') names no field in ${param}: got ${JSON.stringify(requested)}${dropped}. The query was not run: `
5472
+ 'an empty projection was read as no projection, which answers every column. '
5573
+ `${projectionNamesNoField()}, or leave the projection out to read every column.`,
5674
);
@@ -93,3 +111,16 @@ export function assertProjectionNamesAField(
93111
if (Array.isArray(query.fields) && query.fields.length === 0) refuseEmptyProjection(object, operation, "'fields'");
94112
assertExpandProjections(object, operation, query.expand, '');
95113
}
114+
115+
/**
116+
* [#27165] Refuse a projection the unknown-column tolerance emptied: every
117+
* entry of `requested` named a column `object` does not have. Same refusal as
118+
* `fields: []`, naming what was asked for.
119+
*/
120+
export function refuseProjectionOfUnknownColumns(
121+
object: string,
122+
operation: 'find' | 'findOne',
123+
requested: readonly unknown[],
124+
): never {
125+
return refuseEmptyProjection(object, operation, "'fields'", requested);
126+
}

‎packages/objectql/src/engine-empty-projection-refusal.test.ts‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,3 +216,48 @@ describe('[#27008] the in-process engine refuses an empty projection', () => {
216216
expect(row).toEqual({ title: 'Ship', project_id: { name: 'Apollo' } });
217217
});
218218
});
219+
220+
/**
221+
* [#27165] A projection that is empty AFTER the unknown-plain-column tolerance
222+
* dropped every entry is an empty projection, and is refused with the same
223+
* refusal. Before, `find(obj, { fields: ['nope'] })` dropped `nope` (the #7589
224+
* tolerance) and then fell back to every column: a narrower request answered
225+
* wider. The tolerance itself stays for a mix of known and unknown columns.
226+
*/
227+
describe('[#27165] a projection whose every column is unknown is an empty projection', () => {
228+
let engine: ObjectQL;
229+
let reads: Array<{ object: string; fields: unknown }>;
230+
231+
beforeEach(async () => {
232+
engine = new ObjectQL();
233+
const made = makeDriver();
234+
reads = made.reads;
235+
engine.registerDriver(made.driver, true);
236+
await engine.init();
237+
engine.registry.registerObject(projectObject, 'test-package');
238+
engine.registry.registerObject(taskObject, 'test-package');
239+
});
240+
241+
it('find with only unknown columns is refused 400 VALIDATION_ERROR, naming them, before any driver read', async () => {
242+
const answer = await wireAnswer(engine.find('task_27008', { fields: ['nope', 'nada'] }));
243+
expect(answer).toMatchObject({ status: 400, code: 'VALIDATION_ERROR' });
244+
expect(answer.message).toContain(declaredRefusal());
245+
expect(answer.message).toMatch(/^ObjectQL\.find\('task_27008'\) names no field in 'fields': got \["nope","nada"\]/);
246+
expect(reads).toEqual([]);
247+
});
248+
249+
it('findOne with only unknown columns is refused the same way, before any driver read', async () => {
250+
const answer = await wireAnswer(engine.findOne('task_27008', { where: { id: 't1' }, fields: ['nope'] }));
251+
expect(answer).toMatchObject({ status: 400, code: 'VALIDATION_ERROR' });
252+
expect(answer.message).toContain(declaredRefusal());
253+
expect(answer.message).toMatch(/^ObjectQL\.findOne\('task_27008'\) names no field in 'fields': got \["nope"\]/);
254+
expect(reads).toEqual([]);
255+
});
256+
257+
it('CONTROL: a mix of known and unknown columns keeps the tolerance and projects the known ones', async () => {
258+
const [row] = await engine.find('task_27008', { fields: ['nope', 'title'] });
259+
expect(row).toEqual({ title: 'Ship' });
260+
const one = await engine.findOne('task_27008', { where: { id: 't1' }, fields: ['nope', 'title'] });
261+
expect(one).toEqual({ title: 'Ship' });
262+
});
263+
});

‎packages/objectql/src/engine.ts‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -302,7 +302,7 @@ import {
302302
// each body the members it reads (`CountAggregateHost`).
303303
import { count, rejectCredentialAggregation } from './engine-count-aggregate.js';
304304
import { assertGroupByNamesNoJsonStoredField } from './group-by-structured-json-door.js';
305-
import { assertProjectionNamesAField } from './empty-projection-door.js';
305+
import { assertProjectionNamesAField, refuseProjectionOfUnknownColumns } from './empty-projection-door.js';
306306
import { assertAggregationFieldTypesAccepted } from './aggregate-field-type-door.js';
307307
import { assertRecordCurrencyAggregatesGrouped } from './record-currency-aggregate-door.js';
308308
import { assertRecordCurrencyCodes, touchesRecordCurrency } from './validation/record-currency-code.js';
@@ -13634,10 +13634,10 @@ export class ObjectQL implements IObjectQLEngine {
1363413634
// Whole names, no head-splitting: only plain entries reach here (the
1363513635
// dotted refusal above fired on anything carrying a '.').
1363613636
const filtered = ast.fields.filter(f => known.has(f));
13637-
// Guard against an empty projection — fall back to `*` so the
13638-
// request still returns rows. An empty SELECT list would either
13639-
// 400 in Postgres or silently project nothing.
13640-
ast.fields = filtered.length > 0 ? filtered : undefined;
13637+
// [#27165] Nothing left is an empty projection, refused as `fields: []`
13638+
// is — never read as every column.
13639+
if (filtered.length === 0) refuseProjectionOfUnknownColumns(object, 'find', ast.fields);
13640+
ast.fields = filtered;
1364113641
}
1364213642

1364313643
const opCtx: OperationContext = {
@@ -13940,7 +13940,9 @@ export class ObjectQL implements IObjectQLEngine {
1394013940
// in schema.fields (matches `find()` behavior, one list).
1394113941
const known = new Set<string>([...Object.keys(_findOneSchema.fields), ...PLATFORM_PROVISIONED_COLUMNS]);
1394213942
const filtered = ast.fields.filter(f => known.has(f));
13943-
ast.fields = filtered.length > 0 ? filtered : undefined;
13943+
// [#27165] Same refusal as `find`.
13944+
if (filtered.length === 0) refuseProjectionOfUnknownColumns(objectName, 'findOne', ast.fields);
13945+
ast.fields = filtered;
1394413946
}
1394513947

1394613948
const opCtx: OperationContext = {

‎packages/objectql/src/query-expression-conformance.test.ts‎

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1783,14 +1783,17 @@ describe('#4226 — sort / select / expand on the list path (real ObjectQL engin
17831783
for (const r of rows) expect(Object.keys(r).sort()).toEqual(['title']);
17841784
});
17851785

1786-
it('[#7589 GUARD] a projection of ONLY unknown plain columns still falls back to every field', async () => {
1787-
// The `SELECT *` fallback itself, pinned as KEPT for the plain case:
1788-
// this is the documented tolerance the ruling preserves, not a defect
1789-
// this card missed. (The DOTTED route into this same fallback is what
1790-
// was closed above.)
1791-
const rows: any[] = await engine.find('showcase_task', { fields: ['no_such_field'] });
1792-
expect(rows).toHaveLength(5);
1793-
expect(Object.keys(rows[0])).toEqual(expect.arrayContaining(['id', 'title', 'status']));
1786+
it('[#27165] a projection of ONLY unknown plain columns is an empty projection, refused', async () => {
1787+
// The `SELECT *` fallback this guard pinned as KEPT is closed by its
1788+
// own ruling (#27165 triage): a projection the tolerance empties is an
1789+
// empty projection, refused as `fields: []` is (#27008), never read as
1790+
// every column. The tolerance itself stays for a mixed projection
1791+
// (the guard above).
1792+
const err: any = await engine
1793+
.find('showcase_task', { fields: ['no_such_field'] })
1794+
.then(() => null, (e: unknown) => e);
1795+
expect(err).toMatchObject({ status: 400, code: 'VALIDATION_ERROR' });
1796+
expect(err.message).toMatch(/names no field in 'fields': got \["no_such_field"\]/);
17941797
});
17951798

17961799
it('[#7589 GUARD] `engine.findOne` keeps the same plain tolerance', async () => {

‎packages/objectql/src/unprojected-read-declared-fields-conformance.test.ts‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -203,11 +203,11 @@ describe.each<Behaviour>(['whole row', 'projection', 'ladder'])(
203203
});
204204

205205
it('door: an explicit projection naming ONLY a retired column', async () => {
206-
// The engine's unknown-plain tolerance drops the name and reads the
207-
// default projection; it must not read every column.
208-
const rows = await h.engine.find(CONTACT, { fields: ['mailing_street'] });
209-
expect(rows[0]).toMatchObject({ id: 'con_1', name: 'Ada' });
210-
expectNoRetiredColumn(rows);
206+
// The engine's unknown-plain tolerance drops the name, which leaves an
207+
// empty projection: [#27165] refused, as `fields: []` is, so the
208+
// retired column is never read and no row is served.
209+
const err: any = await h.engine.find(CONTACT, { fields: ['mailing_street'] }).then(() => null, (e: unknown) => e);
210+
expect(err).toMatchObject({ status: 400, code: 'VALIDATION_ERROR' });
211211
});
212212

213213
it('door: expand — the related record is the related object\'s declared fields', async () => {

0 commit comments

Comments
 (0)