Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 92 additions & 1 deletion Tests/ApolloTests/Cache/FieldPolicyTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<NoFragments, FieldPolicySchemaMetadata>, @unchecked Sendable {
override class var __selections: [Selection] { [
.field("hero", Hero.self, arguments: ["name": .variable("name")], fieldPolicy: .init(keyArgs: ["name"]))
]}

class Hero: AbstractMockSelectionSet<NoFragments, FieldPolicySchemaMetadata>, @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<HeroSelectionSet>()
query.__variables = ["name": "Luke"]

// 1. Network fetch — writer normalizes the response into the cache.
let serverExpectation = await server.expect(MockQuery<HeroSelectionSet>.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 {
Expand Down
12 changes: 12 additions & 0 deletions apollo-ios/Sources/Apollo/Caching/ApolloStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down
30 changes: 21 additions & 9 deletions apollo-ios/Sources/Apollo/Execution/FieldProjectionCollector.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
52 changes: 44 additions & 8 deletions apollo-ios/Sources/Apollo/Execution/GraphQLExecutor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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
}

}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading
Loading