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/README.md b/README.md index 2ef3ccd..28e49c5 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 @@ -161,7 +161,27 @@ been applied to this query, we'll just get the first five results: Person[0..<5] ``` -### Fetching results +#### Prefetching relationships + +When you know you'll need related objects, you can prefetch relationships to reduce trips to the persistent store: + +```swift +// Prefetch multiple relationships +let ordersWithDetails = Order + .include(#Predicate { $0.status == .active }) + .prefetchRelationships(\.customer, \.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 useful, we want to be able to perform them. When fetching results on the main actor, @@ -174,13 +194,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 +210,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 +219,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 +231,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: @@ -275,6 +314,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/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..b8c8a96 --- /dev/null +++ b/Sources/SwiftQuery/FetchWrappers/FetchAll.swift @@ -0,0 +1,56 @@ +import Foundation +import CoreData +import Dependencies +import SwiftData + +@MainActor +@propertyWrapper +public final class FetchAll: Observable { + 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(query) + } + + deinit { + subscription?.cancel() + } + + private func subscribe(_ query: Query) { + debug { logger.debug("\(Self.self).\(#function)(query: \(String(describing: query))") } + subscription = Task { [modelContainer = self.modelContainer] in + do { + let initialResult = try query.results(in: modelContainer) + trace { + logger.trace("\(Self.self).results: \(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 query.results(in: modelContainer) + trace { + logger.trace("\(Self.self).results: \(String(describing: result.map { $0.persistentModelID } ))") + } + storage.wrappedValue = result + } + } catch { + logger.error("\(error)") + } + } + } + + @Observable + internal class Storage { + var wrappedValue: [Model] = [] + init() {} + } +} diff --git a/Sources/SwiftQuery/FetchWrappers/FetchFirst.swift b/Sources/SwiftQuery/FetchWrappers/FetchFirst.swift new file mode 100644 index 0000000..4c173f8 --- /dev/null +++ b/Sources/SwiftQuery/FetchWrappers/FetchFirst.swift @@ -0,0 +1,52 @@ +import Foundation +import CoreData +import Dependencies +import SwiftData + +@MainActor +@propertyWrapper +public final class FetchFirst { + public var wrappedValue: Model? { + storage.wrappedValue + } + private var storage: Storage = .init() + private var subscription: (Task)? + @Dependency(\.modelContainer) private var modelContainer + + public init(_ query: Query) { + subscribe(query) + } + + deinit { + subscription?.cancel() + } + + private func subscribe(_ query: Query) { + debug { logger.debug("\(Self.self).\(#function)(query: \(String(describing: query))") } + subscription = Task { [modelContainer = self.modelContainer] in + do { + let initialResult = try query.first(in: modelContainer) + trace { logger.trace("\(Self.self).first: \(String(describing: initialResult?.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 query.first(in: modelContainer) + trace { logger.trace("\(Self.self).first: \(String(describing: result?.persistentModelID))") } + storage.wrappedValue = result + } + } catch { + logger.error("\(error)") + } + } + } + + @Observable + internal class Storage { + var wrappedValue: Model? + init() {} + } +} diff --git a/Sources/SwiftQuery/FetchWrappers/FetchResults.swift b/Sources/SwiftQuery/FetchWrappers/FetchResults.swift new file mode 100644 index 0000000..cb1ba6a --- /dev/null +++ b/Sources/SwiftQuery/FetchWrappers/FetchResults.swift @@ -0,0 +1,57 @@ +import Foundation +import CoreData +import Dependencies +import SwiftData + +@MainActor +@propertyWrapper +public final class FetchResults { + 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(query, batchSize: batchSize) + } + + deinit { + subscription?.cancel() + } + + private func subscribe(_ query: Query, batchSize: Int) { + debug { logger.debug("\(Self.self).\(#function)") } + subscription = Task { + do { + + 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 query.fetchedResults(in: modelContainer, batchSize: batchSize) + trace { + logger.trace("\(Self.self).fetchedResults: \(String(describing: result.map { $0.persistentModelID } ))") + } + storage.wrappedValue = result + } + } catch { + logger.error("\(error)") + } + } + } + + @Observable + internal class Storage { + var wrappedValue: FetchResultsCollection? + init() {} + } +} diff --git a/Sources/SwiftQuery/Logging.swift b/Sources/SwiftQuery/Logging.swift new file mode 100644 index 0000000..18bb581 --- /dev/null +++ b/Sources/SwiftQuery/Logging.swift @@ -0,0 +1,29 @@ +import Foundation +import OSLog + +let logger = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "", + category: "SwiftQuery" +) + +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/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/Sources/SwiftQuery/PersistentModel+Query.swift b/Sources/SwiftQuery/PersistentModel+Query.swift index 9e96a3e..fe2b0b7 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,15 @@ public extension PersistentModel { query().sortBy(keyPath, comparator: comparator, order: order) } } + +public extension PersistentModel { + /// 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. + static func fetchKeyPaths(_ keyPaths: PartialKeyPath...) -> Query { + query().fetchKeyPaths(keyPaths) + } +} diff --git a/Sources/SwiftQuery/Query.swift b/Sources/SwiftQuery/Query.swift index db3f4ef..d3bfaee 100644 --- a/Sources/SwiftQuery/Query.swift +++ b/Sources/SwiftQuery/Query.swift @@ -37,6 +37,12 @@ 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. public var sortDescriptors: [SortDescriptor] { @@ -49,6 +55,8 @@ public struct Query { descriptor.fetchOffset = range.lowerBound descriptor.fetchLimit = range.upperBound - range.lowerBound } + descriptor.relationshipKeyPathsForPrefetching = relationshipKeyPaths + descriptor.propertiesToFetch = propertiesToFetch return descriptor } @@ -61,11 +69,15 @@ public struct Query { public init( predicate: Predicate? = nil, sortBy: [AnySortDescriptor] = [], - range: Range? = nil + range: Range? = nil, + 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. @@ -93,7 +105,13 @@ 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, + propertiesToFetch: propertiesToFetch + ) } /// Returns a new query that excludes objects matching the given predicate. @@ -138,7 +156,9 @@ public struct Query { Query( predicate: predicate, sortBy: sortBy.map { $0.reversed() }, - range: range + range: range, + prefetchRelationships: relationshipKeyPaths, + propertiesToFetch: propertiesToFetch ) } @@ -157,7 +177,13 @@ 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, + propertiesToFetch: propertiesToFetch + ) } } } @@ -185,7 +211,13 @@ 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, + propertiesToFetch: propertiesToFetch + ) } /// Adds a sort descriptor for an optional comparable property to the query. @@ -205,7 +237,13 @@ 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, + propertiesToFetch: propertiesToFetch + ) } /// Adds a sort descriptor for a String property with custom comparison. @@ -225,7 +263,13 @@ 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, + propertiesToFetch: propertiesToFetch + ) } /// Adds a sort descriptor for an optional String property with custom comparison. @@ -245,6 +289,83 @@ 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, + propertiesToFetch: propertiesToFetch + ) + } +} + +public extension Query { + /// 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: 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 + 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. + /// + /// 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/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 diff --git a/Tests/SwiftQueryTests/FetchWrapperTests.swift b/Tests/SwiftQueryTests/FetchWrapperTests.swift new file mode 100644 index 0000000..d2a6a0d --- /dev/null +++ b/Tests/SwiftQueryTests/FetchWrapperTests.swift @@ -0,0 +1,195 @@ +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) + + #expect(adults != nil) + #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 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? + } + } +} + +@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 + .include(#Predicate { $0.name == "Jack" }) + } + + static var adults: Query { + Person + .include(#Predicate { $0.age >= 25 }) + .sortBy(\.age, order: .forward) + } +} 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) + } + } } diff --git a/Tests/SwiftQueryTests/PersistentModelQueryTests.swift b/Tests/SwiftQueryTests/PersistentModelQueryTests.swift index 5af7f5b..99d87aa 100644 --- a/Tests/SwiftQueryTests/PersistentModelQueryTests.swift +++ b/Tests/SwiftQueryTests/PersistentModelQueryTests.swift @@ -60,4 +60,20 @@ struct PersistentModelQueryTests { #expect(modelQuery.sortDescriptors.count == directQuery.sortDescriptors.count) #expect(modelQuery.sortDescriptors.first?.order == directQuery.sortDescriptors.first?.order) } + + @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)) + } + + @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 471b4b0..bbf1933 100644 --- a/Tests/SwiftQueryTests/QueryTests.swift +++ b/Tests/SwiftQueryTests/QueryTests.swift @@ -269,4 +269,61 @@ struct QueryTests { } } + @Test func prefetchRelationships_single() async throws { + let query = Query() + .prefetchRelationships(\.name) + + #expect(query.relationshipKeyPaths.count == 1) + #expect(query.relationshipKeyPaths.contains(\Person.name)) + } + + @Test func prefetchRelationships_multiple() async throws { + let query = Query() + .prefetchRelationships(\.name, \.age) + + #expect(query.relationshipKeyPaths.count == 2) + #expect(query.relationshipKeyPaths.contains(\Person.name)) + #expect(query.relationshipKeyPaths.contains(\Person.age)) + } + + @Test func prefetchRelationships_withOtherModifiers() async throws { + let predicate = #Predicate { $0.age >= 18 } + let query = Person.include(predicate) + .sortBy(\.name) + .prefetchRelationships(\.age) + + #expect(query.predicate != nil) + #expect(query.sortBy.count == 1) + #expect(query.relationshipKeyPaths.count == 1) + #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)) + } }