Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .github/workflows/swift.yml
Original file line number Diff line number Diff line change
@@ -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 }}
55 changes: 53 additions & 2 deletions Sources/CoreModelSQLite/Database.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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] {
Expand All @@ -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])
}
}

Expand All @@ -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<EntityName>) {
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 {
Expand Down
143 changes: 143 additions & 0 deletions Tests/CoreModelSQLiteTests/CacheTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
6 changes: 6 additions & 0 deletions Tests/CoreModelSQLiteTests/ProfessionalDriver/Model/VIN.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading