From 5a21e5887286276ec2d43a6a384f72794bf59fab Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 07:29:50 +0000 Subject: [PATCH 1/5] fix(objectql): the engine refuses an empty projection, as every published declaration does ObjectQL.find / findOne read fields: [] as every column, and an expand entry's fields: [] the same way, while the HTTP doors and EngineQueryOptionsSchema.fields refuse it. The entry now refuses it with the declaration's own words and the doors' 400 VALIDATION_ERROR, before any driver read. An absent fields still reads every column. Claude-Session: https://claude.ai/code/session_01FEkQ5mbUpy4ebfWUHxAv87 Co-authored-by: Claude --- .../objectql/src/empty-projection-door.ts | 95 ++++++++ .../engine-empty-projection-refusal.test.ts | 215 ++++++++++++++++++ packages/objectql/src/engine.ts | 5 + 3 files changed, 315 insertions(+) create mode 100644 packages/objectql/src/empty-projection-door.ts create mode 100644 packages/objectql/src/engine-empty-projection-refusal.test.ts diff --git a/packages/objectql/src/empty-projection-door.ts b/packages/objectql/src/empty-projection-door.ts new file mode 100644 index 00000000000..19c6bcd1669 --- /dev/null +++ b/packages/objectql/src/empty-projection-door.ts @@ -0,0 +1,95 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#27008] An empty projection — `fields: []` — is refused by the engine's + * `find` and `findOne` with `VALIDATION_ERROR` / 400, before any driver is + * asked. So is an empty projection on an `expand` entry, at any depth. + * + * ## One rule + * + * #26817, #26909 and #26954 settled it: an empty projection is refused, never + * read as every column. The HTTP doors refuse `select: []` + * (`projectionFieldsValue` in `@objectstack/metadata-protocol`, `400 + * VALIDATION_ERROR`), and every published declaration of a read's projection + * refuses it too, `EngineQueryOptionsSchema.fields` included. The engine never + * parses its options with that schema, and its projection guards ran only when + * `ast.fields.length > 0`, so `fields: []` reached the driver. Every driver + * serves an empty projection as none: a plugin, a hook through `ctx.api` or a + * flow that built an empty list by mistake got owner, organization and audit + * columns back, and was not told. Measured on `origin/main` `07d18e69` with + * `engine.find(obj, { fields: [] })`, `findOne` and an + * `expand: { rel: { fields: [] } }` entry: every column, under no error. + * + * ## Same refusal as the doors + * + * The words are the declaration's own, read off the published + * `EngineQueryOptionsSchema.fields` rather than copied, and the error carries + * the status and code the door's does, so `mapDataError` answers an + * in-process caller and a REST caller with the same `400 VALIDATION_ERROR`. + * The rule is about the shape, not the caller: a system-context read is + * refused too. An absent (or `null`) `fields` still means every column. + * + * ## Why `expand` is judged at the entry + * + * An `expand` entry's projection is forwarded to the related object's own + * `find`, so that sub-read would refuse it — but `expandRelatedRecords` keeps + * the bare foreign key when a sub-read throws, so the refusal would reach the + * caller as a silently unexpanded field. Judged here, before the parent read, + * it reaches the caller as a refusal. + */ + +import { EngineQueryOptionsSchema } from '@objectstack/spec/data'; + +let declaredRefusal: string | undefined; + +/** The declaration's own words for a projection naming no field. */ +function projectionNamesNoField(): string | undefined { + declaredRefusal ??= EngineQueryOptionsSchema.shape.fields.safeParse([]).error?.issues[0]?.message; + return declaredRefusal; +} + +function refuseEmptyProjection(object: string, operation: 'find' | 'findOne', param: string): never { + const err: any = new Error( + `ObjectQL.${operation}('${object}') names no field in ${param}: got []. The query was not run: ` + + 'an empty projection was read as no projection, which answers every column. ' + + `${projectionNamesNoField()}, or leave the projection out to read every column.`, + ); + // The status and code `projectionFieldsValue` throws at the HTTP doors, so + // a host surfacing engine errors answers the same envelope on both. + err.status = 400; + err.code = 'VALIDATION_ERROR'; + err.param = param; + err.object = object; + throw err; +} + +function assertExpandProjections( + object: string, + operation: 'find' | 'findOne', + expand: unknown, + prefix: string, +): void { + if (expand == null || typeof expand !== 'object') return; + for (const [relation, nested] of Object.entries(expand)) { + if (nested == null || typeof nested !== 'object') continue; + const at = `${prefix}expand['${relation}']`; + const fields = (nested as { fields?: unknown }).fields; + if (Array.isArray(fields) && fields.length === 0) refuseEmptyProjection(object, operation, `${at}.fields`); + assertExpandProjections(object, operation, (nested as { expand?: unknown }).expand, `${at}.`); + } +} + +/** + * Refuse a read whose projection, or any `expand` entry's projection, names + * no field. Runs at the entry of `find` / `findOne`, after the option-key + * gate, before any driver call. + */ +export function assertProjectionNamesAField( + object: string, + operation: 'find' | 'findOne', + query: { fields?: unknown; expand?: unknown } | undefined, +): void { + if (!query) return; + if (Array.isArray(query.fields) && query.fields.length === 0) refuseEmptyProjection(object, operation, "'fields'"); + assertExpandProjections(object, operation, query.expand, ''); +} diff --git a/packages/objectql/src/engine-empty-projection-refusal.test.ts b/packages/objectql/src/engine-empty-projection-refusal.test.ts new file mode 100644 index 00000000000..72966e41907 --- /dev/null +++ b/packages/objectql/src/engine-empty-projection-refusal.test.ts @@ -0,0 +1,215 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#27008] The in-process engine refuses an empty projection, as every + * published declaration of a read's projection does. + * + * #26817, #26909 and #26954 settled one rule: an empty projection is refused, + * never read as every column. The HTTP doors refuse `select: []` + * (`projectionFieldsValue`, `400 VALIDATION_ERROR`) and + * `EngineQueryOptionsSchema.fields` declares the same refusal, but + * `ObjectQL.find` / `findOne` never parse their options with that schema. Their + * projection guards ran only when `ast.fields.length > 0`, so `fields: []` + * reached the driver, and every driver serves an empty projection as none: + * owner, organization and audit columns came back, and the caller was not told. + * + * Driven against a REAL {@link ObjectQL} engine over a driver double that + * projects exactly (an empty list answers every column, as `SqlDriver` and + * `InMemoryDriver` do) and counts its reads, so "refused before any driver + * call" is measured rather than assumed. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { EngineQueryOptionsSchema } from '@objectstack/spec/data'; +import { mapDataError } from '@objectstack/types'; +import { ObjectQL } from './engine.js'; + +const projectObject = { + name: 'proj_27008', + label: 'Project', + fields: { + id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true }, + name: { name: 'name', label: 'Name', type: 'text' as const }, + }, +}; + +const taskObject = { + name: 'task_27008', + label: 'Task', + fields: { + id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true }, + title: { name: 'title', label: 'Title', type: 'text' as const }, + project_id: { name: 'project_id', label: 'Project', type: 'lookup' as const, reference: 'proj_27008' }, + }, +}; + +/** The declarations' own refusal words, read off the published schema. */ +const declaredRefusal = (): string => { + const parsed = EngineQueryOptionsSchema.shape.fields.safeParse([]); + expect(parsed.success).toBe(false); + return parsed.error!.issues[0].message; +}; + +function makeDriver() { + const rows: Record[]> = { + proj_27008: [{ id: 'p1', name: 'Apollo', owner_id: 'usr_1', organization_id: 'org_1' }], + task_27008: [{ + id: 't1', title: 'Ship', project_id: 'p1', + owner_id: 'usr_1', organization_id: 'org_1', created_at: '2026-10-28T00:00:00.000Z', + }], + }; + const reads: Array<{ object: string; fields: unknown }> = []; + const project = (row: Record, fields: unknown) => + Array.isArray(fields) && fields.length > 0 + ? Object.fromEntries(fields.map((f: string) => [f, row[f]])) + : { ...row }; + const matches = (row: Record, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + for (const [k, v] of Object.entries(where)) { + if (k === '$and' && Array.isArray(v)) { + if (!v.every((arm) => matches(row, arm))) return false; + continue; + } + if (v && typeof v === 'object' && '$in' in (v as any)) { + if (!(v as any).$in.map(String).includes(String(row[k]))) return false; + continue; + } + const expected = v && typeof v === 'object' && '$eq' in (v as any) ? (v as any).$eq : v; + if (row[k] !== expected) return false; + } + return true; + }; + const driver: any = { + name: 'memory', + version: '0.0.0', + supports: {}, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, + async find(object: string, ast: any) { + reads.push({ object, fields: ast?.fields }); + return (rows[object] ?? []).filter((r) => matches(r, ast?.where)).map((r) => project(r, ast?.fields)); + }, + async findOne(object: string, ast: any) { + reads.push({ object, fields: ast?.fields }); + const hit = (rows[object] ?? []).find((r) => matches(r, ast?.where)); + return hit ? project(hit, ast?.fields) : null; + }, + async create(_o: string, d: any) { return d; }, + async update(_o: string, _id: string, d: any) { return d; }, + async delete() { return true; }, + async count(object: string) { return (rows[object] ?? []).length; }, + async bulkCreate(_o: string, r: any[]) { return r; }, + async bulkUpdate() { return []; }, async bulkDelete() {}, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return { driver, reads }; +} + +/** The refusal as the REST door would answer it: `mapDataError`'s status and code. */ +async function wireAnswer(read: Promise) { + const err = await read.then( + () => { throw new Error('expected a refusal, got an answer'); }, + (e: unknown) => e, + ); + const mapped = mapDataError(err); + return { status: mapped.status, code: mapped.body.code, message: (err as Error).message }; +} + +describe('[#27008] the in-process engine refuses an empty projection', () => { + let engine: ObjectQL; + let reads: Array<{ object: string; fields: unknown }>; + + beforeEach(async () => { + engine = new ObjectQL(); + const made = makeDriver(); + reads = made.reads; + engine.registerDriver(made.driver, true); + await engine.init(); + engine.registry.registerObject(projectObject, 'test-package'); + engine.registry.registerObject(taskObject, 'test-package'); + }); + + it('find with fields: [] is refused 400 VALIDATION_ERROR in the declarations\' words, before any driver read', async () => { + const answer = await wireAnswer(engine.find('task_27008', { fields: [] })); + expect(answer).toMatchObject({ status: 400, code: 'VALIDATION_ERROR' }); + expect(answer.message).toContain(declaredRefusal()); + expect(answer.message).toMatch(/^ObjectQL\.find\('task_27008'\) names no field in 'fields'/); + expect(reads).toEqual([]); + }); + + it('findOne with fields: [] is refused the same way, before any driver read', async () => { + const answer = await wireAnswer(engine.findOne('task_27008', { where: { id: 't1' }, fields: [] })); + expect(answer).toMatchObject({ status: 400, code: 'VALIDATION_ERROR' }); + expect(answer.message).toContain(declaredRefusal()); + expect(answer.message).toMatch(/^ObjectQL\.findOne\('task_27008'\)/); + expect(reads).toEqual([]); + }); + + it('a system-context read is refused too: the rule is about the shape, not the caller', async () => { + const answer = await wireAnswer(engine.find('task_27008', { fields: [], context: { isSystem: true } })); + expect(answer).toMatchObject({ status: 400, code: 'VALIDATION_ERROR' }); + expect(reads).toEqual([]); + }); + + it('an expand sub-read with fields: [] is refused at the entry, not swallowed into a bare foreign key', async () => { + // The expansion's own `catch` keeps the foreign key on a sub-read failure, + // so a refusal raised only inside the sub-read would degrade silently. The + // entry judges the nested projection before the parent row is read. + const answer = await wireAnswer(engine.find('task_27008', { + expand: { project_id: { object: 'proj_27008', fields: [] } }, + })); + expect(answer).toMatchObject({ status: 400, code: 'VALIDATION_ERROR' }); + expect(answer.message).toContain(declaredRefusal()); + expect(answer.message).toContain("expand['project_id']"); + expect(reads).toEqual([]); + }); + + it('a deeper expand level is judged too', async () => { + const answer = await wireAnswer(engine.findOne('task_27008', { + where: { id: 't1' }, + expand: { project_id: { object: 'proj_27008', fields: ['name'], expand: { owner: { object: 'sys_user', fields: [] } } } }, + })); + expect(answer).toMatchObject({ status: 400, code: 'VALIDATION_ERROR' }); + expect(answer.message).toContain("expand['project_id'].expand['owner']"); + expect(reads).toEqual([]); + }); + + it('the REST door answers the same status and code for the same projection', async () => { + const protocol = new ObjectStackProtocolImplementation(engine); + const door = await wireAnswer(protocol.findData({ object: 'task_27008', query: { select: [] } })); + const inProcess = await wireAnswer(engine.find('task_27008', { fields: [] })); + expect({ status: inProcess.status, code: inProcess.code }).toEqual({ status: door.status, code: door.code }); + expect(door).toMatchObject({ status: 400, code: 'VALIDATION_ERROR' }); + expect(door.message).toContain(declaredRefusal()); + expect(reads).toEqual([]); + }); + + // ─── Controls ──────────────────────────────────────────────────── + + it('CONTROL: an absent fields still reads every column', async () => { + const [row] = await engine.find('task_27008', {}); + expect(row).toMatchObject({ id: 't1', title: 'Ship', owner_id: 'usr_1', organization_id: 'org_1' }); + expect(reads).toHaveLength(1); + }); + + it('CONTROL: fields: null is a withdrawal and reads every column', async () => { + const [row] = await engine.find('task_27008', { fields: null as unknown as string[] }); + expect(row).toMatchObject({ id: 't1', title: 'Ship', owner_id: 'usr_1' }); + }); + + it('CONTROL: a named projection projects', async () => { + const [row] = await engine.find('task_27008', { fields: ['title'] }); + expect(row).toEqual({ title: 'Ship' }); + const one = await engine.findOne('task_27008', { where: { id: 't1' }, fields: ['title'] }); + expect(one).toEqual({ title: 'Ship' }); + }); + + it('CONTROL: an expand with a named nested projection still expands', async () => { + const [row] = await engine.find('task_27008', { + fields: ['title', 'project_id'], + expand: { project_id: { object: 'proj_27008', fields: ['name'] } }, + }); + expect(row).toEqual({ title: 'Ship', project_id: { name: 'Apollo' } }); + }); +}); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 63a975f78aa..3dcaaed4af7 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -303,6 +303,7 @@ import { // each body the members it reads (`CountAggregateHost`). import { count, rejectCredentialAggregation } from './engine-count-aggregate.js'; import { assertGroupByNamesNoJsonStoredField } from './group-by-structured-json-door.js'; +import { assertProjectionNamesAField } from './empty-projection-door.js'; import { assertAggregationFieldTypesAccepted } from './aggregate-field-type-door.js'; import { assertAggregatedColumnsAreDistinct, assertDateBucketTargetsAreTemporal } from './aggregate-query-shape-door.js'; import { applyProtection, pluralToSingular, ExternalWriteForbiddenError } from '@objectstack/spec/shared'; @@ -13519,6 +13520,8 @@ export class ObjectQL implements IObjectQLEngine { // (#4371, three shipped instances in #4370). query = foldEngineOptionAliases(object, 'find', query, ENGINE_QUERY_SLOTS, ENGINE_WIRE_ONLY_SLOTS); rejectUnknownEngineOptions(object, 'find', query, ENGINE_FIND_OPTION_KEYS); + // [#27008] An empty projection is refused, never read as every column. + assertProjectionNamesAField(object, 'find', query); query = lowerWhereFilterArray(object, 'find', query, this._registry.getObject(object), this.relatedSchemaOf); this.logger.debug('Find operation starting', { object, query }); const driver = this.getDriver(object); @@ -13847,6 +13850,8 @@ export class ObjectQL implements IObjectQLEngine { // matters here too: findOne({ sort }) means "first row of THIS order". query = foldEngineOptionAliases(objectName, 'findOne', query, ENGINE_QUERY_SLOTS, ENGINE_WIRE_ONLY_SLOTS); rejectUnknownEngineOptions(objectName, 'findOne', query, ENGINE_FIND_OPTION_KEYS); + // [#27008] Same refusal as find(). + assertProjectionNamesAField(objectName, 'findOne', query); query = lowerWhereFilterArray(objectName, 'findOne', query, this._registry.getObject(objectName), this.relatedSchemaOf); this.logger.debug('FindOne operation', { objectName }); const driver = this.getDriver(objectName); From 26ebfb4833a9cafe886c7b258a2d1481a90996b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 07:32:39 +0000 Subject: [PATCH 2/5] docs(objectql): changeset and the queries page for the empty-projection refusal Claude-Session: https://claude.ai/code/session_01FEkQ5mbUpy4ebfWUHxAv87 Co-authored-by: Claude --- .../27008-engine-empty-projection-refused.md | 19 +++++++++++++++++++ content/docs/data-modeling/queries.mdx | 5 +++++ 2 files changed, 24 insertions(+) create mode 100644 .changeset/27008-engine-empty-projection-refused.md diff --git a/.changeset/27008-engine-empty-projection-refused.md b/.changeset/27008-engine-empty-projection-refused.md new file mode 100644 index 00000000000..7ca315f5643 --- /dev/null +++ b/.changeset/27008-engine-empty-projection-refused.md @@ -0,0 +1,19 @@ +--- +'@objectstack/objectql': patch +--- + +fix(objectql)!: the in-process engine refuses an empty projection, as every published declaration of a read's projection does (#27008) + +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. +Metadata-protocol: none + + + +**BREAKING** accept-set narrowing at the in-process read entry (`@objectstack/objectql`). + +- **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. +- **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. +- **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. +- **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. + +**The remedy.** Leave `fields` out to read every column, or name the columns to read. diff --git a/content/docs/data-modeling/queries.mdx b/content/docs/data-modeling/queries.mdx index 18aaa71a68c..367cbf3b1be 100644 --- a/content/docs/data-modeling/queries.mdx +++ b/content/docs/data-modeling/queries.mdx @@ -296,6 +296,11 @@ retired, read them with the operator-only them before `os migrate apply --allow-destructive` drops the column. No runtime door serves them. +`fields` names at least one column. An empty list (`fields: []`, `$select=`) is refused with +`400 VALIDATION_ERROR` — on the data API, and by `engine.find()` / `findOne()` for an in-process +caller (a plugin, a hook, a flow), the top-level `fields` and an `expand` entry's alike — rather +than read as "every column". Leave `fields` out to read every column. + ### Nested / Related Fields {/* os:check */} From a446baeff5c5f2134f2cdff25ad7001b518cfd55 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 08:05:03 +0000 Subject: [PATCH 3/5] test(objectql): the empty-projection suite's driver double holds the caller's limit Claude-Session: https://claude.ai/code/session_01FEkQ5mbUpy4ebfWUHxAv87 Co-authored-by: Claude --- packages/objectql/src/engine-empty-projection-refusal.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/objectql/src/engine-empty-projection-refusal.test.ts b/packages/objectql/src/engine-empty-projection-refusal.test.ts index 72966e41907..09031584930 100644 --- a/packages/objectql/src/engine-empty-projection-refusal.test.ts +++ b/packages/objectql/src/engine-empty-projection-refusal.test.ts @@ -87,7 +87,9 @@ function makeDriver() { async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, async find(object: string, ast: any) { reads.push({ object, fields: ast?.fields }); - return (rows[object] ?? []).filter((r) => matches(r, ast?.where)).map((r) => project(r, ast?.fields)); + const matched = (rows[object] ?? []).filter((r) => matches(r, ast?.where)); + const page = typeof ast?.limit === 'number' ? matched.slice(0, ast.limit) : matched; + return page.map((r) => project(r, ast?.fields)); }, async findOne(object: string, ast: any) { reads.push({ object, fields: ast?.fields }); From 2e447bbd5a69cced1776a074ecc64ee842ce0417 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 08:19:00 +0000 Subject: [PATCH 4/5] test(objectql): the empty-projection suite's where double refuses a combinator it does not implement Claude-Session: https://claude.ai/code/session_01FEkQ5mbUpy4ebfWUHxAv87 Co-authored-by: Claude --- packages/objectql/src/engine-empty-projection-refusal.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/objectql/src/engine-empty-projection-refusal.test.ts b/packages/objectql/src/engine-empty-projection-refusal.test.ts index 09031584930..3bcd17deb83 100644 --- a/packages/objectql/src/engine-empty-projection-refusal.test.ts +++ b/packages/objectql/src/engine-empty-projection-refusal.test.ts @@ -71,6 +71,7 @@ function makeDriver() { if (!v.every((arm) => matches(row, arm))) return false; continue; } + if (k.startsWith('$')) throw new Error(`driver double does not implement the '${k}' combinator`); if (v && typeof v === 'object' && '$in' in (v as any)) { if (!(v as any).$in.map(String).includes(String(row[k]))) return false; continue; From 21ac7e6a13b8562f78e7a88f432e22eedf709702 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 09:24:49 +0000 Subject: [PATCH 5/5] test(service-automation): get_record's empty-fields case pins the engine's refusal the node now carries The engine refuses an empty projection (#27008), so a get_record node authored with fields: [] fails with that refusal instead of reading the whole row. The control for an absent fields stays; the crud-nodes comment says what the empty list now meets. Claude-Session: https://claude.ai/code/session_01FEkQ5mbUpy4ebfWUHxAv87 Co-authored-by: Claude --- .../src/builtin/crud-nodes.ts | 7 ++-- ...t-record-id-projection.integration.test.ts | 35 +++++++++++++------ 2 files changed, 28 insertions(+), 14 deletions(-) diff --git a/packages/services/service-automation/src/builtin/crud-nodes.ts b/packages/services/service-automation/src/builtin/crud-nodes.ts index b7d8ec771a7..64d295900c7 100644 --- a/packages/services/service-automation/src/builtin/crud-nodes.ts +++ b/packages/services/service-automation/src/builtin/crud-nodes.ts @@ -307,9 +307,10 @@ async function serveFamilyRead( * node's `outputVariable` agrees with `{.id}`; it is not taken back off, * which would be a second projection layer over the engine's answer. * - * Only a non-empty projection is touched: no `fields`, or an empty list, is a - * whole-row read already, and a projection that names `id` is handed over as - * written. The `find` branch (`limit > 1`) answers rows only, with no + * Only a non-empty projection is touched: no `fields` is a whole-row read + * already, an empty list is handed over as written for the engine to refuse + * (#27008: an empty projection is never read as the whole row), and a + * projection that names `id` is handed over as written. The `find` branch (`limit > 1`) answers rows only, with no * top-level `id`, and does not come through here. */ function projectionNamingId(fields: string[] | undefined): string[] | undefined { diff --git a/packages/services/service-automation/src/builtin/get-record-id-projection.integration.test.ts b/packages/services/service-automation/src/builtin/get-record-id-projection.integration.test.ts index 9626fa2ea72..70178ecc88a 100644 --- a/packages/services/service-automation/src/builtin/get-record-id-projection.integration.test.ts +++ b/packages/services/service-automation/src/builtin/get-record-id-projection.integration.test.ts @@ -14,8 +14,9 @@ * engine on that branch, and only there: * - a projection that already names `id` is handed over as written (no * second `id`); - * - no projection, or an empty one, stays a whole-row read (no projection is - * invented); + * - no projection stays a whole-row read (no projection is invented); + * - an empty projection is handed over as written, and the engine refuses it + * (#27008): the node fails with that refusal carried, never a whole-row read; * - the `find` branch (`limit > 1`) declares no top-level `id` and is not * touched. * @@ -152,15 +153,27 @@ describe('get_record answers the row id as output.id whatever its fields project expect(result.output.record).toEqual({ id: rowId, title: 'alpha' }); }); - for (const [label, config] of [['no fields', {}], ['empty fields', { fields: [] }]] as const) { - it(`control, ${label}: the whole row is read and the output is the engine's own answer`, async () => { - const whole: any = await ql.findOne(OBJECT.name, { where: { title: 'alpha' }, context: SYS }); - expect(whole.status, 'control: a whole-row read carries the unnamed columns').toBe('open'); - const { result, fields } = await readOne(config); - expect(fields, 'no projection is invented').toEqual((config as { fields?: unknown }).fields); - expect(result.output).toEqual({ record: whole, id: rowId, object: OBJECT.name }); - }); - } + it("control, no fields: the whole row is read and the output is the engine's own answer", async () => { + const whole: any = await ql.findOne(OBJECT.name, { where: { title: 'alpha' }, context: SYS }); + expect(whole.status, 'control: a whole-row read carries the unnamed columns').toBe('open'); + const { result, fields } = await readOne({}); + expect(fields, 'no projection is invented').toBeUndefined(); + expect(result.output).toEqual({ record: whole, id: rowId, object: OBJECT.name }); + }); + + it("empty fields: handed to the engine as written, and the node fails with the engine's refusal carried (#27008)", async () => { + handed.length = 0; + const result = await getRecord.execute( + { id: 'read', type: 'get_record', config: { objectName: OBJECT.name, filter: { title: 'alpha' }, fields: [] } }, + new Map(), + { runAs: 'system', flowName: 'pin_get_record_id' }, + ); + expect(handed, 'the node hands the empty projection over unchanged, once').toEqual([{ method: 'findOne', fields: [] }]); + expect(result.success, 'an empty projection is refused, never read as the whole row').toBe(false); + expect(result.output).toBeUndefined(); + expect(result.innerRefusal).toMatchObject({ code: 'VALIDATION_ERROR' }); + expect(result.error).toMatch(/^get_record\(pin_get_record_id\) failed: ObjectQL\.findOne\('pin_get_record_id'\) names no field in 'fields'/); + }); it('a downstream update_record filtering on read.id updates the row the narrowed get_record read', async () => { await ql.insert(OBJECT.name, { title: 'beta', status: 'open' }, { context: SYS });