diff --git a/Sources/SQLiteData/Documentation.docc/Articles/Fetching.md b/Sources/SQLiteData/Documentation.docc/Articles/Fetching.md index 8e08b673..56cb7b41 100644 --- a/Sources/SQLiteData/Documentation.docc/Articles/Fetching.md +++ b/Sources/SQLiteData/Documentation.docc/Articles/Fetching.md @@ -195,6 +195,39 @@ and its value names each section: var reminders ``` +Sections are not limited to `@FetchAll`. Any query can be fetched into a +``ResultsSectionCollection`` directly, which is useful when defining a custom +``FetchKeyRequest``: + +```swift +struct RemindersByCategory: FetchKeyRequest { + func fetch(_ db: Database) throws -> ResultsSectionCollection { + try Reminder + .order(by: \.title) + .fetchAll(db, sectionBy: { $0.category }) + } +} +``` + +The request can be observed with ``Fetch``, and its sections drive a list just like +``FetchAll/sections`` does: + +```swift +@Fetch(RemindersByCategory()) var sections = ResultsSectionCollection() +``` + +And while `@FetchAll` always names its sections with strings, this form can section by any +`Hashable` value the database can decode, and supports joins and custom selections: + +```swift +try Reminder + .order(by: \.title) + .join(RemindersList.all) { $0.listID.eq($1.id) } + .select { ReminderRow.Columns(title: $0.title, list: $1.name) } + .fetchAll(db, sectionBy: { $1.id }) +// ResultsSectionCollection +``` + [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/FetchAll+Sections.swift b/Sources/SQLiteData/FetchAll+Sections.swift index 1d78bd89..f2d9521a 100644 --- a/Sources/SQLiteData/FetchAll+Sections.swift +++ b/Sources/SQLiteData/FetchAll+Sections.swift @@ -53,7 +53,7 @@ extension FetchAll { /// (`@Dependency(\.defaultDatabase)`). public init( wrappedValue: [Element] = [], - @_SectionBuilder sectionBy sectioning: (Element.TableColumns) -> _Sectioning?, + @_SectionBuilder sectionBy sectioning: (Element.TableColumns) -> _Sectioning?, database: (any DatabaseReader)? = nil ) where Element: StructuredQueriesCore.Table, Element.QueryOutput == Element { @@ -105,7 +105,7 @@ extension FetchAll { public init( wrappedValue: [Element] = [], _ statement: S, - @_SectionBuilder sectionBy sectioning: (S.From.TableColumns) -> _Sectioning?, + @_SectionBuilder sectionBy sectioning: (S.From.TableColumns) -> _Sectioning?, database: (any DatabaseReader)? = nil ) where @@ -182,12 +182,25 @@ extension FetchAll { ) } + /// Initializes this property with a query associated with the wrapped value, grouping results + /// into sections. + /// + /// The query can select custom values and join other tables. The sectioning closure is handed + /// the columns of every table in the query. + /// + /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. + /// - statement: A query associated with the wrapped value. + /// - sectioning: A closure that returns a string expression, or an ordering of one, 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)`). public init< V: QueryRepresentable, From: StructuredQueriesCore.Table >( wrappedValue: [Element] = [], _ statement: Select, - @_SectionBuilder sectionBy sectioning: (From.TableColumns) -> _Sectioning?, + @_SectionBuilder sectionBy sectioning: (From.TableColumns) -> _Sectioning?, database: (any DatabaseReader)? = nil ) where @@ -207,12 +220,24 @@ extension FetchAll { ) } + /// 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. + /// - sectioning: A closure that returns a string expression, or an ordering of one, 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)`). + @_documentation(visibility: private) public init< V: QueryRepresentable, From: StructuredQueriesCore.Table, J: StructuredQueriesCore.Table >( wrappedValue: [Element] = [], _ statement: Select, - @_SectionBuilder sectionBy sectioning: (From.TableColumns, J.TableColumns) -> _Sectioning?, + @_SectionBuilder sectionBy sectioning: (From.TableColumns, J.TableColumns) -> + _Sectioning?, database: (any DatabaseReader)? = nil ) where @@ -232,6 +257,17 @@ extension FetchAll { ) } + /// 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. + /// - sectioning: A closure that returns a string expression, or an ordering of one, 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)`). + @_documentation(visibility: private) public init< V: QueryRepresentable, From: StructuredQueriesCore.Table, @@ -240,9 +276,9 @@ extension FetchAll { >( wrappedValue: [Element] = [], _ statement: Select, - @_SectionBuilder sectionBy sectioning: ( + @_SectionBuilder sectionBy sectioning: ( From.TableColumns, J1.TableColumns, repeat (each J2).TableColumns - ) -> _Sectioning?, + ) -> _Sectioning?, database: (any DatabaseReader)? = nil ) where @@ -274,7 +310,7 @@ extension FetchAll { @discardableResult public func load( _ statement: S, - @_SectionBuilder sectionBy sectioning: (S.From.TableColumns) -> _Sectioning?, + @_SectionBuilder sectionBy sectioning: (S.From.TableColumns) -> _Sectioning?, database: (any DatabaseReader)? = nil ) async throws -> FetchSubscription where @@ -321,12 +357,24 @@ extension FetchAll { ) } + /// Replaces the wrapped value with data from the given query, grouping results into sections. + /// + /// The query can select custom values and join other tables. The sectioning closure is handed + /// the columns of every table in the query. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - sectioning: A closure that returns a string expression, or an ordering of one, 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)`). + /// - Returns: A subscription associated with the observation. @discardableResult public func load< V: QueryRepresentable, From: StructuredQueriesCore.Table >( _ statement: Select, - @_SectionBuilder sectionBy sectioning: (From.TableColumns) -> _Sectioning?, + @_SectionBuilder sectionBy sectioning: (From.TableColumns) -> _Sectioning?, database: (any DatabaseReader)? = nil ) async throws -> FetchSubscription where @@ -344,12 +392,23 @@ extension FetchAll { ) } + /// Replaces the wrapped value with data from the given query, grouping results into sections. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - sectioning: A closure that returns a string expression, or an ordering of one, 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)`). + /// - Returns: A subscription associated with the observation. + @_documentation(visibility: private) @discardableResult public func load< V: QueryRepresentable, From: StructuredQueriesCore.Table, J: StructuredQueriesCore.Table >( _ statement: Select, - @_SectionBuilder sectionBy sectioning: (From.TableColumns, J.TableColumns) -> _Sectioning?, + @_SectionBuilder sectionBy sectioning: (From.TableColumns, J.TableColumns) -> + _Sectioning?, database: (any DatabaseReader)? = nil ) async throws -> FetchSubscription where @@ -367,6 +426,16 @@ extension FetchAll { ) } + /// Replaces the wrapped value with data from the given query, grouping results into sections. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - sectioning: A closure that returns a string expression, or an ordering of one, 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)`). + /// - Returns: A subscription associated with the observation. + @_documentation(visibility: private) @discardableResult public func load< V: QueryRepresentable, @@ -375,9 +444,9 @@ extension FetchAll { each J2: StructuredQueriesCore.Table >( _ statement: Select, - @_SectionBuilder sectionBy sectioning: ( + @_SectionBuilder sectionBy sectioning: ( From.TableColumns, J1.TableColumns, repeat (each J2).TableColumns - ) -> _Sectioning?, + ) -> _Sectioning?, database: (any DatabaseReader)? = nil ) async throws -> FetchSubscription where @@ -398,7 +467,7 @@ extension FetchAll { fileprivate init( wrappedValue: [Element], statement: Select<(), From, ()>, - sectionBy: _Sectioning, + sectionBy: _Sectioning, database: (any DatabaseReader)?, scheduler: (any ValueObservationScheduler & Hashable)? ) @@ -417,8 +486,8 @@ extension FetchAll { fileprivate init( wrappedValue: [Element], - request: FetchAllSectionedStatementValueRequest, - sectionBy: _Sectioning, + request: FetchAllSectionedStatementValueRequest, + sectionBy: _Sectioning, database: (any DatabaseReader)?, scheduler: (any ValueObservationScheduler & Hashable)? ) @@ -441,7 +510,7 @@ extension FetchAll { func loadSections( statement: Select<(), From, ()>, - sectionBy sectioning: _Sectioning?, + sectionBy sectioning: _Sectioning?, database: (any DatabaseReader)?, scheduler: (any ValueObservationScheduler & Hashable)? ) async throws -> FetchSubscription @@ -470,8 +539,8 @@ extension FetchAll { } private func loadSections( - request: FetchAllSectionedStatementValueRequest, - sectionBy sectioning: _Sectioning, + request: FetchAllSectionedStatementValueRequest, + sectionBy sectioning: _Sectioning, database: (any DatabaseReader)?, scheduler: (any ValueObservationScheduler & Hashable)? ) async throws -> FetchSubscription @@ -508,7 +577,7 @@ extension FetchAll { /// asynchronously on the main queue. public init( wrappedValue: [Element] = [], - @_SectionBuilder sectionBy sectioning: (Element.TableColumns) -> _Sectioning?, + @_SectionBuilder sectionBy sectioning: (Element.TableColumns) -> _Sectioning?, database: (any DatabaseReader)? = nil, scheduler: some ValueObservationScheduler & Hashable ) @@ -542,7 +611,7 @@ extension FetchAll { public init( wrappedValue: [Element] = [], _ statement: S, - @_SectionBuilder sectionBy sectioning: (S.From.TableColumns) -> _Sectioning?, + @_SectionBuilder sectionBy sectioning: (S.From.TableColumns) -> _Sectioning?, database: (any DatabaseReader)? = nil, scheduler: some ValueObservationScheduler & Hashable ) @@ -628,12 +697,27 @@ extension FetchAll { ) } + /// Initializes this property with a query associated with the wrapped value, grouping results + /// into sections. + /// + /// The query can select custom values and join other tables. The sectioning closure is handed + /// the columns of every table in the query. + /// + /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. + /// - statement: A query associated with the wrapped value. + /// - sectioning: A closure that returns a string expression, or an ordering of one, 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)`). + /// - scheduler: The scheduler to observe from. By default, database observation is performed + /// asynchronously on the main queue. public init< V: QueryRepresentable, From: StructuredQueriesCore.Table >( wrappedValue: [Element] = [], _ statement: Select, - @_SectionBuilder sectionBy sectioning: (From.TableColumns) -> _Sectioning?, + @_SectionBuilder sectionBy sectioning: (From.TableColumns) -> _Sectioning?, database: (any DatabaseReader)? = nil, scheduler: some ValueObservationScheduler & Hashable ) @@ -654,12 +738,26 @@ extension FetchAll { ) } + /// 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. + /// - sectioning: A closure that returns a string expression, or an ordering of one, 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)`). + /// - scheduler: The scheduler to observe from. By default, database observation is performed + /// asynchronously on the main queue. + @_documentation(visibility: private) public init< V: QueryRepresentable, From: StructuredQueriesCore.Table, J: StructuredQueriesCore.Table >( wrappedValue: [Element] = [], _ statement: Select, - @_SectionBuilder sectionBy sectioning: (From.TableColumns, J.TableColumns) -> _Sectioning?, + @_SectionBuilder sectionBy sectioning: (From.TableColumns, J.TableColumns) -> + _Sectioning?, database: (any DatabaseReader)? = nil, scheduler: some ValueObservationScheduler & Hashable ) @@ -680,6 +778,19 @@ extension FetchAll { ) } + /// 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. + /// - sectioning: A closure that returns a string expression, or an ordering of one, 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)`). + /// - scheduler: The scheduler to observe from. By default, database observation is performed + /// asynchronously on the main queue. + @_documentation(visibility: private) public init< V: QueryRepresentable, From: StructuredQueriesCore.Table, @@ -688,9 +799,9 @@ extension FetchAll { >( wrappedValue: [Element] = [], _ statement: Select, - @_SectionBuilder sectionBy sectioning: ( + @_SectionBuilder sectionBy sectioning: ( From.TableColumns, J1.TableColumns, repeat (each J2).TableColumns - ) -> _Sectioning?, + ) -> _Sectioning?, database: (any DatabaseReader)? = nil, scheduler: some ValueObservationScheduler & Hashable ) @@ -725,7 +836,7 @@ extension FetchAll { @discardableResult public func load( _ statement: S, - @_SectionBuilder sectionBy sectioning: (S.From.TableColumns) -> _Sectioning?, + @_SectionBuilder sectionBy sectioning: (S.From.TableColumns) -> _Sectioning?, database: (any DatabaseReader)? = nil, scheduler: some ValueObservationScheduler & Hashable ) async throws -> FetchSubscription @@ -777,12 +888,26 @@ extension FetchAll { ) } + /// Replaces the wrapped value with data from the given query, grouping results into sections. + /// + /// The query can select custom values and join other tables. The sectioning closure is handed + /// the columns of every table in the query. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - sectioning: A closure that returns a string expression, or an ordering of one, 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)`). + /// - 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< V: QueryRepresentable, From: StructuredQueriesCore.Table >( _ statement: Select, - @_SectionBuilder sectionBy sectioning: (From.TableColumns) -> _Sectioning?, + @_SectionBuilder sectionBy sectioning: (From.TableColumns) -> _Sectioning?, database: (any DatabaseReader)? = nil, scheduler: some ValueObservationScheduler & Hashable ) async throws -> FetchSubscription @@ -801,12 +926,25 @@ extension FetchAll { ) } + /// Replaces the wrapped value with data from the given query, grouping results into sections. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - sectioning: A closure that returns a string expression, or an ordering of one, 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)`). + /// - scheduler: The scheduler to observe from. By default, database observation is performed + /// asynchronously on the main queue. + /// - Returns: A subscription associated with the observation. + @_documentation(visibility: private) @discardableResult public func load< V: QueryRepresentable, From: StructuredQueriesCore.Table, J: StructuredQueriesCore.Table >( _ statement: Select, - @_SectionBuilder sectionBy sectioning: (From.TableColumns, J.TableColumns) -> _Sectioning?, + @_SectionBuilder sectionBy sectioning: (From.TableColumns, J.TableColumns) -> + _Sectioning?, database: (any DatabaseReader)? = nil, scheduler: some ValueObservationScheduler & Hashable ) async throws -> FetchSubscription @@ -825,6 +963,18 @@ extension FetchAll { ) } + /// Replaces the wrapped value with data from the given query, grouping results into sections. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - sectioning: A closure that returns a string expression, or an ordering of one, 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)`). + /// - scheduler: The scheduler to observe from. By default, database observation is performed + /// asynchronously on the main queue. + /// - Returns: A subscription associated with the observation. + @_documentation(visibility: private) @discardableResult public func load< V: QueryRepresentable, @@ -833,9 +983,9 @@ extension FetchAll { each J2: StructuredQueriesCore.Table >( _ statement: Select, - @_SectionBuilder sectionBy sectioning: ( + @_SectionBuilder sectionBy sectioning: ( From.TableColumns, J1.TableColumns, repeat (each J2).TableColumns - ) -> _Sectioning?, + ) -> _Sectioning?, database: (any DatabaseReader)? = nil, scheduler: some ValueObservationScheduler & Hashable ) async throws -> FetchSubscription @@ -871,7 +1021,9 @@ extension FetchAll { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) public init( wrappedValue: [Element] = [], - @_SectionBuilder sectionBy sectioning: (Element.TableColumns) -> _Sectioning?, + @_SectionBuilder sectionBy sectioning: (Element.TableColumns) -> _Sectioning< + String? + >?, database: (any DatabaseReader)? = nil, animation: Animation ) @@ -900,7 +1052,9 @@ extension FetchAll { public init( wrappedValue: [Element] = [], _ statement: S, - @_SectionBuilder sectionBy sectioning: (S.From.TableColumns) -> _Sectioning?, + @_SectionBuilder sectionBy sectioning: (S.From.TableColumns) -> _Sectioning< + String? + >?, database: (any DatabaseReader)? = nil, animation: Animation ) @@ -983,13 +1137,28 @@ extension FetchAll { ) } + /// Initializes this property with a query associated with the wrapped value, grouping results + /// into sections. + /// + /// The query can select custom values and join other tables. The sectioning closure is handed + /// the columns of every table in the query. + /// + /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. + /// - statement: A query associated with the wrapped value. + /// - sectioning: A closure that returns a string expression, or an ordering of one, 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< V: QueryRepresentable, From: StructuredQueriesCore.Table >( wrappedValue: [Element] = [], _ statement: Select, - @_SectionBuilder sectionBy sectioning: (From.TableColumns) -> _Sectioning?, + @_SectionBuilder sectionBy sectioning: (From.TableColumns) -> _Sectioning?, database: (any DatabaseReader)? = nil, animation: Animation ) @@ -1006,13 +1175,27 @@ extension FetchAll { ) } + /// 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. + /// - sectioning: A closure that returns a string expression, or an ordering of one, 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. + @_documentation(visibility: private) @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) public init< V: QueryRepresentable, From: StructuredQueriesCore.Table, J: StructuredQueriesCore.Table >( wrappedValue: [Element] = [], _ statement: Select, - @_SectionBuilder sectionBy sectioning: (From.TableColumns, J.TableColumns) -> _Sectioning?, + @_SectionBuilder sectionBy sectioning: (From.TableColumns, J.TableColumns) -> + _Sectioning?, database: (any DatabaseReader)? = nil, animation: Animation ) @@ -1029,6 +1212,19 @@ extension FetchAll { ) } + /// 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. + /// - sectioning: A closure that returns a string expression, or an ordering of one, 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. + @_documentation(visibility: private) @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) public init< V: QueryRepresentable, @@ -1038,9 +1234,9 @@ extension FetchAll { >( wrappedValue: [Element] = [], _ statement: Select, - @_SectionBuilder sectionBy sectioning: ( + @_SectionBuilder sectionBy sectioning: ( From.TableColumns, J1.TableColumns, repeat (each J2).TableColumns - ) -> _Sectioning?, + ) -> _Sectioning?, database: (any DatabaseReader)? = nil, animation: Animation ) @@ -1072,7 +1268,9 @@ extension FetchAll { @discardableResult public func load( _ statement: S, - @_SectionBuilder sectionBy sectioning: (S.From.TableColumns) -> _Sectioning?, + @_SectionBuilder sectionBy sectioning: (S.From.TableColumns) -> _Sectioning< + String? + >?, database: (any DatabaseReader)? = nil, animation: Animation? ) async throws -> FetchSubscription @@ -1124,13 +1322,27 @@ extension FetchAll { ) } + /// Replaces the wrapped value with data from the given query, grouping results into sections. + /// + /// The query can select custom values and join other tables. The sectioning closure is handed + /// the columns of every table in the query. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - sectioning: A closure that returns a string expression, or an ordering of one, 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< V: QueryRepresentable, From: StructuredQueriesCore.Table >( _ statement: Select, - @_SectionBuilder sectionBy sectioning: (From.TableColumns) -> _Sectioning?, + @_SectionBuilder sectionBy sectioning: (From.TableColumns) -> _Sectioning?, database: (any DatabaseReader)? = nil, animation: Animation? ) async throws -> FetchSubscription @@ -1146,13 +1358,26 @@ extension FetchAll { ) } + /// Replaces the wrapped value with data from the given query, grouping results into sections. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - sectioning: A closure that returns a string expression, or an ordering of one, 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. + @_documentation(visibility: private) @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @discardableResult public func load< V: QueryRepresentable, From: StructuredQueriesCore.Table, J: StructuredQueriesCore.Table >( _ statement: Select, - @_SectionBuilder sectionBy sectioning: (From.TableColumns, J.TableColumns) -> _Sectioning?, + @_SectionBuilder sectionBy sectioning: (From.TableColumns, J.TableColumns) -> + _Sectioning?, database: (any DatabaseReader)? = nil, animation: Animation? ) async throws -> FetchSubscription @@ -1168,6 +1393,18 @@ extension FetchAll { ) } + /// Replaces the wrapped value with data from the given query, grouping results into sections. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - sectioning: A closure that returns a string expression, or an ordering of one, 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. + @_documentation(visibility: private) @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @discardableResult public func load< @@ -1177,9 +1414,9 @@ extension FetchAll { each J2: StructuredQueriesCore.Table >( _ statement: Select, - @_SectionBuilder sectionBy sectioning: ( + @_SectionBuilder sectionBy sectioning: ( From.TableColumns, J1.TableColumns, repeat (each J2).TableColumns - ) -> _Sectioning?, + ) -> _Sectioning?, database: (any DatabaseReader)? = nil, animation: Animation? ) async throws -> FetchSubscription @@ -1197,115 +1434,120 @@ extension FetchAll { } #endif -private func sectionedColumns( +func sectionedColumns( of _: From.Type, - _ sectionBy: _Sectioning -) -> Select<(From, String?), From, ()> { + _ sectionBy: _Sectioning +) -> Select<(From, Key), From, ()> { From.unscoped - .select { ($0, SQLQueryExpression(sectionBy.select, as: String?.self)) } + .select { ($0, SQLQueryExpression(sectionBy.select, as: Key.self)) } .order { _ in SQLQueryExpression(sectionBy.order) } } -private func sectionedColumn( +func sectionedColumn( of _: From.Type, - _ sectionBy: _Sectioning -) -> Select { + _ sectionBy: _Sectioning +) -> Select { From.unscoped.asSelect() - .select { _ in SQLQueryExpression(sectionBy.select, as: String?.self) } + .select { _ in SQLQueryExpression(sectionBy.select, as: Key.self) } } -private func sectionedOrder( +func sectionedOrder( of _: From.Type, - _ sectionBy: _Sectioning + _ sectionBy: _Sectioning ) -> Select<(), From, ()> { From.unscoped.asSelect() .order { _ in SQLQueryExpression(sectionBy.order) } } -public struct _Sectioning: Hashable, Sendable { +public struct _Sectioning: Hashable, Sendable { let select: QueryFragment let order: QueryFragment - package init(_ expression: some QueryExpression>) { + package init(_ expression: some QueryExpression>) { self.select = expression.queryFragment self.order = expression.queryFragment } - package init(_ orderingTerm: _OrderingTerm>) { + package init(_ orderingTerm: _OrderingTerm>) { self.select = orderingTerm.baseQueryFragment self.order = orderingTerm.queryFragment } } @resultBuilder -public enum _SectionBuilder { +public enum _SectionBuilder { public static func buildExpression( _ expression: Never? - ) -> _Sectioning? { + ) -> _Sectioning? { nil } + @_disfavoredOverload public static func buildExpression( - _ expression: some QueryExpression> - ) -> _Sectioning { + _ expression: some QueryExpression> + ) -> _Sectioning { _Sectioning(expression) } public static func buildExpression( - _ orderingTerm: _OrderingTerm> - ) -> _Sectioning { + _ orderingTerm: _OrderingTerm> + ) -> _Sectioning { _Sectioning(orderingTerm) } - public static func buildBlock(_ component: _Sectioning) -> _Sectioning { + public static func buildBlock(_ component: _Sectioning) -> _Sectioning { component } @_disfavoredOverload - public static func buildBlock(_ component: _Sectioning?) -> _Sectioning? { + public static func buildBlock(_ component: _Sectioning?) -> _Sectioning? { component } - public static func buildOptional(_ component: _Sectioning?) -> _Sectioning? { + public static func buildOptional(_ component: _Sectioning?) -> _Sectioning? { component } - public static func buildEither(first component: _Sectioning) -> _Sectioning { + public static func buildEither(first component: _Sectioning) -> _Sectioning { component } - public static func buildEither(second component: _Sectioning) -> _Sectioning { + public static func buildEither(second component: _Sectioning) -> _Sectioning { component } } -struct FetchAllSectionedStatementValueRequest: FetchKeyRequest -where Value.QueryOutput: Sendable { - let statement: SQLQueryExpression<(Value, String?)> +struct FetchAllSectionedStatementValueRequest< + Value: QueryRepresentable, + Key: QueryRepresentable +>: FetchKeyRequest +where Value.QueryOutput: Sendable, Key.QueryOutput: Hashable & Sendable { + let statement: SQLQueryExpression<(Value, Key)> init( statement: Select<(), Value, ()>, - sectionBy: _Sectioning + sectionBy: _Sectioning ) where Value: StructuredQueriesCore.Table { - let prefix: Select<(Value, String?), Value, ()> = sectionedColumns(of: Value.self, sectionBy) - let sectioned: Select<(Value, String?), Value, ()> = prefix + statement + let prefix: Select<(Value, Key), Value, ()> = sectionedColumns(of: Value.self, sectionBy) + let sectioned: Select<(Value, Key), Value, ()> = prefix + statement self.statement = SQLQueryExpression(sectioned) } init( statement: Select, - sectionBy: _Sectioning + sectionBy: _Sectioning ) { let ordered: Select = sectionedOrder(of: From.self, sectionBy) + statement - let sectioned: Select<(Value, String?), From, (repeat each J)> = + let sectioned: Select<(Value, Key), From, (repeat each J)> = ordered + sectionedColumn(of: From.self, sectionBy) self.statement = SQLQueryExpression(sectioned) } - func fetch(_ db: Database) throws -> ResultsSectionCollection { + func fetch(_ db: Database) throws -> ResultsSectionCollection + { try ResultsSectionCollection( - cursor: QuerySectionedValueCursor(db: db, query: statement.queryFragment) + cursor: QuerySectionedCursor(db: db, query: statement.queryFragment) ) } diff --git a/Sources/SQLiteData/FetchAll.swift b/Sources/SQLiteData/FetchAll.swift index 7c81a6eb..99c42b05 100644 --- a/Sources/SQLiteData/FetchAll.swift +++ b/Sources/SQLiteData/FetchAll.swift @@ -31,7 +31,7 @@ public struct FetchAll: Sendable { var sectionedReader: SharedReader> = SharedReader(value: ResultsSectionCollection()) - let sectioning = LockIsolated<_Sectioning?>(nil) + let sectioning = LockIsolated<_Sectioning?>(nil) /// A collection of data associated with the underlying query. public var wrappedValue: [Element] { diff --git a/Sources/SQLiteData/ResultsSectionCollection.swift b/Sources/SQLiteData/ResultsSectionCollection.swift index 5cda04c4..609fb985 100644 --- a/Sources/SQLiteData/ResultsSectionCollection.swift +++ b/Sources/SQLiteData/ResultsSectionCollection.swift @@ -31,7 +31,8 @@ public struct ResultsSectionCollection { let elements: [Element] private let elementIndicesBySectionName: OrderedDictionary - init() { + /// Creates an empty collection of sections. + public init() { elements = [] elementIndicesBySectionName = [:] } @@ -71,10 +72,10 @@ public struct ResultsSectionCollection { } } -extension ResultsSectionCollection where SectionName == String? { - init(cursor: QueryCursor<(Element, String?)>) throws { +extension ResultsSectionCollection { + init(cursor: QueryCursor<(Element, SectionName)>) throws { var elements: [Element] = [] - var elementIndicesBySectionName: OrderedDictionary = [:] + var elementIndicesBySectionName: OrderedDictionary = [:] while let (element, sectionName) = try cursor.next() { let index = elements.count elementIndicesBySectionName[sectionName, default: ElementIndices(range: index..: QueryCursor: QueryCursor< - (QueryValue.QueryOutput, String?) -> { - public typealias Element = (QueryValue.QueryOutput, String?) - +final class QuerySectionedCursor< + Element: QueryRepresentable, + SectionName: QueryRepresentable +>: QueryCursor<(Element.QueryOutput, SectionName.QueryOutput)> { // NB: Required to workaround a "Legacy previews execution" bug // https://github.com/pointfreeco/sqlite-data/pull/60 @usableFromInline @@ -92,10 +91,12 @@ final class QuerySectionedValueCursor: QueryCurs } @inlinable - public override func _element(sqliteStatement _: SQLiteStatement) throws -> Element { + public override func _element( + sqliteStatement _: SQLiteStatement + ) throws -> (Element.QueryOutput, SectionName.QueryOutput) { do { - let element = try QueryValue(decoder: &decoder).queryOutput - let sectionName = try String?(decoder: &decoder) + let element = try Element(decoder: &decoder).queryOutput + let sectionName = try SectionName(decoder: &decoder).queryOutput decoder.next() return (element, sectionName) } catch QueryDecodingError.missingRequiredColumn { diff --git a/Sources/SQLiteData/StructuredQueries+GRDB/Statement+Sections.swift b/Sources/SQLiteData/StructuredQueries+GRDB/Statement+Sections.swift new file mode 100644 index 00000000..d6b25fdd --- /dev/null +++ b/Sources/SQLiteData/StructuredQueries+GRDB/Statement+Sections.swift @@ -0,0 +1,109 @@ +public import GRDB +public import StructuredQueriesCore + +extension SelectStatement where QueryValue == (), Joins == () { + /// Returns all values fetched from the database, grouped into sections. + /// + /// Results are ordered by the given expression and grouped into a section for each of its + /// distinct values: + /// + /// ```swift + /// try Reminder + /// .order(by: \.title) + /// .fetchAll(db, sectionBy: \.priority) + /// ``` + /// + /// - Parameters: + /// - db: A database connection. + /// - sectioning: A closure that returns an expression, or an ordering of one, to group results + /// by. + /// - Returns: A collection of all values decoded from the database, grouped into sections. + public func fetchAll( + _ db: Database, + @_SectionBuilder sectionBy sectioning: (From.TableColumns) -> _Sectioning + ) throws -> ResultsSectionCollection + where Key.QueryOutput: Hashable { + let sectionBy = sectioning(From.columns) + let statement: Select<(), From, ()> = asSelect() + let prefix: Select<(From, Key), From, ()> = sectionedColumns(of: From.self, sectionBy) + let sectioned: Select<(From, Key), From, ()> = prefix + statement + return try sectionedResults(From.self, Key.self, db: db, query: sectioned.query) + } +} + +extension Select { + /// Returns all values fetched from the database, grouped into sections. + /// + /// See ``StructuredQueriesCore/SelectStatement/fetchAll(_:sectionBy:)`` for more information. + /// + /// - Parameters: + /// - db: A database connection. + /// - sectioning: A closure that returns an expression, or an ordering of one, to group results + /// by. + /// - Returns: A collection of all values decoded from the database, grouped into sections. + @_documentation(visibility: private) + @_disfavoredOverload + public func fetchAll( + _ db: Database, + @_SectionBuilder sectionBy sectioning: ( + From.TableColumns, repeat (each J).TableColumns + ) -> _Sectioning + ) throws -> ResultsSectionCollection + where QueryValue: QueryRepresentable, Joins == (repeat each J), Key.QueryOutput: Hashable { + let sectionBy = sectioning(From.columns, repeat (each J).columns) + return try sectionedResults(db, statement: self, sectionBy: sectionBy) + } + + /// Returns all values fetched from the database, grouped into sections. + /// + /// See ``StructuredQueriesCore/SelectStatement/fetchAll(_:sectionBy:)`` for more information. + /// + /// - Parameters: + /// - db: A database connection. + /// - sectioning: A closure that returns an expression, or an ordering of one, to group results + /// by. + /// - Returns: A collection of all values decoded from the database, grouped into sections. + @_documentation(visibility: private) + public func fetchAll( + _ db: Database, + @_SectionBuilder sectionBy sectioning: ( + From.TableColumns, Joins.TableColumns + ) -> _Sectioning + ) throws -> ResultsSectionCollection + where + QueryValue: QueryRepresentable, + Joins: StructuredQueriesCore.Table, + Key.QueryOutput: Hashable + { + let sectionBy = sectioning(From.columns, Joins.columns) + return try sectionedResults(db, statement: self, sectionBy: sectionBy) + } +} + +private func sectionedResults< + Value: QueryRepresentable, + From: StructuredQueriesCore.Table, + each J: StructuredQueriesCore.Table, + Key: QueryRepresentable +>( + _ db: Database, + statement: Select, + sectionBy: _Sectioning +) throws -> ResultsSectionCollection +where Key.QueryOutput: Hashable { + let ordered: Select = + sectionedOrder(of: From.self, sectionBy) + statement + let sectioned: Select<(Value, Key), From, (repeat each J)> = + ordered + sectionedColumn(of: From.self, sectionBy) + return try sectionedResults(Value.self, Key.self, db: db, query: sectioned.query) +} + +private func sectionedResults( + _: Value.Type, + _: Key.Type, + db: Database, + query: QueryFragment +) throws -> ResultsSectionCollection +where Key.QueryOutput: Hashable { + try ResultsSectionCollection(cursor: QuerySectionedCursor(db: db, query: query)) +} diff --git a/Tests/SQLiteDataTests/FetchAllSectionsTests.swift b/Tests/SQLiteDataTests/FetchAllSectionsTests.swift index 1e26ba7d..05b306ad 100644 --- a/Tests/SQLiteDataTests/FetchAllSectionsTests.swift +++ b/Tests/SQLiteDataTests/FetchAllSectionsTests.swift @@ -490,6 +490,122 @@ struct FetchAllSectionsTests { } } +@Suite(.dependency(\.defaultDatabase, try .database())) +struct StatementSectionsTests { + @Dependency(\.defaultDatabase) var database + + @Test func wholeTable() async throws { + let sections = try await database.read { db in + try SectionedReminder.order(by: \.id).fetchAll(db, sectionBy: { $0.category }) + } + + #expect(sections.sectionNames == ["Errands", "Home", "Work"]) + #expect(sections[sectionName: "Home"]?.map(\.title) == ["Dishes", "Laundry"]) + #expect(sections[sectionName: "Work"]?.map(\.title) == ["Standup", "Review"]) + #expect(sections[sectionName: "Errands"]?.map(\.title) == ["Groceries"]) + } + + @Test func sectionOrderingWinsOverQueryOrdering() async throws { + let sections = try await database.read { db in + try SectionedReminder.order { $0.title.desc() }.fetchAll(db, sectionBy: { $0.category }) + } + + #expect(sections.sectionNames == ["Errands", "Home", "Work"]) + #expect(sections[sectionName: "Home"]?.map(\.title) == ["Laundry", "Dishes"]) + } + + @Test func descendingSections() async throws { + let sections = try await database.read { db in + try SectionedReminder.order(by: \.id).fetchAll(db, sectionBy: { $0.category.desc() }) + } + + #expect(sections.sectionNames == ["Work", "Home", "Errands"]) + } + + @Test func nullSections() async throws { + let sections = try await database.read { db in + try SectionedReminder.order(by: \.id).fetchAll(db, sectionBy: { $0.priority }) + } + + #expect(sections.sectionNames == [nil, "high", "low"]) + #expect(sections[sectionName: nil]?.map(\.title) == ["Laundry", "Review"]) + #expect(sections[sectionName: "high"]?.map(\.title) == ["Dishes", "Standup"]) + } + + @Test func integerSections() async throws { + let sections = try await database.read { db in + try SectionedReminder + .order(by: \.id) + .join(SectionedCategory.all) { $0.category.eq($1.name) } + .select { SectionedRow.Columns(title: $0.title, label: $1.label) } + .fetchAll(db, sectionBy: { $1.id }) + } + + #expect(sections.sectionNames == [1, 2, 3]) + #expect(sections[sectionName: 1]?.map(\.title) == ["Dishes", "Laundry"]) + #expect(sections[sectionName: 2]?.map(\.title) == ["Standup", "Review"]) + #expect(sections[sectionName: 3]?.map(\.title) == ["Groceries"]) + } + + @Test func joinedSections() async throws { + let sections = try await database.read { db in + try SectionedReminder + .order(by: \.id) + .join(SectionedCategory.all) { $0.category.eq($1.name) } + .select { SectionedRow.Columns(title: $0.title, label: $1.label) } + .fetchAll(db, sectionBy: { $1.label }) + } + + #expect(sections.sectionNames == ["At Home", "At Work", "Out & About"]) + #expect(sections[sectionName: "At Home"]?.map(\.title) == ["Dishes", "Laundry"]) + } + + @Test func multipleJoins() async throws { + let sections = try await database.read { db in + try SectionedReminder + .order(by: \.id) + .join(SectionedCategory.all) { $0.category.eq($1.name) } + .join(SectionedPriority.all) { reminder, _, priority in + reminder.priority.eq(priority.name) + } + .select { reminder, category, _ in + SectionedRow.Columns(title: reminder.title, label: category.label) + } + .fetchAll(db, sectionBy: { _, _, priority in priority.label }) + } + + #expect(sections.sectionNames == ["High!", "Low!"]) + #expect(sections[sectionName: "High!"]?.map(\.title) == ["Dishes", "Standup"]) + #expect(sections[sectionName: "Low!"]?.map(\.title) == ["Groceries"]) + } + + @Test func selectionWithoutJoins() async throws { + let sections = try await database.read { db in + try SectionedReminder + .order(by: \.id) + .select { SectionedRow.Columns(title: $0.title, label: $0.category) } + .fetchAll(db, sectionBy: { $0.category }) + } + + #expect(sections.sectionNames == ["Errands", "Home", "Work"]) + #expect(sections[sectionName: "Home"]?.map(\.label) == ["Home", "Home"]) + } + + @Test func fetchKeyRequest() async throws { + struct Request: FetchKeyRequest { + func fetch(_ db: Database) throws -> ResultsSectionCollection { + try SectionedReminder.order(by: \.id).fetchAll(db, sectionBy: { $0.category }) + } + } + + @Fetch(Request()) var sections = ResultsSectionCollection() + try await $sections.load() + + #expect(sections.sectionNames == ["Errands", "Home", "Work"]) + #expect(sections[sectionName: "Home"]?.map(\.title) == ["Dishes", "Laundry"]) + } +} + @Table private struct SectionedReminder: Equatable, Identifiable { let id: Int