Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/27165-engine-all-unknown-projection-refused.md
Original file line number Diff line number Diff line change
@@ -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

<!-- 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. -->

**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.
4 changes: 4 additions & 0 deletions content/docs/data-modeling/queries.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 33 additions & 2 deletions packages/objectql/src/empty-projection-door.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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.`,
);
Expand Down Expand Up @@ -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);
}
45 changes: 45 additions & 0 deletions packages/objectql/src/engine-empty-projection-refusal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
});
});
14 changes: 8 additions & 6 deletions packages/objectql/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -13938,7 +13938,9 @@ export class ObjectQL implements IObjectQLEngine {
// in schema.fields (matches `find()` behavior, one list).
const known = new Set<string>([...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 = {
Expand Down
19 changes: 11 additions & 8 deletions packages/objectql/src/query-expression-conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -203,11 +203,11 @@ describe.each<Behaviour>(['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 () => {
Expand Down
Loading