From 13093d72d46672714f77d52ec03272496360cd6d Mon Sep 17 00:00:00 2001 From: John Estropia Date: Fri, 24 Jul 2026 11:51:36 +0900 Subject: [PATCH] cleanup --- CoreStoreTests/ImportTests.swift | 6 +- CoreStoreTests/SwiftUIReaderStateTests.swift | 545 ++++++++++++++++++ Demo/Demo.xcodeproj/project.pbxproj | 6 +- .../xcshareddata/xcschemes/Demo.xcscheme | 18 +- ...ssic.ColorsDemo.DetailViewController.swift | 26 +- .../Modern.ColorsDemo.MainView.swift | 1 + ...Modern.ColorsDemo.SwiftUI.DetailView.swift | 8 +- .../Modern.ColorsDemo.SwiftUI.ItemView.swift | 1 + .../Modern.ColorsDemo.SwiftUI.ListView.swift | 3 +- ...olorsDemo.UIKit.DetailViewController.swift | 19 +- ....ColorsDemo.UIKit.ListViewController.swift | 2 +- .../Modern.PlacemarksDemo.MainView.swift | 6 +- .../PokedexDemo/Modern.PokedexDemo.Form.swift | 2 +- .../Modern.PokedexDemo.MainView.swift | 1 + .../Modern.PokedexDemo.PokedexEntry.swift | 2 +- .../Modern.PokedexDemo.Service.swift | 60 +- .../Modern.PokedexDemo.Species.swift | 9 +- ...PokedexDemo.UIKit.ListViewController.swift | 28 +- Demo/Sources/Helpers/Menu/Menu.MainView.swift | 96 +-- .../Helpers/WithMainActorImmediate.swift | 31 + README.md | 21 +- Sources/AsynchronousDataTransaction.swift | 47 +- Sources/BaseDataTransaction+Importing.swift | 12 +- Sources/BaseDataTransaction.swift | 46 +- Sources/CoreStoreError.swift | 6 +- Sources/CustomSchemaMappingProvider.swift | 8 +- Sources/DataStack+Concurrency.swift | 18 +- Sources/DataStack+Reactive.swift | 4 +- Sources/DataStack+Transaction.swift | 6 +- Sources/DataStack.swift | 2 +- ...aSource.CollectionViewAdapter-AppKit.swift | 2 +- Sources/DispatchQueue+CoreStore.swift | 2 +- Sources/DynamicObject.swift | 8 +- Sources/ImportableObject.swift | 2 +- Sources/ImportableUniqueObject.swift | 8 +- ...ls.CoreStoreFetchedResultsController.swift | 4 +- Sources/Internals.Mutext.swift | 4 +- Sources/Internals.swift | 8 +- Sources/ListPublisher.swift | 6 +- Sources/ListState.swift | 49 +- Sources/NSManagedObject+Convenience.swift | 8 +- Sources/NSManagedObjectContext+Querying.swift | 12 +- .../NSPersistentStoreCoordinator+Setup.swift | 2 +- Sources/ObjectMonitor.swift | 25 +- Sources/ObjectObserver.swift | 36 +- Sources/ObjectReader.swift | 2 +- Sources/ObjectState.swift | 59 +- Sources/SQLiteStore.swift | 6 +- Sources/StorageInterface.swift | 4 +- Sources/SynchronousDataTransaction.swift | 37 ++ Sources/UnsafeDataTransaction.swift | 2 +- 51 files changed, 1083 insertions(+), 243 deletions(-) create mode 100644 CoreStoreTests/SwiftUIReaderStateTests.swift create mode 100644 Demo/Sources/Helpers/WithMainActorImmediate.swift diff --git a/CoreStoreTests/ImportTests.swift b/CoreStoreTests/ImportTests.swift index 4c98ce2..71c0e52 100644 --- a/CoreStoreTests/ImportTests.swift +++ b/CoreStoreTests/ImportTests.swift @@ -1060,17 +1060,17 @@ class ImportTests: BaseTestDataTestCase { // MARK: - TestInsertError -private struct TestInsertError: Error {} +private struct TestInsertError: Swift::Error {} // MARK: - TestUpdateError -private struct TestUpdateError: Error {} +private struct TestUpdateError: Swift::Error {} // MARK: - TestIDError -private struct TestIDError: Error {} +private struct TestIDError: Swift::Error {} // MARK: - TestEntity1 diff --git a/CoreStoreTests/SwiftUIReaderStateTests.swift b/CoreStoreTests/SwiftUIReaderStateTests.swift new file mode 100644 index 0000000..daea48b --- /dev/null +++ b/CoreStoreTests/SwiftUIReaderStateTests.swift @@ -0,0 +1,545 @@ +#if canImport(SwiftUI) && canImport(AppKit) + +import AppKit +import Combine +import SwiftUI +import XCTest + +@testable +import CoreStore + + +@MainActor +private final class ObjectPublisherModel: ObservableObject { + + @Published var objectPublisher: ObjectPublisher? + + init(_ objectPublisher: ObjectPublisher?) { + + self.objectPublisher = objectPublisher + } +} + + +@MainActor +private final class ListPublisherModel: ObservableObject { + + @Published var listPublisher: ListPublisher + + init(_ listPublisher: ListPublisher) { + + self.listPublisher = listPublisher + } +} + + +private struct SignalView: View { + + let value: Value + let report: (Value) -> Void + + + var body: some View { + + SwiftUI.Color.clear + .frame(width: 1, height: 1) + .onChange(of: self.value, initial: true) { _, value in + + self.report(value) + } + } +} + + +@MainActor +private struct ObjectStateHarness: View { + + @ObservedObject var model: ObjectPublisherModel + + let report: (String) -> Void + + @ObjectState + private var object: ObjectSnapshot? + + init( + model: ObjectPublisherModel, + report: @escaping (String) -> Void + ) { + + self.model = model + self.report = report + self._object = .init(model.objectPublisher) + } + + + var body: some View { + + SignalView( + value: self.object?.testString ?? "", + report: self.report + ) + } +} + + +@MainActor +private struct ListStateHarness: View { + + @ObservedObject var model: ListPublisherModel + + let report: (String) -> Void + + @ListState + private var list: ListSnapshot + + init( + model: ListPublisherModel, + report: @escaping (String) -> Void + ) { + + self.model = model + self.report = report + self._list = .init(model.listPublisher) + } + + + var body: some View { + + SignalView( + value: listSignature(self.list), + report: self.report + ) + } +} + + +@MainActor +private struct ObjectReaderHarness: View { + + @ObservedObject var model: ObjectPublisherModel + + let report: (String) -> Void + + + var body: some View { + + ObjectReader( + self.model.objectPublisher, + keyPath: \.testString, + content: { value in + + SignalView( + value: value ?? "", + report: self.report + ) + }, + placeholder: { + + SignalView( + value: "", + report: self.report + ) + } + ) + } +} + + +@MainActor +private struct ListReaderHarness: View { + + @ObservedObject var model: ListPublisherModel + + let report: (String) -> Void + + + var body: some View { + + ListReader(self.model.listPublisher) { list in + + SignalView( + value: listSignature(list), + report: self.report + ) + } + } +} + + +@MainActor +private func listSignature(_ list: ListSnapshot) -> String { + + let ids = list.map { + + String($0.testEntityID?.intValue ?? -1) + } + .joined(separator: ",") + return ids.isEmpty ? "" : ids +} + + +// MARK: - SwiftUIReaderStateTests + +@MainActor +final class SwiftUIReaderStateTests: BaseTestDataTestCase { + + @objc + dynamic func test_ThatObjectState_RebindsWhenPublisherChanges() { + + self.prepareStack { stack in + + self.prepareTestDataForStack(stack) + + let firstPublisher = self.objectPublisher(withID: 101, in: stack) + let secondPublisher = self.objectPublisher(withID: 102, in: stack) + let model = ObjectPublisherModel(firstPublisher) + + let initialExpectation = self.expectation(description: "initial") + let swappedExpectation = self.expectation(description: "swapped") + let finalExpectation = self.expectation(description: "final") + let staleExpectation = self.expectation(description: "stale") + staleExpectation.isInverted = true + + var didSeeInitial = false + var didSeeSwapped = false + var didSeeFinal = false + var values: [String] = [] + + let window = self.host( + ObjectStateHarness(model: model) { value in + + values.append(value) + switch value { + + case "nil:TestEntity1:1" where !didSeeInitial: + didSeeInitial = true + initialExpectation.fulfill() + + case "nil:TestEntity1:2" where !didSeeSwapped: + didSeeSwapped = true + swappedExpectation.fulfill() + + case "new-bound" where !didSeeFinal: + didSeeFinal = true + finalExpectation.fulfill() + + case "old-bound": + staleExpectation.fulfill() + + default: + break + } + } + ) + + self.wait(for: [initialExpectation], timeout: 10) + + model.objectPublisher = secondPublisher + self.wait(for: [swappedExpectation], timeout: 10) + + self.updateObjectString(withID: 101, to: "old-bound", in: stack) + self.updateObjectString(withID: 102, to: "new-bound", in: stack) + self.wait(for: [finalExpectation, staleExpectation], timeout: 10) + + XCTAssertFalse(values.contains("old-bound")) + withExtendedLifetime(window, {}) + } + } + + @objc + dynamic func test_ThatListState_RebindsWhenPublisherChanges() { + + self.prepareStack { stack in + + self.prepareTestDataForStack(stack) + + let firstPublisher = self.listPublisher(matching: true, in: stack) + let secondPublisher = self.listPublisher(matching: false, in: stack) + let model = ListPublisherModel(firstPublisher) + + let initialExpectation = self.expectation(description: "initial") + let swappedExpectation = self.expectation(description: "swapped") + let finalExpectation = self.expectation(description: "final") + let staleExpectation = self.expectation(description: "stale") + staleExpectation.isInverted = true + + var didSeeInitial = false + var didSeeSwapped = false + var didSeeFinal = false + var values: [String] = [] + + let window = self.host( + ListStateHarness(model: model) { value in + + values.append(value) + switch value { + + case "101,103,105" where !didSeeInitial: + didSeeInitial = true + initialExpectation.fulfill() + + case "102,104" where !didSeeSwapped: + didSeeSwapped = true + swappedExpectation.fulfill() + + case "102,104,108" where !didSeeFinal: + didSeeFinal = true + finalExpectation.fulfill() + + case "101,103,105,107": + staleExpectation.fulfill() + + default: + break + } + } + ) + + self.wait(for: [initialExpectation], timeout: 10) + + model.listPublisher = secondPublisher + self.wait(for: [swappedExpectation], timeout: 10) + + self.insertObject(withID: 107, string: "old-list", boolean: true, in: stack) + self.insertObject(withID: 108, string: "new-list", boolean: false, in: stack) + self.wait(for: [finalExpectation, staleExpectation], timeout: 10) + + XCTAssertFalse(values.contains("101,103,105,107")) + withExtendedLifetime(window, {}) + } + } + + @objc + dynamic func test_ThatObjectReaders_RebindAndShowCustomPlaceholders() { + + self.prepareStack { stack in + + self.prepareTestDataForStack(stack) + + let firstPublisher = self.objectPublisher(withID: 101, in: stack) + let secondPublisher = self.objectPublisher(withID: 102, in: stack) + let model = ObjectPublisherModel(firstPublisher) + + let initialExpectation = self.expectation(description: "initial") + let swappedExpectation = self.expectation(description: "swapped") + let placeholderExpectation = self.expectation(description: "placeholder") + let staleExpectation = self.expectation(description: "stale") + staleExpectation.isInverted = true + + var didSeeInitial = false + var didSeeSwapped = false + var didSeePlaceholder = false + var values: [String] = [] + + let window = self.host( + ObjectReaderHarness(model: model) { value in + + values.append(value) + switch value { + + case "nil:TestEntity1:1" where !didSeeInitial: + didSeeInitial = true + initialExpectation.fulfill() + + case "nil:TestEntity1:2" where !didSeeSwapped: + didSeeSwapped = true + swappedExpectation.fulfill() + + case "" where !didSeePlaceholder: + didSeePlaceholder = true + placeholderExpectation.fulfill() + + case "old-reader": + staleExpectation.fulfill() + + default: + break + } + } + ) + + self.wait(for: [initialExpectation], timeout: 10) + + model.objectPublisher = secondPublisher + self.wait(for: [swappedExpectation], timeout: 10) + + self.deleteObject(withID: 102, in: stack) + self.updateObjectString(withID: 101, to: "old-reader", in: stack) + self.wait(for: [placeholderExpectation, staleExpectation], timeout: 10) + + XCTAssertFalse(values.contains("old-reader")) + withExtendedLifetime(window, {}) + } + } + + @objc + dynamic func test_ThatListReaders_RebindWhenPublisherChanges() { + + self.prepareStack { stack in + + self.prepareTestDataForStack(stack) + + let firstPublisher = self.listPublisher(matching: true, in: stack) + let secondPublisher = self.listPublisher(matching: false, in: stack) + let model = ListPublisherModel(firstPublisher) + + let initialExpectation = self.expectation(description: "initial") + let swappedExpectation = self.expectation(description: "swapped") + let finalExpectation = self.expectation(description: "final") + let staleExpectation = self.expectation(description: "stale") + staleExpectation.isInverted = true + + var didSeeInitial = false + var didSeeSwapped = false + var didSeeFinal = false + var values: [String] = [] + + let window = self.host( + ListReaderHarness(model: model) { value in + + values.append(value) + switch value { + + case "101,103,105" where !didSeeInitial: + didSeeInitial = true + initialExpectation.fulfill() + + case "102,104" where !didSeeSwapped: + didSeeSwapped = true + swappedExpectation.fulfill() + + case "102,104,110" where !didSeeFinal: + didSeeFinal = true + finalExpectation.fulfill() + + case "101,103,105,109": + staleExpectation.fulfill() + + default: + break + } + } + ) + + self.wait(for: [initialExpectation], timeout: 10) + + model.listPublisher = secondPublisher + self.wait(for: [swappedExpectation], timeout: 10) + + self.insertObject(withID: 109, string: "old-reader", boolean: true, in: stack) + self.insertObject(withID: 110, string: "new-reader", boolean: false, in: stack) + self.wait(for: [finalExpectation, staleExpectation], timeout: 10) + + XCTAssertFalse(values.contains("101,103,105,109")) + withExtendedLifetime(window, {}) + } + } + + + // MARK: Private + + private func host(_ view: Content) -> NSWindow { + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 8, height: 8), + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + window.contentView = NSHostingView(rootView: view) + window.contentView?.layoutSubtreeIfNeeded() + window.displayIfNeeded() + return window + } + + private func objectPublisher( + withID identifier: Int, + in stack: DataStack + ) -> ObjectPublisher { + + let object = try! stack.fetchOne( + From(), + Where( + #keyPath(TestEntity1.testEntityID), + isEqualTo: NSNumber(value: identifier) + ) + )! + return stack.publishObject(object) + } + + private func listPublisher( + matching boolean: Bool, + in stack: DataStack + ) -> ListPublisher { + + return stack.publishList( + From(), + Where( + #keyPath(TestEntity1.testBoolean), + isEqualTo: NSNumber(value: boolean) + ), + OrderBy(.ascending(#keyPath(TestEntity1.testEntityID))) + ) + } + + private func updateObjectString( + withID identifier: Int, + to string: String, + in stack: DataStack + ) { + + try! stack.perform( + synchronous: { transaction in + + let object = try transaction.fetchOne( + From(), + Where( + #keyPath(TestEntity1.testEntityID), + isEqualTo: NSNumber(value: identifier) + ) + )! + object.testString = string + } + ) + } + + private func deleteObject( + withID identifier: Int, + in stack: DataStack + ) { + + try! stack.perform( + synchronous: { transaction in + + let object = try transaction.fetchOne( + From(), + Where( + #keyPath(TestEntity1.testEntityID), + isEqualTo: NSNumber(value: identifier) + ) + )! + transaction.delete(object) + } + ) + } + + private func insertObject( + withID identifier: Int, + string: String, + boolean: Bool, + in stack: DataStack + ) { + + try! stack.perform( + synchronous: { transaction in + + let object = transaction.create(Into()) + object.testEntityID = NSNumber(value: identifier) + object.testString = string + object.testBoolean = NSNumber(value: boolean) + } + ) + } +} + +#endif diff --git a/Demo/Demo.xcodeproj/project.pbxproj b/Demo/Demo.xcodeproj/project.pbxproj index 1f0df5e..481e526 100644 --- a/Demo/Demo.xcodeproj/project.pbxproj +++ b/Demo/Demo.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 5F035E4F300F726700E98F8F /* WithMainActorImmediate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F035E4E300F725800E98F8F /* WithMainActorImmediate.swift */; }; B531EFE724EA762D005F247D /* Menu.PlaceholderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B531EFE624EA762D005F247D /* Menu.PlaceholderView.swift */; }; B531EFE924EB5A53005F247D /* Modern.PokedexDemo.PokedexEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = B531EFE824EB5A52005F247D /* Modern.PokedexDemo.PokedexEntry.swift */; }; B531EFEB24EB5ECD005F247D /* Modern.PokedexDemo.Service.swift in Sources */ = {isa = PBXBuildFile; fileRef = B531EFEA24EB5ECD005F247D /* Modern.PokedexDemo.Service.swift */; }; @@ -38,7 +39,7 @@ B5A3916024E6925900E7E8BD /* Modern.PlacemarksDemo.MapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5A3915F24E6925900E7E8BD /* Modern.PlacemarksDemo.MapView.swift */; }; B5A3916224E697BA00E7E8BD /* Modern.PlacemarksDemo.MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5A3916124E697BA00E7E8BD /* Modern.PlacemarksDemo.MainView.swift */; }; B5A3916524E698C700E7E8BD /* Modern.PlacemarksDemo.Place.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5A3916424E698C700E7E8BD /* Modern.PlacemarksDemo.Place.swift */; }; - B5A3916B24E698F900E7E8BD /* CoreStore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B5A3916724E698F900E7E8BD /* CoreStore.framework */; }; + B5A3916B24E698F900E7E8BD /* CoreStore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B5A3916724E698F900E7E8BD /* CoreStore.framework */; platformFilter = ios; }; B5A3916C24E698F900E7E8BD /* CoreStore.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = B5A3916724E698F900E7E8BD /* CoreStore.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; B5A3917524E6990200E7E8BD /* MapKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B5A3917424E6990200E7E8BD /* MapKit.framework */; }; B5A3917724E6990700E7E8BD /* CoreLocation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B5A3917624E6990700E7E8BD /* CoreLocation.framework */; }; @@ -115,6 +116,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 5F035E4E300F725800E98F8F /* WithMainActorImmediate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WithMainActorImmediate.swift; sourceTree = ""; }; B531EFE624EA762D005F247D /* Menu.PlaceholderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Menu.PlaceholderView.swift; sourceTree = ""; }; B531EFE824EB5A52005F247D /* Modern.PokedexDemo.PokedexEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Modern.PokedexDemo.PokedexEntry.swift; sourceTree = ""; }; B531EFEA24EB5ECD005F247D /* Modern.PokedexDemo.Service.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Modern.PokedexDemo.Service.swift; sourceTree = ""; }; @@ -366,6 +368,7 @@ B5A3917A24E6A75F00E7E8BD /* Helpers */ = { isa = PBXGroup; children = ( + 5F035E4E300F725800E98F8F /* WithMainActorImmediate.swift */, B5A3917F24E787D900E7E8BD /* InstructionsView.swift */, B5E32C8F24FA41F9003F46AD /* ImageDownloader.swift */, B5A3915424E6857F00E7E8BD /* Menu */, @@ -685,6 +688,7 @@ B5A54401250487C7000DC5E3 /* Advanced.EvolutionDemo.ListView.swift in Sources */, B5A543FF250487B1000DC5E3 /* Advanced.EvolutionDemo.MainView.swift in Sources */, B5D6F209250E14AA00DF5D2F /* Advanced.EvolutionDemo.Migrator.swift in Sources */, + 5F035E4F300F726700E98F8F /* WithMainActorImmediate.swift in Sources */, B5C18F3325138700001BEFB3 /* Advanced.EvolutionDemo.ProgressView.swift in Sources */, B5D6F1F8250E07FD00DF5D2F /* Advanced.EvolutionDemo.V1.swift in Sources */, B5D6F210250E1E3200DF5D2F /* Advanced.EvolutionDemo.V1.xcdatamodeld in Sources */, diff --git a/Demo/Demo.xcodeproj/xcshareddata/xcschemes/Demo.xcscheme b/Demo/Demo.xcodeproj/xcshareddata/xcschemes/Demo.xcscheme index 5f5a183..9e26577 100644 --- a/Demo/Demo.xcodeproj/xcshareddata/xcschemes/Demo.xcscheme +++ b/Demo/Demo.xcodeproj/xcshareddata/xcschemes/Demo.xcscheme @@ -3,9 +3,23 @@ LastUpgradeVersion = "1600" version = "1.3"> + parallelizeBuildables = "NO" + buildImplicitDependencies = "NO"> + + + + , - didUpdateObject object: Classic.ColorsDemo.Palette, + didUpdateObject object: sending Classic.ColorsDemo.Palette, changedPersistentKeys: Set ) { - self.reloadPaletteInfo(object, changedKeys: changedPersistentKeys) + withMainActorImmediate { + + self.reloadPaletteInfo(object, changedKeys: changedPersistentKeys) + } } @@ -248,10 +251,11 @@ extension Classic.ColorsDemo { private dynamic func hueSliderValueDidChange(_ sender: UISlider) { let value = sender.value + let persistentID = self.palette.object?.persistentID() Classic.ColorsDemo.dataStack.perform( - asynchronous: { [weak self] (transaction) in + asynchronous: { (transaction) in - let palette = transaction.edit(self?.palette.object) + let palette = transaction.edit(persistentID) palette?.hue = value }, completion: { _ in } @@ -262,10 +266,11 @@ extension Classic.ColorsDemo { private dynamic func saturationSliderValueDidChange(_ sender: UISlider) { let value = sender.value + let persistentID = self.palette.object?.persistentID() Classic.ColorsDemo.dataStack.perform( - asynchronous: { [weak self] (transaction) in + asynchronous: { (transaction) in - let palette = transaction.edit(self?.palette.object) + let palette = transaction.edit(persistentID) palette?.saturation = value }, completion: { _ in } @@ -276,10 +281,11 @@ extension Classic.ColorsDemo { private dynamic func brightnessSliderValueDidChange(_ sender: UISlider) { let value = sender.value + let persistentID = self.palette.object?.persistentID() Classic.ColorsDemo.dataStack.perform( - asynchronous: { [weak self] (transaction) in + asynchronous: { (transaction) in - let palette = transaction.edit(self?.palette.object) + let palette = transaction.edit(persistentID) palette?.brightness = value }, completion: { _ in } diff --git a/Demo/Sources/Demos/Modern/ColorsDemo/Modern.ColorsDemo.MainView.swift b/Demo/Sources/Demos/Modern/ColorsDemo/Modern.ColorsDemo.MainView.swift index eb81d40..5b0b5ee 100644 --- a/Demo/Sources/Demos/Modern/ColorsDemo/Modern.ColorsDemo.MainView.swift +++ b/Demo/Sources/Demos/Modern/ColorsDemo/Modern.ColorsDemo.MainView.swift @@ -11,6 +11,7 @@ extension Modern.ColorsDemo { // MARK: - Modern.ColorsDemo.MainView + @MainActor struct MainView: View { // MARK: Internal diff --git a/Demo/Sources/Demos/Modern/ColorsDemo/Modern.ColorsDemo.SwiftUI.DetailView.swift b/Demo/Sources/Demos/Modern/ColorsDemo/Modern.ColorsDemo.SwiftUI.DetailView.swift index 5e74d66..8dc59dc 100644 --- a/Demo/Sources/Demos/Modern/ColorsDemo/Modern.ColorsDemo.SwiftUI.DetailView.swift +++ b/Demo/Sources/Demos/Modern/ColorsDemo/Modern.ColorsDemo.SwiftUI.DetailView.swift @@ -11,6 +11,7 @@ extension Modern.ColorsDemo.SwiftUI { // MARK: - Modern.ColorsDemo.SwiftUI.DetailView + @MainActor struct DetailView: View { /** @@ -33,6 +34,7 @@ extension Modern.ColorsDemo.SwiftUI { init(_ palette: ObjectPublisher) { + let persistentID = palette.persistentID() self._palette = .init(palette) self._hue = Binding( get: { palette.hue ?? 0 }, @@ -41,7 +43,7 @@ extension Modern.ColorsDemo.SwiftUI { Modern.ColorsDemo.dataStack.perform( asynchronous: { (transaction) in - let palette = palette.asEditable(in: transaction) + let palette = persistentID.asEditable(in: transaction) palette?.hue = percentage }, completion: { _ in } @@ -55,7 +57,7 @@ extension Modern.ColorsDemo.SwiftUI { Modern.ColorsDemo.dataStack.perform( asynchronous: { (transaction) in - let palette = palette.asEditable(in: transaction) + let palette = persistentID.asEditable(in: transaction) palette?.saturation = percentage }, completion: { _ in } @@ -69,7 +71,7 @@ extension Modern.ColorsDemo.SwiftUI { Modern.ColorsDemo.dataStack.perform( asynchronous: { (transaction) in - let palette = palette.asEditable(in: transaction) + let palette = persistentID.asEditable(in: transaction) palette?.brightness = percentage }, completion: { _ in } diff --git a/Demo/Sources/Demos/Modern/ColorsDemo/Modern.ColorsDemo.SwiftUI.ItemView.swift b/Demo/Sources/Demos/Modern/ColorsDemo/Modern.ColorsDemo.SwiftUI.ItemView.swift index ed2e044..e63f761 100644 --- a/Demo/Sources/Demos/Modern/ColorsDemo/Modern.ColorsDemo.SwiftUI.ItemView.swift +++ b/Demo/Sources/Demos/Modern/ColorsDemo/Modern.ColorsDemo.SwiftUI.ItemView.swift @@ -11,6 +11,7 @@ extension Modern.ColorsDemo.SwiftUI { // MARK: - Modern.ColorsDemo.SwiftUI.ItemView + @MainActor struct ItemView: View { /** diff --git a/Demo/Sources/Demos/Modern/ColorsDemo/Modern.ColorsDemo.SwiftUI.ListView.swift b/Demo/Sources/Demos/Modern/ColorsDemo/Modern.ColorsDemo.SwiftUI.ListView.swift index 52149d0..0a59f05 100644 --- a/Demo/Sources/Demos/Modern/ColorsDemo/Modern.ColorsDemo.SwiftUI.ListView.swift +++ b/Demo/Sources/Demos/Modern/ColorsDemo/Modern.ColorsDemo.SwiftUI.ListView.swift @@ -11,6 +11,7 @@ extension Modern.ColorsDemo.SwiftUI { // MARK: - Modern.ColorsDemo.SwiftUI.ListView + @MainActor struct ListView: View { /** @@ -81,7 +82,7 @@ extension Modern.ColorsDemo.SwiftUI { Modern.ColorsDemo.dataStack.perform( asynchronous: { transaction in - transaction.delete(objectIDs: objectIDsToDelete) + transaction.delete(persistentIDs: objectIDsToDelete) }, completion: { _ in } ) diff --git a/Demo/Sources/Demos/Modern/ColorsDemo/Modern.ColorsDemo.UIKit.DetailViewController.swift b/Demo/Sources/Demos/Modern/ColorsDemo/Modern.ColorsDemo.UIKit.DetailViewController.swift index afb1ed1..d1e3626 100644 --- a/Demo/Sources/Demos/Modern/ColorsDemo/Modern.ColorsDemo.UIKit.DetailViewController.swift +++ b/Demo/Sources/Demos/Modern/ColorsDemo/Modern.ColorsDemo.UIKit.DetailViewController.swift @@ -92,7 +92,7 @@ extension Modern.ColorsDemo.UIKit { _ monitor: ObjectMonitor, didUpdateObject object: sending Modern.ColorsDemo.Palette, changedPersistentKeys: Set, - sourceIdentifier: Any? + sourceIdentifier: (any Sendable)? ) { MainActor.assumeIsolated { @@ -253,10 +253,11 @@ extension Modern.ColorsDemo.UIKit { private dynamic func hueSliderValueDidChange(_ sender: UISlider) { let value = sender.value + let paletteID = self.palette.object?.persistentID() Modern.ColorsDemo.dataStack.perform( - asynchronous: { [weak self] (transaction) in + asynchronous: { transaction in - let palette = transaction.edit(self?.palette.object) + let palette = transaction.edit(paletteID) palette?.hue = value }, completion: { _ in } @@ -268,10 +269,11 @@ extension Modern.ColorsDemo.UIKit { private dynamic func saturationSliderValueDidChange(_ sender: UISlider) { let value = sender.value + let paletteID = self.palette.object?.persistentID() Modern.ColorsDemo.dataStack.perform( - asynchronous: { [weak self] (transaction) in + asynchronous: { transaction in - let palette = transaction.edit(self?.palette.object) + let palette = transaction.edit(paletteID) palette?.saturation = value }, completion: { _ in } @@ -283,10 +285,11 @@ extension Modern.ColorsDemo.UIKit { private dynamic func brightnessSliderValueDidChange(_ sender: UISlider) { let value = sender.value + let paletteID = self.palette.object?.persistentID() Modern.ColorsDemo.dataStack.perform( - asynchronous: { [weak self] (transaction) in + asynchronous: { transaction in - let palette = transaction.edit(self?.palette.object) + let palette = transaction.edit(paletteID) palette?.brightness = value }, completion: { _ in } @@ -294,5 +297,3 @@ extension Modern.ColorsDemo.UIKit { } } } - - diff --git a/Demo/Sources/Demos/Modern/ColorsDemo/Modern.ColorsDemo.UIKit.ListViewController.swift b/Demo/Sources/Demos/Modern/ColorsDemo/Modern.ColorsDemo.UIKit.ListViewController.swift index e82b46f..297e2b9 100644 --- a/Demo/Sources/Demos/Modern/ColorsDemo/Modern.ColorsDemo.UIKit.ListViewController.swift +++ b/Demo/Sources/Demos/Modern/ColorsDemo/Modern.ColorsDemo.UIKit.ListViewController.swift @@ -81,7 +81,7 @@ extension Modern.ColorsDemo.UIKit { self.dataStack.perform( asynchronous: { (transaction) in - transaction.delete(objectIDs: [itemID]) + transaction.delete(itemID) }, sourceIdentifier: Modern.ColorsDemo.TransactionSource.delete, completion: { _ in } diff --git a/Demo/Sources/Demos/Modern/PlacemarksDemo/Modern.PlacemarksDemo.MainView.swift b/Demo/Sources/Demos/Modern/PlacemarksDemo/Modern.PlacemarksDemo.MainView.swift index 7d5dfff..de920bf 100644 --- a/Demo/Sources/Demos/Modern/PlacemarksDemo/Modern.PlacemarksDemo.MainView.swift +++ b/Demo/Sources/Demos/Modern/PlacemarksDemo/Modern.PlacemarksDemo.MainView.swift @@ -14,6 +14,7 @@ extension Modern.PlacemarksDemo { // MARK: - Modern.PlacemarksDemo.MainView + @MainActor struct MainView: View { /** @@ -21,10 +22,11 @@ extension Modern.PlacemarksDemo { */ private func demoAsynchronousTransaction(coordinate: CLLocationCoordinate2D) { + let persistentID = self.$place?.persistentID() Modern.PlacemarksDemo.dataStack.perform( asynchronous: { (transaction) in - let place = self.$place?.asEditable(in: transaction) + let place = persistentID?.asEditable(in: transaction) place?.annotation = .init(coordinate: coordinate) }, completion: { _ in } @@ -107,7 +109,7 @@ extension Modern.PlacemarksDemo { return } let geocoded = await self.geocoder.geocode(place: place) - guard self.place?.objectID() == place.objectID() else { + guard self.place?.persistentID() == place.persistentID() else { return } diff --git a/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.Form.swift b/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.Form.swift index 9399083..f693c38 100644 --- a/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.Form.swift +++ b/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.Form.swift @@ -34,7 +34,7 @@ extension Modern.PokedexDemo { // MARK: ImportableObject - typealias ImportSource = Dictionary + typealias ImportSource = Dictionary // MARK: ImportableUniqueObject diff --git a/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.MainView.swift b/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.MainView.swift index a25b7d2..7abb5b3 100644 --- a/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.MainView.swift +++ b/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.MainView.swift @@ -11,6 +11,7 @@ extension Modern.PokedexDemo { // MARK: - Modern.PokedexDemo.MainView + @MainActor struct MainView: View { // MARK: Internal diff --git a/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.PokedexEntry.swift b/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.PokedexEntry.swift index 89c02a1..033977b 100644 --- a/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.PokedexEntry.swift +++ b/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.PokedexEntry.swift @@ -36,7 +36,7 @@ extension Modern.PokedexDemo { // MARK: ImportableObject - typealias ImportSource = (index: Int, json: Dictionary) + typealias ImportSource = (index: Int, json: Dictionary) func didInsert(from source: ImportSource, in transaction: BaseDataTransaction) throws { diff --git a/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.Service.swift b/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.Service.swift index 37cd65d..0729f13 100644 --- a/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.Service.swift +++ b/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.Service.swift @@ -21,7 +21,7 @@ extension Modern.PokedexDemo { /** ⭐️ Sample 1: Importing a list of JSON data into `ImportableUniqueObject`s whose `ImportSource` are tuples */ - private static func importPokedexEntries(from data: Data) async throws { + private static func importPokedexEntries(from data: Data) async throws(Modern.PokedexDemo.Service.Error) { do { @@ -43,7 +43,7 @@ extension Modern.PokedexDemo { } catch { - throw self.mapError(error) + throw self.mapError(.init(error)) } } @@ -51,31 +51,31 @@ extension Modern.PokedexDemo { ⭐️ Sample 2: Importing a single JSON data into an `ImportableUniqueObject` whose `ImportSource` is a JSON `Dictionary` */ private static func importSpecies( - for detailsObjectID: NSManagedObjectID, + for detailsPersistentID: DynamicObjectID, from data: Data ) async throws -> ObjectSnapshot { - let speciesObjectID = try await Modern.PokedexDemo.dataStack.async.perform { transaction -> NSManagedObjectID in + let speciesPersistentID = try await Modern.PokedexDemo.dataStack.async.perform { transaction in - let json: Dictionary = try self.parseJSON( - try JSONSerialization.jsonObject(with: data, options: []) - ) - guard - let species = try transaction.importUniqueObject( - Into(), - source: json + let json: Dictionary = try self.parseJSON( + try JSONSerialization.jsonObject(with: data, options: []) ) - else { + guard + let species = try transaction.importUniqueObject( + Into(), + source: .init(json: json) + ) + else { throw Modern.PokedexDemo.Service.Error.unexpected } transaction - .edit(Into(), detailsObjectID)? + .edit(Into(), detailsPersistentID)? .species = species - return species.objectID() + return species.persistentID() } guard - let species: Modern.PokedexDemo.Species = Modern.PokedexDemo.dataStack.fetchExisting(speciesObjectID), + let species: Modern.PokedexDemo.Species = Modern.PokedexDemo.dataStack.fetchExisting(speciesPersistentID), let snapshot = species.asSnapshot() else { @@ -88,7 +88,7 @@ extension Modern.PokedexDemo { ⭐️ Sample 3: Importing a list of JSON data into `ImportableUniqueObject`s whose `ImportSource` are JSON `Dictionary`s */ private static func importForms( - for detailsObjectID: NSManagedObjectID, + for detailsPersistentID: DynamicObjectID, from dataArray: [Data] ) async throws { @@ -110,13 +110,13 @@ extension Modern.PokedexDemo { throw Modern.PokedexDemo.Service.Error.unexpected } transaction - .edit(Into(), detailsObjectID)? + .edit(Into(), detailsPersistentID)? .forms = forms } } catch { - throw self.mapError(error) + throw self.mapError(.init(error)) } } @@ -195,8 +195,8 @@ extension Modern.PokedexDemo { if let species = details.$species?.snapshot { self.fetchFormsIfNeeded( - key: species.$id, - detailsObjectID: details.objectID(), + key: String(species.$id), + detailsPersistentID: details.persistentID(), species: species ) return @@ -207,7 +207,7 @@ extension Modern.PokedexDemo { return } let speciesURL = pokedexEntry.$speciesURL - let detailsObjectID = details.objectID() + let detailsPersistentID = details.persistentID() self.detailTasks[key] = Task { [weak self] in guard let self else { @@ -220,7 +220,7 @@ extension Modern.PokedexDemo { } await self.fetchSpecies( key: key, - detailsObjectID: detailsObjectID, + detailsPersistentID: detailsPersistentID, speciesURL: speciesURL ) } @@ -289,7 +289,7 @@ extension Modern.PokedexDemo { private func fetchSpecies( key: String, - detailsObjectID: NSManagedObjectID, + detailsPersistentID: DynamicObjectID, speciesURL: URL ) async { @@ -299,7 +299,7 @@ extension Modern.PokedexDemo { try Task.checkCancellation() let species = try await Self.importSpecies( - for: detailsObjectID, + for: detailsPersistentID, from: data ) guard species.$details?.snapshot?.$forms.isEmpty == true else { @@ -307,7 +307,7 @@ extension Modern.PokedexDemo { return } await self.fetchForms( - detailsObjectID: detailsObjectID, + detailsPersistentID: detailsPersistentID, formsURLs: species.$formsURLs ) } @@ -331,7 +331,7 @@ extension Modern.PokedexDemo { private func fetchFormsIfNeeded( key: String, - detailsObjectID: NSManagedObjectID, + detailsPersistentID: DynamicObjectID, species: ObjectSnapshot ) { @@ -356,14 +356,14 @@ extension Modern.PokedexDemo { self.detailTasks.removeValue(forKey: key) } await self.fetchForms( - detailsObjectID: detailsObjectID, + detailsPersistentID: detailsPersistentID, formsURLs: formsURLs ) } } private func fetchForms( - detailsObjectID: NSManagedObjectID, + detailsPersistentID: DynamicObjectID, formsURLs: [URL] ) async { @@ -378,7 +378,7 @@ extension Modern.PokedexDemo { dataArray.append(data) } try await Self.importForms( - for: detailsObjectID, + for: detailsPersistentID, from: dataArray ) } @@ -408,7 +408,7 @@ extension Modern.PokedexDemo { case networkError(URLError) case parseError(expected: Any.Type, actual: Any.Type, file: String) case saveError(CoreStoreError) - case otherError(Swift.Error) + case otherError(Swift::Error) case unexpected } } diff --git a/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.Species.swift b/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.Species.swift index e1ed4e5..c689649 100644 --- a/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.Species.swift +++ b/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.Species.swift @@ -65,7 +65,10 @@ extension Modern.PokedexDemo { // MARK: ImportableObject - typealias ImportSource = Dictionary + struct ImportSource: @unchecked Sendable { + + let json: Dictionary + } // MARK: ImportableUniqueObject @@ -82,14 +85,14 @@ extension Modern.PokedexDemo { static func uniqueID(from source: ImportSource, in transaction: BaseDataTransaction) throws -> UniqueIDType? { - let json = source + let json = source.json return try Modern.PokedexDemo.Service.parseJSON(json["id"]) } func update(from source: ImportSource, in transaction: BaseDataTransaction) throws { typealias Service = Modern.PokedexDemo.Service - let json = source + let json = source.json self.name = try Service.parseJSON(json["name"]) self.weight = try Service.parseJSON(json["weight"]) diff --git a/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.UIKit.ListViewController.swift b/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.UIKit.ListViewController.swift index cf2e716..e1df82d 100644 --- a/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.UIKit.ListViewController.swift +++ b/Demo/Sources/Demos/Modern/PokedexDemo/Modern.PokedexDemo.UIKit.ListViewController.swift @@ -50,7 +50,7 @@ extension Modern.PokedexDemo.UIKit { fatalError() } - deinit { + isolated deinit { self.listPublisher.removeObserver(self) } @@ -94,13 +94,25 @@ extension Modern.PokedexDemo.UIKit { private func startObservingList() { - self.listPublisher.addObserver(self) { (listPublisher) in - - self.dataSource.apply( - listPublisher.snapshot, - animatingDifferences: true - ) - } + self.listPublisher.addObserver( + self, + notifyInitial: false, + { [weak self] (listPublisher) in + + let snapshot = listPublisher.snapshot + withMainActorImmediate { + + guard let self else { + + return + } + self.dataSource.apply( + snapshot, + animatingDifferences: true + ) + } + } + ) self.dataSource.apply( self.listPublisher.snapshot, animatingDifferences: false diff --git a/Demo/Sources/Helpers/Menu/Menu.MainView.swift b/Demo/Sources/Helpers/Menu/Menu.MainView.swift index 4373f77..b7406e5 100644 --- a/Demo/Sources/Helpers/Menu/Menu.MainView.swift +++ b/Demo/Sources/Helpers/Menu/Menu.MainView.swift @@ -13,52 +13,70 @@ extension Menu { // MARK: - Menu.MainView struct MainView: View { - - @State - private var selection: Menu.Route? - + + @Environment(\.horizontalSizeClass) + private var horizontalSizeClass // MARK: View + @ViewBuilder var body: some View { - - NavigationSplitView( - sidebar: { - - List(selection: self.$selection) { - - ForEach(Menu.Section.allCases, id: \.self) { section in - - Section(section.rawValue) { - - ForEach(section.routes) { route in - - Menu.ItemView( - title: route.title, - subtitle: route.subtitle, - isEnabled: route.isEnabled - ) - .tag(route as Menu.Route?) - .disabled(!route.isEnabled) - } - } - } - } - .navigationTitle("CoreStore Demos") - .listStyle(.sidebar) - }, - detail: { - - if let selection = self.selection { - - selection.destination - } - else { - + + if self.horizontalSizeClass == .compact { + NavigationStack { + self.menuList + } + } + else { + NavigationSplitView( + sidebar: { + self.menuList + }, + detail: { Menu.PlaceholderView() } + ) + } + } + + + // MARK: Private + + @ViewBuilder + private var menuList: some View { + List { + + ForEach(Menu.Section.allCases, id: \.self) { section in + + SwiftUI.Section( + content: { + + ForEach(section.routes) { route in + + NavigationLink( + destination: { + route.destination + }, + label: { + Menu.ItemView( + title: route.title, + subtitle: route.subtitle, + isEnabled: route.isEnabled + ) + } + ) + .disabled(!route.isEnabled) + } + }, + header: { + + Text(section.rawValue) + } + ) } - ) + } + .navigationTitle("CoreStore Demos") + .listStyle(.sidebar) } } } diff --git a/Demo/Sources/Helpers/WithMainActorImmediate.swift b/Demo/Sources/Helpers/WithMainActorImmediate.swift new file mode 100644 index 0000000..2f3aad3 --- /dev/null +++ b/Demo/Sources/Helpers/WithMainActorImmediate.swift @@ -0,0 +1,31 @@ +// +// WithMainActorImmediate.swift +// Demo +// +// Created by John Estropia on 2026/07/21. +// + +import Foundation + + +// MARK: - withMainActorImmediate + +func withMainActorImmediate( + _ task: @MainActor @Sendable @escaping () -> Void +) { + + if #available(iOS 26.0, *) { + Task.immediate(operation: task) + } + else if Thread.isMainThread { + + MainActor.assumeIsolated { + + task() + } + } + else { + + Task.init(operation: task) + } +} diff --git a/README.md b/README.md index eb07208..b18a87b 100644 --- a/README.md +++ b/README.md @@ -2201,6 +2201,23 @@ var body: some View { ) } ``` +The `placeholder:` overload also works with `keyPath:` projections: +```swift +let person: ObjectPublisher + +var body: some View { + ObjectReader( + self.person, + keyPath: \.fullName, + content: { fullName in + Text("Name: \(fullName)") + }, + placeholder: { + Text("Record not found") + } + ) +} +``` ### SwiftUI Property Wrappers @@ -2234,7 +2251,8 @@ If a `ListPublisher` instance is not available yet, the fetch can be done inline From() .sectionBy(\.age) .where(\.isMember == true) - .orderBy(.ascending(\.lastName)) + .orderBy(.ascending(\.lastName)), + in: Globals.dataStack ) var people: ListSnapshot @@ -2509,4 +2527,3 @@ I'd love to hear about apps using CoreStore. Send me a message and I'll welcome # License CoreStore is released under an MIT license. See the [LICENSE](https://raw.githubusercontent.com/JohnEstropia/CoreStore/master/LICENSE) file for more information - diff --git a/Sources/AsynchronousDataTransaction.swift b/Sources/AsynchronousDataTransaction.swift index 4e5546c..eafe416 100644 --- a/Sources/AsynchronousDataTransaction.swift +++ b/Sources/AsynchronousDataTransaction.swift @@ -80,19 +80,21 @@ public nonisolated final class AsynchronousDataTransaction: BaseDataTransaction } /** - Returns an editable proxy of a specified `NSManagedObject` or `CoreStoreObject`. + Returns an editable proxy of the object with the specified `DynamicObjectID`. - - parameter persistentID: the `DynamicObjectID` pertaining ot the `NSManagedObject` or `CoreStoreObject` type to be edited + - parameter into: an `Into` clause specifying the entity type + - parameter persistentID: the `DynamicObjectID` for the object to be edited - returns: an editable proxy for the specified `NSManagedObject` or `CoreStoreObject`. */ - public override func edit( + public override func edit( _ persistentID: DynamicObjectID? ) -> O? { Internals.assert( !self.isCommitted, - "Attempted to update an entity for \(Internals.typeName(persistentID)) from an already committed \(Internals.typeName(self))." + "Attempted to update an entity of type \(Internals.typeName(persistentID)) from an already committed \(Internals.typeName(self))." ) + return super.edit(persistentID) } @@ -114,6 +116,26 @@ public nonisolated final class AsynchronousDataTransaction: BaseDataTransaction return super.edit(object) } + /** + Returns an editable proxy of the object with the specified `DynamicObjectID`. + + - parameter into: an `Into` clause specifying the entity type + - parameter persistentID: the `DynamicObjectID` for the object to be edited + - returns: an editable proxy for the specified `NSManagedObject` or `CoreStoreObject`. + */ + public override func edit( + _ into: Into, + _ persistentID: DynamicObjectID + ) -> O? { + + Internals.assert( + !self.isCommitted, + "Attempted to update an entity of type \(Internals.typeName(into.entityClass)) from an already committed \(Internals.typeName(self))." + ) + + return super.edit(into, persistentID) + } + /** Returns an editable proxy of the object with the specified `NSManagedObjectID`. @@ -133,6 +155,23 @@ public nonisolated final class AsynchronousDataTransaction: BaseDataTransaction return super.edit(into, objectID) } + + /** + Deletes the objects with the specified `NSManagedObjectID`s. + + - parameter objectIDs: the `NSManagedObjectID`s of the objects to delete + */ + public override func delete( + persistentIDs: S + ) where S.Iterator.Element == DynamicObjectID { + + Internals.assert( + !self.isCommitted, + "Attempted to delete an entities from an already committed \(Internals.typeName(self))." + ) + + super.delete(persistentIDs: persistentIDs) + } /** Deletes the objects with the specified `NSManagedObjectID`s. diff --git a/Sources/BaseDataTransaction+Importing.swift b/Sources/BaseDataTransaction+Importing.swift index 0a7a82c..615064b 100644 --- a/Sources/BaseDataTransaction+Importing.swift +++ b/Sources/BaseDataTransaction+Importing.swift @@ -42,7 +42,7 @@ extension BaseDataTransaction { public func importObject( _ into: Into, source: O.ImportSource - ) throws(any Swift.Error) -> O? { + ) throws(any Swift::Error) -> O? { Internals.assert( self.isRunningInAllowedQueue(), @@ -73,7 +73,7 @@ extension BaseDataTransaction { public func importObject( _ object: O, source: O.ImportSource - ) throws(any Swift.Error) { + ) throws(any Swift::Error) { Internals.assert( self.isRunningInAllowedQueue(), @@ -102,7 +102,7 @@ extension BaseDataTransaction { public func importObjects( _ into: Into, sourceArray: S - ) throws(any Swift.Error) -> [O] where S.Iterator.Element == O.ImportSource { + ) throws(any Swift::Error) -> [O] where S.Iterator.Element == O.ImportSource { Internals.assert( self.isRunningInAllowedQueue(), @@ -139,7 +139,7 @@ extension BaseDataTransaction { public func importUniqueObject( _ into: Into, source: O.ImportSource - ) throws(any Swift.Error) -> O? { + ) throws(any Swift::Error) -> O? { Internals.assert( self.isRunningInAllowedQueue(), @@ -194,8 +194,8 @@ extension BaseDataTransaction { sourceArray: S, preProcess: @escaping ( _ mapping: [O.UniqueIDType: O.ImportSource] - ) throws(any Swift.Error) -> [O.UniqueIDType: O.ImportSource] = { $0 } - ) throws(any Swift.Error) -> [O] where S.Iterator.Element == O.ImportSource { + ) throws(any Swift::Error) -> [O.UniqueIDType: O.ImportSource] = { $0 } + ) throws(any Swift::Error) -> [O] where S.Iterator.Element == O.ImportSource { Internals.assert( self.isRunningInAllowedQueue(), diff --git a/Sources/BaseDataTransaction.swift b/Sources/BaseDataTransaction.swift index ee65c71..a890c1e 100644 --- a/Sources/BaseDataTransaction.swift +++ b/Sources/BaseDataTransaction.swift @@ -121,7 +121,7 @@ public /*abstract*/ class BaseDataTransaction { - parameter persistentID: the `DynamicObjectID` pertaining ot the `NSManagedObject` or `CoreStoreObject` type to be edited - returns: an editable proxy for the specified `NSManagedObject` or `CoreStoreObject`. */ - public func edit( + public func edit( _ persistentID: DynamicObjectID? ) -> O? { @@ -157,6 +157,30 @@ public /*abstract*/ class BaseDataTransaction { return self.context.fetchExisting(object) } + /** + Returns an editable proxy of the object with the specified `DynamicObjectID`. + + - parameter into: an `Into` clause specifying the entity type + - parameter persistentID: the `DynamicObjectID` for the object to be edited + - returns: an editable proxy for the specified `NSManagedObject` or `CoreStoreObject`. + */ + public func edit( + _ into: Into, + _ persistentID: DynamicObjectID + ) -> O? { + + Internals.assert( + self.isRunningInAllowedQueue(), + "Attempted to update an entity of type \(Internals.typeName(into.entityClass)) outside its designated queue." + ) + Internals.assert( + into.inferStoreIfPossible + || (into.configuration ?? DataStack.defaultConfigurationName) == persistentID.managedObjectID.persistentStore?.configurationName, + "Attempted to update an entity of type \(Internals.typeName(into.entityClass)) but the specified persistent store do not match the `NSManagedObjectID`." + ) + return self.fetchExisting(persistentID) + } + /** Returns an editable proxy of the object with the specified `NSManagedObjectID`. @@ -180,6 +204,26 @@ public /*abstract*/ class BaseDataTransaction { ) return self.fetchExisting(objectID) } + + /** + Deletes the objects with the specified `DynamicObjectID`s. + + - parameter persistentIDs: the `DynamicObjectID`s of the objects to delete + */ + public func delete( + persistentIDs: S + ) where S.Iterator.Element == DynamicObjectID { + + Internals.assert( + self.isRunningInAllowedQueue(), + "Attempted to delete an entity outside its designated queue." + ) + let context = self.context + persistentIDs.forEach { + + context.fetchExisting($0).map({ context.delete($0.cs_toRaw()) }) + } + } /** Deletes the objects with the specified `NSManagedObjectID`s. diff --git a/Sources/CoreStoreError.swift b/Sources/CoreStoreError.swift index 136b920..d657fc7 100644 --- a/Sources/CoreStoreError.swift +++ b/Sources/CoreStoreError.swift @@ -32,7 +32,7 @@ import Foundation /** All errors thrown from CoreStore are expressed in `CoreStoreError` enum values. */ -public enum CoreStoreError: Error, CustomNSError, Hashable, Sendable { +public enum CoreStoreError: Swift::Error, CustomNSError, Hashable, Sendable { /** A failure occured because of an unknown error. @@ -67,7 +67,7 @@ public enum CoreStoreError: Error, CustomNSError, Hashable, Sendable { /** The transaction was terminated by a user-thrown `Error`. */ - case userError(error: Error) + case userError(error: Swift::Error) /** The transaction was cancelled by the user. @@ -82,7 +82,7 @@ public enum CoreStoreError: Error, CustomNSError, Hashable, Sendable { /** Casts any `Error` to a known `CoreStoreError`, or wraps it in `CoreStoreError.internalError(NSError:)`. */ - public init(_ error: Error?) { + public init(_ error: Swift::Error?) { guard let error = error else { diff --git a/Sources/CustomSchemaMappingProvider.swift b/Sources/CustomSchemaMappingProvider.swift index 3fdd599..4937ccd 100644 --- a/Sources/CustomSchemaMappingProvider.swift +++ b/Sources/CustomSchemaMappingProvider.swift @@ -108,7 +108,7 @@ public final class CustomSchemaMappingProvider: Hashable, SchemaMappingProvider public typealias Transformer = @Sendable ( _ sourceObject: UnsafeSourceObject, _ createDestinationObject: () -> UnsafeDestinationObject - ) throws(any Swift.Error) -> Void + ) throws(any Swift::Error) -> Void /** The `CustomMapping.inferredTransformation` method can be used directly as the `transformer` if the changes can be inferred (i.e. lightweight). @@ -116,7 +116,7 @@ public final class CustomSchemaMappingProvider: Hashable, SchemaMappingProvider public static func inferredTransformation( _ sourceObject: UnsafeSourceObject, _ createDestinationObject: () -> UnsafeDestinationObject - ) throws(any Swift.Error) { + ) throws(any Swift::Error) { let destinationObject = createDestinationObject() destinationObject.enumerateAttributes { (attribute, sourceAttribute) in @@ -556,7 +556,7 @@ public final class CustomSchemaMappingProvider: Hashable, SchemaMappingProvider forSource sInstance: NSManagedObject, in mapping: NSEntityMapping, manager: NSMigrationManager - ) throws(any Swift.Error) { + ) throws(any Swift::Error) { let userInfo = mapping.userInfo! let transformer = userInfo[CustomEntityMigrationPolicy.UserInfoKey.transformer]! as! CustomMapping.Transformer @@ -588,7 +588,7 @@ public final class CustomSchemaMappingProvider: Hashable, SchemaMappingProvider forDestination dInstance: NSManagedObject, in mapping: NSEntityMapping, manager: NSMigrationManager - ) throws(any Swift.Error) { + ) throws(any Swift::Error) { try super.createRelationships(forDestination: dInstance, in: mapping, manager: manager) } diff --git a/Sources/DataStack+Concurrency.swift b/Sources/DataStack+Concurrency.swift index f6c50fc..a0add5c 100644 --- a/Sources/DataStack+Concurrency.swift +++ b/Sources/DataStack+Concurrency.swift @@ -85,7 +85,7 @@ extension DataStack.AsyncNamespace { */ public func addStorage( _ storage: T - ) async throws(any Swift.Error) -> T { + ) async throws(any Swift::Error) -> T { return try await Internals.withCheckedThrowingContinuation { continuation in @@ -118,7 +118,7 @@ extension DataStack.AsyncNamespace { */ public func addStorage( _ storage: T - ) -> AsyncThrowingStream, any Swift.Error> { + ) -> AsyncThrowingStream, any Swift::Error> { return .init( bufferingPolicy: .unbounded, @@ -184,7 +184,7 @@ extension DataStack.AsyncNamespace { public func importObject( _ into: Into, source: O.ImportSource - ) async throws(any Swift.Error) -> O? { + ) async throws(any Swift::Error) -> O? { return try await Internals.withCheckedThrowingContinuation { continuation in @@ -226,7 +226,7 @@ extension DataStack.AsyncNamespace { public func importObject( _ object: O, source: O.ImportSource - ) async throws(any Swift.Error) -> O? { + ) async throws(any Swift::Error) -> O? { nonisolated(unsafe) let object = object return try await Internals.withCheckedThrowingContinuation { continuation in @@ -274,7 +274,7 @@ extension DataStack.AsyncNamespace { public func importUniqueObject( _ into: Into, source: O.ImportSource - ) async throws(any Swift.Error) -> O? { + ) async throws(any Swift::Error) -> O? { return try await Internals.withCheckedThrowingContinuation { continuation in @@ -324,8 +324,8 @@ extension DataStack.AsyncNamespace { sourceArray: S, preProcess: @escaping @Sendable ( _ mapping: [O.UniqueIDType: O.ImportSource] - ) throws(any Swift.Error) -> [O.UniqueIDType: O.ImportSource] = { $0 } - ) async throws(any Swift.Error) -> [O] + ) throws(any Swift::Error) -> [O.UniqueIDType: O.ImportSource] = { $0 } + ) async throws(any Swift::Error) -> [O] where S.Iterator.Element == O.ImportSource { return try await Internals.withCheckedThrowingContinuation { continuation in @@ -374,8 +374,8 @@ extension DataStack.AsyncNamespace { - throws: A `CoreStoreError` value indicating the failure reason */ public func perform( - _ asynchronous: @escaping @Sendable (AsynchronousDataTransaction) throws(any Swift.Error) -> Output - ) async throws(any Swift.Error) -> Output { + _ asynchronous: @escaping @Sendable (AsynchronousDataTransaction) throws(any Swift::Error) -> Output + ) async throws(any Swift::Error) -> Output { return try await Internals.withCheckedThrowingContinuation { continuation in diff --git a/Sources/DataStack+Reactive.swift b/Sources/DataStack+Reactive.swift index f415124..780fe88 100644 --- a/Sources/DataStack+Reactive.swift +++ b/Sources/DataStack+Reactive.swift @@ -326,7 +326,7 @@ extension DataStack.ReactiveNamespace { sourceArray: S, preProcess: @escaping @Sendable ( _ mapping: [O.UniqueIDType: O.ImportSource] - ) throws(any Swift.Error) -> [O.UniqueIDType: O.ImportSource] = { $0 } + ) throws(any Swift::Error) -> [O.UniqueIDType: O.ImportSource] = { $0 } ) -> Future<[O], CoreStoreError> where S.Iterator.Element == O.ImportSource { return .init { (promise) in @@ -379,7 +379,7 @@ extension DataStack.ReactiveNamespace { public func perform( _ asynchronous: @escaping @Sendable ( _ transaction: AsynchronousDataTransaction - ) throws(any Swift.Error) -> Output + ) throws(any Swift::Error) -> Output ) -> Future { return .init { (promise) in diff --git a/Sources/DataStack+Transaction.swift b/Sources/DataStack+Transaction.swift index 68946fd..14fffcb 100644 --- a/Sources/DataStack+Transaction.swift +++ b/Sources/DataStack+Transaction.swift @@ -41,7 +41,7 @@ extension DataStack { public func perform( asynchronous task: @escaping @Sendable ( _ transaction: AsynchronousDataTransaction - ) throws(any Swift.Error) -> T, + ) throws(any Swift::Error) -> T, sourceIdentifier: (any Sendable)? = nil, completion: @escaping @MainActor @Sendable (AsynchronousDataTransaction.Result) -> Void ) { @@ -65,7 +65,7 @@ extension DataStack { public func perform( asynchronous task: @escaping @Sendable ( _ transaction: AsynchronousDataTransaction - ) throws(any Swift.Error) -> T, + ) throws(any Swift::Error) -> T, sourceIdentifier: (any Sendable)? = nil, success: @escaping @MainActor @Sendable (sending T) -> Void, failure: @escaping @MainActor @Sendable (CoreStoreError) -> Void @@ -123,7 +123,7 @@ extension DataStack { public func perform( synchronous task: ( _ transaction: SynchronousDataTransaction - ) throws(any Swift.Error) -> T, + ) throws(any Swift::Error) -> T, waitForAllObservers: Bool = true, sourceIdentifier: (any Sendable)? = nil ) throws(CoreStoreError) -> T { diff --git a/Sources/DataStack.swift b/Sources/DataStack.swift index 5c5c237..bfc6b74 100644 --- a/Sources/DataStack.swift +++ b/Sources/DataStack.swift @@ -535,7 +535,7 @@ public final class DataStack: Equatable, Sendable { _ storage: StorageInterface, finalURL: URL?, finalStoreOptions: [AnyHashable: Any]? - ) throws(any Swift.Error) -> NSPersistentStore { + ) throws(any Swift::Error) -> NSPersistentStore { let persistentStore = try self.coordinator.addPersistentStore( ofType: type(of: storage).storeType, diff --git a/Sources/DiffableDataSource.CollectionViewAdapter-AppKit.swift b/Sources/DiffableDataSource.CollectionViewAdapter-AppKit.swift index e7a488c..6cf9d25 100644 --- a/Sources/DiffableDataSource.CollectionViewAdapter-AppKit.swift +++ b/Sources/DiffableDataSource.CollectionViewAdapter-AppKit.swift @@ -128,7 +128,7 @@ extension DiffableDataSource { itemForRepresentedObjectAt indexPath: IndexPath ) -> NSCollectionViewItem { - guard let objectID = self.itemID(for: indexPath) else { + guard let objectID: NSManagedObjectID = self.itemID(for: indexPath) else { Internals.abort("Object at \(Internals.typeName(IndexPath.self)) \(indexPath) already removed from list") } diff --git a/Sources/DispatchQueue+CoreStore.swift b/Sources/DispatchQueue+CoreStore.swift index 6e20400..90187d3 100644 --- a/Sources/DispatchQueue+CoreStore.swift +++ b/Sources/DispatchQueue+CoreStore.swift @@ -107,7 +107,7 @@ extension DispatchQueue { @nonobjc @inline(__always) internal func cs_barrierSync( - _ closure: () throws(any Swift.Error) -> T + _ closure: () throws(any Swift::Error) -> T ) rethrows -> T { return try self.sync(flags: .barrier) { try autoreleasepool(invoking: closure) } diff --git a/Sources/DynamicObject.swift b/Sources/DynamicObject.swift index 8641747..2448ce0 100644 --- a/Sources/DynamicObject.swift +++ b/Sources/DynamicObject.swift @@ -142,7 +142,9 @@ extension NSManagedObject: DynamicObject { } @_spi(Internals) - public class func cs_fromRaw(object: NSManagedObject) -> Self { + public class func cs_fromRaw( + object: NSManagedObject + ) -> Self { #if swift(>=5.9) return unsafeDowncast(object, to: self) @@ -309,7 +311,9 @@ extension CoreStoreObject { } @_spi(Internals) - public class func cs_fromRaw(object: NSManagedObject) -> Self { + public class func cs_fromRaw( + object: NSManagedObject + ) -> Self { if let coreStoreObject = object.coreStoreObject { diff --git a/Sources/ImportableObject.swift b/Sources/ImportableObject.swift index 2f7f3b9..c8756a5 100644 --- a/Sources/ImportableObject.swift +++ b/Sources/ImportableObject.swift @@ -80,7 +80,7 @@ public protocol ImportableObject: DynamicObject { func didInsert( from source: ImportSource, in transaction: BaseDataTransaction - ) throws(any Swift.Error) + ) throws(any Swift::Error) } diff --git a/Sources/ImportableUniqueObject.swift b/Sources/ImportableUniqueObject.swift index 1ebd5cd..ebe5f91 100644 --- a/Sources/ImportableUniqueObject.swift +++ b/Sources/ImportableUniqueObject.swift @@ -105,7 +105,7 @@ public protocol ImportableUniqueObject: ImportableObject, Hashable { static func uniqueID( from source: ImportSource, in transaction: BaseDataTransaction - ) throws(any Swift.Error) -> UniqueIDType? + ) throws(any Swift::Error) -> UniqueIDType? /** Implements the actual importing of data from `source`. This method is called just after the object is created and assigned its unique ID as returned from `uniqueID(from:in:)`. Implementers should pull values from `source` and assign them to the receiver's attributes. Note that throwing from this method will cause subsequent imports that are part of the same `importUniqueObjects(:sourceArray:)` call to be cancelled. The default implementation simply calls `update(from:in:)`. @@ -116,7 +116,7 @@ public protocol ImportableUniqueObject: ImportableObject, Hashable { func didInsert( from source: ImportSource, in transaction: BaseDataTransaction - ) throws(any Swift.Error) + ) throws(any Swift::Error) /** Implements the actual importing of data from `source`. This method is called just after the existing object is fetched using its unique ID. Implementers should pull values from `source` and assign them to the receiver's attributes. Note that throwing from this method will cause subsequent imports that are part of the same `importUniqueObjects(:sourceArray:)` call to be cancelled. @@ -127,7 +127,7 @@ public protocol ImportableUniqueObject: ImportableObject, Hashable { func update( from source: ImportSource, in transaction: BaseDataTransaction - ) throws(any Swift.Error) + ) throws(any Swift::Error) } @@ -180,7 +180,7 @@ extension ImportableUniqueObject { public func didInsert( from source: Self.ImportSource, in transaction: BaseDataTransaction - ) throws(any Swift.Error) { + ) throws(any Swift::Error) { try self.update(from: source, in: transaction) } diff --git a/Sources/Internals.CoreStoreFetchedResultsController.swift b/Sources/Internals.CoreStoreFetchedResultsController.swift index 7abbbce..7e16ebb 100644 --- a/Sources/Internals.CoreStoreFetchedResultsController.swift +++ b/Sources/Internals.CoreStoreFetchedResultsController.swift @@ -77,7 +77,7 @@ extension Internals { } @nonobjc - internal func performFetchFromSpecifiedStores() throws(any Swift.Error) { + internal func performFetchFromSpecifiedStores() throws(any Swift::Error) { try self.reapplyAffectedStores(self.typedFetchRequest, self.managedObjectContext) try self.performFetch() @@ -106,6 +106,6 @@ extension Internals { private let reapplyAffectedStores: ( _ fetchRequest: Internals.CoreStoreFetchRequest, _ context: NSManagedObjectContext - ) throws(any Swift.Error) -> Void + ) throws(any Swift::Error) -> Void } } diff --git a/Sources/Internals.Mutext.swift b/Sources/Internals.Mutext.swift index d322129..c4b59fa 100644 --- a/Sources/Internals.Mutext.swift +++ b/Sources/Internals.Mutext.swift @@ -46,7 +46,7 @@ extension Internals { borrowing func withLock( _ body: (inout sending Value) throws(E) -> sending Result ) throws(E) -> sending Result - where E: Error, Result: ~Copyable { + where E: Swift::Error, Result: ~Copyable { let storage = self.storage storage.lock() @@ -60,7 +60,7 @@ extension Internals { borrowing func withLockUnchecked( _ body: (inout sending Value) throws(E) -> Result ) throws(E) -> sending Result - where E: Error { + where E: Swift::Error { let storage = self.storage storage.lock() diff --git a/Sources/Internals.swift b/Sources/Internals.swift index eeab985..882279b 100644 --- a/Sources/Internals.swift +++ b/Sources/Internals.swift @@ -133,8 +133,8 @@ internal enum Internals { @inline(__always) internal static func autoreleasepool( - _ closure: () throws(any Swift.Error) -> T - ) throws(any Swift.Error) -> T { + _ closure: () throws(any Swift::Error) -> T + ) throws(any Swift::Error) -> T { return try ObjectiveC.autoreleasepool(invoking: closure) } @@ -142,8 +142,8 @@ internal enum Internals { @inline(__always) internal static func withCheckedThrowingContinuation( function: String = #function, - _ body: (CheckedContinuation) -> Void - ) async throws(any Swift.Error) -> sending T { + _ body: (CheckedContinuation) -> Void + ) async throws(any Swift::Error) -> sending T { return try await _Concurrency.withCheckedThrowingContinuation( function: function, diff --git a/Sources/ListPublisher.swift b/Sources/ListPublisher.swift index 1520847..9afcd36 100644 --- a/Sources/ListPublisher.swift +++ b/Sources/ListPublisher.swift @@ -189,7 +189,7 @@ public final class ListPublisher: Hashable { public func refetch( _ clauseChain: B, sourceIdentifier: (any Sendable)? = nil - ) throws(any Swift.Error) where B.ObjectType == O { + ) throws(any Swift::Error) where B.ObjectType == O { try self.refetch( from: clauseChain.from, @@ -218,7 +218,7 @@ public final class ListPublisher: Hashable { public func refetch( _ clauseChain: B, sourceIdentifier: (any Sendable)? = nil - ) throws(any Swift.Error) where B.ObjectType == O { + ) throws(any Swift::Error) where B.ObjectType == O { try self.refetch( from: clauseChain.from, @@ -347,7 +347,7 @@ public final class ListPublisher: Hashable { sectionBy: SectionBy?, applyFetchClauses: @escaping (_ fetchRequest: Internals.CoreStoreFetchRequest) -> Void, sourceIdentifier: (any Sendable)? - ) throws(any Swift.Error) { + ) throws(any Swift::Error) { let (newFetchedResultsController, newFetchedResultsControllerDelegate) = Self.recreateFetchedResultsController( context: self.fetchedResultsController.managedObjectContext, diff --git a/Sources/ListState.swift b/Sources/ListState.swift index 9cc3ba2..050064a 100644 --- a/Sources/ListState.swift +++ b/Sources/ListState.swift @@ -35,7 +35,7 @@ import SwiftUI A property wrapper type that can read `ListPublisher` changes. */ @propertyWrapper -public struct ListState: DynamicProperty { +public struct ListState: @MainActor DynamicProperty { // MARK: Public @@ -70,6 +70,7 @@ public struct ListState: DynamicProperty { _ listPublisher: ListPublisher ) { + self.sourceListPublisher = listPublisher self._observer = .init(wrappedValue: .init(listPublisher: listPublisher)) } @@ -338,9 +339,11 @@ public struct ListState: DynamicProperty { // MARK: DynamicProperty + @MainActor public mutating func update() { self._observer.update() + self.observer.rebind(to: self.sourceListPublisher) } @@ -349,13 +352,15 @@ public struct ListState: DynamicProperty { @State private var observer: Observer + private let sourceListPublisher: ListPublisher + // MARK: - Observer @MainActor private final class Observer: Observation.Observable { - let listPublisher: ListPublisher + private(set) var listPublisher: ListPublisher nonisolated var items: ListSnapshot { @@ -377,8 +382,35 @@ public struct ListState: DynamicProperty { self.listPublisher = listPublisher self.current = .init(listPublisher.snapshot) + self.attachObserver() + } + + isolated deinit { - listPublisher.addObserver(self) { [weak self] (listPublisher) in + self.listPublisher.removeObserver(self) + } + + func rebind(to listPublisher: ListPublisher) { + + guard self.listPublisher !== listPublisher else { + + return + } + self.listPublisher.removeObserver(self) + self.listPublisher = listPublisher + self.items = listPublisher.snapshot + self.attachObserver() + } + + + // MARK: Private + + private let registrar = ObservationRegistrar() + private let current: Internals.Mutex> + + private func attachObserver() { + + self.listPublisher.addObserver(self) { [weak self] listPublisher in guard let self = self else { @@ -387,17 +419,6 @@ public struct ListState: DynamicProperty { self.items = listPublisher.snapshot } } - - isolated deinit { - - self.listPublisher.removeObserver(self) - } - - - // MARK: Private - - private let registrar = ObservationRegistrar() - private let current: Internals.Mutex> } } diff --git a/Sources/NSManagedObject+Convenience.swift b/Sources/NSManagedObject+Convenience.swift index 3fce1a7..bc934d9 100644 --- a/Sources/NSManagedObject+Convenience.swift +++ b/Sources/NSManagedObject+Convenience.swift @@ -106,7 +106,7 @@ extension NSManagedObject { @nonobjc @inline(__always) public func getValue( forKvcKey kvcKey: KeyPathString, - didGetValue: (Any?) throws(any Swift.Error) -> T + didGetValue: (Any?) throws(any Swift::Error) -> T ) rethrows -> T { self.willAccessValue(forKey: kvcKey) @@ -128,8 +128,8 @@ extension NSManagedObject { @nonobjc @inline(__always) public func getValue( forKvcKey kvcKey: KeyPathString, - willGetValue: () throws(any Swift.Error) -> Void, - didGetValue: (Any?) throws(any Swift.Error) -> T + willGetValue: () throws(any Swift::Error) -> Void, + didGetValue: (Any?) throws(any Swift::Error) -> T ) rethrows -> T { self.willAccessValue(forKey: kvcKey) @@ -196,7 +196,7 @@ extension NSManagedObject { public func setValue( _ value: T, forKvcKey KVCKey: KeyPathString, - willSetValue: (T) throws(any Swift.Error) -> Any?, + willSetValue: (T) throws(any Swift::Error) -> Any?, didSetValue: (Any?) -> Void = { _ in } ) rethrows { diff --git a/Sources/NSManagedObjectContext+Querying.swift b/Sources/NSManagedObjectContext+Querying.swift index eac7a16..531dacf 100644 --- a/Sources/NSManagedObjectContext+Querying.swift +++ b/Sources/NSManagedObjectContext+Querying.swift @@ -76,6 +76,14 @@ extension NSManagedObjectContext: FetchableSource, QueryableSource { } } + @nonobjc + public func fetchExisting( + _ persistentID: DynamicObjectID + ) -> O? { + + return self.fetchExisting(persistentID.managedObjectID) + } + @nonobjc public func fetchExisting( _ objectID: NSManagedObjectID @@ -102,10 +110,10 @@ extension NSManagedObjectContext: FetchableSource, QueryableSource { @nonobjc public func fetchExisting( - _ objectIDs: S + _ persistentIDs: S ) -> [O] where S.Iterator.Element == DynamicObjectID { - return objectIDs.compactMap({ self.fetchExisting($0.managedObjectID) }) + return persistentIDs.compactMap({ self.fetchExisting($0.managedObjectID) }) } @nonobjc diff --git a/Sources/NSPersistentStoreCoordinator+Setup.swift b/Sources/NSPersistentStoreCoordinator+Setup.swift index 8b6c8e0..7ffa26c 100644 --- a/Sources/NSPersistentStoreCoordinator+Setup.swift +++ b/Sources/NSPersistentStoreCoordinator+Setup.swift @@ -49,7 +49,7 @@ extension NSPersistentStoreCoordinator { @nonobjc internal func performSynchronously( - _ closure: @Sendable () throws(any Swift.Error) -> T + _ closure: @Sendable () throws(any Swift::Error) -> T ) throws(CoreStoreError) -> T { do { diff --git a/Sources/ObjectMonitor.swift b/Sources/ObjectMonitor.swift index cc994e1..ae183a0 100644 --- a/Sources/ObjectMonitor.swift +++ b/Sources/ObjectMonitor.swift @@ -72,32 +72,36 @@ public final class ObjectMonitor: Hashable, ObjectRepresentati - parameter observer: an `ObjectObserver` to send change notifications to */ @MainActor - public func addObserver(_ observer: U) where U.ObjectEntityType == O { + public func addObserver(_ observer: U) + where U.ObjectEntityType == O { self.unregisterObserver(observer) self.registerObserver( observer, willChangeObject: { (observer, monitor, object) in + nonisolated(unsafe) let sending = object observer.objectMonitor( monitor, - willUpdateObject: object, + willUpdateObject: sending, sourceIdentifier: monitor.context.saveMetadata?.sourceIdentifier ) }, didDeleteObject: { (observer, monitor, object) in + nonisolated(unsafe) let sending = object observer.objectMonitor( monitor, - didDeleteObject: object, + didDeleteObject: sending, sourceIdentifier: monitor.context.saveMetadata?.sourceIdentifier ) }, didUpdateObject: { (observer, monitor, object, changedPersistentKeys) in + nonisolated(unsafe) let sending = object observer.objectMonitor( monitor, - didUpdateObject: object, + didUpdateObject: sending, changedPersistentKeys: changedPersistentKeys, sourceIdentifier: monitor.context.saveMetadata?.sourceIdentifier ) @@ -113,7 +117,8 @@ public final class ObjectMonitor: Hashable, ObjectRepresentati - parameter observer: an `ObjectObserver` to unregister notifications to */ @MainActor - public func removeObserver(_ observer: U) where U.ObjectEntityType == O { + public func removeObserver(_ observer: U) + where U.ObjectEntityType == O { self.unregisterObserver(observer) } @@ -412,11 +417,13 @@ public final class ObjectMonitor: Hashable, ObjectRepresentati object: self, closure: { [weak self] (note) in - guard let self = self, + guard + let self = self, let userInfo = note.userInfo, - let object = userInfo[String(describing: NSManagedObject.self)] as! NSManagedObject? else { - - return + let object = userInfo[String(describing: NSManagedObject.self)] as! NSManagedObject? + else { + + return } callback(self, O.cs_fromRaw(object: object)) } diff --git a/Sources/ObjectObserver.swift b/Sources/ObjectObserver.swift index cfe5aab..2162d81 100644 --- a/Sources/ObjectObserver.swift +++ b/Sources/ObjectObserver.swift @@ -53,8 +53,8 @@ public protocol ObjectObserver: AnyObject, Sendable { */ func objectMonitor( _ monitor: ObjectMonitor, - willUpdateObject object: ObjectEntityType, - sourceIdentifier: Any? + willUpdateObject object: sending ObjectEntityType, + sourceIdentifier: (any Sendable)? ) /** @@ -66,7 +66,7 @@ public protocol ObjectObserver: AnyObject, Sendable { */ func objectMonitor( _ monitor: ObjectMonitor, - willUpdateObject object: ObjectEntityType + willUpdateObject object: sending ObjectEntityType ) /** @@ -80,9 +80,9 @@ public protocol ObjectObserver: AnyObject, Sendable { */ func objectMonitor( _ monitor: ObjectMonitor, - didUpdateObject object: ObjectEntityType, + didUpdateObject object: sending ObjectEntityType, changedPersistentKeys: Set, - sourceIdentifier: Any? + sourceIdentifier: (any Sendable)? ) /** @@ -95,7 +95,7 @@ public protocol ObjectObserver: AnyObject, Sendable { */ func objectMonitor( _ monitor: ObjectMonitor, - didUpdateObject object: ObjectEntityType, + didUpdateObject object: sending ObjectEntityType, changedPersistentKeys: Set ) @@ -109,8 +109,8 @@ public protocol ObjectObserver: AnyObject, Sendable { */ func objectMonitor( _ monitor: ObjectMonitor, - didDeleteObject object: ObjectEntityType, - sourceIdentifier: Any? + didDeleteObject object: sending ObjectEntityType, + sourceIdentifier: (any Sendable)? ) /** @@ -122,7 +122,7 @@ public protocol ObjectObserver: AnyObject, Sendable { */ func objectMonitor( _ monitor: ObjectMonitor, - didDeleteObject object: ObjectEntityType + didDeleteObject object: sending ObjectEntityType ) } @@ -133,8 +133,8 @@ extension ObjectObserver { public func objectMonitor( _ monitor: ObjectMonitor, - willUpdateObject object: ObjectEntityType, - sourceIdentifier: Any? + willUpdateObject object: sending ObjectEntityType, + sourceIdentifier: (any Sendable)? ) { self.objectMonitor( @@ -145,14 +145,14 @@ extension ObjectObserver { public func objectMonitor( _ monitor: ObjectMonitor, - willUpdateObject object: ObjectEntityType + willUpdateObject object: sending ObjectEntityType ) {} public func objectMonitor( _ monitor: ObjectMonitor, - didUpdateObject object: ObjectEntityType, + didUpdateObject object: sending ObjectEntityType, changedPersistentKeys: Set, - sourceIdentifier: Any? + sourceIdentifier: (any Sendable)? ) { self.objectMonitor( @@ -164,14 +164,14 @@ extension ObjectObserver { public func objectMonitor( _ monitor: ObjectMonitor, - didUpdateObject object: ObjectEntityType, + didUpdateObject object: sending ObjectEntityType, changedPersistentKeys: Set ) {} public func objectMonitor( _ monitor: ObjectMonitor, - didDeleteObject object: ObjectEntityType, - sourceIdentifier: Any? + didDeleteObject object: sending ObjectEntityType, + sourceIdentifier: (any Sendable)? ) { self.objectMonitor( @@ -182,6 +182,6 @@ extension ObjectObserver { public func objectMonitor( _ monitor: ObjectMonitor, - didDeleteObject object: ObjectEntityType + didDeleteObject object: sending ObjectEntityType ) {} } diff --git a/Sources/ObjectReader.swift b/Sources/ObjectReader.swift index 6231dec..fe74ab9 100644 --- a/Sources/ObjectReader.swift +++ b/Sources/ObjectReader.swift @@ -106,7 +106,7 @@ public struct ObjectReader, Value>, @ViewBuilder content: @escaping (Value) -> Content, @ViewBuilder placeholder: @escaping () -> Placeholder - ) where Placeholder == EmptyView { + ) { self._object = .init(objectPublisher) self.content = { diff --git a/Sources/ObjectState.swift b/Sources/ObjectState.swift index bf6156d..8f072a6 100644 --- a/Sources/ObjectState.swift +++ b/Sources/ObjectState.swift @@ -35,7 +35,7 @@ import SwiftUI A property wrapper type that can read `ObjectPublisher` changes. */ @propertyWrapper -public struct ObjectState: DynamicProperty { +public struct ObjectState: @MainActor DynamicProperty { // MARK: Public @@ -65,6 +65,7 @@ public struct ObjectState: DynamicProperty { @MainActor public init(_ objectPublisher: ObjectPublisher?) { + self.sourceObjectPublisher = objectPublisher self._observer = .init(wrappedValue: .init(objectPublisher: objectPublisher)) } @@ -86,9 +87,11 @@ public struct ObjectState: DynamicProperty { // MARK: DynamicProperty + @MainActor public mutating func update() { self._observer.update() + self.observer.rebind(to: self.sourceObjectPublisher) } @@ -97,13 +100,15 @@ public struct ObjectState: DynamicProperty { @State private var observer: Observer + private let sourceObjectPublisher: ObjectPublisher? + // MARK: - Observer @MainActor private final class Observer: Observation.Observable { - let objectPublisher: ObjectPublisher? + private(set) var objectPublisher: ObjectPublisher? nonisolated var item: ObjectSnapshot? { @@ -122,21 +127,28 @@ public struct ObjectState: DynamicProperty { } init(objectPublisher: ObjectPublisher?) { - - guard - let dataStack = objectPublisher?.cs_dataStack(), - let objectPublisher = objectPublisher?.asPublisher(in: dataStack) - else { - - self.objectPublisher = nil - self.current = .init(nil) + + self.objectPublisher = nil + self.current = .init(nil) + self.rebind(to: objectPublisher) + } + + isolated deinit { + + self.objectPublisher?.removeObserver(self) + } + + func rebind(to objectPublisher: ObjectPublisher?) { + + let objectPublisher = Self.canonicalPublisher(for: objectPublisher) + guard self.objectPublisher != objectPublisher else { + return } - + self.objectPublisher?.removeObserver(self) self.objectPublisher = objectPublisher - self.current = .init(objectPublisher.snapshot) - - objectPublisher.addObserver(self) { [weak self] (objectPublisher) in + self.item = objectPublisher?.snapshot + objectPublisher?.addObserver(self) { [weak self] objectPublisher in guard let self = self else { @@ -146,16 +158,25 @@ public struct ObjectState: DynamicProperty { } } - isolated deinit { - - self.objectPublisher?.removeObserver(self) - } - // MARK: Private private let registrar = ObservationRegistrar() private let current: Internals.Mutex?> + + private static func canonicalPublisher( + for objectPublisher: ObjectPublisher? + ) -> ObjectPublisher? { + + guard + let objectPublisher = objectPublisher, + let dataStack = objectPublisher.cs_dataStack() + else { + + return nil + } + return objectPublisher.asPublisher(in: dataStack) + } } } diff --git a/Sources/SQLiteStore.swift b/Sources/SQLiteStore.swift index ccb339f..61f69ed 100644 --- a/Sources/SQLiteStore.swift +++ b/Sources/SQLiteStore.swift @@ -236,7 +236,7 @@ public final class SQLiteStore: LocalStorage { @_spi(Internals) public func cs_finalizeStorageAndWait( soureModelHint: NSManagedObjectModel - ) throws(any Swift.Error) { + ) throws(any Swift::Error) { _ = try withExtendedLifetime(NSPersistentStoreCoordinator(managedObjectModel: soureModelHint)) { (coordinator: NSPersistentStoreCoordinator) in @@ -259,12 +259,12 @@ public final class SQLiteStore: LocalStorage { public func cs_eraseStorageAndWait( metadata: [String: Any], soureModelHint: NSManagedObjectModel? - ) throws(any Swift.Error) { + ) throws(any Swift::Error) { func deleteFiles( storeURL: URL, extraFiles: [String] = [] - ) throws(any Swift.Error) { + ) throws(any Swift::Error) { let fileManager = FileManager.default let extraFiles: [String] = [ diff --git a/Sources/StorageInterface.swift b/Sources/StorageInterface.swift index de929a9..1ac74f6 100644 --- a/Sources/StorageInterface.swift +++ b/Sources/StorageInterface.swift @@ -151,7 +151,7 @@ public protocol LocalStorage: StorageInterface { @_spi(Internals) func cs_finalizeStorageAndWait( soureModelHint: NSManagedObjectModel - ) throws(any Swift.Error) + ) throws(any Swift::Error) /** Called by the `DataStack` to perform actual deletion of the store file from disk. **Do not call directly!** The `sourceModel` argument is a hint for the existing store's model version. Implementers can use the `sourceModel` to perform necessary store operations. (SQLite stores for example, can convert WAL journaling mode to DELETE before deleting) @@ -160,7 +160,7 @@ public protocol LocalStorage: StorageInterface { func cs_eraseStorageAndWait( metadata: [String: Any], soureModelHint: NSManagedObjectModel? - ) throws(any Swift.Error) + ) throws(any Swift::Error) } extension LocalStorage { diff --git a/Sources/SynchronousDataTransaction.swift b/Sources/SynchronousDataTransaction.swift index a219a51..f20e395 100644 --- a/Sources/SynchronousDataTransaction.swift +++ b/Sources/SynchronousDataTransaction.swift @@ -103,6 +103,26 @@ public nonisolated final class SynchronousDataTransaction: BaseDataTransaction { return super.edit(object) } + /** + Returns an editable proxy of the object with the specified `DynamicObjectID`. + + - parameter into: an `Into` clause specifying the entity type + - parameter persistentID: the `DynamicObjectID` for the object to be edited + - returns: an editable proxy for the specified `NSManagedObject` or `CoreStoreObject`. + */ + public override func edit( + _ into: Into, + _ persistentID: DynamicObjectID + ) -> O? { + + Internals.assert( + !self.isCommitted, + "Attempted to update an entity of type \(Internals.typeName(into.entityClass)) from an already committed \(Internals.typeName(self))." + ) + + return super.edit(into, persistentID) + } + /** Returns an editable proxy of the object with the specified `NSManagedObjectID`. @@ -122,6 +142,23 @@ public nonisolated final class SynchronousDataTransaction: BaseDataTransaction { return super.edit(into, objectID) } + + /** + Deletes the objects with the specified `NSManagedObjectID`s. + + - parameter objectIDs: the `NSManagedObjectID`s of the objects to delete + */ + public override func delete( + persistentIDs: S + ) where S.Iterator.Element == DynamicObjectID { + + Internals.assert( + !self.isCommitted, + "Attempted to delete an entities from an already committed \(Internals.typeName(self))." + ) + + super.delete(persistentIDs: persistentIDs) + } /** Deletes the objects with the specified `NSManagedObjectID`s. diff --git a/Sources/UnsafeDataTransaction.swift b/Sources/UnsafeDataTransaction.swift index 1452a15..b6f2356 100644 --- a/Sources/UnsafeDataTransaction.swift +++ b/Sources/UnsafeDataTransaction.swift @@ -114,7 +114,7 @@ public final class UnsafeDataTransaction: BaseDataTransaction, @unchecked Sendab - throws: an error thrown from `closure`, or an error thrown by Core Data (usually validation errors or conflict errors) */ public func flush( - closure: () throws(any Swift.Error) -> Void + closure: () throws(any Swift::Error) -> Void ) rethrows { try closure()