From ff3213d42099da638720553f9f20a99341397685 Mon Sep 17 00:00:00 2001 From: John Clayton Date: Wed, 6 Aug 2025 12:56:51 -0400 Subject: [PATCH 01/11] =?UTF-8?q?WIP=E2=80=94=20property=20wrappers=20that?= =?UTF-8?q?=20expose=20fetch=20results=20based=20on=20queries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Package.resolved | 47 +++++- Package.swift | 5 +- .../DefaultModelContainerKey.swift | 30 ++++ .../SwiftQuery/FetchWrappers/FetchAll.swift | 45 +++++ .../SwiftQuery/FetchWrappers/FetchFirst.swift | 41 +++++ .../FetchWrappers/FetchResults.swift | 45 +++++ Sources/SwiftQuery/Logging.swift | 29 ++++ Tests/SwiftQueryTests/FetchWrapperTests.swift | 158 ++++++++++++++++++ 8 files changed, 398 insertions(+), 2 deletions(-) create mode 100644 Sources/SwiftQuery/FetchWrappers/DefaultModelContainerKey.swift create mode 100644 Sources/SwiftQuery/FetchWrappers/FetchAll.swift create mode 100644 Sources/SwiftQuery/FetchWrappers/FetchFirst.swift create mode 100644 Sources/SwiftQuery/FetchWrappers/FetchResults.swift create mode 100644 Sources/SwiftQuery/Logging.swift create mode 100644 Tests/SwiftQueryTests/FetchWrapperTests.swift diff --git a/Package.resolved b/Package.resolved index cb5b9db..9028898 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,6 +1,51 @@ { - "originHash" : "e20f8927be6a812a24e179daf609cea000f5183b4e82a359a776395ad55774cc", + "originHash" : "228651f057f3c5a137ab88110b3418414a73d28dc6330c6ac36c9d3146aded7b", "pins" : [ + { + "identity" : "combine-schedulers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/combine-schedulers", + "state" : { + "revision" : "5928286acce13def418ec36d05a001a9641086f2", + "version" : "1.0.3" + } + }, + { + "identity" : "swift-clocks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-clocks", + "state" : { + "revision" : "cc46202b53476d64e824e0b6612da09d84ffde8e", + "version" : "1.0.6" + } + }, + { + "identity" : "swift-concurrency-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-concurrency-extras", + "state" : { + "revision" : "82a4ae7170d98d8538ec77238b7eb8e7199ef2e8", + "version" : "1.3.1" + } + }, + { + "identity" : "swift-dependencies", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-dependencies", + "state" : { + "revision" : "eefcdaa88d2e2fd82e3405dfb6eb45872011a0b5", + "version" : "1.9.3" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax", + "state" : { + "revision" : "f99ae8aa18f0cf0d53481901f88a0991dc3bd4a2", + "version" : "601.0.1" + } + }, { "identity" : "xctest-dynamic-overlay", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index cdaf38f..054ccc7 100644 --- a/Package.swift +++ b/Package.swift @@ -18,6 +18,7 @@ let package = Package( ), ], dependencies: [ + .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.8.1"), .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.4.3"), ], targets: [ @@ -25,7 +26,9 @@ let package = Package( // Targets can depend on other targets in this package and products from dependencies. .target( name: "SwiftQuery", - dependencies: [] + dependencies: [ + .product(name: "Dependencies", package: "swift-dependencies") + ] ), .testTarget( name: "SwiftQueryTests", diff --git a/Sources/SwiftQuery/FetchWrappers/DefaultModelContainerKey.swift b/Sources/SwiftQuery/FetchWrappers/DefaultModelContainerKey.swift new file mode 100644 index 0000000..9034d77 --- /dev/null +++ b/Sources/SwiftQuery/FetchWrappers/DefaultModelContainerKey.swift @@ -0,0 +1,30 @@ +import Dependencies +import SwiftData + +@Model final class Empty { + init() {} +} + +enum DefaultModelContainerKey: DependencyKey { + static var liveValue: ModelContainer { + reportIssue( + """ + A blank, in-memory persistent container is being used for the app. + Override this dependency in the entry point of your app using `prepareDependencies`. + """ + ) + let configuration = ModelConfiguration(isStoredInMemoryOnly: true) + return try! ModelContainer(for: Empty.self, configurations: configuration) + } + + static var testValue: ModelContainer { + liveValue + } +} + +public extension DependencyValues { + var modelContainer: ModelContainer { + get { self[DefaultModelContainerKey.self] } + set { self[DefaultModelContainerKey.self] = newValue } + } +} diff --git a/Sources/SwiftQuery/FetchWrappers/FetchAll.swift b/Sources/SwiftQuery/FetchWrappers/FetchAll.swift new file mode 100644 index 0000000..71ee57d --- /dev/null +++ b/Sources/SwiftQuery/FetchWrappers/FetchAll.swift @@ -0,0 +1,45 @@ +import Foundation +import CoreData +import Dependencies +import SwiftData + +@MainActor +@propertyWrapper +public final class FetchAll { + public var wrappedValue: [Model] = [] + private var subscription: Task = Task { } + + public init(_ query: Query) { + subscribe(fetchDescriptor: query.fetchDescriptor) + } + + deinit { + subscription.cancel() + } + + private func subscribe(fetchDescriptor: FetchDescriptor) { + debug { logger.debug("\(Self.self).\(#function)") } + subscription = Task { @MainActor in + // Send initial value + do { + @Dependency(\.modelContainer) var modelContainer + wrappedValue = try modelContainer.mainContext.fetch(fetchDescriptor) + + // Listen for changes + let changeNotifications = NotificationCenter.default.notifications(named: .NSPersistentStoreRemoteChange) + + for try await _ in changeNotifications { + guard !Task.isCancelled else { break } + debug { logger.debug("\(Self.self).NSPersistentStoreRemoteChange")} + let result = try modelContainer.mainContext.fetch(fetchDescriptor) + trace { + logger.trace("\(Self.self).fetchedResults: \(String(describing: result.map { $0.persistentModelID } ))") + } + wrappedValue = result + } + } catch { + logger.error("\(error)") + } + } + } +} diff --git a/Sources/SwiftQuery/FetchWrappers/FetchFirst.swift b/Sources/SwiftQuery/FetchWrappers/FetchFirst.swift new file mode 100644 index 0000000..2ea5be7 --- /dev/null +++ b/Sources/SwiftQuery/FetchWrappers/FetchFirst.swift @@ -0,0 +1,41 @@ +import Foundation +import CoreData +import Dependencies +import SwiftData + +@MainActor +@propertyWrapper +public final class FetchFirst { + public var wrappedValue: Model? = nil + private var subscription: Task = Task { } + + public init(_ query: Query) { + subscribe(fetchDescriptor: query.fetchDescriptor) + } + + deinit { + subscription.cancel() + } + + private func subscribe(fetchDescriptor: FetchDescriptor) { + debug { logger.debug("\(Self.self).\(#function)") } + subscription = Task { + do { + @Dependency(\.modelContainer) var modelContainer + wrappedValue = try modelContainer.mainContext.fetch(fetchDescriptor).first + + let changeNotifications = NotificationCenter.default.notifications(named: .NSPersistentStoreRemoteChange) + + for try await _ in changeNotifications { + guard !Task.isCancelled else { break } + debug { logger.debug("\(Self.self).NSPersistentStoreRemoteChange")} + let result = try modelContainer.mainContext.fetch(fetchDescriptor).first + trace { logger.trace("\(Self.self).fetchedResults: \(String(describing: result?.persistentModelID))") } + wrappedValue = result + } + } catch { + logger.error("\(error)") + } + } + } +} diff --git a/Sources/SwiftQuery/FetchWrappers/FetchResults.swift b/Sources/SwiftQuery/FetchWrappers/FetchResults.swift new file mode 100644 index 0000000..73eb117 --- /dev/null +++ b/Sources/SwiftQuery/FetchWrappers/FetchResults.swift @@ -0,0 +1,45 @@ +import Foundation +import CoreData +import Dependencies +import SwiftData + +@MainActor +@propertyWrapper +public final class FetchResults { + public var wrappedValue: FetchResultsCollection? + private var subscription: Task = Task { } + + public init(_ query: Query, batchSize: Int = 20) { + subscribe(fetchDescriptor: query.fetchDescriptor, batchSize: batchSize) + } + + deinit { + subscription.cancel() + } + + private func subscribe(fetchDescriptor: FetchDescriptor, batchSize: Int) { + debug { logger.debug("\(Self.self).\(#function)") } + subscription = Task { + do { + @Dependency(\.modelContainer) var modelContainer + var descriptor = fetchDescriptor + descriptor.includePendingChanges = false + wrappedValue = try modelContainer.mainContext.fetch(descriptor, batchSize: batchSize) + + let changeNotifications = NotificationCenter.default.notifications(named: .NSPersistentStoreRemoteChange) + + for try await _ in changeNotifications { + guard !Task.isCancelled else { break } + debug { logger.debug("\(Self.self).NSPersistentStoreRemoteChange")} + let result = try modelContainer.mainContext.fetch(descriptor, batchSize: batchSize) + trace { + logger.trace("\(Self.self).fetchedResults: \(String(describing: result.map { $0.persistentModelID } ))") + } + wrappedValue = result + } + } catch { + logger.error("\(error)") + } + } + } +} diff --git a/Sources/SwiftQuery/Logging.swift b/Sources/SwiftQuery/Logging.swift new file mode 100644 index 0000000..05a9990 --- /dev/null +++ b/Sources/SwiftQuery/Logging.swift @@ -0,0 +1,29 @@ +import Foundation +import OSLog + +let logger = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "", + category: "SwiftDataSharing" +) + +func debug(_ operation: () -> Void) { + if + let isEnabledArgument = ProcessInfo.processInfo.environment["com.impossibleflight.SwiftQuery.debug"], + let isEnabled = Bool(isEnabledArgument) + { + if isEnabled { + operation() + } + } +} + +func trace(_ operation: () -> Void) { + if + let isEnabledArgument = ProcessInfo.processInfo.environment["com.impossibleflight.SwiftQuery.trace"], + let isEnabled = Bool(isEnabledArgument) + { + if isEnabled { + operation() + } + } +} diff --git a/Tests/SwiftQueryTests/FetchWrapperTests.swift b/Tests/SwiftQueryTests/FetchWrapperTests.swift new file mode 100644 index 0000000..ef44482 --- /dev/null +++ b/Tests/SwiftQueryTests/FetchWrapperTests.swift @@ -0,0 +1,158 @@ +import Foundation +import Dependencies +import IssueReporting +import SwiftData +import SwiftQuery +import Testing + +@MainActor +struct FetchWrapperTests { + let modelContainer: ModelContainer + + init() throws { + let config = ModelConfiguration(isStoredInMemoryOnly: true) + modelContainer = try ModelContainer(for: Person.self, configurations: config) + } + + @Test func fetchFirst_reflectsChanges() async throws { + try await withDependencies { + $0.modelContainer = modelContainer + } operation: { + @FetchFirst(.jack) + var jack: Person? + #expect(jack == nil) + + modelContainer.mainContext.insert(Person(name: "Jack", age: 25)) + try modelContainer.mainContext.save() + try await Task.sleep(nanoseconds: 100_000_000) + + #expect(jack != nil) + #expect(jack?.age == 25) + + jack?.age = 30 + try modelContainer.mainContext.save() + + try await Task.sleep(nanoseconds: 100_000_000) + + #expect(jack?.age == 30) + } + } + + @Test func fetchAll_reflectsChanges() async throws { + try await withDependencies { + $0.modelContainer = modelContainer + } operation: { + @FetchAll(.adults) var adults: [Person] + + #expect(adults.isEmpty) + + let alice = Person(name: "Alice", age: 25) + let bob = Person(name: "Bob", age: 30) + let charlie = Person(name: "Charlie", age: 20) + + try modelContainer.mainContext.transaction { + modelContainer.mainContext.insert(alice) + modelContainer.mainContext.insert(bob) + modelContainer.mainContext.insert(charlie) + } + + try await Task.sleep(nanoseconds: 100_000_000) + + #expect(adults.count == 2) + #expect(adults[0].name == "Alice") + #expect(adults[1].name == "Bob") + + try modelContainer.mainContext.transaction { + charlie.age = 35 + } + + try await Task.sleep(nanoseconds: 100_000_000) + + #expect(adults.count == 3) + #expect(adults[2].name == "Charlie") + #expect(adults[2].age == 35) + + try modelContainer.mainContext.transaction { + modelContainer.mainContext.delete(bob) + } + + try await Task.sleep(nanoseconds: 100_000_000) + + #expect(adults.count == 2) + #expect(adults[0].name == "Alice") + #expect(adults[1].name == "Charlie") + } + } + + @Test func fetchResults_reflectsChanges() async throws { + try await withDependencies { + $0.modelContainer = modelContainer + } operation: { + @FetchResults(.adults) var adults: FetchResultsCollection? + + #expect(adults == nil) + + let alice = Person(name: "Alice", age: 25) + let bob = Person(name: "Bob", age: 30) + let charlie = Person(name: "Charlie", age: 20) + + try modelContainer.mainContext.transaction { + modelContainer.mainContext.insert(alice) + modelContainer.mainContext.insert(bob) + modelContainer.mainContext.insert(charlie) + } + + try await Task.sleep(nanoseconds: 100_000_000) + + let adultsResults = try #require(adults) + + #expect(adultsResults.count == 2) + #expect(adultsResults[0].name == "Alice") + #expect(adultsResults[1].name == "Bob") + + try modelContainer.mainContext.transaction { + charlie.age = 35 + } + + try await Task.sleep(nanoseconds: 100_000_000) + + #expect(adultsResults.count == 3) +// #expect(adultsResults[2].name == "Charlie") +// #expect(adultsResults[2].age == 35) +// +// try modelContainer.mainContext.transaction { +// modelContainer.mainContext.delete(bob) +// } +// +// try await Task.sleep(nanoseconds: 100_000_000) +// +// #expect(adultsResults.count == 2) +// #expect(adultsResults[0].name == "Alice") +// #expect(adultsResults[1].name == "Charlie") + } + } + +// @Test func recordsIssue_whenMissingModelContainer() { +// withKnownIssue { +// @FetchFirst( +// predicate: #Predicate { $0.name == "Test" } +// ) var person: Person? +// } +// } + +} + + +extension Query where T == Person { + static var jack: Query { + Person + .include(#Predicate { $0.name == "Jack" }) + } + + + static var adults: Query { + Person + .include(#Predicate { $0.age >= 25 }) + .sortBy(\.age, order: .forward) + } +} From 00a0d8a1ed147597351b1ec1b40c44fe27006c97 Mon Sep 17 00:00:00 2001 From: John Clayton Date: Thu, 7 Aug 2025 16:01:10 -0400 Subject: [PATCH 02/11] wip --- README.md | 37 ++++++++++++---- .../SwiftQuery/FetchWrappers/FetchAll.swift | 11 ++++- .../SwiftQuery/FetchWrappers/FetchFirst.swift | 35 +++++++++++---- .../FetchWrappers/FetchResults.swift | 7 +++ .../SwiftQuery/PersistentModel+Fetch.swift | 44 +++++++++++++------ .../PersistentModelFetchTests.swift | 20 +++++++++ 6 files changed, 120 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 2ef3ccd..ddaacf2 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ library, and enforced at compile time, making it painless to adopt best practice ```swift // Query from the main context -let people = Query() +let people = try Query() .include(#Predicate { $0.age >= 18 } ) .sortBy(\.age) .results(in: modelContainer) @@ -53,7 +53,7 @@ Task.detached { ### Building Queries Queries are an expressive layer on top of SwiftData that allow us to quickly build -complex fetch decriptors by successively applying refinements. The resulting query can +complex fetch descriptors by successively applying refinements. The resulting query can be saved for reuse or performed immediately. Queries can be initialized explicitly, but `PersistentModel` has also been extended @@ -174,13 +174,13 @@ Often we just want to fetch a single result. ```swift let jillQuery = Person.include(#Predicate { $0.name == "Jill" }) -let jill = jillQuery.first(in: modelContainer) -let lastJill = jillQuery.last(in: modelContainer) +let jill = try jillQuery.first(in: modelContainer) +let lastJill = try jillQuery.last(in: modelContainer) ``` Or any result: ```swift -let anyone = Person.any(in: modelContainer) +let anyone = try Person.any(in: modelContainer) ``` @@ -190,7 +190,7 @@ When we want to fetch all query results in memory, we can use `results`: ```swift let notJillQuery = Person.exclude(#Predicate { $0.name == "Jill" }) -let notJills = notJillQuery.results(in: modelContainer) +let notJills = try notJillQuery.results(in: modelContainer) ``` #### Lazy results @@ -199,7 +199,7 @@ Sometimes we want a result that is lazily evaluated. For these cases we can get `FetchResultsCollection` using `fetchedResults`: ```swift -let lazyAdults = Person +let lazyAdults = try Person .include(#Predicate { $0.age > 25 }) .fetchedResults(in: modelContainer) ``` @@ -211,16 +211,35 @@ based on a set of filters, or create a new one by default in the case that objec does not yet exist. This is easy with SwiftQuery using `findOrCreate`: ```swift -let jill = Person +let jill = try Person .include(#Predicate { $0.name == "Jill" }) .findOrCreate(in: container) { Person(name: "Jill") } ``` +#### Deleting objects + +We can delete just the objects matching a refined query: + +```swift +try Person + .include(#Predicate { $0.name == "Jill" }) + .delete(in: container) +``` + +Or we can delete every record of a particular type: + +```swift +try Query().delete(in: container) +try Person.deleteAll(in: container) +``` + +`PersistentModel.deleteAll` is equivalent to deleting with an empty query. + ### Async fetches -Where SwiftQuery really shines is it's automatic support for performing queries +Where SwiftQuery really shines is its automatic support for performing queries in a concurrency environment. The current isolation context is passed in to each function that performs a query, so if you have a custom model actor, you can freely perform queries and operate on the results inside the actor: diff --git a/Sources/SwiftQuery/FetchWrappers/FetchAll.swift b/Sources/SwiftQuery/FetchWrappers/FetchAll.swift index 71ee57d..f1e30df 100644 --- a/Sources/SwiftQuery/FetchWrappers/FetchAll.swift +++ b/Sources/SwiftQuery/FetchWrappers/FetchAll.swift @@ -2,14 +2,17 @@ import Foundation import CoreData import Dependencies import SwiftData +#if canImport(SwiftUI) +import SwiftUI +#endif @MainActor @propertyWrapper -public final class FetchAll { +public final class FetchAll: Observable { public var wrappedValue: [Model] = [] private var subscription: Task = Task { } - public init(_ query: Query) { + public init(_ query: Query = .init()) { subscribe(fetchDescriptor: query.fetchDescriptor) } @@ -43,3 +46,7 @@ public final class FetchAll { } } } + +#if canImport(SwiftUI) +extension FetchAll: DynamicProperty {} +#endif diff --git a/Sources/SwiftQuery/FetchWrappers/FetchFirst.swift b/Sources/SwiftQuery/FetchWrappers/FetchFirst.swift index 2ea5be7..c7bedcf 100644 --- a/Sources/SwiftQuery/FetchWrappers/FetchFirst.swift +++ b/Sources/SwiftQuery/FetchWrappers/FetchFirst.swift @@ -2,40 +2,57 @@ import Foundation import CoreData import Dependencies import SwiftData +#if canImport(SwiftUI) +import SwiftUI +#endif @MainActor @propertyWrapper public final class FetchFirst { - public var wrappedValue: Model? = nil - private var subscription: Task = Task { } + private var reference: Reference = .init() + public var wrappedValue: Model? { + reference.wrappedValue + } + private var subscription: (Task)? public init(_ query: Query) { - subscribe(fetchDescriptor: query.fetchDescriptor) + subscribe(query) } deinit { - subscription.cancel() + subscription?.cancel() } - private func subscribe(fetchDescriptor: FetchDescriptor) { - debug { logger.debug("\(Self.self).\(#function)") } + private func subscribe(_ query: Query) { + debug { logger.debug("\(Self.self).\(#function)(query: \(String(describing: query))") } subscription = Task { do { @Dependency(\.modelContainer) var modelContainer - wrappedValue = try modelContainer.mainContext.fetch(fetchDescriptor).first + let initialResult = try query.first(in: modelContainer) + reference.wrappedValue = initialResult let changeNotifications = NotificationCenter.default.notifications(named: .NSPersistentStoreRemoteChange) for try await _ in changeNotifications { guard !Task.isCancelled else { break } debug { logger.debug("\(Self.self).NSPersistentStoreRemoteChange")} - let result = try modelContainer.mainContext.fetch(fetchDescriptor).first + let result = try query.first(in: modelContainer) trace { logger.trace("\(Self.self).fetchedResults: \(String(describing: result?.persistentModelID))") } - wrappedValue = result + reference.wrappedValue = result } } catch { logger.error("\(error)") } } } + + private class Reference { + var wrappedValue: Model? + init() {} + } } + +#if canImport(SwiftUI) +extension FetchFirst: DynamicProperty {} +#endif + diff --git a/Sources/SwiftQuery/FetchWrappers/FetchResults.swift b/Sources/SwiftQuery/FetchWrappers/FetchResults.swift index 73eb117..19c7151 100644 --- a/Sources/SwiftQuery/FetchWrappers/FetchResults.swift +++ b/Sources/SwiftQuery/FetchWrappers/FetchResults.swift @@ -2,6 +2,9 @@ import Foundation import CoreData import Dependencies import SwiftData +#if canImport(SwiftUI) +import SwiftUI +#endif @MainActor @propertyWrapper @@ -43,3 +46,7 @@ public final class FetchResults { } } } + +#if canImport(SwiftUI) +extension FetchResults: DynamicProperty {} +#endif diff --git a/Sources/SwiftQuery/PersistentModel+Fetch.swift b/Sources/SwiftQuery/PersistentModel+Fetch.swift index 88b98de..edb06ae 100644 --- a/Sources/SwiftQuery/PersistentModel+Fetch.swift +++ b/Sources/SwiftQuery/PersistentModel+Fetch.swift @@ -2,66 +2,74 @@ import SwiftData @MainActor public extension PersistentModel { - /// Builds a query over this model type and invokes ``Query/first(in:)`` on that query. + /// Constructs an empty query over this model type and invokes ``Query/first(in:)`` on that query. /// This is named `any` rather than `first` because there is no order. static func any(in container: ModelContainer) throws -> Self? { try query().first(in: container) } - /// Builds a query over this model type and invokes ``Query/results(in:)`` on that query. + /// Constructs an empty query over this model type and invokes ``Query/results(in:)`` on that query. static func results(in container: ModelContainer) throws -> [Self] { try query().results(in: container) } - /// Builds a query over this model type and invokes ``Query/fetchedResults(in:)`` on that query. + /// Constructs an empty query over this model type and invokes ``Query/fetchedResults(in:)`` on that query. static func fetchedResults(in container: ModelContainer) throws -> FetchResultsCollection { try query().fetchedResults(in: container) } - /// Builds a query over this model type and invokes ``Query/fetchedResults(in:batchSize:)`` on that query. + /// Constructs an empty query over this model type and invokes ``Query/fetchedResults(in:batchSize:)`` on that query. static func fetchedResults(in container: ModelContainer, batchSize: Int) throws -> FetchResultsCollection { try query().fetchedResults(in: container, batchSize: batchSize) } - /// Builds a query over this model type and invokes ``Query/count(in:)`` on that query. + /// Constructs an empty query over this model type and invokes ``Query/count(in:)`` on that query. static func count(in container: ModelContainer) throws -> Int { try query().count(in: container) } - /// Builds a query over this model type and invokes ``Query/isEmpty(in:)`` on that query. + /// Constructs an empty query over this model type and invokes ``Query/isEmpty(in:)`` on that query. static func isEmpty(in container: ModelContainer) throws -> Bool { try query().isEmpty(in: container) } - /// Builds a query over this model type and invokes ``Query/findOrCreate(in:body:)`` on that query. + /// Constructs an empty query over this model type and invokes ``Query/findOrCreate(in:body:)`` on that query. static func findOrCreate( in container: ModelContainer, body: () -> Self ) throws -> Self { try query().findOrCreate(in: container, body: body) } + + /// Constructs an empty query over this model type and invokes ``Query/delete(in:)`` on that query. + /// This is named `deleteAll` rather than `delete` to signify with an empty query this will match all objects. + static func deleteAll( + in container: ModelContainer + ) throws { + try query().delete(in: container) + } } public extension PersistentModel { - /// Builds a query over this model type and invokes ``Query/first(isolation:)`` on that query. + /// Constructs an empty query over this model type and invokes ``Query/first(isolation:)`` on that query. /// This is named `any` rather than `first` because there is no order. static func any(isolation: isolated (any ModelActor) = #isolation) throws -> Self? { try query().first() } - /// Builds a query over this model type and invokes ``Query/results(isolation:)`` on that query. + /// Constructs an empty query over this model type and invokes ``Query/results(isolation:)`` on that query. static func results(isolation: isolated (any ModelActor) = #isolation) throws -> [Self] { try query().results() } - /// Builds a query over this model type and invokes ``Query/fetchedResults(isolation:)`` on that query. + /// Constructs an empty query over this model type and invokes ``Query/fetchedResults(isolation:)`` on that query. static func fetchedResults( isolation: isolated (any ModelActor) = #isolation ) throws -> FetchResultsCollection { try query().fetchedResults() } - /// Builds a query over this model type and invokes ``Query/fetchedResults(batchSize:isolation:)`` on that query. + /// Constructs an empty query over this model type and invokes ``Query/fetchedResults(batchSize:isolation:)`` on that query. static func fetchedResults( batchSize: Int, isolation: isolated (any ModelActor) = #isolation @@ -69,21 +77,29 @@ public extension PersistentModel { try query().fetchedResults(batchSize: batchSize) } - /// Builds a query over this model type and invokes ``Query/count(isolation:)`` on that query. + /// Constructs an empty query over this model type and invokes ``Query/count(isolation:)`` on that query. static func count(isolation: isolated (any ModelActor) = #isolation) throws -> Int { try query().count() } - /// Builds a query over this model type and invokes ``Query/isEmpty(isolation:)`` on that query. + /// Constructs an empty query over this model type and invokes ``Query/isEmpty(isolation:)`` on that query. static func isEmpty(isolation: isolated (any ModelActor) = #isolation) throws -> Bool { try query().isEmpty() } - /// Builds a query over this model type and invokes ``Query/findOrCreate(isolation:body:operation:)`` on that query. + /// Constructs an empty query over this model type and invokes ``Query/findOrCreate(isolation:body:)`` on that query. static func findOrCreate( isolation: isolated (any ModelActor) = #isolation, body: () -> Self ) throws -> Self { try query().findOrCreate(body: body) } + + /// Constructs an empty query over this model type and invokes ``Query/delete(isolation:)`` on that query. + /// This is named `deleteAll` rather than `delete` to signify with an empty query this will match all objects. + static func deleteAll( + isolation: isolated (any ModelActor) = #isolation + ) throws { + try query().delete() + } } diff --git a/Tests/SwiftQueryTests/PersistentModelFetchTests.swift b/Tests/SwiftQueryTests/PersistentModelFetchTests.swift index f951dcc..2cc4537 100644 --- a/Tests/SwiftQueryTests/PersistentModelFetchTests.swift +++ b/Tests/SwiftQueryTests/PersistentModelFetchTests.swift @@ -78,6 +78,15 @@ struct PersistentModelFetchTests { #expect(modelResult.age == directResult.age) #expect(modelResult.age != 999) // Should find existing, not create new } + + @Test func deleteAll() throws { + #expect(try Person.count(in: modelContainer) == 3) + + try Person.deleteAll(in: modelContainer) + + #expect(try Person.count(in: modelContainer) == 0) + #expect(try Person.isEmpty(in: modelContainer) == true) + } } struct PersistentModelConcurrentFetchTests { @@ -167,4 +176,15 @@ struct PersistentModelConcurrentFetchTests { #expect(modelPerson.age != 999) // Should find existing, not create new } } + + @Test func deleteAll() async throws { + try await modelContainer.createQueryActor().perform { _ in + #expect(try Person.count() == 3) + + try Person.deleteAll() + + #expect(try Person.count() == 0) + #expect(try Person.isEmpty() == true) + } + } } From 543ae93a1ef386f7cf476480e163260f6b2195f7 Mon Sep 17 00:00:00 2001 From: John Clayton Date: Sun, 10 Aug 2025 09:43:27 -0400 Subject: [PATCH 03/11] wip --- .../SwiftQuery/FetchWrappers/FetchFirst.swift | 23 +++++++------------ Sources/SwiftQuery/Logging.swift | 2 +- 2 files changed, 9 insertions(+), 16 deletions(-) diff --git a/Sources/SwiftQuery/FetchWrappers/FetchFirst.swift b/Sources/SwiftQuery/FetchWrappers/FetchFirst.swift index c7bedcf..180af94 100644 --- a/Sources/SwiftQuery/FetchWrappers/FetchFirst.swift +++ b/Sources/SwiftQuery/FetchWrappers/FetchFirst.swift @@ -2,18 +2,16 @@ import Foundation import CoreData import Dependencies import SwiftData -#if canImport(SwiftUI) -import SwiftUI -#endif @MainActor @propertyWrapper public final class FetchFirst { - private var reference: Reference = .init() + private var storage: Storage = .init() public var wrappedValue: Model? { - reference.wrappedValue + storage.wrappedValue } private var subscription: (Task)? + @Dependency(\.modelContainer) private var modelContainer public init(_ query: Query) { subscribe(query) @@ -25,11 +23,10 @@ public final class FetchFirst { private func subscribe(_ query: Query) { debug { logger.debug("\(Self.self).\(#function)(query: \(String(describing: query))") } - subscription = Task { + subscription = Task { [modelContainer = self.modelContainer] in do { - @Dependency(\.modelContainer) var modelContainer let initialResult = try query.first(in: modelContainer) - reference.wrappedValue = initialResult + storage.wrappedValue = initialResult let changeNotifications = NotificationCenter.default.notifications(named: .NSPersistentStoreRemoteChange) @@ -38,7 +35,7 @@ public final class FetchFirst { debug { logger.debug("\(Self.self).NSPersistentStoreRemoteChange")} let result = try query.first(in: modelContainer) trace { logger.trace("\(Self.self).fetchedResults: \(String(describing: result?.persistentModelID))") } - reference.wrappedValue = result + storage.wrappedValue = result } } catch { logger.error("\(error)") @@ -46,13 +43,9 @@ public final class FetchFirst { } } - private class Reference { + @Observable + internal class Storage { var wrappedValue: Model? init() {} } } - -#if canImport(SwiftUI) -extension FetchFirst: DynamicProperty {} -#endif - diff --git a/Sources/SwiftQuery/Logging.swift b/Sources/SwiftQuery/Logging.swift index 05a9990..18bb581 100644 --- a/Sources/SwiftQuery/Logging.swift +++ b/Sources/SwiftQuery/Logging.swift @@ -3,7 +3,7 @@ import OSLog let logger = Logger( subsystem: Bundle.main.bundleIdentifier ?? "", - category: "SwiftDataSharing" + category: "SwiftQuery" ) func debug(_ operation: () -> Void) { From c8cb1233b14344bd0466b662ef809f43550451a5 Mon Sep 17 00:00:00 2001 From: John Clayton Date: Sun, 10 Aug 2025 18:28:27 -0400 Subject: [PATCH 04/11] wip --- README.md | 16 ++++ .../SwiftQuery/FetchWrappers/FetchFirst.swift | 1 + .../SwiftQuery/PersistentModel+Query.swift | 26 ++++-- Sources/SwiftQuery/Query.swift | 87 +++++++++++++++++-- .../PersistentModelQueryTests.swift | 8 ++ Tests/SwiftQueryTests/QueryTests.swift | 30 +++++++ 6 files changed, 155 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index ddaacf2..d29cc2a 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,22 @@ been applied to this query, we'll just get the first five results: Person[0..<5] ``` +#### Prefetching relationships + +To improve performance when you know you'll need related objects, you can prefetch relationships to reduce database trips: + +```swift +// Prefetch a single relationship +let ordersWithCustomers = Order + .include(#Predicate { $0.status == .active }) + .prefetchRelationship(\.customer) + +// Prefetch multiple relationships +let ordersWithDetails = Order + .prefetchRelationship(\.customer) + .prefetchRelationship(\.items) +``` + ### Fetching results Queries are just descriptions of how to fetch objects from a context. To make them diff --git a/Sources/SwiftQuery/FetchWrappers/FetchFirst.swift b/Sources/SwiftQuery/FetchWrappers/FetchFirst.swift index 180af94..1aea27c 100644 --- a/Sources/SwiftQuery/FetchWrappers/FetchFirst.swift +++ b/Sources/SwiftQuery/FetchWrappers/FetchFirst.swift @@ -26,6 +26,7 @@ public final class FetchFirst { subscription = Task { [modelContainer = self.modelContainer] in do { let initialResult = try query.first(in: modelContainer) + trace { logger.trace("\(Self.self).initialResult: \(String(describing: initialResult?.persistentModelID))") } storage.wrappedValue = initialResult let changeNotifications = NotificationCenter.default.notifications(named: .NSPersistentStoreRemoteChange) diff --git a/Sources/SwiftQuery/PersistentModel+Query.swift b/Sources/SwiftQuery/PersistentModel+Query.swift index 9e96a3e..9d90914 100644 --- a/Sources/SwiftQuery/PersistentModel+Query.swift +++ b/Sources/SwiftQuery/PersistentModel+Query.swift @@ -2,18 +2,24 @@ import Foundation import SwiftData public extension PersistentModel { + /// Constructs an empty query over this model type. + static func query(_ query: Query = .init()) -> Query { + query + } +} + +public extension PersistentModel { + /// Constructs an empty query over this model type and invokes ``Query/include(_:)`` on that query. static func include(_ predicate: Predicate) -> Query { query().include(predicate) } + /// Constructs an empty query over this model type and invokes ``Query/exclude(_:)`` on that query. static func exclude(_ predicate: Predicate) -> Query { query().exclude(predicate) } - static func query(_ query: Query = .init()) -> Query { - query - } - + /// Constructs an empty query over this model type and invokes ``Query/subscript(_:)`` on that query. static subscript(_ range: Range) -> Query { get { query()[range] @@ -22,6 +28,7 @@ public extension PersistentModel { } public extension PersistentModel { + /// Constructs an empty query over this model type and invokes ``Query/sortBy(_:order:)`` on that query. static func sortBy( _ keyPath: any KeyPath & Sendable, order: SortOrder = .forward @@ -31,6 +38,7 @@ public extension PersistentModel { query().sortBy(keyPath, order: order) } + /// Constructs an empty query over this model type and invokes ``Query/sortBy(_:order:)`` on that query. static func sortBy( _ keyPath: any KeyPath & Sendable, order: SortOrder = .forward @@ -40,7 +48,7 @@ public extension PersistentModel { query().sortBy(keyPath, order: order) } - + /// Constructs an empty query over this model type and invokes ``Query/sortBy(_:comparator:order:)`` on that query. static func sortBy( _ keyPath: any KeyPath & Sendable, comparator: String.StandardComparator = .localizedStandard, @@ -49,6 +57,7 @@ public extension PersistentModel { query().sortBy(keyPath, comparator: comparator, order: order) } + /// Constructs an empty query over this model type and invokes ``Query/sortBy(_:comparator:order:)`` on that query. static func sortBy( _ keyPath: any KeyPath & Sendable, comparator: String.StandardComparator = .localizedStandard, @@ -57,3 +66,10 @@ public extension PersistentModel { query().sortBy(keyPath, comparator: comparator, order: order) } } + +public extension PersistentModel { + /// Constructs an empty query over this model type and invokes ``Query/prefetchRelationship(_:)`` on that query. + static func prefetchRelationship(_ keyPath: PartialKeyPath) -> Query { + query().prefetchRelationship(keyPath) + } +} diff --git a/Sources/SwiftQuery/Query.swift b/Sources/SwiftQuery/Query.swift index db3f4ef..538691a 100644 --- a/Sources/SwiftQuery/Query.swift +++ b/Sources/SwiftQuery/Query.swift @@ -38,6 +38,9 @@ public struct Query { /// A range representing the number of results to skip before returning matches and maximum number of results to fetch. When `nil`, the query will return all matching results. public var range: Range? + public var relationshipKeyPaths: [PartialKeyPath] = [] + + /// SwiftData compatible sort descriptors generated from the query's sort configuration. public var sortDescriptors: [SortDescriptor] { sortBy.map { $0.sortDescriptor } @@ -49,6 +52,7 @@ public struct Query { descriptor.fetchOffset = range.lowerBound descriptor.fetchLimit = range.upperBound - range.lowerBound } + descriptor.relationshipKeyPathsForPrefetching = relationshipKeyPaths return descriptor } @@ -61,11 +65,13 @@ public struct Query { public init( predicate: Predicate? = nil, sortBy: [AnySortDescriptor] = [], - range: Range? = nil + range: Range? = nil, + prefetchRelationships: [PartialKeyPath] = [] ) { self.predicate = predicate self.sortBy = sortBy self.range = range + self.relationshipKeyPaths = prefetchRelationships } /// Returns a new query that includes only objects matching the given predicate. @@ -93,7 +99,12 @@ public struct Query { predicate.evaluate($0) && newPredicate.evaluate($0) } } - return Query(predicate: compoundPredicate, sortBy: sortBy, range: range) + return Query( + predicate: compoundPredicate, + sortBy: sortBy, + range: range, + prefetchRelationships: relationshipKeyPaths + ) } /// Returns a new query that excludes objects matching the given predicate. @@ -138,7 +149,8 @@ public struct Query { Query( predicate: predicate, sortBy: sortBy.map { $0.reversed() }, - range: range + range: range, + prefetchRelationships: relationshipKeyPaths ) } @@ -157,7 +169,12 @@ public struct Query { /// ``` public subscript(_ range: Range) -> Self { get { - Query(predicate: predicate, sortBy: sortBy, range: range) + Query( + predicate: predicate, + sortBy: sortBy, + range: range, + prefetchRelationships: relationshipKeyPaths, + ) } } } @@ -185,7 +202,12 @@ public extension Query { ) -> Self where Value: Comparable { - Query(predicate: predicate, sortBy: sortBy + [.init(keyPath, order: order)], range: range) + Query( + predicate: predicate, + sortBy: sortBy + [.init(keyPath, order: order)], + range: range, + prefetchRelationships: relationshipKeyPaths + ) } /// Adds a sort descriptor for an optional comparable property to the query. @@ -205,7 +227,12 @@ public extension Query { ) -> Self where Value: Comparable { - Query(predicate: predicate, sortBy: sortBy + [.init(keyPath, order: order)], range: range) + Query( + predicate: predicate, + sortBy: sortBy + [.init(keyPath, order: order)], + range: range, + prefetchRelationships: relationshipKeyPaths + ) } /// Adds a sort descriptor for a String property with custom comparison. @@ -225,7 +252,12 @@ public extension Query { comparator: String.StandardComparator = .localizedStandard, order: SortOrder = .forward ) -> Self { - Query(predicate: predicate, sortBy: sortBy + [.init(keyPath, comparator: comparator, order: order)], range: range) + Query( + predicate: predicate, + sortBy: sortBy + [.init(keyPath, comparator: comparator, order: order)], + range: range, + prefetchRelationships: relationshipKeyPaths + ) } /// Adds a sort descriptor for an optional String property with custom comparison. @@ -245,6 +277,45 @@ public extension Query { comparator: String.StandardComparator = .localizedStandard, order: SortOrder = .forward ) -> Self { - Query(predicate: predicate, sortBy: sortBy + [.init(keyPath, comparator: comparator, order: order)], range: range) + Query( + predicate: predicate, + sortBy: sortBy + [.init(keyPath, comparator: comparator, order: order)], + range: range, + prefetchRelationships: relationshipKeyPaths + ) + } +} + + + +public extension Query { + /// Returns a new query that prefetches the specified relationship when executing the fetch. + /// + /// Prefetching relationships can improve performance by reducing the number of database + /// trips needed when accessing related objects. Multiple relationships can be prefetched + /// by chaining multiple calls to this method. + /// + /// - Parameter keyPath: Key path to the relationship property to prefetch + /// - Returns: A new query with the additional relationship marked for prefetching + /// + /// ## Example + /// ```swift + /// // Prefetch single relationship + /// let ordersWithCustomers = Order + /// .include(#Predicate { $0.status == .active }) + /// .prefetchRelationship(\.customer) + /// + /// // Prefetch multiple relationships + /// let ordersWithDetails = Order + /// .prefetchRelationship(\.customer) + /// .prefetchRelationship(\.items) + /// ``` + func prefetchRelationship(_ keyPath: PartialKeyPath) -> Self { + Query( + predicate: predicate, + sortBy: sortBy, + range: range, + prefetchRelationships: relationshipKeyPaths + [keyPath] + ) } } diff --git a/Tests/SwiftQueryTests/PersistentModelQueryTests.swift b/Tests/SwiftQueryTests/PersistentModelQueryTests.swift index 5af7f5b..d9c4f61 100644 --- a/Tests/SwiftQueryTests/PersistentModelQueryTests.swift +++ b/Tests/SwiftQueryTests/PersistentModelQueryTests.swift @@ -60,4 +60,12 @@ struct PersistentModelQueryTests { #expect(modelQuery.sortDescriptors.count == directQuery.sortDescriptors.count) #expect(modelQuery.sortDescriptors.first?.order == directQuery.sortDescriptors.first?.order) } + + @Test func prefetchRelationship() async throws { + let modelQuery = Person.prefetchRelationship(\.name) + let directQuery = Query().prefetchRelationship(\.name) + + #expect(modelQuery.relationshipKeyPaths.count == directQuery.relationshipKeyPaths.count) + #expect(modelQuery.relationshipKeyPaths.contains(\Person.name) == directQuery.relationshipKeyPaths.contains(\Person.name)) + } } diff --git a/Tests/SwiftQueryTests/QueryTests.swift b/Tests/SwiftQueryTests/QueryTests.swift index 471b4b0..9ae621e 100644 --- a/Tests/SwiftQueryTests/QueryTests.swift +++ b/Tests/SwiftQueryTests/QueryTests.swift @@ -269,4 +269,34 @@ struct QueryTests { } } + @Test func prefetchRelationship_single() async throws { + let query = Query() + .prefetchRelationship(\.name) + + #expect(query.relationshipKeyPaths.count == 1) + #expect(query.relationshipKeyPaths.contains(\Person.name)) + } + + @Test func prefetchRelationship_multiple() async throws { + let query = Query() + .prefetchRelationship(\.name) + .prefetchRelationship(\.age) + + #expect(query.relationshipKeyPaths.count == 2) + #expect(query.relationshipKeyPaths.contains(\Person.name)) + #expect(query.relationshipKeyPaths.contains(\Person.age)) + } + + @Test func prefetchRelationship_withOtherModifiers() async throws { + let predicate = #Predicate { $0.age >= 18 } + let query = Person.include(predicate) + .sortBy(\.name) + .prefetchRelationship(\.age) + + #expect(query.predicate != nil) + #expect(query.sortBy.count == 1) + #expect(query.relationshipKeyPaths.count == 1) + #expect(query.relationshipKeyPaths.contains(\Person.age)) + } + } From 405bda472d6a1278ad75b0d59b1ae5bf49734d43 Mon Sep 17 00:00:00 2001 From: John Clayton Date: Mon, 11 Aug 2025 15:54:10 -0400 Subject: [PATCH 05/11] wip --- .../SwiftQuery/FetchWrappers/FetchAll.swift | 39 ++++++++++++------- .../SwiftQuery/FetchWrappers/FetchFirst.swift | 6 +-- .../FetchWrappers/FetchResults.swift | 34 ++++++++++------ Tests/SwiftQueryTests/FetchWrapperTests.swift | 35 ++++++++--------- 4 files changed, 68 insertions(+), 46 deletions(-) diff --git a/Sources/SwiftQuery/FetchWrappers/FetchAll.swift b/Sources/SwiftQuery/FetchWrappers/FetchAll.swift index f1e30df..b203c3a 100644 --- a/Sources/SwiftQuery/FetchWrappers/FetchAll.swift +++ b/Sources/SwiftQuery/FetchWrappers/FetchAll.swift @@ -9,42 +9,53 @@ import SwiftUI @MainActor @propertyWrapper public final class FetchAll: Observable { - public var wrappedValue: [Model] = [] - private var subscription: Task = Task { } + public var wrappedValue: [Model] { + storage.wrappedValue + } + private var storage: Storage = .init() + private var subscription: (Task)? + @Dependency(\.modelContainer) private var modelContainer public init(_ query: Query = .init()) { - subscribe(fetchDescriptor: query.fetchDescriptor) + subscribe(query) } deinit { - subscription.cancel() + subscription?.cancel() } - private func subscribe(fetchDescriptor: FetchDescriptor) { - debug { logger.debug("\(Self.self).\(#function)") } - subscription = Task { @MainActor in - // Send initial value + private func subscribe(_ query: Query) { + debug { logger.debug("\(Self.self).\(#function)(query: \(String(describing: query))") } + subscription = Task { [modelContainer = self.modelContainer] in do { - @Dependency(\.modelContainer) var modelContainer - wrappedValue = try modelContainer.mainContext.fetch(fetchDescriptor) + let initialResult = try query.results(in: modelContainer) + trace { + logger.trace("\(Self.self).results: \(String(describing: initialResult.map { $0.persistentModelID } ))") + } + storage.wrappedValue = initialResult - // Listen for changes let changeNotifications = NotificationCenter.default.notifications(named: .NSPersistentStoreRemoteChange) for try await _ in changeNotifications { guard !Task.isCancelled else { break } debug { logger.debug("\(Self.self).NSPersistentStoreRemoteChange")} - let result = try modelContainer.mainContext.fetch(fetchDescriptor) + let result = try query.results(in: modelContainer) trace { - logger.trace("\(Self.self).fetchedResults: \(String(describing: result.map { $0.persistentModelID } ))") + logger.trace("\(Self.self).results: \(String(describing: result.map { $0.persistentModelID } ))") } - wrappedValue = result + storage.wrappedValue = result } } catch { logger.error("\(error)") } } } + + @Observable + internal class Storage { + var wrappedValue: [Model] = [] + init() {} + } } #if canImport(SwiftUI) diff --git a/Sources/SwiftQuery/FetchWrappers/FetchFirst.swift b/Sources/SwiftQuery/FetchWrappers/FetchFirst.swift index 1aea27c..4c173f8 100644 --- a/Sources/SwiftQuery/FetchWrappers/FetchFirst.swift +++ b/Sources/SwiftQuery/FetchWrappers/FetchFirst.swift @@ -6,10 +6,10 @@ import SwiftData @MainActor @propertyWrapper public final class FetchFirst { - private var storage: Storage = .init() public var wrappedValue: Model? { storage.wrappedValue } + private var storage: Storage = .init() private var subscription: (Task)? @Dependency(\.modelContainer) private var modelContainer @@ -26,7 +26,7 @@ public final class FetchFirst { subscription = Task { [modelContainer = self.modelContainer] in do { let initialResult = try query.first(in: modelContainer) - trace { logger.trace("\(Self.self).initialResult: \(String(describing: initialResult?.persistentModelID))") } + trace { logger.trace("\(Self.self).first: \(String(describing: initialResult?.persistentModelID))") } storage.wrappedValue = initialResult let changeNotifications = NotificationCenter.default.notifications(named: .NSPersistentStoreRemoteChange) @@ -35,7 +35,7 @@ public final class FetchFirst { guard !Task.isCancelled else { break } debug { logger.debug("\(Self.self).NSPersistentStoreRemoteChange")} let result = try query.first(in: modelContainer) - trace { logger.trace("\(Self.self).fetchedResults: \(String(describing: result?.persistentModelID))") } + trace { logger.trace("\(Self.self).first: \(String(describing: result?.persistentModelID))") } storage.wrappedValue = result } } catch { diff --git a/Sources/SwiftQuery/FetchWrappers/FetchResults.swift b/Sources/SwiftQuery/FetchWrappers/FetchResults.swift index 19c7151..0554eb4 100644 --- a/Sources/SwiftQuery/FetchWrappers/FetchResults.swift +++ b/Sources/SwiftQuery/FetchWrappers/FetchResults.swift @@ -9,42 +9,54 @@ import SwiftUI @MainActor @propertyWrapper public final class FetchResults { - public var wrappedValue: FetchResultsCollection? - private var subscription: Task = Task { } + public var wrappedValue: FetchResultsCollection? { + storage.wrappedValue + } + private var storage: Storage = .init() + private var subscription: (Task)? + @Dependency(\.modelContainer) private var modelContainer public init(_ query: Query, batchSize: Int = 20) { - subscribe(fetchDescriptor: query.fetchDescriptor, batchSize: batchSize) + subscribe(query, batchSize: batchSize) } deinit { - subscription.cancel() + subscription?.cancel() } - private func subscribe(fetchDescriptor: FetchDescriptor, batchSize: Int) { + private func subscribe(_ query: Query, batchSize: Int) { debug { logger.debug("\(Self.self).\(#function)") } subscription = Task { do { - @Dependency(\.modelContainer) var modelContainer - var descriptor = fetchDescriptor - descriptor.includePendingChanges = false - wrappedValue = try modelContainer.mainContext.fetch(descriptor, batchSize: batchSize) + + let initialResult = try query.fetchedResults(in: modelContainer, batchSize: batchSize) + trace { + logger.trace("\(Self.self).fetchedResults: \(String(describing: initialResult.map { $0.persistentModelID } ))") + } + storage.wrappedValue = initialResult let changeNotifications = NotificationCenter.default.notifications(named: .NSPersistentStoreRemoteChange) for try await _ in changeNotifications { guard !Task.isCancelled else { break } debug { logger.debug("\(Self.self).NSPersistentStoreRemoteChange")} - let result = try modelContainer.mainContext.fetch(descriptor, batchSize: batchSize) + let result = try query.fetchedResults(in: modelContainer, batchSize: batchSize) trace { logger.trace("\(Self.self).fetchedResults: \(String(describing: result.map { $0.persistentModelID } ))") } - wrappedValue = result + storage.wrappedValue = result } } catch { logger.error("\(error)") } } } + + @Observable + internal class Storage { + var wrappedValue: FetchResultsCollection? + init() {} + } } #if canImport(SwiftUI) diff --git a/Tests/SwiftQueryTests/FetchWrapperTests.swift b/Tests/SwiftQueryTests/FetchWrapperTests.swift index ef44482..1e7d05a 100644 --- a/Tests/SwiftQueryTests/FetchWrapperTests.swift +++ b/Tests/SwiftQueryTests/FetchWrapperTests.swift @@ -104,11 +104,10 @@ struct FetchWrapperTests { try await Task.sleep(nanoseconds: 100_000_000) - let adultsResults = try #require(adults) - - #expect(adultsResults.count == 2) - #expect(adultsResults[0].name == "Alice") - #expect(adultsResults[1].name == "Bob") + #expect(adults != nil) + #expect(adults?.count == 2) + #expect(adults?[0].name == "Alice") + #expect(adults?[1].name == "Bob") try modelContainer.mainContext.transaction { charlie.age = 35 @@ -116,19 +115,19 @@ struct FetchWrapperTests { try await Task.sleep(nanoseconds: 100_000_000) - #expect(adultsResults.count == 3) -// #expect(adultsResults[2].name == "Charlie") -// #expect(adultsResults[2].age == 35) -// -// try modelContainer.mainContext.transaction { -// modelContainer.mainContext.delete(bob) -// } -// -// try await Task.sleep(nanoseconds: 100_000_000) -// -// #expect(adultsResults.count == 2) -// #expect(adultsResults[0].name == "Alice") -// #expect(adultsResults[1].name == "Charlie") + #expect(adults?.count == 3) + #expect(adults?[2].name == "Charlie") + #expect(adults?[2].age == 35) + + try modelContainer.mainContext.transaction { + modelContainer.mainContext.delete(bob) + } + + try await Task.sleep(nanoseconds: 100_000_000) + + #expect(adults?.count == 2) + #expect(adults?[0].name == "Alice") + #expect(adults?[1].name == "Charlie") } } From 127b67731808a04bedf346402d50df3edd912747 Mon Sep 17 00:00:00 2001 From: John Clayton Date: Mon, 11 Aug 2025 16:06:13 -0400 Subject: [PATCH 06/11] wip --- README.md | 2 +- Tests/SwiftQueryTests/FetchWrapperTests.swift | 18 ++++++------------ 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index d29cc2a..31ca173 100644 --- a/README.md +++ b/README.md @@ -177,7 +177,7 @@ let ordersWithDetails = Order .prefetchRelationship(\.items) ``` -### Fetching results +### Executing queries Queries are just descriptions of how to fetch objects from a context. To make them useful, we want to be able to perform them. When fetching results on the main actor, diff --git a/Tests/SwiftQueryTests/FetchWrapperTests.swift b/Tests/SwiftQueryTests/FetchWrapperTests.swift index 1e7d05a..a7904eb 100644 --- a/Tests/SwiftQueryTests/FetchWrapperTests.swift +++ b/Tests/SwiftQueryTests/FetchWrapperTests.swift @@ -18,8 +18,7 @@ struct FetchWrapperTests { try await withDependencies { $0.modelContainer = modelContainer } operation: { - @FetchFirst(.jack) - var jack: Person? + @FetchFirst(.jack) var jack: Person? #expect(jack == nil) modelContainer.mainContext.insert(Person(name: "Jack", age: 25)) @@ -131,24 +130,19 @@ struct FetchWrapperTests { } } -// @Test func recordsIssue_whenMissingModelContainer() { -// withKnownIssue { -// @FetchFirst( -// predicate: #Predicate { $0.name == "Test" } -// ) var person: Person? -// } -// } - + @Test func recordsIssue_whenMissingModelContainer() { + withKnownIssue { + @FetchFirst(.jack) var jack: Person? + } + } } - extension Query where T == Person { static var jack: Query { Person .include(#Predicate { $0.name == "Jack" }) } - static var adults: Query { Person .include(#Predicate { $0.age >= 25 }) From d523ab26af143c30d9253798ad172ea58cd73547 Mon Sep 17 00:00:00 2001 From: John Clayton Date: Mon, 11 Aug 2025 16:45:15 -0400 Subject: [PATCH 07/11] wip --- README.md | 89 +++++++++++++++++++ Tests/SwiftQueryTests/FetchWrapperTests.swift | 44 +++++++++ 2 files changed, 133 insertions(+) diff --git a/README.md b/README.md index 31ca173..9ef64ca 100644 --- a/README.md +++ b/README.md @@ -310,6 +310,95 @@ effectively makes it impossible to use the models returned from a query incorrec a multi-context environment, thus guaranteeing the SwiftData concurrency contract at compile time. +### Observable Queries + +Often in the context of view models or views we'd like to passively observe a Query and be notified of changes. SwiftQuery provides property wrappers that automatically update when the underlying data changes. These wrappers use Swift's `@Observable` framework and notify observers whenever the persistent store changes, even if that happens as a result of something like iCloud sync. + +Observable queries use the main context by default. If you are using them inside a macro like `@Observable`, you must add `@ObservationIgnored`. Listeners will still be notified, but not through the enclosing observable. + +#### Fetch types + + +`FetchFirst` fetches and tracks the first result matching a query, if any. + +```swift +struct PersonDetailView: View { + @FetchFirst(Person.include(#Predicate { $0.name == "Jack" })) + private var jack: Person? + + var body: some View { + if let jack { + Text("Jack is \(jack.age) years old") + } else { + Text("Jack not found") + } + } +} +``` + +`FetchAll` fetches and tracks all results matching a query. + +```swift +extension Query where T == Person { + static var adults: Query { + Person.include(#Predicate { $0.age >= 18 }).sortBy(\.name) + } +} + +@Observable +final class PeopleViewModel { + @ObservationIgnored + @FetchAll(.adults) + var adults: [Person] + + var adultCount: Int { + adults.count + } +} +``` + +`FetchResults` fetches and tracks results as a lazy `FetchResultsCollection` with configurable batch size. Useful for very large datasets or performance critical screens. + +```swift +@Reducer +struct PeopleFeature { + @ObservableState + struct State { + @ObservationStateIgnored + @FetchResults(Person.sortBy(\.name), batchSize: 50) + var people: FetchResultsCollection? + + var peopleCount: Int { + people?.count ?? 0 + } + } + + // ... +} +``` + +#### Dependency Injection + +All fetch wrappers use [Swift Dependencies](https://github.com/pointfreeco/swift-dependencies) to access the model container. In your app setup: + +```swift +@main +struct MyApp: App { + let container = ModelContainer(for: Person.self) + + init() { + prepareDependencies { + $0.modelContainer = container + } + } + + // ... +} +``` + +This is also what enables them to be used outside of the SwiftUI environment. + + ## Installation You can add SwiftQuery to an Xcode project by adding it to your project as a package. diff --git a/Tests/SwiftQueryTests/FetchWrapperTests.swift b/Tests/SwiftQueryTests/FetchWrapperTests.swift index a7904eb..d2a6a0d 100644 --- a/Tests/SwiftQueryTests/FetchWrapperTests.swift +++ b/Tests/SwiftQueryTests/FetchWrapperTests.swift @@ -130,6 +130,35 @@ struct FetchWrapperTests { } } + @Test func fetchFirst_withDynamicQuery() async throws { + try await withDependencies { + $0.modelContainer = modelContainer + } operation: { + let viewModel = ViewModel() + + let alice = Person(name: "Alice", age: 30) + let bob = Person(name: "Bob", age: 25) + + try modelContainer.mainContext.transaction { + modelContainer.mainContext.insert(alice) + modelContainer.mainContext.insert(bob) + } + + try await Task.sleep(nanoseconds: 100_000_000) + + viewModel.updateQuery(name: "Alice") + try await Task.sleep(nanoseconds: 100_000_000) + + #expect(viewModel.person?.name == "Alice") + + viewModel.updateQuery(name: "Bob") + try await Task.sleep(nanoseconds: 100_000_000) + + #expect(viewModel.person?.name == "Bob") + } + } + + @Test func recordsIssue_whenMissingModelContainer() { withKnownIssue { @FetchFirst(.jack) var jack: Person? @@ -137,6 +166,21 @@ struct FetchWrapperTests { } } +@MainActor +@Observable +class ViewModel { + @ObservationIgnored + @FetchFirst(Query()) var person: Person? + + func updateQuery(name: String) { + let query = Person.include(#Predicate { person in + person.name == name + }) + _person = FetchFirst(query) + } +} + + extension Query where T == Person { static var jack: Query { Person From 4015d02cb31c6d4003ace5621fcad11fc4d42b59 Mon Sep 17 00:00:00 2001 From: John Clayton Date: Mon, 11 Aug 2025 17:14:28 -0400 Subject: [PATCH 08/11] wip --- README.md | 9 +++ .../SwiftQuery/PersistentModel+Query.swift | 5 ++ Sources/SwiftQuery/Query.swift | 63 ++++++++++++++++--- .../PersistentModelQueryTests.swift | 8 +++ Tests/SwiftQueryTests/QueryTests.swift | 29 +++++++++ 5 files changed, 104 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 9ef64ca..a08a817 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,15 @@ let ordersWithDetails = Order .prefetchRelationship(\.items) ``` +#### Fetching specific properties + +To reduce memory usage, you can fetch only specific properties instead of full objects: + +```swift +// Fetch only specific properties for better performance +let lightweightPeople = Person.fetchKeyPaths(\.name, \.age) +``` + ### Executing queries Queries are just descriptions of how to fetch objects from a context. To make them diff --git a/Sources/SwiftQuery/PersistentModel+Query.swift b/Sources/SwiftQuery/PersistentModel+Query.swift index 9d90914..0072db5 100644 --- a/Sources/SwiftQuery/PersistentModel+Query.swift +++ b/Sources/SwiftQuery/PersistentModel+Query.swift @@ -72,4 +72,9 @@ public extension PersistentModel { static func prefetchRelationship(_ keyPath: PartialKeyPath) -> Query { query().prefetchRelationship(keyPath) } + + /// Constructs an empty query over this model type and invokes ``Query/fetchKeyPaths(_:)`` on that query. + static func fetchKeyPaths(_ keyPaths: PartialKeyPath...) -> Query { + query().fetchKeyPaths(keyPaths) + } } diff --git a/Sources/SwiftQuery/Query.swift b/Sources/SwiftQuery/Query.swift index 538691a..e020696 100644 --- a/Sources/SwiftQuery/Query.swift +++ b/Sources/SwiftQuery/Query.swift @@ -40,6 +40,7 @@ public struct Query { public var relationshipKeyPaths: [PartialKeyPath] = [] + public var propertiesToFetch: [PartialKeyPath] = [] /// SwiftData compatible sort descriptors generated from the query's sort configuration. public var sortDescriptors: [SortDescriptor] { @@ -53,6 +54,7 @@ public struct Query { descriptor.fetchLimit = range.upperBound - range.lowerBound } descriptor.relationshipKeyPathsForPrefetching = relationshipKeyPaths + descriptor.propertiesToFetch = propertiesToFetch return descriptor } @@ -66,12 +68,14 @@ public struct Query { predicate: Predicate? = nil, sortBy: [AnySortDescriptor] = [], range: Range? = nil, - prefetchRelationships: [PartialKeyPath] = [] + prefetchRelationships: [PartialKeyPath] = [], + propertiesToFetch: [PartialKeyPath] = [] ) { self.predicate = predicate self.sortBy = sortBy self.range = range self.relationshipKeyPaths = prefetchRelationships + self.propertiesToFetch = propertiesToFetch } /// Returns a new query that includes only objects matching the given predicate. @@ -103,7 +107,8 @@ public struct Query { predicate: compoundPredicate, sortBy: sortBy, range: range, - prefetchRelationships: relationshipKeyPaths + prefetchRelationships: relationshipKeyPaths, + propertiesToFetch: propertiesToFetch ) } @@ -150,7 +155,8 @@ public struct Query { predicate: predicate, sortBy: sortBy.map { $0.reversed() }, range: range, - prefetchRelationships: relationshipKeyPaths + prefetchRelationships: relationshipKeyPaths, + propertiesToFetch: propertiesToFetch ) } @@ -174,6 +180,7 @@ public struct Query { sortBy: sortBy, range: range, prefetchRelationships: relationshipKeyPaths, + propertiesToFetch: propertiesToFetch ) } } @@ -206,7 +213,8 @@ public extension Query { predicate: predicate, sortBy: sortBy + [.init(keyPath, order: order)], range: range, - prefetchRelationships: relationshipKeyPaths + prefetchRelationships: relationshipKeyPaths, + propertiesToFetch: propertiesToFetch ) } @@ -231,7 +239,8 @@ public extension Query { predicate: predicate, sortBy: sortBy + [.init(keyPath, order: order)], range: range, - prefetchRelationships: relationshipKeyPaths + prefetchRelationships: relationshipKeyPaths, + propertiesToFetch: propertiesToFetch ) } @@ -256,7 +265,8 @@ public extension Query { predicate: predicate, sortBy: sortBy + [.init(keyPath, comparator: comparator, order: order)], range: range, - prefetchRelationships: relationshipKeyPaths + prefetchRelationships: relationshipKeyPaths, + propertiesToFetch: propertiesToFetch ) } @@ -281,13 +291,12 @@ public extension Query { predicate: predicate, sortBy: sortBy + [.init(keyPath, comparator: comparator, order: order)], range: range, - prefetchRelationships: relationshipKeyPaths + prefetchRelationships: relationshipKeyPaths, + propertiesToFetch: propertiesToFetch ) } } - - public extension Query { /// Returns a new query that prefetches the specified relationship when executing the fetch. /// @@ -315,7 +324,41 @@ public extension Query { predicate: predicate, sortBy: sortBy, range: range, - prefetchRelationships: relationshipKeyPaths + [keyPath] + prefetchRelationships: relationshipKeyPaths + [keyPath], + propertiesToFetch: propertiesToFetch ) } + + /// Returns a new query that fetches only the specified key paths when executing the fetch. + /// + /// This can improve performance by reducing memory usage when you only need specific properties. + /// Properties not included will be faulted and loaded on demand. + /// + /// - Parameter keyPaths: Array of key paths to the properties to fetch + /// - Returns: A new query with the additional properties marked for fetching + func fetchKeyPaths(_ keyPaths: [PartialKeyPath]) -> Self { + Query( + predicate: predicate, + sortBy: sortBy, + range: range, + prefetchRelationships: relationshipKeyPaths, + propertiesToFetch: propertiesToFetch + keyPaths + ) + } + + /// Returns a new query that fetches only the specified key paths when executing the fetch. + /// + /// This can improve performance by reducing memory usage when you only need specific properties. + /// Properties not included will be faulted and loaded on demand. + /// + /// - Parameter keyPaths: Key paths to the properties to fetch + /// - Returns: A new query with the additional properties marked for fetching + /// + /// ## Example + /// ```swift + /// let lightweightPeople = Person.fetchKeyPaths(\.name, \.age) + /// ``` + func fetchKeyPaths(_ keyPaths: PartialKeyPath...) -> Self { + fetchKeyPaths(keyPaths) + } } diff --git a/Tests/SwiftQueryTests/PersistentModelQueryTests.swift b/Tests/SwiftQueryTests/PersistentModelQueryTests.swift index d9c4f61..556ec8c 100644 --- a/Tests/SwiftQueryTests/PersistentModelQueryTests.swift +++ b/Tests/SwiftQueryTests/PersistentModelQueryTests.swift @@ -68,4 +68,12 @@ struct PersistentModelQueryTests { #expect(modelQuery.relationshipKeyPaths.count == directQuery.relationshipKeyPaths.count) #expect(modelQuery.relationshipKeyPaths.contains(\Person.name) == directQuery.relationshipKeyPaths.contains(\Person.name)) } + + @Test func fetchKeyPaths() async throws { + let modelQuery = Person.fetchKeyPaths(\.name) + let directQuery = Query().fetchKeyPaths(\.name) + + #expect(modelQuery.propertiesToFetch.count == directQuery.propertiesToFetch.count) + #expect(modelQuery.propertiesToFetch.contains(\Person.name) == directQuery.propertiesToFetch.contains(\Person.name)) + } } diff --git a/Tests/SwiftQueryTests/QueryTests.swift b/Tests/SwiftQueryTests/QueryTests.swift index 9ae621e..ce2dfe8 100644 --- a/Tests/SwiftQueryTests/QueryTests.swift +++ b/Tests/SwiftQueryTests/QueryTests.swift @@ -299,4 +299,33 @@ struct QueryTests { #expect(query.relationshipKeyPaths.contains(\Person.age)) } + @Test func fetchKeyPaths_single() async throws { + let query = Query() + .fetchKeyPaths(\.name) + + #expect(query.propertiesToFetch.count == 1) + #expect(query.propertiesToFetch.contains(\Person.name)) + } + + @Test func fetchKeyPaths_multiple() async throws { + let query = Query() + .fetchKeyPaths(\.name, \.age) + + #expect(query.propertiesToFetch.count == 2) + #expect(query.propertiesToFetch.contains(\Person.name)) + #expect(query.propertiesToFetch.contains(\Person.age)) + } + + @Test func fetchKeyPaths_withOtherModifiers() async throws { + let predicate = #Predicate { $0.age >= 18 } + let query = Person.include(predicate) + .sortBy(\.name) + .fetchKeyPaths(\.age) + + #expect(query.predicate != nil) + #expect(query.sortBy.count == 1) + #expect(query.propertiesToFetch.count == 1) + #expect(query.propertiesToFetch.contains(\Person.age)) + } + } From 959c78730c95c01523792a1eaa1ff2b16eec025c Mon Sep 17 00:00:00 2001 From: John Clayton Date: Mon, 11 Aug 2025 18:32:06 -0400 Subject: [PATCH 09/11] See if we can fix that flaky test --- .../ConcurrentFetchTests.swift | 33 +++++++++++-------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/Tests/SwiftQueryTests/ConcurrentFetchTests.swift b/Tests/SwiftQueryTests/ConcurrentFetchTests.swift index 81bc073..2572d36 100644 --- a/Tests/SwiftQueryTests/ConcurrentFetchTests.swift +++ b/Tests/SwiftQueryTests/ConcurrentFetchTests.swift @@ -466,29 +466,28 @@ struct ConcurrentFetchTests { } } + @MainActor @Test func perform_multipleContextsConcurrentMutation() async throws { - let countOfJacks = 2 + let countOfJacks = try countOfJacks() @Sendable func incrementJacks(by amount: Int) async throws { try await modelContainer.createQueryActor().perform { actor in - let jacks = try Query() - .include(#Predicate { $0.name == "Jack"}) - .results() - - #expect(jacks.count == countOfJacks) + try actor.modelContext.transaction { + let jacks = try Query() + .include(#Predicate { $0.name == "Jack"}) + .results(isolation: actor) - for jack in jacks { - jack.age += amount + for jack in jacks { + jack.age += amount + } } - - try actor.modelContext.save() } } let iterations = 5 let increment = 5 - let age = try await jacksAge() + let age = try jacksAge() try await withThrowingDiscardingTaskGroup { group in (0.. Int { + private func countOfJacks() throws -> Int { + try Query() + .include(#Predicate { $0.name == "Jack"}) + .count(in: modelContainer) + } + + @MainActor + private func jacksAge() throws -> Int { let jacks = try Query() .include(#Predicate { $0.name == "Jack"}) .results(in: modelContainer) return jacks.reduce(0) { $0 + $1.age } } - } @ModelActor From 983fedf40d10d010ec2f0ebc6467d8d6064e424d Mon Sep 17 00:00:00 2001 From: John Clayton Date: Tue, 12 Aug 2025 15:08:35 -0400 Subject: [PATCH 10/11] pr feedback --- README.md | 2 +- Tests/SwiftQueryTests/QueryTests.swift | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index a08a817..15b5d80 100644 --- a/README.md +++ b/README.md @@ -163,7 +163,7 @@ Person[0..<5] #### Prefetching relationships -To improve performance when you know you'll need related objects, you can prefetch relationships to reduce database trips: +When you know you'll need related objects, you can prefetch relationships to reduce trips to the persistent store: ```swift // Prefetch a single relationship diff --git a/Tests/SwiftQueryTests/QueryTests.swift b/Tests/SwiftQueryTests/QueryTests.swift index ce2dfe8..e5385a4 100644 --- a/Tests/SwiftQueryTests/QueryTests.swift +++ b/Tests/SwiftQueryTests/QueryTests.swift @@ -327,5 +327,4 @@ struct QueryTests { #expect(query.propertiesToFetch.count == 1) #expect(query.propertiesToFetch.contains(\Person.age)) } - } From 89eda3e8a739ac339584b0392c542d825ddbef71 Mon Sep 17 00:00:00 2001 From: John Clayton Date: Tue, 12 Aug 2025 15:27:32 -0400 Subject: [PATCH 11/11] PR feedback --- README.md | 9 +--- .../SwiftQuery/FetchWrappers/FetchAll.swift | 7 --- .../FetchWrappers/FetchResults.swift | 7 --- .../SwiftQuery/PersistentModel+Query.swift | 6 +-- Sources/SwiftQuery/Query.swift | 51 +++++++++++-------- .../PersistentModelQueryTests.swift | 6 +-- Tests/SwiftQueryTests/QueryTests.swift | 13 +++-- 7 files changed, 43 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index 15b5d80..28e49c5 100644 --- a/README.md +++ b/README.md @@ -166,15 +166,10 @@ Person[0..<5] When you know you'll need related objects, you can prefetch relationships to reduce trips to the persistent store: ```swift -// Prefetch a single relationship -let ordersWithCustomers = Order - .include(#Predicate { $0.status == .active }) - .prefetchRelationship(\.customer) - // Prefetch multiple relationships let ordersWithDetails = Order - .prefetchRelationship(\.customer) - .prefetchRelationship(\.items) + .include(#Predicate { $0.status == .active }) + .prefetchRelationships(\.customer, \.items) ``` #### Fetching specific properties diff --git a/Sources/SwiftQuery/FetchWrappers/FetchAll.swift b/Sources/SwiftQuery/FetchWrappers/FetchAll.swift index b203c3a..b8c8a96 100644 --- a/Sources/SwiftQuery/FetchWrappers/FetchAll.swift +++ b/Sources/SwiftQuery/FetchWrappers/FetchAll.swift @@ -2,9 +2,6 @@ import Foundation import CoreData import Dependencies import SwiftData -#if canImport(SwiftUI) -import SwiftUI -#endif @MainActor @propertyWrapper @@ -57,7 +54,3 @@ public final class FetchAll: Observable { init() {} } } - -#if canImport(SwiftUI) -extension FetchAll: DynamicProperty {} -#endif diff --git a/Sources/SwiftQuery/FetchWrappers/FetchResults.swift b/Sources/SwiftQuery/FetchWrappers/FetchResults.swift index 0554eb4..cb1ba6a 100644 --- a/Sources/SwiftQuery/FetchWrappers/FetchResults.swift +++ b/Sources/SwiftQuery/FetchWrappers/FetchResults.swift @@ -2,9 +2,6 @@ import Foundation import CoreData import Dependencies import SwiftData -#if canImport(SwiftUI) -import SwiftUI -#endif @MainActor @propertyWrapper @@ -58,7 +55,3 @@ public final class FetchResults { init() {} } } - -#if canImport(SwiftUI) -extension FetchResults: DynamicProperty {} -#endif diff --git a/Sources/SwiftQuery/PersistentModel+Query.swift b/Sources/SwiftQuery/PersistentModel+Query.swift index 0072db5..fe2b0b7 100644 --- a/Sources/SwiftQuery/PersistentModel+Query.swift +++ b/Sources/SwiftQuery/PersistentModel+Query.swift @@ -68,9 +68,9 @@ public extension PersistentModel { } public extension PersistentModel { - /// Constructs an empty query over this model type and invokes ``Query/prefetchRelationship(_:)`` on that query. - static func prefetchRelationship(_ keyPath: PartialKeyPath) -> Query { - query().prefetchRelationship(keyPath) + /// Constructs an empty query over this model type and invokes ``Query/prefetchRelationships(_:)`` on that query. + static func prefetchRelationships(_ keyPaths: PartialKeyPath...) -> Query { + query().prefetchRelationships(keyPaths) } /// Constructs an empty query over this model type and invokes ``Query/fetchKeyPaths(_:)`` on that query. diff --git a/Sources/SwiftQuery/Query.swift b/Sources/SwiftQuery/Query.swift index e020696..d3bfaee 100644 --- a/Sources/SwiftQuery/Query.swift +++ b/Sources/SwiftQuery/Query.swift @@ -37,9 +37,11 @@ public struct Query { /// A range representing the number of results to skip before returning matches and maximum number of results to fetch. When `nil`, the query will return all matching results. public var range: Range? - + + /// Key paths representing related models to fetch as part of this query. public var relationshipKeyPaths: [PartialKeyPath] = [] - + + /// The subset of attributes to fetch for this entity public var propertiesToFetch: [PartialKeyPath] = [] /// SwiftData compatible sort descriptors generated from the query's sort configuration. @@ -298,36 +300,41 @@ public extension Query { } public extension Query { - /// Returns a new query that prefetches the specified relationship when executing the fetch. + /// Returns a new query that prefetches the specified relationships when executing the fetch. /// /// Prefetching relationships can improve performance by reducing the number of database - /// trips needed when accessing related objects. Multiple relationships can be prefetched - /// by chaining multiple calls to this method. - /// - /// - Parameter keyPath: Key path to the relationship property to prefetch - /// - Returns: A new query with the additional relationship marked for prefetching - /// - /// ## Example - /// ```swift - /// // Prefetch single relationship - /// let ordersWithCustomers = Order - /// .include(#Predicate { $0.status == .active }) - /// .prefetchRelationship(\.customer) + /// trips needed when accessing related objects. /// - /// // Prefetch multiple relationships - /// let ordersWithDetails = Order - /// .prefetchRelationship(\.customer) - /// .prefetchRelationship(\.items) - /// ``` - func prefetchRelationship(_ keyPath: PartialKeyPath) -> Self { + /// - Parameter keyPaths: Array of key paths to the relationships to prefetch + /// - Returns: A new query with the additional relationships marked for prefetching + func prefetchRelationships(_ keyPaths: [PartialKeyPath]) -> Self { Query( predicate: predicate, sortBy: sortBy, range: range, - prefetchRelationships: relationshipKeyPaths + [keyPath], + prefetchRelationships: relationshipKeyPaths + keyPaths, propertiesToFetch: propertiesToFetch ) } + + /// Returns a new query that prefetches the specified relationships when executing the fetch. + /// + /// Prefetching relationships can improve performance by reducing the number of database + /// trips needed when accessing related objects. + /// + /// - Parameter keyPaths: Key paths to the relationships to prefetch + /// - Returns: A new query with the additional relationships marked for prefetching + /// + /// ## Example + /// ```swift + /// // Prefetch multiple relationships + /// let ordersWithDetails = Order + /// .include(#Predicate { $0.status == .active }) + /// .prefetchRelationships(\.customer, \.items) + /// ``` + func prefetchRelationships(_ keyPaths: PartialKeyPath...) -> Self { + prefetchRelationships(keyPaths) + } /// Returns a new query that fetches only the specified key paths when executing the fetch. /// diff --git a/Tests/SwiftQueryTests/PersistentModelQueryTests.swift b/Tests/SwiftQueryTests/PersistentModelQueryTests.swift index 556ec8c..99d87aa 100644 --- a/Tests/SwiftQueryTests/PersistentModelQueryTests.swift +++ b/Tests/SwiftQueryTests/PersistentModelQueryTests.swift @@ -61,9 +61,9 @@ struct PersistentModelQueryTests { #expect(modelQuery.sortDescriptors.first?.order == directQuery.sortDescriptors.first?.order) } - @Test func prefetchRelationship() async throws { - let modelQuery = Person.prefetchRelationship(\.name) - let directQuery = Query().prefetchRelationship(\.name) + @Test func prefetchRelationships() async throws { + let modelQuery = Person.prefetchRelationships(\.name) + let directQuery = Query().prefetchRelationships(\.name) #expect(modelQuery.relationshipKeyPaths.count == directQuery.relationshipKeyPaths.count) #expect(modelQuery.relationshipKeyPaths.contains(\Person.name) == directQuery.relationshipKeyPaths.contains(\Person.name)) diff --git a/Tests/SwiftQueryTests/QueryTests.swift b/Tests/SwiftQueryTests/QueryTests.swift index e5385a4..bbf1933 100644 --- a/Tests/SwiftQueryTests/QueryTests.swift +++ b/Tests/SwiftQueryTests/QueryTests.swift @@ -269,29 +269,28 @@ struct QueryTests { } } - @Test func prefetchRelationship_single() async throws { + @Test func prefetchRelationships_single() async throws { let query = Query() - .prefetchRelationship(\.name) + .prefetchRelationships(\.name) #expect(query.relationshipKeyPaths.count == 1) #expect(query.relationshipKeyPaths.contains(\Person.name)) } - @Test func prefetchRelationship_multiple() async throws { + @Test func prefetchRelationships_multiple() async throws { let query = Query() - .prefetchRelationship(\.name) - .prefetchRelationship(\.age) + .prefetchRelationships(\.name, \.age) #expect(query.relationshipKeyPaths.count == 2) #expect(query.relationshipKeyPaths.contains(\Person.name)) #expect(query.relationshipKeyPaths.contains(\Person.age)) } - @Test func prefetchRelationship_withOtherModifiers() async throws { + @Test func prefetchRelationships_withOtherModifiers() async throws { let predicate = #Predicate { $0.age >= 18 } let query = Person.include(predicate) .sortBy(\.name) - .prefetchRelationship(\.age) + .prefetchRelationships(\.age) #expect(query.predicate != nil) #expect(query.sortBy.count == 1)