diff --git a/README.md b/README.md index 225c9e1..0f5c89f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/Sources/CoreModel/InMemoryStorage.swift b/Sources/CoreModel/InMemoryStorage.swift index 6b1be41..2153dc1 100644 --- a/Sources/CoreModel/InMemoryStorage.swift +++ b/Sources/CoreModel/InMemoryStorage.swift @@ -7,7 +7,7 @@ // #if !hasFeature(Embedded) -import Foundation +import Synchronization #endif /// Shared in-memory backing store. @@ -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(_ body: () throws(E) -> T) throws(E) -> T where E: Error { - #if !hasFeature(Embedded) - lock.lock() - defer { lock.unlock() } + private func withState(_ 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) } } @@ -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 { @@ -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 @@ -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. @@ -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 } } } @@ -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 } } @@ -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 diff --git a/Sources/CoreModel/InMemoryStore.swift b/Sources/CoreModel/InMemoryStore.swift index bda223e..d1fa836 100644 --- a/Sources/CoreModel/InMemoryStore.swift +++ b/Sources/CoreModel/InMemoryStore.swift @@ -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 @@ -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. @@ -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 diff --git a/Sources/CoreModel/InMemoryViewContext.swift b/Sources/CoreModel/InMemoryViewContext.swift index d7933be..65fefa3 100644 --- a/Sources/CoreModel/InMemoryViewContext.swift +++ b/Sources/CoreModel/InMemoryViewContext.swift @@ -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 { @@ -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 diff --git a/Tests/CoreModelTests/InMemoryStoreTests.swift b/Tests/CoreModelTests/InMemoryStoreTests.swift index d5ed7a0..941545f 100644 --- a/Tests/CoreModelTests/InMemoryStoreTests.swift +++ b/Tests/CoreModelTests/InMemoryStoreTests.swift @@ -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) @@ -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) @@ -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)) } @@ -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)) } @@ -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) @@ -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)) } @@ -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()) @@ -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 @@ -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) diff --git a/Tests/CoreModelTests/InMemoryViewContextTests.swift b/Tests/CoreModelTests/InMemoryViewContextTests.swift index 5740177..44a2630 100644 --- a/Tests/CoreModelTests/InMemoryViewContextTests.swift +++ b/Tests/CoreModelTests/InMemoryViewContextTests.swift @@ -17,6 +17,7 @@ import Testing EntityDescription(entity: Event.self) ]) + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) @Test func insertAndFetch() throws { let context = InMemoryViewContext(model: Self.model) let person = Person(name: "Alice", age: 30) @@ -28,6 +29,7 @@ import Testing #expect(missing == nil) } + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) @Test func update() throws { let context = InMemoryViewContext(model: Self.model) var person = Person(name: "Alice", age: 30) @@ -40,6 +42,7 @@ import Testing #expect(count == 1) } + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) @Test func batchInsert() throws { let context = InMemoryViewContext(model: Self.model) let people = (1...10).map { Person(name: "Person \($0)", age: UInt(20 + $0)) } @@ -48,6 +51,7 @@ import Testing #expect(count == 10) } + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) @Test func fetchRequest() throws { let context = InMemoryViewContext(model: Self.model) let people = (1...5).map { Person(name: "Person \($0)", age: UInt(20 + $0)) } @@ -78,6 +82,7 @@ import Testing #expect(count == 2) } + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) @Test func fetchID() throws { let context = InMemoryViewContext(model: Self.model) let person = Person(name: "Alice", age: 30) @@ -86,6 +91,7 @@ import Testing #expect(ids == [ObjectID(person.id)]) } + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) @Test func delete() throws { let context = InMemoryViewContext(model: Self.model) let people = (1...3).map { Person(name: "Person \($0)", age: UInt(20 + $0)) } @@ -99,6 +105,7 @@ import Testing #expect(count == 0) } + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) @Test func relationshipPredicate() throws { let context = InMemoryViewContext(model: Self.model) let event = Event(name: "WWDC", date: Date()) @@ -112,6 +119,7 @@ import Testing #expect(attendees == [attendee]) } + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) @Test func customFunction() throws { let context = InMemoryViewContext(model: Self.model) let stringLength = DatabaseFunction(name: "LENGTH", argumentCount: 1) { arguments in @@ -137,6 +145,7 @@ import Testing #expect(longNames.map { $0.name } == ["Alexandra"]) } + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) @Test func sharedDataWithStore() async throws { let store = InMemoryModelStorage(model: Self.model) let context = store.viewContext @@ -158,6 +167,30 @@ import Testing #expect(try context.count(FetchRequest(entity: Person.entityName)) == 1) } + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) + @Test func concurrentAccessWithStore() async throws { + let store = InMemoryModelStorage(model: Self.model) + let context = store.viewContext + let people = (0 ..< 100).map { Person(name: "Person \($0)", age: UInt($0)) } + // writers run on the store's actor while this main-actor task reads the same + // backing, so the two isolation domains genuinely overlap + async let inserts: Void = withThrowingTaskGroup(of: Void.self) { group in + for person in people { + group.addTask { + try await store.insert(person) + } + } + try await group.waitForAll() + } + for _ in 0 ..< 100 { + _ = try context.count(FetchRequest(entity: Person.entityName)) + } + try await inserts + let count = try context.count(FetchRequest(entity: Person.entityName)) + #expect(count == 100) + } + + @available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) @Test func modelValidation() throws { let context = InMemoryViewContext(model: Self.model) let person = Person(name: "Alice", age: 30)