diff --git a/Tests/ApolloTests/Execution/FieldProjectionCollectorTests.swift b/Tests/ApolloTests/Execution/FieldProjectionCollectorTests.swift new file mode 100644 index 000000000..d0905b998 --- /dev/null +++ b/Tests/ApolloTests/Execution/FieldProjectionCollectorTests.swift @@ -0,0 +1,413 @@ +@testable @_spi(Execution) import Apollo +@_spi(Execution) @_spi(Unsafe) @_spi(Internal) import ApolloAPI +@_spi(Execution) @_spi(Unsafe) import ApolloInternalTestHelpers +import Foundation +import Nimble +import XCTest + +final class FieldProjectionCollectorTests: XCTestCase { + + // MARK: - Field selections + + func test__collect__givenSimpleScalarSelection__emitsOneProjectionPerField() throws { + let selections: [Selection] = [ + .field("name", String.self), + .field("age", Int.self), + ] + + let projections = try FieldProjectionCollector.collect( + selections: selections, + cacheKey: "User:1", + variables: nil, + resolveRuntimeType: { nil } + ) + + expect(projections) == Set([ + FieldProjection(cacheKey: "User:1", fieldName: "name", + columnShape: .string, cardinality: .scalar), + FieldProjection(cacheKey: "User:1", fieldName: "age", + columnShape: .int, cardinality: .scalar), + ]) + } + + func test__collect__givenListField__emitsListCardinalityProjection() throws { + let selections: [Selection] = [ + .field("tags", [String].self), + ] + + let projections = try FieldProjectionCollector.collect( + selections: selections, + cacheKey: "User:1", + variables: nil, + resolveRuntimeType: { nil } + ) + + expect(projections) == Set([ + FieldProjection(cacheKey: "User:1", fieldName: "tags", + columnShape: .string, cardinality: .list) + ]) + } + + func test__collect__givenObjectField__emitsChildKeyColumnShape() throws { + class FriendSelectionSet: MockSelectionSet, @unchecked Sendable { + override class var __selections: [Selection] { [ + .field("name", String.self) + ]} + } + + let selections: [Selection] = [ + .field("bestFriend", FriendSelectionSet.self) + ] + + let projections = try FieldProjectionCollector.collect( + selections: selections, + cacheKey: "User:1", + variables: nil, + resolveRuntimeType: { nil } + ) + + expect(projections) == Set([ + FieldProjection(cacheKey: "User:1", fieldName: "bestFriend", + columnShape: .childKey, cardinality: .scalar) + ]) + } + + func test__collect__givenNestedObjectSelection__collectsOnlyTopLevel() throws { + // The collector intentionally does NOT recurse past an object + // boundary — the child's cache key isn't known until the parent's + // child_key_value is loaded. The caller's per-level loop drives the + // next collect() call against the resolved child key. + class FriendSelectionSet: MockSelectionSet, @unchecked Sendable { + override class var __selections: [Selection] { [ + .field("name", String.self), + .field("age", Int.self), + ]} + } + + let selections: [Selection] = [ + .field("name", String.self), + .field("bestFriend", FriendSelectionSet.self), + ] + + let projections = try FieldProjectionCollector.collect( + selections: selections, + cacheKey: "User:1", + variables: nil, + resolveRuntimeType: { nil } + ) + + // Friend's `name` and `age` are NOT in the projection set — only + // the top-level fields are. + expect(projections.count) == 2 + expect(projections.contains(where: { $0.fieldName == "name" })) == true + expect(projections.contains(where: { $0.fieldName == "bestFriend" })) == true + expect(projections.contains(where: { $0.fieldName == "age" })) == false + } + + func test__collect__givenFieldWithArguments__usesCacheKeyForField() throws { + // `Selection.Field.cacheKey(with:)` composes the cache field key + // from the field name and its arguments. The collector forwards + // variables so the cache field key matches what the executor + // would compute. + let selections: [Selection] = [ + .field("hero", String.self, arguments: ["episode": "JEDI"]) + ] + + let projections = try FieldProjectionCollector.collect( + selections: selections, + cacheKey: "Query.viewer", + variables: nil, + resolveRuntimeType: { nil } + ) + + expect(projections.count) == 1 + let projection = try XCTUnwrap(projections.first) + expect(projection.fieldName) == "hero(episode:JEDI)" + } + + // MARK: - Conditional selections (@include / @skip) + + func test__collect__givenConditionalIncludeTrue__entersConditional() throws { + let selections: [Selection] = [ + .field("__typename", String.self), + .include(if: "showAge", .field("age", Int.self)), + ] + + let projections = try FieldProjectionCollector.collect( + selections: selections, + cacheKey: "User:1", + variables: ["showAge": true], + resolveRuntimeType: { nil } + ) + + expect(projections.contains(where: { $0.fieldName == "age" })) == true + } + + func test__collect__givenConditionalIncludeFalse__skipsConditional() throws { + let selections: [Selection] = [ + .field("__typename", String.self), + .include(if: "showAge", .field("age", Int.self)), + ] + + let projections = try FieldProjectionCollector.collect( + selections: selections, + cacheKey: "User:1", + variables: ["showAge": false], + resolveRuntimeType: { nil } + ) + + expect(projections.contains(where: { $0.fieldName == "age" })) == false + // The unconditional field still appears. + expect(projections.contains(where: { $0.fieldName == "__typename" })) == true + } + + func test__collect__givenConditionalSkipTrue__skipsConditional() throws { + let selections: [Selection] = [ + .include(if: !"skipAge", .field("age", Int.self)), + ] + + let projections = try FieldProjectionCollector.collect( + selections: selections, + cacheKey: "User:1", + variables: ["skipAge": true], + resolveRuntimeType: { nil } + ) + + expect(projections.isEmpty) == true + } + + // MARK: - Fragment selections + + func test__collect__givenFragment__alwaysEntersFragmentSelections() throws { + class GivenFragment: MockFragment, @unchecked Sendable { + override class var __selections: [Selection] { [ + .field("name", String.self), + .field("age", Int.self), + ]} + } + + let selections: [Selection] = [ + .field("__typename", String.self), + .fragment(GivenFragment.self), + ] + + let projections = try FieldProjectionCollector.collect( + selections: selections, + cacheKey: "User:1", + variables: nil, + resolveRuntimeType: { nil } + ) + + expect(projections.contains(where: { $0.fieldName == "name" })) == true + expect(projections.contains(where: { $0.fieldName == "age" })) == true + expect(projections.contains(where: { $0.fieldName == "__typename" })) == true + } + + func test__collect__givenSameFieldInOuterAndFragment__dedupesViaSet() throws { + // GraphQL allows the same field to appear in both the outer + // selection and a fragment; both contribute to the same response + // key. The Set return type collapses them into one projection. + class GivenFragment: MockFragment, @unchecked Sendable { + override class var __selections: [Selection] { [ + .field("name", String.self), + ]} + } + + let selections: [Selection] = [ + .field("name", String.self), + .fragment(GivenFragment.self), + ] + + let projections = try FieldProjectionCollector.collect( + selections: selections, + cacheKey: "User:1", + variables: nil, + resolveRuntimeType: { nil } + ) + + expect(projections.count) == 1 + expect(projections.first?.fieldName) == "name" + } + + // MARK: - Inline fragment selections (type cases) + + @MainActor + func test__collect__givenInlineFragmentMatchingRuntimeType__entersTypeCase() throws { + let droidType = Object(typename: "Droid", implementedInterfaces: []) + MockSchemaMetadata.stub_objectTypeForTypeName({ typename in + typename == "Droid" ? droidType : nil + }) + + class AsDroid: MockTypeCase, @unchecked Sendable { + override class var __parentType: any ParentType { + Object(typename: "Droid", implementedInterfaces: []) + } + override class var __selections: [Selection] { [ + .field("primaryFunction", String.self) + ]} + } + + let selections: [Selection] = [ + .field("name", String.self), + .inlineFragment(AsDroid.self), + ] + + let projections = try FieldProjectionCollector.collect( + selections: selections, + cacheKey: "Droid:2001", + variables: nil, + resolveRuntimeType: { droidType } + ) + + expect(projections.contains(where: { $0.fieldName == "primaryFunction" })) == true + expect(projections.contains(where: { $0.fieldName == "name" })) == true + } + + @MainActor + func test__collect__givenInlineFragmentNonMatchingRuntimeType__skipsTypeCase() throws { + let humanType = Object(typename: "Human", implementedInterfaces: []) + + class AsDroid: MockTypeCase, @unchecked Sendable { + override class var __parentType: any ParentType { + Object(typename: "Droid", implementedInterfaces: []) + } + override class var __selections: [Selection] { [ + .field("primaryFunction", String.self) + ]} + } + + let selections: [Selection] = [ + .field("name", String.self), + .inlineFragment(AsDroid.self), + ] + + let projections = try FieldProjectionCollector.collect( + selections: selections, + cacheKey: "Human:1", + variables: nil, + resolveRuntimeType: { humanType } + ) + + expect(projections.contains(where: { $0.fieldName == "primaryFunction" })) == false + expect(projections.contains(where: { $0.fieldName == "name" })) == true + } + + @MainActor + func test__collect__givenInlineFragmentNilRuntimeType__skipsTypeCase() throws { + // When the caller has no `__typename` available (and supplies a + // nil-returning resolver), every inline fragment is conservatively + // skipped — we cannot prove the type case applies. Top-level + // fields still come through. + class AsDroid: MockTypeCase, @unchecked Sendable { + override class var __parentType: any ParentType { + Object(typename: "Droid", implementedInterfaces: []) + } + override class var __selections: [Selection] { [ + .field("primaryFunction", String.self) + ]} + } + + let selections: [Selection] = [ + .field("name", String.self), + .inlineFragment(AsDroid.self), + ] + + let projections = try FieldProjectionCollector.collect( + selections: selections, + cacheKey: "User:1", + variables: nil, + resolveRuntimeType: { nil } + ) + + expect(projections.contains(where: { $0.fieldName == "primaryFunction" })) == false + expect(projections.contains(where: { $0.fieldName == "name" })) == true + } + + // MARK: - Deferred selections + + func test__collect__givenDeferredSelection__entersFragmentSelections() throws { + // The cache executor path eagerly executes deferred fragments + // regardless of the `@defer(if:)` condition. The collector mirrors + // that — both `if:`-true and `if:`-false deferred selections have + // their fields projected, matching what the executor will read. + class AsDroid: MockTypeCase, @unchecked Sendable { + override class var __selections: [Selection] { [ + .field("primaryFunction", String.self) + ]} + } + + let selections: [Selection] = [ + .field("name", String.self), + .deferred(AsDroid.self, label: "AsDroid"), + ] + + let projections = try FieldProjectionCollector.collect( + selections: selections, + cacheKey: "Droid:2001", + variables: nil, + resolveRuntimeType: { nil } + ) + + expect(projections.contains(where: { $0.fieldName == "primaryFunction" })) == true + expect(projections.contains(where: { $0.fieldName == "name" })) == true + } + + func test__collect__givenDeferredWithConditionFalse__entersFragmentSelections() throws { + // `@defer(if: false)` — under the cache path, behaves as fulfilled. + // Fields are projected. + class AsDroid: MockTypeCase, @unchecked Sendable { + override class var __selections: [Selection] { [ + .field("primaryFunction", String.self) + ]} + } + + let selections: [Selection] = [ + .deferred(if: "doDefer", AsDroid.self, label: "AsDroid") + ] + + let projections = try FieldProjectionCollector.collect( + selections: selections, + cacheKey: "Droid:2001", + variables: ["doDefer": false], + resolveRuntimeType: { nil } + ) + + expect(projections.contains(where: { $0.fieldName == "primaryFunction" })) == true + } + + func test__collect__givenDeferredWithConditionTrue__entersFragmentSelections() throws { + // `@defer(if: true)` — under the cache path, eagerly resolved. + // Fields are projected. + class AsDroid: MockTypeCase, @unchecked Sendable { + override class var __selections: [Selection] { [ + .field("primaryFunction", String.self) + ]} + } + + let selections: [Selection] = [ + .deferred(if: "doDefer", AsDroid.self, label: "AsDroid") + ] + + let projections = try FieldProjectionCollector.collect( + selections: selections, + cacheKey: "Droid:2001", + variables: ["doDefer": true], + resolveRuntimeType: { nil } + ) + + expect(projections.contains(where: { $0.fieldName == "primaryFunction" })) == true + } + + // MARK: - Empty input + + func test__collect__givenEmptySelections__returnsEmptySet() throws { + let projections = try FieldProjectionCollector.collect( + selections: [], + cacheKey: "User:1", + variables: nil, + resolveRuntimeType: { nil } + ) + + expect(projections.isEmpty) == true + } +} diff --git a/apollo-ios/Sources/Apollo/Execution/FieldProjectionCollector.swift b/apollo-ios/Sources/Apollo/Execution/FieldProjectionCollector.swift new file mode 100644 index 000000000..9401dd3d0 --- /dev/null +++ b/apollo-ios/Sources/Apollo/Execution/FieldProjectionCollector.swift @@ -0,0 +1,170 @@ +@_spi(Execution) import ApolloAPI + +/// Walks a `[Selection]` tree for one level of a selection set and emits +/// the `FieldProjection`s the cache should be asked to read for that +/// level. This is the "Phase 1" half of ADR 0007 Principle 5's two-phase +/// pattern: a caller traverses the selection set up-front to collect +/// projections, then issues a single `loadFields(_:)` call against the +/// cache, then resolves field values from the returned data. +/// +/// Why one level at a time: object/list fields are stored in the cache +/// as `CacheReference`s pointing at independent records. The cache keys +/// of the child records aren't known until the parent's field values +/// are loaded — so projection collection cannot recurse past a +/// `.object`/`.customScalar`/scalar boundary. The caller drives the +/// per-level loop. (Nested-list `[[T]]` synthetic sub-records are an +/// exception, but they are reached via `child_key_value` resolution at +/// read time, not via projection-time recursion.) +/// +/// # See Also +/// +/// - [ADR 0007 — Selection-set-aware cache reads](../Design/adr/0007-selection-aware-cache-reads.md) +/// Principle 5 (upfront projection); Principle 1 (per-field type info). +/// - `FieldSelectionCollector` — the analogous structure for the +/// existing lazy-resolution executor path. This collector follows the +/// same `Selection` walk shape so the two paths stay in agreement +/// about which fields each `Selection` case contributes. +/// - `FieldProjection` (PR-009b) — the value type this collector emits. +@_spi(Execution) +public enum FieldProjectionCollector { + + /// Collects field projections for one record at one level of a + /// selection set. Returns the projections deduplicated by their + /// natural `Hashable` identity — duplicate selections of the same + /// field across multiple fragments collapse into one projection. + /// + /// - Parameters: + /// - selections: The selections at this level (typically a + /// `SelectionSet.__selections` or a fragment's `.__selections`). + /// - cacheKey: The cache key of the record whose fields are being + /// projected. The same cache key is used for every projection + /// emitted by one call — the caller invokes this method again + /// for each distinct child record discovered when resolving the + /// returned projections. + /// - variables: Operation variables, used to evaluate + /// `@include`/`@skip` conditionals on `.conditional` selections + /// and to compose the cache field key for fields with arguments. + /// - resolveRuntimeType: Returns the runtime `Object` type of the + /// record being projected, used to gate `.inlineFragment` + /// traversal. Passed as a closure so callers can defer resolving + /// `__typename` from the record (or whatever source they have) + /// until an inline fragment is actually encountered. Return + /// `nil` to skip every inline fragment. + /// - Returns: The projections to request from the cache for this + /// record at this level. + public static func collect( + selections: [Selection], + cacheKey: CacheKey, + variables: GraphQLOperation.Variables?, + resolveRuntimeType: () -> Object? + ) throws -> Set { + var projections: Set = [] + try walk( + selections, + into: &projections, + cacheKey: cacheKey, + variables: variables, + resolveRuntimeType: resolveRuntimeType + ) + return projections + } + + // MARK: - Selection walk + + /// Mirrors the `Selection` case handling in + /// `DefaultFieldSelectionCollector.collectFields(...)` so the + /// upfront-projection path and the lazy-resolution path always agree + /// on which fields each Selection case contributes. Differences from + /// the default collector: + /// + /// - The output is a flat `Set` keyed by + /// `(cacheKey, fieldName, columnShape, cardinality)`, not a + /// `FieldSelectionGrouping` of `FieldExecutionInfo`. The collector + /// doesn't need response-key grouping because cache rows are + /// keyed by cache-field-key, not by response key, and the cache + /// read doesn't need to know about fragment fulfillment state. + /// - There's no separate "fulfilled fragment" bookkeeping. The + /// collector enters every fulfilled fragment and inline fragment + /// to collect its inner fields; downstream callers don't need a + /// fulfilled-set output because the projections themselves carry + /// the necessary `(cacheKey, fieldName)` pairs. + /// - `.deferred` fragments are handled identically to the cache's + /// existing executor behavior: the executor sets + /// `shouldAttemptDeferredFragmentExecution = true` for + /// `CacheDataExecutionSource`, so all deferred fragments are + /// eagerly entered here (subject to the deferred condition). + /// This keeps cache reads complete on a cold read; the executor + /// ignores deferred-fragment incrementality on the cache path. + private static func walk( + _ selections: [Selection], + into projections: inout Set, + cacheKey: CacheKey, + variables: GraphQLOperation.Variables?, + resolveRuntimeType: () -> Object? + ) throws { + for selection in selections { + switch selection { + case .field(let field): + let fieldName = try field.cacheKey(with: variables) + projections.insert(FieldProjection( + cacheKey: cacheKey, + fieldName: fieldName, + outputType: field.type + )) + + case .conditional(let conditions, let nested): + if conditions.evaluate(with: variables) { + try walk( + nested, + into: &projections, + cacheKey: cacheKey, + variables: variables, + resolveRuntimeType: resolveRuntimeType + ) + } + + case .fragment(let fragmentType): + try walk( + fragmentType.__selections, + into: &projections, + cacheKey: cacheKey, + variables: variables, + resolveRuntimeType: resolveRuntimeType + ) + + case .inlineFragment(let typeCase): + if let runtimeType = resolveRuntimeType(), + typeCase.__parentType.canBeConverted(from: runtimeType) { + try walk( + typeCase.__selections, + into: &projections, + cacheKey: cacheKey, + variables: variables, + resolveRuntimeType: resolveRuntimeType + ) + } + + case .deferred(_, let typeCase, _): + // The cache executor path treats deferred fragments as fully + // fulfilled regardless of the `@defer(if:)` condition: it + // has no incremental delivery channel to honor `@defer`, so + // `CacheDataExecutionSource` sets + // `shouldAttemptDeferredFragmentExecution = true` and + // `GraphQLExecutor` eagerly executes the deferred fragment's + // selections after the normal grouping pass. Mirror that + // behavior here so the collected projection set is complete + // for the level — every deferred fragment's fields are + // projected. The `if:` condition only controls *whether* the + // fragment is deferred (yes/no); under the cache path the + // fields are read in either branch. + try walk( + typeCase.__selections, + into: &projections, + cacheKey: cacheKey, + variables: variables, + resolveRuntimeType: resolveRuntimeType + ) + } + } + } +}