diff --git a/Examples/CaseStudies/Regression Coverage/ChildIdentityReset.swift b/Examples/CaseStudies/Regression Coverage/ChildIdentityReset.swift new file mode 100644 index 00000000..be7dc421 --- /dev/null +++ b/Examples/CaseStudies/Regression Coverage/ChildIdentityReset.swift @@ -0,0 +1,105 @@ +import SQLiteData +import SwiftUI + +struct ChildIdentityResetCaseStudy: SwiftUICaseStudy { + let readMe = """ + This demonstrates that the `@Fetch*` tools behave like `@State` when a view's identity \ + changes: their state survives re-renders of the parent view, but is discarded and rebuilt \ + when the parent changes the child view's identity with the `id` view modifier. + + Toggle "Favorites only" in the child view to load a filtered query, then tap "Reset child \ + identity". The child view is rebuilt from scratch: the toggle returns to its default and \ + the list returns to the unfiltered query, keeping the UI and data consistent. + """ + let caseStudyTitle = "Resetting child identity" + + @State private var resetCount = 0 + + var body: some View { + List { + Section { + Button("Reset child identity: \(resetCount)") { + resetCount += 1 + } + } + FactsListView() + .id(resetCount) + } + } +} + +private struct FactsListView: View { + @State private var isFavoritesOnly = false + @FetchAll(Fact.all) private var facts + + var body: some View { + Section { + Toggle("Favorites only", isOn: $isFavoritesOnly) + ForEach(facts) { fact in + HStack { + Text(fact.body) + Spacer() + if fact.isFavorite { + Image(systemName: "star.fill") + .foregroundStyle(.yellow) + } + } + } + } + .task(id: isFavoritesOnly) { + await withErrorReporting { + if isFavoritesOnly { + try await $facts.load(Fact.where(\.isFavorite)).task + } else { + try await $facts.load(Fact.all).task + } + } + } + } +} + +@Table +nonisolated private struct Fact: Identifiable { + let id: Int + var body: String + var isFavorite = false +} + +extension DatabaseWriter where Self == DatabaseQueue { + static var childIdentityResetDatabase: Self { + let databaseQueue = try! DatabaseQueue() + var migrator = DatabaseMigrator() + migrator.registerMigration("Create 'facts' table") { db in + try #sql( + """ + CREATE TABLE "facts" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "body" TEXT NOT NULL, + "isFavorite" INTEGER NOT NULL DEFAULT 0 + ) STRICT + """ + ) + .execute(db) + try Fact.insert { + Fact.Draft(body: "SQLite was first released in the year 2000.", isFavorite: true) + Fact.Draft(body: "SQLite is the most widely deployed database in the world.") + Fact.Draft(body: "SQLite is a C library, not a client-server database.", isFavorite: true) + Fact.Draft(body: "SQLite databases are a single file on disk.") + } + .execute(db) + } + try! migrator.migrate(databaseQueue) + return databaseQueue + } +} + +#Preview { + let _ = prepareDependencies { + $0.defaultDatabase = .childIdentityResetDatabase + } + NavigationStack { + CaseStudyView { + ChildIdentityResetCaseStudy() + } + } +} diff --git a/Examples/CaseStudies/Regression Coverage/ParentDrivenQuery.swift b/Examples/CaseStudies/Regression Coverage/ParentDrivenQuery.swift new file mode 100644 index 00000000..b0a4d337 --- /dev/null +++ b/Examples/CaseStudies/Regression Coverage/ParentDrivenQuery.swift @@ -0,0 +1,103 @@ +import SQLiteData +import SwiftUI + +struct ParentDrivenQueryCaseStudy: SwiftUICaseStudy { + let readMe = """ + This demonstrates how to drive a child view's query from parent state by constructing the \ + `@FetchAll` in the child's initializer, analogous to constructing a SwiftData `@Query` with a \ + dynamic predicate in a view's initializer. + + Toggling "Favorites only" re-initializes the child view with a different query, and the child \ + should immediately display the results of the new query. Tapping "Re-render parent" \ + re-initializes the child with the same query, which should have no effect. + """ + let caseStudyTitle = "Parent-driven queries" + + @State private var isFavoritesOnly = false + @State private var rerenderCount = 0 + + var body: some View { + List { + Section { + Toggle("Favorites only", isOn: $isFavoritesOnly) + Button("Re-render parent: \(rerenderCount)") { + rerenderCount += 1 + } + } + FactsListView(isFavoritesOnly: isFavoritesOnly) + } + } +} + +private struct FactsListView: View { + @FetchAll private var facts: [Fact] + + init(isFavoritesOnly: Bool) { + if isFavoritesOnly { + _facts = FetchAll(Fact.where(\.isFavorite)) + } else { + _facts = FetchAll(Fact.all) + } + } + + var body: some View { + Section { + ForEach(facts) { fact in + HStack { + Text(fact.body) + Spacer() + if fact.isFavorite { + Image(systemName: "star.fill") + .foregroundStyle(.yellow) + } + } + } + } + } +} + +@Table +nonisolated private struct Fact: Identifiable { + let id: Int + var body: String + var isFavorite = false +} + +extension DatabaseWriter where Self == DatabaseQueue { + static var parentDrivenQueryDatabase: Self { + let databaseQueue = try! DatabaseQueue() + var migrator = DatabaseMigrator() + migrator.registerMigration("Create 'facts' table") { db in + try #sql( + """ + CREATE TABLE "facts" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "body" TEXT NOT NULL, + "isFavorite" INTEGER NOT NULL DEFAULT 0 + ) STRICT + """ + ) + .execute(db) + try Fact.insert { + Fact.Draft(body: "SQLite was first released in the year 2000.", isFavorite: true) + Fact.Draft(body: "SQLite is the most widely deployed database in the world.") + Fact.Draft(body: "SQLite is a C library, not a client-server database.", isFavorite: true) + Fact.Draft(body: "SQLite databases are a single file on disk.") + } + .execute(db) + } + try! migrator.migrate(databaseQueue) + return databaseQueue + } +} + +#Preview { + let _ = prepareDependencies { + $0.defaultDatabase = .parentDrivenQueryDatabase + } + NavigationStack { + CaseStudyView { + ParentDrivenQueryCaseStudy() + } + } +} diff --git a/Examples/CaseStudies/Regression Coverage/ParentRerenderAnimations.swift b/Examples/CaseStudies/Regression Coverage/ParentRerenderAnimations.swift new file mode 100644 index 00000000..36301722 --- /dev/null +++ b/Examples/CaseStudies/Regression Coverage/ParentRerenderAnimations.swift @@ -0,0 +1,94 @@ +import SQLiteData +import SwiftUI + +struct ParentRerenderAnimationsCaseStudy: SwiftUICaseStudy { + let readMe = """ + This demonstrates that animations provided to the `@Fetch*` tools continue to work after a \ + parent view re-renders. + + The list below is loaded in the child view's `task` with an `animation` parameter, and so \ + tapping "Add fact" animates the new fact into the list. Tapping "Re-render parent" changes \ + `@State` in the parent view, which causes the child view (and its `@FetchAll`) to be \ + re-initialized. Adding a fact should continue to animate afterwards. + """ + let caseStudyTitle = "Animations with re-rendered parent" + + @State private var rerenderCount = 0 + @Dependency(\.defaultDatabase) var database + + var body: some View { + List { + Section { + Button("Re-render parent: \(rerenderCount)") { + rerenderCount += 1 + } + Button("Add fact") { + withErrorReporting { + try database.write { db in + try Fact.insert { + Fact.Draft(body: Date.now.formatted(date: .omitted, time: .standard)) + } + .execute(db) + } + } + } + } + FactsListView() + } + } +} + +private struct FactsListView: View { + @FetchAll(Fact.order { $0.id.desc() }) + private var facts + + var body: some View { + Section { + ForEach(facts) { fact in + Text(fact.body) + } + } + .task { + await withErrorReporting { + try await $facts.load(Fact.order { $0.id.desc() }, animation: .default).task + } + } + } +} + +@Table +nonisolated private struct Fact: Identifiable { + let id: Int + var body: String +} + +extension DatabaseWriter where Self == DatabaseQueue { + static var parentRerenderAnimationsDatabase: Self { + let databaseQueue = try! DatabaseQueue() + var migrator = DatabaseMigrator() + migrator.registerMigration("Create 'facts' table") { db in + try #sql( + """ + CREATE TABLE "facts" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "body" TEXT NOT NULL + ) STRICT + """ + ) + .execute(db) + } + try! migrator.migrate(databaseQueue) + return databaseQueue + } +} + +#Preview { + let _ = prepareDependencies { + $0.defaultDatabase = .parentRerenderAnimationsDatabase + } + NavigationStack { + CaseStudyView { + ParentRerenderAnimationsCaseStudy() + } + } +} diff --git a/Examples/CaseStudies/Regression Coverage/ParentRerenderCancellation.swift b/Examples/CaseStudies/Regression Coverage/ParentRerenderCancellation.swift new file mode 100644 index 00000000..8bacecac --- /dev/null +++ b/Examples/CaseStudies/Regression Coverage/ParentRerenderCancellation.swift @@ -0,0 +1,100 @@ +import SQLiteData +import SwiftUI + +struct ParentRerenderCancellationCaseStudy: SwiftUICaseStudy { + let readMe = """ + This demonstrates that a cancelled observation survives a parent view re-render. + + The child view below observes a list of facts that grows every second, and toggling "Live \ + updates" off cancels the observation using the subscription's `task`, freezing the list. \ + Tapping "Re-render parent" changes `@State` in the parent view, which causes the child view \ + (and its `@FetchAll`) to be re-initialized. The list should remain frozen, and should not \ + silently resume live updates while the toggle remains off. + """ + let caseStudyTitle = "Cancellation with re-rendered parent" + + @State private var rerenderCount = 0 + + var body: some View { + List { + Section { + Button("Re-render parent: \(rerenderCount)") { + rerenderCount += 1 + } + } + FactsListView() + } + } +} + +private struct FactsListView: View { + @State private var isLive = true + @FetchAll(Fact.order { $0.id.desc() }) + private var facts + @Dependency(\.defaultDatabase) var database + + var body: some View { + Section { + Toggle("Live updates", isOn: $isLive) + ForEach(facts) { fact in + Text(fact.body) + } + } + .task(id: isLive) { + guard isLive else { return } + await withErrorReporting { + try await $facts.load(Fact.order { $0.id.desc() }).task + } + } + .task { + do { + while true { + try await Task.sleep(for: .seconds(1)) + try await database.write { db in + try Fact.insert { + Fact.Draft(body: Date.now.formatted(date: .omitted, time: .standard)) + } + .execute(db) + } + } + } catch {} + } + } +} + +@Table +nonisolated private struct Fact: Identifiable { + let id: Int + var body: String +} + +extension DatabaseWriter where Self == DatabaseQueue { + static var parentRerenderCancellationDatabase: Self { + let databaseQueue = try! DatabaseQueue() + var migrator = DatabaseMigrator() + migrator.registerMigration("Create 'facts' table") { db in + try #sql( + """ + CREATE TABLE "facts" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "body" TEXT NOT NULL + ) STRICT + """ + ) + .execute(db) + } + try! migrator.migrate(databaseQueue) + return databaseQueue + } +} + +#Preview { + let _ = prepareDependencies { + $0.defaultDatabase = .parentRerenderCancellationDatabase + } + NavigationStack { + CaseStudyView { + ParentRerenderCancellationCaseStudy() + } + } +} diff --git a/Examples/CaseStudies/Regression Coverage/ParentRerenderDynamicQuery.swift b/Examples/CaseStudies/Regression Coverage/ParentRerenderDynamicQuery.swift new file mode 100644 index 00000000..3a2af0a9 --- /dev/null +++ b/Examples/CaseStudies/Regression Coverage/ParentRerenderDynamicQuery.swift @@ -0,0 +1,104 @@ +import SQLiteData +import SwiftUI + +struct ParentRerenderDynamicQueryCaseStudy: SwiftUICaseStudy { + let readMe = """ + This demonstrates that a dynamically loaded query survives a parent view re-render. + + The child view below starts with a query for all facts, and toggling "Favorites only" loads \ + a filtered query. Tapping "Re-render parent" changes `@State` in the parent view, which \ + causes the child view (and its `@FetchAll`) to be re-initialized. The filtered facts should \ + remain on screen, and should not silently revert to the unfiltered query while the toggle \ + remains on. + """ + let caseStudyTitle = "Dynamic queries with re-rendered parent" + + @State private var rerenderCount = 0 + + var body: some View { + List { + Section { + Button("Re-render parent: \(rerenderCount)") { + rerenderCount += 1 + } + } + FactsListView() + } + } +} + +private struct FactsListView: View { + @State private var isFavoritesOnly = false + @FetchAll(Fact.all) private var facts + + var body: some View { + Section { + Toggle("Favorites only", isOn: $isFavoritesOnly) + ForEach(facts) { fact in + HStack { + Text(fact.body) + Spacer() + if fact.isFavorite { + Image(systemName: "star.fill") + .foregroundStyle(.yellow) + } + } + } + } + .task(id: isFavoritesOnly) { + await withErrorReporting { + if isFavoritesOnly { + try await $facts.load(Fact.where(\.isFavorite)).task + } else { + try await $facts.load(Fact.all).task + } + } + } + } +} + +@Table +nonisolated private struct Fact: Identifiable { + let id: Int + var body: String + var isFavorite = false +} + +extension DatabaseWriter where Self == DatabaseQueue { + static var parentRerenderDynamicQueryDatabase: Self { + let databaseQueue = try! DatabaseQueue() + var migrator = DatabaseMigrator() + migrator.registerMigration("Create 'facts' table") { db in + try #sql( + """ + CREATE TABLE "facts" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "body" TEXT NOT NULL, + "isFavorite" INTEGER NOT NULL DEFAULT 0 + ) STRICT + """ + ) + .execute(db) + try Fact.insert { + Fact.Draft(body: "SQLite was first released in the year 2000.", isFavorite: true) + Fact.Draft(body: "SQLite is the most widely deployed database in the world.") + Fact.Draft(body: "SQLite is a C library, not a client-server database.", isFavorite: true) + Fact.Draft(body: "SQLite databases are a single file on disk.") + } + .execute(db) + } + try! migrator.migrate(databaseQueue) + return databaseQueue + } +} + +#Preview { + let _ = prepareDependencies { + $0.defaultDatabase = .parentRerenderDynamicQueryDatabase + } + NavigationStack { + CaseStudyView { + ParentRerenderDynamicQueryCaseStudy() + } + } +} diff --git a/Examples/CaseStudies/Regression Coverage/ParentRerenderLoadError.swift b/Examples/CaseStudies/Regression Coverage/ParentRerenderLoadError.swift new file mode 100644 index 00000000..b156819a --- /dev/null +++ b/Examples/CaseStudies/Regression Coverage/ParentRerenderLoadError.swift @@ -0,0 +1,75 @@ +import GRDB +import SQLiteData +import SwiftUI + +struct ParentRerenderLoadErrorCaseStudy: SwiftUICaseStudy { + let readMe = """ + This demonstrates that a load error survives a parent view re-render. + + The child view below loads a query that always fails, and so it renders the `loadError` of \ + its `@Fetch` property. Tapping the stepper changes `@State` in the parent view, which causes \ + the child view (and its `@Fetch`) to be re-initialized. The error should remain on screen, \ + and should not be silently discarded, which would make the view appear healthy even though \ + its query failed. + """ + let caseStudyTitle = "Load errors with re-rendered parent" + + @State private var count = 0 + + var body: some View { + List { + Section { + Stepper("Parent state: \(count)", value: $count) + } + FactsView(count: count) + } + } +} + +private struct FactsView: View { + let count: Int + @Fetch private var facts = Facts.Value() + + var body: some View { + Section("Facts (parent state: \(count))") { + if let loadError = $facts.loadError { + Label(loadError.localizedDescription, systemImage: "exclamationmark.triangle") + .foregroundStyle(.red) + } else { + Text("Facts: \(facts.count)") + } + } + .task { + _ = try? await $facts.load(Facts()) + } + } + + private struct Facts: FetchKeyRequest { + struct Value { + var count = 0 + } + func fetch(_ db: Database) throws -> Value { + struct QueryFailure: LocalizedError { + var errorDescription: String? { "Something went wrong." } + } + throw QueryFailure() + } + } +} + +extension DatabaseWriter where Self == DatabaseQueue { + static var parentRerenderLoadErrorDatabase: Self { + try! DatabaseQueue() + } +} + +#Preview { + let _ = prepareDependencies { + $0.defaultDatabase = .parentRerenderLoadErrorDatabase + } + NavigationStack { + CaseStudyView { + ParentRerenderLoadErrorCaseStudy() + } + } +} diff --git a/Examples/CaseStudies/Regression Coverage/ParentRerenderLoadedData.swift b/Examples/CaseStudies/Regression Coverage/ParentRerenderLoadedData.swift new file mode 100644 index 00000000..1c689c0f --- /dev/null +++ b/Examples/CaseStudies/Regression Coverage/ParentRerenderLoadedData.swift @@ -0,0 +1,100 @@ +import GRDB +import SQLiteData +import SwiftUI + +struct ParentRerenderLoadedDataCaseStudy: SwiftUICaseStudy { + let readMe = """ + This demonstrates that data loaded by the `@Fetch*` tools survives a parent view re-render. + + The child view below has a `@Fetch` property that begins with an empty default value and is \ + loaded in the view's `task`. Tapping the stepper changes `@State` in the parent view, which \ + causes the child view (and its `@Fetch`) to be re-initialized. The facts should remain on \ + screen, and should not revert to the empty default value. The same is true when any other \ + dynamic property in the parent changes, such as `@Environment`. + """ + let caseStudyTitle = "Loaded data with re-rendered parent" + + @State private var count = 0 + + var body: some View { + List { + Section { + Stepper("Parent state: \(count)", value: $count) + } + FactsListView(count: count) + } + } +} + +private struct FactsListView: View { + let count: Int + @Fetch private var facts = Facts.Value() + + var body: some View { + Section("Facts (parent state: \(count))") { + if facts.facts.isEmpty { + Text("No facts loaded") + } + ForEach(facts.facts) { fact in + Text(fact.body) + } + } + .task { + await withErrorReporting { + try await $facts.load(Facts()).task + } + } + } + + private struct Facts: FetchKeyRequest { + struct Value { + var facts: [Fact] = [] + } + func fetch(_ db: Database) throws -> Value { + try Value(facts: Fact.order { $0.id.desc() }.fetchAll(db)) + } + } +} + +@Table +nonisolated private struct Fact: Identifiable { + let id: Int + var body: String +} + +extension DatabaseWriter where Self == DatabaseQueue { + static var parentRerenderLoadedDataDatabase: Self { + let databaseQueue = try! DatabaseQueue() + var migrator = DatabaseMigrator() + migrator.registerMigration("Create 'facts' table") { db in + try #sql( + """ + CREATE TABLE "facts" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "body" TEXT NOT NULL + ) STRICT + """ + ) + .execute(db) + try Fact.insert { + Fact.Draft(body: "SQLite was first released in the year 2000.") + Fact.Draft(body: "SQLite is the most widely deployed database in the world.") + Fact.Draft(body: "SQLite is a C library, not a client-server database.") + } + .execute(db) + } + try! migrator.migrate(databaseQueue) + return databaseQueue + } +} + +#Preview { + let _ = prepareDependencies { + $0.defaultDatabase = .parentRerenderLoadedDataDatabase + } + NavigationStack { + CaseStudyView { + ParentRerenderLoadedDataCaseStudy() + } + } +} diff --git a/Sources/SQLiteData/Fetch.swift b/Sources/SQLiteData/Fetch.swift index ea645873..ee36aa4a 100644 --- a/Sources/SQLiteData/Fetch.swift +++ b/Sources/SQLiteData/Fetch.swift @@ -1,7 +1,6 @@ public import GRDB public import Sharing import StructuredQueriesCore -public import Sharing #if canImport(Combine) public import Combine @@ -22,11 +21,31 @@ public import Sharing @dynamicMemberLookup @propertyWrapper public struct Fetch: Sendable { - /// The underlying shared reader powering the property wrapper. - /// - /// Shared readers come from the [Sharing](https://github.com/pointfreeco/swift-sharing) package, - /// a general solution to observing and persisting changes to external data sources. - public var sharedReader: SharedReader + #if canImport(SwiftUI) + /// The underlying shared reader powering the property wrapper. + /// + /// Shared readers come from the [Sharing](https://github.com/pointfreeco/swift-sharing) + /// package, a general solution to observing and persisting changes to external data sources. + public var sharedReader: SharedReader { + @storageRestrictions(initializes: box, state) + init(initialValue) { + let box = FetchBox(sharedReader: initialValue) + self.box = box + state = SwiftUI.State(wrappedValue: box) + } + get { state.wrappedValue.sharedReader } + } + + private let box: FetchBox + private let state: SwiftUI.State> + private let generation = SwiftUI.State(wrappedValue: 0) + #else + /// The underlying shared reader powering the property wrapper. + /// + /// Shared readers come from the [Sharing](https://github.com/pointfreeco/swift-sharing) + /// package, a general solution to observing and persisting changes to external data sources. + public let sharedReader: SharedReader + #endif /// Data associated with the underlying query. public var wrappedValue: Value { @@ -93,6 +112,7 @@ public struct Fetch: Sendable { database: (any DatabaseReader)? = nil ) { sharedReader = SharedReader(wrappedValue: wrappedValue, .fetch(request, database: database)) + setFetchKeyID(for: request, database: database, scheduler: nil) } /// Replaces the wrapped value with data from the given request. @@ -110,6 +130,19 @@ public struct Fetch: Sendable { try await sharedReader.load(.fetch(request, database: database)) return FetchSubscription(sharedReader: sharedReader) } + + #if !canImport(SwiftUI) + @_transparent + #endif + private func setFetchKeyID( + for request: some FetchKeyRequest, + database: (any DatabaseReader)?, + scheduler: (any ValueObservationScheduler & Hashable)? + ) { + #if canImport(SwiftUI) + box.fetchKeyID = FetchKey(request: request, database: database, scheduler: scheduler).id + #endif + } } extension Fetch { @@ -132,6 +165,7 @@ extension Fetch { wrappedValue: wrappedValue, .fetch(request, database: database, scheduler: scheduler) ) + setFetchKeyID(for: request, database: database, scheduler: scheduler) } /// Replaces the wrapped value with data from the given request. @@ -169,7 +203,11 @@ extension Fetch: Equatable where Value: Equatable { #if canImport(SwiftUI) extension Fetch: DynamicProperty { public func update() { - sharedReader.update() + let persisted = state.wrappedValue + if persisted !== box { + persisted.update(from: box) + } + persisted.subscribe(generation: generation) } /// Initializes this property with a request associated with the wrapped value. @@ -192,6 +230,7 @@ extension Fetch: Equatable where Value: Equatable { wrappedValue: wrappedValue, .fetch(request, database: database, animation: animation) ) + setFetchKeyID(for: request, database: database, scheduler: .animation(animation)) } /// Replaces the wrapped value with data from the given request. diff --git a/Sources/SQLiteData/FetchAll.swift b/Sources/SQLiteData/FetchAll.swift index ec118cdf..a75863a6 100644 --- a/Sources/SQLiteData/FetchAll.swift +++ b/Sources/SQLiteData/FetchAll.swift @@ -1,7 +1,6 @@ public import GRDB public import Sharing public import StructuredQueriesCore -public import Sharing #if canImport(Combine) public import Combine @@ -22,11 +21,31 @@ public import Sharing @dynamicMemberLookup @propertyWrapper public struct FetchAll: Sendable { - /// The underlying shared reader powering the property wrapper. - /// - /// Shared readers come from the [Sharing](https://github.com/pointfreeco/swift-sharing) package, - /// a general solution to observing and persisting changes to external data sources. - public var sharedReader: SharedReader<[Element]> = SharedReader(value: []) + #if canImport(SwiftUI) + /// The underlying shared reader powering the property wrapper. + /// + /// Shared readers come from the [Sharing](https://github.com/pointfreeco/swift-sharing) + /// package, a general solution to observing and persisting changes to external data sources. + public var sharedReader: SharedReader<[Element]> { + @storageRestrictions(initializes: box, state) + init(initialValue) { + let box = FetchBox(sharedReader: initialValue) + self.box = box + state = SwiftUI.State(wrappedValue: box) + } + get { state.wrappedValue.sharedReader } + } + + private let box: FetchBox<[Element]> + private let state: SwiftUI.State> + private let generation = SwiftUI.State(wrappedValue: 0) + #else + /// The underlying shared reader powering the property wrapper. + /// + /// Shared readers come from the [Sharing](https://github.com/pointfreeco/swift-sharing) + /// package, a general solution to observing and persisting changes to external data sources. + public let sharedReader: SharedReader<[Element]> + #endif /// A collection of data associated with the underlying query. public var wrappedValue: [Element] { @@ -139,13 +158,12 @@ public struct FetchAll: Sendable { Element == V.QueryOutput, V.QueryOutput: Sendable { + let request = FetchAllStatementValueRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch( - FetchAllStatementValueRequest(statement: statement), - database: database - ) + .fetch(request, database: database) ) + setFetchKeyID(for: request, database: database, scheduler: nil) } /// Initializes this property with a query associated with the wrapped value. @@ -164,13 +182,12 @@ public struct FetchAll: Sendable { Element: QueryRepresentable, Element == S.QueryValue.QueryOutput { + let request = FetchAllStatementValueRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch( - FetchAllStatementValueRequest(statement: statement), - database: database - ) + .fetch(request, database: database) ) + setFetchKeyID(for: request, database: database, scheduler: nil) } /// Replaces the wrapped value with data from the given query. @@ -219,10 +236,29 @@ public struct FetchAll: Sendable { ) return FetchSubscription(sharedReader: sharedReader) } + + #if !canImport(SwiftUI) + @_transparent + #endif + private func setFetchKeyID( + for request: some FetchKeyRequest, + database: (any DatabaseReader)?, + scheduler: (any ValueObservationScheduler & Hashable)? + ) { + #if canImport(SwiftUI) + box.fetchKeyID = FetchKey(request: request, database: database, scheduler: scheduler).id + #endif + } } extension FetchAll { - @available(*, deprecated, message: "Remove unused parameters: 'database', 'scheduler'.") + @available( + *, + deprecated, + message: """ + '@Selection' type requires a query to be fetched; provide one or remove unused parameters: 'database', 'scheduler'. + """ + ) public init( wrappedValue: [Element] = [], database: (any DatabaseReader)? = nil, @@ -294,14 +330,12 @@ extension FetchAll { Element == V.QueryOutput, V.QueryOutput: Sendable { + let request = FetchAllStatementValueRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch( - FetchAllStatementValueRequest(statement: statement), - database: database, - scheduler: scheduler - ) + .fetch(request, database: database, scheduler: scheduler) ) + setFetchKeyID(for: request, database: database, scheduler: scheduler) } /// Initializes this property with a query associated with the wrapped value. @@ -323,14 +357,12 @@ extension FetchAll { Element: QueryRepresentable, Element == S.QueryValue.QueryOutput { + let request = FetchAllStatementValueRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch( - FetchAllStatementValueRequest(statement: statement), - database: database, - scheduler: scheduler - ) + .fetch(request, database: database, scheduler: scheduler) ) + setFetchKeyID(for: request, database: database, scheduler: scheduler) } /// Replaces the wrapped value with data from the given query. @@ -403,10 +435,20 @@ extension FetchAll: Equatable where Element: Equatable { #if canImport(SwiftUI) extension FetchAll: DynamicProperty { public func update() { - sharedReader.update() + let persisted = state.wrappedValue + if persisted !== box { + persisted.update(from: box) + } + persisted.subscribe(generation: generation) } - @available(*, deprecated, message: "Remove unused parameters: 'database', 'animation'.") + @available( + *, + deprecated, + message: """ + '@Selection' type requires a query to be fetched; provide one or remove unused parameters: 'database', 'scheduler'. + """ + ) public init( wrappedValue: [Element] = [], database: (any DatabaseReader)? = nil, diff --git a/Sources/SQLiteData/FetchOne.swift b/Sources/SQLiteData/FetchOne.swift index 4e6b799d..9ec6400e 100644 --- a/Sources/SQLiteData/FetchOne.swift +++ b/Sources/SQLiteData/FetchOne.swift @@ -21,11 +21,31 @@ public import StructuredQueriesCore @dynamicMemberLookup @propertyWrapper public struct FetchOne: Sendable { - /// The underlying shared reader powering the property wrapper. - /// - /// Shared readers come from the [Sharing](https://github.com/pointfreeco/swift-sharing) package, - /// a general solution to observing and persisting changes to external data sources. - public var sharedReader: SharedReader + #if canImport(SwiftUI) + /// The underlying shared reader powering the property wrapper. + /// + /// Shared readers come from the [Sharing](https://github.com/pointfreeco/swift-sharing) + /// package, a general solution to observing and persisting changes to external data sources. + public var sharedReader: SharedReader { + @storageRestrictions(initializes: box, state) + init(initialValue) { + let box = FetchBox(sharedReader: initialValue) + self.box = box + state = SwiftUI.State(wrappedValue: box) + } + get { state.wrappedValue.sharedReader } + } + + private let box: FetchBox + private let state: SwiftUI.State> + private let generation = SwiftUI.State(wrappedValue: 0) + #else + /// The underlying shared reader powering the property wrapper. + /// + /// Shared readers come from the [Sharing](https://github.com/pointfreeco/swift-sharing) + /// package, a general solution to observing and persisting changes to external data sources. + public let sharedReader: SharedReader + #endif /// A value associated with the underlying query. public var wrappedValue: Value { @@ -106,10 +126,12 @@ public struct FetchOne: Sendable { Value: StructuredQueriesCore.Table & QueryRepresentable, Value.QueryOutput == Value { let statement = Value.all.selectStar().asSelect().limit(1) + let request = FetchOneStatementValueRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch(FetchOneStatementValueRequest(statement: statement), database: database) + .fetch(request, database: database) ) + setFetchKeyID(for: request, database: database, scheduler: nil) } /// Initializes this property with a query that fetches the first row from a table. @@ -128,10 +150,12 @@ public struct FetchOne: Sendable { Value.QueryOutput == Value { let statement = Value.all.selectStar().asSelect().limit(1) + let request = FetchOneStatementOptionalProtocolRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch(FetchOneStatementOptionalProtocolRequest(statement: statement), database: database) + .fetch(request, database: database) ) + setFetchKeyID(for: request, database: database, scheduler: nil) } /// Initializes this property with a query associated with the wrapped value. @@ -170,10 +194,12 @@ public struct FetchOne: Sendable { where Value == V.QueryOutput { + let request = FetchOneStatementValueRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch(FetchOneStatementValueRequest(statement: statement), database: database) + .fetch(request, database: database) ) + setFetchKeyID(for: request, database: database, scheduler: nil) } /// Initializes this property with a query associated with the wrapped value. @@ -191,10 +217,12 @@ public struct FetchOne: Sendable { where Value == V.QueryOutput? { + let request = FetchOneStatementOptionalValueRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch(FetchOneStatementOptionalValueRequest(statement: statement), database: database) + .fetch(request, database: database) ) + setFetchKeyID(for: request, database: database, scheduler: nil) } /// Initializes this property with a query associated with the wrapped value. @@ -213,10 +241,12 @@ public struct FetchOne: Sendable { Value: QueryRepresentable, Value == S.QueryValue.QueryOutput { + let request = FetchOneStatementValueRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch(FetchOneStatementValueRequest(statement: statement), database: database) + .fetch(request, database: database) ) + setFetchKeyID(for: request, database: database, scheduler: nil) } /// Initializes this property with a query associated with an optional value. @@ -238,10 +268,12 @@ public struct FetchOne: Sendable { S.Joins == () { let statement = statement.selectStar().asSelect().limit(1) + let request = FetchOneStatementOptionalValueRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch(FetchOneStatementOptionalValueRequest(statement: statement), database: database) + .fetch(request, database: database) ) + setFetchKeyID(for: request, database: database, scheduler: nil) } /// Initializes this property with a query associated with an optional value. @@ -262,13 +294,12 @@ public struct FetchOne: Sendable { S.QueryValue: StructuredQueriesCore._OptionalProtocol, Value == S.QueryValue.QueryOutput { + let request = FetchOneStatementOptionalProtocolRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch( - FetchOneStatementOptionalProtocolRequest(statement: statement), - database: database - ) + .fetch(request, database: database) ) + setFetchKeyID(for: request, database: database, scheduler: nil) } /// Initializes this property with a query associated with an optional value. @@ -288,10 +319,12 @@ public struct FetchOne: Sendable { Value: StructuredQueriesCore._OptionalProtocol, Value.QueryOutput == Value { + let request = FetchOneStatementOptionalProtocolRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch(FetchOneStatementOptionalProtocolRequest(statement: statement), database: database) + .fetch(request, database: database) ) + setFetchKeyID(for: request, database: database, scheduler: nil) } /// Replaces the wrapped value with data from the given query. @@ -428,10 +461,29 @@ public struct FetchOne: Sendable { ) return FetchSubscription(sharedReader: sharedReader) } + + #if !canImport(SwiftUI) + @_transparent + #endif + private func setFetchKeyID( + for request: some FetchKeyRequest, + database: (any DatabaseReader)?, + scheduler: (any ValueObservationScheduler & Hashable)? + ) { + #if canImport(SwiftUI) + box.fetchKeyID = FetchKey(request: request, database: database, scheduler: scheduler).id + #endif + } } extension FetchOne { - @available(*, deprecated, message: "Remove unused parameters: 'database', 'scheduler'.") + @available( + *, + deprecated, + message: """ + '@Selection' type requires a query to be fetched; provide one or remove unused parameters: 'database', 'scheduler'. + """ + ) public init( wrappedValue: sending Value, database: (any DatabaseReader)? = nil, @@ -444,7 +496,13 @@ extension FetchOne { sharedReader = SharedReader(value: wrappedValue) } - @available(*, deprecated, message: "Remove unused parameters: 'database', 'scheduler'.") + @available( + *, + deprecated, + message: """ + '@Selection' type requires a query to be fetched; provide one or remove unused parameters: 'database', 'scheduler'. + """ + ) public init( wrappedValue: sending Value = Value._none, database: (any DatabaseReader)? = nil, @@ -475,14 +533,12 @@ extension FetchOne { Value: StructuredQueriesCore.Table & QueryRepresentable, Value.QueryOutput == Value { let statement = Value.all.selectStar().asSelect().limit(1) + let request = FetchOneStatementValueRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch( - FetchOneStatementValueRequest(statement: statement), - database: database, - scheduler: scheduler - ) + .fetch(request, database: database, scheduler: scheduler) ) + setFetchKeyID(for: request, database: database, scheduler: scheduler) } /// Initializes this property with a query that fetches the first row from a table. @@ -504,14 +560,12 @@ extension FetchOne { Value.QueryOutput == Value { let statement = Value.all.selectStar().asSelect().limit(1) + let request = FetchOneStatementOptionalProtocolRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch( - FetchOneStatementOptionalProtocolRequest(statement: statement), - database: database, - scheduler: scheduler - ) + .fetch(request, database: database, scheduler: scheduler) ) + setFetchKeyID(for: request, database: database, scheduler: scheduler) } /// Initializes this property with a query associated with the wrapped value. @@ -556,14 +610,12 @@ extension FetchOne { where Value == V.QueryOutput { + let request = FetchOneStatementValueRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch( - FetchOneStatementValueRequest(statement: statement), - database: database, - scheduler: scheduler - ) + .fetch(request, database: database, scheduler: scheduler) ) + setFetchKeyID(for: request, database: database, scheduler: scheduler) } /// Initializes this property with a query associated with the wrapped value. @@ -584,14 +636,12 @@ extension FetchOne { where Value == V.QueryOutput? { + let request = FetchOneStatementOptionalValueRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch( - FetchOneStatementOptionalValueRequest(statement: statement), - database: database, - scheduler: scheduler - ) + .fetch(request, database: database, scheduler: scheduler) ) + setFetchKeyID(for: request, database: database, scheduler: scheduler) } /// Initializes this property with a query associated with the wrapped value. @@ -613,14 +663,12 @@ extension FetchOne { Value: QueryRepresentable, Value == S.QueryValue.QueryOutput { + let request = FetchOneStatementValueRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch( - FetchOneStatementValueRequest(statement: statement), - database: database, - scheduler: scheduler - ) + .fetch(request, database: database, scheduler: scheduler) ) + setFetchKeyID(for: request, database: database, scheduler: scheduler) } /// Initializes this property with a query associated with an optional value. @@ -645,14 +693,12 @@ extension FetchOne { S.Joins == () { let statement = statement.selectStar().asSelect().limit(1) + let request = FetchOneStatementOptionalValueRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch( - FetchOneStatementOptionalValueRequest(statement: statement), - database: database, - scheduler: scheduler - ) + .fetch(request, database: database, scheduler: scheduler) ) + setFetchKeyID(for: request, database: database, scheduler: scheduler) } /// Initializes this property with a query associated with an optional value. @@ -676,14 +722,12 @@ extension FetchOne { S.QueryValue: StructuredQueriesCore._OptionalProtocol, Value == S.QueryValue.QueryOutput { + let request = FetchOneStatementOptionalProtocolRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch( - FetchOneStatementOptionalProtocolRequest(statement: statement), - database: database, - scheduler: scheduler - ) + .fetch(request, database: database, scheduler: scheduler) ) + setFetchKeyID(for: request, database: database, scheduler: scheduler) } /// Initializes this property with a query associated with an optional value. @@ -706,14 +750,12 @@ extension FetchOne { Value: StructuredQueriesCore._OptionalProtocol, Value.QueryOutput == Value { + let request = FetchOneStatementOptionalProtocolRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch( - FetchOneStatementOptionalProtocolRequest(statement: statement), - database: database, - scheduler: scheduler - ) + .fetch(request, database: database, scheduler: scheduler) ) + setFetchKeyID(for: request, database: database, scheduler: scheduler) } /// Replaces the wrapped value with data from the given query. @@ -905,10 +947,20 @@ extension FetchOne: Equatable where Value: Equatable { #if canImport(SwiftUI) extension FetchOne: DynamicProperty { public func update() { - sharedReader.update() + let persisted = state.wrappedValue + if persisted !== box { + persisted.update(from: box) + } + persisted.subscribe(generation: generation) } - @available(*, deprecated, message: "Remove unused parameters: 'database', 'animation'.") + @available( + *, + deprecated, + message: """ + '@Selection' type requires a query to be fetched; provide one or remove unused parameters: 'database', 'scheduler'. + """ + ) public init( wrappedValue: sending Value, database: (any DatabaseReader)? = nil, @@ -921,7 +973,13 @@ extension FetchOne: Equatable where Value: Equatable { sharedReader = SharedReader(value: wrappedValue) } - @available(*, deprecated, message: "Remove unused parameters: 'database', 'animation'.") + @available( + *, + deprecated, + message: """ + '@Selection' type requires a query to be fetched; provide one or remove unused parameters: 'database', 'scheduler'. + """ + ) public init( wrappedValue: sending Value = Value._none, database: (any DatabaseReader)? = nil, diff --git a/Sources/SQLiteData/Internal/FetchBox.swift b/Sources/SQLiteData/Internal/FetchBox.swift new file mode 100644 index 00000000..992b79fa --- /dev/null +++ b/Sources/SQLiteData/Internal/FetchBox.swift @@ -0,0 +1,46 @@ +#if canImport(SwiftUI) + import Combine + import ConcurrencyExtras + import Sharing + import SwiftUI + + final class FetchBox: Sendable { + let sharedReader: SharedReader + private let storage = LockIsolated(Storage()) + + var fetchKeyID: FetchKeyID? { + get { storage.withValue { $0.fetchKeyID } } + set { storage.withValue { $0.fetchKeyID = newValue } } + } + + init(sharedReader: SharedReader) { + self.sharedReader = sharedReader + } + + func update(from other: FetchBox) { + guard let otherFetchKeyID = other.fetchKeyID else { return } + let isAdopted = storage.withValue { + guard otherFetchKeyID != $0.fetchKeyID else { return false } + $0.fetchKeyID = otherFetchKeyID + return true + } + guard isAdopted else { return } + sharedReader.projectedValue = other.sharedReader.projectedValue + } + + func subscribe(generation: SwiftUI.State) { + guard #unavailable(iOS 17, macOS 14, tvOS 17, watchOS 10) else { return } + _ = generation.wrappedValue + storage.withValue { + $0.swiftUICancellable = sharedReader.publisher + .dropFirst() + .sink { _ in generation.wrappedValue &+= 1 } + } + } + + private struct Storage { + var fetchKeyID: FetchKeyID? + var swiftUICancellable: AnyCancellable? + } + } +#endif diff --git a/Tests/SQLiteDataTests/FetchBoxTests.swift b/Tests/SQLiteDataTests/FetchBoxTests.swift new file mode 100644 index 00000000..a0a598c7 --- /dev/null +++ b/Tests/SQLiteDataTests/FetchBoxTests.swift @@ -0,0 +1,78 @@ +#if canImport(SwiftUI) + import GRDB + import Sharing + import Testing + + @testable import SQLiteData + + @Suite struct FetchBoxTests { + let database: any DatabaseReader + + init() throws { + database = try DatabaseQueue() + } + + @Test func keyedReinitializationWithNewQueryIsAdopted() { + let persisted = FetchBox(sharedReader: SharedReader(value: 1)) + persisted.fetchKeyID = fetchKeyID(TestRequest(id: 1)) + let fresh = FetchBox(sharedReader: SharedReader(value: 2)) + fresh.fetchKeyID = fetchKeyID(TestRequest(id: 2)) + persisted.update(from: fresh) + #expect(persisted.sharedReader.wrappedValue == 2) + #expect(persisted.fetchKeyID == fresh.fetchKeyID) + } + + @Test func keyedReinitializationWithSameQueryIsIgnored() { + let persisted = FetchBox(sharedReader: SharedReader(value: 1)) + persisted.fetchKeyID = fetchKeyID(TestRequest(id: 1)) + let fresh = FetchBox(sharedReader: SharedReader(value: 2)) + fresh.fetchKeyID = fetchKeyID(TestRequest(id: 1)) + persisted.update(from: fresh) + #expect(persisted.sharedReader.wrappedValue == 1) + } + + @Test func keyedToKeylessReinitializationIsIgnored() { + let persisted = FetchBox(sharedReader: SharedReader(value: 1)) + persisted.fetchKeyID = fetchKeyID(TestRequest(id: 1)) + let fresh = FetchBox(sharedReader: SharedReader(value: 2)) + persisted.update(from: fresh) + #expect(persisted.sharedReader.wrappedValue == 1) + #expect(persisted.fetchKeyID != nil) + } + + @Test func keylessReinitializationIsIgnored() { + let persisted = FetchBox(sharedReader: SharedReader(value: 1)) + let fresh = FetchBox(sharedReader: SharedReader(value: 2)) + persisted.update(from: fresh) + #expect(persisted.sharedReader.wrappedValue == 1) + } + + @Test func keylessToKeyedReinitializationIsAdopted() { + let persisted = FetchBox(sharedReader: SharedReader(value: 1)) + let fresh = FetchBox(sharedReader: SharedReader(value: 2)) + fresh.fetchKeyID = fetchKeyID(TestRequest(id: 2)) + persisted.update(from: fresh) + #expect(persisted.sharedReader.wrappedValue == 2) + #expect(persisted.fetchKeyID == fresh.fetchKeyID) + } + + @Test func keylessReinitializationAfterLocalLoadIsIgnored() { + let persisted = FetchBox(sharedReader: SharedReader(value: [Int]())) + persisted.sharedReader.projectedValue = SharedReader(value: [1, 2, 3]).projectedValue + let fresh = FetchBox(sharedReader: SharedReader(value: [Int]())) + persisted.update(from: fresh) + #expect(persisted.sharedReader.wrappedValue == [1, 2, 3]) + } + + private func fetchKeyID(_ request: some FetchKeyRequest) -> FetchKeyID { + FetchKey(request: request, database: database, scheduler: nil).id + } + } + + private struct TestRequest: FetchKeyRequest, Hashable { + let id: Int + func fetch(_ db: Database) throws -> Int { + id + } + } +#endif