Skip to content

refactor(executor): @fieldPolicy direct-reference resolution + CacheReadStrategy memo (PR-009d-iii) - #1016

Open
AnthonyMDev wants to merge 1 commit into
cache-rewrite/phase-1a-executor-upfront-projection-iifrom
cache-rewrite/phase-1a-cache-field-key-memo
Open

refactor(executor): @fieldPolicy direct-reference resolution + CacheReadStrategy memo (PR-009d-iii)#1016
AnthonyMDev wants to merge 1 commit into
cache-rewrite/phase-1a-executor-upfront-projection-iifrom
cache-rewrite/phase-1a-cache-field-key-memo

Conversation

@AnthonyMDev

Copy link
Copy Markdown
Contributor

Summary

PR-009d-iii in the ADR 0007 implementation sequence. Bundles three coherent read-path changes:

  1. Bug fix — @fieldPolicy write/read asymmetry. A query with both @typePolicy(keyFields:) and @fieldPolicy(keyArgs:) couldn't round-trip the cache: the writer stored QUERY_ROOT[\"hero(name:Luke)\"] but the reader subscripted QUERY_ROOT[\"Hero:Luke\"]. Now matches Apollo Kotlin's FieldPolicyCacheResolver — policy fields resolve to a direct CacheReference target, bypassing the parent-record subscript entirely.
  2. Rename for clarity. Two confusingly-similar identifiers split by concern:
    • Writer side: cacheKeyForField()normalizedFieldName() (the field's name in a normalized record).
    • Reader side: cacheFieldKey() / CacheFieldKeycacheReadStrategy() / CacheReadStrategy (the read-time resolution). Cases now encode the policy/non-policy distinction directly: .parentRecordKey, .policyReference, .policyReferenceList.
  3. The memo (original ADR scope). 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):

Writer:  QUERY_ROOT[\"hero(name:Luke)\"] = CacheReference(\"Hero:Luke\")
         Hero:Luke = { … }
Reader:  QUERY_ROOT[\"Hero:Luke\"] → nil → cache miss

After:

Writer:  unchanged
Reader:  resolveCacheKey sees .policyReference(\"Hero:Luke\")
         → returns CacheReference(\"Hero:Luke\") directly
         → loadObject(forKey: \"Hero:Luke\") → success

Tests

  • New: 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.
  • Full 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`).
  • All 21 FieldPolicyTests pass, including the 20 pre-existing manual-publish tests.

Stack position

phase-1-plan
└── phase-1a-row-per-field-crud (PR #1001)
    └── phase-1a-field-projection (PR #1008, PR-009b)
        └── phase-1a-cache-load-fields (PR #1009, PR-009c)
            └── phase-1a-executor-upfront-projection (PR #1012, PR-009d-i)
                └── phase-1a-executor-upfront-projection-ii (PR-009d-ii)
                    └── phase-1a-cache-field-key-memo (PR-009d-iii ← this PR)

Test plan

  • New round-trip test passes
  • All FieldPolicyTests pass (21/21)
  • Full Apollo-UnitTestPlan: 1116/1116 enabled tests pass
  • Reviewer to verify the rename is comprehensive (no stragglers)
  • Reviewer to verify the resolve-time semantic matches the docs and Apollo Kotlin

🤖 Generated with Claude Code

@apollo-librarian

apollo-librarian Bot commented Jun 2, 2026

Copy link
Copy Markdown

✅ Docs preview ready

The preview is ready to be viewed. View the preview

File Changes

0 new, 1 changed, 0 removed
* (developer-tools)/ios/(latest)/tutorial/tutorial-define-additional-mutations.mdx

Build ID: ccff8ea8b05fa33182ce911e
Build Logs: View logs

URL: https://www.apollographql.com/docs/deploy-preview/ccff8ea8b05fa33182ce911e


✅ AI Style Review — No Changes Detected

No MDX files were changed in this pull request.

Review Log: View detailed log

This review is AI-generated. Please use common sense when accepting these suggestions, as they may not always be accurate or appropriate for your specific context.

@AnthonyMDev

Copy link
Copy Markdown
Contributor Author

Note from a code review of the stack: this PR bundles three coherent-but-distinguishable changes:

  1. The @fieldPolicy read-side bug fixresolveCacheKey returning CacheReference(key) directly for .policyReference / .policyReferenceList, plus the empty-projection short-circuit in loadObject.
  2. The rename: cacheKeyForField()normalizedFieldName(); CacheFieldKey enum → CacheReadStrategy with the new policy-aware cases.
  3. The _cacheReadStrategy memo on FieldExecutionInfo (the original PR-009d-iii scope).

The reviewer flagged that a reviewer working commit-by-commit can miss that the load-bearing fix is essentially two small changes (resolveCacheKey switch + loadObject guard), because the rename touches 6 of 7 files and dominates the diff. Documenting here so the next time we land a bundled PR like this we either split it up-front or call out the load-bearing parts in the description more visibly.

Other items the review surfaced that are out of scope to fix here (tracking for follow-ups):

  • The new round-trip regression test covers @typePolicy(keyFields: ["name"]) + @fieldPolicy(keyArgs: ["name"]) for a single scalar policy. It does not cover: list-typed @fieldPolicy (.policyReferenceList round-trip), programmatic FieldPolicy.Provider round-trip (vs directive-based), policy fields with uniqueKeyGroup non-nil, or policy resolved against a record whose stored __typename mismatches the policy's typename (silently dereferences into the wrong record).
  • The _cacheReadStrategy memo captures responsePath at first-call time and is preserved through FieldExecutionInfo.copy(). For list elements, the copy mutates responsePath to append the index — so a future FieldPolicy.Provider that consults path would see a stale strategy. Today no provider uses path nontrivially so this is dormant; flagging for visibility.
  • The _cacheReadStrategy memo itself is currently dormant: info.cacheReadStrategy() is only called once per resolveField, so the memo never returns its cached value. It's preparatory infrastructure for PR-009g-bis (cross-phase sharing), which would actually exercise the memo. Acceptable as-is.
  • Minor: parentRecordKey as an enum case name reads like a "key for a different record" rather than "this field's name on the parent record." Consider parentRecordFieldName or just fieldName in a future cleanup.

@AnthonyMDev
AnthonyMDev force-pushed the cache-rewrite/phase-1a-executor-upfront-projection-ii branch from d140cab to 6043884 Compare June 29, 2026 20:38
@AnthonyMDev
AnthonyMDev force-pushed the cache-rewrite/phase-1a-cache-field-key-memo branch from 271bd92 to cceb141 Compare June 29, 2026 20:38
…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>
@AnthonyMDev
AnthonyMDev force-pushed the cache-rewrite/phase-1a-executor-upfront-projection-ii branch from 6043884 to c31d98f Compare July 9, 2026 18:59
@AnthonyMDev
AnthonyMDev force-pushed the cache-rewrite/phase-1a-cache-field-key-memo branch from cceb141 to 4d72c4d Compare July 9, 2026 18:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant