diff --git a/Package.resolved b/Package.resolved index f59c9a7b..b4d6bb36 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "a9cc8edf77ac5412b54a8aed27b41c2fc10588741da62b1886a48257aa83a50f", + "originHash" : "f41a80cbf357606a2e81ebd0e998e4ef355096560757e1e4921daabddb5e2c56", "pins" : [ { "identity" : "combine-schedulers", @@ -78,8 +78,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-snapshot-testing", "state" : { - "revision" : "37230a37e83f1b7023be08e1b1a2603fcb1567fb", - "version" : "1.18.4" + "revision" : "b198a568ad24c5a22995c5ff0ecf9667634e860e", + "version" : "1.18.5" } }, { diff --git a/Sources/StructuredQueriesCore/Documentation.docc/Extensions/Select.md b/Sources/StructuredQueriesCore/Documentation.docc/Extensions/Select.md index 47be63f9..465336fc 100644 --- a/Sources/StructuredQueriesCore/Documentation.docc/Extensions/Select.md +++ b/Sources/StructuredQueriesCore/Documentation.docc/Extensions/Select.md @@ -22,6 +22,9 @@ ### Transforming queries +- ``unscoped`` +- ``all`` +- ``none`` - ``map(_:)`` - ``subscript(dynamicMember:)`` - ``StructuredQueriesCore/+(_:_:)`` diff --git a/Sources/StructuredQueriesCore/Internal/Scope.swift b/Sources/StructuredQueriesCore/Internal/Scope.swift new file mode 100644 index 00000000..c4465430 --- /dev/null +++ b/Sources/StructuredQueriesCore/Internal/Scope.swift @@ -0,0 +1,5 @@ +enum Scope { + case unscoped + case `default` + case empty +} diff --git a/Sources/StructuredQueriesCore/Statements/CommonTableExpression.swift b/Sources/StructuredQueriesCore/Statements/CommonTableExpression.swift index 6288e503..77f768a3 100644 --- a/Sources/StructuredQueriesCore/Statements/CommonTableExpression.swift +++ b/Sources/StructuredQueriesCore/Statements/CommonTableExpression.swift @@ -29,20 +29,28 @@ public struct With: Statement { } public var query: QueryFragment { + guard !statement.isEmpty else { return "" } + let cteFragments = ctes.compactMap(\.queryFragment.presence) + guard !cteFragments.isEmpty else { return "" } var query: QueryFragment = "WITH " query.append( - "\(ctes.map(\.queryFragment).joined(separator: ", "))\(.newlineOrSpace)\(statement)" + "\(cteFragments.joined(separator: ", "))\(.newlineOrSpace)\(statement)" ) return query } } +extension QueryFragment { + fileprivate var presence: Self? { isEmpty ? nil : self } +} + public struct CommonTableExpressionClause: QueryExpression { public typealias QueryValue = () let tableName: QueryFragment let select: QueryFragment public var queryFragment: QueryFragment { - "\(tableName) AS (\(.newline)\(select.indented())\(.newline))" + guard !select.isEmpty else { return "" } + return "\(tableName) AS (\(.newline)\(select.indented())\(.newline))" } } diff --git a/Sources/StructuredQueriesCore/Statements/CompoundSelect.swift b/Sources/StructuredQueriesCore/Statements/CompoundSelect.swift index 10abdb65..4f9f8b2b 100644 --- a/Sources/StructuredQueriesCore/Statements/CompoundSelect.swift +++ b/Sources/StructuredQueriesCore/Statements/CompoundSelect.swift @@ -66,6 +66,8 @@ private struct CompoundSelect: PartialSelectStatement { } var query: QueryFragment { - "\(lhs)\(.newlineOrSpace)\(`operator`.indented())\(.newlineOrSpace)\(rhs)" + guard !lhs.isEmpty else { return rhs } + guard !rhs.isEmpty else { return lhs } + return "\(lhs)\(.newlineOrSpace)\(`operator`.indented())\(.newlineOrSpace)\(rhs)" } } diff --git a/Sources/StructuredQueriesCore/Statements/Delete.swift b/Sources/StructuredQueriesCore/Statements/Delete.swift index fd4d602d..b3351676 100644 --- a/Sources/StructuredQueriesCore/Statements/Delete.swift +++ b/Sources/StructuredQueriesCore/Statements/Delete.swift @@ -23,7 +23,7 @@ extension PrimaryKeyedTable { /// - Parameter row: A row to delete. /// - Returns: A delete statement. public static func delete(_ row: Self) -> DeleteOf { - Delete() + delete() .where { $0.primaryKey.eq(TableColumns.PrimaryKey(queryOutput: row[keyPath: $0.primaryKey.keyPath])) } @@ -36,6 +36,7 @@ extension PrimaryKeyedTable { /// /// To learn more, see . public struct Delete { + var isEmpty: Bool var `where`: [QueryFragment] = [] var returning: [QueryFragment] = [] @@ -107,6 +108,7 @@ public struct Delete { returning.append("\(quote: resultColumn.name)") } return Delete( + isEmpty: isEmpty, where: `where`, returning: Array(repeat each selection(From.columns)) ) @@ -126,6 +128,7 @@ public struct Delete { returning.append("\(quote: resultColumn.name)") } return Delete( + isEmpty: isEmpty, where: `where`, returning: returning ) @@ -139,6 +142,7 @@ extension Delete: Statement { public typealias QueryValue = Returning public var query: QueryFragment { + guard !isEmpty else { return "" } var query: QueryFragment = "DELETE FROM " if let schemaName = From.schemaName { query.append("\(quote: schemaName).") diff --git a/Sources/StructuredQueriesCore/Statements/Select.swift b/Sources/StructuredQueriesCore/Statements/Select.swift index 2a356537..1fa301a4 100644 --- a/Sources/StructuredQueriesCore/Statements/Select.swift +++ b/Sources/StructuredQueriesCore/Statements/Select.swift @@ -297,6 +297,7 @@ extension Table { } public struct _SelectClauses: Sendable { + var isEmpty = false var distinct = false var columns: [any QueryExpression] = [] var joins: [_JoinClause] = [] @@ -320,6 +321,11 @@ public struct Select { // NB: A parameter pack compiler crash forces us to heap-allocate this storage. @CopyOnWrite var clauses = _SelectClauses() + fileprivate var isEmpty: Bool { + get { clauses.isEmpty } + set { clauses.isEmpty = newValue } + _modify { yield &clauses.isEmpty } + } fileprivate var distinct: Bool { get { clauses.distinct } set { clauses.distinct = newValue } @@ -362,6 +368,7 @@ public struct Select { } fileprivate init( + isEmpty: Bool, distinct: Bool, columns: [any QueryExpression], joins: [_JoinClause], @@ -371,6 +378,7 @@ public struct Select { order: [QueryFragment], limit: _LimitClause? ) { + self.isEmpty = isEmpty self.columns = columns self.distinct = distinct self.joins = joins @@ -387,7 +395,8 @@ public struct Select { } extension Select { - init(where: [QueryFragment] = []) { + init(isEmpty: Bool = false, where: [QueryFragment] = []) { + self.isEmpty = isEmpty self.where = `where` } @@ -558,6 +567,7 @@ extension Select { Joins == (repeat each J) { Select<(repeat each C1, repeat (each C2).QueryValue), From, (repeat each J)>( + isEmpty: isEmpty, distinct: distinct, columns: columns + Array(repeat each selection((From.columns, repeat (each J).columns))), joins: joins, @@ -612,6 +622,7 @@ extension Select { ) ) return Select<(repeat each C1, repeat each C2), From, (repeat each J1, F, repeat each J2)>( + isEmpty: isEmpty || other.isEmpty, distinct: distinct || other.distinct, columns: columns + other.columns, joins: joins + [join] + other.joins, @@ -651,6 +662,7 @@ extension Select { ) ) return Select<(repeat each C1, repeat each C2), From, (repeat each J, F)>( + isEmpty: isEmpty || other.isEmpty, distinct: distinct || other.distinct, columns: columns + other.columns, joins: joins + [join] + other.joins, @@ -686,6 +698,7 @@ extension Select { ) ) return Select( + isEmpty: isEmpty || other.isEmpty, distinct: distinct || other.distinct, columns: columns + other.columns, joins: joins + [join] + other.joins, @@ -738,6 +751,7 @@ extension Select { From, (repeat each J1, F._Optionalized, repeat (each J2)._Optionalized) >( + isEmpty: isEmpty || other.isEmpty, distinct: distinct || other.distinct, columns: columns + other.columns, joins: joins + [join] + other.joins, @@ -785,6 +799,7 @@ extension Select { From, (repeat each J, F._Optionalized) >( + isEmpty: isEmpty || other.isEmpty, distinct: distinct || other.distinct, columns: columns + other.columns, joins: joins + [join] + other.joins, @@ -822,6 +837,7 @@ extension Select { ) ) return Select( + isEmpty: isEmpty || other.isEmpty, distinct: distinct || other.distinct, columns: columns + other.columns, joins: joins + [join] + other.joins, @@ -874,6 +890,7 @@ extension Select { From._Optionalized, (repeat (each J1)._Optionalized, F, repeat each J2) >( + isEmpty: isEmpty || other.isEmpty, distinct: distinct || other.distinct, columns: columns + other.columns, joins: joins + [join] + other.joins, @@ -921,6 +938,7 @@ extension Select { From._Optionalized, (repeat (each J)._Optionalized, F) >( + isEmpty: isEmpty || other.isEmpty, distinct: distinct || other.distinct, columns: columns + other.columns, joins: joins + [join] + other.joins, @@ -958,6 +976,7 @@ extension Select { ) ) return Select( + isEmpty: isEmpty || other.isEmpty, distinct: distinct || other.distinct, columns: columns + other.columns, joins: joins + [join] + other.joins, @@ -1010,6 +1029,7 @@ extension Select { From._Optionalized, (repeat (each J1)._Optionalized, F._Optionalized, repeat (each J2)._Optionalized) >( + isEmpty: isEmpty || other.isEmpty, distinct: distinct || other.distinct, columns: columns + other.columns, joins: joins + [join] + other.joins, @@ -1057,6 +1077,7 @@ extension Select { From._Optionalized, (repeat (each J)._Optionalized, F._Optionalized) >( + isEmpty: isEmpty || other.isEmpty, distinct: distinct || other.distinct, columns: columns + other.columns, joins: joins + [join] + other.joins, @@ -1094,6 +1115,7 @@ extension Select { ) ) return Select( + isEmpty: isEmpty || other.isEmpty, distinct: distinct || other.distinct, columns: columns + other.columns, joins: joins + [join] + other.joins, @@ -1348,6 +1370,7 @@ extension Select { SQLQueryExpression(iterator.next()!.queryFragment) } return Select<(repeat (each C2).QueryValue), From, Joins>( + isEmpty: isEmpty, distinct: distinct, columns: Array(repeat each transform(repeat { _ in next() }((each C1).self))), joins: joins, @@ -1358,6 +1381,23 @@ extension Select { limit: limit ) } + + /// Returns a fully unscoped version of this select statement. + public var unscoped: Where { + From.unscoped + } + + /// Returns this select statement unchanged. + public var all: Self { + self + } + + /// Returns an empty select statement. + public var none: Self { + var select = self + select.isEmpty = true + return select + } } /// Combines two select statements of the same table type together. @@ -1387,6 +1427,7 @@ public func + < return Select< (repeat each C1, repeat each C2), From, (repeat each J1, repeat each J2) >( + isEmpty: lhs.isEmpty || rhs.isEmpty, distinct: lhs.distinct || rhs.distinct, columns: lhs.columns + rhs.columns, joins: lhs.joins + rhs.joins, @@ -1406,6 +1447,7 @@ extension Select: SelectStatement { } public var query: QueryFragment { + guard !isEmpty else { return "" } var query: QueryFragment = "SELECT" let columns = columns.isEmpty diff --git a/Sources/StructuredQueriesCore/Statements/Update.swift b/Sources/StructuredQueriesCore/Statements/Update.swift index 65fca9a5..e6a1eed7 100644 --- a/Sources/StructuredQueriesCore/Statements/Update.swift +++ b/Sources/StructuredQueriesCore/Statements/Update.swift @@ -91,6 +91,7 @@ extension PrimaryKeyedTable { /// /// To learn more, see . public struct Update { + var isEmpty: Bool var conflictResolution: ConflictResolution? var updates: Updates var `where`: [QueryFragment] = [] @@ -159,6 +160,7 @@ public struct Update { returning.append("\(quote: resultColumn.name)") } return Update( + isEmpty: false, conflictResolution: conflictResolution, updates: updates, where: `where`, @@ -180,6 +182,7 @@ public struct Update { returning.append("\(quote: resultColumn.name)") } return Update( + isEmpty: isEmpty, conflictResolution: conflictResolution, updates: updates, where: `where`, @@ -195,7 +198,7 @@ extension Update: Statement { public typealias QueryValue = Returning public var query: QueryFragment { - guard !updates.isEmpty + guard !isEmpty, !updates.isEmpty else { return "" } var query: QueryFragment = "UPDATE " diff --git a/Sources/StructuredQueriesCore/Statements/Where.swift b/Sources/StructuredQueriesCore/Statements/Where.swift index 9755e570..8b377108 100644 --- a/Sources/StructuredQueriesCore/Statements/Where.swift +++ b/Sources/StructuredQueriesCore/Statements/Where.swift @@ -64,7 +64,7 @@ public struct Where { } var predicates: [QueryFragment] = [] - var unscoped = false + var scope = Scope.default #if compiler(>=6.1) public static subscript(dynamicMember keyPath: KeyPath) -> Self { @@ -94,12 +94,20 @@ extension Where: SelectStatement { public typealias QueryValue = () public func asSelect() -> SelectOf { - (unscoped ? Select() : Select(clauses: From.all._selectClauses)) - .and(self) + let select: SelectOf + switch scope { + case .default: + select = Select(clauses: From.all._selectClauses) + case .empty: + select = Select(isEmpty: true, where: predicates) + case .unscoped: + select = Select() + } + return select.and(self) } public var _selectClauses: _SelectClauses { - _SelectClauses(where: predicates) + _SelectClauses(isEmpty: scope == .empty, where: predicates) } /// A select statement for a column of the filtered table. @@ -476,7 +484,10 @@ extension Where: SelectStatement { /// A delete statement for the filtered table. public func delete() -> DeleteOf { - Delete(where: unscoped ? predicates : From.all._selectClauses.where + predicates) + Delete( + isEmpty: scope == .empty, + where: scope == .unscoped ? predicates : From.all._selectClauses.where + predicates + ) } /// An update statement for the filtered table. @@ -490,9 +501,10 @@ extension Where: SelectStatement { set updates: (inout Updates) -> Void ) -> UpdateOf { Update( + isEmpty: scope == .empty, conflictResolution: conflictResolution, updates: Updates(updates), - where: unscoped ? predicates : From.all._selectClauses.where + predicates + where: scope == .unscoped ? predicates : From.all._selectClauses.where + predicates ) } diff --git a/Sources/StructuredQueriesCore/Table.swift b/Sources/StructuredQueriesCore/Table.swift index 5cb66ba4..614f1ba0 100644 --- a/Sources/StructuredQueriesCore/Table.swift +++ b/Sources/StructuredQueriesCore/Table.swift @@ -49,12 +49,6 @@ public protocol Table: QueryRepresentable where TableColumns.QueryValue == Self static var all: DefaultScope { get } } -extension Table where DefaultScope == Where { - public static var all: DefaultScope { - Where() - } -} - extension Table { /// A select statement on the table with no constraints. /// @@ -86,7 +80,11 @@ extension Table { /// // SELECT "reminders"."id" FROM "reminders" /// ``` public static var unscoped: Where { - Where(unscoped: true) + Where(scope: .unscoped) + } + + public static var none: Where { + Where(scope: .empty) } public static var tableAlias: String? { @@ -112,3 +110,9 @@ extension Table { columns[keyPath: keyPath] } } + +extension Table where DefaultScope == Where { + public static var all: DefaultScope { + Where() + } +} diff --git a/Sources/StructuredQueriesMacros/TableMacro.swift b/Sources/StructuredQueriesMacros/TableMacro.swift index f0ac0afc..91f72211 100644 --- a/Sources/StructuredQueriesMacros/TableMacro.swift +++ b/Sources/StructuredQueriesMacros/TableMacro.swift @@ -567,7 +567,7 @@ extension TableMacro: ExtensionMacro { return [] } - var typeAliases: [DeclSyntax] = [] + var statics: [DeclSyntax] = [] var letSchemaName: DeclSyntax? if let schemaName { letSchemaName = """ @@ -577,7 +577,7 @@ extension TableMacro: ExtensionMacro { var initDecoder: DeclSyntax? if declaration.hasMacroApplication("Selection") { conformances.append("\(moduleName).PartialSelectStatement") - typeAliases.append(contentsOf: [ + statics.append(contentsOf: [ """ public typealias QueryValue = Self @@ -600,7 +600,7 @@ extension TableMacro: ExtensionMacro { """ \(declaration.attributes.availability)extension \(type)\ \(conformances.isEmpty ? "" : ": \(conformances, separator: ", ")") {\ - \(typeAliases, separator: "\n") + \(statics, separator: "\n") public static let columns = TableColumns() public static let tableName = \(tableName)\(letSchemaName)\(initDecoder)\(initFromOther) } diff --git a/Tests/StructuredQueriesTests/CommonTableExpressionTests.swift b/Tests/StructuredQueriesTests/CommonTableExpressionTests.swift index e5f911bb..4ec67b1a 100644 --- a/Tests/StructuredQueriesTests/CommonTableExpressionTests.swift +++ b/Tests/StructuredQueriesTests/CommonTableExpressionTests.swift @@ -213,6 +213,89 @@ extension SnapshotTests { } } + @Test func empty() { + assertQuery( + With { + Reminder + .where { !$0.isCompleted } + .select { IncompleteReminder.Columns(isFlagged: $0.isFlagged, title: $0.title) } + } query: { + Reminder + .none + .delete() + .returning(\.title) + } + ) { + """ + + """ + } results: { + """ + + """ + } + assertQuery( + With { + Reminder + .none + .where { !$0.isCompleted } + .select { IncompleteReminder.Columns(isFlagged: $0.isFlagged, title: $0.title) } + } query: { + Reminder + .delete() + .returning(\.title) + } + ) { + """ + + """ + } results: { + """ + + """ + } + assertQuery( + With { + Reminder + .none + .where { !$0.isCompleted } + .select { IncompleteReminder.Columns(isFlagged: $0.isFlagged, title: $0.title) } + Reminder + .where { !$0.isCompleted } + .select { IncompleteReminder.Columns(isFlagged: $0.isFlagged, title: $0.title) } + } query: { + Reminder + .delete() + .returning(\.title) + } + ) { + """ + WITH "incompleteReminders" AS ( + SELECT "reminders"."isFlagged" AS "isFlagged", "reminders"."title" AS "title" + FROM "reminders" + WHERE NOT ("reminders"."isCompleted") + ) + DELETE FROM "reminders" + RETURNING "reminders"."title" + """ + } results: { + """ + ┌────────────────────────────┐ + │ "Groceries" │ + │ "Haircut" │ + │ "Doctor appointment" │ + │ "Take a walk" │ + │ "Buy concert tickets" │ + │ "Pick up kids from school" │ + │ "Get laundry" │ + │ "Take out trash" │ + │ "Call accountant" │ + │ "Send weekly emails" │ + └────────────────────────────┘ + """ + } + } + @Test func recursive() { assertQuery( With { diff --git a/Tests/StructuredQueriesTests/DeleteTests.swift b/Tests/StructuredQueriesTests/DeleteTests.swift index 05eddb93..e3f810d2 100644 --- a/Tests/StructuredQueriesTests/DeleteTests.swift +++ b/Tests/StructuredQueriesTests/DeleteTests.swift @@ -159,6 +159,16 @@ extension SnapshotTests { """ } } + + @Test func empty() { + assertQuery( + Reminder.none.delete() + ) { + """ + + """ + } + } } } diff --git a/Tests/StructuredQueriesTests/SelectTests.swift b/Tests/StructuredQueriesTests/SelectTests.swift index e1969db0..0503c124 100644 --- a/Tests/StructuredQueriesTests/SelectTests.swift +++ b/Tests/StructuredQueriesTests/SelectTests.swift @@ -959,6 +959,24 @@ extension SnapshotTests { } } + @Test func none() { + #expect(RemindersList.none.query.isEmpty) + #expect(RemindersList.none.select(\.id).query.isEmpty) + #expect(RemindersList.join(Reminder.none) { _, _ in true }.query.isEmpty) + #expect(RemindersList.none.join(Reminder.all) { _, _ in true }.query.isEmpty) + #expect(RemindersList.leftJoin(Reminder.none) { _, _ in true }.query.isEmpty) + #expect(RemindersList.none.leftJoin(Reminder.all) { _, _ in true }.query.isEmpty) + #expect(RemindersList.rightJoin(Reminder.none) { _, _ in true }.query.isEmpty) + #expect(RemindersList.none.rightJoin(Reminder.all) { _, _ in true }.query.isEmpty) + #expect(RemindersList.fullJoin(Reminder.none) { _, _ in true }.query.isEmpty) + #expect(RemindersList.none.fullJoin(Reminder.all) { _, _ in true }.query.isEmpty) + #expect(Reminder.none.where(\.isCompleted).query.isEmpty) + #expect(Reminder.none.group(by: \.id).query.isEmpty) + #expect(Reminder.none.having(\.isCompleted).query.isEmpty) + #expect(Reminder.none.order(by: \.id).query.isEmpty) + #expect(Reminder.none.limit(1).query.isEmpty) + } + #if compiler(>=6.1) @Test func dynamicMember() { assertQuery( diff --git a/Tests/StructuredQueriesTests/TableTests.swift b/Tests/StructuredQueriesTests/TableTests.swift index 1a016e2d..9fc77808 100644 --- a/Tests/StructuredQueriesTests/TableTests.swift +++ b/Tests/StructuredQueriesTests/TableTests.swift @@ -652,6 +652,10 @@ extension SnapshotTests { SELECT "rs"."id" FROM "rows" AS "rs" WHERE CAST("rs"."id" AS TEXT) = '"rs"' + """ + } results: { + """ + """ } } diff --git a/Tests/StructuredQueriesTests/UnionTests.swift b/Tests/StructuredQueriesTests/UnionTests.swift index 67681ca3..2fb8a1ed 100644 --- a/Tests/StructuredQueriesTests/UnionTests.swift +++ b/Tests/StructuredQueriesTests/UnionTests.swift @@ -46,6 +46,26 @@ extension SnapshotTests { } } + @Test func empty() { + assertQuery( + Reminder.none.select { ("reminder", $0.title) } + .union(RemindersList.select { ("list", $0.title) }) + .union(Tag.none.select { ("tag", $0.title) }) + ) { + """ + SELECT 'list', "remindersLists"."title" + FROM "remindersLists" + """ + } results: { + """ + ┌────────┬────────────┐ + │ "list" │ "Business" │ + │ "list" │ "Family" │ + │ "list" │ "Personal" │ + └────────┴────────────┘ + """ + } + } @Test func commonTableExpression() { assertQuery( With { diff --git a/Tests/StructuredQueriesTests/UpdateTests.swift b/Tests/StructuredQueriesTests/UpdateTests.swift index 66b35c5d..f16f08d4 100644 --- a/Tests/StructuredQueriesTests/UpdateTests.swift +++ b/Tests/StructuredQueriesTests/UpdateTests.swift @@ -393,6 +393,16 @@ extension SnapshotTests { """ } } + + @Test func empty() { + assertQuery( + Reminder.none.update { $0.isCompleted.toggle() } + ) { + """ + + """ + } + } } } diff --git a/Tests/StructuredQueriesTests/WhereTests.swift b/Tests/StructuredQueriesTests/WhereTests.swift index a195e1f8..bd92f4da 100644 --- a/Tests/StructuredQueriesTests/WhereTests.swift +++ b/Tests/StructuredQueriesTests/WhereTests.swift @@ -226,6 +226,10 @@ extension SnapshotTests { FROM "remindersLists" LEFT JOIN "reminders" ON ("remindersLists"."id" = "reminders"."remindersListID") WHERE ("remindersLists"."id" = 4) AND "reminders"."isCompleted" + """ + } results: { + """ + """ } }