refactor(executor): @fieldPolicy direct-reference resolution + CacheReadStrategy memo (PR-009d-iii) - #1016
Conversation
✅ Docs preview readyThe preview is ready to be viewed. View the preview File Changes 0 new, 1 changed, 0 removedBuild ID: ccff8ea8b05fa33182ce911e URL: https://www.apollographql.com/docs/deploy-preview/ccff8ea8b05fa33182ce911e ✅ AI Style Review — No Changes DetectedNo MDX files were changed in this pull request. Review Log: View detailed log
|
2887aa4 to
d140cab
Compare
3dfe29d to
271bd92
Compare
|
Note from a code review of the stack: this PR bundles three coherent-but-distinguishable changes:
The reviewer flagged that a reviewer working commit-by-commit can miss that the load-bearing fix is essentially two small changes ( Other items the review surfaced that are out of scope to fix here (tracking for follow-ups):
|
d140cab to
6043884
Compare
271bd92 to
cceb141
Compare
…eadStrategy memo (PR-009d-iii)
Bundles three coherent read-path changes: the `FieldExecutionInfo`
memo PR-009d-iii was originally scoped to add, a fix for the
`@fieldPolicy` write/read asymmetry the round-trip test exposed,
and a rename of two confusingly-similar identifiers.
## The bug
A query with both `@typePolicy(keyFields:)` on the return type and
`@fieldPolicy(keyArgs:)` on the parent field couldn't round-trip
through the cache: a network fetch wrote `QUERY_ROOT["hero(name:Luke)"]
-> CacheReference("Hero:Luke")` and `Hero:Luke = {…}`, but a
subsequent cache-only read missed entirely. The reader was
subscripting `QUERY_ROOT["Hero:Luke"]` (the `@fieldPolicy`-resolved
name), which the writer never stored — the writer is policy-agnostic
and only honors `@typePolicy` at the child-record level.
The docs describe `@typePolicy` and `@fieldPolicy` as complementary
directives that *compute matching cache keys via different mechanisms*,
not redundant write paths. Apollo Kotlin's `FieldPolicyCacheResolver`
confirms the intended semantic: when a field policy applies, the
resolver returns a `CacheKey` target directly and never subscripts
the parent record. The Swift implementation's `object[policyKey]`
subscript was wrong; it happened to pass existing tests only because
every test manually published records with the policy-resolved name
present on the parent.
## The fix (read-side only)
- `CacheDataExecutionSource.resolveCacheKey` now returns
`CacheReference(key)` directly for `@fieldPolicy`-redirected fields,
bypassing the parent-record subscript. The executor's existing
`CacheReference` resolution loads the canonical record.
- `FieldProjectionCollector` skips emitting a parent-record
projection for policy-redirected fields — there's nothing to load
on the parent.
- `ReadTransaction.loadObject` returns an empty `Record(fields: [:])`
placeholder when the projection set is empty (all fields are policy
redirects), instead of throwing `missingValue` from a vacuous parent
lookup. Matches Apollo Kotlin: a policy-resolved field does not
require the parent record to exist.
No writer / normalizer / on-disk format changes. The fix is fully
contained in the read path and the per-field strategy enum.
## The rename
The pre-fix code had two methods with confusingly-similar names:
- `info.cacheKeyForField() -> String` — the field's normalized name
in a record, used by the writer to key the parent-record entry and
by the cache-path machinery as a path segment. Always the plain
`field.cacheKey(with: variables)`.
- `info.field.cacheFieldKey(…) -> CacheFieldKey` — the read-side
policy-aware resolution.
Renamed to make the asymmetry explicit:
- `cacheKeyForField()` → `normalizedFieldName()`; `_cacheKeyForField`
memo → `_normalizedFieldName`. The writer's identity.
- `cacheFieldKey(…)` → `cacheReadStrategy(…)`; `CacheFieldKey` enum
→ `CacheReadStrategy`. The reader's resolution.
File renamed: `Selection.Field+CacheFieldKey.swift` →
`Selection.Field+CacheReadStrategy.swift`.
`CacheReadStrategy`'s cases now encode the policy/non-policy
distinction directly:
- `.parentRecordKey(String)` — subscript `parent[name]` (standard).
- `.policyReference(String)` — `CacheReference(key)` directly
(`@fieldPolicy`).
- `.policyReferenceList([String])` — `[CacheReference(k1), …]` for
list-typed policy fields.
The resolver and the projection collector both switch on this enum.
Their behavior is mechanically distinct by the type system, not by
parallel-but-divergent code.
## The memo (original PR-009d-iii scope)
`FieldExecutionInfo._cacheReadStrategy: CacheReadStrategy?` mirrors
the existing `_normalizedFieldName` memo. `info.cacheReadStrategy()`
caches `field.cacheReadStrategy(variables:schema:responsePath:)`
on first call. The projection-time collector and the per-field
resolver now share a single policy evaluation per `(field, info)`
pair, eliminating the resolver-side recompute that the original ADR
slot targeted.
## Tests
- New: `FieldPolicyTests.test_fieldPolicy_withMatchingTypePolicy_networkFetchPopulatesCache_andCacheReadResolves`
covers the network-write → cache-read round trip with both
directives applied. Asserts the *correct* on-disk layout (writer
uses the normalized name, `@typePolicy` keys the canonical record,
no `@fieldPolicy` name appears on the parent record), then
verifies the cache-only read succeeds via the policy redirect.
This was failing pre-fix; now passes.
- Full Apollo-UnitTestPlan: 1116 passed, 0 failed, 11 not-run
(pre-existing environment-dependent file I/O and concurrency
stress tests — same as on `main`).
- All 21 FieldPolicyTests, including the 20 pre-existing manual-
publish tests, continue to pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
6043884 to
c31d98f
Compare
cceb141 to
4d72c4d
Compare
Summary
PR-009d-iii in the ADR 0007 implementation sequence. Bundles three coherent read-path changes:
@fieldPolicywrite/read asymmetry. A query with both@typePolicy(keyFields:)and@fieldPolicy(keyArgs:)couldn't round-trip the cache: the writer storedQUERY_ROOT[\"hero(name:Luke)\"]but the reader subscriptedQUERY_ROOT[\"Hero:Luke\"]. Now matches Apollo Kotlin'sFieldPolicyCacheResolver— policy fields resolve to a directCacheReferencetarget, bypassing the parent-record subscript entirely.cacheKeyForField()→normalizedFieldName()(the field's name in a normalized record).cacheFieldKey()/CacheFieldKey→cacheReadStrategy()/CacheReadStrategy(the read-time resolution). Cases now encode the policy/non-policy distinction directly:.parentRecordKey,.policyReference,.policyReferenceList.FieldExecutionInfo._cacheReadStrategy: CacheReadStrategy?memoizes the policy resolution mirroring_normalizedFieldName. Resolver and projection collector share a single evaluation per(field, info).Why the bundle
The rename surfaced the bug. The memo's correctness depends on the new enum shape. Untangling them after the fact would be churn. All three live read-side only — no writer / normalizer / on-disk format change.
The fix in one diagram
Before (broken):
After:
Tests
FieldPolicyTests.test_fieldPolicy_withMatchingTypePolicy_networkFetchPopulatesCache_andCacheReadResolves— exercises the round trip with both directives. Asserts the correct on-disk layout, then verifies cache-only reads via the policy redirect. Was failing pre-fix.Apollo-UnitTestPlan: 1116 passed, 0 failed, 11 not-run (pre-existing environment-dependent file I/O and concurrency stress tests; same status as on `main`).Stack position
Test plan
🤖 Generated with Claude Code