Skip to content

Commit 17ce2df

Browse files
committed
fix(spec): refuse an empty projection on the engine-side fields/select declarations
EngineQueryOptionsSchema.fields and RpcQueryOptionsSchema.select take the projection's array arm (at least one name, none blank) from the declaration their transport siblings already use. The shared arm moves above EngineQueryOptionsSchema because RpcQueryOptionsSchema extends it at module load. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzfjH9js8WRa8Xi2dmx
1 parent 3217fc5 commit 17ce2df

3 files changed

Lines changed: 125 additions & 31 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
fix(spec)!: `EngineQueryOptionsSchema.fields` and the RPC request's `select` declare the projection their transport siblings declare, instead of any list (#26954)
6+
7+
Clause-②: no (narrowing) — the published engine-side declarations of a read's projection (`EngineQueryOptionsSchema.fields`, and the deprecated `select` of the RPC query options reached through `DataEngineFindRequestSchema`, `DataEngineFindOneRequestSchema` and `DataEngineRequestSchema`) refuse a list naming no field, which they admitted before. Nothing is widened and no key is added, removed or renamed.
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 metadata type or stored shape moves: these are engine and RPC request declarations, never stored, and no metadata type embeds them. A caller names at least one field or leaves the projection out; there is nothing for a migration to rewrite and no tombstone. -->
11+
12+
**BREAKING** accept-set narrowing of published declarations (`@objectstack/spec/data`). No runtime in this repository parses either member, so no request served here changes; what can break is code outside it that parses with these exported schemas, or a client generated from their JSON Schema, and sent an empty projection.
13+
14+
- **Before.** `EngineQueryOptionsSchema.fields` and the RPC query's `select` admitted any list of strings: `[]`, and a list with a blank or whitespace-only name.
15+
- **Now.** A list must hold at least one name, and no name may be blank (JSON Schema `minItems: 1`, and a non-blank pattern on each name). Both read this rule from the same declaration as `QueryTransportParamsSchema.select` / `$select` and `GetDataRequestSchema.select`, so the refusal messages are the same.
16+
- **Unchanged.** The inferred types (`string[]`), a named projection, and leaving the projection out. The in-process ObjectQL engine does not parse its options with this schema: `engine.find(object, { fields: [] })` still reads every column, as before.
17+
18+
**The remedy.** Name at least one field, or leave `fields` / `select` out to read every column.

‎packages/spec/src/data/data-engine.test.ts‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
DataEngineVectorFindRequestSchema,
2626
DataEngineRequestSchema,
2727
DroppedFieldsEventSchema,
28+
QueryTransportParamsSchema,
2829
} from './data-engine.zod';
2930
import { QuerySchema } from './query.zod';
3031

@@ -1242,3 +1243,72 @@ describe('DroppedFieldsEventSchema.reason — why a write dropped submitted fiel
12421243
expect(issue?.path).toEqual(['reason']);
12431244
});
12441245
});
1246+
1247+
describe('the engine-side projection declarations admit exactly what their transport siblings admit', () => {
1248+
/**
1249+
* [#26954] `EngineQueryOptionsSchema.fields` and the RPC request's
1250+
* deprecated `select` (`DataEngineFindRequestSchema.query`, and so
1251+
* `DataEngineFindOneRequestSchema` and the `DataEngineRequestSchema` union)
1252+
* take the projection's array arm from the one declaration their transport
1253+
* siblings use: at least one name, none blank. `fields` shares
1254+
* `FieldNodeSchema` with `$select`; `select` shares `z.string()` with the
1255+
* bare `select`. So each refusal reads exactly as its sibling's does.
1256+
*/
1257+
const REFUSED: ReadonlyArray<readonly [string, string[], ReadonlyArray<string | number>]> = [
1258+
['an empty list', [], []],
1259+
['a list with a blank name', ['name', ''], [1]],
1260+
['a list with a whitespace name', ['name', ' '], [1]],
1261+
];
1262+
const KEPT: ReadonlyArray<readonly [string, string[]]> = [
1263+
['one name', ['name']],
1264+
['two names', ['name', 'email']],
1265+
];
1266+
1267+
/** The first issue's message and path when `value` sits under `key` of a transport bag. */
1268+
const sibling = (key: 'select' | '$select', value: unknown) => {
1269+
const parsed = QueryTransportParamsSchema.safeParse({ [key]: value });
1270+
expect(parsed.success, `${key} sibling refuses ${JSON.stringify(value)}`).toBe(false);
1271+
return parsed.success ? undefined : parsed.error.issues[0];
1272+
};
1273+
1274+
it.each(REFUSED)('EngineQueryOptionsSchema.fields refuses %s, naming the path, as `$select` does', (_name, value, tail) => {
1275+
const parsed = EngineQueryOptionsSchema.safeParse({ fields: value });
1276+
expect(parsed.success).toBe(false);
1277+
const issue = parsed.success ? undefined : parsed.error.issues[0];
1278+
expect(issue?.path).toEqual(['fields', ...tail]);
1279+
const twin = sibling('$select', value);
1280+
expect(issue?.message).toBe(twin?.message);
1281+
expect(twin?.path).toEqual(['$select', ...tail]);
1282+
});
1283+
1284+
it.each(KEPT)('EngineQueryOptionsSchema.fields keeps %s, unchanged', (_name, value) => {
1285+
expect(EngineQueryOptionsSchema.parse({ fields: value }).fields).toEqual(value);
1286+
});
1287+
1288+
for (const [label, schema, method] of [
1289+
['DataEngineFindRequestSchema', DataEngineFindRequestSchema, 'find'],
1290+
['DataEngineFindOneRequestSchema', DataEngineFindOneRequestSchema, 'findOne'],
1291+
['DataEngineRequestSchema', DataEngineRequestSchema, 'find'],
1292+
] as const) {
1293+
it.each(REFUSED)(`${label}: query.select refuses %s, naming the path, as the bare \`select\` does`, (_name, value, tail) => {
1294+
const parsed = schema.safeParse({ method, object: 'account', query: { select: value } });
1295+
expect(parsed.success).toBe(false);
1296+
const issue = parsed.success ? undefined : parsed.error.issues[0];
1297+
expect(issue?.path).toEqual(['query', 'select', ...tail]);
1298+
expect(issue?.message).toBe(sibling('select', value)?.message);
1299+
});
1300+
1301+
it.each(REFUSED)(`${label}: query.fields refuses %s, naming the path`, (_name, value, tail) => {
1302+
const parsed = schema.safeParse({ method, object: 'account', query: { fields: value } });
1303+
expect(parsed.success).toBe(false);
1304+
const issue = parsed.success ? undefined : parsed.error.issues[0];
1305+
expect(issue?.path).toEqual(['query', 'fields', ...tail]);
1306+
expect(issue?.message).toBe(sibling('$select', value)?.message);
1307+
});
1308+
1309+
it.each(KEPT)(`${label}: query.select keeps %s, folded onto \`fields\``, (_name, value) => {
1310+
const parsed = schema.parse({ method, object: 'account', query: { select: value } }) as { query?: Record<string, unknown> };
1311+
expect(parsed.query).toEqual({ fields: value });
1312+
});
1313+
}
1314+
});

‎packages/spec/src/data/data-engine.zod.ts‎

Lines changed: 37 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,39 @@ export const BaseEngineOptionsSchema = lazySchema(() => z.object({
7575
context: ExecutionContextSchema.partial().optional(),
7676
}));
7777

78+
/**
79+
* [#26909] The projection's accept set — the `select` / `$select` spellings
80+
* of `fields` — as the doors that read it judge it (`projectionFieldsValue` in
81+
* `@objectstack/metadata-protocol`, shared by the list's `findData` and the
82+
* by-id `getData`). A comma string is split, trimmed and emptied of blank
83+
* members there, and a value naming NO field is refused with a 400: absent
84+
* means "every column", so an empty projection read as absent is a widening.
85+
*
86+
* The string arm is that rule as a pattern: `[^\s,]` holds iff some comma
87+
* segment has a non-whitespace character, and `\s` is exactly the set
88+
* `trim()` strips. A pattern, not a refinement, so the published JSON Schema
89+
* states it too. The array arm is not split: the door keeps every member,
90+
* refuses `[]`, and checks every member against the object's fields, where a
91+
* blank name is never one.
92+
*
93+
* `HttpFindQueryParamsSchema.select` and `GetDataRequestSchema.select`
94+
* (`../api/protocol.zod.ts`) read these arms off the `QueryTransportParamsSchema`
95+
* members rather than restating them, so the three declarations of one door
96+
* rule cannot drift apart.
97+
*
98+
* [#26954] The engine-side declarations of the same projection —
99+
* `EngineQueryOptionsSchema.fields` and `RpcQueryOptionsSchema.select` — take
100+
* the array arm from here too. It is declared ahead of both because
101+
* `RpcQueryOptionsSchema` extends `EngineQueryOptionsSchema` at module load,
102+
* which runs the lazy factory before any later `const` exists.
103+
*/
104+
const PROJECTION_NAMES_NO_FIELD = 'Name at least one field';
105+
const TransportProjectionStringSchema = z.string().regex(/[^\s,]/, PROJECTION_NAMES_NO_FIELD);
106+
const transportProjectionArray = <T extends z.ZodString>(member: T) =>
107+
z.array(member.regex(/\S/, 'A field name cannot be blank')).min(1, PROJECTION_NAMES_NO_FIELD);
108+
const PROJECTION_ACCEPT_SET = 'at least one field name; a value naming no field (blank, only commas and spaces, '
109+
+ 'an empty list, or a list with a blank name) is refused';
110+
78111
// ==========================================================================
79112
// 2. method: FIND (QueryAST-aligned)
80113
// ==========================================================================
@@ -101,8 +134,8 @@ export const EngineQueryOptionsSchema = lazySchema(() => BaseEngineOptionsSchema
101134
/** Filter conditions (WHERE) — standard QueryAST `where` */
102135
where: z.union([z.record(z.string(), z.unknown()), FilterConditionSchema]).optional(),
103136

104-
/** Fields to retrieve (SELECT) — standard QueryAST `fields` */
105-
fields: z.array(FieldNodeSchema).optional(),
137+
/** Fields to retrieve (SELECT) — standard QueryAST `fields`; at least one, none blank (#26954) */
138+
fields: transportProjectionArray(FieldNodeSchema).optional(),
106139

107140
/** Sorting instructions (ORDER BY) — standard QueryAST `orderBy` */
108141
orderBy: z.array(SortNodeSchema).optional(),
@@ -774,8 +807,8 @@ function foldRpcQueryOptions(input: object, ctx: z.core.$RefinementCtx): EngineQ
774807
*/
775808
const RpcQueryOptionsSchema = EngineQueryOptionsSchema.extend({
776809
...RpcLegacyFilterMixin,
777-
/** @deprecated Use `fields` */
778-
select: z.array(z.string()).optional(),
810+
/** @deprecated Use `fields` — the same projection rule (#26954) */
811+
select: transportProjectionArray(z.string()).optional(),
779812
/** @deprecated Use `orderBy` */
780813
sort: DataEngineSortSchema.optional(),
781814
/** @deprecated Use `offset` */
@@ -907,33 +940,6 @@ const TransportFilterValueSchema = lazySchema(() => z.union([FilterArraySchema,
907940
*/
908941
const TransportCountValueSchema = lazySchema(() => z.union([z.boolean(), z.enum(['true', 'false'])]));
909942

910-
/**
911-
* [#26909] The projection's accept set — the `select` / `$select` spellings
912-
* of `fields` — as the doors that read it judge it (`projectionFieldsValue` in
913-
* `@objectstack/metadata-protocol`, shared by the list's `findData` and the
914-
* by-id `getData`). A comma string is split, trimmed and emptied of blank
915-
* members there, and a value naming NO field is refused with a 400: absent
916-
* means "every column", so an empty projection read as absent is a widening.
917-
*
918-
* The string arm is that rule as a pattern: `[^\s,]` holds iff some comma
919-
* segment has a non-whitespace character, and `\s` is exactly the set
920-
* `trim()` strips. A pattern, not a refinement, so the published JSON Schema
921-
* states it too. The array arm is not split: the door keeps every member,
922-
* refuses `[]`, and checks every member against the object's fields, where a
923-
* blank name is never one.
924-
*
925-
* `HttpFindQueryParamsSchema.select` and `GetDataRequestSchema.select`
926-
* (`../api/protocol.zod.ts`) read these arms off the members below rather
927-
* than restating them, so the three declarations of one door rule cannot
928-
* drift apart.
929-
*/
930-
const PROJECTION_NAMES_NO_FIELD = 'Name at least one field';
931-
const TransportProjectionStringSchema = z.string().regex(/[^\s,]/, PROJECTION_NAMES_NO_FIELD);
932-
const transportProjectionArray = <T extends z.ZodString>(member: T) =>
933-
z.array(member.regex(/\S/, 'A field name cannot be blank')).min(1, PROJECTION_NAMES_NO_FIELD);
934-
const PROJECTION_ACCEPT_SET = 'at least one field name; a value naming no field (blank, only commas and spaces, '
935-
+ 'an empty list, or a list with a blank name) is refused';
936-
937943
/**
938944
* [#25269] The page window — `limit` / `offset` and every spelling that folds
939945
* onto them (`top`, `$top`, `skip`, `$skip`) — as the Data API list doors

0 commit comments

Comments
 (0)