From e6b5eaf7b28cc11101ede96f9ee7be967928bf0c Mon Sep 17 00:00:00 2001 From: Anthony Miller Date: Mon, 1 Jun 2026 11:38:24 -0700 Subject: [PATCH 1/3] feat(sqlite): cascading deletion of synthetic sub-records MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ...QLiteRowPerElementCascadeDeleteTests.swift | 295 ++++++++++++++++++ .../ApolloSQLite/ApolloSQLiteDatabase.swift | 173 ++++++++-- .../Sources/ApolloSQLite/SQLiteDatabase.swift | 45 ++- 3 files changed, 468 insertions(+), 45 deletions(-) create mode 100644 Tests/ApolloTests/SQLiteRowPerElementCascadeDeleteTests.swift diff --git a/Tests/ApolloTests/SQLiteRowPerElementCascadeDeleteTests.swift b/Tests/ApolloTests/SQLiteRowPerElementCascadeDeleteTests.swift new file mode 100644 index 000000000..a677bb840 --- /dev/null +++ b/Tests/ApolloTests/SQLiteRowPerElementCascadeDeleteTests.swift @@ -0,0 +1,295 @@ +import XCTest +@testable @_spi(Execution) import Apollo +@testable import ApolloSQLite +import ApolloInternalTestHelpers + +/// Cascade-correctness tests for the row-per-element schema. +/// +/// Synthetic sub-records (`..$[N]`) are produced by +/// nested-list writes. When a record is deleted or one of its +/// nested-list fields is overwritten, every reachable synthetic +/// sub-record must be cleaned up so the database doesn't accumulate +/// unreachable orphan rows. +/// +/// The tests in this suite verify orphan removal via the test-only +/// `rowCount(forCacheKey:)` helper, which bypasses +/// `selectRecords`'s synthetic-key filter and queries the database +/// directly. An earlier draft of these tests went through +/// `selectRecords` and silently passed regardless of cascade behavior +/// because the filter masked the orphans — `rowCount` makes the +/// assertions actually load-bearing. +class SQLiteRowPerElementCascadeDeleteTests: XCTestCase { + + // MARK: - Fixtures + + private func makeDatabase() throws -> ApolloSQLiteDatabase { + let db = try ApolloSQLiteDatabase(fileURL: SQLiteTestCacheProvider.temporarySQLiteFileURL()) + try db.createSchemaMetadataTableIfNeeded() + try db.createNewRecordsTableIfNeeded() + return db + } + + /// Wraps a value into a `Record` with one field. Lets the test + /// helpers stay legible when constructing nested-list values. + private func record( + _ key: CacheKey, + field: String, + value: Record.Value, + writtenAt: Int64 = 100 + ) -> Record { + Record( + key: key, + fields: [field: CachedField(value: value, writtenAt: writtenAt)] + ) + } + + // MARK: - deleteRecord(forKey:) cascade + + func test__deleteRecord_forKey__cascadesDepth1NestedListSyntheticSubRecords() throws { + let db = try makeDatabase() + // 2D: outer list with two inner-list elements. Each inner list + // produces one synthetic sub-record at `Math:1.matrix.$[N]`. + let row0: [Record.Value] = [1, 2] + let row1: [Record.Value] = [3, 4] + let matrix: [Record.Value] = [row0 as Record.Value, row1 as Record.Value] + try db.insertOrUpdate(records: [record("Math:1", field: "matrix", value: matrix as Record.Value)]) + + // Pre-check: synthetic sub-records exist. + XCTAssertEqual(try db.rowCount(forCacheKey: "Math:1.matrix.$[0]"), 2) + XCTAssertEqual(try db.rowCount(forCacheKey: "Math:1.matrix.$[1]"), 2) + + try db.deleteRecord(forKey: "Math:1") + + // Parent gone. + XCTAssertEqual(try db.rowCount(forCacheKey: "Math:1"), 0) + // Synthetic descendants gone. + XCTAssertEqual(try db.rowCount(forCacheKey: "Math:1.matrix.$[0]"), 0, + "Depth-1 synthetic sub-record must cascade") + XCTAssertEqual(try db.rowCount(forCacheKey: "Math:1.matrix.$[1]"), 0, + "Depth-1 synthetic sub-record must cascade") + } + + func test__deleteRecord_forKey__cascadesDeeplyNestedSyntheticSubRecords() throws { + let db = try makeDatabase() + // 3D: `[[[5]]]`. Three levels of synthetic sub-records. + let innermost: [Record.Value] = [5] + let middle: [Record.Value] = [innermost as Record.Value] + let outer: [Record.Value] = [middle as Record.Value] + try db.insertOrUpdate(records: [record("Math:cube", field: "cube", value: outer as Record.Value)]) + + // Pre-check: every level exists. + XCTAssertEqual(try db.rowCount(forCacheKey: "Math:cube"), 1) + XCTAssertEqual(try db.rowCount(forCacheKey: "Math:cube.cube.$[0]"), 1) + XCTAssertEqual(try db.rowCount(forCacheKey: "Math:cube.cube.$[0].$[0]"), 1) + + try db.deleteRecord(forKey: "Math:cube") + + XCTAssertEqual(try db.rowCount(forCacheKey: "Math:cube"), 0) + XCTAssertEqual(try db.rowCount(forCacheKey: "Math:cube.cube.$[0]"), 0, + "Level-2 synthetic sub-record must cascade") + XCTAssertEqual(try db.rowCount(forCacheKey: "Math:cube.cube.$[0].$[0]"), 0, + "Level-3 synthetic sub-record must cascade — recursive walk must reach it") + } + + func test__deleteRecord_forKey__cascadeIsolation_leavesOtherRecordsSyntheticSubRecords() throws { + let db = try makeDatabase() + // Two independent records, both with nested-list fields that + // produce synthetic sub-records. Deleting one must not touch + // the other. + let matrixA: [Record.Value] = [[1, 2] as Record.Value] + let matrixB: [Record.Value] = [[3, 4] as Record.Value] + try db.insertOrUpdate(records: [ + record("Math:A", field: "matrix", value: matrixA as Record.Value), + record("Math:B", field: "matrix", value: matrixB as Record.Value), + ]) + + try db.deleteRecord(forKey: "Math:A") + + // A is gone with its descendants. + XCTAssertEqual(try db.rowCount(forCacheKey: "Math:A"), 0) + XCTAssertEqual(try db.rowCount(forCacheKey: "Math:A.matrix.$[0]"), 0) + // B is untouched. + XCTAssertEqual(try db.rowCount(forCacheKey: "Math:B"), 1) + XCTAssertEqual(try db.rowCount(forCacheKey: "Math:B.matrix.$[0]"), 2, + "Sibling record's synthetic sub-records must survive") + } + + func test__deleteRecord_forKey__cascadesAcrossMultipleNestedListFieldsOnTheSameRecord() throws { + let db = try makeDatabase() + // One record with TWO list-of-list fields. Each field + // produces its own synthetic sub-records. Deleting the record + // must cascade through both. + let matrix: [Record.Value] = [[1, 2] as Record.Value] + let coords: [Record.Value] = [[3, 4] as Record.Value] + let fields: Record.Fields = [ + "matrix": CachedField(value: matrix as Record.Value, writtenAt: 100), + "coords": CachedField(value: coords as Record.Value, writtenAt: 100), + ] + try db.insertOrUpdate(records: [Record(key: "Math:multi", fields: fields)]) + + XCTAssertGreaterThan(try db.rowCount(forCacheKey: "Math:multi.matrix.$[0]"), 0) + XCTAssertGreaterThan(try db.rowCount(forCacheKey: "Math:multi.coords.$[0]"), 0) + + try db.deleteRecord(forKey: "Math:multi") + + XCTAssertEqual(try db.rowCount(forCacheKey: "Math:multi.matrix.$[0]"), 0, + "First nested-list field's sub-record must cascade") + XCTAssertEqual(try db.rowCount(forCacheKey: "Math:multi.coords.$[0]"), 0, + "Second nested-list field's sub-record must cascade") + } + + func test__deleteRecord_forKey__doesNotFollowRealCacheReferenceTargets() throws { + let db = try makeDatabase() + // QUERY_ROOT's `users` field is a list of *real* (non-synthetic) + // CacheReferences pointing to User:1 and User:2. Deleting + // QUERY_ROOT must remove QUERY_ROOT's rows but leave the User + // records and their fields untouched — real references are not + // part of the synthetic-sub-record cascade. + let users: [Record.Value] = [ + CacheReference("User:1") as Record.Value, + CacheReference("User:2") as Record.Value, + ] + try db.insertOrUpdate(records: [ + Record(key: "QUERY_ROOT", fields: ["users": CachedField(value: users as Record.Value, writtenAt: 100)]), + Record(key: "User:1", fields: ["name": CachedField(value: "A" as Record.Value, writtenAt: 100)]), + Record(key: "User:2", fields: ["name": CachedField(value: "B" as Record.Value, writtenAt: 100)]), + ]) + + try db.deleteRecord(forKey: "QUERY_ROOT") + + XCTAssertEqual(try db.rowCount(forCacheKey: "QUERY_ROOT"), 0) + XCTAssertEqual(try db.rowCount(forCacheKey: "User:1"), 1, + "Real CacheReference target must not be cascade-deleted") + XCTAssertEqual(try db.rowCount(forCacheKey: "User:2"), 1) + } + + func test__deleteRecord_forKey__doesNotFollowRealCacheReferenceInsideSyntheticSubRecord() throws { + let db = try makeDatabase() + // Org has a list-of-list-of-references: `[[CacheReference]]`. + // The outer list creates synthetic sub-records; each sub-record + // holds an inner list of REAL CacheReferences to User:N records. + // Deleting Org must cascade the synthetic sub-records but must + // leave User:1 / User:2 alone — real references inside + // synthetic sub-records still don't trigger cascade. + let team: [Record.Value] = [ + CacheReference("User:1") as Record.Value, + CacheReference("User:2") as Record.Value, + ] + let teams: [Record.Value] = [team as Record.Value] + try db.insertOrUpdate(records: [ + Record(key: "Org:1", fields: ["teams": CachedField(value: teams as Record.Value, writtenAt: 100)]), + Record(key: "User:1", fields: ["name": CachedField(value: "A" as Record.Value, writtenAt: 100)]), + Record(key: "User:2", fields: ["name": CachedField(value: "B" as Record.Value, writtenAt: 100)]), + ]) + + XCTAssertGreaterThan(try db.rowCount(forCacheKey: "Org:1.teams.$[0]"), 0) + + try db.deleteRecord(forKey: "Org:1") + + XCTAssertEqual(try db.rowCount(forCacheKey: "Org:1"), 0) + XCTAssertEqual(try db.rowCount(forCacheKey: "Org:1.teams.$[0]"), 0, + "Synthetic sub-record under Org:1.teams must cascade") + XCTAssertEqual(try db.rowCount(forCacheKey: "User:1"), 1, + "Real CacheReference inside a synthetic sub-record must NOT be cascaded") + XCTAssertEqual(try db.rowCount(forCacheKey: "User:2"), 1) + } + + // MARK: - insertOrUpdate atomic-rewrite cascade + + func test__insertOrUpdate__atomicRewriteOfNestedListField_cleansSyntheticSubRecords() throws { + let db = try makeDatabase() + let matrix: [Record.Value] = [[1, 2] as Record.Value, [3, 4] as Record.Value] + try db.insertOrUpdate(records: [record("Math:1", field: "matrix", value: matrix as Record.Value)]) + XCTAssertEqual(try db.rowCount(forCacheKey: "Math:1.matrix.$[0]"), 2) + XCTAssertEqual(try db.rowCount(forCacheKey: "Math:1.matrix.$[1]"), 2) + + // Rewrite as a scalar — the prior synthetic sub-records must be + // cleaned up, not orphaned. + try db.insertOrUpdate(records: [record("Math:1", field: "matrix", value: "rewritten" as Record.Value, writtenAt: 200)]) + + XCTAssertEqual(try db.rowCount(forCacheKey: "Math:1.matrix.$[0]"), 0, + "Atomic list→scalar rewrite must cascade synthetic sub-records") + XCTAssertEqual(try db.rowCount(forCacheKey: "Math:1.matrix.$[1]"), 0) + } + + func test__insertOrUpdate__atomicRewriteOfDeeplyNestedList_cleansAllSyntheticDescendants() throws { + let db = try makeDatabase() + // 3D first, then a single-element scalar. + let cube: [Record.Value] = [[[5] as Record.Value] as Record.Value] + try db.insertOrUpdate(records: [record("Math:cube", field: "cube", value: cube as Record.Value)]) + XCTAssertEqual(try db.rowCount(forCacheKey: "Math:cube.cube.$[0]"), 1) + XCTAssertEqual(try db.rowCount(forCacheKey: "Math:cube.cube.$[0].$[0]"), 1) + + try db.insertOrUpdate(records: [record("Math:cube", field: "cube", value: "flat" as Record.Value, writtenAt: 200)]) + + XCTAssertEqual(try db.rowCount(forCacheKey: "Math:cube.cube.$[0]"), 0, + "Level-2 synthetic sub-record must cascade on rewrite") + XCTAssertEqual(try db.rowCount(forCacheKey: "Math:cube.cube.$[0].$[0]"), 0, + "Level-3 synthetic sub-record must cascade — recursive walk must reach it on rewrite too") + } + + func test__insertOrUpdate__atomicRewriteOfOneField_preservesOtherFieldsSyntheticSubRecords() throws { + let db = try makeDatabase() + // Record with two nested-list fields. Rewriting ONE field must + // not cascade-delete the OTHER field's synthetic sub-records. + let matrix: [Record.Value] = [[1, 2] as Record.Value] + let coords: [Record.Value] = [[3, 4] as Record.Value] + let fields: Record.Fields = [ + "matrix": CachedField(value: matrix as Record.Value, writtenAt: 100), + "coords": CachedField(value: coords as Record.Value, writtenAt: 100), + ] + try db.insertOrUpdate(records: [Record(key: "Math:multi", fields: fields)]) + XCTAssertEqual(try db.rowCount(forCacheKey: "Math:multi.matrix.$[0]"), 2) + XCTAssertEqual(try db.rowCount(forCacheKey: "Math:multi.coords.$[0]"), 2) + + // Rewrite only `matrix`. + try db.insertOrUpdate(records: [record("Math:multi", field: "matrix", value: "rewritten" as Record.Value, writtenAt: 200)]) + + XCTAssertEqual(try db.rowCount(forCacheKey: "Math:multi.matrix.$[0]"), 0, + "Rewritten field's synthetic sub-record must cascade") + XCTAssertEqual(try db.rowCount(forCacheKey: "Math:multi.coords.$[0]"), 2, + "Untouched field's synthetic sub-record must survive") + } + + // MARK: - deleteRecords(matchingKey:) cascade + + func test__deleteRecords_matchingKey__cascadesSyntheticDescendantsOfMatchedRecords() throws { + let db = try makeDatabase() + // Two `Animal:` records with nested-list fields. + let matrixA: [Record.Value] = [[1, 2] as Record.Value] + let matrixB: [Record.Value] = [[3, 4] as Record.Value] + try db.insertOrUpdate(records: [ + record("Animal:cat", field: "claws", value: matrixA as Record.Value), + record("Animal:dog", field: "claws", value: matrixB as Record.Value), + ]) + XCTAssertEqual(try db.rowCount(forCacheKey: "Animal:cat.claws.$[0]"), 2) + XCTAssertEqual(try db.rowCount(forCacheKey: "Animal:dog.claws.$[0]"), 2) + + try db.deleteRecords(matchingKey: "Animal:") + + XCTAssertEqual(try db.rowCount(forCacheKey: "Animal:cat"), 0) + XCTAssertEqual(try db.rowCount(forCacheKey: "Animal:dog"), 0) + XCTAssertEqual(try db.rowCount(forCacheKey: "Animal:cat.claws.$[0]"), 0, + "Pattern-deleted record's synthetic sub-record must cascade") + XCTAssertEqual(try db.rowCount(forCacheKey: "Animal:dog.claws.$[0]"), 0, + "Pattern-deleted record's synthetic sub-record must cascade") + } + + func test__deleteRecords_matchingKey__doesNotCascadeUnmatchedRecordsSyntheticSubRecords() throws { + let db = try makeDatabase() + let matrixCat: [Record.Value] = [[1, 2] as Record.Value] + let matrixUser: [Record.Value] = [[5, 6] as Record.Value] + try db.insertOrUpdate(records: [ + record("Animal:cat", field: "matrix", value: matrixCat as Record.Value), + record("User:1", field: "matrix", value: matrixUser as Record.Value), + ]) + + try db.deleteRecords(matchingKey: "Animal:") + + XCTAssertEqual(try db.rowCount(forCacheKey: "Animal:cat.matrix.$[0]"), 0, + "Matched record's synthetic sub-record must cascade") + XCTAssertEqual(try db.rowCount(forCacheKey: "User:1"), 1) + XCTAssertEqual(try db.rowCount(forCacheKey: "User:1.matrix.$[0]"), 2, + "Unmatched record's synthetic sub-record must survive") + } +} diff --git a/apollo-ios/Sources/ApolloSQLite/ApolloSQLiteDatabase.swift b/apollo-ios/Sources/ApolloSQLite/ApolloSQLiteDatabase.swift index 5572844c0..bb2b9b4a7 100644 --- a/apollo-ios/Sources/ApolloSQLite/ApolloSQLiteDatabase.swift +++ b/apollo-ios/Sources/ApolloSQLite/ApolloSQLiteDatabase.swift @@ -367,16 +367,8 @@ public final class ApolloSQLiteDatabase: SQLiteDatabase { } public func deleteRecord(forKey cacheKey: CacheKey) throws { - // Direct delete only — synthetic sub-records (`..$[N]`) - // produced by nested-list writes are not cascaded here. If the - // record being deleted has list-typed fields with depth ≥ 2, the - // corresponding synthetic sub-record rows remain in the database - // as orphans. They are unreachable from any cache key after this - // delete (the parent's `child_key_value` pointers are gone) but - // they take up storage until a follow-up cascade-delete PR cleans - // them up. Reads are unaffected — orphans never surface through - // `selectRecords`. try performSync { + try cascadeDeleteSyntheticDescendants(seedCacheKeys: [cacheKey]) try directDelete(cacheKey: cacheKey) } } @@ -394,6 +386,14 @@ public final class ApolloSQLiteDatabase: SQLiteDatabase { let wildcardPattern = "%\(escaped)%" try performSync { + // Cascade walk: synthetic sub-records reachable from any record + // whose cache_key matches the pattern. The walk's seed is the + // set of matched records, and it follows `child_key_value` + // pointers whose value ends with `.$[]`. The matched + // records themselves are then deleted by the direct DELETE + // below. + try cascadeDeletePatternMatchedSyntheticDescendants(escapedLikePattern: wildcardPattern) + let sql = """ DELETE FROM \(SQLiteSchema.recordsTableName) WHERE \(SQLiteSchema.Records.cacheKey) LIKE ? COLLATE NOCASE ESCAPE '\\' @@ -409,6 +409,28 @@ public final class ApolloSQLiteDatabase: SQLiteDatabase { } } + /// Test-only: returns the number of rows whose `cache_key` exactly + /// matches the given key. Bypasses `selectRecords`'s synthetic-key + /// filter so cascade-correctness tests can verify whether synthetic + /// sub-record rows still exist in the database after a delete or + /// rewrite. Production code should not depend on this helper. + internal func rowCount(forCacheKey cacheKey: CacheKey) throws -> Int { + try performSync { + let sql = """ + SELECT COUNT(*) FROM \(SQLiteSchema.recordsTableName) + WHERE \(SQLiteSchema.Records.cacheKey) = ? + """ + let stmt = try prepareStatement(sql, errorMessage: "Failed to prepare rowCount probe") + defer { sqlite3_finalize(stmt) } + sqlite3_bind_text(stmt, 1, cacheKey, -1, SQLITE_TRANSIENT) + let stepResult = sqlite3_step(stmt) + guard stepResult == SQLITE_ROW else { + throw SQLiteError.step(message: "rowCount probe failed: \(sqliteErrorMessage())", resultCode: stepResult) + } + return Int(sqlite3_column_int64(stmt, 0)) + } + } + /// Test-only read path: loads every row for the given cache keys, /// reassembles them into `Record` instances, and follows synthetic /// sub-record `child_key_value` pointers to materialize nested lists. @@ -442,23 +464,20 @@ public final class ApolloSQLiteDatabase: SQLiteDatabase { /// never-written field /// - nested-list elements recurse via a synthetic sub-record /// - /// Any prior rows for `(cacheKey, fieldName)` are cleared first so - /// shape transitions (scalar → list, list → scalar, longer-list → - /// shorter-list) leave no in-field orphans. The clear-then-write - /// happens inside the caller's transaction so partial states are - /// never observable to readers. - /// - /// Note: this implementation does NOT cascade-clean synthetic sub- - /// records pointed to by the previous rows. If the prior value was - /// a list with depth ≥ 2, those synthetic sub-record rows remain - /// in the database as orphans (unreachable but persistent). A - /// follow-up cascade-delete PR handles that cleanup. + /// Any prior rows for `(cacheKey, fieldName)` are cleared first — + /// along with any reachable synthetic sub-records — so shape + /// transitions (scalar → list, list → scalar, longer-list → + /// shorter-list, nested-list → anything) leave no orphans. The + /// full cascade-then-direct-then-write sequence runs inside the + /// caller's transaction so partial states are never observable to + /// readers. private func writeFieldOrList( cacheKey: CacheKey, fieldName: String, value: Record.Value, writtenAt: Int64 ) throws { + try cascadeDeleteSyntheticDescendantsOfField(cacheKey: cacheKey, fieldName: fieldName) try directDelete(cacheKey: cacheKey, fieldName: fieldName) if let array = value as? [Record.Value] { @@ -618,8 +637,120 @@ public final class ApolloSQLiteDatabase: SQLiteDatabase { } } + // MARK: - Cascading delete walks + + /// Recursive-CTE walk from one or more seed `cache_key`s, following + /// `child_key_value` pointers that end with the synthetic-key suffix + /// (`.$[]`). Deletes every synthetic descendant in one SQL + /// statement. The seed records themselves are *not* deleted — the + /// caller issues a separate `directDelete` for those. + /// + /// Real (non-synthetic) `CacheReference` targets are *not* followed + /// — those point to independent records that may be reachable from + /// other cache keys and have their own lifecycle. + private func cascadeDeleteSyntheticDescendants(seedCacheKeys: [CacheKey]) throws { + guard !seedCacheKeys.isEmpty else { return } + let placeholders = Array(repeating: "?", count: seedCacheKeys.count).joined(separator: ", ") + let sql = """ + WITH RECURSIVE descendants(cache_key) AS ( + SELECT r.\(SQLiteSchema.Records.childKeyValue) FROM \(SQLiteSchema.recordsTableName) r + WHERE r.\(SQLiteSchema.Records.cacheKey) IN (\(placeholders)) + AND r.\(SQLiteSchema.Records.childKeyValue) IS NOT NULL + AND r.\(SQLiteSchema.Records.childKeyValue) LIKE '\(SQLiteSchema.Records.syntheticKeySuffixLikePattern)' ESCAPE '\\' + UNION + SELECT r.\(SQLiteSchema.Records.childKeyValue) FROM \(SQLiteSchema.recordsTableName) r + JOIN descendants d ON r.\(SQLiteSchema.Records.cacheKey) = d.cache_key + WHERE r.\(SQLiteSchema.Records.childKeyValue) IS NOT NULL + AND r.\(SQLiteSchema.Records.childKeyValue) LIKE '\(SQLiteSchema.Records.syntheticKeySuffixLikePattern)' ESCAPE '\\' + ) + DELETE FROM \(SQLiteSchema.recordsTableName) + WHERE \(SQLiteSchema.Records.cacheKey) IN (SELECT cache_key FROM descendants) + """ + let stmt = try prepareStatement(sql, errorMessage: "Failed to prepare cascade-delete walk") + defer { sqlite3_finalize(stmt) } + + for (index, key) in seedCacheKeys.enumerated() { + sqlite3_bind_text(stmt, Int32(index + 1), key, -1, SQLITE_TRANSIENT) + } + let result = sqlite3_step(stmt) + if result != SQLITE_DONE { + throw SQLiteError.step(message: "Cascade-delete walk failed: \(sqliteErrorMessage())", resultCode: result) + } + } + + /// Field-scoped variant of `cascadeDeleteSyntheticDescendants`. Seeds + /// the walk from the synthetic children of `(cacheKey, fieldName)`'s + /// own rows rather than from the whole record. Used by atomic list + /// rewrites to clean up synthetic sub-records left over from the + /// prior list before writing new elements. The `(cacheKey, fieldName)` + /// rows themselves are not deleted here — the caller issues a + /// scoped `directDelete` for those. + private func cascadeDeleteSyntheticDescendantsOfField( + cacheKey: CacheKey, + fieldName: String + ) throws { + let sql = """ + WITH RECURSIVE descendants(cache_key) AS ( + SELECT r.\(SQLiteSchema.Records.childKeyValue) FROM \(SQLiteSchema.recordsTableName) r + WHERE r.\(SQLiteSchema.Records.cacheKey) = ? + AND r.\(SQLiteSchema.Records.fieldName) = ? + AND r.\(SQLiteSchema.Records.childKeyValue) IS NOT NULL + AND r.\(SQLiteSchema.Records.childKeyValue) LIKE '\(SQLiteSchema.Records.syntheticKeySuffixLikePattern)' ESCAPE '\\' + UNION + SELECT r.\(SQLiteSchema.Records.childKeyValue) FROM \(SQLiteSchema.recordsTableName) r + JOIN descendants d ON r.\(SQLiteSchema.Records.cacheKey) = d.cache_key + WHERE r.\(SQLiteSchema.Records.childKeyValue) IS NOT NULL + AND r.\(SQLiteSchema.Records.childKeyValue) LIKE '\(SQLiteSchema.Records.syntheticKeySuffixLikePattern)' ESCAPE '\\' + ) + DELETE FROM \(SQLiteSchema.recordsTableName) + WHERE \(SQLiteSchema.Records.cacheKey) IN (SELECT cache_key FROM descendants) + """ + let stmt = try prepareStatement(sql, errorMessage: "Failed to prepare field cascade-delete walk") + defer { sqlite3_finalize(stmt) } + + sqlite3_bind_text(stmt, 1, cacheKey, -1, SQLITE_TRANSIENT) + sqlite3_bind_text(stmt, 2, fieldName, -1, SQLITE_TRANSIENT) + let result = sqlite3_step(stmt) + if result != SQLITE_DONE { + throw SQLiteError.step(message: "Field cascade-delete walk failed: \(sqliteErrorMessage())", resultCode: result) + } + } + + /// Pattern-scoped variant. The walk seeds from synthetic children + /// of *every record whose cache_key matches the LIKE pattern*, then + /// follows the synthetic chain transitively. The matched records + /// themselves are deleted by the caller's flat pattern DELETE. + private func cascadeDeletePatternMatchedSyntheticDescendants( + escapedLikePattern: String + ) throws { + let sql = """ + WITH RECURSIVE descendants(cache_key) AS ( + SELECT r.\(SQLiteSchema.Records.childKeyValue) FROM \(SQLiteSchema.recordsTableName) r + WHERE r.\(SQLiteSchema.Records.cacheKey) LIKE ? COLLATE NOCASE ESCAPE '\\' + AND r.\(SQLiteSchema.Records.childKeyValue) IS NOT NULL + AND r.\(SQLiteSchema.Records.childKeyValue) LIKE '\(SQLiteSchema.Records.syntheticKeySuffixLikePattern)' ESCAPE '\\' + UNION + SELECT r.\(SQLiteSchema.Records.childKeyValue) FROM \(SQLiteSchema.recordsTableName) r + JOIN descendants d ON r.\(SQLiteSchema.Records.cacheKey) = d.cache_key + WHERE r.\(SQLiteSchema.Records.childKeyValue) IS NOT NULL + AND r.\(SQLiteSchema.Records.childKeyValue) LIKE '\(SQLiteSchema.Records.syntheticKeySuffixLikePattern)' ESCAPE '\\' + ) + DELETE FROM \(SQLiteSchema.recordsTableName) + WHERE \(SQLiteSchema.Records.cacheKey) IN (SELECT cache_key FROM descendants) + """ + let stmt = try prepareStatement(sql, errorMessage: "Failed to prepare pattern cascade-delete walk") + defer { sqlite3_finalize(stmt) } + + sqlite3_bind_text(stmt, 1, escapedLikePattern, -1, SQLITE_TRANSIENT) + let result = sqlite3_step(stmt) + if result != SQLITE_DONE { + throw SQLiteError.step(message: "Pattern cascade-delete walk failed: \(sqliteErrorMessage())", resultCode: result) + } + } + /// Deletes the rows for a given cache key (and optional field). Does - /// not cascade synthetic sub-records — that's a follow-up PR. + /// not cascade — callers run the appropriate `cascadeDelete…` first + /// if synthetic sub-records need cleanup. private func directDelete(cacheKey: CacheKey, fieldName: String? = nil) throws { let sql: String if fieldName == nil { diff --git a/apollo-ios/Sources/ApolloSQLite/SQLiteDatabase.swift b/apollo-ios/Sources/ApolloSQLite/SQLiteDatabase.swift index fbe3a9bdd..137c36548 100644 --- a/apollo-ios/Sources/ApolloSQLite/SQLiteDatabase.swift +++ b/apollo-ios/Sources/ApolloSQLite/SQLiteDatabase.swift @@ -95,9 +95,10 @@ public protocol SQLiteDatabase { /// - **List-typed fields** are written as `N` rows at positions /// `0..N-1`. The write is atomic: any existing rows for /// `(cacheKey, fieldName)` are deleted before the new ones are - /// inserted, so a partial-list state is never observable to - /// readers and shape transitions (scalar↔list, longer-list → - /// shorter-list) leave no in-field orphans. + /// inserted — including any reachable synthetic sub-records the + /// prior rows pointed at — so partial-list states are never + /// observable and shape transitions (scalar↔list, longer-list → + /// shorter-list, nested-list → anything) leave no orphans. /// - **Empty lists** (`[]`) become a single marker row at /// `position = -2` (`SQLiteSchema.Records.emptyListPositionValue`) /// with no value column populated, so a cached empty list stays @@ -116,12 +117,6 @@ public protocol SQLiteDatabase { /// collide with the synthetic sub-record keys generated for /// nested-list storage. /// - /// Note: when overwriting a field whose previous value was a nested - /// list, the synthetic sub-record rows are *not* cleaned up by this - /// method. They remain in the database as orphans until a follow-up - /// cascade-delete PR ships. Reads are unaffected — orphans are - /// unreachable through `selectRecords`. - /// /// If any row fails to bind or step, the whole transaction is rolled /// back so callers either see all writes applied or none. /// @@ -129,26 +124,28 @@ public protocol SQLiteDatabase { /// `createNewRecordsTableIfNeeded()`). func insertOrUpdate(records: [Record]) throws - /// Removes every row whose `cache_key` matches `cacheKey`. - /// - /// Synthetic sub-records (`..$[N]`) produced by - /// nested-list writes against this `cacheKey` are *not* cascade- - /// deleted by this method. If the record being deleted has list- - /// typed fields with depth ≥ 2, the corresponding synthetic sub- - /// record rows remain in the database as orphans (unreachable but - /// persistent). A follow-up cascade-delete PR handles that - /// cleanup. Reads are unaffected — orphans never surface through - /// `selectRecords`. + /// Removes every row whose `cache_key` matches `cacheKey`, plus + /// every row of every synthetic sub-record reachable from those + /// rows via `child_key_value` pointers matching the synthetic-key + /// suffix (`.$[]`). Real (non-synthetic) `CacheReference` + /// targets are *not* followed — those point to independent records + /// that may be reachable from other cache keys and have their own + /// lifecycle. /// /// Distinct from the legacy `deleteRecord(for:)` by the column it - /// targets; this method operates on the row-per-element schema's - /// `cache_key`. + /// targets and the cascade behavior; this method operates on the + /// row-per-element schema's `cache_key`. func deleteRecord(forKey cacheKey: CacheKey) throws /// Removes every row whose `cache_key` matches the wildcard - /// `pattern`. Comparison is case-insensitive (`COLLATE NOCASE`). - /// `\`, `%`, and `_` in `pattern` are escaped so they match - /// literally rather than acting as `LIKE` wildcards. + /// `pattern`, plus every row of every synthetic sub-record + /// reachable from those records via `child_key_value` pointers. + /// Comparison is case-insensitive (`COLLATE NOCASE`). `\`, `%`, + /// and `_` in `pattern` are escaped so they match literally + /// rather than acting as `LIKE` wildcards. The synthetic cascade + /// follows the same rule as `deleteRecord(forKey:)` — only + /// synthetic-suffix children are removed; real `CacheReference` + /// targets are left alone. func deleteRecords(matchingKey pattern: CacheKey) throws } From 8efa80d598c99938115fb5743fcb0cebf7848b93 Mon Sep 17 00:00:00 2001 From: Anthony Miller Date: Thu, 9 Jul 2026 11:57:45 -0700 Subject: [PATCH 2/3] fix(sqlite): atomic cascade deletes + pattern deletes match user records only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ...QLiteRowPerElementCascadeDeleteTests.swift | 44 +++++++++ .../ApolloSQLite/ApolloSQLiteDatabase.swift | 90 ++++++++++++++----- .../Sources/ApolloSQLite/SQLiteDatabase.swift | 24 +++-- 3 files changed, 125 insertions(+), 33 deletions(-) diff --git a/Tests/ApolloTests/SQLiteRowPerElementCascadeDeleteTests.swift b/Tests/ApolloTests/SQLiteRowPerElementCascadeDeleteTests.swift index a677bb840..28b0985a6 100644 --- a/Tests/ApolloTests/SQLiteRowPerElementCascadeDeleteTests.swift +++ b/Tests/ApolloTests/SQLiteRowPerElementCascadeDeleteTests.swift @@ -292,4 +292,48 @@ class SQLiteRowPerElementCascadeDeleteTests: XCTestCase { XCTAssertEqual(try db.rowCount(forCacheKey: "User:1.matrix.$[0]"), 2, "Unmatched record's synthetic sub-record must survive") } + + func test__deleteRecords_matchingKey__patternMatchingSyntheticKeyButNotParent_leavesParentsListStorageIntact() throws { + let db = try makeDatabase() + // Synthetic keys embed the parent field name + // (`User:1.claws.$[0]`), so the pattern "claws" matches the + // synthetic key while the parent key `User:1` doesn't. The + // pattern delete must not touch the synthetic rows in that case — + // deleting them would amputate the internals of a list the caller + // never asked to remove and leave `User:1`'s rows dangling. + let claws: [Record.Value] = [[1, 2] as Record.Value, [3] as Record.Value] + try db.insertOrUpdate(records: [record("User:1", field: "claws", value: claws as Record.Value)]) + XCTAssertEqual(try db.rowCount(forCacheKey: "User:1.claws.$[0]"), 2) + XCTAssertEqual(try db.rowCount(forCacheKey: "User:1.claws.$[1]"), 1) + + try db.deleteRecords(matchingKey: "claws") + + XCTAssertEqual(try db.rowCount(forCacheKey: "User:1"), 2, + "Parent record's rows must survive a pattern that only matches its synthetic children") + XCTAssertEqual(try db.rowCount(forCacheKey: "User:1.claws.$[0]"), 2, + "Synthetic rows must survive a pattern matching them but not their parent") + XCTAssertEqual(try db.rowCount(forCacheKey: "User:1.claws.$[1]"), 1) + + // The nested list still reads back fully intact. + let loaded = try db.selectRecords(forKeys: ["User:1"]) + let outer = loaded[0].fields["claws"]?.value as? [Any] + XCTAssertEqual(outer?.count, 2) + XCTAssertEqual((outer?[0] as? [Any])?.count, 2) + XCTAssertEqual((outer?[1] as? [Any])?.count, 1) + } + + func test__deleteRecords_matchingKey__patternMatchingParentAndSyntheticKeys_deletesBothViaCascade() throws { + let db = try makeDatabase() + // When the pattern matches the parent, the synthetic rows go with + // it (via the cascade), even though the flat delete no longer + // matches synthetic keys directly. + let claws: [Record.Value] = [[1, 2] as Record.Value] + try db.insertOrUpdate(records: [record("Animal:cat", field: "claws", value: claws as Record.Value)]) + + try db.deleteRecords(matchingKey: "cat") + + XCTAssertEqual(try db.rowCount(forCacheKey: "Animal:cat"), 0) + XCTAssertEqual(try db.rowCount(forCacheKey: "Animal:cat.claws.$[0]"), 0, + "Synthetic sub-records of a matched parent must still cascade") + } } diff --git a/apollo-ios/Sources/ApolloSQLite/ApolloSQLiteDatabase.swift b/apollo-ios/Sources/ApolloSQLite/ApolloSQLiteDatabase.swift index bb2b9b4a7..3c4530cf8 100644 --- a/apollo-ios/Sources/ApolloSQLite/ApolloSQLiteDatabase.swift +++ b/apollo-ios/Sources/ApolloSQLite/ApolloSQLiteDatabase.swift @@ -367,9 +367,25 @@ public final class ApolloSQLiteDatabase: SQLiteDatabase { } public func deleteRecord(forKey cacheKey: CacheKey) throws { + // The cascade walk and the direct delete are separate statements; + // wrap them in one transaction so a failure between the two can't + // leave rows pointing at already-deleted synthetic sub-records + // (or vice versa). try performSync { - try cascadeDeleteSyntheticDescendants(seedCacheKeys: [cacheKey]) - try directDelete(cacheKey: cacheKey) + try exec("BEGIN TRANSACTION", errorMessage: "Failed to begin deleteRecord transaction") + do { + try cascadeDeleteSyntheticDescendants(seedCacheKeys: [cacheKey]) + try directDelete(cacheKey: cacheKey) + } catch { + rollbackTransaction() + throw error + } + do { + try exec("COMMIT TRANSACTION", errorMessage: "Failed to commit deleteRecord transaction") + } catch { + rollbackTransaction() + throw error + } } } @@ -385,26 +401,46 @@ public final class ApolloSQLiteDatabase: SQLiteDatabase { .replacingOccurrences(of: "_", with: "\\_") let wildcardPattern = "%\(escaped)%" + // Both the cascade walk and the flat delete match only + // non-synthetic (user) record keys. Synthetic sub-record keys + // embed parent field names (`User:1.claws.$[0]`), so a substring + // pattern could match a synthetic key whose *parent* doesn't + // match — deleting the internals of a list the caller never + // asked to touch and leaving the parent's rows dangling. + // Synthetic rows are removed exclusively via the cascade from + // matched user records. try performSync { - // Cascade walk: synthetic sub-records reachable from any record - // whose cache_key matches the pattern. The walk's seed is the - // set of matched records, and it follows `child_key_value` - // pointers whose value ends with `.$[]`. The matched - // records themselves are then deleted by the direct DELETE - // below. - try cascadeDeletePatternMatchedSyntheticDescendants(escapedLikePattern: wildcardPattern) - - let sql = """ - DELETE FROM \(SQLiteSchema.recordsTableName) - WHERE \(SQLiteSchema.Records.cacheKey) LIKE ? COLLATE NOCASE ESCAPE '\\' - """ - let stmt = try prepareStatement(sql, errorMessage: "Failed to prepare row-per-element pattern delete") - defer { sqlite3_finalize(stmt) } - - sqlite3_bind_text(stmt, 1, wildcardPattern, -1, SQLITE_TRANSIENT) - let result = sqlite3_step(stmt) - if result != SQLITE_DONE { - throw SQLiteError.step(message: "Row-per-element pattern delete failed: \(sqliteErrorMessage())", resultCode: result) + try exec("BEGIN TRANSACTION", errorMessage: "Failed to begin pattern-delete transaction") + do { + // Cascade walk: synthetic sub-records reachable from any + // non-synthetic record whose cache_key matches the pattern. + // The walk follows `child_key_value` pointers whose value + // ends with `.$[]`. The matched records themselves + // are then deleted by the direct DELETE below. + try cascadeDeletePatternMatchedSyntheticDescendants(escapedLikePattern: wildcardPattern) + + let sql = """ + DELETE FROM \(SQLiteSchema.recordsTableName) + WHERE \(SQLiteSchema.Records.cacheKey) LIKE ? COLLATE NOCASE ESCAPE '\\' + AND \(SQLiteSchema.Records.cacheKey) NOT LIKE '\(SQLiteSchema.Records.syntheticKeySuffixLikePattern)' ESCAPE '\\' + """ + let stmt = try prepareStatement(sql, errorMessage: "Failed to prepare row-per-element pattern delete") + defer { sqlite3_finalize(stmt) } + + sqlite3_bind_text(stmt, 1, wildcardPattern, -1, SQLITE_TRANSIENT) + let result = sqlite3_step(stmt) + if result != SQLITE_DONE { + throw SQLiteError.step(message: "Row-per-element pattern delete failed: \(sqliteErrorMessage())", resultCode: result) + } + } catch { + rollbackTransaction() + throw error + } + do { + try exec("COMMIT TRANSACTION", errorMessage: "Failed to commit pattern-delete transaction") + } catch { + rollbackTransaction() + throw error } } } @@ -717,9 +753,14 @@ public final class ApolloSQLiteDatabase: SQLiteDatabase { } /// Pattern-scoped variant. The walk seeds from synthetic children - /// of *every record whose cache_key matches the LIKE pattern*, then - /// follows the synthetic chain transitively. The matched records - /// themselves are deleted by the caller's flat pattern DELETE. + /// of *every non-synthetic record whose cache_key matches the LIKE + /// pattern*, then follows the synthetic chain transitively. The + /// matched records themselves are deleted by the caller's flat + /// pattern DELETE. Synthetic keys matching the pattern are excluded + /// from the seed for the same reason the caller excludes them from + /// the flat delete: a substring pattern may match a synthetic key + /// whose parent record doesn't match, and touching that key's + /// subtree would corrupt the unmatched parent's list storage. private func cascadeDeletePatternMatchedSyntheticDescendants( escapedLikePattern: String ) throws { @@ -727,6 +768,7 @@ public final class ApolloSQLiteDatabase: SQLiteDatabase { WITH RECURSIVE descendants(cache_key) AS ( SELECT r.\(SQLiteSchema.Records.childKeyValue) FROM \(SQLiteSchema.recordsTableName) r WHERE r.\(SQLiteSchema.Records.cacheKey) LIKE ? COLLATE NOCASE ESCAPE '\\' + AND r.\(SQLiteSchema.Records.cacheKey) NOT LIKE '\(SQLiteSchema.Records.syntheticKeySuffixLikePattern)' ESCAPE '\\' AND r.\(SQLiteSchema.Records.childKeyValue) IS NOT NULL AND r.\(SQLiteSchema.Records.childKeyValue) LIKE '\(SQLiteSchema.Records.syntheticKeySuffixLikePattern)' ESCAPE '\\' UNION diff --git a/apollo-ios/Sources/ApolloSQLite/SQLiteDatabase.swift b/apollo-ios/Sources/ApolloSQLite/SQLiteDatabase.swift index 137c36548..50efc34c7 100644 --- a/apollo-ios/Sources/ApolloSQLite/SQLiteDatabase.swift +++ b/apollo-ios/Sources/ApolloSQLite/SQLiteDatabase.swift @@ -137,15 +137,21 @@ public protocol SQLiteDatabase { /// row-per-element schema's `cache_key`. func deleteRecord(forKey cacheKey: CacheKey) throws - /// Removes every row whose `cache_key` matches the wildcard - /// `pattern`, plus every row of every synthetic sub-record - /// reachable from those records via `child_key_value` pointers. - /// Comparison is case-insensitive (`COLLATE NOCASE`). `\`, `%`, - /// and `_` in `pattern` are escaped so they match literally - /// rather than acting as `LIKE` wildcards. The synthetic cascade - /// follows the same rule as `deleteRecord(forKey:)` — only - /// synthetic-suffix children are removed; real `CacheReference` - /// targets are left alone. + /// Removes every row of every *non-synthetic* record whose + /// `cache_key` matches the wildcard `pattern`, plus every row of + /// every synthetic sub-record reachable from those records via + /// `child_key_value` pointers. Comparison is case-insensitive + /// (`COLLATE NOCASE`). `\`, `%`, and `_` in `pattern` are escaped + /// so they match literally rather than acting as `LIKE` wildcards. + /// + /// Synthetic sub-record keys are never matched by `pattern` + /// directly — they embed parent field names, so a substring + /// pattern could match a synthetic key whose parent record does + /// not match, and deleting it would corrupt the parent's list + /// storage. Synthetic rows are removed exclusively through the + /// cascade from matched records. The cascade follows the same rule + /// as `deleteRecord(forKey:)` — only synthetic-suffix children are + /// removed; real `CacheReference` targets are left alone. func deleteRecords(matchingKey pattern: CacheKey) throws } From b57e2a900d8ac2bfbb07575bb72e61516ac8c7b8 Mon Sep 17 00:00:00 2001 From: Anthony Miller Date: Mon, 13 Jul 2026 16:40:15 -0700 Subject: [PATCH 3/3] test(sqlite): move row-count probe out of production; pin classifier agreement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../SQLiteTestDatabaseInspector.swift | 81 ++++++++++ ...QLiteRowPerElementCascadeDeleteTests.swift | 138 ++++++++++-------- .../SQLiteSyntheticKeyClassifierTests.swift | 122 ++++++++++++++++ .../ApolloSQLite/ApolloSQLiteDatabase.swift | 22 --- 4 files changed, 279 insertions(+), 84 deletions(-) create mode 100644 Tests/ApolloInternalTestHelpers/SQLiteTestDatabaseInspector.swift create mode 100644 Tests/ApolloTests/SQLiteSyntheticKeyClassifierTests.swift diff --git a/Tests/ApolloInternalTestHelpers/SQLiteTestDatabaseInspector.swift b/Tests/ApolloInternalTestHelpers/SQLiteTestDatabaseInspector.swift new file mode 100644 index 000000000..d9222f316 --- /dev/null +++ b/Tests/ApolloInternalTestHelpers/SQLiteTestDatabaseInspector.swift @@ -0,0 +1,81 @@ +import Foundation +import SQLite3 +import ApolloSQLite + +/// Storage-level assertions against a SQLite cache database file, made +/// through the inspector's own read-only connection so no test-only +/// query surface needs to live on the production database class. +/// +/// Used by cascade-correctness tests to verify whether synthetic +/// sub-record rows exist after deletes and rewrites — the production +/// read paths deliberately filter synthetic keys out, so asserting +/// through them would pass regardless of cascade behavior. +public enum SQLiteTestDatabaseInspector { + + public enum InspectionError: Error { + case openFailed(path: String) + case prepareFailed(message: String) + case stepFailed(message: String) + } + + private static let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + + /// Returns the number of rows whose `cache_key` exactly matches + /// `cacheKey`, bypassing all production read paths and their + /// synthetic-key filtering. + public static func rowCount(inDatabaseAt url: URL, forCacheKey cacheKey: String) throws -> Int { + var db: OpaquePointer? + guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else { + sqlite3_close(db) + throw InspectionError.openFailed(path: url.path) + } + defer { sqlite3_close(db) } + + let sql = """ + SELECT COUNT(*) FROM \(SQLiteSchema.recordsTableName) + WHERE \(SQLiteSchema.Records.cacheKey) = ? + """ + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { + throw InspectionError.prepareFailed(message: String(cString: sqlite3_errmsg(db))) + } + defer { sqlite3_finalize(stmt) } + + sqlite3_bind_text(stmt, 1, cacheKey, -1, SQLITE_TRANSIENT) + guard sqlite3_step(stmt) == SQLITE_ROW else { + throw InspectionError.stepFailed(message: String(cString: sqlite3_errmsg(db))) + } + return Int(sqlite3_column_int64(stmt, 0)) + } + + /// Evaluates a SQL `LIKE` expression exactly as SQLite would — + /// `SELECT ? LIKE ? ESCAPE ?` against an in-memory database — so + /// tests can compare SQLite's `LIKE` semantics against Swift-side + /// classifiers without reimplementing `LIKE` in Swift. + public static func sqliteLIKEMatches( + pattern: String, + candidate: String, + escape: String = "\\" + ) throws -> Bool { + var db: OpaquePointer? + guard sqlite3_open(":memory:", &db) == SQLITE_OK else { + sqlite3_close(db) + throw InspectionError.openFailed(path: ":memory:") + } + defer { sqlite3_close(db) } + + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, "SELECT ? LIKE ? ESCAPE ?", -1, &stmt, nil) == SQLITE_OK else { + throw InspectionError.prepareFailed(message: String(cString: sqlite3_errmsg(db))) + } + defer { sqlite3_finalize(stmt) } + + sqlite3_bind_text(stmt, 1, candidate, -1, SQLITE_TRANSIENT) + sqlite3_bind_text(stmt, 2, pattern, -1, SQLITE_TRANSIENT) + sqlite3_bind_text(stmt, 3, escape, -1, SQLITE_TRANSIENT) + guard sqlite3_step(stmt) == SQLITE_ROW else { + throw InspectionError.stepFailed(message: String(cString: sqlite3_errmsg(db))) + } + return sqlite3_column_int64(stmt, 0) == 1 + } +} diff --git a/Tests/ApolloTests/SQLiteRowPerElementCascadeDeleteTests.swift b/Tests/ApolloTests/SQLiteRowPerElementCascadeDeleteTests.swift index 28b0985a6..3efcad895 100644 --- a/Tests/ApolloTests/SQLiteRowPerElementCascadeDeleteTests.swift +++ b/Tests/ApolloTests/SQLiteRowPerElementCascadeDeleteTests.swift @@ -11,24 +11,38 @@ import ApolloInternalTestHelpers /// sub-record must be cleaned up so the database doesn't accumulate /// unreachable orphan rows. /// -/// The tests in this suite verify orphan removal via the test-only -/// `rowCount(forCacheKey:)` helper, which bypasses -/// `selectRecords`'s synthetic-key filter and queries the database -/// directly. An earlier draft of these tests went through +/// The tests in this suite verify orphan removal via +/// `SQLiteTestDatabaseInspector.rowCount(inDatabaseAt:forCacheKey:)`, +/// which opens its own connection and bypasses `selectRecords`'s +/// synthetic-key filter. An earlier draft of these tests went through /// `selectRecords` and silently passed regardless of cascade behavior -/// because the filter masked the orphans — `rowCount` makes the -/// assertions actually load-bearing. +/// because the filter masked the orphans — the raw row count makes +/// the assertions actually load-bearing. class SQLiteRowPerElementCascadeDeleteTests: XCTestCase { // MARK: - Fixtures + /// The file URL of the database created by `makeDatabase()`, used by + /// `rowCount(forCacheKey:)` to inspect storage through the test + /// inspector's own connection. + private var databaseFileURL: URL! + private func makeDatabase() throws -> ApolloSQLiteDatabase { - let db = try ApolloSQLiteDatabase(fileURL: SQLiteTestCacheProvider.temporarySQLiteFileURL()) + let fileURL = SQLiteTestCacheProvider.temporarySQLiteFileURL() + databaseFileURL = fileURL + let db = try ApolloSQLiteDatabase(fileURL: fileURL) try db.createSchemaMetadataTableIfNeeded() try db.createNewRecordsTableIfNeeded() return db } + /// Counts stored rows for `cacheKey` via `SQLiteTestDatabaseInspector`, + /// bypassing the production read paths and their synthetic-key filter + /// so orphan assertions stay load-bearing. + private func rowCount(forCacheKey cacheKey: CacheKey) throws -> Int { + try SQLiteTestDatabaseInspector.rowCount(inDatabaseAt: databaseFileURL, forCacheKey: cacheKey) + } + /// Wraps a value into a `Record` with one field. Lets the test /// helpers stay legible when constructing nested-list values. private func record( @@ -55,17 +69,17 @@ class SQLiteRowPerElementCascadeDeleteTests: XCTestCase { try db.insertOrUpdate(records: [record("Math:1", field: "matrix", value: matrix as Record.Value)]) // Pre-check: synthetic sub-records exist. - XCTAssertEqual(try db.rowCount(forCacheKey: "Math:1.matrix.$[0]"), 2) - XCTAssertEqual(try db.rowCount(forCacheKey: "Math:1.matrix.$[1]"), 2) + XCTAssertEqual(try rowCount(forCacheKey: "Math:1.matrix.$[0]"), 2) + XCTAssertEqual(try rowCount(forCacheKey: "Math:1.matrix.$[1]"), 2) try db.deleteRecord(forKey: "Math:1") // Parent gone. - XCTAssertEqual(try db.rowCount(forCacheKey: "Math:1"), 0) + XCTAssertEqual(try rowCount(forCacheKey: "Math:1"), 0) // Synthetic descendants gone. - XCTAssertEqual(try db.rowCount(forCacheKey: "Math:1.matrix.$[0]"), 0, + XCTAssertEqual(try rowCount(forCacheKey: "Math:1.matrix.$[0]"), 0, "Depth-1 synthetic sub-record must cascade") - XCTAssertEqual(try db.rowCount(forCacheKey: "Math:1.matrix.$[1]"), 0, + XCTAssertEqual(try rowCount(forCacheKey: "Math:1.matrix.$[1]"), 0, "Depth-1 synthetic sub-record must cascade") } @@ -78,16 +92,16 @@ class SQLiteRowPerElementCascadeDeleteTests: XCTestCase { try db.insertOrUpdate(records: [record("Math:cube", field: "cube", value: outer as Record.Value)]) // Pre-check: every level exists. - XCTAssertEqual(try db.rowCount(forCacheKey: "Math:cube"), 1) - XCTAssertEqual(try db.rowCount(forCacheKey: "Math:cube.cube.$[0]"), 1) - XCTAssertEqual(try db.rowCount(forCacheKey: "Math:cube.cube.$[0].$[0]"), 1) + XCTAssertEqual(try rowCount(forCacheKey: "Math:cube"), 1) + XCTAssertEqual(try rowCount(forCacheKey: "Math:cube.cube.$[0]"), 1) + XCTAssertEqual(try rowCount(forCacheKey: "Math:cube.cube.$[0].$[0]"), 1) try db.deleteRecord(forKey: "Math:cube") - XCTAssertEqual(try db.rowCount(forCacheKey: "Math:cube"), 0) - XCTAssertEqual(try db.rowCount(forCacheKey: "Math:cube.cube.$[0]"), 0, + XCTAssertEqual(try rowCount(forCacheKey: "Math:cube"), 0) + XCTAssertEqual(try rowCount(forCacheKey: "Math:cube.cube.$[0]"), 0, "Level-2 synthetic sub-record must cascade") - XCTAssertEqual(try db.rowCount(forCacheKey: "Math:cube.cube.$[0].$[0]"), 0, + XCTAssertEqual(try rowCount(forCacheKey: "Math:cube.cube.$[0].$[0]"), 0, "Level-3 synthetic sub-record must cascade — recursive walk must reach it") } @@ -106,11 +120,11 @@ class SQLiteRowPerElementCascadeDeleteTests: XCTestCase { try db.deleteRecord(forKey: "Math:A") // A is gone with its descendants. - XCTAssertEqual(try db.rowCount(forCacheKey: "Math:A"), 0) - XCTAssertEqual(try db.rowCount(forCacheKey: "Math:A.matrix.$[0]"), 0) + XCTAssertEqual(try rowCount(forCacheKey: "Math:A"), 0) + XCTAssertEqual(try rowCount(forCacheKey: "Math:A.matrix.$[0]"), 0) // B is untouched. - XCTAssertEqual(try db.rowCount(forCacheKey: "Math:B"), 1) - XCTAssertEqual(try db.rowCount(forCacheKey: "Math:B.matrix.$[0]"), 2, + XCTAssertEqual(try rowCount(forCacheKey: "Math:B"), 1) + XCTAssertEqual(try rowCount(forCacheKey: "Math:B.matrix.$[0]"), 2, "Sibling record's synthetic sub-records must survive") } @@ -127,14 +141,14 @@ class SQLiteRowPerElementCascadeDeleteTests: XCTestCase { ] try db.insertOrUpdate(records: [Record(key: "Math:multi", fields: fields)]) - XCTAssertGreaterThan(try db.rowCount(forCacheKey: "Math:multi.matrix.$[0]"), 0) - XCTAssertGreaterThan(try db.rowCount(forCacheKey: "Math:multi.coords.$[0]"), 0) + XCTAssertGreaterThan(try rowCount(forCacheKey: "Math:multi.matrix.$[0]"), 0) + XCTAssertGreaterThan(try rowCount(forCacheKey: "Math:multi.coords.$[0]"), 0) try db.deleteRecord(forKey: "Math:multi") - XCTAssertEqual(try db.rowCount(forCacheKey: "Math:multi.matrix.$[0]"), 0, + XCTAssertEqual(try rowCount(forCacheKey: "Math:multi.matrix.$[0]"), 0, "First nested-list field's sub-record must cascade") - XCTAssertEqual(try db.rowCount(forCacheKey: "Math:multi.coords.$[0]"), 0, + XCTAssertEqual(try rowCount(forCacheKey: "Math:multi.coords.$[0]"), 0, "Second nested-list field's sub-record must cascade") } @@ -157,10 +171,10 @@ class SQLiteRowPerElementCascadeDeleteTests: XCTestCase { try db.deleteRecord(forKey: "QUERY_ROOT") - XCTAssertEqual(try db.rowCount(forCacheKey: "QUERY_ROOT"), 0) - XCTAssertEqual(try db.rowCount(forCacheKey: "User:1"), 1, + XCTAssertEqual(try rowCount(forCacheKey: "QUERY_ROOT"), 0) + XCTAssertEqual(try rowCount(forCacheKey: "User:1"), 1, "Real CacheReference target must not be cascade-deleted") - XCTAssertEqual(try db.rowCount(forCacheKey: "User:2"), 1) + XCTAssertEqual(try rowCount(forCacheKey: "User:2"), 1) } func test__deleteRecord_forKey__doesNotFollowRealCacheReferenceInsideSyntheticSubRecord() throws { @@ -182,16 +196,16 @@ class SQLiteRowPerElementCascadeDeleteTests: XCTestCase { Record(key: "User:2", fields: ["name": CachedField(value: "B" as Record.Value, writtenAt: 100)]), ]) - XCTAssertGreaterThan(try db.rowCount(forCacheKey: "Org:1.teams.$[0]"), 0) + XCTAssertGreaterThan(try rowCount(forCacheKey: "Org:1.teams.$[0]"), 0) try db.deleteRecord(forKey: "Org:1") - XCTAssertEqual(try db.rowCount(forCacheKey: "Org:1"), 0) - XCTAssertEqual(try db.rowCount(forCacheKey: "Org:1.teams.$[0]"), 0, + XCTAssertEqual(try rowCount(forCacheKey: "Org:1"), 0) + XCTAssertEqual(try rowCount(forCacheKey: "Org:1.teams.$[0]"), 0, "Synthetic sub-record under Org:1.teams must cascade") - XCTAssertEqual(try db.rowCount(forCacheKey: "User:1"), 1, + XCTAssertEqual(try rowCount(forCacheKey: "User:1"), 1, "Real CacheReference inside a synthetic sub-record must NOT be cascaded") - XCTAssertEqual(try db.rowCount(forCacheKey: "User:2"), 1) + XCTAssertEqual(try rowCount(forCacheKey: "User:2"), 1) } // MARK: - insertOrUpdate atomic-rewrite cascade @@ -200,16 +214,16 @@ class SQLiteRowPerElementCascadeDeleteTests: XCTestCase { let db = try makeDatabase() let matrix: [Record.Value] = [[1, 2] as Record.Value, [3, 4] as Record.Value] try db.insertOrUpdate(records: [record("Math:1", field: "matrix", value: matrix as Record.Value)]) - XCTAssertEqual(try db.rowCount(forCacheKey: "Math:1.matrix.$[0]"), 2) - XCTAssertEqual(try db.rowCount(forCacheKey: "Math:1.matrix.$[1]"), 2) + XCTAssertEqual(try rowCount(forCacheKey: "Math:1.matrix.$[0]"), 2) + XCTAssertEqual(try rowCount(forCacheKey: "Math:1.matrix.$[1]"), 2) // Rewrite as a scalar — the prior synthetic sub-records must be // cleaned up, not orphaned. try db.insertOrUpdate(records: [record("Math:1", field: "matrix", value: "rewritten" as Record.Value, writtenAt: 200)]) - XCTAssertEqual(try db.rowCount(forCacheKey: "Math:1.matrix.$[0]"), 0, + XCTAssertEqual(try rowCount(forCacheKey: "Math:1.matrix.$[0]"), 0, "Atomic list→scalar rewrite must cascade synthetic sub-records") - XCTAssertEqual(try db.rowCount(forCacheKey: "Math:1.matrix.$[1]"), 0) + XCTAssertEqual(try rowCount(forCacheKey: "Math:1.matrix.$[1]"), 0) } func test__insertOrUpdate__atomicRewriteOfDeeplyNestedList_cleansAllSyntheticDescendants() throws { @@ -217,14 +231,14 @@ class SQLiteRowPerElementCascadeDeleteTests: XCTestCase { // 3D first, then a single-element scalar. let cube: [Record.Value] = [[[5] as Record.Value] as Record.Value] try db.insertOrUpdate(records: [record("Math:cube", field: "cube", value: cube as Record.Value)]) - XCTAssertEqual(try db.rowCount(forCacheKey: "Math:cube.cube.$[0]"), 1) - XCTAssertEqual(try db.rowCount(forCacheKey: "Math:cube.cube.$[0].$[0]"), 1) + XCTAssertEqual(try rowCount(forCacheKey: "Math:cube.cube.$[0]"), 1) + XCTAssertEqual(try rowCount(forCacheKey: "Math:cube.cube.$[0].$[0]"), 1) try db.insertOrUpdate(records: [record("Math:cube", field: "cube", value: "flat" as Record.Value, writtenAt: 200)]) - XCTAssertEqual(try db.rowCount(forCacheKey: "Math:cube.cube.$[0]"), 0, + XCTAssertEqual(try rowCount(forCacheKey: "Math:cube.cube.$[0]"), 0, "Level-2 synthetic sub-record must cascade on rewrite") - XCTAssertEqual(try db.rowCount(forCacheKey: "Math:cube.cube.$[0].$[0]"), 0, + XCTAssertEqual(try rowCount(forCacheKey: "Math:cube.cube.$[0].$[0]"), 0, "Level-3 synthetic sub-record must cascade — recursive walk must reach it on rewrite too") } @@ -239,15 +253,15 @@ class SQLiteRowPerElementCascadeDeleteTests: XCTestCase { "coords": CachedField(value: coords as Record.Value, writtenAt: 100), ] try db.insertOrUpdate(records: [Record(key: "Math:multi", fields: fields)]) - XCTAssertEqual(try db.rowCount(forCacheKey: "Math:multi.matrix.$[0]"), 2) - XCTAssertEqual(try db.rowCount(forCacheKey: "Math:multi.coords.$[0]"), 2) + XCTAssertEqual(try rowCount(forCacheKey: "Math:multi.matrix.$[0]"), 2) + XCTAssertEqual(try rowCount(forCacheKey: "Math:multi.coords.$[0]"), 2) // Rewrite only `matrix`. try db.insertOrUpdate(records: [record("Math:multi", field: "matrix", value: "rewritten" as Record.Value, writtenAt: 200)]) - XCTAssertEqual(try db.rowCount(forCacheKey: "Math:multi.matrix.$[0]"), 0, + XCTAssertEqual(try rowCount(forCacheKey: "Math:multi.matrix.$[0]"), 0, "Rewritten field's synthetic sub-record must cascade") - XCTAssertEqual(try db.rowCount(forCacheKey: "Math:multi.coords.$[0]"), 2, + XCTAssertEqual(try rowCount(forCacheKey: "Math:multi.coords.$[0]"), 2, "Untouched field's synthetic sub-record must survive") } @@ -262,16 +276,16 @@ class SQLiteRowPerElementCascadeDeleteTests: XCTestCase { record("Animal:cat", field: "claws", value: matrixA as Record.Value), record("Animal:dog", field: "claws", value: matrixB as Record.Value), ]) - XCTAssertEqual(try db.rowCount(forCacheKey: "Animal:cat.claws.$[0]"), 2) - XCTAssertEqual(try db.rowCount(forCacheKey: "Animal:dog.claws.$[0]"), 2) + XCTAssertEqual(try rowCount(forCacheKey: "Animal:cat.claws.$[0]"), 2) + XCTAssertEqual(try rowCount(forCacheKey: "Animal:dog.claws.$[0]"), 2) try db.deleteRecords(matchingKey: "Animal:") - XCTAssertEqual(try db.rowCount(forCacheKey: "Animal:cat"), 0) - XCTAssertEqual(try db.rowCount(forCacheKey: "Animal:dog"), 0) - XCTAssertEqual(try db.rowCount(forCacheKey: "Animal:cat.claws.$[0]"), 0, + XCTAssertEqual(try rowCount(forCacheKey: "Animal:cat"), 0) + XCTAssertEqual(try rowCount(forCacheKey: "Animal:dog"), 0) + XCTAssertEqual(try rowCount(forCacheKey: "Animal:cat.claws.$[0]"), 0, "Pattern-deleted record's synthetic sub-record must cascade") - XCTAssertEqual(try db.rowCount(forCacheKey: "Animal:dog.claws.$[0]"), 0, + XCTAssertEqual(try rowCount(forCacheKey: "Animal:dog.claws.$[0]"), 0, "Pattern-deleted record's synthetic sub-record must cascade") } @@ -286,10 +300,10 @@ class SQLiteRowPerElementCascadeDeleteTests: XCTestCase { try db.deleteRecords(matchingKey: "Animal:") - XCTAssertEqual(try db.rowCount(forCacheKey: "Animal:cat.matrix.$[0]"), 0, + XCTAssertEqual(try rowCount(forCacheKey: "Animal:cat.matrix.$[0]"), 0, "Matched record's synthetic sub-record must cascade") - XCTAssertEqual(try db.rowCount(forCacheKey: "User:1"), 1) - XCTAssertEqual(try db.rowCount(forCacheKey: "User:1.matrix.$[0]"), 2, + XCTAssertEqual(try rowCount(forCacheKey: "User:1"), 1) + XCTAssertEqual(try rowCount(forCacheKey: "User:1.matrix.$[0]"), 2, "Unmatched record's synthetic sub-record must survive") } @@ -303,16 +317,16 @@ class SQLiteRowPerElementCascadeDeleteTests: XCTestCase { // never asked to remove and leave `User:1`'s rows dangling. let claws: [Record.Value] = [[1, 2] as Record.Value, [3] as Record.Value] try db.insertOrUpdate(records: [record("User:1", field: "claws", value: claws as Record.Value)]) - XCTAssertEqual(try db.rowCount(forCacheKey: "User:1.claws.$[0]"), 2) - XCTAssertEqual(try db.rowCount(forCacheKey: "User:1.claws.$[1]"), 1) + XCTAssertEqual(try rowCount(forCacheKey: "User:1.claws.$[0]"), 2) + XCTAssertEqual(try rowCount(forCacheKey: "User:1.claws.$[1]"), 1) try db.deleteRecords(matchingKey: "claws") - XCTAssertEqual(try db.rowCount(forCacheKey: "User:1"), 2, + XCTAssertEqual(try rowCount(forCacheKey: "User:1"), 2, "Parent record's rows must survive a pattern that only matches its synthetic children") - XCTAssertEqual(try db.rowCount(forCacheKey: "User:1.claws.$[0]"), 2, + XCTAssertEqual(try rowCount(forCacheKey: "User:1.claws.$[0]"), 2, "Synthetic rows must survive a pattern matching them but not their parent") - XCTAssertEqual(try db.rowCount(forCacheKey: "User:1.claws.$[1]"), 1) + XCTAssertEqual(try rowCount(forCacheKey: "User:1.claws.$[1]"), 1) // The nested list still reads back fully intact. let loaded = try db.selectRecords(forKeys: ["User:1"]) @@ -332,8 +346,8 @@ class SQLiteRowPerElementCascadeDeleteTests: XCTestCase { try db.deleteRecords(matchingKey: "cat") - XCTAssertEqual(try db.rowCount(forCacheKey: "Animal:cat"), 0) - XCTAssertEqual(try db.rowCount(forCacheKey: "Animal:cat.claws.$[0]"), 0, + XCTAssertEqual(try rowCount(forCacheKey: "Animal:cat"), 0) + XCTAssertEqual(try rowCount(forCacheKey: "Animal:cat.claws.$[0]"), 0, "Synthetic sub-records of a matched parent must still cascade") } } diff --git a/Tests/ApolloTests/SQLiteSyntheticKeyClassifierTests.swift b/Tests/ApolloTests/SQLiteSyntheticKeyClassifierTests.swift new file mode 100644 index 000000000..7d35c04e2 --- /dev/null +++ b/Tests/ApolloTests/SQLiteSyntheticKeyClassifierTests.swift @@ -0,0 +1,122 @@ +import XCTest +import Nimble +@testable import ApolloSQLite +import ApolloInternalTestHelpers + +/// Pins the relationship between the two synthetic-key classifiers in +/// `SQLiteSchema.Records`: the Swift regex (`syntheticKeySuffixPattern`, +/// used by `isSyntheticKey` on read assembly) and the SQL `LIKE` pattern +/// (`syntheticKeySuffixLikePattern`, used by the cascade-delete walks +/// and pattern-delete exclusions). The `LIKE` pattern is deliberately +/// coarser (SQLite `LIKE` has no digit character class), so exact +/// equivalence is NOT the invariant. The invariants are: +/// +/// 1. Every key the regex classifies as synthetic is also matched by +/// `LIKE` (regex ⊆ LIKE) — the SQL walks never miss a real +/// synthetic key. +/// 2. Any key where the two classifiers disagree contains the reserved +/// token `.$[` — which `insertOrUpdate` rejects at write time +/// (`SQLiteError.reservedCacheKey`) — so no *storable* user key can +/// ever be classified differently by the two implementations. +/// +/// `LIKE` semantics are evaluated by SQLite itself via +/// `SQLiteTestDatabaseInspector`, not re-implemented in Swift. +class SQLiteSyntheticKeyClassifierTests: XCTestCase { + + // MARK: - Classifiers under test + + private func regexMatches(_ key: String) -> Bool { + key.range( + of: SQLiteSchema.Records.syntheticKeySuffixPattern, + options: .regularExpression + ) != nil + } + + private func likeMatches(_ key: String) throws -> Bool { + try SQLiteTestDatabaseInspector.sqliteLIKEMatches( + pattern: SQLiteSchema.Records.syntheticKeySuffixLikePattern, + candidate: key + ) + } + + // MARK: - Canonical key corpus + + /// Keys the writer actually produces for nested-list indirection. + private static let syntheticKeys = [ + "User:1.tags.$[0]", + "User:1.tags.$[12]", + "Math:cube.cube.$[0].$[3]", + "x.$[0]", + ] + + /// Ordinary cache keys a user or the normalizer can produce. + private static let ordinaryKeys = [ + "User:1", + "QUERY_ROOT", + "User:1.tags", + "hero(episode:JEDI)", + "a.b.c", + "price$", + "$[0]", + "User:1.tags.[0]", + ] + + /// Keys containing the reserved token `.$[` without the exact + /// synthetic shape — rejected at write time by the reserved-key + /// audit, so the classifiers' disagreement on them is unreachable + /// for stored data. + private static let reservedLookalikes = [ + "Order:receipt.$[final]", + "X.$[]", + "X.$[1x]", + "X.$[3]extra", + ] + + private static var fullCorpus: [String] { + syntheticKeys + ordinaryKeys + reservedLookalikes + } + + // MARK: - Tests + + func test__classifiers__givenWriterProducedSyntheticKeys__bothMatch() throws { + for key in Self.syntheticKeys { + expect(self.regexMatches(key)).to(beTrue(), description: "regex should match synthetic key '\(key)'") + expect(try self.likeMatches(key)).to(beTrue(), description: "LIKE should match synthetic key '\(key)'") + } + } + + func test__classifiers__givenOrdinaryKeys__neitherMatches() throws { + for key in Self.ordinaryKeys { + expect(self.regexMatches(key)).to(beFalse(), description: "regex should not match ordinary key '\(key)'") + expect(try self.likeMatches(key)).to(beFalse(), description: "LIKE should not match ordinary key '\(key)'") + } + } + + func test__classifiers__regexMatchesAreSubsetOfLikeMatches() throws { + // The SQL walks (which use LIKE) must never miss a key the Swift + // side (regex) considers synthetic. + for key in Self.fullCorpus where regexMatches(key) { + expect(try self.likeMatches(key)).to( + beTrue(), + description: "regex matches '\(key)' but LIKE does not — the SQL cascade walk would miss it" + ) + } + } + + func test__classifiers__anyDisagreementImpliesReservedToken() throws { + // Where the coarser LIKE pattern and the exact regex disagree, + // the key must contain the reserved token `.$[`, which + // `insertOrUpdate` rejects — so no storable key is ever + // classified inconsistently. + for key in Self.fullCorpus { + let regex = regexMatches(key) + let like = try likeMatches(key) + if regex != like { + expect(key.contains(SQLiteSchema.Records.syntheticKeyToken)).to( + beTrue(), + description: "classifiers disagree on '\(key)' (regex: \(regex), LIKE: \(like)) but the key does not contain the reserved token — it would be storable with inconsistent classification" + ) + } + } + } +} diff --git a/apollo-ios/Sources/ApolloSQLite/ApolloSQLiteDatabase.swift b/apollo-ios/Sources/ApolloSQLite/ApolloSQLiteDatabase.swift index 3c4530cf8..3e317ee56 100644 --- a/apollo-ios/Sources/ApolloSQLite/ApolloSQLiteDatabase.swift +++ b/apollo-ios/Sources/ApolloSQLite/ApolloSQLiteDatabase.swift @@ -445,28 +445,6 @@ public final class ApolloSQLiteDatabase: SQLiteDatabase { } } - /// Test-only: returns the number of rows whose `cache_key` exactly - /// matches the given key. Bypasses `selectRecords`'s synthetic-key - /// filter so cascade-correctness tests can verify whether synthetic - /// sub-record rows still exist in the database after a delete or - /// rewrite. Production code should not depend on this helper. - internal func rowCount(forCacheKey cacheKey: CacheKey) throws -> Int { - try performSync { - let sql = """ - SELECT COUNT(*) FROM \(SQLiteSchema.recordsTableName) - WHERE \(SQLiteSchema.Records.cacheKey) = ? - """ - let stmt = try prepareStatement(sql, errorMessage: "Failed to prepare rowCount probe") - defer { sqlite3_finalize(stmt) } - sqlite3_bind_text(stmt, 1, cacheKey, -1, SQLITE_TRANSIENT) - let stepResult = sqlite3_step(stmt) - guard stepResult == SQLITE_ROW else { - throw SQLiteError.step(message: "rowCount probe failed: \(sqliteErrorMessage())", resultCode: stepResult) - } - return Int(sqlite3_column_int64(stmt, 0)) - } - } - /// Test-only read path: loads every row for the given cache keys, /// reassembles them into `Record` instances, and follows synthetic /// sub-record `child_key_value` pointers to materialize nested lists.