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
92 changes: 89 additions & 3 deletions Sources/CoreModel/InMemoryStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,18 +52,84 @@ public final class InMemoryStorage {
public func fetch(_ entity: EntityName, for id: ObjectID) throws(CoreModelError) -> ModelData? {
try withLock { () throws(CoreModelError) in
try validate(entity)
return objects[entity]?[id]
return objects[entity]?[id].map { normalized(entity: entity, $0) }
}
}

public func fetch(_ fetchRequest: FetchRequest) throws(CoreModelError) -> [ModelData] {
try withLock { () throws(CoreModelError) in
try validate(fetchRequest.entity)
let values = objects[fetchRequest.entity].map { Array($0.values) } ?? []
let values = (objects[fetchRequest.entity].map { Array($0.values) } ?? [])
.map { normalized(entity: fetchRequest.entity, $0) }
return fetchRequest.evaluate(values, functions: functions)
}
}

/// Materializes every attribute and to-many relationship the schema declares, the way a
/// SQL row or a Core Data managed object does automatically on read.
///
/// A SQL column binds `NULL` for anything an `INSERT` doesn't provide, and a Core Data
/// managed object always has a value (`nil` for an unset optional) for every attribute
/// its model declares — a row is never partially formed on either backend. This store
/// keeps only the keys `insert` was actually given, so an attribute nobody has ever
/// explicitly set to `.null` (rather than simply never provided) previously decoded as
/// `keyNotFound` instead of the absence it actually represents.
///
/// To-many relationships get the same treatment along a different axis: neither backend
/// stores a to-many value on the row that owns it in the first place. A one-to-many
/// (whose inverse is a to-one foreign key, e.g. `WalletCard.user` → `User`) is answered
/// by scanning the destination table for rows whose foreign key points back here, and a
/// many-to-many (whose inverse is also to-many) by a join table either side can add a
/// link to. A row whose to-many relationship key was never explicitly set — which
/// includes every one-to-many, which is supposed to be entirely computed and never
/// assigned — previously decoded as `keyNotFound` instead of the collection it actually
/// has. To-one relationships are left alone: an absent required reference is a real data
/// problem, not a default.
private func normalized(entity: EntityName, _ value: ModelData) -> ModelData {
guard let description = model[entity] else { return value }
var value = value
for attribute in description.attributes where value.attributes[attribute.id] == nil {
value.attributes[attribute.id] = .null
}
for relationship in description.relationships where relationship.type == .toMany {
guard let destination = model[relationship.destinationEntity],
let inverse = destination.relationships.first(where: { $0.id == relationship.inverseRelationship })
else {
if value.relationships[relationship.id] == nil {
value.relationships[relationship.id] = .toMany([])
}
continue
}
let candidates = objects[relationship.destinationEntity].map { Array($0.values) } ?? []
switch inverse.type {
case .toOne:
// One-to-many: always derived live from the destination rows' foreign key,
// matching SQL/Core Data — this row never stores it itself.
let derived = candidates
.filter { $0.relationships[inverse.id] == .toOne(value.id) }
.map { $0.id }
value.relationships[relationship.id] = .toMany(derived)
case .toMany:
// Many-to-many: no join table here, so union whatever this row already
// records with anything the destination rows record pointing back — a link
// added from either side is visible from both.
var ids = Set<ObjectID>()
if case let .toMany(existing)? = value.relationships[relationship.id] {
ids.formUnion(existing)
}
for candidate in candidates {
if case let .toMany(backLinks)? = candidate.relationships[inverse.id],
backLinks.contains(value.id)
{
ids.insert(candidate.id)
}
}
value.relationships[relationship.id] = .toMany(Array(ids))
}
}
return value
}

public func fetchID(_ fetchRequest: FetchRequest) throws(CoreModelError) -> [ObjectID] {
try fetch(fetchRequest).map { $0.id }
}
Expand All @@ -75,7 +141,27 @@ public final class InMemoryStorage {
public func insert(_ value: ModelData) throws(CoreModelError) {
try withLock { () throws(CoreModelError) in
try validate(value.entity)
objects[value.entity, default: [:]][value.id] = value
// A key present in `value` overrides; a key the existing row already had that
// `value` doesn't mention is preserved — 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 site catalog refresh that never re-states
// `parkingReservations`, which is written by an entirely separate sync) would
// silently wipe those links instead of leaving them alone.
if var existing = objects[value.entity]?[value.id] {
// - Note: Explicit loops rather than `Dictionary.merge(_:uniquingKeysWith:)` —
// the closure-based overload does dynamic casting internally, which is
// disallowed under Embedded Swift.
for (key, attribute) in value.attributes {
existing.attributes[key] = attribute
}
for (key, relationship) in value.relationships {
existing.relationships[key] = relationship
}
objects[value.entity, default: [:]][value.id] = existing
} else {
objects[value.entity, default: [:]][value.id] = value
}
}
}

Expand Down
Loading
Loading