From 60d6b4f7b1710845a414cd0d8727bb2fac1d5dfb Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Sun, 1 Jun 2025 23:04:56 -0700 Subject: [PATCH 1/8] Better support for optionals in queries * Predicates (`where` and `having`) can return optional booleans. * Add `QueryExpression.map` for optionally building queries on non-optional values. For example, a comparison that might use the `#sql` macro as an escape hatch can more safely and succinctly use `map`: ```diff Reminder.where { - #sql("\($0.dueDate) < \(Date())") + $0.dueDate.map { $0 < Date() } } ``` --- Sources/StructuredQueriesCore/Optional.swift | 8 ++++++++ .../StructuredQueriesCore/QueryFragmentBuilder.swift | 6 ++++++ .../StructuredQueriesCore/Statements/Delete.swift | 8 ++++++-- .../StructuredQueriesCore/Statements/Select.swift | 12 ++++++++---- .../Statements/SelectStatement.swift | 2 +- .../StructuredQueriesCore/Statements/Update.swift | 6 ++++-- Sources/StructuredQueriesCore/Statements/Where.swift | 10 +++++----- 7 files changed, 38 insertions(+), 14 deletions(-) diff --git a/Sources/StructuredQueriesCore/Optional.swift b/Sources/StructuredQueriesCore/Optional.swift index e2861c5a..836c8b4b 100644 --- a/Sources/StructuredQueriesCore/Optional.swift +++ b/Sources/StructuredQueriesCore/Optional.swift @@ -133,3 +133,11 @@ where Wrapped.TableColumns: PrimaryKeyedTableDefinition { self[dynamicMember: \.primaryKey] } } + +extension QueryExpression where QueryValue: _OptionalProtocol { + public func map( + _ transform: (SQLQueryExpression) -> some QueryExpression + ) -> some QueryExpression { + SQLQueryExpression(transform(SQLQueryExpression(queryFragment)).queryFragment) + } +} diff --git a/Sources/StructuredQueriesCore/QueryFragmentBuilder.swift b/Sources/StructuredQueriesCore/QueryFragmentBuilder.swift index d029e44a..19e7e1ed 100644 --- a/Sources/StructuredQueriesCore/QueryFragmentBuilder.swift +++ b/Sources/StructuredQueriesCore/QueryFragmentBuilder.swift @@ -27,6 +27,12 @@ extension QueryFragmentBuilder { ) -> [QueryFragment] { [expression.queryFragment] } + + public static func buildExpression( + _ expression: some QueryExpression> + ) -> [QueryFragment] { + [expression.queryFragment] + } } extension QueryFragmentBuilder<()> { diff --git a/Sources/StructuredQueriesCore/Statements/Delete.swift b/Sources/StructuredQueriesCore/Statements/Delete.swift index 3d2898de..4595dbe7 100644 --- a/Sources/StructuredQueriesCore/Statements/Delete.swift +++ b/Sources/StructuredQueriesCore/Statements/Delete.swift @@ -48,7 +48,9 @@ public struct Delete { /// /// - Parameter keyPath: A key path to a Boolean expression to filter by. /// - Returns: A statement with the added predicate. - public func `where`(_ keyPath: KeyPath>) -> Self { + public func `where`( + _ keyPath: KeyPath>> + ) -> Self { var update = self update.where.append(From.columns[keyPath: keyPath].queryFragment) return update @@ -64,7 +66,9 @@ public struct Delete { /// - Parameter predicate: A closure that returns a Boolean expression to filter by. /// - Returns: A statement with the added predicate. @_disfavoredOverload - public func `where`(_ predicate: (From.TableColumns) -> some QueryExpression) -> Self { + public func `where`( + _ predicate: (From.TableColumns) -> some QueryExpression> + ) -> Self { var update = self update.where.append(predicate(From.columns).queryFragment) return update diff --git a/Sources/StructuredQueriesCore/Statements/Select.swift b/Sources/StructuredQueriesCore/Statements/Select.swift index 1297a5f7..1e966ae9 100644 --- a/Sources/StructuredQueriesCore/Statements/Select.swift +++ b/Sources/StructuredQueriesCore/Statements/Select.swift @@ -241,7 +241,7 @@ extension Table { /// columns. /// - Returns: A select statement that is filtered by the given predicate. public static func having( - _ predicate: (TableColumns) -> some QueryExpression + _ predicate: (TableColumns) -> some QueryExpression> ) -> SelectOf { Where().having(predicate) } @@ -1103,7 +1103,7 @@ extension Select { /// - Parameter keyPath: A key path from this select's table to a Boolean expression to filter by. /// - Returns: A new select statement that appends the given predicate to its `WHERE` clause. public func `where`( - _ keyPath: KeyPath> + _ keyPath: KeyPath>> ) -> Self where Joins == () { var select = self @@ -1118,7 +1118,9 @@ extension Select { /// - Returns: A new select statement that appends the given predicate to its `WHERE` clause. @_disfavoredOverload public func `where`( - _ predicate: (From.TableColumns, repeat (each J).TableColumns) -> some QueryExpression + _ predicate: (From.TableColumns, repeat (each J).TableColumns) -> some QueryExpression< + some _OptionalPromotable + > ) -> Self where Joins == (repeat each J) { var select = self @@ -1222,7 +1224,9 @@ extension Select { /// - Returns: A new select statement that appends the given predicate to its `HAVING` clause. @_disfavoredOverload public func having( - _ predicate: (From.TableColumns, repeat (each J).TableColumns) -> some QueryExpression + _ predicate: (From.TableColumns, repeat (each J).TableColumns) -> some QueryExpression< + some _OptionalPromotable + > ) -> Self where Joins == (repeat each J) { var select = self diff --git a/Sources/StructuredQueriesCore/Statements/SelectStatement.swift b/Sources/StructuredQueriesCore/Statements/SelectStatement.swift index 6cda833b..ce7ef301 100644 --- a/Sources/StructuredQueriesCore/Statements/SelectStatement.swift +++ b/Sources/StructuredQueriesCore/Statements/SelectStatement.swift @@ -47,7 +47,7 @@ public typealias SelectStatementOf = extension SelectStatement { public static func `where`( - _ predicate: (From.TableColumns) -> some QueryExpression + _ predicate: (From.TableColumns) -> some QueryExpression> ) -> Self where Self == Where { Self(predicates: [predicate(From.columns).queryFragment]) diff --git a/Sources/StructuredQueriesCore/Statements/Update.swift b/Sources/StructuredQueriesCore/Statements/Update.swift index 685bdf77..6ab67078 100644 --- a/Sources/StructuredQueriesCore/Statements/Update.swift +++ b/Sources/StructuredQueriesCore/Statements/Update.swift @@ -105,7 +105,9 @@ public struct Update { /// /// - Parameter keyPath: A key path to a Boolean expression to filter by. /// - Returns: A statement with the added predicate. - public func `where`(_ keyPath: KeyPath>) -> Self { + public func `where`( + _ keyPath: KeyPath>> + ) -> Self { var update = self update.where.append(From.columns[keyPath: keyPath].queryFragment) return update @@ -117,7 +119,7 @@ public struct Update { /// - Returns: A statement with the added predicate. @_disfavoredOverload public func `where`( - _ predicate: (From.TableColumns) -> some QueryExpression + _ predicate: (From.TableColumns) -> some QueryExpression> ) -> Self { var update = self update.where.append(predicate(From.columns).queryFragment) diff --git a/Sources/StructuredQueriesCore/Statements/Where.swift b/Sources/StructuredQueriesCore/Statements/Where.swift index fae233f1..9aa010fb 100644 --- a/Sources/StructuredQueriesCore/Statements/Where.swift +++ b/Sources/StructuredQueriesCore/Statements/Where.swift @@ -20,7 +20,7 @@ extension Table { /// - Parameter keyPath: A key path to a Boolean expression to filter by. /// - Returns: A `WHERE` clause. public static func `where`( - _ keyPath: KeyPath> + _ keyPath: KeyPath>> ) -> Where { Where(predicates: [columns[keyPath: keyPath].queryFragment]) } @@ -33,7 +33,7 @@ extension Table { /// - Returns: A `WHERE` clause. @_disfavoredOverload public static func `where`( - _ predicate: (TableColumns) -> some QueryExpression + _ predicate: (TableColumns) -> some QueryExpression> ) -> Where { Where(predicates: [predicate(columns).queryFragment]) } @@ -285,7 +285,7 @@ extension Where: SelectStatement { /// - Parameter keyPath: A key path to a Boolean expression to filter by. /// - Returns: A where clause with the added predicate. public func `where`( - _ keyPath: KeyPath> + _ keyPath: KeyPath>> ) -> Self { var `where` = self `where`.predicates.append(From.columns[keyPath: keyPath].queryFragment) @@ -298,7 +298,7 @@ extension Where: SelectStatement { /// - Returns: A where clause with the added predicate. @_disfavoredOverload public func `where`( - _ predicate: (From.TableColumns) -> some QueryExpression + _ predicate: (From.TableColumns) -> some QueryExpression> ) -> Self { var `where` = self `where`.predicates.append(predicate(From.columns).queryFragment) @@ -408,7 +408,7 @@ extension Where: SelectStatement { /// A select statement for the filtered table with the given `HAVING` clause. public func having( - _ predicate: (From.TableColumns) -> some QueryExpression + _ predicate: (From.TableColumns) -> some QueryExpression> ) -> SelectOf { asSelect().having(predicate) } From c0f670c16026998fa803bfc45b5cdb453e178e80 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Sun, 1 Jun 2025 23:36:51 -0700 Subject: [PATCH 2/8] wip --- .../AggregateFunctions.swift | 4 +-- Sources/StructuredQueriesCore/Optional.swift | 32 +++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/Sources/StructuredQueriesCore/AggregateFunctions.swift b/Sources/StructuredQueriesCore/AggregateFunctions.swift index 15ac14f6..dae49fcd 100644 --- a/Sources/StructuredQueriesCore/AggregateFunctions.swift +++ b/Sources/StructuredQueriesCore/AggregateFunctions.swift @@ -84,7 +84,7 @@ extension QueryExpression where QueryValue: QueryBindable { /// - Returns: A maximum aggregate of this expression. public func max( filter: (some QueryExpression)? = Bool?.none - ) -> some QueryExpression { + ) -> some QueryExpression { AggregateFunction("max", self, filter: filter) } @@ -99,7 +99,7 @@ extension QueryExpression where QueryValue: QueryBindable { /// - Returns: A minimum aggregate of this expression. public func min( filter: (some QueryExpression)? = Bool?.none - ) -> some QueryExpression { + ) -> some QueryExpression { AggregateFunction("min", self, filter: filter) } } diff --git a/Sources/StructuredQueriesCore/Optional.swift b/Sources/StructuredQueriesCore/Optional.swift index 836c8b4b..9cc75742 100644 --- a/Sources/StructuredQueriesCore/Optional.swift +++ b/Sources/StructuredQueriesCore/Optional.swift @@ -135,9 +135,41 @@ where Wrapped.TableColumns: PrimaryKeyedTableDefinition { } extension QueryExpression where QueryValue: _OptionalProtocol { + /// Creates and optionalizes a new expression from this one by applying an unwrapped version of + /// this expression to a given closure. + /// + /// ```swift + /// Reminder.where { + /// $0.dueDate.map { $0 > Date() } + /// } + /// // SELECT … FROM "reminders" + /// // WHERE "reminders"."dueDate" > '2018-01-29 00:08:00.000' + /// ``` + /// + /// - Parameter transform: A closure that takes an unwrapped version of this expression. + /// - Returns: The result of the transform function, optionalized. public func map( _ transform: (SQLQueryExpression) -> some QueryExpression ) -> some QueryExpression { SQLQueryExpression(transform(SQLQueryExpression(queryFragment)).queryFragment) } + + /// Creates a new optional expression from this one by applying an unwrapped version of this + /// expression to a given closure. + /// + /// ```swift + /// Reminder.select { + /// $0.dueDate.flatMap { $0.max() } + /// } + /// // SELECT max("reminders"."dueDate") FROM "reminders" + /// // => [Date?] + /// ``` + /// + /// - Parameter transform: A closure that takes an unwrapped version of this expression. + /// - Returns: The result of the transform function. + public func flatMap( + _ transform: (SQLQueryExpression) -> some QueryExpression + ) -> some QueryExpression { + SQLQueryExpression(transform(SQLQueryExpression(queryFragment)).queryFragment) + } } From 2206af7a5d10879cf1b042c83a76fa6848301efc Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Sun, 1 Jun 2025 23:47:39 -0700 Subject: [PATCH 3/8] wip --- Sources/StructuredQueriesCore/AggregateFunctions.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/StructuredQueriesCore/AggregateFunctions.swift b/Sources/StructuredQueriesCore/AggregateFunctions.swift index dae49fcd..dc50ad1c 100644 --- a/Sources/StructuredQueriesCore/AggregateFunctions.swift +++ b/Sources/StructuredQueriesCore/AggregateFunctions.swift @@ -72,7 +72,7 @@ where QueryValue: _OptionalPromotable, QueryValue._Optionalized.Wrapped == Strin } } -extension QueryExpression where QueryValue: QueryBindable { +extension QueryExpression where QueryValue: QueryBindable & _OptionalPromotable { /// A maximum aggregate of this expression. /// /// ```swift @@ -84,7 +84,7 @@ extension QueryExpression where QueryValue: QueryBindable { /// - Returns: A maximum aggregate of this expression. public func max( filter: (some QueryExpression)? = Bool?.none - ) -> some QueryExpression { + ) -> some QueryExpression { AggregateFunction("max", self, filter: filter) } @@ -99,7 +99,7 @@ extension QueryExpression where QueryValue: QueryBindable { /// - Returns: A minimum aggregate of this expression. public func min( filter: (some QueryExpression)? = Bool?.none - ) -> some QueryExpression { + ) -> some QueryExpression { AggregateFunction("min", self, filter: filter) } } From afdc819c1872d099b49989622f81431101647e10 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 2 Jun 2025 00:18:35 -0700 Subject: [PATCH 4/8] wip --- .../ScalarFunctions.swift | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/Sources/StructuredQueriesCore/ScalarFunctions.swift b/Sources/StructuredQueriesCore/ScalarFunctions.swift index f999dcb0..3fcc0472 100644 --- a/Sources/StructuredQueriesCore/ScalarFunctions.swift +++ b/Sources/StructuredQueriesCore/ScalarFunctions.swift @@ -120,7 +120,8 @@ extension QueryExpression where QueryValue: FloatingPoint { } } -extension QueryExpression where QueryValue: Numeric { +extension QueryExpression +where QueryValue: _OptionalPromotable, QueryValue._Optionalized.Wrapped: Numeric { /// Wraps this numeric query expression with the `abs` function. /// /// - Returns: An expression wrapped with the `abs` function. @@ -251,14 +252,18 @@ extension QueryExpression where QueryValue == String { public func instr(_ occurrence: some QueryExpression) -> some QueryExpression { QueryFunction("instr", self, occurrence) } +} +extension QueryExpression where QueryValue: _OptionalPromotable { /// Wraps this string expression with the `lower` function. /// /// - Returns: An expression wrapped with the `lower` function. - public func lower() -> some QueryExpression { + public func lower() -> some QueryExpression { QueryFunction("lower", self) } +} +extension QueryExpression where QueryValue == String { /// Wraps this string expression with the `ltrim` function. /// /// - Parameter characters: Characters to trim. @@ -279,14 +284,18 @@ extension QueryExpression where QueryValue == String { public func octetLength() -> some QueryExpression { QueryFunction("octet_length", self) } +} +extension QueryExpression where QueryValue: _OptionalPromotable { /// Wraps this string expression with the `quote` function. /// /// - Returns: An expression wrapped with the `quote` function. - public func quote() -> some QueryExpression { + public func quote() -> some QueryExpression { QueryFunction("quote", self) } +} +extension QueryExpression where QueryValue == String { /// Creates an expression invoking the `replace` function. /// /// - Parameters: @@ -346,13 +355,15 @@ extension QueryExpression where QueryValue == String { return QueryFunction("trim", self) } } +} +extension QueryExpression where QueryValue: _OptionalPromotable { /// Wraps this string query expression with the `unhex` function. /// /// - Parameter characters: Non-hexadecimal characters to skip. /// - Returns: An optional blob expression of the `unhex` function wrapping this expression. public func unhex( - _ characters: (some QueryExpression)? = QueryValue?.none + _ characters: (some QueryExpression)? = String?.none ) -> some QueryExpression<[UInt8]?> { if let characters { return QueryFunction("unhex", self, characters) From 9a2c2d81492b8988c173b276944c3c1fa323731c Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 2 Jun 2025 00:23:37 -0700 Subject: [PATCH 5/8] wip --- Sources/StructuredQueriesCore/ScalarFunctions.swift | 2 +- Tests/StructuredQueriesTests/AggregateFunctionsTests.swift | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/StructuredQueriesCore/ScalarFunctions.swift b/Sources/StructuredQueriesCore/ScalarFunctions.swift index 3fcc0472..aa392529 100644 --- a/Sources/StructuredQueriesCore/ScalarFunctions.swift +++ b/Sources/StructuredQueriesCore/ScalarFunctions.swift @@ -258,7 +258,7 @@ extension QueryExpression where QueryValue: _OptionalPromotable { /// Wraps this string expression with the `lower` function. /// /// - Returns: An expression wrapped with the `lower` function. - public func lower() -> some QueryExpression { + public func lower() -> some QueryExpression { QueryFunction("lower", self) } } diff --git a/Tests/StructuredQueriesTests/AggregateFunctionsTests.swift b/Tests/StructuredQueriesTests/AggregateFunctionsTests.swift index 49d43281..5797616b 100644 --- a/Tests/StructuredQueriesTests/AggregateFunctionsTests.swift +++ b/Tests/StructuredQueriesTests/AggregateFunctionsTests.swift @@ -126,9 +126,9 @@ extension SnapshotTests { """ } results: { """ - ┌───┐ - │ 1 │ - └───┘ + ┌──────┐ + │ .low │ + └──────┘ """ } } From c0b5e927c961ce440ac3535153726518be7fb21c Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Mon, 2 Jun 2025 00:30:12 -0700 Subject: [PATCH 6/8] wip --- Sources/StructuredQueriesCore/ScalarFunctions.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/StructuredQueriesCore/ScalarFunctions.swift b/Sources/StructuredQueriesCore/ScalarFunctions.swift index aa392529..56cef290 100644 --- a/Sources/StructuredQueriesCore/ScalarFunctions.swift +++ b/Sources/StructuredQueriesCore/ScalarFunctions.swift @@ -290,7 +290,7 @@ extension QueryExpression where QueryValue: _OptionalPromotable { /// Wraps this string expression with the `quote` function. /// /// - Returns: An expression wrapped with the `quote` function. - public func quote() -> some QueryExpression { + public func quote() -> some QueryExpression { QueryFunction("quote", self) } } From bfd23752decead1021d1744cf57f7614b6aceb29 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 17 Jun 2025 12:41:04 -0700 Subject: [PATCH 7/8] Add test for where clause with optional --- Tests/StructuredQueriesTests/WhereTests.swift | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/Tests/StructuredQueriesTests/WhereTests.swift b/Tests/StructuredQueriesTests/WhereTests.swift index e0b61e15..eed8ba23 100644 --- a/Tests/StructuredQueriesTests/WhereTests.swift +++ b/Tests/StructuredQueriesTests/WhereTests.swift @@ -1,6 +1,8 @@ +import Dependencies import Foundation import InlineSnapshotTesting import StructuredQueries +import StructuredQueriesSQLite import Testing extension SnapshotTests { @@ -109,5 +111,30 @@ extension SnapshotTests { """ } } + + @Test func optionalBoolean() throws { + @Dependency(\.defaultDatabase) var db + let remindersListIDs = try db.execute( + RemindersList.insert { + RemindersList.Draft(title: "New list") + } + .returning(\.id) + ) + let remindersListID = try #require(remindersListIDs.first) + + assertQuery( + RemindersList + .find(remindersListID) + .leftJoin(Reminder.all) { $0.id.eq($1.remindersListID) } + .where { $1.isCompleted } + ) { + """ + SELECT "remindersLists"."id", "remindersLists"."color", "remindersLists"."title", "reminders"."id", "reminders"."assignedUserID", "reminders"."dueDate", "reminders"."isCompleted", "reminders"."isFlagged", "reminders"."notes", "reminders"."priority", "reminders"."remindersListID", "reminders"."title" + FROM "remindersLists" + LEFT JOIN "reminders" ON ("remindersLists"."id" = "reminders"."remindersListID") + WHERE ("remindersLists"."id" = 4) AND "reminders"."isCompleted" + """ + } + } } } From ef42f7d13e0354cdfdbc11d9d0260a1a630dc727 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 17 Jun 2025 12:56:44 -0700 Subject: [PATCH 8/8] wip --- Tests/StructuredQueriesTests/WhereTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/StructuredQueriesTests/WhereTests.swift b/Tests/StructuredQueriesTests/WhereTests.swift index eed8ba23..ca72bcf9 100644 --- a/Tests/StructuredQueriesTests/WhereTests.swift +++ b/Tests/StructuredQueriesTests/WhereTests.swift @@ -118,7 +118,7 @@ extension SnapshotTests { RemindersList.insert { RemindersList.Draft(title: "New list") } - .returning(\.id) + .returning(\.id) ) let remindersListID = try #require(remindersListIDs.first)