diff --git a/Sources/SQLiteData/Fetch.swift b/Sources/SQLiteData/Fetch.swift index ee36aa4a..ed50771d 100644 --- a/Sources/SQLiteData/Fetch.swift +++ b/Sources/SQLiteData/Fetch.swift @@ -39,12 +39,16 @@ public struct Fetch: Sendable { private let box: FetchBox private let state: SwiftUI.State> private let generation = SwiftUI.State(wrappedValue: 0) + + var loadGeneration: LoadGeneration { state.wrappedValue.loadGeneration } #else /// The underlying shared reader powering the property wrapper. /// /// Shared readers come from the [Sharing](https://github.com/pointfreeco/swift-sharing) /// package, a general solution to observing and persisting changes to external data sources. public let sharedReader: SharedReader + + let loadGeneration = LoadGeneration() #endif /// Data associated with the underlying query. @@ -58,7 +62,10 @@ public struct Fetch: Sendable { /// ``isLoading``, and ``publisher``. public var projectedValue: Self { get { self } - nonmutating set { sharedReader.projectedValue = newValue.sharedReader.projectedValue } + nonmutating set { + loadGeneration.invalidate() + sharedReader.projectedValue = newValue.sharedReader.projectedValue + } } /// Returns a ``sharedReader`` for the given key path. @@ -127,8 +134,15 @@ public struct Fetch: Sendable { _ request: some FetchKeyRequest, database: (any DatabaseReader)? = nil ) async throws -> FetchSubscription { - try await sharedReader.load(.fetch(request, database: database)) - return FetchSubscription(sharedReader: sharedReader) + try await withSubscription { + try await sharedReader.load(.fetch(request, database: database)) + } + } + + private func withSubscription(_ load: () async throws -> Void) async throws -> FetchSubscription { + let token = loadGeneration.begin() + try await load() + return FetchSubscription(sharedReader: sharedReader, token: token) } #if !canImport(SwiftUI) @@ -183,8 +197,9 @@ extension Fetch { database: (any DatabaseReader)? = nil, scheduler: some ValueObservationScheduler & Hashable ) async throws -> FetchSubscription { - try await sharedReader.load(.fetch(request, database: database, scheduler: scheduler)) - return FetchSubscription(sharedReader: sharedReader) + try await withSubscription { + try await sharedReader.load(.fetch(request, database: database, scheduler: scheduler)) + } } } @@ -249,8 +264,9 @@ extension Fetch: Equatable where Value: Equatable { database: (any DatabaseReader)? = nil, animation: Animation? ) async throws -> FetchSubscription { - try await sharedReader.load(.fetch(request, database: database, animation: animation)) - return FetchSubscription(sharedReader: sharedReader) + try await withSubscription { + try await sharedReader.load(.fetch(request, database: database, animation: animation)) + } } } #endif diff --git a/Sources/SQLiteData/FetchAll+Sections.swift b/Sources/SQLiteData/FetchAll+Sections.swift index 0a822950..16f9b3b1 100644 --- a/Sources/SQLiteData/FetchAll+Sections.swift +++ b/Sources/SQLiteData/FetchAll+Sections.swift @@ -196,7 +196,8 @@ extension FetchAll { /// - database: The database to read from. A value of `nil` will use the default database /// (`@Dependency(\.defaultDatabase)`). public init< - V: QueryRepresentable, From: StructuredQueriesCore.Table + V: QueryRepresentable, + From: StructuredQueriesCore.Table >( wrappedValue: [Element] = [], _ statement: Select, @@ -232,7 +233,9 @@ extension FetchAll { /// (`@Dependency(\.defaultDatabase)`). @_documentation(visibility: private) public init< - V: QueryRepresentable, From: StructuredQueriesCore.Table, J: StructuredQueriesCore.Table + V: QueryRepresentable, + From: StructuredQueriesCore.Table, + J: StructuredQueriesCore.Table >( wrappedValue: [Element] = [], _ statement: Select, @@ -371,7 +374,8 @@ extension FetchAll { /// - Returns: A subscription associated with the observation. @discardableResult public func load< - V: QueryRepresentable, From: StructuredQueriesCore.Table + V: QueryRepresentable, + From: StructuredQueriesCore.Table >( _ statement: Select, @_SectionBuilder sectionBy sectioning: (From.TableColumns) -> _Sectioning?, @@ -404,7 +408,9 @@ extension FetchAll { @_documentation(visibility: private) @discardableResult public func load< - V: QueryRepresentable, From: StructuredQueriesCore.Table, J: StructuredQueriesCore.Table + V: QueryRepresentable, + From: StructuredQueriesCore.Table, + J: StructuredQueriesCore.Table >( _ statement: Select, @_SectionBuilder sectionBy sectioning: (From.TableColumns, J.TableColumns) -> @@ -522,14 +528,15 @@ extension FetchAll { guard let sectioning else { removeSections() let statement: Select = statement.selectStar() - try await sharedReader.load( - FetchKey( - request: FetchAllStatementValueRequest(statement: statement), - database: database, - scheduler: scheduler + return try await withSubscription { + try await sharedReader.load( + FetchKey( + request: FetchAllStatementValueRequest(statement: statement), + database: database, + scheduler: scheduler + ) ) - ) - return FetchSubscription(sharedReader: sharedReader) + } } return try await loadSections( request: FetchAllSectionedStatementValueRequest(statement: statement, sectionBy: sectioning), @@ -553,14 +560,15 @@ extension FetchAll { defer { sharedReader.projectedValue = sectionedReader.elements.projectedValue } - try await sectionedReader.load( - FetchKey( - request: request, - database: database, - scheduler: scheduler + return try await withSubscription { + try await sectionedReader.load( + FetchKey( + request: request, + database: database, + scheduler: scheduler + ) ) - ) - return FetchSubscription(sharedReader: sharedReader, sectionedReader: sectionedReader) + } } } @@ -714,7 +722,8 @@ extension FetchAll { /// - scheduler: The scheduler to observe from. By default, database observation is performed /// asynchronously on the main queue. public init< - V: QueryRepresentable, From: StructuredQueriesCore.Table + V: QueryRepresentable, + From: StructuredQueriesCore.Table >( wrappedValue: [Element] = [], _ statement: Select, @@ -753,7 +762,9 @@ extension FetchAll { /// asynchronously on the main queue. @_documentation(visibility: private) public init< - V: QueryRepresentable, From: StructuredQueriesCore.Table, J: StructuredQueriesCore.Table + V: QueryRepresentable, + From: StructuredQueriesCore.Table, + J: StructuredQueriesCore.Table >( wrappedValue: [Element] = [], _ statement: Select, @@ -905,7 +916,8 @@ extension FetchAll { /// - Returns: A subscription associated with the observation. @discardableResult public func load< - V: QueryRepresentable, From: StructuredQueriesCore.Table + V: QueryRepresentable, + From: StructuredQueriesCore.Table >( _ statement: Select, @_SectionBuilder sectionBy sectioning: (From.TableColumns) -> _Sectioning?, @@ -941,7 +953,9 @@ extension FetchAll { @_documentation(visibility: private) @discardableResult public func load< - V: QueryRepresentable, From: StructuredQueriesCore.Table, J: StructuredQueriesCore.Table + V: QueryRepresentable, + From: StructuredQueriesCore.Table, + J: StructuredQueriesCore.Table >( _ statement: Select, @_SectionBuilder sectionBy sectioning: (From.TableColumns, J.TableColumns) -> @@ -1155,7 +1169,8 @@ extension FetchAll { /// the fetched results. @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) public init< - V: QueryRepresentable, From: StructuredQueriesCore.Table + V: QueryRepresentable, + From: StructuredQueriesCore.Table >( wrappedValue: [Element] = [], _ statement: Select, @@ -1191,7 +1206,9 @@ extension FetchAll { @_documentation(visibility: private) @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) public init< - V: QueryRepresentable, From: StructuredQueriesCore.Table, J: StructuredQueriesCore.Table + V: QueryRepresentable, + From: StructuredQueriesCore.Table, + J: StructuredQueriesCore.Table >( wrappedValue: [Element] = [], _ statement: Select, @@ -1340,7 +1357,8 @@ extension FetchAll { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @discardableResult public func load< - V: QueryRepresentable, From: StructuredQueriesCore.Table + V: QueryRepresentable, + From: StructuredQueriesCore.Table >( _ statement: Select, @_SectionBuilder sectionBy sectioning: (From.TableColumns) -> _Sectioning?, @@ -1374,7 +1392,9 @@ extension FetchAll { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @discardableResult public func load< - V: QueryRepresentable, From: StructuredQueriesCore.Table, J: StructuredQueriesCore.Table + V: QueryRepresentable, + From: StructuredQueriesCore.Table, + J: StructuredQueriesCore.Table >( _ statement: Select, @_SectionBuilder sectionBy sectioning: (From.TableColumns, J.TableColumns) -> diff --git a/Sources/SQLiteData/FetchAll.swift b/Sources/SQLiteData/FetchAll.swift index af10326a..9203e6f7 100644 --- a/Sources/SQLiteData/FetchAll.swift +++ b/Sources/SQLiteData/FetchAll.swift @@ -48,6 +48,8 @@ public struct FetchAll: Sendable { private let box: FetchAllBox private let state: SwiftUI.State> private let generation = SwiftUI.State(wrappedValue: 0) + + var loadGeneration: LoadGeneration { state.wrappedValue.loadGeneration } #else /// The underlying shared reader powering the property wrapper. /// @@ -59,6 +61,8 @@ public struct FetchAll: Sendable { SharedReader(value: ResultsSectionCollection()) let sectioning = LockIsolated<_Sectioning?>(nil) + + let loadGeneration = LoadGeneration() #endif /// A collection of data associated with the underlying query. @@ -73,6 +77,7 @@ public struct FetchAll: Sendable { public var projectedValue: Self { get { self } nonmutating set { + loadGeneration.invalidate() sharedReader.projectedValue = newValue.sharedReader.projectedValue sectionedReader.projectedValue = newValue.sectionedReader.projectedValue sectioning.setValue(newValue.sectioning.value) @@ -247,13 +252,22 @@ public struct FetchAll: Sendable { V.QueryOutput: Sendable { removeSections() - try await sharedReader.load( - .fetch( - FetchAllStatementValueRequest(statement: statement), - database: database + return try await withSubscription { + try await sharedReader.load( + .fetch( + FetchAllStatementValueRequest(statement: statement), + database: database + ) ) - ) - return FetchSubscription(sharedReader: sharedReader) + } + } + + func withSubscription(_ load: () async throws -> Void) async throws -> FetchSubscription { + let token = loadGeneration.begin() + try await load() + return sectioning.value == nil + ? FetchSubscription(sharedReader: sharedReader, token: token) + : FetchSubscription(sharedReader: sharedReader, sectionedReader: sectionedReader, token: token) } #if !canImport(SwiftUI) @@ -435,14 +449,15 @@ extension FetchAll { V.QueryOutput: Sendable { removeSections() - try await sharedReader.load( - .fetch( - FetchAllStatementValueRequest(statement: statement), - database: database, - scheduler: scheduler + return try await withSubscription { + try await sharedReader.load( + .fetch( + FetchAllStatementValueRequest(statement: statement), + database: database, + scheduler: scheduler + ) ) - ) - return FetchSubscription(sharedReader: sharedReader) + } } } @@ -635,14 +650,15 @@ extension FetchAll: Equatable where Element: Equatable { V.QueryOutput: Sendable { removeSections() - try await sharedReader.load( - .fetch( - FetchAllStatementValueRequest(statement: statement), - database: database, - animation: animation + return try await withSubscription { + try await sharedReader.load( + .fetch( + FetchAllStatementValueRequest(statement: statement), + database: database, + animation: animation + ) ) - ) - return FetchSubscription(sharedReader: sharedReader) + } } } #endif diff --git a/Sources/SQLiteData/FetchOne.swift b/Sources/SQLiteData/FetchOne.swift index 7bb4491c..37e4329c 100644 --- a/Sources/SQLiteData/FetchOne.swift +++ b/Sources/SQLiteData/FetchOne.swift @@ -39,12 +39,16 @@ public struct FetchOne: Sendable { private let box: FetchBox private let state: SwiftUI.State> private let generation = SwiftUI.State(wrappedValue: 0) + + var loadGeneration: LoadGeneration { state.wrappedValue.loadGeneration } #else /// The underlying shared reader powering the property wrapper. /// /// Shared readers come from the [Sharing](https://github.com/pointfreeco/swift-sharing) /// package, a general solution to observing and persisting changes to external data sources. public let sharedReader: SharedReader + + let loadGeneration = LoadGeneration() #endif /// A value associated with the underlying query. @@ -58,7 +62,10 @@ public struct FetchOne: Sendable { /// ``isLoading``, and ``publisher``. public var projectedValue: Self { get { self } - nonmutating set { sharedReader.projectedValue = newValue.sharedReader.projectedValue } + nonmutating set { + loadGeneration.invalidate() + sharedReader.projectedValue = newValue.sharedReader.projectedValue + } } /// Returns a ``sharedReader`` for the given key path. @@ -408,10 +415,11 @@ public struct FetchOne: Sendable { where Value == V.QueryOutput { - try await sharedReader.load( - .fetch(FetchOneStatementValueRequest(statement: statement), database: database) - ) - return FetchSubscription(sharedReader: sharedReader) + try await withSubscription { + try await sharedReader.load( + .fetch(FetchOneStatementValueRequest(statement: statement), database: database) + ) + } } /// Replaces the wrapped value with data from the given query. @@ -429,10 +437,11 @@ public struct FetchOne: Sendable { where Value == V.QueryOutput? { - try await sharedReader.load( - .fetch(FetchOneStatementOptionalValueRequest(statement: statement), database: database) - ) - return FetchSubscription(sharedReader: sharedReader) + try await withSubscription { + try await sharedReader.load( + .fetch(FetchOneStatementOptionalValueRequest(statement: statement), database: database) + ) + } } /// Replaces the wrapped value with data from the given query. @@ -454,10 +463,11 @@ public struct FetchOne: Sendable { S.Joins == () { let statement = statement.selectStar().asSelect().limit(1) - try await sharedReader.load( - .fetch(FetchOneStatementOptionalValueRequest(statement: statement), database: database) - ) - return FetchSubscription(sharedReader: sharedReader) + return try await withSubscription { + try await sharedReader.load( + .fetch(FetchOneStatementOptionalValueRequest(statement: statement), database: database) + ) + } } /// Replaces the wrapped value with data from the given query. @@ -478,10 +488,11 @@ public struct FetchOne: Sendable { S.QueryValue: StructuredQueriesCore._OptionalProtocol, Value == S.QueryValue.QueryOutput { - try await sharedReader.load( - .fetch(FetchOneStatementOptionalProtocolRequest(statement: statement), database: database) - ) - return FetchSubscription(sharedReader: sharedReader) + try await withSubscription { + try await sharedReader.load( + .fetch(FetchOneStatementOptionalProtocolRequest(statement: statement), database: database) + ) + } } /// Replaces the wrapped value with data from the given query. @@ -501,10 +512,17 @@ public struct FetchOne: Sendable { Value: StructuredQueriesCore._OptionalProtocol, Value.QueryOutput == Value { - try await sharedReader.load( - .fetch(FetchOneStatementOptionalProtocolRequest(statement: statement), database: database) - ) - return FetchSubscription(sharedReader: sharedReader) + try await withSubscription { + try await sharedReader.load( + .fetch(FetchOneStatementOptionalProtocolRequest(statement: statement), database: database) + ) + } + } + + private func withSubscription(_ load: () async throws -> Void) async throws -> FetchSubscription { + let token = loadGeneration.begin() + try await load() + return FetchSubscription(sharedReader: sharedReader, token: token) } #if !canImport(SwiftUI) @@ -918,14 +936,15 @@ extension FetchOne { where Value == V.QueryOutput { - try await sharedReader.load( - .fetch( - FetchOneStatementValueRequest(statement: statement), - database: database, - scheduler: scheduler + try await withSubscription { + try await sharedReader.load( + .fetch( + FetchOneStatementValueRequest(statement: statement), + database: database, + scheduler: scheduler + ) ) - ) - return FetchSubscription(sharedReader: sharedReader) + } } /// Replaces the wrapped value with data from the given query. @@ -946,14 +965,15 @@ extension FetchOne { where Value == V.QueryOutput? { - try await sharedReader.load( - .fetch( - FetchOneStatementOptionalValueRequest(statement: statement), - database: database, - scheduler: scheduler + try await withSubscription { + try await sharedReader.load( + .fetch( + FetchOneStatementOptionalValueRequest(statement: statement), + database: database, + scheduler: scheduler + ) ) - ) - return FetchSubscription(sharedReader: sharedReader) + } } /// Replaces the wrapped value with data from the given query. @@ -978,14 +998,15 @@ extension FetchOne { S.Joins == () { let statement = statement.selectStar().asSelect().limit(1) - try await sharedReader.load( - .fetch( - FetchOneStatementOptionalValueRequest(statement: statement), - database: database, - scheduler: scheduler + return try await withSubscription { + try await sharedReader.load( + .fetch( + FetchOneStatementOptionalValueRequest(statement: statement), + database: database, + scheduler: scheduler + ) ) - ) - return FetchSubscription(sharedReader: sharedReader) + } } /// Replaces the wrapped value with data from the given query. @@ -1009,14 +1030,15 @@ extension FetchOne { S.QueryValue: StructuredQueriesCore._OptionalProtocol, Value == S.QueryValue.QueryOutput { - try await sharedReader.load( - .fetch( - FetchOneStatementOptionalProtocolRequest(statement: statement), - database: database, - scheduler: scheduler + try await withSubscription { + try await sharedReader.load( + .fetch( + FetchOneStatementOptionalProtocolRequest(statement: statement), + database: database, + scheduler: scheduler + ) ) - ) - return FetchSubscription(sharedReader: sharedReader) + } } /// Replaces the wrapped value with data from the given query. @@ -1039,14 +1061,15 @@ extension FetchOne { Value: StructuredQueriesCore._OptionalProtocol, Value.QueryOutput == Value { - try await sharedReader.load( - .fetch( - FetchOneStatementOptionalProtocolRequest(statement: statement), - database: database, - scheduler: scheduler + try await withSubscription { + try await sharedReader.load( + .fetch( + FetchOneStatementOptionalProtocolRequest(statement: statement), + database: database, + scheduler: scheduler + ) ) - ) - return FetchSubscription(sharedReader: sharedReader) + } } } diff --git a/Sources/SQLiteData/FetchSubscription.swift b/Sources/SQLiteData/FetchSubscription.swift index bfbaf575..ff87335c 100644 --- a/Sources/SQLiteData/FetchSubscription.swift +++ b/Sources/SQLiteData/FetchSubscription.swift @@ -18,18 +18,25 @@ public struct FetchSubscription: Sendable { let cancellable = LockIsolated?>(nil) let onCancel: @Sendable () -> Void - init(sharedReader: SharedReader) { - onCancel = { sharedReader.projectedValue = SharedReader(value: sharedReader.wrappedValue) } + init(sharedReader: SharedReader, token: LoadGeneration.Token) { + onCancel = { + token.ifCurrent { + sharedReader.projectedValue = SharedReader(value: sharedReader.wrappedValue) + } + } } init( sharedReader: SharedReader<[Element]>, - sectionedReader: SharedReader> + sectionedReader: SharedReader>, + token: LoadGeneration.Token ) { onCancel = { - let sections = sectionedReader.wrappedValue - sectionedReader.projectedValue = SharedReader(value: sections) - sharedReader.projectedValue = SharedReader(value: sections.elements) + token.ifCurrent { + let sections = sectionedReader.wrappedValue + sectionedReader.projectedValue = SharedReader(value: sections) + sharedReader.projectedValue = SharedReader(value: sections.elements) + } } } diff --git a/Sources/SQLiteData/Internal/FetchBox.swift b/Sources/SQLiteData/Internal/FetchBox.swift index ed21e1c6..2ca0ff8e 100644 --- a/Sources/SQLiteData/Internal/FetchBox.swift +++ b/Sources/SQLiteData/Internal/FetchBox.swift @@ -6,6 +6,7 @@ final class FetchBox: Sendable { let sharedReader: SharedReader + let loadGeneration = LoadGeneration() private let storage = LockIsolated(Storage()) var fetchKeyID: FetchKeyID? { @@ -25,6 +26,7 @@ return true } guard isAdopted else { return } + loadGeneration.invalidate() sharedReader.projectedValue = other.sharedReader.projectedValue } @@ -48,6 +50,7 @@ let sharedReader: SharedReader<[Element]> let sectionedReader: SharedReader> let sectioning = LockIsolated<_Sectioning?>(nil) + let loadGeneration = LoadGeneration() private let storage = LockIsolated(Storage()) var fetchKeyID: FetchKeyID? { @@ -68,6 +71,7 @@ return true } guard isAdopted else { return } + loadGeneration.invalidate() sharedReader.projectedValue = other.sharedReader.projectedValue sectionedReader.projectedValue = other.sectionedReader.projectedValue sectioning.setValue(other.sectioning.value) diff --git a/Sources/SQLiteData/Internal/LoadGeneration.swift b/Sources/SQLiteData/Internal/LoadGeneration.swift new file mode 100644 index 00000000..e4e3a56e --- /dev/null +++ b/Sources/SQLiteData/Internal/LoadGeneration.swift @@ -0,0 +1,31 @@ +import Foundation + +final class LoadGeneration: @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + + func begin() -> Token { + lock.lock() + defer { lock.unlock() } + count += 1 + return Token(loadGeneration: self, generation: count) + } + + func invalidate() { + lock.lock() + defer { lock.unlock() } + count += 1 + } + + struct Token: Sendable { + fileprivate let loadGeneration: LoadGeneration + fileprivate let generation: Int + + func ifCurrent(_ body: () -> Void) { + loadGeneration.lock.lock() + defer { loadGeneration.lock.unlock() } + guard loadGeneration.count == generation else { return } + body() + } + } +} diff --git a/Tests/SQLiteDataTests/FetchSubscriptionTests.swift b/Tests/SQLiteDataTests/FetchSubscriptionTests.swift index 155dcdc2..b5798013 100644 --- a/Tests/SQLiteDataTests/FetchSubscriptionTests.swift +++ b/Tests/SQLiteDataTests/FetchSubscriptionTests.swift @@ -53,6 +53,24 @@ import Testing #expect(didComplete.value) } + @Test func staleCancellationDoesNotStopNewerLoad() async throws { + @FetchAll var records: [Record] + + let firstSubscription = try await $records.load(Record.all) + let task = Task { + try? await firstSubscription.task + } + try await $records.load(Record.where { $0.id > 0 }) + task.cancel() + await task.value + + try await database.write { db in + try Record.insert { Record.Draft() }.execute(db) + } + try await $records.load() + #expect(records.count == 1) + } + @Test func cancellingOneFetchDoesNotCancelAnother() async throws { @FetchAll var records1: [Record] #expect(records1.count == 0)