Skip to content

Commit

Permalink
Add support for Common Table Expressions (#179)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
gwynne authored Jun 8, 2024
1 parent 25d8170 commit 14f4350
Show file tree
Hide file tree
Showing 15 changed files with 778 additions and 9 deletions.
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/// Builds ``SQLDelete`` queries.
public final class SQLDeleteBuilder: SQLQueryBuilder, SQLPredicateBuilder, SQLReturningBuilder {
public final class SQLDeleteBuilder: SQLQueryBuilder, SQLPredicateBuilder, SQLReturningBuilder, SQLCommonTableExpressionBuilder {
/// ``SQLDelete`` query being built.
public var delete: SQLDelete

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

// See `SQLCommonTableExpressionBuilder.tableExpressionGroup`.
@inlinable
public var tableExpressionGroup: SQLCommonTableExpressionGroup? {
get { self.delete.tableExpressionGroup }
set { self.delete.tableExpressionGroup = newValue }
}

/// Create a new ``SQLDeleteBuilder``.
@inlinable
public init(_ delete: SQLDelete, on database: any SQLDatabase) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
/// > ``SQLInsertBuilder``'s otherwise-identical public APIs overwrite the effects of any previous invocation. It
/// > would ideally be preferable to change ``SQLInsertBuilder``'s semantics in this regard, but this would be a
/// > significant breaking change in the API's behavior, and must therefore wait for a major version bump.
public final class SQLInsertBuilder: SQLQueryBuilder, SQLReturningBuilder/*, SQLUnqualifiedColumnListBuilder*/ {
public final class SQLInsertBuilder: SQLQueryBuilder, SQLReturningBuilder/*, SQLUnqualifiedColumnListBuilder*/, SQLCommonTableExpressionBuilder {
/// The ``SQLInsert`` query this builder builds.
public var insert: SQLInsert

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

// See `SQLCommonTableExpressionBuilder.tableExpressionGroup`.
@inlinable
public var tableExpressionGroup: SQLCommonTableExpressionGroup? {
get { self.insert.tableExpressionGroup }
set { self.insert.tableExpressionGroup = newValue }
}

/// Creates a new `SQLInsertBuilder`.
@inlinable
public init(_ insert: SQLInsert, on database: any SQLDatabase) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/// Builds top-level ``SQLUnion`` queries which may be executed on their own.
public final class SQLUnionBuilder: SQLQueryBuilder, SQLQueryFetcher, SQLCommonUnionBuilder {
public final class SQLUnionBuilder: SQLQueryBuilder, SQLQueryFetcher, SQLCommonUnionBuilder, SQLCommonTableExpressionBuilder {
// See `SQLCommonUnionBuilder.union`.
public var union: SQLUnion

Expand All @@ -12,6 +12,13 @@ public final class SQLUnionBuilder: SQLQueryBuilder, SQLQueryFetcher, SQLCommonU
self.union
}

// See `SQLCommonTableExpressionBuilder.tableExpressionGroup`.
@inlinable
public var tableExpressionGroup: SQLCommonTableExpressionGroup? {
get { self.union.tableExpressionGroup }
set { self.union.tableExpressionGroup = newValue }
}

/// Create a new ``SQLUnionBuilder``.
@inlinable
public init(on database: any SQLDatabase, initialQuery: SQLSelect) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/// Builds ``SQLUpdate`` queries.
public final class SQLUpdateBuilder: SQLQueryBuilder, SQLPredicateBuilder, SQLReturningBuilder, SQLColumnUpdateBuilder {
public final class SQLUpdateBuilder: SQLQueryBuilder, SQLPredicateBuilder, SQLReturningBuilder, SQLColumnUpdateBuilder, SQLCommonTableExpressionBuilder {
/// An ``SQLUpdate`` containing the complete current state of the builder.
public var update: SQLUpdate

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

// See `SQLCommonTableExpressionBuilder.tableExpressionGroup`.
@inlinable
public var tableExpressionGroup: SQLCommonTableExpressionGroup? {
get { self.update.tableExpressionGroup }
set { self.update.tableExpressionGroup = newValue }
}

/// Create a new ``SQLUpdateBuilder``.
///
/// Use this API directly only if you need to have control over the builder's initial update query. Prefer using
Expand Down

Large diffs are not rendered by default.

12 changes: 10 additions & 2 deletions Sources/SQLKit/Builders/Prototypes/SQLSubqueryClauseBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
/// expressions in other queries.
///
/// > Important: Despite the use of the term "subquery", this builder does not provide
/// > methods for specifying subquery operators (e.g. `ANY`, `SOME`) or CTE clauses (`WITH`),
/// > methods for specifying subquery operators (e.g. `ANY`, `SOME`),
/// > nor does it enclose its result in grouping parenthesis, as all of these formations are
/// > context-specific and are the purview of builders that conform to this protocol.
///
Expand All @@ -21,7 +21,8 @@ public protocol SQLSubqueryClauseBuilder:
SQLPredicateBuilder,
SQLSecondaryPredicateBuilder,
SQLPartialResultBuilder,
SQLAliasedColumnListBuilder
SQLAliasedColumnListBuilder,
SQLCommonTableExpressionBuilder
{
/// The ``SQLSelect`` query under construction.
var select: SQLSelect { get set }
Expand Down Expand Up @@ -69,6 +70,13 @@ extension SQLSubqueryClauseBuilder {
get { self.select.columns }
set { self.select.columns = newValue }
}

// See `SQLCommonTableExpressionBuilder.tableExpressionGroup`.
@inlinable
public var tableExpressionGroup: SQLCommonTableExpressionGroup? {
get { self.select.tableExpressionGroup }
set { self.select.tableExpressionGroup = newValue }
}
}

// MARK: - Distinct
Expand Down
85 changes: 85 additions & 0 deletions Sources/SQLKit/Expressions/Clauses/SQLCommonTableExpression.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/// A clause describing a single Common Table Expressions, which in itws simplest form provides
/// additional data to a primary query in the same way as joining to a subquery.
///
/// > Note: There is no ``SQLDialect`` flag for CTE support, as CTEs are supported by all of the
/// > databases for which first-party drivers exist at the time of this writing (although they are
/// > not available in MySQL 5.7, which is long since EOL and should not be in use by anyone anymore).
public struct SQLCommonTableExpression: SQLExpression {
/// Indicates whether the CTE is recursive, e.g. whether its query is a `UNION` whose second subquery
/// refers to the CTE's own aliased name.
///
/// > Warning: Neither ``SQLCommonTableExpression`` nor the methods of ``SQLCommonTableExpressionBuilder``
/// > validate that a recursive CTE's query takes the proper form, nor that a non-recursive CTE's query
/// > is not self-referential. It is the responsibility of the user to specify the flag accurately. Failure
/// > to do so will result in generating invalid SQL.
public var isRecursive: Bool = false

/// The name used to refer to the CTE's data.
public var alias: any SQLExpression

/// A list of column names yielded by the CTE. May be empty.
public var columns: [any SQLExpression] = []

/// The subquery which yields the CTE's data.
public var query: any SQLExpression

/// Create a new Common Table Expression.
///
/// - Parameters:
/// - alias: Specifies the name to be used to refer to the CTE.
/// - query: The subquery which yields the CTE's data.
public init(alias: some SQLExpression, query: some SQLExpression) {
self.alias = alias
self.query = query
}

// See `SQLExpression.serialize(to:)`.
public func serialize(to serializer: inout SQLSerializer) {
serializer.statement {
/// The ``SQLCommonTableExpression/isRecursive`` flag is not used in this logic. This is not an
/// oversight. CTE syntax requires that `RECURSIVE` be specified as part of the overall `WITH`
/// clause, rather on a per-CTE basis. As such, the recursive flag is handled by the serialization
/// logic of ``SQLCommonTableExpressionGroup``.
$0.append(self.alias)
if !self.columns.isEmpty {
$0.append(SQLGroupExpression(self.columns))
}
if let subqueryExpr = self.query as? SQLSubquery {
$0.append("AS", subqueryExpr)
} else if let subqueryExpr = self.query as? SQLUnionSubquery {
$0.append("AS", subqueryExpr)
} else if let groupExpr = self.query as? SQLGroupExpression {
$0.append("AS", groupExpr)
} else {
$0.append("AS", SQLGroupExpression(self.query))
}
}
}
}

/// A clause representing a group of one or more ``SQLCommonTableExpression``s.
///
/// This expression makes up a complete `WITH` clause in the generated SQL, serving to centralize the
/// serialization logic for such a clause in a single location rather than requiring it to be repeated
/// by every query type that supports CTEs.
public struct SQLCommonTableExpressionGroup: SQLExpression {
/// The list of common table expressions which make up the group.
///
/// Must contain at least one expression. If the list is empty, invalid SQL will be generated.
public var tableExpressions: [any SQLExpression]

public init(tableExpressions: [any SQLExpression]) {
self.tableExpressions = tableExpressions
}

// See `SQLExpression.serialize(to:)`.
public func serialize(to serializer: inout SQLSerializer) {
serializer.statement {
$0.append("WITH")
if self.tableExpressions.contains(where: { ($0 as? SQLCommonTableExpression)?.isRecursive ?? false }) {
$0.append("RECURSIVE")
}
$0.append(SQLList(self.tableExpressions))
}
}
}
5 changes: 5 additions & 0 deletions Sources/SQLKit/Expressions/Queries/SQLDelete.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
///
/// See ``SQLDeleteBuilder``.
public struct SQLDelete: SQLExpression {
/// An optional common table expression group.
public var tableExpressionGroup: SQLCommonTableExpressionGroup?

/// The table containing rows to delete.
public var table: any SQLExpression

Expand All @@ -34,6 +37,8 @@ public struct SQLDelete: SQLExpression {
// See `SQLExpression.serialize(to:)`.
public func serialize(to serializer: inout SQLSerializer) {
serializer.statement {
$0.append(self.tableExpressionGroup)

$0.append("DELETE FROM", self.table)
if let predicate = self.predicate {
$0.append("WHERE", predicate)
Expand Down
4 changes: 4 additions & 0 deletions Sources/SQLKit/Expressions/Queries/SQLInsert.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@
///
/// See ``SQLInsertBuilder``.
public struct SQLInsert: SQLExpression {
/// An optional common table expression group.
public var tableExpressionGroup: SQLCommonTableExpressionGroup?

/// The table to which rows are to be added.
public var table: any SQLExpression

Expand Down Expand Up @@ -70,6 +73,7 @@ public struct SQLInsert: SQLExpression {
// See `SQLExpression.serialize(to:)`.
public func serialize(to serializer: inout SQLSerializer) {
serializer.statement {
$0.append(self.tableExpressionGroup)
$0.append("INSERT")
$0.append(self.conflictStrategy?.queryModifier(for: $0))
$0.append("INTO", self.table)
Expand Down
4 changes: 4 additions & 0 deletions Sources/SQLKit/Expressions/Queries/SQLSelect.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@
///
/// See ``SQLSelectBuilder``.
public struct SQLSelect: SQLExpression {
/// An optional common table expression group.
public var tableExpressionGroup: SQLCommonTableExpressionGroup?

/// One or more expessions describing the data to retrieve from the database.
public var columns: [any SQLExpression] = []

Expand Down Expand Up @@ -97,6 +100,7 @@ public struct SQLSelect: SQLExpression {
// See `SQLExpression.serialize(to:)`.
public func serialize(to serializer: inout SQLSerializer) {
serializer.statement {
$0.append(self.tableExpressionGroup)
$0.append("SELECT")
if self.isDistinct {
$0.append("DISTINCT")
Expand Down
5 changes: 5 additions & 0 deletions Sources/SQLKit/Expressions/Queries/SQLUnion.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
///
/// See ``SQLUnionBuilder``.
public struct SQLUnion: SQLExpression {
/// An optional common table expression group.
public var tableExpressionGroup: SQLCommonTableExpressionGroup?

/// The required first query of the union.
public var initialQuery: SQLSelect

Expand Down Expand Up @@ -80,6 +83,8 @@ public struct SQLUnion: SQLExpression {
// See `SQLExpression.serialize(to:)`.
public func serialize(to serializer: inout SQLSerializer) {
serializer.statement { stmt in
stmt.append(self.tableExpressionGroup)

guard !self.unions.isEmpty else {
/// If no unions are specified, serialize as a plain query even if the dialect would otherwise
/// specify the use of parenthesized subqueries. Ignores orderBys, limit, and offset.
Expand Down
4 changes: 4 additions & 0 deletions Sources/SQLKit/Expressions/Queries/SQLUpdate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
///
/// See ``SQLUpdateBuilder``.
public struct SQLUpdate: SQLExpression {
/// An optional common table expression group.
public var tableExpressionGroup: SQLCommonTableExpressionGroup?

/// The table containing the row(s) to be updated.
public var table: any SQLExpression

Expand Down Expand Up @@ -43,6 +46,7 @@ public struct SQLUpdate: SQLExpression {
// See `SQLExpression.serialize(to:)`.
public func serialize(to serializer: inout SQLSerializer) {
serializer.statement {
$0.append(self.tableExpressionGroup)
$0.append("UPDATE", self.table)
$0.append("SET", SQLList(self.values))
if let predicate = self.predicate {
Expand Down
4 changes: 2 additions & 2 deletions Tests/SQLKitTests/SQLCodingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,7 @@ func superCase(_ path: [any CodingKey]) -> any CodingKey {
SomeCodingKey(stringValue: path.last!.stringValue.encapitalized)
}

extension SQLLiteral: Equatable {
extension SQLKit.SQLLiteral: Swift.Equatable {
public static func == (lhs: Self, rhs: Self) -> Bool {
switch (lhs, rhs) {
case (.all, .all), (.`default`, .`default`), (.null, .null): return true
Expand All @@ -277,7 +277,7 @@ extension SQLLiteral: Equatable {
}
}

extension SQLBind: Equatable {
extension SQLKit.SQLBind: Swift.Equatable {
// Don't do this. This is horrible.
public static func == (lhs: Self, rhs: Self) -> Bool { (try? JSONEncoder().encode(lhs.encodable) == JSONEncoder().encode(rhs.encodable)) ?? false }
}
Loading

0 comments on commit 14f4350

Please sign in to comment.