diff --git a/Example/Sources/ChildViewModel.swift b/Example/Sources/ChildViewModel.swift new file mode 100644 index 0000000..aaae82a --- /dev/null +++ b/Example/Sources/ChildViewModel.swift @@ -0,0 +1,28 @@ +// +// ChildViewModel.swift +// DataThespian +// +// Created by Leo Dion on 10/16/24. +// + +import DataThespian +import Foundation +import SwiftData + +internal struct ChildViewModel: Sendable, Identifiable { + internal let model: Model + internal let timestamp: Date + + internal var id: PersistentIdentifier { + model.persistentIdentifier + } + + private init(model: Model, timestamp: Date) { + self.model = model + self.timestamp = timestamp + } + + internal init(child: ItemChild) { + self.init(model: .init(child), timestamp: child.timestamp) + } +} diff --git a/Example/Sources/ContentObject.swift b/Example/Sources/ContentObject.swift index 19e159b..d0ebf39 100644 --- a/Example/Sources/ContentObject.swift +++ b/Example/Sources/ContentObject.swift @@ -17,17 +17,17 @@ internal class ContentObject { private var databaseChangeCancellable: AnyCancellable? private var databaseChangeSubscription: AnyCancellable? private var database: (any Database)? - internal private(set) var items = [ItemModel]() - internal var selectedItemsID: Set = [] + internal private(set) var items = [ItemViewModel]() + internal var selectedItemsID: Set = [] private var newItem: AnyCancellable? internal var error: (any Error)? - internal var selectedItems: [ItemModel] { + internal var selectedItems: [ItemViewModel] { let selectedItemsID = self.selectedItemsID - let items: [ItemModel] + let items: [ItemViewModel] do { items = try self.items.filter( - #Predicate { + #Predicate { selectedItemsID.contains($0.id) } ) @@ -49,17 +49,7 @@ internal class ContentObject { private static func deleteModels(_ models: [Model], from database: (any Database)) async throws { - try await database.withModelContext { modelContext in - let items: [Item] = models.compactMap { - modelContext.model(for: $0.persistentIdentifier) as? Item - } - dump(items.first?.persistentModelID) - assert(items.count == models.count) - for item in items { - modelContext.delete(item) - } - try modelContext.save() - } + try await database.deleteModels(models) } private func beginUpdateItems() { @@ -76,10 +66,10 @@ internal class ContentObject { guard let database else { return } - self.items = try await database.withModelContext({ modelContext in + self.items = try await database.withModelContext { modelContext in let items = try modelContext.fetch(FetchDescriptor()) - return items.map(ItemModel.init) - }) + return items.map(ItemViewModel.init) + } } internal func initialize( @@ -114,20 +104,46 @@ internal class ContentObject { } Task { try await Self.deleteModels(models, from: database) + try await database.save() } } - internal func addItem(withDate date: Date = .init()) { + internal func addChild(to item: ItemViewModel) { guard let database else { return } Task { + let timestamp = Date() + let childModel = await database.insert { + ItemChild(timestamp: timestamp) + } + try await database.withModelContext { modelContext in - let newItem = Item(timestamp: date) - modelContext.insert(newItem) - dump(newItem.persistentModelID) + let item = try modelContext.get(item.model) + let child = try modelContext.get(childModel) + assert(child != nil && item != nil) + child?.parent = item try modelContext.save() } } } + + internal func addItem(withDate date: Date = .init()) { + guard let database else { + return + } + Task { + let insertedModel = await database.insert { Item(timestamp: date) } + print("inserted:", insertedModel.isTemporary) + try await database.save() + let savedModel = try await database.get( + for: .predicate( + #Predicate { + $0.timestamp == date + } + ) + ) + print("saved:", savedModel.isTemporary) + } + } } diff --git a/Example/Sources/ContentView.swift b/Example/Sources/ContentView.swift index 9635d21..e665d7c 100644 --- a/Example/Sources/ContentView.swift +++ b/Example/Sources/ContentView.swift @@ -41,7 +41,7 @@ internal struct ContentView: View { if selectedItems.count > 1 { Text("Multiple Selected") } else if let item = selectedItems.first { - Text("Item at \(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))") + ItemChildView(object: object, item: item) } else { Text("Select an item") } diff --git a/Example/Sources/Item.swift b/Example/Sources/Item.swift index 09b1e81..82a3332 100644 --- a/Example/Sources/Item.swift +++ b/Example/Sources/Item.swift @@ -5,12 +5,20 @@ // Created by Leo Dion on 10/10/24. // +import DataThespian import Foundation import SwiftData @Model -internal final class Item { +internal final class Item: Unique { + internal enum Keys: UniqueKeys { + internal typealias Model = Item + internal static let primary = timestamp + internal static let timestamp = keyPath(\.timestamp) + } + internal private(set) var timestamp: Date + internal private(set) var children: [ItemChild]? internal init(timestamp: Date) { self.timestamp = timestamp diff --git a/Example/Sources/ItemChild.swift b/Example/Sources/ItemChild.swift new file mode 100644 index 0000000..e398ce8 --- /dev/null +++ b/Example/Sources/ItemChild.swift @@ -0,0 +1,19 @@ +// +// ItemChild.swift +// DataThespian +// +// Created by Leo Dion on 10/16/24. +// +import Foundation +import SwiftData + +@Model +internal final class ItemChild { + internal var parent: Item? + internal private(set) var timestamp: Date + + internal init(parent: Item? = nil, timestamp: Date) { + self.parent = parent + self.timestamp = timestamp + } +} diff --git a/Example/Sources/ItemChildView.swift b/Example/Sources/ItemChildView.swift new file mode 100644 index 0000000..7504804 --- /dev/null +++ b/Example/Sources/ItemChildView.swift @@ -0,0 +1,31 @@ +// +// ItemChildView.swift +// DataThespianExample +// +// Created by Leo Dion on 10/16/24. +// + +import SwiftUI + +internal struct ItemChildView: View { + internal var object: ContentObject + internal let item: ItemViewModel + internal var body: some View { + VStack { + Text("Item at \(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))") + Divider() + Button("Add Child") { + object.addChild(to: item) + } + ForEach(item.children) { child in + Text( + "Child at \(child.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))" + ) + } + } + } +} +// +// #Preview { +// ItemChildView() +// } diff --git a/Example/Sources/ItemModel.swift b/Example/Sources/ItemViewModel.swift similarity index 51% rename from Example/Sources/ItemModel.swift rename to Example/Sources/ItemViewModel.swift index f13e75b..02edeeb 100644 --- a/Example/Sources/ItemModel.swift +++ b/Example/Sources/ItemViewModel.swift @@ -9,20 +9,26 @@ import DataThespian import Foundation import SwiftData -internal struct ItemModel: Identifiable { +internal struct ItemViewModel: Sendable, Identifiable { internal let model: Model internal let timestamp: Date + internal let children: [ChildViewModel] internal var id: PersistentIdentifier { model.persistentIdentifier } - private init(model: Model, timestamp: Date) { + private init(model: Model, timestamp: Date, children: [ChildViewModel]?) { self.model = model self.timestamp = timestamp + self.children = children ?? [] } internal init(item: Item) { - self.init(model: .init(item), timestamp: item.timestamp) + self.init( + model: .init(item), + timestamp: item.timestamp, + children: item.children?.map(ChildViewModel.init) + ) } } diff --git a/Sources/DataThespian/BackgroundDatabase.swift b/Sources/DataThespian/Databases/BackgroundDatabase.swift similarity index 100% rename from Sources/DataThespian/BackgroundDatabase.swift rename to Sources/DataThespian/Databases/BackgroundDatabase.swift diff --git a/Sources/DataThespian/Database+Extras.swift b/Sources/DataThespian/Databases/Database+Extras.swift similarity index 82% rename from Sources/DataThespian/Database+Extras.swift rename to Sources/DataThespian/Databases/Database+Extras.swift index 15644f5..5ff5f78 100644 --- a/Sources/DataThespian/Database+Extras.swift +++ b/Sources/DataThespian/Databases/Database+Extras.swift @@ -32,13 +32,7 @@ public import SwiftData extension Database { - public func insert( - _ closuer: @Sendable @escaping () -> PersistentModelType - ) async -> Model { - let id: PersistentIdentifier = await self.insert(closuer) - return .init(persistentIdentifier: id) - } - + @available(*, deprecated) public func with( _ id: Model, _ closure: @escaping @Sendable (PersistentModelType) throws -> U @@ -53,10 +47,13 @@ } } - public func first(_ selectPredicate: Predicate) async throws -> Model? { + @available(*, deprecated) + public func first(_ selectPredicate: Predicate) async throws -> Model? + { try await self.first(selectPredicate, with: Model.ifMap) } + @available(*, deprecated) public func first( _ selectPredicate: Predicate, with closure: @escaping @Sendable (T?) throws -> U ) async throws -> U { @@ -67,38 +64,23 @@ } } - public func first( - fetchWith selectPredicate: Predicate, - otherwiseInsertBy insert: @Sendable @escaping () -> T, - with closure: @escaping @Sendable (T) throws -> U - ) async throws -> U { - let value = try await self.fetch { - .init(predicate: selectPredicate, fetchLimit: 1) - } with: { models in - try models.first.map(closure) - } - - if let value { - return value - } - - let inserted: Model = await self.insert(insert) - - return try await self.with(inserted, closure) - } - + @available(*, deprecated) public func delete(model _: T.Type, where predicate: Predicate? = nil) async throws { try await self.delete(where: predicate) } + @available(*, deprecated) public func delete(_ model: Model) async { await self.delete(T.self, withID: model.persistentIdentifier) } + @available(*, deprecated) public func deleteAll(of types: [any PersistentModel.Type]) async throws { - try await self.transaction { context in for type in types { try context.delete(model: type) } } + try await self.transaction { context in for type in types { try context.delete(model: type) } + } } + @available(*, deprecated) public func fetch( _: T.Type, with closure: @escaping @Sendable ([T]) throws -> U ) async throws -> U { @@ -109,15 +91,19 @@ } } + @available(*, deprecated) public func fetch(_: T.Type) async throws -> [Model] { try await self.fetch(T.self) { models in models.map(Model.init) } } + + @available(*, deprecated) public func fetch( _: T.Type, _ selectDescriptor: @escaping @Sendable () -> FetchDescriptor ) async throws -> [Model] { await self.fetch(selectDescriptor) { models in models.map(Model.init) } } + @available(*, deprecated) public func fetch( of _: T.Type, for objectIDs: [PersistentIdentifier], @@ -134,6 +120,7 @@ } } + @available(*, deprecated) public func get( of _: T.Type, for objectID: PersistentIdentifier, diff --git a/Sources/DataThespian/Database+ModelContext.swift b/Sources/DataThespian/Databases/Database+ModelContext.swift similarity index 91% rename from Sources/DataThespian/Database+ModelContext.swift rename to Sources/DataThespian/Databases/Database+ModelContext.swift index 4107355..763889c 100644 --- a/Sources/DataThespian/Database+ModelContext.swift +++ b/Sources/DataThespian/Databases/Database+ModelContext.swift @@ -34,20 +34,17 @@ public import SwiftData extension Database { - public func save() async throws { try await self.withModelContext { try $0.save() } } - + @available(*, deprecated) @discardableResult public func delete( _ modelType: T.Type, withID id: PersistentIdentifier ) async -> Bool { await self.withModelContext { $0.delete(modelType, withID: id) } } + @available(*, deprecated) public func delete(where predicate: Predicate?) async throws { try await self.withModelContext { try $0.delete(where: predicate) } } - public func insert(_ closuer: @Sendable @escaping () -> some PersistentModel) async - -> PersistentIdentifier - { await self.withModelContext { $0.insert(closuer) } } - + @available(*, deprecated) public func fetch( _ selectDescriptor: @escaping @Sendable () -> FetchDescriptor, with closure: @escaping @Sendable ([T]) throws -> U @@ -55,6 +52,7 @@ try await self.withModelContext { try $0.fetch(selectDescriptor, with: closure) } } + @available(*, deprecated) public func fetch( _ selectDescriptorA: @escaping @Sendable () -> FetchDescriptor, _ selectDescriptorB: @escaping @Sendable () -> FetchDescriptor, @@ -65,12 +63,14 @@ } } + @available(*, deprecated) public func get( for objectID: PersistentIdentifier, with closure: @escaping @Sendable (T?) throws -> U ) async rethrows -> U where T: PersistentModel { try await self.withModelContext { try $0.get(for: objectID, with: closure) } } + @available(*, deprecated) public func transaction(_ block: @Sendable @escaping (ModelContext) throws -> Void) async throws { try await self.withModelContext { try $0.transaction(block: block) } } } diff --git a/Sources/DataThespian/Databases/Database+Queryable.swift b/Sources/DataThespian/Databases/Database+Queryable.swift new file mode 100644 index 0000000..403e585 --- /dev/null +++ b/Sources/DataThespian/Databases/Database+Queryable.swift @@ -0,0 +1,73 @@ +// +// Database+Queryable.swift +// DataThespian +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftData) + public import SwiftData + + extension Database { + public func save() async throws { + try await self.withModelContext { try $0.save() } + } + + public func insert( + _ closuer: @Sendable @escaping () -> PersistentModelType, + with closure: @escaping @Sendable (PersistentModelType) throws -> U + ) async rethrows -> U { + try await self.withModelContext { + try $0.insert(closuer, with: closure) + } + } + + public func getOptional( + for selector: Selector.Get, + with closure: @escaping @Sendable (PersistentModelType?) throws -> U + ) async rethrows -> U { + try await self.withModelContext { + try $0.getOptional(for: selector, with: closure) + } + } + + public func fetch( + for selector: Selector.List, + with closure: @escaping @Sendable ([PersistentModelType]) throws -> U + ) async rethrows -> U { + try await self.withModelContext { + try $0.fetch(for: selector, with: closure) + } + } + + public func delete(_ selector: Selector.Delete) + async throws + { + try await self.withModelContext { + try $0.delete(selector) + } + } + } +#endif diff --git a/Sources/DataThespian/Database.swift b/Sources/DataThespian/Databases/Database.swift similarity index 96% rename from Sources/DataThespian/Database.swift rename to Sources/DataThespian/Databases/Database.swift index 97dfa1b..6a8c6d2 100644 --- a/Sources/DataThespian/Database.swift +++ b/Sources/DataThespian/Databases/Database.swift @@ -33,7 +33,7 @@ public import SwiftData - public protocol Database: Sendable { + public protocol Database: Sendable, Queryable { func withModelContext(_ closure: @Sendable @escaping (ModelContext) throws -> T) async rethrows -> T } diff --git a/Sources/DataThespian/DatabaseKey.swift b/Sources/DataThespian/Databases/EnvironmentValues+Database.swift similarity index 86% rename from Sources/DataThespian/DatabaseKey.swift rename to Sources/DataThespian/Databases/EnvironmentValues+Database.swift index 3c2d0fd..966ef15 100644 --- a/Sources/DataThespian/DatabaseKey.swift +++ b/Sources/DataThespian/Databases/EnvironmentValues+Database.swift @@ -1,5 +1,5 @@ // -// DatabaseKey.swift +// EnvironmentValues+Database.swift // DataThespian // // Created by Leo Dion. @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -#if canImport(SwiftUI) +#if canImport(SwiftUI) && canImport(SwiftData) import Foundation import SwiftData public import SwiftUI @@ -42,15 +42,8 @@ } } - private struct DatabaseKey: EnvironmentKey { - static var defaultValue: any Database { DefaultDatabase.instance } - } - extension EnvironmentValues { - public var database: any Database { - get { self[DatabaseKey.self] } - set { self[DatabaseKey.self] = newValue } - } + @Entry public var database: any Database = DefaultDatabase.instance } extension Scene { diff --git a/Sources/DataThespian/ModelActor+Database.swift b/Sources/DataThespian/Databases/ModelActor+Database.swift similarity index 100% rename from Sources/DataThespian/ModelActor+Database.swift rename to Sources/DataThespian/Databases/ModelActor+Database.swift diff --git a/Sources/DataThespian/ModelActorDatabase.swift b/Sources/DataThespian/Databases/ModelActorDatabase.swift similarity index 100% rename from Sources/DataThespian/ModelActorDatabase.swift rename to Sources/DataThespian/Databases/ModelActorDatabase.swift diff --git a/Sources/DataThespian/ModelContext.swift b/Sources/DataThespian/Databases/QueryError.swift similarity index 69% rename from Sources/DataThespian/ModelContext.swift rename to Sources/DataThespian/Databases/QueryError.swift index 422fae3..7b31182 100644 --- a/Sources/DataThespian/ModelContext.swift +++ b/Sources/DataThespian/Databases/QueryError.swift @@ -1,5 +1,5 @@ // -// ModelContext.swift +// QueryError.swift // DataThespian // // Created by Leo Dion. @@ -28,25 +28,9 @@ // #if canImport(SwiftData) - import Foundation public import SwiftData - extension ModelContext { - public func existingModel(for objectID: PersistentIdentifier) throws -> T? - where T: PersistentModel { - if let registered: T = registeredModel(for: objectID) { - return registered - } - if let notRegistered: T = model(for: objectID) as? T { - return notRegistered - } - - let fetchDescriptor = FetchDescriptor( - predicate: #Predicate { $0.persistentModelID == objectID } - ) - - return try fetch(fetchDescriptor).first - } + public enum QueryError: Error { + case itemNotFound(Selector.Get) } - #endif diff --git a/Sources/DataThespian/Databases/Queryable+Extensions.swift b/Sources/DataThespian/Databases/Queryable+Extensions.swift new file mode 100644 index 0000000..903aefe --- /dev/null +++ b/Sources/DataThespian/Databases/Queryable+Extensions.swift @@ -0,0 +1,138 @@ +// +// Queryable+Extensions.swift +// DataThespian +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftData) + public import SwiftData + + extension Queryable { + @discardableResult + public func insert( + _ closuer: @Sendable @escaping () -> PersistentModelType + ) async -> Model { + await self.insert(closuer, with: Model.init) + } + + public func getOptional( + for selector: Selector.Get + ) async -> Model? { + await self.getOptional(for: selector) { persistentModel in + persistentModel.flatMap(Model.init) + } + } + + public func fetch( + for selector: Selector.List + ) async -> [Model] { + await self.fetch(for: selector) { persistentModels in + persistentModels.map(Model.init) + } + } + + public func get( + for selector: Selector.Get + ) async throws -> Model { + try await self.getOptional(for: selector) { persistentModel in + guard let persistentModel else { + throw QueryError.itemNotFound(selector) + } + return Model(persistentModel) + } + } + + public func get( + for selector: Selector.Get, + with closure: @escaping @Sendable (PersistentModelType) throws -> U + ) async throws -> U { + try await self.getOptional(for: selector) { persistentModel in + guard let persistentModel else { + throw QueryError.itemNotFound(selector) + } + return try closure(persistentModel) + } + } + + public func update( + for selector: Selector.Get, + with closure: @escaping @Sendable (PersistentModelType) throws -> Void + ) async throws { + try await self.get(for: selector, with: closure) + } + + public func update( + for selector: Selector.List, + with closure: @escaping @Sendable ([PersistentModelType]) throws -> Void + ) async throws { + try await self.fetch(for: selector, with: closure) + } + + public func insertIf( + _ model: @Sendable @escaping () -> PersistentModelType, + notExist selector: @Sendable @escaping (PersistentModelType) -> + Selector.Get + ) async -> Model { + let persistentModel = model() + let selector = selector(persistentModel) + let modelOptional = await self.getOptional(for: selector) + + if let modelOptional { + return modelOptional + } else { + return await self.insert(model) + } + } + + public func insertIf( + _ model: @Sendable @escaping () -> PersistentModelType, + notExist selector: @Sendable @escaping (PersistentModelType) -> + Selector.Get, + with closure: @escaping @Sendable (PersistentModelType) throws -> U + ) async throws -> U { + let model = await self.insertIf(model, notExist: selector) + return try await self.get(for: .model(model), with: closure) + } + } + + extension Queryable { + public func deleteModels(_ models: [Model]) + async throws + { + try await withThrowingTaskGroup( + of: Void.self, + body: { group in + for model in models { + group.addTask { + try await self.delete(.model(model)) + } + } + try await group.waitForAll() + } + ) + } + } +#endif diff --git a/Sources/DataThespian/Databases/Queryable.swift b/Sources/DataThespian/Databases/Queryable.swift new file mode 100644 index 0000000..8bb0de9 --- /dev/null +++ b/Sources/DataThespian/Databases/Queryable.swift @@ -0,0 +1,53 @@ +// +// Queryable.swift +// DataThespian +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftData) + public import SwiftData + + public protocol Queryable: Sendable { + func save() async throws + + func insert( + _ closuer: @Sendable @escaping () -> PersistentModelType, + with closure: @escaping @Sendable (PersistentModelType) throws -> U + ) async rethrows -> U + + func getOptional( + for selector: Selector.Get, + with closure: @escaping @Sendable (PersistentModelType?) throws -> U + ) async rethrows -> U + + func fetch( + for selector: Selector.List, + with closure: @escaping @Sendable ([PersistentModelType]) throws -> U + ) async rethrows -> U + + func delete(_ selector: Selector.Delete) async throws + } +#endif diff --git a/Sources/DataThespian/Databases/Selector.swift b/Sources/DataThespian/Databases/Selector.swift new file mode 100644 index 0000000..6b2e559 --- /dev/null +++ b/Sources/DataThespian/Databases/Selector.swift @@ -0,0 +1,66 @@ +// +// Selector.swift +// DataThespian +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftData) + public import Foundation + public import SwiftData + + public enum Selector: Sendable { + public enum Delete: Sendable { + case predicate(Predicate) + case all + case model(Model) + } + public enum List: Sendable { + case descriptor(FetchDescriptor) + } + public enum Get: Sendable { + case model(Model) + case predicate(Predicate) + } + } + + extension Selector.Get { + @available(*, unavailable, message: "Not implemented yet.") + public static func unique( + _ key: UniqueKeyableType, + equals value: UniqueKeyableType.ValueType + ) -> Self where UniqueKeyableType.Model == T { + .predicate( + key.predicate(equals: value) + ) + } + } + + extension Selector.List { + public static func all() -> Selector.List { + .descriptor(.init()) + } + } +#endif diff --git a/Sources/DataThespian/Databases/Unique.swift b/Sources/DataThespian/Databases/Unique.swift new file mode 100644 index 0000000..5371263 --- /dev/null +++ b/Sources/DataThespian/Databases/Unique.swift @@ -0,0 +1,32 @@ +// +// Unique.swift +// DataThespian +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public protocol Unique { + associatedtype Keys: UniqueKeys +} diff --git a/Sources/DataThespian/Databases/UniqueKey.swift b/Sources/DataThespian/Databases/UniqueKey.swift new file mode 100644 index 0000000..9981b50 --- /dev/null +++ b/Sources/DataThespian/Databases/UniqueKey.swift @@ -0,0 +1,37 @@ +// +// UniqueKey.swift +// DataThespian +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +public protocol UniqueKey: Sendable { + associatedtype Model: Unique + associatedtype ValueType: Sendable & Equatable & Codable + + func predicate(equals value: ValueType) -> Predicate +} diff --git a/Sources/DataThespian/Databases/UniqueKeyPath.swift b/Sources/DataThespian/Databases/UniqueKeyPath.swift new file mode 100644 index 0000000..9c065ca --- /dev/null +++ b/Sources/DataThespian/Databases/UniqueKeyPath.swift @@ -0,0 +1,43 @@ +// +// UniqueKeyPath.swift +// DataThespian +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +public struct UniqueKeyPath: UniqueKey { + private let keyPath: KeyPath & Sendable + + internal init(keyPath: any KeyPath & Sendable) { + self.keyPath = keyPath + } + + // swiftlint:disable:next unavailable_function + public func predicate(equals value: ValueType) -> Predicate { + fatalError("Not implemented yet.") + } +} diff --git a/Sources/DataThespian/Databases/UniqueKeys.swift b/Sources/DataThespian/Databases/UniqueKeys.swift new file mode 100644 index 0000000..aded055 --- /dev/null +++ b/Sources/DataThespian/Databases/UniqueKeys.swift @@ -0,0 +1,43 @@ +// +// UniqueKeys.swift +// DataThespian +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public protocol UniqueKeys: Sendable { + associatedtype Model: Unique + associatedtype PrimaryKey: UniqueKey where PrimaryKey.Model == Model + + static var primary: PrimaryKey { get } +} + +extension UniqueKeys { + public static func keyPath( + _ keyPath: any KeyPath & Sendable + ) -> UniqueKeyPath { + UniqueKeyPath(keyPath: keyPath) + } +} diff --git a/Sources/DataThespian/Model.swift b/Sources/DataThespian/Model.swift index ed01dac..547d250 100644 --- a/Sources/DataThespian/Model.swift +++ b/Sources/DataThespian/Model.swift @@ -46,6 +46,10 @@ } extension Model where T: PersistentModel { + public var isTemporary: Bool { + self.persistentIdentifier.isTemporary ?? false + } + public init(_ model: T) { self.init(persistentIdentifier: model.persistentModelID) } internal static func ifMap(_ model: T?) -> Model? { model.map(self.init) } diff --git a/Sources/DataThespian/AgentRegister.swift b/Sources/DataThespian/Notification/AgentRegister.swift similarity index 100% rename from Sources/DataThespian/AgentRegister.swift rename to Sources/DataThespian/Notification/AgentRegister.swift diff --git a/Sources/DataThespian/DatabaseChangePublicist.swift b/Sources/DataThespian/Notification/Combine/DatabaseChangePublicist.swift similarity index 100% rename from Sources/DataThespian/DatabaseChangePublicist.swift rename to Sources/DataThespian/Notification/Combine/DatabaseChangePublicist.swift diff --git a/Sources/DataThespian/DatabaseChangePublicistKey.swift b/Sources/DataThespian/Notification/Combine/EnvironmentValues+DatabaseChangePublicist.swift similarity index 75% rename from Sources/DataThespian/DatabaseChangePublicistKey.swift rename to Sources/DataThespian/Notification/Combine/EnvironmentValues+DatabaseChangePublicist.swift index 1f3b4e6..673cca9 100644 --- a/Sources/DataThespian/DatabaseChangePublicistKey.swift +++ b/Sources/DataThespian/Notification/Combine/EnvironmentValues+DatabaseChangePublicist.swift @@ -1,5 +1,5 @@ // -// DatabaseChangePublicistKey.swift +// EnvironmentValues+DatabaseChangePublicist.swift // DataThespian // // Created by Leo Dion. @@ -32,16 +32,7 @@ public import SwiftUI - private struct DatabaseChangePublicistKey: EnvironmentKey { - typealias Value = DatabaseChangePublicist - - nonisolated static let defaultValue: DatabaseChangePublicist = .never() - } - extension EnvironmentValues { - public var databaseChangePublicist: DatabaseChangePublicist { - get { self[DatabaseChangePublicistKey.self] } - set { self[DatabaseChangePublicistKey.self] = newValue } - } + @Entry public var databaseChangePublicist: DatabaseChangePublicist = .never() } #endif diff --git a/Sources/DataThespian/PublishingAgent.swift b/Sources/DataThespian/Notification/Combine/PublishingAgent.swift similarity index 100% rename from Sources/DataThespian/PublishingAgent.swift rename to Sources/DataThespian/Notification/Combine/PublishingAgent.swift diff --git a/Sources/DataThespian/PublishingRegister.swift b/Sources/DataThespian/Notification/Combine/PublishingRegister.swift similarity index 100% rename from Sources/DataThespian/PublishingRegister.swift rename to Sources/DataThespian/Notification/Combine/PublishingRegister.swift diff --git a/Sources/DataThespian/DataAgent.swift b/Sources/DataThespian/Notification/DataAgent.swift similarity index 100% rename from Sources/DataThespian/DataAgent.swift rename to Sources/DataThespian/Notification/DataAgent.swift diff --git a/Sources/DataThespian/DataMonitor.swift b/Sources/DataThespian/Notification/DataMonitor.swift similarity index 100% rename from Sources/DataThespian/DataMonitor.swift rename to Sources/DataThespian/Notification/DataMonitor.swift diff --git a/Sources/DataThespian/DatabaseChangeSet.swift b/Sources/DataThespian/Notification/DatabaseChangeSet.swift similarity index 100% rename from Sources/DataThespian/DatabaseChangeSet.swift rename to Sources/DataThespian/Notification/DatabaseChangeSet.swift diff --git a/Sources/DataThespian/DatabaseChangeType.swift b/Sources/DataThespian/Notification/DatabaseChangeType.swift similarity index 100% rename from Sources/DataThespian/DatabaseChangeType.swift rename to Sources/DataThespian/Notification/DatabaseChangeType.swift diff --git a/Sources/DataThespian/DatabaseMonitoring.swift b/Sources/DataThespian/Notification/DatabaseMonitoring.swift similarity index 100% rename from Sources/DataThespian/DatabaseMonitoring.swift rename to Sources/DataThespian/Notification/DatabaseMonitoring.swift diff --git a/Sources/DataThespian/ManagedObjectMetadata.swift b/Sources/DataThespian/Notification/ManagedObjectMetadata.swift similarity index 100% rename from Sources/DataThespian/ManagedObjectMetadata.swift rename to Sources/DataThespian/Notification/ManagedObjectMetadata.swift diff --git a/Sources/DataThespian/Notification.swift b/Sources/DataThespian/Notification/Notification.swift similarity index 100% rename from Sources/DataThespian/Notification.swift rename to Sources/DataThespian/Notification/Notification.swift diff --git a/Sources/DataThespian/NotificationDataUpdate.swift b/Sources/DataThespian/Notification/NotificationDataUpdate.swift similarity index 100% rename from Sources/DataThespian/NotificationDataUpdate.swift rename to Sources/DataThespian/Notification/NotificationDataUpdate.swift diff --git a/Sources/DataThespian/RegistrationCollection.swift b/Sources/DataThespian/Notification/RegistrationCollection.swift similarity index 100% rename from Sources/DataThespian/RegistrationCollection.swift rename to Sources/DataThespian/Notification/RegistrationCollection.swift diff --git a/Sources/DataThespian/FetchDescriptor.swift b/Sources/DataThespian/SwiftData/FetchDescriptor.swift similarity index 98% rename from Sources/DataThespian/FetchDescriptor.swift rename to Sources/DataThespian/SwiftData/FetchDescriptor.swift index 1ced50d..9bf6f63 100644 --- a/Sources/DataThespian/FetchDescriptor.swift +++ b/Sources/DataThespian/SwiftData/FetchDescriptor.swift @@ -38,6 +38,8 @@ self.fetchLimit = fetchLimit } + + @available(*, deprecated) public init(model: Model) { let persistentIdentifier = model.persistentIdentifier self.init( diff --git a/Sources/DataThespian/ModelContext+Extension.swift b/Sources/DataThespian/SwiftData/ModelContext+Extension.swift similarity index 87% rename from Sources/DataThespian/ModelContext+Extension.swift rename to Sources/DataThespian/SwiftData/ModelContext+Extension.swift index 3013d03..533767e 100644 --- a/Sources/DataThespian/ModelContext+Extension.swift +++ b/Sources/DataThespian/SwiftData/ModelContext+Extension.swift @@ -32,6 +32,7 @@ public import SwiftData extension ModelContext { + @available(*, deprecated) public func delete(_: T.Type, withID id: PersistentIdentifier) -> Bool { guard let model: T = self.registeredModel(for: id) else { return false @@ -40,10 +41,12 @@ return true } + @available(*, deprecated) public func delete(where predicate: Predicate?) throws where T: PersistentModel { try self.delete(model: T.self, where: predicate) } + @available(*, deprecated) public func insert(_ closuer: @escaping @Sendable () -> some PersistentModel) -> PersistentIdentifier { @@ -51,6 +54,8 @@ self.insert(model) return model.persistentModelID } + + @available(*, deprecated) public func fetch( _ selectDescriptor: @escaping @Sendable () -> FetchDescriptor, with closure: @escaping @Sendable ([T]) throws -> U @@ -58,6 +63,8 @@ let models = try self.fetch(selectDescriptor()) return try closure(models) } + + @available(*, deprecated) public func fetch( _ selectDescriptorA: @escaping @Sendable () -> FetchDescriptor, _ selectDescriptorB: @escaping @Sendable () -> FetchDescriptor, @@ -68,6 +75,7 @@ return try closure(firstModels, secondModels) } + @available(*, deprecated) public func get( for objectID: PersistentIdentifier, with closure: @escaping @Sendable (T?) throws -> U ) throws -> U where T: PersistentModel, U: Sendable { @@ -75,8 +83,15 @@ return try closure(model) } + @available(*, deprecated) public func transaction(block: @escaping @Sendable (ModelContext) throws -> Void) throws { try self.transaction { try block(self) } } + + public func first( + where predicate: Predicate? = nil + ) throws -> PersistentModelType? { + try self.fetch(FetchDescriptor(predicate: predicate, fetchLimit: 1)).first + } } #endif diff --git a/Sources/DataThespian/SwiftData/ModelContext+Queryable.swift b/Sources/DataThespian/SwiftData/ModelContext+Queryable.swift new file mode 100644 index 0000000..4be7149 --- /dev/null +++ b/Sources/DataThespian/SwiftData/ModelContext+Queryable.swift @@ -0,0 +1,83 @@ +// +// ModelContext+Queryable.swift +// DataThespian +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftData) + public import SwiftData + + extension ModelContext { + public func insert( + _ closuer: @Sendable @escaping () -> PersistentModelType, + with closure: @escaping @Sendable (PersistentModelType) throws -> U + ) rethrows -> U { + let persistentModel = closuer() + self.insert(persistentModel) + return try closure(persistentModel) + } + + public func getOptional( + for selector: Selector.Get, + with closure: @escaping @Sendable (PersistentModelType?) throws -> U + ) throws -> U { + let persistentModel: PersistentModelType? + switch selector { + case .model(let model): + persistentModel = try self.get(model) + case .predicate(let predicate): + persistentModel = try self.first(where: predicate) + } + return try closure(persistentModel) + } + + public func fetch( + for selector: Selector.List, + with closure: @escaping @Sendable ([PersistentModelType]) throws -> U + ) throws -> U { + let persistentModels: [PersistentModelType] + switch selector { + case .descriptor(let descriptor): + persistentModels = try self.fetch(descriptor) + } + return try closure(persistentModels) + } + + public func delete(_ selector: Selector.Delete) throws + { + switch selector { + case .all: + try self.delete(model: PersistentModelType.self) + case .model(let model): + if let persistentModel = try self.get(model) { + self.delete(persistentModel) + } + case .predicate(let predicate): + try self.delete(model: PersistentModelType.self, where: predicate) + } + } + } +#endif diff --git a/Sources/DataThespian/SwiftData/ModelContext.swift b/Sources/DataThespian/SwiftData/ModelContext.swift new file mode 100644 index 0000000..251dfb3 --- /dev/null +++ b/Sources/DataThespian/SwiftData/ModelContext.swift @@ -0,0 +1,76 @@ +// +// ModelContext.swift +// DataThespian +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftData) + import Foundation + public import SwiftData + + extension ModelContext { + public func get(_ model: Model) throws -> T? + where T: PersistentModel { + try self.persistentModel(withID: model.persistentIdentifier) + } + + private func persistentModel(withID objectID: PersistentIdentifier) throws -> T? + where T: PersistentModel { + if let registered: T = registeredModel(for: objectID) { + return registered + } + if let notRegistered: T = model(for: objectID) as? T { + return notRegistered + } + + let fetchDescriptor = FetchDescriptor( + predicate: #Predicate { $0.persistentModelID == objectID }, + fetchLimit: 1 + ) + + return try fetch(fetchDescriptor).first + } + + @available(*, deprecated) + internal func existingModel(for objectID: PersistentIdentifier) throws -> T? + where T: PersistentModel { + if let registered: T = registeredModel(for: objectID) { + return registered + } + if let notRegistered: T = model(for: objectID) as? T { + return notRegistered + } + + let fetchDescriptor = FetchDescriptor( + predicate: #Predicate { $0.persistentModelID == objectID }, + fetchLimit: 1 + ) + + return try fetch(fetchDescriptor).first + } + } + +#endif diff --git a/Sources/DataThespian/NSManagedObjectID.swift b/Sources/DataThespian/SwiftData/NSManagedObjectID.swift similarity index 100% rename from Sources/DataThespian/NSManagedObjectID.swift rename to Sources/DataThespian/SwiftData/NSManagedObjectID.swift diff --git a/Sources/DataThespian/SwiftData/PersistentIdentifier.swift b/Sources/DataThespian/SwiftData/PersistentIdentifier.swift new file mode 100644 index 0000000..dee0c99 --- /dev/null +++ b/Sources/DataThespian/SwiftData/PersistentIdentifier.swift @@ -0,0 +1,90 @@ +// +// PersistentIdentifier.swift +// DataThespian +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(CoreData) && canImport(SwiftData) + import CoreData + import Foundation + import SwiftData + + /// Returns the value of a child property of an object using reflection. + /// + /// - Parameters: + /// - object: The object to inspect. + /// - childName: The name of the child property to retrieve. + /// - Returns: The value of the child property, or nil if it does not exist. + private func getMirrorChildValue(of object: Any, childName: String) -> Any? { + guard let child = Mirror(reflecting: object).children.first(where: { $0.label == childName }) + else { + return nil + } + + return child.value + } + + // Extension to add computed properties for accessing underlying CoreData + // implementation details of PersistentIdentifier + extension PersistentIdentifier { + // Private stored property to hold reference to underlying implementation + private var mirrorImplementation: Any? { + guard let implementation = getMirrorChildValue(of: self, childName: "implementation") else { + assertionFailure("Should always be there.") + return nil + } + return implementation + } + + // Computed property to access managedObjectID from implementation + private var objectID: NSManagedObjectID? { + guard let mirrorImplementation, + let objectID = getMirrorChildValue(of: mirrorImplementation, childName: "managedObjectID") + as? NSManagedObjectID + else { + return nil + } + return objectID + } + + // Computed property to access uriRepresentation from objectID + private var uriRepresentation: URL? { + objectID?.uriRepresentation() + } + + // swiftlint:disable:next discouraged_optional_boolean + internal var isTemporary: Bool? { + guard let mirrorImplementation, + let isTemporary = getMirrorChildValue(of: mirrorImplementation, childName: "isTemporary") + as? Bool + else { + assertionFailure("Should always be there.") + return nil + } + return isTemporary + } + } +#endif