Skip to content
Open
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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@

Swift Object Graph

## Requirements

Swift 6.0 or later, macOS 10.15, iOS 13, tvOS 13, and watchOS 6.

`InMemoryStorage`, `InMemoryModelStorage`, and `InMemoryViewContext` are the exception: they serialize access to their shared backing with `Synchronization.Mutex`, which is not back-deployed, so on Apple platforms they require macOS 15, iOS 18, tvOS 18, watchOS 11, or visionOS 2. Everything else in `CoreModel` — the schema types, predicates, and the `ModelStorage`/`ViewContext` protocols — is available at the package's own deployment targets, as are third-party backends such as SQLite and MongoDB. Linux, Windows, Android, and WebAssembly have no such restriction.

## Backends

- CoreData
Expand Down
93 changes: 59 additions & 34 deletions Sources/CoreModel/InMemoryStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
//

#if !hasFeature(Embedded)
import Foundation
import Synchronization
#endif

/// Shared in-memory backing store.
Expand All @@ -17,51 +17,64 @@ import Foundation
/// reference type, a store and a view context that share the same instance observe
/// the exact same data.
///
/// The type is thread-safe: all access is serialized so the actor-isolated
/// ``InMemoryModelStorage`` and the main-actor ``InMemoryViewContext`` can operate
/// on the same instance concurrently. Under Embedded Swift the lock is elided:
/// with a concurrency runtime the backing is only ever touched from within its
/// The type is thread-safe: the mutable state lives inside a `Mutex`, so the
/// actor-isolated ``InMemoryModelStorage`` and the main-actor ``InMemoryViewContext``
/// can operate on the same instance concurrently. Under Embedded Swift the mutex is
/// elided: with a concurrency runtime the backing is only ever touched from within its
/// owning actor, and without one (e.g. bare-metal ARM, where ``ModelStorage``
/// itself is unavailable) this synchronous store is the storage API, used
/// directly from the single-threaded main loop.
public final class InMemoryStorage {
///
/// - Note: On Apple platforms this type is gated on the availability of
/// `Synchronization.Mutex`, which is not back-deployed. The rest of `CoreModel`
/// keeps the package's lower deployment targets.
@available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *)
internal final class InMemoryStorage {

/// The schema entities are validated against.
public let model: Model

private var objects = [EntityName: [ObjectID: ModelData]]()
/// The mutable state, only reachable through ``withState(_:)``.
private struct State {

var objects = [EntityName: [ObjectID: ModelData]]()

private var functions = [String: DatabaseFunction]()
var functions = [String: DatabaseFunction]()
}

#if !hasFeature(Embedded)
private let lock = NSLock()
#if hasFeature(Embedded)
private var state = State()
#else
private let state = Mutex(State())
#endif

public init(model: Model) {
self.model = model
}

private func withLock<T, E>(_ body: () throws(E) -> T) throws(E) -> T where E: Error {
#if !hasFeature(Embedded)
lock.lock()
defer { lock.unlock() }
private func withState<T, E>(_ body: (inout State) throws(E) -> T) throws(E) -> T where E: Error {
#if hasFeature(Embedded)
return try body(&state)
#else
return try state.withLock { (state) throws(E) in
try body(&state)
}
#endif
return try body()
}

public func fetch(_ entity: EntityName, for id: ObjectID) throws(CoreModelError) -> ModelData? {
try withLock { () throws(CoreModelError) in
try withState { (state) throws(CoreModelError) in
try validate(entity)
return objects[entity]?[id].map { normalized(entity: entity, $0) }
return state.objects[entity]?[id].map { normalized(entity: entity, $0, objects: state.objects) }
}
}

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

Expand All @@ -85,7 +98,11 @@ public final class InMemoryStorage {
/// 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 {
private func normalized(
entity: EntityName,
_ value: ModelData,
objects: [EntityName: [ObjectID: ModelData]]
) -> ModelData {
guard let description = model[entity] else { return value }
var value = value
for attribute in description.attributes where value.attributes[attribute.id] == nil {
Expand Down Expand Up @@ -139,7 +156,7 @@ public final class InMemoryStorage {
}

public func insert(_ value: ModelData) throws(CoreModelError) {
try withLock { () throws(CoreModelError) in
try withState { (state) throws(CoreModelError) in
try validate(value.entity)
// 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
Expand All @@ -148,7 +165,7 @@ public final class InMemoryStorage {
// 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] {
if var existing = state.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.
Expand All @@ -158,9 +175,9 @@ public final class InMemoryStorage {
for (key, relationship) in value.relationships {
existing.relationships[key] = relationship
}
objects[value.entity, default: [:]][value.id] = existing
state.objects[value.entity, default: [:]][value.id] = existing
} else {
objects[value.entity, default: [:]][value.id] = value
state.objects[value.entity, default: [:]][value.id] = value
}
}
}
Expand All @@ -172,24 +189,24 @@ public final class InMemoryStorage {
}

public func delete(_ entity: EntityName, for id: ObjectID) throws(CoreModelError) {
try withLock { () throws(CoreModelError) in
try withState { (state) throws(CoreModelError) in
try validate(entity)
objects[entity]?[id] = nil
state.objects[entity]?[id] = nil
}
}

public func delete(_ entity: EntityName, for ids: [ObjectID]) throws(CoreModelError) {
try withLock { () throws(CoreModelError) in
try withState { (state) throws(CoreModelError) in
try validate(entity)
for id in ids {
objects[entity]?[id] = nil
state.objects[entity]?[id] = nil
}
}
}

public func register(function: DatabaseFunction) {
withLock {
functions[function.name] = function
withState { state in
state.functions[function.name] = function
}
}

Expand All @@ -200,6 +217,14 @@ public final class InMemoryStorage {
}
}

// - Note: Safe because every access is serialized through `withLock` (non-Embedded)
// or confined to the owning actor (Embedded).
#if hasFeature(Embedded)
// - Note: Safe because the backing is confined to the owning actor, or to the
// single-threaded main loop on targets without a concurrency runtime.
@available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *)
extension InMemoryStorage: @unchecked Sendable {}
#else
// - Note: Checked: `model` is an immutable `Sendable` value and every piece of
// mutable state lives inside the `Mutex`.
@available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *)
extension InMemoryStorage: Sendable {}
#endif
6 changes: 6 additions & 0 deletions Sources/CoreModel/InMemoryStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@
///
/// On platforms that support it, ``viewContext`` returns a synchronous,
/// main-actor ``InMemoryViewContext`` backed by the same data.
///
/// - Note: Inherits ``InMemoryStorage``'s Apple-platform availability, which is
/// gated on `Synchronization.Mutex`.
@available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *)
public actor InMemoryModelStorage {

internal let backing: InMemoryStorage
Expand Down Expand Up @@ -90,6 +94,7 @@ public actor InMemoryModelStorage {
// MARK: - ViewContext

#if !hasFeature(Embedded)
@available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *)
public extension InMemoryModelStorage {

/// A synchronous, main-actor view context backed by the same data as this store.
Expand All @@ -116,6 +121,7 @@ public extension InMemoryModelStorage {
// `CoreModelError` to `any Error` is disallowed (`#EmbeddedRestrictions`).
// Embedded consumers call the store's methods directly; they provide the
// same API with typed throws.
@available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *)
extension InMemoryModelStorage: ModelStorage {}
#endif

Expand Down
5 changes: 5 additions & 0 deletions Sources/CoreModel/InMemoryViewContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@
///
/// Useful for SwiftUI previews, unit tests, and lightweight main-thread caches
/// where the `async` ``InMemoryModelStorage`` would be inconvenient.
///
/// - Note: Inherits ``InMemoryStorage``'s Apple-platform availability, which is
/// gated on `Synchronization.Mutex`.
@available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *)
@MainActor
public final class InMemoryViewContext {

Expand Down Expand Up @@ -90,6 +94,7 @@ public final class InMemoryViewContext {

// MARK: - ViewContext

@available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *)
extension InMemoryViewContext: ViewContext {}

#endif
9 changes: 9 additions & 0 deletions Tests/CoreModelTests/InMemoryStoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import Testing
EntityDescription(entity: Event.self)
])

@available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *)
@Test func insertAndFetch() async throws {
let store = InMemoryModelStorage(model: Self.model)
let person = Person(name: "Alice", age: 30)
Expand All @@ -27,6 +28,7 @@ import Testing
#expect(missing == nil)
}

@available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *)
@Test func update() async throws {
let store = InMemoryModelStorage(model: Self.model)
var person = Person(name: "Alice", age: 30)
Expand All @@ -39,6 +41,7 @@ import Testing
#expect(count == 1)
}

@available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *)
@Test func batchInsert() async throws {
let store = InMemoryModelStorage(model: Self.model)
let people = (1...10).map { Person(name: "Person \($0)", age: UInt(20 + $0)) }
Expand All @@ -47,6 +50,7 @@ import Testing
#expect(count == 10)
}

@available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *)
@Test func fetchRequest() async throws {
let store = InMemoryModelStorage(model: Self.model)
let people = (1...5).map { Person(name: "Person \($0)", age: UInt(20 + $0)) }
Expand Down Expand Up @@ -77,6 +81,7 @@ import Testing
#expect(count == 2)
}

@available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *)
@Test func fetchID() async throws {
let store = InMemoryModelStorage(model: Self.model)
let person = Person(name: "Alice", age: 30)
Expand All @@ -85,6 +90,7 @@ import Testing
#expect(ids == [ObjectID(person.id)])
}

@available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *)
@Test func delete() async throws {
let store = InMemoryModelStorage(model: Self.model)
let people = (1...3).map { Person(name: "Person \($0)", age: UInt(20 + $0)) }
Expand All @@ -98,6 +104,7 @@ import Testing
#expect(count == 0)
}

@available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *)
@Test func relationshipPredicate() async throws {
let store = InMemoryModelStorage(model: Self.model)
let event = Event(name: "WWDC", date: Date())
Expand All @@ -111,6 +118,7 @@ import Testing
#expect(attendees == [attendee])
}

@available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *)
@Test func customFunction() async throws {
let store = InMemoryModelStorage(model: Self.model)
let stringLength = DatabaseFunction(name: "LENGTH", argumentCount: 1) { arguments in
Expand All @@ -136,6 +144,7 @@ import Testing
#expect(longNames.map { $0.name } == ["Alexandra"])
}

@available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *)
@Test func modelValidation() async throws {
let store = InMemoryModelStorage(model: Self.model)
let person = Person(name: "Alice", age: 30)
Expand Down
Loading
Loading