diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b4bd407e..21b539b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,11 +14,11 @@ jobs: name: macOS strategy: matrix: - xcode: ['16.4'] + xcode: ['26.1'] config: ['debug', 'release'] - runs-on: macos-15 + runs-on: macos-26 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Select Xcode ${{ matrix.xcode }} run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app - name: Run ${{ matrix.config }} tests @@ -28,13 +28,13 @@ jobs: name: Examples strategy: matrix: - xcode: ['16.4'] + xcode: ['26.1'] config: ['debug'] scheme: ['Reminders', 'CaseStudies', 'SyncUps'] - runs-on: macos-15 + runs-on: macos-26 continue-on-error: true steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Select Xcode ${{ matrix.xcode }} run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app - name: List devices available diff --git a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index a0d54872..1f540899 100644 --- a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/combine-schedulers", "state" : { - "revision" : "5928286acce13def418ec36d05a001a9641086f2", - "version" : "1.0.3" + "revision" : "fd16d76fd8b9a976d88bfb6cacc05ca8d19c91b6", + "version" : "1.1.0" } }, { @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/groue/GRDB.swift", "state" : { - "revision" : "8ba1bc9a96afc731a000fd4136dd13a5a46297bd", - "version" : "7.6.1" + "revision" : "18497b68fdbb3a09528d260a0a0e1e7e61c8c53d", + "version" : "7.8.0" } }, { @@ -24,8 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-case-paths", "state" : { - "revision" : "9810c8d6c2914de251e072312f01d3bf80071852", - "version" : "1.7.1" + "revision" : "6989976265be3f8d2b5802c722f9ba168e227c71", + "version" : "1.7.2" } }, { @@ -42,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections", "state" : { - "revision" : "8c0c0a8b49e080e54e5e328cc552821ff07cd341", - "version" : "1.2.1" + "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", + "version" : "1.3.0" } }, { @@ -69,8 +69,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-dependencies", "state" : { - "revision" : "a501eebe552fd23691c560adf474fca2169ad8aa", - "version" : "1.9.4" + "revision" : "a10f9feeb214bc72b5337b6ef6d5a029360db4cc", + "version" : "1.10.0" } }, { @@ -87,8 +87,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-navigation", "state" : { - "revision" : "6b7f44d218e776bb7a5246efb940440d57c8b2cf", - "version" : "2.4.2" + "revision" : "bf498690e1f6b4af790260f542e8428a4ba10d78", + "version" : "2.6.0" } }, { @@ -96,8 +96,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-perception", "state" : { - "revision" : "52bc59a5c7c3f0420c6c31d643d55d64b3f8c013", - "version" : "2.0.7" + "revision" : "4f47ebafed5f0b0172cf5c661454fa8e28fb2ac4", + "version" : "2.0.9" } }, { @@ -114,8 +114,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-snapshot-testing.git", "state" : { - "revision" : "d7e40607dcd6bc26543f5d9433103f06e0b28f8f", - "version" : "1.18.6" + "revision" : "a8b7c5e0ed33d8ab8887d1654d9b59f2cbad529b", + "version" : "1.18.7" } }, { @@ -123,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-structured-queries", "state" : { - "revision" : "3a95b70a81b7027b8a5117e7dd08188837e5f54e", - "version" : "0.24.0" + "revision" : "9c84335373bae5f5c9f7b5f0adf3ae10f2cab5b9", + "version" : "0.25.2" } }, { @@ -132,8 +132,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-syntax", "state" : { - "revision" : "f99ae8aa18f0cf0d53481901f88a0991dc3bd4a2", - "version" : "601.0.1" + "revision" : "4799286537280063c85a32f09884cfbca301b1a1", + "version" : "602.0.0" } }, { @@ -141,8 +141,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", "state" : { - "revision" : "b2ed9eabefe56202ee4939dd9fc46b6241c88317", - "version" : "1.6.1" + "revision" : "4c27acf5394b645b70d8ba19dc249c0472d5f618", + "version" : "1.7.0" } } ], diff --git a/Examples/Reminders/ReminderForm.swift b/Examples/Reminders/ReminderForm.swift index febebdca..a41e28e0 100644 --- a/Examples/Reminders/ReminderForm.swift +++ b/Examples/Reminders/ReminderForm.swift @@ -122,7 +122,7 @@ struct ReminderFormView: View { } .task(id: reminder.remindersListID) { await withErrorReporting { - try await $remindersList.load(RemindersList.find(reminder.remindersListID)) + try await $remindersList.load(RemindersList.find(reminder.remindersListID)).task } } } diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index e9acd229..09fbfc12 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -387,8 +387,14 @@ nonisolated private let logger = Logger(subsystem: "Reminders", category: "Datab func seedSampleData() throws { @Dependency(\.date.now) var now @Dependency(\.uuid) var uuid - let remindersListIDs = (0...2).map { _ in uuid() } - let reminderIDs = (0...10).map { _ in uuid() } + var remindersListIDs: [UUID] = [] + for _ in 0...2 { + remindersListIDs.append(uuid()) + } + var reminderIDs: [UUID] = [] + for _ in 0...10 { + reminderIDs.append(uuid()) + } try seed { RemindersList( id: remindersListIDs[0], diff --git a/Package.swift b/Package.swift index 0d607e9d..8e87eb48 100644 --- a/Package.swift +++ b/Package.swift @@ -32,6 +32,7 @@ let package = Package( .package(url: "https://github.com/pointfreeco/swift-concurrency-extras", from: "1.0.0"), .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.3.3"), .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.9.0"), + .package(url: "https://github.com/pointfreeco/swift-perception", from: "2.0.0"), .package(url: "https://github.com/pointfreeco/swift-sharing", from: "2.3.0"), .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.18.4"), .package( @@ -53,6 +54,7 @@ let package = Package( .product(name: "GRDB", package: "GRDB.swift"), .product(name: "IssueReporting", package: "xctest-dynamic-overlay"), .product(name: "OrderedCollections", package: "swift-collections"), + .product(name: "Perception", package: "swift-perception"), .product(name: "Sharing", package: "swift-sharing"), .product(name: "StructuredQueriesSQLite", package: "swift-structured-queries"), .product( diff --git a/Sources/SQLiteData/Documentation.docc/Articles/MigrationGuides.md b/Sources/SQLiteData/Documentation.docc/Articles/MigrationGuides.md new file mode 100644 index 00000000..5a00b735 --- /dev/null +++ b/Sources/SQLiteData/Documentation.docc/Articles/MigrationGuides.md @@ -0,0 +1,17 @@ +# Migration guides + +Learn how to upgrade your application to the latest version of SQLiteData. + +## Overview + +SQLiteData is under constant development, and we are always looking for ways to simplify the +library and make it more powerful. As such, we often need to deprecate certain APIs in favor of +newer ones. We recommend people update their code as quickly as possible to the newest APIs, and +these guides contain tips to do so. + +> Important: Before following any particular migration guide be sure you have followed all the +> preceding migration guides. + +## Topics + +- diff --git a/Sources/SQLiteData/Documentation.docc/Articles/MigrationGuides/MigratingTo1.4.md b/Sources/SQLiteData/Documentation.docc/Articles/MigrationGuides/MigratingTo1.4.md new file mode 100644 index 00000000..54dfa1b1 --- /dev/null +++ b/Sources/SQLiteData/Documentation.docc/Articles/MigrationGuides/MigratingTo1.4.md @@ -0,0 +1,36 @@ +# Migrating to 1.4 + +SQLiteData 1.4 introduces a new tool for tying the lifecycle database subscriptions to the +lifecycle of the surrounding async context, but it may incidentally cause "Result of call … +is unused" warnings in your project. + +## Overview + +The `load` method defined on [`@FetchAll`]() / [`@FetchOne`]() / +[`@Fetch`]() all now return a discardable result, ``FetchSubscription``. Awaiting the +``FetchSubscription/task`` of that result ties the lifecycle of the subscription to the database +to the lifecycle of the surrounding async context, which can help views to automatically +unsubscribe from the database when they are not visible. + +However, when used with `withErrorReporting` you are likely to get the following warning: + +```swift +private func updateQuery() async { + // ⚠️ Result of call to 'withErrorReporting(_:to:fileID:filePath:line:column:isolation:catching:)' is unused + await withErrorReporting { + try await $rows.load(…) + } +} +``` + +This is happening because although `load` has a discardable result, Swift does not propagate that +to `withErrorReporting`, and so Swift thinks you have an unused value. To fix you will need to +explicitly ignore the result with `_ = `: + +```swift +private func updateQuery() async { + _ = await withErrorReporting { + try await $rows.load(…) + } +} +``` diff --git a/Sources/SQLiteData/Documentation.docc/SQLiteData.md b/Sources/SQLiteData/Documentation.docc/SQLiteData.md index 543481a1..c1ddf49b 100644 --- a/Sources/SQLiteData/Documentation.docc/SQLiteData.md +++ b/Sources/SQLiteData/Documentation.docc/SQLiteData.md @@ -309,6 +309,7 @@ with SQLite to take full advantage of GRDB and SQLiteData. - ``FetchAll`` - ``FetchOne`` - ``Fetch`` +- ``FetchSubscription`` ### CloudKit synchronization and sharing diff --git a/Sources/SQLiteData/Fetch.swift b/Sources/SQLiteData/Fetch.swift index 5f916314..b705dd0f 100644 --- a/Sources/SQLiteData/Fetch.swift +++ b/Sources/SQLiteData/Fetch.swift @@ -98,11 +98,14 @@ public struct Fetch: Sendable { /// - request: A request describing the data to fetch. /// - database: The database to read from. A value of `nil` will use the default database /// (`@Dependency(\.defaultDatabase)`). + /// - Returns: A subscription associated with the observation. + @discardableResult public func load( _ request: some FetchKeyRequest, database: (any DatabaseReader)? = nil - ) async throws { + ) async throws -> FetchSubscription { try await sharedReader.load(.fetch(request, database: database)) + return FetchSubscription(sharedReader: sharedReader) } } @@ -136,12 +139,15 @@ extension Fetch { /// (`@Dependency(\.defaultDatabase)`). /// - scheduler: The scheduler to observe from. By default, database observation is performed /// asynchronously on the main queue. + /// - Returns: A subscription associated with the observation. + @discardableResult public func load( _ request: some FetchKeyRequest, database: (any DatabaseReader)? = nil, scheduler: some ValueObservationScheduler & Hashable - ) async throws { + ) async throws -> FetchSubscription { try await sharedReader.load(.fetch(request, database: database, scheduler: scheduler)) + return FetchSubscription(sharedReader: sharedReader) } } @@ -193,13 +199,16 @@ extension Fetch: Equatable where Value: Equatable { /// (`@Dependency(\.defaultDatabase)`). /// - animation: The animation to use for user interface changes that result from changes to /// the fetched results. + /// - Returns: A subscription associated with the observation. @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @discardableResult public func load( _ request: some FetchKeyRequest, database: (any DatabaseReader)? = nil, animation: Animation - ) async throws { + ) async throws -> FetchSubscription { try await sharedReader.load(.fetch(request, database: database, animation: animation)) + return FetchSubscription(sharedReader: sharedReader) } } #endif diff --git a/Sources/SQLiteData/FetchAll.swift b/Sources/SQLiteData/FetchAll.swift index 0b41db44..e4960c8a 100644 --- a/Sources/SQLiteData/FetchAll.swift +++ b/Sources/SQLiteData/FetchAll.swift @@ -176,10 +176,12 @@ public struct FetchAll: Sendable { /// - statement: A query associated with the wrapped value. /// - database: The database to read from. A value of `nil` will use the default database /// (`@Dependency(\.defaultDatabase)`). + /// - Returns: A subscription associated with the observation. + @discardableResult public func load( _ statement: S, database: (any DatabaseReader)? = nil - ) async throws + ) async throws -> FetchSubscription where Element == S.From.QueryOutput, S.QueryValue == (), @@ -187,7 +189,7 @@ public struct FetchAll: Sendable { S.Joins == () { let statement = statement.selectStar() - try await load(statement, database: database) + return try await load(statement, database: database) } /// Replaces the wrapped value with data from the given query. @@ -196,10 +198,12 @@ public struct FetchAll: Sendable { /// - statement: A query associated with the wrapped value. /// - database: The database to read from. A value of `nil` will use the default database /// (`@Dependency(\.defaultDatabase)`). + /// - Returns: A subscription associated with the observation. + @discardableResult public func load( _ statement: some StructuredQueriesCore.Statement, database: (any DatabaseReader)? = nil - ) async throws + ) async throws -> FetchSubscription where Element == V.QueryOutput, V.QueryOutput: Sendable @@ -210,6 +214,7 @@ public struct FetchAll: Sendable { database: database ) ) + return FetchSubscription(sharedReader: sharedReader) } } @@ -323,11 +328,13 @@ extension FetchAll { /// (`@Dependency(\.defaultDatabase)`). /// - scheduler: The scheduler to observe from. By default, database observation is performed /// asynchronously on the main queue. + /// - Returns: A subscription associated with the observation. + @discardableResult public func load( _ statement: S, database: (any DatabaseReader)? = nil, scheduler: some ValueObservationScheduler & Hashable - ) async throws + ) async throws -> FetchSubscription where Element == S.From.QueryOutput, S.QueryValue == (), @@ -335,7 +342,7 @@ extension FetchAll { S.Joins == () { let statement = statement.selectStar() - try await load(statement, database: database, scheduler: scheduler) + return try await load(statement, database: database, scheduler: scheduler) } /// Replaces the wrapped value with data from the given query. @@ -346,11 +353,13 @@ extension FetchAll { /// (`@Dependency(\.defaultDatabase)`). /// - scheduler: The scheduler to observe from. By default, database observation is performed /// asynchronously on the main queue. + /// - Returns: A subscription associated with the observation. + @discardableResult public func load( _ statement: some StructuredQueriesCore.Statement, database: (any DatabaseReader)? = nil, scheduler: some ValueObservationScheduler & Hashable - ) async throws + ) async throws -> FetchSubscription where Element == V.QueryOutput, V.QueryOutput: Sendable @@ -362,6 +371,7 @@ extension FetchAll { scheduler: scheduler ) ) + return FetchSubscription(sharedReader: sharedReader) } } @@ -495,12 +505,14 @@ extension FetchAll: Equatable where Element: Equatable { /// (`@Dependency(\.defaultDatabase)`). /// - animation: The animation to use for user interface changes that result from changes to /// the fetched results. + /// - Returns: A subscription associated with the observation. @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @discardableResult public func load( _ statement: S, database: (any DatabaseReader)? = nil, animation: Animation - ) async throws + ) async throws -> FetchSubscription where Element == S.From.QueryOutput, S.QueryValue == (), @@ -508,7 +520,7 @@ extension FetchAll: Equatable where Element: Equatable { S.Joins == () { let statement = statement.selectStar() - try await load(statement, database: database, animation: animation) + return try await load(statement, database: database, animation: animation) } /// Replaces the wrapped value with data from the given query. @@ -519,12 +531,14 @@ extension FetchAll: Equatable where Element: Equatable { /// (`@Dependency(\.defaultDatabase)`). /// - animation: The animation to use for user interface changes that result from changes to /// the fetched results. + /// - Returns: A subscription associated with the observation. @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @discardableResult public func load( _ statement: some StructuredQueriesCore.Statement, database: (any DatabaseReader)? = nil, animation: Animation - ) async throws + ) async throws -> FetchSubscription where Element == V.QueryOutput, V.QueryOutput: Sendable @@ -536,6 +550,7 @@ extension FetchAll: Equatable where Element: Equatable { animation: animation ) ) + return FetchSubscription(sharedReader: sharedReader) } } #endif diff --git a/Sources/SQLiteData/FetchOne.swift b/Sources/SQLiteData/FetchOne.swift index 80348bef..5f06f972 100644 --- a/Sources/SQLiteData/FetchOne.swift +++ b/Sources/SQLiteData/FetchOne.swift @@ -299,17 +299,19 @@ public struct FetchOne: Sendable { /// - statement: A query associated with the wrapped value. /// - database: The database to read from. A value of `nil` will use the default database /// (`@Dependency(\.defaultDatabase)`). + /// - Returns: A subscription associated with the observation. + @discardableResult public func load( _ statement: S, database: (any DatabaseReader)? = nil - ) async throws + ) async throws -> FetchSubscription where Value == S.From.QueryOutput, S.QueryValue == (), S.Joins == () { let statement = statement.selectStar().asSelect().limit(1) - try await load(statement, database: database) + return try await load(statement, database: database) } /// Replaces the wrapped value with data from the given query. @@ -318,16 +320,19 @@ public struct FetchOne: Sendable { /// - statement: A query associated with the wrapped value. /// - database: The database to read from. A value of `nil` will use the default database /// (`@Dependency(\.defaultDatabase)`). + /// - Returns: A subscription associated with the observation. + @discardableResult public func load( _ statement: some StructuredQueriesCore.Statement, database: (any DatabaseReader)? = nil - ) async throws + ) async throws -> FetchSubscription where Value == V.QueryOutput { try await sharedReader.load( .fetch(FetchOneStatementValueRequest(statement: statement), database: database) ) + return FetchSubscription(sharedReader: sharedReader) } /// Replaces the wrapped value with data from the given query. @@ -336,16 +341,19 @@ public struct FetchOne: Sendable { /// - statement: A query associated with the wrapped value. /// - database: The database to read from. A value of `nil` will use the default database /// (`@Dependency(\.defaultDatabase)`). + /// - Returns: A subscription associated with the observation. + @discardableResult public func load( _ statement: some StructuredQueriesCore.Statement, database: (any DatabaseReader)? = nil - ) async throws + ) async throws -> FetchSubscription where Value == V.QueryOutput? { try await sharedReader.load( .fetch(FetchOneStatementOptionalValueRequest(statement: statement), database: database) ) + return FetchSubscription(sharedReader: sharedReader) } /// Replaces the wrapped value with data from the given query. @@ -354,10 +362,12 @@ public struct FetchOne: Sendable { /// - statement: A query associated with the wrapped value. /// - database: The database to read from. A value of `nil` will use the default database /// (`@Dependency(\.defaultDatabase)`). + /// - Returns: A subscription associated with the observation. + @discardableResult public func load( _ statement: S, database: (any DatabaseReader)? = nil - ) async throws + ) async throws -> FetchSubscription where Value: _OptionalProtocol, Value == S.From.QueryOutput?, @@ -368,6 +378,7 @@ public struct FetchOne: Sendable { try await sharedReader.load( .fetch(FetchOneStatementOptionalValueRequest(statement: statement), database: database) ) + return FetchSubscription(sharedReader: sharedReader) } /// Replaces the wrapped value with data from the given query. @@ -376,10 +387,12 @@ public struct FetchOne: Sendable { /// - statement: A query associated with the wrapped value. /// - database: The database to read from. A value of `nil` will use the default database /// (`@Dependency(\.defaultDatabase)`). + /// - Returns: A subscription associated with the observation. + @discardableResult public func load( _ statement: S, database: (any DatabaseReader)? = nil - ) async throws + ) async throws -> FetchSubscription where Value: _OptionalProtocol, S.QueryValue: QueryRepresentable, @@ -389,6 +402,7 @@ public struct FetchOne: Sendable { try await sharedReader.load( .fetch(FetchOneStatementOptionalProtocolRequest(statement: statement), database: database) ) + return FetchSubscription(sharedReader: sharedReader) } /// Replaces the wrapped value with data from the given query. @@ -397,10 +411,12 @@ public struct FetchOne: Sendable { /// - statement: A query associated with the wrapped value. /// - database: The database to read from. A value of `nil` will use the default database /// (`@Dependency(\.defaultDatabase)`). + /// - Returns: A subscription associated with the observation. + @discardableResult public func load( _ statement: some StructuredQueriesCore.Statement, database: (any DatabaseReader)? = nil - ) async throws + ) async throws -> FetchSubscription where Value: QueryRepresentable, Value: _OptionalProtocol, @@ -409,6 +425,7 @@ public struct FetchOne: Sendable { try await sharedReader.load( .fetch(FetchOneStatementOptionalProtocolRequest(statement: statement), database: database) ) + return FetchSubscription(sharedReader: sharedReader) } } @@ -679,18 +696,20 @@ extension FetchOne { /// (`@Dependency(\.defaultDatabase)`). /// - scheduler: The scheduler to observe from. By default, database observation is performed /// asynchronously on the main queue. + /// - Returns: A subscription associated with the observation. + @discardableResult public func load( _ statement: S, database: (any DatabaseReader)? = nil, scheduler: some ValueObservationScheduler & Hashable - ) async throws + ) async throws -> FetchSubscription where Value == S.From.QueryOutput, S.QueryValue == (), S.Joins == () { let statement = statement.selectStar().asSelect().limit(1) - try await load(statement, database: database, scheduler: scheduler) + return try await load(statement, database: database, scheduler: scheduler) } /// Replaces the wrapped value with data from the given query. @@ -701,11 +720,13 @@ extension FetchOne { /// (`@Dependency(\.defaultDatabase)`). /// - scheduler: The scheduler to observe from. By default, database observation is performed /// asynchronously on the main queue. + /// - Returns: A subscription associated with the observation. + @discardableResult public func load( _ statement: some StructuredQueriesCore.Statement, database: (any DatabaseReader)? = nil, scheduler: some ValueObservationScheduler & Hashable - ) async throws + ) async throws -> FetchSubscription where Value == V.QueryOutput { @@ -716,6 +737,7 @@ extension FetchOne { scheduler: scheduler ) ) + return FetchSubscription(sharedReader: sharedReader) } /// Replaces the wrapped value with data from the given query. @@ -726,11 +748,13 @@ extension FetchOne { /// (`@Dependency(\.defaultDatabase)`). /// - scheduler: The scheduler to observe from. By default, database observation is performed /// asynchronously on the main queue. + /// - Returns: A subscription associated with the observation. + @discardableResult public func load( _ statement: some StructuredQueriesCore.Statement, database: (any DatabaseReader)? = nil, scheduler: some ValueObservationScheduler & Hashable - ) async throws + ) async throws -> FetchSubscription where Value == V.QueryOutput? { @@ -741,6 +765,7 @@ extension FetchOne { scheduler: scheduler ) ) + return FetchSubscription(sharedReader: sharedReader) } /// Replaces the wrapped value with data from the given query. @@ -751,11 +776,13 @@ extension FetchOne { /// (`@Dependency(\.defaultDatabase)`). /// - scheduler: The scheduler to observe from. By default, database observation is performed /// asynchronously on the main queue. + /// - Returns: A subscription associated with the observation. + @discardableResult public func load( _ statement: S, database: (any DatabaseReader)? = nil, scheduler: some ValueObservationScheduler & Hashable - ) async throws + ) async throws -> FetchSubscription where Value: _OptionalProtocol, Value == S.From.QueryOutput?, @@ -770,6 +797,7 @@ extension FetchOne { scheduler: scheduler ) ) + return FetchSubscription(sharedReader: sharedReader) } /// Replaces the wrapped value with data from the given query. @@ -780,11 +808,13 @@ extension FetchOne { /// (`@Dependency(\.defaultDatabase)`). /// - scheduler: The scheduler to observe from. By default, database observation is performed /// asynchronously on the main queue. + /// - Returns: A subscription associated with the observation. + @discardableResult public func load( _ statement: S, database: (any DatabaseReader)? = nil, scheduler: some ValueObservationScheduler & Hashable - ) async throws + ) async throws -> FetchSubscription where Value: _OptionalProtocol, S.QueryValue: QueryRepresentable, @@ -798,6 +828,7 @@ extension FetchOne { scheduler: scheduler ) ) + return FetchSubscription(sharedReader: sharedReader) } /// Replaces the wrapped value with data from the given query. @@ -808,11 +839,13 @@ extension FetchOne { /// (`@Dependency(\.defaultDatabase)`). /// - scheduler: The scheduler to observe from. By default, database observation is performed /// asynchronously on the main queue. + /// - Returns: A subscription associated with the observation. + @discardableResult public func load( _ statement: some StructuredQueriesCore.Statement, database: (any DatabaseReader)? = nil, scheduler: some ValueObservationScheduler & Hashable - ) async throws + ) async throws -> FetchSubscription where Value: QueryRepresentable, Value: _OptionalProtocol, @@ -825,6 +858,7 @@ extension FetchOne { scheduler: scheduler ) ) + return FetchSubscription(sharedReader: sharedReader) } } @@ -1096,12 +1130,14 @@ extension FetchOne: Equatable where Value: Equatable { /// (`@Dependency(\.defaultDatabase)`). /// - animation: The animation to use for user interface changes that result from changes to /// the fetched results. + /// - Returns: A subscription associated with the observation. @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @discardableResult public func load( _ statement: S, database: (any DatabaseReader)? = nil, animation: Animation - ) async throws + ) async throws -> FetchSubscription where Value == S.From.QueryOutput, S.QueryValue == (), @@ -1118,12 +1154,14 @@ extension FetchOne: Equatable where Value: Equatable { /// (`@Dependency(\.defaultDatabase)`). /// - animation: The animation to use for user interface changes that result from changes to /// the fetched results. + /// - Returns: A subscription associated with the observation. @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @discardableResult public func load( _ statement: some StructuredQueriesCore.Statement, database: (any DatabaseReader)? = nil, animation: Animation - ) async throws + ) async throws -> FetchSubscription where Value == V.QueryOutput { @@ -1138,12 +1176,14 @@ extension FetchOne: Equatable where Value: Equatable { /// (`@Dependency(\.defaultDatabase)`). /// - animation: The animation to use for user interface changes that result from changes to /// the fetched results. + /// - Returns: A subscription associated with the observation. @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @discardableResult public func load( _ statement: some StructuredQueriesCore.Statement, database: (any DatabaseReader)? = nil, animation: Animation - ) async throws + ) async throws -> FetchSubscription where Value == V.QueryOutput? { @@ -1158,12 +1198,14 @@ extension FetchOne: Equatable where Value: Equatable { /// (`@Dependency(\.defaultDatabase)`). /// - animation: The animation to use for user interface changes that result from changes to /// the fetched results. + /// - Returns: A subscription associated with the observation. @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @discardableResult public func load( _ statement: S, database: (any DatabaseReader)? = nil, animation: Animation - ) async throws + ) async throws -> FetchSubscription where Value: _OptionalProtocol, Value == S.From.QueryOutput?, @@ -1181,12 +1223,14 @@ extension FetchOne: Equatable where Value: Equatable { /// (`@Dependency(\.defaultDatabase)`). /// - animation: The animation to use for user interface changes that result from changes to /// the fetched results. + /// - Returns: A subscription associated with the observation. @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @discardableResult public func load( _ statement: S, database: (any DatabaseReader)? = nil, animation: Animation - ) async throws + ) async throws -> FetchSubscription where Value: _OptionalProtocol, S.QueryValue: QueryRepresentable, @@ -1204,12 +1248,14 @@ extension FetchOne: Equatable where Value: Equatable { /// (`@Dependency(\.defaultDatabase)`). /// - animation: The animation to use for user interface changes that result from changes to /// the fetched results. + /// - Returns: A subscription associated with the observation. @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @discardableResult public func load( _ statement: some StructuredQueriesCore.Statement, database: (any DatabaseReader)? = nil, animation: Animation - ) async throws + ) async throws -> FetchSubscription where Value: QueryRepresentable, Value: _OptionalProtocol, diff --git a/Sources/SQLiteData/FetchSubscription.swift b/Sources/SQLiteData/FetchSubscription.swift new file mode 100644 index 00000000..10565f14 --- /dev/null +++ b/Sources/SQLiteData/FetchSubscription.swift @@ -0,0 +1,46 @@ +import Perception +import Sharing + +/// A subscription associated with `@FetchAll`, `@FetchOne`, and `@Fetch` observation. +/// +/// This value can be useful in associating the lifetime of observing a query to the lifetime of a +/// SwiftUI view _via_ the `task` view modifier. For example, loading a query in a view's `task` +/// will automatically cancel the observation when drilling down into a child view, and restart +/// observation when popping back to the view: +/// +/// ```swift +/// .task { +/// try? await $reminders.load(Reminder.all).task +/// } +/// ``` +public struct FetchSubscription: Sendable { + let cancellable = LockIsolated?>(nil) + let onCancel: @Sendable () -> Void + + init(sharedReader: SharedReader) { + onCancel = { sharedReader.projectedValue = SharedReader(value: sharedReader.wrappedValue) } + } + + /// An async handle to the given fetch observation. + /// + /// This handle will suspend until the current task is cancelled, at which point it will terminate + /// the observation of the associated ``FetchAll``, ``FetchOne``, or ``Fetch``. + public var task: Void { + get async throws { + let task = Task { + try await withTaskCancellationHandler { + try await Task.never() + } onCancel: { + onCancel() + } + } + cancellable.withValue { $0 = task } + try await task.cancellableValue + } + } + + /// Cancels the database observation of the associated ``FetchAll``, ``FetchOne``, or ``Fetch``. + public func cancel() { + cancellable.value?.cancel() + } +} diff --git a/Tests/SQLiteDataTests/FetchSubscriptionTests.swift b/Tests/SQLiteDataTests/FetchSubscriptionTests.swift new file mode 100644 index 00000000..e9599535 --- /dev/null +++ b/Tests/SQLiteDataTests/FetchSubscriptionTests.swift @@ -0,0 +1,105 @@ +import DependenciesTestSupport +import Foundation +import SQLiteData +import Testing + +@Suite(.dependency(\.defaultDatabase, try .database())) struct FetchSubscriptionTests { + @Dependency(\.defaultDatabase) var database + + @Test func stopSubscriptionWhenTaskCancelled() async throws { + @FetchAll var records: [Record] + #expect(records.count == 0) + + try await database.write { db in + try Record.insert { Record.Draft() }.execute(db) + } + try await $records.load() + #expect(records.count == 1) + + let task = Task { [$records] in + try? await $records.load(Record.all).task + } + task.cancel() + await task.value + try await database.write { db in + try Record.insert { Record.Draft() }.execute(db) + } + try await $records.load() + #expect(records.count == 1) + } + + @Test func completeWhenTaskExplicitlyCancelled() async throws { + @FetchAll var records: [Record] + #expect(records.count == 0) + let didComplete = LockIsolated(false) + + try await database.write { db in + try Record.insert { Record.Draft() }.execute(db) + } + try await $records.load() + #expect(records.count == 1) + + let subscription = try await $records.load(Record.all) + + let task = Task { + try? await subscription.task + didComplete.withValue { $0 = true } + } + + try await Task.sleep(for: .seconds(1)) + + subscription.cancel() + await task.value + #expect(didComplete.value) + } + + @Test func cancellingOneFetchDoesNotCancelAnother() async throws { + @FetchAll var records1: [Record] + #expect(records1.count == 0) + let task1 = Task { [$records1] in + try? await $records1.load(Record.all).task + } + + @FetchAll var records2: [Record] + #expect(records2.count == 0) + await withUnsafeContinuation { continuation in + Task { [$records2] in + let subscription = try await $records2.load(Record.all) + continuation.resume() + try await subscription.task + } + } + + task1.cancel() + await task1.value + + try await database.write { db in + try Record.insert { Record.Draft() }.execute(db) + } + try await $records1.load() + try await $records2.load() + #expect(records1.count == 0) + #expect(records2.count == 1) + } +} + +@Table +private struct Record: Equatable { + let id: Int +} +extension DatabaseWriter where Self == DatabaseQueue { + fileprivate static func database() throws -> DatabaseQueue { + let database = try DatabaseQueue() + try database.write { db in + try #sql( + """ + CREATE TABLE "records" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT + ) + """ + ) + .execute(db) + } + return database + } +}