diff --git a/Sources/SQLiteData/FetchAll+Sections.swift b/Sources/SQLiteData/FetchAll+Sections.swift index f2d9521a..f7287284 100644 --- a/Sources/SQLiteData/FetchAll+Sections.swift +++ b/Sources/SQLiteData/FetchAll+Sections.swift @@ -1463,12 +1463,12 @@ public struct _Sectioning: Hashable, Sendable { let select: QueryFragment let order: QueryFragment - package init(_ expression: some QueryExpression>) { + package init(_ expression: some QueryExpression) { self.select = expression.queryFragment self.order = expression.queryFragment } - package init(_ orderingTerm: _OrderingTerm>) { + package init(_ orderingTerm: _OrderingTerm) { self.select = orderingTerm.baseQueryFragment self.order = orderingTerm.queryFragment } @@ -1476,6 +1476,41 @@ public struct _Sectioning: Hashable, Sendable { @resultBuilder public enum _SectionBuilder { + public static func buildExpression( + _ expression: some QueryExpression + ) -> _Sectioning { + _Sectioning(expression) + } + + public static func buildExpression( + _ orderingTerm: _OrderingTerm + ) -> _Sectioning { + _Sectioning(orderingTerm) + } + + public static func buildBlock(_ component: _Sectioning) -> _Sectioning { + component + } + + @available( + *, + unavailable, + message: "Sectioning is required here. Add an 'else' branch, or section by an optional key." + ) + public static func buildOptional(_ component: _Sectioning?) -> _Sectioning { + fatalError() + } + + public static func buildEither(first component: _Sectioning) -> _Sectioning { + component + } + + public static func buildEither(second component: _Sectioning) -> _Sectioning { + component + } +} + +extension _SectionBuilder where Key: _OptionalProtocol { public static func buildExpression( _ expression: Never? ) -> _Sectioning? { @@ -1485,34 +1520,33 @@ public enum _SectionBuilder { @_disfavoredOverload public static func buildExpression( _ expression: some QueryExpression> - ) -> _Sectioning { + ) -> _Sectioning? { _Sectioning(expression) } + @_disfavoredOverload public static func buildExpression( _ orderingTerm: _OrderingTerm> - ) -> _Sectioning { + ) -> _Sectioning? { _Sectioning(orderingTerm) } - public static func buildBlock(_ component: _Sectioning) -> _Sectioning { - component - } - @_disfavoredOverload public static func buildBlock(_ component: _Sectioning?) -> _Sectioning? { component } - public static func buildOptional(_ component: _Sectioning?) -> _Sectioning? { - component + public static func buildOptional(_ component: _Sectioning??) -> _Sectioning? { + component ?? nil } - public static func buildEither(first component: _Sectioning) -> _Sectioning { + @_disfavoredOverload + public static func buildEither(first component: _Sectioning?) -> _Sectioning? { component } - public static func buildEither(second component: _Sectioning) -> _Sectioning { + @_disfavoredOverload + public static func buildEither(second component: _Sectioning?) -> _Sectioning? { component } } diff --git a/Sources/SQLiteData/StructuredQueries+GRDB/Statement+Sections.swift b/Sources/SQLiteData/StructuredQueries+GRDB/Statement+Sections.swift index 5303467c..1d70822c 100644 --- a/Sources/SQLiteData/StructuredQueries+GRDB/Statement+Sections.swift +++ b/Sources/SQLiteData/StructuredQueries+GRDB/Statement+Sections.swift @@ -41,7 +41,7 @@ extension SelectStatement where QueryValue == (), Joins == () { public func fetchAll( _ db: Database, sectionBy sectionKeyPath: KeyPath< - From.TableColumns, some QueryExpression> + From.TableColumns, some QueryExpression > ) throws -> ResultsSectionCollection where Key.QueryOutput: Hashable { @@ -108,7 +108,7 @@ extension Select { public func fetchAll( _ db: Database, sectionBy sectionKeyPath: KeyPath< - From.TableColumns, some QueryExpression> + From.TableColumns, some QueryExpression > ) throws -> ResultsSectionCollection where QueryValue: QueryRepresentable, Joins == (), Key.QueryOutput: Hashable { diff --git a/Tests/SQLiteDataTests/FetchAllSectionsTests.swift b/Tests/SQLiteDataTests/FetchAllSectionsTests.swift index 15e36e51..ef8245e2 100644 --- a/Tests/SQLiteDataTests/FetchAllSectionsTests.swift +++ b/Tests/SQLiteDataTests/FetchAllSectionsTests.swift @@ -125,6 +125,77 @@ struct FetchAllSectionsTests { ) } + @Test(arguments: [true, false]) func ifElseNilSectionBy(byCategory: Bool) async throws { + @FetchAll( + SectionedReminder.order(by: \.id), + sectionBy: { + if byCategory { + $0.category + } else { + nil + } + } + ) + var reminders + try await $reminders.load() + + #expect( + $reminders.sections.sectionNames == (byCategory ? ["Errands", "Home", "Work"] : [nil]) + ) + } + + @Test(arguments: [true, false]) func nestedOptionalSectionBy(isSectioned: Bool) async throws { + let byCategory = true + @FetchAll( + SectionedReminder.order(by: \.id), + sectionBy: { columns in + if isSectioned { + if byCategory { + columns.category + } + } + } + ) + var reminders + try await $reminders.load() + + #expect( + $reminders.sections.sectionNames == (isSectioned ? ["Errands", "Home", "Work"] : [nil]) + ) + } + + @Test(arguments: [SectionedReminderSectioning.unsectioned, .category, .priority]) + func switchNestedOptionalSectionBy(sectioning: SectionedReminderSectioning) async throws { + let byCategory = true + @FetchAll( + SectionedReminder.order(by: \.id), + sectionBy: { columns in + switch sectioning { + case .unsectioned: + nil + case .category: + if byCategory { + columns.category + } else { + nil + } + case .priority: + columns.priority.desc(nulls: .last) + } + } + ) + var reminders + try await $reminders.load() + + let expected: [String?] = + switch sectioning { + case .unsectioned: [nil] + case .category: ["Errands", "Home", "Work"] + case .priority: ["low", "high", nil] + } + #expect($reminders.sections.sectionNames == expected) + } + @Test func keyPathSectionBy() async throws { @FetchAll(SectionedReminder.order(by: \.id), sectionBy: \.category) var reminders try await $reminders.load() @@ -143,14 +214,6 @@ struct FetchAllSectionsTests { #expect($reminders.sections.sectionNames == ["Errands", "Home", "Work"]) } - @Test func storedSectioning() async throws { - let sectioning: (SectionedReminder.TableColumns) -> _Sectioning? = { _Sectioning($0.category) } - @FetchAll(SectionedReminder.order(by: \.id), sectionBy: sectioning) var reminders - try await $reminders.load() - - #expect($reminders.sections.sectionNames == ["Errands", "Home", "Work"]) - } - @Test func sectionLookup() async throws { @FetchAll(SectionedReminder.order(by: \.id), sectionBy: \.category) var reminders try await $reminders.load() @@ -531,6 +594,34 @@ struct FetchAllSectionsTests { #expect(sections[sectionName: "high"]?.map(\.title) == ["Dishes", "Standup"]) } + @Test func nonNullSectionsAreNotOptional() async throws { + let sections = try await database.read { db in + try SectionedReminder.order(by: \.id).fetchAll(db, sectionBy: { $0.category }) + } + + let sectionNames: [String] = sections.sectionNames + #expect(sectionNames == ["Errands", "Home", "Work"]) + #expect(sections[sectionName: "Home"]?.name == "Home") + } + + @Test func nonNullSectionsAreNotOptionalWithOrdering() async throws { + let sections = try await database.read { db in + try SectionedReminder.order(by: \.id).fetchAll(db, sectionBy: { $0.category.desc() }) + } + + let sectionNames: [String] = sections.sectionNames + #expect(sectionNames == ["Work", "Home", "Errands"]) + } + + @Test func nullableSectionsStayOptional() async throws { + let sections = try await database.read { db in + try SectionedReminder.order(by: \.id).fetchAll(db, sectionBy: { $0.priority }) + } + + let sectionNames: [String?] = sections.sectionNames + #expect(sectionNames == [nil, "high", "low"]) + } + @Test func integerSections() async throws { let sections = try await database.read { db in try SectionedReminder @@ -613,12 +704,12 @@ struct FetchAllSectionsTests { @Test func fetchKeyRequest() async throws { struct Request: FetchKeyRequest { - func fetch(_ db: Database) throws -> ResultsSectionCollection { + func fetch(_ db: Database) throws -> ResultsSectionCollection { try SectionedReminder.order(by: \.id).fetchAll(db, sectionBy: { $0.category }) } } - @Fetch(Request()) var sections = ResultsSectionCollection() + @Fetch(Request()) var sections = ResultsSectionCollection() try await $sections.load() #expect(sections.sectionNames == ["Errands", "Home", "Work"]) @@ -641,6 +732,12 @@ private struct SectionedRow: Equatable { var label = "" } +enum SectionedReminderSectioning: Sendable { + case unsectioned + case category + case priority +} + @Table private struct SectionedValue: Equatable, Identifiable { let id: Int