Skip to content

feat(sqlite): cascading deletion of synthetic sub-records - #1007

Open
AnthonyMDev wants to merge 3 commits into
cache-rewrite/phase-1a-row-per-field-crudfrom
cache-rewrite/phase-1a-cascade-delete
Open

feat(sqlite): cascading deletion of synthetic sub-records#1007
AnthonyMDev wants to merge 3 commits into
cache-rewrite/phase-1a-row-per-field-crudfrom
cache-rewrite/phase-1a-cascade-delete

Conversation

@AnthonyMDev

Copy link
Copy Markdown
Contributor

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_value LIKE %.$[%] → repeat → DELETE everything reached. Real (non-synthetic) CacheReference targets are never followed.

Helper Used by Seed
cascadeDeleteSyntheticDescendants(seedCacheKeys:) deleteRecord(forKey:) One or more starting cache keys
cascadeDeleteSyntheticDescendantsOfField(cacheKey:fieldName:) insertOrUpdate atomic rewrite Children of one (cacheKey, fieldName)'s rows
cascadeDeletePatternMatchedSyntheticDescendants(escapedLikePattern:) deleteRecords(matchingKey:) Children of every record whose cache_key matches the pattern

The pattern-cascade decision settles the open question from PR-009: deleteRecords(matchingKey:) cascades synthetic sub-records of matched records. Same rule as deleteRecord(forKey:): only synthetic-suffix children are removed; real CacheReference targets are left alone.

Wiring

Three call sites now invoke cascade ahead of their direct delete/insert:

  • deleteRecord(forKey:) — cascade then directDelete(cacheKey:)
  • insertOrUpdate's atomic-rewrite (inside writeFieldOrList) — cascade-of-field then directDelete(cacheKey:fieldName:) then write the new rows
  • deleteRecords(matchingKey:) — pattern-cascade then the flat LIKE DELETE

Test-only rowCount(forCacheKey:) helper

The 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 -> Int on ApolloSQLiteDatabase. It runs a direct SELECT COUNT(*) FROM records WHERE cache_key = ? and bypasses any filter. Every cascade test in this PR uses rowCount for orphan verification, so the assertions actually inspect the database state.

Tests: 11 cascade-correctness cases

SQLiteRowPerElementCascadeDeleteTests.swift (new file, all passing):

deleteRecord(forKey:) cascade:

  1. Depth-1 nested list ([[Int]]) cascade — two synthetic sub-records both removed
  2. Depth-3 nested list ([[[Int]]]) — recursive CTE reaches the level-3 sub-record
  3. Cascade isolation — deleting record A doesn't disturb record B's synthetic sub-records
  4. Multiple list-typed fields on one record — both fields' synthetic sub-records cascade
  5. Real CacheReference targets in a list are NOT cascaded (independent records preserved)
  6. Real CacheReference inside a synthetic sub-record is NOT cascaded — even one level deep into the synthetic chain, the cascade still respects the real-reference boundary

insertOrUpdate atomic-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

  • Three cascade walks implemented with the same recursive-CTE shape.
  • All 11 cascade tests pass.
  • Full Apollo-UnitTestPlan is green: 1045 passed, 0 failed.
  • rowCount(forCacheKey:) test helper added so cascade orphan verification bypasses selectRecords's synthetic-key filter.
  • deleteRecords(matchingKey:) cascade behavior pinned down + tested (was the open question from PR-009).
  • Real CacheReference targets 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 onto cache-rewrite/phase-1-plan (mechanically — no conflicts expected).

Followup

Per ADR 0007's sub-phase 1A.5: PR-009b (FieldProjection types) is the next step once both PR-009 and this cascade PR are merged.

@apollo-librarian

apollo-librarian Bot commented Jun 1, 2026

Copy link
Copy Markdown

✅ Docs preview ready

The preview is ready to be viewed. View the preview

File Changes

0 new, 4 changed, 0 removed
* (developer-tools)/ios/(latest)/fetching/persisted-queries.mdx
* (developer-tools)/ios/(latest)/tutorial/tutorial-connect-queries-to-ui.mdx
* (developer-tools)/ios/(latest)/tutorial/tutorial-define-additional-mutations.mdx
* (developer-tools)/ios/(latest)/tutorial/tutorial-paginate-results.mdx

Build ID: 42c81b84452ba9a5218ed822
Build Logs: View logs

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


✅ AI Style Review — No Changes Detected

No MDX files were changed in this pull request.

Review Log: View detailed log

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

@AnthonyMDev
AnthonyMDev force-pushed the cache-rewrite/phase-1a-cascade-delete branch from 0ce969a to 93292ee Compare June 1, 2026 19:40
@AnthonyMDev
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
AnthonyMDev force-pushed the cache-rewrite/phase-1a-cascade-delete branch from 93292ee to 4523f6e Compare June 1, 2026 22:00
@AnthonyMDev
AnthonyMDev force-pushed the cache-rewrite/phase-1a-row-per-field-crud branch from ef28685 to b63c35a Compare June 2, 2026 21:02
@AnthonyMDev
AnthonyMDev force-pushed the cache-rewrite/phase-1a-cascade-delete branch from 4523f6e to 380ce4e Compare June 2, 2026 21:02
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
AnthonyMDev force-pushed the cache-rewrite/phase-1a-row-per-field-crud branch from b63c35a to 4a05d29 Compare June 9, 2026 18:26
@AnthonyMDev
AnthonyMDev force-pushed the cache-rewrite/phase-1a-cascade-delete branch from 380ce4e to a531d26 Compare June 9, 2026 18:26
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
AnthonyMDev force-pushed the cache-rewrite/phase-1a-row-per-field-crud branch from 4a05d29 to b715e5e Compare June 29, 2026 20:38
@AnthonyMDev
AnthonyMDev force-pushed the cache-rewrite/phase-1a-cascade-delete branch from a531d26 to 36bedb1 Compare June 29, 2026 20:38
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>
AnthonyMDev and others added 2 commits July 9, 2026 11:52
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
AnthonyMDev force-pushed the cache-rewrite/phase-1a-cascade-delete branch from 36bedb1 to 8efa80d Compare July 9, 2026 18:59
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant