Skip to content

Commit b5ffe20

Browse files
committed
Give InMemoryModelStorage the same relationship-materialization and partial-update behavior as SQL/Core Data
insert() now merges into an existing row instead of replacing it outright, so providing only some attributes/relationships preserves whatever the row already had for the rest — the same "only touch the columns you provided" semantics a SQL ON CONFLICT DO UPDATE or a Core Data managed object gives for free. Without this, re-inserting a row from a batch that doesn't touch every relationship (e.g. a catalog sync that doesn't restate reservations written by an unrelated flow) silently wiped those links. fetch() now materializes every to-many relationship the schema declares, the way a SQL row or a Core Data managed object does automatically on read. Neither backend actually stores a to-many value on the row that owns it: a one-to-many (whose inverse is a to-one foreign key) is answered by scanning the destination table for rows whose foreign key points back, and a many-to-many (whose inverse is also to-many) by a join table either side can add a link to. This store kept only what insert() was given, with no such derivation — a to-many relationship that's supposed to be entirely computed and never assigned (which describes every one-to-many) always decoded as either an empty collection regardless of what actually pointed to it, or keyNotFound if nothing had ever touched that key at all. Both are now derived correctly. To-one relationships are left alone: an absent required reference is a real problem, not a default.
1 parent 476c6eb commit b5ffe20

1 file changed

Lines changed: 73 additions & 3 deletions

File tree

‎Sources/CoreModel/InMemoryStorage.swift‎

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,18 +52,75 @@ public final class InMemoryStorage {
5252
public func fetch(_ entity: EntityName, for id: ObjectID) throws(CoreModelError) -> ModelData? {
5353
try withLock { () throws(CoreModelError) in
5454
try validate(entity)
55-
return objects[entity]?[id]
55+
return objects[entity]?[id].map { normalized(entity: entity, $0) }
5656
}
5757
}
5858

5959
public func fetch(_ fetchRequest: FetchRequest) throws(CoreModelError) -> [ModelData] {
6060
try withLock { () throws(CoreModelError) in
6161
try validate(fetchRequest.entity)
62-
let values = objects[fetchRequest.entity].map { Array($0.values) } ?? []
62+
let values = (objects[fetchRequest.entity].map { Array($0.values) } ?? [])
63+
.map { normalized(entity: fetchRequest.entity, $0) }
6364
return fetchRequest.evaluate(values, functions: functions)
6465
}
6566
}
6667

68+
/// Materializes every to-many relationship the schema declares, the way a SQL row or a
69+
/// Core Data managed object does automatically on read.
70+
///
71+
/// Neither backend actually stores a to-many value on the row that owns it — a
72+
/// one-to-many (whose inverse is a to-one foreign key, e.g. `WalletCard.user` → `User`)
73+
/// is answered by scanning the destination table for rows whose foreign key points back
74+
/// here, and a many-to-many (whose inverse is also to-many) by a join table either side
75+
/// can add a link to. This store keeps only what `insert` was given with no such
76+
/// derivation, so a row whose to-many relationship key was never explicitly set — which
77+
/// includes every one-to-many that's supposed to be entirely computed, never assigned —
78+
/// previously decoded as `keyNotFound` instead of the collection it actually has.
79+
///
80+
/// To-one relationships are left alone: an absent required reference is a real data
81+
/// problem, not an empty-collection default.
82+
private func normalized(entity: EntityName, _ value: ModelData) -> ModelData {
83+
guard let description = model[entity] else { return value }
84+
var value = value
85+
for relationship in description.relationships where relationship.type == .toMany {
86+
guard let destination = model[relationship.destinationEntity],
87+
let inverse = destination.relationships.first(where: { $0.id == relationship.inverseRelationship })
88+
else {
89+
if value.relationships[relationship.id] == nil {
90+
value.relationships[relationship.id] = .toMany([])
91+
}
92+
continue
93+
}
94+
let candidates = objects[relationship.destinationEntity].map { Array($0.values) } ?? []
95+
switch inverse.type {
96+
case .toOne:
97+
// One-to-many: always derived live from the destination rows' foreign key,
98+
// matching SQL/Core Data — this row never stores it itself.
99+
let derived = candidates
100+
.filter { $0.relationships[inverse.id] == .toOne(value.id) }
101+
.map { $0.id }
102+
value.relationships[relationship.id] = .toMany(derived)
103+
case .toMany:
104+
// Many-to-many: no join table here, so union whatever this row already
105+
// records with anything the destination rows record pointing back — a link
106+
// added from either side is visible from both.
107+
var ids = Set<ObjectID>()
108+
if case let .toMany(existing)? = value.relationships[relationship.id] {
109+
ids.formUnion(existing)
110+
}
111+
for candidate in candidates {
112+
if case let .toMany(backLinks)? = candidate.relationships[inverse.id],
113+
backLinks.contains(value.id)
114+
{
115+
ids.insert(candidate.id)
116+
}
117+
}
118+
value.relationships[relationship.id] = .toMany(Array(ids))
119+
}
120+
}
121+
return value
122+
}
123+
67124
public func fetchID(_ fetchRequest: FetchRequest) throws(CoreModelError) -> [ObjectID] {
68125
try fetch(fetchRequest).map { $0.id }
69126
}
@@ -75,7 +132,20 @@ public final class InMemoryStorage {
75132
public func insert(_ value: ModelData) throws(CoreModelError) {
76133
try withLock { () throws(CoreModelError) in
77134
try validate(value.entity)
78-
objects[value.entity, default: [:]][value.id] = value
135+
// A key present in `value` overrides; a key the existing row already had that
136+
// `value` doesn't mention is preserved — the same "only touch the columns you
137+
// provided" semantics a SQL `ON CONFLICT DO UPDATE` or a Core Data managed object
138+
// gives for free. Without this, re-inserting a row from a batch that doesn't
139+
// touch every relationship (e.g. a site catalog refresh that never re-states
140+
// `parkingReservations`, which is written by an entirely separate sync) would
141+
// silently wipe those links instead of leaving them alone.
142+
if var existing = objects[value.entity]?[value.id] {
143+
existing.attributes.merge(value.attributes) { _, new in new }
144+
existing.relationships.merge(value.relationships) { _, new in new }
145+
objects[value.entity, default: [:]][value.id] = existing
146+
} else {
147+
objects[value.entity, default: [:]][value.id] = value
148+
}
79149
}
80150
}
81151

0 commit comments

Comments
 (0)