Skip to content

fix(objectql): force the join key into the expand sub-read, strip it when unrequested (#7537) - #7594

Merged
os-help merged 4 commits into
mainfrom
claude/issue-7537-expand-nested-fields-id
Aug 11, 2026
Merged

fix(objectql): force the join key into the expand sub-read, strip it when unrequested (#7537)#7594
os-help merged 4 commits into
mainfrom
claude/issue-7537-expand-nested-fields-id

Conversation

@os-help

@os-help os-help commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Fixes #7537

Premise: verified against origin/main @ 97b6658, and it still holds

The issue was filed against a86db175; engine.ts has moved since. Re-checked on 97b6658expandRelatedRecords is still at :6168, still forwards nestedAST.fields verbatim at :6292, still keys recordMap on rec.id at :6301, and the injection still falls through recordMap.get(String(val)) ?? val at :6330. The root-cause paragraph in the issue is accurate as written.

Empirically reproduced before touching anything. The new integration test failed 3/3 on the pre-fix tree, with exactly the reported symptom:

× expands with `fields: ["name"]`   → expected 'string' to be 'object'
× second object pair (task→project) → expected 'uHiWgyMU_yePSp2T' to equal { name: 'Apollo' }
× findOne takes the same path       → expected 'xYvxWjuwNasB1pNK' to equal { name: 'Acme' }

The two control cases (fields: ['id','name'], and no projection at all) passed pre-fix — matching the issue's "adding id expands correctly".

Two measurements the issue did not have

1. The defect is driver-conditional, which is why no existing suite caught it. SqlDriver emits the requested column list verbatim (builder.select(query.fields), sql-driver.ts:3383) so it genuinely drops id. InMemoryDriver.projectFields force-adds the primary key to every projection ("Always include id if not explicitly listed", memory-driver.ts:1370) so its rows always carry the join key and the map is never empty. The engine's own expand pins all run on mocks that return whole rows. That is why a better-sqlite3 QA run found this and 3157 objectql tests did not — and why the end-to-end pin here is wired to a real SqlDriver rather than a mock.

2. The join key is always literally id — the PM's "is it configurable?" fork resolves. referenceTargetOf (field-value.zod.ts:132) returns a target object name and nothing else; a lookup/master_detail/tree/user field carries no target-column metadata anywhere in spec. (relationshipField/foreignKey in field.zod.ts:712 names the child's FK field for related lists, not a target key on the parent.) The batch filter is { id: { $in: uniqueIds } } by construction, so forcing id is correct rather than a guess at a configurable key.

Route: add-and-strip, not refuse

The issue offered "add-and-strip or refuse the mismatch". Add-and-strip, because the failing spelling is prescribed verbatim by the platform itself — FIELD_NODE_OBJECT_FORM_REMOVED (query.zod.ts:322) and QUERY_JOINS_REMOVED (:359) both tell authors migrating off the retired forms to write expand: { owner: { object: 'user', fields: ['name'] } }, and the same string appears in three more places: migrations/registry.ts:3062, :3083, and the two semantic migration entries 17.query-field-node-object-form-retired.ts / 17.query-joins-retired.ts. A refusal would turn five documented migration targets into an error. Making the prescribed form work is the only answer with no collateral.

Change

packages/objectql/src/engine.tsexpandRelatedRecords:

  • The sub-read's projection gets id appended when the caller's nested fields is a non-empty array that does not already name it. Non-triggering shapes (absent fields, [], fields already containing id) forward exactly as before.
  • The key is stripped back out of each related record before injection, so the emitted nested record carries exactly the columns the projection named — the same contract a top-level fields gets. Deliberately after the recursive-expand block, which rebuilds recordMap keyed on rec.id and still needs it; stripping at sub-read time would break the inner level the same way the outer one was broken.

All four entry points route through this one helper (find :6805, findOne :6944, the recursion :6307), so the fix covers them all — confirmed by test rather than by reading: the findOne case failed pre-fix and passes now, and there is a dedicated recursive-expand pin.

Behaviour change worth flagging

A nested projection that omits id now emits a nested record without id. Previously the outcome was driver-dependent: SQL-backed stores produced the no-op above (so there was no .id to read — the value was a plain FK string), while InMemoryDriver returned an expanded record carrying an unrequested id. Both now emit exactly the projected columns. Callers wanting the key add id to the nested fields. Recorded in the changeset.

Tests

packages/runtime/src/expand-nested-fields-join-key.integration.test.ts (new, real ObjectQL + real SqlDriver on better-sqlite3, on-disk) — 5 cases:

  • the prescribed fields: ['name'] spelling expands, and the nested record equals { name: 'Acme' } exactly (the unrequested industry absent, id absent);
  • fields: ['id','name'] keeps the key (the issue's workaround spelling stays intact);
  • the issue's second object pair showcase_task → project, since the report confirmed 2/2;
  • findOne (single-record entry point);
  • no projection at all → whole related record, unchanged.

packages/objectql/src/engine.test.ts — 4 unit pins under [#7537], asserting the substance at the seam:

  • the sub-read is actually called with fields: ['name','id'] while the emitted record is { name: 'Alice' };
  • a caller-supplied ['id','name'] is forwarded untouched;
  • array-valued (multiple: true) expansion strips too;
  • a recursive nested expand resolves both levels and strips the key at both.

No refusal path was added, so ADR-0112 envelope assertions do not apply.

Gates

All six card gates run locally, all pass: check:adr-anchors, check:durability-log-level, check:engine-double-contract (148 pinned / 133 DEBT / 2 exempt), check:stack-collection-maps, scripts/check-engine-split-ratio.mjs, check:nul-bytes (7015 files). Build closure: full pnpm build, 71/71 tasks. Suites: objectql 3157/3157, runtime 1995/1995, rest 1344/1344. typecheck clean on both changed packages; eslint clean on all three changed files. CI owns the rest of the farm.


Generated by Claude Code

…when unrequested (#7537)

`expand: { account: { object: 'showcase_account', fields: ['name'] } }` answered
200 with `account` still holding the raw foreign-key id: the expansion silently
did not happen, and because the fallback is the caller's own id, nothing in the
response distinguished "expanded" from "not expanded".

`expandRelatedRecords` forwarded `nestedAST.fields` to the sub-read verbatim and
then keyed its lookup map on `rec.id`. A projection that did not name `id`
produced rows with no `id`, an empty map, and an injection that fell through
`recordMap.get(String(val)) ?? val`, writing the original FK back.

The failing spelling is the one the platform prescribes: both
FIELD_NODE_OBJECT_FORM_REMOVED and QUERY_JOINS_REMOVED (plus their three
migration-registry entries) tell authors to write exactly that form. Refusing the
mismatch would turn two retirement messages' own migration target into an error,
so the prescribed form is made to work: `id` is added to the sub-read
unconditionally and stripped back out of the emitted nested record when the
caller did not ask for it. The strip runs after the recursive expand, which
rebuilds its map on `rec.id` and still needs the key.

`id` is the only join key there is — `referenceTargetOf` yields a target OBJECT
and a reference field carries no target-column metadata, so the batch filter is
`{ id: { $in } }` by construction.

Pins: a real-SqlDriver integration test (the defect is invisible to
InMemoryDriver, which force-adds `id` to every projection) covering both object
pairs from the QA repro plus `findOne`, and four engine unit pins covering the
forced projection, the untouched `['id','name']` case, array-valued expansion,
and the recursive nested expand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJVZqLviCkZMJV8Nh3DUao
@vercel

vercel Bot commented Aug 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 11, 2026 7:10am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql.

15 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/objectql)
  • content/docs/data-modeling/formulas.mdx (via packages/objectql)
  • content/docs/deployment/migration-from-objectql.mdx (via @objectstack/objectql)
  • content/docs/deployment/vercel.mdx (via @objectstack/objectql)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/objectql)
  • content/docs/kernel/runtime-services/examples.mdx (via packages/objectql)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/objectql)
  • content/docs/kernel/services.mdx (via @objectstack/objectql)
  • content/docs/permissions/authentication.mdx (via @objectstack/objectql)
  • content/docs/permissions/system-context.mdx (via packages/objectql)
  • content/docs/plugins/index.mdx (via @objectstack/objectql)
  • content/docs/plugins/packages.mdx (via @objectstack/objectql)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/objectql)
  • content/docs/protocol/objectql/query-syntax.mdx (via packages/objectql)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/objectql)

1 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx (via @objectstack/objectql)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

claude added 3 commits August 11, 2026 06:46
…quired in a nested `fields` (#7537)

The page's own documented output (`account: { company_name: 'Acme Corp' }`) was
already the intended behaviour; the implementation was what had drifted. Now that
the join key is added to the sub-read and stripped when unrequested, say so
explicitly, so an author reading the retirement messages' prescribed spelling can
see it is complete.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJVZqLviCkZMJV8Nh3DUao
)

check:query-options-erasure counted the `as any` on the new findOne case as a
new untyped-options site (test surface 249 → 250). The input is on-contract —
`where` + `expand` are both `EngineQueryOptions` members — so the cast was
noise, not a deliberate contract bypass. Removing it satisfies the ratchet the
way the check asks for (type the options) rather than by spending the
`as unknown as EngineQueryOptions` escape hatch, which is reserved for tests
that must build genuinely off-contract input.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJVZqLviCkZMJV8Nh3DUao
…ck (#7537)

check:type-check-debt went red: objectql's TEST_DEBT is a ratchet, and the four
new pins added six raw tsc errors to it — three `vi.mocked(SchemaRegistry.getObject)`
sites, each reported twice (TS2339, since `getObject` is not on the static type,
plus TS7006 on the untyped `name`). The surrounding file is full of that shape,
but it is frozen debt, not a pattern to extend.

The block now goes through one `mockSchema` helper that names the cast once and
types the callback, so the new pins are error-neutral: the test layer measures
353 both with and without them.

Measured while isolating this: the ledger's recorded 355 is already 2 above what
origin/main's own engine.test.ts reports (353) on this base. That gap is
pre-existing and the gate treats it as informational, so it is left for a
deliberate --lower rather than ridden down by this PR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJVZqLviCkZMJV8Nh3DUao
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

expand is a silent no-op when the nested fields omits id — the exact spelling two retirement messages prescribe

2 participants