From 76f7fb775b02cb2e8cd20fcd8259b47aa06ef09a Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Sun, 19 Jul 2026 23:38:15 -0700 Subject: [PATCH 1/4] Add case studies for SwiftUI issue --- Examples/CaseStudies/ChildIdentityReset.swift | 105 ++++++++++++++++++ Examples/CaseStudies/ParentDrivenQuery.swift | 103 +++++++++++++++++ .../ParentRerenderAnimations.swift | 94 ++++++++++++++++ .../ParentRerenderCancellation.swift | 100 +++++++++++++++++ .../ParentRerenderDynamicQuery.swift | 104 +++++++++++++++++ .../CaseStudies/ParentRerenderLoadError.swift | 75 +++++++++++++ .../ParentRerenderLoadedData.swift | 100 +++++++++++++++++ 7 files changed, 681 insertions(+) create mode 100644 Examples/CaseStudies/ChildIdentityReset.swift create mode 100644 Examples/CaseStudies/ParentDrivenQuery.swift create mode 100644 Examples/CaseStudies/ParentRerenderAnimations.swift create mode 100644 Examples/CaseStudies/ParentRerenderCancellation.swift create mode 100644 Examples/CaseStudies/ParentRerenderDynamicQuery.swift create mode 100644 Examples/CaseStudies/ParentRerenderLoadError.swift create mode 100644 Examples/CaseStudies/ParentRerenderLoadedData.swift diff --git a/Examples/CaseStudies/ChildIdentityReset.swift b/Examples/CaseStudies/ChildIdentityReset.swift new file mode 100644 index 00000000..be7dc421 --- /dev/null +++ b/Examples/CaseStudies/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/ParentDrivenQuery.swift b/Examples/CaseStudies/ParentDrivenQuery.swift new file mode 100644 index 00000000..c2443398 --- /dev/null +++ b/Examples/CaseStudies/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/ParentRerenderAnimations.swift b/Examples/CaseStudies/ParentRerenderAnimations.swift new file mode 100644 index 00000000..36301722 --- /dev/null +++ b/Examples/CaseStudies/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/ParentRerenderCancellation.swift b/Examples/CaseStudies/ParentRerenderCancellation.swift new file mode 100644 index 00000000..8bacecac --- /dev/null +++ b/Examples/CaseStudies/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/ParentRerenderDynamicQuery.swift b/Examples/CaseStudies/ParentRerenderDynamicQuery.swift new file mode 100644 index 00000000..3a2af0a9 --- /dev/null +++ b/Examples/CaseStudies/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/ParentRerenderLoadError.swift b/Examples/CaseStudies/ParentRerenderLoadError.swift new file mode 100644 index 00000000..1704cb60 --- /dev/null +++ b/Examples/CaseStudies/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/ParentRerenderLoadedData.swift b/Examples/CaseStudies/ParentRerenderLoadedData.swift new file mode 100644 index 00000000..1c689c0f --- /dev/null +++ b/Examples/CaseStudies/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() + } + } +} From 826120b15c62bd94c953554f907dfbe43957f615 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Sun, 19 Jul 2026 23:49:36 -0700 Subject: [PATCH 2/4] @State fix --- Sources/SQLiteData/Fetch.swift | 27 ++++++++++++++++++++++----- Sources/SQLiteData/FetchAll.swift | 27 ++++++++++++++++++++++----- Sources/SQLiteData/FetchOne.swift | 27 ++++++++++++++++++++++----- 3 files changed, 66 insertions(+), 15 deletions(-) diff --git a/Sources/SQLiteData/Fetch.swift b/Sources/SQLiteData/Fetch.swift index ea645873..29a4cecb 100644 --- a/Sources/SQLiteData/Fetch.swift +++ b/Sources/SQLiteData/Fetch.swift @@ -22,11 +22,28 @@ 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: state) + init(initialValue) { + state = SwiftUI.State(wrappedValue: initialValue) + } + get { state.wrappedValue } + nonmutating set { state.wrappedValue = newValue } + } + + private let state: SwiftUI.State> + #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 var sharedReader: SharedReader + #endif /// Data associated with the underlying query. public var wrappedValue: Value { diff --git a/Sources/SQLiteData/FetchAll.swift b/Sources/SQLiteData/FetchAll.swift index ec118cdf..6569f606 100644 --- a/Sources/SQLiteData/FetchAll.swift +++ b/Sources/SQLiteData/FetchAll.swift @@ -22,11 +22,28 @@ 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: state) + init(initialValue) { + state = SwiftUI.State(wrappedValue: initialValue) + } + get { state.wrappedValue } + nonmutating set { state.wrappedValue = newValue } + } + + private let state: SwiftUI.State> + #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 var sharedReader: SharedReader<[Element]> = SharedReader(value: []) + #endif /// A collection of data associated with the underlying query. public var wrappedValue: [Element] { diff --git a/Sources/SQLiteData/FetchOne.swift b/Sources/SQLiteData/FetchOne.swift index 4e6b799d..01240a4c 100644 --- a/Sources/SQLiteData/FetchOne.swift +++ b/Sources/SQLiteData/FetchOne.swift @@ -21,11 +21,28 @@ 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: state) + init(initialValue) { + state = SwiftUI.State(wrappedValue: initialValue) + } + get { state.wrappedValue } + nonmutating set { state.wrappedValue = newValue } + } + + private let state: SwiftUI.State> + #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 var sharedReader: SharedReader + #endif /// A value associated with the underlying query. public var wrappedValue: Value { From 57c9140c1ed6567ef75d539d79e8cdff937dfc9e Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 20 Jul 2026 08:55:10 -0700 Subject: [PATCH 3/4] FetchBox --- Sources/SQLiteData/Fetch.swift | 41 +++-- Sources/SQLiteData/FetchAll.swift | 84 +++++++---- Sources/SQLiteData/FetchOne.swift | 168 +++++++++++++-------- Sources/SQLiteData/Internal/FetchBox.swift | 32 ++++ Tests/SQLiteDataTests/FetchBoxTests.swift | 78 ++++++++++ 5 files changed, 302 insertions(+), 101 deletions(-) create mode 100644 Sources/SQLiteData/Internal/FetchBox.swift create mode 100644 Tests/SQLiteDataTests/FetchBoxTests.swift diff --git a/Sources/SQLiteData/Fetch.swift b/Sources/SQLiteData/Fetch.swift index 29a4cecb..2a7c5516 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 @@ -27,22 +26,26 @@ public struct Fetch: Sendable { /// /// 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: state) + public private(set) var sharedReader: SharedReader { + @storageRestrictions(initializes: box, state) init(initialValue) { - state = SwiftUI.State(wrappedValue: initialValue) + let box = FetchBox(sharedReader: initialValue) + self.box = box + state = SwiftUI.State(wrappedValue: box) } - get { state.wrappedValue } - nonmutating set { state.wrappedValue = newValue } + get { state.wrappedValue.sharedReader } + nonmutating set { state.wrappedValue.sharedReader = newValue } } - private let state: SwiftUI.State> + 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 var sharedReader: SharedReader + public private(set) var sharedReader: SharedReader #endif /// Data associated with the underlying query. @@ -110,6 +113,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. @@ -127,6 +131,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 { @@ -149,6 +166,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. @@ -186,7 +204,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. @@ -209,6 +231,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 6569f606..e2a9fd3f 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 @@ -27,22 +26,26 @@ public struct FetchAll: Sendable { /// /// 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: state) + public private(set) var sharedReader: SharedReader<[Element]> { + @storageRestrictions(initializes: box, state) init(initialValue) { - state = SwiftUI.State(wrappedValue: initialValue) + let box = FetchBox(sharedReader: initialValue) + self.box = box + state = SwiftUI.State(wrappedValue: box) } - get { state.wrappedValue } - nonmutating set { state.wrappedValue = newValue } + get { state.wrappedValue.sharedReader } + nonmutating set { state.wrappedValue.sharedReader = newValue } } - private let state: SwiftUI.State> + 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 var sharedReader: SharedReader<[Element]> = SharedReader(value: []) + public private(set) var sharedReader: SharedReader<[Element]> = SharedReader(value: []) #endif /// A collection of data associated with the underlying query. @@ -156,13 +159,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. @@ -181,13 +183,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. @@ -236,10 +237,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, @@ -311,14 +331,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. @@ -340,14 +358,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. @@ -420,10 +436,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 01240a4c..39f3ba88 100644 --- a/Sources/SQLiteData/FetchOne.swift +++ b/Sources/SQLiteData/FetchOne.swift @@ -26,22 +26,26 @@ public struct FetchOne: Sendable { /// /// 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: state) + public private(set) var sharedReader: SharedReader { + @storageRestrictions(initializes: box, state) init(initialValue) { - state = SwiftUI.State(wrappedValue: initialValue) + let box = FetchBox(sharedReader: initialValue) + self.box = box + state = SwiftUI.State(wrappedValue: box) } - get { state.wrappedValue } - nonmutating set { state.wrappedValue = newValue } + get { state.wrappedValue.sharedReader } + nonmutating set { state.wrappedValue.sharedReader = newValue } } - private let state: SwiftUI.State> + 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 var sharedReader: SharedReader + public private(set) var sharedReader: SharedReader #endif /// A value associated with the underlying query. @@ -123,10 +127,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. @@ -145,10 +151,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. @@ -187,10 +195,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. @@ -208,10 +218,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. @@ -230,10 +242,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. @@ -255,10 +269,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. @@ -279,13 +295,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. @@ -305,10 +320,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. @@ -445,10 +462,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, @@ -461,7 +497,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, @@ -492,14 +534,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. @@ -521,14 +561,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. @@ -573,14 +611,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. @@ -601,14 +637,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. @@ -630,14 +664,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. @@ -662,14 +694,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. @@ -693,14 +723,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. @@ -723,14 +751,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. @@ -922,10 +948,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, @@ -938,7 +974,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..e0d64073 --- /dev/null +++ b/Sources/SQLiteData/Internal/FetchBox.swift @@ -0,0 +1,32 @@ +#if canImport(SwiftUI) + import Combine + import Sharing + import SwiftUI + + final class FetchBox: @unchecked Sendable { + var sharedReader: SharedReader + var fetchKeyID: FetchKeyID? + private var swiftUICancellable: AnyCancellable? + + init(sharedReader: SharedReader) { + self.sharedReader = sharedReader + } + + func update(from other: FetchBox) { + guard + let otherFetchKeyID = other.fetchKeyID, + otherFetchKeyID != fetchKeyID + else { return } + sharedReader = other.sharedReader + fetchKeyID = other.fetchKeyID + } + + func subscribe(generation: SwiftUI.State) { + guard #unavailable(iOS 17, macOS 14, tvOS 17, watchOS 10) else { return } + _ = generation.wrappedValue + swiftUICancellable = sharedReader.publisher + .dropFirst() + .sink { _ in generation.wrappedValue &+= 1 } + } + } +#endif diff --git a/Tests/SQLiteDataTests/FetchBoxTests.swift b/Tests/SQLiteDataTests/FetchBoxTests.swift new file mode 100644 index 00000000..c4016cfd --- /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 = SharedReader(value: [1, 2, 3]) + 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 From f6a17e1145df841af15bf88155c3eeb2114b283a Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 20 Jul 2026 17:20:09 -0700 Subject: [PATCH 4/4] wip --- .../ChildIdentityReset.swift | 0 .../ParentDrivenQuery.swift | 8 ++-- .../ParentRerenderAnimations.swift | 0 .../ParentRerenderCancellation.swift | 0 .../ParentRerenderDynamicQuery.swift | 0 .../ParentRerenderLoadError.swift | 2 +- .../ParentRerenderLoadedData.swift | 0 Sources/SQLiteData/Fetch.swift | 5 +-- Sources/SQLiteData/FetchAll.swift | 5 +-- Sources/SQLiteData/FetchOne.swift | 5 +-- Sources/SQLiteData/Internal/FetchBox.swift | 40 +++++++++++++------ Tests/SQLiteDataTests/FetchBoxTests.swift | 2 +- 12 files changed, 39 insertions(+), 28 deletions(-) rename Examples/CaseStudies/{ => Regression Coverage}/ChildIdentityReset.swift (100%) rename Examples/CaseStudies/{ => Regression Coverage}/ParentDrivenQuery.swift (92%) rename Examples/CaseStudies/{ => Regression Coverage}/ParentRerenderAnimations.swift (100%) rename Examples/CaseStudies/{ => Regression Coverage}/ParentRerenderCancellation.swift (100%) rename Examples/CaseStudies/{ => Regression Coverage}/ParentRerenderDynamicQuery.swift (100%) rename Examples/CaseStudies/{ => Regression Coverage}/ParentRerenderLoadError.swift (97%) rename Examples/CaseStudies/{ => Regression Coverage}/ParentRerenderLoadedData.swift (100%) diff --git a/Examples/CaseStudies/ChildIdentityReset.swift b/Examples/CaseStudies/Regression Coverage/ChildIdentityReset.swift similarity index 100% rename from Examples/CaseStudies/ChildIdentityReset.swift rename to Examples/CaseStudies/Regression Coverage/ChildIdentityReset.swift diff --git a/Examples/CaseStudies/ParentDrivenQuery.swift b/Examples/CaseStudies/Regression Coverage/ParentDrivenQuery.swift similarity index 92% rename from Examples/CaseStudies/ParentDrivenQuery.swift rename to Examples/CaseStudies/Regression Coverage/ParentDrivenQuery.swift index c2443398..b0a4d337 100644 --- a/Examples/CaseStudies/ParentDrivenQuery.swift +++ b/Examples/CaseStudies/Regression Coverage/ParentDrivenQuery.swift @@ -4,11 +4,11 @@ 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. + `@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" \ + 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" diff --git a/Examples/CaseStudies/ParentRerenderAnimations.swift b/Examples/CaseStudies/Regression Coverage/ParentRerenderAnimations.swift similarity index 100% rename from Examples/CaseStudies/ParentRerenderAnimations.swift rename to Examples/CaseStudies/Regression Coverage/ParentRerenderAnimations.swift diff --git a/Examples/CaseStudies/ParentRerenderCancellation.swift b/Examples/CaseStudies/Regression Coverage/ParentRerenderCancellation.swift similarity index 100% rename from Examples/CaseStudies/ParentRerenderCancellation.swift rename to Examples/CaseStudies/Regression Coverage/ParentRerenderCancellation.swift diff --git a/Examples/CaseStudies/ParentRerenderDynamicQuery.swift b/Examples/CaseStudies/Regression Coverage/ParentRerenderDynamicQuery.swift similarity index 100% rename from Examples/CaseStudies/ParentRerenderDynamicQuery.swift rename to Examples/CaseStudies/Regression Coverage/ParentRerenderDynamicQuery.swift diff --git a/Examples/CaseStudies/ParentRerenderLoadError.swift b/Examples/CaseStudies/Regression Coverage/ParentRerenderLoadError.swift similarity index 97% rename from Examples/CaseStudies/ParentRerenderLoadError.swift rename to Examples/CaseStudies/Regression Coverage/ParentRerenderLoadError.swift index 1704cb60..b156819a 100644 --- a/Examples/CaseStudies/ParentRerenderLoadError.swift +++ b/Examples/CaseStudies/Regression Coverage/ParentRerenderLoadError.swift @@ -40,7 +40,7 @@ private struct FactsView: View { } } .task { - try? await $facts.load(Facts()) + _ = try? await $facts.load(Facts()) } } diff --git a/Examples/CaseStudies/ParentRerenderLoadedData.swift b/Examples/CaseStudies/Regression Coverage/ParentRerenderLoadedData.swift similarity index 100% rename from Examples/CaseStudies/ParentRerenderLoadedData.swift rename to Examples/CaseStudies/Regression Coverage/ParentRerenderLoadedData.swift diff --git a/Sources/SQLiteData/Fetch.swift b/Sources/SQLiteData/Fetch.swift index 2a7c5516..ee36aa4a 100644 --- a/Sources/SQLiteData/Fetch.swift +++ b/Sources/SQLiteData/Fetch.swift @@ -26,7 +26,7 @@ public struct Fetch: Sendable { /// /// 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 private(set) var sharedReader: SharedReader { + public var sharedReader: SharedReader { @storageRestrictions(initializes: box, state) init(initialValue) { let box = FetchBox(sharedReader: initialValue) @@ -34,7 +34,6 @@ public struct Fetch: Sendable { state = SwiftUI.State(wrappedValue: box) } get { state.wrappedValue.sharedReader } - nonmutating set { state.wrappedValue.sharedReader = newValue } } private let box: FetchBox @@ -45,7 +44,7 @@ public struct Fetch: Sendable { /// /// 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 private(set) var sharedReader: SharedReader + public let sharedReader: SharedReader #endif /// Data associated with the underlying query. diff --git a/Sources/SQLiteData/FetchAll.swift b/Sources/SQLiteData/FetchAll.swift index e2a9fd3f..a75863a6 100644 --- a/Sources/SQLiteData/FetchAll.swift +++ b/Sources/SQLiteData/FetchAll.swift @@ -26,7 +26,7 @@ public struct FetchAll: Sendable { /// /// 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 private(set) var sharedReader: SharedReader<[Element]> { + public var sharedReader: SharedReader<[Element]> { @storageRestrictions(initializes: box, state) init(initialValue) { let box = FetchBox(sharedReader: initialValue) @@ -34,7 +34,6 @@ public struct FetchAll: Sendable { state = SwiftUI.State(wrappedValue: box) } get { state.wrappedValue.sharedReader } - nonmutating set { state.wrappedValue.sharedReader = newValue } } private let box: FetchBox<[Element]> @@ -45,7 +44,7 @@ public struct FetchAll: Sendable { /// /// 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 private(set) var sharedReader: SharedReader<[Element]> = SharedReader(value: []) + public let sharedReader: SharedReader<[Element]> #endif /// A collection of data associated with the underlying query. diff --git a/Sources/SQLiteData/FetchOne.swift b/Sources/SQLiteData/FetchOne.swift index 39f3ba88..9ec6400e 100644 --- a/Sources/SQLiteData/FetchOne.swift +++ b/Sources/SQLiteData/FetchOne.swift @@ -26,7 +26,7 @@ public struct FetchOne: Sendable { /// /// 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 private(set) var sharedReader: SharedReader { + public var sharedReader: SharedReader { @storageRestrictions(initializes: box, state) init(initialValue) { let box = FetchBox(sharedReader: initialValue) @@ -34,7 +34,6 @@ public struct FetchOne: Sendable { state = SwiftUI.State(wrappedValue: box) } get { state.wrappedValue.sharedReader } - nonmutating set { state.wrappedValue.sharedReader = newValue } } private let box: FetchBox @@ -45,7 +44,7 @@ public struct FetchOne: Sendable { /// /// 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 private(set) var sharedReader: SharedReader + public let sharedReader: SharedReader #endif /// A value associated with the underlying query. diff --git a/Sources/SQLiteData/Internal/FetchBox.swift b/Sources/SQLiteData/Internal/FetchBox.swift index e0d64073..992b79fa 100644 --- a/Sources/SQLiteData/Internal/FetchBox.swift +++ b/Sources/SQLiteData/Internal/FetchBox.swift @@ -1,32 +1,46 @@ #if canImport(SwiftUI) import Combine + import ConcurrencyExtras import Sharing import SwiftUI - final class FetchBox: @unchecked Sendable { - var sharedReader: SharedReader - var fetchKeyID: FetchKeyID? - private var swiftUICancellable: AnyCancellable? + 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, - otherFetchKeyID != fetchKeyID - else { return } - sharedReader = other.sharedReader - fetchKeyID = other.fetchKeyID + 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 - swiftUICancellable = sharedReader.publisher - .dropFirst() - .sink { _ in generation.wrappedValue &+= 1 } + 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 index c4016cfd..a0a598c7 100644 --- a/Tests/SQLiteDataTests/FetchBoxTests.swift +++ b/Tests/SQLiteDataTests/FetchBoxTests.swift @@ -58,7 +58,7 @@ @Test func keylessReinitializationAfterLocalLoadIsIgnored() { let persisted = FetchBox(sharedReader: SharedReader(value: [Int]())) - persisted.sharedReader = SharedReader(value: [1, 2, 3]) + 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])