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/27008-engine-empty-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 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

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

**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.
5 changes: 5 additions & 0 deletions content/docs/data-modeling/queries.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 */}
Expand Down
95 changes: 95 additions & 0 deletions packages/objectql/src/empty-projection-door.ts
Original file line number Diff line number Diff line change
@@ -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, '');
}
218 changes: 218 additions & 0 deletions packages/objectql/src/engine-empty-projection-refusal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
// 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<string, Record<string, unknown>[]> = {
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<string, unknown>, fields: unknown) =>
Array.isArray(fields) && fields.length > 0
? Object.fromEntries(fields.map((f: string) => [f, row[f]]))
: { ...row };
const matches = (row: Record<string, unknown>, 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 (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;
}
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 });
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 });
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<unknown>) {
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' } });
});
});
5 changes: 5 additions & 0 deletions packages/objectql/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading