diff --git a/Sources/SQLiteData/Fetch.swift b/Sources/SQLiteData/Fetch.swift index ea645873..50eb9fe6 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,32 @@ 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 private(set) 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 } + nonmutating set { state.wrappedValue.sharedReader = newValue } + } + + 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 private(set) var sharedReader: SharedReader + #endif /// Data associated with the underlying query. public var wrappedValue: Value { @@ -93,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. @@ -110,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 { @@ -132,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. @@ -169,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.reconcile(from: box, propertyName: "@Fetch") + } + persisted.subscribe(generation: generation) } /// Initializes this property with a request associated with the wrapped value. @@ -192,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 ec118cdf..e073c42c 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,32 @@ 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 private(set) 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 } + nonmutating set { state.wrappedValue.sharedReader = newValue } + } + + 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 private(set) var sharedReader: SharedReader<[Element]> = SharedReader(value: []) + #endif /// A collection of data associated with the underlying query. public var wrappedValue: [Element] { @@ -139,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. @@ -164,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. @@ -219,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, @@ -294,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. @@ -323,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. @@ -403,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.reconcile(from: box, propertyName: "@FetchAll") + } + 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..07f7e4e9 100644 --- a/Sources/SQLiteData/FetchOne.swift +++ b/Sources/SQLiteData/FetchOne.swift @@ -21,11 +21,32 @@ 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 private(set) 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 } + nonmutating set { state.wrappedValue.sharedReader = newValue } + } + + 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 private(set) var sharedReader: SharedReader + #endif /// A value associated with the underlying query. public var wrappedValue: Value { @@ -106,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. @@ -128,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. @@ -170,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. @@ -191,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. @@ -213,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. @@ -238,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. @@ -262,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. @@ -288,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. @@ -428,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, @@ -444,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, @@ -475,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. @@ -504,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. @@ -556,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. @@ -584,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. @@ -613,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. @@ -645,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. @@ -676,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. @@ -706,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. @@ -905,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.reconcile(from: box, propertyName: "@FetchOne") + } + 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 +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..9170223f --- /dev/null +++ b/Sources/SQLiteData/Internal/FetchBox.swift @@ -0,0 +1,93 @@ +#if canImport(SwiftUI) + import Combine + import Foundation + import IssueReporting + import Sharing + import SwiftUI + + final class FetchBox: @unchecked Sendable { + private let lock = NSLock() + private var storage: Storage + + init(sharedReader: SharedReader) { + storage = Storage(sharedReader: sharedReader) + } + + var sharedReader: SharedReader { + get { lock.withLock { storage.sharedReader } } + set { lock.withLock { storage.sharedReader = newValue } } + } + + var fetchKeyID: FetchKeyID? { + get { lock.withLock { storage.fetchKeyID } } + set { lock.withLock { storage.fetchKeyID = newValue } } + } + + func reconcile(from fresh: FetchBox, propertyName: String) { + let freshSnapshot = fresh.lock.withLock { fresh.storage } + let snapshot = lock.withLock { storage } + if let freshFetchKeyID = freshSnapshot.fetchKeyID { + if freshFetchKeyID != snapshot.fetchKeyID { + update(from: freshSnapshot) + } + } else if snapshot.fetchKeyID != nil { + #if DEBUG + let hasReported = lock.withLock { + defer { storage.hasReportedIgnoredReinitialization = true } + return storage.hasReportedIgnoredReinitialization + } + guard !hasReported else { return } + reportIssue( + """ + A '\(propertyName)' property was re-initialized without a query, but was previously \ + initialized with one; this re-initialization will be ignored, and the property \ + will continue to observe the existing query + """ + ) + #endif + } else if isEqual(freshSnapshot.initialValue, snapshot.initialValue) == false { + update(from: freshSnapshot) + } + } + + private func update(from other: Storage) { + lock.withLock { + storage.sharedReader = other.sharedReader + storage.fetchKeyID = other.fetchKeyID + storage.initialValue = other.initialValue + } + } + + func subscribe(generation: SwiftUI.State) { + guard #unavailable(iOS 17, macOS 14, tvOS 17, watchOS 10) else { return } + _ = generation.wrappedValue + let cancellable = sharedReader.publisher + .dropFirst() + .sink { _ in generation.wrappedValue &+= 1 } + lock.withLock { storage.swiftUICancellable = cancellable } + } + + private struct Storage { + var sharedReader: SharedReader + var fetchKeyID: FetchKeyID? + var initialValue: Value + var swiftUICancellable: AnyCancellable? + #if DEBUG + var hasReportedIgnoredReinitialization = false + #endif + + init(sharedReader: SharedReader) { + self.sharedReader = sharedReader + self.initialValue = sharedReader.wrappedValue + } + } + } + + private func isEqual(_ lhs: T, _ rhs: T) -> Bool? { + func open(_ lhs: U) -> Bool { + lhs == rhs as? U + } + guard let lhs = lhs as? any Equatable else { return nil } + return open(lhs) + } +#endif diff --git a/Tests/SQLiteDataTests/FetchBoxTests.swift b/Tests/SQLiteDataTests/FetchBoxTests.swift new file mode 100644 index 00000000..46853993 --- /dev/null +++ b/Tests/SQLiteDataTests/FetchBoxTests.swift @@ -0,0 +1,99 @@ +#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.reconcile(from: fresh, propertyName: "@Fetch") + #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.reconcile(from: fresh, propertyName: "@Fetch") + #expect(persisted.sharedReader.wrappedValue == 1) + } + + @Test func keylessReinitializationWithSameDefaultIsIgnored() { + let persisted = FetchBox(sharedReader: SharedReader(value: 1)) + let fresh = FetchBox(sharedReader: SharedReader(value: 1)) + persisted.reconcile(from: fresh, propertyName: "@Fetch") + #expect(persisted.sharedReader.wrappedValue == 1) + } + + @Test func keylessReinitializationWithDifferentDefaultIsAdopted() { + let persisted = FetchBox(sharedReader: SharedReader(value: 1)) + let fresh = FetchBox(sharedReader: SharedReader(value: 2)) + persisted.reconcile(from: fresh, propertyName: "@Fetch") + #expect(persisted.sharedReader.wrappedValue == 2) + } + + @Test func keylessAdoptionCarriesSeedForward() { + let persisted = FetchBox(sharedReader: SharedReader(value: 1)) + let fresh = FetchBox(sharedReader: SharedReader(value: 2)) + persisted.reconcile(from: fresh, propertyName: "@Fetch") + persisted.sharedReader = SharedReader(value: 99) + let refresh = FetchBox(sharedReader: SharedReader(value: 2)) + persisted.reconcile(from: refresh, propertyName: "@Fetch") + #expect(persisted.sharedReader.wrappedValue == 99) + } + + @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.reconcile(from: fresh, propertyName: "@FetchAll") + #expect(persisted.sharedReader.wrappedValue == [1, 2, 3]) + } + + @Test func keylessReinitializationWithNonEquatableDefaultIsIgnored() { + struct Opaque: Sendable { + let n: Int + } + let persisted = FetchBox(sharedReader: SharedReader(value: Opaque(n: 1))) + let fresh = FetchBox(sharedReader: SharedReader(value: Opaque(n: 2))) + persisted.reconcile(from: fresh, propertyName: "@Fetch") + #expect(persisted.sharedReader.wrappedValue.n == 1) + } + + @Test func keyedToKeylessReinitializationReportsIssue() { + let persisted = FetchBox(sharedReader: SharedReader(value: 1)) + persisted.fetchKeyID = fetchKeyID(TestRequest(id: 1)) + let fresh = FetchBox(sharedReader: SharedReader(value: 1)) + withKnownIssue { + persisted.reconcile(from: fresh, propertyName: "@Fetch") + } + #expect(persisted.sharedReader.wrappedValue == 1) + #expect(persisted.fetchKeyID != nil) + persisted.reconcile(from: fresh, propertyName: "@Fetch") + } + + 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