diff --git a/Package.resolved b/Package.resolved index 2c8e5093..2cc93a52 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "31c783928cdfa6447732a2134d9f5fbe9824b71294e216b29992d246e7540c06", + "originHash" : "324731b33805c70e83b5ad1f65360ddff963c796cb8c77d1c1acc2f81503187d", "pins" : [ { "identity" : "combine-schedulers", @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/groue/GRDB.swift", "state" : { - "revision" : "9ed8c8457e00ff9c7aedb3bf213f20a2cfdf509e", - "version" : "7.11.0" + "revision" : "b83108d10f42680d78f23fe4d4d80fc88dab3212", + "version" : "7.11.1" } }, { @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-custom-dump", "state" : { - "revision" : "b9b59eb58c946236d6f16305c576ad194c36444e", - "version" : "1.6.0" + "revision" : "a8cd6c976f335ed361dcecddb0dc39ebda51bc3e", + "version" : "1.6.1" } }, { @@ -60,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-dependencies", "state" : { - "revision" : "f80552807ec92f72fe3fe4543d71879182b0bfd5", - "version" : "1.13.0" + "revision" : "706feb7858a7f6c242879d137b8ee30926aa5b26", + "version" : "1.12.0" } }, { @@ -96,8 +96,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-perception", "state" : { - "revision" : "25ac73741c3436605d61eceb5207e896973918e7", - "version" : "2.0.10" + "revision" : "de219a1cf34e958134e75a9ebb134cf09bf52fc6", + "version" : "2.0.11" } }, { @@ -105,8 +105,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-sharing", "state" : { - "revision" : "e47a2f545bafa3c0c702600f3e6ce02b3d566b6f", - "version" : "2.8.2" + "revision" : "8244fe63bf43e58188ab13851ad693eecf6a9e90", + "version" : "2.9.1" } }, { @@ -114,8 +114,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-snapshot-testing", "state" : { - "revision" : "ad5e3190cc63dc288f28546f9c6827efc1e9d495", - "version" : "1.19.2" + "revision" : "1bc16f430d8410e7f087d4c787767b26fd32fe30", + "version" : "1.19.3" } }, { @@ -123,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-structured-queries", "state" : { - "revision" : "50a429884d7a6c0613df2a31a24009cc436bfb18", - "version" : "0.33.2" + "revision" : "dafddd843f10630aa6b5be47c1b6a59cc4ab6586", + "version" : "0.34.0" } }, { @@ -141,8 +141,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", "state" : { - "revision" : "cb281f343fd953280336dcbd3822cdf47c182f5b", - "version" : "1.10.0" + "revision" : "8f6abcf4c8950e2679d5b2fee4ca284fd7c34886", + "version" : "1.11.0" } } ], diff --git a/Package.swift b/Package.swift index eb33cad5..ed2019f9 100644 --- a/Package.swift +++ b/Package.swift @@ -51,7 +51,7 @@ let package = Package( .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.18.4"), .package( url: "https://github.com/pointfreeco/swift-structured-queries", - from: "0.33.0", + from: "0.34.0", traits: [ .trait( name: "LazyInitializableByDefault", diff --git a/Package@swift-6.0.swift b/Package@swift-6.0.swift index 66138923..f1662b29 100644 --- a/Package@swift-6.0.swift +++ b/Package@swift-6.0.swift @@ -28,7 +28,7 @@ let package = Package( .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.9.0"), .package(url: "https://github.com/pointfreeco/swift-sharing", from: "2.3.0"), .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.18.4"), - .package(url: "https://github.com/pointfreeco/swift-structured-queries", from: "0.31.0"), + .package(url: "https://github.com/pointfreeco/swift-structured-queries", from: "0.34.0"), .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.5.0"), ], targets: [ diff --git a/Sources/SQLiteData/Documentation.docc/Articles/Fetching.md b/Sources/SQLiteData/Documentation.docc/Articles/Fetching.md index e2b2d380..8e08b673 100644 --- a/Sources/SQLiteData/Documentation.docc/Articles/Fetching.md +++ b/Sources/SQLiteData/Documentation.docc/Articles/Fetching.md @@ -11,6 +11,7 @@ rows, or fetch a single row (_e.g._, an aggregate computation), or if you want t queries in a single transaction. * [`@FetchAll`](#FetchAll) + * [Sectioning](#Sectioning) * [`@FetchOne`](#FetchOne) * [`@Fetch`](#Fetch) @@ -145,6 +146,55 @@ This is a very efficient query that selects only the bare essentials of data tha needs to do its job. This kind of query is a lot more cumbersome to perform in SwiftData because you must construct a dedicated `FetchDescriptor` value and set its `propertiesToFetch`. +#### Sectioning + +It is also possible to group the results of a query into sections by providing a `sectionBy:` +expression, similar to SwiftData's `@Query(sectionBy:)`. For example, given a `Reminder` table +with a string `category` column: + +```swift +@FetchAll(Reminder.order(by: \.title), sectionBy: \.category) +var reminders +``` + +The projected value's ``FetchAll/sections`` property groups results into a +``ResultsSectionCollection``, which can be used to drive a sectioned list: + +```swift +List { + ForEach($reminders.sections) { section in + Section(section.name ?? "Uncategorized") { + ForEach(section) { reminder in + Text(reminder.title) + } + } + } +} +``` + +The section expression is prepended to the query's `ORDER BY` clause and evaluated by the database, +which groups results into a section for each of its distinct values. Sections are ordered by the +expression, and elements within a section follow the query's order. The expression can be ordered +explicitly to control the direction and `NULL` ordering of sections: + +```swift +@FetchAll(Reminder.order(by: \.title), sectionBy: { $0.category.desc() }) +var reminders +``` + +The expression is not limited to columns: any string SQL expression can be used to section a query, +and its value names each section: + +```swift +@FetchAll( + Reminder.order(by: \.title), + sectionBy: { + Case().when($0.dueDate.isNot(nil), then: "Scheduled").else("Unscheduled").desc() + } +) +var reminders +``` + [sq-safe-sql-strings]: https://swiftpackageindex.com/pointfreeco/swift-structured-queries/~/documentation/structuredqueriescore/safesqlstrings [structured-queries-gh]: https://github.com/pointfreeco/swift-structured-queries [structured-queries-docs]: https://swiftpackageindex.com/pointfreeco/swift-structured-queries/main/documentation/structuredqueriescore/ diff --git a/Sources/SQLiteData/Documentation.docc/Extensions/FetchAll.md b/Sources/SQLiteData/Documentation.docc/Extensions/FetchAll.md index 14c32601..06720669 100644 --- a/Sources/SQLiteData/Documentation.docc/Extensions/FetchAll.md +++ b/Sources/SQLiteData/Documentation.docc/Extensions/FetchAll.md @@ -10,6 +10,15 @@ - ``init(wrappedValue:_:database:)`` - ``load(_:database:)`` +### Sectioning data + +- ``init(wrappedValue:sectionBy:database:)`` +- ``init(wrappedValue:_:sectionBy:database:)`` +- ``load(_:sectionBy:database:)`` +- ``sections`` +- ``ResultsSectionCollection`` +- ``ResultsSection`` + ### Accessing state - ``wrappedValue`` @@ -21,7 +30,10 @@ - ``init(wrappedValue:database:animation:)`` - ``init(wrappedValue:_:database:animation:)`` +- ``init(wrappedValue:sectionBy:database:animation:)`` +- ``init(wrappedValue:_:sectionBy:database:animation:)`` - ``load(_:database:animation:)`` +- ``load(_:sectionBy:database:animation:)`` ### Combine integration @@ -31,7 +43,10 @@ - ``init(wrappedValue:database:scheduler:)`` - ``init(wrappedValue:_:database:scheduler:)`` +- ``init(wrappedValue:sectionBy:database:scheduler:)`` +- ``init(wrappedValue:_:sectionBy:database:scheduler:)`` - ``load(_:database:scheduler:)`` +- ``load(_:sectionBy:database:scheduler:)`` ### Sharing infrastructure diff --git a/Sources/SQLiteData/FetchAll+Sections.swift b/Sources/SQLiteData/FetchAll+Sections.swift new file mode 100644 index 00000000..4cae5fe1 --- /dev/null +++ b/Sources/SQLiteData/FetchAll+Sections.swift @@ -0,0 +1,1186 @@ +import ConcurrencyExtras +public import GRDB +import Sharing +public import StructuredQueriesCore + +#if canImport(SwiftUI) + public import SwiftUI +#endif + +extension FetchAll { + /// The results of the query, grouped into sections. + /// + /// This collection is populated when the property is initialized with a `sectionBy:` expression: + /// + /// ```swift + /// @FetchAll(Reminder.order(by: \.title), sectionBy: \.category) + /// var reminders + /// + /// var body: some View { + /// List { + /// ForEach($reminders.sections) { section in + /// Section(section.name) { + /// ForEach(section) { reminder in + /// Text(reminder.title) + /// } + /// } + /// } + /// } + /// } + /// ``` + /// + /// See ``ResultsSectionCollection`` for more information. + public var sections: ResultsSectionCollection { + guard sectioning.value != nil else { + return ResultsSectionCollection(elements: sharedReader.wrappedValue, sectionName: nil) + } + 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. + /// + /// Results are ordered by the given expression and grouped into a section for each of its + /// distinct values. The expression is evaluated by the database, and its value, formatted as + /// text, names each section. Access the sections from the projected value's ``sections`` + /// property. + /// + /// - 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)`). + public init( + wrappedValue: [Element] = [], + @_SectionBuilder sectionBy sectioning: (Element.TableColumns) -> _Sectioning?, + 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) + return + } + self.init( + wrappedValue: wrappedValue, + statement: statement, + sectionBy: sectioning, + database: database, + scheduler: nil + ) + } + + /// Initializes this property with a query associated with the wrapped value, grouping results + /// into sections. + /// + /// Results are ordered by the given expression and grouped into a section for each of its + /// distinct values: + /// + /// ```swift + /// @FetchAll(Reminder.order(by: \.title), sectionBy: \.category) + /// var reminders + /// ``` + /// + /// The expression is prepended to the query's `ORDER BY` clause so that sections are ordered by + /// the expression, and elements within a section follow the query's order. Sections are ordered + /// ascending by default, and the expression can be ordered explicitly to control the direction + /// and `NULL` ordering of sections: + /// + /// ```swift + /// @FetchAll(Reminder.order(by: \.title), sectionBy: { $0.category.desc() }) + /// var reminders + /// ``` + /// + /// The expression is evaluated by the database, and its value, formatted as text, names each + /// section. Access the sections from the projected value's ``sections`` property. + /// + /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. + /// - statement: A query associated with the wrapped value. + /// - 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( + wrappedValue: [Element] = [], + _ statement: S, + @_SectionBuilder sectionBy sectioning: (S.From.TableColumns) -> _Sectioning?, + database: (any DatabaseReader)? = nil + ) + where + Element == S.From.QueryOutput, + S.QueryValue == (), + 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) + return + } + self.init( + wrappedValue: wrappedValue, + statement: statement, + sectionBy: sectioning, + database: database, + scheduler: nil + ) + } + + /// Initializes this property with a query that fetches every row from a table, grouping results + /// into sections. + /// + /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. + /// - 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] = [], + @_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, + statement: statement, + 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. + public init( + wrappedValue: [Element] = [], + _ statement: S, + @_SectionBuilder sectionBy sectioning: (S.From.TableColumns) -> _Sectioning?, + database: (any DatabaseReader)? = nil, + scheduler: some ValueObservationScheduler & Hashable + ) + where + Element == S.From.QueryOutput, + S.QueryValue == (), + 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: statement, + 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?, + 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?, + 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) + ) + } + } +#endif + +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. + @discardableResult + public func load( + _ statement: S, + @_SectionBuilder sectionBy sectioning: (S.From.TableColumns) -> _Sectioning?, + database: (any DatabaseReader)? = nil + ) 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: 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)`). + /// - 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 + ) + } + + func loadSections( + statement: Select<(), From, ()>, + sectionBy sectioning: _Sectioning?, + database: (any DatabaseReader)?, + scheduler: (any ValueObservationScheduler & Hashable)? + ) async throws -> FetchSubscription + where + Element == From.QueryOutput, + From.QueryOutput: Sendable + { + guard let sectioning else { + removeSections() + let statement: Select = statement.selectStar() + try await sharedReader.load( + FetchKey( + request: FetchAllStatementValueRequest(statement: statement), + database: database, + scheduler: scheduler + ) + ) + return FetchSubscription(sharedReader: sharedReader) + } + return try await loadSections( + request: FetchAllSectionedStatementValueRequest(statement: statement, sectionBy: sectioning), + sectionBy: sectioning, + database: database, + scheduler: scheduler + ) + } + + private func loadSections( + request: FetchAllSectionedStatementValueRequest, + sectionBy sectioning: _Sectioning, + database: (any DatabaseReader)?, + scheduler: (any ValueObservationScheduler & Hashable)? + ) async throws -> FetchSubscription + where + Element == Value.QueryOutput, + Value.QueryOutput: Sendable + { + self.sectioning.setValue(sectioning) + defer { + sharedReader.projectedValue = sectionedReader.elements.projectedValue + } + try await sectionedReader.load( + FetchKey( + request: request, + database: database, + scheduler: scheduler + ) + ) + return FetchSubscription(sharedReader: sharedReader, sectionedReader: sectionedReader) + } +} + +#if canImport(SwiftUI) + extension FetchAll { + /// Replaces the wrapped value with data from the given query, grouping results into sections. + /// + /// - 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. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + public init( + wrappedValue: [Element] = [], + sectionBy sectionKeyPath: KeyPath< + Element.TableColumns, some QueryExpression> + >, + database: (any DatabaseReader)? = nil + ) + where Element: StructuredQueriesCore.Table, Element.QueryOutput == Element { + self.init( + wrappedValue: wrappedValue, + sectionBy: { $0[keyPath: sectionKeyPath] }, + database: database + ) + } + + /// Initializes this property with a query associated with the wrapped value, grouping results + /// into sections. + /// + /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. + /// - statement: A query associated with the wrapped value. + /// - sectionKeyPath: A key path to a string column to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + public init( + wrappedValue: [Element] = [], + _ statement: S, + sectionBy sectionKeyPath: KeyPath< + S.From.TableColumns, some QueryExpression> + >, + database: (any DatabaseReader)? = nil + ) + where + Element == S.From.QueryOutput, + S.QueryValue == (), + S.From.QueryOutput: Sendable, + S.Joins == () + { + self.init( + wrappedValue: wrappedValue, + statement, + sectionBy: { $0[keyPath: sectionKeyPath] }, + database: database + ) + } + + /// 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. + /// - 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, + scheduler: some ValueObservationScheduler & Hashable + ) + where Element: StructuredQueriesCore.Table, Element.QueryOutput == Element { + self.init( + wrappedValue: wrappedValue, + sectionBy: { $0[keyPath: sectionKeyPath] }, + database: database, + scheduler: scheduler + ) + } + + /// Initializes this property with a query associated with the wrapped value, grouping results + /// into sections. + /// + /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. + /// - statement: A query associated with the wrapped value. + /// - sectionKeyPath: A key path to a string 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, + sectionBy sectionKeyPath: KeyPath< + S.From.TableColumns, some QueryExpression> + >, + database: (any DatabaseReader)? = nil, + scheduler: some ValueObservationScheduler & Hashable + ) + where + Element == S.From.QueryOutput, + S.QueryValue == (), + S.From.QueryOutput: Sendable, + S.Joins == () + { + self.init( + wrappedValue: wrappedValue, + statement, + sectionBy: { $0[keyPath: sectionKeyPath] }, + 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. + /// - 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)`). + /// - 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 + where + Element == S.From.QueryOutput, + S.QueryValue == (), + S.From.QueryOutput: Sendable, + S.Joins == () + { + try await load( + statement, + sectionBy: { $0[keyPath: sectionKeyPath] }, + database: database + ) + } + + /// Replaces the wrapped value with data from the given query, grouping results into sections. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - 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, + sectionBy sectionKeyPath: KeyPath< + S.From.TableColumns, some QueryExpression> + >, + database: (any DatabaseReader)? = nil, + scheduler: some ValueObservationScheduler & Hashable + ) async throws -> FetchSubscription + where + Element == S.From.QueryOutput, + S.QueryValue == (), + S.From.QueryOutput: Sendable, + S.Joins == () + { + try await load( + statement, + sectionBy: { $0[keyPath: sectionKeyPath] }, + database: database, + scheduler: scheduler + ) + } +} + +#if canImport(SwiftUI) + extension FetchAll { + /// Initializes this property with a query that fetches every row from a table, grouping + /// results into sections. + /// + /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. + /// - sectionKeyPath: A key path to a string column to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - animation: The animation to use for user interface changes that result from changes to + /// the fetched results. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + public init( + wrappedValue: [Element] = [], + sectionBy sectionKeyPath: KeyPath< + Element.TableColumns, some QueryExpression> + >, + database: (any DatabaseReader)? = nil, + animation: Animation + ) + where Element: StructuredQueriesCore.Table, Element.QueryOutput == Element { + self.init( + wrappedValue: wrappedValue, + sectionBy: { $0[keyPath: sectionKeyPath] }, + database: database, + animation: animation + ) + } + + /// Initializes this property with a query associated with the wrapped value, grouping results + /// into sections. + /// + /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. + /// - statement: A query associated with the wrapped value. + /// - sectionKeyPath: A key path to a string column to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - animation: The animation to use for user interface changes that result from changes to + /// the fetched results. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + public init( + wrappedValue: [Element] = [], + _ statement: S, + sectionBy sectionKeyPath: KeyPath< + S.From.TableColumns, some QueryExpression> + >, + 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: { $0[keyPath: sectionKeyPath] }, + database: database, + 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. + /// - 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)`). + /// - animation: The animation to use for user interface changes that result from changes to + /// the fetched results. + /// - Returns: A subscription associated with the observation. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @discardableResult + public func load( + _ statement: S, + sectionBy sectionKeyPath: KeyPath< + S.From.TableColumns, some QueryExpression> + >, + 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] }, + database: database, + 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) + ) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @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, + animation: Animation? + ) async throws -> FetchSubscription + where + Element == V.QueryOutput, + V.QueryOutput: Sendable + { + try await load( + statement, + sectionBy: sectioning, + database: database, + scheduler: .animation(animation) + ) + } + + @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) + ) + } + + @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?, + database: (any DatabaseReader)? = nil, + animation: Animation? + ) async throws -> FetchSubscription + where + Element == V.QueryOutput, + V.QueryOutput: Sendable + { + try await load( + statement, + sectionBy: sectioning, + database: database, + scheduler: .animation(animation) + ) + } + } +#endif + +private func sectionedColumns( + of _: From.Type, + _ sectionBy: _Sectioning +) -> Select<(From, String?), From, ()> { + From.unscoped + .select { ($0, SQLQueryExpression(sectionBy.select, as: String?.self)) } + .order { _ in SQLQueryExpression(sectionBy.order) } +} + +private func sectionedColumn( + of _: From.Type, + _ sectionBy: _Sectioning +) -> Select { + From.unscoped.asSelect() + .select { _ in SQLQueryExpression(sectionBy.select, as: String?.self) } +} + +private func sectionedOrder( + of _: From.Type, + _ sectionBy: _Sectioning +) -> Select<(), From, ()> { + From.unscoped.asSelect() + .order { _ in SQLQueryExpression(sectionBy.order) } +} + +public struct _Sectioning: Hashable, Sendable { + let select: QueryFragment + let order: QueryFragment + + package init(_ expression: some QueryExpression>) { + self.select = expression.queryFragment + self.order = expression.queryFragment + } + + package init(_ orderingTerm: _OrderingTerm>) { + var orderingTerm = orderingTerm + self.select = orderingTerm.baseQueryFragment + orderingTerm.baseQueryFragment = orderingTerm.base.queryFragment + self.order = orderingTerm.queryFragment + } +} + +@resultBuilder +public enum _SectionBuilder { + public static func buildExpression( + _ expression: Never? + ) -> _Sectioning? { + nil + } + + public static func buildExpression( + _ expression: some QueryExpression> + ) -> _Sectioning { + _Sectioning(expression) + } + + public static func buildExpression( + _ orderingTerm: _OrderingTerm> + ) -> _Sectioning { + _Sectioning(orderingTerm) + } + + public static func buildBlock(_ component: _Sectioning) -> _Sectioning { + component + } + + @_disfavoredOverload + public static func buildBlock(_ component: _Sectioning?) -> _Sectioning? { + component + } + + public static func buildOptional(_ component: _Sectioning?) -> _Sectioning? { + component + } + + public static func buildEither(first component: _Sectioning) -> _Sectioning { + component + } + + public static func buildEither(second component: _Sectioning) -> _Sectioning { + component + } +} + +struct FetchAllSectionedStatementValueRequest: FetchKeyRequest +where Value.QueryOutput: Sendable { + let statement: SQLQueryExpression<(Value, String?)> + + init( + statement: Select<(), Value, ()>, + 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 + self.statement = SQLQueryExpression(sectioned) + } + + init( + statement: Select, + sectionBy: _Sectioning + ) { + let ordered: Select = + sectionedOrder(of: From.self, sectionBy) + statement + let sectioned: Select<(Value, String?), From, (repeat each J)> = + ordered + sectionedColumn(of: From.self, sectionBy) + self.statement = SQLQueryExpression(sectioned) + } + + func fetch(_ db: Database) throws -> ResultsSectionCollection { + try ResultsSectionCollection( + cursor: QuerySectionedValueCursor(db: db, query: statement.queryFragment) + ) + } + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.statement.query == rhs.statement.query + } + + func hash(into hasher: inout Hasher) { + hasher.combine(statement.query) + } +} diff --git a/Sources/SQLiteData/FetchAll.swift b/Sources/SQLiteData/FetchAll.swift index ec118cdf..7c81a6eb 100644 --- a/Sources/SQLiteData/FetchAll.swift +++ b/Sources/SQLiteData/FetchAll.swift @@ -1,7 +1,7 @@ +import ConcurrencyExtras public import GRDB public import Sharing public import StructuredQueriesCore -public import Sharing #if canImport(Combine) public import Combine @@ -26,7 +26,12 @@ public struct FetchAll: Sendable { /// /// Shared readers come from the [Sharing](https://github.com/pointfreeco/swift-sharing) package, /// a general solution to observing and persisting changes to external data sources. - public var sharedReader: SharedReader<[Element]> = SharedReader(value: []) + public internal(set) var sharedReader: SharedReader<[Element]> = SharedReader(value: []) + + var sectionedReader: SharedReader> = + SharedReader(value: ResultsSectionCollection()) + + let sectioning = LockIsolated<_Sectioning?>(nil) /// A collection of data associated with the underlying query. public var wrappedValue: [Element] { @@ -39,7 +44,11 @@ public struct FetchAll: Sendable { /// ``isLoading``, and ``publisher``. public var projectedValue: Self { get { self } - nonmutating set { sharedReader.projectedValue = newValue.sharedReader.projectedValue } + nonmutating set { + sharedReader.projectedValue = newValue.sharedReader.projectedValue + sectionedReader.projectedValue = newValue.sectionedReader.projectedValue + sectioning.setValue(newValue.sectioning.value) + } } /// Returns a ``sharedReader`` for the given key path. @@ -191,7 +200,7 @@ public struct FetchAll: Sendable { S.From.QueryOutput: Sendable, S.Joins == () { - let statement = statement.selectStar() + let statement: Select = statement.selectStar() return try await load(statement, database: database) } @@ -211,6 +220,7 @@ public struct FetchAll: Sendable { Element == V.QueryOutput, V.QueryOutput: Sendable { + removeSections() try await sharedReader.load( .fetch( FetchAllStatementValueRequest(statement: statement), @@ -219,6 +229,12 @@ public struct FetchAll: Sendable { ) return FetchSubscription(sharedReader: sharedReader) } + + func removeSections() { + guard sectioning.value != nil else { return } + sectioning.setValue(nil) + sectionedReader.projectedValue = SharedReader(value: ResultsSectionCollection()) + } } extension FetchAll { @@ -354,7 +370,7 @@ extension FetchAll { S.From.QueryOutput: Sendable, S.Joins == () { - let statement = statement.selectStar() + let statement: Select = statement.selectStar() return try await load(statement, database: database, scheduler: scheduler) } @@ -377,6 +393,7 @@ extension FetchAll { Element == V.QueryOutput, V.QueryOutput: Sendable { + removeSections() try await sharedReader.load( .fetch( FetchAllStatementValueRequest(statement: statement), @@ -396,7 +413,7 @@ extension FetchAll: CustomReflectable { extension FetchAll: Equatable where Element: Equatable { public static func == (lhs: Self, rhs: Self) -> Bool { - lhs.sharedReader == rhs.sharedReader + lhs.sharedReader == rhs.sharedReader && lhs.sectioning.value == rhs.sectioning.value } } @@ -404,6 +421,7 @@ extension FetchAll: Equatable where Element: Equatable { extension FetchAll: DynamicProperty { public func update() { sharedReader.update() + sectionedReader.update() } @available(*, deprecated, message: "Remove unused parameters: 'database', 'animation'.") @@ -542,7 +560,7 @@ extension FetchAll: Equatable where Element: Equatable { S.From.QueryOutput: Sendable, S.Joins == () { - let statement = statement.selectStar() + let statement: Select = statement.selectStar() return try await load(statement, database: database, animation: animation) } @@ -566,6 +584,7 @@ extension FetchAll: Equatable where Element: Equatable { Element == V.QueryOutput, V.QueryOutput: Sendable { + removeSections() try await sharedReader.load( .fetch( FetchAllStatementValueRequest(statement: statement), @@ -578,7 +597,7 @@ extension FetchAll: Equatable where Element: Equatable { } #endif -private struct FetchAllStatementValueRequest: StatementKeyRequest { +struct FetchAllStatementValueRequest: StatementKeyRequest { let statement: SQLQueryExpression init(statement: some StructuredQueriesCore.Statement) { self.statement = SQLQueryExpression(statement) diff --git a/Sources/SQLiteData/FetchSubscription.swift b/Sources/SQLiteData/FetchSubscription.swift index de794216..bfbaf575 100644 --- a/Sources/SQLiteData/FetchSubscription.swift +++ b/Sources/SQLiteData/FetchSubscription.swift @@ -22,6 +22,17 @@ public struct FetchSubscription: Sendable { onCancel = { sharedReader.projectedValue = SharedReader(value: sharedReader.wrappedValue) } } + init( + sharedReader: SharedReader<[Element]>, + sectionedReader: SharedReader> + ) { + onCancel = { + let sections = sectionedReader.wrappedValue + sectionedReader.projectedValue = SharedReader(value: sections) + sharedReader.projectedValue = SharedReader(value: sections.elements) + } + } + /// An async handle to the given fetch observation. /// /// This handle will suspend until the current task is cancelled, at which point it will terminate diff --git a/Sources/SQLiteData/ResultsSectionCollection.swift b/Sources/SQLiteData/ResultsSectionCollection.swift new file mode 100644 index 00000000..5cda04c4 --- /dev/null +++ b/Sources/SQLiteData/ResultsSectionCollection.swift @@ -0,0 +1,179 @@ +import GRDB +import OrderedCollections + +/// A collection of query results grouped into sections. +/// +/// You do not create this collection directly. Instead, initialize a ``FetchAll`` property with a +/// `sectionBy:` expression and access this collection from the projected value's +/// ``FetchAll/sections`` property: +/// +/// ```swift +/// @FetchAll(Reminder.order(by: \.title), sectionBy: \.category) +/// var reminders +/// +/// var body: some View { +/// List { +/// ForEach($reminders.sections) { section in +/// Section(section.name) { +/// ForEach(section) { reminder in +/// Text(reminder.title) +/// } +/// } +/// } +/// } +/// } +/// ``` +/// +/// Results are grouped into a section for each distinct value of the expression, which is evaluated +/// by the database. Sections are ordered by the expression, and elements within a section follow +/// the query's order. +public struct ResultsSectionCollection { + let elements: [Element] + private let elementIndicesBySectionName: OrderedDictionary + + init() { + elements = [] + elementIndicesBySectionName = [:] + } + + init(elements: [Element], sectionName: SectionName) { + self.elements = elements + self.elementIndicesBySectionName = + elements.isEmpty ? [:] : [sectionName: ElementIndices(range: elements.indices)] + } + + /// The names of each section in the collection, in the order the sections appear. + public var sectionNames: [SectionName] { + elementIndicesBySectionName.keys.elements + } + + /// Returns the section with the given name, or `nil` if no such section exists. + /// + /// - Parameter name: The name of a section. + public subscript(sectionName name: SectionName) -> ResultsSection? { + elementIndicesBySectionName[name].map { + ResultsSection(name: name, base: elements, elementIndices: $0) + } + } + + /// Returns whether or not the collection contains a section with the given name. + /// + /// - Parameter name: The name of a section. + public func contains(sectionName name: SectionName) -> Bool { + elementIndicesBySectionName.keys.contains(name) + } + + /// Returns the position of the section with the given name, or `nil` if no such section exists. + /// + /// - Parameter name: The name of a section. + public func index(ofSectionNamed name: SectionName) -> Int? { + elementIndicesBySectionName.index(forKey: name) + } +} + +extension ResultsSectionCollection where SectionName == String? { + init(cursor: QueryCursor<(Element, String?)>) throws { + var elements: [Element] = [] + var elementIndicesBySectionName: OrderedDictionary = [:] + while let (element, sectionName) = try cursor.next() { + let index = elements.count + elementIndicesBySectionName[sectionName, default: ElementIndices(range: index.. ResultsSection { + let (name, elementIndices) = elementIndicesBySectionName.elements[position] + return ResultsSection(name: name, base: elements, elementIndices: elementIndices) + } +} + +extension ResultsSectionCollection: Sendable where Element: Sendable, SectionName: Sendable {} + +extension ResultsSectionCollection: Equatable where Element: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.elementsEqual(rhs) + } +} + +/// A collection of query results in a section, identified by the section's name. +/// +/// See ``ResultsSectionCollection`` for more information. +public struct ResultsSection: Identifiable { + /// The name of the section. + /// + /// This is the value of the `sectionBy:` expression shared by every element in the section. + public let name: SectionName + + private let base: [Element] + private let elementIndices: ElementIndices + + fileprivate init(name: SectionName, base: [Element], elementIndices: ElementIndices) { + self.name = name + self.base = base + self.elementIndices = elementIndices + } + + /// The identity of the section, equivalent to its ``name``. + public var id: SectionName { + name + } +} + +extension ResultsSection: RandomAccessCollection { + public var startIndex: Int { + 0 + } + + public var endIndex: Int { + elementIndices.count + } + + public subscript(position: Int) -> Element { + base[elementIndices[position]] + } +} + +extension ResultsSection: Sendable where Element: Sendable, SectionName: Sendable {} + +extension ResultsSection: Equatable where Element: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.name == rhs.name && lhs.elementsEqual(rhs) + } +} + +private struct ElementIndices: Sendable { + var range: Range + var overflow: [Int] = [] + + var count: Int { + range.count + overflow.count + } + + subscript(position: Int) -> Int { + position < range.count + ? range.lowerBound + position + : overflow[position - range.count] + } + + mutating func append(_ index: Int) { + if index == range.upperBound { + range = range.lowerBound..: QueryCursor: QueryCursor< + (QueryValue.QueryOutput, String?) +> { + public typealias Element = (QueryValue.QueryOutput, String?) + + // NB: Required to workaround a "Legacy previews execution" bug + // https://github.com/pointfreeco/sqlite-data/pull/60 + @usableFromInline + override init(db: Database, query: QueryFragment) throws { + try super.init(db: db, query: query) + } + + @inlinable + public override func _element(sqliteStatement _: SQLiteStatement) throws -> Element { + do { + let element = try QueryValue(decoder: &decoder).queryOutput + let sectionName = try String?(decoder: &decoder) + decoder.next() + return (element, sectionName) + } catch QueryDecodingError.missingRequiredColumn { + let columnIndex = Int(decoder.currentIndex) - 1 + throw DecodingError( + columnIndex: columnIndex, + columnName: _statement.columnNames[columnIndex], + sql: _statement.sql + ) + } + } +} + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @usableFromInline final class QueryPackCursor< diff --git a/Tests/SQLiteDataTests/FetchAllSectionsTests.swift b/Tests/SQLiteDataTests/FetchAllSectionsTests.swift new file mode 100644 index 00000000..89180123 --- /dev/null +++ b/Tests/SQLiteDataTests/FetchAllSectionsTests.swift @@ -0,0 +1,590 @@ +import DependenciesTestSupport +import Foundation +import SQLiteData +import Testing + +@Suite(.dependency(\.defaultDatabase, try .database())) +struct FetchAllSectionsTests { + @Dependency(\.defaultDatabase) var database + + @Test func basics() async throws { + @FetchAll(SectionedReminder.order(by: \.id), sectionBy: \.category) var reminders + try await $reminders.load() + + #expect(reminders.map(\.id) == [3, 1, 4, 2, 5]) + #expect($reminders.sections.count == 3) + #expect($reminders.sections.sectionNames == ["Errands", "Home", "Work"]) + #expect($reminders.sections.map(\.name) == ["Errands", "Home", "Work"]) + #expect($reminders.sections[0].map(\.title) == ["Groceries"]) + #expect($reminders.sections[1].map(\.title) == ["Dishes", "Laundry"]) + #expect($reminders.sections[2].map(\.title) == ["Standup", "Review"]) + } + + @Test func queryOrderAppliesWithinSections() async throws { + @FetchAll(SectionedReminder.order { $0.title.desc() }, sectionBy: \.category) + var reminders + try await $reminders.load() + + #expect($reminders.sections.sectionNames == ["Errands", "Home", "Work"]) + #expect($reminders.sections[1].map(\.title) == ["Laundry", "Dishes"]) + #expect($reminders.sections[2].map(\.title) == ["Standup", "Review"]) + } + + @Test func optionalSectionName() async throws { + @FetchAll(SectionedReminder.order(by: \.id), sectionBy: \.priority) var reminders + try await $reminders.load() + + #expect($reminders.sections.sectionNames == [nil, "high", "low"]) + #expect($reminders.sections[sectionName: nil]?.map(\.title) == ["Laundry", "Review"]) + } + + @Test func expressionSectionName() async throws { + @FetchAll( + SectionedReminder.order(by: \.id), + sectionBy: { Case().when($0.id <= 2, then: "First").else("Rest") } + ) + var reminders + try await $reminders.load() + + #expect($reminders.sections.sectionNames == ["First", "Rest"]) + #expect($reminders.sections[sectionName: "First"]?.map(\.id) == [1, 2]) + #expect($reminders.sections[sectionName: "Rest"]?.map(\.id) == [3, 4, 5]) + } + + @Test func descendingSections() async throws { + @FetchAll(SectionedReminder.order(by: \.id), sectionBy: { $0.category.desc() }) var reminders + try await $reminders.load() + + #expect(reminders.map(\.id) == [2, 5, 1, 4, 3]) + #expect($reminders.sections.sectionNames == ["Work", "Home", "Errands"]) + #expect($reminders.sections[0].map(\.title) == ["Standup", "Review"]) + + try await $reminders.load( + SectionedReminder.order(by: \.id), + sectionBy: { $0.priority.desc(nulls: .last) } + ) + #expect($reminders.sections.sectionNames == ["low", "high", nil]) + #expect($reminders.sections[sectionName: nil]?.map(\.title) == ["Laundry", "Review"]) + } + + @Test func emptySectionNameSortsWithNullSectionName() async throws { + try await database.write { db in + try SectionedReminder.insert { + SectionedReminder.Draft(title: "Errand", category: "Errands", priority: "") + } + .execute(db) + } + + @FetchAll( + SectionedReminder.order(by: \.id), + sectionBy: { $0.priority.asc(nulls: .last) } + ) + var reminders + try await $reminders.load() + + #expect($reminders.sections.sectionNames == ["", "high", "low", nil]) + #expect($reminders.sections[sectionName: ""]?.map(\.title) == ["Errand"]) + #expect($reminders.sections[sectionName: nil]?.map(\.title) == ["Laundry", "Review"]) + } + + @Test(arguments: [true, false]) func dynamicSectionBy(isSectioned: Bool) async throws { + @FetchAll( + SectionedReminder.order(by: \.id), + sectionBy: { + if isSectioned { + $0.category + } + } + ) + var reminders + try await $reminders.load() + + #expect(reminders.count == 5) + #expect( + $reminders.sections.sectionNames == (isSectioned ? ["Errands", "Home", "Work"] : [nil]) + ) + } + + @Test(arguments: [true, false]) func ifElseSectionBy(byCategory: Bool) async throws { + @FetchAll( + SectionedReminder.order(by: \.id), + sectionBy: { + if byCategory { + $0.category + } else { + $0.priority.desc(nulls: .last) + } + } + ) + var reminders + try await $reminders.load() + + #expect( + $reminders.sections.sectionNames + == (byCategory ? ["Errands", "Home", "Work"] : ["low", "high", nil]) + ) + } + + @Test func keyPathSectionBy() async throws { + @FetchAll(SectionedReminder.order(by: \.id), sectionBy: \.category) var reminders + try await $reminders.load() + + #expect($reminders.sections.sectionNames == ["Errands", "Home", "Work"]) + + try await $reminders.load(SectionedReminder.order(by: \.id), sectionBy: \.priority) + #expect($reminders.sections.sectionNames == [nil, "high", "low"]) + } + + @Test func keyPathSectionByWholeTable() async throws { + @FetchAll(sectionBy: \.category) var reminders: [SectionedReminder] + try await $reminders.load() + + #expect(reminders.count == 5) + #expect($reminders.sections.sectionNames == ["Errands", "Home", "Work"]) + } + + @Test func storedSectioning() async throws { + let sectioning: (SectionedReminder.TableColumns) -> _Sectioning? = { _Sectioning($0.category) } + @FetchAll(SectionedReminder.order(by: \.id), sectionBy: sectioning) var reminders + try await $reminders.load() + + #expect($reminders.sections.sectionNames == ["Errands", "Home", "Work"]) + } + + @Test func sectionLookup() async throws { + @FetchAll(SectionedReminder.order(by: \.id), sectionBy: \.category) var reminders + try await $reminders.load() + + let sections = $reminders.sections + #expect(sections[sectionName: "Work"]?.map(\.title) == ["Standup", "Review"]) + #expect(sections[sectionName: "Gym"] == nil) + #expect(sections.contains(sectionName: "Errands")) + #expect(!sections.contains(sectionName: "Gym")) + #expect(sections.index(ofSectionNamed: "Home") == 1) + #expect(sections.index(ofSectionNamed: "Errands") == 0) + #expect(sections.index(ofSectionNamed: "Gym") == nil) + + let work = try #require(sections[sectionName: "Work"]) + #expect(work.id == "Work") + #expect(work.name == "Work") + #expect(work.count == 2) + #expect(work[0].title == "Standup") + } + + @Test func observesChanges() async throws { + @FetchAll(SectionedReminder.order(by: \.id), sectionBy: \.category) var reminders + try await $reminders.load() + #expect($reminders.sections.count == 3) + + try await database.write { db in + try SectionedReminder.insert { + SectionedReminder.Draft(title: "Squats", category: "Gym") + } + .execute(db) + } + try await $reminders.load() + + #expect(reminders.count == 6) + #expect($reminders.sections.sectionNames == ["Errands", "Gym", "Home", "Work"]) + #expect($reminders.sections[sectionName: "Gym"]?.map(\.title) == ["Squats"]) + } + + @Test func loadStatementRemovesSectioning() async throws { + @FetchAll(SectionedReminder.order(by: \.id), sectionBy: \.category) var reminders + try await $reminders.load() + #expect($reminders.sections.count == 3) + + try await $reminders.load(SectionedReminder.where { $0.category.neq("Work") }.order(by: \.id)) + + #expect(reminders.map(\.title) == ["Dishes", "Groceries", "Laundry"]) + #expect($reminders.sections.sectionNames == [nil]) + #expect($reminders.sections[0].map(\.title) == ["Dishes", "Groceries", "Laundry"]) + } + + @Test func emptyResults() async throws { + @FetchAll(SectionedReminder.where { $0.id > 100 }, sectionBy: \.category) var reminders + try await $reminders.load() + + #expect(reminders.isEmpty) + #expect($reminders.sections.isEmpty) + #expect($reminders.sections.sectionNames.isEmpty) + } + + @Test func defaultWrappedValue() throws { + let defaults = [ + SectionedReminder(id: 1, title: "A", category: "One"), + SectionedReminder(id: 2, title: "B", category: "Two"), + SectionedReminder(id: 3, title: "C", category: "One"), + ] + @FetchAll( + wrappedValue: defaults, + SectionedReminder.all, + sectionBy: \.category, + database: try DatabaseQueue() + ) + var reminders + + #expect($reminders.loadError != nil) + #expect(reminders == defaults) + #expect($reminders.sections.sectionNames == [nil]) + #expect($reminders.sections[0].map(\.title) == ["A", "B", "C"]) + } + + @Test func wholeTable() async throws { + @FetchAll(sectionBy: \.category) var reminders: [SectionedReminder] + try await $reminders.load() + + #expect(reminders.count == 5) + #expect($reminders.sections.sectionNames == ["Errands", "Home", "Work"]) + } + + @Test func reassignment() async throws { + @FetchAll(SectionedReminder.order(by: \.id)) var reminders + try await $reminders.load() + #expect(reminders.count == 5) + + $reminders = FetchAll(SectionedReminder.order(by: \.id), sectionBy: \.category) + try await $reminders.load() + #expect(reminders.count == 5) + #expect($reminders.sections.sectionNames == ["Errands", "Home", "Work"]) + + $reminders = FetchAll(SectionedReminder.order(by: \.id), sectionBy: \.priority) + try await $reminders.load() + #expect($reminders.sections.sectionNames == [nil, "high", "low"]) + + $reminders = FetchAll(SectionedReminder.order(by: \.title)) + try await $reminders.load() + #expect(reminders.map(\.title) == ["Dishes", "Groceries", "Laundry", "Review", "Standup"]) + #expect($reminders.sections.sectionNames == [nil]) + #expect($reminders.sections[0].count == 5) + } + + @Test func nilSectionBy() async throws { + @FetchAll(SectionedReminder.order(by: \.id), sectionBy: { _ in nil }) var reminders + try await $reminders.load() + + #expect(reminders.map(\.id) == [1, 2, 3, 4, 5]) + #expect($reminders.sections.sectionNames == [nil]) + #expect($reminders.sections[0].map(\.id) == [1, 2, 3, 4, 5]) + } + + @Test func nilSectionBy2() async throws { + let isSectioned = false + @FetchAll( + SectionedReminder.order(by: \.id), + sectionBy: { + if isSectioned { + $0.title + } + } + ) var reminders + try await $reminders.load() + + #expect(reminders.map(\.id) == [1, 2, 3, 4, 5]) + #expect($reminders.sections.sectionNames == [nil]) + #expect($reminders.sections[0].map(\.id) == [1, 2, 3, 4, 5]) + } + + @Test func nilSectionByWholeTable() async throws { + @FetchAll(sectionBy: { _ in nil }) var reminders: [SectionedReminder] + try await $reminders.load() + + #expect(reminders.count == 5) + #expect($reminders.sections.sectionNames == [nil]) + } + + @Test func loadNilSectionBy() async throws { + @FetchAll(SectionedReminder.order(by: \.id), sectionBy: \.category) var reminders + try await $reminders.load() + #expect($reminders.sections.sectionNames == ["Errands", "Home", "Work"]) + + try await $reminders.load(SectionedReminder.order(by: \.id), sectionBy: { _ in nil }) + #expect(reminders.map(\.id) == [1, 2, 3, 4, 5]) + #expect($reminders.sections.sectionNames == [nil]) + + try await $reminders.load(SectionedReminder.where { $0.id <= 2 }.order(by: \.id)) + #expect(reminders.map(\.title) == ["Dishes", "Standup"]) + #expect($reminders.sections.sectionNames == [nil]) + } + + @Test func loadSectionBy() async throws { + @FetchAll(SectionedReminder.order(by: \.id)) var reminders + try await $reminders.load() + #expect($reminders.sections.sectionNames == [nil]) + + try await $reminders.load(SectionedReminder.order(by: \.id), sectionBy: \.category) + #expect(reminders.count == 5) + #expect($reminders.sections.sectionNames == ["Errands", "Home", "Work"]) + + try await $reminders.load(SectionedReminder.order(by: \.id), sectionBy: \.priority) + #expect($reminders.sections.sectionNames == [nil, "high", "low"]) + + try await $reminders.load(SectionedReminder.where { $0.id <= 2 }.order(by: \.id)) + #expect(reminders.map(\.title) == ["Dishes", "Standup"]) + #expect($reminders.sections.sectionNames == [nil]) + } + + @Test func equatable() async throws { + @FetchAll(SectionedReminder.order(by: \.id), sectionBy: \.category) var byCategory + @FetchAll(SectionedReminder.order(by: \.id), sectionBy: \.category) var byCategoryToo + @FetchAll(SectionedReminder.order(by: \.id), sectionBy: \.priority) var byPriority + @FetchAll(SectionedReminder.order(by: \.id)) var flat + try await $byCategory.load() + try await $byCategoryToo.load() + try await $byPriority.load() + try await $flat.load() + + #expect(byCategory == byCategoryToo) + #expect($byCategory == $byCategoryToo) + #expect($byCategory != $byPriority) + #expect($byCategory != $flat) + } + + @Test func selectionSectionBy() async throws { + let statement = + SectionedReminder + .order(by: \.id) + .select { SectionedRow.Columns(title: $0.title, label: $0.category) } + @FetchAll(statement, sectionBy: { $0.category }) var rows + try await $rows.load() + + #expect(rows.map(\.title) == ["Groceries", "Dishes", "Laundry", "Standup", "Review"]) + #expect($rows.sections.sectionNames == ["Errands", "Home", "Work"]) + #expect($rows.sections[sectionName: "Home"]?.map(\.title) == ["Dishes", "Laundry"]) + } + + @Test func joinedSectionBy() 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, sectionBy: { $1.label }) var rows + try await $rows.load() + + #expect(rows.map(\.title) == ["Dishes", "Laundry", "Standup", "Review", "Groceries"]) + #expect($rows.sections.sectionNames == ["At Home", "At Work", "Out & About"]) + #expect($rows.sections[sectionName: "At Home"]?.map(\.title) == ["Dishes", "Laundry"]) + #expect($rows.sections[sectionName: "Out & About"]?.map(\.title) == ["Groceries"]) + } + + @Test func joinedDescendingSectionBy() 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, 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 = + 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 + try await $rows.load() + #expect($rows.sections.sectionNames == [nil]) + + try await $rows.load(statement, 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, + sectionBy: { + if isSectioned { + $1.label + } + } + ) + var rows + try await $rows.load() + + #expect(rows.count == 5) + #expect( + $rows.sections.sectionNames + == (isSectioned ? ["At Home", "At Work", "Out & About"] : [nil]) + ) + } + + @Test func twoJoinsSectionBy() async throws { + let statement = + 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 + try await $rows.load() + + #expect(rows.map(\.title) == ["Dishes", "Standup", "Groceries"]) + #expect($rows.sections.sectionNames == ["High!", "Low!"]) + #expect($rows.sections[sectionName: "High!"]?.map(\.title) == ["Dishes", "Standup"]) + #expect($rows.sections[sectionName: "Low!"]?.map(\.title) == ["Groceries"]) + } + + @Test func threeJoinsSectionBy() async throws { + let statement = + 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 + try await $rows.load() + + #expect(rows.map(\.title) == ["Groceries", "Dishes", "Standup"]) + #expect($rows.sections.sectionNames == ["Later", "Now"]) + #expect($rows.sections[sectionName: "Now"]?.map(\.title) == ["Dishes", "Standup"]) + #expect($rows.sections[sectionName: "Later"]?.map(\.title) == ["Groceries"]) + } + + @Test func sectionsAccessWithoutSectionBy() async throws { + @FetchAll var reminders: [SectionedReminder] + try await $reminders.load() + + #expect(!reminders.isEmpty) + #expect($reminders.sections.count == 1) + #expect($reminders.sections.sectionNames == [nil]) + #expect($reminders.sections[sectionName: nil]?.map(\.id) == reminders.map(\.id)) + } + + @Test func sectionsAccessWithoutSectionByEmptyResults() async throws { + @FetchAll(SectionedReminder.where { $0.id > 100 }) var reminders + try await $reminders.load() + + #expect(reminders.isEmpty) + #expect($reminders.sections.isEmpty) + } +} + +@Table +private struct SectionedReminder: Equatable, Identifiable { + let id: Int + var title = "" + var category = "" + var priority: String? +} + +@Selection +private struct SectionedRow: Equatable { + var title = "" + var label = "" +} + +@Table +private struct SectionedValue: Equatable, Identifiable { + let id: Int + var title = "" + var value: String? +} + +@Table +private struct SectionedCategory: Equatable, Identifiable { + let id: Int + var name = "" + var label = "" +} + +@Table +private struct SectionedPriority: Equatable, Identifiable { + let id: Int + var name = "" + var label = "" +} + +@Table +private struct SectionedUrgency: Equatable, Identifiable { + let id: Int + var name = "" + var label = "" +} + +extension DatabaseWriter where Self == DatabaseQueue { + fileprivate static func database() throws -> DatabaseQueue { + let database = try DatabaseQueue() + try database.write { db in + try #sql( + """ + CREATE TABLE "sectionedReminders" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "title" TEXT NOT NULL DEFAULT '', + "category" TEXT NOT NULL DEFAULT '', + "priority" TEXT + ) + """ + ) + .execute(db) + try SectionedReminder.insert { + SectionedReminder.Draft(title: "Dishes", category: "Home", priority: "high") + SectionedReminder.Draft(title: "Standup", category: "Work", priority: "high") + SectionedReminder.Draft(title: "Groceries", category: "Errands", priority: "low") + SectionedReminder.Draft(title: "Laundry", category: "Home", priority: nil) + SectionedReminder.Draft(title: "Review", category: "Work", priority: nil) + } + .execute(db) + try #sql( + """ + CREATE TABLE "sectionedCategories" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "name" TEXT NOT NULL DEFAULT '', + "label" TEXT NOT NULL DEFAULT '' + ) + """ + ) + .execute(db) + try SectionedCategory.insert { + SectionedCategory.Draft(name: "Home", label: "At Home") + SectionedCategory.Draft(name: "Work", label: "At Work") + SectionedCategory.Draft(name: "Errands", label: "Out & About") + } + .execute(db) + try #sql( + """ + CREATE TABLE "sectionedPriorities" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "name" TEXT NOT NULL DEFAULT '', + "label" TEXT NOT NULL DEFAULT '' + ) + """ + ) + .execute(db) + try SectionedPriority.insert { + SectionedPriority.Draft(name: "high", label: "High!") + SectionedPriority.Draft(name: "low", label: "Low!") + } + .execute(db) + try #sql( + """ + CREATE TABLE "sectionedUrgencies" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "name" TEXT NOT NULL DEFAULT '', + "label" TEXT NOT NULL DEFAULT '' + ) + """ + ) + .execute(db) + try SectionedUrgency.insert { + SectionedUrgency.Draft(name: "high", label: "Now") + SectionedUrgency.Draft(name: "low", label: "Later") + } + .execute(db) + } + return database + } +} diff --git a/Tests/SQLiteDataTests/PrimaryKeyMigrationTests.swift b/Tests/SQLiteDataTests/PrimaryKeyMigrationTests.swift index 996a4bff..d778ffbd 100644 --- a/Tests/SQLiteDataTests/PrimaryKeyMigrationTests.swift +++ b/Tests/SQLiteDataTests/PrimaryKeyMigrationTests.swift @@ -1219,6 +1219,6 @@ struct PrimaryKeyMigrationTests { extension SQLiteSchema { static let `default` = Self - .where { !$0.name.hasPrefix("sqlite_") } + .where { !$0.name.like("sqlite_%") } .order(by: \.name) }