├── Example
├── Support
│ ├── .gitkeep
│ └── Info.plist
└── Sources
│ ├── Assets.xcassets
│ ├── Contents.json
│ ├── AccentColor.colorset
│ │ └── Contents.json
│ └── AppIcon.appiconset
│ │ └── Contents.json
│ ├── Preview Content
│ └── Preview Assets.xcassets
│ │ └── Contents.json
│ ├── DataThespianExample.entitlements
│ ├── ItemChild.swift
│ ├── Item.swift
│ ├── ChildViewModel.swift
│ ├── ItemChildView.swift
│ ├── ItemViewModel.swift
│ ├── DataThespianExampleApp.swift
│ ├── ContentView.swift
│ └── ContentObject.swift
├── .periphery.yml
├── codecov.yml
├── Mintfile
├── .spi.yml
├── macros.json
├── Tests
└── DataThespianTests
│ ├── Support
│ ├── SwiftDataIsAvailable.swift
│ ├── Thread.swift
│ ├── Child.swift
│ ├── Parent.swift
│ └── TestingDatabase.swift
│ └── Tests
│ ├── CoreDataNotificationKeysTests.swift
│ ├── SelectorExtensionTests.swift
│ ├── NotFoundErrorTests.swift
│ ├── BasicDatabaseTests.swift
│ ├── DataMonitorTests.swift
│ ├── ModelActorTests.swift
│ ├── AnyModelTests.swift
│ ├── PerformanceTests.swift
│ ├── DeleteTests.swift
│ └── FetchTests.swift
├── LICENSE
├── Package.resolved
├── .devcontainer
└── devcontainer.json
├── Sources
└── DataThespian
│ ├── Databases
│ ├── Unique.swift
│ ├── QueryError.swift
│ ├── Database.swift
│ ├── UniqueKey.swift
│ ├── UniqueKeyPath.swift
│ ├── Database+Extras.swift
│ ├── UniqueKeys.swift
│ ├── EnvironmentValues+Database.swift
│ ├── ModelActorDatabase.swift
│ ├── Database+Queryable.swift
│ ├── Queryable.swift
│ ├── ModelActor+Database.swift
│ └── BackgroundDatabase.swift
│ ├── Notification
│ ├── Combine
│ │ ├── EnvironmentValues+DatabaseChangePublicist.swift
│ │ ├── PublishingRegister.swift
│ │ ├── DatabaseChangePublicist.swift
│ │ └── PublishingAgent.swift
│ ├── Notification.swift
│ ├── DatabaseMonitoring.swift
│ ├── AgentRegister.swift
│ ├── DataAgent.swift
│ ├── DatabaseChangeType.swift
│ ├── DatabaseChangeSet.swift
│ ├── ManagedObjectMetadata.swift
│ ├── NotificationDataUpdate.swift
│ ├── RegistrationCollection.swift
│ └── DataMonitor.swift
│ ├── ThespianLogging.swift
│ ├── SwiftData
│ ├── FetchDescriptor.swift
│ ├── ModelContext.swift
│ ├── ModelContext+Extension.swift
│ └── NSManagedObjectID.swift
│ ├── Synchronization
│ ├── SynchronizationDifference.swift
│ ├── ModelSynchronizer.swift
│ ├── ModelDifferenceSynchronizer.swift
│ ├── CollectionDifference.swift
│ └── CollectionSynchronizer.swift
│ ├── Assert.swift
│ ├── Model.swift
│ ├── AnyModel.swift
│ └── Documentation.docc
│ └── DataThespian.md
├── project.yml
├── Package.swift
├── .github
└── workflows
│ ├── claude.yml
│ ├── claude-code-review.yml
│ ├── codeql.yml
│ └── DataThespian.yml
├── Scripts
├── lint.sh
├── header.sh
└── swift-doc.sh
├── .swift-format
├── .swiftlint.yml
└── .gitignore
/Example/Support/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.periphery.yml:
--------------------------------------------------------------------------------
1 | retain_public: true
2 |
--------------------------------------------------------------------------------
/codecov.yml:
--------------------------------------------------------------------------------
1 | ignore:
2 | - "Tests"
3 |
--------------------------------------------------------------------------------
/Mintfile:
--------------------------------------------------------------------------------
1 | swiftlang/swift-format@600.0.0
2 | realm/SwiftLint@0.58.2
3 |
--------------------------------------------------------------------------------
/Example/Sources/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/.spi.yml:
--------------------------------------------------------------------------------
1 | version: 1
2 | builder:
3 | configs:
4 | - documentation_targets: [DataThespian]
5 | swift_version: 6.0
6 |
--------------------------------------------------------------------------------
/Example/Sources/Preview Content/Preview Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/macros.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "fingerprint" : "c55848b2aa4b29a4df542b235dfdd792a6fbe341",
4 | "packageIdentity" : "swift-testing",
5 | "targetName" : "TestingMacros"
6 | }
7 | ]
--------------------------------------------------------------------------------
/Example/Sources/Assets.xcassets/AccentColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "idiom" : "universal"
5 | }
6 | ],
7 | "info" : {
8 | "author" : "xcode",
9 | "version" : 1
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/Tests/DataThespianTests/Support/SwiftDataIsAvailable.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SwiftDataIsAvailable.swift
3 | // DataThespian
4 | //
5 | // Created by Leo Dion on 1/7/25.
6 | //
7 |
8 | internal func swiftDataIsAvailable() -> Bool {
9 | #if canImport(SwiftData)
10 | true
11 | #else
12 | false
13 | #endif
14 | }
15 |
--------------------------------------------------------------------------------
/Example/Sources/DataThespianExample.entitlements:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | com.apple.security.app-sandbox
6 |
7 | com.apple.security.files.user-selected.read-only
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/Tests/DataThespianTests/Support/Thread.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Thread.swift
3 | // DataThespian
4 | //
5 | // Created by Leo Dion on 6/2/25.
6 | //
7 |
8 | import Foundation
9 |
10 | extension Thread {
11 | internal var isRunningXCTest: Bool {
12 | threadDictionary.allKeys
13 | .contains {
14 | ($0 as? String)?
15 | .range(of: "XCTest", options: .caseInsensitive) != nil
16 | }
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/Tests/DataThespianTests/Support/Child.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Child.swift
3 | // DataThespian
4 | //
5 | // Created by Leo Dion on 1/7/25.
6 | //
7 |
8 | #if canImport(SwiftData)
9 | import Foundation
10 | import SwiftData
11 |
12 | @Model
13 | internal class Child {
14 | internal var id: UUID
15 | internal var parent: Parent?
16 | internal init(id: UUID) {
17 | self.id = id
18 | }
19 | }
20 | #endif
21 |
--------------------------------------------------------------------------------
/Example/Sources/ItemChild.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ItemChild.swift
3 | // DataThespian
4 | //
5 | // Created by Leo Dion on 10/16/24.
6 | //
7 | import Foundation
8 | import SwiftData
9 |
10 | @Model
11 | internal final class ItemChild {
12 | internal var parent: Item?
13 | internal private(set) var timestamp: Date
14 |
15 | internal init(parent: Item? = nil, timestamp: Date) {
16 | self.parent = parent
17 | self.timestamp = timestamp
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/Tests/DataThespianTests/Support/Parent.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Parent.swift
3 | // DataThespian
4 | //
5 | // Created by Leo Dion on 1/7/25.
6 | //
7 |
8 | #if canImport(SwiftData)
9 | import Foundation
10 | import SwiftData
11 |
12 | @Model
13 | internal class Parent {
14 | internal var id: UUID
15 | @Relationship(inverse: \Child.parent)
16 | internal var children: [Child]? = []
17 | internal init(id: UUID) {
18 | self.id = id
19 | }
20 | }
21 | #endif
22 |
--------------------------------------------------------------------------------
/Example/Sources/Item.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Item.swift
3 | // DataThespianExample
4 | //
5 | // Created by Leo Dion on 10/10/24.
6 | //
7 |
8 | import DataThespian
9 | import Foundation
10 | import SwiftData
11 |
12 | @Model
13 | internal final class Item: Unique {
14 | internal enum Keys: UniqueKeys {
15 | internal typealias Model = Item
16 | internal static let primary = timestamp
17 | internal static let timestamp = keyPath(\.timestamp)
18 | }
19 |
20 | internal private(set) var timestamp: Date
21 | internal private(set) var children: [ItemChild]?
22 |
23 | internal init(timestamp: Date) {
24 | self.timestamp = timestamp
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/Tests/DataThespianTests/Support/TestingDatabase.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TestingDatabase.swift
3 | // DataThespian
4 | //
5 | // Created by Leo Dion on 1/7/25.
6 | //
7 |
8 | #if canImport(SwiftData)
9 | @testable import DataThespian
10 | import SwiftData
11 |
12 | @ModelActor
13 | internal actor TestingDatabase: Database {
14 | }
15 |
16 | extension TestingDatabase {
17 | internal init(for forTypes: any PersistentModel.Type...) throws {
18 | let container = try ModelContainer(
19 | for: .init(forTypes),
20 | configurations: .init(isStoredInMemoryOnly: true)
21 | )
22 | self.init(modelContainer: container)
23 | }
24 | }
25 | #endif
26 |
--------------------------------------------------------------------------------
/Example/Sources/ChildViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ChildViewModel.swift
3 | // DataThespian
4 | //
5 | // Created by Leo Dion on 10/16/24.
6 | //
7 |
8 | import DataThespian
9 | import Foundation
10 | import SwiftData
11 |
12 | internal struct ChildViewModel: Sendable, Identifiable {
13 | internal let model: Model
14 | internal let timestamp: Date
15 |
16 | internal var id: PersistentIdentifier {
17 | model.persistentIdentifier
18 | }
19 |
20 | private init(model: Model, timestamp: Date) {
21 | self.model = model
22 | self.timestamp = timestamp
23 | }
24 |
25 | internal init(child: ItemChild) {
26 | self.init(model: .init(child), timestamp: child.timestamp)
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/Example/Sources/ItemChildView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ItemChildView.swift
3 | // DataThespianExample
4 | //
5 | // Created by Leo Dion on 10/16/24.
6 | //
7 |
8 | import SwiftUI
9 |
10 | internal struct ItemChildView: View {
11 | internal var object: ContentObject
12 | internal let item: ItemViewModel
13 | internal var body: some View {
14 | VStack {
15 | Text("Item at \(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))")
16 | Divider()
17 | Button("Add Child") {
18 | object.addChild(to: item)
19 | }
20 | ForEach(item.children) { child in
21 | Text(
22 | "Child at \(child.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))"
23 | )
24 | }
25 | }
26 | }
27 | }
28 | //
29 | // #Preview {
30 | // ItemChildView()
31 | // }
32 |
--------------------------------------------------------------------------------
/Example/Sources/ItemViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ItemModel.swift
3 | // DataThespian
4 | //
5 | // Created by Leo Dion on 10/10/24.
6 | //
7 |
8 | import DataThespian
9 | import Foundation
10 | import SwiftData
11 |
12 | internal struct ItemViewModel: Sendable, Identifiable {
13 | internal let model: Model-
14 | internal let timestamp: Date
15 | internal let children: [ChildViewModel]
16 |
17 | internal var id: PersistentIdentifier {
18 | model.persistentIdentifier
19 | }
20 |
21 | private init(model: Model
- , timestamp: Date, children: [ChildViewModel]?) {
22 | self.model = model
23 | self.timestamp = timestamp
24 | self.children = children ?? []
25 | }
26 |
27 | internal init(item: Item) {
28 | self.init(
29 | model: .init(item),
30 | timestamp: item.timestamp,
31 | children: item.children?.map(ChildViewModel.init)
32 | )
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/Example/Support/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | APPL
17 | CFBundleShortVersionString
18 | $(MARKETING_VERSION)
19 | CFBundleVersion
20 | $(CURRENT_PROJECT_VERSION)
21 | ITSAppUsesNonExemptEncryption
22 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/Example/Sources/DataThespianExampleApp.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DataThespianExampleApp.swift
3 | // DataThespianExample
4 | //
5 | // Created by Leo Dion on 10/10/24.
6 | //
7 |
8 | import DataThespian
9 | import SwiftData
10 | import SwiftUI
11 |
12 | @main
13 | internal struct DataThespianExampleApp: App {
14 | private static let databaseChangePublicist = DatabaseChangePublicist()
15 |
16 | private static let database = BackgroundDatabase {
17 | // swift-format-ignore: NeverUseForceTry
18 | // swiftlint:disable:next force_try
19 | try! ModelActorDatabase(modelContainer: ModelContainer(for: Item.self)) {
20 | let context = ModelContext($0)
21 | context.autosaveEnabled = true
22 | return context
23 | }
24 | }
25 |
26 | internal var body: some Scene {
27 | WindowGroup {
28 | ContentView()
29 | }
30 | .database(Self.database)
31 | .environment(\.databaseChangePublicist, Self.databaseChangePublicist)
32 | }
33 |
34 | internal init() {
35 | DataMonitor.shared.begin(with: [])
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/Tests/DataThespianTests/Tests/CoreDataNotificationKeysTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CoreDataNotificationKeysTests.swift
3 | // DataThespian
4 | //
5 | // Created by Testing Team.
6 | //
7 |
8 | import Foundation
9 | import Testing
10 |
11 | @testable import DataThespian
12 |
13 | #if canImport(Combine) && canImport(SwiftData) && canImport(CoreData)
14 | import Combine
15 | import CoreData
16 | import SwiftData
17 |
18 | @Suite(.enabled(if: swiftDataIsAvailable()), .serialized)
19 | internal struct CoreDataNotificationKeysTests {
20 | @Test
21 | internal func testNotificationConstruction() {
22 | // Arrange
23 | let userInfo: [AnyHashable: Any] = [
24 | NSInsertedObjectIDsKey: Set()
25 | ]
26 |
27 | // Act
28 | let notification = Notification(
29 | name: .NSManagedObjectContextDidSaveObjectIDs,
30 | object: nil,
31 | userInfo: userInfo
32 | )
33 |
34 | // Assert
35 | #expect(notification.name == .NSManagedObjectContextDidSaveObjectIDs)
36 | }
37 | }
38 | #endif
39 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 BrightDigit
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Example/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "mac",
5 | "scale" : "1x",
6 | "size" : "16x16"
7 | },
8 | {
9 | "idiom" : "mac",
10 | "scale" : "2x",
11 | "size" : "16x16"
12 | },
13 | {
14 | "idiom" : "mac",
15 | "scale" : "1x",
16 | "size" : "32x32"
17 | },
18 | {
19 | "idiom" : "mac",
20 | "scale" : "2x",
21 | "size" : "32x32"
22 | },
23 | {
24 | "idiom" : "mac",
25 | "scale" : "1x",
26 | "size" : "128x128"
27 | },
28 | {
29 | "idiom" : "mac",
30 | "scale" : "2x",
31 | "size" : "128x128"
32 | },
33 | {
34 | "idiom" : "mac",
35 | "scale" : "1x",
36 | "size" : "256x256"
37 | },
38 | {
39 | "idiom" : "mac",
40 | "scale" : "2x",
41 | "size" : "256x256"
42 | },
43 | {
44 | "idiom" : "mac",
45 | "scale" : "1x",
46 | "size" : "512x512"
47 | },
48 | {
49 | "idiom" : "mac",
50 | "scale" : "2x",
51 | "size" : "512x512"
52 | }
53 | ],
54 | "info" : {
55 | "author" : "xcode",
56 | "version" : 1
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/Tests/DataThespianTests/Tests/SelectorExtensionTests.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import Testing
3 |
4 | @testable import DataThespian
5 |
6 | #if canImport(SwiftData)
7 | import SwiftData
8 | #endif
9 |
10 | @Suite(.enabled(if: swiftDataIsAvailable()))
11 | internal struct SelectorExtensionTests {
12 | @Test internal func testSelectorDeleteAllType() async throws {
13 | #if canImport(SwiftData)
14 | // Test that the .all(Type) extension method returns .all
15 | let selector = Selector.Delete.all(Parent.self)
16 |
17 | // Use pattern matching to verify the case
18 | switch selector {
19 | case .all:
20 | // Test passes - selector is the .all case
21 | break
22 | default:
23 | // Test fails - selector is not the .all case
24 | Issue.record("Expected .all case but got a different case")
25 | }
26 | #endif
27 | }
28 |
29 | @Test internal func testSelectorDeleteAllTypeUsage() async throws {
30 | #if canImport(SwiftData)
31 | let database = try TestingDatabase(for: Parent.self, Child.self)
32 |
33 | // Verify we can call the method without error
34 | // This is mainly checking that the method signature is correct
35 | try await database.delete(.all(Parent.self))
36 | #expect(true, "Should be able to call delete with .all(Type)")
37 | #endif
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "originHash" : "218abd6c7fefffa1c55b8836619484f00b1390a28b87c886220a143df08c3ed2",
3 | "pins" : [
4 | {
5 | "identity" : "felinepine",
6 | "kind" : "remoteSourceControl",
7 | "location" : "https://github.com/brightdigit/FelinePine.git",
8 | "state" : {
9 | "revision" : "54c727e0b069ffda7f80db7af99bfab2518e0780",
10 | "version" : "1.0.0-beta.2"
11 | }
12 | },
13 | {
14 | "identity" : "swift-docc-plugin",
15 | "kind" : "remoteSourceControl",
16 | "location" : "https://github.com/swiftlang/swift-docc-plugin",
17 | "state" : {
18 | "revision" : "85e4bb4e1cd62cec64a4b8e769dcefdf0c5b9d64",
19 | "version" : "1.4.3"
20 | }
21 | },
22 | {
23 | "identity" : "swift-docc-symbolkit",
24 | "kind" : "remoteSourceControl",
25 | "location" : "https://github.com/swiftlang/swift-docc-symbolkit",
26 | "state" : {
27 | "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34",
28 | "version" : "1.0.0"
29 | }
30 | },
31 | {
32 | "identity" : "swift-log",
33 | "kind" : "remoteSourceControl",
34 | "location" : "https://github.com/apple/swift-log.git",
35 | "state" : {
36 | "revision" : "9cb486020ebf03bfa5b5df985387a14a98744537",
37 | "version" : "1.6.1"
38 | }
39 | }
40 | ],
41 | "version" : 3
42 | }
43 |
--------------------------------------------------------------------------------
/.devcontainer/devcontainer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Swift",
3 | "image": "swift:6.0",
4 | "features": {
5 | "ghcr.io/devcontainers/features/common-utils:2": {
6 | "installZsh": "false",
7 | "username": "vscode",
8 | "upgradePackages": "false"
9 | },
10 | "ghcr.io/devcontainers/features/git:1": {
11 | "version": "os-provided",
12 | "ppa": "false"
13 | }
14 | },
15 | "runArgs": [
16 | "--cap-add=SYS_PTRACE",
17 | "--security-opt",
18 | "seccomp=unconfined"
19 | ],
20 | // Configure tool-specific properties.
21 | "customizations": {
22 | // Configure properties specific to VS Code.
23 | "vscode": {
24 | // Set *default* container specific settings.json values on container create.
25 | "settings": {
26 | "lldb.library": "/usr/lib/liblldb.so"
27 | },
28 | // Add the IDs of extensions you want installed when the container is created.
29 | "extensions": [
30 | "sswg.swift-lang"
31 | ]
32 | }
33 | },
34 | // Use 'forwardPorts' to make a list of ports inside the container available locally.
35 | // "forwardPorts": [],
36 |
37 | // Set `remoteUser` to `root` to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
38 | "remoteUser": "vscode"
39 | }
40 |
--------------------------------------------------------------------------------
/Tests/DataThespianTests/Tests/NotFoundErrorTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NotFoundErrorTests.swift
3 | // DataThespian
4 | //
5 | // Created for DataThespian.
6 | //
7 |
8 | import Foundation
9 | import Testing
10 |
11 | @testable import DataThespian
12 |
13 | #if canImport(SwiftData)
14 | import SwiftData
15 | #endif
16 |
17 | @Suite(.enabled(if: swiftDataIsAvailable()))
18 | internal struct NotFoundErrorTests {
19 | @Test internal func testAnyModelNotFoundError() async throws {
20 | #if canImport(SwiftData)
21 | let database = try TestingDatabase(for: Parent.self, Child.self)
22 | try await database.withModelContext { context in
23 | // Create and save a parent
24 | let parent = Parent(id: UUID())
25 | context.insert(parent)
26 | try context.save()
27 |
28 | // Get a valid PersistentIdentifier
29 | let persistentID = parent.persistentModelID
30 |
31 | // Create a NotFoundError with the PersistentIdentifier
32 | let error = AnyModel.NotFoundError(persistentIdentifier: persistentID)
33 |
34 | // Verify the error contains the correct PersistentIdentifier
35 | #expect(error.persistentIdentifier.id == persistentID.id)
36 |
37 | // Create a NotFoundError with the same PersistentIdentifier for the typed Model
38 | let typedError = Model.NotFoundError(persistentIdentifier: persistentID)
39 |
40 | #expect(error.persistentIdentifier.id == typedError.persistentIdentifier.id)
41 | }
42 | #endif
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/Sources/DataThespian/Databases/Unique.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Unique.swift
3 | // DataThespian
4 | //
5 | // Created by Leo Dion.
6 | // Copyright © 2025 BrightDigit.
7 | //
8 | // Permission is hereby granted, free of charge, to any person
9 | // obtaining a copy of this software and associated documentation
10 | // files (the “Software”), to deal in the Software without
11 | // restriction, including without limitation the rights to use,
12 | // copy, modify, merge, publish, distribute, sublicense, and/or
13 | // sell copies of the Software, and to permit persons to whom the
14 | // Software is furnished to do so, subject to the following
15 | // conditions:
16 | //
17 | // The above copyright notice and this permission notice shall be
18 | // included in all copies or substantial portions of the Software.
19 | //
20 | // THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
21 | // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
22 | // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
23 | // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
24 | // HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
25 | // WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
26 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
27 | // OTHER DEALINGS IN THE SOFTWARE.
28 | //
29 |
30 | /// A protocol that defines a type as being unique.
31 | @_documentation(visibility: internal)
32 | public protocol Unique {
33 | /// The associated type that conforms to `UniqueKeys` and represents the unique keys for this type.
34 | associatedtype Keys: UniqueKeys
35 | }
36 |
--------------------------------------------------------------------------------
/Sources/DataThespian/Notification/Combine/EnvironmentValues+DatabaseChangePublicist.swift:
--------------------------------------------------------------------------------
1 | //
2 | // EnvironmentValues+DatabaseChangePublicist.swift
3 | // DataThespian
4 | //
5 | // Created by Leo Dion.
6 | // Copyright © 2025 BrightDigit.
7 | //
8 | // Permission is hereby granted, free of charge, to any person
9 | // obtaining a copy of this software and associated documentation
10 | // files (the “Software”), to deal in the Software without
11 | // restriction, including without limitation the rights to use,
12 | // copy, modify, merge, publish, distribute, sublicense, and/or
13 | // sell copies of the Software, and to permit persons to whom the
14 | // Software is furnished to do so, subject to the following
15 | // conditions:
16 | //
17 | // The above copyright notice and this permission notice shall be
18 | // included in all copies or substantial portions of the Software.
19 | //
20 | // THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
21 | // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
22 | // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
23 | // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
24 | // HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
25 | // WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
26 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
27 | // OTHER DEALINGS IN THE SOFTWARE.
28 | //
29 |
30 | #if canImport(SwiftUI)
31 | import Foundation
32 |
33 | public import SwiftUI
34 |
35 | extension EnvironmentValues {
36 | /// A `DatabaseChangePublicist` that determines how database changes are propagated to the UI.
37 | @Entry public var databaseChangePublicist: DatabaseChangePublicist = .never()
38 | }
39 | #endif
40 |
--------------------------------------------------------------------------------
/project.yml:
--------------------------------------------------------------------------------
1 | name: DataThespian
2 | settings:
3 | LINT_MODE: ${LINT_MODE}
4 | packages:
5 | DataThespian:
6 | path: .
7 | aggregateTargets:
8 | Lint:
9 | buildScripts:
10 | - path: Scripts/lint.sh
11 | name: Lint
12 | basedOnDependencyAnalysis: false
13 | schemes: {}
14 | targets:
15 | DataThespianExample:
16 | type: application
17 | platform: macOS
18 | dependencies:
19 | - package: DataThespian
20 | product: DataThespian
21 | sources:
22 | - path: "Example/Sources"
23 | - path: "Example/Support"
24 | settings:
25 | base:
26 | PRODUCT_BUNDLE_IDENTIFIER: com.Demo.DataThespianExample
27 | SWIFT_STRICT_CONCURRENCY: complete
28 | SWIFT_UPCOMING_FEATURE_CONCISE_MAGIC_FILE: YES
29 | SWIFT_UPCOMING_FEATURE_DEPRECATE_APPLICATION_MAIN: YES
30 | SWIFT_UPCOMING_FEATURE_DISABLE_OUTWARD_ACTOR_ISOLATION: YES
31 | SWIFT_UPCOMING_FEATURE_EXISTENTIAL_ANY: YES
32 | SWIFT_UPCOMING_FEATURE_FORWARD_TRAILING_CLOSURES: YES
33 | SWIFT_UPCOMING_FEATURE_GLOBAL_CONCURRENCY: YES
34 | SWIFT_UPCOMING_FEATURE_IMPLICIT_OPEN_EXISTENTIALS: YES
35 | SWIFT_UPCOMING_FEATURE_IMPORT_OBJC_FORWARD_DECLS: YES
36 | SWIFT_UPCOMING_FEATURE_INFER_SENDABLE_FROM_CAPTURES: YES
37 | SWIFT_UPCOMING_FEATURE_INTERNAL_IMPORTS_BY_DEFAULT: YES
38 | SWIFT_UPCOMING_FEATURE_ISOLATED_DEFAULT_VALUES: YES
39 | SWIFT_UPCOMING_FEATURE_REGION_BASED_ISOLATION: YES
40 | info:
41 | path: Example/Support/Info.plist
42 | properties:
43 | CFBundlePackageType: APPL
44 | ITSAppUsesNonExemptEncryption: false
45 | CFBundleShortVersionString: $(MARKETING_VERSION)
46 | CFBundleVersion: $(CURRENT_PROJECT_VERSION)
--------------------------------------------------------------------------------
/Sources/DataThespian/Notification/Notification.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Notification.swift
3 | // DataThespian
4 | //
5 | // Created by Leo Dion.
6 | // Copyright © 2025 BrightDigit.
7 | //
8 | // Permission is hereby granted, free of charge, to any person
9 | // obtaining a copy of this software and associated documentation
10 | // files (the “Software”), to deal in the Software without
11 | // restriction, including without limitation the rights to use,
12 | // copy, modify, merge, publish, distribute, sublicense, and/or
13 | // sell copies of the Software, and to permit persons to whom the
14 | // Software is furnished to do so, subject to the following
15 | // conditions:
16 | //
17 | // The above copyright notice and this permission notice shall be
18 | // included in all copies or substantial portions of the Software.
19 | //
20 | // THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
21 | // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
22 | // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
23 | // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
24 | // HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
25 | // WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
26 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
27 | // OTHER DEALINGS IN THE SOFTWARE.
28 | //
29 |
30 | #if canImport(CoreData)
31 | import CoreData
32 | import Foundation
33 |
34 | extension Notification {
35 | internal func managedObjectIDs(key: String) -> Set? {
36 | guard let objectIDs = userInfo?[key] as? Set else {
37 | return nil
38 | }
39 |
40 | return Set(objectIDs.compactMap(ManagedObjectMetadata.init(objectID:)))
41 | }
42 | }
43 | #endif
44 |
--------------------------------------------------------------------------------
/Sources/DataThespian/Databases/QueryError.swift:
--------------------------------------------------------------------------------
1 | //
2 | // QueryError.swift
3 | // DataThespian
4 | //
5 | // Created by Leo Dion.
6 | // Copyright © 2025 BrightDigit.
7 | //
8 | // Permission is hereby granted, free of charge, to any person
9 | // obtaining a copy of this software and associated documentation
10 | // files (the “Software”), to deal in the Software without
11 | // restriction, including without limitation the rights to use,
12 | // copy, modify, merge, publish, distribute, sublicense, and/or
13 | // sell copies of the Software, and to permit persons to whom the
14 | // Software is furnished to do so, subject to the following
15 | // conditions:
16 | //
17 | // The above copyright notice and this permission notice shall be
18 | // included in all copies or substantial portions of the Software.
19 | //
20 | // THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
21 | // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
22 | // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
23 | // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
24 | // HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
25 | // WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
26 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
27 | // OTHER DEALINGS IN THE SOFTWARE.
28 | //
29 |
30 | #if canImport(SwiftData)
31 | public import SwiftData
32 | /// An error that occurs when a query fails to find an item.
33 | public enum QueryError: Error {
34 | /// Indicates that the item was not found.
35 | ///
36 | /// - Parameter selector: The `Selector.Get` instance that was used to perform the query.
37 | case itemNotFound(Selector.Get)
38 | }
39 | #endif
40 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 6.0
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 |
6 | // swiftlint:disable explicit_acl explicit_top_level_acl
7 | let swiftSettings: [SwiftSetting] = [
8 | SwiftSetting.enableExperimentalFeature("AccessLevelOnImport"),
9 | SwiftSetting.enableExperimentalFeature("BitwiseCopyable"),
10 | SwiftSetting.enableExperimentalFeature("IsolatedAny"),
11 | SwiftSetting.enableExperimentalFeature("MoveOnlyPartialConsumption"),
12 | SwiftSetting.enableExperimentalFeature("NestedProtocols"),
13 | SwiftSetting.enableExperimentalFeature("NoncopyableGenerics"),
14 | SwiftSetting.enableExperimentalFeature("TransferringArgsAndResults"),
15 | SwiftSetting.enableExperimentalFeature("VariadicGenerics"),
16 |
17 | SwiftSetting.enableUpcomingFeature("FullTypedThrows"),
18 | SwiftSetting.enableUpcomingFeature("InternalImportsByDefault")
19 | ]
20 |
21 | let package = Package(
22 | name: "DataThespian",
23 | platforms: [.iOS(.v17), .macCatalyst(.v17), .macOS(.v14), .tvOS(.v17), .visionOS(.v1), .watchOS(.v10)],
24 | products: [
25 | .library(
26 | name: "DataThespian",
27 | targets: ["DataThespian"]
28 | )
29 | ],
30 | dependencies: [
31 | .package(url: "https://github.com/brightdigit/FelinePine.git", from: "1.0.0-beta.2"),
32 | .package(url: "https://github.com/swiftlang/swift-docc-plugin", from: "1.4.0"),
33 | ],
34 | targets: [
35 | .target(
36 | name: "DataThespian",
37 | dependencies: ["FelinePine"],
38 | swiftSettings: swiftSettings
39 | ),
40 | .testTarget(
41 | name: "DataThespianTests",
42 | dependencies: [
43 | "DataThespian"
44 | ]
45 | )
46 | ]
47 | )
48 | // swiftlint:enable explicit_acl explicit_top_level_acl
49 |
--------------------------------------------------------------------------------
/Sources/DataThespian/Notification/DatabaseMonitoring.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DatabaseMonitoring.swift
3 | // DataThespian
4 | //
5 | // Created by Leo Dion.
6 | // Copyright © 2025 BrightDigit.
7 | //
8 | // Permission is hereby granted, free of charge, to any person
9 | // obtaining a copy of this software and associated documentation
10 | // files (the “Software”), to deal in the Software without
11 | // restriction, including without limitation the rights to use,
12 | // copy, modify, merge, publish, distribute, sublicense, and/or
13 | // sell copies of the Software, and to permit persons to whom the
14 | // Software is furnished to do so, subject to the following
15 | // conditions:
16 | //
17 | // The above copyright notice and this permission notice shall be
18 | // included in all copies or substantial portions of the Software.
19 | //
20 | // THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
21 | // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
22 | // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
23 | // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
24 | // HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
25 | // WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
26 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
27 | // OTHER DEALINGS IN THE SOFTWARE.
28 | //
29 |
30 | #if canImport(SwiftData)
31 | /// A protocol that defines the behavior for database monitoring.
32 | public protocol DatabaseMonitoring: Sendable {
33 | /// Registers an agent with the database monitoring system.
34 | ///
35 | /// - Parameters:
36 | /// - registration: The agent to be registered.
37 | /// - force: A boolean value indicating whether the registration should be forced,
38 | /// even if a registration with the same ID already exists.
39 | func register(_ registration: any AgentRegister, force: Bool)
40 | }
41 | #endif
42 |
--------------------------------------------------------------------------------
/Sources/DataThespian/Databases/Database.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Database.swift
3 | // DataThespian
4 | //
5 | // Created by Leo Dion.
6 | // Copyright © 2025 BrightDigit.
7 | //
8 | // Permission is hereby granted, free of charge, to any person
9 | // obtaining a copy of this software and associated documentation
10 | // files (the “Software”), to deal in the Software without
11 | // restriction, including without limitation the rights to use,
12 | // copy, modify, merge, publish, distribute, sublicense, and/or
13 | // sell copies of the Software, and to permit persons to whom the
14 | // Software is furnished to do so, subject to the following
15 | // conditions:
16 | //
17 | // The above copyright notice and this permission notice shall be
18 | // included in all copies or substantial portions of the Software.
19 | //
20 | // THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
21 | // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
22 | // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
23 | // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
24 | // HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
25 | // WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
26 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
27 | // OTHER DEALINGS IN THE SOFTWARE.
28 | //
29 |
30 | #if canImport(SwiftData)
31 |
32 | import Foundation
33 | public import SwiftData
34 |
35 | /// `Sendable` protocol for querying a `ModelContext`.
36 | public protocol Database: Sendable, Queryable {
37 | /// Executes a closure safely within the context of a model.
38 | ///
39 | /// - Parameter closure: A closure that takes a `ModelContext`
40 | /// and returns a `Sendable` value of type `T`.
41 | /// - Returns: The value returned by the closure.
42 | func withModelContext(_ closure: @Sendable @escaping (ModelContext) throws -> T)
43 | async rethrows -> T
44 | }
45 | #endif
46 |
--------------------------------------------------------------------------------
/Sources/DataThespian/Databases/UniqueKey.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UniqueKey.swift
3 | // DataThespian
4 | //
5 | // Created by Leo Dion.
6 | // Copyright © 2025 BrightDigit.
7 | //
8 | // Permission is hereby granted, free of charge, to any person
9 | // obtaining a copy of this software and associated documentation
10 | // files (the “Software”), to deal in the Software without
11 | // restriction, including without limitation the rights to use,
12 | // copy, modify, merge, publish, distribute, sublicense, and/or
13 | // sell copies of the Software, and to permit persons to whom the
14 | // Software is furnished to do so, subject to the following
15 | // conditions:
16 | //
17 | // The above copyright notice and this permission notice shall be
18 | // included in all copies or substantial portions of the Software.
19 | //
20 | // THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
21 | // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
22 | // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
23 | // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
24 | // HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
25 | // WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
26 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
27 | // OTHER DEALINGS IN THE SOFTWARE.
28 | //
29 |
30 | public import Foundation
31 |
32 | /// A protocol that defines a unique key for a model type.
33 | @_documentation(visibility: internal)
34 | public protocol UniqueKey: Sendable {
35 | /// The model type associated with this unique key.
36 | associatedtype Model: Unique
37 |
38 | /// The value type associated with this unique key.
39 | associatedtype ValueType: Sendable & Equatable & Codable
40 |
41 | /// Creates a predicate that checks if the model's value for this key equals the specified value.
42 | ///
43 | /// - Parameter value: The value to compare against.
44 | /// - Returns: A predicate that checks if the model's value for this key equals the specified value.
45 | func predicate(equals value: ValueType) -> Predicate
46 | }
47 |
--------------------------------------------------------------------------------
/.github/workflows/claude.yml:
--------------------------------------------------------------------------------
1 | name: Claude Code
2 |
3 | on:
4 | issue_comment:
5 | types: [created]
6 | pull_request_review_comment:
7 | types: [created]
8 | issues:
9 | types: [opened, assigned]
10 | pull_request_review:
11 | types: [submitted]
12 |
13 | jobs:
14 | claude:
15 | if: |
16 | (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
17 | (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
18 | (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
19 | (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
20 | runs-on: ubuntu-latest
21 | permissions:
22 | contents: read
23 | pull-requests: read
24 | issues: read
25 | id-token: write
26 | actions: read # Required for Claude to read CI results on PRs
27 | steps:
28 | - name: Checkout repository
29 | uses: actions/checkout@v4
30 | with:
31 | fetch-depth: 1
32 |
33 | - name: Run Claude Code
34 | id: claude
35 | uses: anthropics/claude-code-action@v1
36 | with:
37 | claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
38 |
39 | # This is an optional setting that allows Claude to read CI results on PRs
40 | additional_permissions: |
41 | actions: read
42 |
43 | # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
44 | # prompt: 'Update the pull request description to include a summary of changes.'
45 |
46 | # Optional: Add claude_args to customize behavior and configuration
47 | # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
48 | # or https://docs.claude.com/en/docs/claude-code/sdk#command-line for available options
49 | # claude_args: '--model claude-opus-4-1-20250805 --allowed-tools Bash(gh pr:*)'
50 |
51 |
--------------------------------------------------------------------------------
/Sources/DataThespian/ThespianLogging.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ThespianLogging.swift
3 | // DataThespian
4 | //
5 | // Created by Leo Dion.
6 | // Copyright © 2025 BrightDigit.
7 | //
8 | // Permission is hereby granted, free of charge, to any person
9 | // obtaining a copy of this software and associated documentation
10 | // files (the “Software”), to deal in the Software without
11 | // restriction, including without limitation the rights to use,
12 | // copy, modify, merge, publish, distribute, sublicense, and/or
13 | // sell copies of the Software, and to permit persons to whom the
14 | // Software is furnished to do so, subject to the following
15 | // conditions:
16 | //
17 | // The above copyright notice and this permission notice shall be
18 | // included in all copies or substantial portions of the Software.
19 | //
20 | // THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
21 | // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
22 | // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
23 | // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
24 | // HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
25 | // WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
26 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
27 | // OTHER DEALINGS IN THE SOFTWARE.
28 | //
29 |
30 | public import FelinePine
31 |
32 | /// Conforms to the `FelinePine.Loggable` protocol, where the `LoggingSystemType` is `ThespianLogging`.
33 | internal protocol Loggable: FelinePine.Loggable where Self.LoggingSystemType == ThespianLogging {}
34 |
35 | /// A logging system used in the `DataThespian` application.
36 | @_documentation(visibility: internal)
37 | public enum ThespianLogging: LoggingSystem {
38 | /// Represents the different logging categories used in the `ThespianLogging` system.
39 | public enum Category: String, CaseIterable {
40 | /// Logs related to the application.
41 | case application
42 | /// Logs related to data.
43 | case data
44 | }
45 |
46 | /// Default subsystem to use for logging.
47 | public static var subsystem: String {
48 | "DataThespian"
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/Sources/DataThespian/SwiftData/FetchDescriptor.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FetchDescriptor.swift
3 | // DataThespian
4 | //
5 | // Created by Leo Dion.
6 | // Copyright © 2025 BrightDigit.
7 | //
8 | // Permission is hereby granted, free of charge, to any person
9 | // obtaining a copy of this software and associated documentation
10 | // files (the “Software”), to deal in the Software without
11 | // restriction, including without limitation the rights to use,
12 | // copy, modify, merge, publish, distribute, sublicense, and/or
13 | // sell copies of the Software, and to permit persons to whom the
14 | // Software is furnished to do so, subject to the following
15 | // conditions:
16 | //
17 | // The above copyright notice and this permission notice shall be
18 | // included in all copies or substantial portions of the Software.
19 | //
20 | // THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
21 | // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
22 | // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
23 | // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
24 | // HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
25 | // WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
26 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
27 | // OTHER DEALINGS IN THE SOFTWARE.
28 | //
29 |
30 | #if canImport(SwiftData)
31 | public import Foundation
32 | public import SwiftData
33 | ///
34 | /// Represents a descriptor that can be used to fetch data from a data store.
35 | ///
36 | extension FetchDescriptor {
37 | ///
38 | /// Initializes a `FetchDescriptor` with the specified parameters.
39 | /// - Parameters:
40 | /// - predicate: An optional `Predicate` that filters the results.
41 | /// - sortBy: An array of `SortDescriptor` objects that determine the sort order of the results.
42 | /// - fetchLimit: An optional integer that limits the number of results returned.
43 | public init(predicate: Predicate? = nil, sortBy: [SortDescriptor] = [], fetchLimit: Int?)
44 | {
45 | self.init(predicate: predicate, sortBy: sortBy)
46 | self.fetchLimit = fetchLimit
47 | }
48 | }
49 | #endif
50 |
--------------------------------------------------------------------------------
/Sources/DataThespian/Notification/AgentRegister.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AgentRegister.swift
3 | // DataThespian
4 | //
5 | // Created by Leo Dion.
6 | // Copyright © 2025 BrightDigit.
7 | //
8 | // Permission is hereby granted, free of charge, to any person
9 | // obtaining a copy of this software and associated documentation
10 | // files (the “Software”), to deal in the Software without
11 | // restriction, including without limitation the rights to use,
12 | // copy, modify, merge, publish, distribute, sublicense, and/or
13 | // sell copies of the Software, and to permit persons to whom the
14 | // Software is furnished to do so, subject to the following
15 | // conditions:
16 | //
17 | // The above copyright notice and this permission notice shall be
18 | // included in all copies or substantial portions of the Software.
19 | //
20 | // THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
21 | // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
22 | // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
23 | // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
24 | // HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
25 | // WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
26 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
27 | // OTHER DEALINGS IN THE SOFTWARE.
28 | //
29 |
30 | #if canImport(SwiftData)
31 | ///
32 | /// A protocol that defines an agent register for a specific agent type.
33 | ///
34 | public protocol AgentRegister: Sendable {
35 | ///
36 | /// The agent type associated with this register.
37 | ///
38 | associatedtype AgentType: DataAgent
39 |
40 | ///
41 | /// The unique identifier for this agent register.
42 | ///
43 | var id: String { get }
44 |
45 | ///
46 | /// Asynchronously retrieves the agent associated with this register.
47 | ///
48 | /// - Returns: The agent associated with this register.
49 | ///
50 | @Sendable func agent() async -> AgentType
51 | }
52 |
53 | extension AgentRegister {
54 | ///
55 | /// The unique identifier for this agent register.
56 | ///
57 | public var id: String { "\(AgentType.self)" }
58 | }
59 | #endif
60 |
--------------------------------------------------------------------------------
/.github/workflows/claude-code-review.yml:
--------------------------------------------------------------------------------
1 | name: Claude Code Review
2 |
3 | on:
4 | pull_request:
5 | types: [opened, synchronize]
6 | # Optional: Only run on specific file changes
7 | # paths:
8 | # - "src/**/*.ts"
9 | # - "src/**/*.tsx"
10 | # - "src/**/*.js"
11 | # - "src/**/*.jsx"
12 |
13 | jobs:
14 | claude-review:
15 | # Optional: Filter by PR author
16 | # if: |
17 | # github.event.pull_request.user.login == 'external-contributor' ||
18 | # github.event.pull_request.user.login == 'new-developer' ||
19 | # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
20 |
21 | runs-on: ubuntu-latest
22 | permissions:
23 | contents: read
24 | pull-requests: read
25 | issues: read
26 | id-token: write
27 |
28 | steps:
29 | - name: Checkout repository
30 | uses: actions/checkout@v4
31 | with:
32 | fetch-depth: 1
33 |
34 | - name: Run Claude Code Review
35 | id: claude-review
36 | uses: anthropics/claude-code-action@v1
37 | with:
38 | claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
39 | prompt: |
40 | REPO: ${{ github.repository }}
41 | PR NUMBER: ${{ github.event.pull_request.number }}
42 |
43 | Please review this pull request and provide feedback on:
44 | - Code quality and best practices
45 | - Potential bugs or issues
46 | - Performance considerations
47 | - Security concerns
48 | - Test coverage
49 |
50 | Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback.
51 |
52 | Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR.
53 |
54 | # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
55 | # or https://docs.claude.com/en/docs/claude-code/sdk#command-line for available options
56 | claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"'
57 |
58 |
--------------------------------------------------------------------------------
/Sources/DataThespian/Synchronization/SynchronizationDifference.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SynchronizationDifference.swift
3 | // DataThespian
4 | //
5 | // Created by Leo Dion.
6 | // Copyright © 2025 BrightDigit.
7 | //
8 | // Permission is hereby granted, free of charge, to any person
9 | // obtaining a copy of this software and associated documentation
10 | // files (the “Software”), to deal in the Software without
11 | // restriction, including without limitation the rights to use,
12 | // copy, modify, merge, publish, distribute, sublicense, and/or
13 | // sell copies of the Software, and to permit persons to whom the
14 | // Software is furnished to do so, subject to the following
15 | // conditions:
16 | //
17 | // The above copyright notice and this permission notice shall be
18 | // included in all copies or substantial portions of the Software.
19 | //
20 | // THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
21 | // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
22 | // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
23 | // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
24 | // HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
25 | // WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
26 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
27 | // OTHER DEALINGS IN THE SOFTWARE.
28 | //
29 |
30 | #if canImport(SwiftData)
31 | public import SwiftData
32 |
33 | /// A protocol that defines a synchronization difference between a persistent model and some data.
34 | public protocol SynchronizationDifference: Sendable {
35 | /// The type of the persistent model.
36 | associatedtype PersistentModelType: PersistentModel
37 | /// The type of the data.
38 | associatedtype DataType: Sendable
39 |
40 | /// Compares a persistent model with some data and returns a synchronization difference.
41 | ///
42 | /// - Parameters:
43 | /// - persistentModel: The persistent model to compare.
44 | /// - data: The data to compare.
45 | /// - Returns: The synchronization difference between the persistent model and the data.
46 | static func comparePersistentModel(
47 | _ persistentModel: PersistentModelType,
48 | with data: DataType
49 | ) -> Self
50 | }
51 | #endif
52 |
--------------------------------------------------------------------------------
/Sources/DataThespian/Synchronization/ModelSynchronizer.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ModelSynchronizer.swift
3 | // DataThespian
4 | //
5 | // Created by Leo Dion.
6 | // Copyright © 2025 BrightDigit.
7 | //
8 | // Permission is hereby granted, free of charge, to any person
9 | // obtaining a copy of this software and associated documentation
10 | // files (the “Software”), to deal in the Software without
11 | // restriction, including without limitation the rights to use,
12 | // copy, modify, merge, publish, distribute, sublicense, and/or
13 | // sell copies of the Software, and to permit persons to whom the
14 | // Software is furnished to do so, subject to the following
15 | // conditions:
16 | //
17 | // The above copyright notice and this permission notice shall be
18 | // included in all copies or substantial portions of the Software.
19 | //
20 | // THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
21 | // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
22 | // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
23 | // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
24 | // HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
25 | // WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
26 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
27 | // OTHER DEALINGS IN THE SOFTWARE.
28 | //
29 |
30 | #if canImport(SwiftData)
31 | public import SwiftData
32 |
33 | /// A protocol that defines a model synchronizer.
34 | public protocol ModelSynchronizer {
35 | /// The type of the persistent model.
36 | associatedtype PersistentModelType: PersistentModel
37 | /// The type of the data to be synchronized.
38 | associatedtype DataType: Sendable
39 |
40 | /// Synchronizes the model with the provided data, using the specified database.
41 | ///
42 | /// - Parameters:
43 | /// - model: The model to be synchronized.
44 | /// - data: The data to be synchronized with the model.
45 | /// - database: The database to be used for the synchronization.
46 | /// - Throws: Any errors that may occur during the synchronization process.
47 | static func synchronizeModel(
48 | _ model: Model,
49 | with data: DataType,
50 | using database: any Database
51 | ) async throws
52 | }
53 | #endif
54 |
--------------------------------------------------------------------------------
/Sources/DataThespian/Databases/UniqueKeyPath.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UniqueKeyPath.swift
3 | // DataThespian
4 | //
5 | // Created by Leo Dion.
6 | // Copyright © 2025 BrightDigit.
7 | //
8 | // Permission is hereby granted, free of charge, to any person
9 | // obtaining a copy of this software and associated documentation
10 | // files (the “Software”), to deal in the Software without
11 | // restriction, including without limitation the rights to use,
12 | // copy, modify, merge, publish, distribute, sublicense, and/or
13 | // sell copies of the Software, and to permit persons to whom the
14 | // Software is furnished to do so, subject to the following
15 | // conditions:
16 | //
17 | // The above copyright notice and this permission notice shall be
18 | // included in all copies or substantial portions of the Software.
19 | //
20 | // THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
21 | // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
22 | // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
23 | // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
24 | // HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
25 | // WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
26 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
27 | // OTHER DEALINGS IN THE SOFTWARE.
28 | //
29 |
30 | public import Foundation
31 |
32 | // swiftlint:disable unavailable_function
33 |
34 | /// A struct that represents a unique key path for a model type.
35 | @_documentation(visibility: internal)
36 | public struct UniqueKeyPath: UniqueKey {
37 | /// The key path for the model type.
38 | private let keyPath: KeyPath & Sendable
39 |
40 | /// Initializes a new instance of `UniqueKeyPath` with the given key path.
41 | ///
42 | /// - Parameter keyPath: The key path for the model type.
43 | internal init(keyPath: any KeyPath & Sendable) {
44 | self.keyPath = keyPath
45 | }
46 |
47 | /// Creates a predicate that checks if the value of the key path is equal to the given value.
48 | ///
49 | /// - Parameter value: The value to compare against.
50 | /// - Returns: A predicate that can be used to filter models.
51 | public func predicate(equals value: ValueType) -> Predicate {
52 | fatalError("Not implemented yet.")
53 | }
54 | }
55 |
56 | // swiftlint:enable unavailable_function
57 |
--------------------------------------------------------------------------------
/Scripts/lint.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | set -e # Exit on any error
4 |
5 | ERRORS=0
6 |
7 | run_command() {
8 | if [ "$LINT_MODE" = "STRICT" ]; then
9 | "$@" || ERRORS=$((ERRORS + 1))
10 | else
11 | "$@"
12 | fi
13 | }
14 |
15 | if [ "$LINT_MODE" = "INSTALL" ]; then
16 | exit
17 | fi
18 |
19 | echo "LintMode: $LINT_MODE"
20 |
21 | # More portable way to get script directory
22 | if [ -z "$SRCROOT" ]; then
23 | SCRIPT_DIR=$(dirname "$(readlink -f "$0")")
24 | PACKAGE_DIR="${SCRIPT_DIR}/.."
25 | else
26 | PACKAGE_DIR="${SRCROOT}"
27 | fi
28 |
29 | # Detect OS and set paths accordingly
30 | if [ "$(uname)" = "Darwin" ]; then
31 | DEFAULT_MINT_PATH="/opt/homebrew/bin/mint"
32 | elif [ "$(uname)" = "Linux" ] && [ -n "$GITHUB_ACTIONS" ]; then
33 | DEFAULT_MINT_PATH="$GITHUB_WORKSPACE/Mint/.mint/bin/mint"
34 | elif [ "$(uname)" = "Linux" ]; then
35 | DEFAULT_MINT_PATH="/usr/local/bin/mint"
36 | else
37 | echo "Unsupported operating system"
38 | exit 1
39 | fi
40 |
41 | # Use environment MINT_CMD if set, otherwise use default path
42 | MINT_CMD=${MINT_CMD:-$DEFAULT_MINT_PATH}
43 |
44 | export MINT_PATH="$PACKAGE_DIR/.mint"
45 | MINT_ARGS="-n -m $PACKAGE_DIR/Mintfile --silent"
46 | MINT_RUN="$MINT_CMD run $MINT_ARGS"
47 |
48 | if [ "$LINT_MODE" = "NONE" ]; then
49 | exit
50 | elif [ "$LINT_MODE" = "STRICT" ]; then
51 | SWIFTFORMAT_OPTIONS="--strict --configuration .swift-format"
52 | SWIFTLINT_OPTIONS="--strict"
53 | else
54 | SWIFTFORMAT_OPTIONS="--configuration .swift-format"
55 | SWIFTLINT_OPTIONS=""
56 | fi
57 |
58 | pushd $PACKAGE_DIR
59 | run_command $MINT_CMD bootstrap -m Mintfile
60 |
61 | if [ -z "$CI" ]; then
62 | run_command $MINT_RUN swift-format format $SWIFTFORMAT_OPTIONS --recursive --parallel --in-place Sources Tests
63 | run_command $MINT_RUN swiftlint --fix
64 | fi
65 |
66 | if [ -z "$FORMAT_ONLY" ]; then
67 | run_command $MINT_RUN swift-format lint --configuration .swift-format --recursive --parallel $SWIFTFORMAT_OPTIONS Sources Tests || exit 1
68 | run_command $MINT_RUN swiftlint lint $SWIFTLINT_OPTIONS || exit 1
69 | fi
70 |
71 | $PACKAGE_DIR/Scripts/header.sh -d $PACKAGE_DIR/Sources -c "Leo Dion" -o "BrightDigit" -p "DataThespian"
72 |
73 | run_command $MINT_RUN swiftlint lint $SWIFTLINT_OPTIONS
74 |
75 | run_command $MINT_RUN swift-format lint --recursive --parallel $SWIFTFORMAT_OPTIONS Sources Tests
76 | #$MINT_RUN periphery scan $PERIPHERY_OPTIONS --disable-update-check
77 |
78 | popd
79 |
--------------------------------------------------------------------------------
/Sources/DataThespian/Notification/DataAgent.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DataAgent.swift
3 | // DataThespian
4 | //
5 | // Created by Leo Dion.
6 | // Copyright © 2025 BrightDigit.
7 | //
8 | // Permission is hereby granted, free of charge, to any person
9 | // obtaining a copy of this software and associated documentation
10 | // files (the “Software”), to deal in the Software without
11 | // restriction, including without limitation the rights to use,
12 | // copy, modify, merge, publish, distribute, sublicense, and/or
13 | // sell copies of the Software, and to permit persons to whom the
14 | // Software is furnished to do so, subject to the following
15 | // conditions:
16 | //
17 | // The above copyright notice and this permission notice shall be
18 | // included in all copies or substantial portions of the Software.
19 | //
20 | // THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
21 | // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
22 | // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
23 | // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
24 | // HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
25 | // WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
26 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
27 | // OTHER DEALINGS IN THE SOFTWARE.
28 | //
29 |
30 | #if canImport(SwiftData)
31 | public import Foundation
32 | /// A protocol that defines a data agent responsible for managing database updates and completions.
33 | public protocol DataAgent: Sendable {
34 | /// The unique identifier of the agent.
35 | var agentID: UUID { get }
36 |
37 | /// Called when the database is updated.
38 | ///
39 | /// - Parameter update: The database change set.
40 | func onUpdate(_ update: any DatabaseChangeSet)
41 |
42 | /// Called when the data agent's operations are completed.
43 | ///
44 | /// - Parameter closure: The closure to be executed when the operations are completed.
45 | func onCompleted(_ closure: @Sendable @escaping () -> Void)
46 |
47 | /// Finishes the data agent's operations.
48 | func finish() async
49 | }
50 |
51 | extension DataAgent {
52 | /// Called when the data agent's operations are completed.
53 | ///
54 | /// - Parameter closure: The closure to be executed when the operations are completed.
55 | public func onCompleted(_ closure: @Sendable @escaping () -> Void) {}
56 | }
57 | #endif
58 |
--------------------------------------------------------------------------------
/Sources/DataThespian/Notification/DatabaseChangeType.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DatabaseChangeType.swift
3 | // DataThespian
4 | //
5 | // Created by Leo Dion.
6 | // Copyright © 2025 BrightDigit.
7 | //
8 | // Permission is hereby granted, free of charge, to any person
9 | // obtaining a copy of this software and associated documentation
10 | // files (the “Software”), to deal in the Software without
11 | // restriction, including without limitation the rights to use,
12 | // copy, modify, merge, publish, distribute, sublicense, and/or
13 | // sell copies of the Software, and to permit persons to whom the
14 | // Software is furnished to do so, subject to the following
15 | // conditions:
16 | //
17 | // The above copyright notice and this permission notice shall be
18 | // included in all copies or substantial portions of the Software.
19 | //
20 | // THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
21 | // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
22 | // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
23 | // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
24 | // HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
25 | // WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
26 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
27 | // OTHER DEALINGS IN THE SOFTWARE.
28 | //
29 |
30 | /// An enumeration that represents the different types of changes that can occur in a database.
31 | public enum DatabaseChangeType: CaseIterable, Sendable {
32 | /// Represents an insertion of a new record in the database.
33 | case inserted
34 | /// Represents a deletion of a record in the database.
35 | case deleted
36 | /// Represents an update to an existing record in the database.
37 | case updated
38 |
39 | #if canImport(SwiftData)
40 | /// The key path associated with the current change type.
41 | internal var keyPath: KeyPath> {
42 | switch self {
43 | case .inserted:
44 | return \.inserted
45 | case .deleted:
46 | return \.deleted
47 | case .updated:
48 | return \.updated
49 | }
50 | }
51 | #endif
52 | }
53 |
54 | /// An extension to `Set` where the `Element` is `DatabaseChangeType`.
55 | extension Set where Element == DatabaseChangeType {
56 | /// A static property that represents a set containing all `DatabaseChangeType` cases.
57 | public static let all: Self = .init(DatabaseChangeType.allCases)
58 | }
59 |
--------------------------------------------------------------------------------
/Sources/DataThespian/Notification/Combine/PublishingRegister.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PublishingRegister.swift
3 | // DataThespian
4 | //
5 | // Created by Leo Dion.
6 | // Copyright © 2025 BrightDigit.
7 | //
8 | // Permission is hereby granted, free of charge, to any person
9 | // obtaining a copy of this software and associated documentation
10 | // files (the “Software”), to deal in the Software without
11 | // restriction, including without limitation the rights to use,
12 | // copy, modify, merge, publish, distribute, sublicense, and/or
13 | // sell copies of the Software, and to permit persons to whom the
14 | // Software is furnished to do so, subject to the following
15 | // conditions:
16 | //
17 | // The above copyright notice and this permission notice shall be
18 | // included in all copies or substantial portions of the Software.
19 | //
20 | // THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
21 | // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
22 | // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
23 | // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
24 | // HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
25 | // WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
26 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
27 | // OTHER DEALINGS IN THE SOFTWARE.
28 | //
29 |
30 | #if canImport(Combine) && canImport(SwiftData)
31 | @preconcurrency import Combine
32 | import Foundation
33 |
34 | /// A register that manages the publication of database changes.
35 | internal struct PublishingRegister: AgentRegister {
36 | /// The unique identifier for the register.
37 | internal let id: String
38 |
39 | /// The subject that publishes database change sets.
40 | private let subject: PassthroughSubject
41 |
42 | /// Initializes a new instance of `PublishingRegister`.
43 | ///
44 | /// - Parameters:
45 | /// - id: The unique identifier for the register.
46 | /// - subject: The subject that publishes database change sets.
47 | internal init(id: String, subject: PassthroughSubject) {
48 | self.id = id
49 | self.subject = subject
50 | }
51 |
52 | /// Creates a new publishing agent.
53 | ///
54 | /// - Returns: A new instance of `PublishingAgent`.
55 | internal func agent() async -> PublishingAgent {
56 | let agent = AgentType(id: id, subject: subject)
57 |
58 | return agent
59 | }
60 | }
61 | #endif
62 |
--------------------------------------------------------------------------------
/Sources/DataThespian/Databases/Database+Extras.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Database+Extras.swift
3 | // DataThespian
4 | //
5 | // Created by Leo Dion.
6 | // Copyright © 2025 BrightDigit.
7 | //
8 | // Permission is hereby granted, free of charge, to any person
9 | // obtaining a copy of this software and associated documentation
10 | // files (the “Software”), to deal in the Software without
11 | // restriction, including without limitation the rights to use,
12 | // copy, modify, merge, publish, distribute, sublicense, and/or
13 | // sell copies of the Software, and to permit persons to whom the
14 | // Software is furnished to do so, subject to the following
15 | // conditions:
16 | //
17 | // The above copyright notice and this permission notice shall be
18 | // included in all copies or substantial portions of the Software.
19 | //
20 | // THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
21 | // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
22 | // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
23 | // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
24 | // HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
25 | // WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
26 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
27 | // OTHER DEALINGS IN THE SOFTWARE.
28 | //
29 |
30 | #if canImport(SwiftData)
31 | import Foundation
32 | public import SwiftData
33 |
34 | extension Database {
35 | /// Executes a database transaction asynchronously.
36 | ///
37 | /// - Parameter block: A closure that performs database operations within the transaction.
38 | /// - Throws: Any errors that occur during the transaction.
39 | public func transaction(_ block: @Sendable @escaping (ModelContext) throws -> Void) async throws
40 | {
41 | try await self.withModelContext { context in
42 | try context.transaction {
43 | try block(context)
44 | }
45 | }
46 | }
47 |
48 | /// Deletes all models of the specified types from the database asynchronously.
49 | ///
50 | /// - Parameter types: An array of `PersistentModel.Type` instances
51 | /// representing the model types to delete.
52 | /// - Throws: Any errors that occur during the deletion process.
53 | public func deleteAll(of types: [any PersistentModel.Type]) async throws {
54 | try await self.transaction { context in
55 | for type in types {
56 | try context.delete(model: type)
57 | }
58 | }
59 | }
60 | }
61 | #endif
62 |
--------------------------------------------------------------------------------
/Tests/DataThespianTests/Tests/BasicDatabaseTests.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import Testing
3 |
4 | @testable import DataThespian
5 |
6 | #if canImport(SwiftData)
7 | import SwiftData
8 | #endif
9 |
10 | @Suite(.enabled(if: swiftDataIsAvailable()))
11 | internal struct BasicDatabaseTests {
12 | @Test internal func withModelContext() async throws {
13 | #if canImport(SwiftData)
14 | let database = try TestingDatabase(for: Parent.self, Child.self)
15 | let parentID = UUID()
16 | try await database.withModelContext { context in
17 | context.insert(Parent(id: parentID))
18 | try context.save()
19 | }
20 |
21 | let parentIDs = await database.fetch(for: .all(Parent.self)) { parents in
22 | parents.map(\.id)
23 | }
24 |
25 | #expect(parentIDs == [parentID])
26 | #endif
27 | }
28 |
29 | @Test internal func testInsertAndDelete() async throws {
30 | #if canImport(SwiftData)
31 | let database = try TestingDatabase(for: Parent.self, Child.self)
32 | let parentID = UUID()
33 |
34 | // Test insert
35 | try await database.withModelContext { context in
36 | context.insert(Parent(id: parentID))
37 | try context.save()
38 | }
39 |
40 | // Verify insert
41 | let initialCount = await database.fetch(for: .all(Parent.self)) { parents in
42 | parents.count
43 | }
44 | #expect(initialCount == 1)
45 |
46 | // Test delete using predicate
47 | try await database.delete(
48 | .predicate(
49 | #Predicate { parent in
50 | parent.id == parentID
51 | }
52 | )
53 | )
54 |
55 | // Verify delete
56 | let finalCount = await database.fetch(for: .all(Parent.self)) { parents in
57 | parents.count
58 | }
59 | #expect(finalCount == 0)
60 | #endif
61 | }
62 |
63 | @Test internal func testDeleteAll() async throws {
64 | #if canImport(SwiftData)
65 | let database = try TestingDatabase(for: Parent.self, Child.self)
66 |
67 | // Insert multiple parents
68 | try await database.withModelContext { context in
69 | for _ in 0..<5 {
70 | context.insert(Parent(id: UUID()))
71 | }
72 | try context.save()
73 | }
74 |
75 | // Delete all parents
76 | try await database.delete(.all(Parent.self))
77 |
78 | // Verify all parents were deleted
79 | let parentCount = await database.fetch(for: .all(Parent.self)) { parents in
80 | parents.count
81 | }
82 | #expect(parentCount == 0)
83 | #endif
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/Example/Sources/ContentView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ContentView.swift
3 | // DataThespianExample
4 | //
5 | // Created by Leo Dion on 10/10/24.
6 | //
7 |
8 | import Combine
9 | import DataThespian
10 | import SwiftData
11 | import SwiftUI
12 |
13 | internal struct ContentView: View {
14 | @State private var object = ContentObject()
15 | @Environment(\.database) private var database
16 | @Environment(\.databaseChangePublicist) private var databaseChangePublisher
17 |
18 | internal var body: some View {
19 | NavigationSplitView {
20 | List(selection: self.$object.selectedItemsID) {
21 | ForEach(object.items) { item in
22 | Text(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))
23 | }
24 | .onDelete(perform: object.deleteItems)
25 | }
26 | .navigationSplitViewColumnWidth(min: 200, ideal: 220)
27 | .toolbar {
28 | ToolbarItem {
29 | Button(action: addItem) {
30 | Label("Add Item", systemImage: "plus")
31 | }
32 | }
33 | ToolbarItem {
34 | Button(action: object.deleteSelectedItems) {
35 | Label("Delete Selected Items", systemImage: "trash")
36 | }
37 | }
38 | }
39 | } detail: {
40 | let selectedItems = object.selectedItems
41 | if selectedItems.count > 1 {
42 | Text("Multiple Selected")
43 | } else if let item = selectedItems.first {
44 | ItemChildView(object: object, item: item)
45 | } else {
46 | Text("Select an item")
47 | }
48 | }.onAppear {
49 | self.object.initialize(
50 | withDatabase: database,
51 | databaseChangePublisher: databaseChangePublisher
52 | )
53 | }
54 | }
55 |
56 | private func addItem() {
57 | self.addItem(withDate: .init())
58 | }
59 | private func addItem(withDate date: Date) {
60 | self.object.addItem(withDate: .init())
61 | }
62 | }
63 |
64 | #Preview {
65 | let databaseChangePublicist = DatabaseChangePublicist(dbWatcher: DataMonitor.shared)
66 | let config = ModelConfiguration(isStoredInMemoryOnly: true)
67 |
68 | // swift-format-ignore: NeverUseForceTry
69 | // swiftlint:disable:next force_try
70 | let modelContainer = try! ModelContainer(for: Item.self, configurations: config)
71 |
72 | let backgroundDatabase = BackgroundDatabase(modelContainer: modelContainer) {
73 | let context = ModelContext($0)
74 | context.autosaveEnabled = true
75 | return context
76 | }
77 |
78 | ContentView()
79 | .environment(\.databaseChangePublicist, databaseChangePublicist)
80 | .database(backgroundDatabase)
81 | }
82 |
--------------------------------------------------------------------------------
/Sources/DataThespian/Assert.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Assert.swift
3 | // DataThespian
4 | //
5 | // Created by Leo Dion.
6 | // Copyright © 2025 BrightDigit.
7 | //
8 | // Permission is hereby granted, free of charge, to any person
9 | // obtaining a copy of this software and associated documentation
10 | // files (the “Software”), to deal in the Software without
11 | // restriction, including without limitation the rights to use,
12 | // copy, modify, merge, publish, distribute, sublicense, and/or
13 | // sell copies of the Software, and to permit persons to whom the
14 | // Software is furnished to do so, subject to the following
15 | // conditions:
16 | //
17 | // The above copyright notice and this permission notice shall be
18 | // included in all copies or substantial portions of the Software.
19 | //
20 | // THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
21 | // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
22 | // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
23 | // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
24 | // HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
25 | // WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
26 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
27 | // OTHER DEALINGS IN THE SOFTWARE.
28 | //
29 |
30 | public import Foundation
31 |
32 | /// Asserts that the current thread is the main thread if the `assertIsBackground` parameter is `true`.
33 | ///
34 | /// - Parameters:
35 | /// - isMainThread: A boolean indicating whether the current thread should be the main thread.
36 | /// - assertIsBackground: A boolean indicating whether the assertion should be made.
37 | @inlinable internal func assert(isMainThread: Bool, if assertIsBackground: Bool) {
38 | assert(!assertIsBackground || isMainThread == Thread.isMainThread)
39 | }
40 |
41 | /// Asserts that the current thread is the main thread.
42 | ///
43 | /// - Parameter isMainThread: A boolean indicating whether the current thread should be the main thread.
44 | @inlinable internal func assert(isMainThread: Bool) {
45 | assert(isMainThread == Thread.isMainThread)
46 | }
47 |
48 | /// Asserts that an error has occurred, logging the localized description of the error.
49 | ///
50 | /// - Parameters:
51 | /// - error: The error that has occurred.
52 | /// - file: The file in which the assertion occurred (default is the current file).
53 | /// - line: The line in the file at which the assertion occurred (default is the current line).
54 | @inlinable internal func assertionFailure(
55 | error: any Error, file: StaticString = #file, line: UInt = #line
56 | ) {
57 | assertionFailure(error.localizedDescription, file: file, line: line)
58 | }
59 |
--------------------------------------------------------------------------------
/.swift-format:
--------------------------------------------------------------------------------
1 | {
2 | "fileScopedDeclarationPrivacy" : {
3 | "accessLevel" : "fileprivate"
4 | },
5 | "indentation" : {
6 | "spaces" : 2
7 | },
8 | "indentConditionalCompilationBlocks" : true,
9 | "indentSwitchCaseLabels" : false,
10 | "lineBreakAroundMultilineExpressionChainComponents" : false,
11 | "lineBreakBeforeControlFlowKeywords" : false,
12 | "lineBreakBeforeEachArgument" : false,
13 | "lineBreakBeforeEachGenericRequirement" : false,
14 | "lineLength" : 100,
15 | "maximumBlankLines" : 1,
16 | "multiElementCollectionTrailingCommas" : true,
17 | "noAssignmentInExpressions" : {
18 | "allowedFunctions" : [
19 | "XCTAssertNoThrow"
20 | ]
21 | },
22 | "prioritizeKeepingFunctionOutputTogether" : false,
23 | "respectsExistingLineBreaks" : true,
24 | "rules" : {
25 | "AllPublicDeclarationsHaveDocumentation" : true,
26 | "AlwaysUseLiteralForEmptyCollectionInit" : false,
27 | "AlwaysUseLowerCamelCase" : true,
28 | "AmbiguousTrailingClosureOverload" : true,
29 | "BeginDocumentationCommentWithOneLineSummary" : false,
30 | "DoNotUseSemicolons" : true,
31 | "DontRepeatTypeInStaticProperties" : true,
32 | "FileScopedDeclarationPrivacy" : false,
33 | "FullyIndirectEnum" : true,
34 | "GroupNumericLiterals" : true,
35 | "IdentifiersMustBeASCII" : true,
36 | "NeverForceUnwrap" : true,
37 | "NeverUseForceTry" : true,
38 | "NeverUseImplicitlyUnwrappedOptionals" : true,
39 | "NoAccessLevelOnExtensionDeclaration" : true,
40 | "NoAssignmentInExpressions" : true,
41 | "NoBlockComments" : true,
42 | "NoCasesWithOnlyFallthrough" : true,
43 | "NoEmptyTrailingClosureParentheses" : true,
44 | "NoLabelsInCasePatterns" : true,
45 | "NoLeadingUnderscores" : true,
46 | "NoParensAroundConditions" : true,
47 | "NoPlaygroundLiterals" : true,
48 | "NoVoidReturnOnFunctionSignature" : true,
49 | "OmitExplicitReturns" : false,
50 | "OneCasePerLine" : true,
51 | "OneVariableDeclarationPerLine" : true,
52 | "OnlyOneTrailingClosureArgument" : true,
53 | "OrderedImports" : true,
54 | "ReplaceForEachWithForLoop" : true,
55 | "ReturnVoidInsteadOfEmptyTuple" : true,
56 | "TypeNamesShouldBeCapitalized" : true,
57 | "UseEarlyExits" : false,
58 | "UseExplicitNilCheckInConditions" : true,
59 | "UseLetInEveryBoundCaseVariable" : true,
60 | "UseShorthandTypeNames" : true,
61 | "UseSingleLinePropertyGetter" : true,
62 | "UseSynthesizedInitializer" : true,
63 | "UseTripleSlashForDocumentationComments" : true,
64 | "UseWhereClausesInForLoops" : true,
65 | "ValidateDocumentationComments" : true
66 | },
67 | "spacesAroundRangeFormationOperators" : false,
68 | "tabWidth" : 2,
69 | "version" : 1
70 | }
--------------------------------------------------------------------------------
/Tests/DataThespianTests/Tests/DataMonitorTests.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import Testing
3 |
4 | @testable import DataThespian
5 |
6 | #if canImport(Combine) && canImport(SwiftData) && canImport(CoreData)
7 | import Combine
8 | import CoreData
9 | import SwiftData
10 |
11 | @Suite(.enabled(if: swiftDataIsAvailable()), .serialized)
12 | internal struct DataMonitorTests {
13 | private actor MockAgent: DataAgent {
14 | fileprivate private(set) var receivedUpdates: [any DatabaseChangeSet] = []
15 | let agentID: UUID
16 |
17 | init(agentID: UUID = UUID()) {
18 | self.agentID = agentID
19 | }
20 |
21 | nonisolated func onUpdate(_ update: any DataThespian.DatabaseChangeSet) {
22 | Task {
23 | await self.notify(update)
24 | }
25 | }
26 |
27 | fileprivate func finish() async {
28 | }
29 |
30 | fileprivate func notify(_ update: any DatabaseChangeSet) {
31 | receivedUpdates.append(update)
32 | }
33 | }
34 |
35 | private final class MockAgentRegister: AgentRegister {
36 | typealias AgentType = MockAgent
37 |
38 | fileprivate let id: String
39 | private let agent: MockAgent
40 |
41 | fileprivate init(id: String, agent: MockAgent) {
42 | self.id = id
43 | self.agent = agent
44 | }
45 |
46 | fileprivate func agent() async -> DataMonitorTests.MockAgent {
47 | agent
48 | }
49 | }
50 |
51 | @Test internal func testSharedInstance() async {
52 | let monitor1 = DataMonitor.shared
53 | let monitor2 = DataMonitor.shared
54 |
55 | #expect(ObjectIdentifier(monitor1) == ObjectIdentifier(monitor2))
56 | }
57 |
58 | @Test(.disabled(if: !Thread.current.isRunningXCTest, "Unavailable in Swift Package Manager."))
59 | internal func testBeginMonitoring() async {
60 | let monitor = DataMonitor.shared
61 | let agent = MockAgent()
62 | let registration = MockAgentRegister(id: "testBeginMonitoring", agent: agent)
63 | await monitor.allowEmptyChangesForTesting()
64 |
65 | // Begin monitoring with the agent
66 | monitor.begin(with: [registration])
67 |
68 | // Wait a bit for async setup
69 | try? await Task.sleep(nanoseconds: 100_000_000)
70 | let queuedUpdates = await agent.receivedUpdates.count
71 |
72 | // Create and send a test notification
73 | let notification = Notification(
74 | name: .NSManagedObjectContextDidSaveObjectIDs,
75 | object: nil,
76 | userInfo: [:]
77 | )
78 |
79 | // Post the notification
80 | NotificationCenter.default.post(notification)
81 |
82 | // Wait a bit for async notification
83 | try? await Task.sleep(nanoseconds: 100_000_000)
84 |
85 | // Verify the agent received the update
86 | await #expect(agent.receivedUpdates.count - queuedUpdates > 1)
87 | }
88 | }
89 | #endif
90 |
--------------------------------------------------------------------------------
/Sources/DataThespian/Notification/DatabaseChangeSet.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DatabaseChangeSet.swift
3 | // DataThespian
4 | //
5 | // Created by Leo Dion.
6 | // Copyright © 2025 BrightDigit.
7 | //
8 | // Permission is hereby granted, free of charge, to any person
9 | // obtaining a copy of this software and associated documentation
10 | // files (the “Software”), to deal in the Software without
11 | // restriction, including without limitation the rights to use,
12 | // copy, modify, merge, publish, distribute, sublicense, and/or
13 | // sell copies of the Software, and to permit persons to whom the
14 | // Software is furnished to do so, subject to the following
15 | // conditions:
16 | //
17 | // The above copyright notice and this permission notice shall be
18 | // included in all copies or substantial portions of the Software.
19 | //
20 | // THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
21 | // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
22 | // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
23 | // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
24 | // HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
25 | // WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
26 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
27 | // OTHER DEALINGS IN THE SOFTWARE.
28 | //
29 |
30 | #if canImport(SwiftData)
31 | /// A protocol that represents a set of changes to a database.
32 | public protocol DatabaseChangeSet: Sendable {
33 | /// The set of inserted managed object metadata.
34 | var inserted: Set { get }
35 |
36 | /// The set of deleted managed object metadata.
37 | var deleted: Set { get }
38 |
39 | /// The set of updated managed object metadata.
40 | var updated: Set { get }
41 | }
42 |
43 | extension DatabaseChangeSet {
44 | /// A boolean value that indicates whether the change set is empty.
45 | public var isEmpty: Bool { inserted.isEmpty && deleted.isEmpty && updated.isEmpty }
46 |
47 | /// Checks whether the change set contains any changes of the specified types
48 | /// that match the provided entity names.
49 | ///
50 | /// - Parameters:
51 | /// - types: The set of change types to check for. Defaults to `.all`.
52 | /// - filteringEntityNames: The set of entity names to filter by.
53 | /// - Returns: `true` if the change set contains any changes of the specified types
54 | /// that match the provided entity names, `false` otherwise.
55 | public func update(
56 | of types: Set = .all, contains filteringEntityNames: Set
57 | ) -> Bool {
58 | let updateEntityNamesArray = types.flatMap { self[keyPath: $0.keyPath] }.map(\.entityName)
59 | let updateEntityNames = Set(updateEntityNamesArray)
60 | return !updateEntityNames.isDisjoint(with: filteringEntityNames)
61 | }
62 | }
63 | #endif
64 |
--------------------------------------------------------------------------------
/Sources/DataThespian/SwiftData/ModelContext.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ModelContext.swift
3 | // DataThespian
4 | //
5 | // Created by Leo Dion.
6 | // Copyright © 2025 BrightDigit.
7 | //
8 | // Permission is hereby granted, free of charge, to any person
9 | // obtaining a copy of this software and associated documentation
10 | // files (the “Software”), to deal in the Software without
11 | // restriction, including without limitation the rights to use,
12 | // copy, modify, merge, publish, distribute, sublicense, and/or
13 | // sell copies of the Software, and to permit persons to whom the
14 | // Software is furnished to do so, subject to the following
15 | // conditions:
16 | //
17 | // The above copyright notice and this permission notice shall be
18 | // included in all copies or substantial portions of the Software.
19 | //
20 | // THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
21 | // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
22 | // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
23 | // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
24 | // HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
25 | // WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
26 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
27 | // OTHER DEALINGS IN THE SOFTWARE.
28 | //
29 |
30 | #if canImport(SwiftData)
31 | import Foundation
32 | public import SwiftData
33 |
34 | /// An extension to the `ModelContext` class that provides additional functionality using ``Model``.
35 | extension ModelContext {
36 | /// Retrieves an optional persistent model of the specified type with the given persistent identifier.
37 | ///
38 | /// - Parameter model: The model for which to retrieve the persistent model.
39 | /// - Returns: An optional instance of the specified persistent model,
40 | /// or `nil` if the model was not found.
41 | /// - Throws: A `SwiftData` error.
42 | public func getOptional(_ model: Model) throws -> T?
43 | where T: PersistentModel {
44 | try self.persistentModel(withID: model.persistentIdentifier)
45 | }
46 |
47 | /// Retrieves a persistent model of the specified type with the given persistent identifier.
48 | ///
49 | /// - Parameter objectID: The persistent identifier of the model to retrieve.
50 | /// - Returns: An optional instance of the specified persistent model,
51 | /// or `nil` if the model was not found.
52 | /// - Throws: A `SwiftData` error.
53 | private func persistentModel(withID objectID: PersistentIdentifier) throws -> T?
54 | where T: PersistentModel {
55 | if let registered: T = registeredModel(for: objectID) {
56 | return registered
57 | }
58 | if let notRegistered: T = model(for: objectID) as? T {
59 | return notRegistered
60 | }
61 |
62 | let fetchDescriptor = FetchDescriptor(
63 | predicate: #Predicate { $0.persistentModelID == objectID },
64 | fetchLimit: 1
65 | )
66 |
67 | return try fetch(fetchDescriptor).first
68 | }
69 | }
70 | #endif
71 |
--------------------------------------------------------------------------------
/Sources/DataThespian/Model.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Model.swift
3 | // DataThespian
4 | //
5 | // Created by Leo Dion.
6 | // Copyright © 2025 BrightDigit.
7 | //
8 | // Permission is hereby granted, free of charge, to any person
9 | // obtaining a copy of this software and associated documentation
10 | // files (the “Software”), to deal in the Software without
11 | // restriction, including without limitation the rights to use,
12 | // copy, modify, merge, publish, distribute, sublicense, and/or
13 | // sell copies of the Software, and to permit persons to whom the
14 | // Software is furnished to do so, subject to the following
15 | // conditions:
16 | //
17 | // The above copyright notice and this permission notice shall be
18 | // included in all copies or substantial portions of the Software.
19 | //
20 | // THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
21 | // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
22 | // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
23 | // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
24 | // HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
25 | // WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
26 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
27 | // OTHER DEALINGS IN THE SOFTWARE.
28 | //
29 |
30 | #if canImport(SwiftData)
31 | import Foundation
32 | public import SwiftData
33 | /// Phantom Type for easily retrieving fetching `PersistentModel` objects from a `ModelContext`.
34 | public struct Model: Sendable, Identifiable {
35 | /// An error that is thrown when a `PersistentModel`
36 | /// with the specified `PersistentIdentifier` is not found.
37 | public struct NotFoundError: Error {
38 | /// The `PersistentIdentifier` of the `PersistentModel` that was not found.
39 | public let persistentIdentifier: PersistentIdentifier
40 | }
41 |
42 | /// The unique identifier of the model.
43 | public var id: PersistentIdentifier.ID { persistentIdentifier.id }
44 |
45 | /// The `PersistentIdentifier` of the model.
46 | public let persistentIdentifier: PersistentIdentifier
47 |
48 | /// Initializes a new `Model` instance with the specified `PersistentIdentifier`.
49 | ///
50 | /// - Parameter persistentIdentifier: The `PersistentIdentifier` of the model.
51 | public init(persistentIdentifier: PersistentIdentifier) {
52 | self.persistentIdentifier = persistentIdentifier
53 | }
54 | }
55 |
56 | extension Model where T: PersistentModel {
57 | /// Initializes a new `Model` instance with the specified `PersistentModel`.
58 | ///
59 | /// - Parameter model: The `PersistentModel` to initialize the `Model` with.
60 | public init(_ model: T) {
61 | self.init(persistentIdentifier: model.persistentModelID)
62 | }
63 |
64 | /// Creates a new `Model` instance from the specified `PersistentModel`.
65 | ///
66 | /// - Parameter model: The `PersistentModel` to create the `Model` from.
67 | /// - Returns: A new `Model` instance, or `nil` if the `PersistentModel` is `nil`.
68 | internal static func ifMap(_ model: T?) -> Model? {
69 | model.map(self.init)
70 | }
71 | }
72 | #endif
73 |
--------------------------------------------------------------------------------
/Sources/DataThespian/Notification/Combine/DatabaseChangePublicist.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DatabaseChangePublicist.swift
3 | // DataThespian
4 | //
5 | // Created by Leo Dion.
6 | // Copyright © 2025 BrightDigit.
7 | //
8 | // Permission is hereby granted, free of charge, to any person
9 | // obtaining a copy of this software and associated documentation
10 | // files (the “Software”), to deal in the Software without
11 | // restriction, including without limitation the rights to use,
12 | // copy, modify, merge, publish, distribute, sublicense, and/or
13 | // sell copies of the Software, and to permit persons to whom the
14 | // Software is furnished to do so, subject to the following
15 | // conditions:
16 | //
17 | // The above copyright notice and this permission notice shall be
18 | // included in all copies or substantial portions of the Software.
19 | //
20 | // THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
21 | // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
22 | // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
23 | // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
24 | // HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
25 | // WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
26 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
27 | // OTHER DEALINGS IN THE SOFTWARE.
28 | //
29 |
30 | #if canImport(Combine) && canImport(SwiftData)
31 | public import Combine
32 |
33 | private struct NeverDatabaseMonitor: DatabaseMonitoring {
34 | /// Registers an agent with the database monitor, but always fails.
35 | /// - Parameters:
36 | /// - _: The agent to register.
37 | /// - _: A flag indicating whether the registration should be forced.
38 | func register(_: any AgentRegister, force _: Bool) {
39 | assertionFailure("Using Empty Database Listener")
40 | }
41 | }
42 |
43 | /// A struct that publishes database change events.
44 | public struct DatabaseChangePublicist: Sendable {
45 | private let dbWatcher: DatabaseMonitoring
46 |
47 | /// Initializes a new `DatabaseChangePublicist` instance.
48 | /// - Parameter dbWatcher: The database monitoring instance to use. Defaults to `DataMonitor.shared`.
49 | public init(dbWatcher: any DatabaseMonitoring = DataMonitor.shared) {
50 | self.dbWatcher = dbWatcher
51 | }
52 |
53 | /// Creates a `DatabaseChangePublicist` that never publishes any changes.
54 | public static func never() -> DatabaseChangePublicist {
55 | self.init(dbWatcher: NeverDatabaseMonitor())
56 | }
57 |
58 | /// Publishes database change events for the specified ID.
59 | /// - Parameter id: The ID of the entity to watch for changes.
60 | /// - Returns: A publisher that emits `DatabaseChangeSet` values
61 | /// whenever the database changes for the specified ID.
62 | @Sendable public func callAsFunction(id: String) -> some Publisher
63 | {
64 | // print("Creating Publisher for \(id)")
65 | let subject = PassthroughSubject()
66 | dbWatcher.register(PublishingRegister(id: id, subject: subject), force: true)
67 | return subject
68 | }
69 | }
70 | #endif
71 |
--------------------------------------------------------------------------------
/Tests/DataThespianTests/Tests/ModelActorTests.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import Testing
3 |
4 | @testable import DataThespian
5 |
6 | #if canImport(SwiftData)
7 | import SwiftData
8 | #endif
9 |
10 | @Suite(.enabled(if: swiftDataIsAvailable()))
11 | internal struct ModelActorTests {
12 | @Test internal func testGetOptionalWithModel() async throws {
13 | #if canImport(SwiftData)
14 | let database = try TestingDatabase(for: Parent.self, Child.self)
15 | let parentID = UUID()
16 |
17 | // Insert a parent
18 | try await database.withModelContext { context in
19 | context.insert(Parent(id: parentID))
20 | try context.save()
21 | }
22 |
23 | // Test getOptional with model selector
24 | let parentModels: [Model]
25 | let parentIDs: [UUID]
26 | #if swift(>=6.1)
27 | parentModels = await database.fetch(for: .all(Parent.self))
28 | #else
29 | parentModels = await database.fetch(for: .all(Parent.self))
30 | #endif
31 |
32 | let selectors = parentModels.map { Selector.Get.model($0) }
33 | #expect(parentModels.count == 1)
34 |
35 | #if swift(>=6.1)
36 | parentIDs = await database.fetch(for: selectors) { $0.id }
37 | #else
38 | parentIDs = try await database.fetch(for: selectors) { $0.id }
39 | #endif
40 |
41 | #expect(parentIDs.count == 1)
42 | #expect(parentIDs.first == parentID)
43 | #endif
44 | }
45 |
46 | @Test internal func testGetOptionalWithPredicate() async throws {
47 | #if canImport(SwiftData)
48 | let database = try TestingDatabase(for: Parent.self, Child.self)
49 | let parentID = UUID()
50 |
51 | // Insert a parent
52 | try await database.withModelContext { context in
53 | context.insert(Parent(id: parentID))
54 | try context.save()
55 | }
56 |
57 | // Test getOptional with predicate selector
58 | let predicate = #Predicate { $0.id == parentID }
59 | let result = await database.getOptional(
60 | for: .predicate(predicate)
61 | ) { $0?.id }
62 |
63 | #expect(result == parentID)
64 | #endif
65 | }
66 |
67 | @Test internal func testFetchWithDescriptor() async throws {
68 | #if canImport(SwiftData)
69 | let database = try TestingDatabase(for: Parent.self, Child.self)
70 | let parentIDs = [UUID(), UUID(), UUID()]
71 |
72 | // Insert multiple parents
73 | try await database.withModelContext { context in
74 | for id in parentIDs {
75 | context.insert(Parent(id: id))
76 | }
77 | try context.save()
78 | }
79 |
80 | // Create a descriptor
81 | let descriptor = FetchDescriptor()
82 |
83 | // Test fetch with descriptor
84 | let fetchedModels = await database.fetch(for: .descriptor(descriptor))
85 |
86 | #expect(fetchedModels.count == parentIDs.count)
87 |
88 | // Test error handling by trying to fetch with an invalid selector
89 | // This should log an error and return an empty array
90 | let emptyResult = await database.fetch(for: .all(Parent.self))
91 | #expect(
92 | emptyResult.count == 3, "Should still return results despite the invalid selector case")
93 | #endif
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/Sources/DataThespian/Databases/UniqueKeys.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UniqueKeys.swift
3 | // DataThespian
4 | //
5 | // Created by Leo Dion.
6 | // Copyright © 2025 BrightDigit.
7 | //
8 | // Permission is hereby granted, free of charge, to any person
9 | // obtaining a copy of this software and associated documentation
10 | // files (the “Software”), to deal in the Software without
11 | // restriction, including without limitation the rights to use,
12 | // copy, modify, merge, publish, distribute, sublicense, and/or
13 | // sell copies of the Software, and to permit persons to whom the
14 | // Software is furnished to do so, subject to the following
15 | // conditions:
16 | //
17 | // The above copyright notice and this permission notice shall be
18 | // included in all copies or substantial portions of the Software.
19 | //
20 | // THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
21 | // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
22 | // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
23 | // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
24 | // HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
25 | // WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
26 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
27 | // OTHER DEALINGS IN THE SOFTWARE.
28 | //
29 |
30 | /// A protocol that defines the rules for a type that represents the unique keys for a `Model` type.
31 | ///
32 | /// The `UniqueKeys` protocol has two associated type requirements:
33 | ///
34 | /// - `Model`: The type for which the unique keys are defined.
35 | /// This type must conform to the `Unique` protocol.
36 | /// - `PrimaryKey`: The type that represents the primary key for the `Model` type.
37 | /// This type must conform to the `UniqueKey` protocol, and
38 | /// its `Model` associated type must be the same as
39 | /// the `Model` associated type of the `UniqueKeys` protocol.
40 | ///
41 | /// The protocol also has a static property requirement, `primary`,
42 | /// which returns the primary key for the `Model` type.
43 | @_documentation(visibility: internal)
44 | public protocol UniqueKeys: Sendable {
45 | /// The type for which the unique keys are defined. This type must conform to the `Unique` protocol.
46 | associatedtype Model: Unique
47 |
48 | /// The type that represents the primary key for the `Model` type.
49 | /// This type must conform to the `UniqueKey` protocol, and
50 | /// its `Model` associated type must be the same as
51 | /// the `Model` associated type of the `UniqueKeys` protocol.
52 | associatedtype PrimaryKey: UniqueKey where PrimaryKey.Model == Model
53 |
54 | /// The primary key for the `Model` type.
55 | static var primary: PrimaryKey { get }
56 | }
57 |
58 | extension UniqueKeys {
59 | /// Creates a `UniqueKeyPath` instance for the specified key path.
60 | ///
61 | /// - Parameter keyPath: A key path for a property of
62 | /// the `Model` type. The property must be `Sendable`, `Equatable`, and `Codable`.
63 | /// - Returns: A `UniqueKeyPath` instance for the specified key path.
64 | public static func keyPath(
65 | _ keyPath: any KeyPath & Sendable
66 | ) -> UniqueKeyPath {
67 | UniqueKeyPath(keyPath: keyPath)
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/Sources/DataThespian/Synchronization/ModelDifferenceSynchronizer.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ModelDifferenceSynchronizer.swift
3 | // DataThespian
4 | //
5 | // Created by Leo Dion.
6 | // Copyright © 2025 BrightDigit.
7 | //
8 | // Permission is hereby granted, free of charge, to any person
9 | // obtaining a copy of this software and associated documentation
10 | // files (the “Software”), to deal in the Software without
11 | // restriction, including without limitation the rights to use,
12 | // copy, modify, merge, publish, distribute, sublicense, and/or
13 | // sell copies of the Software, and to permit persons to whom the
14 | // Software is furnished to do so, subject to the following
15 | // conditions:
16 | //
17 | // The above copyright notice and this permission notice shall be
18 | // included in all copies or substantial portions of the Software.
19 | //
20 | // THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
21 | // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
22 | // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
23 | // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
24 | // HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
25 | // WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
26 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
27 | // OTHER DEALINGS IN THE SOFTWARE.
28 | //
29 |
30 | #if canImport(SwiftData)
31 | import SwiftData
32 | /// A protocol that defines the requirements for a synchronizer that can synchronize model differences.
33 | public protocol ModelDifferenceSynchronizer: ModelSynchronizer {
34 | /// The type of synchronization difference used by this synchronizer.
35 | associatedtype SynchronizationDifferenceType: SynchronizationDifference
36 | where
37 | SynchronizationDifferenceType.DataType == DataType,
38 | SynchronizationDifferenceType.PersistentModelType == PersistentModelType
39 |
40 | /// Synchronizes the given synchronization difference with the database.
41 | ///
42 | /// - Parameters:
43 | /// - diff: The synchronization difference to be synchronized.
44 | /// - database: The database to be used for the synchronization.
45 | /// - Throws: An error that may occur during the synchronization process.
46 | static func synchronize(
47 | _ diff: SynchronizationDifferenceType,
48 | using database: any Database
49 | ) async throws
50 | }
51 |
52 | extension ModelDifferenceSynchronizer {
53 | /// Synchronizes the given model with the data using the database.
54 | ///
55 | /// - Parameters:
56 | /// - model: The model to be synchronized.
57 | /// - data: The data to be used for the synchronization.
58 | /// - database: The database to be used for the synchronization.
59 | /// - Throws: An error that may occur during the synchronization process.
60 | public static func synchronizeModel(
61 | _ model: Model,
62 | with data: DataType,
63 | using database: any Database
64 | ) async throws {
65 | let diff = try await database.get(for: .model(model)) { entry in
66 | SynchronizationDifferenceType.comparePersistentModel(entry, with: data)
67 | }
68 |
69 | return try await self.synchronize(diff, using: database)
70 | }
71 | }
72 | #endif
73 |
--------------------------------------------------------------------------------
/Tests/DataThespianTests/Tests/AnyModelTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AnyModelTests.swift
3 | // DataThespian
4 | //
5 | // Created for DataThespian.
6 | //
7 |
8 | import Foundation
9 | import Testing
10 |
11 | @testable import DataThespian
12 |
13 | #if canImport(SwiftData)
14 | import SwiftData
15 | #endif
16 |
17 | @Suite(.enabled(if: swiftDataIsAvailable()))
18 | internal struct AnyModelTests {
19 | @Test internal func testAnyModelInitWithPersistentIdentifier() async throws {
20 | #if canImport(SwiftData)
21 | let database = try TestingDatabase(for: Parent.self, Child.self)
22 | try await database.withModelContext { context in
23 | // Create and save a parent to get a valid PersistentIdentifier
24 | let parent = Parent(id: UUID())
25 | context.insert(parent)
26 | try context.save()
27 |
28 | // Create an AnyModel with the parent's PersistentIdentifier
29 | let persistentID = parent.persistentModelID
30 | let anyModel = AnyModel(persistentIdentifier: persistentID)
31 |
32 | // Verify the AnyModel has the correct persistentIdentifier
33 | #expect(anyModel.persistentIdentifier.id == persistentID.id)
34 | }
35 | #endif
36 | }
37 |
38 | @Test internal func testAnyModelInitWithPersistentModel() async throws {
39 | #if canImport(SwiftData)
40 | let database = try TestingDatabase(for: Parent.self, Child.self)
41 | try await database.withModelContext { context in
42 | // Create and save a parent
43 | let parent = Parent(id: UUID())
44 | context.insert(parent)
45 | try context.save()
46 |
47 | // Create an AnyModel from the parent
48 | let anyModel = AnyModel(parent)
49 |
50 | // Verify the AnyModel has the correct persistentIdentifier
51 | #expect(anyModel.persistentIdentifier.id == parent.persistentModelID.id)
52 | }
53 | #endif
54 | }
55 |
56 | @Test internal func testTypeErasureFromModel() async throws {
57 | #if canImport(SwiftData)
58 | let database = try TestingDatabase(for: Parent.self, Child.self)
59 | try await database.withModelContext { context in
60 | // Create and save a parent
61 | let parent = Parent(id: UUID())
62 | context.insert(parent)
63 | try context.save()
64 |
65 | // Create a typed Model from the parent
66 | let typedModel = Model(parent)
67 |
68 | // Type erase the Model to AnyModel
69 | let anyModel = AnyModel(typeErase: typedModel)
70 |
71 | // Verify the AnyModel has the correct persistentIdentifier
72 | #expect(anyModel.persistentIdentifier.id == typedModel.persistentIdentifier.id)
73 | }
74 | #endif
75 | }
76 |
77 | @Test internal func testCreateTypedModelFromAnyModel() async throws {
78 | #if canImport(SwiftData)
79 | let database = try TestingDatabase(for: Parent.self, Child.self)
80 | try await database.withModelContext { context in
81 | let parent = Parent(id: UUID())
82 | context.insert(parent)
83 | try context.save()
84 |
85 | let anyModel = AnyModel(parent)
86 | let typedModel = Model(anyModel: anyModel, type: Parent.self)
87 |
88 | #expect(typedModel.persistentIdentifier.id == anyModel.persistentIdentifier.id)
89 | }
90 | #endif
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/Sources/DataThespian/Databases/EnvironmentValues+Database.swift:
--------------------------------------------------------------------------------
1 | //
2 | // EnvironmentValues+Database.swift
3 | // DataThespian
4 | //
5 | // Created by Leo Dion.
6 | // Copyright © 2025 BrightDigit.
7 | //
8 | // Permission is hereby granted, free of charge, to any person
9 | // obtaining a copy of this software and associated documentation
10 | // files (the “Software”), to deal in the Software without
11 | // restriction, including without limitation the rights to use,
12 | // copy, modify, merge, publish, distribute, sublicense, and/or
13 | // sell copies of the Software, and to permit persons to whom the
14 | // Software is furnished to do so, subject to the following
15 | // conditions:
16 | //
17 | // The above copyright notice and this permission notice shall be
18 | // included in all copies or substantial portions of the Software.
19 | //
20 | // THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
21 | // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
22 | // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
23 | // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
24 | // HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
25 | // WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
26 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
27 | // OTHER DEALINGS IN THE SOFTWARE.
28 | //
29 |
30 | #if canImport(SwiftUI) && canImport(SwiftData)
31 | import Foundation
32 | import SwiftData
33 | public import SwiftUI
34 | /// Provides a default implementation of the `Database` protocol
35 | /// for use in environments where no other database has been set.
36 | private struct DefaultDatabase: Database {
37 | /// The singleton instance of the `DefaultDatabase`.
38 | static let instance = DefaultDatabase()
39 |
40 | // swiftlint:disable unavailable_function
41 |
42 | /// Executes the provided closure within the context of the default model context,
43 | /// asserting and throwing an error if no database has been set.
44 | ///
45 | /// - Parameter closure: A closure that takes a `ModelContext` and returns a value of type `T`.
46 | /// - Returns: The value returned by the provided closure.
47 | func withModelContext(_ closure: (ModelContext) throws -> T) async rethrows -> T {
48 | assertionFailure("No Database Set.")
49 | fatalError("No Database Set.")
50 | }
51 | // swiftlint:enable unavailable_function
52 | }
53 |
54 | extension EnvironmentValues {
55 | /// The database to be used within the current environment.
56 | @Entry public var database: any Database = DefaultDatabase.instance
57 | }
58 |
59 | extension Scene {
60 | /// Sets the database to be used within the current scene.
61 | ///
62 | /// - Parameter database: The database to be used.
63 | /// - Returns: A modified `Scene` with the provided database.
64 | public func database(_ database: any Database) -> some Scene {
65 | environment(\.database, database)
66 | }
67 | }
68 |
69 | extension View {
70 | /// Sets the database to be used within the current view.
71 | ///
72 | /// - Parameter database: The database to be used.
73 | /// - Returns: A modified `View` with the provided database.
74 | public func database(_ database: any Database) -> some View {
75 | environment(\.database, database)
76 | }
77 | }
78 | #endif
79 |
--------------------------------------------------------------------------------
/Sources/DataThespian/Notification/ManagedObjectMetadata.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ManagedObjectMetadata.swift
3 | // DataThespian
4 | //
5 | // Created by Leo Dion.
6 | // Copyright © 2025 BrightDigit.
7 | //
8 | // Permission is hereby granted, free of charge, to any person
9 | // obtaining a copy of this software and associated documentation
10 | // files (the “Software”), to deal in the Software without
11 | // restriction, including without limitation the rights to use,
12 | // copy, modify, merge, publish, distribute, sublicense, and/or
13 | // sell copies of the Software, and to permit persons to whom the
14 | // Software is furnished to do so, subject to the following
15 | // conditions:
16 | //
17 | // The above copyright notice and this permission notice shall be
18 | // included in all copies or substantial portions of the Software.
19 | //
20 | // THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
21 | // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
22 | // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
23 | // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
24 | // HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
25 | // WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
26 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
27 | // OTHER DEALINGS IN THE SOFTWARE.
28 | //
29 |
30 | #if canImport(SwiftData)
31 | public import SwiftData
32 | /// A struct that holds metadata about a managed object.
33 | public struct ManagedObjectMetadata: Sendable, Hashable {
34 | /// The name of the entity associated with the managed object.
35 | public let entityName: String
36 | /// The persistent identifier of the managed object.
37 | public let persistentIdentifier: PersistentIdentifier
38 |
39 | /// Initializes a `ManagedObjectMetadata` instance
40 | /// with the provided entity name and persistent identifier.
41 | ///
42 | /// - Parameters:
43 | /// - entityName: The name of the entity associated with the managed object.
44 | /// - persistentIdentifier: The persistent identifier of the managed object.
45 | public init(entityName: String, persistentIdentifier: PersistentIdentifier) {
46 | self.entityName = entityName
47 | self.persistentIdentifier = persistentIdentifier
48 | }
49 | }
50 |
51 | #if canImport(CoreData)
52 | import CoreData
53 |
54 | extension ManagedObjectMetadata {
55 | internal init?(objectID: NSManagedObjectID) {
56 | let persistentIdentifier: PersistentIdentifier
57 | do {
58 | persistentIdentifier = try objectID.persistentIdentifier()
59 | } catch {
60 | assertionFailure(error: error)
61 | return nil
62 | }
63 |
64 | guard let entityName = objectID.entityName else {
65 | assertionFailure("Missing entity name.")
66 | return nil
67 | }
68 |
69 | self.init(entityName: entityName, persistentIdentifier: persistentIdentifier)
70 | }
71 |
72 | /// Initializes a `ManagedObjectMetadata` instance with the provided `NSManagedObject`.
73 | ///
74 | /// - Parameter managedObject: The `NSManagedObject` instance to get the metadata from.
75 | internal init?(managedObject: NSManagedObject) {
76 | self.init(objectID: managedObject.objectID)
77 | }
78 | }
79 | #endif
80 | #endif
81 |
--------------------------------------------------------------------------------
/Sources/DataThespian/AnyModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AnyModel.swift
3 | // DataThespian
4 | //
5 | // Created by Leo Dion.
6 | // Copyright © 2025 BrightDigit.
7 | //
8 | // Permission is hereby granted, free of charge, to any person
9 | // obtaining a copy of this software and associated documentation
10 | // files (the “Software”), to deal in the Software without
11 | // restriction, including without limitation the rights to use,
12 | // copy, modify, merge, publish, distribute, sublicense, and/or
13 | // sell copies of the Software, and to permit persons to whom the
14 | // Software is furnished to do so, subject to the following
15 | // conditions:
16 | //
17 | // The above copyright notice and this permission notice shall be
18 | // included in all copies or substantial portions of the Software.
19 | //
20 | // THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
21 | // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
22 | // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
23 | // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
24 | // HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
25 | // WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
26 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
27 | // OTHER DEALINGS IN THE SOFTWARE.
28 | //
29 |
30 | #if canImport(SwiftData)
31 | import Foundation
32 | public import SwiftData
33 |
34 | /// Phantom Type for easily retrieving fetching `PersistentModel` objects from a `ModelContext`.
35 | public struct AnyModel: Sendable, Identifiable {
36 | /// An error that is thrown when a `PersistentModel`
37 | /// with the specified `PersistentIdentifier` is not found.
38 | public struct NotFoundError: Error {
39 | /// The `PersistentIdentifier` of the `PersistentModel` that was not found.
40 | public let persistentIdentifier: PersistentIdentifier
41 | }
42 |
43 | /// The unique identifier of the model.
44 | public var id: PersistentIdentifier.ID { persistentIdentifier.id }
45 |
46 | /// The `PersistentIdentifier` of the model.
47 | public let persistentIdentifier: PersistentIdentifier
48 |
49 | /// Initializes a new `Model` instance with the specified `PersistentIdentifier`.
50 | ///
51 | /// - Parameter persistentIdentifier: The `PersistentIdentifier` of the model.
52 | public init(persistentIdentifier: PersistentIdentifier) {
53 | self.persistentIdentifier = persistentIdentifier
54 | }
55 | }
56 |
57 | extension AnyModel {
58 | /// Initializes a new `Model` instance with the specified `PersistentModel`.
59 | ///
60 | /// - Parameter model: The `PersistentModel` to initialize the `Model` with.
61 | public init(_ model: any PersistentModel) {
62 | self.init(persistentIdentifier: model.persistentModelID)
63 | }
64 |
65 | /// Type erases the ``Model``
66 | /// - Parameter typeErase: Original ``Model``.
67 | public init(typeErase: Model) {
68 | self.init(persistentIdentifier: typeErase.persistentIdentifier)
69 | }
70 | }
71 |
72 | extension Model {
73 | /// Creates a typed ``Model``
74 | /// - Parameters:
75 | /// - anyModel: ``AnyModel``
76 | /// - _: ``Model`` type.
77 | public init(anyModel: AnyModel, type _: T.Type) {
78 | self.init(persistentIdentifier: anyModel.persistentIdentifier)
79 | }
80 | }
81 | #endif
82 |
--------------------------------------------------------------------------------
/.github/workflows/codeql.yml:
--------------------------------------------------------------------------------
1 | # For most projects, this workflow file will not need changing; you simply need
2 | # to commit it to your repository.
3 | #
4 | # You may wish to alter this file to override the set of languages analyzed,
5 | # or to provide custom queries or build logic.
6 | #
7 | # ******** NOTE ********
8 | # We have attempted to detect the languages in your repository. Please check
9 | # the `language` matrix defined below to confirm you have the correct set of
10 | # supported CodeQL languages.
11 | #
12 | name: "CodeQL"
13 |
14 | on:
15 | push:
16 | branches-ignore:
17 | - '*WIP'
18 | pull_request:
19 | # The branches below must be a subset of the branches above
20 | branches: [ "main" ]
21 | schedule:
22 | - cron: '20 11 * * 3'
23 |
24 | jobs:
25 | analyze:
26 | name: Analyze
27 | # Runner size impacts CodeQL analysis time. To learn more, please see:
28 | # - https://gh.io/recommended-hardware-resources-for-running-codeql
29 | # - https://gh.io/supported-runners-and-hardware-resources
30 | # - https://gh.io/using-larger-runners
31 | # Consider using larger runners for possible analysis time improvements.
32 | runs-on: ${{ (matrix.language == 'swift' && 'macos-15') || 'ubuntu-latest' }}
33 | timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }}
34 | permissions:
35 | actions: read
36 | contents: read
37 | security-events: write
38 |
39 | strategy:
40 | fail-fast: false
41 | matrix:
42 | language: [ 'swift' ]
43 | # CodeQL supports [ 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' ]
44 | # Use only 'java-kotlin' to analyze code written in Java, Kotlin or both
45 | # Use only 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
46 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
47 |
48 | steps:
49 | - name: Checkout repository
50 | uses: actions/checkout@v4
51 |
52 | - name: Setup Xcode
53 | run: sudo xcode-select -s /Applications/Xcode_16.4.app/Contents/Developer
54 |
55 | - name: Verify Swift Version
56 | run: |
57 | swift --version
58 | swift package --version
59 |
60 | # Initializes the CodeQL tools for scanning.
61 | - name: Initialize CodeQL
62 | uses: github/codeql-action/init@v3
63 | with:
64 | languages: ${{ matrix.language }}
65 | # If you wish to specify custom queries, you can do so here or in a config file.
66 | # By default, queries listed here will override any specified in a config file.
67 | # Prefix the list here with "+" to use these queries and those in the config file.
68 |
69 | # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
70 | # queries: security-extended,security-and-quality
71 |
72 |
73 | # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift).
74 | # If this step fails, then you should remove it and run the build manually (see below)
75 | - run: |
76 | echo "Run, Build Application using script"
77 | swift build
78 |
79 | - name: Perform CodeQL Analysis
80 | uses: github/codeql-action/analyze@v3
81 | with:
82 | category: "/language:${{matrix.language}}"
83 |
--------------------------------------------------------------------------------
/Scripts/header.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Function to print usage
4 | usage() {
5 | echo "Usage: $0 -d directory -c creator -o company -p package [-y year]"
6 | echo " -d directory Directory to read from (including subdirectories)"
7 | echo " -c creator Name of the creator"
8 | echo " -o company Name of the company with the copyright"
9 | echo " -p package Package or library name"
10 | echo " -y year Copyright year (optional, defaults to current year)"
11 | exit 1
12 | }
13 |
14 | # Get the current year if not provided
15 | current_year=$(date +"%Y")
16 |
17 | # Default values
18 | year="$current_year"
19 |
20 | # Parse arguments
21 | while getopts ":d:c:o:p:y:" opt; do
22 | case $opt in
23 | d) directory="$OPTARG" ;;
24 | c) creator="$OPTARG" ;;
25 | o) company="$OPTARG" ;;
26 | p) package="$OPTARG" ;;
27 | y) year="$OPTARG" ;;
28 | *) usage ;;
29 | esac
30 | done
31 |
32 | # Check for mandatory arguments
33 | if [ -z "$directory" ] || [ -z "$creator" ] || [ -z "$company" ] || [ -z "$package" ]; then
34 | usage
35 | fi
36 |
37 | # Define the header template
38 | header_template="//
39 | // %s
40 | // %s
41 | //
42 | // Created by %s.
43 | // Copyright © %s %s.
44 | //
45 | // Permission is hereby granted, free of charge, to any person
46 | // obtaining a copy of this software and associated documentation
47 | // files (the “Software”), to deal in the Software without
48 | // restriction, including without limitation the rights to use,
49 | // copy, modify, merge, publish, distribute, sublicense, and/or
50 | // sell copies of the Software, and to permit persons to whom the
51 | // Software is furnished to do so, subject to the following
52 | // conditions:
53 | //
54 | // The above copyright notice and this permission notice shall be
55 | // included in all copies or substantial portions of the Software.
56 | //
57 | // THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
58 | // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
59 | // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
60 | // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
61 | // HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
62 | // WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
63 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
64 | // OTHER DEALINGS IN THE SOFTWARE.
65 | //"
66 |
67 | # Loop through each Swift file in the specified directory and subdirectories
68 | find "$directory" -type f -name "*.swift" | while read -r file; do
69 | # Check if the first line is the swift-format-ignore indicator
70 | first_line=$(head -n 1 "$file")
71 | if [[ "$first_line" == "// swift-format-ignore-file" ]]; then
72 | echo "Skipping $file due to swift-format-ignore directive."
73 | continue
74 | fi
75 |
76 | # Create the header with the current filename
77 | filename=$(basename "$file")
78 | header=$(printf "$header_template" "$filename" "$package" "$creator" "$year" "$company")
79 |
80 | # Remove all consecutive lines at the beginning which start with "// ", contain only whitespace, or only "//"
81 | awk '
82 | BEGIN { skip = 1 }
83 | {
84 | if (skip && ($0 ~ /^\/\/ / || $0 ~ /^\/\/$/ || $0 ~ /^$/)) {
85 | next
86 | }
87 | skip = 0
88 | print
89 | }' "$file" > temp_file
90 |
91 | # Add the header to the cleaned file
92 | (echo "$header"; echo; cat temp_file) > "$file"
93 |
94 | # Remove the temporary file
95 | rm temp_file
96 | done
97 |
98 | echo "Headers added or files skipped appropriately across all Swift files in the directory and subdirectories."
--------------------------------------------------------------------------------
/Tests/DataThespianTests/Tests/PerformanceTests.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import Testing
3 |
4 | @testable import DataThespian
5 |
6 | #if canImport(SwiftData)
7 | import SwiftData
8 | #endif
9 |
10 | @Suite(.enabled(if: swiftDataIsAvailable()))
11 | internal struct PerformanceTests {
12 | @Test internal func testBulkInsertPerformance() async throws {
13 | #if canImport(SwiftData)
14 | let database = try TestingDatabase(for: Parent.self, Child.self)
15 | let parentCount = 1_000
16 | let parentIDs = (0.. { parentIDs.contains($0.id) }
59 | )
60 | )
61 | let endTime = Date()
62 |
63 | // Verify all parents were deleted
64 | let newParentCount = await database.fetch(for: .all(Parent.self)) { parents in
65 | parents.count
66 | }
67 | #expect(newParentCount == 0)
68 |
69 | // Log performance metrics
70 | let duration = endTime.timeIntervalSince(startTime)
71 | print("Bulk delete of \(parentCount) parents took \(duration) seconds")
72 | #endif
73 | }
74 |
75 | @Test internal func testBulkFetchPerformance() async throws {
76 | #if canImport(SwiftData)
77 | let database = try TestingDatabase(for: Parent.self, Child.self)
78 | let parentCount = 1_000
79 | let parentIDs = (0..
37 |
38 | /// The set of managed objects that were deleted.
39 | internal let deleted: Set
40 |
41 | /// The set of managed objects that were updated.
42 | internal let updated: Set
43 |
44 | /// Initializes a `NotificationDataUpdate` instance with the specified sets
45 | /// of inserted, deleted, and updated managed objects.
46 | ///
47 | /// - Parameters:
48 | /// - inserted: The set of managed objects that were inserted, or an empty set if none were inserted.
49 | /// - deleted: The set of managed objects that were deleted, or an empty set if none were deleted.
50 | /// - updated: The set of managed objects that were updated, or an empty set if none were updated.
51 | private init(
52 | inserted: Set?,
53 | deleted: Set?,
54 | updated: Set?
55 | ) {
56 | self.init(
57 | inserted: inserted ?? .init(),
58 | deleted: deleted ?? .init(),
59 | updated: updated ?? .init()
60 | )
61 | }
62 |
63 | /// Initializes a `NotificationDataUpdate` instance with
64 | /// the specified sets of inserted, deleted, and updated managed objects.
65 | ///
66 | /// - Parameters:
67 | /// - inserted: The set of managed objects that were inserted.
68 | /// - deleted: The set of managed objects that were deleted.
69 | /// - updated: The set of managed objects that were updated.
70 | private init(
71 | inserted: Set,
72 | deleted: Set,
73 | updated: Set
74 | ) {
75 | self.inserted = inserted
76 | self.deleted = deleted
77 | self.updated = updated
78 | }
79 |
80 | /// Initializes a `NotificationDataUpdate` instance from a Notification object.
81 | ///
82 | /// - Parameter notification: The notification that triggered the data update.
83 | internal init(_ notification: Notification) {
84 | self.init(
85 | inserted: notification.managedObjectIDs(key: NSInsertedObjectIDsKey),
86 | deleted: notification.managedObjectIDs(key: NSDeletedObjectIDsKey),
87 | updated: notification.managedObjectIDs(key: NSUpdatedObjectIDsKey)
88 | )
89 | }
90 | }
91 | #endif
92 |
--------------------------------------------------------------------------------
/Sources/DataThespian/Databases/ModelActorDatabase.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ModelActorDatabase.swift
3 | // DataThespian
4 | //
5 | // Created by Leo Dion.
6 | // Copyright © 2025 BrightDigit.
7 | //
8 | // Permission is hereby granted, free of charge, to any person
9 | // obtaining a copy of this software and associated documentation
10 | // files (the “Software”), to deal in the Software without
11 | // restriction, including without limitation the rights to use,
12 | // copy, modify, merge, publish, distribute, sublicense, and/or
13 | // sell copies of the Software, and to permit persons to whom the
14 | // Software is furnished to do so, subject to the following
15 | // conditions:
16 | //
17 | // The above copyright notice and this permission notice shall be
18 | // included in all copies or substantial portions of the Software.
19 | //
20 | // THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
21 | // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
22 | // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
23 | // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
24 | // HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
25 | // WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
26 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
27 | // OTHER DEALINGS IN THE SOFTWARE.
28 | //
29 |
30 | #if canImport(SwiftData)
31 | public import SwiftData
32 |
33 | /// Simplied and customizable `ModelActor` ``Database``.
34 | public actor ModelActorDatabase: Database, ModelActor {
35 | /// The model executor used by this database.
36 | public nonisolated let modelExecutor: any SwiftData.ModelExecutor
37 | /// The model container used by this database.
38 | public nonisolated let modelContainer: SwiftData.ModelContainer
39 |
40 | /// Initializes a new `ModelActorDatabase` with the given `modelContainer`.
41 | /// - Parameter modelContainer: The model container to use for this database.
42 | public init(modelContainer: SwiftData.ModelContainer) {
43 | self.init(
44 | modelContainer: modelContainer,
45 | modelContext: ModelContext.init
46 | )
47 | }
48 |
49 | /// Initializes a new `ModelActorDatabase` with
50 | /// the given `modelContainer` and a custom `modelContext` closure.
51 | /// - Parameters:
52 | /// - modelContainer: The model container to use for this database.
53 | /// - closure: A closure that creates a
54 | /// custom `ModelContext` from the `ModelContainer`.
55 | public init(
56 | modelContainer: SwiftData.ModelContainer,
57 | modelContext closure: @Sendable @escaping (ModelContainer) -> ModelContext
58 | ) {
59 | self.init(
60 | modelContainer: modelContainer,
61 | modelExecutor: DefaultSerialModelExecutor.create(from: closure)
62 | )
63 | }
64 |
65 | /// Initializes a new `ModelActorDatabase` with
66 | /// the given `modelContainer` and a custom `modelExecutor` closure.
67 | /// - Parameters:
68 | /// - modelContainer: The model container to use for this database.
69 | /// - closure: A closure that creates
70 | /// a custom `ModelExecutor` from the `ModelContainer`.
71 | public init(
72 | modelContainer: SwiftData.ModelContainer,
73 | modelExecutor closure: @Sendable @escaping (ModelContainer) -> any ModelExecutor
74 | ) {
75 | self.init(
76 | modelExecutor: closure(modelContainer),
77 | modelContainer: modelContainer
78 | )
79 | }
80 |
81 | private init(modelExecutor: any ModelExecutor, modelContainer: ModelContainer) {
82 | self.modelExecutor = modelExecutor
83 | self.modelContainer = modelContainer
84 | }
85 | }
86 |
87 | extension DefaultSerialModelExecutor {
88 | fileprivate static func create(
89 | from closure: @Sendable @escaping (ModelContainer) -> ModelContext
90 | ) -> @Sendable (ModelContainer) -> any ModelExecutor {
91 | {
92 | DefaultSerialModelExecutor(modelContext: closure($0))
93 | }
94 | }
95 | }
96 | #endif
97 |
--------------------------------------------------------------------------------
/Sources/DataThespian/Notification/RegistrationCollection.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RegistrationCollection.swift
3 | // DataThespian
4 | //
5 | // Created by Leo Dion.
6 | // Copyright © 2025 BrightDigit.
7 | //
8 | // Permission is hereby granted, free of charge, to any person
9 | // obtaining a copy of this software and associated documentation
10 | // files (the “Software”), to deal in the Software without
11 | // restriction, including without limitation the rights to use,
12 | // copy, modify, merge, publish, distribute, sublicense, and/or
13 | // sell copies of the Software, and to permit persons to whom the
14 | // Software is furnished to do so, subject to the following
15 | // conditions:
16 | //
17 | // The above copyright notice and this permission notice shall be
18 | // included in all copies or substantial portions of the Software.
19 | //
20 | // THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
21 | // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
22 | // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
23 | // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
24 | // HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
25 | // WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
26 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
27 | // OTHER DEALINGS IN THE SOFTWARE.
28 | //
29 |
30 | #if canImport(SwiftData)
31 | import Foundation
32 | /// An actor that manages a collection of `DataAgent` registrations.
33 | internal actor RegistrationCollection: Loggable {
34 | internal static var loggingCategory: ThespianLogging.Category { .application }
35 |
36 | private var registrations = [String: DataAgent]()
37 |
38 | /// Notifies the collection of a database change set update.
39 | /// - Parameter update: The database change set update.
40 | nonisolated internal func notify(_ update: any DatabaseChangeSet) {
41 | Task {
42 | await self.onUpdate(update)
43 | Self.logger.debug("Notification Complete")
44 | }
45 | }
46 |
47 | /// Adds a new `DataAgent` registration to the collection.
48 | /// - Parameters:
49 | /// - id: The unique identifier for the registration.
50 | /// - force: A Boolean value indicating whether to force the registration if it already exists.
51 | /// - agent: A closure that creates the `DataAgent` to be registered.
52 | nonisolated internal func add(
53 | withID id: String, force: Bool, agent: @Sendable @escaping () async -> DataAgent
54 | ) {
55 | Task { await self.append(withID: id, force: force, agent: agent) }
56 | }
57 |
58 | private func append(
59 | withID id: String, force: Bool, agent: @Sendable @escaping () async -> DataAgent
60 | ) async {
61 | if let registration = registrations[id], force {
62 | Self.logger.debug("Overwriting \(id). Already exists.")
63 | await registration.finish()
64 | } else if registrations[id] != nil {
65 | Self.logger.debug("Can't register \(id). Already exists.")
66 | return
67 | }
68 | Self.logger.debug("Registering \(id)")
69 | let agent = await agent()
70 | agent.onCompleted { Task { await self.remove(withID: id, agentID: agent.agentID) } }
71 | registrations[id] = agent
72 | Self.logger.debug("Registration Count \(self.registrations.count)")
73 | }
74 |
75 | private func remove(withID id: String, agentID: UUID) {
76 | guard let agent = registrations[id] else {
77 | Self.logger.warning("No matching registration with id: \(id)")
78 | return
79 | }
80 | guard agent.agentID == agentID else {
81 | Self.logger.warning("No matching registration with agentID: \(agentID)")
82 | return
83 | }
84 | registrations.removeValue(forKey: id)
85 | Self.logger.debug("Registration Count \(self.registrations.count)")
86 | }
87 |
88 | private func onUpdate(_ update: any DatabaseChangeSet) {
89 | for (id, registration) in registrations {
90 | Self.logger.debug("Notifying \(id)")
91 | registration.onUpdate(update)
92 | }
93 | }
94 | }
95 | #endif
96 |
--------------------------------------------------------------------------------
/Sources/DataThespian/Notification/DataMonitor.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DataMonitor.swift
3 | // DataThespian
4 | //
5 | // Created by Leo Dion.
6 | // Copyright © 2025 BrightDigit.
7 | //
8 | // Permission is hereby granted, free of charge, to any person
9 | // obtaining a copy of this software and associated documentation
10 | // files (the “Software”), to deal in the Software without
11 | // restriction, including without limitation the rights to use,
12 | // copy, modify, merge, publish, distribute, sublicense, and/or
13 | // sell copies of the Software, and to permit persons to whom the
14 | // Software is furnished to do so, subject to the following
15 | // conditions:
16 | //
17 | // The above copyright notice and this permission notice shall be
18 | // included in all copies or substantial portions of the Software.
19 | //
20 | // THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
21 | // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
22 | // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
23 | // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
24 | // HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
25 | // WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
26 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
27 | // OTHER DEALINGS IN THE SOFTWARE.
28 | //
29 |
30 | #if canImport(Combine) && canImport(SwiftData) && canImport(CoreData)
31 |
32 | import Combine
33 | import CoreData
34 | import Foundation
35 | import SwiftData
36 | /// Monitors the database for changes and notifies registered agents of those changes.
37 | public actor DataMonitor: DatabaseMonitoring, Loggable {
38 | /// The logging category for this class.
39 | public static var loggingCategory: ThespianLogging.Category { .data }
40 |
41 | /// The shared instance of the `DataMonitor`.
42 | public static let shared = DataMonitor()
43 |
44 | private var object: (any NSObjectProtocol)?
45 | private var registrations = RegistrationCollection()
46 | internal var allowEmptyChanges: Bool = false
47 |
48 | private init() { Self.logger.debug("Creating DatabaseMonitor") }
49 |
50 | /// Registers the given agent with the database monitor.
51 | ///
52 | /// - Parameters:
53 | /// - registration: The agent to register.
54 | /// - force: Whether to force the registration,
55 | /// even if a registration with the same ID already exists.
56 | public nonisolated func register(_ registration: any AgentRegister, force: Bool) {
57 | Task { await self.addRegistration(registration, force: force) }
58 | }
59 |
60 | private func addRegistration(_ registration: any AgentRegister, force: Bool) {
61 | registrations.add(withID: registration.id, force: force, agent: registration.agent)
62 | }
63 |
64 | /// Begins monitoring the database with the given agent registrations.
65 | ///
66 | /// - Parameter builders: The agent registrations to monitor.
67 | public nonisolated func begin(with builders: [any AgentRegister]) {
68 | Task {
69 | await self.addObserver()
70 | for builder in builders { await self.addRegistration(builder, force: false) }
71 | }
72 | }
73 |
74 | internal func allowEmptyChangesForTesting() {
75 | allowEmptyChanges = true
76 | }
77 |
78 | private func addObserver() {
79 | guard object == nil else {
80 | return
81 | }
82 | object = NotificationCenter.default.addObserver(
83 | forName: .NSManagedObjectContextDidSaveObjectIDs,
84 | object: nil,
85 | queue: nil,
86 | using: { notification in
87 | let update = NotificationDataUpdate(notification)
88 | Task { await self.notifyRegisration(update) }
89 | }
90 | )
91 | }
92 |
93 | private func notifyRegisration(_ update: any DatabaseChangeSet) {
94 | guard !update.isEmpty || allowEmptyChanges else {
95 | return
96 | }
97 | Self.logger.debug("Notifying of Update")
98 |
99 | registrations.notify(update)
100 | }
101 | }
102 |
103 | #endif
104 |
--------------------------------------------------------------------------------
/Sources/DataThespian/Databases/Database+Queryable.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Database+Queryable.swift
3 | // DataThespian
4 | //
5 | // Created by Leo Dion.
6 | // Copyright © 2025 BrightDigit.
7 | //
8 | // Permission is hereby granted, free of charge, to any person
9 | // obtaining a copy of this software and associated documentation
10 | // files (the “Software”), to deal in the Software without
11 | // restriction, including without limitation the rights to use,
12 | // copy, modify, merge, publish, distribute, sublicense, and/or
13 | // sell copies of the Software, and to permit persons to whom the
14 | // Software is furnished to do so, subject to the following
15 | // conditions:
16 | //
17 | // The above copyright notice and this permission notice shall be
18 | // included in all copies or substantial portions of the Software.
19 | //
20 | // THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
21 | // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
22 | // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
23 | // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
24 | // HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
25 | // WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
26 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
27 | // OTHER DEALINGS IN THE SOFTWARE.
28 | //
29 |
30 | #if canImport(SwiftData)
31 | public import SwiftData
32 |
33 | extension Database {
34 | /// Saves the current state of the database.
35 | /// - Throws: Any errors that occur during the save operation.
36 | public func save() async throws {
37 | try await self.withModelContext { try $0.save() }
38 | }
39 |
40 | /// Inserts a new persistent model into the database.
41 | /// - Parameters:
42 | /// - closuer: A closure that creates a new instance of the persistent model.
43 | /// - closure: A closure that performs additional operations on the inserted model.
44 | /// - Returns: The result of the `closure` parameter.
45 | public func insert(
46 | _ closuer: @Sendable @escaping () -> PersistentModelType,
47 | with closure: @escaping @Sendable (PersistentModelType) throws -> U
48 | ) async rethrows -> U {
49 | try await self.withModelContext {
50 | try $0.insert(closuer, with: closure)
51 | }
52 | }
53 |
54 | /// Retrieves an optional persistent model from the database.
55 | /// - Parameters:
56 | /// - selector: A selector that specifies the model to retrieve.
57 | /// - closure: A closure that performs additional operations on the retrieved model.
58 | /// - Returns: The result of the `closure` parameter.
59 | public func getOptional(
60 | for selector: Selector.Get,
61 | with closure: @escaping @Sendable (PersistentModelType?) throws -> U
62 | ) async rethrows -> U {
63 | try await self.withModelContext {
64 | try $0.getOptional(for: selector, with: closure)
65 | }
66 | }
67 |
68 | /// Retrieves a list of persistent models from the database.
69 | /// - Parameters:
70 | /// - selector: A selector that specifies the models to retrieve.
71 | /// - closure: A closure that performs additional operations on the retrieved models.
72 | /// - Returns: The result of the `closure` parameter.
73 | public func fetch(
74 | for selector: Selector.List,
75 | with closure: @escaping @Sendable ([PersistentModelType]) throws -> U
76 | ) async rethrows -> U {
77 | try await self.withModelContext {
78 | try $0.fetch(for: selector, with: closure)
79 | }
80 | }
81 |
82 | /// Deletes a persistent model from the database.
83 | /// - Parameter selector: A selector that specifies the model to delete.
84 | /// - Throws: Any errors that occur during the delete operation.
85 | public func delete(_ selector: Selector.Delete)
86 | async throws
87 | {
88 | try await self.withModelContext {
89 | try $0.delete(selector)
90 | }
91 | }
92 | }
93 | #endif
94 |
--------------------------------------------------------------------------------
/Sources/DataThespian/SwiftData/ModelContext+Extension.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ModelContext+Extension.swift
3 | // DataThespian
4 | //
5 | // Created by Leo Dion.
6 | // Copyright © 2025 BrightDigit.
7 | //
8 | // Permission is hereby granted, free of charge, to any person
9 | // obtaining a copy of this software and associated documentation
10 | // files (the “Software”), to deal in the Software without
11 | // restriction, including without limitation the rights to use,
12 | // copy, modify, merge, publish, distribute, sublicense, and/or
13 | // sell copies of the Software, and to permit persons to whom the
14 | // Software is furnished to do so, subject to the following
15 | // conditions:
16 | //
17 | // The above copyright notice and this permission notice shall be
18 | // included in all copies or substantial portions of the Software.
19 | //
20 | // THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
21 | // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
22 | // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
23 | // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
24 | // HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
25 | // WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
26 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
27 | // OTHER DEALINGS IN THE SOFTWARE.
28 | //
29 |
30 | #if canImport(SwiftData)
31 | public import Foundation
32 | public import SwiftData
33 | /// Extension to `ModelContext` to provide additional functionality for managing persistent models.
34 | extension ModelContext {
35 | /// Inserts a new persistent model into the context.
36 | /// - Parameter closuer: A closure that creates a new instance of the `PersistentModel`.
37 | /// - Returns: A `Model` instance representing the newly inserted model.
38 | public func insert(_ closuer: @escaping @Sendable () -> T) -> Model {
39 | let model = closuer()
40 | self.insert(model)
41 | return .init(model)
42 | }
43 |
44 | /// Fetches an array of persistent models based on the provided selectors.
45 | /// - Parameter selectors: An array of `Selector.Get` instances
46 | /// to fetch the models.
47 | /// - Returns: An array of `PersistentModelType` instances.
48 | /// - Throws: A `SwiftData` error.
49 | public func fetch(
50 | for selectors: [Selector.Get]
51 | ) throws -> [PersistentModelType] {
52 | try selectors
53 | .map {
54 | try self.getOptional(for: $0)
55 | }
56 | .compactMap { $0 }
57 | }
58 |
59 | /// Retrieves a persistent model from the context.
60 | /// - Parameter model: A `Model` instance representing the persistent model to fetch.
61 | /// - Returns: The `T` instance of the persistent model.
62 | /// - Throws: `QueryError.itemNotFound` if the model is not found in the context.
63 | public func get(_ model: Model) throws -> T
64 | where T: PersistentModel {
65 | guard let item = try self.getOptional(model) else {
66 | throw QueryError.itemNotFound(.model(model))
67 | }
68 | return item
69 | }
70 |
71 | /// Deletes persistent models based on the provided selectors.
72 | /// - Parameter selectors: An array of `Selector.Delete` instances
73 | /// to delete the models.
74 | /// - Throws: A `SwiftData` error.
75 | public func delete(
76 | _ selectors: [Selector.Delete]
77 | ) throws {
78 | for selector in selectors {
79 | try self.delete(selector)
80 | }
81 | }
82 |
83 | /// Retrieves the first persistent model that matches the provided predicate.
84 | /// - Parameter predicate: An optional `Predicate` instance to filter the results.
85 | /// - Returns: The first `PersistentModelType` instance that matches the predicate,
86 | /// or `nil` if no match is found.
87 | /// - Throws: A `SwiftData` error.
88 | public func first(
89 | where predicate: Predicate? = nil
90 | ) throws -> PersistentModelType? {
91 | try self.fetch(FetchDescriptor(predicate: predicate, fetchLimit: 1)).first
92 | }
93 | }
94 | #endif
95 |
--------------------------------------------------------------------------------
/Sources/DataThespian/Databases/Queryable.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Queryable.swift
3 | // DataThespian
4 | //
5 | // Created by Leo Dion.
6 | // Copyright © 2025 BrightDigit.
7 | //
8 | // Permission is hereby granted, free of charge, to any person
9 | // obtaining a copy of this software and associated documentation
10 | // files (the “Software”), to deal in the Software without
11 | // restriction, including without limitation the rights to use,
12 | // copy, modify, merge, publish, distribute, sublicense, and/or
13 | // sell copies of the Software, and to permit persons to whom the
14 | // Software is furnished to do so, subject to the following
15 | // conditions:
16 | //
17 | // The above copyright notice and this permission notice shall be
18 | // included in all copies or substantial portions of the Software.
19 | //
20 | // THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
21 | // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
22 | // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
23 | // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
24 | // HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
25 | // WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
26 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
27 | // OTHER DEALINGS IN THE SOFTWARE.
28 | //
29 |
30 | #if canImport(SwiftData)
31 | public import SwiftData
32 | /// Providers a set of _CRUD_ methods for a ``Database``.
33 | public protocol Queryable: Sendable {
34 | /// Saves the current state of the Queryable instance to the persistent data store.
35 | /// - Throws: An error that indicates why the save operation failed.
36 | func save() async throws
37 |
38 | /// Inserts a new persistent model into the data store and returns a transformed result.
39 | /// - Parameters:
40 | /// - insertClosure: A closure that creates a new instance of the `PersistentModelType`.
41 | /// - closure: A closure that performs some operation
42 | /// on the newly inserted `PersistentModelType` instance
43 | /// and returns a transformed result of type `U`.
44 | /// - Returns: The transformed result of type `U`.
45 | func insert(
46 | _ insertClosure: @Sendable @escaping () -> PersistentModelType,
47 | with closure: @escaping @Sendable (PersistentModelType) throws -> U
48 | ) async rethrows -> U
49 |
50 | /// Retrieves an optional persistent model from the data store and returns a transformed result.
51 | /// - Parameters:
52 | /// - selector: A `Selector.Get` instance
53 | /// that defines the criteria for retrieving the persistent model.
54 | /// - closure: A closure that performs some operation on
55 | /// the retrieved `PersistentModelType` instance (or `nil`)
56 | /// and returns a transformed result of type `U`.
57 | /// - Returns: The transformed result of type `U`.
58 | func getOptional(
59 | for selector: Selector.Get,
60 | with closure: @escaping @Sendable (PersistentModelType?) throws -> U
61 | ) async rethrows -> U
62 |
63 | /// Retrieves a list of persistent models from the data store and returns a transformed result.
64 | /// - Parameters:
65 | /// - selector: A `Selector.List` instance
66 | /// that defines the criteria for retrieving the list of persistent models.
67 | /// - closure: A closure that performs some operation on t
68 | /// he retrieved list of `PersistentModelType` instances and returns a transformed result of type `U`.
69 | /// - Returns: The transformed result of type `U`.
70 | func fetch(
71 | for selector: Selector.List,
72 | with closure: @escaping @Sendable ([PersistentModelType]) throws -> U
73 | ) async rethrows -> U
74 |
75 | /// Deletes one or more persistent models from the data store based on the provided selector.
76 | /// - Parameter selector: A `Selector.Delete` instance
77 | /// that defines the criteria for deleting the persistent models.
78 | /// - Throws: An error that indicates why the delete operation failed.
79 | func delete(_ selector: Selector.Delete) async throws
80 | }
81 | #endif
82 |
--------------------------------------------------------------------------------
/Example/Sources/ContentObject.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ContentObject.swift
3 | // DataThespian
4 | //
5 | // Created by Leo Dion on 10/10/24.
6 | //
7 |
8 | import Combine
9 | import DataThespian
10 | import Foundation
11 | import SwiftData
12 |
13 | @Observable
14 | @MainActor
15 | internal class ContentObject {
16 | internal let databaseChangePublisher = PassthroughSubject()
17 | private var databaseChangeCancellable: AnyCancellable?
18 | private var databaseChangeSubscription: AnyCancellable?
19 | private var database: (any Database)?
20 | internal private(set) var items = [ItemViewModel]()
21 | internal var selectedItemsID: Set = []
22 | private var newItem: AnyCancellable?
23 | internal var error: (any Error)?
24 |
25 | internal var selectedItems: [ItemViewModel] {
26 | let selectedItemsID = self.selectedItemsID
27 | let items: [ItemViewModel]
28 | do {
29 | items = try self.items.filter(
30 | #Predicate {
31 | selectedItemsID.contains($0.id)
32 | }
33 | )
34 | } catch {
35 | assertionFailure("Unable to filter selected items: \(error.localizedDescription)")
36 | self.error = error
37 | items = []
38 | }
39 | // assert(items.count == selectedItemsID.count)
40 | return items
41 | }
42 |
43 | internal init() {
44 | self.databaseChangeSubscription = self.databaseChangePublisher.sink { _ in
45 | self.beginUpdateItems()
46 | }
47 | }
48 |
49 | private static func deleteModels(_ models: [Model
- ], from database: (any Database))
50 | async throws
51 | {
52 | try await database.deleteModels(models)
53 | }
54 |
55 | private func beginUpdateItems() {
56 | Task {
57 | do {
58 | try await self.updateItems()
59 | } catch {
60 | self.error = error
61 | }
62 | }
63 | }
64 |
65 | private func updateItems() async throws {
66 | guard let database else {
67 | return
68 | }
69 | self.items = try await database.withModelContext { modelContext in
70 | let items = try modelContext.fetch(FetchDescriptor
- ())
71 | return items.map(ItemViewModel.init)
72 | }
73 | }
74 |
75 | internal func initialize(
76 | withDatabase database: any Database, databaseChangePublisher: DatabaseChangePublicist
77 | ) {
78 | self.database = database
79 | self.databaseChangeCancellable = databaseChangePublisher(id: "contentView")
80 | .subscribe(self.databaseChangePublisher)
81 | self.beginUpdateItems()
82 | }
83 |
84 | internal func deleteSelectedItems() {
85 | let models = self.selectedItems.map {
86 | Model
- (persistentIdentifier: $0.id)
87 | }
88 | self.deleteItems(models)
89 | }
90 | internal func deleteItems(offsets: IndexSet) {
91 | let models =
92 | offsets
93 | .compactMap { items[$0].id }
94 | .map(Model
- .init(persistentIdentifier:))
95 |
96 | assert(models.count == offsets.count)
97 |
98 | self.deleteItems(models)
99 | }
100 |
101 | internal func deleteItems(_ models: [Model
- ]) {
102 | guard let database else {
103 | return
104 | }
105 | Task {
106 | try await Self.deleteModels(models, from: database)
107 | try await database.save()
108 | }
109 | }
110 |
111 | internal func addChild(to item: ItemViewModel) {
112 | guard let database else {
113 | return
114 | }
115 | Task {
116 | let timestamp = Date()
117 | let childModel = await database.insert {
118 | ItemChild(timestamp: timestamp)
119 | }
120 |
121 | try await database.withModelContext { modelContext in
122 | let item = try modelContext.get(item.model)
123 | let child = try modelContext.get(childModel)
124 | assert(child != nil && item != nil)
125 | child?.parent = item
126 | try modelContext.save()
127 | }
128 | }
129 | }
130 |
131 | internal func addItem(withDate date: Date = .init()) {
132 | guard let database else {
133 | return
134 | }
135 | Task {
136 | let insertedModel = await database.insert { Item(timestamp: date) }
137 | print("inserted:", insertedModel.isTemporary)
138 | try await database.save()
139 | let savedModel = try await database.get(
140 | for: .predicate(
141 | #Predicate
- {
142 | $0.timestamp == date
143 | }
144 | )
145 | )
146 | print("saved:", savedModel.isTemporary)
147 | }
148 | }
149 | }
150 |
--------------------------------------------------------------------------------
/Tests/DataThespianTests/Tests/FetchTests.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import Testing
3 |
4 | @testable import DataThespian
5 |
6 | #if canImport(SwiftData)
7 | import SwiftData
8 | #endif
9 |
10 | @Suite(.enabled(if: swiftDataIsAvailable()))
11 | internal struct FetchTests {
12 | @Test internal func testFetchAll() async throws {
13 | #if canImport(SwiftData)
14 | let database = try TestingDatabase(for: Parent.self, Child.self)
15 | let parentIDs = [UUID(), UUID(), UUID()]
16 |
17 | // Insert multiple parents
18 | try await database.withModelContext { context in
19 | for id in parentIDs {
20 | context.insert(Parent(id: id))
21 | }
22 | try context.save()
23 | }
24 |
25 | // Fetch all parents
26 | let fetchedIDs = await database.fetch(for: .all(Parent.self)) { parents in
27 | parents.map(\.id)
28 | }
29 |
30 | #expect(fetchedIDs.sorted() == parentIDs.sorted())
31 | #endif
32 | }
33 |
34 | @Test internal func testFetchByID() async throws {
35 | #if canImport(SwiftData)
36 | let database = try TestingDatabase(for: Parent.self, Child.self)
37 | let parentID = UUID()
38 |
39 | // Insert a parent
40 | try await database.withModelContext { context in
41 | context.insert(Parent(id: parentID))
42 | try context.save()
43 | }
44 |
45 | // Fetch by ID
46 | let fetchedID = await database.getOptional(
47 | for: .predicate(#Predicate { $0.id == parentID })
48 | ) { parent in
49 | parent?.id
50 | }
51 |
52 | #expect(fetchedID == parentID)
53 | #endif
54 | }
55 |
56 | @Test internal func testFetchByIDs() async throws {
57 | #if canImport(SwiftData)
58 | let database = try TestingDatabase(for: Parent.self, Child.self)
59 | let parentIDs = [UUID(), UUID(), UUID()]
60 |
61 | // Insert multiple parents
62 | try await database.withModelContext { context in
63 | for id in parentIDs {
64 | context.insert(Parent(id: id))
65 | }
66 | try context.save()
67 | }
68 |
69 | // Fetch by IDs
70 | let fetchedIDs = await database.fetch(
71 | for: .descriptor(predicate: #Predicate { parentIDs.contains($0.id) })
72 | ) { parents in
73 | parents.map(\.id)
74 | }
75 |
76 | #expect(fetchedIDs.sorted() == parentIDs.sorted())
77 | #endif
78 | }
79 |
80 | @Test internal func testFetchByPredicate() async throws {
81 | #if canImport(SwiftData)
82 | let database = try TestingDatabase(for: Parent.self, Child.self)
83 | let parentID = UUID()
84 |
85 | // Insert a parent
86 | try await database.withModelContext { context in
87 | context.insert(Parent(id: parentID))
88 | try context.save()
89 | }
90 |
91 | // Fetch by predicate
92 | let fetchedID = await database.getOptional(
93 | for: .predicate(
94 | #Predicate { parent in
95 | parent.id == parentID
96 | }
97 | )
98 | ) { parent in
99 | parent?.id
100 | }
101 |
102 | #expect(fetchedID == parentID)
103 | #endif
104 | }
105 |
106 | @Test internal func testFetchByValue() async throws {
107 | #if canImport(SwiftData)
108 | let database = try TestingDatabase(for: Parent.self, Child.self)
109 | let parentID = UUID()
110 |
111 | // Insert a parent
112 | try await database.withModelContext { context in
113 | context.insert(Parent(id: parentID))
114 | try context.save()
115 | }
116 |
117 | // Fetch by value
118 | let fetchedID = await database.getOptional(
119 | for: .predicate(#Predicate { $0.id == parentID })
120 | ) { parent in
121 | parent?.id
122 | }
123 |
124 | #expect(fetchedID == parentID)
125 | #endif
126 | }
127 |
128 | @Test internal func testFetchByValues() async throws {
129 | #if canImport(SwiftData)
130 | let database = try TestingDatabase(for: Parent.self, Child.self)
131 | let parentIDs = [UUID(), UUID(), UUID()]
132 |
133 | // Insert multiple parents
134 | try await database.withModelContext { context in
135 | for id in parentIDs {
136 | context.insert(Parent(id: id))
137 | }
138 | try context.save()
139 | }
140 |
141 | // Fetch by values
142 | let fetchedIDs = await database.fetch(
143 | for: .descriptor(predicate: #Predicate { parentIDs.contains($0.id) })
144 | ) { parents in
145 | parents.map(\.id)
146 | }
147 |
148 | #expect(fetchedIDs.sorted() == parentIDs.sorted())
149 | #endif
150 | }
151 | }
152 |
--------------------------------------------------------------------------------
/Sources/DataThespian/Databases/ModelActor+Database.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ModelActor+Database.swift
3 | // DataThespian
4 | //
5 | // Created by Leo Dion.
6 | // Copyright © 2025 BrightDigit.
7 | //
8 | // Permission is hereby granted, free of charge, to any person
9 | // obtaining a copy of this software and associated documentation
10 | // files (the “Software”), to deal in the Software without
11 | // restriction, including without limitation the rights to use,
12 | // copy, modify, merge, publish, distribute, sublicense, and/or
13 | // sell copies of the Software, and to permit persons to whom the
14 | // Software is furnished to do so, subject to the following
15 | // conditions:
16 | //
17 | // The above copyright notice and this permission notice shall be
18 | // included in all copies or substantial portions of the Software.
19 | //
20 | // THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
21 | // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
22 | // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
23 | // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
24 | // HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
25 | // WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
26 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
27 | // OTHER DEALINGS IN THE SOFTWARE.
28 | //
29 |
30 | #if canImport(SwiftData)
31 | import Foundation
32 | import os.log
33 | public import SwiftData
34 |
35 | extension ModelActor where Self: Database {
36 | /// A Boolean value indicating whether the current thread is the background thread.
37 | public static var assertIsBackground: Bool { false }
38 |
39 | /// Executes a closure within the context of the model.
40 | ///
41 | /// - Parameter closure: The closure to execute within the model context.
42 | /// - Returns: The result of the closure execution.
43 | public func withModelContext(
44 | _ closure: @Sendable @escaping (ModelContext) throws -> T
45 | ) async rethrows -> T {
46 | assert(isMainThread: true, if: Self.assertIsBackground)
47 | let modelContext = self.modelContext
48 | return try closure(modelContext)
49 | }
50 | /// Retrieves an optional persistent model from the data store and returns a transformed result.
51 | /// - Parameters:
52 | /// - selector: A `Selector.Get` instance
53 | /// that defines the criteria for retrieving the persistent model.
54 | /// - closure: A closure that performs some operation on
55 | /// the retrieved `PersistentModelType` instance (or `nil`)
56 | /// and returns a transformed result of type `U`.
57 | /// - Returns: The transformed result of type `U`.
58 | public func getOptional(
59 | for selector: Selector.Get,
60 | with closure: @escaping @Sendable (PersistentModelType?) throws -> U
61 | ) async rethrows -> U {
62 | guard case .model(let model) = selector else {
63 | return try await self.withModelContext {
64 | try $0.getOptional(for: selector, with: closure)
65 | }
66 | }
67 |
68 | let persistentIdentifier = model.persistentIdentifier
69 | // return try closure(self[model.persistentIdentifier, as: PersistentModelType.self])
70 |
71 | let fetchDescriptor = FetchDescriptor(predicate: #Predicate{
72 | $0.persistentModelID == persistentIdentifier
73 | }, fetchLimit: 1)
74 |
75 | return try await self.withModelContext { modelContext in
76 | try closure(modelContext.fetch(fetchDescriptor).first)
77 | }
78 | }
79 |
80 | /// Fetches an array of models matching the given list selector
81 | /// - Parameter selector: A selector defining the query criteria for retrieving multiple models
82 | /// - Returns: An array of wrapped Model instances matching the selector criteria
83 | public func fetch(for selector: Selector.List)
84 | async -> [Model] where PersistentModelType: PersistentModel
85 | {
86 | let fetchedIdentifiers: [Model]
87 |
88 | guard case .descriptor(let descriptor) = selector else {
89 | fatalError("Invalid selector: \(selector)")
90 | }
91 |
92 | do {
93 | fetchedIdentifiers = try await self.withModelContext { modelContext in
94 | try modelContext.fetchIdentifiers(descriptor).map(
95 | Model.init(persistentIdentifier:)
96 | )
97 | }
98 | } catch {
99 | os_log(.error, "Failed to fetch identifiers: %{public}@", error.localizedDescription)
100 | return []
101 | }
102 |
103 | return fetchedIdentifiers
104 | }
105 | }
106 | #endif
107 |
--------------------------------------------------------------------------------
/Sources/DataThespian/Synchronization/CollectionDifference.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CollectionDifference.swift
3 | // DataThespian
4 | //
5 | // Created by Leo Dion.
6 | // Copyright © 2025 BrightDigit.
7 | //
8 | // Permission is hereby granted, free of charge, to any person
9 | // obtaining a copy of this software and associated documentation
10 | // files (the “Software”), to deal in the Software without
11 | // restriction, including without limitation the rights to use,
12 | // copy, modify, merge, publish, distribute, sublicense, and/or
13 | // sell copies of the Software, and to permit persons to whom the
14 | // Software is furnished to do so, subject to the following
15 | // conditions:
16 | //
17 | // The above copyright notice and this permission notice shall be
18 | // included in all copies or substantial portions of the Software.
19 | //
20 | // THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
21 | // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
22 | // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
23 | // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
24 | // HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
25 | // WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
26 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
27 | // OTHER DEALINGS IN THE SOFTWARE.
28 | //
29 |
30 | #if canImport(SwiftData)
31 | public import SwiftData
32 | /// Represents the difference between a persistent model and its associated data.
33 | public struct CollectionDifference:
34 | Sendable
35 | {
36 | /// The items that need to be inserted.
37 | public let inserts: [DataType]
38 | /// The models that need to be deleted.
39 | public let modelsToDelete: [Model]
40 | /// The items that need to be updated.
41 | public let updates: [DataType]
42 |
43 | /// Initializes a `CollectionDifference` instance with
44 | /// the specified inserts, models to delete, and updates.
45 | /// - Parameters:
46 | /// - inserts: The items that need to be inserted.
47 | /// - modelsToDelete: The models that need to be deleted.
48 | /// - updates: The items that need to be updated.
49 | public init(
50 | inserts: [DataType], modelsToDelete: [Model], updates: [DataType]
51 | ) {
52 | self.inserts = inserts
53 | self.modelsToDelete = modelsToDelete
54 | self.updates = updates
55 | }
56 | }
57 |
58 | extension CollectionDifference {
59 | /// The delete selectors for the models that need to be deleted.
60 | public var deleteSelectors: [DataThespian.Selector.Delete] {
61 | self.modelsToDelete.map {
62 | .model($0)
63 | }
64 | }
65 |
66 | /// Initializes a `CollectionDifference` instance by comparing the persistent models and data.
67 | /// - Parameters:
68 | /// - persistentModels: The persistent models to compare.
69 | /// - data: The data to compare.
70 | /// - persistentModelKeyPath: The key path to the unique identifier in the persistent models.
71 | /// - dataKeyPath: The key path to the unique identifier in the data.
72 | public init(
73 | persistentModels: [PersistentModelType]?,
74 | data: [DataType]?,
75 | persistentModelKeyPath: KeyPath,
76 | dataKeyPath: KeyPath
77 | ) {
78 | let persistentModels = persistentModels ?? []
79 | let entryMap: [ID: PersistentModelType] =
80 | .init(
81 | uniqueKeysWithValues: persistentModels.map {
82 | ($0[keyPath: persistentModelKeyPath], $0)
83 | }
84 | )
85 |
86 | let data = data ?? []
87 | let dataMap: [ID: DataType] = .init(
88 | uniqueKeysWithValues: data.map {
89 | ($0[keyPath: dataKeyPath], $0)
90 | }
91 | )
92 |
93 | let entryIDsToUpdate = Set(entryMap.keys).intersection(dataMap.keys)
94 | let entryIDsToDelete = Set(entryMap.keys).subtracting(dataMap.keys)
95 | let entryIDsToInsert = Set(dataMap.keys).subtracting(entryMap.keys)
96 |
97 | let entriesToDelete = entryIDsToDelete.compactMap { entryMap[$0] }.map(Model.init)
98 | let entryItemsToInsert = entryIDsToInsert.compactMap { dataMap[$0] }
99 | let entriesToUpdate = entryIDsToUpdate.compactMap {
100 | dataMap[$0]
101 | }
102 |
103 | assert(entryIDsToUpdate.count == entriesToUpdate.count)
104 | assert(entryIDsToDelete.count == entriesToDelete.count)
105 | assert(entryItemsToInsert.count == entryIDsToInsert.count)
106 |
107 | self.init(
108 | inserts: entryItemsToInsert,
109 | modelsToDelete: entriesToDelete,
110 | updates: entriesToUpdate
111 | )
112 | }
113 | }
114 | #endif
115 |
--------------------------------------------------------------------------------
/Scripts/swift-doc.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Check if ANTHROPIC_API_KEY is set
4 | if [ -z "$ANTHROPIC_API_KEY" ]; then
5 | echo "Error: ANTHROPIC_API_KEY environment variable is not set"
6 | echo "Please set it with: export ANTHROPIC_API_KEY='your-key-here'"
7 | exit 1
8 | fi
9 |
10 | # Check if jq is installed
11 | if ! command -v jq &> /dev/null; then
12 | echo "Error: jq is required but not installed."
13 | echo "Please install it:"
14 | echo " - On macOS: brew install jq"
15 | echo " - On Ubuntu/Debian: sudo apt-get install jq"
16 | echo " - On CentOS/RHEL: sudo yum install jq"
17 | exit 1
18 | fi
19 |
20 | # Check if an argument was provided
21 | if [ $# -eq 0 ]; then
22 | echo "Usage: $0 [--skip-backup]"
23 | exit 1
24 | fi
25 |
26 | TARGET=$1
27 | SKIP_BACKUP=0
28 |
29 | # Check for optional flags
30 | if [ "$2" = "--skip-backup" ]; then
31 | SKIP_BACKUP=1
32 | fi
33 |
34 | # Function to clean markdown code blocks
35 | clean_markdown() {
36 | local content="$1"
37 | # Remove ```swift from the start and ``` from the end, if present
38 | content=$(echo "$content" | sed -E '1s/^```swift[[:space:]]*//')
39 | content=$(echo "$content" | sed -E '$s/```[[:space:]]*$//')
40 | echo "$content"
41 | }
42 |
43 | # Function to process a single Swift file
44 | process_swift_file() {
45 | local SWIFT_FILE=$1
46 | echo "Processing: $SWIFT_FILE"
47 |
48 | # Create backup unless skipped
49 | if [ $SKIP_BACKUP -eq 0 ]; then
50 | cp "$SWIFT_FILE" "${SWIFT_FILE}.backup"
51 | echo "Created backup: ${SWIFT_FILE}.backup"
52 | fi
53 |
54 | # Read and escape the Swift file content for JSON
55 | local SWIFT_CODE
56 | SWIFT_CODE=$(jq -Rs . < "$SWIFT_FILE")
57 |
58 | # Create the JSON payload
59 | local JSON_PAYLOAD
60 | JSON_PAYLOAD=$(jq -n \
61 | --arg code "$SWIFT_CODE" \
62 | '{
63 | model: "claude-3-haiku-20240307",
64 | max_tokens: 2000,
65 | messages: [{
66 | role: "user",
67 | 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)"
68 | }]
69 | }')
70 |
71 | # Make the API call to Claude
72 | local response
73 | response=$(curl -s https://api.anthropic.com/v1/messages \
74 | -H "Content-Type: application/json" \
75 | -H "x-api-key: $ANTHROPIC_API_KEY" \
76 | -H "anthropic-version: 2023-06-01" \
77 | -d "$JSON_PAYLOAD")
78 |
79 | # Check if the API call was successful
80 | if [ $? -ne 0 ]; then
81 | echo "Error: API call failed for $SWIFT_FILE"
82 | return 1
83 | fi
84 |
85 | # Extract the content from the response using jq
86 | local documented_code
87 | documented_code=$(echo "$response" | jq -r '.content[0].text // empty')
88 |
89 | # Check if we got valid content back
90 | if [ -z "$documented_code" ]; then
91 | echo "Error: No valid response received for $SWIFT_FILE"
92 | echo "API Response: $response"
93 | return 1
94 | fi
95 |
96 | # Clean the markdown formatting from the response
97 | documented_code=$(clean_markdown "$documented_code")
98 |
99 | # Save the documented code to the file
100 | echo "$documented_code" > "$SWIFT_FILE"
101 |
102 | # Show diff if available and backup exists
103 | if [ $SKIP_BACKUP -eq 0 ] && command -v diff &> /dev/null; then
104 | echo -e "\nChanges made to $SWIFT_FILE:"
105 | diff "${SWIFT_FILE}.backup" "$SWIFT_FILE"
106 | fi
107 |
108 | echo "✓ Documentation added to $SWIFT_FILE"
109 | echo "----------------------------------------"
110 | }
111 |
112 | # Function to process directory
113 | process_directory() {
114 | local DIR=$1
115 | local SWIFT_FILES=0
116 | local PROCESSED=0
117 | local FAILED=0
118 |
119 | # Count total Swift files
120 | SWIFT_FILES=$(find "$DIR" -name "*.swift" | wc -l)
121 | echo "Found $SWIFT_FILES Swift files in $DIR"
122 | echo "----------------------------------------"
123 |
124 | # Process each Swift file
125 | while IFS= read -r file; do
126 | if process_swift_file "$file"; then
127 | ((PROCESSED++))
128 | else
129 | ((FAILED++))
130 | fi
131 | # Add a small delay to avoid API rate limits
132 | sleep 1
133 | done < <(find "$DIR" -name "*.swift")
134 |
135 | echo "Summary:"
136 | echo "- Total Swift files found: $SWIFT_FILES"
137 | echo "- Successfully processed: $PROCESSED"
138 | echo "- Failed: $FAILED"
139 | }
140 |
141 | # Main logic
142 | if [ -f "$TARGET" ]; then
143 | # Single file processing
144 | if [[ "$TARGET" == *.swift ]]; then
145 | process_swift_file "$TARGET"
146 | else
147 | echo "Error: File must have .swift extension"
148 | exit 1
149 | fi
150 | elif [ -d "$TARGET" ]; then
151 | # Directory processing
152 | process_directory "$TARGET"
153 | else
154 | echo "Error: $TARGET is neither a valid file nor directory"
155 | exit 1
156 | fi
--------------------------------------------------------------------------------
/Sources/DataThespian/Documentation.docc/DataThespian.md:
--------------------------------------------------------------------------------
1 | # ``DataThespian``
2 |
3 | A thread-safe implementation of SwiftData.
4 |
5 | ## Overview
6 |
7 | DataThespian combines the power of Actors, SwiftData, and ModelActors to create an optimized and easy-to-use APIs for developers.
8 |
9 | ### Requirements
10 |
11 | **Apple Platforms**
12 |
13 | - Xcode 16.0 or later
14 | - Swift 6.0 or later
15 | - iOS 17 / watchOS 10.0 / tvOS 17 / macOS 14 or later deployment targets
16 |
17 | **Linux**
18 |
19 | - Ubuntu 20.04 or later
20 | - Swift 6.0 or later
21 |
22 | ### Installation
23 |
24 | To integrate **DataThespian** into your app using SPM, specify it in your Package.swift file:
25 |
26 | ```swift
27 | let package = Package(
28 | ...
29 | dependencies: [
30 | .package(
31 | url: "https://github.com/brightdigit/DataThespian.git", from: "1.0.0"
32 | )
33 | ],
34 | targets: [
35 | .target(
36 | name: "YourApps",
37 | dependencies: [
38 | .product(
39 | name: "DataThespian",
40 | package: "DataThespian"
41 | ), ...
42 | ]),
43 | ...
44 | ]
45 | )
46 | ```
47 |
48 | ### Setting up Database
49 |
50 | ```swift
51 | var body: some Scene {
52 | WindowGroup {
53 | RootView()
54 | }.database(ModelActorDatabase(modelContainer: ...))
55 | }
56 | ```
57 |
58 | and then reference it in our SwiftUI View:
59 |
60 | ```swift
61 | @Environment(\.database) private var database
62 | ```
63 |
64 | #### Using with ModelContext
65 |
66 | If you are familiar with Core Data, you probably know that you should use a single `NSManagedObjectContext` throughout your app. The issue here is that our initializer for `ModelActorDatabase` will be called each time the SwiftUI View is redraw. So if we look at the expanded `@ModelActor` Macro for our `ModelActorDatabase`, we see that a new `ModelContext` (SwiftData wrapper or abstraction, etc. of `NSManagedObjectContext`) is created each time:
67 |
68 | ```swift
69 | public init(modelContainer: SwiftData.ModelContainer) {
70 | let modelContext = ModelContext(modelContainer)
71 | self.modelExecutor = DefaultSerialModelExecutor(modelContext: modelContext)
72 | self.modelContainer = modelContainer
73 | }
74 | ```
75 |
76 | The best approach is to create a singleton using `SharedDatabase`:
77 |
78 | ```swift
79 | public struct SharedDatabase {
80 | public static let shared: SharedDatabase = .init()
81 |
82 | public let schemas: [any PersistentModel.Type]
83 | public let modelContainer: ModelContainer
84 | public let database: any Database
85 |
86 | private init(
87 | schemas: [any PersistentModel.Type] = .all,
88 | modelContainer: ModelContainer? = nil,
89 | database: (any Database)? = nil
90 | ) {
91 | self.schemas = schemas
92 | let modelContainer = modelContainer ?? .forTypes(schemas)
93 | self.modelContainer = modelContainer
94 | self.database = database ?? ModelActorDatabase(modelContainer: modelContainer)
95 | }
96 | }
97 | ```
98 |
99 | Then in your SwiftUI code:
100 |
101 | ```swift
102 | var body: some Scene {
103 | WindowGroup {
104 | RootView()
105 | }
106 | .database(SharedDatabase.shared.database)
107 | /* if we wish to continue using @Query
108 | .modelContainer(SharedDatabase.shared.modelContainer)
109 | */
110 | }
111 | ```
112 |
113 | ### Making Queries
114 |
115 | DataThespian uses a type-safe `Selector` enum to specify what data to query:
116 |
117 | ```swift
118 | // Get a single item
119 | let item = try await database.get(for: .predicate(#Predicate
- {
120 | $0.name == "Test"
121 | }))
122 |
123 | // Fetch a list with sorting
124 | let items = await database.fetch(for: .descriptor(
125 | predicate: #Predicate
- { $0.isActive == true },
126 | sortBy: [SortDescriptor(\Item.timestamp, order: .reverse)],
127 | fetchLimit: 10
128 | ))
129 |
130 | // Delete matching items
131 | try await database.delete(.predicate(#Predicate
- {
132 | $0.timestamp < oneWeekAgo
133 | }))
134 | ```
135 |
136 | ### Important: Working with Temporary IDs
137 |
138 | When inserting new models, SwiftData assigns temporary IDs that cannot be used across contexts until explicitly saved. After saving, you must re-query using a field value rather than the Model reference. Here's the safe pattern:
139 |
140 | ```swift
141 | // Create with a known unique value
142 | let timestamp = Date()
143 | let newItem = await database.insert { Item(name: "Test", timestamp: timestamp) }
144 |
145 | // Save to get permanent ID
146 | try await database.save()
147 |
148 | // Re-query using a unique field value
149 | let item = try await database.getOptional(for: .predicate(#Predicate
- {
150 | $0.timestamp == timestamp
151 | }))
152 | ```
153 |
154 | ## Topics
155 |
156 | ### Database
157 |
158 | - ``Database``
159 | - ``BackgroundDatabase``
160 | - ``ModelActorDatabase``
161 |
162 | ### Querying
163 |
164 | - ``Queryable``
165 | - ``QueryError``
166 | - ``Selector``
167 | - ``Model``
168 |
169 | ### Monitoring
170 |
171 | - ``DataMonitor``
172 | - ``DataAgent``
173 | - ``DatabaseChangeSet``
174 | - ``DatabaseMonitoring``
175 | - ``AgentRegister``
176 | - ``ManagedObjectMetadata``
177 | - ``DatabaseChangePublicist``
178 | - ``DatabaseChangeType``
179 |
180 | ### Synchronization
181 |
182 | - ``CollectionSynchronizer``
183 | - ``ModelDifferenceSynchronizer``
184 | - ``ModelSynchronizer``
185 | - ``SynchronizationDifference``
186 | - ``CollectionDifference``
187 |
--------------------------------------------------------------------------------
/Sources/DataThespian/Synchronization/CollectionSynchronizer.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CollectionSynchronizer.swift
3 | // DataThespian
4 | //
5 | // Created by Leo Dion.
6 | // Copyright © 2025 BrightDigit.
7 | //
8 | // Permission is hereby granted, free of charge, to any person
9 | // obtaining a copy of this software and associated documentation
10 | // files (the “Software”), to deal in the Software without
11 | // restriction, including without limitation the rights to use,
12 | // copy, modify, merge, publish, distribute, sublicense, and/or
13 | // sell copies of the Software, and to permit persons to whom the
14 | // Software is furnished to do so, subject to the following
15 | // conditions:
16 | //
17 | // The above copyright notice and this permission notice shall be
18 | // included in all copies or substantial portions of the Software.
19 | //
20 | // THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
21 | // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
22 | // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
23 | // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
24 | // HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
25 | // WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
26 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
27 | // OTHER DEALINGS IN THE SOFTWARE.
28 | //
29 |
30 | #if canImport(SwiftData)
31 | public import SwiftData
32 | private struct SynchronizationUpdate {
33 | var file: DataType?
34 | var entry: PersistentModelType?
35 | }
36 | /// A protocol that defines the synchronization behavior between a persistent model and data.
37 | public protocol CollectionSynchronizer {
38 | /// The type of the persistent model.
39 | associatedtype PersistentModelType: PersistentModel
40 |
41 | /// The type of the data.
42 | associatedtype DataType: Sendable
43 |
44 | /// The type of the identifier.
45 | associatedtype ID: Hashable
46 |
47 | /// The key path to the identifier in the data.
48 | static var dataKey: KeyPath { get }
49 |
50 | /// The key path to the identifier in the persistent model.
51 | static var persistentModelKey: KeyPath { get }
52 |
53 | /// Retrieves a selector for fetching the persistent model from the data.
54 | ///
55 | /// - Parameter data: The data to use for constructing the selector.
56 | /// - Returns: A selector for fetching the persistent model.
57 | static func getSelector(from data: DataType) -> DataThespian.Selector.Get
58 |
59 | /// Creates a persistent model from the provided data.
60 | ///
61 | /// - Parameter data: The data to create the persistent model from.
62 | /// - Returns: The created persistent model.
63 | static func persistentModel(from data: DataType) -> PersistentModelType
64 |
65 | /// Synchronizes the persistent model with the provided data.
66 | ///
67 | /// - Parameters:
68 | /// - persistentModel: The persistent model to synchronize.
69 | /// - data: The data to synchronize the persistent model with.
70 | /// - Throws: Any errors that occur during the synchronization process.
71 | static func synchronize(_ persistentModel: PersistentModelType, with data: DataType) throws
72 | }
73 |
74 | extension CollectionSynchronizer {
75 | /// Synchronizes the difference between a collection of persistent models and a collection of data.
76 | ///
77 | /// - Parameters:
78 | /// - difference: The difference between the persistent models and the data.
79 | /// - modelContext: The model context to use for the synchronization.
80 | /// - Returns: The list of persistent models that were inserted.
81 | /// - Throws: Any errors that occur during the synchronization process.
82 | public static func synchronizeDifference(
83 | _ difference: CollectionDifference,
84 | using modelContext: ModelContext
85 | ) throws -> [PersistentModelType] {
86 | try modelContext.delete(difference.deleteSelectors)
87 |
88 | let modelsToInsert: [Model] = difference.inserts.map { model in
89 | modelContext.insert {
90 | Self.persistentModel(from: model)
91 | }
92 | }
93 |
94 | let inserted = try modelsToInsert.map {
95 | try modelContext.get($0)
96 | }
97 |
98 | let updateSelectors = difference.updates.map {
99 | Self.getSelector(from: $0)
100 | }
101 |
102 | let entriesToUpdate = try modelContext.fetch(for: updateSelectors)
103 |
104 | var dictionary = [ID: SynchronizationUpdate]()
105 |
106 | for file in difference.updates {
107 | let id = file[keyPath: Self.dataKey]
108 | assert(dictionary[id] == nil)
109 | dictionary[id] = SynchronizationUpdate(file: file)
110 | }
111 |
112 | for entry in entriesToUpdate {
113 | let id = entry[keyPath: Self.persistentModelKey]
114 | assert(dictionary[id] != nil)
115 | dictionary[id]?.entry = entry
116 | }
117 |
118 | for update in dictionary.values {
119 | guard let entry = update.entry, let file = update.file else {
120 | assertionFailure()
121 | continue
122 | }
123 | try Self.synchronize(entry, with: file)
124 | }
125 |
126 | return inserted
127 | }
128 | }
129 | #endif
130 |
--------------------------------------------------------------------------------
/Sources/DataThespian/SwiftData/NSManagedObjectID.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NSManagedObjectID.swift
3 | // DataThespian
4 | //
5 | // Created by Leo Dion.
6 | // Copyright © 2025 BrightDigit.
7 | //
8 | // Permission is hereby granted, free of charge, to any person
9 | // obtaining a copy of this software and associated documentation
10 | // files (the “Software”), to deal in the Software without
11 | // restriction, including without limitation the rights to use,
12 | // copy, modify, merge, publish, distribute, sublicense, and/or
13 | // sell copies of the Software, and to permit persons to whom the
14 | // Software is furnished to do so, subject to the following
15 | // conditions:
16 | //
17 | // The above copyright notice and this permission notice shall be
18 | // included in all copies or substantial portions of the Software.
19 | //
20 | // THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
21 | // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
22 | // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
23 | // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
24 | // HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
25 | // WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
26 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
27 | // OTHER DEALINGS IN THE SOFTWARE.
28 | //
29 |
30 | #if canImport(CoreData) && canImport(SwiftData)
31 | public import CoreData
32 | import Foundation
33 | public import SwiftData
34 |
35 | // periphery:ignore
36 | private struct PersistentIdentifierJSON: Codable {
37 | private struct Implementation: Codable {
38 | fileprivate init(
39 | primaryKey: String,
40 | uriRepresentation: URL,
41 | isTemporary: Bool,
42 | storeIdentifier: String,
43 | entityName: String
44 | ) {
45 | self.primaryKey = primaryKey
46 | self.uriRepresentation = uriRepresentation
47 | self.isTemporary = isTemporary
48 | self.storeIdentifier = storeIdentifier
49 | self.entityName = entityName
50 | }
51 | private var primaryKey: String
52 | private var uriRepresentation: URL
53 | private var isTemporary: Bool
54 | private var storeIdentifier: String
55 | private var entityName: String
56 | }
57 |
58 | private var implementation: Implementation
59 |
60 | private init(implementation: PersistentIdentifierJSON.Implementation) {
61 | self.implementation = implementation
62 | }
63 |
64 | fileprivate init(
65 | primaryKey: String,
66 | uriRepresentation: URL,
67 | isTemporary: Bool,
68 | storeIdentifier: String,
69 | entityName: String
70 | ) {
71 | self.init(
72 | implementation:
73 | .init(
74 | primaryKey: primaryKey,
75 | uriRepresentation: uriRepresentation,
76 | isTemporary: isTemporary,
77 | storeIdentifier: storeIdentifier,
78 | entityName: entityName
79 | )
80 | )
81 | }
82 | }
83 |
84 | extension NSManagedObjectID {
85 | public enum PersistentIdentifierError: Error {
86 | case decodingError(DecodingError)
87 | case encodingError(EncodingError)
88 | case missingProperty(Property)
89 |
90 | public enum Property: Sendable {
91 | case storeIdentifier
92 | case entityName
93 | }
94 | }
95 |
96 | /// Compute PersistentIdentifier from NSManagedObjectID.
97 | ///
98 | /// - Returns: A PersistentIdentifier instance.
99 | /// - Throws: `PersistentIdentifierError`
100 | /// if the `storeIdentifier` or `entityName` properties are missing.
101 | public func persistentIdentifier() throws -> PersistentIdentifier {
102 | guard let storeIdentifier else {
103 | throw PersistentIdentifierError.missingProperty(.storeIdentifier)
104 | }
105 | guard let entityName else { throw PersistentIdentifierError.missingProperty(.entityName) }
106 | let json = PersistentIdentifierJSON(
107 | primaryKey: primaryKey,
108 | uriRepresentation: uriRepresentation(),
109 | isTemporary: isTemporaryID,
110 | storeIdentifier: storeIdentifier,
111 | entityName: entityName
112 | )
113 | let encoder = JSONEncoder()
114 | let data: Data
115 | do { data = try encoder.encode(json) } catch let error as EncodingError {
116 | throw PersistentIdentifierError.encodingError(error)
117 | }
118 | let decoder = JSONDecoder()
119 | do { return try decoder.decode(PersistentIdentifier.self, from: data) } catch let error
120 | as DecodingError
121 | { throw PersistentIdentifierError.decodingError(error) }
122 | }
123 | }
124 |
125 | // Extensions to expose needed implementation details
126 | extension NSManagedObjectID {
127 | /// The primary key of the managed object, which is the last path component of the URI.
128 | public var primaryKey: String { uriRepresentation().lastPathComponent }
129 |
130 | /// The store identifier, which is the host of the URI.
131 | public var storeIdentifier: String? {
132 | guard let identifier = uriRepresentation().host() else {
133 | return nil
134 | }
135 | return identifier
136 | }
137 |
138 | /// The entity name, which is derived from the entity.
139 | public var entityName: String? {
140 | guard let entityName = entity.name else {
141 | return nil
142 | }
143 | return entityName
144 | }
145 | }
146 | #endif
147 |
--------------------------------------------------------------------------------
/Sources/DataThespian/Databases/BackgroundDatabase.swift:
--------------------------------------------------------------------------------
1 | //
2 | // BackgroundDatabase.swift
3 | // DataThespian
4 | //
5 | // Created by Leo Dion.
6 | // Copyright © 2025 BrightDigit.
7 | //
8 | // Permission is hereby granted, free of charge, to any person
9 | // obtaining a copy of this software and associated documentation
10 | // files (the “Software”), to deal in the Software without
11 | // restriction, including without limitation the rights to use,
12 | // copy, modify, merge, publish, distribute, sublicense, and/or
13 | // sell copies of the Software, and to permit persons to whom the
14 | // Software is furnished to do so, subject to the following
15 | // conditions:
16 | //
17 | // The above copyright notice and this permission notice shall be
18 | // included in all copies or substantial portions of the Software.
19 | //
20 | // THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
21 | // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
22 | // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
23 | // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
24 | // HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
25 | // WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
26 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
27 | // OTHER DEALINGS IN THE SOFTWARE.
28 | //
29 |
30 | #if canImport(SwiftData)
31 | import Foundation
32 | public import SwiftData
33 |
34 | /// Represents a background database that can be used in a concurrent environment.
35 | public final class BackgroundDatabase: Database {
36 | private actor DatabaseContainer {
37 | private let factory: @Sendable () -> any Database
38 | private var wrappedTask: Task?
39 |
40 | /// Initializes a `DatabaseContainer` with the given factory.
41 | /// - Parameter factory: A closure that creates a new database instance.
42 | fileprivate init(factory: @escaping @Sendable () -> any Database) {
43 | self.factory = factory
44 | }
45 |
46 | /// Provides access to the database instance, creating it lazily if necessary.
47 | fileprivate var database: any Database {
48 | get async {
49 | if let wrappedTask {
50 | return await wrappedTask.value
51 | }
52 | let task = Task { factory() }
53 | self.wrappedTask = task
54 | return await task.value
55 | }
56 | }
57 | }
58 |
59 | private let container: DatabaseContainer
60 |
61 | /// The database instance, accessed asynchronously.
62 | private var database: any Database {
63 | get async {
64 | await container.database
65 | }
66 | }
67 |
68 | /// Initializes a `BackgroundDatabase` with the given database.
69 | /// - Parameter database: a new database instance.
70 | public convenience init(database: @Sendable @escaping @autoclosure () -> any Database) {
71 | self.init(database)
72 | }
73 |
74 | /// Initializes a `BackgroundDatabase` with the given database factory.
75 | /// - Parameter factory: A closure that creates a new database instance.
76 | public init(_ factory: @Sendable @escaping () -> any Database) {
77 | self.container = .init(factory: factory)
78 | }
79 |
80 | /// Executes the given closure within the context of the database's model context.
81 | /// - Parameter closure: A closure that performs operations within the model context.
82 | /// - Returns: The result of the closure.
83 | public func withModelContext(_ closure: @Sendable @escaping (ModelContext) throws -> T)
84 | async rethrows -> T
85 | {
86 | try await self.database.withModelContext(closure)
87 | }
88 | }
89 |
90 | extension BackgroundDatabase {
91 | /// Initializes a `BackgroundDatabase` with the given model container and
92 | /// optional model context closure.
93 | /// - Parameters:
94 | /// - modelContainer: The model container to use.
95 | /// - closure: An optional closure that creates a model context
96 | /// from the provided model container.
97 | public convenience init(
98 | modelContainer: ModelContainer,
99 | modelContext closure: (@Sendable (ModelContainer) -> ModelContext)? = nil
100 | ) {
101 | let closure = closure ?? ModelContext.init
102 | self.init(database: ModelActorDatabase(modelContainer: modelContainer, modelContext: closure))
103 | }
104 |
105 | /// Initializes a `BackgroundDatabase` with the given model container and the default model context.
106 | /// - Parameter modelContainer: The model container to use.
107 | public convenience init(
108 | modelContainer: SwiftData.ModelContainer
109 | ) {
110 | self.init(
111 | modelContainer: modelContainer,
112 | modelContext: ModelContext.init
113 | )
114 | }
115 |
116 | /// Initializes a `BackgroundDatabase` with the given model container and model executor closure.
117 | /// - Parameters:
118 | /// - modelContainer: The model container to use.
119 | /// - closure: A closure that creates a model executor from the provided model container.
120 | public convenience init(
121 | modelContainer: SwiftData.ModelContainer,
122 | modelExecutor closure: @Sendable @escaping (ModelContainer) -> any ModelExecutor
123 | ) {
124 | self.init(
125 | database: ModelActorDatabase(
126 | modelContainer: modelContainer,
127 | modelExecutor: closure
128 | )
129 | )
130 | }
131 | }
132 | #endif
133 |
--------------------------------------------------------------------------------
/.github/workflows/DataThespian.yml:
--------------------------------------------------------------------------------
1 | name: DataThespian
2 | on:
3 | push:
4 | branches-ignore:
5 | - '*WIP'
6 | env:
7 | PACKAGE_NAME: DataThespian
8 | jobs:
9 | build-ubuntu:
10 | name: Build on Ubuntu
11 | runs-on: ubuntu-latest
12 | container: swift:${{ matrix.swift-version }}-${{ matrix.os }}
13 | if: ${{ !contains(github.event.head_commit.message, 'ci skip') }}
14 | strategy:
15 | matrix:
16 | os: ["noble", "jammy"]
17 | swift-version: ["6.0", "6.1", "6.2"]
18 | steps:
19 | - uses: actions/checkout@v4
20 | - uses: brightdigit/swift-build@v1.3.4
21 | - uses: sersoft-gmbh/swift-coverage-action@v4
22 | id: coverage-files
23 | with:
24 | fail-on-empty-output: true
25 | - name: Upload coverage to Codecov
26 | uses: codecov/codecov-action@v4
27 | with:
28 | fail_ci_if_error: true
29 | flags: swift-${{ matrix.swift-version }},ubuntu
30 | verbose: true
31 | token: ${{ secrets.CODECOV_TOKEN }}
32 | files: ${{ join(fromJSON(steps.coverage-files.outputs.files), ',') }}
33 | build-macos:
34 | name: Build on macOS
35 | env:
36 | PACKAGE_NAME: DataThespian
37 | runs-on: ${{ matrix.runs-on }}
38 | if: "!contains(github.event.head_commit.message, 'ci skip')"
39 | strategy:
40 | fail-fast: false
41 | matrix:
42 | include:
43 | # SPM Build Matrix
44 | - runs-on: macos-15
45 | xcode: "/Applications/Xcode_16.1.app"
46 | - runs-on: macos-15
47 | xcode: "/Applications/Xcode_16.3.app"
48 | - runs-on: macos-26
49 | xcode: "/Applications/Xcode_26.0.app"
50 |
51 | # iOS Build Matrix
52 | - type: ios
53 | runs-on: macos-15
54 | xcode: "/Applications/Xcode_16.1.app"
55 | deviceName: "iPhone 16"
56 | osVersion: "18.1"
57 | download-platform: true
58 | - type: ios
59 | runs-on: macos-15
60 | xcode: "/Applications/Xcode_16.3.app"
61 | deviceName: "iPhone 16 Pro"
62 | osVersion: "18.4"
63 | - type: ios
64 | runs-on: macos-26
65 | xcode: "/Applications/Xcode_26.0.app"
66 | deviceName: "iPhone 17 Pro"
67 | osVersion: "26.0"
68 | download-platform: true
69 |
70 | # watchOS Build Matrix
71 | - type: watchos
72 | runs-on: macos-15
73 | xcode: "/Applications/Xcode_16.1.app"
74 | deviceName: "Apple Watch Ultra 2 (49mm)"
75 | osVersion: "11.1"
76 | download-platform: true
77 | - type: watchos
78 | runs-on: macos-15
79 | xcode: "/Applications/Xcode_16.3.app"
80 | deviceName: "Apple Watch Ultra 2 (49mm)"
81 | osVersion: "11.4"
82 | download-platform: true
83 | - type: watchos
84 | runs-on: macos-26
85 | xcode: "/Applications/Xcode_26.0.app"
86 | deviceName: "Apple Watch Ultra 3 (49mm)"
87 | osVersion: "26.0"
88 | download-platform: true
89 |
90 | - type: visionos
91 | runs-on: macos-15
92 | xcode: "/Applications/Xcode_16.3.app"
93 | deviceName: "Apple Vision Pro"
94 | osVersion: "2.4"
95 | - type: visionos
96 | runs-on: macos-26
97 | xcode: "/Applications/Xcode_26.0.app"
98 | deviceName: "Apple Vision Pro"
99 | osVersion: "26.0"
100 | download-platform: true
101 |
102 | steps:
103 | - uses: actions/checkout@v4
104 |
105 | - name: Build and Test
106 | uses: brightdigit/swift-build@v1.3.4
107 | with:
108 | scheme: ${{ env.PACKAGE_NAME }}
109 | type: ${{ matrix.type }}
110 | xcode: ${{ matrix.xcode }}
111 | deviceName: ${{ matrix.deviceName }}
112 | osVersion: ${{ matrix.osVersion }}
113 | download-platform: ${{ matrix['download-platform'] }}
114 |
115 | # Common Coverage Steps
116 | - name: Process Coverage
117 | uses: sersoft-gmbh/swift-coverage-action@v4
118 |
119 | - name: Upload Coverage
120 | uses: codecov/codecov-action@v4
121 | with:
122 | token: ${{ secrets.CODECOV_TOKEN }}
123 | flags: ${{ matrix.type && format('{0}{1}', matrix.type, matrix.osVersion) || 'spm' }}
124 |
125 | lint:
126 | name: Linting
127 | if: "!contains(github.event.head_commit.message, 'ci skip')"
128 | runs-on: ubuntu-latest
129 | needs: [build-ubuntu, build-macos]
130 | env:
131 | MINT_PATH: .mint/lib
132 | MINT_LINK_PATH: .mint/bin
133 | steps:
134 | - uses: actions/checkout@v4
135 | - name: Cache mint
136 | id: cache-mint
137 | uses: actions/cache@v4
138 | env:
139 | cache-name: cache
140 | with:
141 | path: |
142 | .mint
143 | Mint
144 | key: ${{ runner.os }}-mint-${{ hashFiles('**/Mintfile') }}
145 | restore-keys: |
146 | ${{ runner.os }}-mint-
147 | - name: Install mint
148 | if: steps.cache-mint.outputs.cache-hit != 'true'
149 | run: |
150 | git clone https://github.com/yonaskolb/Mint.git
151 | cd Mint
152 | swift run mint install yonaskolb/mint
153 | - name: Lint
154 | run: ./Scripts/lint.sh
155 |
--------------------------------------------------------------------------------
/Sources/DataThespian/Notification/Combine/PublishingAgent.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PublishingAgent.swift
3 | // DataThespian
4 | //
5 | // Created by Leo Dion.
6 | // Copyright © 2025 BrightDigit.
7 | //
8 | // Permission is hereby granted, free of charge, to any person
9 | // obtaining a copy of this software and associated documentation
10 | // files (the “Software”), to deal in the Software without
11 | // restriction, including without limitation the rights to use,
12 | // copy, modify, merge, publish, distribute, sublicense, and/or
13 | // sell copies of the Software, and to permit persons to whom the
14 | // Software is furnished to do so, subject to the following
15 | // conditions:
16 | //
17 | // The above copyright notice and this permission notice shall be
18 | // included in all copies or substantial portions of the Software.
19 | //
20 | // THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
21 | // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
22 | // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
23 | // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
24 | // HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
25 | // WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
26 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
27 | // OTHER DEALINGS IN THE SOFTWARE.
28 | //
29 |
30 | #if canImport(Combine) && canImport(SwiftData)
31 | @preconcurrency import Combine
32 | import Foundation
33 |
34 | /// An actor that manages the publishing of database change sets.
35 | internal actor PublishingAgent: DataAgent, Loggable {
36 | /// The subscription event.
37 | private enum SubscriptionEvent: Sendable {
38 | case cancel
39 | case subscribe
40 | }
41 |
42 | /// The logging category for the `PublishingAgent`.
43 | internal static var loggingCategory: ThespianLogging.Category { .application }
44 |
45 | /// The unique identifier for the agent.
46 | internal let agentID = UUID()
47 |
48 | /// The identifier for the agent.
49 | private let id: String
50 |
51 | /// The subject that publishes the database change sets.
52 | private let subject: PassthroughSubject
53 |
54 | /// The number of subscriptions.
55 | private var subscriptionCount = 0
56 |
57 | /// The cancellable for the subject.
58 | private var cancellable: AnyCancellable?
59 |
60 | /// The completion closure.
61 | private var completed: (@Sendable () -> Void)?
62 |
63 | /// Initializes a new `PublishingAgent` instance.
64 | /// - Parameters:
65 | /// - id: The identifier for the agent.
66 | /// - subject: The subject that publishes the database change sets.
67 | internal init(id: String, subject: PassthroughSubject) {
68 | self.id = id
69 | self.subject = subject
70 | Task { await self.initialize() }
71 | }
72 |
73 | /// Initializes the agent.
74 | private func initialize() {
75 | cancellable = subject.handleEvents { _ in
76 | self.onSubscriptionEvent(.subscribe)
77 | } receiveCancel: {
78 | self.onSubscriptionEvent(.cancel)
79 | }
80 | .sink {
81 | _ in
82 | }
83 | }
84 |
85 | /// Handles a subscription event.
86 | /// - Parameter event: The subscription event.
87 | private nonisolated func onSubscriptionEvent(_ event: SubscriptionEvent) {
88 | Task { await self.updateScriptionStatus(byEvent: event) }
89 | }
90 |
91 | /// Updates the subscription status.
92 | /// - Parameter event: The subscription event.
93 | private func updateScriptionStatus(byEvent event: SubscriptionEvent) {
94 | let oldCount = subscriptionCount
95 | let delta: Int =
96 | switch event {
97 | case .cancel: -1
98 | case .subscribe: 1
99 | }
100 |
101 | subscriptionCount += delta
102 | Self.logger.debug(
103 | // swiftlint:disable:next line_length
104 | "Updated Subscriptions for \(self.id) from \(oldCount) by \(delta) to \(self.subscriptionCount) \(self.agentID)"
105 | )
106 | }
107 |
108 | /// Handles an update to the database.
109 | /// - Parameter update: The database change set.
110 | nonisolated internal func onUpdate(_ update: any DatabaseChangeSet) {
111 | Task { await self.sendUpdate(update) }
112 | }
113 |
114 | /// Sends the update to the subject.
115 | /// - Parameter update: The database change set.
116 | private func sendUpdate(_ update: any DatabaseChangeSet) {
117 | #if swift(>=6.1)
118 | Task { @MainActor in self.subject.send(update) }
119 | #else
120 |
121 | Task { @MainActor in await self.subject.send(update) }
122 | #endif
123 | }
124 |
125 | /// Cancels the agent.
126 | private func cancel() {
127 | Self.logger.debug("Cancelling \(self.id) \(self.agentID)")
128 | cancellable?.cancel()
129 | cancellable = nil
130 | completed?()
131 | completed = nil
132 | }
133 |
134 | /// Sets the completion closure.
135 | /// - Parameter closure: The completion closure.
136 | nonisolated internal func onCompleted(_ closure: @escaping @Sendable () -> Void) {
137 | Task { await self.setCompleted(closure) }
138 | }
139 |
140 | /// Sets the completion closure.
141 | /// - Parameter closure: The completion closure.
142 | internal func setCompleted(_ closure: @escaping @Sendable () -> Void) {
143 | Self.logger.debug("SetCompleted \(self.id) \(self.agentID)")
144 | assert(completed == nil)
145 | completed = closure
146 | }
147 |
148 | /// Finishes the agent.
149 | internal func finish() { cancel() }
150 | }
151 | #endif
152 |
--------------------------------------------------------------------------------