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 } }