From 0663086e22d22c2092105256b87a3149e3ee9d34 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Wed, 16 Oct 2024 15:29:15 -0400 Subject: [PATCH] cleaning up before merge --- Example/Sources/ChildViewModel.swift | 28 ++++++ Example/Sources/ContentObject.swift | 62 ++++++++----- Example/Sources/ContentView.swift | 2 +- Example/Sources/Item.swift | 1 + Example/Sources/ItemChild.swift | 19 ++++ Example/Sources/ItemChildView.swift | 31 +++++++ .../{ItemModel.swift => ItemViewModel.swift} | 12 ++- .../Databases/Database+Extras.swift | 45 ++++------ .../Databases/Database+ModelContext.swift | 6 +- .../Databases/Queryable+Extensions.swift | 18 ++++ .../DataThespian/Databases/Queryable.swift | 2 +- Sources/DataThespian/Model.swift | 4 + .../DataThespian/SwiftData/ModelContext.swift | 4 +- .../SwiftData/PersistentIdentifier.swift | 88 +++++++++++++++++++ 14 files changed, 263 insertions(+), 59 deletions(-) create mode 100644 Example/Sources/ChildViewModel.swift create mode 100644 Example/Sources/ItemChild.swift create mode 100644 Example/Sources/ItemChildView.swift rename Example/Sources/{ItemModel.swift => ItemViewModel.swift} (51%) create mode 100644 Sources/DataThespian/SwiftData/PersistentIdentifier.swift 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..85394b2 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.existingModel(for: item.model) + let child = try modelContext.existingModel(for: 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 4f69f20..82a3332 100644 --- a/Example/Sources/Item.swift +++ b/Example/Sources/Item.swift @@ -18,6 +18,7 @@ internal final class Item: Unique { } 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/Databases/Database+Extras.swift b/Sources/DataThespian/Databases/Database+Extras.swift index 867b9f6..aa2cf88 100644 --- a/Sources/DataThespian/Databases/Database+Extras.swift +++ b/Sources/DataThespian/Databases/Database+Extras.swift @@ -32,13 +32,6 @@ 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) - } - public func with( _ id: Model, _ closure: @escaping @Sendable (PersistentModelType) throws -> U @@ -68,25 +61,25 @@ } } - 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) - } + // 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) + // } public func delete(model _: T.Type, where predicate: Predicate? = nil) async throws diff --git a/Sources/DataThespian/Databases/Database+ModelContext.swift b/Sources/DataThespian/Databases/Database+ModelContext.swift index d544fff..90faeb6 100644 --- a/Sources/DataThespian/Databases/Database+ModelContext.swift +++ b/Sources/DataThespian/Databases/Database+ModelContext.swift @@ -42,9 +42,9 @@ 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) } } + // public func insert(_ closuer: @Sendable @escaping () -> some PersistentModel) async + // -> PersistentIdentifier + // { await self.withModelContext { $0.insert(closuer) } } public func fetch( _ selectDescriptor: @escaping @Sendable () -> FetchDescriptor, diff --git a/Sources/DataThespian/Databases/Queryable+Extensions.swift b/Sources/DataThespian/Databases/Queryable+Extensions.swift index c8fe187..91d97fa 100644 --- a/Sources/DataThespian/Databases/Queryable+Extensions.swift +++ b/Sources/DataThespian/Databases/Queryable+Extensions.swift @@ -30,6 +30,7 @@ public import SwiftData extension Queryable { + @discardableResult public func insert( _ closuer: @Sendable @escaping () -> PersistentModelType ) async -> Model { @@ -115,3 +116,20 @@ extension Queryable { 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() + } + ) + } +} diff --git a/Sources/DataThespian/Databases/Queryable.swift b/Sources/DataThespian/Databases/Queryable.swift index 6298775..dc3b697 100644 --- a/Sources/DataThespian/Databases/Queryable.swift +++ b/Sources/DataThespian/Databases/Queryable.swift @@ -29,7 +29,7 @@ public import SwiftData -public protocol Queryable { +public protocol Queryable: Sendable { func save() async throws func insert( 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/SwiftData/ModelContext.swift b/Sources/DataThespian/SwiftData/ModelContext.swift index b76bf18..ea462d5 100644 --- a/Sources/DataThespian/SwiftData/ModelContext.swift +++ b/Sources/DataThespian/SwiftData/ModelContext.swift @@ -29,10 +29,10 @@ #if canImport(SwiftData) import Foundation - import SwiftData + public import SwiftData extension ModelContext { - internal func existingModel(for model: Model) throws -> T? + public func existingModel(for model: Model) throws -> T? where T: PersistentModel { try self.existingModel(for: model.persistentIdentifier) } diff --git a/Sources/DataThespian/SwiftData/PersistentIdentifier.swift b/Sources/DataThespian/SwiftData/PersistentIdentifier.swift new file mode 100644 index 0000000..b0f39ba --- /dev/null +++ b/Sources/DataThespian/SwiftData/PersistentIdentifier.swift @@ -0,0 +1,88 @@ +// +// 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. +// + +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 + } +}