Skip to content

Commit 9fa70b4

Browse files
fix(objectql)!: the in-process engine refuses an empty projection, as every published declaration does (#27161)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1X8uHuGurhTsusng7ZupVVm1)_ Fixes #27008 Clause-②: no (narrowing) Metadata-protocol: none Skills-docs: updated here — `content/docs/data-modeling/queries.mdx` (Field Selection) says an empty `fields` is refused on the data API and by `engine.find()` / `findOne()`, top level and `expand` alike; `skills/**` teaches `fields` only as a non-empty list and needs no change ## 维护者速读 - **改了什么**:引擎进程内读入口 `ObjectQL.find` / `findOne` 遇到空投影 `fields: []`(以及任意层 `expand` 条目里的 `fields: []`)时直接拒绝,答 `400 VALIDATION_ERROR`,与 REST 门对 `select: []` 的回答相同;措辞直接取自 spec 声明 `EngineQueryOptionsSchema.fields`("Name at least one field"),不另写一份。 - **为什么改**:HTTP 门与所有已发布的投影声明都已拒绝空投影,唯独进程内引擎把 `[]` 当成"不投影",插件、钩子、流程误传空列表时会悄悄拿回全部列(含 owner、组织、审计列)。一条规则:空投影被拒,永不读作全部列。 - **风险与回滚**:低。仓内非测试调用方 0 个传空投影;不传 `fields` 仍读全部列,非空投影照旧。回滚即 revert。 - **先看**:`packages/objectql/src/empty-projection-door.ts`(拒绝本体与 expand 递归);`packages/objectql/src/engine.ts` 两处入口调用;`packages/objectql/src/engine-empty-projection-refusal.test.ts`。 - **两分钟验证**:`pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2 src/engine-empty-projection-refusal.test.ts`,预期 10/10 通过;在 `main` 上同文件 6 条失败(`find`/`findOne`/系统上下文/expand 两层/REST 对等)。 - **需要你做的**:无,席位落地。 ## What changes `packages/objectql/src/empty-projection-door.ts` (new) holds `assertProjectionNamesAField`, called at the entry of `ObjectQL.find` and `ObjectQL.findOne`, right after the option-key gate and before any driver read: | Read | `main` | This head | |---|---|---| | `find(obj, { fields: [] })` | every column, owner / organization / audit included, no error | `400 VALIDATION_ERROR`, no driver read | | `findOne(obj, { where, fields: [] })` | every column | refused the same way | | `find(obj, { fields: [], context: { isSystem: true } })` | every column | refused: the rule is about the shape, not the caller | | `expand: { rel: { fields: [] } }`, at any depth | the related record's every column | refused at the entry, naming the path (`expand['rel'].fields`) | | `fields` absent, or `null` | every column | unchanged | | `fields: ['title']` | projects | unchanged | - **The declarations' words, not a copy.** The sentence's remedy is read off the published schema, `EngineQueryOptionsSchema.shape.fields.safeParse([])`, whose issue message is the spec's `PROJECTION_NAMES_NO_FIELD` ("Name at least one field"). The constant stays module-private in `data-engine.zod.ts`; no spec change. - **The doors' answer.** The error carries `status: 400` and `code: 'VALIDATION_ERROR'`, the shape `projectionFieldsValue` throws at the HTTP doors, so `mapDataError` serves both callers the same status and code. The test pins it against `findData({ query: { select: [] } })` on the same engine. - **Why `expand` is judged at the entry.** An entry's projection is forwarded to the related object's own `find`, which now refuses it, but `expandRelatedRecords` catches a sub-read failure and keeps the bare foreign key. A refusal raised only there would reach the caller as a silently unexpanded field. Judged at the parent's entry, it reaches the caller as a refusal. - **Family measured.** `count` and `aggregate` take no projection (`fields` is not in `ENGINE_COUNT_OPTION_KEYS` / `ENGINE_AGGREGATE_OPTION_KEYS`; the key gate refuses it). `DataEngineVectorFindRequestSchema` has no engine implementation (`vectorFind` is declared in `spec/contracts` only). The scoped repository's `find` / `findOne` delegate to the engine's. - `engine.ts` gains 5 lines (an import and the two calls); the body lives in its own module, in the direction of the #25522 split. ## Why Card #27008, triage `6458590093`: "the engine refuses an empty projection with the declarations' own refusal. One rule (#26811's line: 'an empty projection is refused, never read as every column')." #26817, #26909 and #26954 (PR #27007) made every published declaration of a read's projection refuse `[]`. The in-process engine never parses its options with those schemas, and its projection guards ran only when `ast.fields.length > 0`, so the empty list reached the driver and every driver served it as no projection. ## Tests New `packages/objectql/src/engine-empty-projection-refusal.test.ts`, 10 cases, on a real `ObjectQL` over a driver double that projects exactly (an empty list answers every column, as `SqlDriver` and `InMemoryDriver` do) and records each read: - Refusals, each asserting `mapDataError`'s `status` 400 and `code` `VALIDATION_ERROR`, the declaration's words in the message, and zero driver reads: `find`, `findOne`, a system-context `find`, an `expand` entry, a second-level `expand` entry. - Parity: the REST door's `findData({ select: [] })` and the engine's refusal map to the same status and code. - Controls: absent `fields` reads every column; `fields: null` reads every column; `fields: ['title']` projects on `find` and `findOne`; an `expand` with a named nested projection still expands. <details><summary>Red on main, green on this head, ablation</summary> Red leg, the new file against `origin/main` `07d18e69` engine source (before the fix commit): ``` Tests 6 failed | 4 passed (10) — every refusal case: "expected a refusal, got an answer" probe on the same base: find fields: [] → [{"id":"t1","title":"Ship","project_id":"p1","owner_id":"usr_1","organization_id":"org_1","created_at":"…"}] driver received fields: [] findOne fields: [] → the same row, every column expand { project_id: { fields: [] } } → project_id: {"id":"p1","name":"Apollo","owner_id":"usr_1","organization_id":"org_1"} REST door select: [] → 400 {"code":"VALIDATION_ERROR","error":"Query parameter 'select' names no field; got []. …"} ``` This head: `Tests 10 passed (10)`. Ablation, one leg, through `scripts/ablation-replace.mjs` (the test imports `./engine.js`, so the mutation is on the resolved source): the top-level arm of `assertProjectionNamesAField` replaced by a marker; anchor 1 → 0, blob `19c6bcd1` → `45086f3d`. `Tests 4 failed | 6 passed (10)`: the `find`, `findOne`, system-context and REST-parity cases; the two `expand` cases stay green on their own arm. Restored: blob == HEAD (`19c6bcd1`), `git diff HEAD` empty. </details> ## Acceptance notes - **Caller census, at this head.** Non-test callers across `packages`, `ui`, `examples`, `apps` and `skills` that pass `fields: []` to an engine read: 0. Computed projections handed to `find` / `findOne` (37 call sites by a call-text scan; blind to an options object built away from the call): every one is a non-empty literal or constant, or is guarded non-empty (`summaryKeyColumns.length > 0`). FLS does not rewrite `ast.fields` (it masks rows), and the two middlewares that touch `ast.fields` (approvals' reader-locale labels, audit's served fields) only add columns. One pass-through: the flow `get_record` node hands its authored `cfg.fields` to the engine unchanged (`crud-nodes.ts`), and `GetRecordConfigSchema.fields` is `z.array(z.string()).optional()`, which accepts `[]`. No in-repo flow authors it. Such a node read every column before; it now fails with the engine's refusal (`success: false`, the refusal carried by `refusalCarriedBy`). That answer is loud and deliberate, not a widening; the declaration gap is reported to the seat (see the report). - **Not changed: an all-unknown projection still reads every column in-process.** `engine.find(obj, { fields: ['nope'] })` drops the unknown plain column (the #7589 tolerance, kept by ruling) and then falls back to `*` when nothing is left: measured at this head, the driver receives no `fields` and every column comes back. The REST door refuses the same request `400 INVALID_FIELD`. This is outside the ruling here (it is not an empty projection from the caller) and touches the #7589 ruling, so it goes to the seat as an open question. - **`expand`'s nested declaration.** `EngineQueryOptionsSchema.expand` is `z.record(QuerySchema)`, and `QuerySchema.fields` (`query.zod.ts`) is `z.array(FieldNodeSchema).optional()`, which still accepts `[]`. The engine now refuses what that declaration admits. Reported to the seat as a spec follow-up rather than edited here (outside the claim's file surface). - `Clause-②: no (narrowing)`, as claimed: a shape that read before is refused; nothing is widened. `Metadata-protocol: none`: no authored metadata key is added, removed or re-shaped, and no save or publish door refuses anything new. ## Verification All at head `2e447bbd` unless a line says otherwise. <details><summary>Local runs</summary> - `pnpm --filter '@objectstack/objectql...' build`: VERDICT command-exit 0. - `pnpm --filter @objectstack/objectql build && typecheck && test` at `26ebfb48`: VERDICT command-exit 0; Test Files 477 passed (477), Tests 8898 passed (8898). The two later commits change only the new suite's driver double; that file at this head: Tests 10 passed (10). - `pnpm build` (full, at `26ebfb48`; no source file changed after it): VERDICT command-exit 0, Tasks 116 successful, 116 total. - `eslint . --no-inline-config` scoped to `packages/objectql`: exit 0. - Full dogfood (`pnpm --filter @objectstack/dogfood test`, owed by the narrowing arm): VERDICT command-exit 0; Test Files 348 passed | 3 skipped (351); Tests 2803 passed | 20 skipped (2823). </details> <details><summary>Derived gates</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 93 commands for the 5 changed paths, all run at this head. `--ran` reconciliation: "93 derived famil(ies) accounted for — 93 run, 0 NOT-MEASURED". Every one exited 0. `check-plugin-teardown-shape.mjs --self-test` ran after `git fetch --depth=1` of its pinned fixture commit; `check:type-check-debt` ran under the verify lock (VERDICT command-exit 0). The first sweep (at `26ebfb48`) had two reds, both in the new suite's driver double, fixed in `a446baef` and `2e447bbd`: `check:objectql-double-limit` (the double ignored `limit`; it now applies it after the filter) and `check:where-matcher` (an unhandled `$` key was read as a field name; the double now throws on it). Hand-run beside the derived list: `check-platform-checklist.mjs --changed-from origin/main`, `check-commit-card-trailers.mjs --range origin/main..HEAD`, `check-nul-bytes.mjs`: exit 0 each. </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 with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FEkQ5mbUpy4ebfWUHxAv87 --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5mbUpy4ebfWUHxAv87)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9b242df commit 9fa70b4

7 files changed

Lines changed: 370 additions & 14 deletions

File tree

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 an empty projection, as every published declaration of a read's projection does (#27008)
6+
7+
Clause-②: no (narrowing) — `ObjectQL.find` / `findOne` refuse `fields: []`, and an `expand` entry's `fields: []` at any depth, 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: EngineQueryOptionsSchema.fields already declares the refusal, and the engine now enforces it at its entry. 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.** The HTTP doors refuse `select: []` with `400 VALIDATION_ERROR`, and `EngineQueryOptionsSchema.fields` declares the same refusal, but `engine.find(object, { fields: [] })` and `findOne` never parsed their options with that schema. The empty list reached the driver, which serves it as no projection: every column came back, owner, organization and audit columns included, and the caller was not told. An `expand: { rel: { fields: [] } }` entry expanded every column of the related record the same way.
15+
- **Now.** Both entries refuse an empty projection before any driver read: the top-level `fields`, and every `expand` entry's `fields` at any depth. The error carries `status: 400` and `code: 'VALIDATION_ERROR'`, the answer the HTTP doors give, so `mapDataError` serves an in-process caller and a REST caller alike. Its words are the declaration's own (`Name at least one field`), read off `EngineQueryOptionsSchema.fields`. A system-context read is refused too: the rule is about the shape, not the caller.
16+
- **Unchanged.** An absent or `null` `fields` still reads every column. A non-empty projection projects as before, including the tolerance for an unknown plain column.
17+
- **Who sees it.** A plugin, hook, flow or other in-process caller that passed an empty projection, by mistake or by building one that came out empty. No in-repo caller does.
18+
19+
**The remedy.** Leave `fields` out to read every column, or name the columns to read.

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,11 @@ retired, read them with the operator-only
296296
them before `os migrate apply --allow-destructive` drops the column. No runtime door serves
297297
them.
298298

299+
`fields` names at least one column. An empty list (`fields: []`, `$select=`) is refused with
300+
`400 VALIDATION_ERROR` — on the data API, and by `engine.find()` / `findOne()` for an in-process
301+
caller (a plugin, a hook, a flow), the top-level `fields` and an `expand` entry's alike — rather
302+
than read as "every column". Leave `fields` out to read every column.
303+
299304
### Nested / Related Fields
300305

301306
{/* os:check */}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#27008] An empty projection — `fields: []` — is refused by the engine's
5+
* `find` and `findOne` with `VALIDATION_ERROR` / 400, before any driver is
6+
* asked. So is an empty projection on an `expand` entry, at any depth.
7+
*
8+
* ## One rule
9+
*
10+
* #26817, #26909 and #26954 settled it: an empty projection is refused, never
11+
* read as every column. The HTTP doors refuse `select: []`
12+
* (`projectionFieldsValue` in `@objectstack/metadata-protocol`, `400
13+
* VALIDATION_ERROR`), and every published declaration of a read's projection
14+
* refuses it too, `EngineQueryOptionsSchema.fields` included. The engine never
15+
* parses its options with that schema, and its projection guards ran only when
16+
* `ast.fields.length > 0`, so `fields: []` reached the driver. Every driver
17+
* serves an empty projection as none: a plugin, a hook through `ctx.api` or a
18+
* flow that built an empty list by mistake got owner, organization and audit
19+
* columns back, and was not told. Measured on `origin/main` `07d18e69` with
20+
* `engine.find(obj, { fields: [] })`, `findOne` and an
21+
* `expand: { rel: { fields: [] } }` entry: every column, under no error.
22+
*
23+
* ## Same refusal as the doors
24+
*
25+
* The words are the declaration's own, read off the published
26+
* `EngineQueryOptionsSchema.fields` rather than copied, and the error carries
27+
* the status and code the door's does, so `mapDataError` answers an
28+
* in-process caller and a REST caller with the same `400 VALIDATION_ERROR`.
29+
* The rule is about the shape, not the caller: a system-context read is
30+
* refused too. An absent (or `null`) `fields` still means every column.
31+
*
32+
* ## Why `expand` is judged at the entry
33+
*
34+
* An `expand` entry's projection is forwarded to the related object's own
35+
* `find`, so that sub-read would refuse it — but `expandRelatedRecords` keeps
36+
* the bare foreign key when a sub-read throws, so the refusal would reach the
37+
* caller as a silently unexpanded field. Judged here, before the parent read,
38+
* it reaches the caller as a refusal.
39+
*/
40+
41+
import { EngineQueryOptionsSchema } from '@objectstack/spec/data';
42+
43+
let declaredRefusal: string | undefined;
44+
45+
/** The declaration's own words for a projection naming no field. */
46+
function projectionNamesNoField(): string | undefined {
47+
declaredRefusal ??= EngineQueryOptionsSchema.shape.fields.safeParse([]).error?.issues[0]?.message;
48+
return declaredRefusal;
49+
}
50+
51+
function refuseEmptyProjection(object: string, operation: 'find' | 'findOne', param: string): never {
52+
const err: any = new Error(
53+
`ObjectQL.${operation}('${object}') names no field in ${param}: got []. The query was not run: `
54+
+ 'an empty projection was read as no projection, which answers every column. '
55+
+ `${projectionNamesNoField()}, or leave the projection out to read every column.`,
56+
);
57+
// The status and code `projectionFieldsValue` throws at the HTTP doors, so
58+
// a host surfacing engine errors answers the same envelope on both.
59+
err.status = 400;
60+
err.code = 'VALIDATION_ERROR';
61+
err.param = param;
62+
err.object = object;
63+
throw err;
64+
}
65+
66+
function assertExpandProjections(
67+
object: string,
68+
operation: 'find' | 'findOne',
69+
expand: unknown,
70+
prefix: string,
71+
): void {
72+
if (expand == null || typeof expand !== 'object') return;
73+
for (const [relation, nested] of Object.entries(expand)) {
74+
if (nested == null || typeof nested !== 'object') continue;
75+
const at = `${prefix}expand['${relation}']`;
76+
const fields = (nested as { fields?: unknown }).fields;
77+
if (Array.isArray(fields) && fields.length === 0) refuseEmptyProjection(object, operation, `${at}.fields`);
78+
assertExpandProjections(object, operation, (nested as { expand?: unknown }).expand, `${at}.`);
79+
}
80+
}
81+
82+
/**
83+
* Refuse a read whose projection, or any `expand` entry's projection, names
84+
* no field. Runs at the entry of `find` / `findOne`, after the option-key
85+
* gate, before any driver call.
86+
*/
87+
export function assertProjectionNamesAField(
88+
object: string,
89+
operation: 'find' | 'findOne',
90+
query: { fields?: unknown; expand?: unknown } | undefined,
91+
): void {
92+
if (!query) return;
93+
if (Array.isArray(query.fields) && query.fields.length === 0) refuseEmptyProjection(object, operation, "'fields'");
94+
assertExpandProjections(object, operation, query.expand, '');
95+
}
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#27008] The in-process engine refuses an empty projection, as every
5+
* published declaration of a read's projection does.
6+
*
7+
* #26817, #26909 and #26954 settled one rule: an empty projection is refused,
8+
* never read as every column. The HTTP doors refuse `select: []`
9+
* (`projectionFieldsValue`, `400 VALIDATION_ERROR`) and
10+
* `EngineQueryOptionsSchema.fields` declares the same refusal, but
11+
* `ObjectQL.find` / `findOne` never parse their options with that schema. Their
12+
* projection guards ran only when `ast.fields.length > 0`, so `fields: []`
13+
* reached the driver, and every driver serves an empty projection as none:
14+
* owner, organization and audit columns came back, and the caller was not told.
15+
*
16+
* Driven against a REAL {@link ObjectQL} engine over a driver double that
17+
* projects exactly (an empty list answers every column, as `SqlDriver` and
18+
* `InMemoryDriver` do) and counts its reads, so "refused before any driver
19+
* call" is measured rather than assumed.
20+
*/
21+
22+
import { describe, it, expect, beforeEach } from 'vitest';
23+
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
24+
import { EngineQueryOptionsSchema } from '@objectstack/spec/data';
25+
import { mapDataError } from '@objectstack/types';
26+
import { ObjectQL } from './engine.js';
27+
28+
const projectObject = {
29+
name: 'proj_27008',
30+
label: 'Project',
31+
fields: {
32+
id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true },
33+
name: { name: 'name', label: 'Name', type: 'text' as const },
34+
},
35+
};
36+
37+
const taskObject = {
38+
name: 'task_27008',
39+
label: 'Task',
40+
fields: {
41+
id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true },
42+
title: { name: 'title', label: 'Title', type: 'text' as const },
43+
project_id: { name: 'project_id', label: 'Project', type: 'lookup' as const, reference: 'proj_27008' },
44+
},
45+
};
46+
47+
/** The declarations' own refusal words, read off the published schema. */
48+
const declaredRefusal = (): string => {
49+
const parsed = EngineQueryOptionsSchema.shape.fields.safeParse([]);
50+
expect(parsed.success).toBe(false);
51+
return parsed.error!.issues[0].message;
52+
};
53+
54+
function makeDriver() {
55+
const rows: Record<string, Record<string, unknown>[]> = {
56+
proj_27008: [{ id: 'p1', name: 'Apollo', owner_id: 'usr_1', organization_id: 'org_1' }],
57+
task_27008: [{
58+
id: 't1', title: 'Ship', project_id: 'p1',
59+
owner_id: 'usr_1', organization_id: 'org_1', created_at: '2026-10-28T00:00:00.000Z',
60+
}],
61+
};
62+
const reads: Array<{ object: string; fields: unknown }> = [];
63+
const project = (row: Record<string, unknown>, fields: unknown) =>
64+
Array.isArray(fields) && fields.length > 0
65+
? Object.fromEntries(fields.map((f: string) => [f, row[f]]))
66+
: { ...row };
67+
const matches = (row: Record<string, unknown>, where: any): boolean => {
68+
if (!where || typeof where !== 'object') return true;
69+
for (const [k, v] of Object.entries(where)) {
70+
if (k === '$and' && Array.isArray(v)) {
71+
if (!v.every((arm) => matches(row, arm))) return false;
72+
continue;
73+
}
74+
if (k.startsWith('$')) throw new Error(`driver double does not implement the '${k}' combinator`);
75+
if (v && typeof v === 'object' && '$in' in (v as any)) {
76+
if (!(v as any).$in.map(String).includes(String(row[k]))) return false;
77+
continue;
78+
}
79+
const expected = v && typeof v === 'object' && '$eq' in (v as any) ? (v as any).$eq : v;
80+
if (row[k] !== expected) return false;
81+
}
82+
return true;
83+
};
84+
const driver: any = {
85+
name: 'memory',
86+
version: '0.0.0',
87+
supports: {},
88+
async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; },
89+
async find(object: string, ast: any) {
90+
reads.push({ object, fields: ast?.fields });
91+
const matched = (rows[object] ?? []).filter((r) => matches(r, ast?.where));
92+
const page = typeof ast?.limit === 'number' ? matched.slice(0, ast.limit) : matched;
93+
return page.map((r) => project(r, ast?.fields));
94+
},
95+
async findOne(object: string, ast: any) {
96+
reads.push({ object, fields: ast?.fields });
97+
const hit = (rows[object] ?? []).find((r) => matches(r, ast?.where));
98+
return hit ? project(hit, ast?.fields) : null;
99+
},
100+
async create(_o: string, d: any) { return d; },
101+
async update(_o: string, _id: string, d: any) { return d; },
102+
async delete() { return true; },
103+
async count(object: string) { return (rows[object] ?? []).length; },
104+
async bulkCreate(_o: string, r: any[]) { return r; },
105+
async bulkUpdate() { return []; }, async bulkDelete() {},
106+
async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; },
107+
async commit() {}, async rollback() {},
108+
};
109+
return { driver, reads };
110+
}
111+
112+
/** The refusal as the REST door would answer it: `mapDataError`'s status and code. */
113+
async function wireAnswer(read: Promise<unknown>) {
114+
const err = await read.then(
115+
() => { throw new Error('expected a refusal, got an answer'); },
116+
(e: unknown) => e,
117+
);
118+
const mapped = mapDataError(err);
119+
return { status: mapped.status, code: mapped.body.code, message: (err as Error).message };
120+
}
121+
122+
describe('[#27008] the in-process engine refuses an empty projection', () => {
123+
let engine: ObjectQL;
124+
let reads: Array<{ object: string; fields: unknown }>;
125+
126+
beforeEach(async () => {
127+
engine = new ObjectQL();
128+
const made = makeDriver();
129+
reads = made.reads;
130+
engine.registerDriver(made.driver, true);
131+
await engine.init();
132+
engine.registry.registerObject(projectObject, 'test-package');
133+
engine.registry.registerObject(taskObject, 'test-package');
134+
});
135+
136+
it('find with fields: [] is refused 400 VALIDATION_ERROR in the declarations\' words, before any driver read', async () => {
137+
const answer = await wireAnswer(engine.find('task_27008', { fields: [] }));
138+
expect(answer).toMatchObject({ status: 400, code: 'VALIDATION_ERROR' });
139+
expect(answer.message).toContain(declaredRefusal());
140+
expect(answer.message).toMatch(/^ObjectQL\.find\('task_27008'\) names no field in 'fields'/);
141+
expect(reads).toEqual([]);
142+
});
143+
144+
it('findOne with fields: [] is refused the same way, before any driver read', async () => {
145+
const answer = await wireAnswer(engine.findOne('task_27008', { where: { id: 't1' }, fields: [] }));
146+
expect(answer).toMatchObject({ status: 400, code: 'VALIDATION_ERROR' });
147+
expect(answer.message).toContain(declaredRefusal());
148+
expect(answer.message).toMatch(/^ObjectQL\.findOne\('task_27008'\)/);
149+
expect(reads).toEqual([]);
150+
});
151+
152+
it('a system-context read is refused too: the rule is about the shape, not the caller', async () => {
153+
const answer = await wireAnswer(engine.find('task_27008', { fields: [], context: { isSystem: true } }));
154+
expect(answer).toMatchObject({ status: 400, code: 'VALIDATION_ERROR' });
155+
expect(reads).toEqual([]);
156+
});
157+
158+
it('an expand sub-read with fields: [] is refused at the entry, not swallowed into a bare foreign key', async () => {
159+
// The expansion's own `catch` keeps the foreign key on a sub-read failure,
160+
// so a refusal raised only inside the sub-read would degrade silently. The
161+
// entry judges the nested projection before the parent row is read.
162+
const answer = await wireAnswer(engine.find('task_27008', {
163+
expand: { project_id: { object: 'proj_27008', fields: [] } },
164+
}));
165+
expect(answer).toMatchObject({ status: 400, code: 'VALIDATION_ERROR' });
166+
expect(answer.message).toContain(declaredRefusal());
167+
expect(answer.message).toContain("expand['project_id']");
168+
expect(reads).toEqual([]);
169+
});
170+
171+
it('a deeper expand level is judged too', async () => {
172+
const answer = await wireAnswer(engine.findOne('task_27008', {
173+
where: { id: 't1' },
174+
expand: { project_id: { object: 'proj_27008', fields: ['name'], expand: { owner: { object: 'sys_user', fields: [] } } } },
175+
}));
176+
expect(answer).toMatchObject({ status: 400, code: 'VALIDATION_ERROR' });
177+
expect(answer.message).toContain("expand['project_id'].expand['owner']");
178+
expect(reads).toEqual([]);
179+
});
180+
181+
it('the REST door answers the same status and code for the same projection', async () => {
182+
const protocol = new ObjectStackProtocolImplementation(engine);
183+
const door = await wireAnswer(protocol.findData({ object: 'task_27008', query: { select: [] } }));
184+
const inProcess = await wireAnswer(engine.find('task_27008', { fields: [] }));
185+
expect({ status: inProcess.status, code: inProcess.code }).toEqual({ status: door.status, code: door.code });
186+
expect(door).toMatchObject({ status: 400, code: 'VALIDATION_ERROR' });
187+
expect(door.message).toContain(declaredRefusal());
188+
expect(reads).toEqual([]);
189+
});
190+
191+
// ─── Controls ────────────────────────────────────────────────────
192+
193+
it('CONTROL: an absent fields still reads every column', async () => {
194+
const [row] = await engine.find('task_27008', {});
195+
expect(row).toMatchObject({ id: 't1', title: 'Ship', owner_id: 'usr_1', organization_id: 'org_1' });
196+
expect(reads).toHaveLength(1);
197+
});
198+
199+
it('CONTROL: fields: null is a withdrawal and reads every column', async () => {
200+
const [row] = await engine.find('task_27008', { fields: null as unknown as string[] });
201+
expect(row).toMatchObject({ id: 't1', title: 'Ship', owner_id: 'usr_1' });
202+
});
203+
204+
it('CONTROL: a named projection projects', async () => {
205+
const [row] = await engine.find('task_27008', { fields: ['title'] });
206+
expect(row).toEqual({ title: 'Ship' });
207+
const one = await engine.findOne('task_27008', { where: { id: 't1' }, fields: ['title'] });
208+
expect(one).toEqual({ title: 'Ship' });
209+
});
210+
211+
it('CONTROL: an expand with a named nested projection still expands', async () => {
212+
const [row] = await engine.find('task_27008', {
213+
fields: ['title', 'project_id'],
214+
expand: { project_id: { object: 'proj_27008', fields: ['name'] } },
215+
});
216+
expect(row).toEqual({ title: 'Ship', project_id: { name: 'Apollo' } });
217+
});
218+
});

‎packages/objectql/src/engine.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,7 @@ import {
303303
// each body the members it reads (`CountAggregateHost`).
304304
import { count, rejectCredentialAggregation } from './engine-count-aggregate.js';
305305
import { assertGroupByNamesNoJsonStoredField } from './group-by-structured-json-door.js';
306+
import { assertProjectionNamesAField } from './empty-projection-door.js';
306307
import { assertAggregationFieldTypesAccepted } from './aggregate-field-type-door.js';
307308
import { assertAggregatedColumnsAreDistinct, assertDateBucketTargetsAreTemporal } from './aggregate-query-shape-door.js';
308309
import { applyProtection, pluralToSingular, ExternalWriteForbiddenError } from '@objectstack/spec/shared';
@@ -13519,6 +13520,8 @@ export class ObjectQL implements IObjectQLEngine {
1351913520
// (#4371, three shipped instances in #4370).
1352013521
query = foldEngineOptionAliases(object, 'find', query, ENGINE_QUERY_SLOTS, ENGINE_WIRE_ONLY_SLOTS);
1352113522
rejectUnknownEngineOptions(object, 'find', query, ENGINE_FIND_OPTION_KEYS);
13523+
// [#27008] An empty projection is refused, never read as every column.
13524+
assertProjectionNamesAField(object, 'find', query);
1352213525
query = lowerWhereFilterArray(object, 'find', query, this._registry.getObject(object), this.relatedSchemaOf);
1352313526
this.logger.debug('Find operation starting', { object, query });
1352413527
const driver = this.getDriver(object);
@@ -13847,6 +13850,8 @@ export class ObjectQL implements IObjectQLEngine {
1384713850
// matters here too: findOne({ sort }) means "first row of THIS order".
1384813851
query = foldEngineOptionAliases(objectName, 'findOne', query, ENGINE_QUERY_SLOTS, ENGINE_WIRE_ONLY_SLOTS);
1384913852
rejectUnknownEngineOptions(objectName, 'findOne', query, ENGINE_FIND_OPTION_KEYS);
13853+
// [#27008] Same refusal as find().
13854+
assertProjectionNamesAField(objectName, 'findOne', query);
1385013855
query = lowerWhereFilterArray(objectName, 'findOne', query, this._registry.getObject(objectName), this.relatedSchemaOf);
1385113856
this.logger.debug('FindOne operation', { objectName });
1385213857
const driver = this.getDriver(objectName);

0 commit comments

Comments
 (0)