fix(metadata-protocol): refuse a dotted projection instead of widening the response to every field (#7532) - #7588
Conversation
…g to every field (#7532) `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. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TXK7QuSmTF3DKWji91cmkn
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also reference the affected code. These are read-only:
|
Fixes #7532
What was wrong
POST /api/v1/data/showcase_invoice/query {"fields":["name","account.name"]}answered200carrying every business field — strictly more data than was asked for — and no resolvedaccount.name.GET ...?$select=name,account.namedid the same.assertProjectionFieldsExistvalidated onlyf.split('.')[0], so a dotted entry cleared the #4226 unknown-name gate on its head segment (accountreally is a field) and travelled on to the driver as a projection column.Where the widening actually happens
The card attributed the fallback to the ingress gate. It is one layer lower, and I measured both:
protocol.findDataagainst a stub driver,fields:['title','project_id.name']reaches the driver as["title","project_id.name"]— forwarded verbatim.SqlDriverwidens it. Driven against a realSqlDriver(better-sqlite3,:memory:), callingdriver.finddirectly:The dotted rows are byte-identical to no projection at all. Knex renders
"account"."name"against a table that was never joined, sqlite answersno such column, and the #3821 recovery ladder retriesselect('*')because rows matter more than the projection.Per ruling, this PR closes the ingress gate and leaves the driver ladder alone;
packages/driver-sqlis untouched. The driver behaviour is filed separately as a finding: #7589, carrying this measurement and a drop-in reproduction harness. It is defence-in-depth behind this gate rather than a live user-facing defect once this lands — but it still governs every caller that reaches SqlDriver without passing this ingress (hooks, flows, reports, expand sub-reads, registry-less hosts).What this does
A dotted entry on the projection axis is now
400 INVALID_FIELD, orderedunknown>dottedso this axis reports the same complaint first asassertSortFieldsExistalready does. Two messages, because the fixes differ:expand(or denormalising the value onto the queried object).expandwould be the wrong prescription here.This also settles the card's second complaint: an unknown plain column was a
400while an unknown dotted one was a200with every field — one mistake, opposite verdicts on one endpoint, depending on spelling.Precedent. #5918 faced this exact shape on the analytics
measuresaxis and ruled the same way — refuse the dotted member loudly, naming the caller's original spelling, because there is no correct answer to converge on. That is also the distinction from #5739, where refusing would have rejected queries that already compiled correctly.Is
expandthe sanctioned mechanism for related data on this door?Yes — and after this PR it is the only one. Confirmed against
query-syntax.mdx§4 and the spec:expandresolves reference fields into the related record via batched$inreads, driver-agnostic, and its nestedQueryASTcan both filter (where) and select (fields) the related record's columns. It is what the refusal points at, and a GUARD test pins that it still delivers.One sharp edge, measured while writing that test and pinned: the projection must retain the foreign-key column.
fields:['title'], expand:'project_id'projects the FK away and expansion has nothing to resolve;fields:['title','project_id']works. Worth knowing since this PR now routes callers here.Several in-repo surfaces still offer a dotted
fieldspath as the way to read one related column. Every one of them describes behaviour no driver implements — they were already wrong before this PR, and they are now also contradicted by a400:fieldsdescription (normative, generates the JSON Schema + contract docs)packages/spec/src/data/query.zod.ts:484packages/spec/src/recursive-schema-input-assertions.ts:74,99query.joinsretirement prescriptionpackages/spec/src/migrations/entries/semantic/17.query-joins-retired.ts:10.../17.query-field-node-object-form-retired.ts:8packages/spec/json-schema/api/{FindDataRequest,ExportRequest}.json,objectstack.jsoncontent/docs/protocol/objectql/query-syntax.mdx:140,972;content/docs/references/data/query.mdx:125,134;content/docs/references/api/contract.mdx:407Aligning these is cross-package spec/docs surface that regenerates artifacts, and doing it inside this PR while #7534 is in flight is exactly the collision the serialization constraint exists to prevent.
Filed as #7601 — carrying this table (normative surface first), the measurement that every one of them describes behaviour no driver implements, the coherent-split note (the zod schema still parses a dotted string because it is a shape check — the fix is the
.describe()prose and the generated artifacts, not the schema type), and theexpandforeign-key edge below so the corrected prose carries it. Ruled a restore-invariant fix (declared = enforced), not an open product question; scheduling is what kept it out of this PR.Note the zod schema still parses a dotted string (it is a shape check,
z.string()); the semantic refusal is this ingress gate. That split is coherent, but the.describe()prose is now misleading.File surface
packages/metadata-protocol/src/protocol.tsassertProjectionFieldsExistgains the dotted verdict after the unknown one; shape-rejection hint no longer prescribesselect=owner.name; docblock records the measurementpackages/objectql/src/query-expression-conformance.test.ts.changeset/dotted-projection-refused.md@objectstack/metadata-protocolresolveQueryFieldsis unchanged — the projection path only calls into it.packages/driver-sqlis untouched.The inverted test
query-expression-conformance.test.tscarriedit('a dotted path is still accepted — the replacement the rejection prescribes'), assertingfindData({select:'parent_id.title'})resolves, on the reasoning that "the head segment is validated here, the tail resolved downstream". The tail is resolved nowhere — the measurement above is what that test was actually protecting. It now asserts the refusal and records why it flipped.Reverse verification
Predictions written before running. Source reverted via patch file (never
git stash), new tests retained.POST /querybodyfieldsGET ?$select=GET ?select=fieldscomma stringexpandexpandstill delivers related record8 failed | 122 passedon revert — exactly the 8 predicted, no others. No missed predictions.The narrowing assertions are pinned as exact key sets (
expect(Object.keys(record).sort()).toEqual([...])), not absence checks — an over-return defect passes any assertion written only as "does not contain X".Gates run
@objectstack/metadata-protocolsuite@objectstack/objectqlsuite@objectstack/restsuite (direct consumer)tsc --noEmit(metadata-protocol)protocol.tseslint --no-inline-configon both changed filesConsumption radius
assertProjectionFieldsExistisprivatewith exactly two call sites, both inprotocol.ts—findData(the list door:POST /querybodyfields,GET ?$select=/?select=, all folded intofieldsbyWIRE_QUERY_ALIAS_SLOTSbefore the gate) andgetData(GET /:object/:id?select=). Both card doors verified to route through it. No external callers.Swept
packages/andexamples/for non-test source passing a dotted projection: none. Nothing in-tree breaks.Not measured
Mongo and the memory driver (both resolve dotted paths against the row, where a FK is a scalar id — likely
undefinedrather than a widening, but I did not drive them). Live HTTP; I measured atprotocol.findDataand atdriver.find, not over the wire. Showcase seed data.🤖 Generated with Claude Code
https://claude.ai/code/session_01TXK7QuSmTF3DKWji91cmkn
Generated by Claude Code