diff --git a/.changeset/27165-engine-all-unknown-projection-refused.md b/.changeset/27165-engine-all-unknown-projection-refused.md new file mode 100644 index 00000000000..ac3df9961db --- /dev/null +++ b/.changeset/27165-engine-all-unknown-projection-refused.md @@ -0,0 +1,19 @@ +--- +'@objectstack/objectql': patch +--- + +fix(objectql)!: the in-process engine refuses a projection whose every column is unknown, as it refuses an empty one (#27165) + +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. +Metadata-protocol: none + + + +**BREAKING** accept-set narrowing at the in-process read entry (`@objectstack/objectql`). + +- **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. +- **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. +- **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. +- **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. + +**The remedy.** Name at least one column the object has, or leave `fields` out to read every column. diff --git a/content/docs/data-modeling/queries.mdx b/content/docs/data-modeling/queries.mdx index d3988466838..91fcd9355ae 100644 --- a/content/docs/data-modeling/queries.mdx +++ b/content/docs/data-modeling/queries.mdx @@ -322,6 +322,10 @@ This object form of a field node isn't wired up for top-level `fields` projectio object's schema is registered, the engine's unknown-field filter compares each entry against the schema's field names via `String(f)`, so an object entry never matches and is silently dropped from the projection — the aliased field is simply missing from results, no error. +That holds only while some other entry is known: when **every** top-level `fields` entry is +unknown (object-form entries included), `engine.find()` / `findOne()` refuse the read with +`400 VALIDATION_ERROR` naming the requested columns, instead of reading every column. A mix of +known and unknown entries still drops the unknown ones and projects the rest. Dotted relationship paths (`'owner.name'`) fare no better: the unknown-field filter validates only the **head** segment and keeps the path, but there is no populate step anywhere in the diff --git a/packages/objectql/src/empty-projection-door.ts b/packages/objectql/src/empty-projection-door.ts index 19c6bcd1669..8c24785fc0b 100644 --- a/packages/objectql/src/empty-projection-door.ts +++ b/packages/objectql/src/empty-projection-door.ts @@ -36,6 +36,16 @@ * 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. + * + * ## A projection emptied by the unknown-column tolerance + * + * [#27165] `find` / `findOne` drop a requested plain column the object does not + * have (the #7589 tolerance). When that leaves nothing, the projection is + * empty, and it gets this same refusal, naming what was asked for. Before, the + * engine fell back to every column there, so `fields: ['nope']` answered wider + * than any named projection. A mix of known and unknown columns keeps the + * tolerance. An `expand` entry needs no judgement of its own here: its + * sub-read always names `id`, which is never dropped. */ import { EngineQueryOptionsSchema } from '@objectstack/spec/data'; @@ -48,9 +58,17 @@ function projectionNamesNoField(): string | undefined { return declaredRefusal; } -function refuseEmptyProjection(object: string, operation: 'find' | 'findOne', param: string): never { +function refuseEmptyProjection( + object: string, + operation: 'find' | 'findOne', + param: string, + requested: readonly unknown[] = [], +): never { + const dropped = requested.length > 0 + ? `, and none of them is a field of '${object}' (an unknown column is dropped from a projection)` + : ''; const err: any = new Error( - `ObjectQL.${operation}('${object}') names no field in ${param}: got []. The query was not run: ` + `ObjectQL.${operation}('${object}') names no field in ${param}: got ${JSON.stringify(requested)}${dropped}. 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.`, ); @@ -93,3 +111,16 @@ export function assertProjectionNamesAField( if (Array.isArray(query.fields) && query.fields.length === 0) refuseEmptyProjection(object, operation, "'fields'"); assertExpandProjections(object, operation, query.expand, ''); } + +/** + * [#27165] Refuse a projection the unknown-column tolerance emptied: every + * entry of `requested` named a column `object` does not have. Same refusal as + * `fields: []`, naming what was asked for. + */ +export function refuseProjectionOfUnknownColumns( + object: string, + operation: 'find' | 'findOne', + requested: readonly unknown[], +): never { + return refuseEmptyProjection(object, operation, "'fields'", requested); +} diff --git a/packages/objectql/src/engine-empty-projection-refusal.test.ts b/packages/objectql/src/engine-empty-projection-refusal.test.ts index 3bcd17deb83..817549dddf3 100644 --- a/packages/objectql/src/engine-empty-projection-refusal.test.ts +++ b/packages/objectql/src/engine-empty-projection-refusal.test.ts @@ -216,3 +216,48 @@ describe('[#27008] the in-process engine refuses an empty projection', () => { expect(row).toEqual({ title: 'Ship', project_id: { name: 'Apollo' } }); }); }); + +/** + * [#27165] A projection that is empty AFTER the unknown-plain-column tolerance + * dropped every entry is an empty projection, and is refused with the same + * refusal. Before, `find(obj, { fields: ['nope'] })` dropped `nope` (the #7589 + * tolerance) and then fell back to every column: a narrower request answered + * wider. The tolerance itself stays for a mix of known and unknown columns. + */ +describe('[#27165] a projection whose every column is unknown is 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 only unknown columns is refused 400 VALIDATION_ERROR, naming them, before any driver read', async () => { + const answer = await wireAnswer(engine.find('task_27008', { fields: ['nope', 'nada'] })); + 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': got \["nope","nada"\]/); + expect(reads).toEqual([]); + }); + + it('findOne with only unknown columns is refused the same way, before any driver read', async () => { + const answer = await wireAnswer(engine.findOne('task_27008', { where: { id: 't1' }, fields: ['nope'] })); + expect(answer).toMatchObject({ status: 400, code: 'VALIDATION_ERROR' }); + expect(answer.message).toContain(declaredRefusal()); + expect(answer.message).toMatch(/^ObjectQL\.findOne\('task_27008'\) names no field in 'fields': got \["nope"\]/); + expect(reads).toEqual([]); + }); + + it('CONTROL: a mix of known and unknown columns keeps the tolerance and projects the known ones', async () => { + const [row] = await engine.find('task_27008', { fields: ['nope', 'title'] }); + expect(row).toEqual({ title: 'Ship' }); + const one = await engine.findOne('task_27008', { where: { id: 't1' }, fields: ['nope', 'title'] }); + expect(one).toEqual({ title: 'Ship' }); + }); +}); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 932043014e4..be54b390b48 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -302,7 +302,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 { assertProjectionNamesAField, refuseProjectionOfUnknownColumns } 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'; @@ -13632,10 +13632,10 @@ export class ObjectQL implements IObjectQLEngine { // Whole names, no head-splitting: only plain entries reach here (the // dotted refusal above fired on anything carrying a '.'). const filtered = ast.fields.filter(f => known.has(f)); - // Guard against an empty projection — fall back to `*` so the - // request still returns rows. An empty SELECT list would either - // 400 in Postgres or silently project nothing. - ast.fields = filtered.length > 0 ? filtered : undefined; + // [#27165] Nothing left is an empty projection, refused as `fields: []` + // is — never read as every column. + if (filtered.length === 0) refuseProjectionOfUnknownColumns(object, 'find', ast.fields); + ast.fields = filtered; } const opCtx: OperationContext = { @@ -13938,7 +13938,9 @@ export class ObjectQL implements IObjectQLEngine { // in schema.fields (matches `find()` behavior, one list). const known = new Set([...Object.keys(_findOneSchema.fields), ...PLATFORM_PROVISIONED_COLUMNS]); const filtered = ast.fields.filter(f => known.has(f)); - ast.fields = filtered.length > 0 ? filtered : undefined; + // [#27165] Same refusal as `find`. + if (filtered.length === 0) refuseProjectionOfUnknownColumns(objectName, 'findOne', ast.fields); + ast.fields = filtered; } const opCtx: OperationContext = { diff --git a/packages/objectql/src/query-expression-conformance.test.ts b/packages/objectql/src/query-expression-conformance.test.ts index 43a655e08b1..41679f05c20 100644 --- a/packages/objectql/src/query-expression-conformance.test.ts +++ b/packages/objectql/src/query-expression-conformance.test.ts @@ -1783,14 +1783,17 @@ describe('#4226 — sort / select / expand on the list path (real ObjectQL engin for (const r of rows) expect(Object.keys(r).sort()).toEqual(['title']); }); - it('[#7589 GUARD] a projection of ONLY unknown plain columns still falls back to every field', async () => { - // The `SELECT *` fallback itself, pinned as KEPT for the plain case: - // this is the documented tolerance the ruling preserves, not a defect - // this card missed. (The DOTTED route into this same fallback is what - // was closed above.) - const rows: any[] = await engine.find('showcase_task', { fields: ['no_such_field'] }); - expect(rows).toHaveLength(5); - expect(Object.keys(rows[0])).toEqual(expect.arrayContaining(['id', 'title', 'status'])); + it('[#27165] a projection of ONLY unknown plain columns is an empty projection, refused', async () => { + // The `SELECT *` fallback this guard pinned as KEPT is closed by its + // own ruling (#27165 triage): a projection the tolerance empties is an + // empty projection, refused as `fields: []` is (#27008), never read as + // every column. The tolerance itself stays for a mixed projection + // (the guard above). + const err: any = await engine + .find('showcase_task', { fields: ['no_such_field'] }) + .then(() => null, (e: unknown) => e); + expect(err).toMatchObject({ status: 400, code: 'VALIDATION_ERROR' }); + expect(err.message).toMatch(/names no field in 'fields': got \["no_such_field"\]/); }); it('[#7589 GUARD] `engine.findOne` keeps the same plain tolerance', async () => { diff --git a/packages/objectql/src/unprojected-read-declared-fields-conformance.test.ts b/packages/objectql/src/unprojected-read-declared-fields-conformance.test.ts index 9486a22e4c4..06dfa57284b 100644 --- a/packages/objectql/src/unprojected-read-declared-fields-conformance.test.ts +++ b/packages/objectql/src/unprojected-read-declared-fields-conformance.test.ts @@ -203,11 +203,11 @@ describe.each(['whole row', 'projection', 'ladder'])( }); it('door: an explicit projection naming ONLY a retired column', async () => { - // The engine's unknown-plain tolerance drops the name and reads the - // default projection; it must not read every column. - const rows = await h.engine.find(CONTACT, { fields: ['mailing_street'] }); - expect(rows[0]).toMatchObject({ id: 'con_1', name: 'Ada' }); - expectNoRetiredColumn(rows); + // The engine's unknown-plain tolerance drops the name, which leaves an + // empty projection: [#27165] refused, as `fields: []` is, so the + // retired column is never read and no row is served. + const err: any = await h.engine.find(CONTACT, { fields: ['mailing_street'] }).then(() => null, (e: unknown) => e); + expect(err).toMatchObject({ status: 400, code: 'VALIDATION_ERROR' }); }); it('door: expand — the related record is the related object\'s declared fields', async () => {