diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml new file mode 100644 index 0000000..46783f5 --- /dev/null +++ b/.github/workflows/swift.yml @@ -0,0 +1,39 @@ +name: Swift +on: [push] +jobs: + + macos: + name: macOS + runs-on: macos-26 + strategy: + fail-fast: false + matrix: + config: ["debug", "release"] + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Swift Version + run: swift --version + - name: Build + run: swift build -c ${{ matrix.config }} + - name: Test + run: swift test -c ${{ matrix.config }} + + linux: + name: Linux (${{ matrix.container }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + container: ["swift:latest", "swiftlang/swift:nightly-noble"] + config: ["debug", "release"] + container: ${{ matrix.container }} + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Swift Version + run: swift --version + - name: Build + run: swift build -c ${{ matrix.config }} + - name: Test + run: swift test -c ${{ matrix.config }} diff --git a/Sources/CoreModelSQLite/Database.swift b/Sources/CoreModelSQLite/Database.swift index 379d7a4..76b4771 100644 --- a/Sources/CoreModelSQLite/Database.swift +++ b/Sources/CoreModelSQLite/Database.swift @@ -18,6 +18,19 @@ public actor SQLiteDatabase { internal let connection: SQLite.Connection + /// Fetched objects by entity and ID, mirroring CoreData's row cache: a repeated + /// `fetch(_:for:)` returns the cached value without touching SQLite, skipping both + /// the row query and the per-relationship queries that derive to-many values. + /// Predicate fetches also register their results, so ID lookups following a list + /// fetch are free. + /// + /// Writes drop the cached objects of every entity whose stored or derived values + /// they can change (see `invalidateCache(for:)`). The cache assumes this + /// actor is the database's only writer — the same coherency contract as separate + /// CoreData stacks on one store file. `SQLiteViewContext` reads its own connection + /// directly and always observes committed state. + internal var cache = [EntityName: [ObjectID: ModelData]]() + /// Creates the schema eagerly, synchronously, as part of initialization — not lazily /// on first use. A `SQLiteViewContext` opens its own read-only connection to the same /// file and, being read-only, can never create the schema itself; without this, a @@ -45,12 +58,24 @@ extension SQLiteDatabase: ModelStorage { public func fetch(_ entity: EntityName, for id: ObjectID) async throws -> ModelData? { try await asyncYield() - return try connection.fetch(entity, for: id, model: model) + if let cached = cache[entity]?[id] { + return cached + } + let value = try connection.fetch(entity, for: id, model: model) + if let value { + cache[entity, default: [:]][id] = value + } + return value } public func fetch(_ fetchRequest: FetchRequest) async throws -> [ModelData] { try await asyncYield() - return try connection.fetch(fetchRequest, model: model) + let values = try connection.fetch(fetchRequest, model: model) + // register results so subsequent ID lookups are cache hits + for value in values { + cache[fetchRequest.entity, default: [:]][value.id] = value + } + return values } public func fetchID(_ fetchRequest: FetchRequest) async throws -> [ObjectID] { @@ -66,21 +91,25 @@ extension SQLiteDatabase: ModelStorage { public func insert(_ value: ModelData) async throws { try await asyncYield() try connection.insert(value, model: model) + invalidateCache(for: [value.entity]) } public func insert(_ values: [ModelData]) async throws { try await asyncYield() try connection.insert(values, model: model) + invalidateCache(for: Set(values.lazy.map { $0.entity })) } public func delete(_ entity: EntityName, for id: ObjectID) async throws { try await asyncYield() try connection.delete(entity, for: id, model: model) + invalidateCache(for: [entity]) } public func delete(_ entity: EntityName, for ids: [ObjectID]) async throws { try await asyncYield() try connection.delete(entity, for: ids, model: model) + invalidateCache(for: [entity]) } } @@ -90,6 +119,28 @@ internal extension SQLiteDatabase { await Task.yield() try Task.checkCancellation() } + + /// Drop cached objects of every entity a write to the given entities can affect. + /// + /// A write to entity E touches E's own table, the foreign key columns of E's + /// relationship destinations, and their shared join tables. Derived to-many values + /// are computed only from those same foreign keys and join tables, so the affected + /// set is exactly E plus E's destination entities — including E-typed rows other + /// than the one written (e.g. reassigning a person to a new team also changes the + /// old team's `members`), which is why whole entity caches are dropped rather than + /// single objects. + func invalidateCache(for entities: Set) { + var affected = entities + for entity in entities { + guard let description = model.entities.first(where: { $0.id == entity }) else { continue } + for relationship in description.relationships { + affected.insert(relationship.destinationEntity) + } + } + for entity in affected { + cache[entity] = nil + } + } } internal extension Connection { diff --git a/Tests/CoreModelSQLiteTests/CacheTests.swift b/Tests/CoreModelSQLiteTests/CacheTests.swift new file mode 100644 index 0000000..8cd7d4c --- /dev/null +++ b/Tests/CoreModelSQLiteTests/CacheTests.swift @@ -0,0 +1,143 @@ +import Foundation +import Testing +import CoreModel +import SQLite +@testable import CoreModelSQLite + +/// Verifies `SQLiteDatabase`'s object cache: repeated ID lookups are served from memory, +/// and every write shape that can change a fetched representation — direct updates, +/// inverse relationship changes, reassignment side effects, and deletes — invalidates +/// the affected entries so reads never observe stale data. +@Suite("Object Cache") +struct CacheTests { + + @Test func fetchByIDPopulatesCache() async throws { + let database = try makeDatabase() + let person = ModelData( + entity: "Person", + id: "person1", + attributes: ["name": .string("Alice"), "age": .int32(30)] + ) + try await database.insert(person) + #expect(await database.cache["Person"]?["person1"] == nil) + let fetched = try #require(try await database.fetch("Person", for: "person1")) + #expect(await database.cache["Person"]?["person1"] == fetched) + // a second fetch returns the cached value + let cached = try #require(try await database.fetch("Person", for: "person1")) + #expect(cached == fetched) + } + + @Test func fetchRequestRegistersResults() async throws { + let database = try makeDatabase() + let people = (0..<5).map { index in + ModelData( + entity: "Person", + id: ObjectID(rawValue: "person\(index)"), + attributes: ["name": .string("Person \(index)"), "age": .int32(Int32(20 + index))] + ) + } + try await database.insert(people) + let results = try await database.fetch(FetchRequest(entity: "Person")) + #expect(results.count == 5) + let cache = await database.cache["Person"] + #expect(cache?.count == 5) + for value in results { + #expect(cache?[value.id] == value) + } + } + + @Test func updateInvalidatesCachedObject() async throws { + let database = try makeDatabase() + var person = ModelData( + entity: "Person", + id: "person1", + attributes: ["name": .string("Alice"), "age": .int32(30)] + ) + try await database.insert(person) + _ = try await database.fetch("Person", for: "person1") + // update through the same database + person.attributes["age"] = .int32(31) + try await database.insert(person) + #expect(await database.cache["Person"] == nil) + let fetched = try #require(try await database.fetch("Person", for: "person1")) + #expect(fetched.attributes["age"] == .int32(31)) + } + + @Test func writeInvalidatesInverseRelationship() async throws { + let database = try makeDatabase() + let team = ModelData(entity: "Team", id: "team1", attributes: ["name": .string("Red")]) + try await database.insert(team) + // cache the team with no members + let cached = try #require(try await database.fetch("Team", for: "team1")) + #expect(cached.relationships["members"] == .toMany([])) + // inserting a person pointing at the team changes the team's derived `members` + let person = ModelData( + entity: "Person", + id: "person1", + attributes: ["name": .string("Alice")], + relationships: ["team": .toOne("team1")] + ) + try await database.insert(person) + let fetched = try #require(try await database.fetch("Team", for: "team1")) + #expect(fetched.relationships["members"] == .toMany(["person1"])) + } + + @Test func reassignmentInvalidatesOtherRows() async throws { + let database = try makeDatabase() + let teams = ["team1", "team2"].map { + ModelData(entity: "Team", id: ObjectID(rawValue: $0), attributes: ["name": .string($0)]) + } + try await database.insert(teams) + let person = ModelData( + entity: "Person", + id: "person1", + attributes: ["name": .string("Alice")], + relationships: ["team": .toOne("team2")] + ) + try await database.insert(person) + // cache team2 with its member + let cached = try #require(try await database.fetch("Team", for: "team2")) + #expect(cached.relationships["members"] == .toMany(["person1"])) + // claiming the person for team1 also changes team2's derived `members`, + // a row of the written entity other than the one written + var team1 = teams[0] + team1.relationships["members"] = .toMany(["person1"]) + try await database.insert(team1) + let fetched = try #require(try await database.fetch("Team", for: "team2")) + #expect(fetched.relationships["members"] == .toMany([])) + } + + @Test func deleteRemovesFromCache() async throws { + let database = try makeDatabase() + let person = ModelData( + entity: "Person", + id: "person1", + attributes: ["name": .string("Alice")] + ) + try await database.insert(person) + _ = try await database.fetch("Person", for: "person1") + try await database.delete("Person", for: "person1") + #expect(await database.cache["Person"] == nil) + #expect(try await database.fetch("Person", for: "person1") == nil) + } + + @Test func deleteNullifiesCachedReferences() async throws { + let database = try makeDatabase() + let team = ModelData(entity: "Team", id: "team1", attributes: ["name": .string("Red")]) + let person = ModelData( + entity: "Person", + id: "person1", + attributes: ["name": .string("Alice")], + relationships: ["team": .toOne("team1")] + ) + try await database.insert(team) + try await database.insert(person) + // cache the person with its team reference + let cached = try #require(try await database.fetch("Person", for: "person1")) + #expect(cached.relationships["team"] == .toOne("team1")) + // deleting the team nullifies the cached person's foreign key + try await database.delete("Team", for: "team1") + let fetched = try #require(try await database.fetch("Person", for: "person1")) + #expect(fetched.relationships["team"] == .null) + } +} diff --git a/Tests/CoreModelSQLiteTests/ProfessionalDriver/Model/VIN.swift b/Tests/CoreModelSQLiteTests/ProfessionalDriver/Model/VIN.swift index dc076f7..bce2fa3 100644 --- a/Tests/CoreModelSQLiteTests/ProfessionalDriver/Model/VIN.swift +++ b/Tests/CoreModelSQLiteTests/ProfessionalDriver/Model/VIN.swift @@ -25,14 +25,20 @@ internal extension VehicleIdentificationNumber { static func isValid(_ vin: inout String) -> Bool { vin = vin.uppercased() + #if canImport(Darwin) if #available(iOS 16.0, *) { return vin.wholeMatch(of: /^[A-HJ-NPR-Z0-9]{17}$/) != nil } else { return Self.predicate.evaluate(with: vin) } + #else + return vin.wholeMatch(of: /^[A-HJ-NPR-Z0-9]{17}$/) != nil + #endif } + #if canImport(Darwin) nonisolated(unsafe) static let predicate = NSPredicate(format: "SELF MATCHES %@", "^[A-HJ-NPR-Z0-9]{17}$") + #endif } // MARK: - CustomStringConvertible