Skip to content

Commit 0ff2be3

Browse files
authored
Merge pull request #3 from PureSwift/feature/objectCache
Add object cache for ID lookups
2 parents 7476f78 + bee1d55 commit 0ff2be3

4 files changed

Lines changed: 241 additions & 2 deletions

File tree

.github/workflows/swift.yml

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
name: Swift
2+
on: [push]
3+
jobs:
4+
5+
macos:
6+
name: macOS
7+
runs-on: macos-26
8+
strategy:
9+
fail-fast: false
10+
matrix:
11+
config: ["debug", "release"]
12+
steps:
13+
- name: Checkout
14+
uses: actions/checkout@v4
15+
- name: Swift Version
16+
run: swift --version
17+
- name: Build
18+
run: swift build -c ${{ matrix.config }}
19+
- name: Test
20+
run: swift test -c ${{ matrix.config }}
21+
22+
linux:
23+
name: Linux (${{ matrix.container }})
24+
runs-on: ubuntu-latest
25+
strategy:
26+
fail-fast: false
27+
matrix:
28+
container: ["swift:latest", "swiftlang/swift:nightly-noble"]
29+
config: ["debug", "release"]
30+
container: ${{ matrix.container }}
31+
steps:
32+
- name: Checkout
33+
uses: actions/checkout@v4
34+
- name: Swift Version
35+
run: swift --version
36+
- name: Build
37+
run: swift build -c ${{ matrix.config }}
38+
- name: Test
39+
run: swift test -c ${{ matrix.config }}

Sources/CoreModelSQLite/Database.swift

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,19 @@ public actor SQLiteDatabase {
1818

1919
internal let connection: SQLite.Connection
2020

21+
/// Fetched objects by entity and ID, mirroring CoreData's row cache: a repeated
22+
/// `fetch(_:for:)` returns the cached value without touching SQLite, skipping both
23+
/// the row query and the per-relationship queries that derive to-many values.
24+
/// Predicate fetches also register their results, so ID lookups following a list
25+
/// fetch are free.
26+
///
27+
/// Writes drop the cached objects of every entity whose stored or derived values
28+
/// they can change (see `invalidateCache(for:)`). The cache assumes this
29+
/// actor is the database's only writer — the same coherency contract as separate
30+
/// CoreData stacks on one store file. `SQLiteViewContext` reads its own connection
31+
/// directly and always observes committed state.
32+
internal var cache = [EntityName: [ObjectID: ModelData]]()
33+
2134
/// Creates the schema eagerly, synchronously, as part of initialization — not lazily
2235
/// on first use. A `SQLiteViewContext` opens its own read-only connection to the same
2336
/// file and, being read-only, can never create the schema itself; without this, a
@@ -45,12 +58,24 @@ extension SQLiteDatabase: ModelStorage {
4558

4659
public func fetch(_ entity: EntityName, for id: ObjectID) async throws -> ModelData? {
4760
try await asyncYield()
48-
return try connection.fetch(entity, for: id, model: model)
61+
if let cached = cache[entity]?[id] {
62+
return cached
63+
}
64+
let value = try connection.fetch(entity, for: id, model: model)
65+
if let value {
66+
cache[entity, default: [:]][id] = value
67+
}
68+
return value
4969
}
5070

5171
public func fetch(_ fetchRequest: FetchRequest) async throws -> [ModelData] {
5272
try await asyncYield()
53-
return try connection.fetch(fetchRequest, model: model)
73+
let values = try connection.fetch(fetchRequest, model: model)
74+
// register results so subsequent ID lookups are cache hits
75+
for value in values {
76+
cache[fetchRequest.entity, default: [:]][value.id] = value
77+
}
78+
return values
5479
}
5580

5681
public func fetchID(_ fetchRequest: FetchRequest) async throws -> [ObjectID] {
@@ -66,21 +91,25 @@ extension SQLiteDatabase: ModelStorage {
6691
public func insert(_ value: ModelData) async throws {
6792
try await asyncYield()
6893
try connection.insert(value, model: model)
94+
invalidateCache(for: [value.entity])
6995
}
7096

7197
public func insert(_ values: [ModelData]) async throws {
7298
try await asyncYield()
7399
try connection.insert(values, model: model)
100+
invalidateCache(for: Set(values.lazy.map { $0.entity }))
74101
}
75102

76103
public func delete(_ entity: EntityName, for id: ObjectID) async throws {
77104
try await asyncYield()
78105
try connection.delete(entity, for: id, model: model)
106+
invalidateCache(for: [entity])
79107
}
80108

81109
public func delete(_ entity: EntityName, for ids: [ObjectID]) async throws {
82110
try await asyncYield()
83111
try connection.delete(entity, for: ids, model: model)
112+
invalidateCache(for: [entity])
84113
}
85114
}
86115

@@ -90,6 +119,28 @@ internal extension SQLiteDatabase {
90119
await Task.yield()
91120
try Task.checkCancellation()
92121
}
122+
123+
/// Drop cached objects of every entity a write to the given entities can affect.
124+
///
125+
/// A write to entity E touches E's own table, the foreign key columns of E's
126+
/// relationship destinations, and their shared join tables. Derived to-many values
127+
/// are computed only from those same foreign keys and join tables, so the affected
128+
/// set is exactly E plus E's destination entities — including E-typed rows other
129+
/// than the one written (e.g. reassigning a person to a new team also changes the
130+
/// old team's `members`), which is why whole entity caches are dropped rather than
131+
/// single objects.
132+
func invalidateCache(for entities: Set<EntityName>) {
133+
var affected = entities
134+
for entity in entities {
135+
guard let description = model.entities.first(where: { $0.id == entity }) else { continue }
136+
for relationship in description.relationships {
137+
affected.insert(relationship.destinationEntity)
138+
}
139+
}
140+
for entity in affected {
141+
cache[entity] = nil
142+
}
143+
}
93144
}
94145

95146
internal extension Connection {
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import Foundation
2+
import Testing
3+
import CoreModel
4+
import SQLite
5+
@testable import CoreModelSQLite
6+
7+
/// Verifies `SQLiteDatabase`'s object cache: repeated ID lookups are served from memory,
8+
/// and every write shape that can change a fetched representation — direct updates,
9+
/// inverse relationship changes, reassignment side effects, and deletes — invalidates
10+
/// the affected entries so reads never observe stale data.
11+
@Suite("Object Cache")
12+
struct CacheTests {
13+
14+
@Test func fetchByIDPopulatesCache() async throws {
15+
let database = try makeDatabase()
16+
let person = ModelData(
17+
entity: "Person",
18+
id: "person1",
19+
attributes: ["name": .string("Alice"), "age": .int32(30)]
20+
)
21+
try await database.insert(person)
22+
#expect(await database.cache["Person"]?["person1"] == nil)
23+
let fetched = try #require(try await database.fetch("Person", for: "person1"))
24+
#expect(await database.cache["Person"]?["person1"] == fetched)
25+
// a second fetch returns the cached value
26+
let cached = try #require(try await database.fetch("Person", for: "person1"))
27+
#expect(cached == fetched)
28+
}
29+
30+
@Test func fetchRequestRegistersResults() async throws {
31+
let database = try makeDatabase()
32+
let people = (0..<5).map { index in
33+
ModelData(
34+
entity: "Person",
35+
id: ObjectID(rawValue: "person\(index)"),
36+
attributes: ["name": .string("Person \(index)"), "age": .int32(Int32(20 + index))]
37+
)
38+
}
39+
try await database.insert(people)
40+
let results = try await database.fetch(FetchRequest(entity: "Person"))
41+
#expect(results.count == 5)
42+
let cache = await database.cache["Person"]
43+
#expect(cache?.count == 5)
44+
for value in results {
45+
#expect(cache?[value.id] == value)
46+
}
47+
}
48+
49+
@Test func updateInvalidatesCachedObject() async throws {
50+
let database = try makeDatabase()
51+
var person = ModelData(
52+
entity: "Person",
53+
id: "person1",
54+
attributes: ["name": .string("Alice"), "age": .int32(30)]
55+
)
56+
try await database.insert(person)
57+
_ = try await database.fetch("Person", for: "person1")
58+
// update through the same database
59+
person.attributes["age"] = .int32(31)
60+
try await database.insert(person)
61+
#expect(await database.cache["Person"] == nil)
62+
let fetched = try #require(try await database.fetch("Person", for: "person1"))
63+
#expect(fetched.attributes["age"] == .int32(31))
64+
}
65+
66+
@Test func writeInvalidatesInverseRelationship() async throws {
67+
let database = try makeDatabase()
68+
let team = ModelData(entity: "Team", id: "team1", attributes: ["name": .string("Red")])
69+
try await database.insert(team)
70+
// cache the team with no members
71+
let cached = try #require(try await database.fetch("Team", for: "team1"))
72+
#expect(cached.relationships["members"] == .toMany([]))
73+
// inserting a person pointing at the team changes the team's derived `members`
74+
let person = ModelData(
75+
entity: "Person",
76+
id: "person1",
77+
attributes: ["name": .string("Alice")],
78+
relationships: ["team": .toOne("team1")]
79+
)
80+
try await database.insert(person)
81+
let fetched = try #require(try await database.fetch("Team", for: "team1"))
82+
#expect(fetched.relationships["members"] == .toMany(["person1"]))
83+
}
84+
85+
@Test func reassignmentInvalidatesOtherRows() async throws {
86+
let database = try makeDatabase()
87+
let teams = ["team1", "team2"].map {
88+
ModelData(entity: "Team", id: ObjectID(rawValue: $0), attributes: ["name": .string($0)])
89+
}
90+
try await database.insert(teams)
91+
let person = ModelData(
92+
entity: "Person",
93+
id: "person1",
94+
attributes: ["name": .string("Alice")],
95+
relationships: ["team": .toOne("team2")]
96+
)
97+
try await database.insert(person)
98+
// cache team2 with its member
99+
let cached = try #require(try await database.fetch("Team", for: "team2"))
100+
#expect(cached.relationships["members"] == .toMany(["person1"]))
101+
// claiming the person for team1 also changes team2's derived `members`,
102+
// a row of the written entity other than the one written
103+
var team1 = teams[0]
104+
team1.relationships["members"] = .toMany(["person1"])
105+
try await database.insert(team1)
106+
let fetched = try #require(try await database.fetch("Team", for: "team2"))
107+
#expect(fetched.relationships["members"] == .toMany([]))
108+
}
109+
110+
@Test func deleteRemovesFromCache() async throws {
111+
let database = try makeDatabase()
112+
let person = ModelData(
113+
entity: "Person",
114+
id: "person1",
115+
attributes: ["name": .string("Alice")]
116+
)
117+
try await database.insert(person)
118+
_ = try await database.fetch("Person", for: "person1")
119+
try await database.delete("Person", for: "person1")
120+
#expect(await database.cache["Person"] == nil)
121+
#expect(try await database.fetch("Person", for: "person1") == nil)
122+
}
123+
124+
@Test func deleteNullifiesCachedReferences() async throws {
125+
let database = try makeDatabase()
126+
let team = ModelData(entity: "Team", id: "team1", attributes: ["name": .string("Red")])
127+
let person = ModelData(
128+
entity: "Person",
129+
id: "person1",
130+
attributes: ["name": .string("Alice")],
131+
relationships: ["team": .toOne("team1")]
132+
)
133+
try await database.insert(team)
134+
try await database.insert(person)
135+
// cache the person with its team reference
136+
let cached = try #require(try await database.fetch("Person", for: "person1"))
137+
#expect(cached.relationships["team"] == .toOne("team1"))
138+
// deleting the team nullifies the cached person's foreign key
139+
try await database.delete("Team", for: "team1")
140+
let fetched = try #require(try await database.fetch("Person", for: "person1"))
141+
#expect(fetched.relationships["team"] == .null)
142+
}
143+
}

Tests/CoreModelSQLiteTests/ProfessionalDriver/Model/VIN.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,20 @@ internal extension VehicleIdentificationNumber {
2525

2626
static func isValid(_ vin: inout String) -> Bool {
2727
vin = vin.uppercased()
28+
#if canImport(Darwin)
2829
if #available(iOS 16.0, *) {
2930
return vin.wholeMatch(of: /^[A-HJ-NPR-Z0-9]{17}$/) != nil
3031
} else {
3132
return Self.predicate.evaluate(with: vin)
3233
}
34+
#else
35+
return vin.wholeMatch(of: /^[A-HJ-NPR-Z0-9]{17}$/) != nil
36+
#endif
3337
}
3438

39+
#if canImport(Darwin)
3540
nonisolated(unsafe) static let predicate = NSPredicate(format: "SELF MATCHES %@", "^[A-HJ-NPR-Z0-9]{17}$")
41+
#endif
3642
}
3743

3844
// MARK: - CustomStringConvertible

0 commit comments

Comments
 (0)