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 new file mode 100644 index 000000000..3efcad895 --- /dev/null +++ b/Tests/ApolloTests/SQLiteRowPerElementCascadeDeleteTests.swift @@ -0,0 +1,353 @@ +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 +/// `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 — 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 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( + _ 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 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 rowCount(forCacheKey: "Math:1"), 0) + // Synthetic descendants gone. + XCTAssertEqual(try rowCount(forCacheKey: "Math:1.matrix.$[0]"), 0, + "Depth-1 synthetic sub-record must cascade") + XCTAssertEqual(try 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 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 rowCount(forCacheKey: "Math:cube"), 0) + XCTAssertEqual(try rowCount(forCacheKey: "Math:cube.cube.$[0]"), 0, + "Level-2 synthetic sub-record must cascade") + XCTAssertEqual(try 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 rowCount(forCacheKey: "Math:A"), 0) + XCTAssertEqual(try rowCount(forCacheKey: "Math:A.matrix.$[0]"), 0) + // B is untouched. + XCTAssertEqual(try rowCount(forCacheKey: "Math:B"), 1) + XCTAssertEqual(try 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 rowCount(forCacheKey: "Math:multi.matrix.$[0]"), 0) + XCTAssertGreaterThan(try rowCount(forCacheKey: "Math:multi.coords.$[0]"), 0) + + try db.deleteRecord(forKey: "Math:multi") + + XCTAssertEqual(try rowCount(forCacheKey: "Math:multi.matrix.$[0]"), 0, + "First nested-list field's sub-record must cascade") + XCTAssertEqual(try 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 rowCount(forCacheKey: "QUERY_ROOT"), 0) + XCTAssertEqual(try rowCount(forCacheKey: "User:1"), 1, + "Real CacheReference target must not be cascade-deleted") + XCTAssertEqual(try 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 rowCount(forCacheKey: "Org:1.teams.$[0]"), 0) + + try db.deleteRecord(forKey: "Org:1") + + 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 rowCount(forCacheKey: "User:1"), 1, + "Real CacheReference inside a synthetic sub-record must NOT be cascaded") + XCTAssertEqual(try 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 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 rowCount(forCacheKey: "Math:1.matrix.$[0]"), 0, + "Atomic list→scalar rewrite must cascade synthetic sub-records") + XCTAssertEqual(try 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 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 rowCount(forCacheKey: "Math:cube.cube.$[0]"), 0, + "Level-2 synthetic sub-record must cascade on rewrite") + 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") + } + + 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 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 rowCount(forCacheKey: "Math:multi.matrix.$[0]"), 0, + "Rewritten field's synthetic sub-record must cascade") + XCTAssertEqual(try 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 rowCount(forCacheKey: "Animal:cat.claws.$[0]"), 2) + XCTAssertEqual(try rowCount(forCacheKey: "Animal:dog.claws.$[0]"), 2) + + try db.deleteRecords(matchingKey: "Animal:") + + 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 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 rowCount(forCacheKey: "Animal:cat.matrix.$[0]"), 0, + "Matched record's synthetic sub-record must cascade") + XCTAssertEqual(try rowCount(forCacheKey: "User:1"), 1) + XCTAssertEqual(try 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 rowCount(forCacheKey: "User:1.claws.$[0]"), 2) + XCTAssertEqual(try rowCount(forCacheKey: "User:1.claws.$[1]"), 1) + + try db.deleteRecords(matchingKey: "claws") + + XCTAssertEqual(try rowCount(forCacheKey: "User:1"), 2, + "Parent record's rows must survive a pattern that only matches its synthetic children") + XCTAssertEqual(try rowCount(forCacheKey: "User:1.claws.$[0]"), 2, + "Synthetic rows must survive a pattern matching them but not their parent") + 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"]) + 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 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 5572844c0..3e317ee56 100644 --- a/apollo-ios/Sources/ApolloSQLite/ApolloSQLiteDatabase.swift +++ b/apollo-ios/Sources/ApolloSQLite/ApolloSQLiteDatabase.swift @@ -367,17 +367,25 @@ 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`. + // 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 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 + } } } @@ -393,18 +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 { - 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 } } } @@ -442,23 +478,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 +651,126 @@ 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 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 { + 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.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 + 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..50efc34c7 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,34 @@ 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. + /// 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 }