Skip to content

Commit 14f4350

Browse files
authored
Add support for Common Table Expressions (#179)
* Add support for Common Table Expressions to SELECT, INSERT, UPDATE, DELETE, and UNION queries, including subqueries. Test and docs coverage is 100%. * Address (silly) warnings coming from the Swift 6 compiler * Fix grouping level when a union subquery is used with a CTE * Add a couple of real-world CTE queries to tests
1 parent 25d8170 commit 14f4350

15 files changed

+778
-9
lines changed

Sources/SQLKit/Builders/Implementations/SQLDeleteBuilder.swift

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/// Builds ``SQLDelete`` queries.
2-
public final class SQLDeleteBuilder: SQLQueryBuilder, SQLPredicateBuilder, SQLReturningBuilder {
2+
public final class SQLDeleteBuilder: SQLQueryBuilder, SQLPredicateBuilder, SQLReturningBuilder, SQLCommonTableExpressionBuilder {
33
/// ``SQLDelete`` query being built.
44
public var delete: SQLDelete
55

@@ -26,6 +26,13 @@ public final class SQLDeleteBuilder: SQLQueryBuilder, SQLPredicateBuilder, SQLRe
2626
set { self.delete.returning = newValue }
2727
}
2828

29+
// See `SQLCommonTableExpressionBuilder.tableExpressionGroup`.
30+
@inlinable
31+
public var tableExpressionGroup: SQLCommonTableExpressionGroup? {
32+
get { self.delete.tableExpressionGroup }
33+
set { self.delete.tableExpressionGroup = newValue }
34+
}
35+
2936
/// Create a new ``SQLDeleteBuilder``.
3037
@inlinable
3138
public init(_ delete: SQLDelete, on database: any SQLDatabase) {

Sources/SQLKit/Builders/Implementations/SQLInsertBuilder.swift

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
/// > ``SQLInsertBuilder``'s otherwise-identical public APIs overwrite the effects of any previous invocation. It
66
/// > would ideally be preferable to change ``SQLInsertBuilder``'s semantics in this regard, but this would be a
77
/// > significant breaking change in the API's behavior, and must therefore wait for a major version bump.
8-
public final class SQLInsertBuilder: SQLQueryBuilder, SQLReturningBuilder/*, SQLUnqualifiedColumnListBuilder*/ {
8+
public final class SQLInsertBuilder: SQLQueryBuilder, SQLReturningBuilder/*, SQLUnqualifiedColumnListBuilder*/, SQLCommonTableExpressionBuilder {
99
/// The ``SQLInsert`` query this builder builds.
1010
public var insert: SQLInsert
1111

@@ -25,6 +25,13 @@ public final class SQLInsertBuilder: SQLQueryBuilder, SQLReturningBuilder/*, SQL
2525
set { self.insert.returning = newValue }
2626
}
2727

28+
// See `SQLCommonTableExpressionBuilder.tableExpressionGroup`.
29+
@inlinable
30+
public var tableExpressionGroup: SQLCommonTableExpressionGroup? {
31+
get { self.insert.tableExpressionGroup }
32+
set { self.insert.tableExpressionGroup = newValue }
33+
}
34+
2835
/// Creates a new `SQLInsertBuilder`.
2936
@inlinable
3037
public init(_ insert: SQLInsert, on database: any SQLDatabase) {

Sources/SQLKit/Builders/Implementations/SQLUnionBuilder.swift

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/// Builds top-level ``SQLUnion`` queries which may be executed on their own.
2-
public final class SQLUnionBuilder: SQLQueryBuilder, SQLQueryFetcher, SQLCommonUnionBuilder {
2+
public final class SQLUnionBuilder: SQLQueryBuilder, SQLQueryFetcher, SQLCommonUnionBuilder, SQLCommonTableExpressionBuilder {
33
// See `SQLCommonUnionBuilder.union`.
44
public var union: SQLUnion
55

@@ -12,6 +12,13 @@ public final class SQLUnionBuilder: SQLQueryBuilder, SQLQueryFetcher, SQLCommonU
1212
self.union
1313
}
1414

15+
// See `SQLCommonTableExpressionBuilder.tableExpressionGroup`.
16+
@inlinable
17+
public var tableExpressionGroup: SQLCommonTableExpressionGroup? {
18+
get { self.union.tableExpressionGroup }
19+
set { self.union.tableExpressionGroup = newValue }
20+
}
21+
1522
/// Create a new ``SQLUnionBuilder``.
1623
@inlinable
1724
public init(on database: any SQLDatabase, initialQuery: SQLSelect) {

Sources/SQLKit/Builders/Implementations/SQLUpdateBuilder.swift

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/// Builds ``SQLUpdate`` queries.
2-
public final class SQLUpdateBuilder: SQLQueryBuilder, SQLPredicateBuilder, SQLReturningBuilder, SQLColumnUpdateBuilder {
2+
public final class SQLUpdateBuilder: SQLQueryBuilder, SQLPredicateBuilder, SQLReturningBuilder, SQLColumnUpdateBuilder, SQLCommonTableExpressionBuilder {
33
/// An ``SQLUpdate`` containing the complete current state of the builder.
44
public var update: SQLUpdate
55

@@ -33,6 +33,13 @@ public final class SQLUpdateBuilder: SQLQueryBuilder, SQLPredicateBuilder, SQLRe
3333
set { self.update.returning = newValue }
3434
}
3535

36+
// See `SQLCommonTableExpressionBuilder.tableExpressionGroup`.
37+
@inlinable
38+
public var tableExpressionGroup: SQLCommonTableExpressionGroup? {
39+
get { self.update.tableExpressionGroup }
40+
set { self.update.tableExpressionGroup = newValue }
41+
}
42+
3643
/// Create a new ``SQLUpdateBuilder``.
3744
///
3845
/// Use this API directly only if you need to have control over the builder's initial update query. Prefer using

Sources/SQLKit/Builders/Prototypes/SQLCommonTableExpressionBuilder.swift

Lines changed: 323 additions & 0 deletions
Large diffs are not rendered by default.

Sources/SQLKit/Builders/Prototypes/SQLSubqueryClauseBuilder.swift

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
/// expressions in other queries.
1010
///
1111
/// > Important: Despite the use of the term "subquery", this builder does not provide
12-
/// > methods for specifying subquery operators (e.g. `ANY`, `SOME`) or CTE clauses (`WITH`),
12+
/// > methods for specifying subquery operators (e.g. `ANY`, `SOME`),
1313
/// > nor does it enclose its result in grouping parenthesis, as all of these formations are
1414
/// > context-specific and are the purview of builders that conform to this protocol.
1515
///
@@ -21,7 +21,8 @@ public protocol SQLSubqueryClauseBuilder:
2121
SQLPredicateBuilder,
2222
SQLSecondaryPredicateBuilder,
2323
SQLPartialResultBuilder,
24-
SQLAliasedColumnListBuilder
24+
SQLAliasedColumnListBuilder,
25+
SQLCommonTableExpressionBuilder
2526
{
2627
/// The ``SQLSelect`` query under construction.
2728
var select: SQLSelect { get set }
@@ -69,6 +70,13 @@ extension SQLSubqueryClauseBuilder {
6970
get { self.select.columns }
7071
set { self.select.columns = newValue }
7172
}
73+
74+
// See `SQLCommonTableExpressionBuilder.tableExpressionGroup`.
75+
@inlinable
76+
public var tableExpressionGroup: SQLCommonTableExpressionGroup? {
77+
get { self.select.tableExpressionGroup }
78+
set { self.select.tableExpressionGroup = newValue }
79+
}
7280
}
7381

7482
// MARK: - Distinct
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/// A clause describing a single Common Table Expressions, which in itws simplest form provides
2+
/// additional data to a primary query in the same way as joining to a subquery.
3+
///
4+
/// > Note: There is no ``SQLDialect`` flag for CTE support, as CTEs are supported by all of the
5+
/// > databases for which first-party drivers exist at the time of this writing (although they are
6+
/// > not available in MySQL 5.7, which is long since EOL and should not be in use by anyone anymore).
7+
public struct SQLCommonTableExpression: SQLExpression {
8+
/// Indicates whether the CTE is recursive, e.g. whether its query is a `UNION` whose second subquery
9+
/// refers to the CTE's own aliased name.
10+
///
11+
/// > Warning: Neither ``SQLCommonTableExpression`` nor the methods of ``SQLCommonTableExpressionBuilder``
12+
/// > validate that a recursive CTE's query takes the proper form, nor that a non-recursive CTE's query
13+
/// > is not self-referential. It is the responsibility of the user to specify the flag accurately. Failure
14+
/// > to do so will result in generating invalid SQL.
15+
public var isRecursive: Bool = false
16+
17+
/// The name used to refer to the CTE's data.
18+
public var alias: any SQLExpression
19+
20+
/// A list of column names yielded by the CTE. May be empty.
21+
public var columns: [any SQLExpression] = []
22+
23+
/// The subquery which yields the CTE's data.
24+
public var query: any SQLExpression
25+
26+
/// Create a new Common Table Expression.
27+
///
28+
/// - Parameters:
29+
/// - alias: Specifies the name to be used to refer to the CTE.
30+
/// - query: The subquery which yields the CTE's data.
31+
public init(alias: some SQLExpression, query: some SQLExpression) {
32+
self.alias = alias
33+
self.query = query
34+
}
35+
36+
// See `SQLExpression.serialize(to:)`.
37+
public func serialize(to serializer: inout SQLSerializer) {
38+
serializer.statement {
39+
/// The ``SQLCommonTableExpression/isRecursive`` flag is not used in this logic. This is not an
40+
/// oversight. CTE syntax requires that `RECURSIVE` be specified as part of the overall `WITH`
41+
/// clause, rather on a per-CTE basis. As such, the recursive flag is handled by the serialization
42+
/// logic of ``SQLCommonTableExpressionGroup``.
43+
$0.append(self.alias)
44+
if !self.columns.isEmpty {
45+
$0.append(SQLGroupExpression(self.columns))
46+
}
47+
if let subqueryExpr = self.query as? SQLSubquery {
48+
$0.append("AS", subqueryExpr)
49+
} else if let subqueryExpr = self.query as? SQLUnionSubquery {
50+
$0.append("AS", subqueryExpr)
51+
} else if let groupExpr = self.query as? SQLGroupExpression {
52+
$0.append("AS", groupExpr)
53+
} else {
54+
$0.append("AS", SQLGroupExpression(self.query))
55+
}
56+
}
57+
}
58+
}
59+
60+
/// A clause representing a group of one or more ``SQLCommonTableExpression``s.
61+
///
62+
/// This expression makes up a complete `WITH` clause in the generated SQL, serving to centralize the
63+
/// serialization logic for such a clause in a single location rather than requiring it to be repeated
64+
/// by every query type that supports CTEs.
65+
public struct SQLCommonTableExpressionGroup: SQLExpression {
66+
/// The list of common table expressions which make up the group.
67+
///
68+
/// Must contain at least one expression. If the list is empty, invalid SQL will be generated.
69+
public var tableExpressions: [any SQLExpression]
70+
71+
public init(tableExpressions: [any SQLExpression]) {
72+
self.tableExpressions = tableExpressions
73+
}
74+
75+
// See `SQLExpression.serialize(to:)`.
76+
public func serialize(to serializer: inout SQLSerializer) {
77+
serializer.statement {
78+
$0.append("WITH")
79+
if self.tableExpressions.contains(where: { ($0 as? SQLCommonTableExpression)?.isRecursive ?? false }) {
80+
$0.append("RECURSIVE")
81+
}
82+
$0.append(SQLList(self.tableExpressions))
83+
}
84+
}
85+
}

Sources/SQLKit/Expressions/Queries/SQLDelete.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
///
99
/// See ``SQLDeleteBuilder``.
1010
public struct SQLDelete: SQLExpression {
11+
/// An optional common table expression group.
12+
public var tableExpressionGroup: SQLCommonTableExpressionGroup?
13+
1114
/// The table containing rows to delete.
1215
public var table: any SQLExpression
1316

@@ -34,6 +37,8 @@ public struct SQLDelete: SQLExpression {
3437
// See `SQLExpression.serialize(to:)`.
3538
public func serialize(to serializer: inout SQLSerializer) {
3639
serializer.statement {
40+
$0.append(self.tableExpressionGroup)
41+
3742
$0.append("DELETE FROM", self.table)
3843
if let predicate = self.predicate {
3944
$0.append("WHERE", predicate)

Sources/SQLKit/Expressions/Queries/SQLInsert.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@
2222
///
2323
/// See ``SQLInsertBuilder``.
2424
public struct SQLInsert: SQLExpression {
25+
/// An optional common table expression group.
26+
public var tableExpressionGroup: SQLCommonTableExpressionGroup?
27+
2528
/// The table to which rows are to be added.
2629
public var table: any SQLExpression
2730

@@ -70,6 +73,7 @@ public struct SQLInsert: SQLExpression {
7073
// See `SQLExpression.serialize(to:)`.
7174
public func serialize(to serializer: inout SQLSerializer) {
7275
serializer.statement {
76+
$0.append(self.tableExpressionGroup)
7377
$0.append("INSERT")
7478
$0.append(self.conflictStrategy?.queryModifier(for: $0))
7579
$0.append("INTO", self.table)

Sources/SQLKit/Expressions/Queries/SQLSelect.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@
2929
///
3030
/// See ``SQLSelectBuilder``.
3131
public struct SQLSelect: SQLExpression {
32+
/// An optional common table expression group.
33+
public var tableExpressionGroup: SQLCommonTableExpressionGroup?
34+
3235
/// One or more expessions describing the data to retrieve from the database.
3336
public var columns: [any SQLExpression] = []
3437

@@ -97,6 +100,7 @@ public struct SQLSelect: SQLExpression {
97100
// See `SQLExpression.serialize(to:)`.
98101
public func serialize(to serializer: inout SQLSerializer) {
99102
serializer.statement {
103+
$0.append(self.tableExpressionGroup)
100104
$0.append("SELECT")
101105
if self.isDistinct {
102106
$0.append("DISTINCT")

0 commit comments

Comments
 (0)