From 4d72c4dd99ce7671fc96c5bab9f11ea6b6b31527 Mon Sep 17 00:00:00 2001 From: Anthony Miller Date: Tue, 2 Jun 2026 15:52:52 -0700 Subject: [PATCH] refactor(executor): @fieldPolicy direct-reference resolution + CacheReadStrategy memo (PR-009d-iii) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../ApolloTests/Cache/FieldPolicyTests.swift | 93 +++++++++++++++- .../Sources/Apollo/Caching/ApolloStore.swift | 12 ++ .../CacheDataExecutionSource.swift | 51 +++++---- .../Execution/FieldProjectionCollector.swift | 30 +++-- .../Apollo/Execution/GraphQLExecutor.swift | 52 +++++++-- .../GraphQLResultNormalizer.swift | 2 +- ...> Selection.Field+CacheReadStrategy.swift} | 103 +++++++++++------- 7 files changed, 260 insertions(+), 83 deletions(-) rename apollo-ios/Sources/Apollo/Internal Utilities/{Selection.Field+CacheFieldKey.swift => Selection.Field+CacheReadStrategy.swift} (52%) diff --git a/Tests/ApolloTests/Cache/FieldPolicyTests.swift b/Tests/ApolloTests/Cache/FieldPolicyTests.swift index 01875e03c..ad0460076 100644 --- a/Tests/ApolloTests/Cache/FieldPolicyTests.swift +++ b/Tests/ApolloTests/Cache/FieldPolicyTests.swift @@ -1467,7 +1467,98 @@ final class FieldPolicyTests: XCTestCase, CacheDependentTesting, @unchecked Send XCTAssertEqual(data.heroes[2].isJedi, true) XCTAssertEqual(data.heroes[2].weight, 138.5) } - + + // MARK: - Network write + cache read round-trip + + /// Verifies that a query carrying both `@fieldPolicy(keyArgs:)` on a field and + /// `@typePolicy(keyFields:)` on the field's return type produces matching cache + /// keys at write and read time. The docs describe these directives as + /// complementary — `@typePolicy` keys the object at write time, `@fieldPolicy` + /// derives the same key from field arguments at read time. A network fetch + /// followed by a cache-only fetch of the same query must therefore round-trip + /// successfully. + func test_fieldPolicy_withMatchingTypePolicy_networkFetchPopulatesCache_andCacheReadResolves() async throws { + class HeroSelectionSet: AbstractMockSelectionSet, @unchecked Sendable { + override class var __selections: [Selection] { [ + .field("hero", Hero.self, arguments: ["name": .variable("name")], fieldPolicy: .init(keyArgs: ["name"])) + ]} + + class Hero: AbstractMockSelectionSet, @unchecked Sendable { + override class var __parentType: any ParentType { + Object(typename: "Hero", implementedInterfaces: [], keyFields: ["name"]) + } + override class var __selections: [Selection] { [ + .field("__typename", String.self), + .field("name", String.self), + ]} + } + } + + // @typePolicy(keyFields: ["name"]) on Hero — drives the writer's cache key. + await FieldPolicySchemaMetadata.stub_objectTypeForTypeName { typename in + typename == "Hero" + ? Object(typename: "Hero", implementedInterfaces: [], keyFields: ["name"]) + : nil + } + + let query = MockQuery() + query.__variables = ["name": "Luke"] + + // 1. Network fetch — writer normalizes the response into the cache. + let serverExpectation = await server.expect(MockQuery.self) { _ in + [ + "data": [ + "hero": [ + "__typename": "Hero", + "name": "Luke", + ] + ] + ] + } + + let networkResult = try await client.fetch(query: query, cachePolicy: .networkOnly) + XCTAssertEqual(networkResult.source, .server) + XCTAssertNil(networkResult.errors) + XCTAssertEqual(networkResult.data?.hero?.name, "Luke") + + await fulfillment(of: [serverExpectation], timeout: Self.defaultWaitTimeout) + + // 2. Confirm the on-disk cache layout the writer produces. The writer + // is `@fieldPolicy`-agnostic: it stores the parent-record entry under + // the field's *normalized* name (`hero(name:Luke)`), and `@typePolicy` + // keys the child record canonically (`Hero:Luke`). The `@fieldPolicy` + // redirect happens at read time only — see Apollo Kotlin's + // FieldPolicyCacheResolver for the same split. + try await store.withinReadTransaction { transaction in + let records = try await transaction.readOnlyCache.loadRecords( + forKeys: ["QUERY_ROOT", "Hero:Luke"] + ) + let queryRoot = try XCTUnwrap(records["QUERY_ROOT"], "QUERY_ROOT must exist after the network fetch") + let heroRecord = try XCTUnwrap(records["Hero:Luke"], "@typePolicy(keyFields: [\"name\"]) must key the child record under 'Hero:Luke'") + + XCTAssertEqual( + queryRoot["hero(name:Luke)"] as? CacheReference, + CacheReference("Hero:Luke"), + "Writer stores the reference under the field's normalized name on the parent record" + ) + XCTAssertNil( + queryRoot["Hero:Luke"], + "Writer does NOT store an entry under the @fieldPolicy-resolved name — the reader uses it as a direct reference target instead" + ) + XCTAssertEqual(heroRecord["name"] as? String, "Luke") + } + + // 3. Cache-only fetch — read path applies @fieldPolicy(keyArgs: ["name"]) + // to resolve to "Hero:Luke", matching what @typePolicy wrote. + let cacheResult = try await client.fetch(query: query, cachePolicy: .cacheOnly) + + XCTAssertEqual(cacheResult?.source, .cache) + XCTAssertNil(cacheResult?.errors) + + let data = try XCTUnwrap(cacheResult?.data) + XCTAssertEqual(data.hero?.name, "Luke") + } + } class FieldPolicySchemaMetadata: SchemaMetadata { diff --git a/apollo-ios/Sources/Apollo/Caching/ApolloStore.swift b/apollo-ios/Sources/Apollo/Caching/ApolloStore.swift index d265dee20..cb1f5858c 100644 --- a/apollo-ios/Sources/Apollo/Caching/ApolloStore.swift +++ b/apollo-ios/Sources/Apollo/Caching/ApolloStore.swift @@ -315,6 +315,18 @@ public final class ApolloStore: Sendable { } catch { return .immediate(.failure(error)) } + // Empty projection set means every selected field on this record + // resolves without consulting the parent's storage — typically + // because all fields are `@fieldPolicy`-redirected and produce + // their own `CacheReference`s directly. Skip the parent load and + // hand the executor an empty `Record` to dispatch against; each + // field's `resolveCacheKey` will derive its value from the + // strategy alone. Matches Apollo Kotlin's + // `FieldPolicyCacheResolver`: a policy-resolved field does not + // require the parent record to exist. + guard !projections.isEmpty else { + return .immediate(.success(Record(key: key, fields: [:]))) + } projectionLoader.enqueue(projections) return projectionLoader.deferredRecord(forKey: key).map { record in // `nil` here means the record is *absent* from the cache. The diff --git a/apollo-ios/Sources/Apollo/Execution/ExecutionSources/CacheDataExecutionSource.swift b/apollo-ios/Sources/Apollo/Execution/ExecutionSources/CacheDataExecutionSource.swift index 5b3c679da..c5e148a6e 100644 --- a/apollo-ios/Sources/Apollo/Execution/ExecutionSources/CacheDataExecutionSource.swift +++ b/apollo-ios/Sources/Apollo/Execution/ExecutionSources/CacheDataExecutionSource.swift @@ -53,30 +53,37 @@ struct CacheDataExecutionSource: GraphQLExecutionSource { with info: FieldExecutionInfo, on object: Record ) throws -> JSONValue? { - // `Selection.Field.cacheFieldKey` centralizes the field-policy + // `info.cacheReadStrategy()` centralizes the field-policy // resolution rules (programmatic `FieldPolicy.Provider` first, - // `@fieldPolicy` directive second, standard `cacheKey(with:)` - // last) so this resolver and the projection-time - // `FieldProjectionCollector` always compute the same name(s). - // Without this shared call site the two paths would drift - // silently — the cache load would fetch under one name and the - // resolver would subscript under another, producing a phantom - // miss. - let key = try info.field.cacheFieldKey( - variables: info.parentInfo.variables, - schema: info.parentInfo.schema, - responsePath: info.responsePath - ) - - switch key { - case .single(let name): + // `@fieldPolicy` directive second, plain field name last) and + // memoizes the result on the `FieldExecutionInfo` so the + // projection-time and resolve-time paths share a single + // computation per `(field, info)`. + let strategy = try info.cacheReadStrategy() + + switch strategy { + case .parentRecordKey(let name): + // Standard non-policy read: the field's value lives on the + // parent record under its normalized name (the same name the + // writer used in `GraphQLResultNormalizer`). Subscript to get + // it. return object[name] - case .list(let names): - var values: [JSONValue] = [] - for name in names { - if let value = object[name] { values.append(value) } - } - return values as JSONValue + + case .policyReference(let key): + // `@fieldPolicy` redirect: the field's value is a direct cache + // reference, computed from the field's arguments without + // consulting the parent record. The writer never wrote an + // entry under this name on the parent — the policy targets a + // record that `@typePolicy` (or another write path) populated + // under the canonical key. Return the reference; the executor's + // existing `CacheReference` resolution will load it. + return CacheReference(key) as JSONValue + + case .policyReferenceList(let keys): + // Same as `policyReference`, lifted to a list-typed field: + // each policy-derived key becomes one `CacheReference` in the + // returned array. + return keys.map { CacheReference($0) } as JSONValue } } diff --git a/apollo-ios/Sources/Apollo/Execution/FieldProjectionCollector.swift b/apollo-ios/Sources/Apollo/Execution/FieldProjectionCollector.swift index b76753060..e8d474be4 100644 --- a/apollo-ios/Sources/Apollo/Execution/FieldProjectionCollector.swift +++ b/apollo-ios/Sources/Apollo/Execution/FieldProjectionCollector.swift @@ -140,23 +140,35 @@ public enum FieldProjectionCollector { for selection in selections { switch selection { case .field(let field): - // `Selection.Field.cacheFieldKey` is the shared helper that - // also drives `CacheDataExecutionSource.resolveCacheKey` — - // both call sites compute the same name(s) for the same - // `(field, variables, schema, responsePath)`, so the - // projection's `fieldName` is exactly what the resolver - // will later subscript on the loaded record. - let key = try field.cacheFieldKey( + // `Selection.Field.cacheReadStrategy` is the shared helper that + // also drives `CacheDataExecutionSource.resolveCacheKey` — both + // call sites compute the same strategy for the same + // `(field, variables, schema, responsePath)`. The projection's + // `fieldName` matches what the resolver will subscript on the + // loaded parent record. + // + // For `@fieldPolicy`-redirected fields (`.policyReference` / + // `.policyReferenceList`), the reader does NOT subscript the + // parent record — it produces `CacheReference`s directly from + // the field's arguments. No parent-record projection is needed + // for those cases; the next level of the read loads the + // policy-referenced records via their canonical keys. + let strategy = try field.cacheReadStrategy( variables: variables, schema: schema, responsePath: responsePath ) - for fieldName in key.allNames { + switch strategy { + case .parentRecordKey(let name): projections.insert(FieldProjection( cacheKey: cacheKey, - fieldName: fieldName, + fieldName: name, outputType: field.type )) + case .policyReference, .policyReferenceList: + // No parent-record projection: the field's value is a direct + // `CacheReference` derived from the field's arguments. + break } case .conditional(let conditions, let nested): diff --git a/apollo-ios/Sources/Apollo/Execution/GraphQLExecutor.swift b/apollo-ios/Sources/Apollo/Execution/GraphQLExecutor.swift index 9ac86c0de..1be2267bd 100644 --- a/apollo-ios/Sources/Apollo/Execution/GraphQLExecutor.swift +++ b/apollo-ios/Sources/Apollo/Execution/GraphQLExecutor.swift @@ -74,7 +74,8 @@ public class FieldExecutionInfo { let responseKeyForField: String var cachePath: ResponsePath = [] - private var _cacheKeyForField: String? + private var _normalizedFieldName: String? + private var _cacheReadStrategy: CacheReadStrategy? init( field: Selection.Field, @@ -90,16 +91,50 @@ public class FieldExecutionInfo { } fileprivate func computeCacheKeyAndPath() throws { - cachePath = try parentInfo.cachePath.appending(cacheKeyForField()) + cachePath = try parentInfo.cachePath.appending(normalizedFieldName()) } - - func cacheKeyForField() throws -> String { - guard let _cacheKeyForField else { + + /// The field's name in a normalized cache record: the GraphQL field + /// name combined with its argument values (e.g. `"hero(name:\"Luke\")"`). + /// This is the *write-side* identity: the writer stores the field + /// entry on its parent record under this name, and the cache-path + /// machinery uses it as a path segment when synthesizing a child + /// record key in the absence of an explicit `@typePolicy`. + /// + /// Distinct from [`cacheReadStrategy()`](`FieldExecutionInfo`), which + /// describes how the *reader* resolves this field — including + /// `@fieldPolicy` redirections that bypass the parent-record subscript + /// entirely. For fields with no policy the two compute the same name; + /// for policy fields they intentionally diverge (see + /// [`CacheReadStrategy`](`Selection.Field+CacheReadStrategy.swift`)). + func normalizedFieldName() throws -> String { + guard let _normalizedFieldName else { let cacheKey = try field.cacheKey(with: parentInfo.variables) - _cacheKeyForField = cacheKey + _normalizedFieldName = cacheKey return cacheKey } - return _cacheKeyForField + return _normalizedFieldName + } + + /// How the cache reader resolves this field — either by subscripting + /// the parent record under the field's normalized name, or by following + /// a `@fieldPolicy`-derived direct cache reference. + /// + /// Memoizes the result of `field.cacheReadStrategy(variables:schema:responsePath:)` + /// so the projection-collection path and the per-field resolve path + /// share one policy evaluation per `(field, info)`. Mirrors the + /// `_normalizedFieldName` memo pattern. + func cacheReadStrategy() throws -> CacheReadStrategy { + guard let _cacheReadStrategy else { + let strategy = try field.cacheReadStrategy( + variables: parentInfo.variables, + schema: parentInfo.schema, + responsePath: responsePath + ) + _cacheReadStrategy = strategy + return strategy + } + return _cacheReadStrategy } /// Computes the `ObjectExecutionInfo` and selections that should be used for @@ -150,7 +185,8 @@ public class FieldExecutionInfo { self.responsePath = info.responsePath self.responseKeyForField = info.responseKeyForField self.cachePath = info.cachePath - self._cacheKeyForField = info._cacheKeyForField + self._normalizedFieldName = info._normalizedFieldName + self._cacheReadStrategy = info._cacheReadStrategy } } diff --git a/apollo-ios/Sources/Apollo/Execution/ResultAccumulators/GraphQLResultNormalizer.swift b/apollo-ios/Sources/Apollo/Execution/ResultAccumulators/GraphQLResultNormalizer.swift index aaf0918cf..958acd122 100644 --- a/apollo-ios/Sources/Apollo/Execution/ResultAccumulators/GraphQLResultNormalizer.swift +++ b/apollo-ios/Sources/Apollo/Execution/ResultAccumulators/GraphQLResultNormalizer.swift @@ -46,7 +46,7 @@ class BaseGraphQLResultNormalizer: GraphQLResultAccumulator { final func accept(fieldEntry: JSONValue?, info: FieldExecutionInfo) throws -> (key: String, value: JSONValue)? { guard let fieldEntry else { return nil } - return (try info.cacheKeyForField(), fieldEntry) + return (try info.normalizedFieldName(), fieldEntry) } final func accept( diff --git a/apollo-ios/Sources/Apollo/Internal Utilities/Selection.Field+CacheFieldKey.swift b/apollo-ios/Sources/Apollo/Internal Utilities/Selection.Field+CacheReadStrategy.swift similarity index 52% rename from apollo-ios/Sources/Apollo/Internal Utilities/Selection.Field+CacheFieldKey.swift rename to apollo-ios/Sources/Apollo/Internal Utilities/Selection.Field+CacheReadStrategy.swift index 522f44d3c..c5a5f1530 100644 --- a/apollo-ios/Sources/Apollo/Internal Utilities/Selection.Field+CacheFieldKey.swift +++ b/apollo-ios/Sources/Apollo/Internal Utilities/Selection.Field+CacheReadStrategy.swift @@ -1,49 +1,68 @@ @_spi(Execution) @_spi(Internal) import ApolloAPI -/// The cache field name(s) a `Selection.Field` reads from / writes to -/// on its parent record. Either a single name (the common case: a -/// non-policy field, or a `.single` field-policy result) or a list of -/// names (a `.list` field-policy result, where the parent stores N -/// child references under N distinct names). +/// How the cache reader resolves a `Selection.Field` to its value. /// -/// The discriminator matters at the resolver: a `.single` field reads -/// one value out of the parent record; a `.list` field reads N values -/// and aggregates them into an array. -enum CacheFieldKey { - case single(String) - case list([String]) +/// `@fieldPolicy` is a read-side redirect: it tells the reader how to +/// derive a `CacheReference` directly from the field's arguments, so the +/// reader can find the target record without going through the parent +/// record at all. Fields with no policy fall through to the standard +/// path — subscript the parent record under the field's normalized name. +/// +/// The three cases capture this asymmetry explicitly: +/// +/// - ``parentRecordKey(_:)`` — Standard, non-policy resolution. Subscript +/// the parent record under `name`; the value is whatever the writer +/// stored (a `CacheReference`, a scalar, a list, etc.). +/// - ``policyReference(_:)`` — `@fieldPolicy` applied. The reader +/// produces a `CacheReference(key)` directly. No parent-record +/// subscript. The writer is unaware of this path; it stores the entry +/// on the parent under the normalized field name (`@typePolicy` then +/// keys the child record under the same canonical key, so both +/// directives converge by user-coordinated design — see +/// ). +/// - ``policyReferenceList(_:)`` — Like `policyReference`, but for +/// list-typed policy fields: the reader emits N direct references +/// into a list. +/// +/// # Aligns with Apollo Kotlin +/// Apollo Kotlin's `FieldPolicyCacheResolver` returns a `CacheKey` +/// target directly when key arguments are present; only the +/// `DefaultCacheResolver` (no policy) performs parent-record lookup. +/// This enum encodes the same distinction in the Swift cache executor. +enum CacheReadStrategy { + /// Standard read: subscript the parent record by `name`. Used for + /// every field that does not have a `@fieldPolicy` / + /// `FieldPolicy.Provider` redirect. + case parentRecordKey(String) - /// Flattens both cases into their underlying name(s) — useful for - /// the projection-collection path, which emits one projection per - /// stored name regardless of `.single`/`.list` shape. - var allNames: [String] { - switch self { - case .single(let name): return [name] - case .list(let names): return names - } - } + /// `@fieldPolicy` redirect: the field's value is a single + /// `CacheReference(key)`, resolved directly from the field's + /// arguments. The reader does not subscript the parent record. + case policyReference(String) + + /// `@fieldPolicy` redirect for list-typed fields: the field's value + /// is `[CacheReference(k1), CacheReference(k2), ...]`, resolved + /// directly from the field's arguments. + case policyReferenceList([String]) } extension Selection.Field { - /// Resolves the cache field name(s) the parent record stores this - /// field's value under. Centralizes the policy-resolution rules - /// shared between `CacheDataExecutionSource.resolveCacheKey(with:on:)` - /// (read time) and `FieldProjectionCollector` (projection time): - /// - /// 1. If the field's output type bottoms out at `.object(_)` AND a - /// field policy applies (programmatic via - /// `FieldPolicy.Provider`, falling back to the `@fieldPolicy` - /// directive), the result is the policy-derived name(s) - /// formatted as `"\(uniqueKeyGroup ?? typename):\(id)"`. - /// 2. Otherwise, the result is `.single` of - /// `try cacheKey(with: variables)` — the standard composition - /// of field name plus argument hash. + /// Determines how the cache reader should resolve this field's value. + /// Centralizes the policy-resolution rules so the resolver + /// (`CacheDataExecutionSource.resolveCacheKey(with:on:)`) and the + /// projection collector (`FieldProjectionCollector`) agree by + /// construction on the read strategy for each field. /// - /// Both callers compute the same name(s) for the same - /// `(field, variables, schema, path)` — projection and resolution - /// stay in agreement by construction, so the cache load fetches - /// what the resolver will subscript for. + /// Resolution order: + /// 1. If the field's output type bottoms out at `.object(_)` and a + /// field policy applies (programmatic `FieldPolicy.Provider` first, + /// `@fieldPolicy` directive second), return ``CacheReadStrategy/policyReference(_:)`` + /// or ``CacheReadStrategy/policyReferenceList(_:)`` with the + /// policy-derived key(s) formatted as `"\(uniqueKeyGroup ?? typename):\(id)"`. + /// 2. Otherwise, return ``CacheReadStrategy/parentRecordKey(_:)`` with + /// `try cacheKey(with: variables)` — the field's normalized name on + /// its parent record (matches what the writer wrote). /// /// - Parameters: /// - variables: Operation variables, used to evaluate field @@ -56,11 +75,11 @@ extension Selection.Field { /// - responsePath: The response path passed to /// `FieldPolicy.Provider.cacheKey(...)` / /// `cacheKeyList(...)`. Most providers don't consult it. - func cacheFieldKey( + func cacheReadStrategy( variables: GraphQLOperation.Variables?, schema: (any SchemaMetadata.Type)?, responsePath: ResponsePath - ) throws -> CacheFieldKey { + ) throws -> CacheReadStrategy { if let typename = objectFieldTypename, let policyResult = resolveCacheFieldPolicy( variables: variables, @@ -69,12 +88,12 @@ extension Selection.Field { ) { switch policyResult { case .single(let info): - return .single(formatPolicyCacheKey(info: info, typename: typename)) + return .policyReference(formatPolicyCacheKey(info: info, typename: typename)) case .list(let infos): - return .list(infos.map { formatPolicyCacheKey(info: $0, typename: typename) }) + return .policyReferenceList(infos.map { formatPolicyCacheKey(info: $0, typename: typename) }) } } - return .single(try cacheKey(with: variables)) + return .parentRecordKey(try cacheKey(with: variables)) } /// The typename used to format a policy-derived cache field name.