From d450301f91f05554c6c26a6056dea8a1c40cc502 Mon Sep 17 00:00:00 2001 From: leogdion Date: Fri, 11 Oct 2024 13:13:02 -0400 Subject: [PATCH 1/4] Improving API (#9) --- .swift-format | 18 +-- .swiftlint.yml | 129 +++++++++++++++ .../AccentColor.colorset/Contents.json | 11 ++ .../AppIcon.appiconset/Contents.json | 58 +++++++ Example/Sources/Assets.xcassets/Contents.json | 6 + Example/Sources/ContentObject.swift | 133 ++++++++++++++++ Example/Sources/ContentView.swift | 81 ++++++++++ .../Sources/DataThespianExample.entitlements | 10 ++ Example/Sources/DataThespianExampleApp.swift | 37 +++++ Example/Sources/Item.swift | 18 +++ Example/Sources/ItemModel.swift | 28 ++++ .../Preview Assets.xcassets/Contents.json | 6 + Example/Support/.gitkeep | 0 Example/Support/Info.plist | 24 +++ Mintfile | 4 +- Package.swift | 75 +++++---- Scripts/lint.sh | 28 +++- Sources/DataThespian/Assert.swift | 8 +- Sources/DataThespian/BackgroundDatabase.swift | 88 +++++------ Sources/DataThespian/DataMonitor.swift | 20 +-- Sources/DataThespian/Database+Extras.swift | 145 +++++++++++++++++ .../DataThespian/Database+ModelContext.swift | 78 +++++++++ Sources/DataThespian/Database.swift | 149 +----------------- .../DatabaseChangePublicist.swift | 8 +- .../DatabaseChangePublicistKey.swift | 2 +- Sources/DataThespian/DatabaseChangeSet.swift | 3 +- Sources/DataThespian/DatabaseChangeType.swift | 7 +- Sources/DataThespian/DatabaseKey.swift | 56 +------ Sources/DataThespian/FetchDescriptor.swift | 7 +- .../DataThespian/ManagedObjectMetadata.swift | 6 +- .../{ModelID.swift => Model.swift} | 22 ++- ...tainer.swift => ModelActor+Database.swift} | 50 ++---- Sources/DataThespian/ModelActorDatabase.swift | 103 +++++------- .../DataThespian/ModelContext+Extension.swift | 82 ++++++++++ Sources/DataThespian/ModelContext.swift | 13 +- Sources/DataThespian/NSManagedObjectID.swift | 67 +++++--- Sources/DataThespian/Notification.swift | 7 +- .../DataThespian/NotificationDataUpdate.swift | 11 +- Sources/DataThespian/PublishingAgent.swift | 53 ++++--- Sources/DataThespian/PublishingRegister.swift | 14 +- .../DataThespian/RegistrationCollection.swift | 28 ++-- .../{Logging.swift => ThespianLogging.swift} | 6 +- .../DataThespianTests/DataThespianTests.swift | 7 +- project.yml | 33 ++++ 44 files changed, 1218 insertions(+), 521 deletions(-) create mode 100644 .swiftlint.yml create mode 100644 Example/Sources/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 Example/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 Example/Sources/Assets.xcassets/Contents.json create mode 100644 Example/Sources/ContentObject.swift create mode 100644 Example/Sources/ContentView.swift create mode 100644 Example/Sources/DataThespianExample.entitlements create mode 100644 Example/Sources/DataThespianExampleApp.swift create mode 100644 Example/Sources/Item.swift create mode 100644 Example/Sources/ItemModel.swift create mode 100644 Example/Sources/Preview Content/Preview Assets.xcassets/Contents.json create mode 100644 Example/Support/.gitkeep create mode 100644 Example/Support/Info.plist create mode 100644 Sources/DataThespian/Database+Extras.swift create mode 100644 Sources/DataThespian/Database+ModelContext.swift rename Sources/DataThespian/{ModelID.swift => Model.swift} (71%) rename Sources/DataThespian/{ModelContainer.swift => ModelActor+Database.swift} (57%) create mode 100644 Sources/DataThespian/ModelContext+Extension.swift rename Sources/DataThespian/{Logging.swift => ThespianLogging.swift} (98%) diff --git a/.swift-format b/.swift-format index 4f562bf..5c31a3e 100644 --- a/.swift-format +++ b/.swift-format @@ -6,11 +6,11 @@ "spaces" : 2 }, "indentConditionalCompilationBlocks" : true, - "indentSwitchCaseLabels" : true, - "lineBreakAroundMultilineExpressionChainComponents" : true, - "lineBreakBeforeControlFlowKeywords" : true, - "lineBreakBeforeEachArgument" : true, - "lineBreakBeforeEachGenericRequirement" : true, + "indentSwitchCaseLabels" : false, + "lineBreakAroundMultilineExpressionChainComponents" : false, + "lineBreakBeforeControlFlowKeywords" : false, + "lineBreakBeforeEachArgument" : false, + "lineBreakBeforeEachGenericRequirement" : false, "lineLength" : 100, "maximumBlankLines" : 1, "multiElementCollectionTrailingCommas" : true, @@ -20,7 +20,7 @@ ] }, "prioritizeKeepingFunctionOutputTogether" : false, - "respectsExistingLineBreaks" : false, + "respectsExistingLineBreaks" : true, "rules" : { "AllPublicDeclarationsHaveDocumentation" : true, "AlwaysUseLiteralForEmptyCollectionInit" : false, @@ -29,7 +29,7 @@ "BeginDocumentationCommentWithOneLineSummary" : false, "DoNotUseSemicolons" : true, "DontRepeatTypeInStaticProperties" : true, - "FileScopedDeclarationPrivacy" : true, + "FileScopedDeclarationPrivacy" : false, "FullyIndirectEnum" : true, "GroupNumericLiterals" : true, "IdentifiersMustBeASCII" : true, @@ -42,7 +42,7 @@ "NoCasesWithOnlyFallthrough" : true, "NoEmptyTrailingClosureParentheses" : true, "NoLabelsInCasePatterns" : true, - "NoLeadingUnderscores" : false, + "NoLeadingUnderscores" : true, "NoParensAroundConditions" : true, "NoPlaygroundLiterals" : true, "NoVoidReturnOnFunctionSignature" : true, @@ -67,4 +67,4 @@ "spacesAroundRangeFormationOperators" : false, "tabWidth" : 2, "version" : 1 -} +} \ No newline at end of file diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..38f6ff1 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,129 @@ +opt_in_rules: + - array_init + - closure_body_length + - closure_end_indentation + - closure_spacing + - collection_alignment + - conditional_returns_on_newline + - contains_over_filter_count + - contains_over_filter_is_empty + - contains_over_first_not_nil + - contains_over_range_nil_comparison + - convenience_type + - discouraged_object_literal + - discouraged_optional_boolean + - empty_collection_literal + - empty_count + - empty_string + - empty_xctest_method + - enum_case_associated_values_count + - expiring_todo + - explicit_acl + - explicit_init + - explicit_top_level_acl + # - fallthrough + - fatal_error_message + - file_name + - file_name_no_space + - file_types_order + - first_where + - flatmap_over_map_reduce + - force_unwrapping +# - function_default_parameter_at_end + - ibinspectable_in_extension + - identical_operands + - implicit_return + - implicitly_unwrapped_optional + - indentation_width + - joined_default_parameter + - last_where + - legacy_multiple + - legacy_random + - literal_expression_end_indentation + - lower_acl_than_parent +# - missing_docs + - modifier_order + - multiline_arguments + - multiline_arguments_brackets + - multiline_function_chains + - multiline_literal_brackets + - multiline_parameters + - nimble_operator + - nslocalizedstring_key + - nslocalizedstring_require_bundle + - number_separator + - object_literal + - operator_usage_whitespace + - optional_enum_case_matching + - overridden_super_call + - override_in_extension + - pattern_matching_keywords + - prefer_self_type_over_type_of_self + - prefer_zero_over_explicit_init + - private_action + - private_outlet + - prohibited_interface_builder + - prohibited_super_call + - quick_discouraged_call + - quick_discouraged_focused_test + - quick_discouraged_pending_test + - reduce_into + - redundant_nil_coalescing + - redundant_type_annotation + - required_enum_case + - single_test_class + - sorted_first_last + - sorted_imports + - static_operator + - strong_iboutlet + - toggle_bool +# - trailing_closure + - type_contents_order + - unavailable_function + - unneeded_parentheses_in_closure_argument + - unowned_variable_capture + - untyped_error_in_catch + - vertical_parameter_alignment_on_call + - vertical_whitespace_closing_braces + - vertical_whitespace_opening_braces + - xct_specific_matcher + - yoda_condition +analyzer_rules: + - unused_import + - unused_declaration +cyclomatic_complexity: + - 6 + - 12 +file_length: + warning: 225 + error: 300 +function_body_length: + - 50 + - 76 +function_parameter_count: 8 +line_length: + - 108 + - 200 +closure_body_length: + - 50 + - 60 +identifier_name: + excluded: + - id + - no +excluded: + - DerivedData + - .build +indentation_width: + indentation_width: 2 +file_name: + severity: error +fatal_error_message: + severity: error +disabled_rules: + - nesting + - implicit_getter + - switch_case_alignment + - closure_parameter_position + - trailing_comma + - opening_brace \ No newline at end of file diff --git a/Example/Sources/Assets.xcassets/AccentColor.colorset/Contents.json b/Example/Sources/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/Example/Sources/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Example/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json b/Example/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..3f00db4 --- /dev/null +++ b/Example/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,58 @@ +{ + "images" : [ + { + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Example/Sources/Assets.xcassets/Contents.json b/Example/Sources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Example/Sources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Example/Sources/ContentObject.swift b/Example/Sources/ContentObject.swift new file mode 100644 index 0000000..19e159b --- /dev/null +++ b/Example/Sources/ContentObject.swift @@ -0,0 +1,133 @@ +// +// ContentObject.swift +// DataThespian +// +// Created by Leo Dion on 10/10/24. +// + +import Combine +import DataThespian +import Foundation +import SwiftData + +@Observable +@MainActor +internal class ContentObject { + internal let databaseChangePublisher = PassthroughSubject() + private var databaseChangeCancellable: AnyCancellable? + private var databaseChangeSubscription: AnyCancellable? + private var database: (any Database)? + internal private(set) var items = [ItemModel]() + internal var selectedItemsID: Set = [] + private var newItem: AnyCancellable? + internal var error: (any Error)? + + internal var selectedItems: [ItemModel] { + let selectedItemsID = self.selectedItemsID + let items: [ItemModel] + do { + items = try self.items.filter( + #Predicate { + selectedItemsID.contains($0.id) + } + ) + } catch { + assertionFailure("Unable to filter selected items: \(error.localizedDescription)") + self.error = error + items = [] + } + // assert(items.count == selectedItemsID.count) + return items + } + + internal init() { + self.databaseChangeSubscription = self.databaseChangePublisher.sink { _ in + self.beginUpdateItems() + } + } + + 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() + } + } + + private func beginUpdateItems() { + Task { + do { + try await self.updateItems() + } catch { + self.error = error + } + } + } + + private func updateItems() async throws { + guard let database else { + return + } + self.items = try await database.withModelContext({ modelContext in + let items = try modelContext.fetch(FetchDescriptor()) + return items.map(ItemModel.init) + }) + } + + internal func initialize( + withDatabase database: any Database, databaseChangePublisher: DatabaseChangePublicist + ) { + self.database = database + self.databaseChangeCancellable = databaseChangePublisher(id: "contentView") + .subscribe(self.databaseChangePublisher) + self.beginUpdateItems() + } + + internal func deleteSelectedItems() { + let models = self.selectedItems.map { + Model(persistentIdentifier: $0.id) + } + self.deleteItems(models) + } + internal func deleteItems(offsets: IndexSet) { + let models = + offsets + .compactMap { items[$0].id } + .map(Model.init(persistentIdentifier:)) + + assert(models.count == offsets.count) + + self.deleteItems(models) + } + + internal func deleteItems(_ models: [Model]) { + guard let database else { + return + } + Task { + try await Self.deleteModels(models, from: database) + } + } + + internal func addItem(withDate date: Date = .init()) { + guard let database else { + return + } + Task { + try await database.withModelContext { modelContext in + let newItem = Item(timestamp: date) + modelContext.insert(newItem) + dump(newItem.persistentModelID) + try modelContext.save() + } + } + } +} diff --git a/Example/Sources/ContentView.swift b/Example/Sources/ContentView.swift new file mode 100644 index 0000000..9635d21 --- /dev/null +++ b/Example/Sources/ContentView.swift @@ -0,0 +1,81 @@ +// +// ContentView.swift +// DataThespianExample +// +// Created by Leo Dion on 10/10/24. +// + +import Combine +import DataThespian +import SwiftData +import SwiftUI + +internal struct ContentView: View { + @State private var object = ContentObject() + @Environment(\.database) private var database + @Environment(\.databaseChangePublicist) private var databaseChangePublisher + + internal var body: some View { + NavigationSplitView { + List(selection: self.$object.selectedItemsID) { + ForEach(object.items) { item in + Text(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard)) + } + .onDelete(perform: object.deleteItems) + } + .navigationSplitViewColumnWidth(min: 200, ideal: 220) + .toolbar { + ToolbarItem { + Button(action: addItem) { + Label("Add Item", systemImage: "plus") + } + } + ToolbarItem { + Button(action: object.deleteSelectedItems) { + Label("Delete Selected Items", systemImage: "trash") + } + } + } + } detail: { + let selectedItems = object.selectedItems + 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))") + } else { + Text("Select an item") + } + }.onAppear { + self.object.initialize( + withDatabase: database, + databaseChangePublisher: databaseChangePublisher + ) + } + } + + private func addItem() { + self.addItem(withDate: .init()) + } + private func addItem(withDate date: Date) { + self.object.addItem(withDate: .init()) + } +} + +#Preview { + let databaseChangePublicist = DatabaseChangePublicist(dbWatcher: DataMonitor.shared) + let config = ModelConfiguration(isStoredInMemoryOnly: true) + + // swift-format-ignore: NeverUseForceTry + // swiftlint:disable:next force_try + let modelContainer = try! ModelContainer(for: Item.self, configurations: config) + + let backgroundDatabase = BackgroundDatabase(modelContainer: modelContainer) { + let context = ModelContext($0) + context.autosaveEnabled = true + return context + } + + ContentView() + .environment(\.databaseChangePublicist, databaseChangePublicist) + .database(backgroundDatabase) +} diff --git a/Example/Sources/DataThespianExample.entitlements b/Example/Sources/DataThespianExample.entitlements new file mode 100644 index 0000000..18aff0c --- /dev/null +++ b/Example/Sources/DataThespianExample.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.files.user-selected.read-only + + + diff --git a/Example/Sources/DataThespianExampleApp.swift b/Example/Sources/DataThespianExampleApp.swift new file mode 100644 index 0000000..f3a409c --- /dev/null +++ b/Example/Sources/DataThespianExampleApp.swift @@ -0,0 +1,37 @@ +// +// DataThespianExampleApp.swift +// DataThespianExample +// +// Created by Leo Dion on 10/10/24. +// + +import DataThespian +import SwiftData +import SwiftUI + +@main +internal struct DataThespianExampleApp: App { + private static let databaseChangePublicist = DatabaseChangePublicist() + + private static let database = BackgroundDatabase { + // swift-format-ignore: NeverUseForceTry + // swiftlint:disable:next force_try + try! ModelActorDatabase(modelContainer: ModelContainer(for: Item.self)) { + let context = ModelContext($0) + context.autosaveEnabled = true + return context + } + } + + internal var body: some Scene { + WindowGroup { + ContentView() + } + .database(Self.database) + .environment(\.databaseChangePublicist, Self.databaseChangePublicist) + } + + internal init() { + DataMonitor.shared.begin(with: []) + } +} diff --git a/Example/Sources/Item.swift b/Example/Sources/Item.swift new file mode 100644 index 0000000..09b1e81 --- /dev/null +++ b/Example/Sources/Item.swift @@ -0,0 +1,18 @@ +// +// Item.swift +// DataThespianExample +// +// Created by Leo Dion on 10/10/24. +// + +import Foundation +import SwiftData + +@Model +internal final class Item { + internal private(set) var timestamp: Date + + internal init(timestamp: Date) { + self.timestamp = timestamp + } +} diff --git a/Example/Sources/ItemModel.swift b/Example/Sources/ItemModel.swift new file mode 100644 index 0000000..f13e75b --- /dev/null +++ b/Example/Sources/ItemModel.swift @@ -0,0 +1,28 @@ +// +// ItemModel.swift +// DataThespian +// +// Created by Leo Dion on 10/10/24. +// + +import DataThespian +import Foundation +import SwiftData + +internal struct ItemModel: 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(item: Item) { + self.init(model: .init(item), timestamp: item.timestamp) + } +} diff --git a/Example/Sources/Preview Content/Preview Assets.xcassets/Contents.json b/Example/Sources/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Example/Sources/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Example/Support/.gitkeep b/Example/Support/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Example/Support/Info.plist b/Example/Support/Info.plist new file mode 100644 index 0000000..edc62ca --- /dev/null +++ b/Example/Support/Info.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + ITSAppUsesNonExemptEncryption + + + diff --git a/Mintfile b/Mintfile index 7060932..3f76adc 100644 --- a/Mintfile +++ b/Mintfile @@ -1,2 +1,4 @@ -apple/swift-format@4b62459 +swiftlang/swift-format@600.0.0 +realm/SwiftLint@0.57.0 +a7ex/xcresultparser@1.7.2 peripheryapp/periphery@2.20.0 \ No newline at end of file diff --git a/Package.swift b/Package.swift index 9e49197..30ba2a2 100644 --- a/Package.swift +++ b/Package.swift @@ -3,6 +3,7 @@ import PackageDescription +// swiftlint:disable explicit_acl explicit_top_level_acl let swiftSettings: [SwiftSetting] = [ SwiftSetting.enableExperimentalFeature("AccessLevelOnImport"), SwiftSetting.enableExperimentalFeature("BitwiseCopyable"), @@ -16,46 +17,44 @@ let swiftSettings: [SwiftSetting] = [ SwiftSetting.enableExperimentalFeature("VariadicGenerics"), SwiftSetting.enableUpcomingFeature("FullTypedThrows"), - SwiftSetting.enableUpcomingFeature("InternalImportsByDefault") + SwiftSetting.enableUpcomingFeature("InternalImportsByDefault"), - // SwiftSetting.unsafeFlags([ - // "-Xfrontend", - // "-warn-long-function-bodies=100" - // ]), - // SwiftSetting.unsafeFlags([ - // "-Xfrontend", - // "-warn-long-expression-type-checking=100" - // ]) + SwiftSetting.unsafeFlags([ + "-Xfrontend", + "-warn-long-function-bodies=100" + ]), + SwiftSetting.unsafeFlags([ + "-Xfrontend", + "-warn-long-expression-type-checking=100" + ]) ] let package = Package( - name: "DataThespian", - platforms: [.iOS(.v17), .macCatalyst(.v17), .macOS(.v14), .tvOS(.v17), .visionOS(.v1), .watchOS(.v10)], - products: [ - // Products define the executables and libraries a package produces, making them visible to other packages. - .library( - name: "DataThespian", - targets: ["DataThespian"] - ) - ], - dependencies: [ - .package(url: "https://github.com/brightdigit/FelinePine.git", from: "1.0.0-beta.2"), - .package(url: "https://github.com/swiftlang/swift-testing.git", from: "0.12.0"), - ], - targets: [ - // Targets are the basic building blocks of a package, defining a module or a test suite. - // Targets can depend on other targets in this package and products from dependencies. - .target( - name: "DataThespian", - dependencies: ["FelinePine"], - swiftSettings: swiftSettings - ), - .testTarget( - name: "DataThespianTests", - dependencies: [ - "DataThespian", - .product(name: "Testing", package: "swift-testing"), - ] - ) - ] + name: "DataThespian", + platforms: [.iOS(.v17), .macCatalyst(.v17), .macOS(.v14), .tvOS(.v17), .visionOS(.v1), .watchOS(.v10)], + products: [ + .library( + name: "DataThespian", + targets: ["DataThespian"] + ) + ], + dependencies: [ + .package(url: "https://github.com/brightdigit/FelinePine.git", from: "1.0.0-beta.2"), + .package(url: "https://github.com/swiftlang/swift-testing.git", from: "0.12.0"), + ], + targets: [ + .target( + name: "DataThespian", + dependencies: ["FelinePine"], + swiftSettings: swiftSettings + ), + .testTarget( + name: "DataThespianTests", + dependencies: [ + "DataThespian", + .product(name: "Testing", package: "swift-testing"), + ] + ) + ] ) +// swiftlint:enable explicit_acl explicit_top_level_acl diff --git a/Scripts/lint.sh b/Scripts/lint.sh index c071a32..9e51e5c 100755 --- a/Scripts/lint.sh +++ b/Scripts/lint.sh @@ -1,5 +1,17 @@ #!/bin/sh +set -o pipefail + +ERRORS=0 + +run_command() { + if [ "$LINT_MODE" == "STRICT" ]; then + "$@" || ERRORS=$((ERRORS + 1)) + else + "$@" + fi +} + if [ "$ACTION" == "install" ]; then if [ -n "$SRCROOT" ]; then exit @@ -23,9 +35,11 @@ fi if [ "$LINT_MODE" == "NONE" ]; then exit elif [ "$LINT_MODE" == "STRICT" ]; then - SWIFTFORMAT_OPTIONS="--strict" + SWIFTFORMAT_OPTIONS="--strict --configuration .swift-format" + SWIFTLINT_OPTIONS="--strict" else - SWIFTFORMAT_OPTIONS="" + SWIFTFORMAT_OPTIONS="--configuration .swift-format" + SWIFTLINT_OPTIONS="" fi /opt/homebrew/bin/mint bootstrap @@ -37,14 +51,18 @@ if [ "$LINT_MODE" == "INSTALL" ]; then fi if [ -z "$CI" ]; then - $MINT_RUN swift-format format --recursive --parallel --in-place $PACKAGE_DIR/Sources + run_command $MINT_RUN swiftlint --fix + pushd $PACKAGE_DIR + run_command $MINT_RUN swift-format format $SWIFTFORMAT_OPTIONS --recursive --parallel --in-place Sources Tests Example/Sources + popd else set -e fi $PACKAGE_DIR/scripts/header.sh -d $PACKAGE_DIR/Sources -c "Leo Dion" -o "BrightDigit" -p "DataThespian" -$MINT_RUN swift-format lint --recursive --parallel $SWIFTFORMAT_OPTIONS $PACKAGE_DIR/Sources +run_command $MINT_RUN swiftlint lint $SWIFTLINT_OPTIONS pushd $PACKAGE_DIR -$MINT_RUN periphery scan $PERIPHERY_OPTIONS --disable-update-check +run_command $MINT_RUN swift-format lint --recursive --parallel $SWIFTFORMAT_OPTIONS Sources Tests Example/Sources +#run_command $MINT_RUN periphery scan $PERIPHERY_OPTIONS --disable-update-check popd \ No newline at end of file diff --git a/Sources/DataThespian/Assert.swift b/Sources/DataThespian/Assert.swift index 3be305c..bb575a2 100644 --- a/Sources/DataThespian/Assert.swift +++ b/Sources/DataThespian/Assert.swift @@ -29,10 +29,12 @@ public import Foundation +@inlinable internal func assert(isMainThread: Bool, if assertIsBackground: Bool) { + assert(!assertIsBackground || isMainThread == Thread.isMainThread) +} + @inlinable internal func assert(isMainThread: Bool) { assert(isMainThread == Thread.isMainThread) } @inlinable internal func assertionFailure( - error: any Error, - file: StaticString = #file, - line: UInt = #line + error: any Error, file: StaticString = #file, line: UInt = #line ) { assertionFailure(error.localizedDescription, file: file, line: line) } diff --git a/Sources/DataThespian/BackgroundDatabase.swift b/Sources/DataThespian/BackgroundDatabase.swift index e7edaac..10aa1d6 100644 --- a/Sources/DataThespian/BackgroundDatabase.swift +++ b/Sources/DataThespian/BackgroundDatabase.swift @@ -28,46 +28,10 @@ // #if canImport(SwiftData) - - public import Foundation - + import Foundation public import SwiftData - import SwiftUI public final class BackgroundDatabase: Database { - public func delete(_ modelType: (some PersistentModel).Type, withID id: PersistentIdentifier) - async -> Bool - { await self.database.delete(modelType, withID: id) } - - public func delete(where predicate: Predicate?) async throws { - try await self.database.delete(where: predicate) - } - public func save() async throws { try await self.database.save() } - public func insert(_ closuer: @escaping @Sendable () -> some PersistentModel) async - -> PersistentIdentifier - { await self.database.insert(closuer) } - - public func fetch( - _ selectDescriptor: @escaping @Sendable () -> FetchDescriptor, - with closure: @escaping @Sendable ([T]) throws -> U - ) async throws -> U where T: PersistentModel, U: Sendable { - try await self.database.fetch(selectDescriptor, with: closure) - } - - public func get( - for objectID: PersistentIdentifier, - with closure: @escaping @Sendable (T?) throws -> U - ) async throws -> U where T: PersistentModel, U: Sendable { - try await self.database.get(for: objectID, with: closure) - } - public func fetch( - _ selectDescriptorA: @escaping @Sendable () -> FetchDescriptor, - _ selectDescriptorB: @escaping @Sendable () -> FetchDescriptor, - with closure: @escaping @Sendable ([T], [U]) throws -> V - ) async throws -> V { - try await self.database.fetch(selectDescriptorA, selectDescriptorB, with: closure) - } - private actor DatabaseContainer { private let factory: @Sendable () -> any Database private var wrappedTask: Task? @@ -78,7 +42,9 @@ // swiftlint:disable:next strict_fileprivate fileprivate var database: any Database { get async { - if let wrappedTask { return await wrappedTask.value } + if let wrappedTask { + return await wrappedTask.value + } let task = Task { factory() } self.wrappedTask = task return await task.value @@ -90,21 +56,47 @@ private var database: any Database { get async { await container.database } } - public convenience init(modelContainer: ModelContainer, autosaveEnabled: Bool = false) { - self.init { - assert(isMainThread: false) - return ModelActorDatabase(modelContainer: modelContainer, autosaveEnabled: autosaveEnabled) - } + public convenience init(database: @Sendable @escaping @autoclosure () -> any Database) { + self.init(database) } - internal init(_ factory: @Sendable @escaping () -> any Database) { + public init(_ factory: @Sendable @escaping () -> any Database) { self.container = .init(factory: factory) } - public func transaction(_ block: @escaping @Sendable (ModelContext) throws -> Void) async throws - { - assert(isMainThread: false) - try await self.database.transaction(block) + public func withModelContext(_ closure: @Sendable @escaping (ModelContext) throws -> T) + async rethrows -> T + { try await self.database.withModelContext(closure) } + } + + extension BackgroundDatabase { + public convenience init( + modelContainer: ModelContainer, + modelContext closure: (@Sendable (ModelContainer) -> ModelContext)? = nil + ) { + let closure = closure ?? ModelContext.init + self.init(database: ModelActorDatabase(modelContainer: modelContainer, modelContext: closure)) + } + + public convenience init( + modelContainer: SwiftData.ModelContainer + ) { + self.init( + modelContainer: modelContainer, + modelContext: ModelContext.init + ) + } + + public convenience init( + modelContainer: SwiftData.ModelContainer, + modelExecutor closure: @Sendable @escaping (ModelContainer) -> any ModelExecutor + ) { + self.init( + database: ModelActorDatabase( + modelContainer: modelContainer, + modelExecutor: closure + ) + ) } } #endif diff --git a/Sources/DataThespian/DataMonitor.swift b/Sources/DataThespian/DataMonitor.swift index 50e0efb..413d691 100644 --- a/Sources/DataThespian/DataMonitor.swift +++ b/Sources/DataThespian/DataMonitor.swift @@ -30,9 +30,7 @@ #if canImport(Combine) && canImport(SwiftData) && canImport(CoreData) import Combine - import CoreData - import Foundation import SwiftData @@ -41,8 +39,8 @@ public static let shared = DataMonitor() - var object: (any NSObjectProtocol)? - var registrations = RegistrationCollection() + private var object: (any NSObjectProtocol)? + private var registrations = RegistrationCollection() private init() { Self.logger.debug("Creating DatabaseMonitor") } @@ -50,7 +48,7 @@ Task { await self.addRegistration(registration, force: force) } } - func addRegistration(_ registration: any AgentRegister, force: Bool) { + private func addRegistration(_ registration: any AgentRegister, force: Bool) { registrations.add(withID: registration.id, force: force, agent: registration.agent) } @@ -61,8 +59,10 @@ } } - func addObserver() { - guard object == nil else { return } + private func addObserver() { + guard object == nil else { + return + } object = NotificationCenter.default.addObserver( forName: .NSManagedObjectContextDidSave, object: nil, @@ -74,8 +74,10 @@ ) } - func notifyRegisration(_ update: any DatabaseChangeSet) { - guard !update.isEmpty else { return } + private func notifyRegisration(_ update: any DatabaseChangeSet) { + guard !update.isEmpty else { + return + } Self.logger.debug("Notifying of Update") registrations.notify(update) diff --git a/Sources/DataThespian/Database+Extras.swift b/Sources/DataThespian/Database+Extras.swift new file mode 100644 index 0000000..15644f5 --- /dev/null +++ b/Sources/DataThespian/Database+Extras.swift @@ -0,0 +1,145 @@ +// +// Database+Extras.swift +// DataThespian +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftData) + public import Foundation + public import SwiftData + + 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 + ) async rethrows -> U { + try await self.get(for: id.persistentIdentifier) { (model: PersistentModelType?) -> U in + guard let model else { + throw Model.NotFoundError( + persistentIdentifier: id.persistentIdentifier + ) + } + return try closure(model) + } + } + + public func first(_ selectPredicate: Predicate) async throws -> Model? { + try await self.first(selectPredicate, with: Model.ifMap) + } + + public func first( + _ selectPredicate: Predicate, with closure: @escaping @Sendable (T?) throws -> U + ) async throws -> U { + try await self.fetch { + .init(predicate: selectPredicate, fetchLimit: 1) + } with: { models in + try closure(models.first) + } + } + + 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 + { try await self.delete(where: predicate) } + + public func delete(_ model: Model) async { + await self.delete(T.self, withID: model.persistentIdentifier) + } + + public func deleteAll(of types: [any PersistentModel.Type]) async throws { + try await self.transaction { context in for type in types { try context.delete(model: type) } } + } + + public func fetch( + _: T.Type, with closure: @escaping @Sendable ([T]) throws -> U + ) async throws -> U { + try await self.fetch { + FetchDescriptor() + } with: { models in + try closure(models) + } + } + + public func fetch(_: T.Type) async throws -> [Model] { + try await self.fetch(T.self) { models in models.map(Model.init) } + } + public func fetch( + _: T.Type, _ selectDescriptor: @escaping @Sendable () -> FetchDescriptor + ) async throws -> [Model] { + await self.fetch(selectDescriptor) { models in models.map(Model.init) } + } + + public func fetch( + of _: T.Type, + for objectIDs: [PersistentIdentifier], + with closure: @escaping @Sendable (T) throws -> U + ) async throws -> [U] where T: PersistentModel { + try await withThrowingTaskGroup(of: U?.self, returning: [U].self) { group in + for id in objectIDs { + group.addTask { try await self.get(for: id) { model in try model.map(closure) } } + } + + return try await group.reduce(into: []) { partialResult, item in + if let item { partialResult.append(item) } + } + } + } + + public func get( + of _: T.Type, + for objectID: PersistentIdentifier, + with closure: @escaping @Sendable (T?) throws -> U + ) async throws -> U where T: PersistentModel { + try await self.get(for: objectID) { model in try closure(model) } + } + } +#endif diff --git a/Sources/DataThespian/Database+ModelContext.swift b/Sources/DataThespian/Database+ModelContext.swift new file mode 100644 index 0000000..4107355 --- /dev/null +++ b/Sources/DataThespian/Database+ModelContext.swift @@ -0,0 +1,78 @@ +// +// Database+ModelContext.swift +// DataThespian +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftData) + + public import Foundation + + public import SwiftData + + extension Database { + public func save() async throws { try await self.withModelContext { try $0.save() } } + + @discardableResult public func delete( + _ modelType: T.Type, withID id: PersistentIdentifier + ) async -> Bool { await self.withModelContext { $0.delete(modelType, withID: id) } } + + public func delete(where predicate: Predicate?) async throws { + try await self.withModelContext { try $0.delete(where: predicate) } + } + + public func insert(_ closuer: @Sendable @escaping () -> some PersistentModel) async + -> PersistentIdentifier + { await self.withModelContext { $0.insert(closuer) } } + + public func fetch( + _ selectDescriptor: @escaping @Sendable () -> FetchDescriptor, + with closure: @escaping @Sendable ([T]) throws -> U + ) async rethrows -> U { + try await self.withModelContext { try $0.fetch(selectDescriptor, with: closure) } + } + + public func fetch( + _ selectDescriptorA: @escaping @Sendable () -> FetchDescriptor, + _ selectDescriptorB: @escaping @Sendable () -> FetchDescriptor, + with closure: @escaping @Sendable ([T], [U]) throws -> V + ) async rethrows -> V { + try await self.withModelContext { + try $0.fetch(selectDescriptorA, selectDescriptorB, with: closure) + } + } + + public func get( + for objectID: PersistentIdentifier, with closure: @escaping @Sendable (T?) throws -> U + ) async rethrows -> U where T: PersistentModel { + try await self.withModelContext { try $0.get(for: objectID, with: closure) } + } + + public func transaction(_ block: @Sendable @escaping (ModelContext) throws -> Void) async throws + { try await self.withModelContext { try $0.transaction(block: block) } } + } + +#endif diff --git a/Sources/DataThespian/Database.swift b/Sources/DataThespian/Database.swift index 93a0a69..97dfa1b 100644 --- a/Sources/DataThespian/Database.swift +++ b/Sources/DataThespian/Database.swift @@ -29,155 +29,12 @@ #if canImport(SwiftData) - public import Foundation + import Foundation public import SwiftData public protocol Database: Sendable { - func save() async throws - @discardableResult func delete( - _ modelType: T.Type, - withID id: PersistentIdentifier - ) async -> Bool - - func delete(where predicate: Predicate?) async throws - - func insert(_ closuer: @Sendable @escaping () -> some PersistentModel) async - -> PersistentIdentifier - - func fetch( - _ selectDescriptor: @escaping @Sendable () -> FetchDescriptor, - with closure: @escaping @Sendable ([T]) throws -> U - ) async throws -> U - func fetch( - _ selectDescriptorA: @escaping @Sendable () -> FetchDescriptor, - _ selectDescriptorB: @escaping @Sendable () -> FetchDescriptor, - with closure: @escaping @Sendable ([T], [U]) throws -> V - ) async throws -> V - - func get( - for objectID: PersistentIdentifier, - with closure: @escaping @Sendable (T?) throws -> U - ) async throws -> U where T: PersistentModel - - func transaction(_ block: @Sendable @escaping (ModelContext) throws -> Void) async throws + func withModelContext(_ closure: @Sendable @escaping (ModelContext) throws -> T) + async rethrows -> T } - - extension Database { - public func insert( - _ closuer: @Sendable @escaping () -> PersistentModelType - ) async -> ModelID { - let id: PersistentIdentifier = await self.insert(closuer) - return .init(persistentIdentifier: id) - } - - public func with( - _ id: ModelID, - _ closure: @escaping @Sendable (PersistentModelType) throws -> U - ) async throws -> U { - try await self.get(for: id.persistentIdentifier) { (model: PersistentModelType?) -> U in - guard let model else { - throw ModelID.Error.notFound(id.persistentIdentifier) - } - return try closure(model) - } - } - - public func first(_ selectPredicate: Predicate) async throws -> ModelID< - T - >? { try await self.first(selectPredicate, with: ModelID.ifMap) } - - public func first( - _ selectPredicate: Predicate, - with closure: @escaping @Sendable (T?) throws -> U - ) async throws -> U { - try await self.fetch { - .init(predicate: selectPredicate, fetchLimit: 1) - } with: { models in - try closure(models.first) - } - } - - 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: ModelID = await self.insert(insert) - - return try await self.with(inserted, closure) - } - - public func delete(model _: T.Type, where predicate: Predicate? = nil) - async throws - { try await self.delete(where: predicate) } - - public func delete(_ model: ModelID) async { - await self.delete(T.self, withID: model.persistentIdentifier) - } - - public func deleteAll(of types: [any PersistentModel.Type]) async throws { - try await self.transaction { context in for type in types { try context.delete(model: type) } - } - } - - public func fetch( - _: T.Type, - with closure: @escaping @Sendable ([T]) throws -> U - ) async throws -> U { - try await self.fetch { - FetchDescriptor() - } with: { models in - try closure(models) - } - } - - public func fetch(_: T.Type) async throws -> [ModelID] { - try await self.fetch(T.self) { models in models.map(ModelID.init) } - } - public func fetch( - _: T.Type, - _ selectDescriptor: @escaping @Sendable () -> FetchDescriptor - ) async throws -> [ModelID] { - try await self.fetch(selectDescriptor) { models in models.map(ModelID.init) } - } - - public func fetch( - of _: T.Type, - for objectIDs: [PersistentIdentifier], - with closure: @escaping @Sendable (T) throws -> U - ) async throws -> [U] where T: PersistentModel { - try await withThrowingTaskGroup(of: U?.self, returning: [U].self) { group in - for id in objectIDs { - group.addTask { try await self.get(for: id) { model in try model.map(closure) } } - } - - return try await group.reduce(into: []) { partialResult, item in - if let item { partialResult.append(item) } - } - } - } - - public func get( - of _: T.Type, - for objectID: PersistentIdentifier, - with closure: @escaping @Sendable (T?) throws -> U - ) async throws -> U where T: PersistentModel { - try await self.get(for: objectID) { model in try closure(model) } - } - } -// public extension Database { -// static var loggingCategory: ThespianLogging.Category { -// .data -// } -// } - #endif diff --git a/Sources/DataThespian/DatabaseChangePublicist.swift b/Sources/DataThespian/DatabaseChangePublicist.swift index 77ff089..f8f3b3d 100644 --- a/Sources/DataThespian/DatabaseChangePublicist.swift +++ b/Sources/DataThespian/DatabaseChangePublicist.swift @@ -30,15 +30,17 @@ #if canImport(Combine) && canImport(SwiftData) public import Combine - fileprivate struct NeverDatabaseMonitor: DatabaseMonitoring { + private struct NeverDatabaseMonitor: DatabaseMonitoring { func register(_: any AgentRegister, force _: Bool) { assertionFailure("Using Empty Database Listener") } } public struct DatabaseChangePublicist: Sendable { - let dbWatcher: DatabaseMonitoring - public init(dbWatcher: any DatabaseMonitoring) { self.dbWatcher = dbWatcher } + private let dbWatcher: DatabaseMonitoring + public init(dbWatcher: any DatabaseMonitoring = DataMonitor.shared) { + self.dbWatcher = dbWatcher + } public static func never() -> DatabaseChangePublicist { self.init(dbWatcher: NeverDatabaseMonitor()) diff --git a/Sources/DataThespian/DatabaseChangePublicistKey.swift b/Sources/DataThespian/DatabaseChangePublicistKey.swift index ae7720f..1f3b4e6 100644 --- a/Sources/DataThespian/DatabaseChangePublicistKey.swift +++ b/Sources/DataThespian/DatabaseChangePublicistKey.swift @@ -32,7 +32,7 @@ public import SwiftUI - fileprivate struct DatabaseChangePublicistKey: EnvironmentKey { + private struct DatabaseChangePublicistKey: EnvironmentKey { typealias Value = DatabaseChangePublicist nonisolated static let defaultValue: DatabaseChangePublicist = .never() diff --git a/Sources/DataThespian/DatabaseChangeSet.swift b/Sources/DataThespian/DatabaseChangeSet.swift index beaa02d..310b70f 100644 --- a/Sources/DataThespian/DatabaseChangeSet.swift +++ b/Sources/DataThespian/DatabaseChangeSet.swift @@ -38,8 +38,7 @@ public var isEmpty: Bool { inserted.isEmpty && deleted.isEmpty && updated.isEmpty } public func update( - of types: Set = .all, - contains filteringEntityNames: Set + of types: Set = .all, contains filteringEntityNames: Set ) -> Bool { let updateEntityNamesArray = types.flatMap { self[keyPath: $0.keyPath] }.map(\.entityName) let updateEntityNames = Set(updateEntityNamesArray) diff --git a/Sources/DataThespian/DatabaseChangeType.swift b/Sources/DataThespian/DatabaseChangeType.swift index cef6ea5..d2418eb 100644 --- a/Sources/DataThespian/DatabaseChangeType.swift +++ b/Sources/DataThespian/DatabaseChangeType.swift @@ -32,8 +32,11 @@ public enum DatabaseChangeType: CaseIterable, Sendable { case deleted case updated #if canImport(SwiftData) - var keyPath: KeyPath> { - switch self { case .inserted: \.inserted case .deleted: \.deleted case .updated: \.updated + internal var keyPath: KeyPath> { + switch self { + case .inserted: \.inserted + case .deleted: \.deleted + case .updated: \.updated } } #endif diff --git a/Sources/DataThespian/DatabaseKey.swift b/Sources/DataThespian/DatabaseKey.swift index 6ca2a39..3c2d0fd 100644 --- a/Sources/DataThespian/DatabaseKey.swift +++ b/Sources/DataThespian/DatabaseKey.swift @@ -28,66 +28,21 @@ // #if canImport(SwiftUI) - import Foundation - import SwiftData - public import SwiftUI - fileprivate struct DefaultDatabase: Database { - public func save() async throws { - assertionFailure("No Database Set.") - throw NotImplmentedError.instance - } - func delete(_: (some PersistentModel).Type, withID _: PersistentIdentifier) async -> Bool { - assertionFailure("No Database Set.") - return false - } - - func delete(where _: Predicate?) async throws { - assertionFailure("No Database Set.") - throw NotImplmentedError.instance - } - - func insert(_: @escaping @Sendable () -> some PersistentModel) async -> PersistentIdentifier { - assertionFailure("No Database Set.") - fatalError("No Database Set.") - } - - func fetch( - _: @escaping @Sendable () -> FetchDescriptor, - with _: @escaping @Sendable ([T]) throws -> U - ) async throws -> U where T: PersistentModel, U: Sendable { - assertionFailure("No Database Set.") - throw NotImplmentedError.instance - } - func fetch( - _ selectDescriptorA: @escaping @Sendable () -> FetchDescriptor, - _ selectDescriptorB: @escaping @Sendable () -> FetchDescriptor, - with closure: @escaping @Sendable ([T], [U]) throws -> V - ) async throws -> V { - assertionFailure("No Database Set.") - throw NotImplmentedError.instance - } - func get(for _: PersistentIdentifier, with _: @escaping @Sendable (T?) throws -> U) - async throws -> U where T: PersistentModel, U: Sendable - { - assertionFailure("No Database Set.") - throw NotImplmentedError.instance - } - - private struct NotImplmentedError: Error { static let instance = NotImplmentedError() } - + private struct DefaultDatabase: Database { static let instance = DefaultDatabase() - func transaction(_: @escaping (ModelContext) throws -> Void) async throws { + // swiftlint:disable:next unavailable_function + func withModelContext(_ closure: (ModelContext) throws -> T) async rethrows -> T { assertionFailure("No Database Set.") - throw NotImplmentedError.instance + fatalError("No Database Set.") } } - fileprivate struct DatabaseKey: EnvironmentKey { + private struct DatabaseKey: EnvironmentKey { static var defaultValue: any Database { DefaultDatabase.instance } } @@ -104,7 +59,6 @@ } } - @available(*, deprecated, message: "This is a fix for a bug. Use Scene only eventually.") extension View { public func database(_ database: any Database) -> some View { environment(\.database, database) diff --git a/Sources/DataThespian/FetchDescriptor.swift b/Sources/DataThespian/FetchDescriptor.swift index 9ae2b20..1ced50d 100644 --- a/Sources/DataThespian/FetchDescriptor.swift +++ b/Sources/DataThespian/FetchDescriptor.swift @@ -29,7 +29,6 @@ #if canImport(SwiftData) public import Foundation - public import SwiftData extension FetchDescriptor { @@ -39,10 +38,12 @@ self.fetchLimit = fetchLimit } - public init(model: ModelID) { + public init(model: Model) { let persistentIdentifier = model.persistentIdentifier self.init( - predicate: #Predicate { $0.persistentModelID == persistentIdentifier }, + predicate: #Predicate { + $0.persistentModelID == persistentIdentifier + }, fetchLimit: 1 ) } diff --git a/Sources/DataThespian/ManagedObjectMetadata.swift b/Sources/DataThespian/ManagedObjectMetadata.swift index be45fd3..1cc8202 100644 --- a/Sources/DataThespian/ManagedObjectMetadata.swift +++ b/Sources/DataThespian/ManagedObjectMetadata.swift @@ -28,7 +28,6 @@ // #if canImport(SwiftData) - public import SwiftData public struct ManagedObjectMetadata: Sendable, Hashable { @@ -44,10 +43,9 @@ import CoreData extension ManagedObjectMetadata { - init?(managedObject: NSManagedObject) { + internal init?(managedObject: NSManagedObject) { let persistentIdentifier: PersistentIdentifier - do { persistentIdentifier = try managedObject.objectID.persistentIdentifier() } - catch { + do { persistentIdentifier = try managedObject.objectID.persistentIdentifier() } catch { assertionFailure(error: error) return nil } diff --git a/Sources/DataThespian/ModelID.swift b/Sources/DataThespian/Model.swift similarity index 71% rename from Sources/DataThespian/ModelID.swift rename to Sources/DataThespian/Model.swift index e66ed22..ed01dac 100644 --- a/Sources/DataThespian/ModelID.swift +++ b/Sources/DataThespian/Model.swift @@ -1,5 +1,5 @@ // -// ModelID.swift +// Model.swift // DataThespian // // Created by Leo Dion. @@ -28,20 +28,26 @@ // #if canImport(SwiftData) - import Foundation - public import SwiftData - public struct ModelID: Sendable, Identifiable { - public var id: PersistentIdentifier.ID { return persistentIdentifier.id } + + @available(*, deprecated, renamed: "Model") + public typealias ModelID = Model + + public struct Model: Sendable, Identifiable { + public struct NotFoundError: Error { public let persistentIdentifier: PersistentIdentifier } + + public var id: PersistentIdentifier.ID { persistentIdentifier.id } public let persistentIdentifier: PersistentIdentifier - enum Error: Swift.Error { case notFound(PersistentIdentifier) } + public init(persistentIdentifier: PersistentIdentifier) { + self.persistentIdentifier = persistentIdentifier + } } - extension ModelID where T: PersistentModel { + extension Model where T: PersistentModel { public init(_ model: T) { self.init(persistentIdentifier: model.persistentModelID) } - internal static func ifMap(_ model: T?) -> ModelID? { model.map(self.init) } + internal static func ifMap(_ model: T?) -> Model? { model.map(self.init) } } #endif diff --git a/Sources/DataThespian/ModelContainer.swift b/Sources/DataThespian/ModelActor+Database.swift similarity index 57% rename from Sources/DataThespian/ModelContainer.swift rename to Sources/DataThespian/ModelActor+Database.swift index 9ee7cd1..6b1e1e1 100644 --- a/Sources/DataThespian/ModelContainer.swift +++ b/Sources/DataThespian/ModelActor+Database.swift @@ -1,5 +1,5 @@ // -// ModelContainer.swift +// ModelActor+Database.swift // DataThespian // // Created by Leo Dion. @@ -27,36 +27,18 @@ // OTHER DEALINGS IN THE SOFTWARE. // -//// -//// ModelContainer.swift -//// Copyright (c) 2024 BrightDigit. -//// -// -// #if canImport(SwiftData) -// -// -// -// -// -// public import SwiftData -// -// extension ModelContainer: @retroactive Loggable { -// public static var loggingCategory: ThespianLogging.Category { -// .data -// } -// -// public static func forTypes(_ forTypes: [any PersistentModel.Type]) -> ModelContainer { -// do { -// return try ModelContainer(for: Schema(forTypes)) -// } catch { -// if EnvironmentConfiguration.shared.disallowDatabaseRebuild { -// assertionFailure(error: error) -// } -// logger.error("Unable to read database. Rebuilding the database.") -// // swiftlint:disable:next force_try -// try! ModelContainer().deleteAllData() -// return self.forTypes(forTypes) -// } -// } -// } -// #endif +#if canImport(SwiftData) + public import SwiftData + + extension ModelActor where Self: Database { + public static var assertIsBackground: Bool { false } + + public func withModelContext( + _ closure: @Sendable @escaping (ModelContext) throws -> T + ) async rethrows -> T { + assert(isMainThread: true, if: Self.assertIsBackground) + let modelContext = self.modelContext + return try closure(modelContext) + } + } +#endif diff --git a/Sources/DataThespian/ModelActorDatabase.swift b/Sources/DataThespian/ModelActorDatabase.swift index 09532ad..c451b99 100644 --- a/Sources/DataThespian/ModelActorDatabase.swift +++ b/Sources/DataThespian/ModelActorDatabase.swift @@ -28,84 +28,55 @@ // #if canImport(SwiftData) - - public import Foundation - public import SwiftData - public actor ModelActorDatabase: Database, Loggable { - public func delete(_: T.Type, withID id: PersistentIdentifier) async -> Bool - { - guard let model: T = self.modelContext.registeredModel(for: id) else { return false } - self.modelContext.delete(model) - return true - } + // @ModelActor + // public actor ModelActorDatabase: Database {} - public func delete(where predicate: Predicate?) async throws where T: PersistentModel { - try self.modelContext.delete(model: T.self, where: predicate) - } + public actor ModelActorDatabase: Database, ModelActor { + public nonisolated let modelExecutor: any SwiftData.ModelExecutor + public nonisolated let modelContainer: SwiftData.ModelContainer - public func insert(_ closuer: @escaping @Sendable () -> some PersistentModel) async - -> PersistentIdentifier - { - let model = closuer() - self.modelContext.insert(model) - return model.persistentModelID + private init(modelExecutor: any ModelExecutor, modelContainer: ModelContainer) { + self.modelExecutor = modelExecutor + self.modelContainer = modelContainer } - public func fetch( - _ selectDescriptor: @escaping @Sendable () -> FetchDescriptor, - with closure: @escaping @Sendable ([T]) throws -> U - ) async throws -> U where T: PersistentModel, U: Sendable { - let models = try self.modelContext.fetch(selectDescriptor()) - return try closure(models) - } - public func fetch( - _ selectDescriptorA: @escaping @Sendable () -> FetchDescriptor, - _ selectDescriptorB: @escaping @Sendable () -> FetchDescriptor, - with closure: @escaping @Sendable ([T], [U]) throws -> V - ) async throws -> V { - let a = try self.modelContext.fetch(selectDescriptorA()) - let b = try self.modelContext.fetch(selectDescriptorB()) - return try closure(a, b) + public init(modelContainer: SwiftData.ModelContainer) { + self.init( + modelContainer: modelContainer, + modelContext: ModelContext.init + ) } - public func get( - for objectID: PersistentIdentifier, - with closure: @escaping @Sendable (T?) throws -> U - ) async throws -> U where T: PersistentModel, U: Sendable { - let model: T? = try self.modelContext.existingModel(for: objectID) - return try closure(model) + public init( + modelContainer: SwiftData.ModelContainer, + modelContext closure: @Sendable @escaping (ModelContainer) -> ModelContext + ) { + self.init( + modelContainer: modelContainer, + modelExecutor: DefaultSerialModelExecutor.create(from: closure) + ) } - public static var loggingCategory: ThespianLogging.Category { .data } - public func transaction(_ block: @escaping @Sendable (ModelContext) throws -> Void) async throws - { - assert(isMainThread: false) + public init( + modelContainer: SwiftData.ModelContainer, + modelExecutor closure: @Sendable @escaping (ModelContainer) -> any ModelExecutor + ) { + self.init( + modelExecutor: closure(modelContainer), + modelContainer: modelContainer + ) + } + } - try self.modelContext.transaction { - assert(isMainThread: false) - try block(modelContext) + extension DefaultSerialModelExecutor { + fileprivate static func create( + from closure: @Sendable @escaping (ModelContainer) -> ModelContext + ) -> @Sendable (ModelContainer) -> any ModelExecutor { + { + DefaultSerialModelExecutor(modelContext: closure($0)) } } - public func save() throws { - assert(isMainThread: false) - try self.modelContext.save() - } - public nonisolated let modelExecutor: any SwiftData.ModelExecutor - public nonisolated let modelContainer: SwiftData.ModelContainer - public init(modelContainer: SwiftData.ModelContainer, autosaveEnabled: Bool = false) { - let modelContext = ModelContext(modelContainer) - modelContext.autosaveEnabled = autosaveEnabled - let modelExecutor = DefaultSerialModelExecutor(modelContext: modelContext) - self.init(modelExecutor: modelExecutor, modelContainer: modelContainer) - } - private init(modelExecutor: any ModelExecutor, modelContainer: ModelContainer) { - self.modelExecutor = modelExecutor - self.modelContainer = modelContainer - } } - - extension ModelActorDatabase: SwiftData.ModelActor {} - #endif diff --git a/Sources/DataThespian/ModelContext+Extension.swift b/Sources/DataThespian/ModelContext+Extension.swift new file mode 100644 index 0000000..3013d03 --- /dev/null +++ b/Sources/DataThespian/ModelContext+Extension.swift @@ -0,0 +1,82 @@ +// +// ModelContext+Extension.swift +// DataThespian +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftData) + public import Foundation + public import SwiftData + + extension ModelContext { + public func delete(_: T.Type, withID id: PersistentIdentifier) -> Bool { + guard let model: T = self.registeredModel(for: id) else { + return false + } + self.delete(model) + return true + } + + public func delete(where predicate: Predicate?) throws where T: PersistentModel { + try self.delete(model: T.self, where: predicate) + } + + public func insert(_ closuer: @escaping @Sendable () -> some PersistentModel) + -> PersistentIdentifier + { + let model = closuer() + self.insert(model) + return model.persistentModelID + } + public func fetch( + _ selectDescriptor: @escaping @Sendable () -> FetchDescriptor, + with closure: @escaping @Sendable ([T]) throws -> U + ) throws -> U where T: PersistentModel, U: Sendable { + let models = try self.fetch(selectDescriptor()) + return try closure(models) + } + public func fetch( + _ selectDescriptorA: @escaping @Sendable () -> FetchDescriptor, + _ selectDescriptorB: @escaping @Sendable () -> FetchDescriptor, + with closure: @escaping @Sendable ([T], [U]) throws -> V + ) throws -> V { + let firstModels = try self.fetch(selectDescriptorA()) + let secondModels = try self.fetch(selectDescriptorB()) + return try closure(firstModels, secondModels) + } + + public func get( + for objectID: PersistentIdentifier, with closure: @escaping @Sendable (T?) throws -> U + ) throws -> U where T: PersistentModel, U: Sendable { + let model: T? = try self.existingModel(for: objectID) + return try closure(model) + } + + public func transaction(block: @escaping @Sendable (ModelContext) throws -> Void) throws { + try self.transaction { try block(self) } + } + } +#endif diff --git a/Sources/DataThespian/ModelContext.swift b/Sources/DataThespian/ModelContext.swift index 3298504..422fae3 100644 --- a/Sources/DataThespian/ModelContext.swift +++ b/Sources/DataThespian/ModelContext.swift @@ -31,12 +31,15 @@ import Foundation public import SwiftData - extension ModelContext: DataThespian.Loggable { - public static var loggingCategory: ThespianLogging.Category { .data } - func existingModel(for objectID: PersistentIdentifier) throws -> T? + extension ModelContext { + public func existingModel(for objectID: PersistentIdentifier) throws -> T? where T: PersistentModel { - if let registered: T = registeredModel(for: objectID) { return registered } - if let notRegistered: T = model(for: objectID) as? T { return notRegistered } + if let registered: T = registeredModel(for: objectID) { + return registered + } + if let notRegistered: T = model(for: objectID) as? T { + return notRegistered + } let fetchDescriptor = FetchDescriptor( predicate: #Predicate { $0.persistentModelID == objectID } diff --git a/Sources/DataThespian/NSManagedObjectID.swift b/Sources/DataThespian/NSManagedObjectID.swift index 73bcc48..f7e9303 100644 --- a/Sources/DataThespian/NSManagedObjectID.swift +++ b/Sources/DataThespian/NSManagedObjectID.swift @@ -29,14 +29,12 @@ #if canImport(CoreData) && canImport(SwiftData) public import CoreData - import Foundation - public import SwiftData // periphery:ignore - fileprivate struct PersistentIdentifierJSON: Codable { - fileprivate struct Implementation: Codable { + private struct PersistentIdentifierJSON: Codable { + private struct Implementation: Codable { fileprivate init( primaryKey: String, uriRepresentation: URL, @@ -57,7 +55,30 @@ private var entityName: String } - fileprivate var implementation: Implementation + private var implementation: Implementation + + private init(implementation: PersistentIdentifierJSON.Implementation) { + self.implementation = implementation + } + + fileprivate init( + primaryKey: String, + uriRepresentation: URL, + isTemporary: Bool, + storeIdentifier: String, + entityName: String + ) { + self.init( + implementation: + .init( + primaryKey: primaryKey, + uriRepresentation: uriRepresentation, + isTemporary: isTemporary, + storeIdentifier: storeIdentifier, + entityName: entityName + ) + ) + } } extension NSManagedObjectID { @@ -79,38 +100,42 @@ } guard let entityName else { throw PersistentIdentifierError.missingProperty(.entityName) } let json = PersistentIdentifierJSON( - implementation: .init( - primaryKey: primaryKey, - uriRepresentation: uriRepresentation(), - isTemporary: isTemporaryID, - storeIdentifier: storeIdentifier, - entityName: entityName - ) + primaryKey: primaryKey, + uriRepresentation: uriRepresentation(), + isTemporary: isTemporaryID, + storeIdentifier: storeIdentifier, + entityName: entityName ) let encoder = JSONEncoder() let data: Data - do { data = try encoder.encode(json) } - catch let error as EncodingError { throw PersistentIdentifierError.encodingError(error) } + do { data = try encoder.encode(json) } catch let error as EncodingError { + throw PersistentIdentifierError.encodingError(error) + } let decoder = JSONDecoder() - do { return try decoder.decode(PersistentIdentifier.self, from: data) } - catch let error as DecodingError { throw PersistentIdentifierError.decodingError(error) } + do { return try decoder.decode(PersistentIdentifier.self, from: data) } catch let error + as DecodingError + { throw PersistentIdentifierError.decodingError(error) } } } // Extensions to expose needed implementation details extension NSManagedObjectID { // Primary key is last path component of URI - var primaryKey: String { uriRepresentation().lastPathComponent } + public var primaryKey: String { uriRepresentation().lastPathComponent } // Store identifier is host of URI - var storeIdentifier: String? { - guard let identifier = uriRepresentation().host() else { return nil } + public var storeIdentifier: String? { + guard let identifier = uriRepresentation().host() else { + return nil + } return identifier } // Entity name from entity name - var entityName: String? { - guard let entityName = entity.name else { return nil } + public var entityName: String? { + guard let entityName = entity.name else { + return nil + } return entityName } } diff --git a/Sources/DataThespian/Notification.swift b/Sources/DataThespian/Notification.swift index b8130f9..a1f1ce3 100644 --- a/Sources/DataThespian/Notification.swift +++ b/Sources/DataThespian/Notification.swift @@ -29,12 +29,13 @@ #if canImport(CoreData) import CoreData - import Foundation extension Notification { - func managedObjects(key: String) -> Set? { - guard let objects = userInfo?[key] as? Set else { return nil } + internal func managedObjects(key: String) -> Set? { + guard let objects = userInfo?[key] as? Set else { + return nil + } return Set(objects.compactMap(ManagedObjectMetadata.init(managedObject:))) } diff --git a/Sources/DataThespian/NotificationDataUpdate.swift b/Sources/DataThespian/NotificationDataUpdate.swift index fa3a932..9d1133f 100644 --- a/Sources/DataThespian/NotificationDataUpdate.swift +++ b/Sources/DataThespian/NotificationDataUpdate.swift @@ -29,15 +29,14 @@ #if canImport(CoreData) && canImport(SwiftData) import CoreData - import Foundation - struct NotificationDataUpdate: DatabaseChangeSet, Sendable { - let inserted: Set + internal struct NotificationDataUpdate: DatabaseChangeSet, Sendable { + internal let inserted: Set - let deleted: Set + internal let deleted: Set - let updated: Set + internal let updated: Set private init( inserted: Set?, @@ -61,7 +60,7 @@ self.updated = updated } - init(_ notification: Notification) { + internal init(_ notification: Notification) { self.init( inserted: notification.managedObjects(key: NSInsertedObjectsKey), deleted: notification.managedObjects(key: NSDeletedObjectsKey), diff --git a/Sources/DataThespian/PublishingAgent.swift b/Sources/DataThespian/PublishingAgent.swift index 635a763..ab019bb 100644 --- a/Sources/DataThespian/PublishingAgent.swift +++ b/Sources/DataThespian/PublishingAgent.swift @@ -28,40 +28,39 @@ // #if canImport(Combine) && canImport(SwiftData) - @preconcurrency import Combine - import Foundation - actor PublishingAgent: DataAgent, Loggable { + internal actor PublishingAgent: DataAgent, Loggable { private enum SubscriptionEvent: Sendable { case cancel case subscribe } - static var loggingCategory: ThespianLogging.Category { .application } + internal static var loggingCategory: ThespianLogging.Category { .application } - let agentID = UUID() - let id: String - let subject: PassthroughSubject - var subscriptionCount = 0 - var cancellable: AnyCancellable? - var completed: (@Sendable () -> Void)? + internal let agentID = UUID() + private let id: String + private let subject: PassthroughSubject + private var subscriptionCount = 0 + private var cancellable: AnyCancellable? + private var completed: (@Sendable () -> Void)? - init(id: String, subject: PassthroughSubject) { + internal init(id: String, subject: PassthroughSubject) { self.id = id self.subject = subject Task { await self.initialize() } } - func initialize() { - cancellable = - subject.handleEvents { _ in - self.onSubscriptionEvent(.subscribe) - } receiveCancel: { - self.onSubscriptionEvent(.cancel) - } - .sink { _ in } + private func initialize() { + cancellable = subject.handleEvents { _ in + self.onSubscriptionEvent(.subscribe) + } receiveCancel: { + self.onSubscriptionEvent(.cancel) + } + .sink { + _ in + } } private nonisolated func onSubscriptionEvent(_ event: SubscriptionEvent) { @@ -71,7 +70,9 @@ private func updateScriptionStatus(byEvent event: SubscriptionEvent) { let oldCount = subscriptionCount let delta: Int = - switch event { case .cancel: -1 case .subscribe: 1 + switch event { + case .cancel: -1 + case .subscribe: 1 } subscriptionCount += delta @@ -81,15 +82,15 @@ ) } - nonisolated func onUpdate(_ update: any DatabaseChangeSet) { + nonisolated internal func onUpdate(_ update: any DatabaseChangeSet) { Task { await self.sendUpdate(update) } } - func sendUpdate(_ update: any DatabaseChangeSet) { + private func sendUpdate(_ update: any DatabaseChangeSet) { Task { @MainActor in await self.subject.send(update) } } - func cancel() { + private func cancel() { Self.logger.debug("Cancelling \(self.id) \(self.agentID)") cancellable?.cancel() cancellable = nil @@ -97,16 +98,16 @@ completed = nil } - nonisolated func onCompleted(_ closure: @escaping @Sendable () -> Void) { + nonisolated internal func onCompleted(_ closure: @escaping @Sendable () -> Void) { Task { await self.setCompleted(closure) } } - func setCompleted(_ closure: @escaping @Sendable () -> Void) { + internal func setCompleted(_ closure: @escaping @Sendable () -> Void) { Self.logger.debug("SetCompleted \(self.id) \(self.agentID)") assert(completed == nil) completed = closure } - func finish() { cancel() } + internal func finish() { cancel() } } #endif diff --git a/Sources/DataThespian/PublishingRegister.swift b/Sources/DataThespian/PublishingRegister.swift index 3d64ae0..a87f355 100644 --- a/Sources/DataThespian/PublishingRegister.swift +++ b/Sources/DataThespian/PublishingRegister.swift @@ -29,14 +29,18 @@ #if canImport(Combine) && canImport(SwiftData) @preconcurrency import Combine - import Foundation - struct PublishingRegister: AgentRegister { - let id: String - let subject: PassthroughSubject + internal struct PublishingRegister: AgentRegister { + internal let id: String + private let subject: PassthroughSubject + + internal init(id: String, subject: PassthroughSubject) { + self.id = id + self.subject = subject + } - func agent() async -> PublishingAgent { + internal func agent() async -> PublishingAgent { let agent = AgentType(id: id, subject: subject) return agent diff --git a/Sources/DataThespian/RegistrationCollection.swift b/Sources/DataThespian/RegistrationCollection.swift index 959d246..cadbbbd 100644 --- a/Sources/DataThespian/RegistrationCollection.swift +++ b/Sources/DataThespian/RegistrationCollection.swift @@ -28,35 +28,31 @@ // #if canImport(SwiftData) - import Foundation - actor RegistrationCollection: Loggable { - static var loggingCategory: ThespianLogging.Category { .application } + internal actor RegistrationCollection: Loggable { + internal static var loggingCategory: ThespianLogging.Category { .application } - var registrations = [String: DataAgent]() + private var registrations = [String: DataAgent]() - nonisolated func notify(_ update: any DatabaseChangeSet) { + nonisolated internal func notify(_ update: any DatabaseChangeSet) { Task { await self.onUpdate(update) Self.logger.debug("Notification Complete") } } - nonisolated func add( - withID id: String, - force: Bool, - agent: @Sendable @escaping () async -> DataAgent + nonisolated internal func add( + withID id: String, force: Bool, agent: @Sendable @escaping () async -> DataAgent ) { Task { await self.append(withID: id, force: force, agent: agent) } } - func append(withID id: String, force: Bool, agent: @Sendable @escaping () async -> DataAgent) - async - { + private func append( + withID id: String, force: Bool, agent: @Sendable @escaping () async -> DataAgent + ) async { if let registration = registrations[id], force { Self.logger.debug("Overwriting \(id). Already exists.") await registration.finish() - } - else if registrations[id] != nil { + } else if registrations[id] != nil { Self.logger.debug("Can't register \(id). Already exists.") return } @@ -67,7 +63,7 @@ Self.logger.debug("Registration Count \(self.registrations.count)") } - func remove(withID id: String, agentID: UUID) { + private func remove(withID id: String, agentID: UUID) { guard let agent = registrations[id] else { Self.logger.warning("No matching registration with id: \(id)") return @@ -80,7 +76,7 @@ Self.logger.debug("Registration Count \(self.registrations.count)") } - func onUpdate(_ update: any DatabaseChangeSet) { + private func onUpdate(_ update: any DatabaseChangeSet) { for (id, registration) in registrations { Self.logger.debug("Notifying \(id)") registration.onUpdate(update) diff --git a/Sources/DataThespian/Logging.swift b/Sources/DataThespian/ThespianLogging.swift similarity index 98% rename from Sources/DataThespian/Logging.swift rename to Sources/DataThespian/ThespianLogging.swift index 262db81..ad1edc6 100644 --- a/Sources/DataThespian/Logging.swift +++ b/Sources/DataThespian/ThespianLogging.swift @@ -1,5 +1,5 @@ // -// Logging.swift +// ThespianLogging.swift // DataThespian // // Created by Leo Dion. @@ -29,11 +29,11 @@ public import FelinePine +internal protocol Loggable: FelinePine.Loggable where Self.LoggingSystemType == ThespianLogging {} + public enum ThespianLogging: LoggingSystem { public enum Category: String, CaseIterable { case application case data } } - -internal protocol Loggable: FelinePine.Loggable where Self.LoggingSystemType == ThespianLogging {} diff --git a/Tests/DataThespianTests/DataThespianTests.swift b/Tests/DataThespianTests/DataThespianTests.swift index d6315c8..297aa14 100644 --- a/Tests/DataThespianTests/DataThespianTests.swift +++ b/Tests/DataThespianTests/DataThespianTests.swift @@ -1,6 +1,7 @@ -@testable import DataThespian import Testing -@Test func example() async throws { - // Write your test here and use APIs like `#expect(...)` to check expected conditions. +@testable import DataThespian + +@Test internal func example() async throws { + // Write your test here and use APIs like `#expect(...)` to check expected conditions. } diff --git a/project.yml b/project.yml index bee9fb8..e6c16c9 100644 --- a/project.yml +++ b/project.yml @@ -11,3 +11,36 @@ aggregateTargets: name: Lint basedOnDependencyAnalysis: false schemes: {} +targets: + DataThespianExample: + type: application + platform: macOS + dependencies: + - package: DataThespian + product: DataThespian + sources: + - path: "Example/Sources" + - path: "Example/Support" + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.Demo.DataThespianExample + SWIFT_STRICT_CONCURRENCY: complete + SWIFT_UPCOMING_FEATURE_CONCISE_MAGIC_FILE: YES + SWIFT_UPCOMING_FEATURE_DEPRECATE_APPLICATION_MAIN: YES + SWIFT_UPCOMING_FEATURE_DISABLE_OUTWARD_ACTOR_ISOLATION: YES + SWIFT_UPCOMING_FEATURE_EXISTENTIAL_ANY: YES + SWIFT_UPCOMING_FEATURE_FORWARD_TRAILING_CLOSURES: YES + SWIFT_UPCOMING_FEATURE_GLOBAL_CONCURRENCY: YES + SWIFT_UPCOMING_FEATURE_IMPLICIT_OPEN_EXISTENTIALS: YES + SWIFT_UPCOMING_FEATURE_IMPORT_OBJC_FORWARD_DECLS: YES + SWIFT_UPCOMING_FEATURE_INFER_SENDABLE_FROM_CAPTURES: YES + SWIFT_UPCOMING_FEATURE_INTERNAL_IMPORTS_BY_DEFAULT: YES + SWIFT_UPCOMING_FEATURE_ISOLATED_DEFAULT_VALUES: YES + SWIFT_UPCOMING_FEATURE_REGION_BASED_ISOLATION: YES + info: + path: Example/Support/Info.plist + properties: + CFBundlePackageType: APPL + ITSAppUsesNonExemptEncryption: false + CFBundleShortVersionString: $(MARKETING_VERSION) + CFBundleVersion: $(CURRENT_PROJECT_VERSION) \ No newline at end of file From d5a3068efafa91b016d37287df6b83f304d227c8 Mon Sep 17 00:00:00 2001 From: leogdion Date: Wed, 16 Oct 2024 17:44:49 -0400 Subject: [PATCH 2/4] Adding Queryable (#10) --- Example/Sources/ChildViewModel.swift | 28 ++++ Example/Sources/ContentObject.swift | 62 +++++--- Example/Sources/ContentView.swift | 2 +- Example/Sources/Item.swift | 10 +- Example/Sources/ItemChild.swift | 19 +++ Example/Sources/ItemChildView.swift | 31 ++++ .../{ItemModel.swift => ItemViewModel.swift} | 12 +- .../{ => Databases}/BackgroundDatabase.swift | 0 .../{ => Databases}/Database+Extras.swift | 45 ++---- .../Database+ModelContext.swift | 12 +- .../Databases/Database+Queryable.swift | 73 +++++++++ .../{ => Databases}/Database.swift | 2 +- .../EnvironmentValues+Database.swift} | 13 +- .../{ => Databases}/ModelActor+Database.swift | 0 .../{ => Databases}/ModelActorDatabase.swift | 0 .../QueryError.swift} | 22 +-- .../Databases/Queryable+Extensions.swift | 138 ++++++++++++++++++ .../DataThespian/Databases/Queryable.swift | 53 +++++++ Sources/DataThespian/Databases/Selector.swift | 66 +++++++++ Sources/DataThespian/Databases/Unique.swift | 32 ++++ .../DataThespian/Databases/UniqueKey.swift | 37 +++++ .../Databases/UniqueKeyPath.swift | 43 ++++++ .../DataThespian/Databases/UniqueKeys.swift | 43 ++++++ Sources/DataThespian/Model.swift | 4 + .../{ => Notification}/AgentRegister.swift | 0 .../Combine}/DatabaseChangePublicist.swift | 0 ...nmentValues+DatabaseChangePublicist.swift} | 13 +- .../Combine}/PublishingAgent.swift | 0 .../Combine}/PublishingRegister.swift | 0 .../{ => Notification}/DataAgent.swift | 0 .../{ => Notification}/DataMonitor.swift | 0 .../DatabaseChangeSet.swift | 0 .../DatabaseChangeType.swift | 0 .../DatabaseMonitoring.swift | 0 .../ManagedObjectMetadata.swift | 0 .../{ => Notification}/Notification.swift | 0 .../NotificationDataUpdate.swift | 0 .../RegistrationCollection.swift | 0 .../{ => SwiftData}/FetchDescriptor.swift | 2 + .../ModelContext+Extension.swift | 15 ++ .../SwiftData/ModelContext+Queryable.swift | 83 +++++++++++ .../DataThespian/SwiftData/ModelContext.swift | 76 ++++++++++ .../{ => SwiftData}/NSManagedObjectID.swift | 0 .../SwiftData/PersistentIdentifier.swift | 90 ++++++++++++ 44 files changed, 922 insertions(+), 104 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%) rename Sources/DataThespian/{ => Databases}/BackgroundDatabase.swift (100%) rename Sources/DataThespian/{ => Databases}/Database+Extras.swift (82%) rename Sources/DataThespian/{ => Databases}/Database+ModelContext.swift (91%) create mode 100644 Sources/DataThespian/Databases/Database+Queryable.swift rename Sources/DataThespian/{ => Databases}/Database.swift (96%) rename Sources/DataThespian/{DatabaseKey.swift => Databases/EnvironmentValues+Database.swift} (86%) rename Sources/DataThespian/{ => Databases}/ModelActor+Database.swift (100%) rename Sources/DataThespian/{ => Databases}/ModelActorDatabase.swift (100%) rename Sources/DataThespian/{ModelContext.swift => Databases/QueryError.swift} (69%) create mode 100644 Sources/DataThespian/Databases/Queryable+Extensions.swift create mode 100644 Sources/DataThespian/Databases/Queryable.swift create mode 100644 Sources/DataThespian/Databases/Selector.swift create mode 100644 Sources/DataThespian/Databases/Unique.swift create mode 100644 Sources/DataThespian/Databases/UniqueKey.swift create mode 100644 Sources/DataThespian/Databases/UniqueKeyPath.swift create mode 100644 Sources/DataThespian/Databases/UniqueKeys.swift rename Sources/DataThespian/{ => Notification}/AgentRegister.swift (100%) rename Sources/DataThespian/{ => Notification/Combine}/DatabaseChangePublicist.swift (100%) rename Sources/DataThespian/{DatabaseChangePublicistKey.swift => Notification/Combine/EnvironmentValues+DatabaseChangePublicist.swift} (75%) rename Sources/DataThespian/{ => Notification/Combine}/PublishingAgent.swift (100%) rename Sources/DataThespian/{ => Notification/Combine}/PublishingRegister.swift (100%) rename Sources/DataThespian/{ => Notification}/DataAgent.swift (100%) rename Sources/DataThespian/{ => Notification}/DataMonitor.swift (100%) rename Sources/DataThespian/{ => Notification}/DatabaseChangeSet.swift (100%) rename Sources/DataThespian/{ => Notification}/DatabaseChangeType.swift (100%) rename Sources/DataThespian/{ => Notification}/DatabaseMonitoring.swift (100%) rename Sources/DataThespian/{ => Notification}/ManagedObjectMetadata.swift (100%) rename Sources/DataThespian/{ => Notification}/Notification.swift (100%) rename Sources/DataThespian/{ => Notification}/NotificationDataUpdate.swift (100%) rename Sources/DataThespian/{ => Notification}/RegistrationCollection.swift (100%) rename Sources/DataThespian/{ => SwiftData}/FetchDescriptor.swift (98%) rename Sources/DataThespian/{ => SwiftData}/ModelContext+Extension.swift (87%) create mode 100644 Sources/DataThespian/SwiftData/ModelContext+Queryable.swift create mode 100644 Sources/DataThespian/SwiftData/ModelContext.swift rename Sources/DataThespian/{ => SwiftData}/NSManagedObjectID.swift (100%) 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..d0ebf39 100644 --- a/Example/Sources/ContentObject.swift +++ b/Example/Sources/ContentObject.swift @@ -17,17 +17,17 @@ internal class ContentObject { private var databaseChangeCancellable: AnyCancellable? private var databaseChangeSubscription: AnyCancellable? private var database: (any Database)? - internal private(set) var items = [ItemModel]() - internal var selectedItemsID: Set = [] + internal private(set) var items = [ItemViewModel]() + internal var selectedItemsID: Set = [] private var newItem: AnyCancellable? internal var error: (any Error)? - internal var selectedItems: [ItemModel] { + internal var selectedItems: [ItemViewModel] { let selectedItemsID = self.selectedItemsID - let items: [ItemModel] + let items: [ItemViewModel] do { items = try self.items.filter( - #Predicate { + #Predicate { selectedItemsID.contains($0.id) } ) @@ -49,17 +49,7 @@ internal class ContentObject { private static func deleteModels(_ models: [Model], from database: (any Database)) async throws { - try await database.withModelContext { modelContext in - let items: [Item] = models.compactMap { - modelContext.model(for: $0.persistentIdentifier) as? Item - } - dump(items.first?.persistentModelID) - assert(items.count == models.count) - for item in items { - modelContext.delete(item) - } - try modelContext.save() - } + try await database.deleteModels(models) } private func beginUpdateItems() { @@ -76,10 +66,10 @@ internal class ContentObject { guard let database else { return } - self.items = try await database.withModelContext({ modelContext in + self.items = try await database.withModelContext { modelContext in let items = try modelContext.fetch(FetchDescriptor()) - return items.map(ItemModel.init) - }) + return items.map(ItemViewModel.init) + } } internal func initialize( @@ -114,20 +104,46 @@ internal class ContentObject { } Task { try await Self.deleteModels(models, from: database) + try await database.save() } } - internal func addItem(withDate date: Date = .init()) { + internal func addChild(to item: ItemViewModel) { guard let database else { return } Task { + let timestamp = Date() + let childModel = await database.insert { + ItemChild(timestamp: timestamp) + } + try await database.withModelContext { modelContext in - let newItem = Item(timestamp: date) - modelContext.insert(newItem) - dump(newItem.persistentModelID) + let item = try modelContext.get(item.model) + let child = try modelContext.get(childModel) + assert(child != nil && item != nil) + child?.parent = item try modelContext.save() } } } + + internal func addItem(withDate date: Date = .init()) { + guard let database else { + return + } + Task { + let insertedModel = await database.insert { Item(timestamp: date) } + print("inserted:", insertedModel.isTemporary) + try await database.save() + let savedModel = try await database.get( + for: .predicate( + #Predicate { + $0.timestamp == date + } + ) + ) + print("saved:", savedModel.isTemporary) + } + } } diff --git a/Example/Sources/ContentView.swift b/Example/Sources/ContentView.swift index 9635d21..e665d7c 100644 --- a/Example/Sources/ContentView.swift +++ b/Example/Sources/ContentView.swift @@ -41,7 +41,7 @@ internal struct ContentView: View { if selectedItems.count > 1 { Text("Multiple Selected") } else if let item = selectedItems.first { - Text("Item at \(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))") + ItemChildView(object: object, item: item) } else { Text("Select an item") } diff --git a/Example/Sources/Item.swift b/Example/Sources/Item.swift index 09b1e81..82a3332 100644 --- a/Example/Sources/Item.swift +++ b/Example/Sources/Item.swift @@ -5,12 +5,20 @@ // Created by Leo Dion on 10/10/24. // +import DataThespian import Foundation import SwiftData @Model -internal final class Item { +internal final class Item: Unique { + internal enum Keys: UniqueKeys { + internal typealias Model = Item + internal static let primary = timestamp + internal static let timestamp = keyPath(\.timestamp) + } + internal private(set) var timestamp: Date + internal private(set) var children: [ItemChild]? internal init(timestamp: Date) { self.timestamp = timestamp diff --git a/Example/Sources/ItemChild.swift b/Example/Sources/ItemChild.swift new file mode 100644 index 0000000..e398ce8 --- /dev/null +++ b/Example/Sources/ItemChild.swift @@ -0,0 +1,19 @@ +// +// ItemChild.swift +// DataThespian +// +// Created by Leo Dion on 10/16/24. +// +import Foundation +import SwiftData + +@Model +internal final class ItemChild { + internal var parent: Item? + internal private(set) var timestamp: Date + + internal init(parent: Item? = nil, timestamp: Date) { + self.parent = parent + self.timestamp = timestamp + } +} diff --git a/Example/Sources/ItemChildView.swift b/Example/Sources/ItemChildView.swift new file mode 100644 index 0000000..7504804 --- /dev/null +++ b/Example/Sources/ItemChildView.swift @@ -0,0 +1,31 @@ +// +// ItemChildView.swift +// DataThespianExample +// +// Created by Leo Dion on 10/16/24. +// + +import SwiftUI + +internal struct ItemChildView: View { + internal var object: ContentObject + internal let item: ItemViewModel + internal var body: some View { + VStack { + Text("Item at \(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))") + Divider() + Button("Add Child") { + object.addChild(to: item) + } + ForEach(item.children) { child in + Text( + "Child at \(child.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))" + ) + } + } + } +} +// +// #Preview { +// ItemChildView() +// } diff --git a/Example/Sources/ItemModel.swift b/Example/Sources/ItemViewModel.swift similarity index 51% rename from Example/Sources/ItemModel.swift rename to Example/Sources/ItemViewModel.swift index f13e75b..02edeeb 100644 --- a/Example/Sources/ItemModel.swift +++ b/Example/Sources/ItemViewModel.swift @@ -9,20 +9,26 @@ import DataThespian import Foundation import SwiftData -internal struct ItemModel: Identifiable { +internal struct ItemViewModel: Sendable, Identifiable { internal let model: Model internal let timestamp: Date + internal let children: [ChildViewModel] internal var id: PersistentIdentifier { model.persistentIdentifier } - private init(model: Model, timestamp: Date) { + private init(model: Model, timestamp: Date, children: [ChildViewModel]?) { self.model = model self.timestamp = timestamp + self.children = children ?? [] } internal init(item: Item) { - self.init(model: .init(item), timestamp: item.timestamp) + self.init( + model: .init(item), + timestamp: item.timestamp, + children: item.children?.map(ChildViewModel.init) + ) } } diff --git a/Sources/DataThespian/BackgroundDatabase.swift b/Sources/DataThespian/Databases/BackgroundDatabase.swift similarity index 100% rename from Sources/DataThespian/BackgroundDatabase.swift rename to Sources/DataThespian/Databases/BackgroundDatabase.swift diff --git a/Sources/DataThespian/Database+Extras.swift b/Sources/DataThespian/Databases/Database+Extras.swift similarity index 82% rename from Sources/DataThespian/Database+Extras.swift rename to Sources/DataThespian/Databases/Database+Extras.swift index 15644f5..5ff5f78 100644 --- a/Sources/DataThespian/Database+Extras.swift +++ b/Sources/DataThespian/Databases/Database+Extras.swift @@ -32,13 +32,7 @@ public import SwiftData extension Database { - public func insert( - _ closuer: @Sendable @escaping () -> PersistentModelType - ) async -> Model { - let id: PersistentIdentifier = await self.insert(closuer) - return .init(persistentIdentifier: id) - } - + @available(*, deprecated) public func with( _ id: Model, _ closure: @escaping @Sendable (PersistentModelType) throws -> U @@ -53,10 +47,13 @@ } } - public func first(_ selectPredicate: Predicate) async throws -> Model? { + @available(*, deprecated) + public func first(_ selectPredicate: Predicate) async throws -> Model? + { try await self.first(selectPredicate, with: Model.ifMap) } + @available(*, deprecated) public func first( _ selectPredicate: Predicate, with closure: @escaping @Sendable (T?) throws -> U ) async throws -> U { @@ -67,38 +64,23 @@ } } - public func first( - fetchWith selectPredicate: Predicate, - otherwiseInsertBy insert: @Sendable @escaping () -> T, - with closure: @escaping @Sendable (T) throws -> U - ) async throws -> U { - let value = try await self.fetch { - .init(predicate: selectPredicate, fetchLimit: 1) - } with: { models in - try models.first.map(closure) - } - - if let value { - return value - } - - let inserted: Model = await self.insert(insert) - - return try await self.with(inserted, closure) - } - + @available(*, deprecated) public func delete(model _: T.Type, where predicate: Predicate? = nil) async throws { try await self.delete(where: predicate) } + @available(*, deprecated) public func delete(_ model: Model) async { await self.delete(T.self, withID: model.persistentIdentifier) } + @available(*, deprecated) public func deleteAll(of types: [any PersistentModel.Type]) async throws { - try await self.transaction { context in for type in types { try context.delete(model: type) } } + try await self.transaction { context in for type in types { try context.delete(model: type) } + } } + @available(*, deprecated) public func fetch( _: T.Type, with closure: @escaping @Sendable ([T]) throws -> U ) async throws -> U { @@ -109,15 +91,19 @@ } } + @available(*, deprecated) public func fetch(_: T.Type) async throws -> [Model] { try await self.fetch(T.self) { models in models.map(Model.init) } } + + @available(*, deprecated) public func fetch( _: T.Type, _ selectDescriptor: @escaping @Sendable () -> FetchDescriptor ) async throws -> [Model] { await self.fetch(selectDescriptor) { models in models.map(Model.init) } } + @available(*, deprecated) public func fetch( of _: T.Type, for objectIDs: [PersistentIdentifier], @@ -134,6 +120,7 @@ } } + @available(*, deprecated) public func get( of _: T.Type, for objectID: PersistentIdentifier, diff --git a/Sources/DataThespian/Database+ModelContext.swift b/Sources/DataThespian/Databases/Database+ModelContext.swift similarity index 91% rename from Sources/DataThespian/Database+ModelContext.swift rename to Sources/DataThespian/Databases/Database+ModelContext.swift index 4107355..763889c 100644 --- a/Sources/DataThespian/Database+ModelContext.swift +++ b/Sources/DataThespian/Databases/Database+ModelContext.swift @@ -34,20 +34,17 @@ public import SwiftData extension Database { - public func save() async throws { try await self.withModelContext { try $0.save() } } - + @available(*, deprecated) @discardableResult public func delete( _ modelType: T.Type, withID id: PersistentIdentifier ) async -> Bool { await self.withModelContext { $0.delete(modelType, withID: id) } } + @available(*, deprecated) public func delete(where predicate: Predicate?) async throws { try await self.withModelContext { try $0.delete(where: predicate) } } - public func insert(_ closuer: @Sendable @escaping () -> some PersistentModel) async - -> PersistentIdentifier - { await self.withModelContext { $0.insert(closuer) } } - + @available(*, deprecated) public func fetch( _ selectDescriptor: @escaping @Sendable () -> FetchDescriptor, with closure: @escaping @Sendable ([T]) throws -> U @@ -55,6 +52,7 @@ try await self.withModelContext { try $0.fetch(selectDescriptor, with: closure) } } + @available(*, deprecated) public func fetch( _ selectDescriptorA: @escaping @Sendable () -> FetchDescriptor, _ selectDescriptorB: @escaping @Sendable () -> FetchDescriptor, @@ -65,12 +63,14 @@ } } + @available(*, deprecated) public func get( for objectID: PersistentIdentifier, with closure: @escaping @Sendable (T?) throws -> U ) async rethrows -> U where T: PersistentModel { try await self.withModelContext { try $0.get(for: objectID, with: closure) } } + @available(*, deprecated) public func transaction(_ block: @Sendable @escaping (ModelContext) throws -> Void) async throws { try await self.withModelContext { try $0.transaction(block: block) } } } diff --git a/Sources/DataThespian/Databases/Database+Queryable.swift b/Sources/DataThespian/Databases/Database+Queryable.swift new file mode 100644 index 0000000..403e585 --- /dev/null +++ b/Sources/DataThespian/Databases/Database+Queryable.swift @@ -0,0 +1,73 @@ +// +// Database+Queryable.swift +// DataThespian +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftData) + public import SwiftData + + extension Database { + public func save() async throws { + try await self.withModelContext { try $0.save() } + } + + public func insert( + _ closuer: @Sendable @escaping () -> PersistentModelType, + with closure: @escaping @Sendable (PersistentModelType) throws -> U + ) async rethrows -> U { + try await self.withModelContext { + try $0.insert(closuer, with: closure) + } + } + + public func getOptional( + for selector: Selector.Get, + with closure: @escaping @Sendable (PersistentModelType?) throws -> U + ) async rethrows -> U { + try await self.withModelContext { + try $0.getOptional(for: selector, with: closure) + } + } + + public func fetch( + for selector: Selector.List, + with closure: @escaping @Sendable ([PersistentModelType]) throws -> U + ) async rethrows -> U { + try await self.withModelContext { + try $0.fetch(for: selector, with: closure) + } + } + + public func delete(_ selector: Selector.Delete) + async throws + { + try await self.withModelContext { + try $0.delete(selector) + } + } + } +#endif diff --git a/Sources/DataThespian/Database.swift b/Sources/DataThespian/Databases/Database.swift similarity index 96% rename from Sources/DataThespian/Database.swift rename to Sources/DataThespian/Databases/Database.swift index 97dfa1b..6a8c6d2 100644 --- a/Sources/DataThespian/Database.swift +++ b/Sources/DataThespian/Databases/Database.swift @@ -33,7 +33,7 @@ public import SwiftData - public protocol Database: Sendable { + public protocol Database: Sendable, Queryable { func withModelContext(_ closure: @Sendable @escaping (ModelContext) throws -> T) async rethrows -> T } diff --git a/Sources/DataThespian/DatabaseKey.swift b/Sources/DataThespian/Databases/EnvironmentValues+Database.swift similarity index 86% rename from Sources/DataThespian/DatabaseKey.swift rename to Sources/DataThespian/Databases/EnvironmentValues+Database.swift index 3c2d0fd..966ef15 100644 --- a/Sources/DataThespian/DatabaseKey.swift +++ b/Sources/DataThespian/Databases/EnvironmentValues+Database.swift @@ -1,5 +1,5 @@ // -// DatabaseKey.swift +// EnvironmentValues+Database.swift // DataThespian // // Created by Leo Dion. @@ -27,7 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // -#if canImport(SwiftUI) +#if canImport(SwiftUI) && canImport(SwiftData) import Foundation import SwiftData public import SwiftUI @@ -42,15 +42,8 @@ } } - private struct DatabaseKey: EnvironmentKey { - static var defaultValue: any Database { DefaultDatabase.instance } - } - extension EnvironmentValues { - public var database: any Database { - get { self[DatabaseKey.self] } - set { self[DatabaseKey.self] = newValue } - } + @Entry public var database: any Database = DefaultDatabase.instance } extension Scene { diff --git a/Sources/DataThespian/ModelActor+Database.swift b/Sources/DataThespian/Databases/ModelActor+Database.swift similarity index 100% rename from Sources/DataThespian/ModelActor+Database.swift rename to Sources/DataThespian/Databases/ModelActor+Database.swift diff --git a/Sources/DataThespian/ModelActorDatabase.swift b/Sources/DataThespian/Databases/ModelActorDatabase.swift similarity index 100% rename from Sources/DataThespian/ModelActorDatabase.swift rename to Sources/DataThespian/Databases/ModelActorDatabase.swift diff --git a/Sources/DataThespian/ModelContext.swift b/Sources/DataThespian/Databases/QueryError.swift similarity index 69% rename from Sources/DataThespian/ModelContext.swift rename to Sources/DataThespian/Databases/QueryError.swift index 422fae3..7b31182 100644 --- a/Sources/DataThespian/ModelContext.swift +++ b/Sources/DataThespian/Databases/QueryError.swift @@ -1,5 +1,5 @@ // -// ModelContext.swift +// QueryError.swift // DataThespian // // Created by Leo Dion. @@ -28,25 +28,9 @@ // #if canImport(SwiftData) - import Foundation public import SwiftData - extension ModelContext { - public func existingModel(for objectID: PersistentIdentifier) throws -> T? - where T: PersistentModel { - if let registered: T = registeredModel(for: objectID) { - return registered - } - if let notRegistered: T = model(for: objectID) as? T { - return notRegistered - } - - let fetchDescriptor = FetchDescriptor( - predicate: #Predicate { $0.persistentModelID == objectID } - ) - - return try fetch(fetchDescriptor).first - } + public enum QueryError: Error { + case itemNotFound(Selector.Get) } - #endif diff --git a/Sources/DataThespian/Databases/Queryable+Extensions.swift b/Sources/DataThespian/Databases/Queryable+Extensions.swift new file mode 100644 index 0000000..903aefe --- /dev/null +++ b/Sources/DataThespian/Databases/Queryable+Extensions.swift @@ -0,0 +1,138 @@ +// +// Queryable+Extensions.swift +// DataThespian +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftData) + public import SwiftData + + extension Queryable { + @discardableResult + public func insert( + _ closuer: @Sendable @escaping () -> PersistentModelType + ) async -> Model { + await self.insert(closuer, with: Model.init) + } + + public func getOptional( + for selector: Selector.Get + ) async -> Model? { + await self.getOptional(for: selector) { persistentModel in + persistentModel.flatMap(Model.init) + } + } + + public func fetch( + for selector: Selector.List + ) async -> [Model] { + await self.fetch(for: selector) { persistentModels in + persistentModels.map(Model.init) + } + } + + public func get( + for selector: Selector.Get + ) async throws -> Model { + try await self.getOptional(for: selector) { persistentModel in + guard let persistentModel else { + throw QueryError.itemNotFound(selector) + } + return Model(persistentModel) + } + } + + public func get( + for selector: Selector.Get, + with closure: @escaping @Sendable (PersistentModelType) throws -> U + ) async throws -> U { + try await self.getOptional(for: selector) { persistentModel in + guard let persistentModel else { + throw QueryError.itemNotFound(selector) + } + return try closure(persistentModel) + } + } + + public func update( + for selector: Selector.Get, + with closure: @escaping @Sendable (PersistentModelType) throws -> Void + ) async throws { + try await self.get(for: selector, with: closure) + } + + public func update( + for selector: Selector.List, + with closure: @escaping @Sendable ([PersistentModelType]) throws -> Void + ) async throws { + try await self.fetch(for: selector, with: closure) + } + + public func insertIf( + _ model: @Sendable @escaping () -> PersistentModelType, + notExist selector: @Sendable @escaping (PersistentModelType) -> + Selector.Get + ) async -> Model { + let persistentModel = model() + let selector = selector(persistentModel) + let modelOptional = await self.getOptional(for: selector) + + if let modelOptional { + return modelOptional + } else { + return await self.insert(model) + } + } + + public func insertIf( + _ model: @Sendable @escaping () -> PersistentModelType, + notExist selector: @Sendable @escaping (PersistentModelType) -> + Selector.Get, + with closure: @escaping @Sendable (PersistentModelType) throws -> U + ) async throws -> U { + let model = await self.insertIf(model, notExist: selector) + return try await self.get(for: .model(model), with: closure) + } + } + + extension Queryable { + public func deleteModels(_ models: [Model]) + async throws + { + try await withThrowingTaskGroup( + of: Void.self, + body: { group in + for model in models { + group.addTask { + try await self.delete(.model(model)) + } + } + try await group.waitForAll() + } + ) + } + } +#endif diff --git a/Sources/DataThespian/Databases/Queryable.swift b/Sources/DataThespian/Databases/Queryable.swift new file mode 100644 index 0000000..8bb0de9 --- /dev/null +++ b/Sources/DataThespian/Databases/Queryable.swift @@ -0,0 +1,53 @@ +// +// Queryable.swift +// DataThespian +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftData) + public import SwiftData + + public protocol Queryable: Sendable { + func save() async throws + + func insert( + _ closuer: @Sendable @escaping () -> PersistentModelType, + with closure: @escaping @Sendable (PersistentModelType) throws -> U + ) async rethrows -> U + + func getOptional( + for selector: Selector.Get, + with closure: @escaping @Sendable (PersistentModelType?) throws -> U + ) async rethrows -> U + + func fetch( + for selector: Selector.List, + with closure: @escaping @Sendable ([PersistentModelType]) throws -> U + ) async rethrows -> U + + func delete(_ selector: Selector.Delete) async throws + } +#endif diff --git a/Sources/DataThespian/Databases/Selector.swift b/Sources/DataThespian/Databases/Selector.swift new file mode 100644 index 0000000..6b2e559 --- /dev/null +++ b/Sources/DataThespian/Databases/Selector.swift @@ -0,0 +1,66 @@ +// +// Selector.swift +// DataThespian +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftData) + public import Foundation + public import SwiftData + + public enum Selector: Sendable { + public enum Delete: Sendable { + case predicate(Predicate) + case all + case model(Model) + } + public enum List: Sendable { + case descriptor(FetchDescriptor) + } + public enum Get: Sendable { + case model(Model) + case predicate(Predicate) + } + } + + extension Selector.Get { + @available(*, unavailable, message: "Not implemented yet.") + public static func unique( + _ key: UniqueKeyableType, + equals value: UniqueKeyableType.ValueType + ) -> Self where UniqueKeyableType.Model == T { + .predicate( + key.predicate(equals: value) + ) + } + } + + extension Selector.List { + public static func all() -> Selector.List { + .descriptor(.init()) + } + } +#endif diff --git a/Sources/DataThespian/Databases/Unique.swift b/Sources/DataThespian/Databases/Unique.swift new file mode 100644 index 0000000..5371263 --- /dev/null +++ b/Sources/DataThespian/Databases/Unique.swift @@ -0,0 +1,32 @@ +// +// Unique.swift +// DataThespian +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public protocol Unique { + associatedtype Keys: UniqueKeys +} diff --git a/Sources/DataThespian/Databases/UniqueKey.swift b/Sources/DataThespian/Databases/UniqueKey.swift new file mode 100644 index 0000000..9981b50 --- /dev/null +++ b/Sources/DataThespian/Databases/UniqueKey.swift @@ -0,0 +1,37 @@ +// +// UniqueKey.swift +// DataThespian +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +public protocol UniqueKey: Sendable { + associatedtype Model: Unique + associatedtype ValueType: Sendable & Equatable & Codable + + func predicate(equals value: ValueType) -> Predicate +} diff --git a/Sources/DataThespian/Databases/UniqueKeyPath.swift b/Sources/DataThespian/Databases/UniqueKeyPath.swift new file mode 100644 index 0000000..9c065ca --- /dev/null +++ b/Sources/DataThespian/Databases/UniqueKeyPath.swift @@ -0,0 +1,43 @@ +// +// UniqueKeyPath.swift +// DataThespian +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +public struct UniqueKeyPath: UniqueKey { + private let keyPath: KeyPath & Sendable + + internal init(keyPath: any KeyPath & Sendable) { + self.keyPath = keyPath + } + + // swiftlint:disable:next unavailable_function + public func predicate(equals value: ValueType) -> Predicate { + fatalError("Not implemented yet.") + } +} diff --git a/Sources/DataThespian/Databases/UniqueKeys.swift b/Sources/DataThespian/Databases/UniqueKeys.swift new file mode 100644 index 0000000..aded055 --- /dev/null +++ b/Sources/DataThespian/Databases/UniqueKeys.swift @@ -0,0 +1,43 @@ +// +// UniqueKeys.swift +// DataThespian +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public protocol UniqueKeys: Sendable { + associatedtype Model: Unique + associatedtype PrimaryKey: UniqueKey where PrimaryKey.Model == Model + + static var primary: PrimaryKey { get } +} + +extension UniqueKeys { + public static func keyPath( + _ keyPath: any KeyPath & Sendable + ) -> UniqueKeyPath { + UniqueKeyPath(keyPath: keyPath) + } +} diff --git a/Sources/DataThespian/Model.swift b/Sources/DataThespian/Model.swift index ed01dac..547d250 100644 --- a/Sources/DataThespian/Model.swift +++ b/Sources/DataThespian/Model.swift @@ -46,6 +46,10 @@ } extension Model where T: PersistentModel { + public var isTemporary: Bool { + self.persistentIdentifier.isTemporary ?? false + } + public init(_ model: T) { self.init(persistentIdentifier: model.persistentModelID) } internal static func ifMap(_ model: T?) -> Model? { model.map(self.init) } diff --git a/Sources/DataThespian/AgentRegister.swift b/Sources/DataThespian/Notification/AgentRegister.swift similarity index 100% rename from Sources/DataThespian/AgentRegister.swift rename to Sources/DataThespian/Notification/AgentRegister.swift diff --git a/Sources/DataThespian/DatabaseChangePublicist.swift b/Sources/DataThespian/Notification/Combine/DatabaseChangePublicist.swift similarity index 100% rename from Sources/DataThespian/DatabaseChangePublicist.swift rename to Sources/DataThespian/Notification/Combine/DatabaseChangePublicist.swift diff --git a/Sources/DataThespian/DatabaseChangePublicistKey.swift b/Sources/DataThespian/Notification/Combine/EnvironmentValues+DatabaseChangePublicist.swift similarity index 75% rename from Sources/DataThespian/DatabaseChangePublicistKey.swift rename to Sources/DataThespian/Notification/Combine/EnvironmentValues+DatabaseChangePublicist.swift index 1f3b4e6..673cca9 100644 --- a/Sources/DataThespian/DatabaseChangePublicistKey.swift +++ b/Sources/DataThespian/Notification/Combine/EnvironmentValues+DatabaseChangePublicist.swift @@ -1,5 +1,5 @@ // -// DatabaseChangePublicistKey.swift +// EnvironmentValues+DatabaseChangePublicist.swift // DataThespian // // Created by Leo Dion. @@ -32,16 +32,7 @@ public import SwiftUI - private struct DatabaseChangePublicistKey: EnvironmentKey { - typealias Value = DatabaseChangePublicist - - nonisolated static let defaultValue: DatabaseChangePublicist = .never() - } - extension EnvironmentValues { - public var databaseChangePublicist: DatabaseChangePublicist { - get { self[DatabaseChangePublicistKey.self] } - set { self[DatabaseChangePublicistKey.self] = newValue } - } + @Entry public var databaseChangePublicist: DatabaseChangePublicist = .never() } #endif diff --git a/Sources/DataThespian/PublishingAgent.swift b/Sources/DataThespian/Notification/Combine/PublishingAgent.swift similarity index 100% rename from Sources/DataThespian/PublishingAgent.swift rename to Sources/DataThespian/Notification/Combine/PublishingAgent.swift diff --git a/Sources/DataThespian/PublishingRegister.swift b/Sources/DataThespian/Notification/Combine/PublishingRegister.swift similarity index 100% rename from Sources/DataThespian/PublishingRegister.swift rename to Sources/DataThespian/Notification/Combine/PublishingRegister.swift diff --git a/Sources/DataThespian/DataAgent.swift b/Sources/DataThespian/Notification/DataAgent.swift similarity index 100% rename from Sources/DataThespian/DataAgent.swift rename to Sources/DataThespian/Notification/DataAgent.swift diff --git a/Sources/DataThespian/DataMonitor.swift b/Sources/DataThespian/Notification/DataMonitor.swift similarity index 100% rename from Sources/DataThespian/DataMonitor.swift rename to Sources/DataThespian/Notification/DataMonitor.swift diff --git a/Sources/DataThespian/DatabaseChangeSet.swift b/Sources/DataThespian/Notification/DatabaseChangeSet.swift similarity index 100% rename from Sources/DataThespian/DatabaseChangeSet.swift rename to Sources/DataThespian/Notification/DatabaseChangeSet.swift diff --git a/Sources/DataThespian/DatabaseChangeType.swift b/Sources/DataThespian/Notification/DatabaseChangeType.swift similarity index 100% rename from Sources/DataThespian/DatabaseChangeType.swift rename to Sources/DataThespian/Notification/DatabaseChangeType.swift diff --git a/Sources/DataThespian/DatabaseMonitoring.swift b/Sources/DataThespian/Notification/DatabaseMonitoring.swift similarity index 100% rename from Sources/DataThespian/DatabaseMonitoring.swift rename to Sources/DataThespian/Notification/DatabaseMonitoring.swift diff --git a/Sources/DataThespian/ManagedObjectMetadata.swift b/Sources/DataThespian/Notification/ManagedObjectMetadata.swift similarity index 100% rename from Sources/DataThespian/ManagedObjectMetadata.swift rename to Sources/DataThespian/Notification/ManagedObjectMetadata.swift diff --git a/Sources/DataThespian/Notification.swift b/Sources/DataThespian/Notification/Notification.swift similarity index 100% rename from Sources/DataThespian/Notification.swift rename to Sources/DataThespian/Notification/Notification.swift diff --git a/Sources/DataThespian/NotificationDataUpdate.swift b/Sources/DataThespian/Notification/NotificationDataUpdate.swift similarity index 100% rename from Sources/DataThespian/NotificationDataUpdate.swift rename to Sources/DataThespian/Notification/NotificationDataUpdate.swift diff --git a/Sources/DataThespian/RegistrationCollection.swift b/Sources/DataThespian/Notification/RegistrationCollection.swift similarity index 100% rename from Sources/DataThespian/RegistrationCollection.swift rename to Sources/DataThespian/Notification/RegistrationCollection.swift diff --git a/Sources/DataThespian/FetchDescriptor.swift b/Sources/DataThespian/SwiftData/FetchDescriptor.swift similarity index 98% rename from Sources/DataThespian/FetchDescriptor.swift rename to Sources/DataThespian/SwiftData/FetchDescriptor.swift index 1ced50d..9bf6f63 100644 --- a/Sources/DataThespian/FetchDescriptor.swift +++ b/Sources/DataThespian/SwiftData/FetchDescriptor.swift @@ -38,6 +38,8 @@ self.fetchLimit = fetchLimit } + + @available(*, deprecated) public init(model: Model) { let persistentIdentifier = model.persistentIdentifier self.init( diff --git a/Sources/DataThespian/ModelContext+Extension.swift b/Sources/DataThespian/SwiftData/ModelContext+Extension.swift similarity index 87% rename from Sources/DataThespian/ModelContext+Extension.swift rename to Sources/DataThespian/SwiftData/ModelContext+Extension.swift index 3013d03..533767e 100644 --- a/Sources/DataThespian/ModelContext+Extension.swift +++ b/Sources/DataThespian/SwiftData/ModelContext+Extension.swift @@ -32,6 +32,7 @@ public import SwiftData extension ModelContext { + @available(*, deprecated) public func delete(_: T.Type, withID id: PersistentIdentifier) -> Bool { guard let model: T = self.registeredModel(for: id) else { return false @@ -40,10 +41,12 @@ return true } + @available(*, deprecated) public func delete(where predicate: Predicate?) throws where T: PersistentModel { try self.delete(model: T.self, where: predicate) } + @available(*, deprecated) public func insert(_ closuer: @escaping @Sendable () -> some PersistentModel) -> PersistentIdentifier { @@ -51,6 +54,8 @@ self.insert(model) return model.persistentModelID } + + @available(*, deprecated) public func fetch( _ selectDescriptor: @escaping @Sendable () -> FetchDescriptor, with closure: @escaping @Sendable ([T]) throws -> U @@ -58,6 +63,8 @@ let models = try self.fetch(selectDescriptor()) return try closure(models) } + + @available(*, deprecated) public func fetch( _ selectDescriptorA: @escaping @Sendable () -> FetchDescriptor, _ selectDescriptorB: @escaping @Sendable () -> FetchDescriptor, @@ -68,6 +75,7 @@ return try closure(firstModels, secondModels) } + @available(*, deprecated) public func get( for objectID: PersistentIdentifier, with closure: @escaping @Sendable (T?) throws -> U ) throws -> U where T: PersistentModel, U: Sendable { @@ -75,8 +83,15 @@ return try closure(model) } + @available(*, deprecated) public func transaction(block: @escaping @Sendable (ModelContext) throws -> Void) throws { try self.transaction { try block(self) } } + + public func first( + where predicate: Predicate? = nil + ) throws -> PersistentModelType? { + try self.fetch(FetchDescriptor(predicate: predicate, fetchLimit: 1)).first + } } #endif diff --git a/Sources/DataThespian/SwiftData/ModelContext+Queryable.swift b/Sources/DataThespian/SwiftData/ModelContext+Queryable.swift new file mode 100644 index 0000000..4be7149 --- /dev/null +++ b/Sources/DataThespian/SwiftData/ModelContext+Queryable.swift @@ -0,0 +1,83 @@ +// +// ModelContext+Queryable.swift +// DataThespian +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftData) + public import SwiftData + + extension ModelContext { + public func insert( + _ closuer: @Sendable @escaping () -> PersistentModelType, + with closure: @escaping @Sendable (PersistentModelType) throws -> U + ) rethrows -> U { + let persistentModel = closuer() + self.insert(persistentModel) + return try closure(persistentModel) + } + + public func getOptional( + for selector: Selector.Get, + with closure: @escaping @Sendable (PersistentModelType?) throws -> U + ) throws -> U { + let persistentModel: PersistentModelType? + switch selector { + case .model(let model): + persistentModel = try self.get(model) + case .predicate(let predicate): + persistentModel = try self.first(where: predicate) + } + return try closure(persistentModel) + } + + public func fetch( + for selector: Selector.List, + with closure: @escaping @Sendable ([PersistentModelType]) throws -> U + ) throws -> U { + let persistentModels: [PersistentModelType] + switch selector { + case .descriptor(let descriptor): + persistentModels = try self.fetch(descriptor) + } + return try closure(persistentModels) + } + + public func delete(_ selector: Selector.Delete) throws + { + switch selector { + case .all: + try self.delete(model: PersistentModelType.self) + case .model(let model): + if let persistentModel = try self.get(model) { + self.delete(persistentModel) + } + case .predicate(let predicate): + try self.delete(model: PersistentModelType.self, where: predicate) + } + } + } +#endif diff --git a/Sources/DataThespian/SwiftData/ModelContext.swift b/Sources/DataThespian/SwiftData/ModelContext.swift new file mode 100644 index 0000000..251dfb3 --- /dev/null +++ b/Sources/DataThespian/SwiftData/ModelContext.swift @@ -0,0 +1,76 @@ +// +// ModelContext.swift +// DataThespian +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftData) + import Foundation + public import SwiftData + + extension ModelContext { + public func get(_ model: Model) throws -> T? + where T: PersistentModel { + try self.persistentModel(withID: model.persistentIdentifier) + } + + private func persistentModel(withID objectID: PersistentIdentifier) throws -> T? + where T: PersistentModel { + if let registered: T = registeredModel(for: objectID) { + return registered + } + if let notRegistered: T = model(for: objectID) as? T { + return notRegistered + } + + let fetchDescriptor = FetchDescriptor( + predicate: #Predicate { $0.persistentModelID == objectID }, + fetchLimit: 1 + ) + + return try fetch(fetchDescriptor).first + } + + @available(*, deprecated) + internal func existingModel(for objectID: PersistentIdentifier) throws -> T? + where T: PersistentModel { + if let registered: T = registeredModel(for: objectID) { + return registered + } + if let notRegistered: T = model(for: objectID) as? T { + return notRegistered + } + + let fetchDescriptor = FetchDescriptor( + predicate: #Predicate { $0.persistentModelID == objectID }, + fetchLimit: 1 + ) + + return try fetch(fetchDescriptor).first + } + } + +#endif diff --git a/Sources/DataThespian/NSManagedObjectID.swift b/Sources/DataThespian/SwiftData/NSManagedObjectID.swift similarity index 100% rename from Sources/DataThespian/NSManagedObjectID.swift rename to Sources/DataThespian/SwiftData/NSManagedObjectID.swift diff --git a/Sources/DataThespian/SwiftData/PersistentIdentifier.swift b/Sources/DataThespian/SwiftData/PersistentIdentifier.swift new file mode 100644 index 0000000..dee0c99 --- /dev/null +++ b/Sources/DataThespian/SwiftData/PersistentIdentifier.swift @@ -0,0 +1,90 @@ +// +// PersistentIdentifier.swift +// DataThespian +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(CoreData) && canImport(SwiftData) + import CoreData + import Foundation + import SwiftData + + /// Returns the value of a child property of an object using reflection. + /// + /// - Parameters: + /// - object: The object to inspect. + /// - childName: The name of the child property to retrieve. + /// - Returns: The value of the child property, or nil if it does not exist. + private func getMirrorChildValue(of object: Any, childName: String) -> Any? { + guard let child = Mirror(reflecting: object).children.first(where: { $0.label == childName }) + else { + return nil + } + + return child.value + } + + // Extension to add computed properties for accessing underlying CoreData + // implementation details of PersistentIdentifier + extension PersistentIdentifier { + // Private stored property to hold reference to underlying implementation + private var mirrorImplementation: Any? { + guard let implementation = getMirrorChildValue(of: self, childName: "implementation") else { + assertionFailure("Should always be there.") + return nil + } + return implementation + } + + // Computed property to access managedObjectID from implementation + private var objectID: NSManagedObjectID? { + guard let mirrorImplementation, + let objectID = getMirrorChildValue(of: mirrorImplementation, childName: "managedObjectID") + as? NSManagedObjectID + else { + return nil + } + return objectID + } + + // Computed property to access uriRepresentation from objectID + private var uriRepresentation: URL? { + objectID?.uriRepresentation() + } + + // swiftlint:disable:next discouraged_optional_boolean + internal var isTemporary: Bool? { + guard let mirrorImplementation, + let isTemporary = getMirrorChildValue(of: mirrorImplementation, childName: "isTemporary") + as? Bool + else { + assertionFailure("Should always be there.") + return nil + } + return isTemporary + } + } +#endif From 0f3701c15b235b9e28be3c17103ece806062faf3 Mon Sep 17 00:00:00 2001 From: leogdion Date: Sat, 2 Nov 2024 15:35:22 -0400 Subject: [PATCH 3/4] Verify and Cleanup API (#12) --- .swiftlint.yml | 3 + .../Databases/Database+Extras.swift | 96 ++--------------- .../Databases/Database+ModelContext.swift | 78 -------------- .../Databases/Queryable+Extensions.swift | 27 +++++ Sources/DataThespian/Databases/Selector.swift | 21 ++++ Sources/DataThespian/Model.swift | 3 - .../SwiftData/FetchDescriptor.swift | 15 +-- .../SwiftData/ModelContext+Extension.swift | 68 ++++-------- .../SwiftData/ModelContext+Queryable.swift | 17 ++- .../DataThespian/SwiftData/ModelContext.swift | 20 +--- .../CollectionDifference.swift | 101 ++++++++++++++++++ .../CollectionSyncronizer.swift | 101 ++++++++++++++++++ .../ModelDifferenceSyncronizer.swift | 58 ++++++++++ .../Synchronization/ModelSyncronizer.swift | 43 ++++++++ .../SynchronizationDifference.swift | 42 ++++++++ 15 files changed, 442 insertions(+), 251 deletions(-) delete mode 100644 Sources/DataThespian/Databases/Database+ModelContext.swift create mode 100644 Sources/DataThespian/Synchronization/CollectionDifference.swift create mode 100644 Sources/DataThespian/Synchronization/CollectionSyncronizer.swift create mode 100644 Sources/DataThespian/Synchronization/ModelDifferenceSyncronizer.swift create mode 100644 Sources/DataThespian/Synchronization/ModelSyncronizer.swift create mode 100644 Sources/DataThespian/Synchronization/SynchronizationDifference.swift diff --git a/.swiftlint.yml b/.swiftlint.yml index 38f6ff1..8af5c43 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -107,6 +107,9 @@ line_length: closure_body_length: - 50 - 60 +type_name: + excluded: + - ID identifier_name: excluded: - id diff --git a/Sources/DataThespian/Databases/Database+Extras.swift b/Sources/DataThespian/Databases/Database+Extras.swift index 5ff5f78..ea09701 100644 --- a/Sources/DataThespian/Databases/Database+Extras.swift +++ b/Sources/DataThespian/Databases/Database+Extras.swift @@ -28,105 +28,21 @@ // #if canImport(SwiftData) - public import Foundation + import Foundation public import SwiftData extension Database { - @available(*, deprecated) - public func with( - _ id: Model, - _ closure: @escaping @Sendable (PersistentModelType) throws -> U - ) async rethrows -> U { - try await self.get(for: id.persistentIdentifier) { (model: PersistentModelType?) -> U in - guard let model else { - throw Model.NotFoundError( - persistentIdentifier: id.persistentIdentifier - ) - } - return try closure(model) - } - } - - @available(*, deprecated) - public func first(_ selectPredicate: Predicate) async throws -> Model? + public func transaction(_ block: @Sendable @escaping (ModelContext) throws -> Void) async throws { - try await self.first(selectPredicate, with: Model.ifMap) - } - - @available(*, deprecated) - public func first( - _ selectPredicate: Predicate, with closure: @escaping @Sendable (T?) throws -> U - ) async throws -> U { - try await self.fetch { - .init(predicate: selectPredicate, fetchLimit: 1) - } with: { models in - try closure(models.first) + try await self.withModelContext{ context in + try context.transaction { + try block(context) + } } } - - @available(*, deprecated) - public func delete(model _: T.Type, where predicate: Predicate? = nil) - async throws - { try await self.delete(where: predicate) } - - @available(*, deprecated) - public func delete(_ model: Model) async { - await self.delete(T.self, withID: model.persistentIdentifier) - } - - @available(*, deprecated) public func deleteAll(of types: [any PersistentModel.Type]) async throws { try await self.transaction { context in for type in types { try context.delete(model: type) } } } - - @available(*, deprecated) - public func fetch( - _: T.Type, with closure: @escaping @Sendable ([T]) throws -> U - ) async throws -> U { - try await self.fetch { - FetchDescriptor() - } with: { models in - try closure(models) - } - } - - @available(*, deprecated) - public func fetch(_: T.Type) async throws -> [Model] { - try await self.fetch(T.self) { models in models.map(Model.init) } - } - - @available(*, deprecated) - public func fetch( - _: T.Type, _ selectDescriptor: @escaping @Sendable () -> FetchDescriptor - ) async throws -> [Model] { - await self.fetch(selectDescriptor) { models in models.map(Model.init) } - } - - @available(*, deprecated) - public func fetch( - of _: T.Type, - for objectIDs: [PersistentIdentifier], - with closure: @escaping @Sendable (T) throws -> U - ) async throws -> [U] where T: PersistentModel { - try await withThrowingTaskGroup(of: U?.self, returning: [U].self) { group in - for id in objectIDs { - group.addTask { try await self.get(for: id) { model in try model.map(closure) } } - } - - return try await group.reduce(into: []) { partialResult, item in - if let item { partialResult.append(item) } - } - } - } - - @available(*, deprecated) - public func get( - of _: T.Type, - for objectID: PersistentIdentifier, - with closure: @escaping @Sendable (T?) throws -> U - ) async throws -> U where T: PersistentModel { - try await self.get(for: objectID) { model in try closure(model) } - } } #endif diff --git a/Sources/DataThespian/Databases/Database+ModelContext.swift b/Sources/DataThespian/Databases/Database+ModelContext.swift deleted file mode 100644 index 763889c..0000000 --- a/Sources/DataThespian/Databases/Database+ModelContext.swift +++ /dev/null @@ -1,78 +0,0 @@ -// -// Database+ModelContext.swift -// DataThespian -// -// Created by Leo Dion. -// Copyright © 2024 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the “Software”), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -#if canImport(SwiftData) - - public import Foundation - - public import SwiftData - - extension Database { - @available(*, deprecated) - @discardableResult public func delete( - _ modelType: T.Type, withID id: PersistentIdentifier - ) async -> Bool { await self.withModelContext { $0.delete(modelType, withID: id) } } - - @available(*, deprecated) - public func delete(where predicate: Predicate?) async throws { - try await self.withModelContext { try $0.delete(where: predicate) } - } - - @available(*, deprecated) - public func fetch( - _ selectDescriptor: @escaping @Sendable () -> FetchDescriptor, - with closure: @escaping @Sendable ([T]) throws -> U - ) async rethrows -> U { - try await self.withModelContext { try $0.fetch(selectDescriptor, with: closure) } - } - - @available(*, deprecated) - public func fetch( - _ selectDescriptorA: @escaping @Sendable () -> FetchDescriptor, - _ selectDescriptorB: @escaping @Sendable () -> FetchDescriptor, - with closure: @escaping @Sendable ([T], [U]) throws -> V - ) async rethrows -> V { - try await self.withModelContext { - try $0.fetch(selectDescriptorA, selectDescriptorB, with: closure) - } - } - - @available(*, deprecated) - public func get( - for objectID: PersistentIdentifier, with closure: @escaping @Sendable (T?) throws -> U - ) async rethrows -> U where T: PersistentModel { - try await self.withModelContext { try $0.get(for: objectID, with: closure) } - } - - @available(*, deprecated) - public func transaction(_ block: @Sendable @escaping (ModelContext) throws -> Void) async throws - { try await self.withModelContext { try $0.transaction(block: block) } } - } - -#endif diff --git a/Sources/DataThespian/Databases/Queryable+Extensions.swift b/Sources/DataThespian/Databases/Queryable+Extensions.swift index 903aefe..10c8949 100644 --- a/Sources/DataThespian/Databases/Queryable+Extensions.swift +++ b/Sources/DataThespian/Databases/Queryable+Extensions.swift @@ -54,6 +54,33 @@ } } + public func fetch( + for selectors: [Selector.Get], + with closure: @escaping @Sendable (PersistentModelType) throws -> U + ) async rethrows -> [U] { + try await withThrowingTaskGroup( + of: Optional.self, + returning: [U].self, + body: { group in + for selector in selectors { + group.addTask { + try await self.getOptional(for: selector) { persistentModel in + guard let persistentModel else { + return Optional.none + } + return try closure(persistentModel) + } + } + } + return try await group.reduce(into: [U]()) { partialResult, result in + if let result { + partialResult.append(result) + } + } + } + ) + } + public func get( for selector: Selector.Get ) async throws -> Model { diff --git a/Sources/DataThespian/Databases/Selector.swift b/Sources/DataThespian/Databases/Selector.swift index 6b2e559..2de45e7 100644 --- a/Sources/DataThespian/Databases/Selector.swift +++ b/Sources/DataThespian/Databases/Selector.swift @@ -59,6 +59,27 @@ } extension Selector.List { + public static func descriptor( + _ type: T.Type, + predicate: Predicate? = nil, + sortBy: [SortDescriptor] = [], + fetchLimit: Int? = nil + ) -> Selector.List { + .descriptor(.init(predicate: predicate, sortBy: sortBy, fetchLimit: fetchLimit)) + } + + public static func descriptor( + predicate: Predicate? = nil, + sortBy: [SortDescriptor] = [], + fetchLimit: Int? = nil + ) -> Selector.List { + .descriptor(.init(predicate: predicate, sortBy: sortBy, fetchLimit: fetchLimit)) + } + + public static func all(_ type: T.Type) -> Selector.List { + .descriptor(.init()) + } + public static func all() -> Selector.List { .descriptor(.init()) } diff --git a/Sources/DataThespian/Model.swift b/Sources/DataThespian/Model.swift index 547d250..0c94614 100644 --- a/Sources/DataThespian/Model.swift +++ b/Sources/DataThespian/Model.swift @@ -31,9 +31,6 @@ import Foundation public import SwiftData - @available(*, deprecated, renamed: "Model") - public typealias ModelID = Model - public struct Model: Sendable, Identifiable { public struct NotFoundError: Error { public let persistentIdentifier: PersistentIdentifier } diff --git a/Sources/DataThespian/SwiftData/FetchDescriptor.swift b/Sources/DataThespian/SwiftData/FetchDescriptor.swift index 9bf6f63..dc46066 100644 --- a/Sources/DataThespian/SwiftData/FetchDescriptor.swift +++ b/Sources/DataThespian/SwiftData/FetchDescriptor.swift @@ -32,22 +32,9 @@ public import SwiftData extension FetchDescriptor { - public init(predicate: Predicate? = nil, sortBy: [SortDescriptor] = [], fetchLimit: Int?) - { + public init(predicate: Predicate? = nil, sortBy: [SortDescriptor] = [], fetchLimit: Int?) { self.init(predicate: predicate, sortBy: sortBy) - self.fetchLimit = fetchLimit } - - @available(*, deprecated) - public init(model: Model) { - let persistentIdentifier = model.persistentIdentifier - self.init( - predicate: #Predicate { - $0.persistentModelID == persistentIdentifier - }, - fetchLimit: 1 - ) - } } #endif diff --git a/Sources/DataThespian/SwiftData/ModelContext+Extension.swift b/Sources/DataThespian/SwiftData/ModelContext+Extension.swift index 533767e..bff3ad2 100644 --- a/Sources/DataThespian/SwiftData/ModelContext+Extension.swift +++ b/Sources/DataThespian/SwiftData/ModelContext+Extension.swift @@ -32,60 +32,38 @@ public import SwiftData extension ModelContext { - @available(*, deprecated) - public func delete(_: T.Type, withID id: PersistentIdentifier) -> Bool { - guard let model: T = self.registeredModel(for: id) else { - return false - } - self.delete(model) - return true - } - - @available(*, deprecated) - public func delete(where predicate: Predicate?) throws where T: PersistentModel { - try self.delete(model: T.self, where: predicate) - } - - @available(*, deprecated) - public func insert(_ closuer: @escaping @Sendable () -> some PersistentModel) - -> PersistentIdentifier + public func insert(_ closuer: @escaping @Sendable () -> T) + -> Model { let model = closuer() self.insert(model) - return model.persistentModelID + return .init(model) } - @available(*, deprecated) - public func fetch( - _ selectDescriptor: @escaping @Sendable () -> FetchDescriptor, - with closure: @escaping @Sendable ([T]) throws -> U - ) throws -> U where T: PersistentModel, U: Sendable { - let models = try self.fetch(selectDescriptor()) - return try closure(models) + public func fetch( + for selectors: [Selector.Get] + ) throws -> [PersistentModelType] { + try selectors + .map { + try self.getOptional(for: $0) + } + .compactMap { $0 } } - @available(*, deprecated) - public func fetch( - _ selectDescriptorA: @escaping @Sendable () -> FetchDescriptor, - _ selectDescriptorB: @escaping @Sendable () -> FetchDescriptor, - with closure: @escaping @Sendable ([T], [U]) throws -> V - ) throws -> V { - let firstModels = try self.fetch(selectDescriptorA()) - let secondModels = try self.fetch(selectDescriptorB()) - return try closure(firstModels, secondModels) - } - - @available(*, deprecated) - public func get( - for objectID: PersistentIdentifier, with closure: @escaping @Sendable (T?) throws -> U - ) throws -> U where T: PersistentModel, U: Sendable { - let model: T? = try self.existingModel(for: objectID) - return try closure(model) + public func get(_ model: Model) throws -> T + where T: PersistentModel { + guard let item = try self.getOptional(model) else { + throw QueryError.itemNotFound(.model(model)) + } + return item } - @available(*, deprecated) - public func transaction(block: @escaping @Sendable (ModelContext) throws -> Void) throws { - try self.transaction { try block(self) } + public func delete( + _ selectors: [Selector.Delete] + ) throws { + for selector in selectors { + try self.delete(selector) + } } public func first( diff --git a/Sources/DataThespian/SwiftData/ModelContext+Queryable.swift b/Sources/DataThespian/SwiftData/ModelContext+Queryable.swift index 4be7149..c98f50a 100644 --- a/Sources/DataThespian/SwiftData/ModelContext+Queryable.swift +++ b/Sources/DataThespian/SwiftData/ModelContext+Queryable.swift @@ -40,6 +40,19 @@ return try closure(persistentModel) } + public func getOptional( + for selector: Selector.Get + ) throws -> PersistentModelType? { + let persistentModel: PersistentModelType? + switch selector { + case .model(let model): + persistentModel = try self.getOptional(model) + case .predicate(let predicate): + persistentModel = try self.first(where: predicate) + } + return persistentModel + } + public func getOptional( for selector: Selector.Get, with closure: @escaping @Sendable (PersistentModelType?) throws -> U @@ -47,7 +60,7 @@ let persistentModel: PersistentModelType? switch selector { case .model(let model): - persistentModel = try self.get(model) + persistentModel = try self.getOptional(model) case .predicate(let predicate): persistentModel = try self.first(where: predicate) } @@ -72,7 +85,7 @@ case .all: try self.delete(model: PersistentModelType.self) case .model(let model): - if let persistentModel = try self.get(model) { + if let persistentModel = try self.getOptional(model) { self.delete(persistentModel) } case .predicate(let predicate): diff --git a/Sources/DataThespian/SwiftData/ModelContext.swift b/Sources/DataThespian/SwiftData/ModelContext.swift index 251dfb3..b52909f 100644 --- a/Sources/DataThespian/SwiftData/ModelContext.swift +++ b/Sources/DataThespian/SwiftData/ModelContext.swift @@ -32,7 +32,7 @@ public import SwiftData extension ModelContext { - public func get(_ model: Model) throws -> T? + public func getOptional(_ model: Model) throws -> T? where T: PersistentModel { try self.persistentModel(withID: model.persistentIdentifier) } @@ -53,24 +53,6 @@ return try fetch(fetchDescriptor).first } - - @available(*, deprecated) - internal func existingModel(for objectID: PersistentIdentifier) throws -> T? - where T: PersistentModel { - if let registered: T = registeredModel(for: objectID) { - return registered - } - if let notRegistered: T = model(for: objectID) as? T { - return notRegistered - } - - let fetchDescriptor = FetchDescriptor( - predicate: #Predicate { $0.persistentModelID == objectID }, - fetchLimit: 1 - ) - - return try fetch(fetchDescriptor).first - } } #endif diff --git a/Sources/DataThespian/Synchronization/CollectionDifference.swift b/Sources/DataThespian/Synchronization/CollectionDifference.swift new file mode 100644 index 0000000..73499b8 --- /dev/null +++ b/Sources/DataThespian/Synchronization/CollectionDifference.swift @@ -0,0 +1,101 @@ +// +// CollectionDifference.swift +// DataThespian +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftData) + public import SwiftData + + public struct CollectionDifference< + PersistentModelType: PersistentModel, + DataType: Sendable + >: Sendable { + public let inserts: [DataType] + public let modelsToDelete: [Model] + public let updates: [DataType] + + public init( + inserts: [DataType], + modelsToDelete: [Model], + updates: [DataType] + ) { + self.inserts = inserts + self.modelsToDelete = modelsToDelete + self.updates = updates + } + } + + extension CollectionDifference { + public var deleteSelectors: [DataThespian.Selector.Delete] { + self.modelsToDelete.map { + .model($0) + } + } + + public init( + persistentModels: [PersistentModelType]?, + data: [DataType]?, + persistentModelKeyPath: KeyPath, + dataKeyPath: KeyPath + ) { + let persistentModels = persistentModels ?? [] + let entryMap: [ID: PersistentModelType] = + .init( + uniqueKeysWithValues: persistentModels.map { + ($0[keyPath: persistentModelKeyPath], $0) + } + ) + + let data = data ?? [] + let imageMap: [ID: DataType] = .init( + uniqueKeysWithValues: data.map { + ($0[keyPath: dataKeyPath], $0) + } + ) + + let entryIDsToUpdate = Set(entryMap.keys).intersection(imageMap.keys) + let entryIDsToDelete = Set(entryMap.keys).subtracting(imageMap.keys) + let libraryIDsToInsert = Set(imageMap.keys).subtracting(entryMap.keys) + + let entriesToDelete = entryIDsToDelete.compactMap { entryMap[$0] }.map(Model.init) + let libraryItemsToInsert = libraryIDsToInsert.compactMap { imageMap[$0] } + let imagesToUpdate = entryIDsToUpdate.compactMap { + imageMap[$0] + } + + assert(entryIDsToUpdate.count == imagesToUpdate.count) + assert(entryIDsToDelete.count == entriesToDelete.count) + assert(libraryItemsToInsert.count == libraryIDsToInsert.count) + + self.init( + inserts: libraryItemsToInsert, + modelsToDelete: entriesToDelete, + updates: imagesToUpdate + ) + } + } +#endif diff --git a/Sources/DataThespian/Synchronization/CollectionSyncronizer.swift b/Sources/DataThespian/Synchronization/CollectionSyncronizer.swift new file mode 100644 index 0000000..6bb3f92 --- /dev/null +++ b/Sources/DataThespian/Synchronization/CollectionSyncronizer.swift @@ -0,0 +1,101 @@ +// +// CollectionSyncronizer.swift +// DataThespian +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftData) + public import SwiftData + + private struct SynchronizationUpdate { + var file: DataType? + var entry: PersistentModelType? + } + + public protocol CollectionSyncronizer { + associatedtype PersistentModelType: PersistentModel + associatedtype DataType: Sendable + associatedtype ID: Hashable + + static var dataKey: KeyPath { get } + static var persistentModelKey: KeyPath { get } + + static func getSelector(from data: DataType) -> DataThespian.Selector.Get + + static func persistentModel(from data: DataType) -> PersistentModelType + static func syncronize(_ persistentModel: PersistentModelType, with data: DataType) throws + } + + extension CollectionSyncronizer { + public static func syncronizeDifference ( + _ difference: CollectionDifference, + using modelContext: ModelContext + ) throws -> [PersistentModelType] { + // try await database.withModelContext { modelContext in + try modelContext.delete(difference.deleteSelectors) + + let modelsToInsert: [Model] = difference.inserts.map { model in + modelContext.insert { + Self.persistentModel(from: model) + } + } + + let inserted = try modelsToInsert.map { + try modelContext.get($0) + } + + let updateSelectors = difference.updates.map { + Self.getSelector(from: $0) + } + + let entriesToUpdate = try modelContext.fetch(for: updateSelectors) + + var dictionary = [ID: SynchronizationUpdate]() + + for file in difference.updates { + let id = file[keyPath: Self.dataKey] + assert(dictionary[id] == nil) + dictionary[id] = SynchronizationUpdate(file: file) + } + + for entry in entriesToUpdate { + let id = entry[keyPath: Self.persistentModelKey] + assert(dictionary[id] != nil) + dictionary[id]?.entry = entry + } + + for update in dictionary.values { + guard let entry = update.entry, let file = update.file else { + assertionFailure() + continue + } + try Self.syncronize(entry, with: file) + } + + return inserted + } + } +#endif diff --git a/Sources/DataThespian/Synchronization/ModelDifferenceSyncronizer.swift b/Sources/DataThespian/Synchronization/ModelDifferenceSyncronizer.swift new file mode 100644 index 0000000..f961572 --- /dev/null +++ b/Sources/DataThespian/Synchronization/ModelDifferenceSyncronizer.swift @@ -0,0 +1,58 @@ +// +// ModelDifferenceSyncronizer.swift +// DataThespian +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftData) + public import SwiftData + + public protocol ModelDifferenceSyncronizer: ModelSyncronizer { + associatedtype SynchronizationDifferenceType: SynchronizationDifference + where + SynchronizationDifferenceType.DataType == DataType, + SynchronizationDifferenceType.PersistentModelType == PersistentModelType + + static func synchronize( + _ diff: SynchronizationDifferenceType, + using database: any Database + ) async throws + } + + extension ModelDifferenceSyncronizer { + public static func synchronizeModel( + _ model: Model, + with library: DataType, + using database: any Database + ) async throws { + let diff = try await database.get(for: .model(model)) { libraryEntry in + SynchronizationDifferenceType.comparePersistentModel(libraryEntry, with: library) + } + + return try await self.synchronize(diff, using: database) + } + } +#endif diff --git a/Sources/DataThespian/Synchronization/ModelSyncronizer.swift b/Sources/DataThespian/Synchronization/ModelSyncronizer.swift new file mode 100644 index 0000000..af9b057 --- /dev/null +++ b/Sources/DataThespian/Synchronization/ModelSyncronizer.swift @@ -0,0 +1,43 @@ +// +// ModelSyncronizer.swift +// DataThespian +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftData) + public import SwiftData + + public protocol ModelSyncronizer { + associatedtype PersistentModelType: PersistentModel + associatedtype DataType: Sendable + + static func synchronizeModel( + _ model: Model, + with library: DataType, + using database: any Database + ) async throws + } +#endif diff --git a/Sources/DataThespian/Synchronization/SynchronizationDifference.swift b/Sources/DataThespian/Synchronization/SynchronizationDifference.swift new file mode 100644 index 0000000..c2f3e1a --- /dev/null +++ b/Sources/DataThespian/Synchronization/SynchronizationDifference.swift @@ -0,0 +1,42 @@ +// +// SynchronizationDifference.swift +// DataThespian +// +// Created by Leo Dion. +// Copyright © 2024 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the “Software”), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(SwiftData) + public import SwiftData + + public protocol SynchronizationDifference: Sendable { + associatedtype PersistentModelType: PersistentModel + associatedtype DataType: Sendable + + static func comparePersistentModel( + _ persistentModel: PersistentModelType, + with data: DataType + ) -> Self + } +#endif From c65ab434323e853d927ec503f55f1f3a6c2ab5ff Mon Sep 17 00:00:00 2001 From: leogdion Date: Wed, 6 Nov 2024 14:15:47 -0500 Subject: [PATCH 4/4] Add Documentation (#15) --- .gitignore | 3 +- README.md | 249 ++++++++++++++++++ Scripts/swift-doc.sh | 156 +++++++++++ Scripts/watch-docc.sh | 174 ++++++++++++ Sources/DataThespian/Assert.swift | 22 +- .../Databases/BackgroundDatabase.swift | 40 ++- .../Databases/Database+Extras.swift | 17 +- .../Databases/Database+Queryable.swift | 20 ++ Sources/DataThespian/Databases/Database.swift | 7 +- .../EnvironmentValues+Database.swift | 22 +- .../Databases/ModelActor+Database.swift | 5 + .../Databases/ModelActorDatabase.swift | 30 ++- .../DataThespian/Databases/QueryError.swift | 5 +- .../Databases/Queryable+Extensions.swift | 53 +++- .../DataThespian/Databases/Queryable.swift | 32 ++- Sources/DataThespian/Databases/Selector.swift | 44 +++- Sources/DataThespian/Databases/Unique.swift | 3 + .../DataThespian/Databases/UniqueKey.swift | 9 + .../Databases/UniqueKeyPath.swift | 15 +- .../DataThespian/Databases/UniqueKeys.swift | 26 ++ .../Documentation.docc/Documentation.md | 83 +++++- Sources/DataThespian/Model.swift | 31 ++- .../Notification/AgentRegister.swift | 23 +- .../Combine/DatabaseChangePublicist.swift | 13 + ...onmentValues+DatabaseChangePublicist.swift | 1 + .../Combine/PublishingAgent.swift | 33 +++ .../Combine/PublishingRegister.swift | 12 + .../DataThespian/Notification/DataAgent.swift | 19 +- .../Notification/DataMonitor.swift | 13 +- .../Notification/DatabaseChangeSet.swift | 15 ++ .../Notification/DatabaseChangeType.swift | 17 +- .../Notification/DatabaseMonitoring.swift | 7 + .../Notification/ManagedObjectMetadata.swift | 20 +- .../Notification/Notification.swift | 6 + .../Notification/NotificationDataUpdate.swift | 22 +- .../Notification/RegistrationCollection.swift | 13 +- .../SwiftData/FetchDescriptor.swift | 13 +- .../SwiftData/ModelContext+Extension.swift | 27 +- .../SwiftData/ModelContext+Queryable.swift | 37 ++- .../DataThespian/SwiftData/ModelContext.swift | 14 +- .../SwiftData/NSManagedObjectID.swift | 12 +- .../SwiftData/PersistentIdentifier.swift | 1 - .../CollectionDifference.swift | 29 +- .../CollectionSyncronizer.swift | 36 ++- .../ModelDifferenceSyncronizer.swift | 18 +- .../Synchronization/ModelSyncronizer.swift | 10 + .../SynchronizationDifference.swift | 9 + Sources/DataThespian/ThespianLogging.swift | 6 + 48 files changed, 1395 insertions(+), 77 deletions(-) create mode 100755 Scripts/swift-doc.sh create mode 100755 Scripts/watch-docc.sh diff --git a/.gitignore b/.gitignore index 424ea0c..38b971a 100644 --- a/.gitignore +++ b/.gitignore @@ -143,4 +143,5 @@ xcuserdata # End of https://www.toptal.com/developers/gitignore/api/swift,swiftpm,swiftpackagemanager,xcode,macos test_output.log -.docc-build \ No newline at end of file +.docc-build +public \ No newline at end of file diff --git a/README.md b/README.md index d33dcbd..00ce236 100644 --- a/README.md +++ b/README.md @@ -16,3 +16,252 @@ [![Code Climate maintainability](https://img.shields.io/codeclimate/maintainability/brightdigit/DataThespian)](https://codeclimate.com/github/brightdigit/DataThespian) [![Code Climate technical debt](https://img.shields.io/codeclimate/tech-debt/brightdigit/DataThespian?label=debt)](https://codeclimate.com/github/brightdigit/DataThespian) [![Code Climate issues](https://img.shields.io/codeclimate/issues/brightdigit/DataThespian)](https://codeclimate.com/github/brightdigit/DataThespian) + +# Table of Contents + +* [Introduction](#introduction) + * [Requirements](#requirements) + * [Installation](#installation) + * [Documentation](#documentation) +* [License](#license) + + + + +# Introduction + + + +## Requirements + +**Apple Platforms** + +- Xcode 16.0 or later +- Swift 6.0 or later +- iOS 17 / watchOS 10.0 / tvOS 17 / macOS 14 or later deployment targets + +**Linux** + +- Ubuntu 20.04 or later +- Swift 6.0 or later + +## Installation + +To integrate **DataThespian** into your app using SPM, specify it in your Package.swift file: + +```swift +let package = Package( + ... + dependencies: [ + .package(url: "https://github.com/brightdigit/DataThespian.git", from: "1.0.0") + ], + targets: [ + .target( + name: "YourApps", + dependencies: [ + .product(name: "DataThespian", package: "DataThespian"), ... + ]), + ... + ] +) +``` + + + +## Documentation + +To learn more, check out the full [documentation](https://swiftpackageindex.com/brightdigit/DataThespian/documentation). + +# License + +This code is distributed under the MIT license. See the [LICENSE](https://github.com/brightdigit/DataThespian/LICENSE) file for more info. diff --git a/Scripts/swift-doc.sh b/Scripts/swift-doc.sh new file mode 100755 index 0000000..7b015ff --- /dev/null +++ b/Scripts/swift-doc.sh @@ -0,0 +1,156 @@ +#!/bin/bash + +# Check if ANTHROPIC_API_KEY is set +if [ -z "$ANTHROPIC_API_KEY" ]; then + echo "Error: ANTHROPIC_API_KEY environment variable is not set" + echo "Please set it with: export ANTHROPIC_API_KEY='your-key-here'" + exit 1 +fi + +# Check if jq is installed +if ! command -v jq &> /dev/null; then + echo "Error: jq is required but not installed." + echo "Please install it:" + echo " - On macOS: brew install jq" + echo " - On Ubuntu/Debian: sudo apt-get install jq" + echo " - On CentOS/RHEL: sudo yum install jq" + exit 1 +fi + +# Check if an argument was provided +if [ $# -eq 0 ]; then + echo "Usage: $0 [--skip-backup]" + exit 1 +fi + +TARGET=$1 +SKIP_BACKUP=0 + +# Check for optional flags +if [ "$2" = "--skip-backup" ]; then + SKIP_BACKUP=1 +fi + +# Function to clean markdown code blocks +clean_markdown() { + local content="$1" + # Remove ```swift from the start and ``` from the end, if present + content=$(echo "$content" | sed -E '1s/^```swift[[:space:]]*//') + content=$(echo "$content" | sed -E '$s/```[[:space:]]*$//') + echo "$content" +} + +# Function to process a single Swift file +process_swift_file() { + local SWIFT_FILE=$1 + echo "Processing: $SWIFT_FILE" + + # Create backup unless skipped + if [ $SKIP_BACKUP -eq 0 ]; then + cp "$SWIFT_FILE" "${SWIFT_FILE}.backup" + echo "Created backup: ${SWIFT_FILE}.backup" + fi + + # Read and escape the Swift file content for JSON + local SWIFT_CODE + SWIFT_CODE=$(jq -Rs . < "$SWIFT_FILE") + + # Create the JSON payload + local JSON_PAYLOAD + JSON_PAYLOAD=$(jq -n \ + --arg code "$SWIFT_CODE" \ + '{ + model: "claude-3-haiku-20240307", + max_tokens: 2000, + messages: [{ + role: "user", + content: "Please add Swift documentation comments to the following code. Use /// style comments. Include parameter descriptions and return value documentation where applicable. Return only the documented code without any markdown formatting or explanation:\n\n\($code)" + }] + }') + + # Make the API call to Claude + local response + response=$(curl -s https://api.anthropic.com/v1/messages \ + -H "Content-Type: application/json" \ + -H "x-api-key: $ANTHROPIC_API_KEY" \ + -H "anthropic-version: 2023-06-01" \ + -d "$JSON_PAYLOAD") + + # Check if the API call was successful + if [ $? -ne 0 ]; then + echo "Error: API call failed for $SWIFT_FILE" + return 1 + fi + + # Extract the content from the response using jq + local documented_code + documented_code=$(echo "$response" | jq -r '.content[0].text // empty') + + # Check if we got valid content back + if [ -z "$documented_code" ]; then + echo "Error: No valid response received for $SWIFT_FILE" + echo "API Response: $response" + return 1 + fi + + # Clean the markdown formatting from the response + documented_code=$(clean_markdown "$documented_code") + + # Save the documented code to the file + echo "$documented_code" > "$SWIFT_FILE" + + # Show diff if available and backup exists + if [ $SKIP_BACKUP -eq 0 ] && command -v diff &> /dev/null; then + echo -e "\nChanges made to $SWIFT_FILE:" + diff "${SWIFT_FILE}.backup" "$SWIFT_FILE" + fi + + echo "✓ Documentation added to $SWIFT_FILE" + echo "----------------------------------------" +} + +# Function to process directory +process_directory() { + local DIR=$1 + local SWIFT_FILES=0 + local PROCESSED=0 + local FAILED=0 + + # Count total Swift files + SWIFT_FILES=$(find "$DIR" -name "*.swift" | wc -l) + echo "Found $SWIFT_FILES Swift files in $DIR" + echo "----------------------------------------" + + # Process each Swift file + while IFS= read -r file; do + if process_swift_file "$file"; then + ((PROCESSED++)) + else + ((FAILED++)) + fi + # Add a small delay to avoid API rate limits + sleep 1 + done < <(find "$DIR" -name "*.swift") + + echo "Summary:" + echo "- Total Swift files found: $SWIFT_FILES" + echo "- Successfully processed: $PROCESSED" + echo "- Failed: $FAILED" +} + +# Main logic +if [ -f "$TARGET" ]; then + # Single file processing + if [[ "$TARGET" == *.swift ]]; then + process_swift_file "$TARGET" + else + echo "Error: File must have .swift extension" + exit 1 + fi +elif [ -d "$TARGET" ]; then + # Directory processing + process_directory "$TARGET" +else + echo "Error: $TARGET is neither a valid file nor directory" + exit 1 +fi \ No newline at end of file diff --git a/Scripts/watch-docc.sh b/Scripts/watch-docc.sh new file mode 100755 index 0000000..661ae45 --- /dev/null +++ b/Scripts/watch-docc.sh @@ -0,0 +1,174 @@ +#!/bin/bash + +# Help message +show_usage() { + echo "Usage: $0 " + echo "Watches the specified directory for changes in Swift and Markdown files" + echo "and automatically rebuilds DocC documentation to ./public directory" + exit 1 +} + +# Check if directory argument is provided +if [ $# -ne 1 ]; then + show_usage +fi + +# Configuration +WATCH_DIR="$1" # Use the provided directory +TEMP_DIR=$(mktemp -d) +OUTPUT_DIR="./public" +BUILD_CMD="xcodebuild docbuild -scheme DataThespian -derivedDataPath $TEMP_DIR" +PORT=8000 + +# Global variables for process management +SERVER_PID="" +FSWATCH_PID="" + +# Cleanup function for all processes and temporary directory +cleanup() { + echo -e "\nCleaning up..." + + # Kill the Python server + if [ ! -z "$SERVER_PID" ]; then + echo "Stopping web server (PID: $SERVER_PID)..." + kill -9 "$SERVER_PID" 2>/dev/null + wait "$SERVER_PID" 2>/dev/null + fi + + # Kill fswatch + if [ ! -z "$FSWATCH_PID" ]; then + echo "Stopping file watcher (PID: $FSWATCH_PID)..." + kill -9 "$FSWATCH_PID" 2>/dev/null + wait "$FSWATCH_PID" 2>/dev/null + fi + + # Kill any remaining Python servers on our port (belt and suspenders) + local remaining_servers=$(lsof -ti:$PORT) + if [ ! -z "$remaining_servers" ]; then + echo "Cleaning up remaining processes on port $PORT..." + kill -9 $remaining_servers 2>/dev/null + fi + + echo "Removing temporary directory..." + rm -rf "$TEMP_DIR" + + echo "Cleanup complete" + exit 0 +} + +# Register cleanup function for multiple signals +trap cleanup EXIT INT TERM + +# Validate watch directory +if [ ! -d "$WATCH_DIR" ]; then + echo "Error: Directory '$WATCH_DIR' does not exist" + exit 1 +fi + +# Create output directory if it doesn't exist +mkdir -p "$OUTPUT_DIR" + +# Check for required tools +if ! command -v fswatch >/dev/null 2>&1; then + echo "Error: This script requires fswatch on macOS." + echo "Install it using: brew install fswatch" + exit 1 +fi + +if ! command -v python3 >/dev/null 2>&1; then + echo "Error: This script requires python3 for the web server." + exit 1 +fi + +# Function to find the .doccarchive file +find_doccarchive() { + local archive_path=$(find "$TEMP_DIR" -name "*.doccarchive" -type d | head -n 1) + if [ -z "$archive_path" ]; then + echo "Error: Could not find .doccarchive file" + return 1 + fi + echo "$archive_path" +} + +# Function to start the web server +start_server() { + # Check if something is already running on the port + if lsof -Pi :$PORT -sTCP:LISTEN -t >/dev/null ; then + echo "Port $PORT is already in use. Attempting to clean up..." + kill -9 $(lsof -ti:$PORT) 2>/dev/null + sleep 1 + fi + + echo "Starting web server on http://localhost:$PORT ..." + cd "$OUTPUT_DIR" && python3 -m http.server $PORT & + SERVER_PID=$! + cd - > /dev/null + + # Wait a moment to ensure server starts + sleep 1 + + # Verify server started successfully + if ! lsof -Pi :$PORT -sTCP:LISTEN -t >/dev/null ; then + echo "Failed to start web server" + exit 1 + fi + + echo "Documentation is now available at: http://localhost:$PORT" +} + +# Function to rebuild documentation +rebuild_docs() { + echo "Changes detected in: $1" + echo "Rebuilding documentation..." + + # Clean temporary directory contents while preserving the directory + rm -rf "$TEMP_DIR"/* + + # Build documentation + eval "$BUILD_CMD" + if [ $? -ne 0 ]; then + echo "Error building documentation" + return 1 + fi + + # Find the .doccarchive file + local archive_path=$(find_doccarchive) + if [ $? -ne 0 ]; then + return 1 + fi + + # Process the archive for static hosting + echo "Processing documentation for static hosting..." + $(xcrun --find docc) process-archive \ + transform-for-static-hosting "$archive_path" \ + --output-path "$OUTPUT_DIR" \ + --hosting-base-path "/" + + if [ $? -eq 0 ]; then + echo "Documentation rebuilt successfully at $(date '+%H:%M:%S')" + echo "Documentation available at: http://localhost:$PORT" + else + echo "Error processing documentation archive" + fi +} + +# Initial build +echo "Performing initial documentation build..." +echo "Watching directory: $WATCH_DIR" +echo "Output directory: $OUTPUT_DIR" +rebuild_docs "initial build" + +# Start the web server after initial build +start_server + +# Watch for changes +echo "Watching for changes in Swift and Markdown files..." +fswatch -r "$WATCH_DIR" | while read -r file; do + if [[ "$file" =~ \.(swift|md)$ ]] || [[ "$file" =~ \.docc/ ]]; then + rebuild_docs "$file" + fi +done & +FSWATCH_PID=$! + +# Wait for fswatch to exit (which should only happen if there's an error) +wait $FSWATCH_PID diff --git a/Sources/DataThespian/Assert.swift b/Sources/DataThespian/Assert.swift index bb575a2..e4fb280 100644 --- a/Sources/DataThespian/Assert.swift +++ b/Sources/DataThespian/Assert.swift @@ -29,12 +29,30 @@ public import Foundation +/// Asserts that the current thread is the main thread if the `assertIsBackground` parameter is `true`. +/// +/// - Parameters: +/// - isMainThread: A boolean indicating whether the current thread should be the main thread. +/// - assertIsBackground: A boolean indicating whether the assertion should be made. @inlinable internal func assert(isMainThread: Bool, if assertIsBackground: Bool) { assert(!assertIsBackground || isMainThread == Thread.isMainThread) } -@inlinable internal func assert(isMainThread: Bool) { assert(isMainThread == Thread.isMainThread) } +/// Asserts that the current thread is the main thread. +/// +/// - Parameter isMainThread: A boolean indicating whether the current thread should be the main thread. +@inlinable internal func assert(isMainThread: Bool) { + assert(isMainThread == Thread.isMainThread) +} +/// Asserts that an error has occurred, logging the localized description of the error. +/// +/// - Parameters: +/// - error: The error that has occurred. +/// - file: The file in which the assertion occurred (default is the current file). +/// - line: The line in the file at which the assertion occurred (default is the current line). @inlinable internal func assertionFailure( error: any Error, file: StaticString = #file, line: UInt = #line -) { assertionFailure(error.localizedDescription, file: file, line: line) } +) { + assertionFailure(error.localizedDescription, file: file, line: line) +} diff --git a/Sources/DataThespian/Databases/BackgroundDatabase.swift b/Sources/DataThespian/Databases/BackgroundDatabase.swift index 10aa1d6..38306ee 100644 --- a/Sources/DataThespian/Databases/BackgroundDatabase.swift +++ b/Sources/DataThespian/Databases/BackgroundDatabase.swift @@ -31,15 +31,19 @@ import Foundation public import SwiftData + /// Represents a background database that can be used in a concurrent environment. public final class BackgroundDatabase: Database { private actor DatabaseContainer { private let factory: @Sendable () -> any Database private var wrappedTask: Task? - // swiftlint:disable:next strict_fileprivate - fileprivate init(factory: @escaping @Sendable () -> any Database) { self.factory = factory } + /// Initializes a `DatabaseContainer` with the given factory. + /// - Parameter factory: A closure that creates a new database instance. + fileprivate init(factory: @escaping @Sendable () -> any Database) { + self.factory = factory + } - // swiftlint:disable:next strict_fileprivate + /// Provides access to the database instance, creating it lazily if necessary. fileprivate var database: any Database { get async { if let wrappedTask { @@ -54,22 +58,42 @@ private let container: DatabaseContainer - private var database: any Database { get async { await container.database } } + /// The database instance, accessed asynchronously. + private var database: any Database { + get async { + await container.database + } + } + /// Initializes a `BackgroundDatabase` with the given database. + /// - Parameter database: a new database instance. public convenience init(database: @Sendable @escaping @autoclosure () -> any Database) { self.init(database) } + /// Initializes a `BackgroundDatabase` with the given database factory. + /// - Parameter factory: A closure that creates a new database instance. public init(_ factory: @Sendable @escaping () -> any Database) { self.container = .init(factory: factory) } + /// Executes the given closure within the context of the database's model context. + /// - Parameter closure: A closure that performs operations within the model context. + /// - Returns: The result of the closure. public func withModelContext(_ closure: @Sendable @escaping (ModelContext) throws -> T) async rethrows -> T - { try await self.database.withModelContext(closure) } + { + try await self.database.withModelContext(closure) + } } extension BackgroundDatabase { + /// Initializes a `BackgroundDatabase` with the given model container and + /// optional model context closure. + /// - Parameters: + /// - modelContainer: The model container to use. + /// - closure: An optional closure that creates a model context + /// from the provided model container. public convenience init( modelContainer: ModelContainer, modelContext closure: (@Sendable (ModelContainer) -> ModelContext)? = nil @@ -78,6 +102,8 @@ self.init(database: ModelActorDatabase(modelContainer: modelContainer, modelContext: closure)) } + /// Initializes a `BackgroundDatabase` with the given model container and the default model context. + /// - Parameter modelContainer: The model container to use. public convenience init( modelContainer: SwiftData.ModelContainer ) { @@ -87,6 +113,10 @@ ) } + /// Initializes a `BackgroundDatabase` with the given model container and model executor closure. + /// - Parameters: + /// - modelContainer: The model container to use. + /// - closure: A closure that creates a model executor from the provided model container. public convenience init( modelContainer: SwiftData.ModelContainer, modelExecutor closure: @Sendable @escaping (ModelContainer) -> any ModelExecutor diff --git a/Sources/DataThespian/Databases/Database+Extras.swift b/Sources/DataThespian/Databases/Database+Extras.swift index ea09701..bdad95a 100644 --- a/Sources/DataThespian/Databases/Database+Extras.swift +++ b/Sources/DataThespian/Databases/Database+Extras.swift @@ -32,16 +32,29 @@ public import SwiftData extension Database { + /// Executes a database transaction asynchronously. + /// + /// - Parameter block: A closure that performs database operations within the transaction. + /// - Throws: Any errors that occur during the transaction. public func transaction(_ block: @Sendable @escaping (ModelContext) throws -> Void) async throws { - try await self.withModelContext{ context in + try await self.withModelContext { context in try context.transaction { try block(context) } } } + + /// Deletes all models of the specified types from the database asynchronously. + /// + /// - Parameter types: An array of `PersistentModel.Type` instances + /// representing the model types to delete. + /// - Throws: Any errors that occur during the deletion process. public func deleteAll(of types: [any PersistentModel.Type]) async throws { - try await self.transaction { context in for type in types { try context.delete(model: type) } + try await self.transaction { context in + for type in types { + try context.delete(model: type) + } } } } diff --git a/Sources/DataThespian/Databases/Database+Queryable.swift b/Sources/DataThespian/Databases/Database+Queryable.swift index 403e585..bc32410 100644 --- a/Sources/DataThespian/Databases/Database+Queryable.swift +++ b/Sources/DataThespian/Databases/Database+Queryable.swift @@ -31,10 +31,17 @@ public import SwiftData extension Database { + /// Saves the current state of the database. + /// - Throws: Any errors that occur during the save operation. public func save() async throws { try await self.withModelContext { try $0.save() } } + /// Inserts a new persistent model into the database. + /// - Parameters: + /// - closuer: A closure that creates a new instance of the persistent model. + /// - closure: A closure that performs additional operations on the inserted model. + /// - Returns: The result of the `closure` parameter. public func insert( _ closuer: @Sendable @escaping () -> PersistentModelType, with closure: @escaping @Sendable (PersistentModelType) throws -> U @@ -44,6 +51,11 @@ } } + /// Retrieves an optional persistent model from the database. + /// - Parameters: + /// - selector: A selector that specifies the model to retrieve. + /// - closure: A closure that performs additional operations on the retrieved model. + /// - Returns: The result of the `closure` parameter. public func getOptional( for selector: Selector.Get, with closure: @escaping @Sendable (PersistentModelType?) throws -> U @@ -53,6 +65,11 @@ } } + /// Retrieves a list of persistent models from the database. + /// - Parameters: + /// - selector: A selector that specifies the models to retrieve. + /// - closure: A closure that performs additional operations on the retrieved models. + /// - Returns: The result of the `closure` parameter. public func fetch( for selector: Selector.List, with closure: @escaping @Sendable ([PersistentModelType]) throws -> U @@ -62,6 +79,9 @@ } } + /// Deletes a persistent model from the database. + /// - Parameter selector: A selector that specifies the model to delete. + /// - Throws: Any errors that occur during the delete operation. public func delete(_ selector: Selector.Delete) async throws { diff --git a/Sources/DataThespian/Databases/Database.swift b/Sources/DataThespian/Databases/Database.swift index 6a8c6d2..247ecc9 100644 --- a/Sources/DataThespian/Databases/Database.swift +++ b/Sources/DataThespian/Databases/Database.swift @@ -30,10 +30,15 @@ #if canImport(SwiftData) import Foundation - public import SwiftData + /// `Sendable` protocol for querying a `ModelContext`. public protocol Database: Sendable, Queryable { + /// Executes a closure safely within the context of a model. + /// + /// - Parameter closure: A closure that takes a `ModelContext` + /// and returns a `Sendable` value of type `T`. + /// - Returns: The value returned by the closure. func withModelContext(_ closure: @Sendable @escaping (ModelContext) throws -> T) async rethrows -> T } diff --git a/Sources/DataThespian/Databases/EnvironmentValues+Database.swift b/Sources/DataThespian/Databases/EnvironmentValues+Database.swift index 966ef15..37df93b 100644 --- a/Sources/DataThespian/Databases/EnvironmentValues+Database.swift +++ b/Sources/DataThespian/Databases/EnvironmentValues+Database.swift @@ -31,28 +31,46 @@ import Foundation import SwiftData public import SwiftUI - + /// Provides a default implementation of the `Database` protocol + /// for use in environments where no other database has been set. private struct DefaultDatabase: Database { + /// The singleton instance of the `DefaultDatabase`. static let instance = DefaultDatabase() - // swiftlint:disable:next unavailable_function + // swiftlint:disable unavailable_function + + /// Executes the provided closure within the context of the default model context, + /// asserting and throwing an error if no database has been set. + /// + /// - Parameter closure: A closure that takes a `ModelContext` and returns a value of type `T`. + /// - Returns: The value returned by the provided closure. func withModelContext(_ closure: (ModelContext) throws -> T) async rethrows -> T { assertionFailure("No Database Set.") fatalError("No Database Set.") } + // swiftlint:enable unavailable_function } extension EnvironmentValues { + /// The database to be used within the current environment. @Entry public var database: any Database = DefaultDatabase.instance } extension Scene { + /// Sets the database to be used within the current scene. + /// + /// - Parameter database: The database to be used. + /// - Returns: A modified `Scene` with the provided database. public func database(_ database: any Database) -> some Scene { environment(\.database, database) } } extension View { + /// Sets the database to be used within the current view. + /// + /// - Parameter database: The database to be used. + /// - Returns: A modified `View` with the provided database. public func database(_ database: any Database) -> some View { environment(\.database, database) } diff --git a/Sources/DataThespian/Databases/ModelActor+Database.swift b/Sources/DataThespian/Databases/ModelActor+Database.swift index 6b1e1e1..885ebac 100644 --- a/Sources/DataThespian/Databases/ModelActor+Database.swift +++ b/Sources/DataThespian/Databases/ModelActor+Database.swift @@ -31,8 +31,13 @@ public import SwiftData extension ModelActor where Self: Database { + /// A Boolean value indicating whether the current thread is the background thread. public static var assertIsBackground: Bool { false } + /// Executes a closure within the context of the model. + /// + /// - Parameter closure: The closure to execute within the model context. + /// - Returns: The result of the closure execution. public func withModelContext( _ closure: @Sendable @escaping (ModelContext) throws -> T ) async rethrows -> T { diff --git a/Sources/DataThespian/Databases/ModelActorDatabase.swift b/Sources/DataThespian/Databases/ModelActorDatabase.swift index c451b99..b8a95a3 100644 --- a/Sources/DataThespian/Databases/ModelActorDatabase.swift +++ b/Sources/DataThespian/Databases/ModelActorDatabase.swift @@ -30,18 +30,15 @@ #if canImport(SwiftData) public import SwiftData - // @ModelActor - // public actor ModelActorDatabase: Database {} - + /// Simplied and customizable `ModelActor` ``Database``. public actor ModelActorDatabase: Database, ModelActor { + /// The model executor used by this database. public nonisolated let modelExecutor: any SwiftData.ModelExecutor + /// The model container used by this database. public nonisolated let modelContainer: SwiftData.ModelContainer - private init(modelExecutor: any ModelExecutor, modelContainer: ModelContainer) { - self.modelExecutor = modelExecutor - self.modelContainer = modelContainer - } - + /// Initializes a new `ModelActorDatabase` with the given `modelContainer`. + /// - Parameter modelContainer: The model container to use for this database. public init(modelContainer: SwiftData.ModelContainer) { self.init( modelContainer: modelContainer, @@ -49,6 +46,12 @@ ) } + /// Initializes a new `ModelActorDatabase` with + /// the given `modelContainer` and a custom `modelContext` closure. + /// - Parameters: + /// - modelContainer: The model container to use for this database. + /// - closure: A closure that creates a + /// custom `ModelContext` from the `ModelContainer`. public init( modelContainer: SwiftData.ModelContainer, modelContext closure: @Sendable @escaping (ModelContainer) -> ModelContext @@ -59,6 +62,12 @@ ) } + /// Initializes a new `ModelActorDatabase` with + /// the given `modelContainer` and a custom `modelExecutor` closure. + /// - Parameters: + /// - modelContainer: The model container to use for this database. + /// - closure: A closure that creates + /// a custom `ModelExecutor` from the `ModelContainer`. public init( modelContainer: SwiftData.ModelContainer, modelExecutor closure: @Sendable @escaping (ModelContainer) -> any ModelExecutor @@ -68,6 +77,11 @@ modelContainer: modelContainer ) } + + private init(modelExecutor: any ModelExecutor, modelContainer: ModelContainer) { + self.modelExecutor = modelExecutor + self.modelContainer = modelContainer + } } extension DefaultSerialModelExecutor { diff --git a/Sources/DataThespian/Databases/QueryError.swift b/Sources/DataThespian/Databases/QueryError.swift index 7b31182..d180fac 100644 --- a/Sources/DataThespian/Databases/QueryError.swift +++ b/Sources/DataThespian/Databases/QueryError.swift @@ -29,8 +29,11 @@ #if canImport(SwiftData) public import SwiftData - + /// An error that occurs when a query fails to find an item. public enum QueryError: Error { + /// Indicates that the item was not found. + /// + /// - Parameter selector: The `Selector.Get` instance that was used to perform the query. case itemNotFound(Selector.Get) } #endif diff --git a/Sources/DataThespian/Databases/Queryable+Extensions.swift b/Sources/DataThespian/Databases/Queryable+Extensions.swift index 10c8949..1e05828 100644 --- a/Sources/DataThespian/Databases/Queryable+Extensions.swift +++ b/Sources/DataThespian/Databases/Queryable+Extensions.swift @@ -31,13 +31,19 @@ public import SwiftData extension Queryable { + /// Inserts a new persistent model into the database + /// - Parameter closure: A closure that creates and returns a new persistent model + /// - Returns: A wrapped Model instance containing the inserted persistent model @discardableResult public func insert( - _ closuer: @Sendable @escaping () -> PersistentModelType + _ closure: @Sendable @escaping () -> PersistentModelType ) async -> Model { - await self.insert(closuer, with: Model.init) + await self.insert(closure, with: Model.init) } + /// Retrieves an optional model matching the given selector + /// - Parameter selector: A selector defining the query criteria for retrieving the model + /// - Returns: An optional wrapped Model instance if found, nil otherwise public func getOptional( for selector: Selector.Get ) async -> Model? { @@ -46,6 +52,9 @@ } } + /// Fetches an array of models matching the given list selector + /// - Parameter selector: A selector defining the query criteria for retrieving multiple models + /// - Returns: An array of wrapped Model instances matching the selector criteria public func fetch( for selector: Selector.List ) async -> [Model] { @@ -54,6 +63,11 @@ } } + /// Fetches and transforms multiple models using an array of selectors + /// - Parameters: + /// - selectors: An array of selectors to fetch models + /// - closure: A transformation closure to apply to each fetched model + /// - Returns: An array of transformed results public func fetch( for selectors: [Selector.Get], with closure: @escaping @Sendable (PersistentModelType) throws -> U @@ -81,6 +95,10 @@ ) } + /// Retrieves a required model matching the given selector + /// - Parameter selector: A selector defining the query criteria + /// - Returns: A wrapped Model instance + /// - Throws: QueryError.itemNotFound if the model doesn't exist public func get( for selector: Selector.Get ) async throws -> Model { @@ -92,6 +110,12 @@ } } + /// Retrieves and transforms a required model matching the given selector + /// - Parameters: + /// - selector: A selector defining the query criteria + /// - closure: A transformation closure to apply to the fetched model + /// - Returns: The transformed result + /// - Throws: QueryError.itemNotFound if the model doesn't exist public func get( for selector: Selector.Get, with closure: @escaping @Sendable (PersistentModelType) throws -> U @@ -104,6 +128,11 @@ } } + /// Updates a single model matching the given selector + /// - Parameters: + /// - selector: A selector defining the model to update + /// - closure: A closure that performs the update operation + /// - Throws: QueryError.itemNotFound if the model doesn't exist public func update( for selector: Selector.Get, with closure: @escaping @Sendable (PersistentModelType) throws -> Void @@ -111,6 +140,11 @@ try await self.get(for: selector, with: closure) } + /// Updates multiple models matching the given list selector + /// - Parameters: + /// - selector: A selector defining the models to update + /// - closure: A closure that performs the update operation on the array of models + /// - Throws: Rethrows any errors from the update closure public func update( for selector: Selector.List, with closure: @escaping @Sendable ([PersistentModelType]) throws -> Void @@ -118,6 +152,11 @@ try await self.fetch(for: selector, with: closure) } + /// Inserts a model if it doesn't already exist based on a selector + /// - Parameters: + /// - model: A closure that creates the model to insert + /// - selector: A closure that creates a selector from the model to check existence + /// - Returns: Either the existing model or the newly inserted model public func insertIf( _ model: @Sendable @escaping () -> PersistentModelType, notExist selector: @Sendable @escaping (PersistentModelType) -> @@ -134,6 +173,13 @@ } } + /// Inserts a model if it doesn't exist and transforms it + /// - Parameters: + /// - model: A closure that creates the model to insert + /// - selector: A closure that creates a selector from the model to check existence + /// - closure: A transformation closure to apply to the resulting model + /// - Returns: The transformed result + /// - Throws: Rethrows any errors from the transformation closure public func insertIf( _ model: @Sendable @escaping () -> PersistentModelType, notExist selector: @Sendable @escaping (PersistentModelType) -> @@ -146,6 +192,9 @@ } extension Queryable { + /// Deletes multiple models from the database + /// - Parameter models: An array of models to delete + /// - Throws: Rethrows any errors that occur during deletion public func deleteModels(_ models: [Model]) async throws { diff --git a/Sources/DataThespian/Databases/Queryable.swift b/Sources/DataThespian/Databases/Queryable.swift index 8bb0de9..035d0ce 100644 --- a/Sources/DataThespian/Databases/Queryable.swift +++ b/Sources/DataThespian/Databases/Queryable.swift @@ -29,25 +29,53 @@ #if canImport(SwiftData) public import SwiftData - + /// Providers a set of _CRUD_ methods for a ``Database``. public protocol Queryable: Sendable { + /// Saves the current state of the Queryable instance to the persistent data store. + /// - Throws: An error that indicates why the save operation failed. func save() async throws + /// Inserts a new persistent model into the data store and returns a transformed result. + /// - Parameters: + /// - insertClosure: A closure that creates a new instance of the `PersistentModelType`. + /// - closure: A closure that performs some operation + /// on the newly inserted `PersistentModelType` instance + /// and returns a transformed result of type `U`. + /// - Returns: The transformed result of type `U`. func insert( - _ closuer: @Sendable @escaping () -> PersistentModelType, + _ insertClosure: @Sendable @escaping () -> PersistentModelType, with closure: @escaping @Sendable (PersistentModelType) throws -> U ) async rethrows -> U + /// Retrieves an optional persistent model from the data store and returns a transformed result. + /// - Parameters: + /// - selector: A `Selector.Get` instance + /// that defines the criteria for retrieving the persistent model. + /// - closure: A closure that performs some operation on + /// the retrieved `PersistentModelType` instance (or `nil`) + /// and returns a transformed result of type `U`. + /// - Returns: The transformed result of type `U`. func getOptional( for selector: Selector.Get, with closure: @escaping @Sendable (PersistentModelType?) throws -> U ) async rethrows -> U + /// Retrieves a list of persistent models from the data store and returns a transformed result. + /// - Parameters: + /// - selector: A `Selector.List` instance + /// that defines the criteria for retrieving the list of persistent models. + /// - closure: A closure that performs some operation on t + /// he retrieved list of `PersistentModelType` instances and returns a transformed result of type `U`. + /// - Returns: The transformed result of type `U`. func fetch( for selector: Selector.List, with closure: @escaping @Sendable ([PersistentModelType]) throws -> U ) async rethrows -> U + /// Deletes one or more persistent models from the data store based on the provided selector. + /// - Parameter selector: A `Selector.Delete` instance + /// that defines the criteria for deleting the persistent models. + /// - Throws: An error that indicates why the delete operation failed. func delete(_ selector: Selector.Delete) async throws } #endif diff --git a/Sources/DataThespian/Databases/Selector.swift b/Sources/DataThespian/Databases/Selector.swift index 2de45e7..e59ed1b 100644 --- a/Sources/DataThespian/Databases/Selector.swift +++ b/Sources/DataThespian/Databases/Selector.swift @@ -30,23 +30,40 @@ #if canImport(SwiftData) public import Foundation public import SwiftData - + /// A type that represents a selector for interacting with a `PersistentModel`. public enum Selector: Sendable { + /// A type that represents a way to delete data from a `PersistentModel`. public enum Delete: Sendable { + /// Deletes data that matches the provided `Predicate`. case predicate(Predicate) + /// Deletes all data for the `PersistentModel`. case all + /// Deletes the provided `Model`. case model(Model) } + + /// A type that represents a way to fetch data from a `PersistentModel`. public enum List: Sendable { + /// Fetches data using the provided `FetchDescriptor`. case descriptor(FetchDescriptor) } + + /// A type that represents a way to retrieve a `PersistentModel`. public enum Get: Sendable { + /// Retrieves the `Model` with the provided `Model`. case model(Model) + /// Retrieves the `PersistentModel` instances that match the provided `Predicate`. case predicate(Predicate) } } extension Selector.Get { + /// Retrieves the `PersistentModel` instance with the provided unique key value. + /// + /// - Parameters: + /// - key: The unique key to search for. + /// - value: The value of the unique key to search for. + /// - Returns: A `Selector.Get` case that can be used to retrieve the `PersistentModel` instance. @available(*, unavailable, message: "Not implemented yet.") public static func unique( _ key: UniqueKeyableType, @@ -59,6 +76,15 @@ } extension Selector.List { + /// Creates a `Selector.List` case + /// that fetches `PersistentModel` instances using the provided parameters. + /// + /// - Parameters: + /// - type: The type of `PersistentModel` to fetch. + /// - predicate: An optional `Predicate` to filter the results. + /// - sortBy: An optional array of `SortDescriptor` instances to sort the results. + /// - fetchLimit: An optional limit on the number of results to fetch. + /// - Returns: A `Selector.List` case that can be used to fetch `PersistentModel` instances. public static func descriptor( _ type: T.Type, predicate: Predicate? = nil, @@ -68,6 +94,14 @@ .descriptor(.init(predicate: predicate, sortBy: sortBy, fetchLimit: fetchLimit)) } + /// Creates a `Selector.List` case that fetches `PersistentModel` instances + /// using the provided parameters. + /// + /// - Parameters: + /// - predicate: An optional `Predicate` to filter the results. + /// - sortBy: An optional array of `SortDescriptor` instances to sort the results. + /// - fetchLimit: An optional limit on the number of results to fetch. + /// - Returns: A `Selector.List` case that can be used to fetch `PersistentModel` instances. public static func descriptor( predicate: Predicate? = nil, sortBy: [SortDescriptor] = [], @@ -76,10 +110,18 @@ .descriptor(.init(predicate: predicate, sortBy: sortBy, fetchLimit: fetchLimit)) } + /// Creates a `Selector.List` case that fetches all `PersistentModel` instances of the provided type. + /// + /// - Parameter type: The type of `PersistentModel` to fetch. + /// - Returns: A `Selector.List` case + /// that can be used to fetch all `PersistentModel` instances of the provided type. public static func all(_ type: T.Type) -> Selector.List { .descriptor(.init()) } + /// Creates a `Selector.List` case that fetches all `PersistentModel` instances. + /// + /// - Returns: A `Selector.List` case that can be used to fetch all `PersistentModel` instances. public static func all() -> Selector.List { .descriptor(.init()) } diff --git a/Sources/DataThespian/Databases/Unique.swift b/Sources/DataThespian/Databases/Unique.swift index 5371263..0afbdb4 100644 --- a/Sources/DataThespian/Databases/Unique.swift +++ b/Sources/DataThespian/Databases/Unique.swift @@ -27,6 +27,9 @@ // OTHER DEALINGS IN THE SOFTWARE. // +/// A protocol that defines a type as being unique. +@_documentation(visibility: internal) public protocol Unique { + /// The associated type that conforms to `UniqueKeys` and represents the unique keys for this type. associatedtype Keys: UniqueKeys } diff --git a/Sources/DataThespian/Databases/UniqueKey.swift b/Sources/DataThespian/Databases/UniqueKey.swift index 9981b50..8a7913e 100644 --- a/Sources/DataThespian/Databases/UniqueKey.swift +++ b/Sources/DataThespian/Databases/UniqueKey.swift @@ -29,9 +29,18 @@ public import Foundation +/// A protocol that defines a unique key for a model type. +@_documentation(visibility: internal) public protocol UniqueKey: Sendable { + /// The model type associated with this unique key. associatedtype Model: Unique + + /// The value type associated with this unique key. associatedtype ValueType: Sendable & Equatable & Codable + /// Creates a predicate that checks if the model's value for this key equals the specified value. + /// + /// - Parameter value: The value to compare against. + /// - Returns: A predicate that checks if the model's value for this key equals the specified value. func predicate(equals value: ValueType) -> Predicate } diff --git a/Sources/DataThespian/Databases/UniqueKeyPath.swift b/Sources/DataThespian/Databases/UniqueKeyPath.swift index 9c065ca..1b17300 100644 --- a/Sources/DataThespian/Databases/UniqueKeyPath.swift +++ b/Sources/DataThespian/Databases/UniqueKeyPath.swift @@ -29,15 +29,28 @@ public import Foundation +// swiftlint:disable unavailable_function + +/// A struct that represents a unique key path for a model type. +@_documentation(visibility: internal) public struct UniqueKeyPath: UniqueKey { + /// The key path for the model type. private let keyPath: KeyPath & Sendable + /// Initializes a new instance of `UniqueKeyPath` with the given key path. + /// + /// - Parameter keyPath: The key path for the model type. internal init(keyPath: any KeyPath & Sendable) { self.keyPath = keyPath } - // swiftlint:disable:next unavailable_function + /// Creates a predicate that checks if the value of the key path is equal to the given value. + /// + /// - Parameter value: The value to compare against. + /// - Returns: A predicate that can be used to filter models. public func predicate(equals value: ValueType) -> Predicate { fatalError("Not implemented yet.") } } + +// swiftlint:enable unavailable_function diff --git a/Sources/DataThespian/Databases/UniqueKeys.swift b/Sources/DataThespian/Databases/UniqueKeys.swift index aded055..bc32715 100644 --- a/Sources/DataThespian/Databases/UniqueKeys.swift +++ b/Sources/DataThespian/Databases/UniqueKeys.swift @@ -27,14 +27,40 @@ // OTHER DEALINGS IN THE SOFTWARE. // +/// A protocol that defines the rules for a type that represents the unique keys for a `Model` type. +/// +/// The `UniqueKeys` protocol has two associated type requirements: +/// +/// - `Model`: The type for which the unique keys are defined. +/// This type must conform to the `Unique` protocol. +/// - `PrimaryKey`: The type that represents the primary key for the `Model` type. +/// This type must conform to the `UniqueKey` protocol, and +/// its `Model` associated type must be the same as +/// the `Model` associated type of the `UniqueKeys` protocol. +/// +/// The protocol also has a static property requirement, `primary`, +/// which returns the primary key for the `Model` type. +@_documentation(visibility: internal) public protocol UniqueKeys: Sendable { + /// The type for which the unique keys are defined. This type must conform to the `Unique` protocol. associatedtype Model: Unique + + /// The type that represents the primary key for the `Model` type. + /// This type must conform to the `UniqueKey` protocol, and + /// its `Model` associated type must be the same as + /// the `Model` associated type of the `UniqueKeys` protocol. associatedtype PrimaryKey: UniqueKey where PrimaryKey.Model == Model + /// The primary key for the `Model` type. static var primary: PrimaryKey { get } } extension UniqueKeys { + /// Creates a `UniqueKeyPath` instance for the specified key path. + /// + /// - Parameter keyPath: A key path for a property of + /// the `Model` type. The property must be `Sendable`, `Equatable`, and `Codable`. + /// - Returns: A `UniqueKeyPath` instance for the specified key path. public static func keyPath( _ keyPath: any KeyPath & Sendable ) -> UniqueKeyPath { diff --git a/Sources/DataThespian/Documentation.docc/Documentation.md b/Sources/DataThespian/Documentation.docc/Documentation.md index 3777052..0347d3b 100644 --- a/Sources/DataThespian/Documentation.docc/Documentation.md +++ b/Sources/DataThespian/Documentation.docc/Documentation.md @@ -1,13 +1,88 @@ # ``DataThespian`` -Summary +A thread-safe implementation of SwiftData. ## Overview -Text +DataThespian combines the power of Actors, SwiftData, and ModelActors to create an optimized and easy-to-use APIs for developers. + +### Requirements + +**Apple Platforms** + +- Xcode 16.0 or later +- Swift 6.0 or later +- iOS 17 / watchOS 10.0 / tvOS 17 / macOS 14 or later deployment targets + +**Linux** + +- Ubuntu 20.04 or later +- Swift 6.0 or later + +### Installation + +To integrate **DataThespian** into your app using SPM, specify it in your Package.swift file: + +```swift +let package = Package( + ... + dependencies: [ + .package( + url: "https://github.com/brightdigit/DataThespian.git", from: "1.0.0" + ) + ], + targets: [ + .target( + name: "YourApps", + dependencies: [ + .product( + name: "DataThespian", + package: "DataThespian" + ), ... + ]), + ... + ] +) +``` ## Topics -### Group +### Database + +- ``Database`` +- ``BackgroundDatabase`` +- ``ModelActorDatabase`` + +### Querying + +- ``Queryable`` +- ``QueryError`` +- ``Selector`` +- ``Model`` +- ``Unique`` +- ``UniqueKey`` +- ``UniqueKeys`` +- ``UniqueKeyPath`` + +### Monitoring + +- ``DataMonitor`` +- ``DataAgent`` +- ``DatabaseChangeSet`` +- ``DatabaseMonitoring`` +- ``AgentRegister`` +- ``ManagedObjectMetadata`` +- ``DatabaseChangePublicist`` +- ``DatabaseChangeType`` + +### Syncronization + +- ``CollectionSyncronizer`` +- ``ModelDifferenceSyncronizer`` +- ``ModelSyncronizer`` +- ``SynchronizationDifference`` +- ``CollectionDifference`` + +### Logging -- ``Symbol`` \ No newline at end of file +- ``ThespianLogging`` diff --git a/Sources/DataThespian/Model.swift b/Sources/DataThespian/Model.swift index 0c94614..22ccd39 100644 --- a/Sources/DataThespian/Model.swift +++ b/Sources/DataThespian/Model.swift @@ -30,25 +30,48 @@ #if canImport(SwiftData) import Foundation public import SwiftData - + /// Phantom Type for easily retrieving fetching `PersistentModel` objects from a `ModelContext`. public struct Model: Sendable, Identifiable { - public struct NotFoundError: Error { public let persistentIdentifier: PersistentIdentifier } + /// An error that is thrown when a `PersistentModel` + /// with the specified `PersistentIdentifier` is not found. + public struct NotFoundError: Error { + /// The `PersistentIdentifier` of the `PersistentModel` that was not found. + public let persistentIdentifier: PersistentIdentifier + } + /// The unique identifier of the model. public var id: PersistentIdentifier.ID { persistentIdentifier.id } + + /// The `PersistentIdentifier` of the model. public let persistentIdentifier: PersistentIdentifier + /// Initializes a new `Model` instance with the specified `PersistentIdentifier`. + /// + /// - Parameter persistentIdentifier: The `PersistentIdentifier` of the model. public init(persistentIdentifier: PersistentIdentifier) { self.persistentIdentifier = persistentIdentifier } } extension Model where T: PersistentModel { + /// A boolean value indicating whether the model is temporary or not. public var isTemporary: Bool { self.persistentIdentifier.isTemporary ?? false } - public init(_ model: T) { self.init(persistentIdentifier: model.persistentModelID) } + /// Initializes a new `Model` instance with the specified `PersistentModel`. + /// + /// - Parameter model: The `PersistentModel` to initialize the `Model` with. + public init(_ model: T) { + self.init(persistentIdentifier: model.persistentModelID) + } - internal static func ifMap(_ model: T?) -> Model? { model.map(self.init) } + /// Creates a new `Model` instance from the specified `PersistentModel`. + /// + /// - Parameter model: The `PersistentModel` to create the `Model` from. + /// - Returns: A new `Model` instance, or `nil` if the `PersistentModel` is `nil`. + internal static func ifMap(_ model: T?) -> Model? { + model.map(self.init) + } } #endif diff --git a/Sources/DataThespian/Notification/AgentRegister.swift b/Sources/DataThespian/Notification/AgentRegister.swift index 3f4f89c..932b3e3 100644 --- a/Sources/DataThespian/Notification/AgentRegister.swift +++ b/Sources/DataThespian/Notification/AgentRegister.swift @@ -28,11 +28,32 @@ // #if canImport(SwiftData) + /// + /// A protocol that defines an agent register for a specific agent type. + /// public protocol AgentRegister: Sendable { + /// + /// The agent type associated with this register. + /// associatedtype AgentType: DataAgent + + /// + /// The unique identifier for this agent register. + /// var id: String { get } + + /// + /// Asynchronously retrieves the agent associated with this register. + /// + /// - Returns: The agent associated with this register. + /// @Sendable func agent() async -> AgentType } - extension AgentRegister { public var id: String { "\(AgentType.self)" } } + extension AgentRegister { + /// + /// The unique identifier for this agent register. + /// + public var id: String { "\(AgentType.self)" } + } #endif diff --git a/Sources/DataThespian/Notification/Combine/DatabaseChangePublicist.swift b/Sources/DataThespian/Notification/Combine/DatabaseChangePublicist.swift index f8f3b3d..8b81b51 100644 --- a/Sources/DataThespian/Notification/Combine/DatabaseChangePublicist.swift +++ b/Sources/DataThespian/Notification/Combine/DatabaseChangePublicist.swift @@ -31,21 +31,34 @@ public import Combine private struct NeverDatabaseMonitor: DatabaseMonitoring { + /// Registers an agent with the database monitor, but always fails. + /// - Parameters: + /// - _: The agent to register. + /// - _: A flag indicating whether the registration should be forced. func register(_: any AgentRegister, force _: Bool) { assertionFailure("Using Empty Database Listener") } } + /// A struct that publishes database change events. public struct DatabaseChangePublicist: Sendable { private let dbWatcher: DatabaseMonitoring + + /// Initializes a new `DatabaseChangePublicist` instance. + /// - Parameter dbWatcher: The database monitoring instance to use. Defaults to `DataMonitor.shared`. public init(dbWatcher: any DatabaseMonitoring = DataMonitor.shared) { self.dbWatcher = dbWatcher } + /// Creates a `DatabaseChangePublicist` that never publishes any changes. public static func never() -> DatabaseChangePublicist { self.init(dbWatcher: NeverDatabaseMonitor()) } + /// Publishes database change events for the specified ID. + /// - Parameter id: The ID of the entity to watch for changes. + /// - Returns: A publisher that emits `DatabaseChangeSet` values + /// whenever the database changes for the specified ID. @Sendable public func callAsFunction(id: String) -> some Publisher { // print("Creating Publisher for \(id)") diff --git a/Sources/DataThespian/Notification/Combine/EnvironmentValues+DatabaseChangePublicist.swift b/Sources/DataThespian/Notification/Combine/EnvironmentValues+DatabaseChangePublicist.swift index 673cca9..66f3d4a 100644 --- a/Sources/DataThespian/Notification/Combine/EnvironmentValues+DatabaseChangePublicist.swift +++ b/Sources/DataThespian/Notification/Combine/EnvironmentValues+DatabaseChangePublicist.swift @@ -33,6 +33,7 @@ public import SwiftUI extension EnvironmentValues { + /// A `DatabaseChangePublicist` that determines how database changes are propagated to the UI. @Entry public var databaseChangePublicist: DatabaseChangePublicist = .never() } #endif diff --git a/Sources/DataThespian/Notification/Combine/PublishingAgent.swift b/Sources/DataThespian/Notification/Combine/PublishingAgent.swift index ab019bb..af394f4 100644 --- a/Sources/DataThespian/Notification/Combine/PublishingAgent.swift +++ b/Sources/DataThespian/Notification/Combine/PublishingAgent.swift @@ -31,27 +31,46 @@ @preconcurrency import Combine import Foundation + /// An actor that manages the publishing of database change sets. internal actor PublishingAgent: DataAgent, Loggable { + /// The subscription event. private enum SubscriptionEvent: Sendable { case cancel case subscribe } + /// The logging category for the `PublishingAgent`. internal static var loggingCategory: ThespianLogging.Category { .application } + /// The unique identifier for the agent. internal let agentID = UUID() + + /// The identifier for the agent. private let id: String + + /// The subject that publishes the database change sets. private let subject: PassthroughSubject + + /// The number of subscriptions. private var subscriptionCount = 0 + + /// The cancellable for the subject. private var cancellable: AnyCancellable? + + /// The completion closure. private var completed: (@Sendable () -> Void)? + /// Initializes a new `PublishingAgent` instance. + /// - Parameters: + /// - id: The identifier for the agent. + /// - subject: The subject that publishes the database change sets. internal init(id: String, subject: PassthroughSubject) { self.id = id self.subject = subject Task { await self.initialize() } } + /// Initializes the agent. private func initialize() { cancellable = subject.handleEvents { _ in self.onSubscriptionEvent(.subscribe) @@ -63,10 +82,14 @@ } } + /// Handles a subscription event. + /// - Parameter event: The subscription event. private nonisolated func onSubscriptionEvent(_ event: SubscriptionEvent) { Task { await self.updateScriptionStatus(byEvent: event) } } + /// Updates the subscription status. + /// - Parameter event: The subscription event. private func updateScriptionStatus(byEvent event: SubscriptionEvent) { let oldCount = subscriptionCount let delta: Int = @@ -82,14 +105,19 @@ ) } + /// Handles an update to the database. + /// - Parameter update: The database change set. nonisolated internal func onUpdate(_ update: any DatabaseChangeSet) { Task { await self.sendUpdate(update) } } + /// Sends the update to the subject. + /// - Parameter update: The database change set. private func sendUpdate(_ update: any DatabaseChangeSet) { Task { @MainActor in await self.subject.send(update) } } + /// Cancels the agent. private func cancel() { Self.logger.debug("Cancelling \(self.id) \(self.agentID)") cancellable?.cancel() @@ -98,16 +126,21 @@ completed = nil } + /// Sets the completion closure. + /// - Parameter closure: The completion closure. nonisolated internal func onCompleted(_ closure: @escaping @Sendable () -> Void) { Task { await self.setCompleted(closure) } } + /// Sets the completion closure. + /// - Parameter closure: The completion closure. internal func setCompleted(_ closure: @escaping @Sendable () -> Void) { Self.logger.debug("SetCompleted \(self.id) \(self.agentID)") assert(completed == nil) completed = closure } + /// Finishes the agent. internal func finish() { cancel() } } #endif diff --git a/Sources/DataThespian/Notification/Combine/PublishingRegister.swift b/Sources/DataThespian/Notification/Combine/PublishingRegister.swift index a87f355..72c1166 100644 --- a/Sources/DataThespian/Notification/Combine/PublishingRegister.swift +++ b/Sources/DataThespian/Notification/Combine/PublishingRegister.swift @@ -31,15 +31,27 @@ @preconcurrency import Combine import Foundation + /// A register that manages the publication of database changes. internal struct PublishingRegister: AgentRegister { + /// The unique identifier for the register. internal let id: String + + /// The subject that publishes database change sets. private let subject: PassthroughSubject + /// Initializes a new instance of `PublishingRegister`. + /// + /// - Parameters: + /// - id: The unique identifier for the register. + /// - subject: The subject that publishes database change sets. internal init(id: String, subject: PassthroughSubject) { self.id = id self.subject = subject } + /// Creates a new publishing agent. + /// + /// - Returns: A new instance of `PublishingAgent`. internal func agent() async -> PublishingAgent { let agent = AgentType(id: id, subject: subject) diff --git a/Sources/DataThespian/Notification/DataAgent.swift b/Sources/DataThespian/Notification/DataAgent.swift index a201f75..0244f97 100644 --- a/Sources/DataThespian/Notification/DataAgent.swift +++ b/Sources/DataThespian/Notification/DataAgent.swift @@ -29,12 +29,29 @@ #if canImport(SwiftData) public import Foundation + /// A protocol that defines a data agent responsible for managing database updates and completions. public protocol DataAgent: Sendable { + /// The unique identifier of the agent. var agentID: UUID { get } + + /// Called when the database is updated. + /// + /// - Parameter update: The database change set. func onUpdate(_ update: any DatabaseChangeSet) + + /// Called when the data agent's operations are completed. + /// + /// - Parameter closure: The closure to be executed when the operations are completed. func onCompleted(_ closure: @Sendable @escaping () -> Void) + + /// Finishes the data agent's operations. func finish() async } - extension DataAgent { public func onCompleted(_: @Sendable @escaping () -> Void) {} } + extension DataAgent { + /// Called when the data agent's operations are completed. + /// + /// - Parameter _: The closure to be executed when the operations are completed. + public func onCompleted(_: @Sendable @escaping () -> Void) {} + } #endif diff --git a/Sources/DataThespian/Notification/DataMonitor.swift b/Sources/DataThespian/Notification/DataMonitor.swift index 413d691..86c6974 100644 --- a/Sources/DataThespian/Notification/DataMonitor.swift +++ b/Sources/DataThespian/Notification/DataMonitor.swift @@ -33,10 +33,12 @@ import CoreData import Foundation import SwiftData - + /// Monitors the database for changes and notifies registered agents of those changes. public actor DataMonitor: DatabaseMonitoring, Loggable { + /// The logging category for this class. public static var loggingCategory: ThespianLogging.Category { .data } + /// The shared instance of the `DataMonitor`. public static let shared = DataMonitor() private var object: (any NSObjectProtocol)? @@ -44,6 +46,12 @@ private init() { Self.logger.debug("Creating DatabaseMonitor") } + /// Registers the given agent with the database monitor. + /// + /// - Parameters: + /// - registration: The agent to register. + /// - force: Whether to force the registration, + /// even if a registration with the same ID already exists. public nonisolated func register(_ registration: any AgentRegister, force: Bool) { Task { await self.addRegistration(registration, force: force) } } @@ -52,6 +60,9 @@ registrations.add(withID: registration.id, force: force, agent: registration.agent) } + /// Begins monitoring the database with the given agent registrations. + /// + /// - Parameter builders: The agent registrations to monitor. public nonisolated func begin(with builders: [any AgentRegister]) { Task { await self.addObserver() diff --git a/Sources/DataThespian/Notification/DatabaseChangeSet.swift b/Sources/DataThespian/Notification/DatabaseChangeSet.swift index 310b70f..2940496 100644 --- a/Sources/DataThespian/Notification/DatabaseChangeSet.swift +++ b/Sources/DataThespian/Notification/DatabaseChangeSet.swift @@ -28,15 +28,30 @@ // #if canImport(SwiftData) + /// A protocol that represents a set of changes to a database. public protocol DatabaseChangeSet: Sendable { + /// The set of inserted managed object metadata. var inserted: Set { get } + + /// The set of deleted managed object metadata. var deleted: Set { get } + + /// The set of updated managed object metadata. var updated: Set { get } } extension DatabaseChangeSet { + /// A boolean value that indicates whether the change set is empty. public var isEmpty: Bool { inserted.isEmpty && deleted.isEmpty && updated.isEmpty } + /// Checks whether the change set contains any changes of the specified types + /// that match the provided entity names. + /// + /// - Parameters: + /// - types: The set of change types to check for. Defaults to `.all`. + /// - filteringEntityNames: The set of entity names to filter by. + /// - Returns: `true` if the change set contains any changes of the specified types + /// that match the provided entity names, `false` otherwise. public func update( of types: Set = .all, contains filteringEntityNames: Set ) -> Bool { diff --git a/Sources/DataThespian/Notification/DatabaseChangeType.swift b/Sources/DataThespian/Notification/DatabaseChangeType.swift index d2418eb..47b3b39 100644 --- a/Sources/DataThespian/Notification/DatabaseChangeType.swift +++ b/Sources/DataThespian/Notification/DatabaseChangeType.swift @@ -27,21 +27,32 @@ // OTHER DEALINGS IN THE SOFTWARE. // +/// An enumeration that represents the different types of changes that can occur in a database. public enum DatabaseChangeType: CaseIterable, Sendable { + /// Represents an insertion of a new record in the database. case inserted + /// Represents a deletion of a record in the database. case deleted + /// Represents an update to an existing record in the database. case updated + #if canImport(SwiftData) + /// The key path associated with the current change type. internal var keyPath: KeyPath> { switch self { - case .inserted: \.inserted - case .deleted: \.deleted - case .updated: \.updated + case .inserted: + return \.inserted + case .deleted: + return \.deleted + case .updated: + return \.updated } } #endif } +/// An extension to `Set` where the `Element` is `DatabaseChangeType`. extension Set where Element == DatabaseChangeType { + /// A static property that represents a set containing all `DatabaseChangeType` cases. public static let all: Self = .init(DatabaseChangeType.allCases) } diff --git a/Sources/DataThespian/Notification/DatabaseMonitoring.swift b/Sources/DataThespian/Notification/DatabaseMonitoring.swift index 596b464..327b1b3 100644 --- a/Sources/DataThespian/Notification/DatabaseMonitoring.swift +++ b/Sources/DataThespian/Notification/DatabaseMonitoring.swift @@ -28,7 +28,14 @@ // #if canImport(SwiftData) + /// A protocol that defines the behavior for database monitoring. public protocol DatabaseMonitoring: Sendable { + /// Registers an agent with the database monitoring system. + /// + /// - Parameters: + /// - registration: The agent to be registered. + /// - force: A boolean value indicating whether the registration should be forced, + /// even if a registration with the same ID already exists. func register(_ registration: any AgentRegister, force: Bool) } #endif diff --git a/Sources/DataThespian/Notification/ManagedObjectMetadata.swift b/Sources/DataThespian/Notification/ManagedObjectMetadata.swift index 1cc8202..959c0ba 100644 --- a/Sources/DataThespian/Notification/ManagedObjectMetadata.swift +++ b/Sources/DataThespian/Notification/ManagedObjectMetadata.swift @@ -29,10 +29,19 @@ #if canImport(SwiftData) public import SwiftData - + /// A struct that holds metadata about a managed object. public struct ManagedObjectMetadata: Sendable, Hashable { + /// The name of the entity associated with the managed object. public let entityName: String + /// The persistent identifier of the managed object. public let persistentIdentifier: PersistentIdentifier + + /// Initializes a `ManagedObjectMetadata` instance + /// with the provided entity name and persistent identifier. + /// + /// - Parameters: + /// - entityName: The name of the entity associated with the managed object. + /// - persistentIdentifier: The persistent identifier of the managed object. public init(entityName: String, persistentIdentifier: PersistentIdentifier) { self.entityName = entityName self.persistentIdentifier = persistentIdentifier @@ -43,16 +52,23 @@ import CoreData extension ManagedObjectMetadata { + /// Initializes a `ManagedObjectMetadata` instance with the provided `NSManagedObject`. + /// + /// - Parameter managedObject: The `NSManagedObject` instance to get the metadata from. internal init?(managedObject: NSManagedObject) { let persistentIdentifier: PersistentIdentifier - do { persistentIdentifier = try managedObject.objectID.persistentIdentifier() } catch { + do { + persistentIdentifier = try managedObject.objectID.persistentIdentifier() + } catch { assertionFailure(error: error) return nil } + guard let entityName = managedObject.entity.name else { assertionFailure("Missing entity name.") return nil } + self.init(entityName: entityName, persistentIdentifier: persistentIdentifier) } } diff --git a/Sources/DataThespian/Notification/Notification.swift b/Sources/DataThespian/Notification/Notification.swift index a1f1ce3..d76a83c 100644 --- a/Sources/DataThespian/Notification/Notification.swift +++ b/Sources/DataThespian/Notification/Notification.swift @@ -32,6 +32,12 @@ import Foundation extension Notification { + /// Extracts a set of `ManagedObjectMetadata` from the user info dictionary of the notification. + /// + /// - Parameter key: The key to use to extract the set of `NSManagedObject` instances + /// from the user info dictionary. + /// - Returns: An optional set of `ManagedObjectMetadata` instances, + /// or `nil` if the set of `NSManagedObject` instances could not be found or extracted. internal func managedObjects(key: String) -> Set? { guard let objects = userInfo?[key] as? Set else { return nil diff --git a/Sources/DataThespian/Notification/NotificationDataUpdate.swift b/Sources/DataThespian/Notification/NotificationDataUpdate.swift index 9d1133f..1b457b1 100644 --- a/Sources/DataThespian/Notification/NotificationDataUpdate.swift +++ b/Sources/DataThespian/Notification/NotificationDataUpdate.swift @@ -30,14 +30,24 @@ #if canImport(CoreData) && canImport(SwiftData) import CoreData import Foundation - + /// Represents a set of changes to managed objects in a Core Data store. internal struct NotificationDataUpdate: DatabaseChangeSet, Sendable { + /// The set of managed objects that were inserted. internal let inserted: Set + /// The set of managed objects that were deleted. internal let deleted: Set + /// The set of managed objects that were updated. internal let updated: Set + /// Initializes a `NotificationDataUpdate` instance with the specified sets + /// of inserted, deleted, and updated managed objects. + /// + /// - Parameters: + /// - inserted: The set of managed objects that were inserted, or an empty set if none were inserted. + /// - deleted: The set of managed objects that were deleted, or an empty set if none were deleted. + /// - updated: The set of managed objects that were updated, or an empty set if none were updated. private init( inserted: Set?, deleted: Set?, @@ -50,6 +60,13 @@ ) } + /// Initializes a `NotificationDataUpdate` instance with + /// the specified sets of inserted, deleted, and updated managed objects. + /// + /// - Parameters: + /// - inserted: The set of managed objects that were inserted. + /// - deleted: The set of managed objects that were deleted. + /// - updated: The set of managed objects that were updated. private init( inserted: Set, deleted: Set, @@ -60,6 +77,9 @@ self.updated = updated } + /// Initializes a `NotificationDataUpdate` instance from a Notification object. + /// + /// - Parameter notification: The notification that triggered the data update. internal init(_ notification: Notification) { self.init( inserted: notification.managedObjects(key: NSInsertedObjectsKey), diff --git a/Sources/DataThespian/Notification/RegistrationCollection.swift b/Sources/DataThespian/Notification/RegistrationCollection.swift index cadbbbd..79644ab 100644 --- a/Sources/DataThespian/Notification/RegistrationCollection.swift +++ b/Sources/DataThespian/Notification/RegistrationCollection.swift @@ -29,12 +29,14 @@ #if canImport(SwiftData) import Foundation - + /// An actor that manages a collection of `DataAgent` registrations. internal actor RegistrationCollection: Loggable { internal static var loggingCategory: ThespianLogging.Category { .application } private var registrations = [String: DataAgent]() + /// Notifies the collection of a database change set update. + /// - Parameter update: The database change set update. nonisolated internal func notify(_ update: any DatabaseChangeSet) { Task { await self.onUpdate(update) @@ -42,9 +44,16 @@ } } + /// Adds a new `DataAgent` registration to the collection. + /// - Parameters: + /// - id: The unique identifier for the registration. + /// - force: A Boolean value indicating whether to force the registration if it already exists. + /// - agent: A closure that creates the `DataAgent` to be registered. nonisolated internal func add( withID id: String, force: Bool, agent: @Sendable @escaping () async -> DataAgent - ) { Task { await self.append(withID: id, force: force, agent: agent) } } + ) { + Task { await self.append(withID: id, force: force, agent: agent) } + } private func append( withID id: String, force: Bool, agent: @Sendable @escaping () async -> DataAgent diff --git a/Sources/DataThespian/SwiftData/FetchDescriptor.swift b/Sources/DataThespian/SwiftData/FetchDescriptor.swift index dc46066..ebf6e22 100644 --- a/Sources/DataThespian/SwiftData/FetchDescriptor.swift +++ b/Sources/DataThespian/SwiftData/FetchDescriptor.swift @@ -30,9 +30,18 @@ #if canImport(SwiftData) public import Foundation public import SwiftData - + /// + /// Represents a descriptor that can be used to fetch data from a data store. + /// extension FetchDescriptor { - public init(predicate: Predicate? = nil, sortBy: [SortDescriptor] = [], fetchLimit: Int?) { + /// + /// Initializes a `FetchDescriptor` with the specified parameters. + /// - Parameters: + /// - predicate: An optional `Predicate` that filters the results. + /// - sortBy: An array of `SortDescriptor` objects that determine the sort order of the results. + /// - fetchLimit: An optional integer that limits the number of results returned. + public init(predicate: Predicate? = nil, sortBy: [SortDescriptor] = [], fetchLimit: Int?) + { self.init(predicate: predicate, sortBy: sortBy) self.fetchLimit = fetchLimit } diff --git a/Sources/DataThespian/SwiftData/ModelContext+Extension.swift b/Sources/DataThespian/SwiftData/ModelContext+Extension.swift index bff3ad2..8152aee 100644 --- a/Sources/DataThespian/SwiftData/ModelContext+Extension.swift +++ b/Sources/DataThespian/SwiftData/ModelContext+Extension.swift @@ -30,16 +30,22 @@ #if canImport(SwiftData) public import Foundation public import SwiftData - + /// Extension to `ModelContext` to provide additional functionality for managing persistent models. extension ModelContext { - public func insert(_ closuer: @escaping @Sendable () -> T) - -> Model - { + /// Inserts a new persistent model into the context. + /// - Parameter closuer: A closure that creates a new instance of the `PersistentModel`. + /// - Returns: A `Model` instance representing the newly inserted model. + public func insert(_ closuer: @escaping @Sendable () -> T) -> Model { let model = closuer() self.insert(model) return .init(model) } + /// Fetches an array of persistent models based on the provided selectors. + /// - Parameter selectors: An array of `Selector.Get` instances + /// to fetch the models. + /// - Returns: An array of `PersistentModelType` instances. + /// - Throws: A `SwiftData` error. public func fetch( for selectors: [Selector.Get] ) throws -> [PersistentModelType] { @@ -50,6 +56,10 @@ .compactMap { $0 } } + /// Retrieves a persistent model from the context. + /// - Parameter model: A `Model` instance representing the persistent model to fetch. + /// - Returns: The `T` instance of the persistent model. + /// - Throws: `QueryError.itemNotFound` if the model is not found in the context. public func get(_ model: Model) throws -> T where T: PersistentModel { guard let item = try self.getOptional(model) else { @@ -58,6 +68,10 @@ return item } + /// Deletes persistent models based on the provided selectors. + /// - Parameter selectors: An array of `Selector.Delete` instances + /// to delete the models. + /// - Throws: A `SwiftData` error. public func delete( _ selectors: [Selector.Delete] ) throws { @@ -66,6 +80,11 @@ } } + /// Retrieves the first persistent model that matches the provided predicate. + /// - Parameter predicate: An optional `Predicate` instance to filter the results. + /// - Returns: The first `PersistentModelType` instance that matches the predicate, + /// or `nil` if no match is found. + /// - Throws: A `SwiftData` error. public func first( where predicate: Predicate? = nil ) throws -> PersistentModelType? { diff --git a/Sources/DataThespian/SwiftData/ModelContext+Queryable.swift b/Sources/DataThespian/SwiftData/ModelContext+Queryable.swift index c98f50a..243ddd1 100644 --- a/Sources/DataThespian/SwiftData/ModelContext+Queryable.swift +++ b/Sources/DataThespian/SwiftData/ModelContext+Queryable.swift @@ -29,8 +29,14 @@ #if canImport(SwiftData) public import SwiftData - + /// Extends the `ModelContext` class with additional methods for querying and managing persistent models. extension ModelContext { + /// Inserts a new persistent model and performs a closure on it. + /// + /// - Parameters: + /// - closuer: A closure that creates a new instance of the persistent model. + /// - closure: A closure that performs an operation on the newly inserted persistent model. + /// - Returns: The result of the `closure` parameter. public func insert( _ closuer: @Sendable @escaping () -> PersistentModelType, with closure: @escaping @Sendable (PersistentModelType) throws -> U @@ -40,9 +46,14 @@ return try closure(persistentModel) } - public func getOptional( - for selector: Selector.Get - ) throws -> PersistentModelType? { + /// Retrieves an optional persistent model based on a selector. + /// + /// - Parameter selector: A selector that specifies the criteria for retrieving the persistent model. + /// - Returns: An optional persistent model that matches the selector criteria. + /// - Throws: A `SwiftData` error. + public func getOptional(for selector: Selector.Get) + throws -> PersistentModelType? + { let persistentModel: PersistentModelType? switch selector { case .model(let model): @@ -53,6 +64,13 @@ return persistentModel } + /// Retrieves an optional persistent model based on a selector and performs a closure on it. + /// + /// - Parameters: + /// - selector: A selector that specifies the criteria for retrieving the persistent model. + /// - closure: A closure that performs an operation on the retrieved persistent model. + /// - Returns: The result of the `closure` parameter. + /// - Throws: A `SwiftData` error. public func getOptional( for selector: Selector.Get, with closure: @escaping @Sendable (PersistentModelType?) throws -> U @@ -67,6 +85,13 @@ return try closure(persistentModel) } + /// Retrieves a list of persistent models based on a selector and performs a closure on it. + /// + /// - Parameters: + /// - selector: A selector that specifies the criteria for retrieving the list of persistent models. + /// - closure: A closure that performs an operation on the retrieved list of persistent models. + /// - Returns: The result of the `closure` parameter. + /// - Throws: A `SwiftData` error. public func fetch( for selector: Selector.List, with closure: @escaping @Sendable ([PersistentModelType]) throws -> U @@ -79,6 +104,10 @@ return try closure(persistentModels) } + /// Deletes persistent models based on a selector. + /// + /// - Parameter selector: A selector that specifies the criteria for deleting the persistent models. + /// - Throws: A `SwiftData` error. public func delete(_ selector: Selector.Delete) throws { switch selector { diff --git a/Sources/DataThespian/SwiftData/ModelContext.swift b/Sources/DataThespian/SwiftData/ModelContext.swift index b52909f..1133af5 100644 --- a/Sources/DataThespian/SwiftData/ModelContext.swift +++ b/Sources/DataThespian/SwiftData/ModelContext.swift @@ -31,12 +31,25 @@ import Foundation public import SwiftData + /// An extension to the `ModelContext` class that provides additional functionality using ``Model``. extension ModelContext { + /// Retrieves an optional persistent model of the specified type with the given persistent identifier. + /// + /// - Parameter model: The model for which to retrieve the persistent model. + /// - Returns: An optional instance of the specified persistent model, + /// or `nil` if the model was not found. + /// - Throws: A `SwiftData` error. public func getOptional(_ model: Model) throws -> T? where T: PersistentModel { try self.persistentModel(withID: model.persistentIdentifier) } + /// Retrieves a persistent model of the specified type with the given persistent identifier. + /// + /// - Parameter objectID: The persistent identifier of the model to retrieve. + /// - Returns: An optional instance of the specified persistent model, + /// or `nil` if the model was not found. + /// - Throws: A `SwiftData` error. private func persistentModel(withID objectID: PersistentIdentifier) throws -> T? where T: PersistentModel { if let registered: T = registeredModel(for: objectID) { @@ -54,5 +67,4 @@ return try fetch(fetchDescriptor).first } } - #endif diff --git a/Sources/DataThespian/SwiftData/NSManagedObjectID.swift b/Sources/DataThespian/SwiftData/NSManagedObjectID.swift index f7e9303..741bf41 100644 --- a/Sources/DataThespian/SwiftData/NSManagedObjectID.swift +++ b/Sources/DataThespian/SwiftData/NSManagedObjectID.swift @@ -93,7 +93,11 @@ } } - // Compute PersistentIdentifier from NSManagedObjectID + /// Compute PersistentIdentifier from NSManagedObjectID. + /// + /// - Returns: A PersistentIdentifier instance. + /// - Throws: `PersistentIdentifierError` + /// if the `storeIdentifier` or `entityName` properties are missing. public func persistentIdentifier() throws -> PersistentIdentifier { guard let storeIdentifier else { throw PersistentIdentifierError.missingProperty(.storeIdentifier) @@ -120,10 +124,10 @@ // Extensions to expose needed implementation details extension NSManagedObjectID { - // Primary key is last path component of URI + /// The primary key of the managed object, which is the last path component of the URI. public var primaryKey: String { uriRepresentation().lastPathComponent } - // Store identifier is host of URI + /// The store identifier, which is the host of the URI. public var storeIdentifier: String? { guard let identifier = uriRepresentation().host() else { return nil @@ -131,7 +135,7 @@ return identifier } - // Entity name from entity name + /// The entity name, which is derived from the entity. public var entityName: String? { guard let entityName = entity.name else { return nil diff --git a/Sources/DataThespian/SwiftData/PersistentIdentifier.swift b/Sources/DataThespian/SwiftData/PersistentIdentifier.swift index dee0c99..af684ec 100644 --- a/Sources/DataThespian/SwiftData/PersistentIdentifier.swift +++ b/Sources/DataThespian/SwiftData/PersistentIdentifier.swift @@ -31,7 +31,6 @@ import CoreData import Foundation import SwiftData - /// Returns the value of a child property of an object using reflection. /// /// - Parameters: diff --git a/Sources/DataThespian/Synchronization/CollectionDifference.swift b/Sources/DataThespian/Synchronization/CollectionDifference.swift index 73499b8..60eb630 100644 --- a/Sources/DataThespian/Synchronization/CollectionDifference.swift +++ b/Sources/DataThespian/Synchronization/CollectionDifference.swift @@ -29,19 +29,25 @@ #if canImport(SwiftData) public import SwiftData - - public struct CollectionDifference< - PersistentModelType: PersistentModel, - DataType: Sendable - >: Sendable { + /// Represents the difference between a persistent model and its associated data. + public struct CollectionDifference: + Sendable + { + /// The items that need to be inserted. public let inserts: [DataType] + /// The models that need to be deleted. public let modelsToDelete: [Model] + /// The items that need to be updated. public let updates: [DataType] + /// Initializes a `CollectionDifference` instance with + /// the specified inserts, models to delete, and updates. + /// - Parameters: + /// - inserts: The items that need to be inserted. + /// - modelsToDelete: The models that need to be deleted. + /// - updates: The items that need to be updated. public init( - inserts: [DataType], - modelsToDelete: [Model], - updates: [DataType] + inserts: [DataType], modelsToDelete: [Model], updates: [DataType] ) { self.inserts = inserts self.modelsToDelete = modelsToDelete @@ -50,12 +56,19 @@ } extension CollectionDifference { + /// The delete selectors for the models that need to be deleted. public var deleteSelectors: [DataThespian.Selector.Delete] { self.modelsToDelete.map { .model($0) } } + /// Initializes a `CollectionDifference` instance by comparing the persistent models and data. + /// - Parameters: + /// - persistentModels: The persistent models to compare. + /// - data: The data to compare. + /// - persistentModelKeyPath: The key path to the unique identifier in the persistent models. + /// - dataKeyPath: The key path to the unique identifier in the data. public init( persistentModels: [PersistentModelType]?, data: [DataType]?, diff --git a/Sources/DataThespian/Synchronization/CollectionSyncronizer.swift b/Sources/DataThespian/Synchronization/CollectionSyncronizer.swift index 6bb3f92..34e0425 100644 --- a/Sources/DataThespian/Synchronization/CollectionSyncronizer.swift +++ b/Sources/DataThespian/Synchronization/CollectionSyncronizer.swift @@ -29,32 +29,60 @@ #if canImport(SwiftData) public import SwiftData - private struct SynchronizationUpdate { var file: DataType? var entry: PersistentModelType? } - + /// A protocol that defines the synchronization behavior between a persistent model and data. public protocol CollectionSyncronizer { + /// The type of the persistent model. associatedtype PersistentModelType: PersistentModel + + /// The type of the data. associatedtype DataType: Sendable + + /// The type of the identifier. associatedtype ID: Hashable + /// The key path to the identifier in the data. static var dataKey: KeyPath { get } + + /// The key path to the identifier in the persistent model. static var persistentModelKey: KeyPath { get } + /// Retrieves a selector for fetching the persistent model from the data. + /// + /// - Parameter data: The data to use for constructing the selector. + /// - Returns: A selector for fetching the persistent model. static func getSelector(from data: DataType) -> DataThespian.Selector.Get + /// Creates a persistent model from the provided data. + /// + /// - Parameter data: The data to create the persistent model from. + /// - Returns: The created persistent model. static func persistentModel(from data: DataType) -> PersistentModelType + + /// Synchronizes the persistent model with the provided data. + /// + /// - Parameters: + /// - persistentModel: The persistent model to synchronize. + /// - data: The data to synchronize the persistent model with. + /// - Throws: Any errors that occur during the synchronization process. static func syncronize(_ persistentModel: PersistentModelType, with data: DataType) throws } extension CollectionSyncronizer { - public static func syncronizeDifference ( + /// Synchronizes the difference between a collection of persistent models and a collection of data. + /// + /// - Parameters: + /// - difference: The difference between the persistent models and the data. + /// - modelContext: The model context to use for the synchronization. + /// - Returns: The list of persistent models that were inserted. + /// - Throws: Any errors that occur during the synchronization process. + public static func syncronizeDifference( _ difference: CollectionDifference, using modelContext: ModelContext ) throws -> [PersistentModelType] { - // try await database.withModelContext { modelContext in try modelContext.delete(difference.deleteSelectors) let modelsToInsert: [Model] = difference.inserts.map { model in diff --git a/Sources/DataThespian/Synchronization/ModelDifferenceSyncronizer.swift b/Sources/DataThespian/Synchronization/ModelDifferenceSyncronizer.swift index f961572..fb8dfd4 100644 --- a/Sources/DataThespian/Synchronization/ModelDifferenceSyncronizer.swift +++ b/Sources/DataThespian/Synchronization/ModelDifferenceSyncronizer.swift @@ -28,14 +28,21 @@ // #if canImport(SwiftData) - public import SwiftData - + import SwiftData + /// A protocol that defines the requirements for a synchronizer that can synchronize model differences. public protocol ModelDifferenceSyncronizer: ModelSyncronizer { + /// The type of synchronization difference used by this synchronizer. associatedtype SynchronizationDifferenceType: SynchronizationDifference where SynchronizationDifferenceType.DataType == DataType, SynchronizationDifferenceType.PersistentModelType == PersistentModelType + /// Synchronizes the given synchronization difference with the database. + /// + /// - Parameters: + /// - diff: The synchronization difference to be synchronized. + /// - database: The database to be used for the synchronization. + /// - Throws: An error that may occur during the synchronization process. static func synchronize( _ diff: SynchronizationDifferenceType, using database: any Database @@ -43,6 +50,13 @@ } extension ModelDifferenceSyncronizer { + /// Synchronizes the given model with the library using the database. + /// + /// - Parameters: + /// - model: The model to be synchronized. + /// - library: The library to be used for the synchronization. + /// - database: The database to be used for the synchronization. + /// - Throws: An error that may occur during the synchronization process. public static func synchronizeModel( _ model: Model, with library: DataType, diff --git a/Sources/DataThespian/Synchronization/ModelSyncronizer.swift b/Sources/DataThespian/Synchronization/ModelSyncronizer.swift index af9b057..0ac3537 100644 --- a/Sources/DataThespian/Synchronization/ModelSyncronizer.swift +++ b/Sources/DataThespian/Synchronization/ModelSyncronizer.swift @@ -30,10 +30,20 @@ #if canImport(SwiftData) public import SwiftData + /// A protocol that defines a model synchronizer. public protocol ModelSyncronizer { + /// The type of the persistent model. associatedtype PersistentModelType: PersistentModel + /// The type of the data to be synchronized. associatedtype DataType: Sendable + /// Synchronizes the model with the provided data, using the specified database. + /// + /// - Parameters: + /// - model: The model to be synchronized. + /// - library: The data to be synchronized with the model. + /// - database: The database to be used for the synchronization. + /// - Throws: Any errors that may occur during the synchronization process. static func synchronizeModel( _ model: Model, with library: DataType, diff --git a/Sources/DataThespian/Synchronization/SynchronizationDifference.swift b/Sources/DataThespian/Synchronization/SynchronizationDifference.swift index c2f3e1a..0b275b7 100644 --- a/Sources/DataThespian/Synchronization/SynchronizationDifference.swift +++ b/Sources/DataThespian/Synchronization/SynchronizationDifference.swift @@ -30,10 +30,19 @@ #if canImport(SwiftData) public import SwiftData + /// A protocol that defines a synchronization difference between a persistent model and some data. public protocol SynchronizationDifference: Sendable { + /// The type of the persistent model. associatedtype PersistentModelType: PersistentModel + /// The type of the data. associatedtype DataType: Sendable + /// Compares a persistent model with some data and returns a synchronization difference. + /// + /// - Parameters: + /// - persistentModel: The persistent model to compare. + /// - data: The data to compare. + /// - Returns: The synchronization difference between the persistent model and the data. static func comparePersistentModel( _ persistentModel: PersistentModelType, with data: DataType diff --git a/Sources/DataThespian/ThespianLogging.swift b/Sources/DataThespian/ThespianLogging.swift index ad1edc6..36c895e 100644 --- a/Sources/DataThespian/ThespianLogging.swift +++ b/Sources/DataThespian/ThespianLogging.swift @@ -29,11 +29,17 @@ public import FelinePine +/// Conforms to the `FelinePine.Loggable` protocol, where the `LoggingSystemType` is `ThespianLogging`. internal protocol Loggable: FelinePine.Loggable where Self.LoggingSystemType == ThespianLogging {} +/// A logging system used in the `DataThespian` application. +@_documentation(visibility: internal) public enum ThespianLogging: LoggingSystem { + /// Represents the different logging categories used in the `ThespianLogging` system. public enum Category: String, CaseIterable { + /// Logs related to the application. case application + /// Logs related to data. case data } }