Skip to content

Commit 8201000

Browse files
claude[bot]claude
andauthored
fix(metadata-protocol): refuse a dotted projection instead of widening to every field (#7532) (#7588)
`POST /api/v1/data/:object/query {"fields":["name","account.name"]}` answered 200 carrying every business field — strictly more than was asked for — and no resolved `account.name`. `GET ?$select=name,account.name` did the same. `assertProjectionFieldsExist` validated only `f.split('.')[0]`, so a dotted entry cleared the #4226 unknown-name gate on its head segment and reached the driver as a projection column. Measured on a real SqlDriver (better-sqlite3), a dotted projection comes back byte-identical to no projection at all: Knex renders `"account"."name"` against a table that was never joined, sqlite answers `no such column`, and the #3821 recovery ladder retries `select('*')`. A dotted entry is now 400 INVALID_FIELD, ordered `unknown` > `dotted` to match `assertSortFieldsExist`. The message names the relationship it crossed and sends the caller to `expand`; a dotted path on a real non-reference column gets its own wording. Follows #5918, which ruled the same way on the analytics measures axis for the same reason — there is no correct answer to converge on. The shape rejection's hint no longer prescribes `select=owner.name`: it pointed at the widening defect, the dead end #6924 removed from the sort axis' hint. `resolveQueryFields` is unchanged (additive call only, #7534 in flight on the filter axes). SqlDriver's recovery ladder is deliberately untouched and filed separately. Claude-Session: https://claude.ai/code/session_01TXK7QuSmTF3DKWji91cmkn Co-authored-by: Claude <noreply@anthropic.com>
1 parent af5918b commit 8201000

3 files changed

Lines changed: 294 additions & 25 deletions

File tree

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
---
4+
5+
fix(metadata-protocol): refuse a dotted `fields`/`$select` entry instead of widening the response to every field (#7532)
6+
7+
`POST /api/v1/data/:object/query` with `{"fields":["name","account.name"]}` answered
8+
`200` carrying **every** business field — strictly more data than was asked for — and
9+
no resolved `account.name`. `GET /api/v1/data/:object?$select=name,account.name` did
10+
the same. A parameter whose entire purpose is to return LESS had "return more" as its
11+
failure mode, pointing away from both FLS and data minimisation.
12+
13+
`assertProjectionFieldsExist` validated only `f.split('.')[0]`, so a dotted entry
14+
cleared the #4226 unknown-name gate on its **head** segment (`account` really is a
15+
field) and travelled on to the driver as a projection column. Measured on a real
16+
`SqlDriver` (better-sqlite3):
17+
18+
```
19+
no projection -> account amount created_at id name status updated_at
20+
fields ['name'] -> name (a plain name narrows)
21+
fields ['name','account.name'] -> account amount created_at id name status updated_at
22+
fields ['account.name'] -> account amount created_at id name status updated_at
23+
```
24+
25+
The dotted rows are byte-identical to no projection at all. Knex renders
26+
`"account"."name"` against a table that was never joined, sqlite answers `no such
27+
column`, and the driver's #3821 recovery ladder retries `select('*')` because rows
28+
matter more than the projection.
29+
30+
A dotted entry on this axis is now `400 INVALID_FIELD`, with the `unknown` > `dotted`
31+
precedence {@link assertSortFieldsExist} already applies, so the two axes report the
32+
same complaint first. The message names the relationship it tried to cross and sends
33+
the caller to `expand` — the sanctioned door for related data here — or to
34+
denormalising the value onto the queried object. A dotted path whose head is a real
35+
but non-reference column gets its own wording, since `expand` would be the wrong
36+
prescription for it.
37+
38+
This also settles the second half of the report: an unknown **plain** column was a
39+
`400` while an unknown **dotted** one was a `200` with every field, so one mistake got
40+
opposite verdicts on one endpoint depending on how it was spelled. Both doors —
41+
`POST /query` body `fields` and `GET ?$select=` — fold into the same slot before the
42+
gate and are pinned separately.
43+
44+
Nothing that worked stops working: no driver ever resolved these paths. Plain
45+
projections still narrow, unknown plain columns still refuse per #4226, an unknown
46+
head still gets the unknown-name verdict with its did-you-mean, and `expand` still
47+
delivers related records. The engine's internal-caller projection tolerance and
48+
`SqlDriver`'s recovery ladder are deliberately untouched — refusing at ingress is what
49+
stops a request reaching them with a projection no driver can apply.

packages/metadata-protocol/src/protocol.ts

Lines changed: 116 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5597,6 +5597,52 @@ export class ObjectStackProtocolImplementation implements
55975597
* narrowing it cost no caller anything; nothing equivalent has been measured
55985598
* for the projection axis, and this sentence is not a licence to assume it.
55995599
*
5600+
* [#7532] The DOTTED leg, which this gate used to pass on its head
5601+
* segment. `f.split('.')[0]` is what let `fields=['name','account.name']`
5602+
* through: `account` IS a field, so the entry cleared the unknown-name
5603+
* check above and reached the driver as a projection column. Measured at
5604+
* that commit on a REAL `SqlDriver` (better-sqlite3), against the same
5605+
* object the card reports:
5606+
*
5607+
* ```
5608+
* no projection -> account amount created_at id name status updated_at
5609+
* fields ['name'] -> name (a plain name narrows)
5610+
* fields ['name','account.name'] -> account amount created_at id name status updated_at
5611+
* fields ['account.name'] -> account amount created_at id name status updated_at
5612+
* ```
5613+
*
5614+
* The dotted rows are BYTE-IDENTICAL to no projection at all — the exact
5615+
* "asked for less, received more" this axis' first paragraph describes,
5616+
* reached by a different route. Knex renders `"account"."name"` against a
5617+
* table that was never joined, sqlite answers `no such column`, and
5618+
* `SqlDriver`'s #3821 recovery ladder retries `select('*')` because rows
5619+
* matter more than the projection. That ladder is a DRIVER-side tolerance
5620+
* for internal callers and is deliberately left alone here (filed
5621+
* separately as defence-in-depth); refusing at this ingress is what stops a
5622+
* request from reaching it carrying a projection no driver can apply.
5623+
*
5624+
* It also settles the card's second complaint: an unknown PLAIN column was
5625+
* a 400 while an unknown DOTTED one was a 200 with every field, so one
5626+
* mistake got opposite verdicts on one endpoint depending on spelling.
5627+
*
5628+
* The governing precedent is #5918 on the analytics MEASURES axis, which
5629+
* faced this exact shape and ruled the same way: refuse the dotted member
5630+
* loudly, naming the caller's original spelling, *because there is no
5631+
* correct answer to converge on*. That is the distinction from #5739, where
5632+
* refusing would have rejected queries that already compiled correctly.
5633+
* Here — as there — nothing resolved these paths, so both the typo
5634+
* (`titel.name`) and the genuine traversal intent (`account.name`) eat this
5635+
* 400: the two are not separable at this door, and the alternative is the
5636+
* over-return above.
5637+
*
5638+
* NOT a removal of a working feature — nothing resolved these paths. The
5639+
* spec's `fields` description, `query-syntax.mdx`, `data/query.mdx` and the
5640+
* `query.joins` / nested-select retirement prescriptions all still offer a
5641+
* dotted `fields` path as the way to read one related column; every one of
5642+
* them describes behaviour no driver implements. Aligning that prose with
5643+
* `expand` is spec/docs surface with its own blast radius and is called out
5644+
* on the PR rather than smuggled in here.
5645+
*
56005646
* [#4196] It also owns the projection's SHAPE, which is a different
56015647
* question from its names and is answered first — see below.
56025648
*/
@@ -5620,9 +5666,15 @@ export class ObjectStackProtocolImplementation implements
56205666
? ' The nested-select object form `{ field, fields, alias }` was removed in '
56215667
+ '@objectstack/spec 17 (#4196) — no engine or driver ever read it.'
56225668
: '')
5623-
+ " Select related records with `expand` (`expand=owner` / `{ expand: { owner: "
5624-
+ "{ object: 'user', fields: ['name'] } } }`), or name one related column with a "
5625-
+ 'dotted path (`select=owner.name`).',
5669+
// [#7532] The dotted-path half of this prescription is GONE.
5670+
// It pointed at a spelling this same gate now refuses — and
5671+
// before that refusal it pointed at a spelling no driver
5672+
// resolves, which answered with every field. Naming it here
5673+
// sent the author from one refusal straight into the widening
5674+
// defect, the same dead end #6924 removed from the SORT axis'
5675+
// hint. `expand` is the one door for related data on this axis.
5676+
+ " Select related records with `expand` (`expand=owner`, or `{ expand: { owner: "
5677+
+ "{ object: 'user', fields: ['name'] } } }` to choose its columns).",
56265678
);
56275679
err.code = 'INVALID_FIELD';
56285680
err.status = 400;
@@ -5632,24 +5684,68 @@ export class ObjectStackProtocolImplementation implements
56325684
}
56335685
const gate = this.resolveQueryFields(object);
56345686
if (!gate) return;
5635-
const unknown = (fields as string[]).filter((f) => !gate.known.has(f.split('.')[0]));
5636-
if (unknown.length === 0) return;
5637-
const first = unknown[0];
5638-
const err: any = new Error(
5639-
`Unknown field '${first}' on object '${object}'`
5640-
+ (unknown.length > 1 ? ` (also: ${unknown.slice(1).join(', ')})` : '')
5641-
+ `. '${param}' chooses which fields to return; dropping an unknown one silently `
5642-
+ 'answered a NARROWER projection with a WIDER one — a projection naming no known '
5643-
+ 'field fell all the way back to every field.'
5644-
+ suggestFieldName(first, gate.declared),
5687+
const names = fields as string[];
5688+
const unknown = names.filter((f) => !gate.known.has(f.split('.')[0]));
5689+
if (unknown.length > 0) {
5690+
const first = unknown[0];
5691+
const unknownErr: any = new Error(
5692+
`Unknown field '${first}' on object '${object}'`
5693+
+ (unknown.length > 1 ? ` (also: ${unknown.slice(1).join(', ')})` : '')
5694+
+ `. '${param}' chooses which fields to return; dropping an unknown one silently `
5695+
+ 'answered a NARROWER projection with a WIDER one — a projection naming no known '
5696+
+ 'field fell all the way back to every field.'
5697+
+ suggestFieldName(first, gate.declared),
5698+
);
5699+
unknownErr.code = 'INVALID_FIELD';
5700+
unknownErr.status = 400;
5701+
unknownErr.field = first;
5702+
unknownErr.fields = unknown;
5703+
unknownErr.object = object;
5704+
unknownErr.param = param;
5705+
throw unknownErr;
5706+
}
5707+
// [#7532] The DOTTED verdict — the leg the head-segment check above
5708+
// does not cover, and the one that made this axis fail in the very
5709+
// direction its own docblock warns about.
5710+
//
5711+
// Ordered `unknown` > `dotted`, the same precedence
5712+
// {@link assertSortFieldsExist} applies, so the two axes agree about
5713+
// which complaint a caller hears first when an entry is both.
5714+
//
5715+
// It sits AFTER the `gate` early-return for the same reason the sort
5716+
// axis' dotted verdict does: the relation-vs-not split below reads
5717+
// `gate.fields`, and a registry-less host has no field map to read.
5718+
const dotted = names.filter((f) => f.includes('.'));
5719+
if (dotted.length === 0) return;
5720+
const first = dotted[0];
5721+
const head = first.split('.')[0];
5722+
const headDef: any = gate.fields[head];
5723+
const crossesRelation = headDef != null && REFERENCE_VALUE_TYPES.has(headDef.type);
5724+
const dottedErr: any = new Error(
5725+
(crossesRelation
5726+
? `Field '${first}' on object '${object}' follows the relationship '${head}' into `
5727+
+ `another object — '${param}' reaches only columns of '${object}' itself`
5728+
: `Field '${first}' on object '${object}' is a dotted path — '${param}' reaches only `
5729+
+ `whole columns of '${object}', not values inside them`)
5730+
+ (dotted.length > 1 ? ` (also: ${dotted.slice(1).join(', ')})` : '')
5731+
+ '. No driver resolves it: the path reaches the driver as a column name, matches no '
5732+
+ 'column, and the projection falls back to EVERY field — a narrower request answered '
5733+
+ 'with a wider response, which is the same failure the unknown-name refusal above '
5734+
+ 'exists to stop.'
5735+
+ (crossesRelation
5736+
? ` Read the related record with 'expand' (\`expand=${head}\`, or `
5737+
+ `\`{ expand: { ${head}: { object: '<target>', fields: ['<column>'] } } }\` to `
5738+
+ `choose its columns), or denormalise the value onto '${object}' (a stored `
5739+
+ 'field, written when the source changes) and name that.'
5740+
: ` Name the whole column ('${head}') and read into its value in the caller.`),
56455741
);
5646-
err.code = 'INVALID_FIELD';
5647-
err.status = 400;
5648-
err.field = first;
5649-
err.fields = unknown;
5650-
err.object = object;
5651-
err.param = param;
5652-
throw err;
5742+
dottedErr.code = 'INVALID_FIELD';
5743+
dottedErr.status = 400;
5744+
dottedErr.field = first;
5745+
dottedErr.fields = dotted;
5746+
dottedErr.object = object;
5747+
dottedErr.param = param;
5748+
throw dottedErr;
56535749
}
56545750

56555751
/**

0 commit comments

Comments
 (0)