feat(sqlite): cascading deletion of synthetic sub-records - #1007
Open
AnthonyMDev wants to merge 3 commits into
Open
feat(sqlite): cascading deletion of synthetic sub-records#1007AnthonyMDev wants to merge 3 commits into
AnthonyMDev wants to merge 3 commits into
Conversation
✅ Docs preview readyThe preview is ready to be viewed. View the preview File Changes 0 new, 4 changed, 0 removedBuild ID: 42c81b84452ba9a5218ed822 URL: https://www.apollographql.com/docs/deploy-preview/42c81b84452ba9a5218ed822 ✅ AI Style Review — No Changes DetectedNo MDX files were changed in this pull request. Review Log: View detailed log
|
AnthonyMDev
force-pushed
the
cache-rewrite/phase-1a-cascade-delete
branch
from
June 1, 2026 19:40
0ce969a to
93292ee
Compare
AnthonyMDev
changed the base branch from
cache-rewrite/phase-1-plan
to
cache-rewrite/phase-1a-row-per-field-crud
June 1, 2026 20:05
AnthonyMDev
added a commit
that referenced
this pull request
Jun 1, 2026
Adds the value-type expression of ADR 0007's selection-set-aware cache-read mechanism: a `FieldProjection` struct describing one field's read against one record, plus a `ColumnShape` enum (the six row-per-element column slots from ADR 0006) and a `Cardinality` enum (scalar vs list — the `position` discriminator). `FieldProjection` stores `(cacheKey, fieldName, columnShape, cardinality)` directly — the original draft kept the `Selection.Field.OutputType` around for downstream introspection, but none of the planned 1A.5 consumers actually need it: the cache implementations work from `(columnShape, cardinality)`; the executor that builds projections already has the `Selection.Field` tree in its own state for decoding return values; the dependency tracker builds projections from `(cacheKey, fieldName)` dirty-set entries that aren't paired with a `Selection.Field` at all; and the synthetic-vs-real cache-reference discrimination happens at read time via the `.$[N]` suffix pattern already established by PR #1007's cascade walks. Dropping the OutputType also makes equality more semantically correct — `String?` and `String!` (the same projection at the storage layer) now compare equal. Two initializers: - `init(cacheKey:fieldName:outputType:)` — for the executor. Classifies the OutputType into column shape and cardinality at construction time; the OutputType is consumed, not retained. - `init(cacheKey:fieldName:columnShape:cardinality:)` — for the dependency tracker (PR-009f) and tests that synthesize projections from non-Selection.Field sources. ## Classification rules `Selection.Field.OutputType` → `(ColumnShape, Cardinality)`: - `cardinality` = `.list` if any `.list` wrapper appears in the type's wrapper chain, `.scalar` otherwise. - `columnShape` peels `.nonNull` and `.list` wrappers to the named type and maps that to one of the six column slots: built-in `ScalarType` primitives route to their typed columns (`String`→`.string`, `Int`/`Int32`→`.int`, `Bool`→`.bool`, `Float`/`Double`→`.real`), `.object` maps to `.childKey`, `.customScalar(_)` maps to `.customScalar` (see TODO below). - Nested lists (`[[T]]`, 2+ `.list` wrappers) override the named- type rule and return `.childKey` — the outer-list rows hold pointers to synthetic sub-records per ADR 0006 §3.2. ## Deferred — custom-scalar storage shape All `.customScalar(_)` cases currently route to `.customScalar`. This is correct for user-defined struct/class scalars whose `_jsonValue` is a dictionary/array. But the codegen-default `struct ScalarName: CustomScalarType { let value: String; var _jsonValue: ... { value } }` is unwrapped to `String` by the normalizer's `accept(customScalar:)` path before caching, and `SQLiteFieldEncoding` writes it into `string_value`. The read- side projection routing such scalars to `.customScalar` will miss the value. This mismatch is harmless until PR-009g wires up SQL- level projection. ADR 0007 Principle 7 commits to resolving it via a static `CustomScalarType` declaration; a follow-up PR introduces the declaration and updates codegen to emit it. ## Status No consumers yet — exactly as ADR 0007's PR-009b slot describes. `NormalizedCache.loadRecords(forKeys:)` still has its 2.x signature; PR-009c switches the protocol to `loadFields(_:)`. Type is `@_spi(Execution) public` for now to keep the surface private while sub-phase 1A.5 iterates. PR-009c can drop the SPI once the read-API shape is locked. ## Tests 40 unit tests covering: both initializers (with assertion that the two paths produce equal projections), `Equatable` over all four stored properties (cache key, field name, column shape, cardinality), `Hashable` (set-deduplication), all 6 `ColumnShape` cases, wrapper transparency at every depth, nested-list rule (double + triple-nested), all `Cardinality` cases, custom-scalar routing, plus seven end-to-end shape inferences for common GraphQL field types and one test that `String?` and `String!` classify equally. Full Apollo-UnitTestPlan: 1074 passed, 0 failed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AnthonyMDev
added a commit
that referenced
this pull request
Jun 1, 2026
Adds the value-type expression of ADR 0007's selection-set-aware cache-read mechanism: a `FieldProjection` struct describing one field's read against one record, plus a `ColumnShape` enum (the six row-per-element column slots from ADR 0006) and a `Cardinality` enum (scalar vs list — the `position` discriminator). `FieldProjection` stores `(cacheKey, fieldName, columnShape, cardinality)` directly — the original draft kept the `Selection.Field.OutputType` around for downstream introspection, but none of the planned 1A.5 consumers actually need it: the cache implementations work from `(columnShape, cardinality)`; the executor that builds projections already has the `Selection.Field` tree in its own state for decoding return values; the dependency tracker builds projections from `(cacheKey, fieldName)` dirty-set entries that aren't paired with a `Selection.Field` at all; and the synthetic-vs-real cache-reference discrimination happens at read time via the `.$[N]` suffix pattern already established by PR #1007's cascade walks. Dropping the OutputType also makes equality more semantically correct — `String?` and `String!` (the same projection at the storage layer) now compare equal. Two initializers: - `init(cacheKey:fieldName:outputType:)` — for the executor. Classifies the OutputType into column shape and cardinality at construction time; the OutputType is consumed, not retained. - `init(cacheKey:fieldName:columnShape:cardinality:)` — for the dependency tracker (PR-009f) and tests that synthesize projections from non-Selection.Field sources. ## Classification rules `Selection.Field.OutputType` → `(ColumnShape, Cardinality)`: - `cardinality` = `.list` if any `.list` wrapper appears in the type's wrapper chain, `.scalar` otherwise. - `columnShape` peels `.nonNull` and `.list` wrappers to the named type and maps that to one of the six column slots: built-in `ScalarType` primitives route to their typed columns (`String`→`.string`, `Int`/`Int32`→`.int`, `Bool`→`.bool`, `Float`/`Double`→`.real`), `.object` maps to `.childKey`, `.customScalar(_)` maps to `.customScalar` (see TODO below). - Nested lists (`[[T]]`, 2+ `.list` wrappers) override the named- type rule and return `.childKey` — the outer-list rows hold pointers to synthetic sub-records per ADR 0006 §3.2. ## Deferred — custom-scalar storage shape All `.customScalar(_)` cases currently route to `.customScalar`. This is correct for user-defined struct/class scalars whose `_jsonValue` is a dictionary/array. But the codegen-default `struct ScalarName: CustomScalarType { let value: String; var _jsonValue: ... { value } }` is unwrapped to `String` by the normalizer's `accept(customScalar:)` path before caching, and `SQLiteFieldEncoding` writes it into `string_value`. The read- side projection routing such scalars to `.customScalar` will miss the value. This mismatch is harmless until PR-009g wires up SQL- level projection. ADR 0007 Principle 7 commits to resolving it via a static `CustomScalarType` declaration; a follow-up PR introduces the declaration and updates codegen to emit it. ## Status No consumers yet — exactly as ADR 0007's PR-009b slot describes. `NormalizedCache.loadRecords(forKeys:)` still has its 2.x signature; PR-009c switches the protocol to `loadFields(_:)`. Type is `@_spi(Execution) public` for now to keep the surface private while sub-phase 1A.5 iterates. PR-009c can drop the SPI once the read-API shape is locked. ## Tests 40 unit tests covering: both initializers (with assertion that the two paths produce equal projections), `Equatable` over all four stored properties (cache key, field name, column shape, cardinality), `Hashable` (set-deduplication), all 6 `ColumnShape` cases, wrapper transparency at every depth, nested-list rule (double + triple-nested), all `Cardinality` cases, custom-scalar routing, plus seven end-to-end shape inferences for common GraphQL field types and one test that `String?` and `String!` classify equally. Full Apollo-UnitTestPlan: 1074 passed, 0 failed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AnthonyMDev
force-pushed
the
cache-rewrite/phase-1a-cascade-delete
branch
from
June 1, 2026 22:00
93292ee to
4523f6e
Compare
This was referenced Jun 1, 2026
AnthonyMDev
force-pushed
the
cache-rewrite/phase-1a-row-per-field-crud
branch
from
June 2, 2026 21:02
ef28685 to
b63c35a
Compare
AnthonyMDev
force-pushed
the
cache-rewrite/phase-1a-cascade-delete
branch
from
June 2, 2026 21:02
4523f6e to
380ce4e
Compare
AnthonyMDev
added a commit
that referenced
this pull request
Jun 2, 2026
Adds the value-type expression of ADR 0007's selection-set-aware cache-read mechanism: a `FieldProjection` struct describing one field's read against one record, plus a `ColumnShape` enum (the six row-per-element column slots from ADR 0006) and a `Cardinality` enum (scalar vs list — the `position` discriminator). `FieldProjection` stores `(cacheKey, fieldName, columnShape, cardinality)` directly — the original draft kept the `Selection.Field.OutputType` around for downstream introspection, but none of the planned 1A.5 consumers actually need it: the cache implementations work from `(columnShape, cardinality)`; the executor that builds projections already has the `Selection.Field` tree in its own state for decoding return values; the dependency tracker builds projections from `(cacheKey, fieldName)` dirty-set entries that aren't paired with a `Selection.Field` at all; and the synthetic-vs-real cache-reference discrimination happens at read time via the `.$[N]` suffix pattern already established by PR #1007's cascade walks. Dropping the OutputType also makes equality more semantically correct — `String?` and `String!` (the same projection at the storage layer) now compare equal. Two initializers: - `init(cacheKey:fieldName:outputType:)` — for the executor. Classifies the OutputType into column shape and cardinality at construction time; the OutputType is consumed, not retained. - `init(cacheKey:fieldName:columnShape:cardinality:)` — for the dependency tracker (PR-009f) and tests that synthesize projections from non-Selection.Field sources. ## Classification rules `Selection.Field.OutputType` → `(ColumnShape, Cardinality)`: - `cardinality` = `.list` if any `.list` wrapper appears in the type's wrapper chain, `.scalar` otherwise. - `columnShape` peels `.nonNull` and `.list` wrappers to the named type and maps that to one of the six column slots: built-in `ScalarType` primitives route to their typed columns (`String`→`.string`, `Int`/`Int32`→`.int`, `Bool`→`.bool`, `Float`/`Double`→`.real`), `.object` maps to `.childKey`, `.customScalar(_)` maps to `.customScalar` (see TODO below). - Nested lists (`[[T]]`, 2+ `.list` wrappers) override the named- type rule and return `.childKey` — the outer-list rows hold pointers to synthetic sub-records per ADR 0006 §3.2. ## Deferred — custom-scalar storage shape All `.customScalar(_)` cases currently route to `.customScalar`. This is correct for user-defined struct/class scalars whose `_jsonValue` is a dictionary/array. But the codegen-default `struct ScalarName: CustomScalarType { let value: String; var _jsonValue: ... { value } }` is unwrapped to `String` by the normalizer's `accept(customScalar:)` path before caching, and `SQLiteFieldEncoding` writes it into `string_value`. The read- side projection routing such scalars to `.customScalar` will miss the value. This mismatch is harmless until PR-009g wires up SQL- level projection. ADR 0007 Principle 7 commits to resolving it via a static `CustomScalarType` declaration; a follow-up PR introduces the declaration and updates codegen to emit it. ## Status No consumers yet — exactly as ADR 0007's PR-009b slot describes. `NormalizedCache.loadRecords(forKeys:)` still has its 2.x signature; PR-009c switches the protocol to `loadFields(_:)`. Type is `@_spi(Execution) public` for now to keep the surface private while sub-phase 1A.5 iterates. PR-009c can drop the SPI once the read-API shape is locked. ## Tests 40 unit tests covering: both initializers (with assertion that the two paths produce equal projections), `Equatable` over all four stored properties (cache key, field name, column shape, cardinality), `Hashable` (set-deduplication), all 6 `ColumnShape` cases, wrapper transparency at every depth, nested-list rule (double + triple-nested), all `Cardinality` cases, custom-scalar routing, plus seven end-to-end shape inferences for common GraphQL field types and one test that `String?` and `String!` classify equally. Full Apollo-UnitTestPlan: 1074 passed, 0 failed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AnthonyMDev
force-pushed
the
cache-rewrite/phase-1a-row-per-field-crud
branch
from
June 9, 2026 18:26
b63c35a to
4a05d29
Compare
AnthonyMDev
force-pushed
the
cache-rewrite/phase-1a-cascade-delete
branch
from
June 9, 2026 18:26
380ce4e to
a531d26
Compare
AnthonyMDev
added a commit
that referenced
this pull request
Jun 9, 2026
Adds the value-type expression of ADR 0007's selection-set-aware cache-read mechanism: a `FieldProjection` struct describing one field's read against one record, plus a `ColumnShape` enum (the six row-per-element column slots from ADR 0006) and a `Cardinality` enum (scalar vs list — the `position` discriminator). `FieldProjection` stores `(cacheKey, fieldName, columnShape, cardinality)` directly — the original draft kept the `Selection.Field.OutputType` around for downstream introspection, but none of the planned 1A.5 consumers actually need it: the cache implementations work from `(columnShape, cardinality)`; the executor that builds projections already has the `Selection.Field` tree in its own state for decoding return values; the dependency tracker builds projections from `(cacheKey, fieldName)` dirty-set entries that aren't paired with a `Selection.Field` at all; and the synthetic-vs-real cache-reference discrimination happens at read time via the `.$[N]` suffix pattern already established by PR #1007's cascade walks. Dropping the OutputType also makes equality more semantically correct — `String?` and `String!` (the same projection at the storage layer) now compare equal. Two initializers: - `init(cacheKey:fieldName:outputType:)` — for the executor. Classifies the OutputType into column shape and cardinality at construction time; the OutputType is consumed, not retained. - `init(cacheKey:fieldName:columnShape:cardinality:)` — for the dependency tracker (PR-009f) and tests that synthesize projections from non-Selection.Field sources. ## Classification rules `Selection.Field.OutputType` → `(ColumnShape, Cardinality)`: - `cardinality` = `.list` if any `.list` wrapper appears in the type's wrapper chain, `.scalar` otherwise. - `columnShape` peels `.nonNull` and `.list` wrappers to the named type and maps that to one of the six column slots: built-in `ScalarType` primitives route to their typed columns (`String`→`.string`, `Int`/`Int32`→`.int`, `Bool`→`.bool`, `Float`/`Double`→`.real`), `.object` maps to `.childKey`, `.customScalar(_)` maps to `.customScalar` (see TODO below). - Nested lists (`[[T]]`, 2+ `.list` wrappers) override the named- type rule and return `.childKey` — the outer-list rows hold pointers to synthetic sub-records per ADR 0006 §3.2. ## Deferred — custom-scalar storage shape All `.customScalar(_)` cases currently route to `.customScalar`. This is correct for user-defined struct/class scalars whose `_jsonValue` is a dictionary/array. But the codegen-default `struct ScalarName: CustomScalarType { let value: String; var _jsonValue: ... { value } }` is unwrapped to `String` by the normalizer's `accept(customScalar:)` path before caching, and `SQLiteFieldEncoding` writes it into `string_value`. The read- side projection routing such scalars to `.customScalar` will miss the value. This mismatch is harmless until PR-009g wires up SQL- level projection. ADR 0007 Principle 7 commits to resolving it via a static `CustomScalarType` declaration; a follow-up PR introduces the declaration and updates codegen to emit it. ## Status No consumers yet — exactly as ADR 0007's PR-009b slot describes. `NormalizedCache.loadRecords(forKeys:)` still has its 2.x signature; PR-009c switches the protocol to `loadFields(_:)`. Type is `@_spi(Execution) public` for now to keep the surface private while sub-phase 1A.5 iterates. PR-009c can drop the SPI once the read-API shape is locked. ## Tests 40 unit tests covering: both initializers (with assertion that the two paths produce equal projections), `Equatable` over all four stored properties (cache key, field name, column shape, cardinality), `Hashable` (set-deduplication), all 6 `ColumnShape` cases, wrapper transparency at every depth, nested-list rule (double + triple-nested), all `Cardinality` cases, custom-scalar routing, plus seven end-to-end shape inferences for common GraphQL field types and one test that `String?` and `String!` classify equally. Full Apollo-UnitTestPlan: 1074 passed, 0 failed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AnthonyMDev
force-pushed
the
cache-rewrite/phase-1a-row-per-field-crud
branch
from
June 29, 2026 20:38
4a05d29 to
b715e5e
Compare
AnthonyMDev
force-pushed
the
cache-rewrite/phase-1a-cascade-delete
branch
from
June 29, 2026 20:38
a531d26 to
36bedb1
Compare
AnthonyMDev
added a commit
that referenced
this pull request
Jun 29, 2026
Adds the value-type expression of ADR 0007's selection-set-aware cache-read mechanism: a `FieldProjection` struct describing one field's read against one record, plus a `ColumnShape` enum (the six row-per-element column slots from ADR 0006) and a `Cardinality` enum (scalar vs list — the `position` discriminator). `FieldProjection` stores `(cacheKey, fieldName, columnShape, cardinality)` directly — the original draft kept the `Selection.Field.OutputType` around for downstream introspection, but none of the planned 1A.5 consumers actually need it: the cache implementations work from `(columnShape, cardinality)`; the executor that builds projections already has the `Selection.Field` tree in its own state for decoding return values; the dependency tracker builds projections from `(cacheKey, fieldName)` dirty-set entries that aren't paired with a `Selection.Field` at all; and the synthetic-vs-real cache-reference discrimination happens at read time via the `.$[N]` suffix pattern already established by PR #1007's cascade walks. Dropping the OutputType also makes equality more semantically correct — `String?` and `String!` (the same projection at the storage layer) now compare equal. Two initializers: - `init(cacheKey:fieldName:outputType:)` — for the executor. Classifies the OutputType into column shape and cardinality at construction time; the OutputType is consumed, not retained. - `init(cacheKey:fieldName:columnShape:cardinality:)` — for the dependency tracker (PR-009f) and tests that synthesize projections from non-Selection.Field sources. ## Classification rules `Selection.Field.OutputType` → `(ColumnShape, Cardinality)`: - `cardinality` = `.list` if any `.list` wrapper appears in the type's wrapper chain, `.scalar` otherwise. - `columnShape` peels `.nonNull` and `.list` wrappers to the named type and maps that to one of the six column slots: built-in `ScalarType` primitives route to their typed columns (`String`→`.string`, `Int`/`Int32`→`.int`, `Bool`→`.bool`, `Float`/`Double`→`.real`), `.object` maps to `.childKey`, `.customScalar(_)` maps to `.customScalar` (see TODO below). - Nested lists (`[[T]]`, 2+ `.list` wrappers) override the named- type rule and return `.childKey` — the outer-list rows hold pointers to synthetic sub-records per ADR 0006 §3.2. ## Deferred — custom-scalar storage shape All `.customScalar(_)` cases currently route to `.customScalar`. This is correct for user-defined struct/class scalars whose `_jsonValue` is a dictionary/array. But the codegen-default `struct ScalarName: CustomScalarType { let value: String; var _jsonValue: ... { value } }` is unwrapped to `String` by the normalizer's `accept(customScalar:)` path before caching, and `SQLiteFieldEncoding` writes it into `string_value`. The read- side projection routing such scalars to `.customScalar` will miss the value. This mismatch is harmless until PR-009g wires up SQL- level projection. ADR 0007 Principle 7 commits to resolving it via a static `CustomScalarType` declaration; a follow-up PR introduces the declaration and updates codegen to emit it. ## Status No consumers yet — exactly as ADR 0007's PR-009b slot describes. `NormalizedCache.loadRecords(forKeys:)` still has its 2.x signature; PR-009c switches the protocol to `loadFields(_:)`. Type is `@_spi(Execution) public` for now to keep the surface private while sub-phase 1A.5 iterates. PR-009c can drop the SPI once the read-API shape is locked. ## Tests 40 unit tests covering: both initializers (with assertion that the two paths produce equal projections), `Equatable` over all four stored properties (cache key, field name, column shape, cardinality), `Hashable` (set-deduplication), all 6 `ColumnShape` cases, wrapper transparency at every depth, nested-list rule (double + triple-nested), all `Cardinality` cases, custom-scalar routing, plus seven end-to-end shape inferences for common GraphQL field types and one test that `String?` and `String!` classify equally. Full Apollo-UnitTestPlan: 1074 passed, 0 failed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the cascade walks deferred from PR-009 (#1001), wired into all three places that need them, plus extensive correctness tests. Three recursive-CTE walks share the same shape (seed → follow `child_key_value` LIKE `%.$[%]` → repeat → DELETE everything reached): - `cascadeDeleteSyntheticDescendants(seedCacheKeys:)` — full-record walk for `deleteRecord(forKey:)`. - `cascadeDeleteSyntheticDescendantsOfField(cacheKey:fieldName:)` — scoped to one field, used by `insertOrUpdate`'s atomic rewrite before re-writing the field's element rows. - `cascadeDeletePatternMatchedSyntheticDescendants(escapedLikePattern:)` — seeds from records matching the pattern, used by `deleteRecords(matchingKey:)` to clean up synthetic descendants of every matched record. Real (non-synthetic) `CacheReference` targets are never followed — each walk filters seed children on the synthetic-suffix `LIKE` pattern, so a real ref to an independent record is left alone. Test-only helper `rowCount(forCacheKey:)` is added to `ApolloSQLiteDatabase` so cascade tests can verify orphan removal directly against the database. The previous draft of these tests ran assertions through `selectRecords`, which filters synthetic keys before returning — the filter masked orphans and made the assertions pass regardless of cascade behavior. `rowCount` bypasses the filter via a direct `COUNT(*)` query and surfaces the real DB state. `SQLiteRowPerElementCascadeDeleteTests.swift` (new file) — 11 tests: deleteRecord(forKey:) cascade: - depth-1 nested list (`[[Int]]`) cascade - depth-3 nested list (`[[[Int]]]`) cascade — recursive CTE must reach the level-3 sub-record - cascade isolation — deleting record A doesn't affect record B's synthetic sub-records - multiple list-typed fields on one record — both fields' synthetic sub-records cascade - real CacheReference targets in a list are NOT cascaded - real CacheReference INSIDE a synthetic sub-record is NOT cascaded insertOrUpdate atomic-rewrite cascade: - nested-list → scalar rewrite cleans synthetic sub-records - 3D nested-list → scalar rewrite cleans all synthetic descendants - rewriting one field doesn't disturb the OTHER field's synthetic sub-records on the same record deleteRecords(matchingKey:) cascade: - pattern-matched records' synthetic sub-records cascade - unmatched records' synthetic sub-records survive (cascade isolation through the pattern boundary) Full Apollo-UnitTestPlan: 1045 passed, 0 failed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rds only The cascade walk and the direct delete in deleteRecord(forKey:) and deleteRecords(matchingKey:) ran as two separate autocommit statements; a failure between them could leave record rows pointing at already-deleted synthetic sub-records. Both are now wrapped in a single transaction, matching insertOrUpdate. Pattern deletes no longer match synthetic sub-record keys directly. Synthetic keys embed parent field names (User:1.claws.$[0]), so a substring pattern could match a synthetic key whose parent record doesn't match — deleting the internals of a list the caller never asked to touch and leaving the parent's rows dangling. The flat DELETE and the cascade seed now both exclude synthetic keys; synthetic rows are removed exclusively via the cascade from matched user records. (The reserved-key audit landed below this branch guarantees user records can never match the synthetic classifiers, making this exclusion safe.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
AnthonyMDev
force-pushed
the
cache-rewrite/phase-1a-cascade-delete
branch
from
July 9, 2026 18:59
36bedb1 to
8efa80d
Compare
…agreement rowCount(forCacheKey:) leaves ApolloSQLiteDatabase. The new SQLiteTestDatabaseInspector (ApolloInternalTestHelpers, dev-repo only) opens its own read-only connection to the database file, so storage-level orphan assertions need no test-only surface on the production class. Cascade tests hold the fixture's file URL and delegate through a local helper. Also adds SQLiteSyntheticKeyClassifierTests pinning the relationship between the Swift regex and SQL LIKE synthetic-key classifiers, with LIKE evaluated by SQLite itself (SELECT ? LIKE ? ESCAPE ?) rather than re-implemented. The invariants tested: regex matches are a subset of LIKE matches (the SQL cascade walks never miss a real synthetic key), and any key the two classify differently contains the reserved .$[ token, which insertOrUpdate rejects — so no storable key is ever classified inconsistently. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Goal
Add cascading deletion of synthetic sub-records (
<parent>.<field>.$[N]) to all three delete/rewrite paths in the row-per-element schema, with extensive cascade-correctness tests. Deferred from PR-009 (#1001) so the cascade walks + their tests get a focused review surface.Stacked on PR-009 (#1001)
This branch is stacked on
cache-rewrite/phase-1a-row-per-field-crud(PR #1001). The diff shows only the cascade additions; the row-per-element CRUD foundation is reviewed under PR-009.What's in this PR
Three cascade-CTE walks
All three share the same recursive shape: seed → follow
child_key_valueLIKE%.$[%]→ repeat →DELETEeverything reached. Real (non-synthetic)CacheReferencetargets are never followed.cascadeDeleteSyntheticDescendants(seedCacheKeys:)deleteRecord(forKey:)cascadeDeleteSyntheticDescendantsOfField(cacheKey:fieldName:)insertOrUpdateatomic rewrite(cacheKey, fieldName)'s rowscascadeDeletePatternMatchedSyntheticDescendants(escapedLikePattern:)deleteRecords(matchingKey:)cache_keymatches the patternThe pattern-cascade decision settles the open question from PR-009:
deleteRecords(matchingKey:)cascades synthetic sub-records of matched records. Same rule asdeleteRecord(forKey:): only synthetic-suffix children are removed; realCacheReferencetargets are left alone.Wiring
Three call sites now invoke cascade ahead of their direct delete/insert:
deleteRecord(forKey:)— cascade thendirectDelete(cacheKey:)insertOrUpdate's atomic-rewrite (insidewriteFieldOrList) — cascade-of-field thendirectDelete(cacheKey:fieldName:)then write the new rowsdeleteRecords(matchingKey:)— pattern-cascade then the flatLIKEDELETETest-only
rowCount(forCacheKey:)helperThe cascade tests on PR #1001's earlier draft had a bug that made them load-bearing: they ran assertions through
selectRecords, which filters synthetic keys before returning. The filter masked orphan rows entirely — the assertions passed regardless of cascade behavior.This PR adds
internal func rowCount(forCacheKey:) throws -> IntonApolloSQLiteDatabase. It runs a directSELECT COUNT(*) FROM records WHERE cache_key = ?and bypasses any filter. Every cascade test in this PR usesrowCountfor orphan verification, so the assertions actually inspect the database state.Tests: 11 cascade-correctness cases
SQLiteRowPerElementCascadeDeleteTests.swift(new file, all passing):deleteRecord(forKey:)cascade:[[Int]]) cascade — two synthetic sub-records both removed[[[Int]]]) — recursive CTE reaches the level-3 sub-recordCacheReferencetargets in a list are NOT cascaded (independent records preserved)CacheReferenceinside a synthetic sub-record is NOT cascaded — even one level deep into the synthetic chain, the cascade still respects the real-reference boundaryinsertOrUpdateatomic-rewrite cascade:7. Nested-list → scalar rewrite cleans the prior synthetic sub-records
8. 3D nested-list → scalar rewrite cleans all synthetic descendants (recursive walk reaches level-2 and level-3 sub-records)
9. Rewriting one field on a multi-field record doesn't disturb the other field's synthetic sub-records
deleteRecords(matchingKey:)cascade:10. Pattern-matched records' synthetic sub-records cascade
11. Unmatched records' synthetic sub-records survive (cascade isolation across the pattern boundary)
Acceptance criteria
Apollo-UnitTestPlanis green: 1045 passed, 0 failed.rowCount(forCacheKey:)test helper added so cascade orphan verification bypassesselectRecords's synthetic-key filter.deleteRecords(matchingKey:)cascade behavior pinned down + tested (was the open question from PR-009).CacheReferencetargets are never cascaded — verified both at the top level and one level deep into the synthetic chain.Stacks on
PR-009 (#1001) on branch
cache-rewrite/phase-1a-row-per-field-crud. After PR-009 merges, this PR rebases ontocache-rewrite/phase-1-plan(mechanically — no conflicts expected).Followup
Per ADR 0007's sub-phase 1A.5: PR-009b (
FieldProjectiontypes) is the next step once both PR-009 and this cascade PR are merged.