Skip to content

Commit 1f9d975

Browse files
authored
Fetch wrappers plus relationship and property fetching modifiers (#1)
1 parent afd8d49 commit 1f9d975

16 files changed

+904
-53
lines changed

Package.resolved

Lines changed: 46 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Package.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,17 @@ let package = Package(
1818
),
1919
],
2020
dependencies: [
21+
.package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.8.1"),
2122
.package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.4.3"),
2223
],
2324
targets: [
2425
// Targets are the basic building blocks of a package, defining a module or a test suite.
2526
// Targets can depend on other targets in this package and products from dependencies.
2627
.target(
2728
name: "SwiftQuery",
28-
dependencies: []
29+
dependencies: [
30+
.product(name: "Dependencies", package: "swift-dependencies")
31+
]
2932
),
3033
.testTarget(
3134
name: "SwiftQueryTests",

README.md

Lines changed: 138 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ library, and enforced at compile time, making it painless to adopt best practice
2525

2626
```swift
2727
// Query from the main context
28-
let people = Query<Person>()
28+
let people = try Query<Person>()
2929
.include(#Predicate { $0.age >= 18 } )
3030
.sortBy(\.age)
3131
.results(in: modelContainer)
@@ -53,7 +53,7 @@ Task.detached {
5353
### Building Queries
5454

5555
Queries are an expressive layer on top of SwiftData that allow us to quickly build
56-
complex fetch decriptors by successively applying refinements. The resulting query can
56+
complex fetch descriptors by successively applying refinements. The resulting query can
5757
be saved for reuse or performed immediately.
5858

5959
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:
161161
Person[0..<5]
162162
```
163163

164-
### Fetching results
164+
#### Prefetching relationships
165+
166+
When you know you'll need related objects, you can prefetch relationships to reduce trips to the persistent store:
167+
168+
```swift
169+
// Prefetch multiple relationships
170+
let ordersWithDetails = Order
171+
.include(#Predicate { $0.status == .active })
172+
.prefetchRelationships(\.customer, \.items)
173+
```
174+
175+
#### Fetching specific properties
176+
177+
To reduce memory usage, you can fetch only specific properties instead of full objects:
178+
179+
```swift
180+
// Fetch only specific properties for better performance
181+
let lightweightPeople = Person.fetchKeyPaths(\.name, \.age)
182+
```
183+
184+
### Executing queries
165185

166186
Queries are just descriptions of how to fetch objects from a context. To make them
167187
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.
174194
```swift
175195
let jillQuery = Person.include(#Predicate { $0.name == "Jill" })
176196

177-
let jill = jillQuery.first(in: modelContainer)
178-
let lastJill = jillQuery.last(in: modelContainer)
197+
let jill = try jillQuery.first(in: modelContainer)
198+
let lastJill = try jillQuery.last(in: modelContainer)
179199
```
180200
Or any result:
181201

182202
```swift
183-
let anyone = Person.any(in: modelContainer)
203+
let anyone = try Person.any(in: modelContainer)
184204
```
185205

186206

@@ -190,7 +210,7 @@ When we want to fetch all query results in memory, we can use `results`:
190210

191211
```swift
192212
let notJillQuery = Person.exclude(#Predicate { $0.name == "Jill" })
193-
let notJills = notJillQuery.results(in: modelContainer)
213+
let notJills = try notJillQuery.results(in: modelContainer)
194214
```
195215

196216
#### Lazy results
@@ -199,7 +219,7 @@ Sometimes we want a result that is lazily evaluated. For these cases we can get
199219
`FetchResultsCollection` using `fetchedResults`:
200220

201221
```swift
202-
let lazyAdults = Person
222+
let lazyAdults = try Person
203223
.include(#Predicate { $0.age > 25 })
204224
.fetchedResults(in: modelContainer)
205225
```
@@ -211,16 +231,35 @@ based on a set of filters, or create a new one by default in the case that objec
211231
does not yet exist. This is easy with SwiftQuery using `findOrCreate`:
212232

213233
```swift
214-
let jill = Person
234+
let jill = try Person
215235
.include(#Predicate { $0.name == "Jill" })
216236
.findOrCreate(in: container) {
217237
Person(name: "Jill")
218238
}
219239
```
220240

241+
#### Deleting objects
242+
243+
We can delete just the objects matching a refined query:
244+
245+
```swift
246+
try Person
247+
.include(#Predicate { $0.name == "Jill" })
248+
.delete(in: container)
249+
```
250+
251+
Or we can delete every record of a particular type:
252+
253+
```swift
254+
try Query<Person>().delete(in: container)
255+
try Person.deleteAll(in: container)
256+
```
257+
258+
`PersistentModel.deleteAll` is equivalent to deleting with an empty query.
259+
221260
### Async fetches
222261

223-
Where SwiftQuery really shines is it's automatic support for performing queries
262+
Where SwiftQuery really shines is its automatic support for performing queries
224263
in a concurrency environment. The current isolation context is passed in to each function
225264
that performs a query, so if you have a custom model actor, you can freely perform
226265
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
275314
a multi-context environment, thus guaranteeing the SwiftData concurrency contract at
276315
compile time.
277316

317+
### Observable Queries
318+
319+
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.
320+
321+
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.
322+
323+
#### Fetch types
324+
325+
326+
`FetchFirst` fetches and tracks the first result matching a query, if any.
327+
328+
```swift
329+
struct PersonDetailView: View {
330+
@FetchFirst(Person.include(#Predicate { $0.name == "Jack" }))
331+
private var jack: Person?
332+
333+
var body: some View {
334+
if let jack {
335+
Text("Jack is \(jack.age) years old")
336+
} else {
337+
Text("Jack not found")
338+
}
339+
}
340+
}
341+
```
342+
343+
`FetchAll` fetches and tracks all results matching a query.
344+
345+
```swift
346+
extension Query where T == Person {
347+
static var adults: Query {
348+
Person.include(#Predicate { $0.age >= 18 }).sortBy(\.name)
349+
}
350+
}
351+
352+
@Observable
353+
final class PeopleViewModel {
354+
@ObservationIgnored
355+
@FetchAll(.adults)
356+
var adults: [Person]
357+
358+
var adultCount: Int {
359+
adults.count
360+
}
361+
}
362+
```
363+
364+
`FetchResults` fetches and tracks results as a lazy `FetchResultsCollection` with configurable batch size. Useful for very large datasets or performance critical screens.
365+
366+
```swift
367+
@Reducer
368+
struct PeopleFeature {
369+
@ObservableState
370+
struct State {
371+
@ObservationStateIgnored
372+
@FetchResults(Person.sortBy(\.name), batchSize: 50)
373+
var people: FetchResultsCollection<Person>?
374+
375+
var peopleCount: Int {
376+
people?.count ?? 0
377+
}
378+
}
379+
380+
// ...
381+
}
382+
```
383+
384+
#### Dependency Injection
385+
386+
All fetch wrappers use [Swift Dependencies](https://github.com/pointfreeco/swift-dependencies) to access the model container. In your app setup:
387+
388+
```swift
389+
@main
390+
struct MyApp: App {
391+
let container = ModelContainer(for: Person.self)
392+
393+
init() {
394+
prepareDependencies {
395+
$0.modelContainer = container
396+
}
397+
}
398+
399+
// ...
400+
}
401+
```
402+
403+
This is also what enables them to be used outside of the SwiftUI environment.
404+
405+
278406
## Installation
279407

280408
You can add SwiftQuery to an Xcode project by adding it to your project as a package.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import Dependencies
2+
import SwiftData
3+
4+
@Model final class Empty {
5+
init() {}
6+
}
7+
8+
enum DefaultModelContainerKey: DependencyKey {
9+
static var liveValue: ModelContainer {
10+
reportIssue(
11+
"""
12+
A blank, in-memory persistent container is being used for the app.
13+
Override this dependency in the entry point of your app using `prepareDependencies`.
14+
"""
15+
)
16+
let configuration = ModelConfiguration(isStoredInMemoryOnly: true)
17+
return try! ModelContainer(for: Empty.self, configurations: configuration)
18+
}
19+
20+
static var testValue: ModelContainer {
21+
liveValue
22+
}
23+
}
24+
25+
public extension DependencyValues {
26+
var modelContainer: ModelContainer {
27+
get { self[DefaultModelContainerKey.self] }
28+
set { self[DefaultModelContainerKey.self] = newValue }
29+
}
30+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import Foundation
2+
import CoreData
3+
import Dependencies
4+
import SwiftData
5+
6+
@MainActor
7+
@propertyWrapper
8+
public final class FetchAll<Model: PersistentModel>: Observable {
9+
public var wrappedValue: [Model] {
10+
storage.wrappedValue
11+
}
12+
private var storage: Storage = .init()
13+
private var subscription: (Task<Void, Never>)?
14+
@Dependency(\.modelContainer) private var modelContainer
15+
16+
public init(_ query: Query<Model> = .init()) {
17+
subscribe(query)
18+
}
19+
20+
deinit {
21+
subscription?.cancel()
22+
}
23+
24+
private func subscribe(_ query: Query<Model>) {
25+
debug { logger.debug("\(Self.self).\(#function)(query: \(String(describing: query))") }
26+
subscription = Task { [modelContainer = self.modelContainer] in
27+
do {
28+
let initialResult = try query.results(in: modelContainer)
29+
trace {
30+
logger.trace("\(Self.self).results: \(String(describing: initialResult.map { $0.persistentModelID } ))")
31+
}
32+
storage.wrappedValue = initialResult
33+
34+
let changeNotifications = NotificationCenter.default.notifications(named: .NSPersistentStoreRemoteChange)
35+
36+
for try await _ in changeNotifications {
37+
guard !Task.isCancelled else { break }
38+
debug { logger.debug("\(Self.self).NSPersistentStoreRemoteChange")}
39+
let result = try query.results(in: modelContainer)
40+
trace {
41+
logger.trace("\(Self.self).results: \(String(describing: result.map { $0.persistentModelID } ))")
42+
}
43+
storage.wrappedValue = result
44+
}
45+
} catch {
46+
logger.error("\(error)")
47+
}
48+
}
49+
}
50+
51+
@Observable
52+
internal class Storage {
53+
var wrappedValue: [Model] = []
54+
init() {}
55+
}
56+
}

0 commit comments

Comments
 (0)