Skip to content

Commit 4702c46

Browse files
fix(metadata-protocol)!: the Data API refuses an empty projection and a count flag that is neither true nor false (#26811)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XFuFUYz5AqM65JYxwmKQC8f)_ Fixes #26644 Clause-②: no (narrowing) Metadata-protocol: none **BREAKING** (accept-set narrowing, `patch`): the Data API refuses an empty projection and a count flag that is neither true nor false with `400 VALIDATION_ERROR`, where it answered 200. ADR-0087 disposition in the changeset: `not-required (no-migration-prescription)`. ## 维护者速读 - **改了什么**:数据列表接口遇到空的 `$select`(如 `?$select=`、`?$select=,`)或不是 true/false 的 `$count`(如 `?$count=maybe`),现在直接回 400 并点名参数和取值,不再当作「没传」处理。 - **为什么改**:以前空 `$select` 会返回全部字段,乱写的 `$count` 会照样统计总数,调用方拿到看似正常的 200,发现不了自己写错了。 - **风险与回滚**:只有本来就传了错值的调用会从 200 变 400;不传参数、传合法值的请求行为不变。回滚只需还原 `packages/metadata-protocol/src/protocol.ts` 的这次改动。 - **先看**:`packages/metadata-protocol/src/protocol.ts`(`projectionFieldsValue`、`countFlagValue` 两个函数及 `findData`/`getData` 里的调用点)、`packages/rest/src/data-empty-projection-count-door.test.ts`、`packages/runtime/src/domains/data-empty-projection-count.test.ts`。 - **两分钟验证**:`pnpm --filter @objectstack/metadata-protocol exec vitest run src/protocol.empty-projection-unknown-count.test.ts`,预期 37 个用例全绿。 - **需要你做的**:无,席位落地。 ## What changes - **`findData` (the list normalizer every list door shares).** A projection (`fields`, and its folded `select` / `$select` spellings) that names no field is refused: a blank or comma-only string, or an empty array. It used to become `[]`, which `assertProjectionFieldsExist` has nothing to check and every driver serves as no projection, so every column came back under a 200. A `count` / `$count` that is not the boolean or the string `true` / `false` is refused; it used to stay unlowered, so the `options.count === false` opt-out never matched and the count ran. Both refusals are `400 VALIDATION_ERROR` with `param` set to the spelling the caller wrote and a message quoting the value, thrown before the engine is asked. - **The accepted count values are read from the spec, not restated.** `countFlagValue` parses with `QueryTransportParamsSchema.shape.count` (`@objectstack/spec/data`): the boolean, the strings `true` / `false`, and `null` (absent). No `packages/spec` file is edited. - **`getData` (`GET /data/:object/:id`) takes the same projection rule.** Its comment promised "gated exactly as on the list path"; refusing the empty projection on the list alone would have made that false. A blank `select` was falsy and skipped, a comma-only one became `[]`; both answered every column. It now asks the same helper on any value present. - **Unchanged.** Absent parameters keep their defaults (every column, the count runs). A named projection, with trailing commas and spaces trimmed as before, `$count=true`, `$count=false` and a JSON `null` answer as before. Which doors reach the refusal (measured by reading every `findData(` / `getData(` caller on `origin/main` `0f3d068cf1`, and pinned per door below): | door | normalizer | empty projection | `$count=maybe` | |:--|:--|:--|:--| | `RestServer` `GET /data/:object` | `findData` | 400 `VALIDATION_ERROR` | 400 `VALIDATION_ERROR` | | `RestServer` `POST /data/:object/query` | `findData` (after `FindDataRequestSchema`) | 400 `VALIDATION_ERROR` | already 400 `VALIDATION_FAILED` at the body schema, unchanged | | runtime dispatcher `GET /data/:object` | `findData` (via `callData('query')`) | 400 `VALIDATION_ERROR` | 400 `VALIDATION_ERROR` | | runtime dispatcher `POST /data/:object/query` | `findData` (after `FindDataRequestSchema`) | 400 `VALIDATION_ERROR` | the same `FindDataRequestSchema` gate as REST (not separately measured) | | `RestServer` `GET /data/:object/:id` | `getData` | `VALIDATION_ERROR` at **404** (see Acceptance notes) | n/a | | runtime dispatcher `GET /data/:object/:id` | `getData` (via `callData('get')`) | 400 `VALIDATION_ERROR` | n/a | The in-process callers (`data-action-routes.ts` job and export reads, `import-runner.ts`, `connector-pull.ts`, plugin-security, plugin-auth's user import) pass no projection or a named one; none sends an empty one or a count flag. ## Tests - `packages/metadata-protocol/src/protocol.empty-projection-unknown-count.test.ts` (new, 37 cases): every refused spelling with `code`, `status`, `param` and the engine never reached; the preservation set (projection lists, `count` absent / true / false / null) green in both directions. - `packages/rest/src/data-empty-projection-count-door.test.ts` (new): both REST list routes and the by-id route over a real `SqlDriver` (SQLite): status, `code`, the named spelling, zero reads of the object, and the controls. - `packages/runtime/src/domains/data-empty-projection-count.test.ts` (new): the dispatcher's three doors through `HttpDispatcher.dispatch()` over a real store; a refusal leaves `dispatch()` and both host exits (`declaredEnvelopeForThrow`, `resolveThrownHttpError`) are asked and must agree. - Red first, on the base `0f3d068cf1` with every closure built from it: protocol 21 failed / 16 passed (all 21 refusals, every preservation green); REST 3 failed / 2 passed; dispatcher 8 failed / 1 passed (every refusal answered 200 with every column, or a `total`). - `scripts/engine-double-contract.pinned.json`: the new by-id double's `findOne` opens with `assertEngineFindOnePredicate`; `--write` recorded it. ## Acceptance notes - **REST by-id answers 404, not 400.** `crud-routes.ts`'s `GET /data/:object/:id` catch rewrites every 400 to 404 ("a bad id is a miss"), so every projection refusal on that route, an unknown `select` field included (`?select=nope` answers 404 `INVALID_FIELD`, measured in the door test), already answers 404. The empty projection joins that answer with code `VALIDATION_ERROR`; the dispatcher's by-id door answers 400. Changing the rewrite is outside this card (reported). - **The spec's HTTP declaration is wider than the door.** `HttpFindQueryParamsSchema.count` is `z.coerce.boolean()` (it reads `'false'` as `true` and admits `'maybe'`), and its `select` is `z.string()` (admits a blank). It has no runtime reader in this tree; it ships as `json-schema/api/HttpFindQueryParams.json` and the reference docs. The door reads `QueryTransportParamsSchema.count`, which is exact. `packages/spec` is outside this lane (reported). - **A client SDK caller passing `select: []`** (`packages/client/src/index.ts` serialises it as `select=`) now gets the 400 instead of every column. That is the refusal working as intended; no in-repo caller passes an empty list. - **Code choice.** The blank `$top` refusal this mirrors answers `INVALID_REQUEST` (a ledger code); these answer the standard-catalog `VALIDATION_ERROR`, as the dispatch ruled. ## Verification All readings at the final head `28a10e6f8b` unless stated; exit codes captured before any pipe; heavy runs under `scripts/pm/os-verify-lock.sh` (its `VERDICT` line quoted). <details><summary>Package suites and typecheck</summary> - `pnpm build` (full, at `27848a9b`, the fix's source identical to the head): `VERDICT command-exit 0`. - `pnpm --filter @objectstack/metadata-protocol test`: exit 0 · Test Files 270 passed, 3 skipped · Tests 21653 passed, 19 skipped. `typecheck`: exit 0. The new file re-run at the head after its double change: 37/37. - `pnpm --filter @objectstack/rest test`: exit 0 · Test Files 327 passed · Tests 6503 passed, 326 skipped. `typecheck`: exit 0. - `pnpm --filter @objectstack/runtime test`: exit 0 · Test Files 460 passed · Tests 7457 passed, 19 skipped. `typecheck` (with `check:test-typecheck`) at the head: exit 0; the new door file 9/9. - `eslint --no-inline-config packages/metadata-protocol packages/rest packages/runtime`: exit 0. </details> <details><summary>Red first, on the base `0f3d068cf1`</summary> - `protocol.empty-projection-unknown-count.test.ts`: Tests 21 failed, 16 passed — every refusal red (accepted, answered), every preservation green. - `data-empty-projection-count-door.test.ts`: 3 failed, 2 passed — e.g. `$select= blank (the card)`: 200 with all columns `label`, `note` and the system columns; `select=""` by id: 200, whole record. - `data-empty-projection-count.test.ts` (dispatcher): 8 failed, 1 passed — each refused request answered `{"status":200,"body":{"success":true,…}}`, every column or a `total`. </details> <details><summary>Dogfood (the narrowing arm)</summary> - `pnpm --filter @objectstack/dogfood test` at `25a8556236`: `VERDICT command-exit 0` · Test Files 302 passed, 3 skipped · Tests 2507 passed, 20 skipped · no failures. The head `28a10e6f8b` differs from it only in `packages/runtime/src/domains/data-empty-projection-count.test.ts` (a type annotation in a test outside the dogfood suite). </details> <details><summary>Derived gates</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, then every family run at `28a10e6f8b`: 70 derived, 70 run, all exit 0. `--ran` reconciliation: `70 derived famil(ies) accounted for — 70 run, 0 NOT-MEASURED`. - One red on the first pass, fixed: `check:engine-double-contract` pinned the new by-id double's `findOne` (now opens with `assertEngineFindOnePredicate`; ledger recorded by `--write`). - `test:repo` (vitest `repo` projects) not run locally: no repo-project test or census file is in the diff; CI `Test Core` runs it. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5kRR5RttkLiWWAkDA5)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d8b943b commit 4702c46

6 files changed

Lines changed: 683 additions & 11 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
fix(metadata-protocol)!: the Data API refuses an empty projection and a count flag that is neither true nor false, instead of reading them as absent (#26644)
6+
7+
Clause-②: no (narrowing) — `GET /data/:object`, `POST /data/:object/query` (both transports: `RestServer` and the runtime dispatcher) and `GET /data/:object/:id` refuse a projection that names no field, and the list doors refuse a `count` / `$count` other than true or false, where they answered 200 before. Nothing is widened and no export changes.
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 export or type changes: two query-parameter values the list normalizer read as the absent default are now refused 400 VALIDATION_ERROR naming the parameter. A caller drops the parameter or sends a well-formed value; there is nothing for a migration to rewrite and no tombstone. -->
11+
12+
**BREAKING** accept-set narrowing at the shared list normalizer (`findData`) and the by-id read (`getData`), served by every Data API door that uses them.
13+
14+
- **Before.** A blank or comma-only `$select` / `select` / `fields` (`?$select=`, `?$select=,`), and an empty array in a JSON body (`"fields": []`), became an empty projection, which every driver serves as no projection: all columns, under a 200. A `$count` / `count` other than `true` or `false` (`?$count=maybe`, `?$count=1`, a blank `?count=`) was left unlowered, so the `count=false` opt-out never matched and the count ran: a `total` was reported as if `$count=true` had been sent.
15+
- **Now.** Both are refused `400 VALIDATION_ERROR` before any read, with a message naming the parameter the caller wrote and the value (`Query parameter '$select' names no field; got ""`, `Query parameter '$count' must be true or false; got "maybe"`), as a blank `$top` already is. The accepted count values are the spec's `QueryTransportParamsSchema.count`: the boolean, or the strings `true` and `false`. `GET /data/:object/:id?select=` answers with the same code; that route's catch already reports every projection refusal (an unknown field included) as `404`, and the empty projection joins that answer.
16+
- **Unchanged.** An absent parameter keeps its default: every column, and the count runs. A named projection (`$select=a,b`, trailing commas and spaces trimmed as before), `$count=true`, `$count=false` and a JSON `null` answer exactly as before. `POST /data/:object/query` already refused a non-boolean `count` at its body schema (`400 VALIDATION_FAILED`), and still does.
17+
- **Who sees it.** A caller that builds `$select` from a list that can be empty, or sends a mistyped `$count`. It now learns that from a 400 instead of an answer that looked normal.
18+
19+
**The remedy.** Leave the parameter out to read every column or to keep the count, or send a projection naming at least one field and a count flag of `true` or `false`.
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #26644 — the list normalizer read two malformed values as the absent default.
5+
*
6+
* - **Projection.** A string `fields` (or its folded `select` / `$select`
7+
* spellings) was `split(',').map(trim).filter(Boolean)`, so a blank or
8+
* comma-only value became `[]` — and `assertProjectionFieldsExist` has
9+
* nothing to refuse in an empty list, so an empty projection was served as
10+
* NO projection: every column. An explicit `fields: []` took the same road.
11+
* Absent means "every column"; an empty projection asks for none, so reading
12+
* it as absent is the silent widening `pageWindowValue` already refuses on
13+
* the row axis (a blank `$top`).
14+
* - **Count.** The boolean step lowered only `'true'` / `'false'`; any other
15+
* value stayed as it was, `countOptOut = options.count === false` was false,
16+
* and the count ran — `?$count=maybe` reported a `total` like `?$count=true`.
17+
*
18+
* Both are now refused `400 VALIDATION_ERROR` naming the parameter the caller
19+
* wrote and the value, before the engine is asked. The accepted `count` values
20+
* are the spec's (`QueryTransportParamsSchema.count`): the boolean, or the two
21+
* strings a querystring carries it as.
22+
*
23+
* `getData` (`GET /data/:object/:id`) reads `select` against the same field
24+
* map, and its own comment promised "gated exactly as on the list path", so its
25+
* projection takes the same refusal: one rule for both routes.
26+
*
27+
* ## Two assertion classes
28+
*
29+
* 1. **REFUSAL** — `code`, `status` and `param` on the throw, and the engine
30+
* never reached. The unfixed normalizer never threw for these, so a bare
31+
* `toThrow()` would be half a pin.
32+
* 2. **PRESERVATION** — a well-formed projection reaches `engine.find` as the
33+
* same list it always did, and `count` absent / `true` / `false` (either
34+
* spelling, `null` as absent) counts or skips exactly as before.
35+
*
36+
* The door-level half (both REST list routes and the by-id route over a real
37+
* `SqlDriver`) is `packages/rest/src/data-empty-projection-count-door.test.ts`;
38+
* the runtime dispatcher's is
39+
* `packages/runtime/src/domains/data-empty-projection-count.test.ts`.
40+
*/
41+
42+
import { describe, it, expect, vi } from 'vitest';
43+
import { assertEngineFindOnePredicate, type EngineFindOneQueryInput } from '@objectstack/metadata-core';
44+
import { ObjectStackProtocolImplementation } from './protocol.js';
45+
46+
const SCHEMA = {
47+
name: 'invoice',
48+
nameField: 'name',
49+
fields: {
50+
name: { name: 'name', type: 'text' },
51+
status: { name: 'status', type: 'text' },
52+
},
53+
};
54+
55+
function makeProtocol() {
56+
const find = vi.fn(async () => [{ id: 'r1', name: 'a', status: 'open' }] as unknown[]);
57+
const findOne = vi.fn(async (object: string, query?: EngineFindOneQueryInput) => {
58+
assertEngineFindOnePredicate(object, query);
59+
return { id: 'r1', name: 'a', status: 'open' };
60+
});
61+
const count = vi.fn(async () => 7);
62+
const engine = {
63+
registry: { getObject: (n: string) => (n === 'invoice' ? SCHEMA : undefined) },
64+
find,
65+
findOne,
66+
count,
67+
aggregate: vi.fn(async () => [] as unknown[]),
68+
};
69+
return { p: new ObjectStackProtocolImplementation(engine as any), find, findOne, count };
70+
}
71+
72+
type Refusal = { message: string; status?: number; code?: string; param?: string };
73+
74+
async function refusal(run: (p: any) => Promise<unknown>, reached: (m: ReturnType<typeof makeProtocol>) => unknown[]): Promise<Refusal> {
75+
const m = makeProtocol();
76+
let answered: unknown;
77+
try {
78+
answered = await run(m.p);
79+
} catch (e) {
80+
const err = e as Error & { status?: number; code?: string; param?: string };
81+
for (const spy of reached(m)) expect(spy, 'the engine was reached before the refusal').not.toHaveBeenCalled();
82+
return { message: err.message, status: err.status, code: err.code, param: err.param };
83+
}
84+
throw new Error(`ACCEPTED (answered ${JSON.stringify(answered)}) instead of refused`);
85+
}
86+
87+
const listRefusal = (query: Record<string, unknown>) =>
88+
refusal((p) => p.findData({ object: 'invoice', query }), (m) => [m.find, m.count]);
89+
const getRefusal = (select: unknown) =>
90+
refusal((p) => p.getData({ object: 'invoice', id: 'r1', select }), (m) => [m.findOne]);
91+
92+
// ---------------------------------------------------------------------------
93+
// 1. REFUSAL
94+
// ---------------------------------------------------------------------------
95+
96+
describe('#26644 — an empty projection is refused, naming the spelling the caller wrote', () => {
97+
it.each<[string, Record<string, unknown>]>([
98+
['$select', { $select: '' }],
99+
['$select', { $select: ' ' }],
100+
['$select', { $select: ',' }],
101+
['$select', { $select: ' , ,' }],
102+
['select', { select: '' }],
103+
['fields', { fields: '' }],
104+
['fields', { fields: [] }],
105+
['select', { select: [] }],
106+
])('list: %s in %j answers 400 VALIDATION_ERROR (was: every column)', async (param, query) => {
107+
const err = await listRefusal(query);
108+
109+
expect(err.code).toBe('VALIDATION_ERROR');
110+
expect(err.status).toBe(400);
111+
expect(err.param).toBe(param);
112+
expect(err.message).toContain(`Query parameter '${param}' names no field`);
113+
});
114+
115+
it.each<[unknown]>([[''], [' '], [','], [[]]])('by id: select %j answers 400 VALIDATION_ERROR (was: every column)', async (select) => {
116+
const err = await getRefusal(select);
117+
118+
expect(err.code).toBe('VALIDATION_ERROR');
119+
expect(err.status).toBe(400);
120+
expect(err.param).toBe('select');
121+
expect(err.message).toContain("Query parameter 'select' names no field");
122+
});
123+
124+
it('quotes the value it refused', async () => {
125+
const err = await listRefusal({ $select: ' , ' });
126+
127+
expect(err.message).toContain('got " , "');
128+
});
129+
});
130+
131+
describe('#26644 — a count flag that is not true or false is refused', () => {
132+
it.each<[string, Record<string, unknown>]>([
133+
['$count', { $count: 'maybe' }],
134+
['$count', { $count: '' }],
135+
['$count', { $count: '1' }],
136+
['$count', { $count: 'TRUE' }],
137+
['count', { count: 'yes' }],
138+
['count', { count: 0 }],
139+
['count', { count: 1 }],
140+
])('%s in %j answers 400 VALIDATION_ERROR (was: counted)', async (param, query) => {
141+
const err = await listRefusal(query);
142+
143+
expect(err.code).toBe('VALIDATION_ERROR');
144+
expect(err.status).toBe(400);
145+
expect(err.param).toBe(param);
146+
expect(err.message).toContain(`Query parameter '${param}' must be true or false`);
147+
});
148+
149+
it('quotes the value it refused, bounded', async () => {
150+
const err = await listRefusal({ $count: 'x'.repeat(500) });
151+
152+
expect(err.message).toContain('got "xxxx');
153+
expect(err.message.length).toBeLessThan(400);
154+
});
155+
});
156+
157+
// ---------------------------------------------------------------------------
158+
// 2. PRESERVATION
159+
// ---------------------------------------------------------------------------
160+
161+
describe('#26644 — a well-formed projection and count flag answer exactly as before', () => {
162+
it.each<[Record<string, unknown>, unknown]>([
163+
[{ $select: 'name,status' }, ['name', 'status']],
164+
[{ $select: ' name , status ,' }, ['name', 'status']],
165+
[{ select: 'name' }, ['name']],
166+
[{ fields: ['name'] }, ['name']],
167+
[{}, undefined],
168+
[{ fields: null }, undefined],
169+
])('list: %j → fields %j', async (query, fields) => {
170+
const { p, find } = makeProtocol();
171+
await p.findData({ object: 'invoice', query } as never);
172+
173+
expect(find).toHaveBeenCalledTimes(1);
174+
expect(((find.mock.calls[0] as unknown[])[1] as Record<string, unknown>).fields).toEqual(fields);
175+
});
176+
177+
it.each<[unknown, unknown]>([
178+
['name,status', ['name', 'status']],
179+
[['name'], ['name']],
180+
[undefined, undefined],
181+
[null, undefined],
182+
])('by id: select %j → fields %j', async (select, fields) => {
183+
const { p, findOne } = makeProtocol();
184+
await p.getData({ object: 'invoice', id: 'r1', select } as never);
185+
186+
expect(findOne).toHaveBeenCalledTimes(1);
187+
expect(((findOne.mock.calls[0] as unknown[])[1] as Record<string, unknown>).fields).toEqual(fields);
188+
});
189+
190+
it.each<[Record<string, unknown>, boolean]>([
191+
[{}, true],
192+
[{ $count: 'true' }, true],
193+
[{ count: true }, true],
194+
[{ count: null }, true],
195+
[{ $count: 'false' }, false],
196+
[{ count: false }, false],
197+
])('%j with a window → counted: %s', async (query, counted) => {
198+
const { p, count } = makeProtocol();
199+
// The count runs only for a paged request: unpaged, the page IS the total.
200+
const res: any = await p.findData({ object: 'invoice', query: { $top: '1', ...query } } as never);
201+
202+
expect(count).toHaveBeenCalledTimes(counted ? 1 : 0);
203+
expect('total' in res).toBe(counted);
204+
});
205+
});

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 88 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ import {
156156
foldQueryAliasSlots,
157157
QUERY_TRANSPORT_ALIAS_SLOTS, QUERY_TRANSPORT_DOLLAR_ALIASES, QUERY_TRANSPORT_DOLLAR_PARAMS,
158158
QueryPageWindowParamSchema,
159+
QueryTransportParamsSchema,
159160
type QueryAliasConflict,
160161
type DroppedFieldsEvent, type QueryAST, type EngineQueryOptionsParsed,
161162
type ValidationAdvisoryEvent,
@@ -4621,6 +4622,73 @@ function pageWindowValue(value: unknown, param: string): number {
46214622
throw err;
46224623
}
46234624

4625+
/**
4626+
* A refused query value as the refusal quotes it: JSON, so a blank or
4627+
* whitespace-only value is visible, and bounded, so a long one cannot bury
4628+
* the sentence around it.
4629+
*/
4630+
function quotedQueryValue(value: unknown): string {
4631+
// `String` for a number: `JSON.stringify(Infinity)` is `null`.
4632+
const shown = typeof value === 'number' ? String(value) : JSON.stringify(value) ?? String(value);
4633+
return shown.length > 64 ? `${shown.slice(0, 64)}…` : shown;
4634+
}
4635+
4636+
/**
4637+
* [#26644] A projection (`fields`, spelled `$select` / `select` on the wire) as
4638+
* the list of field names the engine reads, or a refusal naming the parameter
4639+
* the caller wrote.
4640+
*
4641+
* A comma string is split and trimmed as it always was; what is refused is a
4642+
* value that names NO field — a blank or comma-only string, or an empty array.
4643+
* It used to become `[]`, which `assertProjectionFieldsExist` has nothing to
4644+
* check in and every driver serves as no projection: every column, under a
4645+
* 200. Absent means "every column"; an empty projection asks for none, so
4646+
* reading it as absent is the widening {@link pageWindowValue} refuses on the
4647+
* row axis (a blank `$top`), here on the column axis.
4648+
*
4649+
* One rule for both routes that read a projection: the list's `findData` and
4650+
* the by-id `getData`.
4651+
*/
4652+
function projectionFieldsValue(value: string | readonly unknown[], param: string): unknown[] {
4653+
const fields = typeof value === 'string'
4654+
? value.split(',').map((s) => s.trim()).filter(Boolean)
4655+
: [...value];
4656+
if (fields.length > 0) return fields;
4657+
const err: any = new Error(
4658+
`Query parameter '${param}' names no field; got ${quotedQueryValue(value)}. The query was not run: `
4659+
+ 'an empty projection was read as no projection, which answers every column. Name at least one '
4660+
+ 'field, or leave the parameter out to read every column.',
4661+
);
4662+
err.status = 400;
4663+
err.code = 'VALIDATION_ERROR';
4664+
err.param = param;
4665+
throw err;
4666+
}
4667+
4668+
/**
4669+
* [#26644] The response's total-count flag (`count`, spelled `$count` on the
4670+
* wire) as the boolean `findData` reads its opt-out from, or a refusal naming
4671+
* the parameter the caller wrote.
4672+
*
4673+
* The accept set is the spec's `QueryTransportParamsSchema.count`, parsed here
4674+
* rather than restated: the boolean, or the two strings a querystring carries
4675+
* it as (`null` is absent). Any other value used to stay as it was, so the
4676+
* `options.count === false` opt-out read it as "count anyway" — `?$count=maybe`
4677+
* reported a `total` exactly like `?$count=true`.
4678+
*/
4679+
function countFlagValue(value: unknown, param: string): boolean | null | undefined {
4680+
const flag = QueryTransportParamsSchema.shape.count.safeParse(value);
4681+
if (flag.success) return typeof flag.data === 'string' ? flag.data === 'true' : flag.data;
4682+
const err: any = new Error(
4683+
`Query parameter '${param}' must be true or false; got ${quotedQueryValue(value)}. The query was not `
4684+
+ 'run: any other value was read as absent, which counts and reports a total.',
4685+
);
4686+
err.status = 400;
4687+
err.code = 'VALIDATION_ERROR';
4688+
err.param = param;
4689+
throw err;
4690+
}
4691+
46244692
/**
46254693
* [#7321] Refuse a repeated occurrence of any list-query parameter this
46264694
* normalizer reads as single-valued, and normalise the benign one-element array
@@ -12936,10 +13004,14 @@ export class ObjectStackProtocolImplementation implements
1293613004
// querystring param arrives as a bare string — `?fields=name` or the
1293713005
// folded `?select=` / `$select` spellings — which drivers' `.map()`
1293813006
// calls over `query.fields` would otherwise throw on.
13007+
//
13008+
// [#26644] …and a value naming no field is refused rather than becoming
13009+
// `[]`, which every driver serves as no projection at all (see
13010+
// {@link projectionFieldsValue}).
1293913011
const projectionKey = slotParam('fields');
12940-
if (typeof options.fields === 'string') {
12941-
options.fields = options.fields.split(',').map((s: string) => s.trim()).filter(Boolean);
12942-
} else if (options.fields !== undefined && !Array.isArray(options.fields)) {
13012+
if (typeof options.fields === 'string' || Array.isArray(options.fields)) {
13013+
options.fields = projectionFieldsValue(options.fields, projectionKey);
13014+
} else if (options.fields !== undefined) {
1294313015
delete options.fields;
1294413016
}
1294513017
// [#4226] Unknown projection columns are refused rather than dropped —
@@ -13113,11 +13185,12 @@ export class ObjectStackProtocolImplementation implements
1311313185
);
1311413186
if (narrowedSearchFields) options.searchFields = narrowedSearchFields;
1311513187

13116-
// Boolean fields
13117-
for (const key of ['distinct', 'count']) {
13118-
if (options[key] === 'true') options[key] = true;
13119-
else if (options[key] === 'false') options[key] = false;
13120-
}
13188+
// Boolean fields. [#26644] `count` is CHECKED, not merely lowered: a
13189+
// value that is neither true nor false is refused rather than left to
13190+
// read as "count anyway" below (see {@link countFlagValue}).
13191+
if (options.distinct === 'true') options.distinct = true;
13192+
else if (options.distinct === 'false') options.distinct = false;
13193+
if (options.count != null) options.count = countFlagValue(options.count, slotParam('count'));
1312113194

1312213195
// [#2926 ⑩] Every supported OData-style `$` alias has been consumed and
1312313196
// deleted above ($top/$skip/$orderby/$select/$count/$search/
@@ -13511,9 +13584,13 @@ export class ObjectStackProtocolImplementation implements
1351113584
// widened projection would recreate, one route over, the very split
1351213585
// ("two routes, opposite answers for one input") this issue was filed
1351313586
// about.
13514-
if (request.select) {
13515-
queryOptions.fields = typeof request.select === 'string'
13516-
? request.select.split(',').map((s: string) => s.trim()).filter(Boolean)
13587+
//
13588+
// [#26644] …including the empty projection: a blank `?select=` was
13589+
// falsy and skipped, a comma-only one became `[]` — both answered every
13590+
// column. Asked on any value present, as the list path asks it.
13591+
if (request.select != null) {
13592+
queryOptions.fields = typeof request.select === 'string' || Array.isArray(request.select)
13593+
? projectionFieldsValue(request.select, 'select')
1351713594
: request.select;
1351813595
this.assertProjectionFieldsExist(request.object, queryOptions.fields, 'select');
1351913596
}

0 commit comments

Comments
 (0)