diff --git a/Package.resolved b/Package.resolved index 2cc93a52..c7f91d8f 100644 --- a/Package.resolved +++ b/Package.resolved @@ -60,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-dependencies", "state" : { - "revision" : "706feb7858a7f6c242879d137b8ee30926aa5b26", - "version" : "1.12.0" + "revision" : "8dc1fbf2f6255a73dec53b4648164884898db4c5", + "version" : "1.14.1" } }, { diff --git a/Sources/SQLiteData/Documentation.docc/Articles/Fetching.md b/Sources/SQLiteData/Documentation.docc/Articles/Fetching.md index 8e08b673..8fb41354 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: \.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 4cae5fe1..f2d9521a 100644 --- a/Sources/SQLiteData/FetchAll+Sections.swift +++ b/Sources/SQLiteData/FetchAll+Sections.swift @@ -37,52 +37,6 @@ extension FetchAll { return sectionedReader.wrappedValue } - fileprivate init( - wrappedValue: [Element], - statement: Select<(), From, ()>, - sectionBy: _Sectioning, - database: (any DatabaseReader)?, - scheduler: (any ValueObservationScheduler & Hashable)? - ) - where - Element == From.QueryOutput, - From.QueryOutput: Sendable - { - self.init( - wrappedValue: wrappedValue, - request: FetchAllSectionedStatementValueRequest(statement: statement, sectionBy: sectionBy), - sectionBy: sectionBy, - database: database, - scheduler: scheduler - ) - } - - fileprivate init( - wrappedValue: [Element], - request: FetchAllSectionedStatementValueRequest, - sectionBy: _Sectioning, - database: (any DatabaseReader)?, - scheduler: (any ValueObservationScheduler & Hashable)? - ) - where - Element == Value.QueryOutput, - Value.QueryOutput: Sendable - { - let sectionedReader = SharedReader( - wrappedValue: ResultsSectionCollection(elements: wrappedValue, sectionName: nil), - FetchKey( - request: request, - database: database, - scheduler: scheduler - ) - ) - self.sectionedReader = sectionedReader - self.sharedReader = sectionedReader.elements - self.sectioning.setValue(sectionBy) - } -} - -extension FetchAll { /// Initializes this property with a query that fetches every row from a table, grouping results /// into sections. /// @@ -99,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 { @@ -151,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 @@ -179,30 +133,21 @@ extension FetchAll { /// /// - Parameters: /// - wrappedValue: A default collection to associate with this property. - /// - sectioning: A closure that returns a string expression, or an ordering of one, to group - /// results by, or `nil` for no grouping. + /// - sectionKeyPath: A key path to a string column 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] = [], - @_SectionBuilder sectionBy sectioning: (Element.TableColumns) -> _Sectioning?, - database: (any DatabaseReader)? = nil, - scheduler: some ValueObservationScheduler & Hashable + sectionBy sectionKeyPath: KeyPath< + Element.TableColumns, some QueryExpression> + >, + database: (any DatabaseReader)? = nil ) where Element: StructuredQueriesCore.Table, Element.QueryOutput == Element { - let statement: Select<(), Element, ()> = Element.all.asSelect() - guard let sectioning = sectioning(Element.columns) else { - self.init(wrappedValue: wrappedValue, statement, database: database, scheduler: scheduler) - return - } self.init( wrappedValue: wrappedValue, - statement: statement, - sectionBy: sectioning, - database: database, - scheduler: scheduler + sectionBy: { $0[keyPath: sectionKeyPath] }, + database: database ) } @@ -212,18 +157,16 @@ extension FetchAll { /// - 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. + /// - sectionKeyPath: A key path to a string column 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, - @_SectionBuilder sectionBy sectioning: (S.From.TableColumns) -> _Sectioning?, - database: (any DatabaseReader)? = nil, - scheduler: some ValueObservationScheduler & Hashable + sectionBy sectionKeyPath: KeyPath< + S.From.TableColumns, some QueryExpression> + >, + database: (any DatabaseReader)? = nil ) where Element == S.From.QueryOutput, @@ -231,88 +174,130 @@ extension FetchAll { S.From.QueryOutput: Sendable, S.Joins == () { - let statement: Select<(), S.From, ()> = statement.asSelect() - guard let sectioning = sectioning(S.From.columns) else { - self.init(wrappedValue: wrappedValue, statement, database: database, scheduler: scheduler) + self.init( + wrappedValue: wrappedValue, + statement, + sectionBy: { $0[keyPath: sectionKeyPath] }, + database: database + ) + } + + /// 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?, + database: (any DatabaseReader)? = nil + ) + where + Element == V.QueryOutput, + V.QueryOutput: Sendable + { + guard let sectioning = sectioning(From.columns) else { + self.init(wrappedValue: wrappedValue, statement, database: database) return } self.init( wrappedValue: wrappedValue, - statement: statement, + request: FetchAllSectionedStatementValueRequest(statement: statement, sectionBy: sectioning), sectionBy: sectioning, database: database, - scheduler: scheduler + scheduler: nil ) } -} -#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. - /// - 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( - wrappedValue: [Element] = [], - @_SectionBuilder sectionBy sectioning: (Element.TableColumns) -> _Sectioning?, - database: (any DatabaseReader)? = nil, - animation: Animation - ) - where Element: StructuredQueriesCore.Table, Element.QueryOutput == Element { - self.init( - wrappedValue: wrappedValue, - sectionBy: sectioning, - 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. + /// - 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?, + database: (any DatabaseReader)? = nil + ) + where + Element == V.QueryOutput, + V.QueryOutput: Sendable + { + guard let sectioning = sectioning(From.columns, J.columns) else { + self.init(wrappedValue: wrappedValue, statement, database: database) + return } - - /// 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. - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - public init( - wrappedValue: [Element] = [], - _ statement: S, - @_SectionBuilder sectionBy sectioning: (S.From.TableColumns) -> _Sectioning?, - database: (any DatabaseReader)? = nil, - animation: Animation + self.init( + wrappedValue: wrappedValue, + request: FetchAllSectionedStatementValueRequest(statement: statement, sectionBy: sectioning), + sectionBy: sectioning, + database: database, + scheduler: nil ) - where - Element == S.From.QueryOutput, - S.QueryValue == (), - S.From.QueryOutput: Sendable, - S.Joins == () - { - self.init( - wrappedValue: wrappedValue, - statement, - sectionBy: sectioning, - 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. + /// - 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, + J1: StructuredQueriesCore.Table, + each J2: StructuredQueriesCore.Table + >( + wrappedValue: [Element] = [], + _ statement: Select, + @_SectionBuilder sectionBy sectioning: ( + From.TableColumns, J1.TableColumns, repeat (each J2).TableColumns + ) -> _Sectioning?, + database: (any DatabaseReader)? = nil + ) + where + Element == V.QueryOutput, + V.QueryOutput: Sendable + { + guard let sectioning = sectioning(From.columns, J1.columns, repeat (each J2).columns) else { + self.init(wrappedValue: wrappedValue, statement, database: database) + return } + self.init( + wrappedValue: wrappedValue, + request: FetchAllSectionedStatementValueRequest(statement: statement, sectionBy: sectioning), + sectionBy: sectioning, + database: database, + scheduler: nil + ) } -#endif -extension FetchAll { /// Replaces the wrapped value with data from the given query, grouping results into sections. /// /// - Parameters: @@ -325,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 @@ -347,19 +332,17 @@ extension FetchAll { /// /// - 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. + /// - sectionKeyPath: A key path to a string column 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, - @_SectionBuilder sectionBy sectioning: (S.From.TableColumns) -> _Sectioning?, - database: (any DatabaseReader)? = nil, - scheduler: some ValueObservationScheduler & Hashable + sectionBy sectionKeyPath: KeyPath< + S.From.TableColumns, some QueryExpression> + >, + database: (any DatabaseReader)? = nil ) async throws -> FetchSubscription where Element == S.From.QueryOutput, @@ -367,18 +350,167 @@ extension FetchAll { S.From.QueryOutput: Sendable, S.Joins == () { - let statement: Select<(), S.From, ()> = statement.asSelect() - return try await loadSections( - statement: statement, - sectionBy: sectioning(S.From.columns), - database: database, - scheduler: scheduler + try await load( + statement, + sectionBy: { $0[keyPath: sectionKeyPath] }, + database: database ) } - func loadSections( - statement: Select<(), From, ()>, - sectionBy sectioning: _Sectioning?, + /// 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?, + database: (any DatabaseReader)? = nil + ) async throws -> FetchSubscription + where + Element == V.QueryOutput, + V.QueryOutput: Sendable + { + guard let sectioning = sectioning(From.columns) else { + return try await load(statement, database: database) + } + return try await loadSections( + request: FetchAllSectionedStatementValueRequest(statement: statement, sectionBy: sectioning), + sectionBy: sectioning, + database: database, + scheduler: nil + ) + } + + /// 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?, + database: (any DatabaseReader)? = nil + ) async throws -> FetchSubscription + where + Element == V.QueryOutput, + V.QueryOutput: Sendable + { + guard let sectioning = sectioning(From.columns, J.columns) else { + return try await load(statement, database: database) + } + return try await loadSections( + request: FetchAllSectionedStatementValueRequest(statement: statement, sectionBy: sectioning), + sectionBy: sectioning, + database: database, + scheduler: nil + ) + } + + /// 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, + J1: StructuredQueriesCore.Table, + each J2: StructuredQueriesCore.Table + >( + _ statement: Select, + @_SectionBuilder sectionBy sectioning: ( + From.TableColumns, J1.TableColumns, repeat (each J2).TableColumns + ) -> _Sectioning?, + database: (any DatabaseReader)? = nil + ) async throws -> FetchSubscription + where + Element == V.QueryOutput, + V.QueryOutput: Sendable + { + guard let sectioning = sectioning(From.columns, J1.columns, repeat (each J2).columns) else { + return try await load(statement, database: database) + } + return try await loadSections( + request: FetchAllSectionedStatementValueRequest(statement: statement, sectionBy: sectioning), + sectionBy: sectioning, + database: database, + scheduler: nil + ) + } + + fileprivate init( + wrappedValue: [Element], + statement: Select<(), From, ()>, + sectionBy: _Sectioning, + database: (any DatabaseReader)?, + scheduler: (any ValueObservationScheduler & Hashable)? + ) + where + Element == From.QueryOutput, + From.QueryOutput: Sendable + { + self.init( + wrappedValue: wrappedValue, + request: FetchAllSectionedStatementValueRequest(statement: statement, sectionBy: sectionBy), + sectionBy: sectionBy, + database: database, + scheduler: scheduler + ) + } + + fileprivate init( + wrappedValue: [Element], + request: FetchAllSectionedStatementValueRequest, + sectionBy: _Sectioning, + database: (any DatabaseReader)?, + scheduler: (any ValueObservationScheduler & Hashable)? + ) + where + Element == Value.QueryOutput, + Value.QueryOutput: Sendable + { + let sectionedReader = SharedReader( + wrappedValue: ResultsSectionCollection(elements: wrappedValue, sectionName: nil), + FetchKey( + request: request, + database: database, + scheduler: scheduler + ) + ) + self.sectionedReader = sectionedReader + self.sharedReader = sectionedReader.elements + self.sectioning.setValue(sectionBy) + } + + func loadSections( + statement: Select<(), From, ()>, + sectionBy sectioning: _Sectioning?, database: (any DatabaseReader)?, scheduler: (any ValueObservationScheduler & Hashable)? ) async throws -> FetchSubscription @@ -407,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 @@ -431,64 +563,36 @@ extension FetchAll { } } -#if canImport(SwiftUI) - 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. - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @discardableResult - public func load( - _ statement: S, - @_SectionBuilder sectionBy sectioning: (S.From.TableColumns) -> _Sectioning?, - 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: sectioning, - database: database, - scheduler: .animation(animation) - ) - } - } -#endif - 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 column to group results by. + /// - 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( wrappedValue: [Element] = [], - sectionBy sectionKeyPath: KeyPath< - Element.TableColumns, some QueryExpression> - >, - database: (any DatabaseReader)? = nil + @_SectionBuilder sectionBy sectioning: (Element.TableColumns) -> _Sectioning?, + database: (any DatabaseReader)? = nil, + scheduler: some ValueObservationScheduler & Hashable ) where Element: StructuredQueriesCore.Table, Element.QueryOutput == Element { + let statement: Select<(), Element, ()> = Element.all.asSelect() + guard let sectioning = sectioning(Element.columns) else { + self.init(wrappedValue: wrappedValue, statement, database: database, scheduler: scheduler) + return + } self.init( wrappedValue: wrappedValue, - sectionBy: { $0[keyPath: sectionKeyPath] }, - database: database + statement: statement, + sectionBy: sectioning, + database: database, + scheduler: scheduler ) } @@ -498,16 +602,18 @@ extension FetchAll { /// - 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 column to group results by. + /// - 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( wrappedValue: [Element] = [], _ statement: S, - sectionBy sectionKeyPath: KeyPath< - S.From.TableColumns, some QueryExpression> - >, - database: (any DatabaseReader)? = nil + @_SectionBuilder sectionBy sectioning: (S.From.TableColumns) -> _Sectioning?, + database: (any DatabaseReader)? = nil, + scheduler: some ValueObservationScheduler & Hashable ) where Element == S.From.QueryOutput, @@ -515,11 +621,17 @@ extension FetchAll { S.From.QueryOutput: Sendable, S.Joins == () { + let statement: Select<(), S.From, ()> = statement.asSelect() + guard let sectioning = sectioning(S.From.columns) else { + self.init(wrappedValue: wrappedValue, statement, database: database, scheduler: scheduler) + return + } self.init( wrappedValue: wrappedValue, - statement, - sectionBy: { $0[keyPath: sectionKeyPath] }, - database: database + statement: statement, + sectionBy: sectioning, + database: database, + scheduler: scheduler ) } @@ -585,32 +697,161 @@ extension FetchAll { ) } - /// Replaces the wrapped value with data from the given query, grouping results into sections. + /// 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. - /// - sectionKeyPath: A key path to a string column to group results by. + /// - 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( - _ statement: S, - sectionBy sectionKeyPath: KeyPath< - S.From.TableColumns, some QueryExpression> - >, - database: (any DatabaseReader)? = nil - ) async throws -> FetchSubscription + /// - 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?, + database: (any DatabaseReader)? = nil, + scheduler: some ValueObservationScheduler & Hashable + ) where - Element == S.From.QueryOutput, - S.QueryValue == (), - S.From.QueryOutput: Sendable, - S.Joins == () + Element == V.QueryOutput, + V.QueryOutput: Sendable { - try await load( - statement, - sectionBy: { $0[keyPath: sectionKeyPath] }, - database: database + guard let sectioning = sectioning(From.columns) else { + self.init(wrappedValue: wrappedValue, statement, database: database, scheduler: scheduler) + return + } + self.init( + wrappedValue: wrappedValue, + request: FetchAllSectionedStatementValueRequest(statement: statement, sectionBy: sectioning), + sectionBy: sectioning, + 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. + /// - 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?, + database: (any DatabaseReader)? = nil, + scheduler: some ValueObservationScheduler & Hashable + ) + where + Element == V.QueryOutput, + V.QueryOutput: Sendable + { + guard let sectioning = sectioning(From.columns, J.columns) else { + self.init(wrappedValue: wrappedValue, statement, database: database, scheduler: scheduler) + return + } + self.init( + wrappedValue: wrappedValue, + request: FetchAllSectionedStatementValueRequest(statement: statement, sectionBy: sectioning), + sectionBy: sectioning, + 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. + /// - 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, + J1: StructuredQueriesCore.Table, + each J2: StructuredQueriesCore.Table + >( + wrappedValue: [Element] = [], + _ statement: Select, + @_SectionBuilder sectionBy sectioning: ( + From.TableColumns, J1.TableColumns, repeat (each J2).TableColumns + ) -> _Sectioning?, + database: (any DatabaseReader)? = nil, + scheduler: some ValueObservationScheduler & Hashable + ) + where + Element == V.QueryOutput, + V.QueryOutput: Sendable + { + guard let sectioning = sectioning(From.columns, J1.columns, repeat (each J2).columns) else { + self.init(wrappedValue: wrappedValue, statement, database: database, scheduler: scheduler) + return + } + self.init( + wrappedValue: wrappedValue, + request: FetchAllSectionedStatementValueRequest(statement: statement, sectionBy: sectioning), + sectionBy: sectioning, + database: database, + scheduler: scheduler + ) + } + + /// 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. + @discardableResult + public func load( + _ statement: S, + @_SectionBuilder sectionBy sectioning: (S.From.TableColumns) -> _Sectioning?, + 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<(), S.From, ()> = statement.asSelect() + return try await loadSections( + statement: statement, + sectionBy: sectioning(S.From.columns), + database: database, + scheduler: scheduler ) } @@ -646,10 +887,192 @@ extension FetchAll { scheduler: scheduler ) } + + /// 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?, + database: (any DatabaseReader)? = nil, + scheduler: some ValueObservationScheduler & Hashable + ) async throws -> FetchSubscription + where + Element == V.QueryOutput, + V.QueryOutput: Sendable + { + guard let sectioning = sectioning(From.columns) else { + return try await load(statement, database: database, scheduler: scheduler) + } + return try await loadSections( + request: FetchAllSectionedStatementValueRequest(statement: statement, sectionBy: sectioning), + sectionBy: sectioning, + database: database, + scheduler: scheduler + ) + } + + /// 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?, + database: (any DatabaseReader)? = nil, + scheduler: some ValueObservationScheduler & Hashable + ) async throws -> FetchSubscription + where + Element == V.QueryOutput, + V.QueryOutput: Sendable + { + guard let sectioning = sectioning(From.columns, J.columns) else { + return try await load(statement, database: database, scheduler: scheduler) + } + return try await loadSections( + request: FetchAllSectionedStatementValueRequest(statement: statement, sectionBy: sectioning), + sectionBy: sectioning, + database: database, + scheduler: scheduler + ) + } + + /// 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, + J1: StructuredQueriesCore.Table, + each J2: StructuredQueriesCore.Table + >( + _ statement: Select, + @_SectionBuilder sectionBy sectioning: ( + From.TableColumns, J1.TableColumns, repeat (each J2).TableColumns + ) -> _Sectioning?, + database: (any DatabaseReader)? = nil, + scheduler: some ValueObservationScheduler & Hashable + ) async throws -> FetchSubscription + where + Element == V.QueryOutput, + V.QueryOutput: Sendable + { + guard let sectioning = sectioning(From.columns, J1.columns, repeat (each J2).columns) else { + return try await load(statement, database: database, scheduler: scheduler) + } + return try await loadSections( + request: FetchAllSectionedStatementValueRequest(statement: statement, sectionBy: sectioning), + sectionBy: sectioning, + 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. + /// - 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( + wrappedValue: [Element] = [], + @_SectionBuilder sectionBy sectioning: (Element.TableColumns) -> _Sectioning< + String? + >?, + database: (any DatabaseReader)? = nil, + animation: Animation + ) + where Element: StructuredQueriesCore.Table, Element.QueryOutput == Element { + self.init( + wrappedValue: wrappedValue, + sectionBy: sectioning, + 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. + /// - 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( + wrappedValue: [Element] = [], + _ statement: S, + @_SectionBuilder sectionBy sectioning: (S.From.TableColumns) -> _Sectioning< + String? + >?, + 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: sectioning, + database: database, + scheduler: .animation(animation) + ) + } + /// Initializes this property with a query that fetches every row from a table, grouping /// results into sections. /// @@ -705,12 +1128,163 @@ extension FetchAll { S.From.QueryOutput: Sendable, S.Joins == () { - self.init( - wrappedValue: wrappedValue, + self.init( + wrappedValue: wrappedValue, + statement, + sectionBy: { $0[keyPath: sectionKeyPath] }, + database: database, + animation: animation + ) + } + + /// 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?, + database: (any DatabaseReader)? = nil, + animation: Animation + ) + where + Element == V.QueryOutput, + V.QueryOutput: Sendable + { + self.init( + wrappedValue: wrappedValue, + statement, + sectionBy: sectioning, + 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. + /// - 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?, + database: (any DatabaseReader)? = nil, + animation: Animation + ) + where + Element == V.QueryOutput, + V.QueryOutput: Sendable + { + self.init( + wrappedValue: wrappedValue, + statement, + sectionBy: sectioning, + 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. + /// - 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, + J1: StructuredQueriesCore.Table, + each J2: StructuredQueriesCore.Table + >( + wrappedValue: [Element] = [], + _ statement: Select, + @_SectionBuilder sectionBy sectioning: ( + From.TableColumns, J1.TableColumns, repeat (each J2).TableColumns + ) -> _Sectioning?, + database: (any DatabaseReader)? = nil, + animation: Animation + ) + where + Element == V.QueryOutput, + V.QueryOutput: Sendable + { + self.init( + wrappedValue: wrappedValue, + statement, + sectionBy: sectioning, + database: database, + scheduler: .animation(animation) + ) + } + + /// 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. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @discardableResult + public func load( + _ statement: S, + @_SectionBuilder sectionBy sectioning: (S.From.TableColumns) -> _Sectioning< + String? + >?, + 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: { $0[keyPath: sectionKeyPath] }, + sectionBy: sectioning, database: database, - animation: animation + scheduler: .animation(animation) ) } @@ -747,259 +1321,28 @@ extension FetchAll { animation: animation ) } - } -#endif - -extension FetchAll { - public init< - V: QueryRepresentable, From: StructuredQueriesCore.Table, each J: StructuredQueriesCore.Table - >( - wrappedValue: [Element] = [], - _ statement: some SelectStatement, - @_SectionBuilder sectionBy sectioning: ( - From.TableColumns, repeat (each J).TableColumns - ) -> _Sectioning?, - database: (any DatabaseReader)? = nil - ) - where - Element == V.QueryOutput, - V.QueryOutput: Sendable - { - let statement: Select = statement.asSelect() - guard let sectioning = sectioning(From.columns, repeat (each J).columns) else { - self.init(wrappedValue: wrappedValue, statement, database: database) - return - } - self.init( - wrappedValue: wrappedValue, - request: FetchAllSectionedStatementValueRequest(statement: statement, sectionBy: sectioning), - sectionBy: sectioning, - database: database, - scheduler: nil - ) - } - - public init< - V: QueryRepresentable, From: StructuredQueriesCore.Table, each J: StructuredQueriesCore.Table - >( - wrappedValue: [Element] = [], - _ statement: some SelectStatement, - @_SectionBuilder sectionBy sectioning: ( - From.TableColumns, repeat (each J).TableColumns - ) -> _Sectioning?, - database: (any DatabaseReader)? = nil, - scheduler: some ValueObservationScheduler & Hashable - ) - where - Element == V.QueryOutput, - V.QueryOutput: Sendable - { - let statement: Select = statement.asSelect() - guard let sectioning = sectioning(From.columns, repeat (each J).columns) else { - self.init(wrappedValue: wrappedValue, statement, database: database, scheduler: scheduler) - return - } - self.init( - wrappedValue: wrappedValue, - request: FetchAllSectionedStatementValueRequest(statement: statement, sectionBy: sectioning), - sectionBy: sectioning, - database: database, - scheduler: scheduler - ) - } - - @discardableResult - public func load< - V: QueryRepresentable, From: StructuredQueriesCore.Table, each J: StructuredQueriesCore.Table - >( - _ statement: some SelectStatement, - @_SectionBuilder sectionBy sectioning: ( - From.TableColumns, repeat (each J).TableColumns - ) -> _Sectioning?, - database: (any DatabaseReader)? = nil - ) async throws -> FetchSubscription - where - Element == V.QueryOutput, - V.QueryOutput: Sendable - { - let statement: Select = statement.asSelect() - guard let sectioning = sectioning(From.columns, repeat (each J).columns) else { - return try await load(statement, database: database) - } - return try await loadSections( - request: FetchAllSectionedStatementValueRequest(statement: statement, sectionBy: sectioning), - sectionBy: sectioning, - database: database, - scheduler: nil - ) - } - - @discardableResult - public func load< - V: QueryRepresentable, From: StructuredQueriesCore.Table, each J: StructuredQueriesCore.Table - >( - _ statement: some SelectStatement, - @_SectionBuilder sectionBy sectioning: ( - From.TableColumns, repeat (each J).TableColumns - ) -> _Sectioning?, - database: (any DatabaseReader)? = nil, - scheduler: some ValueObservationScheduler & Hashable - ) async throws -> FetchSubscription - where - Element == V.QueryOutput, - V.QueryOutput: Sendable - { - let statement: Select = statement.asSelect() - guard let sectioning = sectioning(From.columns, repeat (each J).columns) else { - return try await load(statement, database: database, scheduler: scheduler) - } - return try await loadSections( - request: FetchAllSectionedStatementValueRequest(statement: statement, sectionBy: sectioning), - sectionBy: sectioning, - database: database, - scheduler: scheduler - ) - } -} - -extension FetchAll { - public init< - V: QueryRepresentable, From: StructuredQueriesCore.Table, J: StructuredQueriesCore.Table - >( - wrappedValue: [Element] = [], - _ statement: Select, - @_SectionBuilder sectionBy sectioning: (From.TableColumns, J.TableColumns) -> _Sectioning?, - database: (any DatabaseReader)? = nil - ) - where - Element == V.QueryOutput, - V.QueryOutput: Sendable - { - guard let sectioning = sectioning(From.columns, J.columns) else { - self.init(wrappedValue: wrappedValue, statement, database: database) - return - } - self.init( - wrappedValue: wrappedValue, - request: FetchAllSectionedStatementValueRequest(statement: statement, sectionBy: sectioning), - sectionBy: sectioning, - database: database, - scheduler: nil - ) - } - - public init< - V: QueryRepresentable, From: StructuredQueriesCore.Table, J: StructuredQueriesCore.Table - >( - wrappedValue: [Element] = [], - _ statement: Select, - @_SectionBuilder sectionBy sectioning: (From.TableColumns, J.TableColumns) -> _Sectioning?, - database: (any DatabaseReader)? = nil, - scheduler: some ValueObservationScheduler & Hashable - ) - where - Element == V.QueryOutput, - V.QueryOutput: Sendable - { - guard let sectioning = sectioning(From.columns, J.columns) else { - self.init(wrappedValue: wrappedValue, statement, database: database, scheduler: scheduler) - return - } - self.init( - wrappedValue: wrappedValue, - request: FetchAllSectionedStatementValueRequest(statement: statement, sectionBy: sectioning), - sectionBy: sectioning, - database: database, - scheduler: scheduler - ) - } - - @discardableResult - public func load< - V: QueryRepresentable, From: StructuredQueriesCore.Table, J: StructuredQueriesCore.Table - >( - _ statement: Select, - @_SectionBuilder sectionBy sectioning: (From.TableColumns, J.TableColumns) -> _Sectioning?, - database: (any DatabaseReader)? = nil - ) async throws -> FetchSubscription - where - Element == V.QueryOutput, - V.QueryOutput: Sendable - { - guard let sectioning = sectioning(From.columns, J.columns) else { - return try await load(statement, database: database) - } - return try await loadSections( - request: FetchAllSectionedStatementValueRequest(statement: statement, sectionBy: sectioning), - sectionBy: sectioning, - database: database, - scheduler: nil - ) - } - - @discardableResult - public func load< - V: QueryRepresentable, From: StructuredQueriesCore.Table, J: StructuredQueriesCore.Table - >( - _ statement: Select, - @_SectionBuilder sectionBy sectioning: (From.TableColumns, J.TableColumns) -> _Sectioning?, - database: (any DatabaseReader)? = nil, - scheduler: some ValueObservationScheduler & Hashable - ) async throws -> FetchSubscription - where - Element == V.QueryOutput, - V.QueryOutput: Sendable - { - guard let sectioning = sectioning(From.columns, J.columns) else { - return try await load(statement, database: database, scheduler: scheduler) - } - return try await loadSections( - request: FetchAllSectionedStatementValueRequest(statement: statement, sectionBy: sectioning), - sectionBy: sectioning, - database: database, - scheduler: scheduler - ) - } -} - -#if canImport(SwiftUI) - extension FetchAll { - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - public init< - V: QueryRepresentable, From: StructuredQueriesCore.Table, - each J: StructuredQueriesCore.Table - >( - wrappedValue: [Element] = [], - _ statement: some SelectStatement, - @_SectionBuilder sectionBy sectioning: ( - From.TableColumns, repeat (each J).TableColumns - ) -> _Sectioning?, - database: (any DatabaseReader)? = nil, - animation: Animation - ) - where - Element == V.QueryOutput, - V.QueryOutput: Sendable - { - self.init( - wrappedValue: wrappedValue, - statement, - sectionBy: sectioning, - database: database, - scheduler: .animation(animation) - ) - } + /// 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, - each J: StructuredQueriesCore.Table + V: QueryRepresentable, From: StructuredQueriesCore.Table >( - _ statement: some SelectStatement, - @_SectionBuilder sectionBy sectioning: ( - From.TableColumns, repeat (each J).TableColumns - ) -> _Sectioning?, + _ statement: Select, + @_SectionBuilder sectionBy sectioning: (From.TableColumns) -> _Sectioning?, database: (any DatabaseReader)? = nil, animation: Animation? ) async throws -> FetchSubscription @@ -1015,22 +1358,34 @@ 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, *) - public init< + @discardableResult + public func load< 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 - ) + animation: Animation? + ) async throws -> FetchSubscription where Element == V.QueryOutput, V.QueryOutput: Sendable { - self.init( - wrappedValue: wrappedValue, + try await load( statement, sectionBy: sectioning, database: database, @@ -1038,13 +1393,30 @@ 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 + V: QueryRepresentable, + From: StructuredQueriesCore.Table, + J1: StructuredQueriesCore.Table, + each J2: StructuredQueriesCore.Table >( - _ statement: Select, - @_SectionBuilder sectionBy sectioning: (From.TableColumns, J.TableColumns) -> _Sectioning?, + _ statement: Select, + @_SectionBuilder sectionBy sectioning: ( + From.TableColumns, J1.TableColumns, repeat (each J2).TableColumns + ) -> _Sectioning?, database: (any DatabaseReader)? = nil, animation: Animation? ) async throws -> FetchSubscription @@ -1062,117 +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>) { - var orderingTerm = orderingTerm + package init(_ orderingTerm: _OrderingTerm>) { self.select = orderingTerm.baseQueryFragment - orderingTerm.baseQueryFragment = orderingTerm.base.queryFragment 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..5303467c --- /dev/null +++ b/Sources/SQLiteData/StructuredQueries+GRDB/Statement+Sections.swift @@ -0,0 +1,145 @@ +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) + } + + /// Returns all values fetched from the database, grouped into sections. + /// + /// See ``StructuredQueriesCore/SelectStatement/fetchAll(_:sectionBy:)`` for more information. + /// + /// - Parameters: + /// - db: A database connection. + /// - sectionKeyPath: A key path to a column to group results by. + /// - Returns: A collection of all values decoded from the database, grouped into sections. + public func fetchAll( + _ db: Database, + sectionBy sectionKeyPath: KeyPath< + From.TableColumns, some QueryExpression> + > + ) throws -> ResultsSectionCollection + where Key.QueryOutput: Hashable { + try fetchAll(db, sectionBy: { $0[keyPath: sectionKeyPath] }) + } +} + +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) + } + + /// Returns all values fetched from the database, grouped into sections. + /// + /// See ``StructuredQueriesCore/SelectStatement/fetchAll(_:sectionBy:)`` for more information. + /// + /// - Parameters: + /// - db: A database connection. + /// - sectionKeyPath: A key path to a column to group results by. + /// - Returns: A collection of all values decoded from the database, grouped into sections. + public func fetchAll( + _ db: Database, + sectionBy sectionKeyPath: KeyPath< + From.TableColumns, some QueryExpression> + > + ) throws -> ResultsSectionCollection + where QueryValue: QueryRepresentable, Joins == (), Key.QueryOutput: Hashable { + try fetchAll(db, sectionBy: { $0[keyPath: sectionKeyPath] }) + } +} + +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 89180123..15e36e51 100644 --- a/Tests/SQLiteDataTests/FetchAllSectionsTests.swift +++ b/Tests/SQLiteDataTests/FetchAllSectionsTests.swift @@ -341,11 +341,13 @@ struct FetchAllSectionsTests { } @Test func selectionSectionBy() async throws { - let statement = + @FetchAll( SectionedReminder - .order(by: \.id) - .select { SectionedRow.Columns(title: $0.title, label: $0.category) } - @FetchAll(statement, sectionBy: { $0.category }) var rows + .order(by: \.id) + .select { SectionedRow.Columns(title: $0.title, label: $0.category) }, + sectionBy: { $0.category } + ) + var rows try await $rows.load() #expect(rows.map(\.title) == ["Groceries", "Dishes", "Laundry", "Standup", "Review"]) @@ -354,12 +356,14 @@ struct FetchAllSectionsTests { } @Test func joinedSectionBy() async throws { - let statement = + @FetchAll( SectionedReminder - .order(by: \.id) - .join(SectionedCategory.all) { $0.category.eq($1.name) } - .select { SectionedRow.Columns(title: $0.title, label: $1.label) } - @FetchAll(statement, sectionBy: { $1.label }) var rows + .order(by: \.id) + .join(SectionedCategory.all) { $0.category.eq($1.name) } + .select { SectionedRow.Columns(title: $0.title, label: $1.label) }, + sectionBy: { $1.label } + ) + var rows try await $rows.load() #expect(rows.map(\.title) == ["Dishes", "Laundry", "Standup", "Review", "Groceries"]) @@ -369,40 +373,47 @@ struct FetchAllSectionsTests { } @Test func joinedDescendingSectionBy() async throws { - let statement = + @FetchAll( SectionedReminder - .order(by: \.id) - .join(SectionedCategory.all) { $0.category.eq($1.name) } - .select { SectionedRow.Columns(title: $0.title, label: $1.label) } - @FetchAll(statement, sectionBy: { $1.label.desc() }) var rows + .order(by: \.id) + .join(SectionedCategory.all) { $0.category.eq($1.name) } + .select { SectionedRow.Columns(title: $0.title, label: $1.label) }, + sectionBy: { $1.label.desc() } + ) + var rows try await $rows.load() #expect($rows.sections.sectionNames == ["Out & About", "At Work", "At Home"]) } @Test func loadJoinedSectionBy() async throws { - let statement = + @FetchAll( SectionedReminder - .order(by: \.id) - .join(SectionedCategory.all) { $0.category.eq($1.name) } - .select { SectionedRow.Columns(title: $0.title, label: $1.label) } - @FetchAll(statement) var rows + .order(by: \.id) + .join(SectionedCategory.all) { $0.category.eq($1.name) } + .select { SectionedRow.Columns(title: $0.title, label: $1.label) } + ) + var rows try await $rows.load() #expect($rows.sections.sectionNames == [nil]) - try await $rows.load(statement, sectionBy: { $1.label }) + try await $rows.load( + SectionedReminder + .order(by: \.id) + .join(SectionedCategory.all) { $0.category.eq($1.name) } + .select { SectionedRow.Columns(title: $0.title, label: $1.label) }, + sectionBy: { $1.label } + ) #expect(rows.map(\.title) == ["Dishes", "Laundry", "Standup", "Review", "Groceries"]) #expect($rows.sections.sectionNames == ["At Home", "At Work", "Out & About"]) } @Test(arguments: [true, false]) func dynamicJoinedSectionBy(isSectioned: Bool) async throws { - let statement = - SectionedReminder - .order(by: \.id) - .join(SectionedCategory.all) { $0.category.eq($1.name) } - .select { SectionedRow.Columns(title: $0.title, label: $1.label) } @FetchAll( - statement, + SectionedReminder + .order(by: \.id) + .join(SectionedCategory.all) { $0.category.eq($1.name) } + .select { SectionedRow.Columns(title: $0.title, label: $1.label) }, sectionBy: { if isSectioned { $1.label @@ -420,13 +431,16 @@ struct FetchAllSectionsTests { } @Test func twoJoinsSectionBy() async throws { - let statement = + @FetchAll( SectionedReminder - .order(by: \.id) - .join(SectionedCategory.all) { $0.category.eq($1.name) } - .join(SectionedPriority.all) { reminder, _, priority in reminder.priority.eq(priority.name) } - .select { SectionedRow.Columns(title: $0.title, label: $2.label) } - @FetchAll(statement, sectionBy: { $2.label }) var rows + .order(by: \.id) + .join(SectionedCategory.all) { $0.category.eq($1.name) } + .join(SectionedPriority.all) { reminder, _, priority in reminder.priority.eq(priority.name) + } + .select { SectionedRow.Columns(title: $0.title, label: $2.label) }, + sectionBy: { $2.label } + ) + var rows try await $rows.load() #expect(rows.map(\.title) == ["Dishes", "Standup", "Groceries"]) @@ -436,16 +450,19 @@ struct FetchAllSectionsTests { } @Test func threeJoinsSectionBy() async throws { - let statement = + @FetchAll( SectionedReminder - .order(by: \.id) - .join(SectionedCategory.all) { $0.category.eq($1.name) } - .join(SectionedPriority.all) { reminder, _, priority in reminder.priority.eq(priority.name) } - .join(SectionedUrgency.all) { reminder, _, _, urgency in - reminder.priority.eq(urgency.name) - } - .select { SectionedRow.Columns(title: $0.title, label: $3.label) } - @FetchAll(statement, sectionBy: { $3.label }) var rows + .order(by: \.id) + .join(SectionedCategory.all) { $0.category.eq($1.name) } + .join(SectionedPriority.all) { reminder, _, priority in reminder.priority.eq(priority.name) + } + .join(SectionedUrgency.all) { reminder, _, _, urgency in + reminder.priority.eq(urgency.name) + } + .select { SectionedRow.Columns(title: $0.title, label: $3.label) }, + sectionBy: { $3.label } + ) + var rows try await $rows.load() #expect(rows.map(\.title) == ["Groceries", "Dishes", "Standup"]) @@ -471,6 +488,143 @@ struct FetchAllSectionsTests { #expect(reminders.isEmpty) #expect($reminders.sections.isEmpty) } + + @Suite + 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 selectionKeyPath() 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: \.category) + } + + #expect(sections.sectionNames == ["Errands", "Home", "Work"]) + #expect(sections[sectionName: "Home"]?.map(\.title) == ["Dishes", "Laundry"]) + } + + @Test func nullableKeyPath() async throws { + let sections = try await database.read { db in + try SectionedReminder.order(by: \.id).fetchAll(db, sectionBy: \.priority) + } + + #expect(sections.sectionNames == [nil, "high", "low"]) + #expect(sections[sectionName: nil]?.map(\.title) == ["Laundry", "Review"]) + } + + @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