diff --git a/Sources/SQLiteData/Documentation.docc/Articles/Fetching.md b/Sources/SQLiteData/Documentation.docc/Articles/Fetching.md index e2b2d380..7d9729fd 100644 --- a/Sources/SQLiteData/Documentation.docc/Articles/Fetching.md +++ b/Sources/SQLiteData/Documentation.docc/Articles/Fetching.md @@ -145,6 +145,36 @@ This is a very efficient query that selects only the bare essentials of data tha needs to do its job. This kind of query is a lot more cumbersome to perform in SwiftData because you must construct a dedicated `FetchDescriptor` value and set its `propertiesToFetch`. +It is also possible to group the results of a query into sections by providing a `sectionBy:` key +path, similar to SwiftData's `@Query(sectionBy:)`. For example, given a `Reminder` table with a +string `category` column: + +```swift +@FetchAll(Reminder.order(by: \.category), sectionBy: \.category) +var reminders +``` + +The flat results are still available from the property itself, and the projected value's +``FetchAll/sections`` property groups them into a ``ResultsSectionCollection``, which is perfect +for driving a sectioned list: + +```swift +List { + ForEach($reminders.sections) { section in + Section(section.name) { + ForEach(section) { reminder in + Text(reminder.title) + } + } + } +} +``` + +Results are grouped into a section for each distinct value at the key path. Sections are ordered +by the position of their first element in the query's results, and elements within a section +follow the query's order, so you can control the order of sections by ordering the query by the +sectioned column. + [sq-safe-sql-strings]: https://swiftpackageindex.com/pointfreeco/swift-structured-queries/~/documentation/structuredqueriescore/safesqlstrings [structured-queries-gh]: https://github.com/pointfreeco/swift-structured-queries [structured-queries-docs]: https://swiftpackageindex.com/pointfreeco/swift-structured-queries/main/documentation/structuredqueriescore/ diff --git a/Sources/SQLiteData/Documentation.docc/Extensions/FetchAll.md b/Sources/SQLiteData/Documentation.docc/Extensions/FetchAll.md index 14c32601..06720669 100644 --- a/Sources/SQLiteData/Documentation.docc/Extensions/FetchAll.md +++ b/Sources/SQLiteData/Documentation.docc/Extensions/FetchAll.md @@ -10,6 +10,15 @@ - ``init(wrappedValue:_:database:)`` - ``load(_:database:)`` +### Sectioning data + +- ``init(wrappedValue:sectionBy:database:)`` +- ``init(wrappedValue:_:sectionBy:database:)`` +- ``load(_:sectionBy:database:)`` +- ``sections`` +- ``ResultsSectionCollection`` +- ``ResultsSection`` + ### Accessing state - ``wrappedValue`` @@ -21,7 +30,10 @@ - ``init(wrappedValue:database:animation:)`` - ``init(wrappedValue:_:database:animation:)`` +- ``init(wrappedValue:sectionBy:database:animation:)`` +- ``init(wrappedValue:_:sectionBy:database:animation:)`` - ``load(_:database:animation:)`` +- ``load(_:sectionBy:database:animation:)`` ### Combine integration @@ -31,7 +43,10 @@ - ``init(wrappedValue:database:scheduler:)`` - ``init(wrappedValue:_:database:scheduler:)`` +- ``init(wrappedValue:sectionBy:database:scheduler:)`` +- ``init(wrappedValue:_:sectionBy:database:scheduler:)`` - ``load(_:database:scheduler:)`` +- ``load(_:sectionBy:database:scheduler:)`` ### Sharing infrastructure diff --git a/Sources/SQLiteData/Fetch.swift b/Sources/SQLiteData/Fetch.swift index ea645873..f7be994e 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+Sections.swift b/Sources/SQLiteData/FetchAll+Sections.swift new file mode 100644 index 00000000..5de44461 --- /dev/null +++ b/Sources/SQLiteData/FetchAll+Sections.swift @@ -0,0 +1,1339 @@ +public import GRDB +public import StructuredQueriesCore +import Sharing + +#if canImport(SwiftUI) + public import SwiftUI +#endif + +extension FetchAll { + /// The results of the query, grouped into sections. + /// + /// This collection is populated when the property is initialized with a `sectionBy:` key path: + /// + /// ```swift + /// @FetchAll(Reminder.order(by: \.category), sectionBy: \.category) + /// var reminders + /// + /// var body: some View { + /// List { + /// ForEach($reminders.sections) { section in + /// Section(section.name) { + /// ForEach(section) { reminder in + /// Text(reminder.title) + /// } + /// } + /// } + /// } + /// } + /// ``` + /// + /// See ``ResultsSectionCollection`` for more information. + public var sections: ResultsSectionCollection { + guard sectionedBy.value != nil else { + return ResultsSectionCollection(elements: sharedReader.wrappedValue, sectionName: "") + } + return sectionedReader.wrappedValue + } + + fileprivate init( + wrappedValue: [Element], + statement: some StructuredQueriesCore.Statement, + sectionBy: SectionBy, + database: (any DatabaseReader)?, + scheduler: (any ValueObservationScheduler & Hashable)? + ) + where + Element == V.QueryOutput, + V.QueryOutput: Sendable + { + let request = FetchAllSectionedStatementValueRequest(statement: statement, sectionBy: sectionBy) + let sectionedReader = SharedReader( + wrappedValue: ResultsSectionCollection(elements: wrappedValue, sectionName: sectionBy.name), + FetchKey(request: request, database: database, scheduler: scheduler) + ) + self.sharedReader = sectionedReader[dynamicMember: \.elements] + self.sectionedReader = sectionedReader + self.sectionedBy.setValue(sectionBy) + setFetchKeyID(for: request, database: database, scheduler: scheduler) + } +} + +extension FetchAll { + /// Initializes this property with a query that fetches every row from a table, grouping results + /// into sections. + /// + /// Results are grouped into a section for each distinct value at the given key path. Sections + /// are ordered by the position of their first element in the query's results, and elements + /// within a section follow the query's order. Access the sections from the projected value's + /// ``sections`` property. + /// + /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. + /// - sectionKeyPath: A key path to a string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + public init( + wrappedValue: [Element] = [], + sectionBy sectionKeyPath: KeyPath?, + database: (any DatabaseReader)? = nil + ) + where Element: StructuredQueriesCore.Table, Element.QueryOutput == Element { + let statement: Select = Element.all.selectStar().asSelect() + guard let sectionKeyPath else { + self.init(wrappedValue: wrappedValue, statement, database: database) + return + } + self.init( + wrappedValue: wrappedValue, + statement: statement, + sectionBy: SectionBy(sectionKeyPath), + database: database, + scheduler: nil + ) + } + + /// Initializes this property with a query associated with the wrapped value, grouping results + /// into sections. + /// + /// Results are grouped into a section for each distinct value at the given key path. Sections + /// are ordered by the position of their first element in the query's results, and elements + /// within a section follow the query's order. To control the order of sections, order the query + /// by the sectioned column: + /// + /// ```swift + /// @FetchAll(Reminder.order(by: \.category), sectionBy: \.category) + /// var reminders + /// ``` + /// + /// Access the sections from the projected value's ``sections`` property. + /// + /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. + /// - statement: A query associated with the wrapped value. + /// - sectionKeyPath: A key path to a string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + public init( + wrappedValue: [Element] = [], + _ statement: S, + sectionBy sectionKeyPath: KeyPath?, + database: (any DatabaseReader)? = nil + ) + where + Element == S.From.QueryOutput, + S.QueryValue == (), + S.From.QueryOutput: Sendable, + S.Joins == () + { + let statement: Select = statement.selectStar() + guard let sectionKeyPath else { + self.init(wrappedValue: wrappedValue, statement, database: database) + return + } + self.init( + wrappedValue: wrappedValue, + statement: statement, + sectionBy: SectionBy(sectionKeyPath), + database: database, + scheduler: nil + ) + } + + /// Initializes this property with a query associated with the wrapped value, grouping results + /// into sections. + /// + /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. + /// - statement: A query associated with the wrapped value. + /// - sectionKeyPath: A key path to a string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + public init( + wrappedValue: [Element] = [], + _ statement: some StructuredQueriesCore.Statement, + sectionBy sectionKeyPath: KeyPath?, + database: (any DatabaseReader)? = nil + ) + where + Element == V.QueryOutput, + V.QueryOutput: Sendable + { + guard let sectionKeyPath else { + self.init(wrappedValue: wrappedValue, statement, database: database) + return + } + self.init( + wrappedValue: wrappedValue, + statement: statement, + sectionBy: SectionBy(sectionKeyPath), + database: database, + scheduler: nil + ) + } + + /// Initializes this property with a query associated with the wrapped value, grouping results + /// into sections. + /// + /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. + /// - statement: A query associated with the wrapped value. + /// - sectionKeyPath: A key path to a string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + public init>( + wrappedValue: [Element] = [], + _ statement: S, + sectionBy sectionKeyPath: KeyPath?, + database: (any DatabaseReader)? = nil + ) + where + Element: QueryRepresentable, + Element == S.QueryValue.QueryOutput + { + guard let sectionKeyPath else { + self.init(wrappedValue: wrappedValue, statement, database: database) + return + } + self.init( + wrappedValue: wrappedValue, + statement: statement, + sectionBy: SectionBy(sectionKeyPath), + database: database, + scheduler: nil + ) + } + + /// Initializes this property with a query that fetches every row from a table, grouping results + /// into sections. + /// + /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. + /// - sectionKeyPath: A key path to an optional string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + public init( + wrappedValue: [Element] = [], + sectionBy sectionKeyPath: KeyPath?, + database: (any DatabaseReader)? = nil + ) + where Element: StructuredQueriesCore.Table, Element.QueryOutput == Element { + let statement: Select = Element.all.selectStar().asSelect() + guard let sectionKeyPath else { + self.init(wrappedValue: wrappedValue, statement, database: database) + return + } + self.init( + wrappedValue: wrappedValue, + statement: statement, + sectionBy: SectionBy(sectionKeyPath), + database: database, + scheduler: nil + ) + } + + /// Initializes this property with a query associated with the wrapped value, grouping results + /// into sections. + /// + /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. + /// - statement: A query associated with the wrapped value. + /// - sectionKeyPath: A key path to an optional string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + public init( + wrappedValue: [Element] = [], + _ statement: S, + sectionBy sectionKeyPath: KeyPath?, + database: (any DatabaseReader)? = nil + ) + where + Element == S.From.QueryOutput, + S.QueryValue == (), + S.From.QueryOutput: Sendable, + S.Joins == () + { + let statement: Select = statement.selectStar() + guard let sectionKeyPath else { + self.init(wrappedValue: wrappedValue, statement, database: database) + return + } + self.init( + wrappedValue: wrappedValue, + statement: statement, + sectionBy: SectionBy(sectionKeyPath), + database: database, + scheduler: nil + ) + } + + /// Initializes this property with a query associated with the wrapped value, grouping results + /// into sections. + /// + /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. + /// - statement: A query associated with the wrapped value. + /// - sectionKeyPath: A key path to an optional string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + public init( + wrappedValue: [Element] = [], + _ statement: some StructuredQueriesCore.Statement, + sectionBy sectionKeyPath: KeyPath?, + database: (any DatabaseReader)? = nil + ) + where + Element == V.QueryOutput, + V.QueryOutput: Sendable + { + guard let sectionKeyPath else { + self.init(wrappedValue: wrappedValue, statement, database: database) + return + } + self.init( + wrappedValue: wrappedValue, + statement: statement, + sectionBy: SectionBy(sectionKeyPath), + database: database, + scheduler: nil + ) + } + + /// Initializes this property with a query associated with the wrapped value, grouping results + /// into sections. + /// + /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. + /// - statement: A query associated with the wrapped value. + /// - sectionKeyPath: A key path to an optional string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + public init>( + wrappedValue: [Element] = [], + _ statement: S, + sectionBy sectionKeyPath: KeyPath?, + database: (any DatabaseReader)? = nil + ) + where + Element: QueryRepresentable, + Element == S.QueryValue.QueryOutput + { + guard let sectionKeyPath else { + self.init(wrappedValue: wrappedValue, statement, database: database) + return + } + self.init( + wrappedValue: wrappedValue, + statement: statement, + sectionBy: SectionBy(sectionKeyPath), + database: database, + scheduler: nil + ) + } +} + +extension FetchAll { + /// Initializes this property with a query that fetches every row from a table, grouping results + /// into sections. + /// + /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. + /// - sectionKeyPath: A key path to a string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - scheduler: The scheduler to observe from. By default, database observation is performed + /// asynchronously on the main queue. + public init( + wrappedValue: [Element] = [], + sectionBy sectionKeyPath: KeyPath?, + database: (any DatabaseReader)? = nil, + scheduler: some ValueObservationScheduler & Hashable + ) + where Element: StructuredQueriesCore.Table, Element.QueryOutput == Element { + let statement: Select = Element.all.selectStar().asSelect() + guard let sectionKeyPath else { + self.init(wrappedValue: wrappedValue, statement, database: database, scheduler: scheduler) + return + } + self.init( + wrappedValue: wrappedValue, + statement: statement, + sectionBy: SectionBy(sectionKeyPath), + database: database, + scheduler: scheduler + ) + } + + /// Initializes this property with a query associated with the wrapped value, grouping results + /// into sections. + /// + /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. + /// - statement: A query associated with the wrapped value. + /// - sectionKeyPath: A key path to a string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - scheduler: The scheduler to observe from. By default, database observation is performed + /// asynchronously on the main queue. + public init( + wrappedValue: [Element] = [], + _ statement: S, + sectionBy sectionKeyPath: KeyPath?, + database: (any DatabaseReader)? = nil, + scheduler: some ValueObservationScheduler & Hashable + ) + where + Element == S.From.QueryOutput, + S.QueryValue == (), + S.From.QueryOutput: Sendable, + S.Joins == () + { + let statement: Select = statement.selectStar() + guard let sectionKeyPath else { + self.init(wrappedValue: wrappedValue, statement, database: database, scheduler: scheduler) + return + } + self.init( + wrappedValue: wrappedValue, + statement: statement, + sectionBy: SectionBy(sectionKeyPath), + database: database, + scheduler: scheduler + ) + } + + /// Initializes this property with a query associated with the wrapped value, grouping results + /// into sections. + /// + /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. + /// - statement: A query associated with the wrapped value. + /// - sectionKeyPath: A key path to a string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - scheduler: The scheduler to observe from. By default, database observation is performed + /// asynchronously on the main queue. + public init( + wrappedValue: [Element] = [], + _ statement: some StructuredQueriesCore.Statement, + sectionBy sectionKeyPath: KeyPath?, + database: (any DatabaseReader)? = nil, + scheduler: some ValueObservationScheduler & Hashable + ) + where + Element == V.QueryOutput, + V.QueryOutput: Sendable + { + guard let sectionKeyPath else { + self.init(wrappedValue: wrappedValue, statement, database: database, scheduler: scheduler) + return + } + self.init( + wrappedValue: wrappedValue, + statement: statement, + sectionBy: SectionBy(sectionKeyPath), + database: database, + scheduler: scheduler + ) + } + + /// Initializes this property with a query associated with the wrapped value, grouping results + /// into sections. + /// + /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. + /// - statement: A query associated with the wrapped value. + /// - sectionKeyPath: A key path to a string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - scheduler: The scheduler to observe from. By default, database observation is performed + /// asynchronously on the main queue. + public init>( + wrappedValue: [Element] = [], + _ statement: S, + sectionBy sectionKeyPath: KeyPath?, + database: (any DatabaseReader)? = nil, + scheduler: some ValueObservationScheduler & Hashable + ) + where + Element: QueryRepresentable, + Element == S.QueryValue.QueryOutput + { + guard let sectionKeyPath else { + self.init(wrappedValue: wrappedValue, statement, database: database, scheduler: scheduler) + return + } + self.init( + wrappedValue: wrappedValue, + statement: statement, + sectionBy: SectionBy(sectionKeyPath), + database: database, + scheduler: scheduler + ) + } + + /// Initializes this property with a query that fetches every row from a table, grouping results + /// into sections. + /// + /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. + /// - sectionKeyPath: A key path to an optional string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - scheduler: The scheduler to observe from. By default, database observation is performed + /// asynchronously on the main queue. + public init( + wrappedValue: [Element] = [], + sectionBy sectionKeyPath: KeyPath?, + database: (any DatabaseReader)? = nil, + scheduler: some ValueObservationScheduler & Hashable + ) + where Element: StructuredQueriesCore.Table, Element.QueryOutput == Element { + let statement: Select = Element.all.selectStar().asSelect() + guard let sectionKeyPath else { + self.init(wrappedValue: wrappedValue, statement, database: database, scheduler: scheduler) + return + } + self.init( + wrappedValue: wrappedValue, + statement: statement, + sectionBy: SectionBy(sectionKeyPath), + database: database, + scheduler: scheduler + ) + } + + /// Initializes this property with a query associated with the wrapped value, grouping results + /// into sections. + /// + /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. + /// - statement: A query associated with the wrapped value. + /// - sectionKeyPath: A key path to an optional string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - scheduler: The scheduler to observe from. By default, database observation is performed + /// asynchronously on the main queue. + public init( + wrappedValue: [Element] = [], + _ statement: S, + sectionBy sectionKeyPath: KeyPath?, + database: (any DatabaseReader)? = nil, + scheduler: some ValueObservationScheduler & Hashable + ) + where + Element == S.From.QueryOutput, + S.QueryValue == (), + S.From.QueryOutput: Sendable, + S.Joins == () + { + let statement: Select = statement.selectStar() + guard let sectionKeyPath else { + self.init(wrappedValue: wrappedValue, statement, database: database, scheduler: scheduler) + return + } + self.init( + wrappedValue: wrappedValue, + statement: statement, + sectionBy: SectionBy(sectionKeyPath), + database: database, + scheduler: scheduler + ) + } + + /// Initializes this property with a query associated with the wrapped value, grouping results + /// into sections. + /// + /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. + /// - statement: A query associated with the wrapped value. + /// - sectionKeyPath: A key path to an optional string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - scheduler: The scheduler to observe from. By default, database observation is performed + /// asynchronously on the main queue. + public init( + wrappedValue: [Element] = [], + _ statement: some StructuredQueriesCore.Statement, + sectionBy sectionKeyPath: KeyPath?, + database: (any DatabaseReader)? = nil, + scheduler: some ValueObservationScheduler & Hashable + ) + where + Element == V.QueryOutput, + V.QueryOutput: Sendable + { + guard let sectionKeyPath else { + self.init(wrappedValue: wrappedValue, statement, database: database, scheduler: scheduler) + return + } + self.init( + wrappedValue: wrappedValue, + statement: statement, + sectionBy: SectionBy(sectionKeyPath), + database: database, + scheduler: scheduler + ) + } + + /// Initializes this property with a query associated with the wrapped value, grouping results + /// into sections. + /// + /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. + /// - statement: A query associated with the wrapped value. + /// - sectionKeyPath: A key path to an optional string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - scheduler: The scheduler to observe from. By default, database observation is performed + /// asynchronously on the main queue. + public init>( + wrappedValue: [Element] = [], + _ statement: S, + sectionBy sectionKeyPath: KeyPath?, + database: (any DatabaseReader)? = nil, + scheduler: some ValueObservationScheduler & Hashable + ) + where + Element: QueryRepresentable, + Element == S.QueryValue.QueryOutput + { + guard let sectionKeyPath else { + self.init(wrappedValue: wrappedValue, statement, database: database, scheduler: scheduler) + return + } + self.init( + wrappedValue: wrappedValue, + statement: statement, + sectionBy: SectionBy(sectionKeyPath), + database: database, + scheduler: scheduler + ) + } +} + +#if canImport(SwiftUI) + extension FetchAll { + /// Initializes this property with a query that fetches every row from a table, grouping + /// results into sections. + /// + /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. + /// - sectionKeyPath: A key path to a string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - animation: The animation to use for user interface changes that result from changes to + /// the fetched results. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + public init( + wrappedValue: [Element] = [], + sectionBy sectionKeyPath: KeyPath?, + database: (any DatabaseReader)? = nil, + animation: Animation + ) + where Element: StructuredQueriesCore.Table, Element.QueryOutput == Element { + self.init( + wrappedValue: wrappedValue, + sectionBy: sectionKeyPath, + database: database, + scheduler: .animation(animation) + ) + } + + /// Initializes this property with a query associated with the wrapped value, grouping results + /// into sections. + /// + /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. + /// - statement: A query associated with the wrapped value. + /// - sectionKeyPath: A key path to a string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - animation: The animation to use for user interface changes that result from changes to + /// the fetched results. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + public init( + wrappedValue: [Element] = [], + _ statement: S, + sectionBy sectionKeyPath: KeyPath?, + database: (any DatabaseReader)? = nil, + animation: Animation + ) + where + Element == S.From.QueryOutput, + S.QueryValue == (), + S.From.QueryOutput: Sendable, + S.Joins == () + { + self.init( + wrappedValue: wrappedValue, + statement, + sectionBy: sectionKeyPath, + database: database, + scheduler: .animation(animation) + ) + } + + /// Initializes this property with a query associated with the wrapped value, grouping results + /// into sections. + /// + /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. + /// - statement: A query associated with the wrapped value. + /// - sectionKeyPath: A key path to a string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - animation: The animation to use for user interface changes that result from changes to + /// the fetched results. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + public init( + wrappedValue: [Element] = [], + _ statement: some StructuredQueriesCore.Statement, + sectionBy sectionKeyPath: KeyPath?, + database: (any DatabaseReader)? = nil, + animation: Animation + ) + where + Element == V.QueryOutput, + V.QueryOutput: Sendable + { + self.init( + wrappedValue: wrappedValue, + statement, + sectionBy: sectionKeyPath, + database: database, + scheduler: .animation(animation) + ) + } + + /// Initializes this property with a query associated with the wrapped value, grouping results + /// into sections. + /// + /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. + /// - statement: A query associated with the wrapped value. + /// - sectionKeyPath: A key path to a string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - animation: The animation to use for user interface changes that result from changes to + /// the fetched results. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + public init>( + wrappedValue: [Element] = [], + _ statement: S, + sectionBy sectionKeyPath: KeyPath?, + database: (any DatabaseReader)? = nil, + animation: Animation + ) + where + Element: QueryRepresentable, + Element == S.QueryValue.QueryOutput + { + self.init( + wrappedValue: wrappedValue, + statement, + sectionBy: sectionKeyPath, + database: database, + scheduler: .animation(animation) + ) + } + + /// Initializes this property with a query that fetches every row from a table, grouping + /// results into sections. + /// + /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. + /// - sectionKeyPath: A key path to an optional string to group results by, or `nil` for + /// no grouping. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - animation: The animation to use for user interface changes that result from changes to + /// the fetched results. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + public init( + wrappedValue: [Element] = [], + sectionBy sectionKeyPath: KeyPath?, + database: (any DatabaseReader)? = nil, + animation: Animation + ) + where Element: StructuredQueriesCore.Table, Element.QueryOutput == Element { + self.init( + wrappedValue: wrappedValue, + sectionBy: sectionKeyPath, + database: database, + scheduler: .animation(animation) + ) + } + + /// Initializes this property with a query associated with the wrapped value, grouping results + /// into sections. + /// + /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. + /// - statement: A query associated with the wrapped value. + /// - sectionKeyPath: A key path to an optional string to group results by, or `nil` for + /// no grouping. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - animation: The animation to use for user interface changes that result from changes to + /// the fetched results. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + public init( + wrappedValue: [Element] = [], + _ statement: S, + sectionBy sectionKeyPath: KeyPath?, + database: (any DatabaseReader)? = nil, + animation: Animation + ) + where + Element == S.From.QueryOutput, + S.QueryValue == (), + S.From.QueryOutput: Sendable, + S.Joins == () + { + self.init( + wrappedValue: wrappedValue, + statement, + sectionBy: sectionKeyPath, + database: database, + scheduler: .animation(animation) + ) + } + + /// Initializes this property with a query associated with the wrapped value, grouping results + /// into sections. + /// + /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. + /// - statement: A query associated with the wrapped value. + /// - sectionKeyPath: A key path to an optional string to group results by, or `nil` for + /// no grouping. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - animation: The animation to use for user interface changes that result from changes to + /// the fetched results. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + public init( + wrappedValue: [Element] = [], + _ statement: some StructuredQueriesCore.Statement, + sectionBy sectionKeyPath: KeyPath?, + database: (any DatabaseReader)? = nil, + animation: Animation + ) + where + Element == V.QueryOutput, + V.QueryOutput: Sendable + { + self.init( + wrappedValue: wrappedValue, + statement, + sectionBy: sectionKeyPath, + database: database, + scheduler: .animation(animation) + ) + } + + /// Initializes this property with a query associated with the wrapped value, grouping results + /// into sections. + /// + /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. + /// - statement: A query associated with the wrapped value. + /// - sectionKeyPath: A key path to an optional string to group results by, or `nil` for + /// no grouping. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - animation: The animation to use for user interface changes that result from changes to + /// the fetched results. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + public init>( + wrappedValue: [Element] = [], + _ statement: S, + sectionBy sectionKeyPath: KeyPath?, + database: (any DatabaseReader)? = nil, + animation: Animation + ) + where + Element: QueryRepresentable, + Element == S.QueryValue.QueryOutput + { + self.init( + wrappedValue: wrappedValue, + statement, + sectionBy: sectionKeyPath, + database: database, + scheduler: .animation(animation) + ) + } + } +#endif + +extension FetchAll { + /// Replaces the wrapped value with data from the given query, grouping results into sections. + /// + /// The given key path replaces any sectioning previously applied to this property, and is used + /// by all subsequent loads. Pass `nil` to remove sectioning from this property. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - sectionKeyPath: A key path to a string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - Returns: A subscription associated with the observation. + @discardableResult + public func load( + _ statement: S, + sectionBy sectionKeyPath: KeyPath?, + database: (any DatabaseReader)? = nil + ) async throws -> FetchSubscription + where + Element == S.From.QueryOutput, + S.QueryValue == (), + S.From.QueryOutput: Sendable, + S.Joins == () + { + let statement: Select = statement.selectStar() + return try await loadSections( + statement: statement, + sectionBy: sectionKeyPath.map { SectionBy($0) }, + database: database, + scheduler: nil + ) + } + + /// Replaces the wrapped value with data from the given query, grouping results into sections. + /// + /// The given key path replaces any sectioning previously applied to this property, and is used + /// by all subsequent loads. Pass `nil` to remove sectioning from this property. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - sectionKeyPath: A key path to a string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - Returns: A subscription associated with the observation. + @discardableResult + public func load( + _ statement: some StructuredQueriesCore.Statement, + sectionBy sectionKeyPath: KeyPath?, + database: (any DatabaseReader)? = nil + ) async throws -> FetchSubscription + where + Element == V.QueryOutput, + V.QueryOutput: Sendable + { + try await loadSections( + statement: statement, + sectionBy: sectionKeyPath.map { SectionBy($0) }, + database: database, + scheduler: nil + ) + } + + /// Replaces the wrapped value with data from the given query, grouping results into sections. + /// + /// A `nil` value at the given key path is grouped into a section named by the empty string. The + /// given key path replaces any sectioning previously applied to this property, and is used by + /// all subsequent loads. Pass `nil` to remove sectioning from this property. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - sectionKeyPath: A key path to an optional string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - Returns: A subscription associated with the observation. + @discardableResult + public func load( + _ statement: S, + sectionBy sectionKeyPath: KeyPath?, + database: (any DatabaseReader)? = nil + ) async throws -> FetchSubscription + where + Element == S.From.QueryOutput, + S.QueryValue == (), + S.From.QueryOutput: Sendable, + S.Joins == () + { + let statement: Select = statement.selectStar() + return try await loadSections( + statement: statement, + sectionBy: sectionKeyPath.map { SectionBy($0) }, + database: database, + scheduler: nil + ) + } + + /// Replaces the wrapped value with data from the given query, grouping results into sections. + /// + /// A `nil` value at the given key path is grouped into a section named by the empty string. The + /// given key path replaces any sectioning previously applied to this property, and is used by + /// all subsequent loads. Pass `nil` to remove sectioning from this property. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - sectionKeyPath: A key path to an optional string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - Returns: A subscription associated with the observation. + @discardableResult + public func load( + _ statement: some StructuredQueriesCore.Statement, + sectionBy sectionKeyPath: KeyPath?, + database: (any DatabaseReader)? = nil + ) async throws -> FetchSubscription + where + Element == V.QueryOutput, + V.QueryOutput: Sendable + { + try await loadSections( + statement: statement, + sectionBy: sectionKeyPath.map { SectionBy($0) }, + database: database, + scheduler: nil + ) + } + + /// Replaces the wrapped value with data from the given query, grouping results into sections. + /// + /// The given key path replaces any sectioning previously applied to this property, and is used + /// by all subsequent loads. Pass `nil` to remove sectioning from this property. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - sectionKeyPath: A key path to a string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - scheduler: The scheduler to observe from. By default, database observation is performed + /// asynchronously on the main queue. + /// - Returns: A subscription associated with the observation. + @discardableResult + public func load( + _ statement: S, + sectionBy sectionKeyPath: KeyPath?, + database: (any DatabaseReader)? = nil, + scheduler: some ValueObservationScheduler & Hashable + ) async throws -> FetchSubscription + where + Element == S.From.QueryOutput, + S.QueryValue == (), + S.From.QueryOutput: Sendable, + S.Joins == () + { + let statement: Select = statement.selectStar() + return try await loadSections( + statement: statement, + sectionBy: sectionKeyPath.map { SectionBy($0) }, + database: database, + scheduler: scheduler + ) + } + + /// Replaces the wrapped value with data from the given query, grouping results into sections. + /// + /// The given key path replaces any sectioning previously applied to this property, and is used + /// by all subsequent loads. Pass `nil` to remove sectioning from this property. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - sectionKeyPath: A key path to a string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - scheduler: The scheduler to observe from. By default, database observation is performed + /// asynchronously on the main queue. + /// - Returns: A subscription associated with the observation. + @discardableResult + public func load( + _ statement: some StructuredQueriesCore.Statement, + sectionBy sectionKeyPath: KeyPath?, + database: (any DatabaseReader)? = nil, + scheduler: some ValueObservationScheduler & Hashable + ) async throws -> FetchSubscription + where + Element == V.QueryOutput, + V.QueryOutput: Sendable + { + try await loadSections( + statement: statement, + sectionBy: sectionKeyPath.map { SectionBy($0) }, + database: database, + scheduler: scheduler + ) + } + + /// Replaces the wrapped value with data from the given query, grouping results into sections. + /// + /// A `nil` value at the given key path is grouped into a section named by the empty string. The + /// given key path replaces any sectioning previously applied to this property, and is used by + /// all subsequent loads. Pass `nil` to remove sectioning from this property. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - sectionKeyPath: A key path to an optional string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - scheduler: The scheduler to observe from. By default, database observation is performed + /// asynchronously on the main queue. + /// - Returns: A subscription associated with the observation. + @discardableResult + public func load( + _ statement: S, + sectionBy sectionKeyPath: KeyPath?, + database: (any DatabaseReader)? = nil, + scheduler: some ValueObservationScheduler & Hashable + ) async throws -> FetchSubscription + where + Element == S.From.QueryOutput, + S.QueryValue == (), + S.From.QueryOutput: Sendable, + S.Joins == () + { + let statement: Select = statement.selectStar() + return try await loadSections( + statement: statement, + sectionBy: sectionKeyPath.map { SectionBy($0) }, + database: database, + scheduler: scheduler + ) + } + + /// Replaces the wrapped value with data from the given query, grouping results into sections. + /// + /// A `nil` value at the given key path is grouped into a section named by the empty string. The + /// given key path replaces any sectioning previously applied to this property, and is used by + /// all subsequent loads. Pass `nil` to remove sectioning from this property. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - sectionKeyPath: A key path to an optional string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - scheduler: The scheduler to observe from. By default, database observation is performed + /// asynchronously on the main queue. + /// - Returns: A subscription associated with the observation. + @discardableResult + public func load( + _ statement: some StructuredQueriesCore.Statement, + sectionBy sectionKeyPath: KeyPath?, + database: (any DatabaseReader)? = nil, + scheduler: some ValueObservationScheduler & Hashable + ) async throws -> FetchSubscription + where + Element == V.QueryOutput, + V.QueryOutput: Sendable + { + try await loadSections( + statement: statement, + sectionBy: sectionKeyPath.map { SectionBy($0) }, + database: database, + scheduler: scheduler + ) + } + + func loadSections( + statement: some StructuredQueriesCore.Statement, + sectionBy newSectionBy: SectionBy?, + database: (any DatabaseReader)?, + scheduler: (any ValueObservationScheduler & Hashable)? + ) async throws -> FetchSubscription + where + Element == V.QueryOutput, + V.QueryOutput: Sendable + { + sectionedBy.setValue(newSectionBy) + guard let newSectionBy else { + sectionedReader.projectedValue = SharedReader(value: ResultsSectionCollection()) + try await sharedReader.load( + FetchKey( + request: FetchAllStatementValueRequest(statement: statement), + database: database, + scheduler: scheduler + ) + ) + return FetchSubscription(sharedReader: sharedReader) + } + defer { + sharedReader.projectedValue = sectionedReader[dynamicMember: \.elements].projectedValue + } + try await sectionedReader.load( + FetchKey( + request: FetchAllSectionedStatementValueRequest( + statement: statement, + sectionBy: newSectionBy + ), + database: database, + scheduler: scheduler + ) + ) + return FetchSubscription(sharedReader: sharedReader, sectionedReader: sectionedReader) + } +} + +#if canImport(SwiftUI) + extension FetchAll { + /// Replaces the wrapped value with data from the given query, grouping results into sections. + /// + /// The given key path replaces any sectioning previously applied to this property, and is + /// used by all subsequent loads. Pass `nil` to remove sectioning from this property. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - sectionKeyPath: A key path to a string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - animation: The animation to use for user interface changes that result from changes to + /// the fetched results. + /// - Returns: A subscription associated with the observation. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @discardableResult + public func load( + _ statement: S, + sectionBy sectionKeyPath: KeyPath?, + database: (any DatabaseReader)? = nil, + animation: Animation? + ) async throws -> FetchSubscription + where + Element == S.From.QueryOutput, + S.QueryValue == (), + S.From.QueryOutput: Sendable, + S.Joins == () + { + try await load( + statement, + sectionBy: sectionKeyPath, + database: database, + scheduler: .animation(animation) + ) + } + + /// Replaces the wrapped value with data from the given query, grouping results into sections. + /// + /// The given key path replaces any sectioning previously applied to this property, and is + /// used by all subsequent loads. Pass `nil` to remove sectioning from this property. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - sectionKeyPath: A key path to a string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - animation: The animation to use for user interface changes that result from changes to + /// the fetched results. + /// - Returns: A subscription associated with the observation. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @discardableResult + public func load( + _ statement: some StructuredQueriesCore.Statement, + sectionBy sectionKeyPath: KeyPath?, + database: (any DatabaseReader)? = nil, + animation: Animation? + ) async throws -> FetchSubscription + where + Element == V.QueryOutput, + V.QueryOutput: Sendable + { + try await load( + statement, + sectionBy: sectionKeyPath, + database: database, + scheduler: .animation(animation) + ) + } + + /// Replaces the wrapped value with data from the given query, grouping results into sections. + /// The given key path replaces any sectioning previously applied to this property, and is + /// used by all subsequent loads. Pass `nil` to remove sectioning from this property. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - sectionKeyPath: A key path to an optional string to group results by, or `nil` for + /// no grouping. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - animation: The animation to use for user interface changes that result from changes to + /// the fetched results. + /// - Returns: A subscription associated with the observation. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @discardableResult + public func load( + _ statement: S, + sectionBy sectionKeyPath: KeyPath?, + database: (any DatabaseReader)? = nil, + animation: Animation? + ) async throws -> FetchSubscription + where + Element == S.From.QueryOutput, + S.QueryValue == (), + S.From.QueryOutput: Sendable, + S.Joins == () + { + try await load( + statement, + sectionBy: sectionKeyPath, + database: database, + scheduler: .animation(animation) + ) + } + + /// Replaces the wrapped value with data from the given query, grouping results into sections. + /// The given key path replaces any sectioning previously applied to this property, and is + /// used by all subsequent loads. Pass `nil` to remove sectioning from this property. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - sectionKeyPath: A key path to an optional string to group results by, or `nil` for + /// no grouping. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - animation: The animation to use for user interface changes that result from changes to + /// the fetched results. + /// - Returns: A subscription associated with the observation. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @discardableResult + public func load( + _ statement: some StructuredQueriesCore.Statement, + sectionBy sectionKeyPath: KeyPath?, + database: (any DatabaseReader)? = nil, + animation: Animation? + ) async throws -> FetchSubscription + where + Element == V.QueryOutput, + V.QueryOutput: Sendable + { + try await load( + statement, + sectionBy: sectionKeyPath, + database: database, + scheduler: .animation(animation) + ) + } + } +#endif + +struct FetchAllSectionedStatementValueRequest: FetchKeyRequest +where Value.QueryOutput: Sendable { + let statement: SQLQueryExpression + let sectionedBy: SectionBy + + init( + statement: some StructuredQueriesCore.Statement, + sectionBy: SectionBy + ) { + self.statement = SQLQueryExpression(statement) + self.sectionedBy = sectionBy + } + + func fetch(_ db: Database) throws -> ResultsSectionCollection { + try ResultsSectionCollection( + cursor: statement.fetchCursor(db), + sectionName: sectionedBy.name + ) + } + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.statement.query == rhs.statement.query && lhs.sectionedBy == rhs.sectionedBy + } + + func hash(into hasher: inout Hasher) { + hasher.combine(statement.query) + hasher.combine(sectionedBy) + } +} diff --git a/Sources/SQLiteData/FetchAll.swift b/Sources/SQLiteData/FetchAll.swift index ec118cdf..582b276a 100644 --- a/Sources/SQLiteData/FetchAll.swift +++ b/Sources/SQLiteData/FetchAll.swift @@ -1,7 +1,7 @@ +import ConcurrencyExtras public import GRDB public import Sharing public import StructuredQueriesCore -public import Sharing #if canImport(Combine) public import Combine @@ -22,11 +22,46 @@ 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 internal(set) var sharedReader: SharedReader<[Element]> { + @storageRestrictions(initializes: box, state) + init(initialValue) { + let box = FetchBox(sharedReader: initialValue, extra: FetchAllSections()) + self.box = box + state = SwiftUI.State(wrappedValue: box) + } + get { state.wrappedValue.sharedReader } + nonmutating set { state.wrappedValue.sharedReader = newValue } + } + + var sectionedReader: SharedReader> { + get { state.wrappedValue.extra.sectionedReader } + nonmutating set { state.wrappedValue.extra.sectionedReader = newValue } + } + + var sectionedBy: LockIsolated?> { + state.wrappedValue.extra.sectionedBy + } + + private let box: FetchBox<[Element], FetchAllSections> + 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 internal(set) var sharedReader: SharedReader<[Element]> = SharedReader(value: []) + + var sectionedReader: SharedReader> = + SharedReader(value: ResultsSectionCollection()) + + let sectionedBy = LockIsolated?>(nil) + #endif /// A collection of data associated with the underlying query. public var wrappedValue: [Element] { @@ -39,7 +74,11 @@ public struct FetchAll: Sendable { /// ``isLoading``, and ``publisher``. public var projectedValue: Self { get { self } - nonmutating set { sharedReader.projectedValue = newValue.sharedReader.projectedValue } + nonmutating set { + sharedReader.projectedValue = newValue.sharedReader.projectedValue + sectionedReader.projectedValue = newValue.sectionedReader.projectedValue + sectionedBy.setValue(newValue.sectionedBy.value) + } } /// Returns a ``sharedReader`` for the given key path. @@ -139,13 +178,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 +202,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. @@ -211,6 +248,14 @@ public struct FetchAll: Sendable { Element == V.QueryOutput, V.QueryOutput: Sendable { + if let sectionBy = sectionedBy.value { + return try await loadSections( + statement: statement, + sectionBy: sectionBy, + database: database, + scheduler: nil + ) + } try await sharedReader.load( .fetch( FetchAllStatementValueRequest(statement: statement), @@ -219,10 +264,29 @@ public struct FetchAll: Sendable { ) return FetchSubscription(sharedReader: sharedReader) } + + #if !canImport(SwiftUI) + @_transparent + #endif + 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 +358,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 +385,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. @@ -377,6 +437,14 @@ extension FetchAll { Element == V.QueryOutput, V.QueryOutput: Sendable { + if let sectionBy = sectionedBy.value { + return try await loadSections( + statement: statement, + sectionBy: sectionBy, + database: database, + scheduler: scheduler + ) + } try await sharedReader.load( .fetch( FetchAllStatementValueRequest(statement: statement), @@ -396,17 +464,27 @@ extension FetchAll: CustomReflectable { extension FetchAll: Equatable where Element: Equatable { public static func == (lhs: Self, rhs: Self) -> Bool { - lhs.sharedReader == rhs.sharedReader + lhs.sharedReader == rhs.sharedReader && lhs.sectionedBy.value == rhs.sectionedBy.value } } #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, @@ -566,6 +644,14 @@ extension FetchAll: Equatable where Element: Equatable { Element == V.QueryOutput, V.QueryOutput: Sendable { + if let sectionBy = sectionedBy.value { + return try await loadSections( + statement: statement, + sectionBy: sectionBy, + database: database, + scheduler: AnimatedScheduler(animation: animation) + ) + } try await sharedReader.load( .fetch( FetchAllStatementValueRequest(statement: statement), @@ -578,7 +664,7 @@ extension FetchAll: Equatable where Element: Equatable { } #endif -private struct FetchAllStatementValueRequest: StatementKeyRequest { +struct FetchAllStatementValueRequest: StatementKeyRequest { let statement: SQLQueryExpression init(statement: some StructuredQueriesCore.Statement) { self.statement = SQLQueryExpression(statement) @@ -587,3 +673,11 @@ private struct FetchAllStatementValueRequest: Stateme try statement.fetchAll(db) } } + +#if canImport(SwiftUI) + struct FetchAllSections: Sendable { + var sectionedReader: SharedReader> = + SharedReader(value: ResultsSectionCollection()) + let sectionedBy = LockIsolated?>(nil) + } +#endif diff --git a/Sources/SQLiteData/FetchOne.swift b/Sources/SQLiteData/FetchOne.swift index 4e6b799d..c3b4ee55 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/FetchSubscription.swift b/Sources/SQLiteData/FetchSubscription.swift index de794216..59bbdd81 100644 --- a/Sources/SQLiteData/FetchSubscription.swift +++ b/Sources/SQLiteData/FetchSubscription.swift @@ -22,6 +22,17 @@ public struct FetchSubscription: Sendable { onCancel = { sharedReader.projectedValue = SharedReader(value: sharedReader.wrappedValue) } } + init( + sharedReader: SharedReader<[Element]>, + sectionedReader: SharedReader> + ) { + onCancel = { + let sections = sectionedReader.wrappedValue + sectionedReader.projectedValue = SharedReader(value: sections) + sharedReader.projectedValue = SharedReader(value: sections.elements) + } + } + /// An async handle to the given fetch observation. /// /// This handle will suspend until the current task is cancelled, at which point it will terminate diff --git a/Sources/SQLiteData/Internal/FetchBox.swift b/Sources/SQLiteData/Internal/FetchBox.swift new file mode 100644 index 00000000..20ff74bf --- /dev/null +++ b/Sources/SQLiteData/Internal/FetchBox.swift @@ -0,0 +1,106 @@ +#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, extra: Extra) { + storage = Storage(sharedReader: sharedReader, extra: extra) + } + + 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 } } + } + + var extra: Extra { + get { lock.withLock { storage.extra } } + set { lock.withLock { storage.extra = 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 extra: Extra + var swiftUICancellable: AnyCancellable? + #if DEBUG + var hasReportedIgnoredReinitialization = false + #endif + + init(sharedReader: SharedReader, extra: Extra) { + self.sharedReader = sharedReader + self.initialValue = sharedReader.wrappedValue + self.extra = extra + } + } + } + + extension FetchBox where Extra == Void { + convenience init(sharedReader: SharedReader) { + self.init(sharedReader: sharedReader, extra: ()) + } + } + + 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/Sources/SQLiteData/ResultsSectionCollection.swift b/Sources/SQLiteData/ResultsSectionCollection.swift new file mode 100644 index 00000000..c30293fa --- /dev/null +++ b/Sources/SQLiteData/ResultsSectionCollection.swift @@ -0,0 +1,187 @@ +import ConcurrencyExtras +import GRDB +import OrderedCollections + +/// A collection of query results grouped into sections. +/// +/// You do not create this collection directly. Instead, initialize a ``FetchAll`` property with a +/// `sectionBy:` key path and access this collection from the projected value's +/// ``FetchAll/sections`` property: +/// +/// ```swift +/// @FetchAll(Reminder.order(by: \.category), sectionBy: \.category) +/// var reminders +/// +/// var body: some View { +/// List { +/// ForEach($reminders.sections) { section in +/// Section(section.name) { +/// ForEach(section) { reminder in +/// Text(reminder.title) +/// } +/// } +/// } +/// } +/// } +/// ``` +/// +/// Results are grouped into a section for each distinct value at the key path. Sections are +/// ordered by the position of their first element in the query's results, and elements within a +/// section follow the query's order. To control the order of sections, order the query by the +/// sectioned column. +public struct ResultsSectionCollection { + let elements: [Element] + private let elementIndicesBySectionName: OrderedDictionary + + init() { + elements = [] + elementIndicesBySectionName = [:] + } + + init(elements: some Sequence, sectionName: (Element) -> SectionName) { + var allElements: [Element] = [] + var elementIndicesBySectionName: OrderedDictionary = [:] + for element in elements { + elementIndicesBySectionName[sectionName(element), default: []].append(allElements.count) + allElements.append(element) + } + self.elements = allElements + self.elementIndicesBySectionName = elementIndicesBySectionName + } + + init(elements: [Element], sectionName: SectionName) { + self.elements = elements + self.elementIndicesBySectionName = + elements.isEmpty ? [:] : [sectionName: Array(elements.indices)] + } + + init(cursor: QueryCursor, sectionName: (Element) -> SectionName) throws { + var elements: [Element] = [] + while let element = try cursor.next() { + elements.append(element) + } + self.init(elements: elements, sectionName: sectionName) + } + + /// The names of each section in the collection, in the order the sections appear. + public var sectionNames: [SectionName] { + Array(elementIndicesBySectionName.keys) + } + + /// Returns the section with the given name, or `nil` if no such section exists. + /// + /// - Parameter name: The name of a section. + public subscript(sectionName name: SectionName) -> ResultsSection? { + elementIndicesBySectionName[name].map { + ResultsSection(name: name, base: elements, elementIndices: $0) + } + } + + /// Returns whether or not the collection contains a section with the given name. + /// + /// - Parameter name: The name of a section. + public func contains(sectionName name: SectionName) -> Bool { + elementIndicesBySectionName.keys.contains(name) + } + + /// Returns the position of the section with the given name, or `nil` if no such section exists. + /// + /// - Parameter name: The name of a section. + public func index(ofSectionNamed name: SectionName) -> Int? { + elementIndicesBySectionName.index(forKey: name) + } +} + +extension ResultsSectionCollection: RandomAccessCollection { + public var startIndex: Int { + elementIndicesBySectionName.elements.startIndex + } + + public var endIndex: Int { + elementIndicesBySectionName.elements.endIndex + } + + public subscript(position: Int) -> ResultsSection { + let (name, elementIndices) = elementIndicesBySectionName.elements[position] + return ResultsSection(name: name, base: elements, elementIndices: elementIndices) + } +} + +extension ResultsSectionCollection: Sendable where Element: Sendable, SectionName: Sendable {} + +extension ResultsSectionCollection: Equatable where Element: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.elementsEqual(rhs) + } +} + +/// A collection of query results in a section, identified by the section's name. +/// +/// See ``ResultsSectionCollection`` for more information. +public struct ResultsSection: Identifiable { + /// The name of the section. + /// + /// This is the value at the `sectionBy:` key path shared by every element in the section. + public let name: SectionName + + private let base: [Element] + private let elementIndices: [Int] + + init(name: SectionName, base: [Element], elementIndices: [Int]) { + self.name = name + self.base = base + self.elementIndices = elementIndices + } + + /// The identity of the section, equivalent to its ``name``. + public var id: SectionName { + name + } +} + +extension ResultsSection: RandomAccessCollection { + public var startIndex: Int { + elementIndices.startIndex + } + + public var endIndex: Int { + elementIndices.endIndex + } + + public subscript(position: Int) -> Element { + base[elementIndices[position]] + } +} + +extension ResultsSection: Sendable where Element: Sendable, SectionName: Sendable {} + +extension ResultsSection: Equatable where Element: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.name == rhs.name && lhs.elementsEqual(rhs) + } +} + +struct SectionBy: Hashable, Sendable { + let keyPath: AnyHashableSendable + let name: @Sendable (Element) -> String + + init(_ keyPath: KeyPath) { + let keyPath = unsafeBitCast(keyPath, to: (any KeyPath & Sendable).self) + self.keyPath = AnyHashableSendable(keyPath) + self.name = { $0[keyPath: keyPath] } + } + + init(_ keyPath: KeyPath) { + let keyPath = unsafeBitCast(keyPath, to: (any KeyPath & Sendable).self) + self.keyPath = AnyHashableSendable(keyPath) + self.name = { $0[keyPath: keyPath] ?? "" } + } + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.keyPath == rhs.keyPath + } + + func hash(into hasher: inout Hasher) { + hasher.combine(keyPath) + } +} diff --git a/Tests/SQLiteDataTests/FetchAllSectionsTests.swift b/Tests/SQLiteDataTests/FetchAllSectionsTests.swift new file mode 100644 index 00000000..f983ccbd --- /dev/null +++ b/Tests/SQLiteDataTests/FetchAllSectionsTests.swift @@ -0,0 +1,268 @@ +import DependenciesTestSupport +import Foundation +import SQLiteData +import Testing + +@Suite(.dependency(\.defaultDatabase, try .database())) +struct FetchAllSectionsTests { + @Dependency(\.defaultDatabase) var database + + @Test func basics() async throws { + @FetchAll(SectionedReminder.order(by: \.id), sectionBy: \.category) var reminders + try await $reminders.load() + + #expect(reminders.map(\.id) == [1, 2, 3, 4, 5]) + #expect($reminders.sections.count == 3) + #expect($reminders.sections.sectionNames == ["Home", "Work", "Errands"]) + #expect($reminders.sections.map(\.name) == ["Home", "Work", "Errands"]) + #expect($reminders.sections[0].map(\.title) == ["Dishes", "Laundry"]) + #expect($reminders.sections[1].map(\.title) == ["Standup", "Review"]) + #expect($reminders.sections[2].map(\.title) == ["Groceries"]) + } + + @Test func sectionOrderFollowsQueryOrder() async throws { + @FetchAll(SectionedReminder.order { $0.category.desc() }, sectionBy: \.category) var reminders + try await $reminders.load() + + #expect($reminders.sections.sectionNames == ["Work", "Home", "Errands"]) + } + + @Test func optionalSectionName() async throws { + @FetchAll(SectionedReminder.order(by: \.id), sectionBy: \.priority) var reminders + try await $reminders.load() + + #expect($reminders.sections.sectionNames == ["high", "low", ""]) + #expect($reminders.sections[sectionName: ""]?.map(\.title) == ["Laundry", "Review"]) + } + + @Test func sectionLookup() async throws { + @FetchAll(SectionedReminder.order(by: \.id), sectionBy: \.category) var reminders + try await $reminders.load() + + let sections = $reminders.sections + #expect(sections[sectionName: "Work"]?.map(\.title) == ["Standup", "Review"]) + #expect(sections[sectionName: "Gym"] == nil) + #expect(sections.contains(sectionName: "Errands")) + #expect(!sections.contains(sectionName: "Gym")) + #expect(sections.index(ofSectionNamed: "Home") == 0) + #expect(sections.index(ofSectionNamed: "Errands") == 2) + #expect(sections.index(ofSectionNamed: "Gym") == nil) + + let work = try #require(sections[sectionName: "Work"]) + #expect(work.id == "Work") + #expect(work.name == "Work") + #expect(work.count == 2) + #expect(work[0].title == "Standup") + } + + @Test func observesChanges() async throws { + @FetchAll(SectionedReminder.order(by: \.id), sectionBy: \.category) var reminders + try await $reminders.load() + #expect($reminders.sections.count == 3) + + try await database.write { db in + try SectionedReminder.insert { + SectionedReminder.Draft(title: "Squats", category: "Gym") + } + .execute(db) + } + try await $reminders.load() + + #expect(reminders.count == 6) + #expect($reminders.sections.sectionNames == ["Home", "Work", "Errands", "Gym"]) + #expect($reminders.sections[sectionName: "Gym"]?.map(\.title) == ["Squats"]) + } + + @Test func loadStatementPreservesSectioning() async throws { + @FetchAll(SectionedReminder.order(by: \.id), sectionBy: \.category) var reminders + try await $reminders.load() + #expect($reminders.sections.count == 3) + + try await $reminders.load(SectionedReminder.where { $0.category.neq("Work") }.order(by: \.id)) + + #expect(reminders.map(\.title) == ["Dishes", "Groceries", "Laundry"]) + #expect($reminders.sections.sectionNames == ["Home", "Errands"]) + #expect($reminders.sections[sectionName: "Home"]?.map(\.title) == ["Dishes", "Laundry"]) + } + + @Test func emptyResults() async throws { + @FetchAll(SectionedReminder.where { $0.id > 100 }, sectionBy: \.category) var reminders + try await $reminders.load() + + #expect(reminders.isEmpty) + #expect($reminders.sections.isEmpty) + #expect($reminders.sections.sectionNames.isEmpty) + } + + @Test func defaultWrappedValueIsSectioned() throws { + let defaults = [ + SectionedReminder(id: 1, title: "A", category: "One"), + SectionedReminder(id: 2, title: "B", category: "Two"), + SectionedReminder(id: 3, title: "C", category: "One"), + ] + @FetchAll( + wrappedValue: defaults, + SectionedReminder.all, + sectionBy: \.category, + database: try DatabaseQueue() + ) + var reminders + + #expect($reminders.loadError != nil) + #expect(reminders == defaults) + #expect($reminders.sections.sectionNames == ["One", "Two"]) + #expect($reminders.sections[0].map(\.title) == ["A", "C"]) + } + + @Test func wholeTable() async throws { + @FetchAll(sectionBy: \SectionedReminder.category) var reminders + try await $reminders.load() + + #expect(reminders.count == 5) + #expect($reminders.sections.sectionNames == ["Home", "Work", "Errands"]) + } + + @Test func reassignment() async throws { + @FetchAll(SectionedReminder.order(by: \.id)) var reminders + try await $reminders.load() + #expect(reminders.count == 5) + + $reminders = FetchAll(SectionedReminder.order(by: \.id), sectionBy: \.category) + try await $reminders.load() + #expect(reminders.count == 5) + #expect($reminders.sections.sectionNames == ["Home", "Work", "Errands"]) + + $reminders = FetchAll(SectionedReminder.order(by: \.id), sectionBy: \.priority) + try await $reminders.load(SectionedReminder.order(by: \.id)) + #expect($reminders.sections.sectionNames == ["high", "low", ""]) + + $reminders = FetchAll(SectionedReminder.order(by: \.title)) + try await $reminders.load() + #expect(reminders.map(\.title) == ["Dishes", "Groceries", "Laundry", "Review", "Standup"]) + #expect($reminders.sections.sectionNames == [""]) + #expect($reminders.sections[0].count == 5) + } + + @Test func nilSectionBy() async throws { + let sectionKeyPath: KeyPath? = nil + @FetchAll(SectionedReminder.order(by: \.id), sectionBy: sectionKeyPath) var reminders + try await $reminders.load() + + #expect(reminders.map(\.id) == [1, 2, 3, 4, 5]) + #expect($reminders.sections.sectionNames == [""]) + #expect($reminders.sections[0].map(\.id) == [1, 2, 3, 4, 5]) + } + + @Test func nilSectionByWholeTable() async throws { + @FetchAll(sectionBy: nil as KeyPath?) var reminders + try await $reminders.load() + + #expect(reminders.count == 5) + #expect($reminders.sections.sectionNames == [""]) + } + + @Test func loadNilSectionBy() async throws { + @FetchAll(SectionedReminder.order(by: \.id), sectionBy: \.category) var reminders + try await $reminders.load() + #expect($reminders.sections.sectionNames == ["Home", "Work", "Errands"]) + + try await $reminders.load( + SectionedReminder.order(by: \.id), + sectionBy: nil as KeyPath? + ) + #expect(reminders.map(\.id) == [1, 2, 3, 4, 5]) + #expect($reminders.sections.sectionNames == [""]) + + try await $reminders.load(SectionedReminder.where { $0.id <= 2 }.order(by: \.id)) + #expect(reminders.map(\.title) == ["Dishes", "Standup"]) + #expect($reminders.sections.sectionNames == [""]) + } + + @Test func loadSectionBy() async throws { + @FetchAll(SectionedReminder.order(by: \.id)) var reminders + try await $reminders.load() + #expect($reminders.sections.sectionNames == [""]) + + try await $reminders.load(SectionedReminder.order(by: \.id), sectionBy: \.category) + #expect(reminders.count == 5) + #expect($reminders.sections.sectionNames == ["Home", "Work", "Errands"]) + + try await $reminders.load(SectionedReminder.order(by: \.id), sectionBy: \.priority) + #expect($reminders.sections.sectionNames == ["high", "low", ""]) + + try await $reminders.load(SectionedReminder.where { $0.id <= 2 }.order(by: \.id)) + #expect(reminders.map(\.title) == ["Dishes", "Standup"]) + #expect($reminders.sections.sectionNames == ["high"]) + } + + @Test func equatable() async throws { + @FetchAll(SectionedReminder.order(by: \.id), sectionBy: \.category) var byCategory + @FetchAll(SectionedReminder.order(by: \.id), sectionBy: \.category) var byCategoryToo + @FetchAll(SectionedReminder.order(by: \.id), sectionBy: \.priority) var byPriority + @FetchAll(SectionedReminder.order(by: \.id)) var flat + try await $byCategory.load() + try await $byCategoryToo.load() + try await $byPriority.load() + try await $flat.load() + + #expect(byCategory == byCategoryToo) + #expect($byCategory == $byCategoryToo) + #expect($byCategory != $byPriority) + #expect($byCategory != $flat) + } + + @Test func sectionsAccessWithoutSectionBy() async throws { + @FetchAll var reminders: [SectionedReminder] + try await $reminders.load() + + #expect(!reminders.isEmpty) + #expect($reminders.sections.count == 1) + #expect($reminders.sections.sectionNames == [""]) + #expect($reminders.sections[sectionName: ""]?.map(\.id) == reminders.map(\.id)) + } + + @Test func sectionsAccessWithoutSectionByEmptyResults() async throws { + @FetchAll(SectionedReminder.where { $0.id > 100 }) var reminders + try await $reminders.load() + + #expect(reminders.isEmpty) + #expect($reminders.sections.isEmpty) + } +} + +@Table +private struct SectionedReminder: Equatable, Identifiable { + let id: Int + var title = "" + var category = "" + var priority: String? +} + +extension DatabaseWriter where Self == DatabaseQueue { + fileprivate static func database() throws -> DatabaseQueue { + let database = try DatabaseQueue() + try database.write { db in + try #sql( + """ + CREATE TABLE "sectionedReminders" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "title" TEXT NOT NULL DEFAULT '', + "category" TEXT NOT NULL DEFAULT '', + "priority" TEXT + ) + """ + ) + .execute(db) + try SectionedReminder.insert { + SectionedReminder.Draft(title: "Dishes", category: "Home", priority: "high") + SectionedReminder.Draft(title: "Standup", category: "Work", priority: "high") + SectionedReminder.Draft(title: "Groceries", category: "Errands", priority: "low") + SectionedReminder.Draft(title: "Laundry", category: "Home", priority: nil) + SectionedReminder.Draft(title: "Review", category: "Work", priority: nil) + } + .execute(db) + } + return database + } +} + 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