Skip to content

Commit 22211c1

Browse files
committed
Add object cache for fetching by ID
1 parent 7476f78 commit 22211c1

1 file changed

Lines changed: 53 additions & 2 deletions

File tree

‎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 {

0 commit comments

Comments
 (0)