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 2a7c5516..2bffc162 100644 --- a/Sources/SQLiteData/Fetch.swift +++ b/Sources/SQLiteData/Fetch.swift @@ -37,8 +37,8 @@ public struct Fetch: Sendable { nonmutating set { state.wrappedValue.sharedReader = newValue } } - private let box: FetchBox - private let state: SwiftUI.State> + private let box: FetchBox + private let state: SwiftUI.State> private let generation = SwiftUI.State(wrappedValue: 0) #else /// The underlying shared reader powering the property wrapper. diff --git a/Sources/SQLiteData/FetchAll+Sections.swift b/Sources/SQLiteData/FetchAll+Sections.swift new file mode 100644 index 00000000..5c6d425c --- /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.withLock(\.self) != 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.withLock { $0 = 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.withLock { $0 = 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 e2a9fd3f..ef2165bb 100644 --- a/Sources/SQLiteData/FetchAll.swift +++ b/Sources/SQLiteData/FetchAll.swift @@ -26,10 +26,10 @@ public struct FetchAll: Sendable { /// /// Shared readers come from the [Sharing](https://github.com/pointfreeco/swift-sharing) /// package, a general solution to observing and persisting changes to external data sources. - public private(set) var sharedReader: SharedReader<[Element]> { + public internal(set) var sharedReader: SharedReader<[Element]> { @storageRestrictions(initializes: box, state) init(initialValue) { - let box = FetchBox(sharedReader: initialValue) + let box = FetchBox(sharedReader: initialValue, extra: FetchAllSections()) self.box = box state = SwiftUI.State(wrappedValue: box) } @@ -37,15 +37,29 @@ public struct FetchAll: Sendable { nonmutating set { state.wrappedValue.sharedReader = newValue } } - private let box: FetchBox<[Element]> - private let state: SwiftUI.State> + 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 private(set) var sharedReader: SharedReader<[Element]> = SharedReader(value: []) + 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. @@ -59,7 +73,12 @@ 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 + let newSectionBy = newValue.sectionedBy.withLock(\.self) + sectionedBy.withLock { $0 = newSectionBy } + } } /// Returns a ``sharedReader`` for the given key path. @@ -229,6 +248,14 @@ public struct FetchAll: Sendable { Element == V.QueryOutput, V.QueryOutput: Sendable { + if let sectionBy = sectionedBy.withLock(\.self) { + return try await loadSections( + statement: statement, + sectionBy: sectionBy, + database: database, + scheduler: nil + ) + } try await sharedReader.load( .fetch( FetchAllStatementValueRequest(statement: statement), @@ -241,7 +268,7 @@ public struct FetchAll: Sendable { #if !canImport(SwiftUI) @_transparent #endif - private func setFetchKeyID( + func setFetchKeyID( for request: some FetchKeyRequest, database: (any DatabaseReader)?, scheduler: (any ValueObservationScheduler & Hashable)? @@ -410,6 +437,14 @@ extension FetchAll { Element == V.QueryOutput, V.QueryOutput: Sendable { + if let sectionBy = sectionedBy.withLock(\.self) { + return try await loadSections( + statement: statement, + sectionBy: sectionBy, + database: database, + scheduler: scheduler + ) + } try await sharedReader.load( .fetch( FetchAllStatementValueRequest(statement: statement), @@ -429,7 +464,7 @@ 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.withLock(\.self) == rhs.sectionedBy.withLock(\.self) } } @@ -609,6 +644,14 @@ extension FetchAll: Equatable where Element: Equatable { Element == V.QueryOutput, V.QueryOutput: Sendable { + if let sectionBy = sectionedBy.withLock(\.self) { + return try await loadSections( + statement: statement, + sectionBy: sectionBy, + database: database, + scheduler: AnimatedScheduler(animation: animation) + ) + } try await sharedReader.load( .fetch( FetchAllStatementValueRequest(statement: statement), @@ -621,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) @@ -630,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 39f3ba88..a7ced1e6 100644 --- a/Sources/SQLiteData/FetchOne.swift +++ b/Sources/SQLiteData/FetchOne.swift @@ -37,8 +37,8 @@ public struct FetchOne: Sendable { nonmutating set { state.wrappedValue.sharedReader = newValue } } - private let box: FetchBox - private let state: SwiftUI.State> + private let box: FetchBox + private let state: SwiftUI.State> private let generation = SwiftUI.State(wrappedValue: 0) #else /// The underlying shared reader powering the property wrapper. diff --git a/Sources/SQLiteData/FetchSubscription.swift b/Sources/SQLiteData/FetchSubscription.swift index 00644b50..ca44f702 100644 --- a/Sources/SQLiteData/FetchSubscription.swift +++ b/Sources/SQLiteData/FetchSubscription.swift @@ -21,6 +21,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/AnyHashableSendable.swift b/Sources/SQLiteData/Internal/AnyHashableSendable.swift new file mode 100644 index 00000000..4090db4c --- /dev/null +++ b/Sources/SQLiteData/Internal/AnyHashableSendable.swift @@ -0,0 +1,24 @@ +package struct AnyHashableSendable: Hashable, Sendable { + package let base: any Hashable & Sendable + + @_disfavoredOverload + package init(_ base: any Hashable & Sendable) { + self.init(base) + } + + package init(_ base: some Hashable & Sendable) { + if let base = base as? AnyHashableSendable { + self = base + } else { + self.base = base + } + } + + package static func == (lhs: Self, rhs: Self) -> Bool { + AnyHashable(lhs.base) == AnyHashable(rhs.base) + } + + package func hash(into hasher: inout Hasher) { + hasher.combine(base) + } +} diff --git a/Sources/SQLiteData/Internal/FetchBox.swift b/Sources/SQLiteData/Internal/FetchBox.swift index 7368147d..9b0d3918 100644 --- a/Sources/SQLiteData/Internal/FetchBox.swift +++ b/Sources/SQLiteData/Internal/FetchBox.swift @@ -4,11 +4,11 @@ import Sharing import SwiftUI - final class FetchBox: @unchecked Sendable { + final class FetchBox: @unchecked Sendable { private let storage: LockIsolated - init(sharedReader: SharedReader) { - storage = LockIsolated(Storage(sharedReader: sharedReader)) + init(sharedReader: SharedReader, extra: Extra) { + storage = LockIsolated(Storage(sharedReader: sharedReader, extra: extra)) } var sharedReader: SharedReader { @@ -21,6 +21,11 @@ set { storage.withLock { $0.fetchKeyID = newValue } } } + var extra: Extra { + get { storage.withLock(\.extra) } + set { storage.withLock { $0.extra = newValue } } + } + func update(from other: FetchBox) { guard let otherFetchKeyID = other.storage.withLock(\.fetchKeyID), @@ -29,6 +34,7 @@ storage.withLock { $0.sharedReader = other.sharedReader $0.fetchKeyID = other.fetchKeyID + $0.extra = other.extra } } @@ -45,7 +51,14 @@ private struct Storage { var sharedReader: SharedReader var fetchKeyID: FetchKeyID? + var extra: Extra var swiftUICancellable: AnyCancellable? } } + + extension FetchBox where Extra == Void { + convenience init(sharedReader: SharedReader) { + self.init(sharedReader: sharedReader, extra: ()) + } + } #endif diff --git a/Sources/SQLiteData/ResultsSectionCollection.swift b/Sources/SQLiteData/ResultsSectionCollection.swift new file mode 100644 index 00000000..f33d130d --- /dev/null +++ b/Sources/SQLiteData/ResultsSectionCollection.swift @@ -0,0 +1,186 @@ +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 + } +} +