From 56055e97cabec16ffcfffb6b6ebb61e7aa3de056 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Wed, 1 Jul 2026 22:31:02 -0700 Subject: [PATCH 1/6] Add sectioning to `@FetchAll` --- .../Documentation.docc/Articles/Fetching.md | 30 + .../Documentation.docc/Extensions/FetchAll.md | 15 + Sources/SQLiteData/FetchAll+Sections.swift | 1288 +++++++++++++++++ Sources/SQLiteData/FetchAll.swift | 41 +- Sources/SQLiteData/FetchSubscription.swift | 11 + .../SQLiteData/ResultsSectionCollection.swift | 213 +++ .../FetchAllSectionsTests.swift | 222 +++ 7 files changed, 1817 insertions(+), 3 deletions(-) create mode 100644 Sources/SQLiteData/FetchAll+Sections.swift create mode 100644 Sources/SQLiteData/ResultsSectionCollection.swift create mode 100644 Tests/SQLiteDataTests/FetchAllSectionsTests.swift diff --git a/Sources/SQLiteData/Documentation.docc/Articles/Fetching.md b/Sources/SQLiteData/Documentation.docc/Articles/Fetching.md index e2b2d380..7d9729fd 100644 --- a/Sources/SQLiteData/Documentation.docc/Articles/Fetching.md +++ b/Sources/SQLiteData/Documentation.docc/Articles/Fetching.md @@ -145,6 +145,36 @@ This is a very efficient query that selects only the bare essentials of data tha needs to do its job. This kind of query is a lot more cumbersome to perform in SwiftData because you must construct a dedicated `FetchDescriptor` value and set its `propertiesToFetch`. +It is also possible to group the results of a query into sections by providing a `sectionBy:` key +path, similar to SwiftData's `@Query(sectionBy:)`. For example, given a `Reminder` table with a +string `category` column: + +```swift +@FetchAll(Reminder.order(by: \.category), sectionBy: \.category) +var reminders +``` + +The flat results are still available from the property itself, and the projected value's +``FetchAll/sections`` property groups them into a ``ResultsSectionCollection``, which is perfect +for driving a sectioned list: + +```swift +List { + ForEach($reminders.sections) { section in + Section(section.name) { + ForEach(section) { reminder in + Text(reminder.title) + } + } + } +} +``` + +Results are grouped into a section for each distinct value at the key path. Sections are ordered +by the position of their first element in the query's results, and elements within a section +follow the query's order, so you can control the order of sections by ordering the query by the +sectioned column. + [sq-safe-sql-strings]: https://swiftpackageindex.com/pointfreeco/swift-structured-queries/~/documentation/structuredqueriescore/safesqlstrings [structured-queries-gh]: https://github.com/pointfreeco/swift-structured-queries [structured-queries-docs]: https://swiftpackageindex.com/pointfreeco/swift-structured-queries/main/documentation/structuredqueriescore/ diff --git a/Sources/SQLiteData/Documentation.docc/Extensions/FetchAll.md b/Sources/SQLiteData/Documentation.docc/Extensions/FetchAll.md index 14c32601..06720669 100644 --- a/Sources/SQLiteData/Documentation.docc/Extensions/FetchAll.md +++ b/Sources/SQLiteData/Documentation.docc/Extensions/FetchAll.md @@ -10,6 +10,15 @@ - ``init(wrappedValue:_:database:)`` - ``load(_:database:)`` +### Sectioning data + +- ``init(wrappedValue:sectionBy:database:)`` +- ``init(wrappedValue:_:sectionBy:database:)`` +- ``load(_:sectionBy:database:)`` +- ``sections`` +- ``ResultsSectionCollection`` +- ``ResultsSection`` + ### Accessing state - ``wrappedValue`` @@ -21,7 +30,10 @@ - ``init(wrappedValue:database:animation:)`` - ``init(wrappedValue:_:database:animation:)`` +- ``init(wrappedValue:sectionBy:database:animation:)`` +- ``init(wrappedValue:_:sectionBy:database:animation:)`` - ``load(_:database:animation:)`` +- ``load(_:sectionBy:database:animation:)`` ### Combine integration @@ -31,7 +43,10 @@ - ``init(wrappedValue:database:scheduler:)`` - ``init(wrappedValue:_:database:scheduler:)`` +- ``init(wrappedValue:sectionBy:database:scheduler:)`` +- ``init(wrappedValue:_:sectionBy:database:scheduler:)`` - ``load(_:database:scheduler:)`` +- ``load(_:sectionBy:database:scheduler:)`` ### Sharing infrastructure diff --git a/Sources/SQLiteData/FetchAll+Sections.swift b/Sources/SQLiteData/FetchAll+Sections.swift new file mode 100644 index 00000000..de01ff97 --- /dev/null +++ b/Sources/SQLiteData/FetchAll+Sections.swift @@ -0,0 +1,1288 @@ +public import GRDB +public import StructuredQueriesCore +import Sharing + +#if canImport(SwiftUI) + public import SwiftUI +#endif + +extension FetchAll { + /// The results of the query, grouped into sections. + /// + /// This collection is populated when the property is initialized with a `sectionBy:` key path: + /// + /// ```swift + /// @FetchAll(Reminder.order(by: \.category), sectionBy: \.category) + /// var reminders + /// + /// var body: some View { + /// List { + /// ForEach($reminders.sections) { section in + /// Section(section.name) { + /// ForEach(section) { reminder in + /// Text(reminder.title) + /// } + /// } + /// } + /// } + /// } + /// ``` + /// + /// See ``ResultsSectionCollection`` for more information. + /// + /// If this property was not initialized with a `sectionBy:` key path, this collection is empty, + /// even when the query has results. + public var sections: ResultsSectionCollection { + sectionedReader.wrappedValue + } + + fileprivate init( + wrappedValue: [Element], + statement: some StructuredQueriesCore.Statement, + sectionBy: SectionBy, + database: (any DatabaseReader)?, + scheduler: (any ValueObservationScheduler & Hashable)? + ) + where + Element == V.QueryOutput, + V.QueryOutput: Sendable + { + let sectionedReader = SharedReader( + wrappedValue: ResultsSectionCollection(elements: wrappedValue, sectionName: sectionBy.name), + FetchKey( + request: FetchAllSectionedStatementValueRequest(statement: statement, sectionBy: sectionBy), + database: database, + scheduler: scheduler + ) + ) + self.sectionedReader = sectionedReader + self.sharedReader = sectionedReader[dynamicMember: \.elements] + self.sectionedBy.setValue(sectionBy) + } +} + +extension FetchAll { + /// Initializes this property with a query that fetches every row from a table, grouping results + /// into sections. + /// + /// Results are grouped into a section for each distinct value at the given key path. Sections + /// are ordered by the position of their first element in the query's results, and elements + /// within a section follow the query's order. Access the sections from the projected value's + /// ``sections`` property. + /// + /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. + /// - sectionKeyPath: A key path to a string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + public init( + wrappedValue: [Element] = [], + sectionBy sectionKeyPath: KeyPath, + database: (any DatabaseReader)? = nil + ) + where Element: StructuredQueriesCore.Table, Element.QueryOutput == Element { + let statement: Select = Element.all.selectStar().asSelect() + self.init( + wrappedValue: wrappedValue, + statement: statement, + sectionBy: SectionBy(sectionKeyPath), + database: database, + scheduler: nil + ) + } + + /// Initializes this property with a query associated with the wrapped value, grouping results + /// into sections. + /// + /// Results are grouped into a section for each distinct value at the given key path. Sections + /// are ordered by the position of their first element in the query's results, and elements + /// within a section follow the query's order. To control the order of sections, order the query + /// by the sectioned column: + /// + /// ```swift + /// @FetchAll(Reminder.order(by: \.category), sectionBy: \.category) + /// var reminders + /// ``` + /// + /// Access the sections from the projected value's ``sections`` property. + /// + /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. + /// - statement: A query associated with the wrapped value. + /// - sectionKeyPath: A key path to a string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + public init( + wrappedValue: [Element] = [], + _ statement: S, + sectionBy sectionKeyPath: KeyPath, + database: (any DatabaseReader)? = nil + ) + where + Element == S.From.QueryOutput, + S.QueryValue == (), + S.From.QueryOutput: Sendable, + S.Joins == () + { + let statement: Select = statement.selectStar() + self.init( + wrappedValue: wrappedValue, + statement: statement, + sectionBy: SectionBy(sectionKeyPath), + database: database, + scheduler: nil + ) + } + + /// Initializes this property with a query associated with the wrapped value, grouping results + /// into sections. + /// + /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. + /// - statement: A query associated with the wrapped value. + /// - sectionKeyPath: A key path to a string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + public init( + wrappedValue: [Element] = [], + _ statement: some StructuredQueriesCore.Statement, + sectionBy sectionKeyPath: KeyPath, + database: (any DatabaseReader)? = nil + ) + where + Element == V.QueryOutput, + V.QueryOutput: Sendable + { + self.init( + wrappedValue: wrappedValue, + statement: statement, + sectionBy: SectionBy(sectionKeyPath), + database: database, + scheduler: nil + ) + } + + /// Initializes this property with a query associated with the wrapped value, grouping results + /// into sections. + /// + /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. + /// - statement: A query associated with the wrapped value. + /// - sectionKeyPath: A key path to a string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + public init>( + wrappedValue: [Element] = [], + _ statement: S, + sectionBy sectionKeyPath: KeyPath, + database: (any DatabaseReader)? = nil + ) + where + Element: QueryRepresentable, + Element == S.QueryValue.QueryOutput + { + self.init( + wrappedValue: wrappedValue, + statement: statement, + sectionBy: SectionBy(sectionKeyPath), + database: database, + scheduler: nil + ) + } + + /// Initializes this property with a query that fetches every row from a table, grouping results + /// into sections. + /// + /// A `nil` value at the given key path is grouped into a section named by the empty string. + /// + /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. + /// - sectionKeyPath: A key path to an optional string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + public init( + wrappedValue: [Element] = [], + sectionBy sectionKeyPath: KeyPath, + database: (any DatabaseReader)? = nil + ) + where Element: StructuredQueriesCore.Table, Element.QueryOutput == Element { + let statement: Select = Element.all.selectStar().asSelect() + self.init( + wrappedValue: wrappedValue, + statement: statement, + sectionBy: SectionBy(sectionKeyPath), + database: database, + scheduler: nil + ) + } + + /// Initializes this property with a query associated with the wrapped value, grouping results + /// into sections. + /// + /// A `nil` value at the given key path is grouped into a section named by the empty string. + /// + /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. + /// - statement: A query associated with the wrapped value. + /// - sectionKeyPath: A key path to an optional string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + public init( + wrappedValue: [Element] = [], + _ statement: S, + sectionBy sectionKeyPath: KeyPath, + database: (any DatabaseReader)? = nil + ) + where + Element == S.From.QueryOutput, + S.QueryValue == (), + S.From.QueryOutput: Sendable, + S.Joins == () + { + let statement: Select = statement.selectStar() + self.init( + wrappedValue: wrappedValue, + statement: statement, + sectionBy: SectionBy(sectionKeyPath), + database: database, + scheduler: nil + ) + } + + /// Initializes this property with a query associated with the wrapped value, grouping results + /// into sections. + /// + /// A `nil` value at the given key path is grouped into a section named by the empty string. + /// + /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. + /// - statement: A query associated with the wrapped value. + /// - sectionKeyPath: A key path to an optional string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + public init( + wrappedValue: [Element] = [], + _ statement: some StructuredQueriesCore.Statement, + sectionBy sectionKeyPath: KeyPath, + database: (any DatabaseReader)? = nil + ) + where + Element == V.QueryOutput, + V.QueryOutput: Sendable + { + self.init( + wrappedValue: wrappedValue, + statement: statement, + sectionBy: SectionBy(sectionKeyPath), + database: database, + scheduler: nil + ) + } + + /// Initializes this property with a query associated with the wrapped value, grouping results + /// into sections. + /// + /// A `nil` value at the given key path is grouped into a section named by the empty string. + /// + /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. + /// - statement: A query associated with the wrapped value. + /// - sectionKeyPath: A key path to an optional string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + public init>( + wrappedValue: [Element] = [], + _ statement: S, + sectionBy sectionKeyPath: KeyPath, + database: (any DatabaseReader)? = nil + ) + where + Element: QueryRepresentable, + Element == S.QueryValue.QueryOutput + { + self.init( + wrappedValue: wrappedValue, + statement: statement, + sectionBy: SectionBy(sectionKeyPath), + database: database, + scheduler: nil + ) + } +} + +extension FetchAll { + /// Initializes this property with a query that fetches every row from a table, grouping results + /// into sections. + /// + /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. + /// - sectionKeyPath: A key path to a string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - scheduler: The scheduler to observe from. By default, database observation is performed + /// asynchronously on the main queue. + public init( + wrappedValue: [Element] = [], + sectionBy sectionKeyPath: KeyPath, + database: (any DatabaseReader)? = nil, + scheduler: some ValueObservationScheduler & Hashable + ) + where Element: StructuredQueriesCore.Table, Element.QueryOutput == Element { + let statement: Select = Element.all.selectStar().asSelect() + self.init( + wrappedValue: wrappedValue, + statement: statement, + sectionBy: SectionBy(sectionKeyPath), + database: database, + scheduler: scheduler + ) + } + + /// Initializes this property with a query associated with the wrapped value, grouping results + /// into sections. + /// + /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. + /// - statement: A query associated with the wrapped value. + /// - sectionKeyPath: A key path to a string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - scheduler: The scheduler to observe from. By default, database observation is performed + /// asynchronously on the main queue. + public init( + wrappedValue: [Element] = [], + _ statement: S, + sectionBy sectionKeyPath: KeyPath, + database: (any DatabaseReader)? = nil, + scheduler: some ValueObservationScheduler & Hashable + ) + where + Element == S.From.QueryOutput, + S.QueryValue == (), + S.From.QueryOutput: Sendable, + S.Joins == () + { + let statement: Select = statement.selectStar() + self.init( + wrappedValue: wrappedValue, + statement: statement, + sectionBy: SectionBy(sectionKeyPath), + database: database, + scheduler: scheduler + ) + } + + /// Initializes this property with a query associated with the wrapped value, grouping results + /// into sections. + /// + /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. + /// - statement: A query associated with the wrapped value. + /// - sectionKeyPath: A key path to a string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - scheduler: The scheduler to observe from. By default, database observation is performed + /// asynchronously on the main queue. + public init( + wrappedValue: [Element] = [], + _ statement: some StructuredQueriesCore.Statement, + sectionBy sectionKeyPath: KeyPath, + database: (any DatabaseReader)? = nil, + scheduler: some ValueObservationScheduler & Hashable + ) + where + Element == V.QueryOutput, + V.QueryOutput: Sendable + { + self.init( + wrappedValue: wrappedValue, + statement: statement, + sectionBy: SectionBy(sectionKeyPath), + database: database, + scheduler: scheduler + ) + } + + /// Initializes this property with a query associated with the wrapped value, grouping results + /// into sections. + /// + /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. + /// - statement: A query associated with the wrapped value. + /// - sectionKeyPath: A key path to a string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - scheduler: The scheduler to observe from. By default, database observation is performed + /// asynchronously on the main queue. + public init>( + wrappedValue: [Element] = [], + _ statement: S, + sectionBy sectionKeyPath: KeyPath, + database: (any DatabaseReader)? = nil, + scheduler: some ValueObservationScheduler & Hashable + ) + where + Element: QueryRepresentable, + Element == S.QueryValue.QueryOutput + { + self.init( + wrappedValue: wrappedValue, + statement: statement, + sectionBy: SectionBy(sectionKeyPath), + database: database, + scheduler: scheduler + ) + } + + /// Initializes this property with a query that fetches every row from a table, grouping results + /// into sections. + /// + /// A `nil` value at the given key path is grouped into a section named by the empty string. + /// + /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. + /// - sectionKeyPath: A key path to an optional string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - scheduler: The scheduler to observe from. By default, database observation is performed + /// asynchronously on the main queue. + public init( + wrappedValue: [Element] = [], + sectionBy sectionKeyPath: KeyPath, + database: (any DatabaseReader)? = nil, + scheduler: some ValueObservationScheduler & Hashable + ) + where Element: StructuredQueriesCore.Table, Element.QueryOutput == Element { + let statement: Select = Element.all.selectStar().asSelect() + self.init( + wrappedValue: wrappedValue, + statement: statement, + sectionBy: SectionBy(sectionKeyPath), + database: database, + scheduler: scheduler + ) + } + + /// Initializes this property with a query associated with the wrapped value, grouping results + /// into sections. + /// + /// A `nil` value at the given key path is grouped into a section named by the empty string. + /// + /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. + /// - statement: A query associated with the wrapped value. + /// - sectionKeyPath: A key path to an optional string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - scheduler: The scheduler to observe from. By default, database observation is performed + /// asynchronously on the main queue. + public init( + wrappedValue: [Element] = [], + _ statement: S, + sectionBy sectionKeyPath: KeyPath, + database: (any DatabaseReader)? = nil, + scheduler: some ValueObservationScheduler & Hashable + ) + where + Element == S.From.QueryOutput, + S.QueryValue == (), + S.From.QueryOutput: Sendable, + S.Joins == () + { + let statement: Select = statement.selectStar() + self.init( + wrappedValue: wrappedValue, + statement: statement, + sectionBy: SectionBy(sectionKeyPath), + database: database, + scheduler: scheduler + ) + } + + /// Initializes this property with a query associated with the wrapped value, grouping results + /// into sections. + /// + /// A `nil` value at the given key path is grouped into a section named by the empty string. + /// + /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. + /// - statement: A query associated with the wrapped value. + /// - sectionKeyPath: A key path to an optional string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - scheduler: The scheduler to observe from. By default, database observation is performed + /// asynchronously on the main queue. + public init( + wrappedValue: [Element] = [], + _ statement: some StructuredQueriesCore.Statement, + sectionBy sectionKeyPath: KeyPath, + database: (any DatabaseReader)? = nil, + scheduler: some ValueObservationScheduler & Hashable + ) + where + Element == V.QueryOutput, + V.QueryOutput: Sendable + { + self.init( + wrappedValue: wrappedValue, + statement: statement, + sectionBy: SectionBy(sectionKeyPath), + database: database, + scheduler: scheduler + ) + } + + /// Initializes this property with a query associated with the wrapped value, grouping results + /// into sections. + /// + /// A `nil` value at the given key path is grouped into a section named by the empty string. + /// + /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. + /// - statement: A query associated with the wrapped value. + /// - sectionKeyPath: A key path to an optional string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - scheduler: The scheduler to observe from. By default, database observation is performed + /// asynchronously on the main queue. + public init>( + wrappedValue: [Element] = [], + _ statement: S, + sectionBy sectionKeyPath: KeyPath, + database: (any DatabaseReader)? = nil, + scheduler: some ValueObservationScheduler & Hashable + ) + where + Element: QueryRepresentable, + Element == S.QueryValue.QueryOutput + { + self.init( + wrappedValue: wrappedValue, + statement: statement, + sectionBy: SectionBy(sectionKeyPath), + database: database, + scheduler: scheduler + ) + } +} + +#if canImport(SwiftUI) + extension FetchAll { + /// Initializes this property with a query that fetches every row from a table, grouping + /// results into sections. + /// + /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. + /// - sectionKeyPath: A key path to a string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - animation: The animation to use for user interface changes that result from changes to + /// the fetched results. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + public init( + wrappedValue: [Element] = [], + sectionBy sectionKeyPath: KeyPath, + database: (any DatabaseReader)? = nil, + animation: Animation + ) + where Element: StructuredQueriesCore.Table, Element.QueryOutput == Element { + self.init( + wrappedValue: wrappedValue, + sectionBy: sectionKeyPath, + database: database, + scheduler: .animation(animation) + ) + } + + /// Initializes this property with a query associated with the wrapped value, grouping results + /// into sections. + /// + /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. + /// - statement: A query associated with the wrapped value. + /// - sectionKeyPath: A key path to a string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - animation: The animation to use for user interface changes that result from changes to + /// the fetched results. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + public init( + wrappedValue: [Element] = [], + _ statement: S, + sectionBy sectionKeyPath: KeyPath, + database: (any DatabaseReader)? = nil, + animation: Animation + ) + where + Element == S.From.QueryOutput, + S.QueryValue == (), + S.From.QueryOutput: Sendable, + S.Joins == () + { + self.init( + wrappedValue: wrappedValue, + statement, + sectionBy: sectionKeyPath, + database: database, + scheduler: .animation(animation) + ) + } + + /// Initializes this property with a query associated with the wrapped value, grouping results + /// into sections. + /// + /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. + /// - statement: A query associated with the wrapped value. + /// - sectionKeyPath: A key path to a string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - animation: The animation to use for user interface changes that result from changes to + /// the fetched results. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + public init( + wrappedValue: [Element] = [], + _ statement: some StructuredQueriesCore.Statement, + sectionBy sectionKeyPath: KeyPath, + database: (any DatabaseReader)? = nil, + animation: Animation + ) + where + Element == V.QueryOutput, + V.QueryOutput: Sendable + { + self.init( + wrappedValue: wrappedValue, + statement, + sectionBy: sectionKeyPath, + database: database, + scheduler: .animation(animation) + ) + } + + /// Initializes this property with a query associated with the wrapped value, grouping results + /// into sections. + /// + /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. + /// - statement: A query associated with the wrapped value. + /// - sectionKeyPath: A key path to a string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - animation: The animation to use for user interface changes that result from changes to + /// the fetched results. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + public init>( + wrappedValue: [Element] = [], + _ statement: S, + sectionBy sectionKeyPath: KeyPath, + database: (any DatabaseReader)? = nil, + animation: Animation + ) + where + Element: QueryRepresentable, + Element == S.QueryValue.QueryOutput + { + self.init( + wrappedValue: wrappedValue, + statement, + sectionBy: sectionKeyPath, + database: database, + scheduler: .animation(animation) + ) + } + + /// Initializes this property with a query that fetches every row from a table, grouping + /// results into sections. + /// + /// A `nil` value at the given key path is grouped into a section named by the empty string. + /// + /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. + /// - sectionKeyPath: A key path to an optional string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - animation: The animation to use for user interface changes that result from changes to + /// the fetched results. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + public init( + wrappedValue: [Element] = [], + sectionBy sectionKeyPath: KeyPath, + database: (any DatabaseReader)? = nil, + animation: Animation + ) + where Element: StructuredQueriesCore.Table, Element.QueryOutput == Element { + self.init( + wrappedValue: wrappedValue, + sectionBy: sectionKeyPath, + database: database, + scheduler: .animation(animation) + ) + } + + /// Initializes this property with a query associated with the wrapped value, grouping results + /// into sections. + /// + /// A `nil` value at the given key path is grouped into a section named by the empty string. + /// + /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. + /// - statement: A query associated with the wrapped value. + /// - sectionKeyPath: A key path to an optional string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - animation: The animation to use for user interface changes that result from changes to + /// the fetched results. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + public init( + wrappedValue: [Element] = [], + _ statement: S, + sectionBy sectionKeyPath: KeyPath, + database: (any DatabaseReader)? = nil, + animation: Animation + ) + where + Element == S.From.QueryOutput, + S.QueryValue == (), + S.From.QueryOutput: Sendable, + S.Joins == () + { + self.init( + wrappedValue: wrappedValue, + statement, + sectionBy: sectionKeyPath, + database: database, + scheduler: .animation(animation) + ) + } + + /// Initializes this property with a query associated with the wrapped value, grouping results + /// into sections. + /// + /// A `nil` value at the given key path is grouped into a section named by the empty string. + /// + /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. + /// - statement: A query associated with the wrapped value. + /// - sectionKeyPath: A key path to an optional string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - animation: The animation to use for user interface changes that result from changes to + /// the fetched results. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + public init( + wrappedValue: [Element] = [], + _ statement: some StructuredQueriesCore.Statement, + sectionBy sectionKeyPath: KeyPath, + database: (any DatabaseReader)? = nil, + animation: Animation + ) + where + Element == V.QueryOutput, + V.QueryOutput: Sendable + { + self.init( + wrappedValue: wrappedValue, + statement, + sectionBy: sectionKeyPath, + database: database, + scheduler: .animation(animation) + ) + } + + /// Initializes this property with a query associated with the wrapped value, grouping results + /// into sections. + /// + /// A `nil` value at the given key path is grouped into a section named by the empty string. + /// + /// - Parameters: + /// - wrappedValue: A default collection to associate with this property. + /// - statement: A query associated with the wrapped value. + /// - sectionKeyPath: A key path to an optional string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - animation: The animation to use for user interface changes that result from changes to + /// the fetched results. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + public init>( + wrappedValue: [Element] = [], + _ statement: S, + sectionBy sectionKeyPath: KeyPath, + database: (any DatabaseReader)? = nil, + animation: Animation + ) + where + Element: QueryRepresentable, + Element == S.QueryValue.QueryOutput + { + self.init( + wrappedValue: wrappedValue, + statement, + sectionBy: sectionKeyPath, + database: database, + scheduler: .animation(animation) + ) + } + } +#endif + +extension FetchAll { + /// Replaces the wrapped value with data from the given query, grouping results into sections. + /// + /// The given key path replaces any sectioning previously applied to this property, and is used + /// by all subsequent loads. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - sectionKeyPath: A key path to a string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - Returns: A subscription associated with the observation. + @discardableResult + public func load( + _ statement: S, + sectionBy sectionKeyPath: KeyPath, + database: (any DatabaseReader)? = nil + ) async throws -> FetchSubscription + where + Element == S.From.QueryOutput, + S.QueryValue == (), + S.From.QueryOutput: Sendable, + S.Joins == () + { + let statement: Select = statement.selectStar() + return try await loadSections( + statement: statement, + sectionBy: SectionBy(sectionKeyPath), + database: database, + scheduler: nil + ) + } + + /// Replaces the wrapped value with data from the given query, grouping results into sections. + /// + /// The given key path replaces any sectioning previously applied to this property, and is used + /// by all subsequent loads. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - sectionKeyPath: A key path to a string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - Returns: A subscription associated with the observation. + @discardableResult + public func load( + _ statement: some StructuredQueriesCore.Statement, + sectionBy sectionKeyPath: KeyPath, + database: (any DatabaseReader)? = nil + ) async throws -> FetchSubscription + where + Element == V.QueryOutput, + V.QueryOutput: Sendable + { + try await loadSections( + statement: statement, + sectionBy: SectionBy(sectionKeyPath), + database: database, + scheduler: nil + ) + } + + /// Replaces the wrapped value with data from the given query, grouping results into sections. + /// + /// A `nil` value at the given key path is grouped into a section named by the empty string. The + /// given key path replaces any sectioning previously applied to this property, and is used by + /// all subsequent loads. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - sectionKeyPath: A key path to an optional string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - Returns: A subscription associated with the observation. + @discardableResult + public func load( + _ statement: S, + sectionBy sectionKeyPath: KeyPath, + database: (any DatabaseReader)? = nil + ) async throws -> FetchSubscription + where + Element == S.From.QueryOutput, + S.QueryValue == (), + S.From.QueryOutput: Sendable, + S.Joins == () + { + let statement: Select = statement.selectStar() + return try await loadSections( + statement: statement, + sectionBy: SectionBy(sectionKeyPath), + database: database, + scheduler: nil + ) + } + + /// Replaces the wrapped value with data from the given query, grouping results into sections. + /// + /// A `nil` value at the given key path is grouped into a section named by the empty string. The + /// given key path replaces any sectioning previously applied to this property, and is used by + /// all subsequent loads. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - sectionKeyPath: A key path to an optional string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - Returns: A subscription associated with the observation. + @discardableResult + public func load( + _ statement: some StructuredQueriesCore.Statement, + sectionBy sectionKeyPath: KeyPath, + database: (any DatabaseReader)? = nil + ) async throws -> FetchSubscription + where + Element == V.QueryOutput, + V.QueryOutput: Sendable + { + try await loadSections( + statement: statement, + sectionBy: SectionBy(sectionKeyPath), + database: database, + scheduler: nil + ) + } + + /// Replaces the wrapped value with data from the given query, grouping results into sections. + /// + /// The given key path replaces any sectioning previously applied to this property, and is used + /// by all subsequent loads. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - sectionKeyPath: A key path to a string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - scheduler: The scheduler to observe from. By default, database observation is performed + /// asynchronously on the main queue. + /// - Returns: A subscription associated with the observation. + @discardableResult + public func load( + _ statement: S, + sectionBy sectionKeyPath: KeyPath, + database: (any DatabaseReader)? = nil, + scheduler: some ValueObservationScheduler & Hashable + ) async throws -> FetchSubscription + where + Element == S.From.QueryOutput, + S.QueryValue == (), + S.From.QueryOutput: Sendable, + S.Joins == () + { + let statement: Select = statement.selectStar() + return try await loadSections( + statement: statement, + sectionBy: SectionBy(sectionKeyPath), + database: database, + scheduler: scheduler + ) + } + + /// Replaces the wrapped value with data from the given query, grouping results into sections. + /// + /// The given key path replaces any sectioning previously applied to this property, and is used + /// by all subsequent loads. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - sectionKeyPath: A key path to a string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - scheduler: The scheduler to observe from. By default, database observation is performed + /// asynchronously on the main queue. + /// - Returns: A subscription associated with the observation. + @discardableResult + public func load( + _ statement: some StructuredQueriesCore.Statement, + sectionBy sectionKeyPath: KeyPath, + database: (any DatabaseReader)? = nil, + scheduler: some ValueObservationScheduler & Hashable + ) async throws -> FetchSubscription + where + Element == V.QueryOutput, + V.QueryOutput: Sendable + { + try await loadSections( + statement: statement, + sectionBy: SectionBy(sectionKeyPath), + database: database, + scheduler: scheduler + ) + } + + /// Replaces the wrapped value with data from the given query, grouping results into sections. + /// + /// A `nil` value at the given key path is grouped into a section named by the empty string. The + /// given key path replaces any sectioning previously applied to this property, and is used by + /// all subsequent loads. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - sectionKeyPath: A key path to an optional string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - scheduler: The scheduler to observe from. By default, database observation is performed + /// asynchronously on the main queue. + /// - Returns: A subscription associated with the observation. + @discardableResult + public func load( + _ statement: S, + sectionBy sectionKeyPath: KeyPath, + database: (any DatabaseReader)? = nil, + scheduler: some ValueObservationScheduler & Hashable + ) async throws -> FetchSubscription + where + Element == S.From.QueryOutput, + S.QueryValue == (), + S.From.QueryOutput: Sendable, + S.Joins == () + { + let statement: Select = statement.selectStar() + return try await loadSections( + statement: statement, + sectionBy: SectionBy(sectionKeyPath), + database: database, + scheduler: scheduler + ) + } + + /// Replaces the wrapped value with data from the given query, grouping results into sections. + /// + /// A `nil` value at the given key path is grouped into a section named by the empty string. The + /// given key path replaces any sectioning previously applied to this property, and is used by + /// all subsequent loads. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - sectionKeyPath: A key path to an optional string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - scheduler: The scheduler to observe from. By default, database observation is performed + /// asynchronously on the main queue. + /// - Returns: A subscription associated with the observation. + @discardableResult + public func load( + _ statement: some StructuredQueriesCore.Statement, + sectionBy sectionKeyPath: KeyPath, + database: (any DatabaseReader)? = nil, + scheduler: some ValueObservationScheduler & Hashable + ) async throws -> FetchSubscription + where + Element == V.QueryOutput, + V.QueryOutput: Sendable + { + try await loadSections( + statement: statement, + sectionBy: SectionBy(sectionKeyPath), + database: database, + scheduler: scheduler + ) + } + + func loadSections( + statement: some StructuredQueriesCore.Statement, + sectionBy newSectionBy: SectionBy, + database: (any DatabaseReader)?, + scheduler: (any ValueObservationScheduler & Hashable)? + ) async throws -> FetchSubscription + where + Element == V.QueryOutput, + V.QueryOutput: Sendable + { + sectionedBy.setValue(newSectionBy) + defer { + sharedReader.projectedValue = sectionedReader[dynamicMember: \.elements].projectedValue + } + try await sectionedReader.load( + FetchKey( + request: FetchAllSectionedStatementValueRequest( + statement: statement, + sectionBy: newSectionBy + ), + database: database, + scheduler: scheduler + ) + ) + return FetchSubscription(sharedReader: sharedReader, sectionedReader: sectionedReader) + } +} + +#if canImport(SwiftUI) + extension FetchAll { + /// Replaces the wrapped value with data from the given query, grouping results into sections. + /// + /// The given key path replaces any sectioning previously applied to this property, and is + /// used by all subsequent loads. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - sectionKeyPath: A key path to a string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - animation: The animation to use for user interface changes that result from changes to + /// the fetched results. + /// - Returns: A subscription associated with the observation. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @discardableResult + public func load( + _ statement: S, + sectionBy sectionKeyPath: KeyPath, + database: (any DatabaseReader)? = nil, + animation: Animation? + ) async throws -> FetchSubscription + where + Element == S.From.QueryOutput, + S.QueryValue == (), + S.From.QueryOutput: Sendable, + S.Joins == () + { + try await load( + statement, + sectionBy: sectionKeyPath, + database: database, + scheduler: .animation(animation) + ) + } + + /// Replaces the wrapped value with data from the given query, grouping results into sections. + /// + /// The given key path replaces any sectioning previously applied to this property, and is + /// used by all subsequent loads. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - sectionKeyPath: A key path to a string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - animation: The animation to use for user interface changes that result from changes to + /// the fetched results. + /// - Returns: A subscription associated with the observation. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @discardableResult + public func load( + _ statement: some StructuredQueriesCore.Statement, + sectionBy sectionKeyPath: KeyPath, + database: (any DatabaseReader)? = nil, + animation: Animation? + ) async throws -> FetchSubscription + where + Element == V.QueryOutput, + V.QueryOutput: Sendable + { + try await load( + statement, + sectionBy: sectionKeyPath, + database: database, + scheduler: .animation(animation) + ) + } + + /// Replaces the wrapped value with data from the given query, grouping results into sections. + /// + /// A `nil` value at the given key path is grouped into a section named by the empty string. + /// The given key path replaces any sectioning previously applied to this property, and is + /// used by all subsequent loads. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - sectionKeyPath: A key path to an optional string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - animation: The animation to use for user interface changes that result from changes to + /// the fetched results. + /// - Returns: A subscription associated with the observation. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @discardableResult + public func load( + _ statement: S, + sectionBy sectionKeyPath: KeyPath, + database: (any DatabaseReader)? = nil, + animation: Animation? + ) async throws -> FetchSubscription + where + Element == S.From.QueryOutput, + S.QueryValue == (), + S.From.QueryOutput: Sendable, + S.Joins == () + { + try await load( + statement, + sectionBy: sectionKeyPath, + database: database, + scheduler: .animation(animation) + ) + } + + /// Replaces the wrapped value with data from the given query, grouping results into sections. + /// + /// A `nil` value at the given key path is grouped into a section named by the empty string. + /// The given key path replaces any sectioning previously applied to this property, and is + /// used by all subsequent loads. + /// + /// - Parameters: + /// - statement: A query associated with the wrapped value. + /// - sectionKeyPath: A key path to an optional string to group results by. + /// - database: The database to read from. A value of `nil` will use the default database + /// (`@Dependency(\.defaultDatabase)`). + /// - animation: The animation to use for user interface changes that result from changes to + /// the fetched results. + /// - Returns: A subscription associated with the observation. + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @discardableResult + public func load( + _ statement: some StructuredQueriesCore.Statement, + sectionBy sectionKeyPath: KeyPath, + database: (any DatabaseReader)? = nil, + animation: Animation? + ) async throws -> FetchSubscription + where + Element == V.QueryOutput, + V.QueryOutput: Sendable + { + try await load( + statement, + sectionBy: sectionKeyPath, + database: database, + scheduler: .animation(animation) + ) + } + } +#endif + +struct FetchAllSectionedStatementValueRequest: FetchKeyRequest +where Value.QueryOutput: Sendable { + let statement: SQLQueryExpression + let sectionedBy: SectionBy + + init( + statement: some StructuredQueriesCore.Statement, + sectionBy: SectionBy + ) { + self.statement = SQLQueryExpression(statement) + self.sectionedBy = sectionBy + } + + func fetch(_ db: Database) throws -> ResultsSectionCollection { + try ResultsSectionCollection( + cursor: statement.fetchCursor(db), + sectionName: sectionedBy.name + ) + } + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.statement.query == rhs.statement.query && lhs.sectionedBy == rhs.sectionedBy + } + + func hash(into hasher: inout Hasher) { + hasher.combine(statement.query) + hasher.combine(sectionedBy) + } +} diff --git a/Sources/SQLiteData/FetchAll.swift b/Sources/SQLiteData/FetchAll.swift index ec118cdf..286100d3 100644 --- a/Sources/SQLiteData/FetchAll.swift +++ b/Sources/SQLiteData/FetchAll.swift @@ -1,3 +1,4 @@ +import ConcurrencyExtras public import GRDB public import Sharing public import StructuredQueriesCore @@ -26,7 +27,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 sectionedBy = LockIsolated?>(nil) /// A collection of data associated with the underlying query. public var wrappedValue: [Element] { @@ -39,7 +45,11 @@ public struct FetchAll: Sendable { /// ``isLoading``, and ``publisher``. public var projectedValue: Self { get { self } - nonmutating set { sharedReader.projectedValue = newValue.sharedReader.projectedValue } + nonmutating set { + sharedReader.projectedValue = newValue.sharedReader.projectedValue + sectionedReader.projectedValue = newValue.sectionedReader.projectedValue + sectionedBy.setValue(newValue.sectionedBy.value) + } } /// Returns a ``sharedReader`` for the given key path. @@ -211,6 +221,14 @@ public struct FetchAll: Sendable { Element == V.QueryOutput, V.QueryOutput: Sendable { + if let sectionBy = sectionedBy.value { + return try await loadSections( + statement: statement, + sectionBy: sectionBy, + database: database, + scheduler: nil + ) + } try await sharedReader.load( .fetch( FetchAllStatementValueRequest(statement: statement), @@ -377,6 +395,14 @@ extension FetchAll { Element == V.QueryOutput, V.QueryOutput: Sendable { + if let sectionBy = sectionedBy.value { + return try await loadSections( + statement: statement, + sectionBy: sectionBy, + database: database, + scheduler: scheduler + ) + } try await sharedReader.load( .fetch( FetchAllStatementValueRequest(statement: statement), @@ -396,7 +422,7 @@ extension FetchAll: CustomReflectable { extension FetchAll: Equatable where Element: Equatable { public static func == (lhs: Self, rhs: Self) -> Bool { - lhs.sharedReader == rhs.sharedReader + lhs.sharedReader == rhs.sharedReader && lhs.sectionedBy.value == rhs.sectionedBy.value } } @@ -404,6 +430,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'.") @@ -566,6 +593,14 @@ extension FetchAll: Equatable where Element: Equatable { Element == V.QueryOutput, V.QueryOutput: Sendable { + if let sectionBy = sectionedBy.value { + return try await loadSections( + statement: statement, + sectionBy: sectionBy, + database: database, + scheduler: AnimatedScheduler(animation: animation) + ) + } try await sharedReader.load( .fetch( FetchAllStatementValueRequest(statement: statement), diff --git a/Sources/SQLiteData/FetchSubscription.swift b/Sources/SQLiteData/FetchSubscription.swift index de794216..59bbdd81 100644 --- a/Sources/SQLiteData/FetchSubscription.swift +++ b/Sources/SQLiteData/FetchSubscription.swift @@ -22,6 +22,17 @@ public struct FetchSubscription: Sendable { onCancel = { sharedReader.projectedValue = SharedReader(value: sharedReader.wrappedValue) } } + init( + sharedReader: SharedReader<[Element]>, + sectionedReader: SharedReader> + ) { + onCancel = { + let sections = sectionedReader.wrappedValue + sectionedReader.projectedValue = SharedReader(value: sections) + sharedReader.projectedValue = SharedReader(value: sections.elements) + } + } + /// An async handle to the given fetch observation. /// /// This handle will suspend until the current task is cancelled, at which point it will terminate diff --git a/Sources/SQLiteData/ResultsSectionCollection.swift b/Sources/SQLiteData/ResultsSectionCollection.swift new file mode 100644 index 00000000..c556315a --- /dev/null +++ b/Sources/SQLiteData/ResultsSectionCollection.swift @@ -0,0 +1,213 @@ +import ConcurrencyExtras +import GRDB + +/// A collection of query results grouped into sections. +/// +/// You do not create this collection directly. Instead, initialize a ``FetchAll`` property with a +/// `sectionBy:` key path and access this collection from the projected value's +/// ``FetchAll/sections`` property: +/// +/// ```swift +/// @FetchAll(Reminder.order(by: \.category), sectionBy: \.category) +/// var reminders +/// +/// var body: some View { +/// List { +/// ForEach($reminders.sections) { section in +/// Section(section.name) { +/// ForEach(section) { reminder in +/// Text(reminder.title) +/// } +/// } +/// } +/// } +/// } +/// ``` +/// +/// Results are grouped into a section for each distinct value at the key path. Sections are +/// ordered by the position of their first element in the query's results, and elements within a +/// section follow the query's order. To control the order of sections, order the query by the +/// sectioned column. +public struct ResultsSectionCollection { + let elements: [Element] + private let sections: [ResultsSection] + private let sectionIndicesByName: [SectionName: Int] + + private init( + elements: [Element], + sections: [ResultsSection], + sectionIndicesByName: [SectionName: Int] + ) { + self.elements = elements + self.sections = sections + self.sectionIndicesByName = sectionIndicesByName + } + + init() { + self.init(elements: [], sections: [], sectionIndicesByName: [:]) + } + + init(elements: some Sequence, sectionName: (Element) -> SectionName) { + var builder = Builder() + for element in elements { + builder.append(element, sectionName: sectionName(element)) + } + self = builder.finish() + } + + init(cursor: QueryCursor, sectionName: (Element) -> SectionName) throws { + var builder = Builder() + while let element = try cursor.next() { + builder.append(element, sectionName: sectionName(element)) + } + self = builder.finish() + } + + /// The names of each section in the collection, in the order the sections appear. + public var sectionNames: [SectionName] { + sections.map(\.name) + } + + /// 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? { + sectionIndicesByName[name].map { sections[$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 { + sectionIndicesByName[name] != nil + } + + /// 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? { + sectionIndicesByName[name] + } + + private struct Builder { + private var elements: [Element] = [] + private var names: [SectionName] = [] + private var elementIndices: [[Int]] = [] + private var sectionIndicesByName: [SectionName: Int] = [:] + + mutating func append(_ element: Element, sectionName: SectionName) { + let elementIndex = elements.count + elements.append(element) + if let sectionIndex = sectionIndicesByName[sectionName] { + elementIndices[sectionIndex].append(elementIndex) + } else { + sectionIndicesByName[sectionName] = names.count + names.append(sectionName) + elementIndices.append([elementIndex]) + } + } + + func finish() -> ResultsSectionCollection { + ResultsSectionCollection( + elements: elements, + sections: zip(names, elementIndices).map { + ResultsSection(name: $0, base: elements, elementIndices: $1) + }, + sectionIndicesByName: sectionIndicesByName + ) + } + } +} + +extension ResultsSectionCollection: RandomAccessCollection { + public var startIndex: Int { + sections.startIndex + } + + public var endIndex: Int { + sections.endIndex + } + + public subscript(position: Int) -> ResultsSection { + sections[position] + } +} + +extension ResultsSectionCollection: Sendable where Element: Sendable, SectionName: Sendable {} + +extension ResultsSectionCollection: Equatable where Element: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.elementsEqual(rhs) + } +} + +/// A collection of query results in a section, identified by the section's name. +/// +/// See ``ResultsSectionCollection`` for more information. +public struct ResultsSection: Identifiable { + /// The name of the section. + /// + /// This is the value at the `sectionBy:` key path shared by every element in the section. + public let name: SectionName + + private let base: [Element] + private let elementIndices: [Int] + + init(name: SectionName, base: [Element], elementIndices: [Int]) { + self.name = name + self.base = base + self.elementIndices = elementIndices + } + + /// The identity of the section, equivalent to its ``name``. + public var id: SectionName { + name + } +} + +extension ResultsSection: RandomAccessCollection { + public var startIndex: Int { + 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) + } +} + +struct SectionBy: Hashable, Sendable { + let keyPath: AnyHashableSendable + let name: @Sendable (Element) -> String + + init(_ keyPath: KeyPath) { + let keyPath = unsafeBitCast(keyPath, to: (any KeyPath & Sendable).self) + self.keyPath = AnyHashableSendable(keyPath) + self.name = { $0[keyPath: keyPath] } + } + + init(_ keyPath: KeyPath) { + let keyPath = unsafeBitCast(keyPath, to: (any KeyPath & Sendable).self) + self.keyPath = AnyHashableSendable(keyPath) + self.name = { $0[keyPath: keyPath] ?? "" } + } + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.keyPath == rhs.keyPath + } + + func hash(into hasher: inout Hasher) { + hasher.combine(keyPath) + } +} diff --git a/Tests/SQLiteDataTests/FetchAllSectionsTests.swift b/Tests/SQLiteDataTests/FetchAllSectionsTests.swift new file mode 100644 index 00000000..16acc90b --- /dev/null +++ b/Tests/SQLiteDataTests/FetchAllSectionsTests.swift @@ -0,0 +1,222 @@ +import DependenciesTestSupport +import Foundation +import SQLiteData +import Testing + +@Suite(.dependency(\.defaultDatabase, try .database())) +struct FetchAllSectionsTests { + @Dependency(\.defaultDatabase) var database + + @Test func basics() async throws { + @FetchAll(SectionedReminder.order(by: \.id), sectionBy: \.category) var reminders + try await $reminders.load() + + #expect(reminders.map(\.id) == [1, 2, 3, 4, 5]) + #expect($reminders.sections.count == 3) + #expect($reminders.sections.sectionNames == ["Home", "Work", "Errands"]) + #expect($reminders.sections.map(\.name) == ["Home", "Work", "Errands"]) + #expect($reminders.sections[0].map(\.title) == ["Dishes", "Laundry"]) + #expect($reminders.sections[1].map(\.title) == ["Standup", "Review"]) + #expect($reminders.sections[2].map(\.title) == ["Groceries"]) + } + + @Test func sectionOrderFollowsQueryOrder() async throws { + @FetchAll(SectionedReminder.order { $0.category.desc() }, sectionBy: \.category) var reminders + try await $reminders.load() + + #expect($reminders.sections.sectionNames == ["Work", "Home", "Errands"]) + } + + @Test func optionalSectionName() async throws { + @FetchAll(SectionedReminder.order(by: \.id), sectionBy: \.priority) var reminders + try await $reminders.load() + + #expect($reminders.sections.sectionNames == ["high", "low", ""]) + #expect($reminders.sections[sectionName: ""]?.map(\.title) == ["Laundry", "Review"]) + } + + @Test func sectionLookup() async throws { + @FetchAll(SectionedReminder.order(by: \.id), sectionBy: \.category) var reminders + try await $reminders.load() + + let sections = $reminders.sections + #expect(sections[sectionName: "Work"]?.map(\.title) == ["Standup", "Review"]) + #expect(sections[sectionName: "Gym"] == nil) + #expect(sections.contains(sectionName: "Errands")) + #expect(!sections.contains(sectionName: "Gym")) + #expect(sections.index(ofSectionNamed: "Home") == 0) + #expect(sections.index(ofSectionNamed: "Errands") == 2) + #expect(sections.index(ofSectionNamed: "Gym") == nil) + + let work = try #require(sections[sectionName: "Work"]) + #expect(work.id == "Work") + #expect(work.name == "Work") + #expect(work.count == 2) + #expect(work[0].title == "Standup") + } + + @Test func observesChanges() async throws { + @FetchAll(SectionedReminder.order(by: \.id), sectionBy: \.category) var reminders + try await $reminders.load() + #expect($reminders.sections.count == 3) + + try await database.write { db in + try SectionedReminder.insert { + SectionedReminder.Draft(title: "Squats", category: "Gym") + } + .execute(db) + } + try await $reminders.load() + + #expect(reminders.count == 6) + #expect($reminders.sections.sectionNames == ["Home", "Work", "Errands", "Gym"]) + #expect($reminders.sections[sectionName: "Gym"]?.map(\.title) == ["Squats"]) + } + + @Test func loadStatementPreservesSectioning() async throws { + @FetchAll(SectionedReminder.order(by: \.id), sectionBy: \.category) var reminders + try await $reminders.load() + #expect($reminders.sections.count == 3) + + try await $reminders.load(SectionedReminder.where { $0.category.neq("Work") }.order(by: \.id)) + + #expect(reminders.map(\.title) == ["Dishes", "Groceries", "Laundry"]) + #expect($reminders.sections.sectionNames == ["Home", "Errands"]) + #expect($reminders.sections[sectionName: "Home"]?.map(\.title) == ["Dishes", "Laundry"]) + } + + @Test func emptyResults() async throws { + @FetchAll(SectionedReminder.where { $0.id > 100 }, sectionBy: \.category) var reminders + try await $reminders.load() + + #expect(reminders.isEmpty) + #expect($reminders.sections.isEmpty) + #expect($reminders.sections.sectionNames.isEmpty) + } + + @Test func defaultWrappedValueIsSectioned() throws { + let defaults = [ + SectionedReminder(id: 1, title: "A", category: "One"), + SectionedReminder(id: 2, title: "B", category: "Two"), + SectionedReminder(id: 3, title: "C", category: "One"), + ] + @FetchAll( + wrappedValue: defaults, + SectionedReminder.all, + sectionBy: \.category, + database: try DatabaseQueue() + ) + var reminders + + #expect($reminders.loadError != nil) + #expect(reminders == defaults) + #expect($reminders.sections.sectionNames == ["One", "Two"]) + #expect($reminders.sections[0].map(\.title) == ["A", "C"]) + } + + @Test func wholeTable() async throws { + @FetchAll(sectionBy: \SectionedReminder.category) var reminders + try await $reminders.load() + + #expect(reminders.count == 5) + #expect($reminders.sections.sectionNames == ["Home", "Work", "Errands"]) + } + + @Test func reassignment() async throws { + @FetchAll(SectionedReminder.order(by: \.id)) var reminders + try await $reminders.load() + #expect(reminders.count == 5) + + $reminders = FetchAll(SectionedReminder.order(by: \.id), sectionBy: \.category) + try await $reminders.load() + #expect(reminders.count == 5) + #expect($reminders.sections.sectionNames == ["Home", "Work", "Errands"]) + + $reminders = FetchAll(SectionedReminder.order(by: \.id), sectionBy: \.priority) + try await $reminders.load(SectionedReminder.order(by: \.id)) + #expect($reminders.sections.sectionNames == ["high", "low", ""]) + + $reminders = FetchAll(SectionedReminder.order(by: \.title)) + try await $reminders.load() + #expect(reminders.map(\.title) == ["Dishes", "Groceries", "Laundry", "Review", "Standup"]) + #expect($reminders.sections.isEmpty) + } + + @Test func loadSectionBy() async throws { + @FetchAll(SectionedReminder.order(by: \.id)) var reminders + try await $reminders.load() + #expect($reminders.sections.isEmpty) + + try await $reminders.load(SectionedReminder.order(by: \.id), sectionBy: \.category) + #expect(reminders.count == 5) + #expect($reminders.sections.sectionNames == ["Home", "Work", "Errands"]) + + try await $reminders.load(SectionedReminder.order(by: \.id), sectionBy: \.priority) + #expect($reminders.sections.sectionNames == ["high", "low", ""]) + + try await $reminders.load(SectionedReminder.where { $0.id <= 2 }.order(by: \.id)) + #expect(reminders.map(\.title) == ["Dishes", "Standup"]) + #expect($reminders.sections.sectionNames == ["high"]) + } + + @Test func equatable() async throws { + @FetchAll(SectionedReminder.order(by: \.id), sectionBy: \.category) var byCategory + @FetchAll(SectionedReminder.order(by: \.id), sectionBy: \.category) var byCategoryToo + @FetchAll(SectionedReminder.order(by: \.id), sectionBy: \.priority) var byPriority + @FetchAll(SectionedReminder.order(by: \.id)) var flat + try await $byCategory.load() + try await $byCategoryToo.load() + try await $byPriority.load() + try await $flat.load() + + #expect(byCategory == byCategoryToo) + #expect($byCategory == $byCategoryToo) + #expect($byCategory != $byPriority) + #expect($byCategory != $flat) + } + + @Test func sectionsAccessWithoutSectionBy() async throws { + @FetchAll var reminders: [SectionedReminder] + try await $reminders.load() + + #expect(!reminders.isEmpty) + #expect($reminders.sections.isEmpty) + } +} + +@Table +private struct SectionedReminder: Equatable, Identifiable { + let id: Int + var title = "" + var category = "" + var priority: String? +} + +extension DatabaseWriter where Self == DatabaseQueue { + fileprivate static func database() throws -> DatabaseQueue { + let database = try DatabaseQueue() + try database.write { db in + try #sql( + """ + CREATE TABLE "sectionedReminders" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "title" TEXT NOT NULL DEFAULT '', + "category" TEXT NOT NULL DEFAULT '', + "priority" TEXT + ) + """ + ) + .execute(db) + try SectionedReminder.insert { + SectionedReminder.Draft(title: "Dishes", category: "Home", priority: "high") + SectionedReminder.Draft(title: "Standup", category: "Work", priority: "high") + SectionedReminder.Draft(title: "Groceries", category: "Errands", priority: "low") + SectionedReminder.Draft(title: "Laundry", category: "Home", priority: nil) + SectionedReminder.Draft(title: "Review", category: "Work", priority: nil) + } + .execute(db) + } + return database + } +} + From 491174d26fb36edb05cd0b9036b3010620f9a122 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Thu, 2 Jul 2026 12:55:52 -0700 Subject: [PATCH 2/6] wip --- Sources/SQLiteData/FetchAll+Sections.swift | 243 +++++++++++------- Sources/SQLiteData/FetchAll.swift | 2 +- .../SQLiteData/ResultsSectionCollection.swift | 14 + .../FetchAllSectionsTests.swift | 50 +++- 4 files changed, 211 insertions(+), 98 deletions(-) diff --git a/Sources/SQLiteData/FetchAll+Sections.swift b/Sources/SQLiteData/FetchAll+Sections.swift index de01ff97..1ee70d5d 100644 --- a/Sources/SQLiteData/FetchAll+Sections.swift +++ b/Sources/SQLiteData/FetchAll+Sections.swift @@ -29,11 +29,11 @@ extension FetchAll { /// ``` /// /// See ``ResultsSectionCollection`` for more information. - /// - /// If this property was not initialized with a `sectionBy:` key path, this collection is empty, - /// even when the query has results. public var sections: ResultsSectionCollection { - sectionedReader.wrappedValue + guard sectionedBy.value != nil else { + return ResultsSectionCollection(elements: sharedReader.wrappedValue, sectionName: "") + } + return sectionedReader.wrappedValue } fileprivate init( @@ -77,11 +77,15 @@ extension FetchAll { /// (`@Dependency(\.defaultDatabase)`). public init( wrappedValue: [Element] = [], - sectionBy sectionKeyPath: KeyPath, + sectionBy sectionKeyPath: KeyPath?, database: (any DatabaseReader)? = nil ) where Element: StructuredQueriesCore.Table, Element.QueryOutput == Element { let statement: Select = Element.all.selectStar().asSelect() + guard let sectionKeyPath else { + self.init(wrappedValue: wrappedValue, statement, database: database) + return + } self.init( wrappedValue: wrappedValue, statement: statement, @@ -115,7 +119,7 @@ extension FetchAll { public init( wrappedValue: [Element] = [], _ statement: S, - sectionBy sectionKeyPath: KeyPath, + sectionBy sectionKeyPath: KeyPath?, database: (any DatabaseReader)? = nil ) where @@ -125,6 +129,10 @@ extension FetchAll { S.Joins == () { let statement: Select = statement.selectStar() + guard let sectionKeyPath else { + self.init(wrappedValue: wrappedValue, statement, database: database) + return + } self.init( wrappedValue: wrappedValue, statement: statement, @@ -146,13 +154,17 @@ extension FetchAll { public init( wrappedValue: [Element] = [], _ statement: some StructuredQueriesCore.Statement, - sectionBy sectionKeyPath: KeyPath, + sectionBy sectionKeyPath: KeyPath?, database: (any DatabaseReader)? = nil ) where Element == V.QueryOutput, V.QueryOutput: Sendable { + guard let sectionKeyPath else { + self.init(wrappedValue: wrappedValue, statement, database: database) + return + } self.init( wrappedValue: wrappedValue, statement: statement, @@ -174,13 +186,17 @@ extension FetchAll { public init>( wrappedValue: [Element] = [], _ statement: S, - sectionBy sectionKeyPath: KeyPath, + sectionBy sectionKeyPath: KeyPath?, database: (any DatabaseReader)? = nil ) where Element: QueryRepresentable, Element == S.QueryValue.QueryOutput { + guard let sectionKeyPath else { + self.init(wrappedValue: wrappedValue, statement, database: database) + return + } self.init( wrappedValue: wrappedValue, statement: statement, @@ -193,8 +209,6 @@ extension FetchAll { /// Initializes this property with a query that fetches every row from a table, grouping results /// into sections. /// - /// A `nil` value at the given key path is grouped into a section named by the empty string. - /// /// - Parameters: /// - wrappedValue: A default collection to associate with this property. /// - sectionKeyPath: A key path to an optional string to group results by. @@ -202,11 +216,15 @@ extension FetchAll { /// (`@Dependency(\.defaultDatabase)`). public init( wrappedValue: [Element] = [], - sectionBy sectionKeyPath: KeyPath, + sectionBy sectionKeyPath: KeyPath?, database: (any DatabaseReader)? = nil ) where Element: StructuredQueriesCore.Table, Element.QueryOutput == Element { let statement: Select = Element.all.selectStar().asSelect() + guard let sectionKeyPath else { + self.init(wrappedValue: wrappedValue, statement, database: database) + return + } self.init( wrappedValue: wrappedValue, statement: statement, @@ -219,8 +237,6 @@ extension FetchAll { /// Initializes this property with a query associated with the wrapped value, grouping results /// into sections. /// - /// A `nil` value at the given key path is grouped into a section named by the empty string. - /// /// - Parameters: /// - wrappedValue: A default collection to associate with this property. /// - statement: A query associated with the wrapped value. @@ -230,7 +246,7 @@ extension FetchAll { public init( wrappedValue: [Element] = [], _ statement: S, - sectionBy sectionKeyPath: KeyPath, + sectionBy sectionKeyPath: KeyPath?, database: (any DatabaseReader)? = nil ) where @@ -240,6 +256,10 @@ extension FetchAll { S.Joins == () { let statement: Select = statement.selectStar() + guard let sectionKeyPath else { + self.init(wrappedValue: wrappedValue, statement, database: database) + return + } self.init( wrappedValue: wrappedValue, statement: statement, @@ -252,8 +272,6 @@ extension FetchAll { /// Initializes this property with a query associated with the wrapped value, grouping results /// into sections. /// - /// A `nil` value at the given key path is grouped into a section named by the empty string. - /// /// - Parameters: /// - wrappedValue: A default collection to associate with this property. /// - statement: A query associated with the wrapped value. @@ -263,13 +281,17 @@ extension FetchAll { public init( wrappedValue: [Element] = [], _ statement: some StructuredQueriesCore.Statement, - sectionBy sectionKeyPath: KeyPath, + sectionBy sectionKeyPath: KeyPath?, database: (any DatabaseReader)? = nil ) where Element == V.QueryOutput, V.QueryOutput: Sendable { + guard let sectionKeyPath else { + self.init(wrappedValue: wrappedValue, statement, database: database) + return + } self.init( wrappedValue: wrappedValue, statement: statement, @@ -282,8 +304,6 @@ extension FetchAll { /// Initializes this property with a query associated with the wrapped value, grouping results /// into sections. /// - /// A `nil` value at the given key path is grouped into a section named by the empty string. - /// /// - Parameters: /// - wrappedValue: A default collection to associate with this property. /// - statement: A query associated with the wrapped value. @@ -293,13 +313,17 @@ extension FetchAll { public init>( wrappedValue: [Element] = [], _ statement: S, - sectionBy sectionKeyPath: KeyPath, + sectionBy sectionKeyPath: KeyPath?, database: (any DatabaseReader)? = nil ) where Element: QueryRepresentable, Element == S.QueryValue.QueryOutput { + guard let sectionKeyPath else { + self.init(wrappedValue: wrappedValue, statement, database: database) + return + } self.init( wrappedValue: wrappedValue, statement: statement, @@ -323,12 +347,16 @@ extension FetchAll { /// asynchronously on the main queue. public init( wrappedValue: [Element] = [], - sectionBy sectionKeyPath: KeyPath, + sectionBy sectionKeyPath: KeyPath?, database: (any DatabaseReader)? = nil, scheduler: some ValueObservationScheduler & Hashable ) where Element: StructuredQueriesCore.Table, Element.QueryOutput == Element { let statement: Select = Element.all.selectStar().asSelect() + guard let sectionKeyPath else { + self.init(wrappedValue: wrappedValue, statement, database: database, scheduler: scheduler) + return + } self.init( wrappedValue: wrappedValue, statement: statement, @@ -352,7 +380,7 @@ extension FetchAll { public init( wrappedValue: [Element] = [], _ statement: S, - sectionBy sectionKeyPath: KeyPath, + sectionBy sectionKeyPath: KeyPath?, database: (any DatabaseReader)? = nil, scheduler: some ValueObservationScheduler & Hashable ) @@ -363,6 +391,10 @@ extension FetchAll { S.Joins == () { let statement: Select = statement.selectStar() + guard let sectionKeyPath else { + self.init(wrappedValue: wrappedValue, statement, database: database, scheduler: scheduler) + return + } self.init( wrappedValue: wrappedValue, statement: statement, @@ -386,7 +418,7 @@ extension FetchAll { public init( wrappedValue: [Element] = [], _ statement: some StructuredQueriesCore.Statement, - sectionBy sectionKeyPath: KeyPath, + sectionBy sectionKeyPath: KeyPath?, database: (any DatabaseReader)? = nil, scheduler: some ValueObservationScheduler & Hashable ) @@ -394,6 +426,10 @@ extension FetchAll { Element == V.QueryOutput, V.QueryOutput: Sendable { + guard let sectionKeyPath else { + self.init(wrappedValue: wrappedValue, statement, database: database, scheduler: scheduler) + return + } self.init( wrappedValue: wrappedValue, statement: statement, @@ -417,7 +453,7 @@ extension FetchAll { public init>( wrappedValue: [Element] = [], _ statement: S, - sectionBy sectionKeyPath: KeyPath, + sectionBy sectionKeyPath: KeyPath?, database: (any DatabaseReader)? = nil, scheduler: some ValueObservationScheduler & Hashable ) @@ -425,6 +461,10 @@ extension FetchAll { Element: QueryRepresentable, Element == S.QueryValue.QueryOutput { + guard let sectionKeyPath else { + self.init(wrappedValue: wrappedValue, statement, database: database, scheduler: scheduler) + return + } self.init( wrappedValue: wrappedValue, statement: statement, @@ -437,8 +477,6 @@ extension FetchAll { /// Initializes this property with a query that fetches every row from a table, grouping results /// into sections. /// - /// A `nil` value at the given key path is grouped into a section named by the empty string. - /// /// - Parameters: /// - wrappedValue: A default collection to associate with this property. /// - sectionKeyPath: A key path to an optional string to group results by. @@ -448,12 +486,16 @@ extension FetchAll { /// asynchronously on the main queue. public init( wrappedValue: [Element] = [], - sectionBy sectionKeyPath: KeyPath, + sectionBy sectionKeyPath: KeyPath?, database: (any DatabaseReader)? = nil, scheduler: some ValueObservationScheduler & Hashable ) where Element: StructuredQueriesCore.Table, Element.QueryOutput == Element { let statement: Select = Element.all.selectStar().asSelect() + guard let sectionKeyPath else { + self.init(wrappedValue: wrappedValue, statement, database: database, scheduler: scheduler) + return + } self.init( wrappedValue: wrappedValue, statement: statement, @@ -466,8 +508,6 @@ extension FetchAll { /// Initializes this property with a query associated with the wrapped value, grouping results /// into sections. /// - /// A `nil` value at the given key path is grouped into a section named by the empty string. - /// /// - Parameters: /// - wrappedValue: A default collection to associate with this property. /// - statement: A query associated with the wrapped value. @@ -479,7 +519,7 @@ extension FetchAll { public init( wrappedValue: [Element] = [], _ statement: S, - sectionBy sectionKeyPath: KeyPath, + sectionBy sectionKeyPath: KeyPath?, database: (any DatabaseReader)? = nil, scheduler: some ValueObservationScheduler & Hashable ) @@ -490,6 +530,10 @@ extension FetchAll { S.Joins == () { let statement: Select = statement.selectStar() + guard let sectionKeyPath else { + self.init(wrappedValue: wrappedValue, statement, database: database, scheduler: scheduler) + return + } self.init( wrappedValue: wrappedValue, statement: statement, @@ -502,8 +546,6 @@ extension FetchAll { /// Initializes this property with a query associated with the wrapped value, grouping results /// into sections. /// - /// A `nil` value at the given key path is grouped into a section named by the empty string. - /// /// - Parameters: /// - wrappedValue: A default collection to associate with this property. /// - statement: A query associated with the wrapped value. @@ -515,7 +557,7 @@ extension FetchAll { public init( wrappedValue: [Element] = [], _ statement: some StructuredQueriesCore.Statement, - sectionBy sectionKeyPath: KeyPath, + sectionBy sectionKeyPath: KeyPath?, database: (any DatabaseReader)? = nil, scheduler: some ValueObservationScheduler & Hashable ) @@ -523,6 +565,10 @@ extension FetchAll { Element == V.QueryOutput, V.QueryOutput: Sendable { + guard let sectionKeyPath else { + self.init(wrappedValue: wrappedValue, statement, database: database, scheduler: scheduler) + return + } self.init( wrappedValue: wrappedValue, statement: statement, @@ -535,8 +581,6 @@ extension FetchAll { /// Initializes this property with a query associated with the wrapped value, grouping results /// into sections. /// - /// A `nil` value at the given key path is grouped into a section named by the empty string. - /// /// - Parameters: /// - wrappedValue: A default collection to associate with this property. /// - statement: A query associated with the wrapped value. @@ -548,7 +592,7 @@ extension FetchAll { public init>( wrappedValue: [Element] = [], _ statement: S, - sectionBy sectionKeyPath: KeyPath, + sectionBy sectionKeyPath: KeyPath?, database: (any DatabaseReader)? = nil, scheduler: some ValueObservationScheduler & Hashable ) @@ -556,6 +600,10 @@ extension FetchAll { Element: QueryRepresentable, Element == S.QueryValue.QueryOutput { + guard let sectionKeyPath else { + self.init(wrappedValue: wrappedValue, statement, database: database, scheduler: scheduler) + return + } self.init( wrappedValue: wrappedValue, statement: statement, @@ -581,7 +629,7 @@ extension FetchAll { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) public init( wrappedValue: [Element] = [], - sectionBy sectionKeyPath: KeyPath, + sectionBy sectionKeyPath: KeyPath?, database: (any DatabaseReader)? = nil, animation: Animation ) @@ -609,7 +657,7 @@ extension FetchAll { public init( wrappedValue: [Element] = [], _ statement: S, - sectionBy sectionKeyPath: KeyPath, + sectionBy sectionKeyPath: KeyPath?, database: (any DatabaseReader)? = nil, animation: Animation ) @@ -643,7 +691,7 @@ extension FetchAll { public init( wrappedValue: [Element] = [], _ statement: some StructuredQueriesCore.Statement, - sectionBy sectionKeyPath: KeyPath, + sectionBy sectionKeyPath: KeyPath?, database: (any DatabaseReader)? = nil, animation: Animation ) @@ -675,7 +723,7 @@ extension FetchAll { public init>( wrappedValue: [Element] = [], _ statement: S, - sectionBy sectionKeyPath: KeyPath, + sectionBy sectionKeyPath: KeyPath?, database: (any DatabaseReader)? = nil, animation: Animation ) @@ -695,11 +743,10 @@ extension FetchAll { /// Initializes this property with a query that fetches every row from a table, grouping /// results into sections. /// - /// A `nil` value at the given key path is grouped into a section named by the empty string. - /// /// - Parameters: /// - wrappedValue: A default collection to associate with this property. - /// - sectionKeyPath: A key path to an optional string to group results by. + /// - sectionKeyPath: A key path to an optional string to group results by, or `nil` for + /// no grouping. /// - database: The database to read from. A value of `nil` will use the default database /// (`@Dependency(\.defaultDatabase)`). /// - animation: The animation to use for user interface changes that result from changes to @@ -707,7 +754,7 @@ extension FetchAll { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) public init( wrappedValue: [Element] = [], - sectionBy sectionKeyPath: KeyPath, + sectionBy sectionKeyPath: KeyPath?, database: (any DatabaseReader)? = nil, animation: Animation ) @@ -723,12 +770,11 @@ extension FetchAll { /// Initializes this property with a query associated with the wrapped value, grouping results /// into sections. /// - /// A `nil` value at the given key path is grouped into a section named by the empty string. - /// /// - Parameters: /// - wrappedValue: A default collection to associate with this property. /// - statement: A query associated with the wrapped value. - /// - sectionKeyPath: A key path to an optional string to group results by. + /// - sectionKeyPath: A key path to an optional string to group results by, or `nil` for + /// no grouping. /// - database: The database to read from. A value of `nil` will use the default database /// (`@Dependency(\.defaultDatabase)`). /// - animation: The animation to use for user interface changes that result from changes to @@ -737,7 +783,7 @@ extension FetchAll { public init( wrappedValue: [Element] = [], _ statement: S, - sectionBy sectionKeyPath: KeyPath, + sectionBy sectionKeyPath: KeyPath?, database: (any DatabaseReader)? = nil, animation: Animation ) @@ -759,12 +805,11 @@ extension FetchAll { /// Initializes this property with a query associated with the wrapped value, grouping results /// into sections. /// - /// A `nil` value at the given key path is grouped into a section named by the empty string. - /// /// - Parameters: /// - wrappedValue: A default collection to associate with this property. /// - statement: A query associated with the wrapped value. - /// - sectionKeyPath: A key path to an optional string to group results by. + /// - sectionKeyPath: A key path to an optional string to group results by, or `nil` for + /// no grouping. /// - database: The database to read from. A value of `nil` will use the default database /// (`@Dependency(\.defaultDatabase)`). /// - animation: The animation to use for user interface changes that result from changes to @@ -773,7 +818,7 @@ extension FetchAll { public init( wrappedValue: [Element] = [], _ statement: some StructuredQueriesCore.Statement, - sectionBy sectionKeyPath: KeyPath, + sectionBy sectionKeyPath: KeyPath?, database: (any DatabaseReader)? = nil, animation: Animation ) @@ -793,12 +838,11 @@ extension FetchAll { /// Initializes this property with a query associated with the wrapped value, grouping results /// into sections. /// - /// A `nil` value at the given key path is grouped into a section named by the empty string. - /// /// - Parameters: /// - wrappedValue: A default collection to associate with this property. /// - statement: A query associated with the wrapped value. - /// - sectionKeyPath: A key path to an optional string to group results by. + /// - sectionKeyPath: A key path to an optional string to group results by, or `nil` for + /// no grouping. /// - database: The database to read from. A value of `nil` will use the default database /// (`@Dependency(\.defaultDatabase)`). /// - animation: The animation to use for user interface changes that result from changes to @@ -807,7 +851,7 @@ extension FetchAll { public init>( wrappedValue: [Element] = [], _ statement: S, - sectionBy sectionKeyPath: KeyPath, + sectionBy sectionKeyPath: KeyPath?, database: (any DatabaseReader)? = nil, animation: Animation ) @@ -830,7 +874,7 @@ extension FetchAll { /// Replaces the wrapped value with data from the given query, grouping results into sections. /// /// The given key path replaces any sectioning previously applied to this property, and is used - /// by all subsequent loads. + /// by all subsequent loads. Pass `nil` to remove sectioning from this property. /// /// - Parameters: /// - statement: A query associated with the wrapped value. @@ -841,7 +885,7 @@ extension FetchAll { @discardableResult public func load( _ statement: S, - sectionBy sectionKeyPath: KeyPath, + sectionBy sectionKeyPath: KeyPath?, database: (any DatabaseReader)? = nil ) async throws -> FetchSubscription where @@ -853,7 +897,7 @@ extension FetchAll { let statement: Select = statement.selectStar() return try await loadSections( statement: statement, - sectionBy: SectionBy(sectionKeyPath), + sectionBy: sectionKeyPath.map { SectionBy($0) }, database: database, scheduler: nil ) @@ -862,7 +906,7 @@ extension FetchAll { /// Replaces the wrapped value with data from the given query, grouping results into sections. /// /// The given key path replaces any sectioning previously applied to this property, and is used - /// by all subsequent loads. + /// by all subsequent loads. Pass `nil` to remove sectioning from this property. /// /// - Parameters: /// - statement: A query associated with the wrapped value. @@ -873,7 +917,7 @@ extension FetchAll { @discardableResult public func load( _ statement: some StructuredQueriesCore.Statement, - sectionBy sectionKeyPath: KeyPath, + sectionBy sectionKeyPath: KeyPath?, database: (any DatabaseReader)? = nil ) async throws -> FetchSubscription where @@ -882,7 +926,7 @@ extension FetchAll { { try await loadSections( statement: statement, - sectionBy: SectionBy(sectionKeyPath), + sectionBy: sectionKeyPath.map { SectionBy($0) }, database: database, scheduler: nil ) @@ -892,7 +936,7 @@ extension FetchAll { /// /// A `nil` value at the given key path is grouped into a section named by the empty string. The /// given key path replaces any sectioning previously applied to this property, and is used by - /// all subsequent loads. + /// all subsequent loads. Pass `nil` to remove sectioning from this property. /// /// - Parameters: /// - statement: A query associated with the wrapped value. @@ -903,7 +947,7 @@ extension FetchAll { @discardableResult public func load( _ statement: S, - sectionBy sectionKeyPath: KeyPath, + sectionBy sectionKeyPath: KeyPath?, database: (any DatabaseReader)? = nil ) async throws -> FetchSubscription where @@ -915,7 +959,7 @@ extension FetchAll { let statement: Select = statement.selectStar() return try await loadSections( statement: statement, - sectionBy: SectionBy(sectionKeyPath), + sectionBy: sectionKeyPath.map { SectionBy($0) }, database: database, scheduler: nil ) @@ -925,7 +969,7 @@ extension FetchAll { /// /// A `nil` value at the given key path is grouped into a section named by the empty string. The /// given key path replaces any sectioning previously applied to this property, and is used by - /// all subsequent loads. + /// all subsequent loads. Pass `nil` to remove sectioning from this property. /// /// - Parameters: /// - statement: A query associated with the wrapped value. @@ -936,7 +980,7 @@ extension FetchAll { @discardableResult public func load( _ statement: some StructuredQueriesCore.Statement, - sectionBy sectionKeyPath: KeyPath, + sectionBy sectionKeyPath: KeyPath?, database: (any DatabaseReader)? = nil ) async throws -> FetchSubscription where @@ -945,7 +989,7 @@ extension FetchAll { { try await loadSections( statement: statement, - sectionBy: SectionBy(sectionKeyPath), + sectionBy: sectionKeyPath.map { SectionBy($0) }, database: database, scheduler: nil ) @@ -954,7 +998,7 @@ extension FetchAll { /// Replaces the wrapped value with data from the given query, grouping results into sections. /// /// The given key path replaces any sectioning previously applied to this property, and is used - /// by all subsequent loads. + /// by all subsequent loads. Pass `nil` to remove sectioning from this property. /// /// - Parameters: /// - statement: A query associated with the wrapped value. @@ -967,7 +1011,7 @@ extension FetchAll { @discardableResult public func load( _ statement: S, - sectionBy sectionKeyPath: KeyPath, + sectionBy sectionKeyPath: KeyPath?, database: (any DatabaseReader)? = nil, scheduler: some ValueObservationScheduler & Hashable ) async throws -> FetchSubscription @@ -980,7 +1024,7 @@ extension FetchAll { let statement: Select = statement.selectStar() return try await loadSections( statement: statement, - sectionBy: SectionBy(sectionKeyPath), + sectionBy: sectionKeyPath.map { SectionBy($0) }, database: database, scheduler: scheduler ) @@ -989,7 +1033,7 @@ extension FetchAll { /// Replaces the wrapped value with data from the given query, grouping results into sections. /// /// The given key path replaces any sectioning previously applied to this property, and is used - /// by all subsequent loads. + /// by all subsequent loads. Pass `nil` to remove sectioning from this property. /// /// - Parameters: /// - statement: A query associated with the wrapped value. @@ -1002,7 +1046,7 @@ extension FetchAll { @discardableResult public func load( _ statement: some StructuredQueriesCore.Statement, - sectionBy sectionKeyPath: KeyPath, + sectionBy sectionKeyPath: KeyPath?, database: (any DatabaseReader)? = nil, scheduler: some ValueObservationScheduler & Hashable ) async throws -> FetchSubscription @@ -1012,7 +1056,7 @@ extension FetchAll { { try await loadSections( statement: statement, - sectionBy: SectionBy(sectionKeyPath), + sectionBy: sectionKeyPath.map { SectionBy($0) }, database: database, scheduler: scheduler ) @@ -1022,7 +1066,7 @@ extension FetchAll { /// /// A `nil` value at the given key path is grouped into a section named by the empty string. The /// given key path replaces any sectioning previously applied to this property, and is used by - /// all subsequent loads. + /// all subsequent loads. Pass `nil` to remove sectioning from this property. /// /// - Parameters: /// - statement: A query associated with the wrapped value. @@ -1035,7 +1079,7 @@ extension FetchAll { @discardableResult public func load( _ statement: S, - sectionBy sectionKeyPath: KeyPath, + sectionBy sectionKeyPath: KeyPath?, database: (any DatabaseReader)? = nil, scheduler: some ValueObservationScheduler & Hashable ) async throws -> FetchSubscription @@ -1048,7 +1092,7 @@ extension FetchAll { let statement: Select = statement.selectStar() return try await loadSections( statement: statement, - sectionBy: SectionBy(sectionKeyPath), + sectionBy: sectionKeyPath.map { SectionBy($0) }, database: database, scheduler: scheduler ) @@ -1058,7 +1102,7 @@ extension FetchAll { /// /// A `nil` value at the given key path is grouped into a section named by the empty string. The /// given key path replaces any sectioning previously applied to this property, and is used by - /// all subsequent loads. + /// all subsequent loads. Pass `nil` to remove sectioning from this property. /// /// - Parameters: /// - statement: A query associated with the wrapped value. @@ -1071,7 +1115,7 @@ extension FetchAll { @discardableResult public func load( _ statement: some StructuredQueriesCore.Statement, - sectionBy sectionKeyPath: KeyPath, + sectionBy sectionKeyPath: KeyPath?, database: (any DatabaseReader)? = nil, scheduler: some ValueObservationScheduler & Hashable ) async throws -> FetchSubscription @@ -1081,7 +1125,7 @@ extension FetchAll { { try await loadSections( statement: statement, - sectionBy: SectionBy(sectionKeyPath), + sectionBy: sectionKeyPath.map { SectionBy($0) }, database: database, scheduler: scheduler ) @@ -1089,7 +1133,7 @@ extension FetchAll { func loadSections( statement: some StructuredQueriesCore.Statement, - sectionBy newSectionBy: SectionBy, + sectionBy newSectionBy: SectionBy?, database: (any DatabaseReader)?, scheduler: (any ValueObservationScheduler & Hashable)? ) async throws -> FetchSubscription @@ -1098,6 +1142,17 @@ extension FetchAll { V.QueryOutput: Sendable { sectionedBy.setValue(newSectionBy) + guard let newSectionBy else { + sectionedReader.projectedValue = SharedReader(value: ResultsSectionCollection()) + try await sharedReader.load( + FetchKey( + request: FetchAllStatementValueRequest(statement: statement), + database: database, + scheduler: scheduler + ) + ) + return FetchSubscription(sharedReader: sharedReader) + } defer { sharedReader.projectedValue = sectionedReader[dynamicMember: \.elements].projectedValue } @@ -1120,7 +1175,7 @@ extension FetchAll { /// Replaces the wrapped value with data from the given query, grouping results into sections. /// /// The given key path replaces any sectioning previously applied to this property, and is - /// used by all subsequent loads. + /// used by all subsequent loads. Pass `nil` to remove sectioning from this property. /// /// - Parameters: /// - statement: A query associated with the wrapped value. @@ -1134,7 +1189,7 @@ extension FetchAll { @discardableResult public func load( _ statement: S, - sectionBy sectionKeyPath: KeyPath, + sectionBy sectionKeyPath: KeyPath?, database: (any DatabaseReader)? = nil, animation: Animation? ) async throws -> FetchSubscription @@ -1155,7 +1210,7 @@ extension FetchAll { /// Replaces the wrapped value with data from the given query, grouping results into sections. /// /// The given key path replaces any sectioning previously applied to this property, and is - /// used by all subsequent loads. + /// used by all subsequent loads. Pass `nil` to remove sectioning from this property. /// /// - Parameters: /// - statement: A query associated with the wrapped value. @@ -1169,7 +1224,7 @@ extension FetchAll { @discardableResult public func load( _ statement: some StructuredQueriesCore.Statement, - sectionBy sectionKeyPath: KeyPath, + sectionBy sectionKeyPath: KeyPath?, database: (any DatabaseReader)? = nil, animation: Animation? ) async throws -> FetchSubscription @@ -1186,14 +1241,13 @@ extension FetchAll { } /// Replaces the wrapped value with data from the given query, grouping results into sections. - /// - /// A `nil` value at the given key path is grouped into a section named by the empty string. /// The given key path replaces any sectioning previously applied to this property, and is - /// used by all subsequent loads. + /// used by all subsequent loads. Pass `nil` to remove sectioning from this property. /// /// - Parameters: /// - statement: A query associated with the wrapped value. - /// - sectionKeyPath: A key path to an optional string to group results by. + /// - sectionKeyPath: A key path to an optional string to group results by, or `nil` for + /// no grouping. /// - database: The database to read from. A value of `nil` will use the default database /// (`@Dependency(\.defaultDatabase)`). /// - animation: The animation to use for user interface changes that result from changes to @@ -1203,7 +1257,7 @@ extension FetchAll { @discardableResult public func load( _ statement: S, - sectionBy sectionKeyPath: KeyPath, + sectionBy sectionKeyPath: KeyPath?, database: (any DatabaseReader)? = nil, animation: Animation? ) async throws -> FetchSubscription @@ -1222,14 +1276,13 @@ extension FetchAll { } /// Replaces the wrapped value with data from the given query, grouping results into sections. - /// - /// A `nil` value at the given key path is grouped into a section named by the empty string. /// The given key path replaces any sectioning previously applied to this property, and is - /// used by all subsequent loads. + /// used by all subsequent loads. Pass `nil` to remove sectioning from this property. /// /// - Parameters: /// - statement: A query associated with the wrapped value. - /// - sectionKeyPath: A key path to an optional string to group results by. + /// - sectionKeyPath: A key path to an optional string to group results by, or `nil` for + /// no grouping. /// - database: The database to read from. A value of `nil` will use the default database /// (`@Dependency(\.defaultDatabase)`). /// - animation: The animation to use for user interface changes that result from changes to @@ -1239,7 +1292,7 @@ extension FetchAll { @discardableResult public func load( _ statement: some StructuredQueriesCore.Statement, - sectionBy sectionKeyPath: KeyPath, + sectionBy sectionKeyPath: KeyPath?, database: (any DatabaseReader)? = nil, animation: Animation? ) async throws -> FetchSubscription diff --git a/Sources/SQLiteData/FetchAll.swift b/Sources/SQLiteData/FetchAll.swift index 286100d3..bcf48dbf 100644 --- a/Sources/SQLiteData/FetchAll.swift +++ b/Sources/SQLiteData/FetchAll.swift @@ -613,7 +613,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/ResultsSectionCollection.swift b/Sources/SQLiteData/ResultsSectionCollection.swift index c556315a..05712026 100644 --- a/Sources/SQLiteData/ResultsSectionCollection.swift +++ b/Sources/SQLiteData/ResultsSectionCollection.swift @@ -55,6 +55,20 @@ public struct ResultsSectionCollection { self = builder.finish() } + init(elements: [Element], sectionName: SectionName) { + if elements.isEmpty { + self.init() + } else { + self.init( + elements: elements, + sections: [ + ResultsSection(name: sectionName, base: elements, elementIndices: Array(elements.indices)) + ], + sectionIndicesByName: [sectionName: 0] + ) + } + } + init(cursor: QueryCursor, sectionName: (Element) -> SectionName) throws { var builder = Builder() while let element = try cursor.next() { diff --git a/Tests/SQLiteDataTests/FetchAllSectionsTests.swift b/Tests/SQLiteDataTests/FetchAllSectionsTests.swift index 16acc90b..f983ccbd 100644 --- a/Tests/SQLiteDataTests/FetchAllSectionsTests.swift +++ b/Tests/SQLiteDataTests/FetchAllSectionsTests.swift @@ -139,13 +139,49 @@ struct FetchAllSectionsTests { $reminders = FetchAll(SectionedReminder.order(by: \.title)) try await $reminders.load() #expect(reminders.map(\.title) == ["Dishes", "Groceries", "Laundry", "Review", "Standup"]) - #expect($reminders.sections.isEmpty) + #expect($reminders.sections.sectionNames == [""]) + #expect($reminders.sections[0].count == 5) + } + + @Test func nilSectionBy() async throws { + let sectionKeyPath: KeyPath? = nil + @FetchAll(SectionedReminder.order(by: \.id), sectionBy: sectionKeyPath) var reminders + try await $reminders.load() + + #expect(reminders.map(\.id) == [1, 2, 3, 4, 5]) + #expect($reminders.sections.sectionNames == [""]) + #expect($reminders.sections[0].map(\.id) == [1, 2, 3, 4, 5]) + } + + @Test func nilSectionByWholeTable() async throws { + @FetchAll(sectionBy: nil as KeyPath?) var reminders + try await $reminders.load() + + #expect(reminders.count == 5) + #expect($reminders.sections.sectionNames == [""]) + } + + @Test func loadNilSectionBy() async throws { + @FetchAll(SectionedReminder.order(by: \.id), sectionBy: \.category) var reminders + try await $reminders.load() + #expect($reminders.sections.sectionNames == ["Home", "Work", "Errands"]) + + try await $reminders.load( + SectionedReminder.order(by: \.id), + sectionBy: nil as KeyPath? + ) + #expect(reminders.map(\.id) == [1, 2, 3, 4, 5]) + #expect($reminders.sections.sectionNames == [""]) + + try await $reminders.load(SectionedReminder.where { $0.id <= 2 }.order(by: \.id)) + #expect(reminders.map(\.title) == ["Dishes", "Standup"]) + #expect($reminders.sections.sectionNames == [""]) } @Test func loadSectionBy() async throws { @FetchAll(SectionedReminder.order(by: \.id)) var reminders try await $reminders.load() - #expect($reminders.sections.isEmpty) + #expect($reminders.sections.sectionNames == [""]) try await $reminders.load(SectionedReminder.order(by: \.id), sectionBy: \.category) #expect(reminders.count == 5) @@ -180,6 +216,16 @@ struct FetchAllSectionsTests { try await $reminders.load() #expect(!reminders.isEmpty) + #expect($reminders.sections.count == 1) + #expect($reminders.sections.sectionNames == [""]) + #expect($reminders.sections[sectionName: ""]?.map(\.id) == reminders.map(\.id)) + } + + @Test func sectionsAccessWithoutSectionByEmptyResults() async throws { + @FetchAll(SectionedReminder.where { $0.id > 100 }) var reminders + try await $reminders.load() + + #expect(reminders.isEmpty) #expect($reminders.sections.isEmpty) } } From 7e21ca21ac6377c1e617f4a1ae2a4eaef5393575 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Thu, 2 Jul 2026 13:00:44 -0700 Subject: [PATCH 3/6] wip --- .../SQLiteData/ResultsSectionCollection.swift | 108 ++++++------------ 1 file changed, 33 insertions(+), 75 deletions(-) diff --git a/Sources/SQLiteData/ResultsSectionCollection.swift b/Sources/SQLiteData/ResultsSectionCollection.swift index 05712026..2c6eb73a 100644 --- a/Sources/SQLiteData/ResultsSectionCollection.swift +++ b/Sources/SQLiteData/ResultsSectionCollection.swift @@ -1,5 +1,6 @@ import ConcurrencyExtras import GRDB +import OrderedCollections /// A collection of query results grouped into sections. /// @@ -30,120 +31,79 @@ import GRDB /// sectioned column. public struct ResultsSectionCollection { let elements: [Element] - private let sections: [ResultsSection] - private let sectionIndicesByName: [SectionName: Int] - - private init( - elements: [Element], - sections: [ResultsSection], - sectionIndicesByName: [SectionName: Int] - ) { - self.elements = elements - self.sections = sections - self.sectionIndicesByName = sectionIndicesByName - } + private let sections: OrderedDictionary init() { - self.init(elements: [], sections: [], sectionIndicesByName: [:]) + elements = [] + sections = [:] } init(elements: some Sequence, sectionName: (Element) -> SectionName) { - var builder = Builder() + var allElements: [Element] = [] + var sections: OrderedDictionary = [:] for element in elements { - builder.append(element, sectionName: sectionName(element)) + allElements.append(element) + sections[sectionName(element), default: []].append(element) } - self = builder.finish() + self.elements = allElements + self.sections = sections } init(elements: [Element], sectionName: SectionName) { - if elements.isEmpty { - self.init() - } else { - self.init( - elements: elements, - sections: [ - ResultsSection(name: sectionName, base: elements, elementIndices: Array(elements.indices)) - ], - sectionIndicesByName: [sectionName: 0] - ) - } + self.elements = elements + self.sections = elements.isEmpty ? [:] : [sectionName: elements] } init(cursor: QueryCursor, sectionName: (Element) -> SectionName) throws { - var builder = Builder() + var elements: [Element] = [] + var sections: OrderedDictionary = [:] while let element = try cursor.next() { - builder.append(element, sectionName: sectionName(element)) + elements.append(element) + sections[sectionName(element), default: []].append(element) } - self = builder.finish() + self.elements = elements + self.sections = sections } /// The names of each section in the collection, in the order the sections appear. public var sectionNames: [SectionName] { - sections.map(\.name) + Array(sections.keys) } /// Returns the section with the given name, or `nil` if no such section exists. /// /// - Parameter name: The name of a section. public subscript(sectionName name: SectionName) -> ResultsSection? { - sectionIndicesByName[name].map { sections[$0] } + sections[name].map { ResultsSection(name: name, elements: $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 { - sectionIndicesByName[name] != nil + sections.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? { - sectionIndicesByName[name] - } - - private struct Builder { - private var elements: [Element] = [] - private var names: [SectionName] = [] - private var elementIndices: [[Int]] = [] - private var sectionIndicesByName: [SectionName: Int] = [:] - - mutating func append(_ element: Element, sectionName: SectionName) { - let elementIndex = elements.count - elements.append(element) - if let sectionIndex = sectionIndicesByName[sectionName] { - elementIndices[sectionIndex].append(elementIndex) - } else { - sectionIndicesByName[sectionName] = names.count - names.append(sectionName) - elementIndices.append([elementIndex]) - } - } - - func finish() -> ResultsSectionCollection { - ResultsSectionCollection( - elements: elements, - sections: zip(names, elementIndices).map { - ResultsSection(name: $0, base: elements, elementIndices: $1) - }, - sectionIndicesByName: sectionIndicesByName - ) - } + sections.index(forKey: name) } } extension ResultsSectionCollection: RandomAccessCollection { public var startIndex: Int { - sections.startIndex + sections.elements.startIndex } public var endIndex: Int { - sections.endIndex + sections.elements.endIndex } public subscript(position: Int) -> ResultsSection { - sections[position] + let (name, elements) = sections.elements[position] + return ResultsSection(name: name, elements: elements) } } @@ -164,13 +124,11 @@ public struct ResultsSection: Identifiable { /// This is the value at the `sectionBy:` key path shared by every element in the section. public let name: SectionName - private let base: [Element] - private let elementIndices: [Int] + private let elements: [Element] - init(name: SectionName, base: [Element], elementIndices: [Int]) { + init(name: SectionName, elements: [Element]) { self.name = name - self.base = base - self.elementIndices = elementIndices + self.elements = elements } /// The identity of the section, equivalent to its ``name``. @@ -181,15 +139,15 @@ public struct ResultsSection: Identifiable { extension ResultsSection: RandomAccessCollection { public var startIndex: Int { - 0 + elements.startIndex } public var endIndex: Int { - elementIndices.count + elements.endIndex } public subscript(position: Int) -> Element { - base[elementIndices[position]] + elements[position] } } @@ -197,7 +155,7 @@ extension ResultsSection: Sendable where Element: Sendable, SectionName: Sendabl extension ResultsSection: Equatable where Element: Equatable { public static func == (lhs: Self, rhs: Self) -> Bool { - lhs.name == rhs.name && lhs.elementsEqual(rhs) + lhs.name == rhs.name && lhs.elements == rhs.elements } } From 73c68dad9cd37a8eaf8862d0bde0cf6c1d11fee1 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Thu, 2 Jul 2026 13:08:15 -0700 Subject: [PATCH 4/6] wip --- .../SQLiteData/ResultsSectionCollection.swift | 52 ++++++++++--------- 1 file changed, 27 insertions(+), 25 deletions(-) diff --git a/Sources/SQLiteData/ResultsSectionCollection.swift b/Sources/SQLiteData/ResultsSectionCollection.swift index 2c6eb73a..c30293fa 100644 --- a/Sources/SQLiteData/ResultsSectionCollection.swift +++ b/Sources/SQLiteData/ResultsSectionCollection.swift @@ -31,79 +31,79 @@ import OrderedCollections /// sectioned column. public struct ResultsSectionCollection { let elements: [Element] - private let sections: OrderedDictionary + private let elementIndicesBySectionName: OrderedDictionary init() { elements = [] - sections = [:] + elementIndicesBySectionName = [:] } init(elements: some Sequence, sectionName: (Element) -> SectionName) { var allElements: [Element] = [] - var sections: OrderedDictionary = [:] + var elementIndicesBySectionName: OrderedDictionary = [:] for element in elements { + elementIndicesBySectionName[sectionName(element), default: []].append(allElements.count) allElements.append(element) - sections[sectionName(element), default: []].append(element) } self.elements = allElements - self.sections = sections + self.elementIndicesBySectionName = elementIndicesBySectionName } init(elements: [Element], sectionName: SectionName) { self.elements = elements - self.sections = elements.isEmpty ? [:] : [sectionName: elements] + self.elementIndicesBySectionName = + elements.isEmpty ? [:] : [sectionName: Array(elements.indices)] } init(cursor: QueryCursor, sectionName: (Element) -> SectionName) throws { var elements: [Element] = [] - var sections: OrderedDictionary = [:] while let element = try cursor.next() { elements.append(element) - sections[sectionName(element), default: []].append(element) } - self.elements = elements - self.sections = sections + self.init(elements: elements, sectionName: sectionName) } /// The names of each section in the collection, in the order the sections appear. public var sectionNames: [SectionName] { - Array(sections.keys) + Array(elementIndicesBySectionName.keys) } /// Returns the section with the given name, or `nil` if no such section exists. /// /// - Parameter name: The name of a section. public subscript(sectionName name: SectionName) -> ResultsSection? { - sections[name].map { ResultsSection(name: name, elements: $0) } + 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 { - sections.keys.contains(name) + 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? { - sections.index(forKey: name) + elementIndicesBySectionName.index(forKey: name) } } extension ResultsSectionCollection: RandomAccessCollection { public var startIndex: Int { - sections.elements.startIndex + elementIndicesBySectionName.elements.startIndex } public var endIndex: Int { - sections.elements.endIndex + elementIndicesBySectionName.elements.endIndex } public subscript(position: Int) -> ResultsSection { - let (name, elements) = sections.elements[position] - return ResultsSection(name: name, elements: elements) + let (name, elementIndices) = elementIndicesBySectionName.elements[position] + return ResultsSection(name: name, base: elements, elementIndices: elementIndices) } } @@ -124,11 +124,13 @@ public struct ResultsSection: Identifiable { /// This is the value at the `sectionBy:` key path shared by every element in the section. public let name: SectionName - private let elements: [Element] + private let base: [Element] + private let elementIndices: [Int] - init(name: SectionName, elements: [Element]) { + init(name: SectionName, base: [Element], elementIndices: [Int]) { self.name = name - self.elements = elements + self.base = base + self.elementIndices = elementIndices } /// The identity of the section, equivalent to its ``name``. @@ -139,15 +141,15 @@ public struct ResultsSection: Identifiable { extension ResultsSection: RandomAccessCollection { public var startIndex: Int { - elements.startIndex + elementIndices.startIndex } public var endIndex: Int { - elements.endIndex + elementIndices.endIndex } public subscript(position: Int) -> Element { - elements[position] + base[elementIndices[position]] } } @@ -155,7 +157,7 @@ extension ResultsSection: Sendable where Element: Sendable, SectionName: Sendabl extension ResultsSection: Equatable where Element: Equatable { public static func == (lhs: Self, rhs: Self) -> Bool { - lhs.name == rhs.name && lhs.elements == rhs.elements + lhs.name == rhs.name && lhs.elementsEqual(rhs) } } From 64a1b6938e591dd73eb479f9bebc1ba76411ed62 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 6 Jul 2026 16:34:48 -0700 Subject: [PATCH 5/6] wip --- Sources/SQLiteData/Fetch.swift | 54 +++++++- Sources/SQLiteData/FetchAll.swift | 81 +++++++---- Sources/SQLiteData/FetchOne.swift | 149 +++++++++++++-------- Sources/SQLiteData/Internal/FetchBox.swift | 96 +++++++++++++ Tests/SQLiteDataTests/FetchBoxTests.swift | 99 ++++++++++++++ 5 files changed, 390 insertions(+), 89 deletions(-) create mode 100644 Sources/SQLiteData/Internal/FetchBox.swift create mode 100644 Tests/SQLiteDataTests/FetchBoxTests.swift diff --git a/Sources/SQLiteData/Fetch.swift b/Sources/SQLiteData/Fetch.swift index ea645873..50eb9fe6 100644 --- a/Sources/SQLiteData/Fetch.swift +++ b/Sources/SQLiteData/Fetch.swift @@ -1,7 +1,6 @@ public import GRDB public import Sharing import StructuredQueriesCore -public import Sharing #if canImport(Combine) public import Combine @@ -22,11 +21,32 @@ public import Sharing @dynamicMemberLookup @propertyWrapper public struct Fetch: Sendable { - /// The underlying shared reader powering the property wrapper. - /// - /// Shared readers come from the [Sharing](https://github.com/pointfreeco/swift-sharing) package, - /// a general solution to observing and persisting changes to external data sources. - public var sharedReader: SharedReader + #if canImport(SwiftUI) + /// The underlying shared reader powering the property wrapper. + /// + /// Shared readers come from the [Sharing](https://github.com/pointfreeco/swift-sharing) + /// package, a general solution to observing and persisting changes to external data sources. + public private(set) var sharedReader: SharedReader { + @storageRestrictions(initializes: box, state) + init(initialValue) { + let box = FetchBox(sharedReader: initialValue) + self.box = box + state = SwiftUI.State(wrappedValue: box) + } + get { state.wrappedValue.sharedReader } + nonmutating set { state.wrappedValue.sharedReader = newValue } + } + + private let box: FetchBox + private let state: SwiftUI.State> + private let generation = SwiftUI.State(wrappedValue: 0) + #else + /// The underlying shared reader powering the property wrapper. + /// + /// Shared readers come from the [Sharing](https://github.com/pointfreeco/swift-sharing) + /// package, a general solution to observing and persisting changes to external data sources. + public private(set) var sharedReader: SharedReader + #endif /// Data associated with the underlying query. public var wrappedValue: Value { @@ -93,6 +113,7 @@ public struct Fetch: Sendable { database: (any DatabaseReader)? = nil ) { sharedReader = SharedReader(wrappedValue: wrappedValue, .fetch(request, database: database)) + setFetchKeyID(for: request, database: database, scheduler: nil) } /// Replaces the wrapped value with data from the given request. @@ -110,6 +131,19 @@ public struct Fetch: Sendable { try await sharedReader.load(.fetch(request, database: database)) return FetchSubscription(sharedReader: sharedReader) } + + #if !canImport(SwiftUI) + @_transparent + #endif + private func setFetchKeyID( + for request: some FetchKeyRequest, + database: (any DatabaseReader)?, + scheduler: (any ValueObservationScheduler & Hashable)? + ) { + #if canImport(SwiftUI) + box.fetchKeyID = FetchKey(request: request, database: database, scheduler: scheduler).id + #endif + } } extension Fetch { @@ -132,6 +166,7 @@ extension Fetch { wrappedValue: wrappedValue, .fetch(request, database: database, scheduler: scheduler) ) + setFetchKeyID(for: request, database: database, scheduler: scheduler) } /// Replaces the wrapped value with data from the given request. @@ -169,7 +204,11 @@ extension Fetch: Equatable where Value: Equatable { #if canImport(SwiftUI) extension Fetch: DynamicProperty { public func update() { - sharedReader.update() + let persisted = state.wrappedValue + if persisted !== box { + persisted.reconcile(from: box, propertyName: "@Fetch") + } + persisted.subscribe(generation: generation) } /// Initializes this property with a request associated with the wrapped value. @@ -192,6 +231,7 @@ extension Fetch: Equatable where Value: Equatable { wrappedValue: wrappedValue, .fetch(request, database: database, animation: animation) ) + setFetchKeyID(for: request, database: database, scheduler: .animation(animation)) } /// Replaces the wrapped value with data from the given request. diff --git a/Sources/SQLiteData/FetchAll.swift b/Sources/SQLiteData/FetchAll.swift index ec118cdf..43e8c5f2 100644 --- a/Sources/SQLiteData/FetchAll.swift +++ b/Sources/SQLiteData/FetchAll.swift @@ -1,7 +1,6 @@ public import GRDB public import Sharing public import StructuredQueriesCore -public import Sharing #if canImport(Combine) public import Combine @@ -22,11 +21,32 @@ public import Sharing @dynamicMemberLookup @propertyWrapper public struct FetchAll: Sendable { - /// The underlying shared reader powering the property wrapper. - /// - /// Shared readers come from the [Sharing](https://github.com/pointfreeco/swift-sharing) package, - /// a general solution to observing and persisting changes to external data sources. - public var sharedReader: SharedReader<[Element]> = SharedReader(value: []) + #if canImport(SwiftUI) + /// The underlying shared reader powering the property wrapper. + /// + /// Shared readers come from the [Sharing](https://github.com/pointfreeco/swift-sharing) + /// package, a general solution to observing and persisting changes to external data sources. + public private(set) var sharedReader: SharedReader<[Element]> { + @storageRestrictions(initializes: box, state) + init(initialValue) { + let box = FetchBox(sharedReader: initialValue) + self.box = box + state = SwiftUI.State(wrappedValue: box) + } + get { state.wrappedValue.sharedReader } + nonmutating set { state.wrappedValue.sharedReader = newValue } + } + + private let box: FetchBox<[Element]> + private let state: SwiftUI.State> + private let generation = SwiftUI.State(wrappedValue: 0) + #else + /// The underlying shared reader powering the property wrapper. + /// + /// Shared readers come from the [Sharing](https://github.com/pointfreeco/swift-sharing) + /// package, a general solution to observing and persisting changes to external data sources. + public private(set) var sharedReader: SharedReader<[Element]> = SharedReader(value: []) + #endif /// A collection of data associated with the underlying query. public var wrappedValue: [Element] { @@ -139,13 +159,12 @@ public struct FetchAll: Sendable { Element == V.QueryOutput, V.QueryOutput: Sendable { + let request = FetchAllStatementValueRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch( - FetchAllStatementValueRequest(statement: statement), - database: database - ) + .fetch(request, database: database) ) + setFetchKeyID(for: request, database: database, scheduler: nil) } /// Initializes this property with a query associated with the wrapped value. @@ -164,13 +183,12 @@ public struct FetchAll: Sendable { Element: QueryRepresentable, Element == S.QueryValue.QueryOutput { + let request = FetchAllStatementValueRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch( - FetchAllStatementValueRequest(statement: statement), - database: database - ) + .fetch(request, database: database) ) + setFetchKeyID(for: request, database: database, scheduler: nil) } /// Replaces the wrapped value with data from the given query. @@ -219,6 +237,19 @@ public struct FetchAll: Sendable { ) return FetchSubscription(sharedReader: sharedReader) } + + #if !canImport(SwiftUI) + @_transparent + #endif + private func setFetchKeyID( + for request: some FetchKeyRequest, + database: (any DatabaseReader)?, + scheduler: (any ValueObservationScheduler & Hashable)? + ) { + #if canImport(SwiftUI) + box.fetchKeyID = FetchKey(request: request, database: database, scheduler: scheduler).id + #endif + } } extension FetchAll { @@ -294,14 +325,12 @@ extension FetchAll { Element == V.QueryOutput, V.QueryOutput: Sendable { + let request = FetchAllStatementValueRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch( - FetchAllStatementValueRequest(statement: statement), - database: database, - scheduler: scheduler - ) + .fetch(request, database: database, scheduler: scheduler) ) + setFetchKeyID(for: request, database: database, scheduler: scheduler) } /// Initializes this property with a query associated with the wrapped value. @@ -323,14 +352,12 @@ extension FetchAll { Element: QueryRepresentable, Element == S.QueryValue.QueryOutput { + let request = FetchAllStatementValueRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch( - FetchAllStatementValueRequest(statement: statement), - database: database, - scheduler: scheduler - ) + .fetch(request, database: database, scheduler: scheduler) ) + setFetchKeyID(for: request, database: database, scheduler: scheduler) } /// Replaces the wrapped value with data from the given query. @@ -403,7 +430,11 @@ extension FetchAll: Equatable where Element: Equatable { #if canImport(SwiftUI) extension FetchAll: DynamicProperty { public func update() { - sharedReader.update() + let persisted = state.wrappedValue + if persisted !== box { + persisted.reconcile(from: box, propertyName: "@FetchAll") + } + persisted.subscribe(generation: generation) } @available(*, deprecated, message: "Remove unused parameters: 'database', 'animation'.") diff --git a/Sources/SQLiteData/FetchOne.swift b/Sources/SQLiteData/FetchOne.swift index 4e6b799d..8b11592a 100644 --- a/Sources/SQLiteData/FetchOne.swift +++ b/Sources/SQLiteData/FetchOne.swift @@ -21,11 +21,32 @@ public import StructuredQueriesCore @dynamicMemberLookup @propertyWrapper public struct FetchOne: Sendable { - /// The underlying shared reader powering the property wrapper. - /// - /// Shared readers come from the [Sharing](https://github.com/pointfreeco/swift-sharing) package, - /// a general solution to observing and persisting changes to external data sources. - public var sharedReader: SharedReader + #if canImport(SwiftUI) + /// The underlying shared reader powering the property wrapper. + /// + /// Shared readers come from the [Sharing](https://github.com/pointfreeco/swift-sharing) + /// package, a general solution to observing and persisting changes to external data sources. + public private(set) var sharedReader: SharedReader { + @storageRestrictions(initializes: box, state) + init(initialValue) { + let box = FetchBox(sharedReader: initialValue) + self.box = box + state = SwiftUI.State(wrappedValue: box) + } + get { state.wrappedValue.sharedReader } + nonmutating set { state.wrappedValue.sharedReader = newValue } + } + + private let box: FetchBox + private let state: SwiftUI.State> + private let generation = SwiftUI.State(wrappedValue: 0) + #else + /// The underlying shared reader powering the property wrapper. + /// + /// Shared readers come from the [Sharing](https://github.com/pointfreeco/swift-sharing) + /// package, a general solution to observing and persisting changes to external data sources. + public private(set) var sharedReader: SharedReader + #endif /// A value associated with the underlying query. public var wrappedValue: Value { @@ -106,10 +127,12 @@ public struct FetchOne: Sendable { Value: StructuredQueriesCore.Table & QueryRepresentable, Value.QueryOutput == Value { let statement = Value.all.selectStar().asSelect().limit(1) + let request = FetchOneStatementValueRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch(FetchOneStatementValueRequest(statement: statement), database: database) + .fetch(request, database: database) ) + setFetchKeyID(for: request, database: database, scheduler: nil) } /// Initializes this property with a query that fetches the first row from a table. @@ -128,10 +151,12 @@ public struct FetchOne: Sendable { Value.QueryOutput == Value { let statement = Value.all.selectStar().asSelect().limit(1) + let request = FetchOneStatementOptionalProtocolRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch(FetchOneStatementOptionalProtocolRequest(statement: statement), database: database) + .fetch(request, database: database) ) + setFetchKeyID(for: request, database: database, scheduler: nil) } /// Initializes this property with a query associated with the wrapped value. @@ -170,10 +195,12 @@ public struct FetchOne: Sendable { where Value == V.QueryOutput { + let request = FetchOneStatementValueRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch(FetchOneStatementValueRequest(statement: statement), database: database) + .fetch(request, database: database) ) + setFetchKeyID(for: request, database: database, scheduler: nil) } /// Initializes this property with a query associated with the wrapped value. @@ -191,10 +218,12 @@ public struct FetchOne: Sendable { where Value == V.QueryOutput? { + let request = FetchOneStatementOptionalValueRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch(FetchOneStatementOptionalValueRequest(statement: statement), database: database) + .fetch(request, database: database) ) + setFetchKeyID(for: request, database: database, scheduler: nil) } /// Initializes this property with a query associated with the wrapped value. @@ -213,10 +242,12 @@ public struct FetchOne: Sendable { Value: QueryRepresentable, Value == S.QueryValue.QueryOutput { + let request = FetchOneStatementValueRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch(FetchOneStatementValueRequest(statement: statement), database: database) + .fetch(request, database: database) ) + setFetchKeyID(for: request, database: database, scheduler: nil) } /// Initializes this property with a query associated with an optional value. @@ -238,10 +269,12 @@ public struct FetchOne: Sendable { S.Joins == () { let statement = statement.selectStar().asSelect().limit(1) + let request = FetchOneStatementOptionalValueRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch(FetchOneStatementOptionalValueRequest(statement: statement), database: database) + .fetch(request, database: database) ) + setFetchKeyID(for: request, database: database, scheduler: nil) } /// Initializes this property with a query associated with an optional value. @@ -262,13 +295,12 @@ public struct FetchOne: Sendable { S.QueryValue: StructuredQueriesCore._OptionalProtocol, Value == S.QueryValue.QueryOutput { + let request = FetchOneStatementOptionalProtocolRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch( - FetchOneStatementOptionalProtocolRequest(statement: statement), - database: database - ) + .fetch(request, database: database) ) + setFetchKeyID(for: request, database: database, scheduler: nil) } /// Initializes this property with a query associated with an optional value. @@ -288,10 +320,12 @@ public struct FetchOne: Sendable { Value: StructuredQueriesCore._OptionalProtocol, Value.QueryOutput == Value { + let request = FetchOneStatementOptionalProtocolRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch(FetchOneStatementOptionalProtocolRequest(statement: statement), database: database) + .fetch(request, database: database) ) + setFetchKeyID(for: request, database: database, scheduler: nil) } /// Replaces the wrapped value with data from the given query. @@ -428,6 +462,19 @@ public struct FetchOne: Sendable { ) return FetchSubscription(sharedReader: sharedReader) } + + #if !canImport(SwiftUI) + @_transparent + #endif + private func setFetchKeyID( + for request: some FetchKeyRequest, + database: (any DatabaseReader)?, + scheduler: (any ValueObservationScheduler & Hashable)? + ) { + #if canImport(SwiftUI) + box.fetchKeyID = FetchKey(request: request, database: database, scheduler: scheduler).id + #endif + } } extension FetchOne { @@ -475,14 +522,12 @@ extension FetchOne { Value: StructuredQueriesCore.Table & QueryRepresentable, Value.QueryOutput == Value { let statement = Value.all.selectStar().asSelect().limit(1) + let request = FetchOneStatementValueRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch( - FetchOneStatementValueRequest(statement: statement), - database: database, - scheduler: scheduler - ) + .fetch(request, database: database, scheduler: scheduler) ) + setFetchKeyID(for: request, database: database, scheduler: scheduler) } /// Initializes this property with a query that fetches the first row from a table. @@ -504,14 +549,12 @@ extension FetchOne { Value.QueryOutput == Value { let statement = Value.all.selectStar().asSelect().limit(1) + let request = FetchOneStatementOptionalProtocolRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch( - FetchOneStatementOptionalProtocolRequest(statement: statement), - database: database, - scheduler: scheduler - ) + .fetch(request, database: database, scheduler: scheduler) ) + setFetchKeyID(for: request, database: database, scheduler: scheduler) } /// Initializes this property with a query associated with the wrapped value. @@ -556,14 +599,12 @@ extension FetchOne { where Value == V.QueryOutput { + let request = FetchOneStatementValueRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch( - FetchOneStatementValueRequest(statement: statement), - database: database, - scheduler: scheduler - ) + .fetch(request, database: database, scheduler: scheduler) ) + setFetchKeyID(for: request, database: database, scheduler: scheduler) } /// Initializes this property with a query associated with the wrapped value. @@ -584,14 +625,12 @@ extension FetchOne { where Value == V.QueryOutput? { + let request = FetchOneStatementOptionalValueRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch( - FetchOneStatementOptionalValueRequest(statement: statement), - database: database, - scheduler: scheduler - ) + .fetch(request, database: database, scheduler: scheduler) ) + setFetchKeyID(for: request, database: database, scheduler: scheduler) } /// Initializes this property with a query associated with the wrapped value. @@ -613,14 +652,12 @@ extension FetchOne { Value: QueryRepresentable, Value == S.QueryValue.QueryOutput { + let request = FetchOneStatementValueRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch( - FetchOneStatementValueRequest(statement: statement), - database: database, - scheduler: scheduler - ) + .fetch(request, database: database, scheduler: scheduler) ) + setFetchKeyID(for: request, database: database, scheduler: scheduler) } /// Initializes this property with a query associated with an optional value. @@ -645,14 +682,12 @@ extension FetchOne { S.Joins == () { let statement = statement.selectStar().asSelect().limit(1) + let request = FetchOneStatementOptionalValueRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch( - FetchOneStatementOptionalValueRequest(statement: statement), - database: database, - scheduler: scheduler - ) + .fetch(request, database: database, scheduler: scheduler) ) + setFetchKeyID(for: request, database: database, scheduler: scheduler) } /// Initializes this property with a query associated with an optional value. @@ -676,14 +711,12 @@ extension FetchOne { S.QueryValue: StructuredQueriesCore._OptionalProtocol, Value == S.QueryValue.QueryOutput { + let request = FetchOneStatementOptionalProtocolRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch( - FetchOneStatementOptionalProtocolRequest(statement: statement), - database: database, - scheduler: scheduler - ) + .fetch(request, database: database, scheduler: scheduler) ) + setFetchKeyID(for: request, database: database, scheduler: scheduler) } /// Initializes this property with a query associated with an optional value. @@ -706,14 +739,12 @@ extension FetchOne { Value: StructuredQueriesCore._OptionalProtocol, Value.QueryOutput == Value { + let request = FetchOneStatementOptionalProtocolRequest(statement: statement) sharedReader = SharedReader( wrappedValue: wrappedValue, - .fetch( - FetchOneStatementOptionalProtocolRequest(statement: statement), - database: database, - scheduler: scheduler - ) + .fetch(request, database: database, scheduler: scheduler) ) + setFetchKeyID(for: request, database: database, scheduler: scheduler) } /// Replaces the wrapped value with data from the given query. @@ -905,7 +936,11 @@ extension FetchOne: Equatable where Value: Equatable { #if canImport(SwiftUI) extension FetchOne: DynamicProperty { public func update() { - sharedReader.update() + let persisted = state.wrappedValue + if persisted !== box { + persisted.reconcile(from: box, propertyName: "@FetchOne") + } + persisted.subscribe(generation: generation) } @available(*, deprecated, message: "Remove unused parameters: 'database', 'animation'.") diff --git a/Sources/SQLiteData/Internal/FetchBox.swift b/Sources/SQLiteData/Internal/FetchBox.swift new file mode 100644 index 00000000..e2f3d54b --- /dev/null +++ b/Sources/SQLiteData/Internal/FetchBox.swift @@ -0,0 +1,96 @@ +#if canImport(SwiftUI) + import Combine + import Foundation + import IssueReporting + import Sharing + import SwiftUI + + final class FetchBox: @unchecked Sendable { + private let lock = NSLock() + private var storage: Storage + + init(sharedReader: SharedReader) { + storage = Storage(sharedReader: sharedReader) + } + + var sharedReader: SharedReader { + get { lock.withLock { storage.sharedReader } } + set { lock.withLock { storage.sharedReader = newValue } } + } + + var fetchKeyID: FetchKeyID? { + get { lock.withLock { storage.fetchKeyID } } + set { lock.withLock { storage.fetchKeyID = newValue } } + } + + func reconcile(from fresh: FetchBox, propertyName: String) { + let freshSnapshot = fresh.lock.withLock { fresh.storage } + let snapshot = lock.withLock { storage } + if let freshFetchKeyID = freshSnapshot.fetchKeyID { + if freshFetchKeyID != snapshot.fetchKeyID { + update(from: freshSnapshot) + } + } else if snapshot.fetchKeyID != nil { + #if DEBUG + let hasReported = lock.withLock { + defer { storage.hasReportedIgnoredReinitialization = true } + return storage.hasReportedIgnoredReinitialization + } + guard !hasReported else { return } + reportIssue( + """ + A '\(propertyName)' property was re-initialized without a query, but was previously \ + initialized with one. This re-initialization is ignored, and the property continues \ + to observe its current query. + + To change the query associated with this property, invoke 'load' on its projected \ + value, or reset the enclosing view's identity with 'View.id'. + """ + ) + #endif + } else if isEqual(freshSnapshot.initialValue, snapshot.initialValue) == false { + update(from: freshSnapshot) + } + } + + private func update(from other: Storage) { + lock.withLock { + storage.sharedReader = other.sharedReader + storage.fetchKeyID = other.fetchKeyID + storage.initialValue = other.initialValue + } + } + + func subscribe(generation: SwiftUI.State) { + guard #unavailable(iOS 17, macOS 14, tvOS 17, watchOS 10) else { return } + _ = generation.wrappedValue + let cancellable = sharedReader.publisher + .dropFirst() + .sink { _ in generation.wrappedValue &+= 1 } + lock.withLock { storage.swiftUICancellable = cancellable } + } + + private struct Storage { + var sharedReader: SharedReader + var fetchKeyID: FetchKeyID? + var initialValue: Value + var swiftUICancellable: AnyCancellable? + #if DEBUG + var hasReportedIgnoredReinitialization = false + #endif + + init(sharedReader: SharedReader) { + self.sharedReader = sharedReader + self.initialValue = sharedReader.wrappedValue + } + } + } + + private func isEqual(_ lhs: T, _ rhs: T) -> Bool? { + func open(_ lhs: U) -> Bool { + lhs == rhs as? U + } + guard let lhs = lhs as? any Equatable else { return nil } + return open(lhs) + } +#endif diff --git a/Tests/SQLiteDataTests/FetchBoxTests.swift b/Tests/SQLiteDataTests/FetchBoxTests.swift new file mode 100644 index 00000000..46853993 --- /dev/null +++ b/Tests/SQLiteDataTests/FetchBoxTests.swift @@ -0,0 +1,99 @@ +#if canImport(SwiftUI) + import GRDB + import Sharing + import Testing + + @testable import SQLiteData + + @Suite struct FetchBoxTests { + let database: any DatabaseReader + + init() throws { + database = try DatabaseQueue() + } + + @Test func keyedReinitializationWithNewQueryIsAdopted() { + let persisted = FetchBox(sharedReader: SharedReader(value: 1)) + persisted.fetchKeyID = fetchKeyID(TestRequest(id: 1)) + let fresh = FetchBox(sharedReader: SharedReader(value: 2)) + fresh.fetchKeyID = fetchKeyID(TestRequest(id: 2)) + persisted.reconcile(from: fresh, propertyName: "@Fetch") + #expect(persisted.sharedReader.wrappedValue == 2) + #expect(persisted.fetchKeyID == fresh.fetchKeyID) + } + + @Test func keyedReinitializationWithSameQueryIsIgnored() { + let persisted = FetchBox(sharedReader: SharedReader(value: 1)) + persisted.fetchKeyID = fetchKeyID(TestRequest(id: 1)) + let fresh = FetchBox(sharedReader: SharedReader(value: 2)) + fresh.fetchKeyID = fetchKeyID(TestRequest(id: 1)) + persisted.reconcile(from: fresh, propertyName: "@Fetch") + #expect(persisted.sharedReader.wrappedValue == 1) + } + + @Test func keylessReinitializationWithSameDefaultIsIgnored() { + let persisted = FetchBox(sharedReader: SharedReader(value: 1)) + let fresh = FetchBox(sharedReader: SharedReader(value: 1)) + persisted.reconcile(from: fresh, propertyName: "@Fetch") + #expect(persisted.sharedReader.wrappedValue == 1) + } + + @Test func keylessReinitializationWithDifferentDefaultIsAdopted() { + let persisted = FetchBox(sharedReader: SharedReader(value: 1)) + let fresh = FetchBox(sharedReader: SharedReader(value: 2)) + persisted.reconcile(from: fresh, propertyName: "@Fetch") + #expect(persisted.sharedReader.wrappedValue == 2) + } + + @Test func keylessAdoptionCarriesSeedForward() { + let persisted = FetchBox(sharedReader: SharedReader(value: 1)) + let fresh = FetchBox(sharedReader: SharedReader(value: 2)) + persisted.reconcile(from: fresh, propertyName: "@Fetch") + persisted.sharedReader = SharedReader(value: 99) + let refresh = FetchBox(sharedReader: SharedReader(value: 2)) + persisted.reconcile(from: refresh, propertyName: "@Fetch") + #expect(persisted.sharedReader.wrappedValue == 99) + } + + @Test func keylessReinitializationAfterLocalLoadIsIgnored() { + let persisted = FetchBox(sharedReader: SharedReader(value: [Int]())) + persisted.sharedReader = SharedReader(value: [1, 2, 3]) + let fresh = FetchBox(sharedReader: SharedReader(value: [Int]())) + persisted.reconcile(from: fresh, propertyName: "@FetchAll") + #expect(persisted.sharedReader.wrappedValue == [1, 2, 3]) + } + + @Test func keylessReinitializationWithNonEquatableDefaultIsIgnored() { + struct Opaque: Sendable { + let n: Int + } + let persisted = FetchBox(sharedReader: SharedReader(value: Opaque(n: 1))) + let fresh = FetchBox(sharedReader: SharedReader(value: Opaque(n: 2))) + persisted.reconcile(from: fresh, propertyName: "@Fetch") + #expect(persisted.sharedReader.wrappedValue.n == 1) + } + + @Test func keyedToKeylessReinitializationReportsIssue() { + let persisted = FetchBox(sharedReader: SharedReader(value: 1)) + persisted.fetchKeyID = fetchKeyID(TestRequest(id: 1)) + let fresh = FetchBox(sharedReader: SharedReader(value: 1)) + withKnownIssue { + persisted.reconcile(from: fresh, propertyName: "@Fetch") + } + #expect(persisted.sharedReader.wrappedValue == 1) + #expect(persisted.fetchKeyID != nil) + persisted.reconcile(from: fresh, propertyName: "@Fetch") + } + + private func fetchKeyID(_ request: some FetchKeyRequest) -> FetchKeyID { + FetchKey(request: request, database: database, scheduler: nil).id + } + } + + private struct TestRequest: FetchKeyRequest, Hashable { + let id: Int + func fetch(_ db: Database) throws -> Int { + id + } + } +#endif From 558526c207e4481051e803a473427e992de98083 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 7 Jul 2026 14:10:01 -0700 Subject: [PATCH 6/6] wip --- Sources/SQLiteData/FetchAll.swift | 16 +++++++++-- Sources/SQLiteData/FetchOne.swift | 32 +++++++++++++++++++--- Sources/SQLiteData/Internal/FetchBox.swift | 7 ++--- 3 files changed, 44 insertions(+), 11 deletions(-) diff --git a/Sources/SQLiteData/FetchAll.swift b/Sources/SQLiteData/FetchAll.swift index 43e8c5f2..e073c42c 100644 --- a/Sources/SQLiteData/FetchAll.swift +++ b/Sources/SQLiteData/FetchAll.swift @@ -253,7 +253,13 @@ public struct FetchAll: Sendable { } extension FetchAll { - @available(*, deprecated, message: "Remove unused parameters: 'database', 'scheduler'.") + @available( + *, + deprecated, + message: """ + '@Selection' type requires a query to be fetched; provide one or remove unused parameters: 'database', 'scheduler'. + """ + ) public init( wrappedValue: [Element] = [], database: (any DatabaseReader)? = nil, @@ -437,7 +443,13 @@ extension FetchAll: Equatable where Element: Equatable { persisted.subscribe(generation: generation) } - @available(*, deprecated, message: "Remove unused parameters: 'database', 'animation'.") + @available( + *, + deprecated, + message: """ + '@Selection' type requires a query to be fetched; provide one or remove unused parameters: 'database', 'scheduler'. + """ + ) public init( wrappedValue: [Element] = [], database: (any DatabaseReader)? = nil, diff --git a/Sources/SQLiteData/FetchOne.swift b/Sources/SQLiteData/FetchOne.swift index 8b11592a..07f7e4e9 100644 --- a/Sources/SQLiteData/FetchOne.swift +++ b/Sources/SQLiteData/FetchOne.swift @@ -478,7 +478,13 @@ public struct FetchOne: Sendable { } extension FetchOne { - @available(*, deprecated, message: "Remove unused parameters: 'database', 'scheduler'.") + @available( + *, + deprecated, + message: """ + '@Selection' type requires a query to be fetched; provide one or remove unused parameters: 'database', 'scheduler'. + """ + ) public init( wrappedValue: sending Value, database: (any DatabaseReader)? = nil, @@ -491,7 +497,13 @@ extension FetchOne { sharedReader = SharedReader(value: wrappedValue) } - @available(*, deprecated, message: "Remove unused parameters: 'database', 'scheduler'.") + @available( + *, + deprecated, + message: """ + '@Selection' type requires a query to be fetched; provide one or remove unused parameters: 'database', 'scheduler'. + """ + ) public init( wrappedValue: sending Value = Value._none, database: (any DatabaseReader)? = nil, @@ -943,7 +955,13 @@ extension FetchOne: Equatable where Value: Equatable { persisted.subscribe(generation: generation) } - @available(*, deprecated, message: "Remove unused parameters: 'database', 'animation'.") + @available( + *, + deprecated, + message: """ + '@Selection' type requires a query to be fetched; provide one or remove unused parameters: 'database', 'scheduler'. + """ + ) public init( wrappedValue: sending Value, database: (any DatabaseReader)? = nil, @@ -956,7 +974,13 @@ extension FetchOne: Equatable where Value: Equatable { sharedReader = SharedReader(value: wrappedValue) } - @available(*, deprecated, message: "Remove unused parameters: 'database', 'animation'.") + @available( + *, + deprecated, + message: """ + '@Selection' type requires a query to be fetched; provide one or remove unused parameters: 'database', 'scheduler'. + """ + ) public init( wrappedValue: sending Value = Value._none, database: (any DatabaseReader)? = nil, diff --git a/Sources/SQLiteData/Internal/FetchBox.swift b/Sources/SQLiteData/Internal/FetchBox.swift index e2f3d54b..9170223f 100644 --- a/Sources/SQLiteData/Internal/FetchBox.swift +++ b/Sources/SQLiteData/Internal/FetchBox.swift @@ -40,11 +40,8 @@ reportIssue( """ A '\(propertyName)' property was re-initialized without a query, but was previously \ - initialized with one. This re-initialization is ignored, and the property continues \ - to observe its current query. - - To change the query associated with this property, invoke 'load' on its projected \ - value, or reset the enclosing view's identity with 'View.id'. + initialized with one; this re-initialization will be ignored, and the property \ + will continue to observe the existing query """ ) #endif