From f1263a2e4e9c9f925d3fe891ca41261b71dad28d Mon Sep 17 00:00:00 2001 From: Gwynne Raskind Date: Sat, 13 Apr 2024 15:00:50 -0500 Subject: [PATCH] Major overhaul of the entire SQLKit package (#172) * Bump Swift to 5.8, add Dependabot config * Docs overhaul * Deprecate \(raw:) in SQLQueryString with rename to explicitly "unsafe" version, numerous other SQLQueryString cleanups * ExistentialAny compliance * Overhaul SQLRowDecoder and SQLQueryEncoder * NIO -> NIOCore in imports * Fix all Sendable complaints from the compiler * Make SQLDatabaseReportedVersion Comparable * Deprecate SQLError and SQLErrorType, deprecate use of binds in SQLRaw. * Improve the behavior of the async-aware implementations * Basically redo all the tests * Cleanup of SQLKitBenchmark * Deprecate `SQLTriggerWhen/Event/Each/Order/Timing` in favor of better-namespaced names, add missing support for `SQLTriggerSyntax.Create.supportsDefiner` * Add SQLBetween and SQLQualifiedTable * Structural updates * Add missing predicate builder method to match the secondary predicate builder API * Misc general cleanup * Add basic support for "INSERT ... SELECT" queries to SQLInsert, add SQLSubquery and SQLSubqueryBuilder, use them in SQLCreateTableBuilder and SQLInsertBuilder * Make SQLLiteral.string and SQLIdentifier aware of proper escaping of their respective quoting. * Add all/first(decodingColumn:) utilities to SQLQueryFetcher * Add column list builders * Be more consistent about use of `String` versus `StringProtocol` and `any SQLExpression` versus `some SQLExpression`. * Add missing model decoding methods to SQLQueryFetcher. Further revise SQLRowDecoder and SQLQueryEncoder to provide the documented functionality correctly. Improve docs a bunch more. Add several missing "model"-handling methods to SQLInsertBuilder, SQLColumnUpdateBuilder, etc. Separate out the string handling utilities into their own file and refine the coding error handling. * Add SQLDataType.timestamp * Make the SQLStatement API more useful, speed up serialization very slightly, improve various other serialize(to:) methods * Make SQLDropBehavior respect the dialect, add predicate support to SQLCreateIndex, use SQLDropBehavior in SQLDropEnum (and make it respect the dialect for IF EXISTS), correct the docs and implementation of SQLDistinct, docs and serialization improvements for the rest of the query expressions. SQLDropTrigger gets a dropBehavior property, and SQLDropEnum's name is now mutable as with other properties. * Remove useless property on SQLAliasedColumnListBuilder, fix SQLColumnUpdateBuilder to correctly use SQLColumnAssignment * Make SQLPredicateBuilder and SQLSecondaryPredicateBuilder as fully consistent as possible. * Make the async version of SQLDatabase/execute a protocol requirement so non-default implementations are invoked when used through existentials * Fix SQLSelect and SQLList serialization, fix tests --- .github/.codecov.yml | 37 + .github/CONTRIBUTING.md | 5 - .github/dependabot.yml | 10 + .github/workflows/test.yml | 10 +- .gitignore | 5 +- Package.swift | 55 +- README.md | 38 +- .../Implementations/SQLAlterEnumBuilder.swift | 16 +- .../SQLAlterTableBuilder.swift | 11 +- .../SQLConflictUpdateBuilder.swift | 64 +- .../SQLCreateEnumBuilder.swift | 22 +- .../SQLCreateIndexBuilder.swift | 34 +- .../SQLCreateTableAsSubqueryBuilder.swift | 15 - .../SQLCreateTableBuilder.swift | 16 +- .../SQLCreateTriggerBuilder.swift | 65 +- .../Implementations/SQLDeleteBuilder.swift | 15 +- .../Implementations/SQLDropEnumBuilder.swift | 38 +- .../Implementations/SQLDropIndexBuilder.swift | 12 +- .../Implementations/SQLDropTableBuilder.swift | 10 +- .../SQLDropTriggerBuilder.swift | 32 +- .../Implementations/SQLInsertBuilder.swift | 243 ++++- .../SQLPredicateGroupBuilder.swift | 4 +- .../Implementations/SQLRawBuilder.swift | 8 +- .../SQLSecondaryPredicateGroupBuilder.swift | 4 +- .../Implementations/SQLSelectBuilder.swift | 12 +- .../Implementations/SQLSubqueryBuilder.swift | 47 + .../Implementations/SQLUnionBuilder.swift | 32 +- .../Implementations/SQLUpdateBuilder.swift | 36 +- .../SQLAliasedColumnListBuilder.swift | 31 + .../Prototypes/SQLColumnUpdateBuilder.swift | 104 +- .../Builders/Prototypes/SQLJoinBuilder.swift | 14 - .../Prototypes/SQLPartialResultBuilder.swift | 3 +- .../Prototypes/SQLPredicateBuilder.swift | 133 ++- .../Builders/Prototypes/SQLQueryBuilder.swift | 25 +- .../Builders/Prototypes/SQLQueryFetcher.swift | 450 +++++++- .../Prototypes/SQLReturningBuilder.swift | 23 +- .../SQLSecondaryPredicateBuilder.swift | 145 ++- .../Prototypes/SQLSubqueryClauseBuilder.swift | 154 +-- .../SQLUnqualifiedColumnListBuilder.swift | 56 + .../Concurrency/SQLDatabase+Concurrency.swift | 7 - .../SQLQueryBuilder+Concurrency.swift | 7 - .../SQLQueryFetcher+Concurrency.swift | 27 - Sources/SQLKit/Database/SQLDatabase.swift | 267 +++-- .../Database/SQLDatabaseReportedVersion.swift | 104 +- Sources/SQLKit/Database/SQLDialect.swift | 556 +++++++--- .../Deprecated/SQLDatabase+Deprecated.swift | 40 + Sources/SQLKit/Deprecated/SQLError.swift | 12 +- .../SQLExpressions+Deprecated.swift | 125 +++ .../SQLQueryBuilders+Deprecated.swift | 60 ++ Sources/SQLKit/Docs.docc/BasicUsage.md | 291 ++++++ .../Resources/codingkey-quotation.svg | 14 + .../Docs.docc/Resources/vapor-sqlkit-logo.svg | 22 + .../Docs.docc/SQLDatabase+ExtensionDocs.md | 66 ++ .../Docs.docc/SQLDialect+ExtensionDocs.md | 37 + Sources/SQLKit/Docs.docc/SQLKit.md | 159 +++ .../SQLQueryFetcher+ExtensionDocs.md | 43 + Sources/SQLKit/Docs.docc/index.md | 12 - Sources/SQLKit/Docs.docc/theme-settings.json | 21 + Sources/SQLKit/Exports.swift | 14 +- .../SQLKit/Expressions/Basics/SQLAlias.swift | 41 +- .../Expressions/Basics/SQLBetween.swift | 692 +++++++++++++ .../SQLKit/Expressions/Basics/SQLColumn.swift | 7 + .../Expressions/Basics/SQLConstraint.swift | 33 +- .../Expressions/Basics/SQLDataType.swift | 63 +- .../Expressions/Basics/SQLDirection.swift | 23 +- .../Expressions/Basics/SQLDistinct.swift | 48 +- .../Basics/SQLForeignKeyAction.swift | 36 +- .../Basics/SQLNestedSubpathExpression.swift | 23 +- .../Basics/SQLQualifiedTable.swift | 33 + .../Expressions/Basics/SQLQueryString.swift | 267 +++++ .../SQLAlterColumnDefinitionType.swift | 28 +- .../Clauses/SQLColumnAssignment.swift | 21 +- .../SQLColumnConstraintAlgorithm.swift | 167 ++- .../Clauses/SQLColumnDefinition.swift | 70 +- .../Clauses/SQLConflictAction.swift | 24 +- .../SQLConflictResolutionStrategy.swift | 22 +- .../Clauses/SQLDropBehaviour.swift | 20 +- .../Expressions/Clauses/SQLEnumDataType.swift | 88 +- .../Clauses/SQLExcludedColumn.swift | 22 +- .../Expressions/Clauses/SQLForeignKey.swift | 38 +- .../SQLKit/Expressions/Clauses/SQLJoin.swift | 30 +- .../Expressions/Clauses/SQLJoinMethod.swift | 35 + .../Clauses/SQLLockingClause.swift | 12 +- .../Expressions/Clauses/SQLOrderBy.swift | 26 +- .../Expressions/Clauses/SQLReturning.swift | 29 +- .../Expressions/Clauses/SQLSubquery.swift | 25 + .../Clauses/SQLTableConstraintAlgorithm.swift | 63 +- .../Expressions/Queries/SQLAlterEnum.swift | 25 +- .../Expressions/Queries/SQLAlterTable.swift | 106 +- .../Expressions/Queries/SQLCreateEnum.swift | 30 +- .../Expressions/Queries/SQLCreateIndex.swift | 61 +- .../Expressions/Queries/SQLCreateTable.swift | 70 +- .../Queries/SQLCreateTrigger.swift | 304 ++++-- .../Expressions/Queries/SQLDelete.swift | 31 +- .../Expressions/Queries/SQLDropEnum.swift | 41 +- .../Expressions/Queries/SQLDropIndex.swift | 37 +- .../Expressions/Queries/SQLDropTable.swift | 47 +- .../Expressions/Queries/SQLDropTrigger.swift | 44 +- .../Expressions/Queries/SQLInsert.swift | 89 +- .../Expressions/Queries/SQLSelect.swift | 187 ++-- .../SQLKit/Expressions/Queries/SQLUnion.swift | 205 ++-- .../Expressions/Queries/SQLUpdate.swift | 54 +- .../SQLKit/Expressions/SQLExpression.swift | 61 +- .../SQLKit/Expressions/SQLSerializer.swift | 23 +- Sources/SQLKit/Expressions/SQLStatement.swift | 218 +++- .../Syntax/SQLBinaryExpression.swift | 64 +- .../Syntax/SQLBinaryOperator.swift | 91 +- .../SQLKit/Expressions/Syntax/SQLBind.swift | 18 +- .../Expressions/Syntax/SQLFunction.swift | 84 +- .../Syntax/SQLGroupExpression.swift | 28 + .../Expressions/Syntax/SQLIdentifier.swift | 48 +- .../SQLKit/Expressions/Syntax/SQLList.swift | 42 +- .../Expressions/Syntax/SQLLiteral.swift | 66 +- .../Expressions/Syntax/SQLQueryString.swift | 161 --- .../SQLKit/Expressions/Syntax/SQLRaw.swift | 33 +- Sources/SQLKit/Rows/SQLCodingUtilities.swift | 239 +++++ Sources/SQLKit/Rows/SQLQueryEncoder.swift | 528 +++++++--- Sources/SQLKit/Rows/SQLRow.swift | 96 +- Sources/SQLKit/Rows/SQLRowDecoder.swift | 544 ++++++---- Sources/SQLKit/Utilities/SomeCodingKey.swift | 32 + Sources/SQLKit/Utilities/StringHandling.swift | 142 +++ .../SQLBenchmark+Codable.swift | 26 +- .../SQLKitBenchmark/SQLBenchmark+Enum.swift | 57 +- .../SQLBenchmark+JSONPaths.swift | 42 +- .../SQLBenchmark+Planets.swift | 103 +- .../SQLKitBenchmark/SQLBenchmark+Union.swift | 87 +- .../SQLKitBenchmark/SQLBenchmark+Upsert.swift | 183 ++-- Sources/SQLKitBenchmark/SQLBenchmarker.swift | 74 +- .../SQLKitTests/Async/AsyncSQLKitTests.swift | 916 ---------------- .../Async/AsyncSQLKitTriggerTests.swift | 93 -- Tests/SQLKitTests/AsyncTests.swift | 163 +++ Tests/SQLKitTests/BaseTests.swift | 247 +++++ Tests/SQLKitTests/BasicQueryTests.swift | 434 ++++++++ Tests/SQLKitTests/DeprecatedTests.swift | 145 +++ Tests/SQLKitTests/SQLBetweenTests.swift | 77 ++ Tests/SQLKitTests/SQLCodingTests.swift | 283 +++++ .../SQLCreateDropTriggerTests.swift | 143 +++ Tests/SQLKitTests/SQLCreateTableTests.swift | 224 ++++ .../SQLKitTests/SQLDialectFeatureTests.swift | 160 +++ Tests/SQLKitTests/SQLExpressionTests.swift | 268 +++++ Tests/SQLKitTests/SQLInsertUpsertTests.swift | 121 +++ Tests/SQLKitTests/SQLKitTests.swift | 980 ------------------ Tests/SQLKitTests/SQLKitTriggerTests.swift | 93 -- Tests/SQLKitTests/SQLQueryEncoderTests.swift | 297 ++++++ Tests/SQLKitTests/SQLQueryStringTests.swift | 155 ++- Tests/SQLKitTests/SQLRowDecoderTests.swift | 248 +++++ Tests/SQLKitTests/SQLUnionTests.swift | 202 ++++ Tests/SQLKitTests/TestMocks.swift | 153 +++ Tests/SQLKitTests/Utilities.swift | 150 ++- Tests/SQLKitTests/XCTAsyncAssertions.swift | 266 +++++ 150 files changed, 11347 insertions(+), 4796 deletions(-) create mode 100644 .github/.codecov.yml delete mode 100644 .github/CONTRIBUTING.md create mode 100644 .github/dependabot.yml delete mode 100644 Sources/SQLKit/Builders/Implementations/SQLCreateTableAsSubqueryBuilder.swift create mode 100644 Sources/SQLKit/Builders/Implementations/SQLSubqueryBuilder.swift create mode 100644 Sources/SQLKit/Builders/Prototypes/SQLAliasedColumnListBuilder.swift create mode 100644 Sources/SQLKit/Builders/Prototypes/SQLUnqualifiedColumnListBuilder.swift delete mode 100644 Sources/SQLKit/Concurrency/SQLDatabase+Concurrency.swift delete mode 100644 Sources/SQLKit/Concurrency/SQLQueryBuilder+Concurrency.swift delete mode 100644 Sources/SQLKit/Concurrency/SQLQueryFetcher+Concurrency.swift create mode 100644 Sources/SQLKit/Deprecated/SQLDatabase+Deprecated.swift create mode 100644 Sources/SQLKit/Deprecated/SQLExpressions+Deprecated.swift create mode 100644 Sources/SQLKit/Deprecated/SQLQueryBuilders+Deprecated.swift create mode 100644 Sources/SQLKit/Docs.docc/BasicUsage.md create mode 100644 Sources/SQLKit/Docs.docc/Resources/codingkey-quotation.svg create mode 100644 Sources/SQLKit/Docs.docc/Resources/vapor-sqlkit-logo.svg create mode 100644 Sources/SQLKit/Docs.docc/SQLDatabase+ExtensionDocs.md create mode 100644 Sources/SQLKit/Docs.docc/SQLDialect+ExtensionDocs.md create mode 100644 Sources/SQLKit/Docs.docc/SQLKit.md create mode 100644 Sources/SQLKit/Docs.docc/SQLQueryFetcher+ExtensionDocs.md delete mode 100644 Sources/SQLKit/Docs.docc/index.md create mode 100644 Sources/SQLKit/Docs.docc/theme-settings.json create mode 100644 Sources/SQLKit/Expressions/Basics/SQLBetween.swift create mode 100644 Sources/SQLKit/Expressions/Basics/SQLQualifiedTable.swift create mode 100644 Sources/SQLKit/Expressions/Basics/SQLQueryString.swift create mode 100644 Sources/SQLKit/Expressions/Clauses/SQLSubquery.swift delete mode 100644 Sources/SQLKit/Expressions/Syntax/SQLQueryString.swift create mode 100644 Sources/SQLKit/Rows/SQLCodingUtilities.swift create mode 100644 Sources/SQLKit/Utilities/SomeCodingKey.swift create mode 100644 Sources/SQLKit/Utilities/StringHandling.swift delete mode 100644 Tests/SQLKitTests/Async/AsyncSQLKitTests.swift delete mode 100644 Tests/SQLKitTests/Async/AsyncSQLKitTriggerTests.swift create mode 100644 Tests/SQLKitTests/AsyncTests.swift create mode 100644 Tests/SQLKitTests/BaseTests.swift create mode 100644 Tests/SQLKitTests/BasicQueryTests.swift create mode 100644 Tests/SQLKitTests/DeprecatedTests.swift create mode 100644 Tests/SQLKitTests/SQLBetweenTests.swift create mode 100644 Tests/SQLKitTests/SQLCodingTests.swift create mode 100644 Tests/SQLKitTests/SQLCreateDropTriggerTests.swift create mode 100644 Tests/SQLKitTests/SQLCreateTableTests.swift create mode 100644 Tests/SQLKitTests/SQLDialectFeatureTests.swift create mode 100644 Tests/SQLKitTests/SQLExpressionTests.swift create mode 100644 Tests/SQLKitTests/SQLInsertUpsertTests.swift delete mode 100644 Tests/SQLKitTests/SQLKitTests.swift delete mode 100644 Tests/SQLKitTests/SQLKitTriggerTests.swift create mode 100644 Tests/SQLKitTests/SQLQueryEncoderTests.swift create mode 100644 Tests/SQLKitTests/SQLRowDecoderTests.swift create mode 100644 Tests/SQLKitTests/SQLUnionTests.swift create mode 100644 Tests/SQLKitTests/TestMocks.swift create mode 100644 Tests/SQLKitTests/XCTAsyncAssertions.swift diff --git a/.github/.codecov.yml b/.github/.codecov.yml new file mode 100644 index 00000000..220b77a6 --- /dev/null +++ b/.github/.codecov.yml @@ -0,0 +1,37 @@ +codecov: + notify: + after_n_builds: 1 + wait_for_ci: false + require_ci_to_pass: false +comment: + behavior: default + layout: diff, files + require_changes: true +coverage: + status: + patch: + default: + branches: + - ^main$ + informational: true + only_pulls: false + paths: + - ^Sources.* + target: auto + project: + default: + branches: + - ^main$ + informational: true + only_pulls: false + paths: + - ^Sources.* + target: auto +github_checks: + annotations: true +ignore: +- ^Sources/SQLKitBenchmark/.* +- ^Tests/.* +- ^.build/.* +slack_app: false + diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md deleted file mode 100644 index 5f5bc8a0..00000000 --- a/.github/CONTRIBUTING.md +++ /dev/null @@ -1,5 +0,0 @@ -# Maintainers - -- [@gwynne](https://github.com/gwynne) - -See the [Vapor maintainers doc](https://github.com/vapor/vapor/blob/main/.github/maintainers.md) for more information. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..998a0ebe --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + groups: + dependencies: + patterns: + - "*" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1a180eee..0e5a0c30 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,21 +17,21 @@ jobs: if: ${{ !(github.event.pull_request.draft || false) }} services: mysql-a: - image: mysql:8 + image: mysql:latest env: { MYSQL_USER: test_username, MYSQL_PASSWORD: test_password, MYSQL_DATABASE: test_database, MYSQL_ALLOW_EMPTY_PASSWORD: true } mysql-b: - image: mysql:8 + image: mysql:latest env: { MYSQL_USER: test_username, MYSQL_PASSWORD: test_password, MYSQL_DATABASE: test_database, MYSQL_ALLOW_EMPTY_PASSWORD: true } psql-a: - image: postgres:16 + image: postgres:latest env: { POSTGRES_USER: test_username, POSTGRES_PASSWORD: test_password, POSTGRES_DB: test_database } psql-b: - image: postgres:16 + image: postgres:latest env: { POSTGRES_USER: test_username, POSTGRES_PASSWORD: test_password, POSTGRES_DB: test_database } strategy: fail-fast: false matrix: - swift-image: ['swift:5.9-jammy'] + swift-image: ['swift:5.10-jammy'] driver: - { sqlkit: 'sqlite-kit', fluent: 'fluent-sqlite-driver' } - { sqlkit: 'mysql-kit', fluent: 'fluent-mysql-driver' } diff --git a/.gitignore b/.gitignore index 85734ead..eea50ff7 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,6 @@ /.build /Packages /*.xcodeproj -Package.resolved +/Package.resolved DerivedData -.swiftpm -Tests/LinuxMain.swift +/.swiftpm diff --git a/Package.swift b/Package.swift index 1102d5bc..89961756 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.6 +// swift-tools-version:5.8 import PackageDescription let package = Package( @@ -14,20 +14,47 @@ let package = Package( .library(name: "SQLKitBenchmark", targets: ["SQLKitBenchmark"]), ], dependencies: [ - .package(url: "https://github.com/apple/swift-nio.git", from: "2.0.0"), - .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"), + .package(url: "https://github.com/apple/swift-nio.git", from: "2.64.0"), + .package(url: "https://github.com/apple/swift-log.git", from: "1.5.4"), + .package(url: "https://github.com/apple/swift-collections.git", from: "1.1.0"), + .package(url: "https://github.com/apple/swift-docc-plugin.git", from: "1.3.0"), ], targets: [ - .target(name: "SQLKit", dependencies: [ - .product(name: "Logging", package: "swift-log"), - .product(name: "NIO", package: "swift-nio"), - ]), - .target(name: "SQLKitBenchmark", dependencies: [ - .target(name: "SQLKit") - ]), - .testTarget(name: "SQLKitTests", dependencies: [ - .target(name: "SQLKit"), - .target(name: "SQLKitBenchmark"), - ]), + .target( + name: "SQLKit", + dependencies: [ + .product(name: "Logging", package: "swift-log"), + .product(name: "NIOCore", package: "swift-nio"), + .product(name: "Collections", package: "swift-collections"), + ], + swiftSettings: swiftSettings + ), + .target( + name: "SQLKitBenchmark", + dependencies: [ + .target(name: "SQLKit"), + ], + swiftSettings: swiftSettings + ), + .testTarget( + name: "SQLKitTests", + dependencies: [ + .target(name: "SQLKit"), + .target(name: "SQLKitBenchmark"), + ], + swiftSettings: swiftSettings + ), ] ) + +var swiftSettings: [SwiftSetting] { [ + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("ConciseMagicFile"), + .enableUpcomingFeature("ForwardTrailingClosures"), + .enableUpcomingFeature("ImportObjcForwardDeclarations"), + .enableUpcomingFeature("DisableOutwardActorInference"), + .enableUpcomingFeature("IsolatedDefaultValues"), + .enableUpcomingFeature("GlobalConcurrency"), + .enableUpcomingFeature("StrictConcurrency"), + .enableExperimentalFeature("StrictConcurrency=complete"), +] } diff --git a/README.md b/README.md index dbf6ca8e..4804095e 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,17 @@

- SQLKit -
-
- - Documentation - - - Team Chat - - - MIT License - - - Continuous Integration - - - Swift 5.6 - + + + + SQLKit + +
+
+Documentation +Team Chat +MIT License +Continuous Integration + +Swift 5.8+


@@ -60,7 +55,7 @@ SQLKit does not deal with creating or managing database connections itself. This ### Database -Instances of `SQLDatabase` are capable of serializing and executing `SQLExpression`. +Instances of `SQLDatabase` are capable of serializing and executing `SQLExpression`s. ```swift let db: any SQLDatabase = ... @@ -282,6 +277,5 @@ SELECT * FROM "planets" WHERE "name" = $1 -- bindings: ["planet"] The `\(bind:)` interpolation should be used for any user input to avoid SQL injection. The `\(ident:)` interpolation is used to safely specify identifiers such as table and column names. -##### ⚠️ **Important!**⚠️ - -Always prefer a structured query (i.e. one for which a builder or expression type exists) over raw queries. Consider writing your own `SQLExpression`s, and even your own `SQLQueryBuilder`s, rather than using raw queries, and don't hesitate to [open an issue](https://github.com/vapor/sql-kit/issues/new) to ask for additional feature support. +> [!IMPORTANT] +> Always prefer a structured query (i.e. one for which a builder or expression type exists) over raw queries. Consider writing your own `SQLExpression`s, and even your own `SQLQueryBuilder`s, rather than using raw queries, and don't hesitate to [open an issue](https://github.com/vapor/sql-kit/issues/new) to ask for additional feature support. diff --git a/Sources/SQLKit/Builders/Implementations/SQLAlterEnumBuilder.swift b/Sources/SQLKit/Builders/Implementations/SQLAlterEnumBuilder.swift index b0fb6c39..1eb7b729 100644 --- a/Sources/SQLKit/Builders/Implementations/SQLAlterEnumBuilder.swift +++ b/Sources/SQLKit/Builders/Implementations/SQLAlterEnumBuilder.swift @@ -1,14 +1,12 @@ -import NIOCore - /// Builds ``SQLAlterEnum`` queries. public final class SQLAlterEnumBuilder: SQLQueryBuilder { /// ``SQLAlterEnum`` query being built. public var alterEnum: SQLAlterEnum - /// See ``SQLQueryBuilder/database``. + // See `SQLQueryBuilder.database`. public var database: any SQLDatabase - /// See ``SQLQueryBuilder/query``. + // See `SQLQueryBuilder.query`. @inlinable public var query: any SQLExpression { self.alterEnum @@ -35,16 +33,6 @@ public final class SQLAlterEnumBuilder: SQLQueryBuilder { self.alterEnum.value = value return self } - - /// See ``SQLQueryBuilder/run()-2zws8``. - @inlinable - public func run() -> EventLoopFuture { - guard self.database.dialect.enumSyntax == .typeName else { - self.database.logger.warning("Database does not support standalone enum types.") - return self.database.eventLoop.makeSucceededFuture(()) - } - return self.database.execute(sql: self.query) { _ in } - } } extension SQLDatabase { diff --git a/Sources/SQLKit/Builders/Implementations/SQLAlterTableBuilder.swift b/Sources/SQLKit/Builders/Implementations/SQLAlterTableBuilder.swift index 32a4a081..64a476ae 100644 --- a/Sources/SQLKit/Builders/Implementations/SQLAlterTableBuilder.swift +++ b/Sources/SQLKit/Builders/Implementations/SQLAlterTableBuilder.swift @@ -3,22 +3,15 @@ public final class SQLAlterTableBuilder: SQLQueryBuilder { /// ``SQLAlterTable`` query being built. public var alterTable: SQLAlterTable - /// See ``SQLQueryBuilder/database``. + // See `SQLQueryBuilder.database`. public var database: any SQLDatabase - /// See ``SQLQueryBuilder/query``. + // See `SQLQueryBuilder.query`. @inlinable public var query: any SQLExpression { self.alterTable } - /// The set of column alteration expressions. - @inlinable - public var columns: [any SQLExpression] { - get { self.alterTable.addColumns } - set { self.alterTable.addColumns = newValue } - } - /// Create a new ``SQLAlterTableBuilder``. @inlinable public init(_ alterTable: SQLAlterTable, on database: any SQLDatabase) { diff --git a/Sources/SQLKit/Builders/Implementations/SQLConflictUpdateBuilder.swift b/Sources/SQLKit/Builders/Implementations/SQLConflictUpdateBuilder.swift index 5d1f2a23..3ed7dada 100644 --- a/Sources/SQLKit/Builders/Implementations/SQLConflictUpdateBuilder.swift +++ b/Sources/SQLKit/Builders/Implementations/SQLConflictUpdateBuilder.swift @@ -1,21 +1,20 @@ /// A builder for specifying column updates and an optional predicate to be applied to /// rows that caused unique key conflicts during an `INSERT`. public final class SQLConflictUpdateBuilder: SQLColumnUpdateBuilder, SQLPredicateBuilder { - /// See ``SQLColumnUpdateBuilder/values``. - public var values: [any SQLExpression] + // See `SQLColumnUpdateBuilder.values`. + public var values: [any SQLExpression] = [] - /// See ``SQLPredicateBuilder/predicate``. - public var predicate: (any SQLExpression)? + // See `SQLPredicateBuilder.predicate`. + public var predicate: (any SQLExpression)? = nil /// Create a conflict update builder. @usableFromInline - init() { - self.values = [] - self.predicate = nil - } + init() {} /// Add an assignment of the column with the given name, using the value the column was - /// given in the `INSERT` query's `VALUES` list. See ``SQLExcludedColumn``. + /// given in the `INSERT` query's `VALUES` list. + /// + /// See ``SQLExcludedColumn`` for additional details. @inlinable @discardableResult public func set(excludedValueOf columnName: String) -> Self { @@ -23,7 +22,9 @@ public final class SQLConflictUpdateBuilder: SQLColumnUpdateBuilder, SQLPredicat } /// Add an assignment of the given column, using the value the column was given in the - /// `INSERT` query's `VALUES` list. See ``SQLExcludedColumn``. + /// `INSERT` query's `VALUES` list. + /// + /// See ``SQLExcludedColumn`` for additional details. @inlinable @discardableResult public func set(excludedValueOf column: any SQLExpression) -> Self { @@ -31,12 +32,47 @@ public final class SQLConflictUpdateBuilder: SQLColumnUpdateBuilder, SQLPredicat return self } - /// Encodes the given ``Encodable`` value to a sequence of key-value pairs and adds an assignment + /// Encodes the given `Encodable` value to a sequence of key-value pairs and adds an assignment + /// for each pair which uses the values each column was given in the original `INSERT` query's + /// `VALUES` list. + /// + /// See ``SQLExcludedColumn`` and ``SQLQueryEncoder`` for additional details. + /// + /// > Important: The actual values stored in the provided `model` _are not used_ by this method. + /// > The model is encoded, then the resulting values are discarded and the list of column names + /// > is used to repeatedly invoke ``set(excludedValueOf:)-zmis``. This is potentially very + /// > inefficient; a future version of the API will offer the ability to efficiently set the + /// > excluded values for all input columns in one operation. + @inlinable + @discardableResult + public func set( + excludedContentOf model: some Encodable & Sendable, + prefix: String? = nil, + keyEncodingStrategy: SQLQueryEncoder.KeyEncodingStrategy = .useDefaultKeys, + nilEncodingStrategy: SQLQueryEncoder.NilEncodingStrategy = .default, + userInfo: [CodingUserInfoKey: any Sendable] = [:] + ) throws -> Self { + try self.set( + excludedContentOf: model, + with: .init(prefix: prefix, keyEncodingStrategy: keyEncodingStrategy, nilEncodingStrategy: nilEncodingStrategy, userInfo: userInfo) + ) + } + + /// Encodes the given `Encodable` value to a sequence of key-value pairs and adds an assignment /// for each pair which uses the values each column was given in the original `INSERT` query's - /// `VALUES` list. See ``SQLExcludedColumn``. + /// `VALUES` list. See ``SQLExcludedColumn`` and ``SQLQueryEncoder``. + /// + /// > Important: The actual values stored in the provided `model` _are not used_ by this method. + /// > The model is encoded, then the resulting values are discarded and the list of column names + /// > is used to repeatedly invoke ``set(excludedValueOf:)-zmis``. This is potentially very + /// > inefficient; a future version of the API will offer the ability to efficiently set the + /// > excluded values for all input columns in one operation. @inlinable @discardableResult - public func set(excludedContentOf model: E) throws -> Self where E: Encodable { - try SQLQueryEncoder().encode(model).reduce(self) { $0.set(excludedValueOf: $1.0) } + public func set( + excludedContentOf model: some Encodable & Sendable, + with encoder: SQLQueryEncoder + ) throws -> Self { + try encoder.encode(model).reduce(self) { $0.set(excludedValueOf: $1.0) } } } diff --git a/Sources/SQLKit/Builders/Implementations/SQLCreateEnumBuilder.swift b/Sources/SQLKit/Builders/Implementations/SQLCreateEnumBuilder.swift index 719771b0..f17750f4 100644 --- a/Sources/SQLKit/Builders/Implementations/SQLCreateEnumBuilder.swift +++ b/Sources/SQLKit/Builders/Implementations/SQLCreateEnumBuilder.swift @@ -1,20 +1,12 @@ -import NIOCore - /// Builds ``SQLCreateEnum`` queries. -/// -/// db.create(enum: "meal") -/// .value("breakfast") -/// .value("lunch") -/// .value("dinner") -/// .run() public final class SQLCreateEnumBuilder: SQLQueryBuilder { /// ``SQLCreateEnum`` query being built. public var createEnum: SQLCreateEnum - /// See ``SQLQueryBuilder/database``. + // See `SQLQueryBuilder.database`. public var database: any SQLDatabase - /// See ``SQLQueryBuilder/query``. + // See `SQLQueryBuilder.query`. @inlinable public var query: any SQLExpression { self.createEnum @@ -41,16 +33,6 @@ public final class SQLCreateEnumBuilder: SQLQueryBuilder { self.createEnum.values.append(value) return self } - - /// See ``SQLQueryBuilder/run()-2sxsg``. - @inlinable - public func run() -> EventLoopFuture { - guard self.database.dialect.enumSyntax == .typeName else { - self.database.logger.warning("Database does not support standalone enum types.") - return self.database.eventLoop.makeSucceededFuture(()) - } - return self.database.execute(sql: self.query) { _ in } - } } extension SQLDatabase { diff --git a/Sources/SQLKit/Builders/Implementations/SQLCreateIndexBuilder.swift b/Sources/SQLKit/Builders/Implementations/SQLCreateIndexBuilder.swift index b110381d..d85b8a09 100644 --- a/Sources/SQLKit/Builders/Implementations/SQLCreateIndexBuilder.swift +++ b/Sources/SQLKit/Builders/Implementations/SQLCreateIndexBuilder.swift @@ -1,19 +1,24 @@ /// Builds ``SQLCreateIndex`` queries. -/// -/// db.create(index: "planet_name_unique").on("planet").column("name").unique().run() public final class SQLCreateIndexBuilder: SQLQueryBuilder { /// ``SQLCreateIndex`` query being built. public var createIndex: SQLCreateIndex - /// See ``SQLQueryBuilder/database``. + // See `SQLQueryBuilder.database`. public var database: any SQLDatabase - /// See ``SQLQueryBuilder/query``. + // See `SQLQueryBuilder.query`. @inlinable public var query: any SQLExpression { self.createIndex } + /// Create a new ``SQLCreateIndexBuilder``. + @inlinable + public init(_ createIndex: SQLCreateIndex, on database: any SQLDatabase) { + self.createIndex = createIndex + self.database = database + } + /// Adds `UNIQUE` modifier to the index being created. @inlinable @discardableResult @@ -51,35 +56,18 @@ public final class SQLCreateIndexBuilder: SQLQueryBuilder { self.createIndex.columns.append(column) return self } - - /// Create a new `SQLCreateIndexBuilder`. - @inlinable - public init(_ createIndex: SQLCreateIndex, on database: any SQLDatabase) { - self.createIndex = createIndex - self.database = database - } } // MARK: Connection extension SQLDatabase { - /// Creates a new `SQLCreateIndexBuilder`. - /// - /// db.create(index: "foo")... - /// - /// - Parameters: - /// - name: Name for this index. + /// Creates a new ``SQLCreateIndexBuilder``. @inlinable public func create(index name: String) -> SQLCreateIndexBuilder { self.create(index: SQLIdentifier(name)) } - /// Creates a new `SQLCreateIndexBuilder`. - /// - /// db.create(index: "foo")... - /// - /// - Parameters: - /// - name: Name for this index. + /// Creates a new ``SQLCreateIndexBuilder``. @inlinable public func create(index name: any SQLExpression) -> SQLCreateIndexBuilder { .init(.init(name: name), on: self) diff --git a/Sources/SQLKit/Builders/Implementations/SQLCreateTableAsSubqueryBuilder.swift b/Sources/SQLKit/Builders/Implementations/SQLCreateTableAsSubqueryBuilder.swift deleted file mode 100644 index a7e0bfa1..00000000 --- a/Sources/SQLKit/Builders/Implementations/SQLCreateTableAsSubqueryBuilder.swift +++ /dev/null @@ -1,15 +0,0 @@ -/// A builder used to construct a `SELECT` query for use as part of a `CREATE TABLE` query. -/// -/// - Note: There's really nothing for this builder to do besides provide a concrete storage -/// for the `select` property of ``SQLSubqueryClauseBuilder``. All of the interesting methods -/// are on the protocol. -public final class SQLCreateTableAsSubqueryBuilder: SQLSubqueryClauseBuilder { - // See `SQLSubqueryClauseBuilder.select`. - public var select: SQLSelect - - /// Create a new `SQLCreateTableAsSubqueryBuilder`. - @usableFromInline - internal init() { - self.select = .init() - } -} diff --git a/Sources/SQLKit/Builders/Implementations/SQLCreateTableBuilder.swift b/Sources/SQLKit/Builders/Implementations/SQLCreateTableBuilder.swift index e8373b42..c7d31596 100644 --- a/Sources/SQLKit/Builders/Implementations/SQLCreateTableBuilder.swift +++ b/Sources/SQLKit/Builders/Implementations/SQLCreateTableBuilder.swift @@ -1,19 +1,12 @@ /// Builds ``SQLCreateTable`` queries. -/// -/// db.create(table: Planet.self).ifNotExists() -/// .column("id", type: .int, .primaryKey) -/// .column("galaxy_id", type: .int, .references(Galaxy.schema, "id")) -/// .run() -/// -/// See `SQLColumnBuilder` and `SQLQueryBuilder` for more information. public final class SQLCreateTableBuilder: SQLQueryBuilder { /// ``SQLCreateTable`` query being built. public var createTable: SQLCreateTable - /// See ``SQLQueryBuilder/database``. + // See `SQLQueryBuilder.database`. public var database: any SQLDatabase - /// See ``SQLQueryBuilder/query``. + // See `SQLQueryBuilder.query`. @inlinable public var query: any SQLExpression { self.createTable @@ -102,8 +95,8 @@ public final class SQLCreateTableBuilder: SQLQueryBuilder { /// If called more than once, each subsequent invocation overwrites the query from the one before. @inlinable @discardableResult - public func select(_ closure: (SQLCreateTableAsSubqueryBuilder) throws -> SQLCreateTableAsSubqueryBuilder) rethrows -> Self { - let builder = SQLCreateTableAsSubqueryBuilder() + public func select(_ closure: (SQLSubqueryBuilder) throws -> SQLSubqueryBuilder) rethrows -> Self { + let builder = SQLSubqueryBuilder() _ = try closure(builder) self.createTable.asQuery = builder.select return self @@ -280,6 +273,7 @@ extension SQLDatabase { } /// Create a new ``SQLCreateTableBuilder``. + @inlinable public func create(table: any SQLExpression) -> SQLCreateTableBuilder { .init(.init(name: table), on: self) } diff --git a/Sources/SQLKit/Builders/Implementations/SQLCreateTriggerBuilder.swift b/Sources/SQLKit/Builders/Implementations/SQLCreateTriggerBuilder.swift index 1fef563e..c81d539d 100644 --- a/Sources/SQLKit/Builders/Implementations/SQLCreateTriggerBuilder.swift +++ b/Sources/SQLKit/Builders/Implementations/SQLCreateTriggerBuilder.swift @@ -3,10 +3,10 @@ public final class SQLCreateTriggerBuilder: SQLQueryBuilder { /// ``SQLCreateTrigger`` query being built. public var createTrigger: SQLCreateTrigger - /// See ``SQLQueryBuilder/database``. + // See `SQLQueryBuilder.database`. public var database: any SQLDatabase - /// See ``SQLQueryBuilder/query``. + // See `SQLQueryBuilder.query`. @inlinable public var query: any SQLExpression { self.createTrigger @@ -14,7 +14,13 @@ public final class SQLCreateTriggerBuilder: SQLQueryBuilder { /// Create a new ``SQLCreateTriggerBuilder``. @usableFromInline - init(trigger: any SQLExpression, table: any SQLExpression, when: any SQLExpression, event: any SQLExpression, on database: any SQLDatabase) { + init( + trigger: any SQLExpression, + table: any SQLExpression, + when: any SQLExpression, + event: any SQLExpression, + on database: any SQLDatabase + ) { self.createTrigger = .init(trigger: trigger, table: table, when: when, event: event) self.database = database } @@ -22,9 +28,8 @@ public final class SQLCreateTriggerBuilder: SQLQueryBuilder { /// Identifies whether the trigger applies to each row or each statement. @inlinable @discardableResult - public func each(_ value: SQLTriggerEach) -> Self { - self.createTrigger.each = value - return self + public func each(_ value: SQLCreateTrigger.EachSpecifier) -> Self { + self.each(value as any SQLExpression) } /// Identifies whether the trigger applies to each row or each statement. @@ -60,17 +65,16 @@ public final class SQLCreateTriggerBuilder: SQLQueryBuilder { /// Specify the trigger's timing. /// - /// - Note: Only applies to constraint triggers. + /// > Note: Only applies to constraint triggers. @inlinable @discardableResult - public func timing(_ value: SQLTriggerTiming) -> Self { - self.createTrigger.timing = value - return self + public func timing(_ value: SQLCreateTrigger.TimingSpecifier) -> Self { + self.timing(value as any SQLExpression) } /// Specify the trigger's timing. /// - /// - Note: Only applies to constraint triggers. + /// > Note: Only applies to constraint triggers. @inlinable @discardableResult public func timing(_ value: any SQLExpression) -> Self { @@ -78,14 +82,6 @@ public final class SQLCreateTriggerBuilder: SQLQueryBuilder { return self } - /// Specify a conditional expression which determines whether the trigger is actually executed. - @available(*, deprecated, message: "Specifying conditions as raw strings is unsafe. Use `SQLBinaryExpression` etc. instead.") - @inlinable - @discardableResult - public func condition(_ value: String) -> Self { - self.condition(SQLRaw(value)) - } - /// Specify a conditional expression which determines whether the trigger is actually executed. @inlinable @discardableResult @@ -98,7 +94,7 @@ public final class SQLCreateTriggerBuilder: SQLQueryBuilder { /// /// To specify a schema-qualified table, use ``SQLQualifiedTable``. /// - /// - Note: This option is used for foreign key constraints and is not recommended for general use. Only applies to constraint triggers. + /// > Note: This option is used for foreign key constraints and is not recommended for general use. Only applies to constraint triggers. @inlinable @discardableResult public func referencedTable(_ value: String) -> Self { @@ -109,7 +105,7 @@ public final class SQLCreateTriggerBuilder: SQLQueryBuilder { /// /// To specify a schema-qualified table, use ``SQLQualifiedTable``. /// - /// - Note: This option is used for foreign key constraints and is not recommended for general use. Only applies to constraint triggers. + /// > Note: This option is used for foreign key constraints and is not recommended for general use. Only applies to constraint triggers. @inlinable @discardableResult public func referencedTable(_ value: any SQLExpression) -> Self { @@ -117,14 +113,6 @@ public final class SQLCreateTriggerBuilder: SQLQueryBuilder { return self } - /// Specify a body for the trigger. - @available(*, deprecated, message: "Specifying SQL statements as raw strings is unsafe. Use `SQLQueryString` or `SQLRaw` explicitly.") - @inlinable - @discardableResult - public func body(_ statements: [String]) -> Self { - self.body(statements.map { SQLRaw($0) }) - } - /// Specify a body for the trigger. @inlinable @discardableResult @@ -151,14 +139,14 @@ public final class SQLCreateTriggerBuilder: SQLQueryBuilder { /// Specify whether this trigger precedes or follows a referenced trigger. @inlinable @discardableResult - public func order(precedence: SQLTriggerOrder, otherTriggerName: String) -> Self { + public func order(precedence: SQLCreateTrigger.OrderSpecifier, otherTriggerName: String) -> Self { self.order(precedence: precedence, otherTriggerName: SQLIdentifier(otherTriggerName)) } /// Specify whether this trigger precedes or follows a referenced trigger. @inlinable @discardableResult - public func order(precedence: SQLTriggerOrder, otherTriggerName: any SQLExpression) -> Self { + public func order(precedence: SQLCreateTrigger.OrderSpecifier, otherTriggerName: any SQLExpression) -> Self { self.order(precedence: precedence as any SQLExpression, otherTriggerName: otherTriggerName) } @@ -175,14 +163,23 @@ public final class SQLCreateTriggerBuilder: SQLQueryBuilder { extension SQLDatabase { /// Create a new ``SQLCreateTriggerBuilder``. @inlinable - public func create(trigger: String, table: String, when: SQLTriggerWhen, event: SQLTriggerEvent) -> SQLCreateTriggerBuilder { + public func create( + trigger: String, + table: String, + when: SQLCreateTrigger.WhenSpecifier, + event: SQLCreateTrigger.EventSpecifier + ) -> SQLCreateTriggerBuilder { self.create(trigger: SQLIdentifier(trigger), table: SQLIdentifier(table), when: when, event: event) } /// Create a new ``SQLCreateTriggerBuilder``. @inlinable - public func create(trigger: any SQLExpression, table: any SQLExpression, when: any SQLExpression, event: any SQLExpression) -> SQLCreateTriggerBuilder { + public func create( + trigger: any SQLExpression, + table: any SQLExpression, + when: any SQLExpression, + event: any SQLExpression + ) -> SQLCreateTriggerBuilder { .init(trigger: trigger, table: table, when: when, event: event, on: self) } } - diff --git a/Sources/SQLKit/Builders/Implementations/SQLDeleteBuilder.swift b/Sources/SQLKit/Builders/Implementations/SQLDeleteBuilder.swift index b3dd2c16..93e2f413 100644 --- a/Sources/SQLKit/Builders/Implementations/SQLDeleteBuilder.swift +++ b/Sources/SQLKit/Builders/Implementations/SQLDeleteBuilder.swift @@ -1,31 +1,25 @@ /// Builds ``SQLDelete`` queries. -/// -/// db.delete(from: Planet.self) -/// .where("name", .notEqual, "Earth") -/// .run() -/// -/// See ``SQLPredicateBuilder`` for additional information. public final class SQLDeleteBuilder: SQLQueryBuilder, SQLPredicateBuilder, SQLReturningBuilder { /// ``SQLDelete`` query being built. public var delete: SQLDelete - /// See ``SQLQueryBuilder/database``. + // See `SQLQueryBuilder.database`. public var database: any SQLDatabase - /// See ``SQLQueryBuilder/query``. + // See `SQLQueryBuilder.query`. @inlinable public var query: any SQLExpression { self.delete } - /// See ``SQLPredicateBuilder/predicate``. + // See `SQLPredicateBuilder.predicate`. @inlinable public var predicate: (any SQLExpression)? { get { self.delete.predicate } set { self.delete.predicate = newValue } } - /// See ``SQLReturningBuilder/returning``. + // See `SQLReturningBuilder.returning`. @inlinable public var returning: SQLReturning? { get { self.delete.returning } @@ -48,6 +42,7 @@ extension SQLDatabase { } /// Create a new ``SQLDeleteBuilder``. + @inlinable public func delete(from table: any SQLExpression) -> SQLDeleteBuilder { .init(.init(table: table), on: self) } diff --git a/Sources/SQLKit/Builders/Implementations/SQLDropEnumBuilder.swift b/Sources/SQLKit/Builders/Implementations/SQLDropEnumBuilder.swift index 5f093a86..36d21110 100644 --- a/Sources/SQLKit/Builders/Implementations/SQLDropEnumBuilder.swift +++ b/Sources/SQLKit/Builders/Implementations/SQLDropEnumBuilder.swift @@ -1,14 +1,12 @@ -import NIOCore - /// Builds ``SQLDropEnum`` queries. public final class SQLDropEnumBuilder: SQLQueryBuilder { /// ``SQLDropEnum`` query being built. public var dropEnum: SQLDropEnum - /// See ``SQLQueryBuilder/database``. + // See `SQLQueryBuilder.database`. public var database: any SQLDatabase - /// See ``SQLQueryBuilder/query``. + // See `SQLQueryBuilder.query`. @inlinable public var query: any SQLExpression { self.dropEnum @@ -30,24 +28,30 @@ public final class SQLDropEnumBuilder: SQLQueryBuilder { return self } - /// The optional `CASCADE` clause drops other objects that depend on this type - /// (such as table columns, functions, and operators), and in turn all objects - /// that depend on those objects. + /// The drop behavior clause specifies if objects that depend on a type + /// should also be dropped or not when the type is dropped, for databases + /// that support this. @inlinable @discardableResult - public func cascade() -> Self { - self.dropEnum.cascade = true + public func behavior(_ behavior: SQLDropBehavior) -> Self { + self.dropEnum.dropBehavior = behavior return self } - - /// See ``SQLQueryBuilder/run()-2sxsg``. + + /// Adds a `CASCADE` clause to the `DROP TYPE` statement instructing that + /// objects that depend on this type should also be dropped. @inlinable - public func run() -> EventLoopFuture { - guard self.database.dialect.enumSyntax == .typeName else { - self.database.logger.warning("Database does not support standalone enum types.") - return self.database.eventLoop.makeSucceededFuture(()) - } - return self.database.execute(sql: self.query) { _ in } + @discardableResult + public func cascade() -> Self { + self.behavior(.cascade) + } + + /// Adds a `RESTRICT` clause to the `DROP TYPE` statement instructing that + /// if any objects depend on this type, the drop should be refused. + @inlinable + @discardableResult + public func restrict() -> Self { + self.behavior(.restrict) } } diff --git a/Sources/SQLKit/Builders/Implementations/SQLDropIndexBuilder.swift b/Sources/SQLKit/Builders/Implementations/SQLDropIndexBuilder.swift index 06c98919..e1dc2363 100644 --- a/Sources/SQLKit/Builders/Implementations/SQLDropIndexBuilder.swift +++ b/Sources/SQLKit/Builders/Implementations/SQLDropIndexBuilder.swift @@ -3,10 +3,10 @@ public final class SQLDropIndexBuilder: SQLQueryBuilder { /// ``SQLDropIndex`` query being built. public var dropIndex: SQLDropIndex - /// See ``SQLQueryBuilder/database``. + // See `SQLQueryBuilder.database`. public var database: any SQLDatabase - /// See ``SQLQueryBuilder/query``. + // See `SQLQueryBuilder.query`. public var query: any SQLExpression { self.dropIndex } @@ -62,8 +62,7 @@ public final class SQLDropIndexBuilder: SQLQueryBuilder { @inlinable @discardableResult public func cascade() -> Self { - self.dropIndex.behavior = SQLDropBehavior.cascade - return self + self.behavior(.cascade) } /// Adds a `RESTRICT` clause to the `DROP INDEX` statement instructing that @@ -71,18 +70,19 @@ public final class SQLDropIndexBuilder: SQLQueryBuilder { @inlinable @discardableResult public func restrict() -> Self { - self.dropIndex.behavior = SQLDropBehavior.restrict - return self + self.behavior(.restrict) } } extension SQLDatabase { /// Create a new ``SQLDropIndexBuilder``. + @inlinable public func drop(index name: String) -> SQLDropIndexBuilder { self.drop(index: SQLIdentifier(name)) } /// Create a new ``SQLDropIndexBuilder``. + @inlinable public func drop(index name: any SQLExpression) -> SQLDropIndexBuilder { .init(.init(name: name), on: self) } diff --git a/Sources/SQLKit/Builders/Implementations/SQLDropTableBuilder.swift b/Sources/SQLKit/Builders/Implementations/SQLDropTableBuilder.swift index 7d952e39..0c7291c9 100644 --- a/Sources/SQLKit/Builders/Implementations/SQLDropTableBuilder.swift +++ b/Sources/SQLKit/Builders/Implementations/SQLDropTableBuilder.swift @@ -3,10 +3,10 @@ public final class SQLDropTableBuilder: SQLQueryBuilder { /// ``SQLDropTable`` query being built. public var dropTable: SQLDropTable - /// See ``SQLQueryBuilder/database``. + // See `SQLQueryBuilder.database`. public var database: any SQLDatabase - /// See ``SQLQueryBuilder/query``. + // See `SQLQueryBuilder.query`. @inlinable public var query: any SQLExpression { self.dropTable @@ -43,8 +43,7 @@ public final class SQLDropTableBuilder: SQLQueryBuilder { @inlinable @discardableResult public func cascade() -> Self { - self.dropTable.behavior = SQLDropBehavior.cascade - return self + self.behavior(.cascade) } /// Adds a `RESTRICT` clause to the `DROP TABLE` statement instructing that @@ -52,8 +51,7 @@ public final class SQLDropTableBuilder: SQLQueryBuilder { @inlinable @discardableResult public func restrict() -> Self { - self.dropTable.behavior = SQLDropBehavior.restrict - return self + self.behavior(.restrict) } /// If the `TEMPORARY` keyword occurs between `DROP` and `TABLE`, then only diff --git a/Sources/SQLKit/Builders/Implementations/SQLDropTriggerBuilder.swift b/Sources/SQLKit/Builders/Implementations/SQLDropTriggerBuilder.swift index 593c2615..3ec6046e 100644 --- a/Sources/SQLKit/Builders/Implementations/SQLDropTriggerBuilder.swift +++ b/Sources/SQLKit/Builders/Implementations/SQLDropTriggerBuilder.swift @@ -3,10 +3,10 @@ public final class SQLDropTriggerBuilder: SQLQueryBuilder { /// ``SQLDropTrigger`` query being built. public var dropTrigger: SQLDropTrigger - /// See ``SQLQueryBuilder/database``. + // See `SQLQueryBuilder.database`. public var database: any SQLDatabase - /// See ``SQLQueryBuilder/query``. + // See `SQLQueryBuilder.query`. @inlinable public var query: any SQLExpression { self.dropTrigger @@ -28,16 +28,32 @@ public final class SQLDropTriggerBuilder: SQLQueryBuilder { return self } - /// The optional `CASCADE` clause drops other objects that depend on this type - /// (such as table columns, functions, and operators), and in turn all objects - /// that depend on those objects. + /// The drop behavior clause specifies if objects that depend on a trigger + /// should also be dropped or not when the trigger is dropped, for databases + /// that support this. @inlinable @discardableResult - public func cascade() -> Self { - self.dropTrigger.cascade = true + public func behavior(_ behavior: SQLDropBehavior) -> Self { + self.dropTrigger.dropBehavior = behavior return self } - + + /// Adds a `CASCADE` clause to the `DROP TRIGGER` statement instructing that + /// objects that depend on this trigger should also be dropped. + @inlinable + @discardableResult + public func cascade() -> Self { + self.behavior(.cascade) + } + + /// Adds a `RESTRICT` clause to the `DROP TRIGGER` statement instructing that + /// if any objects depend on this trigger, the drop should be refused. + @inlinable + @discardableResult + public func restrict() -> Self { + self.behavior(.restrict) + } + /// Specify an associated table that owns the trigger to drop, for dialects that require it. @inlinable @discardableResult diff --git a/Sources/SQLKit/Builders/Implementations/SQLInsertBuilder.swift b/Sources/SQLKit/Builders/Implementations/SQLInsertBuilder.swift index 474b60db..5ca04987 100644 --- a/Sources/SQLKit/Builders/Implementations/SQLInsertBuilder.swift +++ b/Sources/SQLKit/Builders/Implementations/SQLInsertBuilder.swift @@ -1,18 +1,24 @@ /// Builds ``SQLInsert`` queries. -public final class SQLInsertBuilder: SQLQueryBuilder, SQLReturningBuilder { - /// ``SQLInsert`` query being built. +/// +/// > Note: Although in the strictest sense, this builder could conform to ``SQLUnqualifiedColumnListBuilder``, doing +/// > so would be semantically inappropriate. The protocol documents its `columns()` methods as being additive, but +/// > ``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*/ { + /// The ``SQLInsert`` query this builder builds. public var insert: SQLInsert - /// See ``SQLQueryBuilder/database``. + // See `SQLQueryBuilder.database`. public var database: any SQLDatabase - /// See ``SQLQueryBuilder/query``. + // See `SQLQueryBuilder.query`. @inlinable public var query: any SQLExpression { self.insert } - /// See ``SQLReturningBuilder/returning``. + // See `SQLReturningBuilder.returning`. @inlinable public var returning: SQLReturning? { get { self.insert.returning } @@ -26,90 +32,214 @@ public final class SQLInsertBuilder: SQLQueryBuilder, SQLReturningBuilder { self.database = database } - /// Adds a single encodable value to be inserted. + /// Use an `Encodable` value to generate a row to insert and add that row to the query. /// - /// db.insert(into: Planet.self).model(earth).run() + /// Example usage: /// - /// - Note: The term "model" here does _not_ refer to Fluent's `Model` type. + /// ```swift + /// let earth = Planet(id: nil, name: "Earth", isInhabited: true) + /// + /// try await sqlDatabase.insert(into: "planets") + /// .model(earth, keyEncodingStrategy: .convertToSnakeCase) + /// .run() + /// + /// // Effectively the same as: + /// try await sqlDatabase.insert(into: "planets") + /// .columns("id", "name", "is_inhabited") + /// .values(SQLBind(earth.id), SQLBind(earth.name), SQLBind(earth.isInhabited)) + /// .run() + /// ``` + /// + /// > Note: The term "model" does _not_ refer to Fluent's `Model` type. Fluent models are not compatible with + /// > this method or any of its variants. /// /// - Parameters: - /// - model: ``Encodable`` model to insert. This can be any encodable type. - /// - prefix: An optional prefix to apply to the value's derived column names. - /// - keyEncodingStrategy: See ``SQLQueryEncoder/KeyEncodingStrategy-swift.enum``. - /// - nilEncodingStrategy: See ``SQLQueryEncoder/NilEncodingStrategy-swift.enum``. + /// - model: A value to insert. This can be any encodable type which represents an aggregate value. + /// - prefix: See ``SQLQueryEncoder/prefix``. + /// - keyEncodingStrategy: See ``SQLQueryEncoder/keyEncodingStrategy-swift.property``. + /// - nilEncodingStrategy: See ``SQLQueryEncoder/nilEncodingStrategy-swift.property`. + /// - userInfo: See ``SQLQueryEncoder/userInfo``. @inlinable @discardableResult - public func model( - _ model: E, // TODO: When we start requiring Swift 5.7+, use `some Encodable` here. + public func model( + _ model: some Encodable, prefix: String? = nil, keyEncodingStrategy: SQLQueryEncoder.KeyEncodingStrategy = .useDefaultKeys, - nilEncodingStrategy: SQLQueryEncoder.NilEncodingStrategy = .default + nilEncodingStrategy: SQLQueryEncoder.NilEncodingStrategy = .default, + userInfo: [CodingUserInfoKey: any Sendable] = [:] ) throws -> Self { - try models([model], prefix: prefix, keyEncodingStrategy: keyEncodingStrategy, nilEncodingStrategy: nilEncodingStrategy) + try self.models( + [model], + prefix: prefix, + keyEncodingStrategy: keyEncodingStrategy, + nilEncodingStrategy: nilEncodingStrategy, + userInfo: userInfo + ) } - - /// Adds an array of encodable values to be inserted. + + /// Use an `Encodable` value to generate a row to insert and add that row to the query. /// - /// db.insert(into: Planet.self).models([mercury, venus, earth, mars]).run() + /// Example usage: /// - /// - Note: The term "model" here does _not_ refer to Fluent's `Model` type. + /// ```swift + /// let earth = Planet(id: nil, name: "Earth", isInhabited: true) + /// let encoder = SQLQueryEncoder(nilEncodingStrategy: .asNil) + /// + /// try await sqlDatabase.insert(into: "planets") + /// .model(earth, with: encoder) + /// .run() + /// + /// // Effectively the same as: + /// try await sqlDatabase.insert(into: "planets") + /// .columns("id", "name", "isInhabited") + /// .values(SQLLiteral.null, SQLBind(earth.name), SQLBind(earth.isInhabited)) + /// .run() + /// ``` + /// + /// > Note: The term "model" does _not_ refer to Fluent's `Model` type. Fluent models are not compatible with + /// > this method or any of its variants. /// /// - Parameters: - /// - models: ``Encodable`` models to insert. - /// - prefix: An optional prefix to apply to the values' derived column names. - /// - keyEncodingStrategy: See ``SQLQueryEncoder/KeyEncodingStrategy-swift.enum``. - /// - nilEncodingStrategy: See ``SQLQueryEncoder/NilEncodingStrategy-swift.enum``. + /// - model: A value to insert. This can be any encodable type which represents an aggregate value. + /// - encoder: A preconfigured ``SQLQueryEncoder`` to use for encoding. + @inlinable @discardableResult - public func models( - _ models: [E], // TODO: When we start requiring Swift 5.7+, use `some Encodable` here. + public func model( + _ model: some Encodable, + with encoder: SQLQueryEncoder + ) throws -> Self { + try self.models([model], with: encoder) + } + + /// Use an array of `Encodable` values to generate rows to insert and add those rows to the query. + /// + /// Example usage: + /// + /// ```swift + /// let earth = Planet(id: nil, name: "Earth", isInhabited: true) + /// let mars = Planet(id: nil, name: "Mars", isInhabited: false) + /// + /// try await sqlDatabase.insert(into: "planets") + /// .models([earth, mars], keyEncodingStrategy: .convertToSnakeCase) + /// .run() + /// + /// // Effectively the same as: + /// try await sqlDatabase.insert(into: "planets") + /// .columns("id", "name", "is_inhabited") + /// .values(SQLBind(earth.id), SQLBind(earth.name), SQLBind(earth.isInhabited)) + /// .values(SQLBind(mars.id), SQLBind(mars.name), SQLBind(mars.isInhabited)) + /// .run() + /// ``` + /// + /// > Note: The term "model" does _not_ refer to Fluent's `Model` type. Fluent models are not compatible with + /// > this method or any of its variants. + /// + /// - Parameters: + /// - models: Array of values of a given type to insert. The given type may be any encodable type which + /// represents an aggregate value. + /// - prefix: See ``SQLQueryEncoder/prefix``. + /// - keyEncodingStrategy: See ``SQLQueryEncoder/keyEncodingStrategy-swift.property``. + /// - nilEncodingStrategy: See ``SQLQueryEncoder/nilEncodingStrategy-swift.property`. + /// - userInfo: See ``SQLQueryEncoder/userInfo``. + @inlinable + @discardableResult + public func models( + _ models: [some Encodable], prefix: String? = nil, keyEncodingStrategy: SQLQueryEncoder.KeyEncodingStrategy = .useDefaultKeys, - nilEncodingStrategy: SQLQueryEncoder.NilEncodingStrategy = .default + nilEncodingStrategy: SQLQueryEncoder.NilEncodingStrategy = .default, + userInfo: [CodingUserInfoKey: any Sendable] = [:] ) throws -> Self { - let encoder = SQLQueryEncoder(prefix: prefix, keyEncodingStrategy: keyEncodingStrategy, nilEncodingStrategy: nilEncodingStrategy) - + try self.models(models, with: .init(prefix: prefix, keyEncodingStrategy: keyEncodingStrategy, nilEncodingStrategy: nilEncodingStrategy, userInfo: userInfo)) + } + + /// Use an array of `Encodable` values to generate rows to insert and add those rows to the query. + /// + /// Example usage: + /// ```swift + /// let earth = Planet(id: nil, name: "Earth", isInhabited: true) + /// let mars = Planet(id: nil, name: "Mars", isInhabited: false) + /// let encoder = SQLQueryEncoder(nilEncodingStrategy: .asNil) + /// + /// try await sqlDatabase.insert(into: "planets") + /// .models([earth, mars], with: encoder) + /// .run() + /// + /// // Effectively the same as: + /// try await sqlDatabase.insert(into: "planets") + /// .columns("id", "name", "isInhabited") + /// .values(SQLLiteral.null, SQLBind(earth.name), SQLBind(earth.isInhabited)) + /// .values(SQLLiteral.null, SQLBind(mars.name), SQLBind(mars.isInhabited)) + /// .run() + /// ``` + /// + /// > Note: The term "model" does _not_ refer to Fluent's `Model` type. Fluent models are not compatible with + /// > this method or any of its variants. + /// + /// - Parameters: + /// - models: Array of values of a given type to insert. The given type may be any encodable type which + /// represents an aggregate value. + /// - encodder: A preconfigured ``SQLQueryEncoder`` to use for encoding. + @discardableResult + public func models( + _ models: [some Encodable], + with encoder: SQLQueryEncoder + ) throws -> Self { + var validColumns: [String] = [] + for model in models { let row = try encoder.encode(model) - if self.insert.columns.isEmpty { - self.columns(row.map(\.0)) + if validColumns.isEmpty { + validColumns = row.map(\.0) + self.columns(validColumns) } else { - assert(self.insert.columns.count == row.count, "Wrong number of columns in model (wanted \(self.insert.columns.count), got \(row.count)): \(model)") + /// This is not the most ideal way to handle the "inconsistent NULL-ness" problem, but the established + /// public API of ``SQLQueryEncoder`` makes doing something nicer sufficiently complicated as to be + /// impractical; this will be rectified properly when the major version of SQLKit is next bumped. + guard validColumns == row.map(\.0) else { + throw EncodingError.invalidValue(model, .init(codingPath: [], debugDescription: """ + One or more input models does not encode to the same set of columns. \ + This is usually the result of only some of the inputs having `nil` values for optional properties. \ + Try using `NilEncodingStrategy.asNil` to avoid this error. + """ + )) + } } self.values(row.map(\.1)) } return self } - /// Specify the set of columns that appear in the list(s) of values. + /// Specify mutiple columns to be included in the list of columns for the query. /// - /// Overwrites the existing set of columns, if any. + /// Overwrites any previously specified column list. @inlinable @discardableResult public func columns(_ columns: String...) -> Self { self.columns(columns) } - /// Specify the set of columns that appear in the list(s) of values. + /// Specify mutiple columns to be included in the list of columns for the query. /// - /// Overwrites the existing set of columns, if any. + /// Overwrites any previously specified column list. @inlinable @discardableResult public func columns(_ columns: [String]) -> Self { self.columns(columns.map(SQLIdentifier.init(_:))) } - /// Specify the set of columns that appear in the list(s) of values. + /// Specify mutiple columns to be included in the list of columns for the query. /// - /// Overwrites the existing set of columns, if any. + /// Overwrites any previously specified column list. @inlinable @discardableResult public func columns(_ columns: any SQLExpression...) -> Self { self.columns(columns) } - /// Specify the set of columns that appear in the list(s) of values. + /// Specify mutiple columns to be included in the list of columns for the query. /// - /// Overwrites the existing set of columns, if any. + /// Overwrites any previously specified column list. @inlinable @discardableResult public func columns(_ columns: [any SQLExpression]) -> Self { @@ -121,15 +251,15 @@ public final class SQLInsertBuilder: SQLQueryBuilder, SQLReturningBuilder { @inlinable @discardableResult @_disfavoredOverload - public func values(_ values: any Encodable...) -> Self { // TODO: When we require Swift 5.7+, use `some Encodable` here. + public func values(_ values: any Encodable & Sendable...) -> Self { self.values(values) } /// Add a set of values to be inserted as a single row. @inlinable @discardableResult - public func values(_ values: [any Encodable]) -> Self { // TODO: When we require Swift 5.7+, use `some Encodable` here. - self.values(values.map(SQLBind.init(_:))) + public func values(_ values: [any Encodable & Sendable]) -> Self { + self.values(values.map { SQLBind($0) }) } /// Add a set of values to be inserted as a single row. @@ -146,6 +276,33 @@ public final class SQLInsertBuilder: SQLQueryBuilder, SQLReturningBuilder { self.insert.values.append(values) return self } + + /// Specify a `SELECT` query to generate rows to insert. + /// + /// Example usage: + /// + /// ```swift + /// try await database.insert(into: "table") + /// .columns("id", "foo", "bar") + /// .select { $0 + /// .column(SQLLiteral.default, as: "id") + /// .column("foo", table: "other") + /// .column("bar", table: "other") + /// .from("other") + /// .where(SQLColumn("created_at", table: "other"), .greaterThan, SQLBind(someDate)) + /// } + /// .run() + /// ``` + /// + /// - Parameter closure: A closure which builds a `SELECT` subquery using the provided builder. + @inlinable + @discardableResult + public func select(_ closure: (SQLSubqueryBuilder) throws -> SQLSubqueryBuilder) rethrows -> Self { + let builder = SQLSubqueryBuilder() + _ = try closure(builder) + self.insert.valueQuery = builder.select + return self + } /// Specify that constraint violations for the key over the given column should cause the conflicting /// row(s) to be ignored. diff --git a/Sources/SQLKit/Builders/Implementations/SQLPredicateGroupBuilder.swift b/Sources/SQLKit/Builders/Implementations/SQLPredicateGroupBuilder.swift index abea203d..e9171455 100644 --- a/Sources/SQLKit/Builders/Implementations/SQLPredicateGroupBuilder.swift +++ b/Sources/SQLKit/Builders/Implementations/SQLPredicateGroupBuilder.swift @@ -1,6 +1,6 @@ /// Nested ``SQLPredicateBuilder`` for building expression groups. public final class SQLPredicateGroupBuilder: SQLPredicateBuilder { - /// See ``SQLPredicateBuilder/predicate``. + // See `SQLPredicateBuilder.predicate`. public var predicate: (any SQLExpression)? /// Create a new ``SQLPredicateGroupBuilder``. @@ -30,7 +30,7 @@ extension SQLPredicateBuilder { } } - /// Builds a grouped `WHERE` expression by disjunction (`OR`). + /// Builds a grouped `WHERE` expression by inclusive disjunction (`OR`). /// /// builder.where("name", .equal, "Jupiter").orWhere { /// $0.where("name", .equal, "Earth").where("type", .equal, PlanetType.smallRocky) diff --git a/Sources/SQLKit/Builders/Implementations/SQLRawBuilder.swift b/Sources/SQLKit/Builders/Implementations/SQLRawBuilder.swift index 023546c5..d57254d8 100644 --- a/Sources/SQLKit/Builders/Implementations/SQLRawBuilder.swift +++ b/Sources/SQLKit/Builders/Implementations/SQLRawBuilder.swift @@ -1,16 +1,13 @@ /// Builds raw SQL queries. -/// -/// db.raw("SELECT \(SQLLiteral.all) FROM \(ident: "planets") WHERE \(ident: "name") = \(bind: "Earth")") -/// .all(decoding: Planet.self) public final class SQLRawBuilder: SQLQueryBuilder, SQLQueryFetcher { /// Raw query being built. @usableFromInline var sql: SQLQueryString - /// See ``SQLQueryBuilder/database``. + // See `SQLQueryBuilder.database`. public var database: any SQLDatabase - /// See ``SQLQueryBuilder/query``. + // See `SQLQueryBuilder.query`. @inlinable public var query: any SQLExpression { self.sql @@ -26,6 +23,7 @@ public final class SQLRawBuilder: SQLQueryBuilder, SQLQueryFetcher { extension SQLDatabase { /// Create a new ``SQLRawBuilder``. + @inlinable public func raw(_ sql: SQLQueryString) -> SQLRawBuilder { .init(sql, on: self) } diff --git a/Sources/SQLKit/Builders/Implementations/SQLSecondaryPredicateGroupBuilder.swift b/Sources/SQLKit/Builders/Implementations/SQLSecondaryPredicateGroupBuilder.swift index 623ada5b..307b5eec 100644 --- a/Sources/SQLKit/Builders/Implementations/SQLSecondaryPredicateGroupBuilder.swift +++ b/Sources/SQLKit/Builders/Implementations/SQLSecondaryPredicateGroupBuilder.swift @@ -1,6 +1,6 @@ /// Nested ``SQLSecondaryPredicateBuilder`` for building expression groups. public final class SQLSecondaryPredicateGroupBuilder: SQLSecondaryPredicateBuilder { - /// See ``SQLSecondaryPredicateBuilder/secondaryPredicate``. + // See `SQLSecondaryPredicateBuilder.secondaryPredicate`. public var secondaryPredicate: (any SQLExpression)? /// Create a new ``SQLSecondaryPredicateGroupBuilder``. @@ -30,7 +30,7 @@ extension SQLSecondaryPredicateBuilder { } } - /// Builds a grouped `HAVING` expression by disjunction ('OR'). + /// Builds a grouped `HAVING` expression by inclusive disjunction ('OR'). /// /// builder.having("name", .equal, "Jupiter").orHaving { /// $0.having("name", .equal, "Earth").having("type", .equal, PlanetType.smallRocky) diff --git a/Sources/SQLKit/Builders/Implementations/SQLSelectBuilder.swift b/Sources/SQLKit/Builders/Implementations/SQLSelectBuilder.swift index fce54a62..a6398282 100644 --- a/Sources/SQLKit/Builders/Implementations/SQLSelectBuilder.swift +++ b/Sources/SQLKit/Builders/Implementations/SQLSelectBuilder.swift @@ -1,16 +1,16 @@ /// Builds ``SQLSelect`` queries. /// -/// - Note: This is effectively nothing but a concrete conformance to ``SQLSubqueryClauseBuilder`` -/// which provides storage for the ``SQLSelect`` and adds ``SQLQueryFetcher`` so the query can -/// actually be executed. +/// > Note: This is effectively nothing but a concrete conformance to ``SQLSubqueryClauseBuilder`` +/// > which provides storage for the ``SQLSelect`` and adds ``SQLQueryFetcher`` so the query can +/// > actually be executed. public final class SQLSelectBuilder: SQLQueryBuilder, SQLQueryFetcher, SQLSubqueryClauseBuilder { - /// See ``SQLSubqueryClauseBuilder/select``. + // See `SQLSubqueryClauseBuilder.select`. public var select: SQLSelect - /// See ``SQLQueryBuilder/database``. + // See `SQLQueryBuilder.database`. public var database: any SQLDatabase - /// See ``SQLQueryBuilder/query``. + // See `SQLQueryBuilder.query`. @inlinable public var query: any SQLExpression { self.select diff --git a/Sources/SQLKit/Builders/Implementations/SQLSubqueryBuilder.swift b/Sources/SQLKit/Builders/Implementations/SQLSubqueryBuilder.swift new file mode 100644 index 00000000..7014174e --- /dev/null +++ b/Sources/SQLKit/Builders/Implementations/SQLSubqueryBuilder.swift @@ -0,0 +1,47 @@ +/// Builds ``SQLSubquery`` queries. +/// +/// > Note: This is an even thinner wrapper over ``SQLSubqueryClauseBuilder`` than is ``SQLSelectBuilder``. +public final class SQLSubqueryBuilder: SQLSubqueryClauseBuilder { + /// The ``SQLSubquery`` built by this builder. + public var query: SQLSubquery + + // See `SQLSubqueryClauseBuilder.select`. + @inlinable + public var select: SQLSelect { + get { self.query.subquery } + set { self.query.subquery = newValue } + } + + /// Create a new ``SQLSubqueryBuilder``. + @inlinable + public init() { + self.query = .init(.init()) + } +} + +extension SQLSubquery { + /// Create a ``SQLSubquery`` expression using an inline query builder. + /// + /// Example usage: + /// + /// ```swift + /// try await db.update("foos") + /// .set(SQLIdentifier("bar_id"), to: SQLSubquery.select { $0 + /// .column("id") + /// .from("bars") + /// .where("baz", .notEqual, "bamf") + /// }) + /// .run() + /// ``` + /// + /// > Note: At this time, only `SELECT` subqueries are supported by the API. + @inlinable + public static func select( + _ build: (any SQLSubqueryClauseBuilder) throws -> any SQLSubqueryClauseBuilder + ) rethrows -> some SQLExpression { + let builder = SQLSubqueryBuilder() + + _ = try build(builder) + return builder.query + } +} diff --git a/Sources/SQLKit/Builders/Implementations/SQLUnionBuilder.swift b/Sources/SQLKit/Builders/Implementations/SQLUnionBuilder.swift index 764241f8..a301c68b 100644 --- a/Sources/SQLKit/Builders/Implementations/SQLUnionBuilder.swift +++ b/Sources/SQLKit/Builders/Implementations/SQLUnionBuilder.swift @@ -3,10 +3,10 @@ public final class SQLUnionBuilder: SQLQueryBuilder, SQLQueryFetcher, SQLPartial /// The ``SQLUnion`` being built. public var union: SQLUnion - /// See ``SQLQueryBuilder/database``. + // See `SQLQueryBuilder.database`. public var database: any SQLDatabase - /// See ``SQLQueryBuilder/query``. + // See `SQLQueryBuilder.query`. @inlinable public var query: any SQLExpression { self.union @@ -69,21 +69,21 @@ public final class SQLUnionBuilder: SQLQueryBuilder, SQLQueryFetcher, SQLPartial } extension SQLUnionBuilder { - /// See ``SQLPartialResultBuilder/orderBys``. + // See `SQLPartialResultBuilder.orderBys`. @inlinable public var orderBys: [any SQLExpression] { get { self.union.orderBys } set { self.union.orderBys = newValue } } - /// See ``SQLPartialResultBuilder/limit``. + // See `SQLPartialResultBuilder.limit`. @inlinable public var limit: Int? { get { self.union.limit } set { self.union.limit = newValue } } - /// See ``SQLPartialResultBuilder/offset``. + // See `SQLPartialResultBuilder.offset`. @inlinable public var offset: Int? { get { self.union.offset } @@ -130,7 +130,7 @@ extension SQLUnionBuilder { try self.intersect(all: predicate(.init(on: self.database)).select) } - /// Alias ``intersect(distinct:)-3t74e`` so it acts as the "default". + /// Alias ``intersect(distinct:)-1i7fc`` so it acts as the "default". @inlinable public func intersect(_ predicate: (SQLSelectBuilder) throws -> SQLSelectBuilder) rethrows -> Self { try self.intersect(distinct: predicate) @@ -148,7 +148,7 @@ extension SQLUnionBuilder { try self.except(all: predicate(.init(on: self.database)).select) } - /// Alias ``except(distinct:)-2xe8f`` so it acts as the "default". + /// Alias ``except(distinct:)-8pdro`` so it acts as the "default". @inlinable public func except(_ predicate: (SQLSelectBuilder) throws -> SQLSelectBuilder) rethrows -> Self { try self.except(distinct: predicate) @@ -156,55 +156,55 @@ extension SQLUnionBuilder { } extension SQLSelectBuilder { - /// See ``SQLUnionBuilder/union(distinct:)-79krl``. + // See `SQLUnionBuilder.union(distinct:)`. @inlinable public func union(distinct predicate: (SQLSelectBuilder) throws -> SQLSelectBuilder) rethrows -> SQLUnionBuilder { try .init(on: self.database, initialQuery: self.select).union(distinct: predicate) } - /// See ``SQLUnionBuilder/union(all:)-8lkyh``. + // See `SQLUnionBuilder.union(all:)`. @inlinable public func union(all predicate: (SQLSelectBuilder) throws -> SQLSelectBuilder) rethrows -> SQLUnionBuilder { try .init(on: self.database, initialQuery: self.select).union(all: predicate) } - /// See ``SQLUnionBuilder/union(_:)``. + // See `SQLUnionBuilder.union(_:)`. @inlinable public func union(_ predicate: (SQLSelectBuilder) throws -> SQLSelectBuilder) rethrows -> SQLUnionBuilder { try self.union(distinct: predicate) } - /// See ``SQLUnionBuilder/intersect(distinct:)-15945``. + // See `SQLUnionBuilder.intersect(distinct:)`. @inlinable public func intersect(distinct predicate: (SQLSelectBuilder) throws -> SQLSelectBuilder) rethrows -> SQLUnionBuilder { try .init(on: self.database, initialQuery: self.select).intersect(distinct: predicate) } - /// See ``SQLUnionBuilder/intersect(all:)-8i8ic``. + // See `SQLUnionBuilder.intersect(all:)`. @inlinable public func intersect(all predicate: (SQLSelectBuilder) throws -> SQLSelectBuilder) rethrows -> SQLUnionBuilder { try .init(on: self.database, initialQuery: self.select).intersect(all: predicate) } - /// See ``SQLUnionBuilder/intersect(_:)``. + // See `SQLUnionBuilder.intersect(_:)`. @inlinable public func intersect(_ predicate: (SQLSelectBuilder) throws -> SQLSelectBuilder) rethrows -> SQLUnionBuilder { try self.intersect(distinct: predicate) } - /// See ``SQLUnionBuilder/except(distinct:)-2m81r``. + // See `SQLUnionBuilder.except(distinct:)`. @inlinable public func except(distinct predicate: (SQLSelectBuilder) throws -> SQLSelectBuilder) rethrows -> SQLUnionBuilder { try .init(on: self.database, initialQuery: self.select).except(distinct: predicate) } - /// See ``SQLUnionBuilder/except(all:)-16hlm``. + // See `SQLUnionBuilder.except(all:)`. @inlinable public func except(all predicate: (SQLSelectBuilder) throws -> SQLSelectBuilder) rethrows -> SQLUnionBuilder { try .init(on: self.database, initialQuery: self.select).except(all: predicate) } - /// See ``SQLUnionBuilder/except(_:)``. + // See `SQLUnionBuilder.except(_:)`. @inlinable public func except(_ predicate: (SQLSelectBuilder) throws -> SQLSelectBuilder) rethrows -> SQLUnionBuilder { try self.except(distinct: predicate) diff --git a/Sources/SQLKit/Builders/Implementations/SQLUpdateBuilder.swift b/Sources/SQLKit/Builders/Implementations/SQLUpdateBuilder.swift index fb09341c..502cfda7 100644 --- a/Sources/SQLKit/Builders/Implementations/SQLUpdateBuilder.swift +++ b/Sources/SQLKit/Builders/Implementations/SQLUpdateBuilder.swift @@ -1,37 +1,32 @@ /// Builds ``SQLUpdate`` queries. -/// -/// db.update(Planet.schema) -/// .set("name", to: "Earth") -/// .where("name", .equal, "Earth") -/// .run() public final class SQLUpdateBuilder: SQLQueryBuilder, SQLPredicateBuilder, SQLReturningBuilder, SQLColumnUpdateBuilder { - /// ``SQLUpdate`` query being built. + /// An ``SQLUpdate`` containing the complete current state of the builder. public var update: SQLUpdate - /// See ``SQLQueryBuilder/database``. + // See `SQLQueryBuilder.database`. public var database: any SQLDatabase - /// See ``SQLQueryBuilder/query``. + // See `SQLQueryBuilder.query`. @inlinable public var query: any SQLExpression { self.update } - /// See ``SQLColumnUpdateBuilder/values``. + // See `SQLColumnUpdateBuilder.values`. @inlinable public var values: [any SQLExpression] { get { self.update.values } set { self.update.values = newValue } } - /// See ``SQLPredicateBuilder/predicate``. + // See `SQLPredicateBuilder.predicate`. @inlinable public var predicate: (any SQLExpression)? { get { self.update.predicate } set { self.update.predicate = newValue } } - /// See ``SQLReturningBuilder/returning``. + // See `SQLReturningBuilder.returning`. @inlinable public var returning: SQLReturning? { get { self.update.returning } @@ -39,6 +34,13 @@ public final class SQLUpdateBuilder: SQLQueryBuilder, SQLPredicateBuilder, SQLRe } /// Create a new ``SQLUpdateBuilder``. + /// + /// Use this API directly only if you need to have control over the builder's initial update query. Prefer using + /// ``SQLDatabase/update(_:)-2tf1c`` or ``SQLDatabase/update(_:)-80964`` whnever possible. + /// + /// - Parameters: + /// - update: A query to use as the builder's initial state. It must at minimum specify a table to update. + /// - database: A database to associate with the builder. @inlinable public init(_ update: SQLUpdate, on database: any SQLDatabase) { self.update = update @@ -47,12 +49,20 @@ public final class SQLUpdateBuilder: SQLQueryBuilder, SQLPredicateBuilder, SQLRe } extension SQLDatabase { - /// Create a new ``SQLUpdateBuilder``. + /// Create a new ``SQLUpdateBuilder`` associated with this database. + /// + /// - Parameter table: A table to specify for the builder's update query. + /// - Returns: A new builder. + @inlinable public func update(_ table: String) -> SQLUpdateBuilder { self.update(SQLIdentifier(table)) } - /// Create a new ``SQLUpdateBuilder``. + /// Create a new ``SQLUpdateBuilder`` associated with this database. + /// + /// - Parameter table: An expression used as the target of the builder's update query. + /// - Returns: A new builder. + @inlinable public func update(_ table: any SQLExpression) -> SQLUpdateBuilder { .init(.init(table: table), on: self) } diff --git a/Sources/SQLKit/Builders/Prototypes/SQLAliasedColumnListBuilder.swift b/Sources/SQLKit/Builders/Prototypes/SQLAliasedColumnListBuilder.swift new file mode 100644 index 00000000..ed968123 --- /dev/null +++ b/Sources/SQLKit/Builders/Prototypes/SQLAliasedColumnListBuilder.swift @@ -0,0 +1,31 @@ +/// Common definitions for query builders which permit specifying aliased column names. +/// +/// Aliasable column lists are typically used in areas of SQL syntax where columns belonging to arbitrary +/// database objects may be specified, such as the list of result columns in a `SELECT` or `VALUES` query. +/// +/// An aliased column list builder is also an unqualified column list builder. +/// See ``SQLUnqualifiedColumnListBuilder``. +public protocol SQLAliasedColumnListBuilder: SQLUnqualifiedColumnListBuilder {} + +extension SQLAliasedColumnListBuilder { + /// Specify a column to retrieve with an aliased name. + @inlinable + @discardableResult + public func column(_ column: String, as alias: String) -> Self { + self.column(SQLColumn(column), as: SQLIdentifier(alias)) + } + + /// Specify a column to retrieve with an aliased name. + @inlinable + @discardableResult + public func column(_ column: any SQLExpression, as alias: String) -> Self { + self.column(column, as: SQLIdentifier(alias)) + } + + /// Specify a column to retrieve with an aliased name. + @inlinable + @discardableResult + public func column(_ column: any SQLExpression, as alias: any SQLExpression) -> Self { + self.column(SQLAlias(column, as: alias)) + } +} diff --git a/Sources/SQLKit/Builders/Prototypes/SQLColumnUpdateBuilder.swift b/Sources/SQLKit/Builders/Prototypes/SQLColumnUpdateBuilder.swift index 33d18fea..14d20519 100644 --- a/Sources/SQLKit/Builders/Prototypes/SQLColumnUpdateBuilder.swift +++ b/Sources/SQLKit/Builders/Prototypes/SQLColumnUpdateBuilder.swift @@ -1,46 +1,122 @@ -/// Builds column value assignment pairs for `UPDATE` queries. +/// Common definitions for query builders which support assigning values to columns. /// -/// builder.set("name", to: "Earth") +/// It is unspecified whether columns specified via this protocol are qualified or aliasable. public protocol SQLColumnUpdateBuilder: AnyObject { /// List of assignment pairs. var values: [any SQLExpression] { get set } } extension SQLColumnUpdateBuilder { - /// Encodes the given ``Encodable`` value to a sequence of key-value pairs and adds an assignment - /// for each pair. + /// Using a default-configured ``SQLQueryEncoder``, transform the provided model into a series of key/value + /// pairs and add an assignment for each pair. + /// + /// Column names are left unqualified. + /// + /// - Parameter model: An `Encodable` value whose keys and values will form a series of column assignments. @inlinable @discardableResult - public func set(model: E) throws -> Self where E: Encodable { - try SQLQueryEncoder().encode(model).reduce(self) { $0.set(SQLColumn($1.0), to: $1.1) } + public func set(model: some Encodable & Sendable) throws -> Self { + try self.set(model: model, with: .init()) } - - /// Add an assignment of the column with the given name to the provided bound value. + + /// Configure a new ``SQLQueryEncoder`` as specified, use it to transform the provided model into a series of + /// key/value pairs, and add an assignment for each pair. + /// + /// Column names are left unqualified. + /// + /// - Parameters: + /// - model: An `Encodable` value whose keys and values will form a series of column assignments. + /// - prefix: See ``SQLQueryEncoder/prefix``. + /// - keyEncodingStrategy: See ``SQLQueryEncoder/keyEncodingStrategy-swift.property``. + /// - nilEncodingStrategy: See ``SQLQueryEncoder/nilEncodingStrategy-swift.property``. + /// - userInfo: See ``SQLQueryEncoder/userInfo``. + @inlinable + @discardableResult + public func set( + model: some Encodable & Sendable, + prefix: String? = nil, + keyEncodingStrategy: SQLQueryEncoder.KeyEncodingStrategy = .useDefaultKeys, + nilEncodingStrategy: SQLQueryEncoder.NilEncodingStrategy = .default, + userInfo: [CodingUserInfoKey: any Sendable] = [:] + ) throws -> Self { + try self.set( + model: model, + with: .init( + prefix: prefix, + keyEncodingStrategy: keyEncodingStrategy, + nilEncodingStrategy: nilEncodingStrategy, + userInfo: userInfo + ) + ) + } + + /// Using the given ``SQLQueryEncoder``, transform the provided model into a series of key/value pairs and add an + /// assignment for each pair. + /// + /// Column names are left unqualified. + /// + /// - Parameters: + /// - model: An `Encodable` value whose keys and values will form a series of column assignments. + /// - encoder: A configured ``SQLQueryEncoder`` to use. + @inlinable + @discardableResult + public func set( + model: some Encodable & Sendable, + with encoder: SQLQueryEncoder + ) throws -> Self { + try encoder.encode(model).reduce(self) { $0.set(SQLColumn($1.0), to: $1.1) } + } + + /// Add an assignment setting the named column to the provided `Encodable` value. + /// + /// The column name is left unqualified. + /// + /// - Parameters: + /// - column: The name of the column whose value is to be set. + /// - bind: The value to assign to the named column. @inlinable @discardableResult - public func set(_ column: String, to bind: any Encodable) -> Self { + public func set(_ column: String, to bind: some Encodable & Sendable) -> Self { self.set(SQLColumn(column), to: SQLBind(bind)) } - /// Add an assignment of the column with the given name to the given expression. + /// Add an assignment setting the named column to the provided expression. + /// + /// The column name is left unqualified. + /// + /// - Parameters: + /// - column: The name of the column whose value is to be set. + /// - value: The expression describing the value to assign to the named column. @inlinable @discardableResult public func set(_ column: String, to value: any SQLExpression) -> Self { self.set(SQLColumn(column), to: value) } - /// Add an assignment of the given column to the provided bound value. + /// Add an assignment setting the given column to the provided `Encodable` value. + /// + /// The column name is left unqualified. + /// + /// - Parameters: + /// - column: The column whose value is to be set. + /// - bind: The value to assign to the given column. @inlinable @discardableResult - public func set(_ column: any SQLExpression, to bind: any Encodable) -> Self { + public func set(_ column: any SQLExpression, to bind: some Encodable & Sendable) -> Self { self.set(column, to: SQLBind(bind)) } - /// Add an assignment of the given column to the given expression. + /// Add an assignment setting the given column to the provided expression. + /// + /// The column name is left unqualified. + /// + /// - Parameters: + /// - column: The column whose value is to be set. + /// - value: The expression describing the value to assign to the named column. @inlinable @discardableResult public func set(_ column: any SQLExpression, to value: any SQLExpression) -> Self { - self.values.append(SQLBinaryExpression(left: column, op: SQLBinaryOperator.equal, right: value)) + self.values.append(SQLColumnAssignment(setting: column, to: value)) return self } } diff --git a/Sources/SQLKit/Builders/Prototypes/SQLJoinBuilder.swift b/Sources/SQLKit/Builders/Prototypes/SQLJoinBuilder.swift index ba0fbd75..fe69810e 100644 --- a/Sources/SQLKit/Builders/Prototypes/SQLJoinBuilder.swift +++ b/Sources/SQLKit/Builders/Prototypes/SQLJoinBuilder.swift @@ -5,20 +5,6 @@ public protocol SQLJoinBuilder: AnyObject { } extension SQLJoinBuilder { - /// Include the given table in the list of those used by the query, performing an explicit join using the - /// given method and condition(s). Tables are joined left to right. - /// - /// - Parameters: - /// - table: The name of the table to join. - /// - method: The join method to use. - /// - expression: A string containing a join condition. - @available(*, deprecated, message: "Specifying conditions as raw strings is unsafe. Use `SQLBinaryExpression` etc. instead.") - @inlinable - @discardableResult - public func join(_ table: String, method: SQLJoinMethod = .inner, on expression: String) -> Self { - self.join(SQLIdentifier(table), method: method, on: SQLRaw(expression)) - } - /// Include the given table in the list of those used by the query, performing an explicit join using the /// given method and condition(s). Tables are joined left to right. /// diff --git a/Sources/SQLKit/Builders/Prototypes/SQLPartialResultBuilder.swift b/Sources/SQLKit/Builders/Prototypes/SQLPartialResultBuilder.swift index e22cadb5..ea3b1990 100644 --- a/Sources/SQLKit/Builders/Prototypes/SQLPartialResultBuilder.swift +++ b/Sources/SQLKit/Builders/Prototypes/SQLPartialResultBuilder.swift @@ -16,6 +16,7 @@ extension SQLPartialResultBuilder { /// Adds a `LIMIT` clause to the query. If called more than once, the last call wins. /// /// - Parameter max: Optional maximum limit. If `nil`, any existing limit is removed. + /// The value may not be negative. @inlinable @discardableResult public func limit(_ max: Int?) -> Self { @@ -25,7 +26,7 @@ extension SQLPartialResultBuilder { /// Adds a `OFFSET` clause to the query. If called more than once, the last call wins. /// - /// - Parameter max: Optional offset. If `nil`, any existing offset is removed. + /// - Parameter max: Optional offset. If `nil`, any existing offset is removed. The value may not be negative. /// - Returns: `self` for chaining. @inlinable @discardableResult diff --git a/Sources/SQLKit/Builders/Prototypes/SQLPredicateBuilder.swift b/Sources/SQLKit/Builders/Prototypes/SQLPredicateBuilder.swift index 746de67e..2c16faa5 100644 --- a/Sources/SQLKit/Builders/Prototypes/SQLPredicateBuilder.swift +++ b/Sources/SQLKit/Builders/Prototypes/SQLPredicateBuilder.swift @@ -1,40 +1,43 @@ /// Common definitions for any query builder which permits specifying a primary predicate. /// -/// builder.where("name", .equal, "Earth") +/// - Expressions specified with ``where(_:)`` are considered conjunctive (`AND`). +/// - Expressions specified with ``orWhere(_:)`` are considered inclusively disjunctive (`OR`). /// -/// Expressions specified with ``where(_:)`` are considered conjunctive (`AND`). -/// Expressions specified with ``orWhere(_:)`` are considered inclusively disjunctive (`OR`). -/// See ``SQLPredicateGroupBuilder`` for details of grouping expressions (i.e. with parenthesis). +/// See ``SQLPredicateGroupBuilder`` for details of grouping expressions (e.g. with parenthesis). public protocol SQLPredicateBuilder: AnyObject { - /// Expression being built. + /// The predicate under construction. var predicate: (any SQLExpression)? { get set } } +// MARK: - Conjunctive (AND) + extension SQLPredicateBuilder { - /// Adds a column to column comparison to this builder's `WHERE` clause by `AND`ing. + // MARK: - Column/value comparison + + /// Adds a column to encodable comparison to this builder's `WHERE` clause by `AND`ing. /// - /// builder.where("firstName", .equal, column: "lastName") + /// builder.where("name", .equal, "Earth") /// - /// This method compares two _columns_. + /// The encodable value supplied will be bound to the query as a parameter. /// - /// SELECT * FROM "users" WHERE "firstName" = "lastName" + /// SELECT * FROM "planets" WHERE "name" = $0 ["Earth"] @inlinable @discardableResult - public func `where`(_ lhs: String, _ op: SQLBinaryOperator, column rhs: String) -> Self { - self.where(SQLColumn(lhs), op, SQLColumn(rhs)) + public func `where`(_ lhs: String, _ op: SQLBinaryOperator, _ rhs: some Encodable & Sendable) -> Self { + self.where(SQLColumn(lhs), op, SQLBind(rhs)) } - /// Adds a column to column comparison to this builder's `WHERE` clause by `AND`ing. + /// Adds a column to encodable array comparison to this builder's `WHERE` clause by `AND`ing. /// - /// builder.where("firstName", .equal, column: "lastName") + /// builder.where("name", .equal, ["Earth", "Mars"]) /// - /// This method compares two _columns_. + /// The encodable values supplied will be bound to the query as parameters. /// - /// SELECT * FROM "users" WHERE "firstName" = "lastName" + /// SELECT * FROM "planets" WHERE "name" IN ($0, $1) ["Earth", "Mars"] @inlinable @discardableResult - public func `where`(_ lhs: SQLIdentifier, _ op: SQLBinaryOperator, column rhs: SQLIdentifier) -> Self { - self.where(SQLColumn(lhs), op, SQLColumn(rhs)) + public func `where`(_ lhs: String, _ op: SQLBinaryOperator, _ rhs: [some Encodable & Sendable]) -> Self { + self.where(SQLColumn(lhs), op, SQLBind.group(rhs)) } /// Adds a column to encodable comparison to this builder's `WHERE` clause by `AND`ing. @@ -46,7 +49,7 @@ extension SQLPredicateBuilder { /// SELECT * FROM "planets" WHERE "name" = $0 ["Earth"] @inlinable @discardableResult - public func `where`(_ lhs: SQLIdentifier, _ op: SQLBinaryOperator, _ rhs: E) -> Self { // TODO: Use `some Encodable` when possible. + public func `where`(_ lhs: SQLIdentifier, _ op: SQLBinaryOperator, _ rhs: some Encodable & Sendable) -> Self { self.where(SQLColumn(lhs), op, SQLBind(rhs)) } @@ -59,10 +62,40 @@ extension SQLPredicateBuilder { /// SELECT * FROM "planets" WHERE "name" IN ($0, $1) ["Earth", "Mars"] @inlinable @discardableResult - public func `where`(_ lhs: SQLIdentifier, _ op: SQLBinaryOperator, _ rhs: [E]) -> Self { // TODO: Use `some Encodable` when possible. + public func `where`(_ lhs: SQLIdentifier, _ op: SQLBinaryOperator, _ rhs: [some Encodable & Sendable]) -> Self { self.where(SQLColumn(lhs), op, SQLBind.group(rhs)) } + // MARK: - Column/column comparison + + /// Adds a column to column comparison to this builder's `WHERE` clause by `AND`ing. + /// + /// builder.where("firstName", .equal, column: "lastName") + /// + /// This method compares two _columns_. + /// + /// SELECT * FROM "users" WHERE "firstName" = "lastName" + @inlinable + @discardableResult + public func `where`(_ lhs: String, _ op: SQLBinaryOperator, column rhs: String) -> Self { + self.where(SQLColumn(lhs), op, SQLColumn(rhs)) + } + + /// Adds a column to column comparison to this builder's `WHERE` clause by `AND`ing. + /// + /// builder.where("firstName", .equal, column: "lastName") + /// + /// This method compares two _columns_. + /// + /// SELECT * FROM "users" WHERE "firstName" = "lastName" + @inlinable + @discardableResult + public func `where`(_ lhs: SQLIdentifier, _ op: SQLBinaryOperator, column rhs: SQLIdentifier) -> Self { + self.where(SQLColumn(lhs), op, SQLColumn(rhs)) + } + + // MARK: - Column/expression comparison + /// Adds a column to expression comparison to this builder' `WHERE` clause by `AND`ing. @inlinable @discardableResult @@ -76,6 +109,8 @@ extension SQLPredicateBuilder { public func `where`(_ lhs: SQLIdentifier, _ op: SQLBinaryOperator, _ rhs: any SQLExpression) -> Self { self.where(SQLColumn(lhs), op, rhs) } + + // MARK: - Expressions /// Adds an expression to expression comparison to this builder's `WHERE` clause by `AND`ing. @inlinable @@ -105,41 +140,75 @@ extension SQLPredicateBuilder { } } +// MARK: - Inclusively disjunctive (OR) + extension SQLPredicateBuilder { - /// Adds a column to column comparison to this builder's `WHERE` clause by `OR`ing. + // MARK: - Column/value comparison + + /// Adds a column to encodable comparison to this builder's `WHERE` clause by `OR`ing. /// - /// builder.where(SQLLiteral.boolean(false)).orWhere("firstName", .equal, column: "lastName") + /// builder.orWhere("name", .equal, "Earth") /// - /// This method compares two _columns_. + /// The encodable value supplied will be bound to the query as a parameter. /// - /// SELECT * FROM "users" WHERE 0 OR "firstName" = "lastName" + /// SELECT * FROM "planets" WHERE "name" = $0 ["Earth"] @inlinable @discardableResult - public func orWhere(_ lhs: String, _ op: SQLBinaryOperator, column rhs: String) -> Self { - self.orWhere(SQLColumn(lhs), op, SQLColumn(rhs)) + public func orWhere(_ lhs: String, _ op: SQLBinaryOperator, _ rhs: some Encodable & Sendable) -> Self { + self.orWhere(SQLColumn(lhs), op, SQLBind(rhs)) } - /// Adds a column to column comparison to this builder's `WHERE` clause by `OR`ing. + /// Adds a column to encodable array comparison to this builder's `WHERE` clause by `OR`ing. + /// + /// builder.orWhere("name", .equal, ["Earth", "Mars"]) + /// + /// The encodable values supplied will be bound to the query as parameters. + /// + /// SELECT * FROM "planets" WHERE "name" IN ($0, $1) ["Earth", "Mars"] @inlinable @discardableResult - public func orWhere(_ lhs: SQLIdentifier, _ op: SQLBinaryOperator, column rhs: SQLIdentifier) -> Self { - self.orWhere(SQLColumn(lhs), op, SQLColumn(rhs)) + public func orWhere(_ lhs: String, _ op: SQLBinaryOperator, _ rhs: [some Encodable & Sendable]) -> Self { + self.orWhere(SQLColumn(lhs), op, SQLBind.group(rhs)) } - + /// Adds a column to encodable comparison to this builder's `WHERE` clause by `OR`ing. @inlinable @discardableResult - public func orWhere(_ lhs: SQLIdentifier, _ op: SQLBinaryOperator, _ rhs: E) -> Self { + public func orWhere(_ lhs: SQLIdentifier, _ op: SQLBinaryOperator, _ rhs: some Encodable & Sendable) -> Self { self.orWhere(SQLColumn(lhs), op, SQLBind(rhs)) } /// Adds a column to encodable array comparison to this builder's `WHERE` clause by `OR`ing. @inlinable @discardableResult - public func orWhere(_ lhs: SQLIdentifier, _ op: SQLBinaryOperator, _ rhs: [E]) -> Self { + public func orWhere(_ lhs: SQLIdentifier, _ op: SQLBinaryOperator, _ rhs: [some Encodable & Sendable]) -> Self { self.orWhere(SQLColumn(lhs), op, SQLBind.group(rhs)) } + // MARK: - Column/column comparison + + /// Adds a column to column comparison to this builder's `WHERE` clause by `OR`ing. + /// + /// builder.where(SQLLiteral.boolean(false)).orWhere("firstName", .equal, column: "lastName") + /// + /// This method compares two _columns_. + /// + /// SELECT * FROM "users" WHERE 0 OR "firstName" = "lastName" + @inlinable + @discardableResult + public func orWhere(_ lhs: String, _ op: SQLBinaryOperator, column rhs: String) -> Self { + self.orWhere(SQLColumn(lhs), op, SQLColumn(rhs)) + } + + /// Adds a column to column comparison to this builder's `WHERE` clause by `OR`ing. + @inlinable + @discardableResult + public func orWhere(_ lhs: SQLIdentifier, _ op: SQLBinaryOperator, column rhs: SQLIdentifier) -> Self { + self.orWhere(SQLColumn(lhs), op, SQLColumn(rhs)) + } + + // MARK: - Column/expression comparison + /// Adds a column to expression comparison to the `WHERE` clause by `OR`ing. @inlinable @discardableResult @@ -154,6 +223,8 @@ extension SQLPredicateBuilder { self.orWhere(SQLColumn(lhs), op, rhs) } + // MARK: - Expressions + /// Adds an expression to expression comparison to this builder's `WHERE` clause by `OR`ing. @inlinable @discardableResult diff --git a/Sources/SQLKit/Builders/Prototypes/SQLQueryBuilder.swift b/Sources/SQLKit/Builders/Prototypes/SQLQueryBuilder.swift index f383155b..eb258737 100644 --- a/Sources/SQLKit/Builders/Prototypes/SQLQueryBuilder.swift +++ b/Sources/SQLKit/Builders/Prototypes/SQLQueryBuilder.swift @@ -1,6 +1,8 @@ -import NIOCore +import class NIOCore.EventLoopFuture -/// Base definitions for builders which set up queries and executes them on a connection. +/// Base definitions for builders which set up queries and execute them against a given database. +/// +/// Almost all concrete builders conform to this protocol. public protocol SQLQueryBuilder: AnyObject { /// Query being built. var query: any SQLExpression { get } @@ -9,13 +11,30 @@ public protocol SQLQueryBuilder: AnyObject { var database: any SQLDatabase { get } /// Execute the query on the connection, ignoring any results. + /// + /// Although it is a protocol requirement for historical reasons, this is considered a legacy interface + /// thanks to its reliance on `EventLoopFuture`. Users should call ``run()-3tldd`` whenever possible. func run() -> EventLoopFuture + + /// Execute the query on the connection, ignoring any results. + func run() async throws + } extension SQLQueryBuilder { - /// Execute the query on the connection, ignoring any results. + /// Execute the query associated with the builder on the builder's database, ignoring any results. + /// + /// See ``SQLQueryFetcher`` for methods which retrieve results from a query. @inlinable public func run() -> EventLoopFuture { self.database.execute(sql: self.query) { _ in } } + + /// Execute the query associated with the builder on the builder's database, ignoring any results. + /// + /// See ``SQLQueryFetcher`` for methods which retrieve results from a query. + @inlinable + public func run() async throws { + try await self.database.execute(sql: self.query) { _ in } + } } diff --git a/Sources/SQLKit/Builders/Prototypes/SQLQueryFetcher.swift b/Sources/SQLKit/Builders/Prototypes/SQLQueryFetcher.swift index 0c24187c..ddbabfc2 100644 --- a/Sources/SQLKit/Builders/Prototypes/SQLQueryFetcher.swift +++ b/Sources/SQLKit/Builders/Prototypes/SQLQueryFetcher.swift @@ -1,56 +1,436 @@ -import NIOCore +import class NIOCore.EventLoopFuture -/// Common definitions for ``SQLQueryBuilder``s which support decoding results. +/// Common definitions for ``SQLQueryBuilder``s which support retrieving result rows. public protocol SQLQueryFetcher: SQLQueryBuilder {} +// MARK: - First (EventLoopFuture) + extension SQLQueryFetcher { - // MARK: First + /// Returns the named column from the first output row, if any, decoded as a given type. + /// + /// - Parameters: + /// - column: The name of the column to decode. + /// - type: The type of the desired value. + /// - Returns: A future containing the decoded value, if any. + @inlinable + public func first(decodingColumn column: String, as type: D.Type) -> EventLoopFuture { + self.first().flatMapThrowing { try $0?.decode(column: column, as: D.self) } + } - /// Returns the first output row, if any, decoded as a given type. - public func first(decoding: D.Type) -> EventLoopFuture { - self.first().flatMapThrowing { - try $0?.decode(model: D.self) - } + /// Using a default-configured ``SQLRowDecoder``, returns the first output row, if any, decoded as a given type. + /// + /// - Parameter type: The type of the desired value. + /// - Returns: A future containing the decoded value, if any. + @inlinable + public func first(decoding type: D.Type) -> EventLoopFuture { + self.first(decoding: D.self, with: .init()) } - + + /// Configure a new ``SQLRowDecoder`` as specified and use it to decode and return the first output row, if any, + /// as a given type. + /// + /// - Parameters: + /// - type: The type of the desired value. + /// - prefix: See ``SQLRowDecoder/prefix``. + /// - keyDecodingStrategy: See ``SQLRowDecoder/keyDecodingStrategy-swift.property``. + /// - userInfo: See ``SQLRowDecoder/userInfo``. + /// - Returns: A future containing the decoded value, if any. + @inlinable + public func first( + decoding type: D.Type, + prefix: String? = nil, + keyDecodingStrategy: SQLRowDecoder.KeyDecodingStrategy = .useDefaultKeys, + userInfo: [CodingUserInfoKey: any Sendable] = [:] + ) -> EventLoopFuture { + self.first(decoding: D.self, with: .init(prefix: prefix, keyDecodingStrategy: keyDecodingStrategy, userInfo: userInfo)) + } + + /// Using the given ``SQLRowDecoder``, returns the first output row, if any, decoded as a given type. + /// + /// - Parameters: + /// - type: The type of the desired value. + /// - decoder: A configured ``SQLRowDecoder`` to use. + /// - Returns: A future containing the decoded value, if any. + @inlinable + public func first(decoding type: D.Type, with decoder: SQLRowDecoder) -> EventLoopFuture { + self.first().flatMapThrowing { try $0?.decode(model: D.self, with: decoder) } + } + /// Returns the first output row, if any. + /// + /// If `self` conforms to ``SQLPartialResultBuilder``, ``SQLPartialResultBuilder/limit(_:)`` is used to avoid + /// loading more rows than necessary from the database. + /// + /// - Returns: A future containing the first output row, if any. + @inlinable public func first() -> EventLoopFuture<(any SQLRow)?> { - if let partialBuilder = self as? (any SQLPartialResultBuilder & SQLQueryFetcher) { - return partialBuilder.limit(1).all().map(\.first) - } else { - return self.all().map(\.first) - } + (self as? any SQLPartialResultBuilder)?.limit(1) + #if swift(>=5.10) + nonisolated(unsafe) var rows = [any SQLRow]() + return self.run { if rows.isEmpty { rows.append($0) } }.map { rows.first } + #else + let rows = RowsBox() + return self.run { if rows.all.isEmpty { rows.all.append($0) } }.map { rows.all.first } + #endif + } +} + +// MARK: - First (async) + +extension SQLQueryFetcher { + /// Returns the named column from the first output row, if any, decoded as a given type. + /// + /// - Parameters: + /// - column: The name of the column to decode. + /// - type: The type of the desired value. + /// - Returns: The decoded value, if any. + @inlinable + public func first(decodingColumn column: String, as type: D.Type) async throws -> D? { + try await self.first()?.decode(column: column, as: D.self) + } + + /// Using a default-configured ``SQLRowDecoder``, returns the first output row, if any, decoded as a given type. + /// + /// - Parameter type: The type of the desired value. + /// - Returns: The decoded value, if any. + @inlinable + public func first(decoding type: D.Type) async throws -> D? { + try await self.first(decoding: D.self, with: .init()) + } + + /// Configure a new ``SQLRowDecoder`` as specified and use it to decode and return the first output row, if any, + /// as a given type. + /// + /// - Parameters: + /// - type: The type of the desired value. + /// - prefix: See ``SQLRowDecoder/prefix``. + /// - keyDecodingStrategy: See ``SQLRowDecoder/keyDecodingStrategy-swift.property``. + /// - userInfo: See ``SQLRowDecoder/userInfo``. + /// - Returns: The decoded value, if any. + @inlinable + public func first( + decoding type: D.Type, + prefix: String? = nil, + keyDecodingStrategy: SQLRowDecoder.KeyDecodingStrategy = .useDefaultKeys, + userInfo: [CodingUserInfoKey: any Sendable] = [:] + ) async throws -> D? { + try await self.first(decoding: D.self, with: .init(prefix: prefix, keyDecodingStrategy: keyDecodingStrategy, userInfo: userInfo)) + } + + /// Using the given ``SQLRowDecoder``, returns the first output row, if any, decoded as a given type. + /// + /// - Parameters: + /// - type: The type of the desired value. + /// - decoder: A configured ``SQLRowDecoder`` to use. + /// - Returns: The decoded value, if any. + @inlinable + public func first(decoding type: D.Type, with decoder: SQLRowDecoder) async throws -> D? { + try await self.first()?.decode(model: D.self, with: decoder) } - - // MARK: All - /// Returns all output rows, if any, decoded as a given type. - public func all(decoding: D.Type) -> EventLoopFuture<[D]> { - self.all().flatMapThrowing { - try $0.map { - try $0.decode(model: D.self) - } - } + /// Returns the first output row, if any. + /// + /// If `self` conforms to ``SQLPartialResultBuilder``, ``SQLPartialResultBuilder/limit(_:)`` is used to avoid + /// loading more rows than necessary from the database. + /// + /// - Returns: The first output row, if any. + @inlinable + public func first() async throws -> (any SQLRow)? { + (self as? any SQLPartialResultBuilder)?.limit(1) + #if swift(>=5.10) + nonisolated(unsafe) var rows = [any SQLRow]() + try await self.run { if rows.isEmpty { rows.append($0) } } + return rows.first + #else + let rows = RowsBox() + try await self.run { if rows.all.isEmpty { rows.all.append($0) } } + return rows.all.first + #endif + } +} + +// MARK: - All (EventLoopFuture) + +extension SQLQueryFetcher { + /// Returns the named column from each output row, if any, decoded as a given type. + /// + /// - Parameters: + /// - column: The name of the column to decode. + /// - type: The type of the desired values. + /// - Returns: A future containing the decoded values, if any. + @inlinable + public func all(decodingColumn column: String, as type: D.Type) -> EventLoopFuture<[D]> { + self.all().flatMapThrowing { try $0.map { try $0.decode(column: column, as: D.self) } } + } + + /// Using a default-configured ``SQLRowDecoder``, returns all output rows, if any, decoded as a given type. + /// + /// - Parameter type: The type of the desired values. + /// - Returns: A future containing the decoded values, if any. + @inlinable + public func all(decoding type: D.Type) -> EventLoopFuture<[D]> { + self.all(decoding: D.self, with: .init()) } - /// Collects all raw output into an array and returns it. + /// Configure a new ``SQLRowDecoder`` as specified and use it to decode and return the output rows, if any, + /// as a given type. + /// + /// - Parameters: + /// - type: The type of the desired values. + /// - prefix: See ``SQLRowDecoder/prefix``. + /// - keyDecodingStrategy: See ``SQLRowDecoder/keyDecodingStrategy-swift.property``. + /// - userInfo: See ``SQLRowDecoder/userInfo``. + /// - Returns: A future containing the decoded values, if any. + @inlinable + public func all( + decoding type: D.Type, + prefix: String? = nil, + keyDecodingStrategy: SQLRowDecoder.KeyDecodingStrategy = .useDefaultKeys, + userInfo: [CodingUserInfoKey: any Sendable] = [:] + ) -> EventLoopFuture<[D]> { + self.all(decoding: D.self, with: .init(prefix: prefix, keyDecodingStrategy: keyDecodingStrategy, userInfo: userInfo)) + } + + /// Using the given ``SQLRowDecoder``, returns the output rows, if any, decoded as a given type. + /// + /// - Parameters: + /// - type: The type of the desired values. + /// - decoder: A configured ``SQLRowDecoder`` to use. + /// - Returns: A future containing the decoded values, if any. + @inlinable + public func all(decoding type: D.Type, with decoder: SQLRowDecoder) -> EventLoopFuture<[D]> { + self.all().flatMapThrowing { try $0.map { try $0.decode(model: D.self, with: decoder) } } + } + + /// Returns all output rows, if any. + /// + /// - Returns: A future containing the output rows, if any. + @inlinable public func all() -> EventLoopFuture<[any SQLRow]> { - var all: [any SQLRow] = [] - return self.run { row in - all.append(row) - }.map { all } + #if swift(>=5.10) + nonisolated(unsafe) var rows = [any SQLRow]() + return self.run { row in rows.append(row) }.map { rows } + #else + let rows = RowsBox() + return self.run { row in rows.all.append(row) }.map { rows.all } + #endif + } +} + +// MARK: - All (async) + +extension SQLQueryFetcher { + /// Returns the named column from each output row, if any, decoded as a given type. + /// + /// - Parameters: + /// - column: The name of the column to decode. + /// - type: The type of the desired values. + /// - Returns: The decoded values, if any. + @inlinable + public func all(decodingColumn column: String, as type: D.Type) async throws -> [D] { + try await self.all().map { try $0.decode(column: column, as: D.self) } + } + + /// Using a default-configured ``SQLRowDecoder``, returns all output rows, if any, decoded as a given type. + /// + /// - Parameter type: The type of the desired values. + /// - Returns: The decoded values, if any. + @inlinable + public func all(decoding type: D.Type) async throws -> [D] { + try await self.all(decoding: D.self, with: .init()) } - // MARK: Run + /// Configure a new ``SQLRowDecoder`` as specified and use it to decode and return the output rows, if any, + /// as a given type. + /// + /// - Parameters: + /// - type: The type of the desired values. + /// - prefix: See ``SQLRowDecoder/prefix``. + /// - keyDecodingStrategy: See ``SQLRowDecoder/keyDecodingStrategy-swift.property``. + /// - userInfo: See ``SQLRowDecoder/userInfo``. + /// - Returns: The decoded values, if any. + @inlinable + public func all( + decoding type: D.Type, + prefix: String? = nil, + keyDecodingStrategy: SQLRowDecoder.KeyDecodingStrategy = .useDefaultKeys, + userInfo: [CodingUserInfoKey: any Sendable] = [:] + ) async throws -> [D] { + try await self.all(decoding: D.self, with: .init(prefix: prefix, keyDecodingStrategy: keyDecodingStrategy, userInfo: userInfo)) + } + + /// Using the given ``SQLRowDecoder``, returns the output rows, if any, decoded as a given type. + /// + /// - Parameters: + /// - type: The type of the desired values. + /// - decoder: A configured ``SQLRowDecoder`` to use. + /// - Returns: The decoded values, if any. + @inlinable + public func all(decoding type: D.Type, with decoder: SQLRowDecoder) async throws -> [D] { + try await self.all().map { try $0.decode(model: D.self, with: decoder) } + } - /// Executes the query, decoding each output row as a given type and calling a provided handler with the result. - public func run(decoding: D.Type, _ handler: @escaping (Result) -> ()) -> EventLoopFuture { - self.run { row in handler(Result { try row.decode(model: D.self) }) } + /// Returns all output rows, if any. + /// + /// - Returns: The output rows, if any. + @inlinable + public func all() async throws -> [any SQLRow] { + #if swift(>=5.10) + nonisolated(unsafe) var rows = [any SQLRow]() + try await self.run { rows.append($0) } + return rows + #else + let rows = RowsBox() + try await self.run { rows.all.append($0) } + return rows.all + #endif + } +} + +// MARK: - Run (EventLoopFuture) + +extension SQLQueryFetcher { + /// Using a default-configured ``SQLRowDecoder``, call the provided handler closure with the result of decoding + /// each output row, if any, as a given type. + /// + /// - Parameters: + /// - type: The type of the desired values. + /// - handler: A closure which receives the result of each decoding operation, row by row. + /// - Returns: A completion future. + @preconcurrency + @inlinable + public func run(decoding type: D.Type, _ handler: @escaping @Sendable (Result) -> ()) -> EventLoopFuture { + self.run(decoding: D.self, with: .init(), handler) } - /// Runs the query, passing output to the supplied closure as it is recieved. - /// The returned future signals completion of the query. - public func run(_ handler: @escaping (any SQLRow) -> ()) -> EventLoopFuture { + /// Configure a new ``SQLRowDecoder`` as specified, use it to to decode each output row, if any, as a given type, + /// and call the provided handler closure with each decoding result. + /// + /// - Parameters: + /// - type: The type of the desired values. + /// - prefix: See ``SQLRowDecoder/prefix``. + /// - keyDecodingStrategy: See ``SQLRowDecoder/keyDecodingStrategy-swift.property``. + /// - userInfo: See ``SQLRowDecoder/userInfo``. + /// - handler: A closure which receives the result of each decoding operation, row by row. + /// - Returns: A completion future. + @preconcurrency + @inlinable + public func run( + decoding type: D.Type, + prefix: String? = nil, + keyDecodingStrategy: SQLRowDecoder.KeyDecodingStrategy = .useDefaultKeys, + userInfo: [CodingUserInfoKey: any Sendable] = [:], + _ handler: @escaping @Sendable (Result) -> () + ) -> EventLoopFuture { + self.run(decoding: D.self, with: .init(prefix: prefix, keyDecodingStrategy: keyDecodingStrategy, userInfo: userInfo), handler) + } + + /// Using the given ``SQLRowDecoder``, call the provided handler closure with the result of decoding each output + /// row, if any, as a given type. + /// + /// - Parameters: + /// - type: The type of the desired values. + /// - decoder: A configured ``SQLRowDecoder`` to use. + /// - handler: A closure which receives the result of each decoding operation, row by row. + /// - Returns: A completion future. + @preconcurrency + @inlinable + public func run( + decoding type: D.Type, + with decoder: SQLRowDecoder, + _ handler: @escaping @Sendable (Result) -> () + ) -> EventLoopFuture { + self.run { row in handler(.init { try row.decode(model: D.self, with: decoder) }) } + } + + /// Run the query specified by the builder, calling the provided handler closure with each output row, if any, as + /// it is received. + /// + /// - Parameter handler: A closure which receives each output row one at a time. + /// - Returns: A completion future. + @preconcurrency + @inlinable + public func run(_ handler: @escaping @Sendable (any SQLRow) -> ()) -> EventLoopFuture { self.database.execute(sql: self.query, handler) } } + +// MARK: - Run (async) + +extension SQLQueryFetcher { + /// Using a default-configured ``SQLRowDecoder``, call the provided handler closure with the result of decoding + /// each output row, if any, as a given type. + /// + /// - Parameters: + /// - type: The type of the desired values. + /// - handler: A closure which receives the result of each decoding operation, row by row. + @inlinable + public func run(decoding type: D.Type, _ handler: @escaping @Sendable (Result) -> ()) async throws { + try await self.run(decoding: D.self, with: .init(), handler) + } + + /// Configure a new ``SQLRowDecoder`` as specified, use it to to decode each output row, if any, as a given type, + /// and call the provided handler closure with each decoding result. + /// + /// - Parameters: + /// - type: The type of the desired values. + /// - prefix: See ``SQLRowDecoder/prefix``. + /// - keyDecodingStrategy: See ``SQLRowDecoder/keyDecodingStrategy-swift.property``. + /// - userInfo: See ``SQLRowDecoder/userInfo``. + /// - handler: A closure which receives the result of each decoding operation, row by row. + @inlinable + @preconcurrency + public func run( + decoding type: D.Type, + prefix: String? = nil, + keyDecodingStrategy: SQLRowDecoder.KeyDecodingStrategy = .useDefaultKeys, + userInfo: [CodingUserInfoKey: any Sendable] = [:], + _ handler: @escaping @Sendable (Result) -> () + ) async throws { + try await self.run(decoding: D.self, with: .init(prefix: prefix, keyDecodingStrategy: keyDecodingStrategy, userInfo: userInfo), handler) + } + + /// Using the given ``SQLRowDecoder``, call the provided handler closure with the result of decoding each output + /// row, if any, as a given type. + /// + /// - Parameters: + /// - type: The type of the desired values. + /// - decoder: A configured ``SQLRowDecoder`` to use. + /// - handler: A closure which receives the result of each decoding operation, row by row. + @inlinable + @preconcurrency + public func run( + decoding type: D.Type, + with decoder: SQLRowDecoder, + _ handler: @escaping @Sendable (Result) -> () + ) async throws { + try await self.run { row in handler(Result { try row.decode(model: D.self, with: decoder) }) } + } + + /// Run the query specified by the builder, calling the provided handler closure with each output row, if any, as + /// it is received. + /// + /// - Parameter handler: A closure which receives each output row one at a time. + @inlinable + public func run(_ handler: @escaping @Sendable (any SQLRow) -> ()) async throws { + try await self.database.execute(sql: self.query, handler) + } +} + +// MARK: - Utility + +#if swift(<5.10) + +/// A simple helper type for working with a mutable value capture across concurrency domains. +/// +/// Only used before Swift 5.10. +@usableFromInline +final class RowsBox: @unchecked Sendable { + @usableFromInline + var all: [any SQLRow] = [] + + @usableFromInline + init() {} +} + +#endif diff --git a/Sources/SQLKit/Builders/Prototypes/SQLReturningBuilder.swift b/Sources/SQLKit/Builders/Prototypes/SQLReturningBuilder.swift index 3f27015b..8123faf6 100644 --- a/Sources/SQLKit/Builders/Prototypes/SQLReturningBuilder.swift +++ b/Sources/SQLKit/Builders/Prototypes/SQLReturningBuilder.swift @@ -33,7 +33,7 @@ extension SQLReturningBuilder { /// A builder returned from the methods of ``SQLReturningBuilder``; this builder wraps the original /// builder with one which provides ``SQLQueryFetcher`` conformance. As such, the -/// ``SQLReturningBuilder/returning(_:)-84avj`` methods always be the last ones in any call chain. +/// ``SQLReturningBuilder/returning(_:)-84avj`` methods must always be the last ones in any call chain. /// /// Example: /// @@ -43,20 +43,21 @@ extension SQLReturningBuilder { /// // Incorrect: /// db.insert(into: "foo").returning("id").model(foo).first() // Syntax error /// -/// - Note: The only reason we can't make ``SQLReturningResultBuilder`` conditionally conform to the -/// other builder protocols and thus remove the "last-in-chain" restriction is that it has historically -/// exposed its ``query`` and ``database`` properties as both mutable and public, whereas they are -/// get-only in the ``SQLQueryBuilder`` protocol. As a result, we cannot simply store the original -/// builder instead, because users may have been leveraging the ability to modify the query and/or -/// database, whereas those mutations could not be applied to the original builder. An unfortunate -/// example of Hyrum's Law, that. +/// > Note: The only reason we can't make ``SQLReturningResultBuilder`` conditionally conform to the +/// > other builder protocols and thus remove the "last-in-chain" restriction is that it has historically +/// > exposed its ``query`` and ``database`` properties as both mutable and public, whereas they are +/// > get-only in the ``SQLQueryBuilder`` protocol - a classic example of [Hyrum's Law](https://hyrumslaw.com) +/// > and its consequences. Conforming ``SQLReturningBuilder`` directly to ``SQLQueryFetcher`` would have been +/// > a simpler approach, but then the availability of the fetching methods would not have been contingent upon +/// > the presence of a returning clause. public final class SQLReturningResultBuilder: SQLQueryFetcher { - /// See ``SQLQueryBuilder/query``. + // See `SQLQueryBuilder.query`. public var query: any SQLExpression - /// See ``SQLQueryBuilder/database``. + // See `SQLQueryBuilder.database`. public var database: any SQLDatabase - + + /// Create a new last-in-chain fetching query wrapper. @usableFromInline init(_ builder: QueryBuilder) { self.query = builder.query diff --git a/Sources/SQLKit/Builders/Prototypes/SQLSecondaryPredicateBuilder.swift b/Sources/SQLKit/Builders/Prototypes/SQLSecondaryPredicateBuilder.swift index b4f259bd..3eb72abe 100644 --- a/Sources/SQLKit/Builders/Prototypes/SQLSecondaryPredicateBuilder.swift +++ b/Sources/SQLKit/Builders/Prototypes/SQLSecondaryPredicateBuilder.swift @@ -1,42 +1,43 @@ /// Common definitions for any query builder which permits specifying a secondary predicate. /// -/// A "secondary predicate" is a `HAVING` clause on a query using `GROUP BY`. +/// - Expressions specified with ``having(_:)`` are considered conjunctive (`AND`). +/// - Expressions specified with ``orHaving(_:)`` are considered inclusively disjunctive (`OR`). /// -/// builder.having("name", .equal, "Earth") -/// -/// Expressions specified with ``having(_:)`` are considered conjunctive (`AND`). -/// Expressions specified with ``orHaving(_:)`` are considered inclusively disjunctive (`OR`). -/// See ``SQLSecondaryPredicateGroupBuilder`` for details of grouping expressions (i.e. with parenthesis). +/// See ``SQLSecondaryPredicateGroupBuilder`` for details of grouping expressions (e.g. with parenthesis). public protocol SQLSecondaryPredicateBuilder: AnyObject { - /// Expression being built. + /// The secondary predicate under construction. var secondaryPredicate: (any SQLExpression)? { get set } } +// MARK: - Conjunctive (AND) + extension SQLSecondaryPredicateBuilder { - /// Adds a column to column comparison to this builder's `HAVING` clause by `AND`ing. + // MARK: - Column/value comparison + + /// Adds a column to encodable comparison to this builder's `HAVING` clause by `AND`ing. /// - /// builder.having("firstName", .equal, column: "lastName") + /// builder.having("name", .equal, "Earth") /// - /// This method compares two _columns_. + /// The encodable value supplied will be bound to the query as a parameter. /// - /// SELECT * FROM "users" HAVING "firstName" = "lastName" + /// SELECT * FROM "planets" HAVING "name" = $0 ["Earth"] @inlinable @discardableResult - public func having(_ lhs: String, _ op: SQLBinaryOperator, column rhs: String) -> Self { - self.having(SQLColumn(lhs), op, SQLColumn(rhs)) + public func having(_ lhs: String, _ op: SQLBinaryOperator, _ rhs: some Encodable & Sendable) -> Self { + self.having(SQLColumn(lhs), op, SQLBind(rhs)) } - /// Adds a column to column comparison to this builder's `HAVING` clause by `AND`ing. + /// Adds a column to encodable array comparison to this builder's `HAVING` clause by `AND`ing. /// - /// builder.having("firstName", .equal, column: "lastName") + /// builder.having("name", .equal, ["Earth", "Mars"]) /// - /// This method compares two _columns_. + /// The encodable values supplied will be bound to the query as parameters. /// - /// SELECT * FROM "users" HAVING "firstName" = "lastName" + /// SELECT * FROM "planets" HAVING "name" IN ($0, $1) ["Earth", "Mars"] @inlinable @discardableResult - public func having(_ lhs: SQLIdentifier, _ op: SQLBinaryOperator, column rhs: SQLIdentifier) -> Self { - self.having(SQLColumn(lhs), op, SQLColumn(rhs)) + public func having(_ lhs: String, _ op: SQLBinaryOperator, _ rhs: [some Encodable & Sendable]) -> Self { + self.having(SQLColumn(lhs), op, SQLBind.group(rhs)) } /// Adds a column to encodable comparison to this builder's `HAVING` clause by `AND`ing. @@ -48,37 +49,53 @@ extension SQLSecondaryPredicateBuilder { /// SELECT * FROM "planets" HAVING "name" = $0 ["Earth"] @inlinable @discardableResult - @_disfavoredOverload // try to prefer the generic version - public func having(_ lhs: String, _ op: SQLBinaryOperator, _ rhs: any Encodable) -> Self { - return self.having(SQLColumn(lhs), op, SQLBind(rhs)) + public func having(_ lhs: SQLIdentifier, _ op: SQLBinaryOperator, _ rhs: some Encodable & Sendable) -> Self { + self.having(SQLColumn(lhs), op, SQLBind(rhs)) } - /// Adds a column to encodable comparison to this builder's `HAVING` clause by `AND`ing. + /// Adds a column to encodable array comparison to this builder's `HAVING` clause by `AND`ing. /// - /// builder.having("name", .equal, "Earth") + /// builder.having("name", .in, ["Earth", "Mars"]) /// - /// The encodable value supplied will be bound to the query as a parameter. + /// The encodable values supplied will be bound to the query as parameters. /// - /// SELECT * FROM "planets" HAVING "name" = $0 ["Earth"] + /// SELECT * FROM "planets" HAVING "name" IN ($0, $1) ["Earth", "Mars"] @inlinable @discardableResult - public func having(_ lhs: SQLIdentifier, _ op: SQLBinaryOperator, _ rhs: E) -> Self { - self.having(SQLColumn(lhs), op, SQLBind(rhs)) + public func having(_ lhs: SQLIdentifier, _ op: SQLBinaryOperator, _ rhs: [some Encodable & Sendable]) -> Self { + self.having(SQLColumn(lhs), op, SQLBind.group(rhs)) } - /// Adds a column to encodable array comparison to this builder's `HAVING` clause by `AND`ing. + // MARK: - Column/column comparison + + /// Adds a column to column comparison to this builder's `HAVING` clause by `AND`ing. /// - /// builder.having("name", .in, ["Earth", "Mars"]) + /// builder.having("firstName", .equal, column: "lastName") /// - /// The encodable values supplied will be bound to the query as parameters. + /// This method compares two _columns_. /// - /// SELECT * FROM "planets" HAVING "name" IN ($0, $1) ["Earth", "Mars"] + /// SELECT * FROM "users" HAVING "firstName" = "lastName" @inlinable @discardableResult - public func having(_ lhs: SQLIdentifier, _ op: SQLBinaryOperator, _ rhs: [E]) -> Self { - self.having(SQLColumn(lhs), op, SQLBind.group(rhs)) + public func having(_ lhs: String, _ op: SQLBinaryOperator, column rhs: String) -> Self { + self.having(SQLColumn(lhs), op, SQLColumn(rhs)) } + /// Adds a column to column comparison to this builder's `HAVING` clause by `AND`ing. + /// + /// builder.having("firstName", .equal, column: "lastName") + /// + /// This method compares two _columns_. + /// + /// SELECT * FROM "users" HAVING "firstName" = "lastName" + @inlinable + @discardableResult + public func having(_ lhs: SQLIdentifier, _ op: SQLBinaryOperator, column rhs: SQLIdentifier) -> Self { + self.having(SQLColumn(lhs), op, SQLColumn(rhs)) + } + + // MARK: - Column/expression comparison + /// Adds a column to expression comparison to this builder' `HAVING` clause by `AND`ing. @inlinable @discardableResult @@ -93,6 +110,8 @@ extension SQLSecondaryPredicateBuilder { self.having(SQLColumn(lhs), op, rhs) } + // MARK: - Expressions + /// Adds an expression to expression comparison to this builder's `HAVING` clause by `AND`ing. @inlinable @discardableResult @@ -121,49 +140,63 @@ extension SQLSecondaryPredicateBuilder { } } +// MARK: - Inclusively disjunctive (OR) + extension SQLSecondaryPredicateBuilder { - /// Adds a column to column comparison to this builder's `HAVING` clause by `OR`ing. - /// - /// builder.having(SQLLiteral.boolean(false)).orHaving("firstName", .equal, column: "lastName") - /// - /// This method compares two _columns_. - /// - /// SELECT * FROM "users" HAVING 0 OR "firstName" = "lastName" + // MARK: - Column/value comparison + + /// Adds a column to encodable comparison to this builder's `HAVING` clause by `OR`ing. @inlinable @discardableResult - public func orHaving(_ lhs: String, _ op: SQLBinaryOperator, column rhs: String) -> Self { - self.orHaving(SQLColumn(lhs), op, SQLColumn(rhs)) + public func orHaving(_ lhs: String, _ op: SQLBinaryOperator, _ rhs: some Encodable & Sendable) -> Self { + self.orHaving(SQLColumn(lhs), op, SQLBind(rhs)) } - /// Adds a column to column comparison to this builder's `HAVING` clause by `OR`ing. + /// Adds a column to encodable array comparison to this builder's `HAVING` clause by `OR`ing. @inlinable @discardableResult - public func orHaving(_ lhs: SQLIdentifier, _ op: SQLBinaryOperator, column rhs: SQLIdentifier) -> Self { - self.orHaving(SQLColumn(lhs), op, SQLColumn(rhs)) + public func orHaving(_ lhs: String, _ op: SQLBinaryOperator, _ rhs: [some Encodable & Sendable]) -> Self { + self.orHaving(SQLColumn(lhs), op, SQLBind.group(rhs)) } - + /// Adds a column to encodable comparison to this builder's `HAVING` clause by `OR`ing. @inlinable @discardableResult - @_disfavoredOverload // try to prefer the generic version - public func orHaving(_ lhs: String, _ op: SQLBinaryOperator, _ rhs: any Encodable) -> Self { + public func orHaving(_ lhs: SQLIdentifier, _ op: SQLBinaryOperator, _ rhs: some Encodable & Sendable) -> Self { self.orHaving(SQLColumn(lhs), op, SQLBind(rhs)) } - /// Adds a column to encodable comparison to this builder's `HAVING` clause by `OR`ing. + /// Adds a column to encodable array comparison to this builder's `HAVING` clause by `OR`ing. @inlinable @discardableResult - public func orHaving(_ lhs: SQLIdentifier, _ op: SQLBinaryOperator, _ rhs: E) -> Self { - self.orHaving(SQLColumn(lhs), op, SQLBind(rhs)) + public func orHaving(_ lhs: SQLIdentifier, _ op: SQLBinaryOperator, _ rhs: [some Encodable & Sendable]) -> Self { + self.orHaving(SQLColumn(lhs), op, SQLBind.group(rhs)) } - /// Adds a column to encodable array comparison to this builder's `HAVING` clause by `OR`ing. + // MARK: - Column/column comparison + + /// Adds a column to column comparison to this builder's `HAVING` clause by `OR`ing. + /// + /// builder.having(SQLLiteral.boolean(false)).orHaving("firstName", .equal, column: "lastName") + /// + /// This method compares two _columns_. + /// + /// SELECT * FROM "users" HAVING 0 OR "firstName" = "lastName" @inlinable @discardableResult - public func orHaving(_ lhs: SQLIdentifier, _ op: SQLBinaryOperator, _ rhs: [E]) -> Self { - self.orHaving(SQLColumn(lhs), op, SQLBind.group(rhs)) + public func orHaving(_ lhs: String, _ op: SQLBinaryOperator, column rhs: String) -> Self { + self.orHaving(SQLColumn(lhs), op, SQLColumn(rhs)) } + /// Adds a column to column comparison to this builder's `HAVING` clause by `OR`ing. + @inlinable + @discardableResult + public func orHaving(_ lhs: SQLIdentifier, _ op: SQLBinaryOperator, column rhs: SQLIdentifier) -> Self { + self.orHaving(SQLColumn(lhs), op, SQLColumn(rhs)) + } + + // MARK: - Column/expression comparison + /// Adds a column to expression comparison to the `HAVING` clause by `OR`ing. @inlinable @discardableResult @@ -178,6 +211,8 @@ extension SQLSecondaryPredicateBuilder { self.orHaving(SQLColumn(lhs), op, rhs) } + // MARK: - Expressions + /// Adds an expression to expression comparison to this builder's `HAVING` clause by `OR`ing. @inlinable @discardableResult diff --git a/Sources/SQLKit/Builders/Prototypes/SQLSubqueryClauseBuilder.swift b/Sources/SQLKit/Builders/Prototypes/SQLSubqueryClauseBuilder.swift index 97fa6cda..844185a3 100644 --- a/Sources/SQLKit/Builders/Prototypes/SQLSubqueryClauseBuilder.swift +++ b/Sources/SQLKit/Builders/Prototypes/SQLSubqueryClauseBuilder.swift @@ -2,61 +2,73 @@ /// Useful for building CTEs, `CREATE TABLE ... SELECT` clauses, etc., not to /// mention actual `SELECT` 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`), -/// 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. +/// Due to unfortunate naming choices which this API is now stuck with until a major version bump, +/// this protocol is very easily confused with ``SQLSubqueryBuilder``. For clarification, this protocol +/// provides methods common to the construction of `SELECT` subqueries, whereas ``SQLSubqueryBuilder`` is +/// a concrete type which conforms to this protocol and provides support for embedding ``SQLSubquery`` +/// expressions in other queries. /// -/// - Note: The primary motivation for the existence of this protocol is to make it easier -/// to construct `SELECT` queries without specifying a database or providing the -/// ``SQLQueryBuilder`` and ``SQLQueryFetcher`` methods in inappropriate contexts. -public protocol SQLSubqueryClauseBuilder: SQLJoinBuilder, SQLPredicateBuilder, SQLSecondaryPredicateBuilder, SQLPartialResultBuilder { - /// The ``SQLSelect`` query being built. +/// > 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`), +/// > 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. +/// +/// > Note: The primary motivation for the existence of this protocol is to make it easier +/// > to construct `SELECT` queries without specifying a database or providing the +/// > ``SQLQueryBuilder`` and ``SQLQueryFetcher`` methods in inappropriate contexts. +public protocol SQLSubqueryClauseBuilder: + SQLJoinBuilder, + SQLPredicateBuilder, + SQLSecondaryPredicateBuilder, + SQLPartialResultBuilder, + SQLAliasedColumnListBuilder +{ + /// The ``SQLSelect`` query under construction. var select: SQLSelect { get set } } extension SQLSubqueryClauseBuilder { - /// See ``SQLJoinBuilder/joins``. + // See `SQLJoinBuilder.joins`. public var joins: [any SQLExpression] { get { self.select.joins } set { self.select.joins = newValue } } -} -extension SQLSubqueryClauseBuilder { - /// See ``SQLPredicateBuilder/predicate``. + // See `SQLPredicateBuilder.predicate`. public var predicate: (any SQLExpression)? { get { return self.select.predicate } set { self.select.predicate = newValue } } -} -extension SQLSubqueryClauseBuilder { - /// See ``SQLSecondaryPredicateBuilder/secondaryPredicate``. + // See `SQLSecondaryPredicateBuilder.secondaryPredicate`. public var secondaryPredicate: (any SQLExpression)? { get { return self.select.having } set { self.select.having = newValue } } -} -extension SQLSubqueryClauseBuilder { - /// See ``SQLPartialResultBuilder/orderBys``. + // See `SQLPartialResultBuilder.orderBys`. public var orderBys: [any SQLExpression] { get { self.select.orderBy } set { self.select.orderBy = newValue } } - /// See ``SQLPartialResultBuilder/limit``. + // See `SQLPartialResultBuilder.limit`. public var limit: Int? { get { self.select.limit } set { self.select.limit = newValue } } - /// See ``SQLPartialResultBuilder/offset``. + // See `SQLPartialResultBuilder.offset`. public var offset: Int? { get { self.select.offset } set { self.select.offset = newValue } } + + // See `SQLUnqualifiedColumnListBuilder.columnList`. + public var columnList: [any SQLExpression] { + get { self.select.columns } + set { self.select.columns = newValue } + } } // MARK: - Distinct @@ -73,7 +85,7 @@ extension SQLSubqueryClauseBuilder { /// Adds a `DISTINCT` clause to the select statement and explicitly specifies columns to select, /// overwriting any previously specified columns. /// - /// - Warning: This does _NOT_ invoke PostgreSQL's `DISTINCT ON (...)` syntax! + /// > Warning: This does _NOT_ invoke PostgreSQL's `DISTINCT ON (...)` syntax! @inlinable @discardableResult public func distinct(on column: String, _ columns: String...) -> Self { @@ -83,7 +95,7 @@ extension SQLSubqueryClauseBuilder { /// Adds a `DISTINCT` clause to the select statement and explicitly specifies columns to select, /// overwriting any previously specified columns. /// - /// - Warning: This does _NOT_ invoke PostgreSQL's `DISTINCT ON (...)` syntax! + /// > Warning: This does _NOT_ invoke PostgreSQL's `DISTINCT ON (...)` syntax! @inlinable @discardableResult public func distinct(on column: any SQLExpression, _ columns: any SQLExpression...) -> Self { @@ -93,7 +105,7 @@ extension SQLSubqueryClauseBuilder { /// Adds a `DISTINCT` clause to the select statement and explicitly specifies columns to select, /// overwriting any previously specified columns. /// - /// - Warning: This does _NOT_ invoke PostgreSQL's `DISTINCT ON (...)` syntax! + /// > Warning: This does _NOT_ invoke PostgreSQL's `DISTINCT ON (...)` syntax! @inlinable @discardableResult public func distinct(on columns: [any SQLExpression]) -> Self { @@ -103,88 +115,6 @@ extension SQLSubqueryClauseBuilder { } } -// MARK: - Columns - -extension SQLSubqueryClauseBuilder { - /// Specify a column to be part of the result set of the query. - /// - /// The string `*` (a single asterisk) is replaced with ``SQLLiteral/all``. - @inlinable - @discardableResult - public func column(_ column: String) -> Self { - self.column(column == "*" ? SQLLiteral.all : SQLColumn(column)) - } - - /// Specify a column qualified with a table name to be part of the result set of the query. - /// - /// The string `*` (a single asterisk) is replaced with ``SQLLiteral/all``. - @inlinable - @discardableResult - public func column(table: String, column: String) -> Self { - self.column(SQLColumn(column == "*" ? SQLLiteral.all : SQLIdentifier(column), table: SQLIdentifier(table))) - } - - /// Specify a column to retrieve with an aliased name. - @inlinable - @discardableResult - public func column(_ column: String, as alias: String) -> Self { - return self.column(SQLColumn(column), as: SQLIdentifier(alias)) - } - - /// Specify a column to retrieve with an aliased name. - @inlinable - @discardableResult - public func column(_ column: any SQLExpression, as alias: String) -> Self { - self.column(column, as: SQLIdentifier(alias)) - } - - /// Specify a column to retrieve with an aliased name. - @inlinable - @discardableResult - public func column(_ column: any SQLExpression, as alias: any SQLExpression) -> Self { - self.column(SQLAlias(column, as: alias)) - } - - /// Specify an arbitrary expression as a column to be part of the result set of the query. - @inlinable - @discardableResult - public func column(_ expr: any SQLExpression) -> Self { - self.select.columns.append(expr) - return self - } - - /// Specify a list of columns to be part of the result set of the query. The string `*` is - /// replaced with ``SQLLiteral/all``. - @inlinable - @discardableResult - public func columns(_ columns: String...) -> Self { - self.columns(columns) - } - - /// Specify a list of columns to be part of the result set of the query. The string `*` is - /// replaced with ``SQLLiteral/all``. - @inlinable - @discardableResult - public func columns(_ columns: [String]) -> Self { - self.columns(columns.map { $0 == "*" ? SQLLiteral.all as any SQLExpression : SQLColumn($0) }) - } - - /// Specify a list of arbitrary expressions as columns to be part of the result set of the query. - @inlinable - @discardableResult - public func columns(_ columns: any SQLExpression...) -> Self { - self.columns(columns) - } - - /// Specify a list of arbitrary expressions as columns to be part of the result set of the query. - @inlinable - @discardableResult - public func columns(_ columns: [any SQLExpression]) -> Self { - self.select.columns.append(contentsOf: columns) - return self - } -} - // MARK: - From extension SQLSubqueryClauseBuilder { @@ -262,8 +192,8 @@ extension SQLSubqueryClauseBuilder { /// determined by the specific locking clause and the underlying database's support for /// this construct. /// - /// - Warning: If the database in use does not support locking reads, the locking clause - /// will be silently ignored regardless of its value. + /// > Warning: If the database in use does not support locking reads, the locking clause + /// > will be silently ignored regardless of its value. @inlinable @discardableResult public func `for`(_ lockingClause: SQLLockingClause) -> Self { @@ -281,10 +211,10 @@ extension SQLSubqueryClauseBuilder { /// determined by the specific locking clause and the underlying database's support for /// this construct. /// - /// - Note: This method allows providing an arbitrary SQL expression as the locking clause. - /// - /// - Warning: If the database in use does not support locking reads, the locking clause - /// will be silently ignored regardless of its value. + /// > Note: This method allows providing an arbitrary SQL expression as the locking clause. + /// + /// > Warning: If the database in use does not support locking reads, the locking clause + /// > will be silently ignored regardless of its value. @inlinable @discardableResult public func lockingClause(_ lockingClause: any SQLExpression) -> Self { diff --git a/Sources/SQLKit/Builders/Prototypes/SQLUnqualifiedColumnListBuilder.swift b/Sources/SQLKit/Builders/Prototypes/SQLUnqualifiedColumnListBuilder.swift new file mode 100644 index 00000000..1fbb8965 --- /dev/null +++ b/Sources/SQLKit/Builders/Prototypes/SQLUnqualifiedColumnListBuilder.swift @@ -0,0 +1,56 @@ +/// Common definitions for query builders which permit or require specifying a list of _unqualified_ column names. +/// +/// Unqualified column lists are typically used in areas of SQL syntax where only columns belonging to the "current" +/// table (in context) make sense, such as the initial column list of an `INSERT` query, the list of indexed columns +/// for a `CREATE INDEX` query, or the set of columns for the `UPDATE OF` clause of a `CREATE TRIGGER` query. +/// +/// For specifying aliasable columns - such as in a `SELECT` query - see ``SQLAliasedColumnListBuilder``. +public protocol SQLUnqualifiedColumnListBuilder: AnyObject { + var columnList: [any SQLExpression] { get set } +} + +extension SQLUnqualifiedColumnListBuilder { + /// Specify a single column to be included in the list of columns for the query. + @inlinable + @discardableResult + public func column(_ column: String) -> Self { + self.column(column == "*" ? SQLLiteral.all : SQLColumn(column)) + } + + /// Specify a single column to be included in the list of columns for the query. + @inlinable + @discardableResult + public func column(_ column: any SQLExpression) -> Self { + self.columnList.append(column) + return self + } + + /// Specify mutiple columns to be included in the list of columns for the query. + @inlinable + @discardableResult + public func columns(_ columns: String...) -> Self { + self.columns(columns) + } + + /// Specify mutiple columns to be included in the list of columns for the query. + @inlinable + @discardableResult + public func columns(_ columns: [String]) -> Self { + self.columns(columns.map { $0 == "*" ? SQLLiteral.all as any SQLExpression : SQLColumn($0) }) + } + + /// Specify mutiple columns to be included in the list of columns for the query. + @inlinable + @discardableResult + public func columns(_ columns: any SQLExpression...) -> Self { + self.columns(columns) + } + + /// Specify mutiple columns to be included in the list of columns for the query. + @inlinable + @discardableResult + public func columns(_ columns: [any SQLExpression]) -> Self { + self.columnList.append(contentsOf: columns) + return self + } +} diff --git a/Sources/SQLKit/Concurrency/SQLDatabase+Concurrency.swift b/Sources/SQLKit/Concurrency/SQLDatabase+Concurrency.swift deleted file mode 100644 index de95ac7f..00000000 --- a/Sources/SQLKit/Concurrency/SQLDatabase+Concurrency.swift +++ /dev/null @@ -1,7 +0,0 @@ -import NIOCore - -public extension SQLDatabase { - func execute(sql query: any SQLExpression, _ onRow: @escaping @Sendable (any SQLRow) -> ()) async throws { - try await self.execute(sql: query, onRow).get() - } -} diff --git a/Sources/SQLKit/Concurrency/SQLQueryBuilder+Concurrency.swift b/Sources/SQLKit/Concurrency/SQLQueryBuilder+Concurrency.swift deleted file mode 100644 index 6d222d6d..00000000 --- a/Sources/SQLKit/Concurrency/SQLQueryBuilder+Concurrency.swift +++ /dev/null @@ -1,7 +0,0 @@ -import NIOCore - -public extension SQLQueryBuilder { - func run() async throws -> Void { - try await self.run().get() - } -} diff --git a/Sources/SQLKit/Concurrency/SQLQueryFetcher+Concurrency.swift b/Sources/SQLKit/Concurrency/SQLQueryFetcher+Concurrency.swift deleted file mode 100644 index 359d6850..00000000 --- a/Sources/SQLKit/Concurrency/SQLQueryFetcher+Concurrency.swift +++ /dev/null @@ -1,27 +0,0 @@ -import NIOCore - -public extension SQLQueryFetcher { - func first(decoding: D.Type) async throws -> D? where D: Decodable { - try await self.first(decoding: D.self).get() - } - - func first() async throws -> (any SQLRow)? { - try await self.first().get() - } - - func all(decoding: D.Type) async throws -> [D] where D: Decodable { - try await self.all(decoding: D.self).get() - } - - func all() async throws -> [any SQLRow] { - try await self.all().get() - } - - func run(decoding: D.Type, _ handler: @escaping @Sendable (Result) -> ()) async throws -> Void where D: Decodable { - try await self.run(decoding: D.self, handler).get() - } - - func run(_ handler: @escaping @Sendable (any SQLRow) -> ()) async throws -> Void { - try await self.run(handler).get() - } -} diff --git a/Sources/SQLKit/Database/SQLDatabase.swift b/Sources/SQLKit/Database/SQLDatabase.swift index d72d3bde..376186c4 100644 --- a/Sources/SQLKit/Database/SQLDatabase.swift +++ b/Sources/SQLKit/Database/SQLDatabase.swift @@ -1,22 +1,29 @@ -import Logging -import NIOCore +import protocol NIOCore.EventLoop +import class NIOCore.EventLoopFuture +import struct Logging.Logger -/// The core of an SQLKit driver. This common interface is the access point of both SQLKit itself and -/// SQLKit clients to all of the information and behaviors necessary to provide and leverage the -/// package's functionality. +/// The common interface to SQLKit for both drivers and client code. +/// +/// ``SQLDatabase`` is the core of an SQLKit driver and the primary entry point for user code. This common interface +/// provides the information and behaviors necessary to define and leverage the package's functionality. /// /// Conformances to ``SQLDatabase`` are typically provided by an external database-specific driver -/// package, alongside a few utility wrapper types for handling deferred and pooled connection -/// logic and for substituting ``Logger``s. A driver package must also provide concrete -/// implementations of ``SQLDialect`` and ``SQLRow`` (both of which are hooked up via ``SQLDatabase``). +/// package, alongside several wrapper types for handling connection logic and other details. +/// A driver package must at minimum provide concrete implementations of ``SQLDatabase``, ``SQLDialect``, +/// and ``SQLRow``. /// -/// - Note: Most of ``SQLDatabase``'s functionality is relatively low-level. Clients of SQLKit -/// who want to query a database should use the higher-level API rooted at ``SQLQueryBuilder``. +/// The API described by the base ``SQLDatabase`` protocol is low-level, meant for SQLKit drivers to +/// implement; most users will not need to interact with these APIs directly. The high-level starting point +/// for SQLKit is ``SQLQueryBuilder``; the various query builders provide extension methods on ``SQLDatabase`` +/// which are the intended public interface. /// -/// Example of manually constructing and executing a query from expressions without a query builder: +/// For comparison, this is an example of using ``SQLDatabase`` and ``SQLExpression``s directly: /// /// ```swift +/// let database: SQLDatabase = ... +/// /// var select = SQLSelect() +/// /// select.columns = [SQLColumn(SQLIdentifier("x"))] /// select.tables = [SQLIdentifier("y")] /// select.predicate = SQLBinaryExpression( @@ -24,117 +31,235 @@ import NIOCore /// op: SQLBinaryOperator.equal, /// right: SQLLiteral.numeric("1") /// ) -/// var resultRows: [SQLRow] = [] -/// let sqlDb = // obtain an SQLDatabase from somewhere /// -/// try await sqlDb.execute(sql: select, resultRows.append(_:)) -/// // Executed query: SELECT x FROM y WHERE z = 1, as represented in the database's SQL dialect. +/// nonisolated(unsafe) var resultRows: [SQLRow] = [] +/// +/// try await database.execute(sql: select, { resultRows.append($0) }) +/// // Executed query: SELECT x FROM y WHERE z = 1 +/// +/// var resultValues: [Int] = try resultRows.map { +/// try $0.decode(column: "x", as: Int.self) +/// } /// ``` /// -/// It should almost never be necessary for a client to call ``SQLDatabase/execute(sql:_:)-90wi9`` -/// directly; such a need usually indicates a design flaw or functionality gap in SQLKit itself. -public protocol SQLDatabase { - /// The ``Logger`` to be used for logging all SQLKit operations relating to a given database. +/// And this is the same example, written to make use of ``SQLSelectBuilder``: +/// +/// ```swift +/// let database: SQLDatabase = ... +/// let resultValues: [Int] = try await database.select() +/// .column("x") +/// .from("y") +/// .where("z", .equal, 1) +/// .all(decodingColumn: "x", as: Int.self) +/// ``` +public protocol SQLDatabase: Sendable { + /// The `Logger` used for logging all operations relating to a given database. var logger: Logger { get } - /// The ``NIOCore/EventLoop`` used for asynchronous operations on a given database. If there is no - /// specific ``NIOCore/EventLoop`` which handles the database (such as because it is a connection - /// pool which assigns loops to connections at point of use, or because the underlying implementation - /// is based on Swift Concurrency or some other asynchronous execution technology), it is recommended - /// to return an event loop from ``NIOCore/EventLoopGroup/any()``. + /// The `EventLoop` used for asynchronous operations on a given database. + /// + /// If there is no specific `EventLoop` which handles the database (such as because it is a connection pool which + /// assigns loops to connections at point of use, or because the underlying implementation is based on Swift + /// Concurrency or some other asynchronous execution technology), a single consistent `EventLoop` must be chosen + /// for the database and returned for this property nonetheless. var eventLoop: any EventLoop { get } - /// The version number the connection reports for itself, provided as a type conforming to the - /// ``SQLDatabaseReportedVersion`` protocol. If the version number is not applicable (such as for - /// a connection pool dispatch wrapper) or not yet known, `nil` may be returned. Version numbers - /// may also change at runtime (for example, if a connection is auto-reconnected after a remote - /// update), or even become unknown again after being known. - /// - /// - Warning: This version number has nothing to do with ``SQLKit`` or (usually) of the driver - /// implementation for the database, nor does it represent any data stored within the database; - /// it is the version of the database implementation _itself_ (such as of a MySQL server or - /// `libsqlite3` library). A significant part of the motivation to finally add this property comes - /// from a larger desire to enable customizing a given ``SQLDialect``'s configuration based on the - /// actual feature set available at runtime instead of having to hardcode a "safe" baseline. + /// The version number the database reports for itself. + /// + /// The version must be provided via a type conforming to the ``SQLDatabaseReportedVersion`` protocol. If the + /// version number is not applicable (such as for a connection pool dispatch wrapper) or not yet known, `nil` may + /// be returned. Version numbers may also change at runtime (for example, if a connection is auto-reconnected + /// after a remote update), or even become unknown again after being known. + /// + /// > Note: This version number has nothing to do with SQLKit or the driver implementation for the + /// > database, nor does it represent any data stored within the database; it is the version of the + /// > database to which the ``SQLDatabase`` object represents a connection (such as a MySQL server, or + /// > a linked `libsqlite3` library). The primary motivation for finally adding this property stemmed + /// > from the desire to enable customizing ``SQLDialect`` configurations based on the actual feature set + /// > available at runtime, rather than the old solution of hardcoding a "safe" (but limited) baseline. var version: (any SQLDatabaseReportedVersion)? { get } - /// The descriptor for the SQL dialect supported by the given database. It is permitted for different - /// connections to the same database to have different dialects, though it's unclear how this would - /// be useful in practice. + /// The descriptor for the dialect of SQL supported by the given database. + /// + /// The dialect must be provided via a type conforming to the ``SQLDialect`` protocol. It is permitted for + /// different connections to the same database to report different dialects, although it's unclear how this would + /// be useful in practice; a dialect that differs based on database version should differentiate based on the + /// ``version-22wnn`` property instead. var dialect: any SQLDialect { get } /// The logging level used for reporting queries run on the given database to the database's logger. - /// Defaults to ``Logging/Logger/Level/debug``. + /// Defaults to `.debug`. /// /// This log level applies _only_ to logging the serialized SQL text and bound parameter values (if /// any) of queries; it does not affect any logging performed by the underlying driver or any other /// subsystem. If the value is `nil`, query logging is disabled. /// - /// - Important: Conforming drivers must provide a means to configure this value and to use the default - /// ``Logging/Logger/Level/debug`` level if no explicit value is provided. It is also the responsibility - /// of the driver to actually perform the query logging, including respecting the logging level. - /// - /// The lack of enforcement of these requirements is obviously less than ideal, but unavoidable due to - /// the lack of direct entry points to SQLKit not provided by driver implementations. + /// > Important: Conforming drivers must provide a means to configure this value and to use the default + /// > `.debug` level if no explicit value is provided. It is also the responsibility of the driver to + /// > actually perform the query logging, including respecting the logging level. + /// > + /// > The lack of enforcement of these requirements is obviously less than ideal, but for the moment + /// > it's unavoidable, as there are no direct entry points to SQLKit without a driver. var queryLogLevel: Logger.Level? { get } /// Requests that the given generic SQL query be serialized and executed on the database, and that - /// the ``onRow`` closure be invoked once for each result row the query returns (if any). + /// the `onRow` closure be invoked once for each result row the query returns (if any). + /// + /// Although it is a protocol requirement for historical reasons, this is considered a legacy interface thanks + /// to its reliance on `EventLoopFuture`. Implementers should implement both this method and + /// ``execute(sql:_:)-7trgm`` if they can, and users should use ``execute(sql:_:)-7trgm`` whenever possible. + /// + /// - Parameters: + /// - query: An ``SQLExpression`` representing a complete query to execute. + /// - onRow: A closure which is invoked once for each result row returned by the query (if any). + /// - Returns: An `EventLoopFuture`. + @preconcurrency func execute( sql query: any SQLExpression, - _ onRow: @escaping (any SQLRow) -> () + _ onRow: @escaping @Sendable (any SQLRow) -> () ) -> EventLoopFuture + + /// Requests that the given generic SQL query be serialized and executed on the database, and that + /// the `onRow` closure be invoked once for each result row the query returns (if any). + /// + /// If a concrete type conforming to ``SQLDatabase`` can provide a more efficient Concurrency-based implementation + /// than forwarding the invocation through the legacy `EventLoopFuture`-based API, it should override this method + /// in order to do so. + /// + /// - Parameters: + /// - query: An ``SQLExpression`` representing a complete query to execute. + /// - onRow: A closure which is invoked once for each result row returned by the query (if any). + func execute( + sql query: any SQLExpression, + _ onRow: @escaping @Sendable (any SQLRow) -> () + ) async throws + + /// Requests the provided closure be called with a database which is guaranteed to represent a single + /// "session", suitable for e.g. executing a series of queries representing a transaction. + /// + /// This method is provided for the benefit of SQLKit drivers which vend concrete database objects which may not + /// necessarily always execute consecutive queries in the same remote context, such as in the case of connection + /// pooling or multiplexing. The default implementation simply passes `self` to the closure; it is the + /// responsibility of individual drivers to do otherwise as needed. + /// + /// - Parameter closure: A closure to invoke. The single parameter shall be an implementation of ``SQLDatabase`` + /// which represents a single "session". Implementations may pass the same database on which this method was + /// originally invoked. + func withSession( + _ closure: @escaping @Sendable (any SQLDatabase) async throws -> R + ) async throws -> R } extension SQLDatabase { /// The ``version-22wnn`` property was added to ``SQLDatabase`` multiple years after the protocol's /// original definition; it was in fact the first change of any kind to the protocol since Fluent 4's - /// original release. As such, we must provide a default value so that drivers which haven't been - /// updated don't lose source compatibility. Conveniently, a value of `nil` represents "database - /// version is unknown", an obvious choice for this scenario. - public var version: SQLDatabaseReportedVersion? { nil } + /// original release. Therefore it is necessary to provide a default value for the benefit of drivers + /// which haven't been updated, to avoid a source compatibility break. Conveniently, a `nil` version + /// represents an obviously desirable default: "database version is unknown". + public var version: (any SQLDatabaseReportedVersion)? { nil } /// Drivers which do not provide the ``queryLogLevel-991s4`` property must be given the automatic default - /// of ``Logging/Logger/Level/debug``. It would be preferable not to provide a default conformance, - /// but this is unfortunately impractical, as the property was another late addition to the protocol. + /// of `.debug`. It would be preferable not to provide a default conformance, but as the property was + /// another late addition to the protocol, it is required for source compatibility. public var queryLogLevel: Logger.Level? { .debug } } extension SQLDatabase { - /// Convenience utility for serializing arbitrary ``SQLExpression``s. + /// Serialize an arbitrary ``SQLExpression`` using the database's dialect. /// /// The expression need not represent a complete query. Serialization transforms the expression into: /// - /// 1. A corresponding string of raw SQL in the database's dialect, and, - /// 2. An array of inputs to use as the values of any bound parameters of the query. - public func serialize(_ expression: any SQLExpression) -> (sql: String, binds: [any Encodable]) { + /// 1. A string containing raw SQL text rendered in the database's dialect, and, + /// 2. A potentially empty array of values for any bound parameters referenced by the query. + public func serialize(_ expression: any SQLExpression) -> (sql: String, binds: [any Encodable & Sendable]) { var serializer = SQLSerializer(database: self) expression.serialize(to: &serializer) return (serializer.sql, serializer.binds) } - } +} extension SQLDatabase { - /// Returns a ``SQLDatabase`` which is exactly the same database as the original, except that - /// all logging done to the new ``SQLDatabase`` will go to the specified ``Logger`` instead. + /// Return a new ``SQLDatabase`` which is indistinguishable from the original save that its + /// ``SQLDatabase/logger`` property is replaced by the given `Logger`. + /// + /// This has the effect of redirecting logging performed on or by the original database to the + /// provided `Logger`. + /// + /// > Warning: The log redirection applies only to the new ``SQLDatabase`` that is returned from + /// > this method; logging operations performed on the original (i.e. `self`) are unaffected. + /// + /// > Note: Because this method returns a generic ``SQLDatabase``, the type it returns need not be public + /// > API. Unfortunately, this also means that no inlining or static dispatch of the implementation is + /// > possible, thus imposing a performance penalty on the use of this otherwise trivial utility. + /// + /// - Parameter logger: The new `Logger` to use. + /// - Returns: A database object which logs to the new `Logger`. public func logging(to logger: Logger) -> any SQLDatabase { CustomLoggerSQLDatabase(database: self, logger: logger) } } -/// An ``SQLDatabase`` which trivially wraps another ``SQLDatabase`` in order to substitute the -/// original's ``Logger`` with another. -/// -/// - Note: Since ``SQLDatabase/logging(to:)`` returns a generic ``SQLDatabase``, this type's -/// actual implementation need not be part of the public API. +extension SQLDatabase { + /// The default implementation for ``execute(sql:_:)-4eg19``. + @inlinable + public func execute( + sql query: any SQLExpression, + _ onRow: @escaping @Sendable (any SQLRow) -> () + ) async throws { + try await self.execute(sql: query, onRow).get() + } + + /// The default implementation for ``withSession(_:)-9b68j``. + @inlinable + public func withSession( + _ closure: @escaping @Sendable (any SQLDatabase) async throws -> R + ) async throws -> R { + try await closure(self) + } +} + +/// Replaces the `Logger` of an existing ``SQLDatabase`` while forwarding all other properties and methods +/// to the original. private struct CustomLoggerSQLDatabase: SQLDatabase { + /// The underlying database. let database: D + + // See `SQLDatabase.logger`. let logger: Logger - var eventLoop: any EventLoop { self.database.eventLoop } - var version: (any SQLDatabaseReportedVersion)? { self.database.version } - var dialect: any SQLDialect { self.database.dialect } - func execute(sql query: any SQLExpression, _ onRow: @escaping (any SQLRow) -> ()) -> EventLoopFuture { + + // See `SQLDatabase.eventLoop`. + var eventLoop: any EventLoop { + self.database.eventLoop + } + + // See `SQLDatabase.version`. + var version: (any SQLDatabaseReportedVersion)? { + self.database.version + } + + // See `SQLDatabase.dialect`. + var dialect: any SQLDialect { + self.database.dialect + } + + // See `SQLDatabase.queryLogLevel`. + var queryLogLevel: Logger.Level? { + self.database.queryLogLevel + } + + // See `SQLDatabase.execute(sql:_:)`. + func execute( + sql query: any SQLExpression, + _ onRow: @escaping @Sendable (any SQLRow) -> () + ) -> EventLoopFuture { self.database.execute(sql: query, onRow) } - var queryLogLevel: Logger.Level? { self.database.queryLogLevel } + + // See `SQLDatabase.execute(sql:_:)`. + func execute( + sql query: any SQLExpression, + _ onRow: @escaping @Sendable (any SQLRow) -> () + ) async throws { + try await self.database.execute(sql: query, onRow) + } } diff --git a/Sources/SQLKit/Database/SQLDatabaseReportedVersion.swift b/Sources/SQLKit/Database/SQLDatabaseReportedVersion.swift index d30c1691..a3843475 100644 --- a/Sources/SQLKit/Database/SQLDatabaseReportedVersion.swift +++ b/Sources/SQLKit/Database/SQLDatabaseReportedVersion.swift @@ -1,3 +1,5 @@ +/// Provides a protocol for reporting and comparing database version numbers. +/// /// SQLKit allows databases to report their versions. As any given database implementation /// may have its own particular format for version numbers, the version is provided to /// ``SQLDatabase`` as a value of a type conforming to this protocol, which defines an @@ -5,73 +7,93 @@ /// of implementation-specific details. /// /// The most common uses for database version information are disabling or enabling feature -/// suport in ``SQLDialect`` by version, tracking usage metrics by version, logging versions, +/// support in ``SQLDialect`` by version, tracking usage metrics by version, logging versions, /// and recording versions for debugging. /// /// Each type implementing ``SQLDatabaseReportedVersion`` is responsible for providing -/// defintions of equality and ordering semantics between versions which are meaningful +/// definitions of equality and ordering semantics between versions which are meaningful /// in the versioning scheme of the underlying database. -/// -/// - Important: Because of limitations of ``SQLDatabase``'s design and the use of -/// existential values before Swift 5.7, this protocol does not require `Equatable` -/// or `Comparable` conformance, despite the obvious utility of both. -public protocol SQLDatabaseReportedVersion { +public protocol SQLDatabaseReportedVersion: Comparable, Sendable { /// The version represented as a `String`. var stringValue: String { get } /// Returns `true` if the provided version is the same version as `self`. /// - /// Corresponds to ``Swift/Equatable/==(_:_:)``. + /// Implementations of this method must check that the provided version and `self` represent the same type. + /// If no implementation is provided, the default is to compare the `type(of:)` and `stringValue` of both + /// versions for exact equality. + /// + /// > Warning: This method has been deprecated for callers, although it remains a protocol requirement for + /// > drivers. Users should use the `==` operator instead. /// /// - Parameters: - /// - otherVersion: The version to compare against. `type(of: self)` must be the same as `type(of: otherVersion)`. + /// - otherVersion: The version to compare against. /// - Returns: `true` if both versions are equal, `false` otherwise. - func isEqual(to otherVersion: SQLDatabaseReportedVersion) -> Bool + func isEqual(to otherVersion: any SQLDatabaseReportedVersion) -> Bool - /// Check whether the current version (i.e. `self`) is older than the one given. + /// Returns `true` if the provided version is newer than the version represented by `self`. + /// + /// Implementations of this method must check that the provided version and `self` represent the same type. + /// If no implementation is provided, the default is to compare the `type(of:)` both versions for equality and + /// the `stringValue` of both versions for lexocographic ordering. /// - /// Corresponds to ``Swift/Comparable/<(_:_:)``. + /// > Warning: This method has been deprecated for callers, although it remains a protocol requirement for + /// > drivers. Users should use the `==` operator instead. /// /// - Parameters: - /// - otherVersion: The version to compare against. `type(of: self)` must be the same as `type(of: otherVersion)`. + /// - otherVersion: The version to compare against. /// - Returns: `true` if `otherVersion` is equal to or greater than `self`, otherwise `false`. - func isOlder(than otherVersion: SQLDatabaseReportedVersion) -> Bool + func isOlder(than otherVersion: any SQLDatabaseReportedVersion) -> Bool } extension SQLDatabaseReportedVersion { - /// Check whether the current version (i.e. `self`) is older than or equal to the one given. - /// - /// Corresponds to ``Swift/Comparable/<=(_:_:)``. - /// - /// - Parameters: - /// - otherVersion: The version to compare against. `type(of: self)` must be the same as `type(of: otherVersion)`. - /// - Returns: `true` if `otherVersion` is greater than `self`, otherwise `false`. + // See `Equatable.==(_:_:)` @inlinable - public func isNotNewer(than otherVersion: SQLDatabaseReportedVersion) -> Bool { - self.isEqual(to: otherVersion) || self.isOlder(than: otherVersion) + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.isEqual(to: rhs) + } + + // See `Equatable.!=(_:_:)` + @inlinable + public static func != (lhs: Self, rhs: Self) -> Bool { + !lhs.isEqual(to: rhs) + } + + // See `Comparable.<(_:_:)` + @inlinable + public static func < (lhs: Self, rhs: Self) -> Bool { + lhs.isOlder(than: rhs) } - /// Check whether the current version (i.e. `self`) is newer than the one given. - /// - /// Corresponds to ``Swift/Comparable/>(_:_:)``. - /// - /// - Parameters: - /// - otherVersion: The version to compare against. `type(of: self)` must be the same as `type(of: otherVersion)`. - /// - Returns: `true` if `otherVersion` is equal to or less than `self`, otherwise `false`. + // See `Comparable.<=(_:_:)` @inlinable - public func isNewer(than otherVersion: SQLDatabaseReportedVersion) -> Bool { - !self.isNotNewer(than: otherVersion) + public static func <= (lhs: Self, rhs: Self) -> Bool { + lhs == rhs || lhs < rhs } - /// Check whether the current version (i.e. `self`) is newer than or equal to the one given. - /// - /// Corresponds to ``Swift/Comparable/>=(_:_:)``. - /// - /// - Parameters: - /// - otherVersion: The version to compare against. `type(of: self)` must be the same as `type(of: otherVersion)`. - /// - Returns: `true` if `otherVersion` is less than `self`, otherwise `false`. + // See `Comparable.>(_:_:)` + @inlinable + public static func > (lhs: Self, rhs: Self) -> Bool { + !(lhs <= rhs) + } + + // See `Comparable.>=(_:_:)` + @inlinable + public static func >= (lhs: Self, rhs: Self) -> Bool { + !(lhs < rhs) + } +} + +extension SQLDatabaseReportedVersion { + /// Default implementation of ``isEqual(to:)-6ybn8``. + @inlinable + public func isEqual(to otherVersion: any SQLDatabaseReportedVersion) -> Bool { + (otherVersion as? Self).map { $0.stringValue == self.stringValue } ?? false + } + + /// Default implementation of ``isOlder(than:)-1o58v``. @inlinable - public func isNotOlder(than otherVersion: SQLDatabaseReportedVersion) -> Bool { - !self.isOlder(than: otherVersion) + public func isOlder(than otherVersion: any SQLDatabaseReportedVersion) -> Bool { + (otherVersion as? Self).map { self.stringValue.lexicographicallyPrecedes($0.stringValue) } ?? false } } diff --git a/Sources/SQLKit/Database/SQLDialect.swift b/Sources/SQLKit/Database/SQLDialect.swift index 581f1592..41d7b030 100644 --- a/Sources/SQLKit/Database/SQLDialect.swift +++ b/Sources/SQLKit/Database/SQLDialect.swift @@ -1,10 +1,11 @@ -/// An abstract definition of a specific dialect of SQL. SQLKit uses an ``SQLDatabase``'s -/// dialect to control various aspects of query serialization, with the intent of keeping -/// SQLKit's user-facing API from having to expose database-specific details as much as -/// possible. While SQL dialects in the wild vary too widely in practice for this to ever -/// be 100% effective, they also have enough in common to avoid having to rewrite the -/// entire serialization logic for each database driver. -public protocol SQLDialect { +/// An abstract definition of a specific dialect of SQL. +/// +/// SQLKit uses the dialect provided by an instance of ``SQLDatabase`` to control various aspects +/// of query serialization, with the intent of keeping SQLKit's user-facing API from having to +/// expose database-specific details as much as possible. While SQL dialects in the wild vary too +/// widely in practice for this to ever be 100% effective, they also have enough in common to avoid +/// having to rewrite every line of serialization logic for each database driver. +public protocol SQLDialect: Sendable { /// The name of the dialect. /// /// Dialect names were intended to just be human-readable strings, but in reality there @@ -17,184 +18,227 @@ public protocol SQLDialect { /// No default is provided. var name: String { get } - /// An expression (usually an `SQLRaw`) giving the character(s) used to quote SQL - /// identifiers, such as table and column names. The identifier quote is placed - /// immediately preceding and following each identifier. + /// An expression (usually an ``SQLRaw``) giving the character(s) used to quote SQL + /// identifiers, such as table and column names. + /// + /// The identifier quote is placed immediately preceding and following each identifier. /// /// No default is provided. var identifierQuote: any SQLExpression { get } - /// An expression (usually an `SQLRaw`) giving the character(s) used to quote literal - /// string values which appear in a query, such as enumerator names. The literal quote - /// is placed immediately preceding and following each string literal. + /// An expression (usually an ``SQLRaw``) giving the character(s) used to quote literal + /// string values which appear in a query, such as enumerator names. + /// + /// The literal quote is placed immediately preceding and following each string literal. /// /// Defaults to an apostrophe (`'`). var literalStringQuote: any SQLExpression { get } /// `true` if the dialect supports auto-increment for primary key values when inserting - /// new rows, `false` if not. See also ``autoIncrementClause`` and ``autoIncrementFunction``. + /// new rows, `false` if not. + /// + /// See also ``autoIncrementClause`` and ``autoIncrementFunction-4cc1b``. /// /// No default is provided. var supportsAutoIncrement: Bool { get } /// An expression inserted in a column definition when a `.primaryKey(autoincrement: true)` - /// constraint is specified for the column. The clause will be included immediately after - /// `PRIMARY KEY` in the resulting SQL. + /// constraint is specified for the column. + /// + /// The expression will be included immediately after `PRIMARY KEY` in the resulting SQL. /// /// This property is ignored when ``supportsAutoIncrement`` is `false`, or when - /// ``autoIncrementFunction`` is _not_ `nil`. + /// ``autoIncrementFunction-4cc1b`` is _not_ `nil`. /// /// No default is provided. var autoIncrementClause: any SQLExpression { get } - /// An expression inserted in a column definition when a `.primaryKey(autoincrement: true)` - /// constraint is specified for the column. The expression will be immediately preceded by - /// the ``literalDefault`` expression and appear immediately before `PRIMARY KEY` in the - /// resulting SQL. + /// An expression inserted in a column definition when a + /// ``SQLColumnConstraintAlgorithm/primaryKey(autoIncrement:)`` or + /// ``SQLTableConstraintAlgorithm/primaryKey(columns:)`` constraint is specified for the + /// column. + /// + /// The expression will be immediately preceded by the ``literalDefault-4l1ox`` expression + /// and appear immediately before `PRIMARY KEY` in the resulting SQL. /// /// This property is ignored when ``supportsAutoIncrement`` is `false`. If this property is /// not `nil`, it takes precedence over ``autoIncrementClause``. /// /// Defaults to `nil`. /// - /// - Note: The design of this and the other autoincrement-released properties is less than - /// ideal, but it's public API and we're stuck with it for now. + /// > Note: The design of this and the other autoincrement-released properties is less than + /// > ideal, but it's public API and we're stuck with it for now. var autoIncrementFunction: (any SQLExpression)? { get } /// A function which returns an expression to be used as the placeholder for the `position`th - /// bound parameter in a query. The function can ignore the value of `position` if the syntax - /// doesn't require it. + /// bound parameter in a query. /// - /// - Parameter position: Indicates which bound parameter to create a placeholder for, where - /// the first parameter has position `1`. This value is guaranteed to be greater than zero. + /// The function may ignore the value of `position` if the syntax doesn't require or + /// support it. /// /// No default is provided. + /// + /// - Parameter position: Indicates which bound parameter to create a placeholder for, where + /// the first parameter has position `1`. This value is guaranteed to be greater than zero. func bindPlaceholder(at position: Int) -> any SQLExpression - /// A function which returns an SQL expression (usually an `SQLRaw`) representing the given + /// A function which returns an SQL expression (usually an ``SQLRaw``) representing the given /// literal boolean value. - /// + /// /// No default is provided. + /// + /// - Parameter value: The boolean value to represent. func literalBoolean(_ value: Bool) -> any SQLExpression - /// An expression (usually an `SQLRaw`) giving the syntax used to express both "use this as + /// An expression (usually an ``SQLRaw``) giving the syntax used to express both "use this as /// the default value" in a column definition and "use the default value for this column" in - /// a value list. ``SQLLiteral.literal`` always serializes to this expression. + /// a value list. + /// + /// ``SQLLiteral/default`` always serializes to this expression. /// /// Defaults to `SQLRaw("DEFAULT")`. var literalDefault: any SQLExpression { get } /// `true` if the dialect supports the `IF EXISTS` modifier for all types of `DROP` queries - /// (such as `SQLDropEnum`, `SQLDropIndex`, `SQLDropTable`, and `SQLDropTrigger`) and the - /// `IF NOT EXISTS` modifier for `SQLCreateTable` queries. It is not possible to indicate - /// partial support at this time. + /// (such as ``SQLDropEnum``, ``SQLDropIndex``, ``SQLDropTable``, and ``SQLDropTrigger``) and + /// the `IF NOT EXISTS` modifier for ``SQLCreateTable`` queries. + /// + /// It is not possible to indicate partial support at this time. /// /// Defaults to `true`. var supportsIfExists: Bool { get } - /// The syntax the dialect supports for strongly-typed enumerations. See ``SQLEnumSyntax`` - /// for possible values. + /// The syntax the dialect supports for strongly-typed enumerations. /// - /// No default is provided. // TODO: Why not? + /// See ``SQLEnumSyntax`` for possible values. + /// + /// Defaults to ``SQLEnumSyntax/unsupported``. var enumSyntax: SQLEnumSyntax { get } - /// `true` if the dialect supports the ``SQLDropBehavior`` modifiers for `DROP` queries, - /// `false` if not. See ``SQLDropBehavior`` for more information. + /// `true` if the dialect supports the `behavior modifiers for `DROP` queries, `false` if not. + /// + /// See ``SQLDropBehavior`` for more information. /// /// Defauls to `false`. var supportsDropBehavior: Bool { get } - /// `true` if the dialect supports the `RETURNING` syntax for retrieving output values from - /// DML queries (`INSERT`, `UPDATE`, `DELETE`). See ``SQLReturning`` and ``SQLReturningBuilder``. + /// `true` if the dialect supports `RETURNING` syntax for retrieving output values from + /// DML queries (`INSERT`, `UPDATE`, `DELETE`). + /// + /// See ``SQLReturning`` and ``SQLReturningBuilder`` for more information. /// /// Defaults to `false`. var supportsReturning: Bool { get } - /// Various flags describing the dialect's support for specific features of `CREATE/DROP TRIGGER` - /// queries. See ``SQLTriggerSyntax`` for more information. + /// Various flags describing the dialect's support for specific features of + /// ``SQLCreateTrigger`` and ``SQLDropTrigger`` queries. + /// + /// See ``SQLTriggerSyntax`` for more information. /// /// Defaults to no feature flags set. var triggerSyntax: SQLTriggerSyntax { get } - /// A description of the syntax the dialect supports for `ALTER TABLE` queries. See - /// ``SQLAlterTableSyntax`` for more information. + /// A description of the syntax the dialect supports for ``SQLAlterTable`` queries. + /// + /// See ``SQLAlterTableSyntax`` for more information. /// /// Defaults to indicating no support at all. var alterTableSyntax: SQLAlterTableSyntax { get } /// A function which is consulted whenever an ``SQLDataType`` will be serialized into a /// query. The dialect may return an expression which will replace the default serialization - /// of the given type. Returning `nil` causes the default to be used. This is intended to - /// provide a customization point for dialects to override or supplement the default set of - /// types and their default definitions. + /// of the given type. Returning `nil` causes the default to be used. + /// + /// This is intended to provide a customization point for dialects to override or supplement + /// the default set of types and their default definitions. /// /// Defaults to returning `nil` for all inputs. func customDataType(for dataType: SQLDataType) -> (any SQLExpression)? /// A function which is consulted whenever a constraint name will be serialized into a /// query. The dialect must return an expression for an identifer which is unique to the - /// input identifier and is a valid constraint name for the dialect. This provides an - /// interception point for dialects which impose limitations on constraint names, such as - /// length limits or a database-wide uniqueness requirement. It is not required that it - /// be possible to convert a normalized identifer back to its original form (the conversion - /// may be lossy). This function must not return the same result for different inputs, and - /// must always return the same result when given the same input. A hashing function with - /// a sufficiently large output size, such as SHA-256, is one possible correct implementation. + /// input identifier and is a valid constraint name for the dialect. + /// + /// This provides an interception point for dialects which impose limitations on constraint + /// names, such as length limits or a database-wide uniqueness requirement. It is not + /// required that it be possible to convert a normalized identifer back to its original form + /// (the conversion may be lossy). This function must not return the same result for + /// different inputs, and must always return the same result when given the same input. A + /// hashing function with a sufficiently large output size, such as SHA-256, is one possible + /// correct implementation. /// /// Defaults to returning the input identifier unchanged. func normalizeSQLConstraint(identifier: any SQLExpression) -> any SQLExpression - /// The type of `UPSERT` syntax supported by the dialect. See ``SQLUpsertSyntax`` for possible - /// values and more information. + /// The type of `UPSERT` syntax supported by the dialect. /// - /// Defaults to `.unsupported`. + /// See ``SQLUpsertSyntax`` for possible values and more information. + /// + /// Defaults to ``SQLUpsertSyntax/unsupported``. var upsertSyntax: SQLUpsertSyntax { get } /// A set of feature flags describing the dialect's support for various forms of `UNION` with - /// `SELECT` queries. See ``SQLUnionFeatures`` for the possible flags and more information. + /// `SELECT` queries. + /// + /// See ``SQLUnionFeatures`` for the possible flags and more information. /// /// Defaults to `[.union, .unionAll]`. var unionFeatures: SQLUnionFeatures { get } - /// A serialization for ``SQLLockingClause/share``, representing a request for a shared "reader" - /// lock on rows retrieved by a `SELECT` query. A `nil` value means the database doesn't - /// support shared locking requests, which causes the locking clause to be silently ignored. + /// A serialization for ``SQLLockingClause/share``. + /// + /// Represents a request for a shared "reader" lock on rows retrieved by a `SELECT` query. A + /// `nil` value signals that the dialect doesn't support shared locking requests, in which + /// cas the locking clause is silently ignored. + /// + /// Defaults to `nil`. var sharedSelectLockExpression: (any SQLExpression)? { get } - /// A serialization for ``SQLLockingClause/update``, representing a request for an exclusive - /// "writer" lock on rows retrieved by a `SELECT` query. A `nil` value means the database doesn't - /// support exclusive locking requests, which causes the locking clause to be silently ignored. + /// A serialization for ``SQLLockingClause/update``. + /// + /// Represents a request for an exclusive "writer" lock on rows retrieved by a `SELECT` + /// query. A `nil` value signals that the dialect doesn't support exclusive locking requests, + /// in which case the locking clause is silently ignored. + /// + /// Defaults to `nil`. var exclusiveSelectLockExpression: (any SQLExpression)? { get } - /// Given a column name and a path consisting of one or more elements, assume the column is of - /// JSON type and return an appropriate expression for accessing the value at the given JSON - /// path, according to the semantics of the dialect. Return `nil` if JSON subpath expressions - /// are not supported or the given path is not valid in the dialect. + /// Given a column name and a path consisting of one or more elements, return an expression + /// appropriate for accessing a value at the given JSON path. + /// + /// A `nil` result signals that JSON subpath expressions are not supported, or that the given + /// path is not valid for this dialect. /// /// Defaults to returning `nil`. func nestedSubpathExpression(in column: any SQLExpression, for path: [String]) -> (any SQLExpression)? } -/// Controls `ALTER TABLE` syntax. -public struct SQLAlterTableSyntax { - /// Expression for altering a column's definition. +/// Encapsulates a dialect's support for `ALTER TABLE` syntax. +public struct SQLAlterTableSyntax: Sendable { + /// Expression used when altering a column's definition. /// - /// ALTER TABLE table [alterColumnDefinitionClause] column column_definition + /// ```sql + /// ALTER TABLE table [alterColumnDefinitionClause] column column_definition + /// ``` /// /// `nil` indicates lack of support for altering existing column definitions. public var alterColumnDefinitionClause: (any SQLExpression)? - /// Expression for altering a column definition's type. + /// Expression used when altering a column's type. Ignored if ``alterColumnDefinitionClause`` is `nil`. /// - /// ALTER TABLE table [alterColumnDefinitionClause] column [alterColumnDefinitionTypeClause] dataType + /// ```sql + /// ALTER TABLE table [alterColumnDefinitionClause] column [alterColumnDefinitionTypeClause] dataType + /// ``` /// /// `nil` indicates that no extra keyword is required. public var alterColumnDefinitionTypeKeyword: (any SQLExpression)? - /// If true, the dialect supports chaining multiple modifications together. If false, - /// the dialect requires separate statements for each change. + /// Indicates support for performing multiple alterations to a table in a single query. + /// + /// If `false`, a separate `ALTER TABLE` statement must be executed for each desired change. public var allowsBatch: Bool - + + /// Memberwise initializer. @inlinable public init( alterColumnDefinitionClause: (any SQLExpression)? = nil, @@ -207,128 +251,328 @@ public struct SQLAlterTableSyntax { } } -/// Controls strongly-typed enumeration syntax. -public enum SQLEnumSyntax { - /// For ex. MySQL, which uses the `ENUM` literal followed by the options. +/// Possible values for a dialect's strongly-typed enumeration support. +public enum SQLEnumSyntax: Sendable { + /// MySQL's "inline" enumerations. + /// + /// MySQL defines an `ENUM` field type, which contains a listing of its individual cases + /// inline. The cases can be changed after the initial defintion via `ALTER TABLE`. + /// + /// MySQL example: + /// ```sql + /// CREATE TABLE `foo` ( + /// `id` BIGINT NOT NULL PRIMARY KEY AUTO_INCREMENT, + /// `my_fruit` ENUM ('apple', 'orange', 'banana') NOT NULL + /// ); + /// ``` case inline - /// For ex. PostgreSQL, which uses the name of a type that must be - /// created separately. + /// PostgreSQL's custom user type enumerations. + /// + /// PostgreSQL implements enums as one of a few different kinds of user-defined custom data + /// types, which must be created separately before their use in a table. Once created, an + /// enumeration may add new cases and rename existing ones, but may not delete them without + /// deleting the entire custom type. + /// + /// PostgreSQL example: + /// ```sql + /// CREATE TYPE "fruit" AS ENUM ( 'apple', 'orange', 'banana' ); + /// + /// CREATE TABLE "foo" ( + /// "id" BIGINT NOT NULL PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, + /// "my_fruit" fruit NOT NULL + /// ); + /// ``` case typeName - /// For ex. SQL Server, which does not have an enum syntax. + /// No enumeration type is supported. /// - /// - Note: you can likely simulate an enum with a `CHECK` constraint. + /// For dialects which do not have native enumeration support, a simple string column can + /// serve the same function, with the caveat that its correctness will not be enforced by the + /// database, unless the database supports `CHECK` constraints and such a constraint is + /// appropriately applied. + /// + /// SQLite example: + /// ```sql + /// CREATE TABLE "foo" ( + /// "id" INTEGER PRIMARY KEY, + /// "my_fruit" TEXT NOT NULL CHECK + /// ("my_fruit" IN ('apple', 'orange', 'banana')) + /// ); + /// ``` case unsupported } -/// Controls `CREATE TRIGGER` and `DROP TRIGGER` syntax. -public struct SQLTriggerSyntax { - public struct Create: OptionSet { +/// Encapsulates a dialect's support for `CREATE TRIGGER` and `DROP TRIGGER` syntax. +public struct SQLTriggerSyntax: Sendable { + /// Describes specific feature support for `CREATE TRIGGER` syntax. + public struct Create: OptionSet, Sendable { + // See `RawRepresentable.rawValue`. public var rawValue = 0 - public init(rawValue: Int) { self.rawValue = rawValue } - - public static var requiresForEachRow: Self { .init(rawValue: 1 << 0) } - public static var supportsBody: Self { .init(rawValue: 1 << 1) } - public static var supportsCondition: Self { .init(rawValue: 1 << 2) } - public static var supportsDefiner: Self { .init(rawValue: 1 << 3) } - public static var supportsForEach: Self { .init(rawValue: 1 << 4) } - public static var supportsOrder: Self { .init(rawValue: 1 << 5) } - public static var supportsUpdateColumns: Self { .init(rawValue: 1 << 6) } - public static var supportsConstraints: Self { .init(rawValue: 1 << 7) } - public static var postgreSQLChecks: Self { .init(rawValue: 1 << 8) } - public static var conditionRequiresParentheses: Self { .init(rawValue: 1 << 9) } + + // See `OptionSet.init(rawValue:)`. + public init(rawValue: Int) { + self.rawValue = rawValue + } + + /// Indicates that the `FOR EACH ROW` clause is syntactically required for trigger creation. + public static var requiresForEachRow: Self { + .init(rawValue: 1 << 0) + } + + /// Indicates support for specifying a trigger's implementation as an inline sequence of statements. + public static var supportsBody: Self { + .init(rawValue: 1 << 1) + } + + /// Indicates support for a conditional predicate controlling invocation of the trigger. + public static var supportsCondition: Self { + .init(rawValue: 1 << 2) + } + + /// Indicates support for specifying a `DEFINER` clause for the purposes of access control. + public static var supportsDefiner: Self { + .init(rawValue: 1 << 3) + } + + /// Indicates support for the `FOR EACH ROW` and `FOR EACH STATEMENT` syntax. + public static var supportsForEach: Self { + .init(rawValue: 1 << 4) + } + + /// `Indicates support for ordering triggers relative to one another. + public static var supportsOrder: Self { + .init(rawValue: 1 << 5) + } + + /// Indicates support for an `OF` clause on `UPDATE` triggers specifying that only a subset of columns should + /// invoke the trigger. + public static var supportsUpdateColumns: Self { + .init(rawValue: 1 << 6) + } + + /// Indicates support for the `CONSTRAINT` trigger type. + public static var supportsConstraints: Self { + .init(rawValue: 1 << 7) + } + + /// Indicates that PostgreSQL-specific syntax correctness checks should be made at runtime. + /// + /// > Important: The checks in question are implemented as logging statements with the `.warning` level; + /// > invalid SQL syntax may still be generated. + public static var postgreSQLChecks: Self { + .init(rawValue: 1 << 8) + } + + /// When ``supportsCondition`` is also set, indicates that the condition must be wrapped by parenthesis. + public static var conditionRequiresParentheses: Self { + .init(rawValue: 1 << 9) + } } - public struct Drop: OptionSet { + /// Describes specific feature support for `CREATE TRIGGER` syntax. + public struct Drop: OptionSet, Sendable { + // See `RawRepresentable.rawValue`. public var rawValue = 0 - public init(rawValue: Int) { self.rawValue = rawValue } + + // See `OptionSet.init(rawValue:)`. + public init(rawValue: Int) { + self.rawValue = rawValue + } - public static var supportsTableName: Self { .init(rawValue: 1 << 0) } - public static var supportsCascade: Self { .init(rawValue: 1 << 1) } + /// Indicates support for an `OF` clause indicating which table the trigger to be dropped is attached to. + public static var supportsTableName: Self { + .init(rawValue: 1 << 0) + } + + /// Indicates support for the `CASCADE` modifier; see ``SQLDropBehavior`` for details. + public static var supportsCascade: Self { + .init(rawValue: 1 << 1) + } } + /// Syntax options for `CREATE TRIGGER`. public var create: Create + + /// Syntax options for `DROP TRIGGER`. public var drop: Drop + /// Memberwise initializer. public init(create: Create = [], drop: Drop = []) { self.create = create self.drop = drop } } -/// The supported syntax variants which a SQL dialect can use to to specify `UPSERT` clauses. -public enum SQLUpsertSyntax: Equatable, CaseIterable { - /// Indicates usage of the SQL-standard `ON CONFLICT ...` syntax, including index and update predicates, the - /// `excluded.` pseudo-table name, and the `DO NOTHING` action for "ignore conflicts". +/// The supported syntax variants which a SQL dialect can use to to specify conflict resolution clauses. +public enum SQLUpsertSyntax: Equatable, CaseIterable, Sendable { + /// Indicates support for the SQL-standard `ON CONFLICT ...` syntax, including index and update + /// predicates, the `excluded.` pseudo-table name, and the `DO NOTHING` action for "ignore + /// conflicts". case standard - /// Indicates usage of the nonstandard `ON DUPLICATE KEY UPDATE ...` syntax, the `VALUES()` function, and - /// `INSERT IGNORE` for "ignore conflicts". This syntax does not support conflict targets or update predicates. + /// Indicates support for the nonstandard `ON DUPLICATE KEY UPDATE ...` syntax, the `VALUES()` + /// function, and `INSERT IGNORE` for "ignore conflicts". This syntax does not support + /// conflict targets or update predicates. case mysqlLike - - /// Indicates that upserts are not supported at all. + + /// Indicates lack of any support for conflict resolution. case unsupported } /// A set of feature support flags for `UNION` queries. /// -/// - Note: The `union` and `unionAll` flags are a bit redundant, since every dialect SQLKit supports -/// at the time of this writing supports them. Still, there are SQL dialects in the wild that do not, -/// such as mSQL, so the flags are here for completeness' sake. -public struct SQLUnionFeatures: OptionSet { - // See ``RawRepresentable.rawValue``. - public var rawValue: Int = 0 - - // See ``RawRepresentable.init(rawValue:)``. +/// > Note: The `union` and `unionAll` flags are a bit redundant, since every dialect SQLKit +/// > supports at the time of this writing supports them. Still, there are SQL dialects in the +/// > wild that do not, such as mSQL, so the flags are here for completeness' sake. +public struct SQLUnionFeatures: OptionSet, Sendable { + // See `RawRepresentable.rawValue`. + public var rawValue = 0 + + // See `OptionSet.init(rawValue:)`. public init(rawValue: Int) { self.rawValue = rawValue } - /// Indicates basic support for `UNION` queries. All other flags are ignored unless this one is set. - public static var union: Self { .init(rawValue: 1 << 0) } + /// Indicates support for `UNION DISTINCT` unions. + public static var union: Self { + .init(rawValue: 1 << 0) + } - /// Indicates whether the dialect supports `UNION ALL`. - public static var unionAll: Self { .init(rawValue: 1 << 1) } + /// Indicates support for `UNION ALL` unions. + public static var unionAll: Self { + .init(rawValue: 1 << 1) + } - /// Indicates whether the dialect supports `INTERSECT`. - public static var intersect: Self { .init(rawValue: 1 << 2) } + /// Indicates support for `INTERSECT DISTINCT` unions. + public static var intersect: Self { + .init(rawValue: 1 << 2) + } - /// Indicates whether the dialect supports `INTERSECT ALL`. - public static var intersectAll: Self { .init(rawValue: 1 << 3) } + /// Indicates support for `INTERSECT ALL` unions. + public static var intersectAll: Self { + .init(rawValue: 1 << 3) + } - /// Indicates whether the dialect supports `EXCEPT`. - public static var except: Self { .init(rawValue: 1 << 4) } + /// Indicates support for `EXCEPT DISTINCT` unions. + public static var except: Self { + .init(rawValue: 1 << 4) + } - /// Indicates whether the dialect supports `EXCEPT ALL`. - public static var exceptAll: Self { .init(rawValue: 1 << 5) } - - /// Indicates whether the dialect supports explicitly specifying `DISTINCT` on supported union types. - public static var explicitDistinct: Self { .init(rawValue: 1 << 6) } + /// Indicates support for `EXCEPT ALL` unions. + public static var exceptAll: Self { + .init(rawValue: 1 << 5) + } - /// Indicates whether the dialect allows parenthesizing the individual `SELECT` queries in a union. - public static var parenthesizedSubqueries: Self { .init(rawValue: 1 << 7) } + /// Indicates that the `DISTINCT` modifier must be explicitly specified for the relevant union types. + public static var explicitDistinct: Self { + .init(rawValue: 1 << 6) + } + /// Indicates that the individual `SELECT` queries in a union must be parenthesized. + public static var parenthesizedSubqueries: Self { + .init(rawValue: 1 << 7) + } } -/// Provides defaults for many of the `SQLDialect` properties. The defaults are chosen to reflect -/// a baseline set of syntax and features which are correct for as many dialects as possible, -/// so as to avoid breaking all existing dialects every time a new requirement is added to the -/// protocol and allow gradual adoption of new capabilities. +/// Provides defaults for many of the ``SQLDialect`` properties. The defaults are chosen to +/// reflect a baseline set of syntax and features which are correct for as many dialects +/// as possible, so as to avoid breaking all existing dialects every time a new requirement +/// is added to the protocol and allow gradual adoption of new capabilities. extension SQLDialect { - public var literalDefault: any SQLExpression { SQLRaw("DEFAULT") } - public var literalStringQuote: any SQLExpression { SQLRaw("'") } - public var supportsIfExists: Bool { true } - public var autoIncrementFunction: (any SQLExpression)? { nil } - public var supportsDropBehavior: Bool { false } - public var supportsReturning: Bool { false } - public var alterTableSyntax: SQLAlterTableSyntax { .init() } - public var triggerSyntax: SQLTriggerSyntax { .init() } - public func customDataType(for dataType: SQLDataType) -> (any SQLExpression)? { nil } - public func normalizeSQLConstraint(identifier: any SQLExpression) -> any SQLExpression { identifier } - public var upsertSyntax: SQLUpsertSyntax { .unsupported } - public var unionFeatures: SQLUnionFeatures { [.union, .unionAll] } - public var sharedSelectLockExpression: (any SQLExpression)? { nil } - public var exclusiveSelectLockExpression: (any SQLExpression)? { nil } - public func nestedSubpathExpression(in column: any SQLExpression, for path: [String]) -> (any SQLExpression)? { nil } + /// Default implementation of ``literalStringQuote-3ur0m``. + @inlinable + public var literalStringQuote: any SQLExpression { + SQLRaw("'") + } + + /// Default implementation of ``autoIncrementFunction-1ktxy``. + @inlinable + public var autoIncrementFunction: (any SQLExpression)? { + nil + } + + /// Default implementation of ``literalDefault-7nz7t``. + @inlinable + public var literalDefault: any SQLExpression { + SQLRaw("DEFAULT") + } + + /// Default implementation of ``supportsIfExists-5dxcu``. + @inlinable + public var supportsIfExists: Bool { + true + } + + /// Default implementation of ``enumSyntax-7atad``. + @inlinable + public var enumSyntax: SQLEnumSyntax { + .unsupported + } + + /// Default implementation of ``supportsDropBehavior-6vvl0``. + @inlinable + public var supportsDropBehavior: Bool { + false + } + + /// Default implementation of ``supportsReturning-r61k``. + @inlinable + public var supportsReturning: Bool { + false + } + + /// Default implementation of ``triggerSyntax-9579a``. + @inlinable + public var triggerSyntax: SQLTriggerSyntax { + .init() + } + + /// Default implementation of ``alterTableSyntax-9bmcr``. + @inlinable + public var alterTableSyntax: SQLAlterTableSyntax { + .init() + } + + /// Default implementation of ``customDataType(for:)-2firt``. + @inlinable + public func customDataType(for: SQLDataType) -> (any SQLExpression)? { + nil + } + + /// Default implementation of ``normalizeSQLConstraint(identifier:)-3vca6``. + @inlinable + public func normalizeSQLConstraint(identifier: any SQLExpression) -> any SQLExpression { + identifier + } + + /// Default implementation of ``upsertSyntax-snn6``. + @inlinable + public var upsertSyntax: SQLUpsertSyntax { + .unsupported + } + + /// Default implementation of ``unionFeatures-473tk``. + @inlinable + public var unionFeatures: SQLUnionFeatures { + [.union, .unionAll] + } + + /// Default implementation of ``sharedSelectLockExpression-6lb8t``. + @inlinable + public var sharedSelectLockExpression: (any SQLExpression)? { + nil + } + + /// Default implementation of ``exclusiveSelectLockExpression-21gkt``. + @inlinable + public var exclusiveSelectLockExpression: (any SQLExpression)? { + nil + } + + /// Default implementation of ``nestedSubpathExpression(in:for:)-7d4cw``. + @inlinable + public func nestedSubpathExpression(in: any SQLExpression, for: [String]) -> (any SQLExpression)? { + nil + } } diff --git a/Sources/SQLKit/Deprecated/SQLDatabase+Deprecated.swift b/Sources/SQLKit/Deprecated/SQLDatabase+Deprecated.swift new file mode 100644 index 00000000..fb0edd41 --- /dev/null +++ b/Sources/SQLKit/Deprecated/SQLDatabase+Deprecated.swift @@ -0,0 +1,40 @@ +extension SQLDatabaseReportedVersion { + /// Check whether the current version (i.e. `self`) is older than or equal to the one given. + /// + /// > Warning: This method has been deprecated; use the `<=` operator instead. + /// + /// - Parameters: + /// - otherVersion: The version to compare against. `type(of: self)` must be the same as `type(of: otherVersion)`. + /// - Returns: `true` if `otherVersion` is greater than `self`, otherwise `false`. + @inlinable + @available(*, deprecated, renamed: "<=", message: "Use the `<=` operator instead.") + public func isNotNewer(than otherVersion: any SQLDatabaseReportedVersion) -> Bool { + (otherVersion as? Self).map { self.isEqual(to: $0) || self.isOlder(than: $0) } ?? false + } + + /// Check whether the current version (i.e. `self`) is newer than the one given. + /// + /// > Warning: This method has been deprecated; use the `>` operator instead. + /// + /// - Parameters: + /// - otherVersion: The version to compare against. `type(of: self)` must be the same as `type(of: otherVersion)`. + /// - Returns: `true` if `otherVersion` is equal to or less than `self`, otherwise `false`. + @inlinable + @available(*, deprecated, renamed: ">", message: "Use the `>` operator instead.") + public func isNewer(than otherVersion: any SQLDatabaseReportedVersion) -> Bool { + (otherVersion as? Self).map { !self.isNotNewer(than: $0) } ?? false + } + + /// Check whether the current version (i.e. `self`) is newer than or equal to the one given. + /// + /// > Warning: This method has been deprecated; use the `>=` operator instead. + /// + /// - Parameters: + /// - otherVersion: The version to compare against. `type(of: self)` must be the same as `type(of: otherVersion)`. + /// - Returns: `true` if `otherVersion` is less than `self`, otherwise `false`. + @inlinable + @available(*, deprecated, renamed: ">=", message: "Use the `>=` operator instead.") + public func isNotOlder(than otherVersion: any SQLDatabaseReportedVersion) -> Bool { + (otherVersion as? Self).map { !self.isOlder(than: $0) } ?? false + } +} diff --git a/Sources/SQLKit/Deprecated/SQLError.swift b/Sources/SQLKit/Deprecated/SQLError.swift index 53217355..04295692 100644 --- a/Sources/SQLKit/Deprecated/SQLError.swift +++ b/Sources/SQLKit/Deprecated/SQLError.swift @@ -1,12 +1,20 @@ /// An error from a SQL query or database operation. /// Constains an additional property detailing what type of SQL error has occurred. +/// +/// This protocol is deprecated; it has never been used by SQLKit or any of its drivers or dependents, +/// and serves no useful purpose. +@available(*, deprecated, message: "SQLKit does not support or use this protocol; it will be removed in a future version.") public protocol SQLError: Error { /// SQL-specific error type. var sqlErrorType: SQLErrorType { get } } /// Types of SQL errors. -public struct SQLErrorType: Equatable { +/// +/// This type is deprecated; it has never been used by SQLKit or any of its drivers or dependents, +/// and serves no useful purpose. +@available(*, deprecated, message: "SQLKit does not support or use this type; it will be removed in a future version.") +public struct SQLErrorType: Equatable, Sendable { /// An IO error occured during database query. public static var io: SQLErrorType { .init(code: .io) } @@ -24,7 +32,7 @@ public struct SQLErrorType: Equatable { // MARK: Private - private enum Code { + private enum Code: Sendable { case io case constraint case permission diff --git a/Sources/SQLKit/Deprecated/SQLExpressions+Deprecated.swift b/Sources/SQLKit/Deprecated/SQLExpressions+Deprecated.swift new file mode 100644 index 00000000..6e884d74 --- /dev/null +++ b/Sources/SQLKit/Deprecated/SQLExpressions+Deprecated.swift @@ -0,0 +1,125 @@ +extension SQLCreateTrigger.TimingSpecifier { + /// A legacy specifier. Behaves identically to ``deferrable``, which should be used instead. + @available(*, deprecated, renamed: "deferrable") + public static var initiallyImmediate: Self { .deferrable } + + /// A legacy specifier. Behaves identically to ``deferredByDefault``, which should be used instead. + @available(*, deprecated, renamed: "deferredByDefault") + public static var initiallyDeferred: Self { .deferredByDefault } +} + +extension SQLDataType { + /// An inadvertently public test utility. Do not use. + @available(*, deprecated, message: "This is a test utility method that was incorrectly made public. Use `.custom()` directly instead.") + @inlinable + public static func type(_ string: String) -> Self { + .custom(SQLIdentifier(string)) + } +} + +extension SQLDropEnum { + /// A legacy alias for toggling ``dropBehavior`` between ``SQLDropBehavior/restrict`` (`false`) and + /// ``SQLDropBehavior/cascade`` (`true`). Prefer setting ``dropBehavior`` directly instead. + @available(*, deprecated, renamed: "dropBehavior") + public var cascade: Bool { + get { self.dropBehavior == .cascade } + set { self.dropBehavior = newValue ? .cascade : .restrict } + } +} + +extension SQLDropTrigger { + /// A legacy alias for toggling ``dropBehavior`` between ``SQLDropBehavior/restrict`` (`false`) and + /// ``SQLDropBehavior/cascade`` (`true`). Prefer setting ``dropBehavior`` directly instead. + @available(*, deprecated, renamed: "dropBehavior") + public var cascade: Bool { + get { self.dropBehavior == .cascade } + set { self.dropBehavior = newValue ? .cascade : .restrict } + } +} + +extension SQLQueryString { + /// [DEPRECATED] Adds an interpolated string of raw SQL. + /// + /// > Important: This is a deprecated legacy alias of ``appendInterpolation(unsafeRaw:)``. Update your + /// > code to use that method, or better yet to not use raw interpolation at all. + @available(*, deprecated, renamed: "appendInterpolation(unsafeRaw:)", message: """ + This method has been renamed to clarify that raw interpolation is unsafe. Use `\\(unsafeRaw:)` instead. + """) + @inlinable + public mutating func appendInterpolation(raw value: String) { + self.appendInterpolation(unsafeRaw: value) + } + + /// [EVEN MORE DEPRECATED] Adds an interpolated string of raw SQL. + /// + /// This is the deprecated original version of ``appendInterpolation(unsafeRaw:)``; its naming is misleading, + /// and it has previously been trivially easy to invoke it by accident, with no indication of the potential + /// risk its use carries. It is now strictly deprecated and will generate a compiler warning if used. As with + /// ``appendInterpolation(raw:)``, update calling code to use ``appendInterpolation(unsafeRaw:)``, or, + /// preferably, don't use raw interpolation at all. + /// + /// > Note: The maintainer of this package regrets that there are a total of no less than _three_ copies of + /// > this method, all of which are identical aside from naming, and of which two of the three are potentially + /// > unsafe. The next major version of SQLKit will take considerable pleasure in being able to finally address + /// > such problems for good. + @available(*, deprecated, renamed: "appendInterpolation(unsafeRaw:)", message: """ + This method is misleading; it does not act on literals in the SQL sense of the term and offers no indication + that it allows for unsafe direct injection of arbitrary SQL text. Use `\\(unsafeRaw:)` instead. + """) + @inlinable + public mutating func appendInterpolation(_ value: String) { + self.appendInterpolation(unsafeRaw: value) + } +} + +extension SQLRaw { + @available(*, deprecated, message: "Binds set in an `SQLRaw` are ignored. Use `SQLBind`instead.") + @inlinable + public init(_ sql: String, _ binds: [any Encodable & Sendable]) { + self.sql = sql + self.binds = binds + } +} + +/// Old name for ``SQLCreateTrigger/WhenSpecifier``. +@available(*, deprecated, renamed: "SQLCreateTrigger.WhenSpecifier") +public typealias SQLTriggerWhen = SQLCreateTrigger.WhenSpecifier + +/// Old name for ``SQLCreateTrigger/EventSpecifier``. +@available(*, deprecated, renamed: "SQLCreateTrigger.EventSpecifier") +public typealias SQLTriggerEvent = SQLCreateTrigger.EventSpecifier + +/// Old name for ``SQLCreateTrigger/TimingSpecifier``. +@available(*, deprecated, renamed: "SQLCreateTrigger.TimingSpecifier") +public typealias SQLTriggerTiming = SQLCreateTrigger.TimingSpecifier + +/// Old name for ``SQLCreateTrigger/EachSpecifier``. +@available(*, deprecated, renamed: "SQLCreateTrigger.EachSpecifier") +public typealias SQLTriggerEach = SQLCreateTrigger.EachSpecifier + +/// Old name for ``SQLCreateTrigger/OrderSpecifier``. +@available(*, deprecated, renamed: "SQLCreateTrigger.OrderSpecifier") +public typealias SQLTriggerOrder = SQLCreateTrigger.OrderSpecifier + +extension SQLUnionJoiner { + @available(*, deprecated, message: "Use `.type` instead.") + @inlinable + public var all: Bool { + get { [.unionAll, .intersectAll, .exceptAll].contains(self.type) } + set { switch (self.type, newValue) { + case (.union, true): self.type = .unionAll + case (.unionAll, false): self.type = .union + case (.intersect, true): self.type = .intersectAll + case (.intersectAll, false): self.type = .intersect + case (.except, true): self.type = .exceptAll + case (.exceptAll, false): self.type = .except + default: break + } } + } + + @available(*, deprecated, message: "Use `.init(type:)` instead.") + @inlinable + public init(all: Bool) { + self.init(type: all ? .unionAll : .union) + } +} diff --git a/Sources/SQLKit/Deprecated/SQLQueryBuilders+Deprecated.swift b/Sources/SQLKit/Deprecated/SQLQueryBuilders+Deprecated.swift new file mode 100644 index 00000000..b3ad5e5d --- /dev/null +++ b/Sources/SQLKit/Deprecated/SQLQueryBuilders+Deprecated.swift @@ -0,0 +1,60 @@ +extension SQLAliasedColumnListBuilder { + /// Specify a column qualified with a table name to be part of the result set of the query. + /// + /// This method is deprecated. Use ``SQLColumn/init(_:table:)-19zso`` or ``SQLColumn/init(_:table:)-77d24`` instead. + @inlinable + @discardableResult + @available(*, deprecated, renamed: "SQLColumn.init(_:table:)", message: "Use ``SQLColumn.init(_:table:)`` instead.") + public func column(table: String, column: String) -> Self { + self.column(SQLColumn(column, table: table)) + } +} + +extension SQLAlterTableBuilder { + /// The set of column alteration expressions. + @available(*, deprecated, message: "This property does not reflect an accurate view of columns affected by an alter table query; access the query's properties directly instead.") + @inlinable + public var columns: [any SQLExpression] { + get { self.alterTable.addColumns } + set { self.alterTable.addColumns = newValue } + } +} + +/// Formerly a separate builder used to construct `SELECT` subqueries in `CREATE TABLE` queries, now a deprecated +/// alias for the more general-purpose ``SQLSubqueryBuilder``. +@available(*, deprecated, renamed: "SQLSubqueryBuilder", message: "Superseded by SQLSubqueryBuilder") +public typealias SQLCreateTableAsSubqueryBuilder = SQLSubqueryBuilder + +extension SQLCreateTriggerBuilder { + /// Specify a conditional expression which determines whether the trigger is actually executed. + @available(*, deprecated, message: "Specifying conditions as raw strings is unsafe. Use `SQLBinaryExpression` etc. instead.") + @inlinable + @discardableResult + public func condition(_ value: String) -> Self { + self.condition(SQLRaw(value)) + } + + /// Specify a body for the trigger. + @available(*, deprecated, message: "Specifying SQL statements as raw strings is unsafe. Use `SQLQueryString` or `SQLRaw` explicitly.") + @inlinable + @discardableResult + public func body(_ statements: [String]) -> Self { + self.body(statements.map { SQLRaw($0) }) + } +} + +extension SQLJoinBuilder { + /// Include the given table in the list of those used by the query, performing an explicit join using the + /// given method and condition(s). Tables are joined left to right. + /// + /// - Parameters: + /// - table: The name of the table to join. + /// - method: The join method to use. + /// - expression: A string containing a join condition. + @available(*, deprecated, message: "Specifying conditions as raw strings is unsafe. Use `SQLBinaryExpression` etc. instead.") + @inlinable + @discardableResult + public func join(_ table: String, method: SQLJoinMethod = .inner, on expression: String) -> Self { + self.join(SQLIdentifier(table), method: method, on: SQLRaw(expression)) + } +} diff --git a/Sources/SQLKit/Docs.docc/BasicUsage.md b/Sources/SQLKit/Docs.docc/BasicUsage.md new file mode 100644 index 00000000..a1c414de --- /dev/null +++ b/Sources/SQLKit/Docs.docc/BasicUsage.md @@ -0,0 +1,291 @@ +# Basic Usage + +Getting started with SQLKit + +## Overview + +_Query builders_ make up the primary API surface for SQLKit. A query builder is an object associated with a database used to build and execute a query, as shown in the following example: + +```swift +/// A simple data model +struct Planet: Codable { + let id: Int + let name: String +} + +/// Database connection objects are vended by SQLKit drivers; the details +/// differ from driver to driver. +let database: any SQLDatabase = ... + +/// This value can come from user input, such a query parameter. +let planetName: String = ... + +let planets = try await database + .select() + .columns("id", "name") + .from("planets") + .where("name", .equal, planetName) + .all(decoding: Planet.self) +``` + +The actual query executed by this example depends on the driver used to get the database object. The [PostgreSQL driver](https://github.com/vapor/postgres-kit) generates this query: + +```PLpgsql +SELECT "id", "name" FROM "planets" WHERE "name" = $1 +``` + +... and the [SQLite driver](https://github.com/vapor/sqlite-kit)'s output is very similar: + +```sql +SELECT "id", "name" FROM "planets" WHERE "name" = ?1 +``` + +... whereas the [MySQL driver](https://github.com/vapor/mysql-kit)'s output is less so: + +```mysql +SELECT `id`, `name` FROM `planets` WHERE `name` = ? +``` + +## Databases, Expressions, and Builders + +Instances of ``SQLDatabase`` are capable of executing arbitrary ``SQLExpression``s: + +```swift +let db: any SQLDatabase = // obtain a database from an SQLKit driver +let query = db + .select() + .column(SQLLiteral.string("a")) + .query + +try await db.execute( + sql: query, + onRow: { (row: any SQLRow) in + // ... + } +) +``` + +The ``SQLExpression`` protocol provides a common interface for transforming an arbitrary set of syntactical building blocks into a string of SQL code. A comprehensive set of SQL building blocks for SQL syntax is provided, along with numerous expressions representing composed clauses, and even complete SQL queries. Expressions are serialized to a combination of a raw string of SQL text and an array of zero or more bound parameter values. + +Here is an example of constructing a `SELECT` query using the ``SQLSelect`` expression type, along with several syntactical building blocks, directly: + +```swift +var select = SQLSelect() + +select.columns = [ + SQLColumn("column1"), + SQLColumn("column2", table: "table2"), +] +select.tables = [ + SQLIdentifier("table1") +] +select.joins = [ + SQLJoin( + method: SQLJoinMethod.inner, + table: SQLIdentifier("table2"), + expression: SQLBinaryExpression( + SQLColumn("column1", table: "table1"), + .equal, + SQLColumn("column2", table: "table2") + ) + ) +] +select.predicate = SQLBinaryExpression( + SQLBinaryExpression(SQLColumn("column1"), .equal, SQLBind("value")), + .and, + SQLBinaryExpression(SQLColumn("column2"), .is, SQLLiteral.null) +) +``` + +When serialized against a database using the PostgreSQL dialect, the resulting query looks like this (whitespace has been added for readability): + +```PLpgsql +SELECT "column1", "table2"."column2" +FROM "table1" +INNER JOIN "table2" ON "table1"."column1" = "table2"."column2" +WHERE "column1" = $1 AND "column2" IS NULL +``` + +Of course, this is an _awful_ lot of code to achieve such a relatively straightforward result, which is why SQLKit provides query builders. + +### Rows + +For query builders that support returning results (e.g. any builder conforming to the ``SQLQueryFetcher`` protocol), there are additional methods for handling the database output: + +- `all()`: Returns an array of rows. +- `first()`: Returns an optional row. +- `run(_:)`: Accepts a closure that handles rows as they are returned. + +Each of these methods provides one or more ``SQLRow``s. ``SQLRow`` is a protocol providing methods for accessing column values: + +```swift +let row: any SQLRow +let name = try row.decode(column: "name", as: String.self) +print(name) // String +``` + +### Codable + +``SQLRow`` also supports decoding `Codable` models directly: + +```swift +struct Planet: Codable { + var name: String +} + +let planet = try row.decode(model: Planet.self) +``` + +Query builders that support returning results have convenience methods for automatically decoding models. + +```swift +let planets: [Planet] = try await db.select() + ... + .all(decoding: Planet.self) +``` + +## Select + +The ``SQLDatabase/select()`` method creates a `SELECT` query builder: + +```swift +let planets: [any SQLRow] = try await db.select() + .columns("id", "name") + .from("planets") + .where("name", .equal, "Earth") + .all() +``` + +This code generates the following SQL when used with the PostgresKit driver: + +```PLpgsql +SELECT "id", "name" FROM "planets" WHERE "name" = $1 -- bindings: ["Earth"] +``` + +Notice that `Encodable` values are automatically bound as parameters instead of being serialized directly to the query. + +The select builder includes the following methods (most of which have numerous variations): + +- `columns()` (specify a list of columns and/or expressions to return) +- `from()` (specify a table to select from) +- `join()` (specify additional tables and how to relate them to others) +- `where()` and `orWhere()` (specify conditions that narrow down the possible results) +- `limit()` and `offset()` (specify a limited and/or offsetted range of results to return) +- `orderBy()` (specify how to sort results before returning them) +- `groupBy()` (specify columns and/or expressions for aggregating results) +- `having()` and `orHaving()` (specify secondary conditions to apply to the results after aggregation) +- `distinct()` (specify coalescing of duplicate results) +- `for()` and `lockingClause()` (specify locking behavior for rows that appear in results) + +Conditional expressions provided to `where()` or `having()` are joined with `AND`. Corresponding `orWhere()` and `orHaving()` methods join conditions with `OR` instead. + +```swift +builder.where("name", .equal, "Earth").orWhere("name", .equal, "Mars") +``` + +This code generates the following SQL when used with the MySQL driver: + +```mysql +WHERE `name` = ? OR `name` = ? -- bindings: ["Earth", "Mars"] +``` + +`where()`, `orWhere()`, `having()`, and `orHaving()` also support creating grouped clauses: + +```swift +builder.where("name", .notEqual, SQLLiteral.null).where { + $0.where("name", .equal, SQLBind("Milky Way")) + .orWhere("name", .equal, SQLBind("Andromeda")) +} +``` + +This code generates the following SQL when used with the SQLite driver: + +```sql +WHERE "name" <> NULL AND ("name" = ?1 OR "name" = ?2) -- bindings: ["Milky Way", "Andromeda"] +``` + +## Insert + +The ``SQLDatabase/insert(into:)-67oqt`` and ``SQLDatabase/insert(into:)-5n3gh`` methods create an `INSERT` query builder: + +```swift +try await db.insert(into: "galaxies") + .columns("id", "name") + .values(SQLLiteral.default, SQLBind("Milky Way")) + .values(SQLLiteral.default, SQLBind("Andromeda")) + .run() +``` + +This code generates the following SQL when used with the PostgreSQL driver: + +```PLpgsql +INSERT INTO "galaxies" ("id", "name") VALUES (DEFAULT, $1), (DEFAULT, $2) -- bindings: ["Milky Way", "Andromeda"] +``` + +The insert builder also has methods for encoding `Codable` types as sets of values: + +```swift +struct Galaxy: Codable { + var name: String +} + +try builder.model(Galaxy(name: "Milky Way")) +``` + +This code generates the same SQL as would `builder.columns("name").values("Milky Way")`. + +## Update + +The ``SQLDatabase/update(_:)-2tf1c`` and ``SQLDatabase/update(_:)-80964`` methods create an `UPDATE` query builder: + +```swift +try await db.update("planets") + .set("name", to: "Jupiter") + .where("name", .equal, "Jupiter") + .run() +``` + +This code generates the following SQL when used with the MySQL driver: + +```mysql +UPDATE `planets` SET `name` = ? WHERE `name` = ? -- bindings: ["Jupiter", "Jupiter"] +``` + +The update builder supports the same `where()` and `orWhere()` methods as the select builder, via the ``SQLPredicateBuilder`` protocol. + +## Delete + +The ``SQLDatabase/delete(from:)-3tx4f`` and ``SQLDatabase/delete(from:)-4bqlu`` methods create a `DELETE` query builder: + +```swift +try await db.delete(from: "planets") + .where("name", .equal, "Jupiter") + .run() +``` + +This code generates the following SQL when used with the SQLite driver: + +```sql +DELETE FROM "planets" WHERE "name" = ?1 -- bindings: ["Jupiter"] +``` + +The delete builder also conforms to ``SQLPredicateBuilder``. + +## Raw + +The ``SQLDatabase/raw(_:)`` method allows passing custom SQL query strings, with support for parameterized bindings and correctly-quoted identifiers: + +```swift +let planets = try await db.raw("SELECT \(SQLLiteral.all) FROM \(ident: table) WHERE \(ident: name) = \(bind: "planet")") + .all() +``` + +This code generates the following SQL when used with the PostgreSQL driver: + +```PLpgsql +SELECT * FROM "planets" WHERE "name" = $1 -- bindings: ["planet"] +``` + +The ``SQLQueryString/appendInterpolation(bind:)`` interpolation should be used for any user input to avoid SQL injection. The ``SQLQueryString/appendInterpolation(ident:)`` interpolation is used to safely specify identifiers such as table and column names. + +> Important: Always prefer a structured query (i.e. one for which a builder or expression type exists) over raw queries. Consider writing your own ``SQLExpression``s, and even your own ``SQLQueryBuilder``s, rather than using raw queries, and don't hesitate to [open an issue](https://github.com/vapor/sql-kit/issues/new) to ask for additional feature support. diff --git a/Sources/SQLKit/Docs.docc/Resources/codingkey-quotation.svg b/Sources/SQLKit/Docs.docc/Resources/codingkey-quotation.svg new file mode 100644 index 00000000..35cd15f8 --- /dev/null +++ b/Sources/SQLKit/Docs.docc/Resources/codingkey-quotation.svg @@ -0,0 +1,14 @@ + + + +CodingKey is the standard library’s answer to the concept of a +protocol whose requirements are a recursive identity crisis. + + +–– Unknown + diff --git a/Sources/SQLKit/Docs.docc/Resources/vapor-sqlkit-logo.svg b/Sources/SQLKit/Docs.docc/Resources/vapor-sqlkit-logo.svg new file mode 100644 index 00000000..053016d0 --- /dev/null +++ b/Sources/SQLKit/Docs.docc/Resources/vapor-sqlkit-logo.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + diff --git a/Sources/SQLKit/Docs.docc/SQLDatabase+ExtensionDocs.md b/Sources/SQLKit/Docs.docc/SQLDatabase+ExtensionDocs.md new file mode 100644 index 00000000..888ef775 --- /dev/null +++ b/Sources/SQLKit/Docs.docc/SQLDatabase+ExtensionDocs.md @@ -0,0 +1,66 @@ +# ``SQLKit/SQLDatabase`` + +## Topics + +### Properties + +- ``SQLDatabase/logger`` +- ``SQLDatabase/eventLoop`` +- ``SQLDatabase/version`` +- ``SQLDatabase/dialect`` +- ``SQLDatabase/queryLogLevel`` + +### Query interface + +- ``SQLDatabase/execute(sql:_:)-4eg19`` +- ``SQLDatabase/withSession(_:)-9b68j`` + +### DML queries + +- ``SQLDatabase/delete(from:)-3tx4f`` +- ``SQLDatabase/delete(from:)-4bqlu`` +- ``SQLDatabase/insert(into:)-67oqt`` +- ``SQLDatabase/insert(into:)-5n3gh`` +- ``SQLDatabase/select()`` +- ``SQLDatabase/union(_:)`` +- ``SQLDatabase/update(_:)-2tf1c`` +- ``SQLDatabase/update(_:)-80964`` + +### DDL queries + +- ``SQLDatabase/alter(table:)-42uao`` +- ``SQLDatabase/alter(table:)-68pbr`` +- ``SQLDatabase/create(table:)-czz4`` +- ``SQLDatabase/create(table:)-2wdmn`` +- ``SQLDatabase/drop(table:)-938qt`` +- ``SQLDatabase/drop(table:)-7k2ai`` + +- ``SQLDatabase/alter(enum:)-66oin`` +- ``SQLDatabase/alter(enum:)-7nb5b`` +- ``SQLDatabase/create(enum:)-81hl4`` +- ``SQLDatabase/create(enum:)-70oeh`` +- ``SQLDatabase/drop(enum:)-5leu1`` +- ``SQLDatabase/drop(enum:)-3jgv`` + +- ``SQLDatabase/create(index:)-7yh28`` +- ``SQLDatabase/create(index:)-1iuey`` +- ``SQLDatabase/drop(index:)-62i2j`` +- ``SQLDatabase/drop(index:)-19tfk`` + +- ``SQLDatabase/create(trigger:table:when:event:)-6ntdo`` +- ``SQLDatabase/create(trigger:table:when:event:)-9upcb`` +- ``SQLDatabase/drop(trigger:)-53mq6`` +- ``SQLDatabase/drop(trigger:)-5sfa8`` + +### Raw queries + +- ``SQLDatabase/raw(_:)`` +- ``SQLDatabase/serialize(_:)`` + +### Logging + +- ``SQLDatabase/logging(to:)`` + +### Legacy query interface + +- ``SQLDatabase/execute(sql:_:)-90wi9`` diff --git a/Sources/SQLKit/Docs.docc/SQLDialect+ExtensionDocs.md b/Sources/SQLKit/Docs.docc/SQLDialect+ExtensionDocs.md new file mode 100644 index 00000000..8ea8ba15 --- /dev/null +++ b/Sources/SQLKit/Docs.docc/SQLDialect+ExtensionDocs.md @@ -0,0 +1,37 @@ +# ``SQLKit/SQLDialect`` + +## Topics + +### Basics + +- ``SQLDialect/name`` +- ``SQLDialect/identifierQuote`` +- ``SQLDialect/literalStringQuote-3ur0m`` +- ``SQLDialect/bindPlaceholder(at:)`` +- ``SQLDialect/literalBoolean(_:)`` +- ``SQLDialect/literalDefault-7nz7t`` + +### Support Flags + +- ``SQLDialect/supportsAutoIncrement`` +- ``SQLDialect/supportsIfExists-5dxcu`` +- ``SQLDialect/supportsDropBehavior-6vvl0`` +- ``SQLDialect/supportsReturning-r61k`` +- ``SQLDialect/unionFeatures-473tk`` + +### Syntax Indicators + +- ``SQLDialect/enumSyntax-7atad`` +- ``SQLDialect/triggerSyntax-9579a`` +- ``SQLDialect/alterTableSyntax-9bmcr`` +- ``SQLDialect/autoIncrementClause`` +- ``SQLDialect/autoIncrementFunction-1ktxy`` +- ``SQLDialect/upsertSyntax-snn6`` +- ``SQLDialect/sharedSelectLockExpression-6lb8t`` +- ``SQLDialect/exclusiveSelectLockExpression-21gkt`` + +### Modifier Methods + +- ``SQLDialect/customDataType(for:)-2firt`` +- ``SQLDialect/normalizeSQLConstraint(identifier:)-3vca6`` +- ``SQLDialect/nestedSubpathExpression(in:for:)-7d4cw`` diff --git a/Sources/SQLKit/Docs.docc/SQLKit.md b/Sources/SQLKit/Docs.docc/SQLKit.md new file mode 100644 index 00000000..2503ad55 --- /dev/null +++ b/Sources/SQLKit/Docs.docc/SQLKit.md @@ -0,0 +1,159 @@ +# ``SQLKit`` + +@Metadata { + @TitleHeading(Package) +} + +SQLKit is a library for building and serializing SQL queries in Swift. + +SQLKit's query construction facilities provide mappings between Swift types and database field types, and a direct interface for executing SQL queries. It attempts to abstract away the many differences between the various dialects of SQL whenever practical, allowing users to construct queries for use with any of the supported database systems. Custom SQL can be directly specified as needed, such as when abstraction of syntax is not possible or unimplemented. + +> Note: Having been originally designed as a low-level "construction kit" for the Fluent ORM, the current incarnation of SQLKit is often excessively verbose, and offers relatively few user-friendly APIs. A future major release of Fluent is expected to replace both packages with an API designed around the same concepts as SQLKit, except targeted for both high-level and low-level use. + +SQLKit does _not_ provide facilities for creating or managing database connections; this functionality must be provided by a separate driver package which implements the required SQLKit protocols. + +## Topics + +- + +### Fundamentals + +- ``SQLExpression`` +- ``SQLSerializer`` +- ``SQLStatement`` + +### Data Access + +- ``SQLDatabase`` +- ``SQLRow`` +- ``SQLRowDecoder`` +- ``SQLQueryEncoder`` + +### Drivers + +- ``SQLDialect`` +- ``SQLDatabaseReportedVersion`` +- ``SQLAlterTableSyntax`` +- ``SQLTriggerSyntax`` +- ``SQLUnionFeatures`` +- ``SQLEnumSyntax`` +- ``SQLUpsertSyntax`` + +### Builder Protocols + +- ``SQLAliasedColumnListBuilder`` +- ``SQLColumnUpdateBuilder`` +- ``SQLJoinBuilder`` +- ``SQLPartialResultBuilder`` +- ``SQLPredicateBuilder`` +- ``SQLQueryBuilder`` +- ``SQLQueryFetcher`` +- ``SQLReturningBuilder`` +- ``SQLSecondaryPredicateBuilder`` +- ``SQLSubqueryClauseBuilder`` +- ``SQLUnqualifiedColumnListBuilder`` + +### Query Builders + +- ``SQLAlterEnumBuilder`` +- ``SQLAlterTableBuilder`` +- ``SQLConflictUpdateBuilder`` +- ``SQLCreateEnumBuilder`` +- ``SQLCreateIndexBuilder`` +- ``SQLCreateTableBuilder`` +- ``SQLCreateTriggerBuilder`` +- ``SQLDeleteBuilder`` +- ``SQLDropEnumBuilder`` +- ``SQLDropIndexBuilder`` +- ``SQLDropTableBuilder`` +- ``SQLDropTriggerBuilder`` +- ``SQLInsertBuilder`` +- ``SQLPredicateGroupBuilder`` +- ``SQLRawBuilder`` +- ``SQLReturningResultBuilder`` +- ``SQLSecondaryPredicateGroupBuilder`` +- ``SQLSelectBuilder`` +- ``SQLSubqueryBuilder`` +- ``SQLUnionBuilder`` +- ``SQLUpdateBuilder`` + +### Syntactic Expressions + +- ``SQLBinaryExpression`` +- ``SQLBinaryOperator`` +- ``SQLBind`` +- ``SQLFunction`` +- ``SQLGroupExpression`` +- ``SQLIdentifier`` +- ``SQLList`` +- ``SQLLiteral`` +- ``SQLRaw`` + +### Basic Expressions + +- ``SQLAlias`` +- ``SQLBetween`` +- ``SQLColumn`` +- ``SQLConstraint`` +- ``SQLDataType`` +- ``SQLDirection`` +- ``SQLDistinct`` +- ``SQLForeignKeyAction`` +- ``SQLNestedSubpathExpression`` +- ``SQLQualifiedTable`` +- ``SQLQueryString`` + +### Clause Expressions + +- ``SQLAlterColumnDefinitionType`` +- ``SQLColumnAssignment`` +- ``SQLColumnConstraintAlgorithm`` +- ``SQLColumnDefinition`` +- ``SQLConflictAction`` +- ``SQLConflictResolutionStrategy`` +- ``SQLDropBehavior`` +- ``SQLEnumDataType`` +- ``SQLExcludedColumn`` +- ``SQLForeignKey`` +- ``SQLInsertModifier`` +- ``SQLJoin`` +- ``SQLJoinMethod`` +- ``SQLLockingClause`` +- ``SQLOrderBy`` +- ``SQLReturning`` +- ``SQLSubquery`` +- ``SQLTableConstraintAlgorithm`` +- ``SQLUnionJoiner`` + +### Query Expressions + +- ``SQLAlterEnum`` +- ``SQLAlterTable`` +- ``SQLCreateEnum`` +- ``SQLCreateIndex`` +- ``SQLCreateTable`` +- ``SQLCreateTrigger`` +- ``SQLDelete`` +- ``SQLDropEnum`` +- ``SQLDropIndex`` +- ``SQLDropTable`` +- ``SQLDropTrigger`` +- ``SQLInsert`` +- ``SQLSelect`` +- ``SQLUnion`` +- ``SQLUpdate`` + +### Miscellaneous + +- ``SomeCodingKey`` + +### Deprecated + +- ``SQLCreateTableAsSubqueryBuilder`` +- ``SQLError`` +- ``SQLErrorType`` +- ``SQLTriggerEach`` +- ``SQLTriggerEvent`` +- ``SQLTriggerOrder`` +- ``SQLTriggerTiming`` +- ``SQLTriggerWhen`` diff --git a/Sources/SQLKit/Docs.docc/SQLQueryFetcher+ExtensionDocs.md b/Sources/SQLKit/Docs.docc/SQLQueryFetcher+ExtensionDocs.md new file mode 100644 index 00000000..0bb8066c --- /dev/null +++ b/Sources/SQLKit/Docs.docc/SQLQueryFetcher+ExtensionDocs.md @@ -0,0 +1,43 @@ +# ``SQLKit/SQLQueryFetcher`` + +## Topics + +### Getting Rows + +- ``SQLQueryFetcher/run(_:)-40swz`` +- ``SQLQueryFetcher/run(decoding:_:)-476q1`` +- ``SQLQueryFetcher/run(decoding:prefix:keyDecodingStrategy:userInfo:_:)-3q92a`` +- ``SQLQueryFetcher/run(decoding:with:_:)-8y7ux`` + +### Getting All Rows + +- ``SQLQueryFetcher/all()-8yci1`` +- ``SQLQueryFetcher/all(decoding:)-5dt2x`` +- ``SQLQueryFetcher/all(decoding:prefix:keyDecodingStrategy:userInfo:)-5u1nz`` +- ``SQLQueryFetcher/all(decoding:with:)-6n5ox`` +- ``SQLQueryFetcher/all(decodingColumn:as:)-7x9bs`` + +### Getting One Row + +- ``SQLQueryFetcher/first()-99pqx`` +- ``SQLQueryFetcher/first(decoding:)-63noi`` +- ``SQLQueryFetcher/first(decoding:prefix:keyDecodingStrategy:userInfo:)-2str1`` +- ``SQLQueryFetcher/first(decoding:with:)-58l9p`` +- ``SQLQueryFetcher/first(decodingColumn:as:)-1bcz6`` + +### Legacy `EventLoopFuture` Interfaces + +- ``SQLQueryFetcher/run(_:)-542bs`` +- ``SQLQueryFetcher/run(decoding:_:)-6z89k`` +- ``SQLQueryFetcher/run(decoding:prefix:keyDecodingStrategy:userInfo:_:)-6axmo`` +- ``SQLQueryFetcher/run(decoding:with:_:)-4tte7`` +- ``SQLQueryFetcher/all()-5j67e`` +- ``SQLQueryFetcher/all(decoding:)-6q02f`` +- ``SQLQueryFetcher/all(decoding:prefix:keyDecodingStrategy:userInfo:)-91za9`` +- ``SQLQueryFetcher/all(decoding:with:)-5fc4b`` +- ``SQLQueryFetcher/all(decodingColumn:as:)-197ym`` +- ``SQLQueryFetcher/first()-7o93q`` +- ``SQLQueryFetcher/first(decoding:)-6gqh3`` +- ``SQLQueryFetcher/first(decoding:prefix:keyDecodingStrategy:userInfo:)-4hfrz`` +- ``SQLQueryFetcher/first(decoding:with:)-1n97m`` +- ``SQLQueryFetcher/first(decodingColumn:as:)-4965m`` diff --git a/Sources/SQLKit/Docs.docc/index.md b/Sources/SQLKit/Docs.docc/index.md deleted file mode 100644 index 1e359351..00000000 --- a/Sources/SQLKit/Docs.docc/index.md +++ /dev/null @@ -1,12 +0,0 @@ -# ``SQLKit`` - -SQLKit is a library for building SQL queries in Swift. It is designed to be database agnostic and officially supports PostgreSQL, MySQL, and SQLite. - -SQLKit queries provide type-safety and mapping to Swift types to make it easy to use SQL from Swift. An example query looks like: - -```swift -try await db.select().column(table: "planets", column: "*") - .from("planets") - .where("name", .equal, SQLBind("Earth")) - .run() -``` diff --git a/Sources/SQLKit/Docs.docc/theme-settings.json b/Sources/SQLKit/Docs.docc/theme-settings.json new file mode 100644 index 00000000..97ddfb3f --- /dev/null +++ b/Sources/SQLKit/Docs.docc/theme-settings.json @@ -0,0 +1,21 @@ +{ + "theme": { + "aside": { "border-radius": "16px", "border-style": "double", "border-width": "3px" }, + "border-radius": "0", + "button": { "border-radius": "16px", "border-width": "1px", "border-style": "solid" }, + "code": { "border-radius": "16px", "border-width": "1px", "border-style": "solid" }, + "color": { + "sqlkit": { "dark": "hsl(32, 77%, 63%)", "light": "hsl(32, 80%, 60%)" }, + "documentation-intro-fill": "radial-gradient(circle at top, var(--color-sqlkit) 30%, #000 100%)", + "documentation-intro-accent": "var(--color-sqlkit)", + "logo-base": { "dark": "#fff", "light": "#000" }, + "logo-shape": { "dark": "#000", "light": "#fff" }, + "fill": { "dark": "#000", "light": "#fff" } + }, + "icons": { "technology": "/sqlkit/images/vapor-sqlkit-logo.svg" } + }, + "features": { + "quickNavigation": { "enable": true }, + "i18n": { "enable": true } + } +} diff --git a/Sources/SQLKit/Exports.swift b/Sources/SQLKit/Exports.swift index f9dc10ac..370dd880 100644 --- a/Sources/SQLKit/Exports.swift +++ b/Sources/SQLKit/Exports.swift @@ -1,13 +1,3 @@ -#if swift(>=5.8) - -@_documentation(visibility: internal) @_exported import protocol NIO.EventLoop -@_documentation(visibility: internal) @_exported import class NIO.EventLoopFuture +@_documentation(visibility: internal) @_exported import protocol NIOCore.EventLoop +@_documentation(visibility: internal) @_exported import class NIOCore.EventLoopFuture @_documentation(visibility: internal) @_exported import struct Logging.Logger - -#else - -@_exported import protocol NIO.EventLoop -@_exported import class NIO.EventLoopFuture -@_exported import struct Logging.Logger - -#endif diff --git a/Sources/SQLKit/Expressions/Basics/SQLAlias.swift b/Sources/SQLKit/Expressions/Basics/SQLAlias.swift index 312e5c63..17506001 100644 --- a/Sources/SQLKit/Expressions/Basics/SQLAlias.swift +++ b/Sources/SQLKit/Expressions/Basics/SQLAlias.swift @@ -1,24 +1,49 @@ +/// Encapsulates SQL's ` [AS] ` syntax, most often used to declare aliaed names +/// for columns and tables. public struct SQLAlias: SQLExpression { + /// The ``SQLExpression`` to alias. public var expression: any SQLExpression + + /// The alias itself. public var alias: any SQLExpression + /// Create an alias expression from an expression and an alias expression. + /// + /// - Parameters: + /// - expression: The expression to alias. + /// - alias: The alias itself. @inlinable public init(_ expression: any SQLExpression, as alias: any SQLExpression) { self.expression = expression self.alias = alias } + /// Create an alias expression from an expression and an alias name. + /// + /// - Parameters: + /// - expression: The expression to alias. + /// - alias: The aliased name. @inlinable - public func serialize(to serializer: inout SQLSerializer) { - self.expression.serialize(to: &serializer) - serializer.write(" AS ") - self.alias.serialize(to: &serializer) + public init(_ expression: any SQLExpression, as alias: String) { + self.init(expression, as: SQLIdentifier(alias)) } -} -extension SQLAlias { + /// Create an alias expression from a name and an alias name. + /// + /// - Parameters: + /// - name: The name to alias. + /// - alias: The aliased name. @inlinable - public init(_ expression: any SQLExpression, as alias: String) { - self.init(expression, as: SQLIdentifier(alias)) + public init(_ name: String, as alias: String) { + self.init(SQLIdentifier(name), as: SQLIdentifier(alias)) + } + + // See `SQLExpression.serialize(to:)`. + @inlinable + public func serialize(to serializer: inout SQLSerializer) { + serializer.statement { + $0.append(self.expression) + $0.append("AS", self.alias) + } } } diff --git a/Sources/SQLKit/Expressions/Basics/SQLBetween.swift b/Sources/SQLKit/Expressions/Basics/SQLBetween.swift new file mode 100644 index 00000000..8bfb1de0 --- /dev/null +++ b/Sources/SQLKit/Expressions/Basics/SQLBetween.swift @@ -0,0 +1,692 @@ +/// An ``SQLExpression`` which constructs SQL of the form ` BETWEEN AND `. +/// +/// This syntax is a more readable way of expressing the usually identical SQL construct +/// `((operand >= lowerBound) AND (operand <= upperBound))`. However, it is functionally distinct from the +/// dual-condition syntax in the case that the `operand` is a nondeterministic expression whose results +/// can or may change per-evaluation (such as `RANDOM()`), in which case `BETWEEN` will evaluate it exactly +/// once rather than twice. +/// +/// > Note: While it would be possible to use conditional conformance to `Strideable` to enable translating +/// > Swift `RangeExpression`s into ``SQLBetween`` expressions, this is considered slightly above the intended +/// > level of SQLKit's API. +public struct SQLBetween: SQLExpression { + public let operand: T + public let lowerBound: U + public let upperBound: V + + /// Create a ``SQLBetween`` expression from three ``SQLExpression``s. + /// + /// - Parameters: + /// - operand: The value to evaluate the range against. + /// - lowerBound: The lower bound of the range. + /// - upperBound: The upper bound of the range. + @inlinable + public init( + operand: T, + lowerBound: U, + upperBound: V + ) { + self.operand = operand + self.lowerBound = lowerBound + self.upperBound = upperBound + } + + // See `SQLExpression.serialize(to:)`. + public func serialize(to serializer: inout SQLSerializer) { + serializer.statement { + $0.append(self.operand) + $0.append("BETWEEN", self.lowerBound) + $0.append("AND", self.upperBound) + } + } +} + +// MARK: - Convenience initializers + +extension SQLBetween { + /// Create a ``SQLBetween`` expression from three bindable values. + @inlinable + public init( + _ operand: some Encodable & Sendable, + between lowerBound: some Encodable & Sendable, + and upperBound: some Encodable & Sendable + ) where T == SQLBind, U == SQLBind, V == SQLBind { + self.init(operand: .init(operand), lowerBound: .init(lowerBound), upperBound: .init(upperBound)) + } + + /// Create an ``SQLBetween`` expression from a bindable value and two ``SQLExpression``s. + @inlinable + public init( + _ operand: some Encodable & Sendable, + between lowerBound: U, + and upperBound: V + ) where T == SQLBind { + self.init(operand: .init(operand), lowerBound: lowerBound, upperBound: upperBound) + } + + /// Create an ``SQLBetween`` expression from an ``SQLExpression``, a bindable values, and another ``SQLExpression``. + @inlinable + public init( + _ operand: T, + between lowerBound: some Encodable & Sendable, + and upperBound: V + ) where U == SQLBind { + self.init(operand: operand, lowerBound: .init(lowerBound), upperBound: upperBound) + } + + /// Create an ``SQLBetween`` expression from two ``SQLExpression``s and a bindable value. + @inlinable + public init( + _ operand: T, + between lowerBound: U, + and upperBound: some Encodable & Sendable + ) where V == SQLBind { + self.init(operand: operand, lowerBound: lowerBound, upperBound: .init(upperBound)) + } + + /// Create an ``SQLBetween`` expression from two bindable values and an ``SQLExpression``. + @inlinable + public init( + _ operand: some Encodable & Sendable, + between lowerBound: some Encodable & Sendable, + and upperBound: V + ) where T == SQLBind, U == SQLBind { + self.init(operand: .init(operand), lowerBound: .init(lowerBound), upperBound: upperBound) + } + + /// Create an ``SQLBetween`` expression from an ``SQLExpression`` and two bindable values. + @inlinable + public init( + _ operand: T, + between lowerBound: some Encodable & Sendable, + and upperBound: some Encodable & Sendable + ) where U == SQLBind, V == SQLBind { + self.init(operand: operand, lowerBound: .init(lowerBound), upperBound: .init(upperBound)) + } + + /// Create an ``SQLBetween`` expression from a bindable value, an ``SQLExpression``, and a bindable value. + @inlinable + public init( + _ operand: some Encodable & Sendable, + between lowerBound: U, + and upperBound: some Encodable & Sendable + ) where T == SQLBind, V == SQLBind { + self.init(operand: .init(operand), lowerBound: lowerBound, upperBound: .init(upperBound)) + } + + /// Create a ``SQLBetween`` expression from a column name and two bindable values. + @inlinable + public init( + column: String, + between lowerBound: some Encodable & Sendable, + and upperBound: some Encodable & Sendable + ) where T == SQLIdentifier, U == SQLBind, V == SQLBind { + self.init(operand: .init(column), lowerBound: .init(lowerBound), upperBound: .init(upperBound)) + } + + /// Create a ``SQLBetween`` expression from a column name, a bindable value, and an ``SQLExpression``. + @inlinable + public init( + column: String, + between lowerBound: some Encodable & Sendable, + and upperBound: V + ) where T == SQLIdentifier, U == SQLBind { + self.init(operand: .init(column), lowerBound: .init(lowerBound), upperBound: upperBound) + } + + /// Create a ``SQLBetween`` expression from a column name, an ``SQLExpression``, and a bindable value. + @inlinable + public init( + column: String, + between lowerBound: U, + and upperBound: some Encodable & Sendable + ) where T == SQLIdentifier, V == SQLBind { + self.init(operand: .init(column), lowerBound: lowerBound, upperBound: .init(upperBound)) + } + + /// Create a ``SQLBetween`` expression from a column name and two ``SQLExpression``s. + @inlinable + public init( + column: String, + between lowerBound: U, + and upperBound: V + ) where T == SQLIdentifier { + self.init(operand: .init(column), lowerBound: lowerBound, upperBound: upperBound) + } +} + +// MARK: - `SQLPredicateBuilder` extensions + +extension SQLPredicateBuilder { + /// Shorthand for `where(SQLBetween(operand, lower, upperBound))`. + @discardableResult + @inlinable + public func `where`( + _ operand: some Encodable & Sendable, + between lowerBound: some Encodable & Sendable, + and upperBound: some Encodable & Sendable + ) -> Self { + self.where(SQLBetween(operand, between: lowerBound, and: upperBound)) + } + + /// Shorthand for `where(SQLBetween(operand, lowerBound, upperBound))`. + @discardableResult + @inlinable + public func `where`( + _ operand: some SQLExpression, + between lowerBound: some Encodable & Sendable, + and upperBound: some Encodable & Sendable + ) -> Self { + self.where(SQLBetween(operand, between: lowerBound, and: upperBound)) + } + + /// Shorthand for `where(SQLBetween(operand, lowerBound, upperBound))`. + @discardableResult + @inlinable + public func `where`( + _ operand: some Encodable & Sendable, + between lowerBound: some SQLExpression, + and upperBound: some Encodable & Sendable + ) -> Self { + self.where(SQLBetween(operand, between: lowerBound, and: upperBound)) + } + + /// Shorthand for `where(SQLBetween(operand, lowerBound, upperBound))`. + @discardableResult + @inlinable + public func `where`( + _ operand: some Encodable & Sendable, + between lowerBound: some Encodable & Sendable, + and upperBound: some SQLExpression + ) -> Self { + self.where(SQLBetween(operand, between: lowerBound, and: upperBound)) + } + + /// Shorthand for `where(SQLBetween(operand, lowerBound, upperBound))`. + @discardableResult + @inlinable + public func `where`( + _ operand: some SQLExpression, + between lowerBound: some SQLExpression, + and upperBound: some Encodable & Sendable + ) -> Self { + self.where(SQLBetween(operand, between: lowerBound, and: upperBound)) + } + + /// Shorthand for `where(SQLBetween(operand, lowerBound, upperBound))`. + @discardableResult + @inlinable + public func `where`( + _ operand: some SQLExpression, + between lowerBound: some Encodable & Sendable, + and upperBound: some SQLExpression + ) -> Self { + self.where(SQLBetween(operand, between: lowerBound, and: upperBound)) + } + + /// Shorthand for `where(SQLBetween(operand, lowerBound, upperBound))`. + @discardableResult + @inlinable + public func `where`( + _ operand: some Encodable & Sendable, + between lowerBound: some SQLExpression, + and upperBound: some SQLExpression + ) -> Self { + self.where(SQLBetween(operand, between: lowerBound, and: upperBound)) + } + + /// Shorthand for `where(SQLBetween(operand: SQLBind(operand), lowerBound: SQLBind(lowerBound), upperBound: SQLBind(upperBound)))`. + @discardableResult + @inlinable + public func `where`( + _ operand: some SQLExpression, + between lowerBound: some SQLExpression, + and upperBound: some SQLExpression + ) -> Self { + self.where(SQLBetween(operand: operand, lowerBound: lowerBound, upperBound: upperBound)) + } + + /// Shorthand for `where(SQLBetween(operand: SQLColumn(column), lowerBound: SQLBind(lowerBound), upperBound: SQLBind(upperBound)))`. + @discardableResult + @inlinable + public func `where`( + column: String, + between lowerBound: some Encodable & Sendable, + and upperBound: some Encodable & Sendable + ) -> Self { + self.where(SQLBetween(operand: SQLColumn(column), lowerBound: SQLBind(lowerBound), upperBound: SQLBind(upperBound))) + } + + /// Shorthand for `where(SQLBetween(operand: SQLColumn(column), lowerBound: SQLBind(lowerBound), upperBound: upperBound))`. + @discardableResult + @inlinable + public func `where`( + column: String, + between lowerBound: some Encodable & Sendable, + and upperBound: some SQLExpression + ) -> Self { + self.where(SQLBetween(operand: SQLColumn(column), lowerBound: SQLBind(lowerBound), upperBound: upperBound)) + } + + /// Shorthand for `where(SQLBetween(operand: SQLColumn(column), lowerBound: lowerBound, upperBound: SQLBind(upperBound)))`. + @discardableResult + @inlinable + public func `where`( + column: String, + between lowerBound: some SQLExpression, + and upperBound: some Encodable & Sendable + ) -> Self { + self.where(SQLBetween(operand: SQLColumn(column), lowerBound: lowerBound, upperBound: SQLBind(upperBound))) + } + + /// Shorthand for `where(SQLBetween(operand: SQLColumn(column), lowerBound: lowerBound, upperBound: upperBound))`. + @discardableResult + @inlinable + public func `where`( + column: String, + between lowerBound: some SQLExpression, + and upperBound: some SQLExpression + ) -> Self { + self.where(SQLBetween(operand: SQLColumn(column), lowerBound: lowerBound, upperBound: upperBound)) + } + + /// Shorthand for `orWhere(SQLBetween(operand, lowerBound, upperBound))`. + @discardableResult + @inlinable + public func orWhere( + _ operand: some Encodable & Sendable, + between lowerBound: some Encodable & Sendable, + and upperBound: some Encodable & Sendable + ) -> Self { + self.orWhere(SQLBetween(operand, between: lowerBound, and: upperBound)) + } + + /// Shorthand for `orWhere(SQLBetween(operand, lowerBound, upperBound))`. + @discardableResult + @inlinable + public func orWhere( + _ operand: some SQLExpression, + between lowerBound: some Encodable & Sendable, + and upperBound: some Encodable & Sendable + ) -> Self { + self.orWhere(SQLBetween(operand, between: lowerBound, and: upperBound)) + } + + /// Shorthand for `orWhere(SQLBetween(operand, lowerBound, upperBound))`. + @discardableResult + @inlinable + public func orWhere( + _ operand: some Encodable & Sendable, + between lowerBound: some SQLExpression, + and upperBound: some Encodable & Sendable + ) -> Self { + self.orWhere(SQLBetween(operand, between: lowerBound, and: upperBound)) + } + + /// Shorthand for `orWhere(SQLBetween(operand, lowerBound, upperBound))`. + @discardableResult + @inlinable + public func orWhere( + _ operand: some Encodable & Sendable, + between lowerBound: some Encodable & Sendable, + and upperBound: some SQLExpression + ) -> Self { + self.orWhere(SQLBetween(operand, between: lowerBound, and: upperBound)) + } + + /// Shorthand for `orWhere(SQLBetween(operand, lowerBound, upperBound))`. + @discardableResult + @inlinable + public func orWhere( + _ operand: some SQLExpression, + between lowerBound: some SQLExpression, + and upperBound: some Encodable & Sendable + ) -> Self { + self.orWhere(SQLBetween(operand, between: lowerBound, and: upperBound)) + } + + /// Shorthand for `orWhere(SQLBetween(operand, lowerBound, upperBound))`. + @discardableResult + @inlinable + public func orWhere( + _ operand: some SQLExpression, + between lowerBound: some Encodable & Sendable, + and upperBound: some SQLExpression + ) -> Self { + self.orWhere(SQLBetween(operand, between: lowerBound, and: upperBound)) + } + + /// Shorthand for `orWhere(SQLBetween(operand, lowerBound, upperBound))`. + @discardableResult + @inlinable + public func orWhere( + _ operand: some Encodable & Sendable, + between lowerBound: some SQLExpression, + and upperBound: some SQLExpression + ) -> Self { + self.orWhere(SQLBetween(operand, between: lowerBound, and: upperBound)) + } + + /// Shorthand for `orWhere(SQLBetween(operand: SQLBind(operand), lowerBound: SQLBind(lowerBound), upperBound: SQLBind(upperBound)))`. + @discardableResult + @inlinable + public func orWhere( + _ operand: some SQLExpression, + between lowerBound: some SQLExpression, + and upperBound: some SQLExpression + ) -> Self { + self.orWhere(SQLBetween(operand: operand, lowerBound: lowerBound, upperBound: upperBound)) + } + + /// Shorthand for `orWhere(SQLBetween(operand: SQLColumn(column), lowerBound: SQLBind(lowerBound), upperBound: SQLBind(upperBound)))`. + @discardableResult + @inlinable + public func orWhere( + column: String, + between lowerBound: some Encodable & Sendable, + and upperBound: some Encodable & Sendable + ) -> Self { + self.orWhere(SQLBetween(operand: SQLColumn(column), lowerBound: SQLBind(lowerBound), upperBound: SQLBind(upperBound))) + } + + /// Shorthand for `orWhere(SQLBetween(operand: SQLColumn(column), lowerBound: SQLBind(lowerBound), upperBound: upperBound))`. + @discardableResult + @inlinable + public func orWhere( + column: String, + between lowerBound: some Encodable & Sendable, + and upperBound: some SQLExpression + ) -> Self { + self.orWhere(SQLBetween(operand: SQLColumn(column), lowerBound: SQLBind(lowerBound), upperBound: upperBound)) + } + + /// Shorthand for `orWhere(SQLBetween(operand: SQLColumn(column), lowerBound: lowerBound, upperBound: SQLBind(upperBound)))`. + @discardableResult + @inlinable + public func orWhere( + column: String, + between lowerBound: some SQLExpression, + and upperBound: some Encodable & Sendable + ) -> Self { + self.orWhere(SQLBetween(operand: SQLColumn(column), lowerBound: lowerBound, upperBound: SQLBind(upperBound))) + } + + /// Shorthand for `orWhere(SQLBetween(operand: SQLColumn(column), lowerBound: lowerBound, upperBound: upperBound))`. + @discardableResult + @inlinable + public func orWhere( + column: String, + between lowerBound: some SQLExpression, + and upperBound: some SQLExpression + ) -> Self { + self.orWhere(SQLBetween(operand: SQLColumn(column), lowerBound: lowerBound, upperBound: upperBound)) + } +} + +// MARK: - `SQLSecondaryPredicateBuilder` extensions + +extension SQLSecondaryPredicateBuilder { + /// Shorthand for `having(SQLBetween(operand, lowerBound, upperBound))`. + @discardableResult + @inlinable + public func having( + _ operand: some Encodable & Sendable, + between lowerBound: some Encodable & Sendable, + and upperBound: some Encodable & Sendable + ) -> Self { + self.having(SQLBetween(operand, between: lowerBound, and: upperBound)) + } + + /// Shorthand for `having(SQLBetween(operand, lowerBound, upperBound))`. + @discardableResult + @inlinable + public func having( + _ operand: some SQLExpression, + between lowerBound: some Encodable & Sendable, + and upperBound: some Encodable & Sendable + ) -> Self { + self.having(SQLBetween(operand, between: lowerBound, and: upperBound)) + } + + /// Shorthand for `having(SQLBetween(operand, lowerBound, upperBound))`. + @discardableResult + @inlinable + public func having( + _ operand: some Encodable & Sendable, + between lowerBound: some SQLExpression, + and upperBound: some Encodable & Sendable + ) -> Self { + self.having(SQLBetween(operand, between: lowerBound, and: upperBound)) + } + + /// Shorthand for `having(SQLBetween(operand, lowerBound, upperBound))`. + @discardableResult + @inlinable + public func having( + _ operand: some Encodable & Sendable, + between lowerBound: some Encodable & Sendable, + and upperBound: some SQLExpression + ) -> Self { + self.having(SQLBetween(operand, between: lowerBound, and: upperBound)) + } + + /// Shorthand for `having(SQLBetween(operand, lowerBound, upperBound))`. + @discardableResult + @inlinable + public func having( + _ operand: some SQLExpression, + between lowerBound: some SQLExpression, + and upperBound: some Encodable & Sendable + ) -> Self { + self.having(SQLBetween(operand, between: lowerBound, and: upperBound)) + } + + /// Shorthand for `having(SQLBetween(operand, lowerBound, upperBound))`. + @discardableResult + @inlinable + public func having( + _ operand: some SQLExpression, + between lowerBound: some Encodable & Sendable, + and upperBound: some SQLExpression + ) -> Self { + self.having(SQLBetween(operand, between: lowerBound, and: upperBound)) + } + + /// Shorthand for `having(SQLBetween(operand, lowerBound, upperBound))`. + @discardableResult + @inlinable + public func having( + _ operand: some Encodable & Sendable, + between lowerBound: some SQLExpression, + and upperBound: some SQLExpression + ) -> Self { + self.having(SQLBetween(operand, between: lowerBound, and: upperBound)) + } + + /// Shorthand for `having(SQLBetween(operand: SQLBind(operand), lowerBound: SQLBind(lowerBound), upperBound: SQLBind(upperBound)))`. + @discardableResult + @inlinable + public func having( + _ operand: some SQLExpression, + between lowerBound: some SQLExpression, + and upperBound: some SQLExpression + ) -> Self { + self.having(SQLBetween(operand: operand, lowerBound: lowerBound, upperBound: upperBound)) + } + + /// Shorthand for `having(SQLBetween(operand: SQLColumn(column), lowerBound: SQLBind(lowerBound), upperBound: SQLBind(upperBound)))`. + @discardableResult + @inlinable + public func having( + column: String, + between lowerBound: some Encodable & Sendable, + and upperBound: some Encodable & Sendable + ) -> Self { + self.having(SQLBetween(operand: SQLColumn(column), lowerBound: SQLBind(lowerBound), upperBound: SQLBind(upperBound))) + } + + /// Shorthand for `having(SQLBetween(operand: SQLColumn(column), lowerBound: SQLBind(lowerBound), upperBound: upperBound))`. + @discardableResult + @inlinable + public func having( + column: String, + between lowerBound: some Encodable & Sendable, + and upperBound: some SQLExpression + ) -> Self { + self.having(SQLBetween(operand: SQLColumn(column), lowerBound: SQLBind(lowerBound), upperBound: upperBound)) + } + + /// Shorthand for `having(SQLBetween(operand: SQLColumn(column), lowerBound: lowerBound, upperBound: SQLBind(upperBound)))`. + @discardableResult + @inlinable + public func having( + column: String, + between lowerBound: some SQLExpression, + and upperBound: some Encodable & Sendable + ) -> Self { + self.having(SQLBetween(operand: SQLColumn(column), lowerBound: lowerBound, upperBound: SQLBind(upperBound))) + } + + /// Shorthand for `having(SQLBetween(operand: SQLColumn(column), lowerBound: lowerBound, upperBound: upperBound))`. + @discardableResult + @inlinable + public func having( + column: String, + between lowerBound: some SQLExpression, + and upperBound: some SQLExpression + ) -> Self { + self.having(SQLBetween(operand: SQLColumn(column), lowerBound: lowerBound, upperBound: upperBound)) + } + + /// Shorthand for `orHaving(SQLBetween(operand, lowerBound, upperBound))`. + @discardableResult + @inlinable + public func orHaving( + _ operand: some Encodable & Sendable, + between lowerBound: some Encodable & Sendable, + and upperBound: some Encodable & Sendable + ) -> Self { + self.orHaving(SQLBetween(operand, between: lowerBound, and: upperBound)) + } + + /// Shorthand for `orHaving(SQLBetween(operand, lowerBound, upperBound))`. + @discardableResult + @inlinable + public func orHaving( + _ operand: some SQLExpression, + between lowerBound: some Encodable & Sendable, + and upperBound: some Encodable & Sendable + ) -> Self { + self.orHaving(SQLBetween(operand, between: lowerBound, and: upperBound)) + } + + /// Shorthand for `orHaving(SQLBetween(operand, lowerBound, upperBound))`. + @discardableResult + @inlinable + public func orHaving( + _ operand: some Encodable & Sendable, + between lowerBound: some SQLExpression, + and upperBound: some Encodable & Sendable + ) -> Self { + self.orHaving(SQLBetween(operand, between: lowerBound, and: upperBound)) + } + + /// Shorthand for `orHaving(SQLBetween(operand, lowerBound, upperBound))`. + @discardableResult + @inlinable + public func orHaving( + _ operand: some Encodable & Sendable, + between lowerBound: some Encodable & Sendable, + and upperBound: some SQLExpression + ) -> Self { + self.orHaving(SQLBetween(operand, between: lowerBound, and: upperBound)) + } + + /// Shorthand for `orHaving(SQLBetween(operand, lowerBound, upperBound))`. + @discardableResult + @inlinable + public func orHaving( + _ operand: some SQLExpression, + between lowerBound: some SQLExpression, + and upperBound: some Encodable & Sendable + ) -> Self { + self.orHaving(SQLBetween(operand, between: lowerBound, and: upperBound)) + } + + /// Shorthand for `orHaving(SQLBetween(operand, lowerBound, upperBound))`. + @discardableResult + @inlinable + public func orHaving( + _ operand: some SQLExpression, + between lowerBound: some Encodable & Sendable, + and upperBound: some SQLExpression + ) -> Self { + self.orHaving(SQLBetween(operand, between: lowerBound, and: upperBound)) + } + + /// Shorthand for `orHaving(SQLBetween(operand, lowerBound, upperBound))`. + @discardableResult + @inlinable + public func orHaving( + _ operand: some Encodable & Sendable, + between lowerBound: some SQLExpression, + and upperBound: some SQLExpression + ) -> Self { + self.orHaving(SQLBetween(operand, between: lowerBound, and: upperBound)) + } + + /// Shorthand for `orHaving(SQLBetween(operand: SQLBind(operand), lowerBound: SQLBind(lowerBound), upperBound: SQLBind(upperBound)))`. + @discardableResult + @inlinable + public func orHaving( + _ operand: some SQLExpression, + between lowerBound: some SQLExpression, + and upperBound: some SQLExpression + ) -> Self { + self.orHaving(SQLBetween(operand: operand, lowerBound: lowerBound, upperBound: upperBound)) + } + + /// Shorthand for `orHaving(SQLBetween(operand: SQLColumn(column), lowerBound: SQLBind(lowerBound), upperBound: SQLBind(upperBound)))`. + @discardableResult + @inlinable + public func orHaving( + column: String, + between lowerBound: some Encodable & Sendable, + and upperBound: some Encodable & Sendable + ) -> Self { + self.orHaving(SQLBetween(operand: SQLColumn(column), lowerBound: SQLBind(lowerBound), upperBound: SQLBind(upperBound))) + } + + /// Shorthand for `orHaving(SQLBetween(operand: SQLColumn(column), lowerBound: SQLBind(lowerBound), upperBound: upperBound))`. + @discardableResult + @inlinable + public func orHaving( + column: String, + between lowerBound: some Encodable & Sendable, + and upperBound: some SQLExpression + ) -> Self { + self.orHaving(SQLBetween(operand: SQLColumn(column), lowerBound: SQLBind(lowerBound), upperBound: upperBound)) + } + + /// Shorthand for `orHaving(SQLBetween(operand: SQLColumn(column), lowerBound: lowerBound, upperBound: SQLBind(upperBound)))`. + @discardableResult + @inlinable + public func orHaving( + column: String, + between lowerBound: some SQLExpression, + and upperBound: some Encodable & Sendable + ) -> Self { + self.orHaving(SQLBetween(operand: SQLColumn(column), lowerBound: lowerBound, upperBound: SQLBind(upperBound))) + } + + /// Shorthand for `orHaving(SQLBetween(operand: SQLColumn(column), lowerBound: lowerBound, upperBound: upperBound))`. + @discardableResult + @inlinable + public func orHaving( + column: String, + between lowerBound: some SQLExpression, + and upperBound: some SQLExpression + ) -> Self { + self.orHaving(SQLBetween(operand: SQLColumn(column), lowerBound: lowerBound, upperBound: upperBound)) + } +} diff --git a/Sources/SQLKit/Expressions/Basics/SQLColumn.swift b/Sources/SQLKit/Expressions/Basics/SQLColumn.swift index b3e80644..6bdb1fc3 100644 --- a/Sources/SQLKit/Expressions/Basics/SQLColumn.swift +++ b/Sources/SQLKit/Expressions/Basics/SQLColumn.swift @@ -1,18 +1,25 @@ +/// An expression representing an optionally table-qualified column in an SQL table. public struct SQLColumn: SQLExpression { + /// The column name, usually an ``SQLIdentifier``. public var name: any SQLExpression + + /// If specified, the table to which the column belongs. Usually an ``SQLIdentifier`` when not `nil`. public var table: (any SQLExpression)? + /// Create an ``SQLColumn`` from a name and optional table name. @inlinable public init(_ name: String, table: String? = nil) { self.init(SQLIdentifier(name), table: table.flatMap(SQLIdentifier.init(_:))) } + /// Create an ``SQLColumn`` from an identifier and optional table identifier. @inlinable public init(_ name: any SQLExpression, table: (any SQLExpression)? = nil) { self.name = name self.table = table } + // See `SQLExpression.serialize(to:)`. @inlinable public func serialize(to serializer: inout SQLSerializer) { if let table = self.table { diff --git a/Sources/SQLKit/Expressions/Basics/SQLConstraint.swift b/Sources/SQLKit/Expressions/Basics/SQLConstraint.swift index 561184c2..ad3d1931 100644 --- a/Sources/SQLKit/Expressions/Basics/SQLConstraint.swift +++ b/Sources/SQLKit/Expressions/Basics/SQLConstraint.swift @@ -1,28 +1,37 @@ -/// Constraints for ``SQLCreateTable`` (column and table constraints). +/// An expression representing the combination of a constraint name and algorithm for table constraints. +/// +/// See ``SQLTableConstraintAlgorithm``. public struct SQLConstraint: SQLExpression { - /// Name of constraint + /// The constraint's name, if any. /// - /// `CONSTRAINT ` + /// It is pointless to use ``SQLConstraint`` in the absence of a ``name``, but the optionality is part of + /// preexisting public API and cannot be changed. public var name: (any SQLExpression)? - /// Algorithm. See `SQLTableConstraintAlgorithm` - /// and `SQLColumnConstraintAlgorithm` - /// TODO: Make optional. + /// The constraint's algorithm. + /// + /// See ``SQLTableConstraintAlgorithm``. public var algorithm: any SQLExpression + /// Create an ``SQLConstraint``. + /// + /// - Parameters: + /// - algorithm: The constraint algorithm. + /// - name: The optional constraint name. @inlinable public init(algorithm: any SQLExpression, name: (any SQLExpression)? = nil) { self.name = name self.algorithm = algorithm } + // See `SQLExpression.serialize(to:)`. public func serialize(to serializer: inout SQLSerializer) { - if let name = self.name { - serializer.write("CONSTRAINT ") - let normalizedName = serializer.dialect.normalizeSQLConstraint(identifier: name) - normalizedName.serialize(to: &serializer) - serializer.write(" ") + serializer.statement { + if let name = self.name { + let normalized = $0.dialect.normalizeSQLConstraint(identifier: name) + $0.append("CONSTRAINT", normalized) + } + $0.append(self.algorithm) } - self.algorithm.serialize(to: &serializer) } } diff --git a/Sources/SQLKit/Expressions/Basics/SQLDataType.swift b/Sources/SQLKit/Expressions/Basics/SQLDataType.swift index a942095d..1f037933 100644 --- a/Sources/SQLKit/Expressions/Basics/SQLDataType.swift +++ b/Sources/SQLKit/Expressions/Basics/SQLDataType.swift @@ -1,33 +1,66 @@ -/// SQL data type protocol, i.e., `INTEGER`, `TEXT`, etc. +/// Represents a value's type in SQL. +/// +/// In practice it is not generally possible to list all of the data types supported by any given database, nor +/// to define a useful set of types with identical behaviors which are available across all databases, despite the +/// attempted influence of ANSI SQL. As such, this type primarily functions as a front end for +/// ``SQLDialect/customDataType(for:)-2firt``. public enum SQLDataType: SQLExpression { + /// Translates to `SMALLINT`, unless overriden by dialect. Usually an integer with at least 16-bit range. case smallint + + /// Translates to `INTEGER`, unless overridden by dialect. Usually an integer with at least 32-bit range. case int + + /// Translates to `BIGINT`, unless overridden by dialect. Almost always an integer with 64-bit range. case bigint - case text + + /// Translates to `REAL`, unless overridden by dialect. Usually a decimal value with at least 32-bit precision. case real - case blob - case custom(any SQLExpression) - @available(*, deprecated, message: "This is a test utility method that was incorrectly made public. Use `.custom()` directly instead.") - @inlinable - public static func type(_ string: String) -> Self { - .custom(SQLIdentifier(string)) + /// Translates to `TEXT`, unless overridden by dialect. Represents non-binary textual data (i.e. human-readable + /// text potentially having an explicit character set and collation). + case text + + /// Translates to `BLOB`, unless overridden by dialect. Represents binary non-textual data (i.e. an arbitrary + /// byte string admitting of no particular format or representation). + case blob + + /// Translates to `TIMESTAMP`, unless overridden by dialect. Represents a type suitable for storing the encoded + /// value of a `Date` in a form which can be saved to and reloaded from the database without suffering skew caused + /// by time zone calculations. + /// + /// > Note: Implemented as a static var rather than a new case for now because adding new cases to a public enum + /// > is a source-breaking change. + public static var timestamp: Self { + .custom(SQLRaw("TIMESTAMP")) } + /// Translates to the serialization of the given expression, unless overridden by dialect. + case custom(any SQLExpression) + + // See `SQLExpression.serialize(to:)`. @inlinable public func serialize(to serializer: inout SQLSerializer) { let sql: any SQLExpression + if let dialect = serializer.dialect.customDataType(for: self) { sql = dialect } else { switch self { - case .smallint: sql = SQLRaw("SMALLINT") - case .int: sql = SQLRaw("INTEGER") - case .bigint: sql = SQLRaw("BIGINT") - case .text: sql = SQLRaw("TEXT") - case .real: sql = SQLRaw("REAL") - case .blob: sql = SQLRaw("BLOB") - case .custom(let exp): sql = exp + case .smallint: + sql = SQLRaw("SMALLINT") + case .int: + sql = SQLRaw("INTEGER") + case .bigint: + sql = SQLRaw("BIGINT") + case .text: + sql = SQLRaw("TEXT") + case .real: + sql = SQLRaw("REAL") + case .blob: + sql = SQLRaw("BLOB") + case .custom(let exp): + sql = exp } } sql.serialize(to: &serializer) diff --git a/Sources/SQLKit/Expressions/Basics/SQLDirection.swift b/Sources/SQLKit/Expressions/Basics/SQLDirection.swift index cc6a43b2..df92626a 100644 --- a/Sources/SQLKit/Expressions/Basics/SQLDirection.swift +++ b/Sources/SQLKit/Expressions/Basics/SQLDirection.swift @@ -1,18 +1,29 @@ +/// Describes an ordering direction for a given sorting key. public enum SQLDirection: SQLExpression { + /// Ascending order (minimum to maximum), as defined by the sorting key's data type. case ascending + + /// Descending order (maximum to minimum), as defined by the sorting key's data type. case descending - /// Order in which NULL values come first. + + /// `NULL` order (`NULL` values followed by non-`NULL` valeus). case null - /// Order in which NOT NULL values come first. + + /// `NOT NULL` order (non-`NULL` values followed by `NULL` values). case notNull + // See `SQLExpression.serialize(to:)`. @inlinable public func serialize(to serializer: inout SQLSerializer) { switch self { - case .ascending: serializer.write("ASC") - case .descending: serializer.write("DESC") - case .null: serializer.write("NULL") - case .notNull: serializer.write("NOT NULL") + case .ascending: + serializer.write("ASC") + case .descending: + serializer.write("DESC") + case .null: + serializer.write("NULL") + case .notNull: + serializer.write("NOT NULL") } } } diff --git a/Sources/SQLKit/Expressions/Basics/SQLDistinct.swift b/Sources/SQLKit/Expressions/Basics/SQLDistinct.swift index e9d191d1..dd22debc 100644 --- a/Sources/SQLKit/Expressions/Basics/SQLDistinct.swift +++ b/Sources/SQLKit/Expressions/Basics/SQLDistinct.swift @@ -1,32 +1,58 @@ +/// An expression representing the subexpression of an aggregate function call which specifies whether the aggregate +/// groups over all result rows or only distinct rows. +/// +/// This expression is another example of incomplete API design; it should properly be implemented as an expression +/// called `SQLAggregateFunction` which includes the aggregate function name as part of the expression and allows +/// specifying `ORDER BY` and `FILTER` clauses as supported by various dialects. An example of using it in the current +/// implementation: +/// +/// ```sql +/// let count = try await database.select() +/// .column(SQLFunction("count", SQLDistinct(SQLColumn("column1"))), as: "count") +/// .first(decodingColumn: "count", as: Int.self)! +/// ``` public struct SQLDistinct: SQLExpression { + /// Zero or more identifiers and/or expressions to treat as a combined uniquing key. public let args: [any SQLExpression] + /// Shorthand for `SQLDistinct(SQLLiteral.all)`. @inlinable - public init(_ args: String...) { + public static var all: SQLDistinct { + .init(SQLLiteral.all) + } + + /// Create a `DISTINCT` expression with a list of string identifiers. + @inlinable + public init(_ args: String...) { + self.init(args) + } + + /// Create a `DISTINCT` expression with a list of string identifiers. + @inlinable + public init(_ args: [String]) { self.init(args.map(SQLIdentifier.init(_:))) } + /// Create a `DISTINCT` expression with a list of expressions. @inlinable public init(_ args: any SQLExpression...) { self.init(args) } + /// Create a `DISTINCT` expression with a list of expressions. @inlinable public init(_ args: [any SQLExpression]) { self.args = args } + // See `SQLExpression.serialize(to:)`. @inlinable public func serialize(to serializer: inout SQLSerializer) { - guard !args.isEmpty else { return } - serializer.write("DISTINCT") - SQLGroupExpression(args).serialize(to: &serializer) - } -} - -extension SQLDistinct { - @inlinable - public static var all: SQLDistinct { - .init(SQLLiteral.all) + guard !self.args.isEmpty else { + return + } + serializer.statement { + $0.append("DISTINCT", SQLList(self.args)) + } } } diff --git a/Sources/SQLKit/Expressions/Basics/SQLForeignKeyAction.swift b/Sources/SQLKit/Expressions/Basics/SQLForeignKeyAction.swift index 7e99ce37..91b81a4d 100644 --- a/Sources/SQLKit/Expressions/Basics/SQLForeignKeyAction.swift +++ b/Sources/SQLKit/Expressions/Basics/SQLForeignKeyAction.swift @@ -1,23 +1,43 @@ -/// `RESTRICT | CASCADE | SET NULL | NO ACTION | SET DEFAULT` +/// An expression specifying a behavior for a foreign key constraint violation. public enum SQLForeignKeyAction: SQLExpression { - /// Produce an error indicating that the deletion or update would create a foreign key constraint violation. - /// If the constraint is deferred, this error will be produced at constraint check time if there still exist any referencing rows. + /// The `NO ACTION` behavior. + /// + /// `NO ACTION` triggers an SQL error indicating that the operation in progress has violated a foreign + /// key constraint. For immediate constraints (the default in most systems), the behavior is identical to + /// ``restrict``. If the violated constraint is deferred, the error is not raised immediately, and the + /// remainder of the query in progress is given an opportunity to complete and potentially negate the + /// violation. + /// /// This is the default action. case noAction - /// Produce an error indicating that the deletion or update would create a foreign key constraint violation. + /// The `RESTRICT` behavior. + /// + /// `RESTRICT` triggers an SQL error indicating that the operation in progress has violated a foreign + /// key constraint. The error is raised immediately, regardless of the deferred status of the constraint. case restrict - /// Delete any rows referencing the deleted row, or update the values of the referencing column(s) to the new values of the referenced columns, respectively. + /// The `CASCADE` behavior. + /// + /// `CASCADE` specifies that the action which triggered the constraint violation shall be forwarded to + /// the referenced foreign row(s) (causing them to be deleted or updated as appropriate). Cascading foreign + /// key behaviors are recursive. case cascade - /// Set the referencing column(s) to null. + /// The `SET NULL` behavior. + /// + /// `SET NULL` specifies that a violation of a foreign key constraint shall result in setting the values of + /// the columns comprising the constraint to `NULL`. case setNull - /// Set the referencing column(s) to their default values. - /// (There must be a row in the referenced table matching the default values, if they are not null, or the operation will fail.) + /// The `SET DEFAULT` behavior. + /// + /// `SET DEFAULT` specifies that a violation of a foreign key constraint shall result in setting the values of + /// the columns comprising the constraint to their respective default values. The resulting contents of the + /// updated row must comprise a valid reference to the foreign table. case setDefault + // See `SQLExpression.serialize(to:)`. @inlinable public func serialize(to serializer: inout SQLSerializer) { switch self { diff --git a/Sources/SQLKit/Expressions/Basics/SQLNestedSubpathExpression.swift b/Sources/SQLKit/Expressions/Basics/SQLNestedSubpathExpression.swift index 32b0f174..387b5086 100644 --- a/Sources/SQLKit/Expressions/Basics/SQLNestedSubpathExpression.swift +++ b/Sources/SQLKit/Expressions/Basics/SQLNestedSubpathExpression.swift @@ -1,9 +1,22 @@ -/// Represents a "nested subpath" expression. At this time, this always represents a key path leading to a -/// specific value in a JSON object. +/// A "nested subpath" expression is used to descend into the "deeper" structure of a non-scalar value, +/// such as a dictionary, array, or JSON value. +/// +/// This expression is effectively an API for ``SQLDialect/nestedSubpathExpression(in:for:)-6lhiy``, +/// which is defined as providing an expression for descending specifically into JSON values only. As a +/// result, the more "general" usage of applying a nested subpath to _any_ non-scalar value is not +/// available via this interface. public struct SQLNestedSubpathExpression: SQLExpression { + /// The expression to which the nested subpath is applied. public var column: any SQLExpression + + /// The subpath itself. **Must** always contain at least one element. public var path: [String] + /// Create a nested subpath from an expression and an array of one or more path elements. + /// + /// - Parameters: + /// - column: The expression to which the nested subpath applies. + /// - path: The subpath itself. If this array is empty, a runtime error occurs. public init(column: any SQLExpression, path: [String]) { assert(!path.isEmpty) @@ -11,10 +24,16 @@ public struct SQLNestedSubpathExpression: SQLExpression { self.path = path } + /// Create a nested subpath from an identifier string and an array of one or more path elements. + /// + /// - Parameters: + /// - column: A string to treat as an identifier to which the nested subpath applies. + /// - path: The subpath itself. If this array is empty, a runtime error occurs. public init(column: String, path: [String]) { self.init(column: SQLIdentifier(column), path: path) } + // See `SQLExpression.serialize(to:)`. public func serialize(to serializer: inout SQLSerializer) { serializer.dialect.nestedSubpathExpression(in: self.column, for: self.path)?.serialize(to: &serializer) } diff --git a/Sources/SQLKit/Expressions/Basics/SQLQualifiedTable.swift b/Sources/SQLKit/Expressions/Basics/SQLQualifiedTable.swift new file mode 100644 index 00000000..06fae82b --- /dev/null +++ b/Sources/SQLKit/Expressions/Basics/SQLQualifiedTable.swift @@ -0,0 +1,33 @@ +/// An expression representing an optionally second-level-qualified SQL table. +/// +/// The meaning of a second-level qualification as applied to a table is dependent on the underlying database. +/// In PostgreSQL, a table reference is typically qualified with a schema; in MySQL or SQLite, a qualified table +/// reference refers to an alternate database. +public struct SQLQualifiedTable: SQLExpression { + /// The table name, usually an ``SQLIdentifier``. + public var table: any SQLExpression + + /// If specified, the second-level namespace to which the table belongs. + /// Usually an ``SQLIdentifier`` if not `nil`. + public var space: (any SQLExpression)? + + /// Create an ``SQLQualifiedTable`` from a name and optional second-level namespace. + public init(_ table: String, space: String? = nil) { + self.init(SQLIdentifier(table), space: space.flatMap(SQLIdentifier.init(_:))) + } + + /// Create an ``SQLQualifiedTable`` from an identifier and optional second-level identifier. + public init(_ table: any SQLExpression, space: (any SQLExpression)? = nil) { + self.table = table + self.space = space + } + + // See `SQLExpression.serialize(to:)`. + public func serialize(to serializer: inout SQLSerializer) { + if let space = self.space { + space.serialize(to: &serializer) + serializer.write(".") + } + self.table.serialize(to: &serializer) + } +} diff --git a/Sources/SQLKit/Expressions/Basics/SQLQueryString.swift b/Sources/SQLKit/Expressions/Basics/SQLQueryString.swift new file mode 100644 index 00000000..a9dd7298 --- /dev/null +++ b/Sources/SQLKit/Expressions/Basics/SQLQueryString.swift @@ -0,0 +1,267 @@ +/// An expression consisting of an array of constituent subexpressions generated by custom string interpolations. +/// +/// Query strings are primarily intended for use with ``SQLRawBuilder``, providing for the inclusion of bound +/// parameters in otherwise "raw" queries. The API also supports some of the more commonly used quoting functionality. +/// Query strings are also ``SQLExpression``s, allowing them to be used almost anywhere in SQLKit. +/// +/// A corollary to this is that, while a given ``SQLQueryString`` can represent an entire complete "query" to execute +/// against a database, it can also - as with any ``SQLExpression`` but particularly similarly to ``SQLStatement`` - +/// represent any lesser fragment of SQL right down to an empty string, or anywhere in between. +/// +/// Example usage: +/// +/// ```swift +/// // As an entire query: +/// try await database.raw(""" +/// UPDATE \(ident: "foo") +/// SET \(ident: "bar")=\(bind: value) +/// WHERE \(ident: "baz")=\(literal: "bop") +/// """).run() +/// +/// // As an SQL fragment (albeit in an extremely contrived fashion): +/// try await database.update("foo") +/// .set("bar", to: value) +/// .where("\(ident: "baz")" as SQLQueryString, .equal, "\(literal: "bop")" as SQLQueryString) +/// .run() +/// ``` +/// +/// ``SQLQueryString``'s additional interpolations (such as `\(ident:)`, `\(literal:)`, etc., as well as the ability +/// to embed arbitrary expressions with `\(_:)`) are useful in particular for writing raw queries which are +/// nonetheless compatible with multiple SQL dialects, such as in the following example: +/// +/// ```swift +/// let messyIdentifer = someCondition ? "abcd{}efgh" : "marmalade!!" // invalid identifiers if not escaped +/// try await database.raw(""" +/// SELECT \(ident: messyIdentifier) FROM \(ident: "whatever") WHERE \(ident: "x")=\(bind: "foo") +/// """).all() +/// // This query renders differently in various dialect: +/// // - PostgreSQL: SELECT "abcd{}efgh" FROM "whatever" WHERE "x"=$0 ["foo"] +/// // - MySQL: SELECT `abcd{}efgh` FROM `whatever` WHERE `x`=? ["foo"] +/// // - SQLite: SELECT "abcd{}efgh" FROM "whatever" WHERE "x"=?0 ["foo"] +/// ``` +/// +/// > Bonus remarks: +/// > +/// > - Even in Swift 5.10, language limitations prevent supporting literal strings everywhere ``SQLExpression``s +/// > are allowed, because the necessary conformance (e.g. +/// > `extension SQLExpression: ExtensibleByStringLiteral where Self == SQLQueryString`) is not allowed by the +/// > compiler. The maintainer of this package at the time of this writing considers this to perhaps be a blessing +/// > in disguise, given the concern that it is already "too easy" as things stand to embed raw SQL into queries +/// > without worrying about injection concerns. As she might put it, "You can already write entire raw queries +/// > without escaping any of the things you ought to be," paying no heed to the fact that she was the one who +/// > brought up the topic in the first place. +/// > - ``SQLQueryString`` is almost identical to ``SQLStatement``; they track content identically, operate by +/// > building up output based on progressive inputs, and often (indeed, usually) represent entire queries. At this +/// > point, the only remaining reason they haven't been made into a single type is the confusion wouldn't be worth +/// > it in light of the expectation, at the time of this writing, that this package will soon be receiving a major +/// > version bump, at which point far more opportunities will indeed abound. +public struct SQLQueryString: SQLExpression, ExpressibleByStringInterpolation, StringInterpolationProtocol { + @usableFromInline + var fragments: [any SQLExpression] + + /// Create a query string from a plain string containing raw SQL. + @inlinable + public init(_ string: String) { + self.fragments = [SQLRaw(string)] + } + + // See `SQLExpression.serialize(to:)`. + @inlinable + public func serialize(to serializer: inout SQLSerializer) { + self.fragments.forEach { $0.serialize(to: &serializer) } + } +} + +// MARK: - Interpolation support +extension SQLQueryString { + // See `ExpressibleByStringLiteral.init(stringLiteral:)`. + @inlinable + public init(stringLiteral value: String) { + self.init(value) + } + + // See `ExpressibleByStringInterpolation.init(stringInterpolation:)`. + @inlinable + public init(stringInterpolation: SQLQueryString) { + /// Since ``SQLQueryString`` is its own string interpolation type, we can just use it as-is and save + /// any additional allocations or copying. + self = stringInterpolation + } + + // See `StringInterpolationProtocol.init(literalCapacity:interpolationCount:)`. + @inlinable + public init(literalCapacity: Int, interpolationCount: Int) { + self.fragments = [] + self.fragments.reserveCapacity(literalCapacity + interpolationCount) + } + + // See `StringInterpolationProtocol.appendLiteral(_:)`. + @inlinable + public mutating func appendLiteral(_ literal: String) { + self.fragments.append(SQLRaw(literal)) + } +} + +// MARK: - Custom interpolations +extension SQLQueryString { + /// Adds an interpolated string of raw SQL, potentially including associated parameter bindings. + /// + /// > Warning: This interpolation is inherently unsafe. It provides no protection whatsoever against SQL + /// > injection attacks and maintains no awareness of dialect considerations or syntactical constraints. Use + /// > a purpose-specific expression instead whenever possible. + @inlinable + public mutating func appendInterpolation(unsafeRaw value: String) { + self.fragments.append(SQLRaw(value)) + } + + /// Embed an `Encodable` value as a binding in the SQL query. + /// + /// This overload is provided as shorthand - `\(bind: "a")` is identical to `\(SQLBind("a"))`. + @inlinable + public mutating func appendInterpolation(bind value: some Encodable & Sendable) { + self.fragments.append(SQLBind(value)) + } + + /// Embed any number of `Encodable` values as bindings in the SQL query, separating the bind + /// placeholders with commas. + /// + /// This overload is equivalent to `\(SQLList(values.map(SQLBind.init(_:))))`. + @inlinable + public mutating func appendInterpolation(binds values: [any Encodable & Sendable]) { + self.fragments.append(SQLList(values.map { SQLBind($0) })) + } + + /// Embed a `Bool` as a literal value, as if via ``SQLLiteral/boolean(_:)``. + @inlinable + public mutating func appendInterpolation(_ value: Bool) { + self.fragments.append(SQLLiteral.boolean(value)) + } + + /// Embed an integer as a literal value, as if via ``SQLLiteral/numeric(_:)`` + /// + /// Use this interpolation when a value is already known to be safe otherwise, to ensure numeric values are + /// appropriately and accurately serialized. Do _not_ use this method for arbitrary numeric input; bind such + /// values to the query via ``SQLBind`` or ``appendInterpolation(bind:)``. + @inlinable + public mutating func appendInterpolation(literal: some BinaryInteger) { + self.fragments.append(SQLLiteral.numeric("\(literal)")) + } + + /// Embed a floating-point number as a literal value, as if via ``SQLLiteral/numeric(_:)`` + /// + /// Use this preferentially to ensure values are appropriately represented in the database's dialect. + @inlinable + public mutating func appendInterpolation(literal: some BinaryFloatingPoint) { + self.fragments.append(SQLLiteral.numeric("\(literal)")) + } + + /// Embed a `String` as a literal value, as if via ``SQLLiteral/string(_:)``. + /// + /// Use this preferentially to ensure string values are appropriately represented in the + /// database's dialect. + @inlinable + public mutating func appendInterpolation(literal: String) { + self.fragments.append(SQLLiteral.string(literal)) + } + + /// Embed an array of `String`s as a list of literal values, placing the `joiner` between each pair of values. + /// + /// This is equivalent to adding an ``SQLList`` whose subexpressions are all ``SQLLiteral/string(_:)``s and whose + /// separator is the `joiner` wrapped by ``SQLRaw``. + /// + /// Example: + /// + /// ```swift + /// sqliteDatabase.serialize(""" + /// SELECT \(literals: "a", "b", "c", "d", joinedBy: "||") FROM \(ident: "nowhere") + /// """ as SQLQueryString + /// ).sql + /// // SELECT 'a'||'b'||'c'||'d' FROM "nowhere" + /// ``` + @inlinable + public mutating func appendInterpolation(literals: [String], joinedBy joiner: String) { + self.fragments.append(SQLList(literals.map { SQLLiteral.string($0) }, separator: SQLRaw(joiner))) + } + + /// Embed a `String` as an identifier, as if via ``SQLIdentifier``. + /// + /// Use this interpolation preferentially to ensure that table names, column names, and other non-keyword + /// identifier are correctly quoted and escaped. + @inlinable + public mutating func appendInterpolation(ident: String) { + self.fragments.append(SQLIdentifier(ident)) + } + + /// Embed an array of `String`s as a list of SQL identifiers, using the `joiner` to separate them. + /// + /// > Important: This interprets each string as an identifier, _not_ as a literal value! + /// + /// Example: + /// + /// ```swift + /// sqliteDatabase.serialize(""" + /// SELECT + /// \(idents: "a", "b", "c", "d", joinedBy: ",") + /// FROM + /// \(ident: "nowhere") + /// """ as SQLQueryString + /// ).sql + /// // SELECT "a", "b", "c", "d" FROM "nowhere" + /// ``` + @inlinable + public mutating func appendInterpolation(idents: [String], joinedBy joiner: String) { + self.fragments.append(SQLList(idents.map { SQLIdentifier($0) }, separator: SQLRaw(joiner))) + } + + /// Embed an arbitary ``SQLExpression`` in the string. + @inlinable + public mutating func appendInterpolation(_ expression: any SQLExpression) { + self.fragments.append(expression) + } +} + +// MARK: - Operators +extension SQLQueryString { + /// Concatenate two ``SQLQueryString``s and return the combined result. + @inlinable + public static func + (lhs: Self, rhs: Self) -> Self { + "\(lhs)\(rhs)" + } + + /// Append one ``SQLQueryString`` to another in-place. + @inlinable + public static func += (lhs: inout Self, rhs: Self) { + lhs.fragments.append(contentsOf: rhs.fragments) + } +} + +// MARK: - Sequence +extension Sequence { + /// Returns a new ``SQLQueryString`` formed by concatenating the elements of the sequence, adding the given + /// separator between each element. + /// + /// - Parameter separator: A string to insert between each of the elements in this sequence. The default + /// separator is an empty string. + /// - Returns: A single, concatenated string. + @inlinable + public func joined(separator: String = "") -> SQLQueryString { + self.joined(separator: SQLRaw(separator)) + } + + /// Returns a new ``SQLQueryString`` formed by concatenating the elements of the sequence, adding the given + /// separator between each element. + /// + /// - Parameter separator: An expression to insert between each of the elements in this sequence. + /// - Returns: A single, concatenated string. + @inlinable + public func joined(separator: some SQLExpression) -> SQLQueryString { + var iter = self.makeIterator() + var result = iter.next() ?? "" + + while let str = iter.next() { + result.fragments.append(separator) + result.fragments.append(contentsOf: str.fragments) + } + return result + } +} diff --git a/Sources/SQLKit/Expressions/Clauses/SQLAlterColumnDefinitionType.swift b/Sources/SQLKit/Expressions/Clauses/SQLAlterColumnDefinitionType.swift index fdd01d1c..2912bbe8 100644 --- a/Sources/SQLKit/Expressions/Clauses/SQLAlterColumnDefinitionType.swift +++ b/Sources/SQLKit/Expressions/Clauses/SQLAlterColumnDefinitionType.swift @@ -1,23 +1,47 @@ -/// Alters the data type of an existing column. +/// A clause specifying a new data type to be applied to an existing column. /// -/// column [alterColumnDefinitionTypeClause] dataType +/// This expression is used by ``SQLAlterTableBuilder/modifyColumn(_:type:_:)-24c9h`` to abstract over the use of +/// ``SQLAlterTableSyntax/alterColumnDefinitionTypeKeyword`` in the dialect's ``SQLDialect/alterTableSyntax-9bmcr``. +/// The serialized SQL is of the form: /// +/// ```sql +/// column alterColumnDefinitionTypeKeyword dataType +/// -- Given column == SQLIdentifier("col"), dataType == SQLDataTyoe.text: +/// -- PostgreSQL: "col" SET DATA TYPE TEXT +/// -- MySQL: `col` TEXT +/// ``` +/// +/// Users should not use this expression. It is an oversight that it is public API; it will eventually be removed. public struct SQLAlterColumnDefinitionType: SQLExpression { + /// The column to alter. public var column: any SQLExpression + + /// The new data type. public var dataType: any SQLExpression + /// Create a new ``SQLAlterColumnDefinitionType`` expression. + /// + /// - Parameters: + /// - column: The column to alter. + /// - dataType: The new data type. @inlinable public init(column: SQLIdentifier, dataType: SQLDataType) { self.column = column self.dataType = dataType } + /// Create a new ``SQLAlterColumnDefinitionType`` expression. + /// + /// - Parameters: + /// - column: The column to alter. + /// - dataType: The new data type. @inlinable public init(column: any SQLExpression, dataType: any SQLExpression) { self.column = column self.dataType = dataType } + // See `SQLExpression.serialize(to:)`. @inlinable public func serialize(to serializer: inout SQLSerializer) { serializer.statement { diff --git a/Sources/SQLKit/Expressions/Clauses/SQLColumnAssignment.swift b/Sources/SQLKit/Expressions/Clauses/SQLColumnAssignment.swift index bdd854c7..43cefda3 100644 --- a/Sources/SQLKit/Expressions/Clauses/SQLColumnAssignment.swift +++ b/Sources/SQLKit/Expressions/Clauses/SQLColumnAssignment.swift @@ -1,4 +1,4 @@ -/// Encapsulates a `col_name=value` expression in the context of an `UPDATE` query's value +/// Encapsulates a `column_name=value` expression in the context of an `UPDATE` query's value /// assignment list. This is distinct from an ``SQLBinaryExpression`` using the `.equal` /// operator in that the left side must be an _unqualified_ column name, the operator must /// be `=`, and the right side may use ``SQLExcludedColumn`` when the assignment appears in @@ -19,13 +19,13 @@ public struct SQLColumnAssignment: SQLExpression { /// Create a column assignment from a column identifier and value binding. @inlinable - public init(setting columnName: any SQLExpression, to value: any Encodable) { + public init(setting columnName: any SQLExpression, to value: some Encodable & Sendable) { self.init(setting: columnName, to: SQLBind(value)) } /// Create a column assignment from a column name and value binding. @inlinable - public init(setting columnName: String, to value: any Encodable) { + public init(setting columnName: String, to value: some Encodable & Sendable) { self.init(setting: columnName, to: SQLBind(value)) } @@ -36,26 +36,29 @@ public struct SQLColumnAssignment: SQLExpression { } /// Create a column assignment from a column name and using the excluded value - /// from an upsert's values list. See ``SQLExcludedColumn``. + /// from an upsert's values list. + /// + /// See ``SQLExcludedColumn`` for additional details about excluded values. @inlinable public init(settingExcludedValueFor columnName: String) { self.init(settingExcludedValueFor: SQLColumn(columnName)) } /// Create a column assignment from a column identifier and using the excluded value - /// from an upsert's values list. See ``SQLExcludedColumn``. + /// from an upsert's values list. + /// + /// See ``SQLExcludedColumn`` for additional details about excluded values. @inlinable public init(settingExcludedValueFor column: any SQLExpression) { self.init(setting: column, to: SQLExcludedColumn(column)) } - /// See `SQLExpression.serialize(to:)`. + // See `SQLExpression.serialize(to:)`. @inlinable public func serialize(to serializer: inout SQLSerializer) { serializer.statement { - $0.append(self.columnName) - $0.append("=") // do not use SQLBinaryOperator.equal, which may be `==` in some dialects - $0.append(self.value) + /// N.B.: Do not use SQLBinaryOperator.equal here; it can vary between dialects + $0.append(self.columnName, "=", self.value) } } } diff --git a/Sources/SQLKit/Expressions/Clauses/SQLColumnConstraintAlgorithm.swift b/Sources/SQLKit/Expressions/Clauses/SQLColumnConstraintAlgorithm.swift index 02e28d3d..390f5299 100644 --- a/Sources/SQLKit/Expressions/Clauses/SQLColumnConstraintAlgorithm.swift +++ b/Sources/SQLKit/Expressions/Clauses/SQLColumnConstraintAlgorithm.swift @@ -1,69 +1,128 @@ -/// Column constraint algorithms used by ``SQLColumnDefinition`` +/// Column-level data constraints. +/// +/// Most dialects of SQL support both column-level (specific to a single column) and table-level (applicable to a list +/// of one or more columns within the table) constraints. While some constraints can be expressed either way, others +/// are only allowed at the column level. See ``SQLTableConstraintAlgorithm`` for table-level constraints. +/// +/// Column-level constraints typically do not have separate constraint names, and are thus not used in concert with +/// ``SQLConstraint`` expressions except in unusual cases. +/// +/// Column constraints are used primarily by ``SQLColumnDefinition``, and also appear directly in the APIs of +/// ``SQLAlterTableBuilder``, ``SQLCreateIndexBuilder``, and ``SQLCreateTableBuilder``. public enum SQLColumnConstraintAlgorithm: SQLExpression { - /// `PRIMARY KEY`column constraint. + /// A `PRIMARY KEY` constraint, either with or without the auto-increment characteristic. + /// + /// Different SQL dialects define and express auto-increment functionality in widely varying ways. For example, + /// with SQLite, auto-increment determines the algorithm used for generating internal row identifiers, not whether + /// or not values are autogenerated. In PostgreSQL, auto-increment implies an additional ``generated(_:)`` + /// column constraint. In recognition of this, a future version of this API will handle auto-increment + /// functionality separately from primary key constraints. + /// + /// If the SQL dialect does not specify support for auto-increment, the flag has no effect. + /// + /// See also ``SQLTableConstraintAlgorithm/primaryKey(columns:)``. case primaryKey(autoIncrement: Bool) - /// `NOT NULL` column constraint. + /// A `NOT NULL` column constraint. + /// + /// This is a column-only data constraint; it cannot be specified at the table level. case notNull - /// `UNIQUE` column constraint. + /// A `UNIQUE` column constraint, also called a unique key. + /// + /// In most SQL dialects, a `UNIQUE` constraint also implies the presence of an index over the constrained column. + /// + /// See also ``SQLTableConstraintAlgorithm/unique(columns:)``. case unique - /// `CHECK` column constraint. + /// A `CHECK` column constraint and its associated validation expression. + /// + /// See also ``SQLTableConstraintAlgorithm/check(_:)``. case check(any SQLExpression) - /// `COLLATE` column constraint. + /// A `COLLATE` column constraint, specifying a text collation. + /// + /// This is considered an "informative" constraint, describing the behavior of the column's data, rather than a + /// validation constraint limiting the data itself. In most SQL dialects, it is only valid for columns of textual + /// data type. + /// + /// This is a column-only data constraint; it cannot be specified at the table level. case collate(name: any SQLExpression) - /// `DEFAULT` column constraint. + /// A `DEFAULT` column constraint, specifying a default column value. + /// + /// This is considered an "informative" constraint, describing the behavior of the column's data, rather than a + /// validation constraint limiting the data itself. + /// + /// This is a column-only data constraint; it cannot be specified at the table level. case `default`(any SQLExpression) - /// `FOREIGN KEY` column constraint. + /// A `FOREIGN KEY` column constraint, specifying the referenced data. + /// + /// The `references` expression is usually an instance of ``SQLForeignKey``. + /// + /// See also ``SQLTableConstraintAlgorithm/foreignKey(columns:references:)``. case foreignKey(references: any SQLExpression) - /// `GENERATED ALWAYS AS` column constraint. + /// A `GENERATED` column constraint and its associated data-generating expression. + /// + /// This can be considered either an "informative" constraint or a validation constraint depending on context. + /// + /// Only `STORED` generated columns are currently supported. + /// + /// This is a column-only data constraint; it cannot be specified at the table level. case generated(any SQLExpression) - /// Just serializes ``SQLExpression`` + /// An arbitrary expression used in place of a defined constraint. + /// + /// This case is redundant with the ability to specify a constraint as an arbitrary ``SQLExpression`` at the next + /// higher layer of API and should not be used. case custom(any SQLExpression) - /// `PRIMARY KEY` with auto incrementing turned on. + /// Equivalent to `.primaryKey(autoIncrement: true)`. @inlinable public static var primaryKey: SQLColumnConstraintAlgorithm { .primaryKey(autoIncrement: true) } - /// `COLLATE` column constraint. + /// Equivalent to `.collate(name: SQLIdentifier(name))`. @inlinable public static func collate(name: String) -> SQLColumnConstraintAlgorithm { .collate(name: SQLIdentifier(name)) } - /// `DEFAULT` column constraint. + /// Equivalent to `.default(SQLLiteral.string(value))`. @inlinable public static func `default`(_ value: String) -> SQLColumnConstraintAlgorithm { .default(SQLLiteral.string(value)) } - /// `DEFAULT` column constraint. + /// Equivalent to `.default(SQLLiteral.numeric("\(value)"))`. @inlinable public static func `default`(_ value: T) -> SQLColumnConstraintAlgorithm { .default(SQLLiteral.numeric("\(value)")) } - /// `DEFAULT` column constraint. + /// Equivalent to `.default(SQLLiteral.numeric("\(value)"))`. @inlinable public static func `default`(_ value: T) -> SQLColumnConstraintAlgorithm { .default(SQLLiteral.numeric("\(value)")) } - /// `DEFAULT` column constraint. + /// Equivalent to `.default(SQLLiteral.boolean(value))`. @inlinable public static func `default`(_ value: Bool) -> SQLColumnConstraintAlgorithm { .default(SQLLiteral.boolean(value)) } - /// `FOREIGN KEY` column constraint. + /// Specifies a `FOREIGN KEY` constraint by individual parameters. + /// + /// - Parameters: + /// - table: The table to reference with the foreign key. + /// - column: A column in the referenced table to refer to. + /// - onDelete: Desired behavior when the row referenced by the key is deleted (default unspecified). + /// - onUpdate: Desired behavior when the row referenced by the key is updated (default unspecified). + /// - Returns: A configured ``SQLColumnConstraintAlgorithm``. @inlinable public static func references( _ table: String, @@ -79,7 +138,14 @@ public enum SQLColumnConstraintAlgorithm: SQLExpression { ) } - /// `FOREIGN KEY` column constraint. + /// Specifies a `FOREIGN KEY` constraint by individual parameters. + /// + /// - Parameters: + /// - table: The table to reference with the foreign key. + /// - column: A column in the referenced table to refer to. + /// - onDelete: Desired behavior when the row referenced by the key is deleted (default unspecified). + /// - onUpdate: Desired behavior when the row referenced by the key is updated (default unspecified). + /// - Returns: A configured ``SQLColumnConstraintAlgorithm``. @inlinable public static func references( _ table: any SQLExpression, @@ -96,51 +162,38 @@ public enum SQLColumnConstraintAlgorithm: SQLExpression { ) ) } - + + // See `SQLExpression.serialize(to:)`. public func serialize(to serializer: inout SQLSerializer) { - switch self { - case .primaryKey(let autoIncrement): - if autoIncrement { - if serializer.database.dialect.supportsAutoIncrement { - if let function = serializer.database.dialect.autoIncrementFunction { - serializer.dialect.literalDefault.serialize(to: &serializer) - serializer.write(" ") - function.serialize(to: &serializer) - serializer.write(" ") - serializer.write("PRIMARY KEY") + serializer.statement { + switch self { + case .primaryKey(let autoIncrement): + if autoIncrement, $0.dialect.supportsAutoIncrement { + if let function = $0.dialect.autoIncrementFunction { + $0.append("DEFAULT", function, "PRIMARY KEY") } else { - serializer.write("PRIMARY KEY") - serializer.write(" ") - serializer.dialect.autoIncrementClause.serialize(to: &serializer) + $0.append("PRIMARY KEY", $0.dialect.autoIncrementClause) } } else { - serializer.database.logger.warning("Autoincrement not supported, skipping") - serializer.write("PRIMARY KEY") + $0.append("PRIMARY KEY") } - } else { - serializer.write("PRIMARY KEY") + case .notNull: + $0.append("NOT NULL") + case .unique: + $0.append("UNIQUE") + case .check(let expression): + $0.append("CHECK", SQLGroupExpression(expression)) + case .collate(name: let collate): + $0.append("COLLATE", collate) + case .default(let expression): + $0.append("DEFAULT", expression) + case .foreignKey(let foreignKey): + $0.append(foreignKey) + case .generated(let expression): + $0.append("GENERATED ALWAYS AS", SQLGroupExpression(expression), "STORED") + case .custom(let expression): + $0.append(expression) } - case .notNull: - serializer.write("NOT NULL") - case .unique: - serializer.write("UNIQUE") - case .check(let expression): - serializer.write("CHECK ") - SQLGroupExpression(expression).serialize(to: &serializer) - case .collate(name: let collate): - serializer.write("COLLATE ") - collate.serialize(to: &serializer) - case .default(let expression): - serializer.write("DEFAULT ") - expression.serialize(to: &serializer) - case .foreignKey(let foreignKey): - foreignKey.serialize(to: &serializer) - case .generated(let expression): - serializer.write("GENERATED ALWAYS AS ") - SQLGroupExpression(expression).serialize(to: &serializer) - serializer.write(" STORED") - case .custom(let expression): - expression.serialize(to: &serializer) } } } diff --git a/Sources/SQLKit/Expressions/Clauses/SQLColumnDefinition.swift b/Sources/SQLKit/Expressions/Clauses/SQLColumnDefinition.swift index e667a13f..c7fe4552 100644 --- a/Sources/SQLKit/Expressions/Clauses/SQLColumnDefinition.swift +++ b/Sources/SQLKit/Expressions/Clauses/SQLColumnDefinition.swift @@ -1,48 +1,60 @@ -/// Table column definition. DDL. Used by ``SQLCreateTable`` and ``SQLAlterTable``. +/// A clause expressing a column definition, for use when creating and altering tables. /// -/// See ``SQLCreateTableBuilder`` and ``SQLAlterTableBuilder``. +/// See ``SQLCreateTable``, ``SQLCreateTableBuilder``, ``SQLAlterTable``, and ``SQLAlterTableBuilder``. public struct SQLColumnDefinition: SQLExpression { + /// The name of the column to create or alter. public var column: any SQLExpression + /// The desired data type of the column. + /// + /// Usually valid only when creating a table. When altering an existing column, ``SQLAlterColumnDefinitionType`` + /// should be used to ensure correct behavior between dialects. public var dataType: any SQLExpression + /// A list of column-level constraints to apply to the column. + /// + /// See ``SQLColumnConstraintAlgorithm``. Do not add table-level constraints to this list. public var constraints: [any SQLExpression] - /// Creates a new ``SQLColumnDefinition`` from column identifier, data type, and zero or more constraints. + /// Create a new columm definition from a name, data type, and zero or more constraints. + /// + /// - Parameters: + /// - column: The column name to create or alter. + /// - dataType: The desired data type of the column. + /// - constraints: The constraints to apply to the column, if any. @inlinable - public init(column: any SQLExpression, dataType: any SQLExpression, constraints: [any SQLExpression] = []) { + public init( + column: any SQLExpression, + dataType: any SQLExpression, + constraints: [any SQLExpression] = [] + ) { self.column = column self.dataType = dataType self.constraints = constraints } - public func serialize(to serializer: inout SQLSerializer) { - self.column.serialize(to: &serializer) - serializer.write(" ") - self.dataType.serialize(to: &serializer) - if !self.constraints.isEmpty { - serializer.write(" ") - SQLList(self.constraints, separator: SQLRaw(" ")).serialize(to: &serializer) - } - } -} - -extension SQLColumnDefinition { - /// Create a new column definition from a string, data type, and array of constraints. + /// Create a new columm definition from a name, data type, and zero or more constraints. /// - /// Turns this: - /// - /// SQLColumnDefinition( - /// column: SQLIdentifier("id"), - /// dataType: SQLDataType.bigInt, - /// constraints: [SQLColumnConstraintAlgorithm.primaryKey, SQLColumnConstraintAlgorithm.notNull] - /// ) - /// - /// into this: - /// - /// SQLColumnDefinition("id", dataType: .bigint, constraints: [.primaryKey, .notNull]) + /// - Parameters: + /// - column: The column name to create or alter. + /// - dataType: The desired data type of the column. + /// - constraints: The constraints to apply to the column, if any. @inlinable - public init(_ name: String, dataType: SQLDataType, constraints: [SQLColumnConstraintAlgorithm] = []) { + public init( + _ name: String, + dataType: SQLDataType, + constraints: [SQLColumnConstraintAlgorithm] = [] + ) { self.init(column: SQLIdentifier(name), dataType: dataType, constraints: constraints) } + + // See `SQLExpression.serialize(to:)`. + public func serialize(to serializer: inout SQLSerializer) { + serializer.statement { + $0.append(self.column, self.dataType) + if !self.constraints.isEmpty { + $0.append(SQLList(self.constraints, separator: SQLRaw(" "))) + } + } + } } diff --git a/Sources/SQLKit/Expressions/Clauses/SQLConflictAction.swift b/Sources/SQLKit/Expressions/Clauses/SQLConflictAction.swift index 3a0839cc..3c5fffe7 100644 --- a/Sources/SQLKit/Expressions/Clauses/SQLConflictAction.swift +++ b/Sources/SQLKit/Expressions/Clauses/SQLConflictAction.swift @@ -1,21 +1,21 @@ /// An action to take when an `INSERT` query encounters a unique constraint violation. /// -/// - Note: This is one of the only types at this layer which is _not_ an `SQLExpression`. -/// This is down to the unfortunate fact that while PostgreSQL and SQLite both support the -/// standard's straightforward `ON CONFLICT DO NOTHING` syntax which goes in the same place -/// in the query as an update action would, MySQL can only express the `noAction` case -/// with `INSERT IGNORE`. This requires considering the conflict action twice in the same -/// query when serializing, and to decide what to emit in either location based on both -/// the specific action _and_ the dialect's supported sybtax. As a result, the logic for -/// this has to live in ``SQLInsert``, and it is not possible to serialize a conflict action -/// to SQL in isolation (but again, _only_ because MySQL couldn't be bothered), and this -/// enum can not conform to ``SQLExpression``. -public enum SQLConflictAction { +/// > Note: This is one of the only types at this layer which is _not_ an ``SQLExpression``. +/// > This is down to the unfortunate fact that while PostgreSQL and SQLite both support the +/// > standard's straightforward `ON CONFLICT DO NOTHING` syntax which goes in the same place +/// > in the query as an update action would, MySQL can only express the ``noAction`` case +/// > with `INSERT IGNORE`. This requires considering the conflict action twice in the same +/// > query when serializing, and to decide what to emit in either location based on both +/// > the specific action _and_ the dialect's supported sybtax. As a result, the logic for +/// > this has to live in ``SQLInsert``, and it is not possible to serialize a conflict action +/// > to SQL in isolation (but again, _only_ because MySQL couldn't be bothered), and this +/// > enum can not conform to ``SQLExpression``. +public enum SQLConflictAction: Sendable { /// Specifies that conflicts this action is applied to should be ignored, allowing the query to complete /// successfully without inserting any new rows or changing any existing rows. case noAction - /// Specifies that conflicts this action is applied to shall cause the INSERT to be converted to an UPDATE + /// Specifies that conflicts this action is applied to shall cause the `INSERT` to be converted to an `UPDATE` /// on the same schema which performs the column updates specified by the associated column assignments and, /// where supported by the database, constrained by the associated predicate. The column assignments may /// include ``SQLExcludedColumn`` expressions to refer to values which would have been inserted into the row diff --git a/Sources/SQLKit/Expressions/Clauses/SQLConflictResolutionStrategy.swift b/Sources/SQLKit/Expressions/Clauses/SQLConflictResolutionStrategy.swift index 3c68d8dd..efeb35ab 100644 --- a/Sources/SQLKit/Expressions/Clauses/SQLConflictResolutionStrategy.swift +++ b/Sources/SQLKit/Expressions/Clauses/SQLConflictResolutionStrategy.swift @@ -42,6 +42,16 @@ public struct SQLConflictResolutionStrategy: SQLExpression { self.action = action } + /// An expression to be embedded into the same `INSERT` query as the strategy expression to + /// work around MySQL's desire to make life difficult. + @inlinable + public func queryModifier(for database: any SQLDatabase) -> (any SQLExpression)? { + if database.dialect.upsertSyntax == .mysqlLike, case .noAction = self.action { + return SQLInsertModifier() + } + return nil + } + /// An expression to be embedded into the same `INSERT` query as the strategy expression to /// work around MySQL's desire to make life difficult. @inlinable @@ -52,6 +62,16 @@ public struct SQLConflictResolutionStrategy: SQLExpression { return nil } + /// An expression to be embedded into the same `INSERT` query as the strategy expression to + /// work around MySQL's desire to make life difficult. + @inlinable + public func queryModifier(for statement: SQLStatement) -> (any SQLExpression)? { + if statement.dialect.upsertSyntax == .mysqlLike, case .noAction = self.action { + return SQLInsertModifier() + } + return nil + } + // See `SQLSerializer.serialize(to:)`. public func serialize(to serializer: inout SQLSerializer) { serializer.statement { @@ -63,7 +83,6 @@ public struct SQLConflictResolutionStrategy: SQLExpression { } $0.append("DO NOTHING") case (.standard, .update(let assignments, let predicate)): - assert(!assignments.isEmpty, "Must specify at least one column for updates; consider using noAction instead.") $0.append("ON CONFLICT") if !self.targetColumns.isEmpty { $0.append(SQLGroupExpression(self.targetColumns)) @@ -73,7 +92,6 @@ public struct SQLConflictResolutionStrategy: SQLExpression { case (.mysqlLike, .noAction): break case (.mysqlLike, .update(let assignments, _)): - assert(!assignments.isEmpty, "Must specify at least one column for updates; consider using noAction instead.") $0.append("ON DUPLICATE KEY UPDATE", SQLList(assignments)) case (.unsupported, _): break diff --git a/Sources/SQLKit/Expressions/Clauses/SQLDropBehaviour.swift b/Sources/SQLKit/Expressions/Clauses/SQLDropBehaviour.swift index 2ebad7df..de96e16b 100644 --- a/Sources/SQLKit/Expressions/Clauses/SQLDropBehaviour.swift +++ b/Sources/SQLKit/Expressions/Clauses/SQLDropBehaviour.swift @@ -1,16 +1,24 @@ -/// RESTRICT | CASCADE +/// Specifies a behavior when performing a `DROP` operation on a database object which is referenced by other objects. +/// +/// These behaviors are not supported by all dialects. If the dialect does not claim support, nothing is serialized. public enum SQLDropBehavior: SQLExpression { - /// The drop behavior clause specifies if objects that depend on a table - /// should also be dropped or not when the table is dropped. - - /// Refuse to drop the table if any objects depend on it. + /// Refuse to drop the object if it has any remaining references from other objects. case restrict - /// Automatically drop objects that depend on the table (such as views). + /// When the object is referenced from other objects, recursively delete the referencing objects as well. + /// + /// Be cautious when using ``cascade`` behavior - any object which blocks the drop in any way will be itself + /// dropped; the cascade recurses as many levels deep as necessary to succeed. This can in some cases result in + /// unexpected data loss if the dependencies between database objects are poorly understood. case cascade + // See `SQLExpression.serialize(to:)`. @inlinable public func serialize(to serializer: inout SQLSerializer) { + guard serializer.dialect.supportsDropBehavior else { + return + } + switch self { case .restrict: serializer.write("RESTRICT") case .cascade: serializer.write("CASCADE") diff --git a/Sources/SQLKit/Expressions/Clauses/SQLEnumDataType.swift b/Sources/SQLKit/Expressions/Clauses/SQLEnumDataType.swift index 7d443518..3e9848e5 100644 --- a/Sources/SQLKit/Expressions/Clauses/SQLEnumDataType.swift +++ b/Sources/SQLKit/Expressions/Clauses/SQLEnumDataType.swift @@ -1,49 +1,73 @@ -extension SQLDataType { - @inlinable - public static func `enum`(_ cases: String...) -> Self { - self.enum(cases) - } - - @inlinable - public static func `enum`(_ cases: [String]) -> Self { - self.enum(cases.map { SQLLiteral.string($0) }) - } - @inlinable - public static func `enum`(_ cases: [any SQLExpression]) -> Self { - self.custom(SQLEnumDataType(cases: cases)) - } -} - +/// Represents a data type which specifies an enumeration in the database. +/// +/// Used to hide some of the complexity in supporting ``SQLEnumSyntax/inline`` enum syntax. If the dialect does not +/// use inline syntax, reverts to ``SQLDataType/text``. +/// +/// Instances of this expression are typically embedded within a ``SQLDataType`` ``SQLDataType/custom(_:)`` case. See +/// ``SQLDataType/enum(_:)-677jw``, ``SQLDataType/enum(_:)-6k432``, and ``SQLDataType/enum(_:)-9jlju``, public struct SQLEnumDataType: SQLExpression { - /// The possible values of the enum type. - /// - /// Commonly implemented as a ``SQLGroupExpression``. + /// The individual cases defined by the enumeration. @usableFromInline var cases: [any SQLExpression] + /// Create a new enumeration type with a list of cases. + /// + /// - Parameter cases: The list of cases in the enumeration. @inlinable public init(cases: [String]) { - self.init(cases: cases.map { SQLLiteral.string($0) }) + self.init(cases: cases.map(SQLLiteral.string(_:))) } + /// Create a new enumeration type with a list of cases. + /// + /// - Parameter cases: The list of cases in the enumeration. @inlinable public init(cases: [any SQLExpression]) { self.cases = cases } + // See `SQLExpression.serialize(to:)`. public func serialize(to serializer: inout SQLSerializer) { - switch serializer.dialect.enumSyntax { - case .inline: - // e.g. ENUM('case1', 'case2') - SQLRaw("ENUM").serialize(to: &serializer) - SQLGroupExpression(self.cases).serialize(to: &serializer) - default: - // NOTE: Consider using a CHECK constraint - // with a TEXT type to verify that the - // text value for a column is in a list - // of possible options. - SQLDataType.text.serialize(to: &serializer) - serializer.database.logger.warning("Database does not support inline enums. Storing as TEXT instead.") + serializer.statement { + switch $0.dialect.enumSyntax { + case .inline: + $0.append("ENUM", SQLGroupExpression(self.cases)) + case .typeName: + $0.logger.debug("SQLEnumDataType is not intended for use with PostgreSQL-style enum syntax.") + fallthrough + case .unsupported: + // Do not warn for this case; just transparently fall back to text. + $0.append(SQLDataType.text) + } } } } + +extension SQLDataType { + /// Translates to an enumeration including the specified list of cases. + /// + /// - Parameter cases: The list of cases in the numeration. + /// - Returns: An appropriate ``SQLDataType``. + @inlinable + public static func `enum`(_ cases: String...) -> Self { + self.enum(cases) + } + + /// Translates to an enumeration including the specified list of cases. + /// + /// - Parameter cases: The list of cases in the numeration. + /// - Returns: An appropriate ``SQLDataType``. + @inlinable + public static func `enum`(_ cases: [String]) -> Self { + self.enum(cases.map(SQLLiteral.string(_:))) + } + + /// Translates to an enumeration including the specified list of cases. + /// + /// - Parameter cases: The list of cases in the numeration. + /// - Returns: An appropriate ``SQLDataType``. + @inlinable + public static func `enum`(_ cases: [any SQLExpression]) -> Self { + self.custom(SQLEnumDataType(cases: cases)) + } +} diff --git a/Sources/SQLKit/Expressions/Clauses/SQLExcludedColumn.swift b/Sources/SQLKit/Expressions/Clauses/SQLExcludedColumn.swift index efcecab8..3cf0ab8c 100644 --- a/Sources/SQLKit/Expressions/Clauses/SQLExcludedColumn.swift +++ b/Sources/SQLKit/Expressions/Clauses/SQLExcludedColumn.swift @@ -2,29 +2,33 @@ /// is part of an upsert's update acion, refers to the value which was originally to be inserted for /// the given column. /// -/// - Note: If the serializer's dialect does not support upserts, this expression silently evaluates -/// to nothing at all. +/// > Note: If the serializer's dialect does not support upserts, this expression silently evaluates +/// > to nothing at all. /// -/// - Warning: At the time of this writing, MySQL 8.0's recommended "table alias" syntax for -/// excluded columns is not implemented, due to there currently being no way for a ``SQLDialect`` -/// to vary its contents based on the database server version (for that matter, we don't even -/// have support for retrieving the version from `MySQLNIO`). For now, the deprecared `VALUES()` -/// function is used unconditionally, which will throw warnings starting from MySQL 8.0.20. -/// If this affects your usage, use a raw query or manually construct the necessary expressions -/// to specify and use the alias for now. +/// > Warning: At the time of this writing, MySQL 8.0's recommended "table alias" syntax for +/// > excluded columns is not implemented, due to there currently being no way for a ``SQLDialect`` +/// > to vary its contents based on the database server version (for that matter, we don't even +/// > have support for retrieving the version from `MySQLNIO`). For now, the deprecated `VALUES()` +/// > function is used unconditionally, which will throw warnings starting from MySQL 8.0.20. +/// > If this affects your usage, use a raw query or manually construct the necessary expressions +/// > to specify and use the alias for now. public struct SQLExcludedColumn: SQLExpression { + /// The excluded column's name. public var name: any SQLExpression + /// Create an excluded column specifier. @inlinable public init(_ name: String) { self.init(SQLColumn(name)) } + /// Create an excluded column specifier. @inlinable public init(_ name: any SQLExpression) { self.name = name } + // See `SQLExpression.serialize(to:)`. @inlinable public func serialize(to serializer: inout SQLSerializer) { switch serializer.dialect.upsertSyntax { diff --git a/Sources/SQLKit/Expressions/Clauses/SQLForeignKey.swift b/Sources/SQLKit/Expressions/Clauses/SQLForeignKey.swift index 89a1e299..96268f2b 100644 --- a/Sources/SQLKit/Expressions/Clauses/SQLForeignKey.swift +++ b/Sources/SQLKit/Expressions/Clauses/SQLForeignKey.swift @@ -1,13 +1,28 @@ -/// `FOREIGN KEY` clause. +/// A complete `FOREIGN KEY` constraint specification. +/// +/// Does not include the constraint name (if any); see ``SQLConstraint``. public struct SQLForeignKey: SQLExpression { + /// The table referenced by the foreign key. public let table: any SQLExpression + /// The key column or columns referenced by the foreign key. + /// + /// At least one column must be specified. public let columns: [any SQLExpression] + /// An action to take when one or more referenced rows are deleted from the referenced table. public let onDelete: (any SQLExpression)? + /// An action to take when one or more referenced rows are updated in the referenced table. public let onUpdate: (any SQLExpression)? + /// Create a foreign key specification. + /// + /// - Parameters: + /// - table: The table to reference. + /// - columns: One or more columns to reference. + /// - onDelete: An optional action to take when referenced rows are deleted. + /// - onUpdate: An optional action to take when referenced rows are updated. @inlinable public init( table: any SQLExpression, @@ -21,19 +36,16 @@ public struct SQLForeignKey: SQLExpression { self.onUpdate = onUpdate } + // See `SQLExpression.serialize(to:)`. public func serialize(to serializer: inout SQLSerializer) { - serializer.write("REFERENCES ") - self.table.serialize(to: &serializer) - serializer.write(" ") - SQLGroupExpression(self.columns).serialize(to: &serializer) - - if let onDelete = self.onDelete { - serializer.write(" ON DELETE ") - onDelete.serialize(to: &serializer) - } - if let onUpdate = self.onUpdate { - serializer.write(" ON UPDATE ") - onUpdate.serialize(to: &serializer) + serializer.statement { + $0.append("REFERENCES", self.table, SQLGroupExpression(self.columns)) + if let onDelete = self.onDelete { + $0.append("ON DELETE", onDelete) + } + if let onUpdate = self.onUpdate { + $0.append("ON UPDATE", onUpdate) + } } } } diff --git a/Sources/SQLKit/Expressions/Clauses/SQLJoin.swift b/Sources/SQLKit/Expressions/Clauses/SQLJoin.swift index 606cbaaf..6408a500 100644 --- a/Sources/SQLKit/Expressions/Clauses/SQLJoin.swift +++ b/Sources/SQLKit/Expressions/Clauses/SQLJoin.swift @@ -1,12 +1,28 @@ -///// `JOIN` clause. +/// Encapsulates a single SQL `JOIN`, specifying the join type, the right-side table, and condition. +/// +/// This expression does _not_ include the left side of the join, as individual join clauses are more naturally +/// syntactically treated as starting at the join method; additionally, this simplifies the expressing joins where the +/// left side is part of a `FROM` or other non-join clause. +/// +/// See ``SQLJoinBuilder``. public struct SQLJoin: SQLExpression { + /// The join method. + /// + /// See ``SQLJoinMethod``. public var method: any SQLExpression + /// The table with which to join. public var table: any SQLExpression + /// The join condition. public var expression: any SQLExpression - /// Creates a new `SQLJoin`. + /// Create a new `SQLJoin`. + /// + /// - Parameters: + /// - method: The join method. + /// - table: The table to join. + /// - expression: The join condition. @inlinable public init(method: any SQLExpression, table: any SQLExpression, expression: any SQLExpression) { self.method = method @@ -14,11 +30,11 @@ public struct SQLJoin: SQLExpression { self.expression = expression } + // See `SQLExpression.serialize(to:)`. public func serialize(to serializer: inout SQLSerializer) { - self.method.serialize(to: &serializer) - serializer.write(" JOIN ") - self.table.serialize(to: &serializer) - serializer.write(" ON ") - self.expression.serialize(to: &serializer) + serializer.statement { + $0.append(self.method, "JOIN") + $0.append(self.table, "ON", self.expression) + } } } diff --git a/Sources/SQLKit/Expressions/Clauses/SQLJoinMethod.swift b/Sources/SQLKit/Expressions/Clauses/SQLJoinMethod.swift index 1d13dc2f..8358a282 100644 --- a/Sources/SQLKit/Expressions/Clauses/SQLJoinMethod.swift +++ b/Sources/SQLKit/Expressions/Clauses/SQLJoinMethod.swift @@ -1,9 +1,44 @@ +/// The method used by a table join clause. +/// +/// Used by ``SQLJoin`` and ``SQLJoinBuilder``. +/// +/// The set of joins expressible with this type is known to be very limited. This is partly on purpose, given the +/// relatively large number of join types that exist across the various SQL dialects, the relatively few of those types +/// which are supported by more than one dialect, and the ability to specify join methods as arbitrary +/// ``SQLExpression``s. It is also, however, a side effect of yet another of SQLKit's current API design flaws, in this +/// case the choice to have ``SQLJoinMethod`` serialize only to the join type, not even including the `JOIN` keyword, +/// which makes it that much more difficult to express nontrivial join methods syntactically correctly. public enum SQLJoinMethod: SQLExpression { + /// An inner join. + /// + /// Most often, this type of join is what's meant when saying simply, "a join". An inner join is the result of + /// filtering the Cartesian product (a cross join) of all rows in both tables with the join condition/predicate. case inner + + /// An outer join not otherwise specified. + /// + /// Although this expression does generate `OUTER JOIN` for this case, this is not a valid join in most dialects. + /// It is therefore deprecated and should not be used. Users who need it can use `SQLRaw("OUTER JOIN")` instead. + /// + /// > Note: Presumably, the original intention of this case was to allow expressing a `FULL JOIN` or + /// > `FULL OUTER JOIN`, which is simply a combination of the effects of a left and right join. + @available(*, deprecated, message: "SQLJoinMethod.outer does not generate valid SQL syntax; do not use it.") case outer + + /// A left (outer) join. + /// + /// A left join is the result of performing an inner join, followed by adding additional result rows for every row + /// of the left-side table which has no match in the right-side table with `NULL` values for any columns belonging + /// to the right-side table. case left + + /// A right (outer) join. + /// + /// A right join is simply the mirror version of a left join; additional result rows are for missing matches in the + /// left-side table etc. case right + // See `SQLExpression.serialize(to:)`. @inlinable public func serialize(to serializer: inout SQLSerializer) { switch self { diff --git a/Sources/SQLKit/Expressions/Clauses/SQLLockingClause.swift b/Sources/SQLKit/Expressions/Clauses/SQLLockingClause.swift index ef5fe6fe..5506f39c 100644 --- a/Sources/SQLKit/Expressions/Clauses/SQLLockingClause.swift +++ b/Sources/SQLKit/Expressions/Clauses/SQLLockingClause.swift @@ -1,7 +1,11 @@ -/// General locking expressions for a SQL locking clause. The actual locking clause syntax -/// for any given SQL dialect is defined by the dialect. +/// An SQL locking clause. /// -/// SELECT ... FOR UPDATE +/// A locking clause is an optional clause added to a `SELECT` query to specify an additional locking mode for rows +/// matched by the query, most often to improve performance when multiple transactions access and/or update the same +/// data simultaneously. +/// +/// The actual syntax for a locking clause is provided by the database's dialect; when a dialect doesn't support a +/// particular type of lock (or none at all), this expression generates no serialized output. /// /// See ``SQLSubqueryClauseBuilder/for(_:)`` and ``SQLSelect/lockingClause``. public enum SQLLockingClause: SQLExpression { @@ -11,7 +15,7 @@ public enum SQLLockingClause: SQLExpression { /// Request a shared "reader" lock. case share - /// See ``SQLExpression/serialize(to:)``. + // See `SQLExpression.serialize(to:)`. @inlinable public func serialize(to serializer: inout SQLSerializer) { serializer.statement { diff --git a/Sources/SQLKit/Expressions/Clauses/SQLOrderBy.swift b/Sources/SQLKit/Expressions/Clauses/SQLOrderBy.swift index 77e74bd4..63852811 100644 --- a/Sources/SQLKit/Expressions/Clauses/SQLOrderBy.swift +++ b/Sources/SQLKit/Expressions/Clauses/SQLOrderBy.swift @@ -1,20 +1,36 @@ -/// `ORDER BY` clause. +/// A pair of expressions, one describing a query sort key and the other a directionality for that key. +/// +/// Use ``SQLDirection`` to describe directionality unless a nonstandard value is needed. +/// +/// This expression type is an implementation detail of ``SQLPartialResultBuilder`` and should not have been +/// made public API. Users should avoid using this type. public struct SQLOrderBy: SQLExpression { + /// A sorting key. public var expression: any SQLExpression + /// A sort directionality. + /// + /// See ``SQLDirection``. public var direction: any SQLExpression - /// Creates a new `SQLOrderBy`. + /// Creates a new ordering clause. + /// + /// - Parameters: + /// - expression: The sorting key. + /// - direction: The sort directionality. @inlinable public init(expression: any SQLExpression, direction: any SQLExpression) { self.expression = expression self.direction = direction } + // See `SQLExpression.serialize(to:)`. @inlinable public func serialize(to serializer: inout SQLSerializer) { - self.expression.serialize(to: &serializer) - serializer.write(" ") - self.direction.serialize(to: &serializer) + /// The mere fact that the serialization of this clause is this trivial underscores how excessively verbose + /// making use of it is and why it is superfluous in a better-designed API. + serializer.statement { + $0.append(self.expression, self.direction) + } } } diff --git a/Sources/SQLKit/Expressions/Clauses/SQLReturning.swift b/Sources/SQLKit/Expressions/Clauses/SQLReturning.swift index e2ae165f..6ac02f0d 100644 --- a/Sources/SQLKit/Expressions/Clauses/SQLReturning.swift +++ b/Sources/SQLKit/Expressions/Clauses/SQLReturning.swift @@ -1,30 +1,45 @@ -/// `RETURNING ...` statement. +/// A clause describing a list of values to be returned from a data-modifying query. +/// +/// Most - though not all - dialects support `RETURNING` clauses for DML queries such as `INSERT`, `UPDATE`, and +/// `DELETE`. This clause will not emit any SQL if the dialect reports lacking this support. +/// +/// This clause is the building block underlying ``SQLReturningBuilder``. public struct SQLReturning: SQLExpression { + /// The list of columns to be returned. + /// + /// If empty, the expression does not serialize any content. public var columns: [any SQLExpression] - /// Creates a new `SQLReturning`. + /// Create a new returning-values clause. + /// + /// - Parameter column: A single column to return from a query. @inlinable public init(_ column: SQLColumn) { self.init([column]) } - /// Creates a new `SQLReturning`. + /// Creates a new returning-values clause. + /// + /// - Parameter columns: One or more columns to return from a query. @inlinable public init(_ columns: [any SQLExpression]) { self.columns = columns } + // See `SQLExpression.serialize(to:)`. public func serialize(to serializer: inout SQLSerializer) { guard serializer.dialect.supportsReturning else { - serializer.database.logger.warning("\(serializer.dialect.name) does not support 'RETURNING' clause, skipping.") + /// This logging can be a bit noisy for MySQL users, elide it for now. + //serializer.database.logger.debug("\(serializer.dialect.name) does not support 'RETURNING' clause, skipping.") return } - guard !columns.isEmpty else { return } + guard !self.columns.isEmpty else { + return + } serializer.statement { - $0.append("RETURNING") - $0.append(SQLList(columns)) + $0.append("RETURNING", SQLList(self.columns)) } } } diff --git a/Sources/SQLKit/Expressions/Clauses/SQLSubquery.swift b/Sources/SQLKit/Expressions/Clauses/SQLSubquery.swift new file mode 100644 index 00000000..6c057954 --- /dev/null +++ b/Sources/SQLKit/Expressions/Clauses/SQLSubquery.swift @@ -0,0 +1,25 @@ +/// An expression which wraps a ``SQLSelect`` query in a ``SQLGroupExpression`` in order to form a syntactically +/// valid subquery expression. +/// +/// See also ``SQLSubqueryBuilder``. +/// +/// > Note: This type exists because 1) it allows simplifying the syntax of the builder API via type inference, and +/// > 2) design limitations of ``SQLExpression`` prevent enabling said inference in a less roundabout fashion. +public struct SQLSubquery: SQLExpression { + /// The (sub)query. + public var subquery: SQLSelect + + /// Create a new subquery expression from a select query. + /// + /// - Parameter subquery: A ``SQLSelect`` query to use as a subquery. + @inlinable + public init(_ subquery: SQLSelect) { + self.subquery = subquery + } + + // See `SQLExpression.serialize(to:)`. + @inlinable + public func serialize(to serializer: inout SQLSerializer) { + SQLGroupExpression(self.subquery).serialize(to: &serializer) + } +} diff --git a/Sources/SQLKit/Expressions/Clauses/SQLTableConstraintAlgorithm.swift b/Sources/SQLKit/Expressions/Clauses/SQLTableConstraintAlgorithm.swift index de320fac..3feac4c3 100644 --- a/Sources/SQLKit/Expressions/Clauses/SQLTableConstraintAlgorithm.swift +++ b/Sources/SQLKit/Expressions/Clauses/SQLTableConstraintAlgorithm.swift @@ -1,33 +1,56 @@ -/// Table constraint algorithms used by ``SQLCreateTable``. +/// Table-level data constraints. +/// +/// Most dialects of SQL support both column-level (specific to a single column) and table-level (applicable to a list +/// of one or more columns within the table) constraints. While some constraints can be expressed either way, others +/// are only allowed at the column level. See ``SQLColumnConstraintAlgorithm`` for column-level constraints. +/// +/// Most table-level constraints can optionally be explicitly named; see ``SQLConstraint`` for this functionality. +/// +/// Table constraints are used by ``SQLCreateTable`` and ``SQLAlterTable``, and also appear directly in the APIs of +/// ``SQLAlterTableBuilder``, ``SQLCreateIndexBuilder``, and ``SQLCreateTableBuilder``. public enum SQLTableConstraintAlgorithm: SQLExpression { - /// `PRIMARY KEY` table constraint. + /// A `PRIMARY KEY` constraint over one or more columns. + /// + /// Table-level primary key constraints are not associated with auto-increment functionality, and in most dialects, + /// a primary key constraint either has no name at all or always has the same name. + /// + /// See also ``SQLColumnConstraintAlgorithm/primaryKey(autoIncrement:)``. case primaryKey(columns: [any SQLExpression]) - /// `UNIQUE` table constraint. + /// A `UNIQUE` value constraint, also called a unique key, over one or more columns. + /// + /// In most SQL dialects, a `UNIQUE` constraint also implies the presence of an index over the constrained + /// column(s), such that uniqueness is treated as a boolean attribute of such an index. + /// + /// See also ``SQLColumnConstraintAlgorithm/unique``. case unique(columns: [any SQLExpression]) - /// `CHECK` table constraint. + /// A `CHECK` constraint and its associated validation expression. + /// + /// See also ``SQLColumnConstraintAlgorithm/check(_:)``. case check(any SQLExpression) - /// `FOREIGN KEY` table constraint. + /// A `FOREIGN KEY` constraint over one or more columns, specifying the referenced data. + /// + /// The `references` expression is usually an instance of ``SQLForeignKey``, and must specify the same number of + /// columns as are present in the `columns` array of the constraint. + /// + /// See also ``SQLColumnConstraintAlgorithm/foreignKey(references:)``. case foreignKey(columns: [any SQLExpression], references: any SQLExpression) + // See `SQLExpression.serialize(to:)`. public func serialize(to serializer: inout SQLSerializer) { - switch self { - case .primaryKey(columns: let columns): - serializer.write("PRIMARY KEY ") - SQLGroupExpression(columns).serialize(to: &serializer) - case .unique(columns: let columns): - serializer.write("UNIQUE ") - SQLGroupExpression(columns).serialize(to: &serializer) - case .check(let expression): - serializer.write("CHECK ") - SQLGroupExpression(expression).serialize(to: &serializer) - case .foreignKey(columns: let columns, let foreignKey): - serializer.write("FOREIGN KEY ") - SQLGroupExpression(columns).serialize(to: &serializer) - serializer.write(" ") - foreignKey.serialize(to: &serializer) + serializer.statement { + switch self { + case .primaryKey(columns: let columns): + $0.append("PRIMARY KEY", SQLGroupExpression(columns)) + case .unique(columns: let columns): + $0.append("UNIQUE", SQLGroupExpression(columns)) + case .check(let expression): + $0.append("CHECK", SQLGroupExpression(expression)) + case .foreignKey(columns: let columns, let foreignKey): + $0.append("FOREIGN KEY", SQLGroupExpression(columns), foreignKey) + } } } } diff --git a/Sources/SQLKit/Expressions/Queries/SQLAlterEnum.swift b/Sources/SQLKit/Expressions/Queries/SQLAlterEnum.swift index b22d5e9d..64736d0e 100644 --- a/Sources/SQLKit/Expressions/Queries/SQLAlterEnum.swift +++ b/Sources/SQLKit/Expressions/Queries/SQLAlterEnum.swift @@ -1,18 +1,33 @@ -/// `ALTER TYPE enum_type ADD VALUE 'new_value';` +/// An expression representing an `ALTER TYPE` query. Used to add new cases to enumeration types. +/// +/// ```sql +/// ALTER TYPE "name" ADD VALUE 'value'; +/// ``` +/// +/// This expression does _not_ check whether the current dialect supports separate enumeration types; users should +/// take care not to use it with incompatible drivers. /// /// See ``SQLAlterEnumBuilder``. +/// +/// > Note: Despite both its name and the query it represents, this expression can neither perform arbitrary enum +/// > alterations, nor represent the full range of possible `ALTER TYPE` queries, even in dialects which support +/// > them in the first place. This would probably have been better named something like `SQLAddEnumCase`. public struct SQLAlterEnum: SQLExpression { + /// The name of the type to alter. public var name: any SQLExpression + + /// A new enumeration value to add to an existing type. + /// + /// > Warning: Although this property is optional, setting it to `nil` will result in invalid serialized SQL. public var value: (any SQLExpression)? + // See `SQLExpression.serialize(to:)`. @inlinable public func serialize(to serializer: inout SQLSerializer) { serializer.statement { - $0.append("ALTER TYPE") - $0.append(self.name) + $0.append("ALTER TYPE", self.name) if let value = self.value { - $0.append("ADD VALUE") - $0.append(value) + $0.append("ADD VALUE", value) } } } diff --git a/Sources/SQLKit/Expressions/Queries/SQLAlterTable.swift b/Sources/SQLKit/Expressions/Queries/SQLAlterTable.swift index 097e7400..b236ac4e 100644 --- a/Sources/SQLKit/Expressions/Queries/SQLAlterTable.swift +++ b/Sources/SQLKit/Expressions/Queries/SQLAlterTable.swift @@ -1,73 +1,83 @@ -/// `ALTER TABLE` query. +/// An expression representing an `ALTER TABLE` query. Used to modify the structure of existing tables. +/// +/// This expression is partially dialect-aware and will respect specific settings under ``SQLAlterTableSyntax``. +/// However, it does not handle the caae where a dialect has no table alteration support at all (such as SQLite). +/// +/// ```sql +/// ALTER TABLE "name" +/// RENAME TO "new_name" +/// ALTER TABLE "new_name" +/// ADD "column1" BLOB NOT NULL, +/// DROP "column2", +/// ALTER "column3" SET DATA TYPE TEXT +/// ``` /// /// See ``SQLAlterTableBuilder``. +/// +/// > Warning: There are numerous table alteration operations possible in various dialects which are not supported +/// > by this expression. public struct SQLAlterTable: SQLExpression { + /// The name of the table to alter. public var name: any SQLExpression - /// New name - public var renameTo: (any SQLExpression)? - /// Columns to add. - public var addColumns: [any SQLExpression] - /// Columns to update. - public var modifyColumns: [any SQLExpression] - /// Columns to delete. - public var dropColumns: [any SQLExpression] - /// Table constraints, such as `FOREIGN KEY`, to add. - public var addTableConstraints: [any SQLExpression] - /// Table constraints, such as `FOREIGN KEY`, to delete. - public var dropTableConstraints: [any SQLExpression] + /// If not `nil`, a new name for the table (rename table operation). + public var renameTo: (any SQLExpression)? = nil + + /// A list of zero or more new column definitions (add column operation). + public var addColumns: [any SQLExpression] = [] + + /// A list of zero or more column alteration specifications (modify column operation). + public var modifyColumns: [any SQLExpression] = [] + + /// A list of zero or more columns to remove (drop column operation). + public var dropColumns: [any SQLExpression] = [] + + /// A list of zero or more new table constraints (add table constraint operation). + public var addTableConstraints: [any SQLExpression] = [] + + /// A list of zero or more table constraint names to remove (drop table constraint operation). + public var dropTableConstraints: [any SQLExpression] = [] - /// Creates a new ``SQLAlterTable``. See ``SQLAlterTableBuilder``. + /// Create a table alteration query for a given table, with no operations specified to start with. @inlinable public init(name: any SQLExpression) { self.name = name - self.renameTo = nil - self.addColumns = [] - self.modifyColumns = [] - self.dropColumns = [] - self.addTableConstraints = [] - self.dropTableConstraints = [] } + // See `SQLExpression.serialize(to:)`. public func serialize(to serializer: inout SQLSerializer) { let syntax = serializer.dialect.alterTableSyntax - if !syntax.allowsBatch && self.addColumns.count + self.modifyColumns.count + self.dropColumns.count > 1 { - serializer.database.logger.warning("Database does not support batch table alterations. You will need to rewrite as individual alter statements.") + if !syntax.allowsBatch, + [self.addColumns, self.modifyColumns, self.dropColumns, self.addTableConstraints, self.dropTableConstraints].map(\.count).reduce(0, +) > 1 + { + serializer.database.logger.debug("Database does not support multiple table operation per statement; perform multiple queries with one alteration each instead.") + // Emit the query anyway so the error will propagate when the database rejects it. } - if syntax.alterColumnDefinitionClause == nil && self.modifyColumns.count > 0 { - serializer.database.logger.warning("Database does not support column modifications. You will need to rewrite as drop and add clauses.") - } - - let additions = (self.addColumns + self.addTableConstraints).map { column in - (verb: SQLRaw("ADD"), definition: column) - } - - let removals = (self.dropColumns + self.dropTableConstraints).map { column in - (verb: SQLRaw("DROP"), definition: column) - } - - let alterColumnDefinitionCaluse = syntax.alterColumnDefinitionClause ?? SQLRaw("MODIFY") - let modifications = self.modifyColumns.map { column in - (verb: alterColumnDefinitionCaluse, definition: column) + if syntax.alterColumnDefinitionClause == nil, !self.modifyColumns.isEmpty { + serializer.database.logger.debug("Database does not support column modifications.") + // Emit the query anyway so the error will propagate when the database rejects it. } + let additions = (self.addColumns + self.addTableConstraints).map { (verb: SQLRaw("ADD"), definition: $0) } + let removals = (self.dropColumns + self.dropTableConstraints).map { (verb: SQLRaw("DROP"), definition: $0) } + let modifications = self.modifyColumns.map { (verb: syntax.alterColumnDefinitionClause ?? SQLRaw("__INVALID__"), definition: $0) } let alterations = additions + removals + modifications serializer.statement { - $0.append("ALTER TABLE") - $0.append(self.name) - if let renameTo = renameTo { - $0.append("RENAME TO") - $0.append(renameTo) + $0.append("ALTER TABLE", self.name) + if let renameTo = self.renameTo { + $0.append("RENAME TO", renameTo) + } + + var iter = alterations.makeIterator() + + if let firstAlter = iter.next() { + $0.append(firstAlter.verb, firstAlter.definition) } - for (idx, alteration) in alterations.enumerated() { - if idx > 0 { - $0.append(",") - } - $0.append(alteration.verb) - $0.append(alteration.definition) + while let alteration = iter.next() { + $0.append(",", alteration.verb, alteration.definition) } } } diff --git a/Sources/SQLKit/Expressions/Queries/SQLCreateEnum.swift b/Sources/SQLKit/Expressions/Queries/SQLCreateEnum.swift index 923e637d..3a281112 100644 --- a/Sources/SQLKit/Expressions/Queries/SQLCreateEnum.swift +++ b/Sources/SQLKit/Expressions/Queries/SQLCreateEnum.swift @@ -1,25 +1,41 @@ -/// The `CREATE TYPE` command is used to create a new types in a database. -/// +/// An expression representing a `CREATE TYPE` query. Used to create enumeration types. +/// +/// ```sql +/// CREATE TYPE "name" AS ENUM ('value1', 'value2'); +/// ``` +/// +/// This expression does _not_ check whether the current dialect supports separate enumeration types; users should +/// take care not to use it with incompatible drivers. +/// +/// > Note: As with ``SQLAlterEnum``, the full range of the `CREATE TYPE` query is not supported by this expression. +/// /// See ``SQLCreateEnumBuilder``. public struct SQLCreateEnum: SQLExpression { - /// Name of type to create. + /// The name for the created type. public var name: any SQLExpression + /// The enumeration values for the new type. + /// + /// Must contain at least one value. public var values: [any SQLExpression] + /// Create a type creation query for the given name and value list. + /// + /// - Parameters: + /// - name: The name of the new type. + /// - values: One or more enumeration values associated with the new type. @inlinable public init(name: any SQLExpression, values: [any SQLExpression]) { self.name = name self.values = values } + // See `SQLExpression.serialize(to:)`. @inlinable public func serialize(to serializer: inout SQLSerializer) { serializer.statement { - $0.append("CREATE TYPE") - $0.append(self.name) - $0.append("AS ENUM") - $0.append(SQLGroupExpression(self.values)) + $0.append("CREATE TYPE", self.name) + $0.append("AS ENUM", SQLGroupExpression(self.values)) } } } diff --git a/Sources/SQLKit/Expressions/Queries/SQLCreateIndex.swift b/Sources/SQLKit/Expressions/Queries/SQLCreateIndex.swift index 0b33ba31..0cfa3102 100644 --- a/Sources/SQLKit/Expressions/Queries/SQLCreateIndex.swift +++ b/Sources/SQLKit/Expressions/Queries/SQLCreateIndex.swift @@ -1,39 +1,68 @@ -/// `CREATE INDEX` query. +/// An expression representing a `CREATE INDEX` query. Used to add indexes over columns to an existing table. +/// +/// ```sql +/// CREATE INDEX "name" ON "table" ("column1", "column2") WHERE "column1"=$0; +/// ``` +/// +/// Not all dialects support index predicates, nor does this expression attempt to support all of the numerous +/// additional indexing options available with different drivers. +/// +/// > Note: Because support for an `IF NOT EXISTS` clause on `CREATE IDNEX` queries varies in unusual ways between +/// > dialects, it is not currently supported by this expression. /// /// See ``SQLCreateIndexBuilder``. public struct SQLCreateIndex: SQLExpression { + /// The name of the index. + /// + /// In some dialects, an index's name may be required to be unique within an entire database or schema, not just + /// within the same table. This name is also used as the name of the `UNIQUE` constraint which is added to the + /// table, and thus must also follow the restrictions on constraint naming. public var name: any SQLExpression + /// The table containing the data to be indexed. + /// + /// This value is optional only due to legacy API design; it is required by all dialects and serialization will + /// produce invalid syntax if it is `nil`. public var table: (any SQLExpression)? - /// Type of index to create, see `SQLIndexModifier`. + /// If not `nil`, must be set to ``SQLColumnConstraintAlgorithm/unique``. + /// + /// This property is another legacy API design flaw, as well as reflecting the overlap in most dialects between + /// table-level `UNIQUE` constraints and unique indexes, both of which imply each other but are treated as + /// more or less distinct entities at the syntactic level. public var modifier: (any SQLExpression)? - /// Columns to index. + /// The list of columns covered by the index. public var columns: [any SQLExpression] - /// Creates a new `SQLCreateIndex. + /// If not `nil`, a predicate identifying which rows of the table are included in the index. + /// + /// Not all dialects support partial indexes. There is currently no check for this; users must ensure that a + /// predicate is not specified when not supported. + public var predicate: (any SQLExpression)? + + /// Create a index creation query. + /// + /// - Parameter name: The name to assign to the index/constraint. @inlinable public init(name: any SQLExpression) { self.name = name self.table = nil self.modifier = nil self.columns = [] + self.predicate = nil } + // See `SQLExpression.serialize(to:)`. public func serialize(to serializer: inout SQLSerializer) { - serializer.write("CREATE") - if let modifier = self.modifier { - serializer.write(" ") - modifier.serialize(to: &serializer) - } - serializer.write(" INDEX ") - self.name.serialize(to: &serializer) - if let table = self.table { - serializer.write(" ON ") - table.serialize(to: &serializer) + serializer.statement { + $0.append("CREATE", self.modifier) + $0.append("INDEX", self.name) + $0.append("ON", self.table) + $0.append(SQLGroupExpression(self.columns)) + if let predicate = self.predicate { + $0.append("WHERE", predicate) + } } - serializer.write(" ") - SQLGroupExpression(self.columns).serialize(to: &serializer) } } diff --git a/Sources/SQLKit/Expressions/Queries/SQLCreateTable.swift b/Sources/SQLKit/Expressions/Queries/SQLCreateTable.swift index ac148b44..9440a885 100644 --- a/Sources/SQLKit/Expressions/Queries/SQLCreateTable.swift +++ b/Sources/SQLKit/Expressions/Queries/SQLCreateTable.swift @@ -1,30 +1,56 @@ -/// The `CREATE TABLE` command is used to create a new table in a database. +/// An expression representing a `CREATE TABLE` query. Used to create new tables. +/// +/// ```sql +/// CREATE TEMPORARY TABLE IF NOT EXISTS "table" ( +/// "id" INT NOT NULL GENERATED BY DEFAULT AS IDENTITY, +/// "column1" TEXT NOT NULL, +/// UNIQUE ("column1") +/// ) AS SELECT +/// DEFAULT AS "id", +/// "column1" +/// FROM +/// "other_table"; +/// ``` +/// +/// Temporary tables and the `IF NOT EXISTS` modifier are ignored when not supported by the dialect /// /// See ``SQLCreateTableBuilder``. public struct SQLCreateTable: SQLExpression { - /// Name of table to create. + /// The name of the new table. public var table: any SQLExpression - /// If the "TEMP" or "TEMPORARY" keyword occurs between the "CREATE" and "TABLE" then the new table is created in the temp database. + /// If `true`, the table is marked temporary, limiting its lifetime to that of the current database session. public var temporary: Bool - /// It is usually an error to attempt to create a new table in a database that already contains a table, index or view of the - /// same name. However, if the "IF NOT EXISTS" clause is specified as part of the CREATE TABLE statement and a table or view - /// of the same name already exists, the CREATE TABLE command simply has no effect (and no error message is returned). An - /// error is still returned if the table cannot be created because of an existing index, even if the "IF NOT EXISTS" clause is - /// specified. + /// If `true`, requests idempotent behavior for table creation. + /// + /// In other words, this flag requests that the database ignore the entire query rather than raising an error if a + /// name collision occurs (i.e. if a table with the requested name already exists in the same scope). + /// + /// This flag is ignored if not supported by the dialect. public var ifNotExists: Bool - /// Columns to create. + /// A list of one or more column definitions to include in the new table. + /// + /// If ``asQuery`` is not `nil` and the dialect supports it, this list is allowed to be empty; in this case, the + /// table's columns are inferred from the results of the subquery. public var columns: [any SQLExpression] - /// Table constraints, such as `FOREIGN KEY`, to add. + /// A list of zero or more table constraints to specify on the new table. + /// + /// These are usually ``SQLTableConstraintAlgorithm``s and/or ``SQLConstraint``s. Constraints may be specified for + /// a table even if no column defintions are given (see ``columns``). public var tableConstraints: [any SQLExpression] - /// A subquery which, when present, is used to fill in the contents of the new table. + /// If not `nil`, a `SELECT` subquery which is used to populate the new table. + /// + /// In some dialects (most notable SQLite), it is forbidden to explicitly specify column definitions when providing + /// a data query. There is no check for this at this time. public var asQuery: (any SQLExpression)? - /// Creates a new `SQLCreateTable` query. + /// Create a new table creation query. + /// + /// - Parameter name: The name of the new table. @inlinable public init(name: any SQLExpression) { self.table = name @@ -35,6 +61,7 @@ public struct SQLCreateTable: SQLExpression { self.asQuery = nil } + // See `SQLExpression.serialize(to:)`. public func serialize(to serializer: inout SQLSerializer) { serializer.statement { $0.append("CREATE") @@ -42,19 +69,16 @@ public struct SQLCreateTable: SQLExpression { $0.append("TEMPORARY") } $0.append("TABLE") - if self.ifNotExists { - if $0.dialect.supportsIfExists { - $0.append("IF NOT EXISTS") - } else { - $0.database.logger.warning("\($0.dialect.name) does not support IF NOT EXISTS") - } + if self.ifNotExists, $0.dialect.supportsIfExists { + $0.append("IF NOT EXISTS") + } + $0.append(self.table) + if !self.columns.isEmpty || !self.tableConstraints.isEmpty { + /// Don't add empty parenthesis `()` unless there's at least one column or constraint. + $0.append(SQLGroupExpression(self.columns + self.tableConstraints)) } - // There's no reason not to have a space between the table name and its definitions, but not - // having it is the established behavior, which the tests check for. - $0.append(SQLList([self.table, SQLGroupExpression(self.columns + self.tableConstraints)], separator: SQLRaw(""))) if let asQuery = self.asQuery { - $0.append("AS") - $0.append(asQuery) + $0.append("AS", asQuery) } } } diff --git a/Sources/SQLKit/Expressions/Queries/SQLCreateTrigger.swift b/Sources/SQLKit/Expressions/Queries/SQLCreateTrigger.swift index 8869af6f..b97b2711 100644 --- a/Sources/SQLKit/Expressions/Queries/SQLCreateTrigger.swift +++ b/Sources/SQLKit/Expressions/Queries/SQLCreateTrigger.swift @@ -1,54 +1,120 @@ -/// The `CREATE TRIGGER` command is used to create a trigger against a table. +/// An expression representing a `CREATE TRIGGER` query. Used to create new triggers for actions on a table. +/// +/// ```sql +/// CREATE CONSTRAINT TRIGGER "trigger" +/// DEFINER=foo +/// AFTER UPDATE OF "column1", "column2" ON "table" +/// FROM "other_table" NOT DEFERRABLE +/// FOR EACH ROW +/// WHEN ("column3"="four") +/// FOLLOWS "other_trigger" +/// BEGIN +/// ... +/// END; +/// ``` +/// +/// When used with the PostgreSQL driver, ``SQLCreateTrigger`` performs strong validation of its properties with +/// respect to PostgreSQL's syntax restrictions. In general, the dialect specifies in granular detail exactly which +/// features it supports; properties specifying features not supported by the dialect are generally ignored, except +/// with respect to the trigger body/procedure and the definer (if specified), which are validated by assertion (a +/// runtime error results from invalid use in debug builds, whereas invalid syntax is silently emitted in release +/// builds so that the database will report the issue). /// /// See ``SQLCreateTriggerBuilder``. public struct SQLCreateTrigger: SQLExpression { - /// Name of the trigger to create. + /// The name for the new trigger. public var name: any SQLExpression - /// The table which uses the trigger. + /// The table the new trigger is applied to. public var table: any SQLExpression - /// The column(s) which are watched by the trigger. + /// A list of zero or more columns to which the trigger is applied, if supported. + /// + /// The optionality of this property is an API design flaw. Both `nil` and an empty array are treated identically, + /// indicating that the trigger applies to all columns. public var columns: [any SQLExpression]? - /// Whether or not this is a `CONSTRAINT` trigger. + /// `true` if the new trigger will be a constraint trigger, if supported. public var isConstraint: Bool - /// When the trigger should run. + /// The ordering of the trigger's execution relative to the triggering event. + /// + /// See ``SQLCreateTrigger/WhenSpecifier``. If set to any other type of expression, validity checking is skipped. public var when: any SQLExpression - /// The event which causes the trigger to execute. + /// The event the trigger watches for. + /// + /// See ``SQLCreateTrigger/EventSpecifier``. If set to any other type of expression, validity checking is skipped. public var event: any SQLExpression - /// The timing of the tirgger. This can only be specified for constraint triggers. + /// The deferability status of a constraint trigger with respect to the triggering event, if not `nil`. + /// + /// This can only be specified for constraint triggers, and is ignored otherwise. + /// + /// See ``SQLCreateTrigger/TimingSpecifier``. public var timing: (any SQLExpression)? - /// Used for foreign key constraints and is not recommended for general use. + /// Specifies a table referenced by a foreign key constraint for a constraint trigger, if not `nil`. + /// + /// The use of this functionality is not recommended, and is ignored on non-contraint triggers. public var referencedTable: (any SQLExpression)? - /// Whether the trigger is fired: once for every row or just once per SQL statement. + /// When supported, describes whether the trigger executes on a per-row or per-statement basis. + /// + /// Even when this is left as `nil`, `FOR EACH ROW` may be emitted anyway if the dialect requires it. + /// + /// See ``SQLCreateTrigger/EachSpecifier``. If set to any other type of expression, validity checking is skipped. public var each: (any SQLExpression)? - /// The condition as to when the trigger should be fired. + /// A predicate determining whether the trigger should execute for a given event, if supported. public var condition: (any SQLExpression)? - /// The name of the function which must take no arguments and return a `TRIGGER` type. + /// The name of a pre-existing stored procedure to invoke as the body of the trigger. + /// + /// This is a stored procedure in the SQL sense, a routine defined by a `CREATE PROCEDURE` query. Either this + /// property or ``body`` must be non-`nil`, and most dialects only support one or the other. public var procedure: (any SQLExpression)? - /// The MySQL account to be used when checking access privileges at trigger activation time. - /// Use `'user_name'@'host_name'`, `CURRENT_USER`, or `CURRENT_USER()` + /// If supported by dialect, a user or role to be treated as the trigger's owner for purposes of determining the + /// privileges available to the trigger's body. + /// + /// Currently only supported by MySQL. public var definer: (any SQLExpression)? - /// The trigger body to execute for dialects that support it. - /// - Note: You should **not** include `BEGIN`/`END` statements. They are added automatically. + /// One or more expressions containing procedural SQL statements in the syntax supported by the dialect. + /// + /// That this is an array is an API design flaw; the expressions in the array, if any, are joined with space + /// characters and the result is used as the body. It is recommended to use ``SQLQueryString`` to generate + /// an appropriate expression. Either this property or ``procedure`` must be non-`nil`, and most dialects only + /// support one or the other. + /// + /// > Note: The body should not include `BEGIN`/`END` statements, regardless of dialect. public var body: [any SQLExpression]? - /// A ``SQLTriggerOrder`` used by MySQL + /// Specifies the order of the new trigger with regards to another trigger, in concert with ``orderTriggerName``. + /// + /// If `nil` or unsupported, ``orderTriggerName`` is ignored. + /// + /// > Note: The order and the name to apply it to being separate properties is yet another API designf law. public var order: (any SQLExpression)? - /// The other trigger name for for the ``order`` + /// When ``order`` is not `nil`, specifies the name of the trigger to which the ordering will apply. + /// + /// If ``order`` is not `nil`, but this property is, ``order`` is ignored. + /// + /// See ``SQLCreateTrigger/OrderSpecifier``. + /// + /// > Note: The order and the name to apply it to being separate properties is yet another API design flaw. public var orderTriggerName: (any SQLExpression)? + /// Create a new trigger creation query. + /// + /// - Parameters: + /// - trigger: The name for the new trigger. + /// - table: The table to which the new trigger is attached. + /// - when: Specifies when the trigger runs relative to the triggering event. + /// See ``SQLCreateTrigger/WhenSpecifier``. + /// - event: Specifies the triggering event for the trigger. See ``SQLCreateTrigger/EventSpecifier``. @inlinable public init(trigger: any SQLExpression, table: any SQLExpression, when: any SQLExpression, event: any SQLExpression) { self.name = trigger @@ -58,55 +124,79 @@ public struct SQLCreateTrigger: SQLExpression { self.isConstraint = false } + /// Create a new trigger creation query. + /// + /// - Parameters: + /// - trigger: The name for the new trigger. + /// - table: The table to which the new trigger is attached. + /// - when: Specifies when the trigger runs relative to the triggering event. + /// See ``SQLCreateTrigger/WhenSpecifier``. + /// - event: Specifies the triggering event for the trigger. See ``SQLCreateTrigger/EventSpecifier``. @inlinable - public init(trigger: String, table: String, when: SQLTriggerWhen, event: SQLTriggerEvent) { + public init(trigger: String, table: String, when: WhenSpecifier, event: EventSpecifier) { self.init(trigger: SQLIdentifier(trigger), table: SQLIdentifier(table), when: when, event: event) } + // See `SQLExpression.serialize(to:)`. public func serialize(to serializer: inout SQLSerializer) { let syntax = serializer.dialect.triggerSyntax.create - let when = self.when as? SQLTriggerWhen, event = self.event as? SQLTriggerEvent, each = self.each as? SQLTriggerEach + let when = self.when as? WhenSpecifier, event = self.event as? EventSpecifier, each = self.each as? EachSpecifier if syntax.contains(.postgreSQLChecks) { - assert(when != .instead || event != .update || self.columns == nil, "INSTEAD OF UPDATE events do not support lists of columns") - assert(when != .instead || each == .row, "INSTEAD OF triggers must be FOR EACH ROW") - assert(!syntax.contains(.supportsUpdateColumns) || (columns?.isEmpty ?? true) || event != .update, "Only UPDATE triggers may specify a list of columns.") - assert(!syntax.contains(.supportsCondition) || when != .instead || self.condition == nil, "INSTEAD OF triggers do not support WHEN conditions.") + if !(when != .instead || event != .update || (self.columns?.isEmpty ?? true)) { serializer.database.logger.debug("INSTEAD OF UPDATE events do not support lists of columns") } + if !(when != .instead || each == .row) { serializer.database.logger.debug("INSTEAD OF triggers must be FOR EACH ROW") } + if !(!syntax.contains(.supportsUpdateColumns) || (self.columns?.isEmpty ?? true) || event == .update) { serializer.database.logger.debug("Only UPDATE triggers may specify a list of columns.") } + if !(!syntax.contains(.supportsCondition) || when != .instead || self.condition == nil) { serializer.database.logger.debug("INSTEAD OF triggers do not support WHEN conditions.") } if syntax.contains(.supportsConstraints) { - assert(!self.isConstraint || when == .after, "CONSTRAINT triggers may only be SQLTriggerWhen.after") - assert(!self.isConstraint || each == .row, "CONSTRAINT triggers may only be specified FOR EACH ROW") - assert(self.isConstraint || self.timing == nil, "May only specify SQLTriggerTiming on CONSTRAINT triggers.") + if !(!self.isConstraint || when == .after) { serializer.database.logger.debug("CONSTRAINT triggers may only be SQLTriggerWhen.after") } + if !(!self.isConstraint || each == .row) { serializer.database.logger.debug("CONSTRAINT triggers may only be specified FOR EACH ROW") } + if !(self.isConstraint || self.timing == nil) { serializer.database.logger.debug("May only specify SQLTriggerTiming on CONSTRAINT triggers.") } } } - assert(!syntax.contains(.supportsBody) || self.body != nil, "Must define a trigger body.") - assert(syntax.contains(.supportsBody) || self.procedure != nil, "Must define a trigger procedure.") + if !(syntax.contains(.supportsDefiner) || self.definer == nil) { serializer.database.logger.debug("Must not specify a definer when dialect does not support it.") } + if !(!syntax.contains(.supportsBody) || self.body != nil) { serializer.database.logger.debug("Must define a trigger body.") } + if !(syntax.contains(.supportsBody) || self.procedure != nil) { serializer.database.logger.debug("Must define a trigger procedure.") } serializer.statement { $0.append("CREATE") - if syntax.contains(.supportsConstraints), self.isConstraint { $0.append("CONSTRAINT") } + if syntax.contains(.supportsConstraints), self.isConstraint { + $0.append("CONSTRAINT") + } + if let definer = self.definer, syntax.contains(.supportsDefiner) { + $0.append("DEFINER =", definer) + } $0.append("TRIGGER", self.name) - $0.append(self.when) - $0.append(self.event) - if let columns = self.columns, !columns.isEmpty, syntax.contains(.supportsUpdateColumns) { $0.append("OF", SQLList(columns)) } + $0.append(self.when, self.event) + if let columns = self.columns, !columns.isEmpty, syntax.contains(.supportsUpdateColumns) { + $0.append("OF", SQLList(columns)) + } + $0.append("ON", self.table) - if let referencedTable = self.referencedTable, syntax.contains(.supportsConstraints) { $0.append("FROM", referencedTable) } - if let timing = self.timing, syntax.contains(.supportsConstraints) { $0.append(timing) } + + if let referencedTable = self.referencedTable, syntax.contains(.supportsConstraints) { + $0.append("FROM", referencedTable) + } + + if let timing = self.timing, syntax.contains(.supportsConstraints) { + $0.append(timing) + } + if syntax.contains(.requiresForEachRow) || (syntax.isSuperset(of: [.supportsForEach, .supportsConstraints]) && self.isConstraint) { - $0.append(SQLTriggerEach.row) + $0.append(EachSpecifier.row) } else if syntax.contains(.supportsForEach), let each = self.each { $0.append(each) } + if let condition = self.condition, syntax.contains(.supportsCondition) { $0.append("WHEN", syntax.contains(.conditionRequiresParentheses) ? SQLGroupExpression(condition) : condition) } + if let order = self.order, let orderTriggerName = self.orderTriggerName, syntax.contains(.supportsOrder) { - $0.append(order) - $0.append(orderTriggerName) + $0.append(order, orderTriggerName) } + if syntax.contains(.supportsBody), let body = self.body { - $0.append("BEGIN") - $0.append(SQLList(body, separator: SQLRaw(" "))) - $0.append("END;") + $0.append("BEGIN", SQLList(body, separator: SQLRaw(" ")), "END;") } else if let procedure = self.procedure { $0.append("EXECUTE PROCEDURE", procedure) } @@ -114,77 +204,99 @@ public struct SQLCreateTrigger: SQLExpression { } } -public enum SQLTriggerWhen: SQLExpression { - case before - case after - case instead +extension SQLCreateTrigger { + /// Specifies how a trigger executes relative to the event that triggers it. + public enum WhenSpecifier: String, SQLExpression { + /// Run the trigger before the event. + case before = "BEFORE" + + /// Run the trgger after the event. + case after = "AFTER" + + /// Replace the event with the trigger's execution. + /// + /// Not supported by all dialects. + case instead = "INSTEAD OF" - @inlinable - public func serialize(to serializer: inout SQLSerializer) { - switch self { - case .before: serializer.write("BEFORE") - case .after: serializer.write("AFTER") - case .instead: serializer.write("INSTEAD OF") + // See `SQLExpression.serialize(to:)`. + @inlinable + public func serialize(to serializer: inout SQLSerializer) { + serializer.write(self.rawValue) } } -} -public enum SQLTriggerEvent: SQLExpression { - case insert - case update - case delete - case truncate + /// Specifies an event which causes a trigger to execute. + public enum EventSpecifier: String, SQLExpression { + /// Execute the trigger when a row is inserted into the table. + case insert = "INSERT" + + /// Execute the trigger when one or more rows in the table are updated. + /// + /// If an `UPDATE` query runs without updating any rows, the trigger is _not_ executed. + case update = "UPDATE" + + /// Execute the trigger when one or more rows in the table are deleted. + /// + /// If a `DELETE` query runs without deleting any rows, the trigger is _not_ executed. + case delete = "DELETE" + + /// Execute the trigger when the table is truncated. + case truncate = "TRUNCATE" - @inlinable - public func serialize(to serializer: inout SQLSerializer) { - switch self { - case .insert: serializer.write("INSERT") - case .update: serializer.write("UPDATE") - case .delete: serializer.write("DELETE") - case .truncate: serializer.write("TRUNCATE") + // See `SQLExpression.serialize(to:)`. + @inlinable + public func serialize(to serializer: inout SQLSerializer) { + serializer.write(self.rawValue) } } -} -public enum SQLTriggerTiming: SQLExpression { - case deferrable - case notDeferrable - case initiallyImmediate - case initiallyDeferred - - @inlinable - public func serialize(to serializer: inout SQLSerializer) { - switch self { - case .deferrable: serializer.write("DEFERRABLE") - case .notDeferrable: serializer.write("NOT DEFERRABLE") - case .initiallyImmediate: serializer.write("INITIALLY IMMEDIATE") - case .initiallyDeferred: serializer.write("INITIALLY DEFERRED") + /// Specifies the deferability of a contraint trigger vis a vis the associated constraint. + public enum TimingSpecifier: String, SQLExpression, Equatable { + /// The trigger's execution may be deferred until the end of the active transaction by + /// `SET CONSTRAINTS ... DEFERRED`, but runs immediately by default. + case deferrable = "DEFERRABLE INITIALLY IMMEDIATE" + + /// The trigger's execution is deferred until the end of the active transaction unless + /// changed by `SET CONSTRAINTS ... IMMEDIATE`. + case deferredByDefault = "DEFERRABLE INITIALLY DEFERRED" + + /// The trigger's execution may not be deferred; it always runs immediately. + case notDeferrable = "NOT DEFERRABLE" + + // See `SQLExpression.serialize(to:)`. + @inlinable + public func serialize(to serializer: inout SQLSerializer) { + serializer.write(self.rawValue) } } -} -public enum SQLTriggerEach: SQLExpression { - case row - case statement + /// Specifies whether a trigger executes for each row affected by an event or once for each triggering statement. + public enum EachSpecifier: String, SQLExpression { + /// Execute the trigger once for each row affected by the statement which triggered it. + case row = "FOR EACH ROW" + + /// Execute the trigger once each time a statement triggers it. + case statement = "FOR EACH STATEMENT" - @inlinable - public func serialize(to serializer: inout SQLSerializer) { - switch self { - case .row: serializer.write("FOR EACH ROW") - case .statement: serializer.write("FOR EACH STATEMENT") + // See `SQLExpression.serialize(to:)`. + @inlinable + public func serialize(to serializer: inout SQLSerializer) { + serializer.write(self.rawValue) } } -} -public enum SQLTriggerOrder: SQLExpression { - case follows - case precedes + /// Specifies ordering for a trigger relative to another trigger. + public enum OrderSpecifier: String, SQLExpression { + /// The trigger will execute after the specified existing trigger. + case follows = "FOLLOWS" + + /// The trigger will execute before the specified existing trigger. + case precedes = "PRECEDES" - @inlinable - public func serialize(to serializer: inout SQLSerializer) { - switch self { - case .follows: serializer.write("FOLLOWS") - case .precedes: serializer.write("PRECEDES") + // See `SQLExpression.serialize(to:)`. + @inlinable + public func serialize(to serializer: inout SQLSerializer) { + serializer.write(self.rawValue) } } } diff --git a/Sources/SQLKit/Expressions/Queries/SQLDelete.swift b/Sources/SQLKit/Expressions/Queries/SQLDelete.swift index 9ae1ec55..c7b234da 100644 --- a/Sources/SQLKit/Expressions/Queries/SQLDelete.swift +++ b/Sources/SQLKit/Expressions/Queries/SQLDelete.swift @@ -1,33 +1,44 @@ -/// `DELETE ... FROM` query. +/// An expression representing a `CREATE TRIGGER` query. Used to remove rows from a table. +/// +/// ```sql +/// DELETE FROM "table" +/// WHERE "column1"=$0 +/// RETURNING "id" +/// ``` /// /// See ``SQLDeleteBuilder``. public struct SQLDelete: SQLExpression { - /// Identifier of table to delete from. + /// The table containing rows to delete. public var table: any SQLExpression - /// If the `WHERE` clause is not present, all records in the table are deleted. If a WHERE clause is supplied, - /// then only those rows for which the WHERE clause boolean expression is true are deleted. Rows for which - /// the expression is false or NULL are retained. + /// A predicate specifying which rows to delete. + /// + /// If this is `nil`, all records in the table are deleted. When this is the intended behavior, `TRUNCATE` is + /// usually much faster, but does not play nicely with transactions in some dialects. public var predicate: (any SQLExpression)? - /// Optionally append a `RETURNING` clause that, where supported, returns the supplied supplied columns. + /// An optional ``SQLReturning`` clause specifying data to return from the deleted rows. + /// + /// This can be used to perform a "queue pop" operation by both reading and deleting a row, but is not the most + /// performant way to do so. public var returning: SQLReturning? - /// Creates a new `SQLDelete`. + /// Create a new row deletion query. + /// + /// - Parameter table: The table containing the rows to be deleted. @inlinable public init(table: any SQLExpression) { self.table = table } + // See `SQLExpression.serialize(to:)`. public func serialize(to serializer: inout SQLSerializer) { serializer.statement { $0.append("DELETE FROM", self.table) if let predicate = self.predicate { $0.append("WHERE", predicate) } - if let returning = self.returning { - $0.append(returning) - } + $0.append(self.returning) } } } diff --git a/Sources/SQLKit/Expressions/Queries/SQLDropEnum.swift b/Sources/SQLKit/Expressions/Queries/SQLDropEnum.swift index 30aaa169..9853b9ae 100644 --- a/Sources/SQLKit/Expressions/Queries/SQLDropEnum.swift +++ b/Sources/SQLKit/Expressions/Queries/SQLDropEnum.swift @@ -1,39 +1,46 @@ -/// `DROP TYPE` query. +/// An expression representing a `DROP TYPE` query. Used to delete enumeration types. /// +/// ```sql +/// DROP TYPE IF EXISTS "enum_type" CASCADE: +/// ``` +/// +/// This expression does _not_ check whether the current dialect supports separate enumeration types; users should +/// take care not to use it with incompatible drivers. +/// /// See ``SQLDropEnumBuilder``. public struct SQLDropEnum: SQLExpression { - /// Type to drop. - public let name: any SQLExpression + /// The name of the type to drop. + public var name: any SQLExpression - /// The optional `IF EXISTS` clause suppresses the error that would normally - /// result if the type does not exist. + /// If `true`, requests idempotent behavior (e.g. that no error be raised if the named type does not exist). + /// + /// Ignored if not supported by the dialect. public var ifExists: Bool - /// The optional `CASCADE` clause drops other objects that depend on this type - /// (such as table columns, functions, and operators), and in turn all objects - /// that depend on those objects. - public var cascade: Bool + /// A drop behavior. + /// + /// Ignored if not supported by the dialect. See ``SQLDropBehavior``. + public var dropBehavior: SQLDropBehavior - /// Creates a new `SQLDropEnum`. + /// Create a new enumeration deletion query. + /// + /// - Parameter name: The name of the enumeration to delete. @inlinable public init(name: any SQLExpression) { self.name = name self.ifExists = false - self.cascade = false + self.dropBehavior = .restrict } - /// See `SQLExpression`. + // See `SQLExpression.serialize(to:)`. @inlinable public func serialize(to serializer: inout SQLSerializer) { serializer.statement { $0.append("DROP TYPE") - if self.ifExists { + if self.ifExists, $0.dialect.supportsIfExists { $0.append("IF EXISTS") } - $0.append(self.name) - if self.cascade { - $0.append("CASCADE") - } + $0.append(self.name, self.dropBehavior) } } } diff --git a/Sources/SQLKit/Expressions/Queries/SQLDropIndex.swift b/Sources/SQLKit/Expressions/Queries/SQLDropIndex.swift index b0472308..c3add974 100644 --- a/Sources/SQLKit/Expressions/Queries/SQLDropIndex.swift +++ b/Sources/SQLKit/Expressions/Queries/SQLDropIndex.swift @@ -1,31 +1,44 @@ -/// `DROP INDEX` query. +/// An expression representing a `DROP INDEX` query. Used to delete indexes from tables. +/// +/// ```sql +/// DROP INDEX IF EXISTS ON `table` CASCADE; +/// ``` +/// +/// Not all dialects require or allow specifying the owning table of an index when dropping it, while others enforce +/// doing so. There is no dialect functionality enabling a check for this at this time, so users must track for +/// themselves whether or not to specify the table. At the time of this writing, the table _must_ be specified for +/// MySQL and must _not_ be specified for other drivers. /// /// See ``SQLDropIndexBuilder``. public struct SQLDropIndex: SQLExpression { - /// Index to drop. + /// The name of the index to drop. public var name: any SQLExpression - /// The optional `IF EXISTS` clause suppresses the error that would normally - /// result if the index does not exist. + /// If `true`, requests idempotent behavior (e.g. that no error be raised if the named index does not exist). + /// + /// Ignored if not supported by the dialect. public var ifExists: Bool - /// The object (usually a table) on which the index exists. Not all databases support specifying - /// this, while others require it. + /// The object (usually a table) on which the index exists. + /// + /// Not allowed by most dialects. Required by the MySQL dialect. public var owningObject: (any SQLExpression)? - /// The optional drop behavior clause specifies if objects that depend on the - /// index should also be dropped or not, for databases that support this - /// (either `CASCADE` or `RESTRICT`). + /// A drop behavior. + /// + /// Ignored if not supported by the dialect. See ``SQLDropBehavior``. public var behavior: (any SQLExpression)? - /// Creates a new `SQLDropIndex`. + /// Create a new index deletion query. + /// + /// - Parameter name: The name of the index to delete. @inlinable public init(name: any SQLExpression) { self.name = name self.ifExists = false } - /// See `SQLExpression`. + // See `SQLExpression.serialize(to:)`. public func serialize(to serializer: inout SQLSerializer) { serializer.statement { $0.append("DROP INDEX") @@ -37,7 +50,7 @@ public struct SQLDropIndex: SQLExpression { $0.append("ON", owningObject) } if $0.dialect.supportsDropBehavior { - $0.append(self.behavior ?? SQLDropBehavior.restrict) + $0.append(self.behavior) } } } diff --git a/Sources/SQLKit/Expressions/Queries/SQLDropTable.swift b/Sources/SQLKit/Expressions/Queries/SQLDropTable.swift index c6e0a15e..c9a4c39a 100644 --- a/Sources/SQLKit/Expressions/Queries/SQLDropTable.swift +++ b/Sources/SQLKit/Expressions/Queries/SQLDropTable.swift @@ -1,24 +1,33 @@ -/// `DROP TABLE` query. -/// +/// An expression representing a `DROP TABLE` query. Used to delete entire tables. +/// +/// ```sql +/// DROP TEMPORARY TABLE IF EXISTS "table" CASCADE; +/// ``` +/// /// See ``SQLDropTableBuilder``. public struct SQLDropTable: SQLExpression { - /// Table to drop. - public let table: any SQLExpression + /// The table to drop. + public var table: any SQLExpression - /// The optional `IF EXISTS` clause suppresses the error that would normally - /// result if the table does not exist. + /// If `true`, requests idempotent behavior (e.g. that no error be raised if the named table does not exist). + /// + /// Ignored if not supported by the dialect. public var ifExists: Bool - /// The optional drop behavior clause specifies if objects that depend on the - /// table should also be dropped or not, for databases that support this - /// (either `CASCADE` or `RESTRICT`). + /// A drop behavior. + /// + /// Ignored if not supported by the dialect. See ``SQLDropBehavior``. public var behavior: (any SQLExpression)? - /// If the "TEMPORARY" keyword occurs between "DROP" and "TABLE" then only temporary tables are dropped, - /// and the drop does not cause an implicit transaction commit. + /// If `true`, requests that an error be raised if the named table exists but is not temporary. + /// + /// This modifier is only supported by MySQL, and there is no check for it; users must be sure to only use it + /// where available. public var temporary: Bool - /// Creates a new ``SQLDropTable``. + /// Create a new table deletion query. + /// + /// - Parameter table: The name of the table to drop. @inlinable public init(table: any SQLExpression) { self.table = table @@ -27,24 +36,20 @@ public struct SQLDropTable: SQLExpression { self.temporary = false } - /// See ``SQLExpression/serialize(to:)``. + // See `SQLExpression.serialize(to:)`. public func serialize(to serializer: inout SQLSerializer) { serializer.statement { $0.append("DROP") - if self.temporary { // TODO: Add `SQLDialect` field to signal support for this, only MySQL has it + if self.temporary { $0.append("TEMPORARY") } $0.append("TABLE") - if self.ifExists { - if $0.dialect.supportsIfExists { - $0.append("IF EXISTS") - } else { - $0.database.logger.warning("\($0.dialect.name) does not support IF EXISTS") - } + if self.ifExists, $0.dialect.supportsIfExists { + $0.append("IF EXISTS") } $0.append(self.table) if $0.dialect.supportsDropBehavior { - $0.append(self.behavior ?? (SQLDropBehavior.restrict as any SQLExpression)) + $0.append(self.behavior) } } } diff --git a/Sources/SQLKit/Expressions/Queries/SQLDropTrigger.swift b/Sources/SQLKit/Expressions/Queries/SQLDropTrigger.swift index 1ca10d80..8f083ec2 100644 --- a/Sources/SQLKit/Expressions/Queries/SQLDropTrigger.swift +++ b/Sources/SQLKit/Expressions/Queries/SQLDropTrigger.swift @@ -1,44 +1,50 @@ -/// `DROP TRIGGER` query. +/// An expression representing a `DROP TRIGGER` query. Used to delete triggers. +/// +/// ```sql +/// DROP TRIGGER IF EXISTS "name" ON "table" CASCADE; +/// ``` /// /// See ``SQLDropTriggerBuilder``. public struct SQLDropTrigger: SQLExpression { - /// Trigger to drop. - public let name: any SQLExpression + /// The name of the trigger to drop. + public var name: any SQLExpression - /// The table the trigger is attached to + /// The table to which the trigger is attached. + /// + /// This value is ignored if the dialect does not support its use. public var table: (any SQLExpression)? - /// The optional `IF EXISTS` clause suppresses the error that would normally - /// result if the type does not exist. + /// If `true`, requests idempotent behavior (e.g. that no error be raised if the named trigger does not exist). + /// + /// Ignored if not supported by the dialect. public var ifExists = false - /// The optional `CASCADE` clause drops other objects that depend on this type - /// (such as table columns, functions, and operators), and in turn all objects - /// that depend on those objects. - public var cascade = false + /// A drop behavior. + /// + /// Ignored if not supported by the dialect. See ``SQLDropBehavior``. + public var dropBehavior = SQLDropBehavior.restrict - /// Creates a new ``SQLDropTrigger``. + /// Create a new trigger deletion query. + /// + /// - Parameter name: The name of the trigger to drop. @inlinable public init(name: any SQLExpression) { self.name = name } - /// See ``SQLExpression/serialize(to:)``. + // See `SQLExpression.serialize(to:)`. public func serialize(to serializer: inout SQLSerializer) { - let dialect = serializer.dialect - let triggerDropSyntax = dialect.triggerSyntax.drop - serializer.statement { $0.append("DROP TRIGGER") - if self.ifExists && dialect.supportsIfExists { + if self.ifExists && $0.dialect.supportsIfExists { $0.append("IF EXISTS") } $0.append(self.name) - if let table = self.table, triggerDropSyntax.contains(.supportsTableName) { + if let table = self.table, $0.dialect.triggerSyntax.drop.contains(.supportsTableName) { $0.append("ON", table) } - if self.cascade && triggerDropSyntax.contains(.supportsCascade) { - $0.append("CASCADE") + if $0.dialect.triggerSyntax.drop.contains(.supportsCascade) { + $0.append(self.dropBehavior) } } } diff --git a/Sources/SQLKit/Expressions/Queries/SQLInsert.swift b/Sources/SQLKit/Expressions/Queries/SQLInsert.swift index 0cda25cc..3389f6f0 100644 --- a/Sources/SQLKit/Expressions/Queries/SQLInsert.swift +++ b/Sources/SQLKit/Expressions/Queries/SQLInsert.swift @@ -1,51 +1,86 @@ -/// `INSERT INTO ...` statement. +/// An expression representing an `INSERT` query. Used to add new rows to a table. +/// +/// ```sql +/// INSERT INTO "table" +/// ("id", "column1", "column2") +/// VALUES +/// (DEFAULT, 'a', 'b'), +/// (DEFAULT, 'c', 'd') +/// ON CONFLICT DO NOTHING +/// RETURNING "id"; +/// +/// INSERT INTO "table" +/// ("id", "column1", "column2") +/// SELECT +/// NULL as "id", +/// "column1", +/// "column2" +/// FROM "other_table" +/// ON CONFLICT DO UPDATE SET "column1"="excluded"."column1", "column2"="excluded"."column2" +/// RETURNING "id"; +/// ``` /// /// See ``SQLInsertBuilder``. public struct SQLInsert: SQLExpression { + /// The table to which rows are to be added. public var table: any SQLExpression - /// Array of column identifiers to insert values for. - public var columns: [any SQLExpression] + /// List of one or more columns which specify the ordering and count of the inserted values. + public var columns: [any SQLExpression] = [] + + /// An array of arrays providing a list of rows to insert as lists of expressions. + /// + /// The outer array can be thought of as a list of rows, with each "row" being a list of values for each column. + /// In any given "row", the value at a given index corresponds to the column at that same index in ``columns``. + /// Each "row" must have the same number of elements as every other row, which must also be the same number + /// elements in ``columns``; if this rule is not followed, invalid SQL is generated. ``SQLLiteral/default`` and/or + /// ``SQLLiteral/null`` can be used to fill in gaps in a given row as appropriate for the column. + /// + /// If ``values`` is not an empty array, it is always used, even if ``valueQuery`` is not `nil`. If ``values`` is + /// empty and ``valueQuery`` is `nil`, invalid SQL is generated. + public var values: [[any SQLExpression]] = [] - /// Two-dimensional array of values to insert. The count of each nested array _must_ - /// be equal to the count of `columns`. + /// If not `nil`, a subquery providing a `SELECT` statement which generates rows to insert. + /// + /// This will usually be a instance of ``SQLSelect``. Using ``SQLSubquery`` may result in syntax errors in + /// some dialects. /// - /// Use the `DEFAULT` literal to omit a value and that is specified as a column. - public var values: [[any SQLExpression]] + /// Ignored unless ``values`` is an empty array. If ``values`` is empty and ``valueQuery`` is `nil`, invalid SQL + /// is generated. + public var valueQuery: (any SQLExpression)? = nil - /// A unique key conflict resolution strategy. - public var conflictStrategy: SQLConflictResolutionStrategy? + /// If not `nil`, a strategy for resolving conflicts created by violations of applicable constraints. + /// + /// See ``SQLConflictResolutionStrategy``. + public var conflictStrategy: SQLConflictResolutionStrategy? = nil - /// Optionally append a `RETURNING` clause that, where supported, returns the supplied supplied columns. - public var returning: SQLReturning? + /// An optional ``SQLReturning`` clause specifying data to return from the inserted rows. + /// + /// Most often used to return a list of identifiers automatically generated for newly inserted rows. + public var returning: SQLReturning? = nil - /// Creates a new `SQLInsert`. + /// Create a new row insertion query. + /// + /// - Parameter table: The table to which rows are to be added. @inlinable public init(table: any SQLExpression) { self.table = table - self.columns = [] - self.values = [] - self.conflictStrategy = nil - self.returning = nil } + // See `SQLExpression.serialize(to:)`. public func serialize(to serializer: inout SQLSerializer) { - let modifier = self.conflictStrategy?.queryModifier(for: serializer) - serializer.statement { $0.append("INSERT") - if let modifier = modifier { - $0.append(modifier) - } + $0.append(self.conflictStrategy?.queryModifier(for: $0)) $0.append("INTO", self.table) $0.append(SQLGroupExpression(self.columns)) - $0.append("VALUES", SQLList(self.values.map(SQLGroupExpression.init))) - if let conflictStrategy = self.conflictStrategy, modifier == nil { - $0.append(conflictStrategy) - } - if let returning = self.returning { - $0.append(returning) + if !self.values.isEmpty { + $0.append("VALUES", SQLList(self.values.map(SQLGroupExpression.init))) + } else if let subquery = self.valueQuery { + $0.append(subquery) } + $0.append(self.conflictStrategy) + $0.append(self.returning) } } } diff --git a/Sources/SQLKit/Expressions/Queries/SQLSelect.swift b/Sources/SQLKit/Expressions/Queries/SQLSelect.swift index 14773f04..10adf8bc 100644 --- a/Sources/SQLKit/Expressions/Queries/SQLSelect.swift +++ b/Sources/SQLKit/Expressions/Queries/SQLSelect.swift @@ -1,93 +1,134 @@ -/// `SELECT` statement. +/// An expression representing a `SELECT` query. Used to retrieve rows and expression results from a database. +/// +/// ```sql +/// SELECT +/// DISTINCT +/// "table1"."column1", "table2"."column2", COUNT("table3"."column3") AS "count" +/// FROM +/// "table1" +/// INNER JOIN "table2" ON "table1"."id"="table2"."table1_id" +/// LEFT JOIN "table3" ON "table2"."id"="table3"."table2_id" +/// WHERE +/// "table1"."column1"!=$0 +/// GROUP BY +/// "table2"."column2", "table3"."column3" +/// HAVING +/// "table2"."column2"=$1 +/// ORDER BY +/// "table1"."column1" +/// LIMIT 10, 20 +/// LOCK IN SHARE MODE +/// ``` +/// +/// > Note: In any given SQL dialect, `SELECT` is all but universally the most complex of all queries, offering more +/// > variations and features within and between dialects than almost any other self-contained SQL statement. +/// > Accordingly, even more so than with other queries, SQLKit cannot hope to offer more than a baseline of common +/// > functionality. Some of the more obvious omissions in this version of the package include the `WINDOW` clause, +/// > the `INTO` (MySQL) or `AS` (Postgres) clauses, and Common Table Expressions (the `WITH` clause); support for +/// > most or all of these is under consideration for SQLKit's next major version. /// /// See ``SQLSelectBuilder``. public struct SQLSelect: SQLExpression { - public var columns: [any SQLExpression] - public var tables: [any SQLExpression] + /// One or more expessions describing the data to retrieve from the database. + public var columns: [any SQLExpression] = [] + + /// One or more tables to include as sources for data to retrieve. + /// + /// This array rarely contains more than one element; when multiple tables are specified by this property, they + /// are included in the resulting query via the comma operator, effectively creating a `CROSS JOIN` (Cartesian + /// product); if not filtered by the ``predicate``, this can result in extremely slow and expensive queries. It + /// is almost always preferable to specify all but the first source table in the ``joins`` array. + public var tables: [any SQLExpression] = [] - public var isDistinct: Bool + /// If `true`, final result rows are deduplicated before being returned. + /// + /// `DISTINCT` processing occurs after all other processing, except `LIMIT`. Be aware that deduplication occurs + /// across _entire_ rows, not any single field. There is no support for PostgreSQL's `DISTINCT ON` syntax at + /// this time. + public var isDistinct: Bool = false - public var joins: [any SQLExpression] + /// Zero or more joins to apply to the overall data sources. + /// + /// These will almost ways be instances of ``SQLJoin``. + public var joins: [any SQLExpression] = [] - public var predicate: (any SQLExpression)? + /// If not `nil`, an expression which filters the source data to determine the result rows. + /// + /// This corresponds to a `SELECT` query's `WHERE` clause and applies _before_ any `GROUP BY` clause(s). Most + /// often the predicate will consist of one or more nested ``SQLBinaryExpression``s. + public var predicate: (any SQLExpression)? = nil - /// Zero or more `GROUP BY` clauses. - public var groupBy: [any SQLExpression] + /// Zero or more columns or expressions specifying grouping keys for the filtered result rows. + /// + /// This corresponds to a `SELECT` query's `GROUP BY` clause. + public var groupBy: [any SQLExpression] = [] - public var having: (any SQLExpression)? + /// Like ``predicate``, but specifies filtering which applies _after_ ``groupBy`` keys are processed. + /// + /// `HAVING` clauses tend to be inefficient. + public var having: (any SQLExpression)? = nil - /// Zero or more `ORDER BY` clauses. - public var orderBy: [any SQLExpression] - - /// If set, limits the maximum number of results. - public var limit: Int? + /// Zero or more columns or expressions specifying sort keys and directionalities for the filtered result rows. + /// + /// The order in which an `ORDER BY` clause takes effect is complex and varies between dialects. + /// + /// See ``SQLDirection``. + public var orderBy: [any SQLExpression] = [] - /// If set, offsets the results. - public var offset: Int? + /// If not `nil`, limits the number of result rows returned. Applies _after_ ``offset`` (if specified). + /// + /// Although the type of this property is `Int`, it is invalid to specify a negative value. + public var limit: Int? = nil - /// Adds a locking expression to this `SELECT` statement. + /// If not `nil`, skips the given number of result rows before starting to return results. /// - /// SELECT ... FOR UPDATE + /// Although the type of this property is `Int`, it is invalid to specify a negative value. + public var offset: Int? = nil + + /// If not `nil`, specifies a locking clause which applies to the rows looked up by the query. /// - /// See ``SQLSelectBuilder/for(_:)`` and ``SQLLockingClause``. - public var lockingClause: (any SQLExpression)? + /// See ``SQLLockingClause``. + public var lockingClause: (any SQLExpression)? = nil - /// Creates a new ``SQLSelect``. + /// Create a new data retrieval query. @inlinable - public init() { - self.columns = [] - self.tables = [] - self.isDistinct = false - self.joins = [] - self.predicate = nil - self.limit = nil - self.offset = nil - self.groupBy = [] - self.having = nil - self.orderBy = [] - } + public init() {} + // See `SQLExpression.serialize(to:)`. public func serialize(to serializer: inout SQLSerializer) { - serializer.write("SELECT ") - if self.isDistinct { - serializer.write("DISTINCT ") - } - SQLList(self.columns).serialize(to: &serializer) - if !self.tables.isEmpty { - serializer.write(" FROM ") - SQLList(self.tables).serialize(to: &serializer) - } - if !self.joins.isEmpty { - serializer.write(" ") - SQLList(self.joins, separator: SQLRaw(" ")).serialize(to: &serializer) - } - if let predicate = self.predicate { - serializer.write(" WHERE ") - predicate.serialize(to: &serializer) - } - if !self.groupBy.isEmpty { - serializer.write(" GROUP BY ") - SQLList(self.groupBy).serialize(to: &serializer) - } - if let having = self.having { - serializer.write(" HAVING ") - having.serialize(to: &serializer) - } - if !self.orderBy.isEmpty { - serializer.write(" ORDER BY ") - SQLList(self.orderBy).serialize(to: &serializer) - } - if let limit = self.limit { - serializer.write(" LIMIT ") - serializer.write(limit.description) - } - if let offset = self.offset { - serializer.write(" OFFSET ") - serializer.write(offset.description) - } - if let lockingClause = self.lockingClause { - serializer.write(" ") - lockingClause.serialize(to: &serializer) + serializer.statement { + $0.append("SELECT") + if self.isDistinct { + $0.append("DISTINCT") + } + if !self.columns.isEmpty { + $0.append(SQLList(self.columns)) + } + if !self.tables.isEmpty { + $0.append("FROM", SQLList(self.tables)) + } + if !self.joins.isEmpty { + $0.append(SQLList(self.joins, separator: SQLRaw(" "))) + } + if self.predicate != nil { + $0.append("WHERE", self.predicate) + } + if !self.groupBy.isEmpty { + $0.append("GROUP BY", SQLList(self.groupBy)) + } + if self.having != nil { + $0.append("HAVING", self.having) + } + if !self.orderBy.isEmpty { + $0.append("ORDER BY", SQLList(self.orderBy)) + } + if let limit = self.limit { + $0.append("LIMIT", SQLLiteral.numeric("\(limit)")) + } + if let offset = self.offset { + $0.append("OFFSET", SQLLiteral.numeric("\(offset)")) + } + $0.append(self.lockingClause) } } } diff --git a/Sources/SQLKit/Expressions/Queries/SQLUnion.swift b/Sources/SQLKit/Expressions/Queries/SQLUnion.swift index 0a4be401..c15c3168 100644 --- a/Sources/SQLKit/Expressions/Queries/SQLUnion.swift +++ b/Sources/SQLKit/Expressions/Queries/SQLUnion.swift @@ -1,130 +1,183 @@ +/// An expression representing two or more `SELECT` queries joined by `UNION` clauses. Used to merge the results of +/// multiple queries into a single result set. +/// +/// ```sql +/// (SELECT ...) +/// UNION ALL +/// (SELECT ...) +/// INTERSECT DISTINCT +/// (SELECT ...) +/// EXCEPT ALL +/// (SELECT ...) +/// ``` +/// +/// There are numerous variations in support and syntax for `UNION` joiners between dialects; this expression respects +/// dialect differences to the extent possible but will someimtes generate invalid SQL if an operation unsupported by +/// the current dialect is described by its inputs. +/// +/// See ``SQLUnionBuilder``. public struct SQLUnion: SQLExpression { + /// The required first query of the union. public var initialQuery: SQLSelect + + /// Zero or more additional queries whose results are to be combined with that of the initial query and + /// associated joiner expressions describing the combining operation. + /// + /// This is conceptually similar to ``SQLSelect/joins`` in that each item in the list represents a method and an + /// additional expression. + /// + /// See ``SQLSelect`` and ``SQLUnionJoiner``. public var unions: [(SQLUnionJoiner, SQLSelect)] - /// Zero or more `ORDER BY` clauses. - public var orderBys: [any SQLExpression] + /// Zero or more columns or expressions specifying sort keys and directionalities for the overall result rows. + /// + /// See ``SQLDirection``. + public var orderBys: [any SQLExpression] = [] - /// If set, limits the maximum number of results. - public var limit: Int? + /// If not `nil`, limits the number of result rows returned. Applies _after_ ``offset`` (if specified). + /// + /// Although the type of this property is `Int`, it is invalid to specify a negative value. + public var limit: Int? = nil - /// If set, offsets the results. - public var offset: Int? + /// If not `nil`, skips the given number of result rows before starting to return results. + /// + /// Although the type of this property is `Int`, it is invalid to specify a negative value. + public var offset: Int? = nil + /// Create a new set of combined queries. + /// + /// See ``SQLSelect`` and ``SQLUnionJoiner``. + /// + /// - Parameters: + /// - initialQuery: The first query of the set. + /// - unions: A list of zero or more pairs of joiner expressions and additional queries. @inlinable public init(initialQuery: SQLSelect, unions: [(SQLUnionJoiner, SQLSelect)] = []) { self.initialQuery = initialQuery self.unions = unions - self.limit = nil - self.offset = nil - self.orderBys = [] } + /// Add an additional query to the union using the `UNION` or `UNION ALL` joiner. + /// + /// - Parameters: + /// - query: The query to add. + /// - all: If true, use `UNION ALL` as the joiner, otherwise use `UNION DISTINCT`. @inlinable public mutating func add(_ query: SQLSelect, all: Bool) { self.add(query, joiner: .init(type: all ? .unionAll : .union)) } + /// Add an additional query to the union using the provided joiner. + /// + /// - Parameters: + /// - query: The query to add. + /// - joiner: THe joiner to use. See ``SQLUnionJoiner``. @inlinable public mutating func add(_ query: SQLSelect, joiner: SQLUnionJoiner) { self.unions.append((joiner, query)) } + // See `SQLExpression.serialize(to:)`. public func serialize(to serializer: inout SQLSerializer) { - guard !self.unions.isEmpty else { - return initialQuery.serialize(to: &serializer) - } - - serializer.statement { statement in - func appendQuery(_ query: SQLSelect) { - if statement.dialect.unionFeatures.contains(.parenthesizedSubqueries) { - statement.append(SQLGroupExpression(query)) - } else { - statement.append(query) - } + serializer.statement { stmt in + 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. + return stmt.append(self.initialQuery) } + + let parenthesize = stmt.dialect.unionFeatures.contains(.parenthesizedSubqueries) - appendQuery(self.initialQuery) - self.unions.forEach { joiner, query in - statement.append(joiner) - appendQuery(query) + stmt.append(parenthesize ? SQLGroupExpression(self.initialQuery) : self.initialQuery) + for (joiner, query) in self.unions { + stmt.append(joiner, parenthesize ? SQLGroupExpression(query) : query) } - if !self.orderBys.isEmpty { - statement.append("ORDER BY") - statement.append(SQLList(self.orderBys)) + stmt.append("ORDER BY", SQLList(self.orderBys)) } if let limit = self.limit { - statement.append("LIMIT") - statement.append(limit.description) + stmt.append("LIMIT", SQLLiteral.numeric("\(limit)")) } if let offset = self.offset { - statement.append("OFFSET") - statement.append(offset.description) + stmt.append("OFFSET", SQLLiteral.numeric("\(offset)")) } } } } -/// - Note: There's no technical reason that this is an `enum` nested in a `struct` rather than just a bare -/// `enum`. It's this way because Gwynne merged a PR for an early version of this code and it was released -/// publicly before she realized there were several missing pieces; changing it now would be potentially -/// source-breaking, so it has to be left like this until the next major version. +/// An expression representing one of the six supported query union operations. +/// +/// If the current dialect does not support a given operation, no SQL is output, typically resulting in invalid +/// syntax in the overall query. +/// +/// See ``SQLUnion`` and ``SQLUnionBuilder``. public struct SQLUnionJoiner: SQLExpression { - public enum `Type`: Equatable, CaseIterable { - case union, unionAll, intersect, intersectAll, except, exceptAll + /// The supported query union operations. + public enum `Type`: Equatable, CaseIterable, Sendable { + /// The `UNION` operation, also called `UNION DISTINCT`. + /// + /// Returns all result rows from both sides of the union, de-duplicating the combined set. + case union + + /// The `UNION ALL` operation. + /// + /// Returns all result rows from both sides of the union, including duplicates. + case unionAll + + /// The `INTERSECT` or `INTERSECT DISTINCT` operation. + /// + /// Returns all result rows which occur on both sides of the union, de-duplicating the results. + case intersect + + /// The `INTERSECT ALL` operation. + /// + /// Returns all result rows which occur on both sides of the union, including duplicates. + case intersectAll + + /// The `EXCEPT` or `EXCEPT DISTINCT` operation. + /// + /// Returns all result rows which occur _only_ on the left side of the union, de-duplcating the results. + case except + + /// The `EXCEPT ALL` operation. + /// + /// Returns all result rows which occur _only_ on the left side of the union, including duplicates. + case exceptAll } + /// The operation this joiner describes. public var type: `Type` - - @available(*, deprecated, message: "Use .type` instead.") - @inlinable - public var all: Bool { - get { [.unionAll, .intersectAll, .exceptAll].contains(self.type) } - set { switch (self.type, newValue) { - case (.union, true): self.type = .unionAll - case (.unionAll, false): self.type = .union - case (.intersect, true): self.type = .intersectAll - case (.intersectAll, false): self.type = .intersect - case (.except, true): self.type = .exceptAll - case (.exceptAll, false): self.type = .except - default: break - } } - } - - @available(*, deprecated, message: "Use .init(type:)` instead.") - @inlinable - public init(all: Bool) { - self.init(type: all ? .unionAll : .union) - } + /// Create a new union joiner expression. + /// + /// - Parameter type: The operation the joiner describes. @inlinable public init(type: `Type`) { self.type = type } + // See `SQLExpression.serialize(to:)`. public func serialize(to serializer: inout SQLSerializer) { - func serialize(keyword: String, if flag: SQLUnionFeatures, uniqued: Bool, to statement: inout SQLStatement) { - if !statement.dialect.unionFeatures.contains(flag) { - return print("WARNING: The \(statement.dialect.name) dialect does not support \(keyword)\(uniqued ? " ALL" :"")!") - } - statement.append(keyword) - if !uniqued { - statement.append("ALL") - } else if statement.dialect.unionFeatures.contains(.explicitDistinct) { - statement.append("DISTINCT") + serializer.statement { statement in + func write(keyword: String, if flag: SQLUnionFeatures, uniqued: Bool) { + if !statement.dialect.unionFeatures.contains(flag) { + return statement.logger.debug("The \(statement.dialect.name) dialect does not support \(keyword)\(uniqued ? " ALL" : "").") + } + statement.append(keyword) + if !uniqued { + statement.append("ALL") + } else if statement.dialect.unionFeatures.contains(.explicitDistinct) { + statement.append("DISTINCT") + } } - } - serializer.statement { switch self.type { - case .union: serialize(keyword: "UNION", if: .union, uniqued: true, to: &$0) - case .unionAll: serialize(keyword: "UNION", if: .unionAll, uniqued: false, to: &$0) - case .intersect: serialize(keyword: "INTERSECT", if: .intersect, uniqued: true, to: &$0) - case .intersectAll: serialize(keyword: "INTERSECT", if: .intersectAll, uniqued: false, to: &$0) - case .except: serialize(keyword: "EXCEPT", if: .except, uniqued: true, to: &$0) - case .exceptAll: serialize(keyword: "EXCEPT", if: .exceptAll, uniqued: false, to: &$0) + case .union: write(keyword: "UNION", if: .union, uniqued: true) + case .unionAll: write(keyword: "UNION", if: .unionAll, uniqued: false) + case .intersect: write(keyword: "INTERSECT", if: .intersect, uniqued: true) + case .intersectAll: write(keyword: "INTERSECT", if: .intersectAll, uniqued: false) + case .except: write(keyword: "EXCEPT", if: .except, uniqued: true) + case .exceptAll: write(keyword: "EXCEPT", if: .exceptAll, uniqued: false) } } } } - diff --git a/Sources/SQLKit/Expressions/Queries/SQLUpdate.swift b/Sources/SQLKit/Expressions/Queries/SQLUpdate.swift index e14899dd..7709e674 100644 --- a/Sources/SQLKit/Expressions/Queries/SQLUpdate.swift +++ b/Sources/SQLKit/Expressions/Queries/SQLUpdate.swift @@ -1,40 +1,54 @@ -/// `UPDATE` statement. +/// An expression representing an `UPDATE` query. Used to modify existing rows in a single table. +/// +/// ```sql +/// UPDATE "table" SET +/// "column1"=$0, +/// "column2"=$1 +/// WHERE +/// "column3"!=$2 +/// RETURNING +/// "id" +/// ; +/// ``` +/// +/// Because of the radically different syntax required for "multi-table" updates between dialects, additional dialect +/// support would be required to implement this functionality; this is planned for the next major update to SQLKit. /// /// See ``SQLUpdateBuilder``. public struct SQLUpdate: SQLExpression { - /// Table to update. + /// The table containing the row(s) to be updated. public var table: any SQLExpression - /// Zero or more identifier: expression pairs to update. - public var values: [any SQLExpression] + /// One or more column assignment expressions describing how to update the value in each affected row. + /// + /// See ``SQLColumnAssignment`` and ``SQLColumnUpdateBuilder``. + public var values: [any SQLExpression] = [] - /// Optional predicate to limit updated rows. - public var predicate: (any SQLExpression)? + /// If not `nil`, a predicate which describes the row(s) to be updated. + /// + /// If no predicate if given, all rows in the table are implicitly eligible for updating. + public var predicate: (any SQLExpression)? = nil - /// Optionally append a `RETURNING` clause that, where supported, returns the supplied supplied columns. - public var returning: SQLReturning? + /// An optional ``SQLReturning`` clause specifying data to return from the updated rows. + public var returning: SQLReturning? = nil - /// Creates a new ``SQLUpdate``. + /// Create a new row modification query. + /// + /// - Parameter table: The table containing the row(s) to update. @inlinable public init(table: any SQLExpression) { self.table = table - self.values = [] - self.predicate = nil } + // See `SQLExpression.serialize(to:)`. public func serialize(to serializer: inout SQLSerializer) { serializer.statement { - $0.append("UPDATE") - $0.append(self.table) - $0.append("SET") - $0.append(SQLList(self.values)) + $0.append("UPDATE", self.table) + $0.append("SET", SQLList(self.values)) if let predicate = self.predicate { - $0.append("WHERE") - $0.append(predicate) - } - if let returning = self.returning { - $0.append(returning) + $0.append("WHERE", predicate) } + $0.append(self.returning) } } } diff --git a/Sources/SQLKit/Expressions/SQLExpression.swift b/Sources/SQLKit/Expressions/SQLExpression.swift index 12206cfd..bf91049f 100644 --- a/Sources/SQLKit/Expressions/SQLExpression.swift +++ b/Sources/SQLKit/Expressions/SQLExpression.swift @@ -1,3 +1,62 @@ -public protocol SQLExpression { +/// The fundamental base type of anything which can be represented as SQL using SQLKit. +/// +/// ``SQLExpression``s are not well-enough organized in practice to be considered a proper Abstract Syntax Tree +/// representation, but they nonetheless conceptually act as AST nodes. As such, _anything_ which is executed as +/// SQL by an ``SQLDatabase`` is represented by a value conforming to ``SQLExpression`` - even if that value is an +/// instance of ``SQLRaw`` containing arbitrary SQL text. +/// +/// The single requirement of ``SQLExpression`` is the ``SQLExpression/serialize(to:)`` method, which must output +/// the appropriate raw text, bindings, and/or subexpressions to the provided ``SQLSerializer`` when invoked. Most +/// interaction with ``SQLDialect`` takes place in the serialization logic of various ``SQLExpression``s - for +/// example, ``SQLIdentifier`` uses the ``SQLDialect/identifierQuote`` of the serializer's dialect when quoting +/// identifiers (naturally enough). Many ``SQLExpression``s - especially those representing entire SQL queries, such +/// as ``SQLSelect`` or ``SQLCreateTable`` - function solely as containers of other expressions which are serialized +/// in an appropriate sequence. +/// +/// See ``SQLSerializer`` and ``SQLDatabase/serialize(_:)`` for additional details regarding serialization. +/// +/// Here is an example of implementing a trivial (and somewhat pointless) ``SQLExpression``: +/// +/// ```swift +/// public struct SQLOptionalExpression: SQLExpression { +/// public var subexpression: E? +/// +/// public init(_ subexpression: E?) { +/// self.subexpression = subexpression +/// } +/// +/// public func serialize(to serializer: inout SQLSerializer) { +/// if let subexpression = self.subexpression { +/// subexpression.serialize(to: serializer) +/// } +/// } +/// } +/// ``` +/// +/// > Note: The example expression above treats the type of the "subexpression" it contains generically; this is +/// > currently considered best practice whenever possible. However, this pattern is unfortunately _not_ adopted +/// > by any of the expressions included in SQLKit itself - instead, the existential type `any SQLExpression` is +/// > used with great abandon. This is, to say the least, not optimal, but as usual with pre-existing public API, +/// > it cannot be changed until the next major version bump. The API in its present form was designed back when +/// > Swift 5.1 was the current release; the language features needed to usefully handle expressions generically +/// > were largely absent before Swift 5.7, and even then it would have been severely limited before the advent of +/// > Swift 5.9 and support for variadic generics. +public protocol SQLExpression: Sendable { + /// Invoked when a request is made to serialize the expression to raw SQL. + /// + /// Implementations of this requirement should invoke various ``SQLSerializer`` methods as appropriate to + /// convert its contents to raw SQL form, including inspecting ``SQLSerializer/dialect`` as needed. + /// + /// > Important: Because this method is not throwing, an expression which encounters a serialization + /// > failure has limited options to report it. Implementations are _STRONGLY_ discouraged from triggering a + /// > runtime error (such as via `fatalError()`) or from using `print()` to inform the user; instead, the + /// > recommended behavior for such failures is: + /// > + /// > 1. (Optional) Use the ``SQLDatabase/logger`` of the ``SQLSerializer/database`` to log an appropriate + /// > message at an appropriate severity level. + /// > 2. Either output no content at all, or output deliberately syntactically invalid SQL to ensure an attempt + /// > to execute a query containing the failing expression will fail in turn. + /// + /// - Parameter serializer: The ``SQLSerializer`` to use. func serialize(to serializer: inout SQLSerializer) } diff --git a/Sources/SQLKit/Expressions/SQLSerializer.swift b/Sources/SQLKit/Expressions/SQLSerializer.swift index 861416f9..1a57be95 100644 --- a/Sources/SQLKit/Expressions/SQLSerializer.swift +++ b/Sources/SQLKit/Expressions/SQLSerializer.swift @@ -1,13 +1,24 @@ -public struct SQLSerializer { +/// Encapsulates the most basic operations for serializing ``SQLExpression``s into a raw SQL string and a +/// (potentially empty) sequence of bound parameter values. +public struct SQLSerializer: Sendable { + /// The generated raw SQL text. public var sql: String - public var binds: [any Encodable] + + /// The list of bound parameter values (if any). + public var binds: [any Encodable & Sendable] + + /// The database for this serializer. public let database: any SQLDatabase + /// Convenience accessor for the ``SQLDatabase/dialect`` of ``SQLSerializer/database``. @inlinable public var dialect: any SQLDialect { self.database.dialect } + /// Create a new ``SQLSerializer`` for a given ``SQLDatabase``. + /// + /// - Parameter database: The database which will run the serialized query. @inlinable public init(database: any SQLDatabase) { self.sql = "" @@ -15,13 +26,19 @@ public struct SQLSerializer { self.database = database } + /// Add a bound parameter value to the serializer. + /// + /// - Parameter encodable: The value to bind. @inlinable - public mutating func write(bind encodable: any Encodable) { + public mutating func write(bind encodable: some Encodable & Sendable) { self.binds.append(encodable) self.dialect.bindPlaceholder(at: self.binds.count) .serialize(to: &self) } + /// Append raw SQL to the serializer. + /// + /// - Parameter sql: The text to append. @inlinable public mutating func write(_ sql: String) { self.sql += sql diff --git a/Sources/SQLKit/Expressions/SQLStatement.swift b/Sources/SQLKit/Expressions/SQLStatement.swift index 828886db..9a595c62 100644 --- a/Sources/SQLKit/Expressions/SQLStatement.swift +++ b/Sources/SQLKit/Expressions/SQLStatement.swift @@ -1,4 +1,50 @@ +import struct Logging.Logger + extension SQLSerializer { + /// Invoke the provided closure with a new ``SQLStatement`` to use for serialization. + /// + /// This method is the entry point for the alternate expression serialization API provided by ``SQLStatement``. + /// The name of the type is somewhat misleading; the serialized result is not required to be a complete SQL + /// "statement"; as with the usual ``SQLSerializer`` API, the inputs and resultant output can be arbitrary. + /// + /// To use the "statement" API, call this method in the implentation of ``SQLExpression/serialize(to:)``, and + /// provide a closure which contains the serialization logic for the expression. Call methods of the + /// ``SQLStatement`` passed to the closure to add individual textual and subexpression pieces to the final + /// result. Do _not_ access the ``SQLSerializer`` from inside the closure. + /// + /// For example, consider ``SQLEnumDataType``'s ``SQLEnumDataType/serialize(to:)`` method: + /// + /// ```swift + /// public func serialize(to serializer: inout SQLSerializer) { + /// switch serializer.dialect.enumSyntax { + /// case .inline: + /// SQLRaw("ENUM").serialize(to: &serializer) + /// SQLGroupExpression(self.cases).serialize(to: &serializer) + /// default: + /// SQLDataType.text.serialize(to: &serializer) + /// serializer.database.logger.debug("Database does not support inline enums. Storing as TEXT instead.") + /// } + /// } + /// ``` + /// + /// Rewritten using ``SQLSerializer/statement(_:)``, the method becomes: + /// + /// ```swift + /// public func serialize(to serializer: inout SQLSerializer) { + /// serializer.statement { + /// switch $0.dialect.enumSyntax { + /// case .inline: + /// $0.append("ENUM", SQLGroupExpression(self.cases)) + /// default: + /// $0.append(SQLDataType.text) + /// $0.logger.debug("Database does not support inline enums. Storing as TEXT instead.") + /// } + /// } + /// } + /// ``` + /// + /// > Note: While doing so is not especially useful, this method can be called more than once within the same + /// > context; each invocation immediately serializes the statement upon return from the provided closure. @inlinable public mutating func statement(_ closure: (inout SQLStatement) -> ()) { var sql = SQLStatement(database: self.database) @@ -7,62 +53,190 @@ extension SQLSerializer { } } -/// A helper type for building complete SQL statements up from fragments. -/// See also `SQLSerializer.statement(_:)`. +/// An alternative API for serialization of ``SQLExpression``s. +/// +/// The ``SQLSerializer/statement(_:)`` method provides access to the "statement" serialization API, which offers +/// a more consistent and readable interface for serialization than the repeated calls to ``SQLSerializer/write(_:)`` +/// and ``SQLExpression/serialize(to:)`` originally described by ``SQLExpression``. See the documentation of +/// ``SQLSerializer/statement(_:)`` for example usage. +/// +/// > Note: Although ``SQLStatement`` itself conforms to ``SQLExpression``, users are not expected to explicitly +/// > include it in the serialization of any other expression; it is serialized automatically by +/// > ``SQLSerializer/statement(_:)`` when appropriate. public struct SQLStatement: SQLExpression { + /// The individual expressions collected by the statement, in order. + /// + /// The serialization of a given ``SQLStatement`` is that of each element of its ``parts`` array, with a + /// single space character placed between the SQL text of each element. public var parts: [any SQLExpression] = [] + /// The ``SQLDatabase`` obtained from the original ``SQLSerializer``. @usableFromInline let database: any SQLDatabase + /// Designated initializer. + /// + /// External users may not invoke this method; use ``SQLSerializer/statement(_:)``. @usableFromInline init(database: any SQLDatabase) { self.database = database } + /// Convenience accessor for the database's `Logger`. + /// + /// > Note: The compiler's exclusive access checking rules prevent statement closures from accessing the + /// > original serializer directly. + @inlinable + public var logger: Logger { + self.database.logger + } + /// Convenience accessor for the database's ``SQLDialect``. /// - /// - Note: Statement closures can't access the serializer directly due to exclusive access rules. + /// > Note: The compiler's exclusive access checking rules prevent statement closures from accessing the + /// > original serializer directly. @inlinable public var dialect: any SQLDialect { self.database.dialect } - /// Add raw text to the output. + // See `SQLExpression.serialize(to:)`. + @inlinable + public func serialize(to serializer: inout SQLSerializer) { + /// Although `self.parts.interspersed(with: SQLRaw(" ")).forEach { $0.serialize(to: &serializer) }` would be a + /// more "elegant" way to write this, it results in the creation of `self.parts.count - 1` identical instances + /// of ``SQLRaw`` and requires the compiler to dynamically dispatch a call to each one's `serialize(to:)` + /// method. While the total overhead of this behavior is unlikely to be measurable in practice unless the + /// statement has a very large number of constitutent parts, saving a couple of extra lines of code with a + /// "clever trick" is still not at all worth it - especially since it also requires importing the + /// `swift-algorithms` package, an entire additional dependency which adds insult to injury in the form of + /// increased overall compile time. + var iter = self.parts.makeIterator() + + iter.next()?.serialize(to: &serializer) + while let part = iter.next() { + var temp = SQLSerializer(database: serializer.database) + temp.binds = serializer.binds + + part.serialize(to: &temp) + if !temp.sql.isEmpty { + serializer.sql += " \(temp.sql)" + } + serializer.binds = temp.binds//.append(contentsOf: temp.binds) // Can't just append because we need to keep numbers in sync + } + } + + // MARK: - Append methods, cardinality 1 + + /// Add raw text to the statement output. @inlinable public mutating func append(_ raw: String) { self.append(SQLRaw(raw)) } - /// Add raw text followed by an ``SQLExpression`` to tbe output. + /// Add an unserialized ``SQLExpression`` to the statement output. /// - /// - Note: "Text + expr" pairs appear quite often when building statments. - @inlinable - public mutating func append(_ raw: String, _ part: any SQLExpression) { - self.append(raw) - self.append(part) - } - - /// Add an ``SQLExpression`` of any kind to the output. + /// > Warning: Unlike the ``SQLSerializer`` API, in which serializing an expression is the only way to include it + /// > in the output of the overal operation, expressions added to ``SQLStatement``s are retained in their original + /// > forms until the statement itself is esrialized. This may produce unexpected behavior if an expression is a + /// > reference type with mutable properties, or if its serialization is dependent on the current overall + /// > serialization state. @inlinable public mutating func append(_ part: any SQLExpression) { self.parts.append(part) } - /// Add an ``SQLExpression`` of any kind to the output, but only if it isn't `nil`. + /// Add an optional unserialized ``SQLExpression`` of any kind to the output. + /// + /// This is shorthand for `if let expr { statement.append(expr) }`. @inlinable public mutating func append(_ maybePart: (any SQLExpression)?) { maybePart.map { self.append($0) } } - /// See ``SQLExpression/serialize(to:)``. + // MARK: - Append methods, cardinality 2 + + /// Add two raw text strings to the statement output. @inlinable - public func serialize(to serializer: inout SQLSerializer) { - for i in self.parts.indices { - if i > self.parts.startIndex { - serializer.write(" ") - } - self.parts[i].serialize(to: &serializer) - } + public mutating func append(_ raw1: String, _ raw2: String) { + self.parts.append(contentsOf: [SQLRaw(raw1), SQLRaw(raw2)]) + } + + /// Add raw text and an unserialized ``SQLExpression`` to the statement output, in that order. + @inlinable + public mutating func append(_ raw: String, _ part: any SQLExpression) { + self.parts.append(contentsOf: [SQLRaw(raw), part]) + } + + /// Add raw text and an optional unserialized ``SQLExpression`` to the statement output, in that order. + /// + /// > Note: Because this method's non-optional variant, ``append(_:_:)-53s9b``, already existed as public API, + /// > source compatibility requires that this version must be declared separately, rather than allowing the + /// > compiler to infer the optionality as needed as with, for example, ``append(_:_:)-4g2tf``. + @inlinable + public mutating func append(_ raw: String, _ part: (any SQLExpression)?) { + self.parts.append(contentsOf: [SQLRaw(raw), part].compactMap { $0 }) + } + + /// Add an optional unserialized ``SQLExpression`` and raw text to the statement output, in that order. + @inlinable + public mutating func append(_ part: (any SQLExpression)?, _ raw: String) { + self.parts.append(contentsOf: [part, SQLRaw(raw)].compactMap { $0 }) + } + + /// Add two optional unserialized ``SQLExpression``s to the statement output. + @inlinable + public mutating func append(_ part1: (any SQLExpression)?, _ part2: (any SQLExpression)?) { + self.parts.append(contentsOf: [part1, part2].compactMap { $0 }) + } + + // MARK: - Append methods, cardinality 3 + + /// Add three raw text strings to the statement. + @inlinable + public mutating func append(_ p1: String, _ p2: String, _ p3: String) { + self.parts.append(contentsOf: [SQLRaw(p1), SQLRaw(p2), SQLRaw(p3)]) + } + + /// Add an optional unserialized ``SQLExpression`` and two raw text strings to the statement output. + @inlinable + public mutating func append(_ p1: (any SQLExpression)?, _ p2: String, _ p3: String) { + self.parts.append(contentsOf: [p1, SQLRaw(p2), SQLRaw(p3)].compactMap { $0 }) + } + + /// Add raw text, an optional unserialized ``SQLExpression``, and more raw text to the statement output. + @inlinable + public mutating func append(_ p1: String, _ p2: (any SQLExpression)?, _ p3: String) { + self.parts.append(contentsOf: [SQLRaw(p1), p2, SQLRaw(p3)].compactMap { $0 }) + } + + /// Add two optional unserialized ``SQLExpression``s and raw text to the statement output. + @inlinable + public mutating func append(_ p1: (any SQLExpression)?, _ p2: (any SQLExpression)?, _ p3: String) { + self.parts.append(contentsOf: [p1, p2, SQLRaw(p3)].compactMap { $0 }) + } + + /// Add two raw texts strings and an optional unserialized ``SQLExpression`` to the statement output, in that order. + @inlinable + public mutating func append(_ p1: String, _ p2: String, _ p3: (any SQLExpression)?) { + self.parts.append(contentsOf: [SQLRaw(p1), SQLRaw(p2), p3].compactMap { $0 }) + } + + /// Add raw text and two optional unserialized ``SQLExpression``s to the statement output. + @inlinable + public mutating func append(_ p1: String, _ p2: (any SQLExpression)?, _ p3: (any SQLExpression)?) { + self.parts.append(contentsOf: [SQLRaw(p1), p2, p3].compactMap { $0 }) + } + + /// Add an optional unserialized ``SQLExpression``, raw text, and an optional unserialized ``SQLExpression`` to the statement output. + @inlinable + public mutating func append(_ p1: (any SQLExpression)?, _ p2: String, _ p3: (any SQLExpression)?) { + self.parts.append(contentsOf: [p1, SQLRaw(p2), p3].compactMap { $0 }) + } + + /// Add three optional unserialized ``SQLExpression``s to the statement output. + @inlinable + public mutating func append(_ p1: (any SQLExpression)?, _ p2: (any SQLExpression)?, _ p3: (any SQLExpression)?) { + self.parts.append(contentsOf: [p1, p2, p3].compactMap { $0 }) } } diff --git a/Sources/SQLKit/Expressions/Syntax/SQLBinaryExpression.swift b/Sources/SQLKit/Expressions/Syntax/SQLBinaryExpression.swift index 7f7bc114..12bcea8e 100644 --- a/Sources/SQLKit/Expressions/Syntax/SQLBinaryExpression.swift +++ b/Sources/SQLKit/Expressions/Syntax/SQLBinaryExpression.swift @@ -1,15 +1,70 @@ +/// A fundamental syntactical expression - a left and right operand joined by an infix operator. +/// +/// This construct forms the basis of most comparisons, conditionals, and compounds which can be +/// represented by an expression. +/// +/// For example, the expression `foo = 1 AND bar <> 'baz' OR bop - 5 NOT IN (1, 3)` can be represented +/// in terms of nested ``SQLBinaryExpression``s (note that there is more than one "correct" way to nest +/// this particular example): +/// +/// ```swift +/// let expr = SQLBinaryExpression( +/// SQLBinaryExpression(SQLColumn("foo"), .equal, SQLLiteral.numeric("1")), +/// .and, +/// SQLBinaryExpression( +/// SQLBinaryExpression(SQLColumn("bar"), .notEqual, SQLLiteral.string("baz")), +/// .or, +/// SQLBinaryExpression( +/// SQLBinaryExpression(SQLColumn("bop"), .subtract, SQLLiteral.numeric("5")), +/// .notIn, +/// SQLGroupExpression(SQLLiteral.numeric("1"), SQLLiteral.numeric("3")) +/// ) +/// ) +/// ) +/// ``` public struct SQLBinaryExpression: SQLExpression { + /// The left-side operand of the expression. public let left: any SQLExpression + + /// The operator joining the left and right operands. public let op: any SQLExpression + + /// The right-side operand of the expression. public let right: any SQLExpression + /// Create an ``SQLBinaryExpression`` from component expressions. + /// + /// - Parameters: + /// - left: The left-side oeprand. + /// - op: The operator. + /// - right: The right-side operand. @inlinable - public init(left: any SQLExpression, op: any SQLExpression, right: any SQLExpression) { + public init( + left: any SQLExpression, + op: any SQLExpression, + right: any SQLExpression + ) { self.left = left self.op = op self.right = right } + /// Create an ``SQLBinaryExpression`` from two operand expressions and a predefined binary operator. + /// + /// - Parameters: + /// - left: The left-side operand. + /// - op: The binary operator. + /// - right: The right-side operand. + @inlinable + public init( + _ left: any SQLExpression, + _ op: SQLBinaryOperator, + _ right: any SQLExpression + ) { + self.init(left: left, op: op, right: right) + } + + // See `SQLExpression.serialize(to:)`. @inlinable public func serialize(to serializer: inout SQLSerializer) { serializer.statement { @@ -19,10 +74,3 @@ public struct SQLBinaryExpression: SQLExpression { } } } - -extension SQLBinaryExpression { - @inlinable - public init(_ left: any SQLExpression, _ op: SQLBinaryOperator, _ right: any SQLExpression) { - self.init(left: left, op: op, right: right) - } -} diff --git a/Sources/SQLKit/Expressions/Syntax/SQLBinaryOperator.swift b/Sources/SQLKit/Expressions/Syntax/SQLBinaryOperator.swift index 1d41414a..f8ef65f4 100644 --- a/Sources/SQLKit/Expressions/Syntax/SQLBinaryOperator.swift +++ b/Sources/SQLKit/Expressions/Syntax/SQLBinaryOperator.swift @@ -1,94 +1,95 @@ -/// SQL binary expression operators, i.e., `==`, `!=`, `AND`, `+`, etc. +/// SQL binary expression operators. public enum SQLBinaryOperator: SQLExpression { - /// `=` or `==` + /// Equality. `=` or `==` in most dialects. case equal - /// `!=` or `<>` + /// Inequality. `!=` or `<>` in most dialects. case notEqual - /// `>` + /// Arranged in descending order, or `>`. case greaterThan - /// `<` + /// Arranged in ascending order, or `<`. case lessThan - /// `>=` + /// Not arranged in ascending order, or `>=`. case greaterThanOrEqual - /// `<=` + /// Not arranged in descending order, or `<=`. case lessThanOrEqual - /// `LIKE` + /// SQL pattern match, or `LIKE`. case like - /// `NOT LIKE` + /// SQL pattern mismatch, or `NOT LIKE`. case notLike - /// `IN` + /// Set membership, or `IN`. case `in` - /// `NOT IN` + /// Set exclusion, or `NOT IN`. case `notIn` - /// `AND` + /// Logical conjunction, or `AND`. case and - /// `OR` + /// Logical disjunction, or `OR`. case or - /// `||` - case concatenate - - /// `*` + /// Arithmetic multiplication, or `*`. case multiply - /// `/` + /// Arithmetic division, or `/`. case divide - /// `%` + /// Arithmetic remainder, or `%`. case modulo - /// `+` + /// Arithmetic addition, or `+`. case add - /// `-` + /// Arithmetic subtraction, or `-`. case subtract - /// `IS` + /// Typed identity, or `IS`. case `is` - /// `IS NOT` + /// Typed dissimilarity, or `IS NOT`. case isNot + /// String concatenation, or `||`. + /// + /// This operator is not implemented. Attempting to use it will trigger a runtime error. + @available(*, deprecated, message: "The || concatenation operator is not implemented due to legacy compatibility issues. Use SQLFunction(\"concat\") instead.") + case concatenate + + // See `SQLExpression.serialize(to:)`. @inlinable public func serialize(to serializer: inout SQLSerializer) { switch self { - case .equal: serializer.write("=") - case .notEqual: serializer.write("<>") - case .and: serializer.write("AND") - case .or: serializer.write("OR") - case .in: serializer.write("IN") - case .notIn: serializer.write("NOT IN") - case .greaterThan: serializer.write(">") + case .equal: serializer.write("=") + case .notEqual: serializer.write("<>") + case .and: serializer.write("AND") + case .or: serializer.write("OR") + case .in: serializer.write("IN") + case .notIn: serializer.write("NOT IN") + case .greaterThan: serializer.write(">") case .greaterThanOrEqual: serializer.write(">=") - case .lessThan: serializer.write("<") - case .lessThanOrEqual: serializer.write("<=") - case .is: serializer.write("IS") - case .isNot: serializer.write("IS NOT") - case .like: serializer.write("LIKE") - case .notLike: serializer.write("NOT LIKE") - case .multiply: serializer.write("*") - case .divide: serializer.write("/") - case .modulo: serializer.write("%") - case .add: serializer.write("+") - case .subtract: serializer.write("-") + case .lessThan: serializer.write("<") + case .lessThanOrEqual: serializer.write("<=") + case .is: serializer.write("IS") + case .isNot: serializer.write("IS NOT") + case .like: serializer.write("LIKE") + case .notLike: serializer.write("NOT LIKE") + case .multiply: serializer.write("*") + case .divide: serializer.write("/") + case .modulo: serializer.write("%") + case .add: serializer.write("+") + case .subtract: serializer.write("-") // See https://dev.mysql.com/doc/refman/8.0/en/sql-mode.html#sqlmode_pipes_as_concat case .concatenate: - fatalError(""" - || is not implemented because MySQL doesn't always support it, even though everyone else does. - Use `SQLFunction("CONCAT", args...)` for MySQL or `SQLRaw("||")` with Postgres and SQLite. - """) + serializer.database.logger.debug("|| is not implemented, because it doesn't always work. Use `SQLFunction(\"CONCAT\", args...)` for MySQL or `SQLRaw(\"||\")` for Postgres and SQLite.") } } } diff --git a/Sources/SQLKit/Expressions/Syntax/SQLBind.swift b/Sources/SQLKit/Expressions/Syntax/SQLBind.swift index a00625c9..f25fd9e1 100644 --- a/Sources/SQLKit/Expressions/Syntax/SQLBind.swift +++ b/Sources/SQLKit/Expressions/Syntax/SQLBind.swift @@ -1,21 +1,23 @@ /// A parameterizied value bound to the SQL query. public struct SQLBind: SQLExpression { - public let encodable: any Encodable + /// The actual bound value. + public let encodable: any Encodable & Sendable + /// Create a binding to a value. @inlinable - public init(_ encodable: any Encodable) { + public init(_ encodable: some Encodable & Sendable) { self.encodable = encodable } + /// Create a list of bindings to an array of values, with the placeholders wrapped in an ``SQLGroupExpression``. @inlinable - public func serialize(to serializer: inout SQLSerializer) { - serializer.write(bind: self.encodable) + public static func group(_ items: some Collection) -> any SQLExpression { + SQLGroupExpression(items.map(SQLBind.init)) } -} -extension SQLBind { + // See `SQLExpression.serialize(to:)`. @inlinable - public static func group(_ items: [any Encodable]) -> any SQLExpression { - SQLGroupExpression(items.map(SQLBind.init)) + public func serialize(to serializer: inout SQLSerializer) { + serializer.write(bind: self.encodable) } } diff --git a/Sources/SQLKit/Expressions/Syntax/SQLFunction.swift b/Sources/SQLKit/Expressions/Syntax/SQLFunction.swift index 42fd4062..26f48bb1 100644 --- a/Sources/SQLKit/Expressions/Syntax/SQLFunction.swift +++ b/Sources/SQLKit/Expressions/Syntax/SQLFunction.swift @@ -1,28 +1,73 @@ +/// A call to a function available in SQL, expressed as a name and a (possibly empty) list of arguments. +/// +/// Example usage: +/// +/// ```swift +/// try await sqlDatabase.select() +/// .column(SQLFunction("coalesce", args: SQlColumn("col1"), SQlColumn("col2"), SQLBind(defaultValue))) +/// .from("table") +/// .all() +/// ``` +/// +/// > Note: ``SQLFunction`` is permitted to substitute function names during serialization based on the current +/// > dialect if a known, unambiguous replacement for an unavailable name is available. At the time of this writing, +/// > no such substitutions take place in practice, but it would be of obvious utility in certain common cases, such +/// > as SQLite's lack of support for the `NOW()` function. public struct SQLFunction: SQLExpression { + /// The function's name. + /// + /// In this version of SQLKit, function names are always emitted as raw unquoted SQL. public let name: String + + /// The list of function arguments. May be empty. public let args: [any SQLExpression] + /// Create a function from a name and list of arguments. + /// + /// Each argument is treated as a quotable identifier, _not_ raw SQL or a string literal. + /// + /// - Parameters: + /// - name: The function name. + /// - args: The list of arguments. @inlinable public init(_ name: String, args: String...) { self.init(name, args: args.map { SQLIdentifier($0) }) } + /// Create a function from a name and list of arguments. + /// + /// Each argument is treated as a quotable identifier, _not_ raw SQL or a string literal. + /// + /// - Parameters: + /// - name: The function name. + /// - args: The list of arguments. @inlinable public init(_ name: String, args: [String]) { self.init(name, args: args.map { SQLIdentifier($0) }) } + /// Create a function from a name and list of arguments. + /// + /// - Parameters: + /// - name: The function name. + /// - args: The list of arguments. @inlinable public init(_ name: String, args: any SQLExpression...) { self.init(name, args: args) } + /// Create a function from a name and list of arguments. + /// + /// - Parameters: + /// - name: The function name. + /// - args: The list of arguments. @inlinable public init(_ name: String, args: [any SQLExpression] = []) { self.name = name self.args = args } + // See `SQLExpression.serialize(to:)`. public func serialize(to serializer: inout SQLSerializer) { serializer.write(self.name) SQLGroupExpression(self.args).serialize(to: &serializer) @@ -30,14 +75,43 @@ public struct SQLFunction: SQLExpression { } extension SQLFunction { + /// A factory method to simplify use of the standard `COALESCE()` function. + /// + /// The SQL `COALESCE()` function takes one or more arguments, and returns the first such arguments which passes + /// an `IS NOT NULL` test. If all arguments evaluate to `NULL`, `NULL` is returned. + /// + /// Example: + /// + /// ```swift + /// try await database.select() + /// .column(SQLFunction.coalesce(SQLColumn("col1"), SQLBind(defaultValue))) + /// .all() + /// ``` + /// + /// - Parameter exprs: A list of expressions to coalesce. + /// - Returns: An appropriately-constructed ``SQLFunction``. @inlinable - public static func coalesce(_ expressions: [any SQLExpression]) -> SQLFunction { - .init("COALESCE", args: expressions) + public static func coalesce(_ exprs: any SQLExpression...) -> SQLFunction { + self.coalesce(exprs) } - /// Convenience for creating a `COALESCE(foo)` function call (returns the first non-null expression). + /// A factory method to simplify use of the standard `COALESCE()` function. + /// + /// The SQL `COALESCE()` function takes one or more arguments, and returns the first such arguments which passes + /// an `IS NOT NULL` test. If all arguments evaluate to `NULL`, `NULL` is returned. + /// + /// Example: + /// + /// ```swift + /// try await database.select() + /// .column(SQLFunction.coalesce(SQLColumn("col1"), SQLBind(defaultValue))) + /// .all() + /// ``` + /// + /// - Parameter exprs: A list of expressions to coalesce. + /// - Returns: An appropriately-constructed ``SQLFunction``. @inlinable - public static func coalesce(_ exprs: any SQLExpression...) -> SQLFunction { - self.coalesce(exprs) + public static func coalesce(_ expressions: [any SQLExpression]) -> SQLFunction { + .init("COALESCE", args: expressions) } } diff --git a/Sources/SQLKit/Expressions/Syntax/SQLGroupExpression.swift b/Sources/SQLKit/Expressions/Syntax/SQLGroupExpression.swift index b092f4d6..671793d2 100644 --- a/Sources/SQLKit/Expressions/Syntax/SQLGroupExpression.swift +++ b/Sources/SQLKit/Expressions/Syntax/SQLGroupExpression.swift @@ -1,16 +1,44 @@ +/// A fundamental syntactical expression - an arbitrary expression or list of expressions, surroudned by parenthesis. +/// +/// This construct provides "grouping" syntax in numerous contexts. When a "group" contains more than one +/// subexpression, all subexpressions are joined using ``SQLList`` with the default separator. See +/// ``SQLList/init(_:separator:)`` for aadditional information. +/// +/// Example usage: +/// +/// ```swift +/// try await database.select() +/// .column(...) +/// .where("foo", .in, SQLGroupExpression(SQLBind(foo), SQLBind(bar))) +/// ... +/// // Generated SQL: `SELECT ... FROM .. WHERE "foo" IN ($0, $1)`. +/// ``` public struct SQLGroupExpression: SQLExpression { + /// The potentially empty list of expressions to group. + /// + /// When there is more than one expression in the list, they are wrapped with an ``SQLList`` before serialization. public let expressions: [any SQLExpression] + /// Create a group expression with a single subexpresion. + /// + /// - Parameter expression: The subexpression to parenthesize. @inlinable public init(_ expression: any SQLExpression) { self.expressions = [expression] } + /// Create a group expression with a list of zero or more subexpressions. + /// + /// When more than one expression is provided, they are wrapped with a default ``SQLList`` before serialization, + /// resulting in a parenthesized comma-separated list. + /// + /// - Parameter expressions: The list of expressions to parenthesize. @inlinable public init(_ expressions: [any SQLExpression]) { self.expressions = expressions } + // See `SQLExpression.serialize(to:)`. public func serialize(to serializer: inout SQLSerializer) { serializer.write("(") SQLList(self.expressions).serialize(to: &serializer) diff --git a/Sources/SQLKit/Expressions/Syntax/SQLIdentifier.swift b/Sources/SQLKit/Expressions/Syntax/SQLIdentifier.swift index 132b630e..a1eed59b 100644 --- a/Sources/SQLKit/Expressions/Syntax/SQLIdentifier.swift +++ b/Sources/SQLKit/Expressions/Syntax/SQLIdentifier.swift @@ -1,25 +1,49 @@ -/// An escaped identifier, i.e., `"name"`. -public struct SQLIdentifier: SQLExpression { - /// String value. +/// A fundamental syntactical expression - a quoted identifier (also often referred to as a "name" or "object name"). +/// +/// Most identifiers in SQL are references to various objects - tables, columns, functions, indexes, constraints, +/// etc.; if something is not a keyword, punctuation, or a literal, it is more likely than not an identifier. +/// +/// In most SQL dialects, quoting is only required for identifiers if they contain characters not otherwise allowed in +/// identifiers in that dialect or conflict with an SQL keyword, but may optionally be included even when not needed. +/// For the sake of maximum correctness, maximum consistency, and avoiding the need to do expensive checks to check +/// for invalid characters, ``SQLIdentifier`` adds quoting unconditionally. +/// +/// To avoid the risk of accidental SQL injection vulnerabilities, in addition to quoting, identifiers are scanned for +/// the identifier quote character(s) themselves; if found, they are escaped appropriately (by doubling any embedded +/// quoting character(s), a syntax supported by all known dialects). +public struct SQLIdentifier: SQLExpression, ExpressibleByStringLiteral { + /// The actual identifier itself, unescaped and unquoted. public var string: String - /// Creates a new `SQLIdentifier`. + /// Create an identifier with a string. @inlinable public init(_ string: String) { self.string = string } + // See `ExpressibleByStringLiteral.init(stringLiteral:)`. @inlinable - public func serialize(to serializer: inout SQLSerializer) { - serializer.dialect.identifierQuote.serialize(to: &serializer) - serializer.write(self.string) - serializer.dialect.identifierQuote.serialize(to: &serializer) + public init(stringLiteral value: String) { + self.init(value) } -} -extension SQLIdentifier: ExpressibleByStringLiteral { + // See `SQLExpression.serialize(to:)`. @inlinable - public init(stringLiteral value: StringLiteralType) { - self.init(value) + public func serialize(to serializer: inout SQLSerializer) { + /// This is another instance where legacy API choices limit the robustness of the API's overall behavior. + /// Specifically, ``SQLDialect`` allows the ``SQLDialect/identifierQuote`` and + /// ``SQLDialect/literalStringQuote-3ur0m`` to be specified as arbitrary ``SQLExpression``s; this probably + /// seemed like a good idea for flexibility at the time, but in reality creates additional performance + /// bottlenecks and prevents error-proof quoting, short of making ``SQLDialect`` even more confusing (or a + /// major version bump). Fortunately, in practice all knwon dialects always return their quoting characters + /// as instances of ``SQLRaw``, so we check for that case and perform the appropriate quoting and/or escaping + /// as needed, while falling back to quoting without escaping if the check fails. + if let rawQuote = (serializer.dialect.identifierQuote as? SQLRaw)?.sql { + serializer.write("\(rawQuote)\(self.string.sqlkit_replacing(rawQuote, with: "\(rawQuote)\(rawQuote)"))\(rawQuote)") + } else { + serializer.dialect.identifierQuote.serialize(to: &serializer) + serializer.write(self.string) + serializer.dialect.identifierQuote.serialize(to: &serializer) + } } } diff --git a/Sources/SQLKit/Expressions/Syntax/SQLList.swift b/Sources/SQLKit/Expressions/Syntax/SQLList.swift index 964eb7df..a6309c36 100644 --- a/Sources/SQLKit/Expressions/Syntax/SQLList.swift +++ b/Sources/SQLKit/Expressions/Syntax/SQLList.swift @@ -1,21 +1,45 @@ +/// A fundamental syntactical expression - a list of subexpresions with a specified "separator" subexpression. +/// +/// When serialized, an empty ``SQLList`` outputs nothing, a single-item ``SQLList`` outputs the serialization of +/// that one expression, and all other ``SQLList``s output the entire list of subexpressions joined by an appropriate +/// number of copies of the separator subexpression. The default separator is `SQLRaw(", ")`. +/// +/// Examples: +/// +/// ```swift +/// print(database.serialize(SQLList(SQLLiteral.string("a"), SQLLiteral.string("b"))).sql) +/// // "'a', 'b'" +/// print(database.serialize(SQLList(SQLLiteral.string("a"), SQLLiteral.string("b"), separator: SQLBinaryOperator.and)).sql) +/// // "'a'AND'b'" +/// print(database.serialize(SQLList(SQLLiteral.string("a"), SQLLiteral.string("b"), separator: SQLRaw(" AND ")).sql) +/// // "'a' AND 'b'" +/// ``` public struct SQLList: SQLExpression { + /// The list of subexpressions to join. public var expressions: [any SQLExpression] + + /// The expression with which to join the list of subexpressions. public var separator: any SQLExpression - + + /// Create a list from a list of expressions and an optional separator expression. + /// + /// - Parameters: + /// - expressions: The list of expressions. + /// - separator: A separator expression. If not given, defaults to `SQLRaw(", ")`. @inlinable public init(_ expressions: [any SQLExpression], separator: any SQLExpression = SQLRaw(", ")) { self.expressions = expressions self.separator = separator } - + + // See `SQLExpression.serialize(to:)`. public func serialize(to serializer: inout SQLSerializer) { - var first = true - for el in self.expressions { - if !first { - self.separator.serialize(to: &serializer) - } - first = false - el.serialize(to: &serializer) + var iter = self.expressions.makeIterator() + + iter.next()?.serialize(to: &serializer) + while let item = iter.next() { + self.separator.serialize(to: &serializer) + item.serialize(to: &serializer) } } } diff --git a/Sources/SQLKit/Expressions/Syntax/SQLLiteral.swift b/Sources/SQLKit/Expressions/Syntax/SQLLiteral.swift index e1066f0c..04574400 100644 --- a/Sources/SQLKit/Expressions/Syntax/SQLLiteral.swift +++ b/Sources/SQLKit/Expressions/Syntax/SQLLiteral.swift @@ -1,40 +1,66 @@ -/// Literal expression value, i.e., `DEFAULT`, `FALSE`, `42`, etc. +/// A fundamental syntactical expression - one of several various kinds of literal SQL expressions. public enum SQLLiteral: SQLExpression { - /// * + /// The `*` symbol, when used as a column name (but _not_ when used as the multiplication operator), + /// meaning "all columns". case all - /// Creates a new ``SQLLiteral`` from a string. - case string(String) - - /// Creates a new ``SQLLiteral`` from a numeric string (no quotes). - case numeric(String) + /// A literal expression representing the current dialect's equivalent of the `DEFAULT` keyword. + /// + /// > Note: There isn't really any reason for this to be a literal with special handling, especially since there + /// > aren't any dialects which don't use `DEFAULT` as their ``SQLDialect/literalDefault-4l1ox`` but it's + /// > long-standing public API. + case `default` - /// Creates a new null ``SQLLiteral``, i.e., `NULL`. + /// A literal expression representing a `NULL` SQL value in the current dialect. + /// + /// > Note: This makes more sense as a literal; although `NULL` is a keyword, it nonetheless represents a + /// > specific literal value. case null - /// Creates a new default ``SQLLiteral`` literal, i.e., `DEFAULT` or sometimes `NULL`. - case `default` - - /// Creates a new boolean ``SQLLiteral``, i.e., `FALSE` or sometimes `0`. + /// A literal expression representing a boolean literal in the current dialect. case boolean(Bool) + /// A literal expression representing a numeric literal in the current dialect. + /// + /// Because the range of supported numeric types between SQL dialects is extremely wide, and that range rarely + /// at best overlaps cleanyl with Swift's numeric type support, numeric literals are specified using their + /// stringified representations. + case numeric(String) + + /// A literal expression representing a literal string in the current dialect. + /// + /// Literal strings undergo quoting and escaping in exactly the same fashion described by ``SQLIdentifier``, + /// except the dialect's ``SQLDialect/literalStringQuote-2vqlo`` is used. + case string(String) + + // See `SQLExpression.serialize(to:)`. @inlinable public func serialize(to serializer: inout SQLSerializer) { switch self { case .all: serializer.write("*") - case .string(let string): - serializer.dialect.literalStringQuote.serialize(to: &serializer) - serializer.write(string) - serializer.dialect.literalStringQuote.serialize(to: &serializer) - case .numeric(let numeric): - serializer.write(numeric) - case .null: - serializer.write("NULL") + case .default: serializer.dialect.literalDefault.serialize(to: &serializer) + + case .null: + serializer.write("NULL") + case .boolean(let bool): serializer.dialect.literalBoolean(bool).serialize(to: &serializer) + + case .numeric(let numeric): + serializer.write(numeric) + + case .string(let string): + /// See ``SQLIdentifier/serialize(to:)`` for a discussion on why this is written the way it is. + if let rawQuote = (serializer.dialect.literalStringQuote as? SQLRaw)?.sql { + serializer.write("\(rawQuote)\(string.sqlkit_replacing(rawQuote, with: "\(rawQuote)\(rawQuote)"))\(rawQuote)") + } else { + serializer.dialect.literalStringQuote.serialize(to: &serializer) + serializer.write(string) + serializer.dialect.literalStringQuote.serialize(to: &serializer) + } } } } diff --git a/Sources/SQLKit/Expressions/Syntax/SQLQueryString.swift b/Sources/SQLKit/Expressions/Syntax/SQLQueryString.swift deleted file mode 100644 index 52a5dfa1..00000000 --- a/Sources/SQLKit/Expressions/Syntax/SQLQueryString.swift +++ /dev/null @@ -1,161 +0,0 @@ -public struct SQLQueryString { - @usableFromInline - var fragments: [any SQLExpression] - - /// Create a query string from a plain string containing raw SQL. - @inlinable - public init(_ string: S) { - self.fragments = [SQLRaw(string.description)] - } -} - -extension SQLQueryString: ExpressibleByStringLiteral { - /// See `ExpressibleByStringLiteral.init(stringLiteral:)` - @inlinable - public init(stringLiteral value: String) { - self.init(value) - } -} - -extension SQLQueryString: ExpressibleByStringInterpolation { - /// See `ExpressibleByStringInterpolation.init(stringInterpolation:)` - @inlinable - public init(stringInterpolation: SQLQueryString) { - self.fragments = stringInterpolation.fragments - } -} - -extension SQLQueryString: StringInterpolationProtocol { - /// See `StringInterpolationProtocol.init(literalCapacity:interpolationCount:)` - @inlinable - public init(literalCapacity: Int, interpolationCount: Int) { - self.fragments = [] - } - - /// Adds raw SQL to the string. Despite the use of the term "literal" dictated by the interpolation protocol, this - /// produces `SQLRaw` content, _not_ SQL string literals. - @inlinable - public mutating func appendLiteral(_ literal: String) { - self.fragments.append(SQLRaw(literal)) - } - - /// Adds an interpolated string of raw SQL. Despite the use of the term "literal" dictated by the interpolation - /// protocol, this produces `SQLRaw` content, _not_ SQL string literals. - @available(*, deprecated, message: "Use 'raw' label") - @inlinable - public mutating func appendInterpolation(_ literal: String) { - self.fragments.append(SQLRaw(literal)) - } - - /// Adds an interpolated string of raw SQL. Despite the use of the term "literal" dictated by the interpolation - /// protocol, this produces `SQLRaw` content, _not_ SQL string literals. - @inlinable - public mutating func appendInterpolation(raw value: String) { - self.fragments.append(SQLRaw(value.description)) - } - - /// Embed an ``Encodable`` value as a binding in the SQL query. - @inlinable - public mutating func appendInterpolation(bind value: any Encodable) { - self.fragments.append(SQLBind(value)) - } - - /// Embed multiple ``Encodable`` values as bindings in the SQL query, separating the bind placeholders with commas. - /// Most commonly useful when working with the `IN` operator. - @inlinable - public mutating func appendInterpolation(binds values: [any Encodable]) { - self.fragments.append(SQLList(values.map(SQLBind.init))) - } - - /// Embed an integer as a literal value, as if via `SQLLiteral.numeric()` - /// Use this preferentially to ensure values are appropriately represented in the database's dialect. - @inlinable - public mutating func appendInterpolation(literal: I) { - self.fragments.append(SQLLiteral.numeric("\(literal)")) - } - - /// Embed a `Bool` as a literal value, as if via `SQLLiteral.boolean()` - @inlinable - public mutating func appendInterpolation(_ value: Bool) { - self.fragments.append(SQLLiteral.boolean(value)) - } - - /// Embed a `String` as a literal value, as if via `SQLLiteral.string()` - /// Use this preferentially to ensure string values are appropriately represented in the database's dialect. - @inlinable - public mutating func appendInterpolation(literal: String) { - self.fragments.append(SQLLiteral.string(literal)) - } - - /// Embed an array of `Strings` as a list of literal values, using the `joiner` to separate them. - /// - /// Example: - /// - /// "SELECT \(literals: "a", "b", "c", "d", joinedBy: "||") FROM nowhere" - /// - /// Rendered by the SQLite dialect: - /// - /// SELECT 'a'||'b'||'c'||'d' FROM nowhere - @inlinable - public mutating func appendInterpolation(literals: [String], joinedBy joiner: String) { - self.fragments.append(SQLList(literals.map(SQLLiteral.string(_:)), separator: SQLRaw(joiner))) - } - - /// Embed a `String` as an SQL identifier, as if with `SQLIdentifier` - /// Use this preferentially to ensure table names, column names, and other non-keyword identifiers are appropriately - /// represented in the database's dialect. - @inlinable - public mutating func appendInterpolation(ident: String) { - self.fragments.append(SQLIdentifier(ident)) - } - - /// Embed an array of `Strings` as a list of SQL identifiers, using the `joiner` to separate them. - /// - /// - Important: This interprets each string as an identifier, _not_ as a literal value! - /// - /// Example: - /// - /// "SELECT \(idents: "a", "b", "c", "d", joinedBy: ",") FROM \(ident: "nowhere")" - /// - /// Rendered by the SQLite dialect: - /// - /// SELECT "a", "b", "c", "d" FROM "nowhere" - @inlinable - public mutating func appendInterpolation(idents: [String], joinedBy joiner: String) { - self.fragments.append(SQLList(idents.map(SQLIdentifier.init(_:)), separator: SQLRaw(joiner))) - } - - /// Embed any `SQLExpression` into the string, to be serialized according to its type. - @inlinable - public mutating func appendInterpolation(_ expression: any SQLExpression) { - self.fragments.append(expression) - } -} - -extension SQLQueryString { - @inlinable - public static func +(lhs: SQLQueryString, rhs: SQLQueryString) -> SQLQueryString { - return "\(lhs)\(rhs)" - } - - @inlinable - public static func +=(lhs: inout SQLQueryString, rhs: SQLQueryString) { - lhs.fragments.append(contentsOf: rhs.fragments) - } -} - -extension Array where Element == SQLQueryString { - @inlinable - public func joined(separator: String) -> SQLQueryString { - let separator = "\(raw: separator)" as SQLQueryString - return self.first.map { self.dropFirst().lazy.reduce($0) { $0 + separator + $1 } } ?? "" - } -} - -extension SQLQueryString: SQLExpression { - /// See ``SQLExpression/serialize(to:)``. - @inlinable - public func serialize(to serializer: inout SQLSerializer) { - self.fragments.forEach { $0.serialize(to: &serializer) } - } -} diff --git a/Sources/SQLKit/Expressions/Syntax/SQLRaw.swift b/Sources/SQLKit/Expressions/Syntax/SQLRaw.swift index 3ca98435..da0faa71 100644 --- a/Sources/SQLKit/Expressions/Syntax/SQLRaw.swift +++ b/Sources/SQLKit/Expressions/Syntax/SQLRaw.swift @@ -1,16 +1,39 @@ +/// A fundamental syntactical expression - an arbitrary string of raw SQL with no escaping or formating of any kind. +/// +/// Users should almost never need to use ``SQLRaw`` directly; there is almost always a better/safer/more specific +/// expression available for any given purpose. The most common use for ``SQLRaw`` by end users is to represent SQL +/// keywords specific to a dialect, such as `SQLRaw("EXPLAIN VERBOSE")`. +/// +/// In effect, ``SQLRaw`` is nothing but a wrapper which makes `String`s into ``SQLExpression``s, since conforming +/// `String` directly to the protocol would cause numerous issues with SQLKit's existing public API (yet another design +/// flaw). In the past, ``SQLRaw`` was intended to also contain bound values to be serialized with the text, but this +/// functionality was never implemented fully and is now entirely defunct. +/// +/// > Note: Just to add further insult to injury, ``SQLRaw`` is entirely redundant in the presence of +/// > ``SQLQueryString`` and ``SQLStatement``, but is used so pervasively that it cannot reasonably be deprecated. public struct SQLRaw: SQLExpression { + /// The raw SQL text serialized by this expression. public var sql: String - public var binds: [Encodable] + + /// Legacy property specifying bound values. This property's value is **IGNORED**. + /// + /// The original intention was that bindings set in this property be serialized along with the SQL text, but this + /// functionality was never properly implemented and was never used, and is deprecated. Use ``SQLBind`` and/or + /// ``SQLQueryString`` to achieve the same effect. + @available(*, deprecated, message: "Binds set in an `SQLRaw` are ignored. Use `SQLBind` instead.") + public var binds: [any Encodable & Sendable] = [] + /// Create a new raw SQL text expression. + /// + /// - Parameter sql: The raw SQL text to serialize. @inlinable - public init(_ sql: String, _ binds: [Encodable] = []) { + public init(_ sql: String) { self.sql = sql - self.binds = binds } - + + // See `SQLExpression.serialize(to:)`. @inlinable public func serialize(to serializer: inout SQLSerializer) { serializer.write(self.sql) - serializer.binds += self.binds } } diff --git a/Sources/SQLKit/Rows/SQLCodingUtilities.swift b/Sources/SQLKit/Rows/SQLCodingUtilities.swift new file mode 100644 index 00000000..92134767 --- /dev/null +++ b/Sources/SQLKit/Rows/SQLCodingUtilities.swift @@ -0,0 +1,239 @@ +/// Errors raised by ``SQLRowDecoder`` and ``SQLQueryEncoder``. +@_spi(CodableUtilities) +public enum SQLCodingError: Error, CustomStringConvertible, Sendable { + /// An attempt was made to invoke one of the forbidden coding methods, or a restricted coding method in an + /// unsupported context, during query encoding or row decoding. + /// + /// The following methods are always usupported: + /// + /// - `Encoder.unkeyedContainer()` + /// - `Decoder.unkeyedContainer()` + /// - `KeyedEncodingContainer.nestedContainer(keyedBy:forKey:)` + /// - `KeyedEncodingContainer.nestedUnkeyedContainer(forKey:)` + /// - `KeyedEncodingContainer.superEncoder()` + /// - `KeyedDecodingContainer.nestedContainer(keyedBy:forKey:)` + /// - `KeyedDecodingContainer.nestedUnkeyedContainer(forKey:)` + /// - `KeyedDecodingContainer.superDecoder()` + /// - Any use of `UnkeyedEncodingContainer` + /// - Any use of `UnkeyedDecodingContainer` + /// + /// The following methods are unsupported unless the current coding path is empty: + /// + /// - `Encoder.container(keyedBy:)` + /// - `Decoder.container(keyedBy:)` + /// - `KeyedEncodingContainer.superEncoder(forKey:)` + /// - `KeyedDecodingContainer.superDecoder(forKey:)` + case unsupportedOperation(String, codingPath: [any CodingKey]) + + // See `CustomStringConvertible.description`. + public var description: String { + switch self { + case .unsupportedOperation(let operation, codingPath: let path): + return "Value at path '\(path.map(\.stringValue).joined(separator: "."))' attempted an unsupported operation: '\(operation)'" + } + } +} + +@_spi(CodableUtilities) +extension Error where Self == SQLCodingError { + /// Yield a ``SQLCodingError/unsupportedOperation(_:codingPath:)`` for the given operation and path. + public static func invalid(_ function: String = #function, at path: [any CodingKey]) -> Self { + .unsupportedOperation(function, codingPath: path) + } +} + +/// A `CodingKey` which can't be successfully initialized and never holds a value. +/// +/// Used as a placeholder by ``FailureEncoder``. +@_spi(CodableUtilities) +public struct NeverKey: CodingKey { + // See `CodingKey.stringValue`. + public let stringValue: String = "" + + // See `CodingKey.intValue`. + public let intValue: Int? = nil + + // See `CodingKey.init(stringValue:)`. + public init?(stringValue: String) { + nil + } + + // See `CodingKey.init?(intValue:)`. + public init?(intValue: Int) { + nil + } +} + +/// An encoder which throws a predetermined error from every method which can throw and recurses back to itself from +/// everything else. +/// +/// This type functions as a workaround for the inability of encoders to throw errors from various places that it +/// would otherwise be useful to throw errors from. +/// +/// > Besides: It's still better than calling `fatalError()`. +@_spi(CodableUtilities) +public struct FailureEncoder: Encoder, KeyedEncodingContainerProtocol, UnkeyedEncodingContainer, SingleValueEncodingContainer { + let error: any Error + public init(_ error: any Error) { self.error = error } + public init(_ error: any Error) where K == NeverKey { self.error = error } + public var codingPath: [any CodingKey] { [] } + public var userInfo: [CodingUserInfoKey: Any] { [:] } + public var count: Int { 0 } + public func encodeNil() throws { throw self.error } + public func encodeNil(forKey: K) throws { throw self.error } + public func encode(_: some Encodable) throws { throw self.error } + public func encode(_: some Encodable, forKey: K) throws { throw self.error } + public func superEncoder() -> any Encoder { self } + public func superEncoder(forKey: K) -> any Encoder { self } + public func unkeyedContainer() -> any UnkeyedEncodingContainer { self } + public func nestedUnkeyedContainer() -> any UnkeyedEncodingContainer { self } + public func nestedUnkeyedContainer(forKey: K) -> any UnkeyedEncodingContainer { self } + public func singleValueContainer() -> any SingleValueEncodingContainer { self } + public func container(keyedBy: N.Type = N.self) -> KeyedEncodingContainer { .init(FailureEncoder(self.error)) } + public func nestedContainer(keyedBy: N.Type) -> KeyedEncodingContainer { self.container() } + public func nestedContainer(keyedBy: N.Type, forKey: K) -> KeyedEncodingContainer { self.container() } +} + +@_spi(CodableUtilities) +extension Encoder where Self == FailureEncoder { + /// Yield a ``FailureEncoder`` which throws ``SQLCodingError/unsupportedOperation(_:codingPath:)`` from a context + /// which expects an `Encoder`. + public static func invalid(_ f: String = #function, at: [any CodingKey]) -> Self { + .init(.invalid(f, at: at)) + } +} + +@_spi(CodableUtilities) +extension KeyedEncodingContainer { + /// Yield a ``FailureEncoder`` which throws ``SQLCodingError/unsupportedOperation(_:codingPath:)`` from a context + /// which expects a `KeyedEncodingContainer`. + public static func invalid(_ f: String = #function, at: [any CodingKey]) -> Self { + .init(FailureEncoder(.invalid(f, at: at))) + } +} + +@_spi(CodableUtilities) +extension UnkeyedEncodingContainer where Self == FailureEncoder { + /// Yield a ``FailureEncoder`` which throws ``SQLCodingError/unsupportedOperation(_:codingPath:)`` from a context + /// which expects an `UnkeyedEncodingContainer`. + public static func invalid(_ f: String = #function, at: [any CodingKey]) -> Self { + .init(.invalid(f, at: at)) + } +} + +extension DecodingError { + /// Return the same error with its context modified to have the given coding path prepended. + func under(path: [any CodingKey]) -> Self { + switch self { + case let .valueNotFound(type, context): + return .valueNotFound(type, context.with(prefix: path)) + case let .dataCorrupted(context): + return .dataCorrupted(context.with(prefix: path)) + case let .typeMismatch(type, context): + return .typeMismatch(type, context.with(prefix: path)) + case let .keyNotFound(key, context): + return .keyNotFound(key, context.with(prefix: path)) + @unknown default: return self + } + } +} + +extension DecodingError.Context { + /// Return the same context with the given coding path prepended. + fileprivate func with(prefix: [any CodingKey]) -> Self { + .init( + codingPath: prefix + self.codingPath, + debugDescription: self.debugDescription, + underlyingError: self.underlyingError + ) + } +} + +/// A helper used to pass `Encodable` but non-`Sendable` values provided by the `Encoder` API to +/// ``SQLBind/init(_:)``, which requires `Sendable` conformance, without warnings. +/// +/// As a side effect of the way this type is implemented, it can be used as a general "pretend things are +/// `Sendable` when they're not" wrapper. As with NIO's `UnsafeTransfer` type, this disables the compiler's +/// checking entirely and should be used as sparingly as possible. +/// +/// > Note: This wrapper more often than not ends up wrapping values of types that _are_ in fact `Sendable`, +/// > but that can't be treated as such because of the limitations of `Codable`'s design and the inability +/// > to check for `Sendable` conformance at runtime. +/// +/// In addition to `Encodable` conformance, this type provides passthrough `Decodable`, `Equatable`, `Hashable`, +/// `CustomStringConvertible`, and `CustomDebugStringConvertible` implementations when the corresponding +/// conformances exist on the underlying value's type. +@_spi(CodableUtilities) +public struct FakeSendableCodable: @unchecked Sendable { + /// The underlying non-`Sendable` value. + public let value: T + + /// Trivial initializer. + @inlinable + public init(_ value: T) { + self.value = value + } +} + +/// Include `Encodable` conformance for ``FakeSendableCodable`` when available. +extension FakeSendableCodable: Encodable where T: Encodable { + // See `Encodable.encode(to:)`. + public func encode(to encoder: any Encoder) throws { + /// It is important to encode the desired value into a single-value container rather than invoking its + /// `encode(to:)` method directly, so that any type-specific logic within the encoder itself (such as + /// that found in `JSONEncoder` for `Date`, `Data`, etc.) takes effect. In essence, the encoder must have + /// the opportunity to intercept the value and its type. With `SQLQueryEncoder`, this makes the difference + /// between ``FakeSendableCodable`` being fully transparent versus not. + var container = encoder.singleValueContainer() + + try container.encode(self.value) + } +} + +/// Include `Decodable` conformance for ``FakeSendableCodable`` when available. +extension FakeSendableCodable: Decodable where T: Decodable { + // See `Decodable.init(from:)`. + public init(from decoder: any Decoder) throws { + /// As with the `Encodable` conformance, decode from a single-value container rather than directly so any + /// type-specific logic in the decoder is able to take effect. + let container = try decoder.singleValueContainer() + + self.value = try container.decode(T.self) + } +} + +/// Include `Equatable` conformance for ``FakeSendableCodable`` when available. +extension FakeSendableCodable: Equatable where T: Equatable { + // See `Equatable.==(_:_:)`. + @inlinable + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.value == rhs.value + } +} + +/// Include `Hashable` conformance for ``FakeSendableCodable`` when available. +extension FakeSendableCodable: Hashable where T: Hashable { + // See `Hashable.hash(into:)`. + @inlinable + public func hash(into hasher: inout Hasher) { + hasher.combine(self.value) + } +} + +/// Include `CustomStringConvertible` conformance for ``FakeSendableCodable`` when available. +extension FakeSendableCodable: CustomStringConvertible where T: CustomStringConvertible { + // See `CustomStringConvertible.description`. + @inlinable + public var description: String { + self.value.description + } +} + +/// Include `CustomDebugStringConvertible` conformance for ``FakeSendableCodable`` when available. +extension FakeSendableCodable: CustomDebugStringConvertible where T: CustomDebugStringConvertible { + // See `CustomDebugStringConvertible.debugDescription`. + @inlinable + public var debugDescription: String { + self.value.debugDescription + } +} diff --git a/Sources/SQLKit/Rows/SQLQueryEncoder.swift b/Sources/SQLKit/Rows/SQLQueryEncoder.swift index 4cad2671..60c143b6 100644 --- a/Sources/SQLKit/Rows/SQLQueryEncoder.swift +++ b/Sources/SQLKit/Rows/SQLQueryEncoder.swift @@ -1,159 +1,429 @@ -public struct SQLQueryEncoder { - public enum NilEncodingStrategy { - /// Skips nilable columns with nil values during encoding. +import struct OrderedCollections.OrderedDictionary + +/// An implementation of `Encoder` designed to encode "models" (or, in general, aggregate `Encodable` types) into a +/// form which can be used as input to a database query. +/// +/// At present, there is no "input"-capable equivalent of an ``SQLRow``, so this encoder returns a somewhat awkward +/// array of "column name"/"value expression" pairs. +/// +/// This type is, somewhat confusingly, designed primarily for use with methods such as +/// +/// - ``SQLInsertBuilder``: +/// - ``SQLInsertBuilder/model(_:prefix:keyEncodingStrategy:nilEncodingStrategy:userInfo:)`` +/// - ``SQLInsertBuilder/model(_:with:)`` +/// - ``SQLInsertBuilder/models(_:prefix:keyEncodingStrategy:nilEncodingStrategy:userInfo:)`` +/// - ``SQLInsertBuilder/models(_:with:)`` +/// - ``SQLColumnUpdateBuilder``: +/// - ``SQLColumnUpdateBuilder/set(model:prefix:keyEncodingStrategy:nilEncodingStrategy:userInfo:)`` +/// - ``SQLColumnUpdateBuilder/set(model:with:)`` +/// - ``SQLConflictUpdateBuilder``: +/// - ``SQLConflictUpdateBuilder/set(excludedContentOf:prefix:keyEncodingStrategy:nilEncodingStrategy:userInfo:)`` +/// - ``SQLConflictUpdateBuilder/set(excludedContentOf:with:)`` +/// +/// It can also be manually invoked. For example: +/// +/// ```swift +/// struct MySimpleUserModel: Codable { +/// var id: Int? = nil +/// var username: String +/// var passwordHash: [UInt8] +/// var email: String? +/// var createdAt: Date +/// } +/// +/// let users: [MySimpleUserModel] = [ +/// .init(username: "johndoe", passwordHash: (0..<32).random(in: .min ... .max), email: "foo@bar.com", createdAt: .init()), +/// .init(username: "janedoe", passwordHash: (0..<32).random(in: .min ... .max), email: nil, createdAt: .init()), +/// ] +/// +/// // Direct usage (not recommended): +/// let encoder = SQLQueryEncoder(keyEncodingStrategy: .convertToSnakeCase, nilEncodingStrategy: .asNil) +/// let rows = try users.map { user in try encoder.encode(user) } +/// let query = sqlDatabase +/// .insert(into: "my_simple_users") +/// .columns(rows[0].map(\.0)) +/// for row in rows { +/// query.values(row.map(\.1)) +/// } +/// try await query.run() +/// +/// // Invoked via SQLInsertBuilder and SQLConflictUpdateBuilder: +/// let encoder = SQLQueryEncoder(keyEncodingStrategy: .convertToSnakeCase, nilEncodingStrategy: .asNil) +/// try await sqlDatabase.insert(into: "my_simple_users") +/// .models(users, with: encoder) +/// .onConflict { $0.set(excludedContentOf: users[0], with: encoder) } +/// .run() +/// +/// // Invoked via SQLUpdateBuilder: +/// try await sqlDatabase.update("my_simple_users") +/// .set(model: users[0], keyEncodingStrategy: .convertToSnakeCase, nilEncodingStrategy: .asNil) +/// .where("id", .equal, SQLBind(1)) +/// .run() +/// ``` +public struct SQLQueryEncoder: Sendable { + /// A strategy describing the desired encoding of `nil` input values. + public enum NilEncodingStrategy: Sendable { + /// Encode nothing at all for columns with `nil` values. case `default` - /// Encodes nilable columns with nil values as nil. Useful when using `SQLInsertBuilder` to insert `Codable` models without Fluent + + /// Encode an explicit `NULL` value for columns with `nil` values. + /// + /// Intended for use with ``SQLInsertBuilder/model(_:prefix:keyEncodingStrategy:nilEncodingStrategy:userInfo:)`` + /// and ``SQLInsertBuilder/models(_:prefix:keyEncodingStrategy:nilEncodingStrategy:userInfo:)``. case asNil } - public enum KeyEncodingStrategy { - /// A key encoding strategy that doesn't change key names during encoding. + /// A strategy describing how to transform individual keys into encoded column names. + public enum KeyEncodingStrategy: Sendable { + /// Use input keys unmodified. This is the default strategy. case useDefaultKeys - /// A key encoding strategy that converts camel-case keys to snake-case keys. - case convertToSnakeCase - case custom(([any CodingKey]) -> any CodingKey) - } - public var prefix: String? = nil - public var keyEncodingStrategy: KeyEncodingStrategy = .useDefaultKeys - public var nilEncodingStrategy: NilEncodingStrategy = .default + /// Convert from `camelCaseKeys` to `snake_case_keys` before writing a key to a row. + /// + /// Capital characters are determined by testing `Character.isUppercase`. + /// + /// Converting from camel case to snake case: + /// + /// 1. Splits words at the boundary of lower-case to upper-case. + /// 2. Inserts `_` between words. + /// 3. Lowercases the entire string. + /// 4. Preserves starting and ending `_`. + /// + /// For example, `oneTwoThree` becomes `one_two_three`. `_oneTwoThree_` becomes `_one_two_three_`. + /// + /// > Note: Using a key encoding strategy has a nominal performance cost, as each string key has + /// > to be converted. + case convertToSnakeCase - public init() { - self.init(prefix: nil, keyEncodingStrategy: .useDefaultKeys, nilEncodingStrategy: .default) + /// Provide a custom conversion to the key in the encoded row from the keys specified by the + /// encoded types. + /// + /// The full path to the current encoding position is provided for context (in case you need to + /// locate this key within the payload). The returned key is used in place of the last component + /// in the coding path before encoding. + /// + /// If the result of the conversion is a duplicate key, then only one value will be present in + /// the result. + @preconcurrency + case custom(@Sendable (_ codingPath: [any CodingKey]) -> any CodingKey) + + /// Apply the strategy to the given coding key, returning the transformed result. + /// + /// This is a _forward_ transformation, converting a coding key from the provided model type to a column + /// name which will be stored in the database. + fileprivate func apply(to name: any CodingKey) -> String { + switch self { + case .useDefaultKeys: + return name.stringValue + case .convertToSnakeCase: + return name.stringValue.convertedToSnakeCase + case .custom(let closure): + return closure([name]).stringValue + } + } } - init(prefix: String?, keyEncodingStrategy: KeyEncodingStrategy, nilEncodingStrategy: NilEncodingStrategy) { + /// A prefix to be added to keys when encoding column names. + /// + /// The ``prefix``, if set, is applied _after_ the ``keyEncodingStrategy-swift.property``. + /// + /// Example: + /// + /// Prefix|Strategy|Coding key|Column name + /// -|-|-|- + /// _nil_|``KeyEncodingStrategy-swift.enum/useDefaultKeys``|`FooBar`|`FooBar` + /// `p`|``KeyEncodingStrategy-swift.enum/useDefaultKeys``|`FooBar`|`pFooBar` + /// _nil_|``KeyEncodingStrategy-swift.enum/convertToSnakeCase``|`FooBar`|`foo_bar` + /// `p`|``KeyEncodingStrategy-swift.enum/convertToSnakeCase``|`FooBar`|`pfoo_bar` + public var prefix: String? + + /// The key encoding strategy to use. + /// + /// The ``prefix``, if set, is applied _after_ the ``keyEncodingStrategy-swift.property``. + /// + /// Example: + /// + /// Prefix|Strategy|Coding key|Column name + /// -|-|-|- + /// _nil_|``KeyEncodingStrategy-swift.enum/useDefaultKeys``|`FooBar`|`FooBar` + /// `p`|``KeyEncodingStrategy-swift.enum/useDefaultKeys``|`FooBar`|`pFooBar` + /// _nil_|``KeyEncodingStrategy-swift.enum/convertToSnakeCase``|`FooBar`|`foo_bar` + /// `p`|``KeyEncodingStrategy-swift.enum/convertToSnakeCase``|`FooBar`|`pfoo_bar` + public var keyEncodingStrategy: KeyEncodingStrategy + + /// The `nil` value encoding strategy to use. + public var nilEncodingStrategy: NilEncodingStrategy + + /// User info to provide to the underlying `Encoder`. + public var userInfo: [CodingUserInfoKey: any Sendable] + + /// Create a configured ``SQLQueryEncoder``. + /// + /// - Parameters: + /// - prefix: The key prefix to use for column names. Defaults to none. See ``prefix`` for details. + /// - keyEncodingStrategy: The key encoding strategy used for translating coding keys to column names. Defaults + /// to ``KeyEncodingStrategy-swift.enum/useDefaultKeys``. See ``keyEncodingStrategy-swift.property`` for + /// details. + /// - nilEncodingStrategy: The strategy used for encoding `nil` values. Defaults to + /// ``NilEncodingStrategy-swift.enum/default``. See ``nilEncodingStrategy-swift.property`` for details. + /// - userInfo: Key-value pairs to provide as user info to the underlying encoder. Defaults to none. + public init( + prefix: String? = nil, + keyEncodingStrategy: KeyEncodingStrategy = .useDefaultKeys, + nilEncodingStrategy: NilEncodingStrategy = .default, + userInfo: [CodingUserInfoKey: any Sendable] = [:] + ) { self.prefix = prefix self.keyEncodingStrategy = keyEncodingStrategy self.nilEncodingStrategy = nilEncodingStrategy + self.userInfo = userInfo } - public func encode(_ encodable: E) throws -> [(String, any SQLExpression)] { - let encoder = _Encoder(options: options) + /// Encode an `Encodable` value to an array of key/expression pairs suitable for use as input to + /// ``SQLInsertBuilder/values(_:)-1pro8``, ``SQLUpdateBuilder/set(_:to:)-8mvob``, and other related APIs. + /// + /// - Parameter encodable: The value to encode. + /// - Returns: A sequence of (column name, value expression) pairs representing an output row. The order of the + /// results is considered significant, although it will rarely be meaningful in practice. + public func encode(_ encodable: some Encodable) throws -> [(String, any SQLExpression)] { + let encoder = SQLQueryEncoderImpl(configuration: self, output: .init()) + try encodable.encode(to: encoder) - return encoder.row + return encoder.output.row.map { $0 } } - fileprivate struct _Options { - let prefix: String? - let keyEncodingStrategy: KeyEncodingStrategy - let nilEncodingStrategy: NilEncodingStrategy - } + /// Underlying implementation. + private struct SQLQueryEncoderImpl: Encoder { + /// A trivial reference-type wrapper around `OrderedDictionary`. + final class Output { + var row: OrderedDictionary = [:] + } + + /// Holds configuration information for the encoding process. + let configuration: SQLQueryEncoder + + /// Holds a reference, shared with any subencoders, to the final encoded output. + var output: Output + + // See `Encoder.codingPath`. + var codingPath: [any CodingKey] = [] + + // See `Encoder.userInfo`. + var userInfo: [CodingUserInfoKey: Any] { + self.configuration.userInfo + } - /// The options set on the top-level decoder. - fileprivate var options: _Options { - _Options( - prefix: self.prefix, - keyEncodingStrategy: self.keyEncodingStrategy, - nilEncodingStrategy: self.nilEncodingStrategy) - } -} + // See `Encoder.container(keyedBy:)`. + func container(keyedBy: Key.Type) -> KeyedEncodingContainer { + /// If the coding path is not empty, we have reached this point via `superEncoder(forKey:)`, from which + /// the only valid request is for a single-value container. Since this method cannot throw directly, + /// return a ``FailureEncoder`` which will throw the error at the earliest possible opportunity. + guard self.codingPath.isEmpty else { + return .invalid(at: self.codingPath) + } + + /// Otherwise, a keyed container request is valid. + return .init(KeyedContainer(encoder: self)) + } -private final class _Encoder: Encoder { - fileprivate let options: SQLQueryEncoder._Options - var row: [(String, any SQLExpression)] = [] - var codingPath: [any CodingKey] { [] } - var userInfo: [CodingUserInfoKey: Any] { [:] } + // See `Encoder.unkeyedContainer()`. + func unkeyedContainer() -> any UnkeyedEncodingContainer { + /// It is never valid to request an unkeyed container in the current implementation. In a design having + /// differing public API, a row could be conceivably treated as an unkeyed container if written in terms + /// of column indexes rather than column names. Since this method cannot throw directly, return a + /// ``FailureEncoder`` which will throw the error at the earliest possible opportunity. + .invalid(at: self.codingPath) + } - init(options: SQLQueryEncoder._Options) { self.options = options } + // See `Encoder.singleValueContainer()`. + func singleValueContainer() -> any SingleValueEncodingContainer { + SingleValueContainer(encoder: self) + } - func container(keyedBy: Key.Type) -> KeyedEncodingContainer { - switch options.nilEncodingStrategy { - case .asNil: return KeyedEncodingContainer(_NilColumnKeyedEncoder(encoder: self)) - case .default: return KeyedEncodingContainer(_KeyedEncoder(encoder: self)) + /// Store a given expression in the output using the transformed column name for a given key. + /// + /// Setting the same input key (and thus output column name) more than once overwrites previous values. + private func set(_ expr: any SQLExpression, forKey key: some CodingKey) { + self.output.row["\(self.configuration.prefix ?? "")\(self.configuration.keyEncodingStrategy.apply(to: key))"] = expr } - } - func unkeyedContainer() -> any UnkeyedEncodingContainer { fatalError() } - func singleValueContainer() -> any SingleValueEncodingContainer { fatalError() } - - struct _NilColumnKeyedEncoder: KeyedEncodingContainerProtocol { - var codingPath: [any CodingKey] { self.encoder.codingPath } - let encoder: _Encoder - func column(for key: Key) -> String { - var encodedKey = key.stringValue - switch self.encoder.options.keyEncodingStrategy { - case .useDefaultKeys: break - case .convertToSnakeCase: encodedKey = _convertToSnakeCase(encodedKey) - case .custom(let customKeyEncodingFunc): encodedKey = customKeyEncodingFunc([key]).stringValue + + /// An implementation of `KeyedEncodingContainerProtocol` for ``SQLQueryEncoderImpl``. + private struct KeyedContainer: KeyedEncodingContainerProtocol { + // See `KeyedEncodingContainerProtocol.codingPath`. + var codingPath: [any CodingKey] { + self.encoder.codingPath } - if let prefix = self.encoder.options.prefix { return prefix + encodedKey } else { return encodedKey } - } - mutating func encodeNil(forKey key: Key) throws { self.encoder.row.append((self.column(for: key), SQLLiteral.null)) } - mutating func encode(_ value: T, forKey key: Key) throws { - self.encoder.row.append((self.column(for: key), (value as? SQLExpression) ?? SQLBind(value))) - } - mutating func _encodeIfPresent(_ value: T?, forKey key: Key) throws { - if let value = value { try encode(value, forKey: key) } else { try encodeNil(forKey: key) } - } - mutating func encodeIfPresent(_ value: T?, forKey key: Key) throws { try _encodeIfPresent(value, forKey: key)} - mutating func encodeIfPresent(_ value: Int?, forKey key: Key) throws { try _encodeIfPresent(value, forKey: key) } - mutating func encodeIfPresent(_ value: Int8?, forKey key: Key) throws { try _encodeIfPresent(value, forKey: key) } - mutating func encodeIfPresent(_ value: Int16?, forKey key: Key) throws { try _encodeIfPresent(value, forKey: key) } - mutating func encodeIfPresent(_ value: Int32?, forKey key: Key) throws { try _encodeIfPresent(value, forKey: key) } - mutating func encodeIfPresent(_ value: Int64?, forKey key: Key) throws { try _encodeIfPresent(value, forKey: key) } - mutating func encodeIfPresent(_ value: UInt?, forKey key: Key) throws { try _encodeIfPresent(value, forKey: key) } - mutating func encodeIfPresent(_ value: UInt16?, forKey key: Key) throws { try _encodeIfPresent(value, forKey: key) } - mutating func encodeIfPresent(_ value: UInt32?, forKey key: Key) throws { try _encodeIfPresent(value, forKey: key) } - mutating func encodeIfPresent(_ value: UInt64?, forKey key: Key) throws { try _encodeIfPresent(value, forKey: key) } - mutating func encodeIfPresent(_ value: Double?, forKey key: Key) throws { try _encodeIfPresent(value, forKey: key) } - mutating func encodeIfPresent(_ value: Float?, forKey key: Key) throws { try _encodeIfPresent(value, forKey: key) } - mutating func encodeIfPresent(_ value: String?, forKey key: Key) throws { try _encodeIfPresent(value, forKey: key) } - mutating func encodeIfPresent(_ value: Bool?, forKey key: Key) throws { try _encodeIfPresent(value, forKey: key) } - mutating func nestedContainer(keyedBy: N.Type, forKey: Key) -> KeyedEncodingContainer { fatalError() } - mutating func nestedUnkeyedContainer(forKey: Key) -> any UnkeyedEncodingContainer { fatalError() } - mutating func superEncoder() -> any Encoder { self.encoder } - mutating func superEncoder(forKey key: Key) -> any Encoder { self.encoder } - } - struct _KeyedEncoder: KeyedEncodingContainerProtocol { - var codingPath: [CodingKey] { self.encoder.codingPath } - let encoder: _Encoder - func column(for key: Key) -> String { - var encodedKey = key.stringValue - switch self.encoder.options.keyEncodingStrategy { - case .useDefaultKeys: break - case .convertToSnakeCase: encodedKey = _convertToSnakeCase(encodedKey) - case .custom(let customKeyEncodingFunc): encodedKey = customKeyEncodingFunc([key]).stringValue + + /// Trivial helper to shorten the expression which checks the nil encoding strategy. + var nils: Bool { + self.encoder.configuration.nilEncodingStrategy == .asNil } - if let prefix = self.encoder.options.prefix { return prefix + encodedKey } else { return encodedKey } - } - mutating func encodeNil(forKey key: Key) throws { self.encoder.row.append((self.column(for: key), SQLLiteral.null)) } - mutating func encode(_ value: T, forKey key: Key) throws { - self.encoder.row.append((self.column(for: key), (value as? SQLExpression) ?? SQLBind(value))) - } - mutating func nestedContainer(keyedBy: N.Type, forKey: Key) -> KeyedEncodingContainer { fatalError() } - mutating func nestedUnkeyedContainer(forKey: Key) -> any UnkeyedEncodingContainer { fatalError() } - mutating func superEncoder() -> any Encoder { self.encoder } - mutating func superEncoder(forKey: Key) -> any Encoder { self.encoder } - } -} + + /// The encoder which created this container. + let encoder: SQLQueryEncoderImpl + + // See `KeyedEncodingContainerProtocol.encodeNil(forKey:)`. + mutating func encodeNil(forKey key: Key) throws { + /// Deliberately do _not_ check the ``SQLQueryEncoder/nilEncodingStrategy-swift.property`` here, + /// so that `encodeNil(forKey:)` may be used to encode explicit `NULL` values even when the strategy + /// in use requires skipping `nil` values. (This method is also invoked when the strategy calls for + /// explicitly encoding such values, making such a check redundant as well.) The strategy _is_ + /// respected by the `encodeIfPresent(_:forKey:)` methods. + self.encoder.set(SQLLiteral.null, forKey: key) + } + + /// We must provide the fourteen fundamental type overloads in order to elide the need for the + /// `FakeSendable` wrapper for those values, which saves a significant amount of unnecessary overhead. + + // See `KeyedEncodingContainerProtocol.encode(_:forKey:)`. + mutating func encode(_ value: Bool, forKey key: Key) throws { self.encoder.set(SQLBind(value), forKey: key) } + mutating func encode(_ value: String, forKey key: Key) throws { self.encoder.set(SQLBind(value), forKey: key) } + mutating func encode(_ value: Double, forKey key: Key) throws { self.encoder.set(SQLBind(value), forKey: key) } + mutating func encode(_ value: Float, forKey key: Key) throws { self.encoder.set(SQLBind(value), forKey: key) } + mutating func encode(_ value: Int, forKey key: Key) throws { self.encoder.set(SQLBind(value), forKey: key) } + mutating func encode(_ value: Int8, forKey key: Key) throws { self.encoder.set(SQLBind(value), forKey: key) } + mutating func encode(_ value: Int16, forKey key: Key) throws { self.encoder.set(SQLBind(value), forKey: key) } + mutating func encode(_ value: Int32, forKey key: Key) throws { self.encoder.set(SQLBind(value), forKey: key) } + mutating func encode(_ value: Int64, forKey key: Key) throws { self.encoder.set(SQLBind(value), forKey: key) } + mutating func encode(_ value: UInt, forKey key: Key) throws { self.encoder.set(SQLBind(value), forKey: key) } + mutating func encode(_ value: UInt8, forKey key: Key) throws { self.encoder.set(SQLBind(value), forKey: key) } + mutating func encode(_ value: UInt16, forKey key: Key) throws { self.encoder.set(SQLBind(value), forKey: key) } + mutating func encode(_ value: UInt32, forKey key: Key) throws { self.encoder.set(SQLBind(value), forKey: key) } + mutating func encode(_ value: UInt64, forKey key: Key) throws { self.encoder.set(SQLBind(value), forKey: key) } -private extension _Encoder { - /// This is a custom implementation which does not require Foundation as opposed to the one at which needs CharacterSet from Foundation https://github.com/apple/swift/blob/master/stdlib/public/Darwin/Foundation/JSONEncoder.swift - /// - /// Provide a custom conversion to the key in the encoded JSON from the keys specified by the encoded types. - /// The full path to the current encoding position is provided for context (in case you need to locate this key within the payload). The returned key is used in place of the last component in the coding path before encoding. - /// If the result of the conversion is a duplicate key, then only one value will be present in the result. - static func _convertToSnakeCase(_ stringKey: String) -> String { - guard !stringKey.isEmpty else { return stringKey } - enum Status { case uppercase, lowercase } - var status = Status.lowercase, snakeCasedString = "", i = stringKey.startIndex - while i < stringKey.endIndex { - let nextIndex = stringKey.index(after: i) - if stringKey[i].isUppercase { - switch status { - case .uppercase where nextIndex < stringKey.endIndex && stringKey[nextIndex].isLowercase, - .lowercase where i != stringKey.startIndex: - snakeCasedString.append("_") - default: break + // See `KeyedEncodingContainerProtocol.encode(_:forKey:)`. + mutating func encode(_ value: some Encodable, forKey key: Key) throws { + /// For generic `Encodable` values, we must forcibly silence the `Sendable` warning from ``SQLBind``. + self.encoder.set((value as? any SQLExpression) ?? SQLBind(FakeSendableCodable(value)), forKey: key) + } + + /// Because each `encodeIfPresent(_:forKey:)` method is given a default implementation by the + /// `KeyedEncodingContainerProtocol` protocol, all fourteen overloads must be overridden in order to + /// provide the desired semantics. The content of each overload also cannot be generalized in a generic + /// method because we need concrete dispatch for the fundamental types in order to avoid excess usage + /// of the `FakeSendable` wrapper. + + // See `KeyedEncodingContainerProtocol.encodeIfPresent(_:forKey:)`. + mutating func encodeIfPresent(_ val: Bool?, forKey key: Key) throws { if let val { try self.encode(val, forKey: key) } else if self.nils { try self.encodeNil(forKey: key) } } + mutating func encodeIfPresent(_ val: String?, forKey key: Key) throws { if let val { try self.encode(val, forKey: key) } else if self.nils { try self.encodeNil(forKey: key) } } + mutating func encodeIfPresent(_ val: Double?, forKey key: Key) throws { if let val { try self.encode(val, forKey: key) } else if self.nils { try self.encodeNil(forKey: key) } } + mutating func encodeIfPresent(_ val: Float?, forKey key: Key) throws { if let val { try self.encode(val, forKey: key) } else if self.nils { try self.encodeNil(forKey: key) } } + mutating func encodeIfPresent(_ val: Int?, forKey key: Key) throws { if let val { try self.encode(val, forKey: key) } else if self.nils { try self.encodeNil(forKey: key) } } + mutating func encodeIfPresent(_ val: Int8?, forKey key: Key) throws { if let val { try self.encode(val, forKey: key) } else if self.nils { try self.encodeNil(forKey: key) } } + mutating func encodeIfPresent(_ val: Int16?, forKey key: Key) throws { if let val { try self.encode(val, forKey: key) } else if self.nils { try self.encodeNil(forKey: key) } } + mutating func encodeIfPresent(_ val: Int32?, forKey key: Key) throws { if let val { try self.encode(val, forKey: key) } else if self.nils { try self.encodeNil(forKey: key) } } + mutating func encodeIfPresent(_ val: Int64?, forKey key: Key) throws { if let val { try self.encode(val, forKey: key) } else if self.nils { try self.encodeNil(forKey: key) } } + mutating func encodeIfPresent(_ val: UInt?, forKey key: Key) throws { if let val { try self.encode(val, forKey: key) } else if self.nils { try self.encodeNil(forKey: key) } } + mutating func encodeIfPresent(_ val: UInt8?, forKey key: Key) throws { if let val { try self.encode(val, forKey: key) } else if self.nils { try self.encodeNil(forKey: key) } } + mutating func encodeIfPresent(_ val: UInt16?, forKey key: Key) throws { if let val { try self.encode(val, forKey: key) } else if self.nils { try self.encodeNil(forKey: key) } } + mutating func encodeIfPresent(_ val: UInt32?, forKey key: Key) throws { if let val { try self.encode(val, forKey: key) } else if self.nils { try self.encodeNil(forKey: key) } } + mutating func encodeIfPresent(_ val: UInt64?, forKey key: Key) throws { if let val { try self.encode(val, forKey: key) } else if self.nils { try self.encodeNil(forKey: key) } } + mutating func encodeIfPresent(_ val: (some Encodable)?, forKey key: Key) throws { if let val { try self.encode(val, forKey: key) } else if self.nils { try self.encodeNil(forKey: key) } } + + // See `KeyedEncodingContainerProtocol.superEncoder(forKey:)`. + mutating func superEncoder(forKey key: Key) -> any Encoder { + /// Return a valid encoder so that implementations which then encode scalar values into a + /// single-value container may operate properly. Recursion back into the keyed container path is + /// prevented by the check for an empty coding path in `container(keyedBy:)`. + SQLQueryEncoderImpl( + configuration: self.encoder.configuration, + output: self.encoder.output, + codingPath: self.codingPath + [key] + ) + } + + // See `KeyedEncodingContainerProtocol.nestedContainer(keyedBy:forKey:)`. + mutating func nestedContainer(keyedBy: N.Type, forKey key: Key) -> KeyedEncodingContainer { + /// Nested containers are never supported. Since this method cannot throw directly, return a + /// ``FailureEncoder`` which will throw the error at the earliest possible opportunity. + .invalid(at: self.codingPath + [key]) + } + + // See `KeyedEncodingContainerProtocol.nestedUnkeyedContainer(forKey:)`. + mutating func nestedUnkeyedContainer(forKey key: Key) -> any UnkeyedEncodingContainer { + /// Neither nested nor unkeyed containers are supported. Since this method cannot throw directly, + /// return a ``FailureEncoder`` which will throw the error at the earliest possible opportunity. + .invalid(at: self.codingPath + [key]) + } + + // See `KeyedEncodingContainerProtocol.superEncoder()`. + mutating func superEncoder() -> any Encoder { + /// This method is ostensibly equivalent to `superEncoder(forKey: "super")`, but conceptually does not + /// have the same meaning; its actual intent is not supported. Since this method cannot throw directly, + /// return a ``FailureEncoder`` which will throw the error at the earliest possible opportunity. + .invalid(at: self.codingPath) + } + } + + /// An implementation of `SingleValueEncodingContainer` for ``SQLQueryEncoderImpl``. + private struct SingleValueContainer: SingleValueEncodingContainer { + // See `SingleValueEncodingContainer.codingPath`. + var codingPath: [any CodingKey] { + self.encoder.codingPath + } + + /// The encoder which created this container. + let encoder: SQLQueryEncoderImpl + + // See `SingleValueEncodingContainer.encodeNil()`. + mutating func encodeNil() throws { + /// If the coding path is empty, the attempt to encode a scalar value is taking place on the + /// top-level encoder, which is invalid. + guard let key = self.codingPath.last else { + throw .invalid(at: self.codingPath) + } + + /// Account for the configured ``SQLQueryEncoder/nilEncodingStrategy-swift.property``. + guard self.encoder.configuration.nilEncodingStrategy == .asNil else { + return + } + + self.encoder.set(SQLLiteral.null, forKey: key) + } + + // See `SingleValueEncodingContainer.encode(_:)`. + mutating func encode(_ value: some Encodable) throws { + /// If the coding path is not empty, we reached this point via a keyed container's + /// `superEncoder(forKey:)`, so we want to encode the provided value for the given column directly, + /// so that database-specific logic for handling arrays or other non-scalar values is in effect (i.e. + /// this encoder never goes deeper than one level). This allows support for types such as Fluent's + /// `Model`, whose `Encodable` conformance calls `superEncoder(forKey:).singleValueContainer()` for + /// all properties. (That some of those properties do not properly encode in practice even with this + /// support is a separate problem that does not concern SQLKit; this logic is here because it is + /// technically required for a fully correct Codable implementation.) + if let key = self.codingPath.last { + self.encoder.set(SQLBind(FakeSendableCodable(value)), forKey: key) + } + /// Otherwise, we reached this point via the top-level encoder's `singleValueContainer()`, and we want + /// to recurse back into our own logic without triggering the "can't encode single values" failure + /// mode right away. This enables support for types which encode an aggregate from inside a single- + /// value container (or any number of layers of single-value containers, as long as there's an + /// aggregate at the innermost layer). We avoid infinite recursion by implementing each of the various + /// type-specifc `encode(_:)` methods such that they unconditionally fail (taking advantage of the + /// knowledge that all encoding must eventually either encode nothing, call `encodeNil()`, or invoke + /// one of the concrete `encode(_:)` methods). + else { + try value.encode(to: self.encoder) } - status = .uppercase - snakeCasedString.append(stringKey[i].lowercased()) - } else { - status = .lowercase - snakeCasedString.append(stringKey[i]) } - i = nextIndex + + /// See `encode(_:)` above for why these are here. + + // See `SingleValueEncodingContainer.encode(_:)`. + mutating func encode(_: Bool) throws { throw .invalid(at: self.codingPath) } + mutating func encode(_: String) throws { throw .invalid(at: self.codingPath) } + mutating func encode(_: Float) throws { throw .invalid(at: self.codingPath) } + mutating func encode(_: Double) throws { throw .invalid(at: self.codingPath) } + mutating func encode(_: Int) throws { throw .invalid(at: self.codingPath) } + mutating func encode(_: Int8) throws { throw .invalid(at: self.codingPath) } + mutating func encode(_: Int16) throws { throw .invalid(at: self.codingPath) } + mutating func encode(_: Int32) throws { throw .invalid(at: self.codingPath) } + mutating func encode(_: Int64) throws { throw .invalid(at: self.codingPath) } + mutating func encode(_: UInt) throws { throw .invalid(at: self.codingPath) } + mutating func encode(_: UInt8) throws { throw .invalid(at: self.codingPath) } + mutating func encode(_: UInt16) throws { throw .invalid(at: self.codingPath) } + mutating func encode(_: UInt32) throws { throw .invalid(at: self.codingPath) } + mutating func encode(_: UInt64) throws { throw .invalid(at: self.codingPath) } } - return snakeCasedString } } diff --git a/Sources/SQLKit/Rows/SQLRow.swift b/Sources/SQLKit/Rows/SQLRow.swift index 3267299e..7fd48fa0 100644 --- a/Sources/SQLKit/Rows/SQLRow.swift +++ b/Sources/SQLKit/Rows/SQLRow.swift @@ -1,49 +1,80 @@ /// Represents a single row in a result set returned from an executed SQL query. -public protocol SQLRow { - /// The list of all column names available in the row. Not guaranteed to be in any particular order. +/// +/// Each of the protocol's requirements corresponds closely to a similarly-named requirement of Swift's +/// `KeyedDecodingContainerProtocol`, in order to provide a `Codable`-like interface for generic row access. +/// The additional logic which covers the gap between `Decodable` types and ``SQLRow``s is provided by +/// ``SQLRowDecoder``; see that type for additional discussion and further detail. +public protocol SQLRow: Sendable { + /// The list of all column names available in the row, in no particular order. + /// + /// Corresponds to `KeyedDecodingContainer.allKeys`. var allColumns: [String] { get } /// Returns `true` if the given column name is available in the row, `false `otherwise. + /// + /// Corresponds to `KeyedDecodingContainer.contains(key:)`. func contains(column: String) -> Bool /// Must return `true` if the given column name is missing from the row **or** if it exists but has a /// value equivalent to an SQL `NULL`, or `false` if the column name exists with a non-`NULL` value. /// - /// - Note: This deliberately matches the semantics of ``Swift/KeyedDecodingContainer/decodeNil(forKey:)`` - /// as regards the treatment of "missing" keys. + /// Corresponds to `KeyedDecodingContainer.decodeNil(forKey:)`, especially with respect to the treatment + /// of "missing" keys. func decodeNil(column: String) throws -> Bool /// If the given column name exists in the row, attempt to decode it as the given type and return the - /// result if successful. Must throw an error if the column name does not exist in the row. - func decode(column: String, as type: D.Type) throws -> D - where D: Decodable + /// result if successful. + /// + /// The implementation _must_ throw an error - preferably `DecodingError.keyNotFound` - if the column name + /// does not exist in the row. + /// + /// Corresponds to `KeyedDecodingContainer.decode(_:forKey:)`. + func decode(column: String, as: D.Type) throws -> D } extension SQLRow { - /// Decode an entire `Decodable` type at once, optionally applying a prefix and/or a decoding strategy - /// to each key of the type before looking it up in the row. - public func decode(model type: D.Type, prefix: String? = nil, keyDecodingStrategy: SQLRowDecoder.KeyDecodingStrategy = .useDefaultKeys) throws -> D - where D: Decodable - { - var rowDecoder = SQLRowDecoder() - rowDecoder.prefix = prefix - rowDecoder.keyDecodingStrategy = keyDecodingStrategy - return try rowDecoder.decode(D.self, from: self) + /// Decode an entire `Decodable` "model" type at once, optionally applying a prefix and/or + /// ``SQLRowDecoder/KeyDecodingStrategy-swift.enum`` to the type's coding keys. + /// + /// See ``SQLRowDecoder`` for additional details. + /// + /// Most users should consider using ``SQLQueryFetcher/all(decoding:prefix:keyDecodingStrategy:userInfo:)-5u1nz`` + /// and/or ``SQLQueryFetcher/first(decoding:prefix:keyDecodingStrategy:userInfo:)-2str1`` instead. + /// + /// - Parameters: + /// - type: The type to decode. + /// - prefix: A prefix to discard from column names when looking up coding keys. + /// - keyDecodingStrategy: A decoding strategy to use for coding keys. + /// - userInfo: See ``SQLRowDecoder/userInfo``. + /// - Returns: An instance of the decoded type. + public func decode( + model type: D.Type, + prefix: String? = nil, + keyDecodingStrategy: SQLRowDecoder.KeyDecodingStrategy = .useDefaultKeys, + userInfo: [CodingUserInfoKey: any Sendable] = [:] + ) throws -> D { + try self.decode(model: D.self, with: .init(prefix: prefix, keyDecodingStrategy: keyDecodingStrategy, userInfo: userInfo)) } - /// Decode an entire `Decodable` type at once using an explicit `SQLRowDecoder`. - public func decode(model type: D.Type, with rowDecoder: SQLRowDecoder) throws -> D - where D: Decodable - { - return try rowDecoder.decode(D.self, from: self) + /// Decode an entire `Decodable` "model" type at once using an explicit ``SQLRowDecoder``. + /// + /// See ``SQLRowDecoder`` for additional details. + /// + /// Most users should consider using ``SQLQueryFetcher/all(decoding:with:)-6n5ox`` and/or + /// ``SQLQueryFetcher/first(decoding:with:)-58l9p`` instead. + /// + /// - Parameters: + /// - type: The type to decode. + /// - decoder: The ``SQLRowDecoder`` to use for decoding. + /// - Returns: An instance of the decoded type. + public func decode(model type: D.Type, with decoder: SQLRowDecoder) throws -> D { + try decoder.decode(D.self, from: self) } - /// This method exists to enable the compiler to perform type inference on - /// the generic parameter `D` of `SQLRow.decode(column:as:)`. Protocols can - /// not provide default arguments to methods, which is required for - /// inference to work with generic type parameters. It is not expected that - /// user code will invoke this method directly; rather it will be selected - /// by the compiler automatically, as in this example: + /// This method exists to enable the compiler to perform type inference on the generic parameter `D` of + /// ``SQLRow/decode(column:as:)``. Protocols can not provide default arguments to methods, which is required for + /// inference to work with generic type parameters. It is not expected that user code will invoke this method + /// directly; rather it will be selected by the compiler automatically, as in this example: /// /// ``` /// let row = getAnSQLRowFromSomewhere() @@ -53,14 +84,7 @@ extension SQLRow { /// let item = Item(property: try row.decode(column: "property")) // `D` inferred as `Bool` /// let meti = Item(property: try row.decode(column: "property", as: Bool?.self)) // Error: Can't assign Bool? to Bool /// ``` - /// - /// - Note: The presence of this method in a protocol extension allows it to - /// be available without requiring explicit support from individual - /// database drivers. - /// - /// - Todo: Find a way to accomplish this result without polluting the - /// method namespace. - public func decode(column: String, inferringAs type: D.Type = D.self) throws -> D where D: Decodable { - return try self.decode(column: column, as: D.self) + public func decode(column: String, inferringAs: D.Type = D.self) throws -> D { + try self.decode(column: column, as: D.self) } } diff --git a/Sources/SQLKit/Rows/SQLRowDecoder.swift b/Sources/SQLKit/Rows/SQLRowDecoder.swift index e99bd041..547f86b3 100644 --- a/Sources/SQLKit/Rows/SQLRowDecoder.swift +++ b/Sources/SQLKit/Rows/SQLRowDecoder.swift @@ -1,205 +1,399 @@ -import Foundation +/// An implementation of `Decoder` designed to decode "models" (or, in general, aggregate `Decodable` types) from +/// ``SQLRow``s returned from a database query. +/// +/// This type essentially acts as a bridge between `Codable` structure types and the per-column decoding methods +/// provided by ``SQLRow``. It is, somewhat confusingly, designed primarily for use via ``SQLQueryFetcher``'s +/// ``SQLQueryFetcher/all(decoding:)-5dt2x`` and ``SQLQueryFetcher/first(decoding:)-63noi`` methods, or somewhat more +/// directly via ``SQLRow/decode(model:prefix:keyDecodingStrategy:userInfo:)`` and ``SQLRow/decode(model:with:)``, but +/// it can also be manually invoked. For example: +/// +/// ```swift +/// struct MySimpleUserModel: Codable { +/// var id: Int +/// var username: String +/// var passwordHash: [UInt8] +/// var email: String? +/// var createdAt: Date +/// } +/// +/// let query = sqlDatabase.select() +/// .columns("id", "username", "password_hash", "email", "created_at") +/// .from("my_simple_users") +/// +/// // Direct usage: +/// let rows = try await query.all() +/// let decoder = SQLRowDecoder(keyDecodingStrategy: .convertFromSnakeCase) +/// let userModels = try rows.map { row in +/// try decoder.decode(MySimpleUserModel.self, from: row) +/// } +/// +/// // Invoked via SQLRow: +/// let userModels = try rows.map { row in +/// try row.decode(MySimpleUserModel.self, keyDecodingStrategy: .convertFromSnakeCase) +/// } +/// +/// // Invoked via SQLQueryFetcher: +/// let userModels = try await query.all( +/// decoding: MySimpleUserModel.self, +/// keyDecodingStrategy: .convertFromSnakeCase +/// ) +/// ``` +/// +/// > Important: This API is designed for use with models in the generic sense, i.e. Swift structures which conform +/// > to `Codable`. It is _not_ designed to bridge between FluentKit's `Model` protocol and SQLKit methods; an attempt +/// > to do so will result in errors and/or unexpected behavior. +public struct SQLRowDecoder: Sendable { + /// A strategy describing how to transform column names in a row to match the expectations of decoded type(s). + public enum KeyDecodingStrategy: Sendable { + /// Use the keys specified by each type. This is the default strategy. + case useDefaultKeys + + /// Convert from `snake_case_keys` to `camelCaseKeys` before attempting to match a key with + /// the one specified by each type. + /// + /// Converting from snake case to camel case: + /// + /// 1. Capitalizes the word starting after each `_` chartacter. + /// 2. Removes all `_` characters (except as specified below). + /// 3. Preserves starting and ending `_` (as these are often used to indicate private variables + /// or other metadata). + /// For example, `one_two_three` becomes `oneTwoThree`. `_one_two_three_` becomes `_oneTwoThree_`. + /// + /// > Note: Using a key decoding strategy has a nominal performance cost, as each string key has + /// to be inspected for the `_` character. + case convertFromSnakeCase + + /// Perform a user-specified conversion between keys in a query result row and the `CodingKey`s used by + /// the decoded model type. + /// + /// The full path to the current decoding position is provided for context (in case you need to + /// locate this key within the payload). The returned key is used in place of the last component + /// in the coding path before decoding. + /// + /// If the result of the conversion is a duplicate key, then only one value will be present in the + /// container for the type to decode from. + /// + /// > Note: The coding "path" will in reality always contain exactly one coding key. Users may consider + /// > this an API guarantee and safely write code which relies on this assumption. + /// + /// > Warning: The naming conventions used by ``SQLRowDecoder/KeyDecodingStrategy-swift.enum`` are + /// > misleading. In particular, although the ``convertFromSnakeCase`` strategy implies conversion + /// > to camel-cased keys _from_ snake-cased originals, in reality any given `CodingKey` is subjected to + /// > the inverse transformation (as described by + /// > ``SQLQueryEncoder/KeyEncodingStrategy-swift.enum/convertToSnakeCase``). Likewise, the closure provided to + /// > the ``custom(_:)`` strategy is expected to perform a _forward_ translation, translating a Swift-side + /// > `CodingKey` into the database-side column name found in a given query result row. Users are encouraged + /// > to consider the use of ``SomeCodingKey`` for returning results. + /// > + /// > It is also worth noting that this behavior is inconsistent with how a `KeyDecodingStrategy` specified + /// > on Foundation's `JSONDecoder` works. + /// + /// - Parameter closure: A closure which performs a _forward_ conversion of a `CodingKey` to the equivalent + /// database column name. + @preconcurrency + case custom(@Sendable ([any CodingKey]) -> any CodingKey) + + /// Apply the strategy to the given coding key, returning the transformed result. + /// + /// > Note: As noted elsewhere, although the strategy implies performing _backwards_ translations (converting + /// > database column names to coding keys), the actual operation of applying the strategy is identical to + /// > that of ``SQLQueryEncoder/KeyEncodingStrategy-swift.enum`` - a _forward_ translation from coding keys to + /// > database column names. + fileprivate func apply(to key: some StringProtocol) -> String { + switch self { + case .useDefaultKeys: + return .init(key) + case .convertFromSnakeCase: + return .init(key).convertedToSnakeCase // N.B.: NOT a typo! + case .custom(let custom): + return custom([String(key).codingKeyValue]).stringValue + } + } + } -public struct SQLRowDecoder { + /// A prefix to be applied to coding keys before interpreting them as column names. + /// + /// The ``prefix``, if set, is applied _after_ the ``keyDecodingStrategy-swift.property``. + /// + /// Example: + /// + /// Prefix|Strategy|Coding key|Column name + /// -|-|-|- + /// _nil_|``KeyDecodingStrategy-swift.enum/useDefaultKeys``|`FooBar`|`FooBar` + /// `p`|``KeyDecodingStrategy-swift.enum/useDefaultKeys``|`FooBar`|`pFooBar` + /// _nil_|``KeyDecodingStrategy-swift.enum/convertFromSnakeCase``|`FooBar`|`foo_bar` + /// `p`|``KeyDecodingStrategy-swift.enum/convertFromSnakeCase``|`FooBar`|`pfoo_bar` public var prefix: String? + + /// The key decoding strategy to use. + /// + /// The ``prefix``, if set, is applied _after_ the ``keyDecodingStrategy-swift.property``. + /// + /// Example: + /// + /// Prefix|Strategy|Coding key|Column name + /// -|-|-|- + /// _nil_|``KeyDecodingStrategy-swift.enum/useDefaultKeys``|`FooBar`|`FooBar` + /// `p`|``KeyDecodingStrategy-swift.enum/useDefaultKeys``|`FooBar`|`pFooBar` + /// _nil_|``KeyDecodingStrategy-swift.enum/convertFromSnakeCase``|`FooBar`|`foo_bar` + /// `p`|``KeyDecodingStrategy-swift.enum/convertFromSnakeCase``|`FooBar`|`pfoo_bar` public var keyDecodingStrategy: KeyDecodingStrategy + + /// User info to provide to the underlying `Decoder`. + public var userInfo: [CodingUserInfoKey: any Sendable] - public init(prefix: String? = nil, keyDecodingStrategy: KeyDecodingStrategy = .useDefaultKeys) { + /// Create a configured ``SQLRowDecoder``. + /// + /// - Parameters: + /// - prefix: The key prefix to use for column names. See ``prefix`` for details. + /// - keyDecodingStrategy: The strategy to use for translating column names to keys. See + /// ``keyDecodingStrategy-swift.property`` for details. + /// - userInfo: Key-value pairs to provide as user info to the underlying decoder. + public init( + prefix: String? = nil, + keyDecodingStrategy: KeyDecodingStrategy = .useDefaultKeys, + userInfo: [CodingUserInfoKey: any Sendable] = [:] + ) { self.prefix = prefix self.keyDecodingStrategy = keyDecodingStrategy + self.userInfo = userInfo } - func decode(_ type: T.Type, from row: SQLRow) throws -> T - where T: Decodable - { - return try T.init(from: _Decoder(row: row, options: options)) - } - - public enum KeyDecodingStrategy { - case useDefaultKeys - case convertFromSnakeCase - case custom(([CodingKey]) -> CodingKey) - } - - fileprivate struct _Options { - let prefix: String? - let keyDecodingStrategy: KeyDecodingStrategy - } - - /// The options set on the top-level decoder. - fileprivate var options: _Options { - return _Options(prefix: prefix, keyDecodingStrategy: keyDecodingStrategy) - } - - enum _Error: Error { - case nesting - case unkeyedContainer - case singleValueContainer + /// Decode a value of type `T` from the given ``SQLRow``. + /// + /// - Parameters: + /// - type: The type to decode. + /// - row: The row containing the data to decode. + /// - Returns: An instance of `type` decoded from `row`. + /// - Throws: Any error which occurs during the decoding process. + public func decode(_ type: T.Type, from row: some SQLRow) throws -> T { + try T.init(from: SQLRowDecoderImpl(configuration: self, row: row)) } - struct _Decoder: Decoder { - fileprivate let options: SQLRowDecoder._Options - let row: SQLRow - var codingPath: [CodingKey] = [] - var userInfo: [CodingUserInfoKey : Any] { - [:] + /// Underlying implementation. + private struct SQLRowDecoderImpl: Decoder { + /// Holds configuration information for the decoding process. + let configuration: SQLRowDecoder + + /// The row containing the data to be decoded. + let row: Row + + // See `Decoder.codingPath`. + var codingPath: [any CodingKey] = [] + + // See `Decoder.userInfo`. + var userInfo: [CodingUserInfoKey: Any] { + self.configuration.userInfo } - fileprivate init(row: SQLRow, codingPath: [CodingKey] = [], options: _Options) { - self.options = options - self.row = row - self.codingPath = codingPath + // See `Decoder.container(keyedBy:)`. + func container(keyedBy: Key.Type) throws -> KeyedDecodingContainer { + /// If the coding path is not empty, we have reached this point via `superDecoder(forKey:)`, from which + /// the only valid request is for a single-value container. + guard self.codingPath.isEmpty else { + throw .invalid(at: self.codingPath) + } + + /// Otherwise, a keyed container request is valid. + return .init(KeyedContainer(decoder: self)) } - - func container(keyedBy type: Key.Type) throws -> KeyedDecodingContainer - where Key: CodingKey - { - .init(_KeyedDecoder(referencing: self, row: self.row, codingPath: self.codingPath)) + + // See `Decoder.unkeyedContainer()`. + func unkeyedContainer() throws -> any UnkeyedDecodingContainer { + /// It is never valid to request an unkeyed container in the current implementation. In a design having + /// differing public API, a row could be conceivably treated as an unkeyed container if accessed in terms + /// of column indexes rather than column names. + throw .invalid(at: self.codingPath) } - - func unkeyedContainer() throws -> UnkeyedDecodingContainer { - throw _Error.unkeyedContainer + + // See `Decoder.singleValueContainer()`. + func singleValueContainer() throws -> any SingleValueDecodingContainer { + SingleValueContainer(decoder: self) } - func singleValueContainer() throws -> SingleValueDecodingContainer { - throw _Error.singleValueContainer + /// Apply the configured transformation to convert a given coding key to a column name. + private func column(for key: String) -> String { + "\(self.configuration.prefix ?? "")\(self.configuration.keyDecodingStrategy.apply(to: key))" } - } - struct _KeyedDecoder: KeyedDecodingContainerProtocol - where Key: CodingKey - { - /// A reference to the decoder we're reading from. - private let decoder: _Decoder - let row: SQLRow - var codingPath: [CodingKey] = [] - var allKeys: [Key] { - self.row.allColumns.compactMap { - Key.init(stringValue: $0) + /// An implementation of `KeyedDecodingContainerProtocol` for ``SQLRowDecoderImpl``. + private struct KeyedContainer: KeyedDecodingContainerProtocol { + // See `KeyedDecodingContainerProtocol.codingPath`. + var codingPath: [any CodingKey] { + self.decoder.codingPath } - } - - fileprivate init(referencing decoder: _Decoder, row: SQLRow, codingPath: [CodingKey] = []) { - self.decoder = decoder - self.row = row - } + + /// The decoder which created this container. + let decoder: SQLRowDecoderImpl - func column(for key: Key) -> String { - var decodedKey = key.stringValue - switch self.decoder.options.keyDecodingStrategy { - case .useDefaultKeys: - break - case .convertFromSnakeCase: - decodedKey = _convertFromSnakeCase(decodedKey) - case .custom(let customKeyDecodingFunc): - decodedKey = customKeyDecodingFunc([key]).stringValue + // See `KeyedDecodingContainerProtocol.allKeys`. + var allKeys: [Key] { + /// Warning: This does not return accurate results! To be correct, each column name must have the + /// configured key decoding strategy and key prefix applied to it _in reverse_, an operation which can + /// not be reliably performed with the existing + /// ``SQLRowDecoder/KeyDecodingStrategy-swift.enum/custom(_:)`` API. This implementation does the best + /// it can with the given limitations. + self.decoder.row.allColumns.map { + String($0.drop(prefix: self.decoder.configuration.prefix)) + }.map { + switch self.decoder.configuration.keyDecodingStrategy { + case .useDefaultKeys: + return $0 + case .convertFromSnakeCase: + return $0.convertedFromSnakeCase + case .custom(_): + return $0 // this is inaccurate but there's little to be done about it + } + }.compactMap(Key.init(stringValue:)) } - - if let prefix = self.decoder.options.prefix { - return prefix + decodedKey - } else { - return decodedKey + + // See `KeyedDecodingContainerProtocol.contains(_:)`. + func contains(_ key: Key) -> Bool { + self.decoder.row.contains(column: self.decoder.column(for: key.stringValue)) + } + + // See `KeyedDecodingContainerProtocol.decodeNil(forKey:)`. + func decodeNil(forKey key: Key) throws -> Bool { + do { + /// Do _not_ check `contains(_:)` here; most often it will have already been called by the + /// default `decodeIfPresent(_:forKey:)` implementation, and even if not, we don't necessarily + /// want to make such a check. + return try self.decoder.row.decodeNil(column: self.decoder.column(for: key.stringValue)) + } catch let error as DecodingError { + /// Ensure that errors contain complete coding paths. + throw error.under(path: self.codingPath + [key]) + } + } + + // See `KeyedDecodingContainerProtocol.decode(_:forKey:)`. + func decode(_: T.Type, forKey key: Key) throws -> T { + let column = self.decoder.column(for: key.stringValue) + + guard self.decoder.row.contains(column: column) else { + throw DecodingError.keyNotFound(key, .init( + codingPath: self.codingPath, + debugDescription: "No value associated with key \"\(key.stringValue)\" (as \"\(column)\")." + )) + } + do { + return try self.decoder.row.decode(column: column) + } catch let error as DecodingError { + /// Ensure that errors contain complete coding paths. + throw error.under(path: self.codingPath + [key]) + } + } + + // See `KeyedDecodingContainerProtocol.superDecoder(forKey:)`. + func superDecoder(forKey key: Key) throws -> any Decoder { + /// Return a valid decoder so that implementations which then decode scalar values from a + /// single-value container may operate properly. Recursion back into the keyed container path is + /// prevented by the check for an empty coding path in `container(keyedBy:)`. + SQLRowDecoderImpl( + configuration: self.decoder.configuration, + row: self.decoder.row, + codingPath: self.codingPath + [key] + ) } - } - - func contains(_ key: Key) -> Bool { - self.row.contains(column: self.column(for: key)) - } - - func decodeNil(forKey key: Key) throws -> Bool { - try self.row.decodeNil(column: self.column(for: key)) - } - - func decode(_ type: T.Type, forKey key: Key) throws -> T - where T : Decodable - { - try self.row.decode(column: self.column(for: key), as: T.self) - } - - func nestedContainer( - keyedBy type: NestedKey.Type, - forKey key: Key - ) throws -> KeyedDecodingContainer - where NestedKey : CodingKey - { - throw _Error.nesting - } - - func nestedUnkeyedContainer(forKey key: Key) throws -> UnkeyedDecodingContainer { - throw _Error.nesting - } - - func superDecoder() throws -> Decoder { - _Decoder(row: self.row, codingPath: self.codingPath, options: self.decoder.options) - } - func superDecoder(forKey key: Key) throws -> Decoder { - throw _Error.nesting + // See `KeyedDecodingContainerProtocol.nestedContainer(keyedBy:forKey:)`. + func nestedContainer(keyedBy: N.Type, forKey key: Key) throws -> KeyedDecodingContainer { + /// Nested containers are never supported. + throw .invalid(at: self.codingPath + [key]) + } + + // See `KeyedDecodingContainerProtocol.nestedUnkeyedContainer(forKey:)`. + func nestedUnkeyedContainer(forKey key: Key) throws -> any UnkeyedDecodingContainer { + /// Neither nested nor unkeyed containers are supported. + throw .invalid(at: self.codingPath + [key]) + } + + // See `KeyedDecodingContainerProtocol.superDecoder()`. + func superDecoder() throws -> any Decoder { + /// This method is ostensibly equivalent to `superDecoder(forKey: "super")`, but conceptually does not + /// have the same meaning; its actual intent is not supported. + throw .invalid(at: self.codingPath) + } } - } -} - -fileprivate extension SQLRowDecoder { - /// This is an implementation is taken from from Swift's JSON KeyDecodingStrategy - /// https://github.com/apple/swift/blob/master/stdlib/public/Darwin/Foundation/JSONEncoder.swift - // Convert from "snake_case_keys" to "camelCaseKeys" before attempting to match a key with the one specified by each type. - /// - /// The conversion to upper case uses `Locale.system`, also known as the ICU "root" locale. This means the result is consistent regardless of the current user's locale and language preferences. - /// - /// Converting from snake case to camel case: - /// 1. Capitalizes the word starting after each `_` - /// 2. Removes all `_` - /// 3. Preserves starting and ending `_` (as these are often used to indicate private variables or other metadata). - /// For example, `one_two_three` becomes `oneTwoThree`. `_one_two_three_` becomes `_oneTwoThree_`. - /// - /// - Note: Using a key decoding strategy has a nominal performance cost, as each string key has to be inspected for the `_` character. - static func _convertFromSnakeCase(_ stringKey: String) -> String { - guard !stringKey.isEmpty else { return stringKey } - - var words : [Range] = [] - // The general idea of this algorithm is to split words on transition from lower to upper case, then on transition of >1 upper case characters to lowercase - // - // myProperty -> my_property - // myURLProperty -> my_url_property - // - // We assume, per Swift naming conventions, that the first character of the key is lowercase. - var wordStart = stringKey.startIndex - var searchRange = stringKey.index(after: wordStart)..1 capital letters. Turn those into a word, stopping at the capital before the lower case character. - let beforeLowerIndex = stringKey.index(before: lowerCaseRange.lowerBound) - words.append(upperCaseRange.lowerBound.. Bool { + if let key = self.codingPath.last { + /// This is the same path as the one described for the identical branch in `decode(_:)` + /// immediately below; refer to that discussion for details about this logic, with the additional + /// remark that, as with the `else` branch, our inability to throw errors from this method forces + /// us to always assume non-`nil` and force calling code into a branch where we _can_ throw. + return (try? self.decoder.row.decodeNil(column: self.decoder.column(for: key.stringValue))) ?? false + } else { + /// We would much prefer to be able to throw an error from here when the coding path is empty, but + /// ironically, while we _can_ throw from the equivalent encoding method, this is one of the only + /// places in the decoding infrastructure from which we cannot. By returning false we at least ensure + /// that the overwhelmingly most common way to reach this path - the [`Decodable` conformance of + /// `Optional`](optionaldecodable) - will be forced to fallback to `decode(_:)`, which will throw the + /// error we would have thrown here. + /// + /// [optionaldecodable]: https://github.com/apple/swift/blob/6a86bf34646a18cf7eb74f7bf7e1ae815bd97739/stdlib/public/core/Codable.swift#L5397 + return false + } } - searchRange = lowerCaseRange.upperBound..(_: T.Type) throws -> T { + /// If the coding path is not empty, we reached this point via a keyed container's + /// `superDecoder(forKey:)`, so we want to decode the row's actual value for the given column directly, + /// so that database-specific logic for handling arrays or other non-scalar values is in effect (i.e. + /// this decoder never goes deeper than one level). This allows support for types such as Fluent's + /// `Model`, whose `Decodable` conformance calls `superDecoder(forKey:).singleValueContainer()` for all + /// properties. (That some of those properties do not properly decode in practice even with this + /// support is a separate problem that does not concern SQLKit; this logic is here because it is + /// technically required for a fully correct Codable implementation.) + if let key = self.codingPath.last { + do { + return try self.decoder.row.decode(column: self.decoder.column(for: key.stringValue)) + } catch let error as DecodingError { + /// Ensure that errors contain complete coding paths. + throw error.under(path: self.codingPath + [key]) + } + } + /// Otherwise, we reached this point via the top-level decoder's `singleValueContainer()`, and we want + /// to recurse back into our own logic without triggering the "can't decode single values" failure + /// mode right away. This enables support for types which decode an aggregate from inside a single- + /// value container (or any number of layers of single-value containers, as long as there's an + /// aggregate at the innermost layer). We avoid infinite recursion by implementing each of the various + /// type-specifc `decode(_:)` methods such that they unconditionally fail (taking advantage of the + /// knowledge that all decoding must eventually either decode nothing, call `decodeNil()`, or invoke + /// one of the concrete `decode(_:)` methods). + else { + return try T.init(from: self.decoder) + } + } + + /// See `decode(_:)` above for why these are here. + + // See `SingleValueDecodingContainer.decode(_:)`. + func decode(_: Bool.Type) throws -> Bool { throw .invalid(at: self.codingPath) } + func decode(_: String.Type) throws -> String { throw .invalid(at: self.codingPath) } + func decode(_: Float.Type) throws -> Float { throw .invalid(at: self.codingPath) } + func decode(_: Double.Type) throws -> Double { throw .invalid(at: self.codingPath) } + func decode(_: Int.Type) throws -> Int { throw .invalid(at: self.codingPath) } + func decode(_: Int8.Type) throws -> Int8 { throw .invalid(at: self.codingPath) } + func decode(_: Int16.Type) throws -> Int16 { throw .invalid(at: self.codingPath) } + func decode(_: Int32.Type) throws -> Int32 { throw .invalid(at: self.codingPath) } + func decode(_: Int64.Type) throws -> Int64 { throw .invalid(at: self.codingPath) } + func decode(_: UInt.Type) throws -> UInt { throw .invalid(at: self.codingPath) } + func decode(_: UInt8.Type) throws -> UInt8 { throw .invalid(at: self.codingPath) } + func decode(_: UInt16.Type) throws -> UInt16 { throw .invalid(at: self.codingPath) } + func decode(_: UInt32.Type) throws -> UInt32 { throw .invalid(at: self.codingPath) } + func decode(_: UInt64.Type) throws -> UInt64 { throw .invalid(at: self.codingPath) } } - words.append(wordStart.. Note: Both the purpose and implementation of this type are almost exactly identical +/// > to those of the standard library's internal [`_DictionaryCodingKey`] type. +/// +/// ![Quotation](codingkey-quotation) +/// +/// [`_DictionaryCodingKey`]: https://github.com/apple/swift/blob/swift-5.10-RELEASE/stdlib/public/core/Codable.swift#L5509 +/// [`CodingKeyRepresentable`]: https://developer.apple.com/documentation/swift/codingkeyrepresentable +public struct SomeCodingKey: CodingKey, Hashable, Sendable { + // See `CodingKey.stringValue`. + public let stringValue: String + + // See `CodingKey.intValue`. + public let intValue: Int? + + // See `CodingKey.init(stringValue:)`. + public init(stringValue: String) { + self.stringValue = stringValue + self.intValue = Int(stringValue) + } + + // See `CodingKey.init(intValue:)`. + public init(intValue: Int) { + self.stringValue = "\(intValue)" + self.intValue = intValue + } +} diff --git a/Sources/SQLKit/Utilities/StringHandling.swift b/Sources/SQLKit/Utilities/StringHandling.swift new file mode 100644 index 00000000..e2f2bbf0 --- /dev/null +++ b/Sources/SQLKit/Utilities/StringHandling.swift @@ -0,0 +1,142 @@ +extension StringProtocol where Self: RangeReplaceableCollection, Self.Element: Equatable { + /// Provides a version of `StringProtocol.firstRange(of:)` which is guaranteed to be available on + /// pre-Ventura Apple platforms. + @inlinable + func sqlkit_firstRange(of other: some StringProtocol) -> Range? { + /// N.B.: This implementation is apparently some 650% faster than `firstRange(of:)`, at least on macOS... + guard self.count >= other.count, let starter = other.first else { return nil } + var index = self.startIndex + let lastIndex = self.index(self.endIndex, offsetBy: -other.count) + + while index <= lastIndex, let start = self[index...].firstIndex(of: starter) { + guard let upperIndex = self.index(start, offsetBy: other.count, limitedBy: self.endIndex) else { + return nil + } + + if self[start ..< upperIndex] == other { + return start ..< upperIndex + } + index = self.index(after: start) + } + return nil + } + + /// Provides a version of `StringProtocol.replacing(_:with:)` which is guaranteed to be available on + /// pre-Ventura Apple platforms. + @inlinable + func sqlkit_replacing(_ search: some StringProtocol, with replacement: some StringProtocol) -> String { + /// N.B.: Even on Ventura/Sonoma, the handwritten implementation is orders of magnitude faster than + /// `replacing(_:with:)`, at least as of the time of this writing. Thus we use the handwritten version + /// unconditionally. It's still 4x slower than Foundation's version, but that's a lot better than 25x. + guard !self.isEmpty, !search.isEmpty, self.count >= search.count else { return .init(self) } + + var result = "", prevIndex = self.startIndex + + result.reserveCapacity(self.count + replacement.count) + while let range = self[prevIndex...].sqlkit_firstRange(of: search) { + result.append(contentsOf: self[prevIndex ..< range.lowerBound]) + result.append(contentsOf: replacement) + prevIndex = range.upperBound + } + result.append(contentsOf: self[prevIndex...]) + return result + } + + /// Returns the string with its first character lowercased. + @inlinable + var decapitalized: String { + self.isEmpty ? "" : "\(self[self.startIndex].lowercased())\(self.dropFirst())" + } + + /// Returns the string with its first character uppercased. + @inlinable + var encapitalized: String { + self.isEmpty ? "" : "\(self[self.startIndex].uppercased())\(self.dropFirst())" + } + + /// Returns the string with any `snake_case` converted to `camelCase`. + /// + /// This is a modified version of Foundation's implementation: + /// https://github.com/apple/swift-foundation/blob/8010dfe6b1c38cdf363c8d3d3b43d7d4f4c9987b/Sources/FoundationEssentials/JSON/JSONDecoder.swift + /// + /// > Note: This method is _not_ idempotent with respect to `convertedToSnakeCase` for all inputs. + var convertedFromSnakeCase: String { + guard !self.isEmpty, let firstNonUnderscore = self.firstIndex(where: { $0 != "_" }) else { + return .init(self) + } + + var lastNonUnderscore = self.endIndex + repeat { + self.formIndex(before: &lastNonUnderscore) + } while lastNonUnderscore > firstNonUnderscore && self[lastNonUnderscore] == "_" + + let keyRange = self[firstNonUnderscore...lastNonUnderscore] + let leading = self[self.startIndex.. 1 else { + return "\(leading)\(keyRange)\(trailing)" + } + return "\(leading)\(([words[0].decapitalized] + words[1...].map(\.encapitalized)).joined())\(trailing)" + } + + /// Returns the string with any `camelCase` converted to `snake_case`. + /// + /// This is a modified version of Foundation's implementation: + /// https://github.com/apple/swift-foundation/blob/8010dfe6b1c38cdf363c8d3d3b43d7d4f4c9987b/Sources/FoundationEssentials/JSON/JSONEncoder.swift + /// + /// > Note: This method is _not_ idempotent with respect to `convertedFromSnakeCase` for all inputs. + var convertedToSnakeCase: String { + guard !self.isEmpty else { + return .init(self) + } + + var words: [Range] = [] + var wordStart = self.startIndex, searchIndex = self.index(after: wordStart) + + while let upperCaseIndex = self[searchIndex...].firstIndex(where: \.isUppercase) { + words.append(wordStart.. Self.SubSequence { + #if !DEBUG + if #available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) { + return prefix.map(self.trimmingPrefix(_:)) ?? self[...] + } + #endif + guard let prefix, self.starts(with: prefix) else { + return self[self.startIndex ..< self.endIndex] + } + return self.dropFirst(prefix.count) + } +} diff --git a/Sources/SQLKitBenchmark/SQLBenchmark+Codable.swift b/Sources/SQLKitBenchmark/SQLBenchmark+Codable.swift index 184af4eb..252d2030 100644 --- a/Sources/SQLKitBenchmark/SQLBenchmark+Codable.swift +++ b/Sources/SQLKitBenchmark/SQLBenchmark+Codable.swift @@ -1,35 +1,35 @@ import SQLKit extension SQLBenchmarker { - public func testCodable() throws { - try self.runTest { - try $0.drop(table: "planets") + public func testCodable() async throws { + try await self.runTest { + try await $0.drop(table: "planets") .ifExists() - .run().wait() - try $0.drop(table: "galaxies") + .run() + try await $0.drop(table: "galaxies") .ifExists() - .run().wait() - try $0.create(table: "galaxies") + .run() + try await $0.create(table: "galaxies") .column("id", type: .bigint, .primaryKey) .column("name", type: .text) - .run().wait() - try $0.create(table: "planets") + .run() + try await $0.create(table: "planets") .column("id", type: .bigint, .primaryKey) .column("name", type: .text, [.default(SQLLiteral.string("Unamed Planet")), .notNull]) .column("is_inhabited", type: .custom(SQLRaw("boolean")), .notNull) .column("galaxyID", type: .bigint, .references("galaxies", "id")) - .run().wait() + .run() // insert let galaxy = Galaxy(name: "milky way") - try $0.insert(into: "galaxies").model(galaxy).run().wait() + try await $0.insert(into: "galaxies").model(galaxy).run() // insert with keyEncodingStrategy let earth = Planet(name: "Earth", isInhabited: true) let mars = Planet(name: "Mars", isInhabited: false) - try $0.insert(into: "planets") + try await $0.insert(into: "planets") .models([earth, mars], keyEncodingStrategy: .convertToSnakeCase) - .run().wait() + .run() } } } diff --git a/Sources/SQLKitBenchmark/SQLBenchmark+Enum.swift b/Sources/SQLKitBenchmark/SQLBenchmark+Enum.swift index 787d7ddc..defe9fa8 100644 --- a/Sources/SQLKitBenchmark/SQLBenchmark+Enum.swift +++ b/Sources/SQLKitBenchmark/SQLBenchmark+Enum.swift @@ -1,53 +1,58 @@ import SQLKit extension SQLBenchmarker { - public func testEnum() throws { - try self.runTest { - try $0.drop(table: "planets") + public func testEnum() async throws { + try await self.runTest { + try await $0.drop(table: "planets") .ifExists() - .run().wait() - try $0.drop(table: "galaxies") + .run() + try await $0.drop(table: "galaxies") .ifExists() - .run().wait() + .run() + if $0.dialect.enumSyntax == .typeName { + try await $0.drop(enum: "planet_type") + .ifExists() + .run() + } // setup sql data type for enum let planetType: SQLDataType switch $0.dialect.enumSyntax { case .typeName: planetType = .custom(SQLIdentifier("planet_type")) - try $0.create(enum: "planet_type") + try await $0.create(enum: "planet_type") .value("smallRocky") .value("gasGiant") - .run().wait() + .run() case .inline: planetType = .enum("smallRocky", "gasGiant") case .unsupported: planetType = .text } - try $0.create(table: "planets") + try await $0.create(table: "planets") .column("id", type: .bigint, .primaryKey) .column("name", type: .text, .notNull) .column("type", type: planetType, .notNull) - .run().wait() + .run() let earth = Planet(name: "Earth", type: .smallRocky) let jupiter = Planet(name: "Jupiter", type: .gasGiant) - try $0.insert(into: "planets") + try await $0.insert(into: "planets") .model(earth) .model(jupiter) - .run().wait() + .run() // add dwarf type switch $0.dialect.enumSyntax { case .typeName: - try $0.alter(enum: "planet_type") + try await $0.alter(enum: "planet_type") .add(value: "dwarf") - .run().wait() + .run() case .inline: - try $0.alter(table: "planets") + try await $0.alter(table: "planets") .update(column: "type", type: .enum("smallRocky", "gasGiant", "dwarf")) - .run().wait() + .run() case .unsupported: // do nothing break @@ -55,15 +60,15 @@ extension SQLBenchmarker { // add new planet using dwarf type let pluto = Planet(name: "Pluto", type: .dwarf) - try $0.insert(into: "planets") + try await $0.insert(into: "planets") .model(pluto) - .run().wait() + .run() // delete all gas giants - try $0 + try await $0 .delete(from: "planets") .where("type", .equal, PlanetType.gasGiant as any SQLExpression) - .run().wait() + .run() // drop gas giant enum value switch $0.dialect.enumSyntax { @@ -71,23 +76,23 @@ extension SQLBenchmarker { // cannot be removed break case .inline: - try $0.alter(table: "planets") + try await $0.alter(table: "planets") .update(column: "type", type: .enum("smallRocky", "dwarf")) - .run().wait() + .run() case .unsupported: // do nothing break } // drop table - try $0.drop(table: "planets") - .run().wait() + try await $0.drop(table: "planets") + .run() // drop custom type switch $0.dialect.enumSyntax { case .typeName: - try $0.drop(enum: "planet_type") - .run().wait() + try await $0.drop(enum: "planet_type") + .run() case .inline, .unsupported: // do nothing break diff --git a/Sources/SQLKitBenchmark/SQLBenchmark+JSONPaths.swift b/Sources/SQLKitBenchmark/SQLBenchmark+JSONPaths.swift index f9ac46bf..2af8bd67 100644 --- a/Sources/SQLKitBenchmark/SQLBenchmark+JSONPaths.swift +++ b/Sources/SQLKitBenchmark/SQLBenchmark+JSONPaths.swift @@ -2,43 +2,43 @@ import SQLKit import XCTest extension SQLBenchmarker { - public func testJSONPaths() throws { - try self.runTest { - try $0.drop(table: "planet_metadata") + public func testJSONPaths() async throws { + try await self.runTest { + try await $0.drop(table: "planet_metadata") .ifExists() - .run().wait() - try $0.create(table: "planet_metadata") - .column("id", type: .bigint, .primaryKey(autoIncrement: $0.dialect.supportsAutoIncrement)) + .run() + try await $0.create(table: "planet_metadata") + .column("id", type: .bigint, .primaryKey(autoIncrement: $0.dialect.supportsAutoIncrement)) .column("metadata", type: .custom(SQLRaw($0.dialect.name == "postgresql" ? "jsonb" : "json"))) - .run().wait() + .run() // insert - try $0.insert(into: "planet_metadata") + try await $0.insert(into: "planet_metadata") .columns("id", "metadata") .values(SQLLiteral.default, SQLLiteral.string(#"{"a":{"b":{"c":[1,2,3]}}}"#)) - .run().wait() + .run() // try to extract fields - let objectARows = try $0.select().column(SQLNestedSubpathExpression(column: "metadata", path: ["a"]), as: "data").from("planet_metadata").all().wait() - let objectARow = try XCTUnwrap(objectARows.first) - let objectARaw = try objectARow.decode(column: "data", as: String.self) - let objectA = try JSONDecoder().decode([String: [String: [Int]]].self, from: objectARaw.data(using: .utf8)!) + let objectARows = try await $0.select().column(SQLNestedSubpathExpression(column: "metadata", path: ["a"]), as: "data").from("planet_metadata").all() + let objectARow = try XCTUnwrap(objectARows.first) + let objectARaw = try objectARow.decode(column: "data", as: String.self) + let objectA = try JSONDecoder().decode([String: [String: [Int]]].self, from: objectARaw.data(using: .utf8)!) XCTAssertEqual(objectARows.count, 1) XCTAssertEqual(objectA, ["b": ["c": [1, 2 ,3]]]) - let objectBRows = try $0.select().column(SQLNestedSubpathExpression(column: "metadata", path: ["a", "b"]), as: "data").from("planet_metadata").all().wait() - let objectBRow = try XCTUnwrap(objectBRows.first) - let objectBRaw = try objectBRow.decode(column: "data", as: String.self) - let objectB = try JSONDecoder().decode([String: [Int]].self, from: objectBRaw.data(using: .utf8)!) + let objectBRows = try await $0.select().column(SQLNestedSubpathExpression(column: "metadata", path: ["a", "b"]), as: "data").from("planet_metadata").all() + let objectBRow = try XCTUnwrap(objectBRows.first) + let objectBRaw = try objectBRow.decode(column: "data", as: String.self) + let objectB = try JSONDecoder().decode([String: [Int]].self, from: objectBRaw.data(using: .utf8)!) XCTAssertEqual(objectBRows.count, 1) XCTAssertEqual(objectB, ["c": [1, 2, 3]]) - let objectCRows = try $0.select().column(SQLNestedSubpathExpression(column: "metadata", path: ["a", "b", "c"]), as: "data").from("planet_metadata").all().wait() - let objectCRow = try XCTUnwrap(objectCRows.first) - let objectCRaw = try objectCRow.decode(column: "data", as: String.self) - let objectC = try JSONDecoder().decode([Int].self, from: objectCRaw.data(using: .utf8)!) + let objectCRows = try await $0.select().column(SQLNestedSubpathExpression(column: "metadata", path: ["a", "b", "c"]), as: "data").from("planet_metadata").all() + let objectCRow = try XCTUnwrap(objectCRows.first) + let objectCRaw = try objectCRow.decode(column: "data", as: String.self) + let objectC = try JSONDecoder().decode([Int].self, from: objectCRaw.data(using: .utf8)!) XCTAssertEqual(objectCRows.count, 1) XCTAssertEqual(objectC, [1, 2, 3]) diff --git a/Sources/SQLKitBenchmark/SQLBenchmark+Planets.swift b/Sources/SQLKitBenchmark/SQLBenchmark+Planets.swift index 0706665c..45c3e4ea 100644 --- a/Sources/SQLKitBenchmark/SQLBenchmark+Planets.swift +++ b/Sources/SQLKitBenchmark/SQLBenchmark+Planets.swift @@ -2,115 +2,112 @@ import XCTest import SQLKit extension SQLBenchmarker { - public func testPlanets() throws { - try self.testPlanets_createSchema() - try self.testPlanets_seedTables() - try self.testPlanets_alterSchema() + public func testPlanets() async throws { + try await self.testPlanets_createSchema() + try await self.testPlanets_seedTables() + try await self.testPlanets_alterSchema() } - private func testPlanets_createSchema() throws { - try self.runTest { - try $0.drop(table: "planets") - .ifExists() - .run().wait() - try $0.drop(table: "galaxies") - .ifExists() - .run().wait() - try $0.create(table: "galaxies") - .column("id", type: .bigint, .primaryKey) - .column("name", type: .text) - .run().wait() - try $0.create(table: "planets") - .ifNotExists() - .column("id", type: .bigint, .primaryKey) + private func testPlanets_createSchema() async throws { + try await self.runTest { + try await $0.drop(table: "planets").ifExists() + .run() + try await $0.drop(table: "galaxies").ifExists() + .run() + try await $0.create(table: "galaxies") + .column("id", type: .bigint, .primaryKey) + .column("name", type: .text) + .run() + try await $0.create(table: "planets").ifNotExists() + .column("id", type: .bigint, .primaryKey) .column("galaxyID", type: .bigint, .references("galaxies", "id")) - .run().wait() - try $0.alter(table: "planets") - .column("name", type: .text, .default(SQLLiteral.string("Unamed Planet"))) - .run().wait() - try $0.create(index: "test_index") + .run() + try await $0.alter(table: "planets") + .column("name", type: .text, .default(SQLLiteral.string("Unnamed Planet"))) + .run() + try await $0.create(index: "test_index") .on("planets") .column("id") .unique() - .run().wait() + .run() } } - private func testPlanets_seedTables() throws { - try self.runTest { + private func testPlanets_seedTables() async throws { + try await self.runTest { // INSERT INTO "galaxies" ("id", "name") VALUES (DEFAULT, $1) - try $0.insert(into: "galaxies") + try await $0.insert(into: "galaxies") .columns("id", "name") .values(SQLLiteral.default, SQLBind("Milky Way")) .values(SQLLiteral.default, SQLBind("Andromeda")) // .value(Galaxy(name: "Milky Way")) - .run().wait() + .run() // SELECT * FROM galaxies WHERE name != NULL AND (name == ? OR name == ?) - _ = try $0.select() + _ = try await $0.select() .column("*") .from("galaxies") .where("name", .notEqual, SQLLiteral.null) - .where { - $0.where("name", .equal, SQLBind("Milky Way")) - .orWhere("name", .equal, SQLBind("Andromeda")) + .where { $0 + .where("name", .equal, SQLBind("Milky Way")) + .orWhere("name", .equal, SQLBind("Andromeda")) } - .all().wait() + .all() - _ = try $0.select() + _ = try await $0.select() .column("*") .from("galaxies") .where(SQLColumn("name"), .equal, SQLBind("Milky Way")) .groupBy("id") .orderBy("name", .descending) - .all().wait() + .all() - try $0.insert(into: "planets") + try await $0.insert(into: "planets") .columns("id", "name") .values(SQLLiteral.default, SQLBind("Earth")) - .run().wait() + .run() - try $0.insert(into: "planets") + try await $0.insert(into: "planets") .columns("id", "name") .values(SQLLiteral.default, SQLBind("Mercury")) .values(SQLLiteral.default, SQLBind("Venus")) .values(SQLLiteral.default, SQLBind("Mars")) .values(SQLLiteral.default, SQLBind("Jpuiter")) .values(SQLLiteral.default, SQLBind("Pluto")) - .run().wait() + .run() - try $0.select() + try await $0.select() .column(SQLFunction("count", args: "name")) .from("planets") .where("galaxyID", .equal, SQLBind(5)) - .run().wait() + .run() - try $0.select() + try await $0.select() .column(SQLFunction("count", args: SQLLiteral.all)) .from("planets") .where("galaxyID", .equal, SQLBind(5)) - .run().wait() + .run() } } - private func testPlanets_alterSchema() throws { - try self.runTest { + private func testPlanets_alterSchema() async throws { + try await self.runTest { // add columns for the sake of testing adding columns - try $0.alter(table: "planets") + try await $0.alter(table: "planets") .column("extra", type: .int) - .run().wait() + .run() if $0.dialect.alterTableSyntax.allowsBatch { - try $0.alter(table: "planets") - .column("very_extra", type: .bigint) + try await $0.alter(table: "planets") + .column("very_extra", type: .bigint) .column("extra_extra", type: .text) - .run().wait() + .run() // drop, add, and modify columns - try $0.alter(table: "planets") + try await $0.alter(table: "planets") .dropColumn("extra_extra") .update(column: "extra", type: .text) .column("hi", type: .text) - .run().wait() + .run() } } } diff --git a/Sources/SQLKitBenchmark/SQLBenchmark+Union.swift b/Sources/SQLKitBenchmark/SQLBenchmark+Union.swift index 2733fecf..4f7a25a6 100644 --- a/Sources/SQLKitBenchmark/SQLBenchmark+Union.swift +++ b/Sources/SQLKitBenchmark/SQLBenchmark+Union.swift @@ -2,48 +2,54 @@ import XCTest import SQLKit extension SQLBenchmarker { - public func testUnions() throws { - try self.testUnions_Setup() - defer { try? self.testUnions_Teardown() } - - try self.testUnions_union() - try self.testUnions_unionAll() - try self.testUnions_intersect() - try self.testUnions_intersectAll() - try self.testUnions_except() - try self.testUnions_exceptAll() + public func testUnions() async throws { + try await self.testUnions_Setup() + do { + try await self.testUnions_union() + try await self.testUnions_unionAll() + try await self.testUnions_intersect() + try await self.testUnions_intersectAll() + try await self.testUnions_except() + try await self.testUnions_exceptAll() + try? await self.testUnions_Teardown() + } catch { + try? await self.testUnions_Teardown() + throw error + } } private struct Item: Codable { let id: Int } - private func testUnions_Setup() throws { - try self.database.drop(table: "union_test").ifExists().run().wait() - try self.database.create(table: "union_test") - .column("id", type: .int, .primaryKey(autoIncrement: false), .notNull) + private func testUnions_Setup() async throws { + try await self.database.drop(table: "union_test").ifExists() + .run() + try await self.database.create(table: "union_test") + .column("id", type: .int, .primaryKey(autoIncrement: false), .notNull) .column("field1", type: .text, .notNull) .column("field2", type: .text) - .run().wait() - try self.database.insert(into: "union_test") + .run() + try await self.database.insert(into: "union_test") .columns("id", "field1", "field2") .values(1, "a", String?.none) .values(2, "b", "B") .values(3, "c", "C") - .run().wait() + .run() } - private func testUnions_Teardown() throws { - try self.database.drop(table: "union_test").ifExists().run().wait() + private func testUnions_Teardown() async throws { + try await self.database.drop(table: "union_test").ifExists() + .run() } - private func testUnions_union() throws { - try self.runTest { + private func testUnions_union() async throws { + try await self.runTest { guard $0.dialect.unionFeatures.contains(.union) else { return } - let results = try $0.select() + let results = try await $0.select() .column("id") .from("union_test") .where("field1", .equal, "a") @@ -54,7 +60,6 @@ extension SQLBenchmarker { .orWhere("field2", .equal, "B") }) .all(decoding: Item.self) - .wait() XCTAssertEqual(results.count, 2) XCTAssertEqual(results.filter { $0.id == 1 }.count, 1) @@ -62,13 +67,13 @@ extension SQLBenchmarker { } } - private func testUnions_unionAll() throws { - try self.runTest { + private func testUnions_unionAll() async throws { + try await self.runTest { guard $0.dialect.unionFeatures.contains(.unionAll) else { return } - let results = try $0.select() + let results = try await $0.select() .column("id") .from("union_test") .where("field1", .equal, "a") @@ -79,7 +84,6 @@ extension SQLBenchmarker { .orWhere("field2", .equal, "B") }) .all(decoding: Item.self) - .wait() XCTAssertEqual(results.count, 3) XCTAssertEqual(results.filter { $0.id == 1 }.count, 2) @@ -87,13 +91,13 @@ extension SQLBenchmarker { } } - private func testUnions_intersect() throws { - try self.runTest { + private func testUnions_intersect() async throws { + try await self.runTest { guard $0.dialect.unionFeatures.contains(.intersect) else { return } - let results = try $0.select() + let results = try await $0.select() .column("id") .from("union_test") .where("field1", .equal, "a") @@ -104,20 +108,19 @@ extension SQLBenchmarker { .orWhere("field2", .equal, "B") }) .all(decoding: Item.self) - .wait() XCTAssertEqual(results.count, 1) XCTAssertEqual(results.filter { $0.id == 1 }.count, 1) } } - private func testUnions_intersectAll() throws { - try self.runTest { + private func testUnions_intersectAll() async throws { + try await self.runTest { guard $0.dialect.unionFeatures.contains(.intersectAll) else { return } - let results = try $0.select() + let results = try await $0.select() .column("id") .from("union_test") .where("field1", .equal, "a") @@ -132,20 +135,19 @@ extension SQLBenchmarker { .orWhere("field2", .equal, "B") }) .all(decoding: Item.self) - .wait() XCTAssertEqual(results.count, 2) XCTAssertEqual(results.filter { $0.id == 1 }.count, 2) } } - private func testUnions_except() throws { - try self.runTest { + private func testUnions_except() async throws { + try await self.runTest { guard $0.dialect.unionFeatures.contains(.except) else { return } - let results = try $0.select() + let results = try await $0.select() .column("id") .from("union_test") .union(all: { $0 @@ -159,20 +161,19 @@ extension SQLBenchmarker { .orWhere("field2", .equal, "B") }) .all(decoding: Item.self) - .wait() XCTAssertEqual(results.count, 1) XCTAssertEqual(results.filter { $0.id == 3 }.count, 1) } } - private func testUnions_exceptAll() throws { - try self.runTest { + private func testUnions_exceptAll() async throws { + try await self.runTest { guard $0.dialect.unionFeatures.contains(.exceptAll) else { return } - let results = try $0.select() + let results = try await $0.select() .column("id") .from("union_test") .union(all: { $0 @@ -186,11 +187,9 @@ extension SQLBenchmarker { .orWhere("field2", .equal, "B") }) .all(decoding: Item.self) - .wait() XCTAssertEqual(results.count, 2) XCTAssertEqual(results.filter { $0.id == 3 }.count, 2) } } - } diff --git a/Sources/SQLKitBenchmark/SQLBenchmark+Upsert.swift b/Sources/SQLKitBenchmark/SQLBenchmark+Upsert.swift index ae1d40fb..25911d99 100644 --- a/Sources/SQLKitBenchmark/SQLBenchmark+Upsert.swift +++ b/Sources/SQLKitBenchmark/SQLBenchmark+Upsert.swift @@ -2,94 +2,154 @@ import XCTest import SQLKit extension SQLBenchmarker { - public func testUpserts() throws { + public func testUpserts() async throws { guard self.database.dialect.upsertSyntax != .unsupported else { return } - try self.testUpserts_createSchema() - try self.testUpserts_ignoreAction() - try self.testUpserts_simpleUpdate() - try self.testUpserts_predicateUpdate() - try self.testUpserts_cleanupSchema() + try await self.testUpserts_createSchema() + try await self.testUpserts_ignoreAction() + try await self.testUpserts_simpleUpdate() + try await self.testUpserts_predicateUpdate() + try await self.testUpserts_cleanupSchema() } // the test table name - fileprivate static var testSchema: String { "stargate_construction_entries" } + private static var testSchema: String { "stargate_construction_entries" } + // column definitions for the test table - fileprivate static var testColDefs: [SQLColumnDefinition] { [ - .init("id", dataType: .bigint, constraints: [.primaryKey(autoIncrement: true), .notNull]), - .init("planet_id", dataType: .bigint, constraints: [/*.references("planets", "id"),*/ .notNull]), - .init("point_of_origin", dataType: .text, constraints: [.notNull]), // e.g. 𑀙, 𑀵, ᛡ, 𐋈, 𑁍, 𑁞, that kind of thing - .init("naquadah_stabilizer_setting", dataType: .real, constraints: [.default(SQLLiteral.null)]), - .init("production_start", dataType: .real, constraints: [.notNull]), - .init("projected_completion", dataType: .real, constraints: [.default(SQLLiteral.null)]), - .init("last_status_update", dataType: .real, constraints: [.default(SQLLiteral.null)]), + private static var testColDefs: [SQLColumnDefinition] { [ + .init("id", dataType: .bigint, constraints: [.primaryKey(autoIncrement: true), .notNull]), + .init("planet_id", dataType: .bigint, constraints: [/*.references("planets", "id"),*/ .notNull]), + .init("point_of_origin"/*e.g. 𑀙,𑀵,ᛡ,𐋈,𑁍,𑁞,etc.*/, dataType: .text, constraints: [.notNull]), + .init("naquadah_stabilizer_setting", dataType: .real, constraints: [.default(SQLLiteral.null)]), + .init("production_start", dataType: .real, constraints: [.notNull]), + .init("projected_completion", dataType: .real, constraints: [.default(SQLLiteral.null)]), + .init("last_status_update", dataType: .real, constraints: [.default(SQLLiteral.null)]), .init("number_of_silly_pointless_fields_in_this_table", dataType: .bigint, constraints: [.default(Int64.max), .notNull]), ] } + // list of column identifiers for the test table - fileprivate static var testCols: [any SQLExpression] { self.testColDefs.map(\.column) } + private static var testCols: [any SQLExpression] { + self.testColDefs.map(\.column) + } + // generate a row of values for the test table suitable for passing to SQLInsertBuilder - fileprivate static func testVals(id: Int? = nil, planet: Int, poi: String, setting: Double? = nil, start: Date = Date(), finish: Date? = nil, update: Date? = nil) -> [any SQLExpression] { [ - id.map { SQLBind($0) } ?? SQLLiteral.default, SQLBind(planet), SQLBind(poi), SQLBind(setting), - SQLBind(start.timeIntervalSince1970), SQLBind(finish?.timeIntervalSince1970), SQLBind(update?.timeIntervalSince1970), - SQLBind(Int64.random(in: .min ... .max)) // SQLite makes specifying "use the default" hard for no reason + private static func testVals( + id: Int? = nil, + planet: Int, + poi: String, + setting: Double? = nil, + start: Date = Date(), + finish: Date? = nil, + update: Date? = nil + ) -> [any SQLExpression] { [ + id.map { SQLBind($0) } ?? SQLLiteral.default, + SQLBind(planet), + SQLBind(poi), + SQLBind(setting), + SQLBind(start.timeIntervalSince1970), + SQLBind(finish?.timeIntervalSince1970), + SQLBind(update?.timeIntervalSince1970), + SQLBind(Int64.random(in: .min ... .max)) // SQLite makes specifying "use the default" hard for no reason ] } + // do an insert of the given rows allowing extra config of the insert, if ok is false then assert that the insert // errors out otherwise assert that it does not - fileprivate func testInsert(ok: Bool, _ vals: [any SQLExpression], on database: any SQLDatabase, file: StaticString = #filePath, line: UInt = #line, _ moreConfig: (SQLInsertBuilder) -> SQLInsertBuilder = { $0 }) { + private func testInsert( + ok: Bool, + _ vals: [any SQLExpression], + on database: any SQLDatabase, + file: StaticString = #filePath, + line: UInt = #line, + _ moreConfig: (SQLInsertBuilder) -> SQLInsertBuilder = { $0 } + ) async { if !ok { - XCTAssertThrowsError( - try moreConfig(database.insert(into: Self.testSchema).columns(Self.testCols).values(vals)).run().wait(), "", + await XCTAssertThrowsErrorAsync( + try await moreConfig(database.insert(into: Self.testSchema).columns(Self.testCols).values(vals)).run(), file: file, line: line ) { error in // TODO: Add a common error info protocol so we can validate that the error is a constraint violation } } else { - XCTAssertNoThrow(try moreConfig(database.insert(into: Self.testSchema).columns(Self.testCols).values(vals)).run().wait(), "", - file: file, line: line) + await XCTAssertNoThrowAsync( + try await moreConfig(database.insert(into: Self.testSchema).columns(Self.testCols).values(vals)).run(), + file: file, line: line + ) } } + // retrieve a count of all rows matching the criteria by the predicate, with the caller configuring the predicate - fileprivate func testCount(on database: any SQLDatabase, _ predicate: (SQLSelectBuilder) -> SQLSelectBuilder) throws -> Int { - try predicate(database.select().column(SQLFunction("COUNT", args: SQLLiteral.all)).from(Self.testSchema)) - .all().flatMapThrowing { try $0[0].decode(column: $0[0].allColumns[0], as: Int.self) }.wait() + private func testCount(on database: any SQLDatabase, _ predicate: (SQLSelectBuilder) -> SQLSelectBuilder) async throws -> Int { + let rows = try await predicate(database + .select() + .column(SQLFunction("COUNT", args: SQLLiteral.all)) + .from(Self.testSchema) + ) + .all() + return try rows[0].decode(column: rows[0].allColumns[0], as: Int.self) } /// Sets up tables and indexes used for testing. - public func testUpserts_createSchema() throws { - try self.runTest { - try $0.drop(table: Self.testSchema).ifExists().run().wait() - try $0.create(table: Self.testSchema).column(definitions: Self.testColDefs).unique(["planet_id", "point_of_origin"]).run().wait() - try $0.insert(into: Self.testSchema) + public func testUpserts_createSchema() async throws { + try await self.runTest { + let testValues: [Double?] = [ + /// 𝑐 - speed of light in a vacuum (cleaner) + 299_792_458.0, + /// nothing + nil, + /// π - ratio of a circle's circumference to its diameter + 3.141592653589793238, + /// 𝑒 - Euler's number + 2.7182818284, + /// √2 - Pythagoras's constant + 1.4142135623, + /// 𝒉×10³⁴ - 100 decillion times the Planck constant + 6.62607015, + ] + + try await $0.drop(table: Self.testSchema) + .ifExists() + .run() + try await $0.create(table: Self.testSchema) + .column(definitions: Self.testColDefs) + .unique(["planet_id", "point_of_origin"]) + .run() + try await $0.insert(into: Self.testSchema) .columns(Self.testCols) - .values(Self.testVals(planet: 1, poi: "A", setting: 299_792_458.0/*𝑐*/, start: Date() - (31_557_600 * 1.8))) - .values(Self.testVals(planet: 1, poi: "B", setting: nil, start: Date() - 8_640_000.0, finish: Date.distantFuture)) - .values(Self.testVals(planet: 2, poi: "C", setting: 3.141592653589793238/*π*/, start: Date() - 31_557_600_000.0)) - .values(Self.testVals(planet: 3, poi: "D", setting: 2.7182818284/*𝑒*/, start: Date.distantPast)) - .values(Self.testVals(planet: 4, poi: "E", setting: 1.4142135623/*√2*/, start: Date())) // Date.bigBang sadly isn't a thing - .values(Self.testVals(planet: 4, poi: "F", setting: 6.62607015/*𝒉×10³⁴*/, start: Date(timeIntervalSinceReferenceDate: Date.timeIntervalSinceReferenceDate.nextUp))) - .run().wait() + .values(Self.testVals(planet: 1, poi: "A", setting: testValues[0], start: Date() - (31_557_600 * 1.8))) + .values(Self.testVals(planet: 1, poi: "B", setting: testValues[1], start: Date() - 8_640_000.0, finish: Date.distantFuture)) + .values(Self.testVals(planet: 2, poi: "C", setting: testValues[2], start: Date() - 31_557_600_000.0)) + .values(Self.testVals(planet: 3, poi: "D", setting: testValues[3], start: Date.distantPast)) + .values(Self.testVals(planet: 4, poi: "E", setting: testValues[4], start: Date())) // Date.bigBang sadly isn't a thing + .values(Self.testVals(planet: 4, poi: "F", setting: testValues[5], start: Date(timeIntervalSinceReferenceDate: Date.timeIntervalSinceReferenceDate.nextUp))) + .run() } } /// Tests the "ignore conflicts" functionality. (Technically part of upserts.) - public func testUpserts_ignoreAction() throws { - self.runTest { - testInsert(ok: true, Self.testVals(id: 1, planet: 5, poi: "0"), on: $0) { $0.ignoringConflicts(with: ["id"]) } - + public func testUpserts_ignoreAction() async throws { + await self.runTest { + await self.testInsert(ok: true, Self.testVals(id: 1, planet: 5, poi: "0"), on: $0) { $0.ignoringConflicts(with: ["id"]) } guard $0.dialect.upsertSyntax != .mysqlLike else { return } - - testInsert(ok: false, Self.testVals(id: 1, planet: 5, poi: "0"), on: $0) { $0.ignoringConflicts(with: ["planet_id"]) } + await self.testInsert(ok: false, Self.testVals(id: 1, planet: 5, poi: "0"), on: $0) { $0.ignoringConflicts(with: ["planet_id"]) } } } /// Tests upserts with simple updates. - public func testUpserts_simpleUpdate() throws { - try self.runTest { - testInsert(ok: true, Self.testVals(id: 1, planet: 1, poi: "0"), on: $0) { $0.onConflict(with: ["id"]) { $0.set("last_status_update", to: Date().timeIntervalSince1970) } } - XCTAssertEqual(try self.testCount(on: $0) { $0.where("last_status_update", .isNot, SQLLiteral.null) }, 1) + public func testUpserts_simpleUpdate() async throws { + try await self.runTest { + await self.testInsert(ok: true, Self.testVals(id: 1, planet: 1, poi: "0"), on: $0) { + $0.onConflict(with: ["id"]) { + $0.set("last_status_update", to: Date().timeIntervalSince1970) + } + } + await XCTAssertEqualAsync(try await self.testCount(on: $0) { $0.where("last_status_update", .isNot, SQLLiteral.null) }, 1) - testInsert(ok: true, Self.testVals(planet: 2, poi: "C", update: Date()), on: $0) { $0.onConflict(with: ["planet_id", "point_of_origin"]) { $0.set(excludedValueOf: "last_status_update") } } - XCTAssertEqual(try self.testCount(on: $0) { $0.where("planet_id", .equal, 2).where("point_of_origin", .equal, "C").where("last_status_update", .isNot, SQLLiteral.null) }, 1) + await self.testInsert(ok: true, Self.testVals(planet: 2, poi: "C", update: Date()), on: $0) { + $0.onConflict(with: ["planet_id", "point_of_origin"]) { + $0.set(excludedValueOf: "last_status_update") + } + } + await XCTAssertEqualAsync(try await self.testCount(on: $0) { $0.where("planet_id", .equal, 2).where("point_of_origin", .equal, "C").where("last_status_update", .isNot, SQLLiteral.null) }, 1) /// Lots of other cases need verification - collisions with multiple uniques in the same row and different /// rows, updates of multiple rows, etc. @@ -97,19 +157,24 @@ extension SQLBenchmarker { } /// Tests upserts with updates using predicates (when supported). - public func testUpserts_predicateUpdate() throws { - try self.runTest { + public func testUpserts_predicateUpdate() async throws { + try await self.runTest { guard $0.dialect.upsertSyntax != .mysqlLike else { return } // not supported by MySQL syntax - testInsert(ok: true, Self.testVals(planet: 4, poi: "F", update: Date()), on: $0) { $0.onConflict(with: ["planet_id", "point_of_origin"]) { $0.set(excludedValueOf: "last_status_update").where(SQLExcludedColumn("last_status_update"), .is, SQLLiteral.null) } } - XCTAssertEqual(try self.testCount(on: $0) { $0.where("planet_id", .equal, 4).where("point_of_origin", .equal, "F").where("last_status_update", .isNot, SQLLiteral.null) }, 0) + await self.testInsert(ok: true, Self.testVals(planet: 4, poi: "F", update: Date()), on: $0) { + $0.onConflict(with: ["planet_id", "point_of_origin"]) { $0 + .set(excludedValueOf: "last_status_update") + .where(SQLExcludedColumn("last_status_update"), .is, SQLLiteral.null) + } + } + await XCTAssertEqualAsync(try await self.testCount(on: $0) { $0.where("planet_id", .equal, 4).where("point_of_origin", .equal, "F").where("last_status_update", .isNot, SQLLiteral.null) }, 0) } } /// Remove tables used by these tests. - public func testUpserts_cleanupSchema() throws { - try self.runTest { - try $0.drop(table: Self.testSchema).run().wait() + public func testUpserts_cleanupSchema() async throws { + try await self.runTest { + try await $0.drop(table: Self.testSchema).run() } } } diff --git a/Sources/SQLKitBenchmark/SQLBenchmarker.swift b/Sources/SQLKitBenchmark/SQLBenchmarker.swift index f2301ff2..5f999561 100644 --- a/Sources/SQLKitBenchmark/SQLBenchmarker.swift +++ b/Sources/SQLKitBenchmark/SQLBenchmarker.swift @@ -1,33 +1,79 @@ import SQLKit import XCTest -public final class SQLBenchmarker { - internal let database: any SQLDatabase +public final class SQLBenchmarker: Sendable { + let database: any SQLDatabase public init(on database: any SQLDatabase) { self.database = database } - public func testAll() throws { - try self.testPlanets() - try self.testCodable() - try self.testEnum() + public func runAllTests() async throws { + try await self.testPlanets() + try await self.testCodable() + try await self.testEnum() if self.database.dialect.name != "generic" { - try self.testUpserts() - try self.testUnions() - try self.testJSONPaths() + try await self.testUpserts() + try await self.testUnions() + try await self.testJSONPaths() } } + @available(*, deprecated, renamed: "runAllTests()", message: "Use `runAllTests()` instead.") + public func testAll() throws { + try database.eventLoop.makeFutureWithTask { try await self.runAllTests() }.wait() + } + + @available(*, deprecated, renamed: "runAllTests()", message: "Use `runAllTests()` instead.") public func run() throws { try self.testAll() } - - internal func runTest( + + func runTest( _ name: String = #function, - _ test: (any SQLDatabase) throws -> () - ) rethrows { + _ test: (any SQLDatabase) async throws -> () + ) async rethrows { self.database.logger.notice("[SQLBenchmark] Running \(name)...") - try test(self.database) + try await test(self.database) + } +} + +func XCTAssertEqualAsync( + _ expression1: @autoclosure () async throws -> T, + _ expression2: @autoclosure () async throws -> T, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, line: UInt = #line +) async where T: Equatable { + do { + let expr1 = try await expression1(), expr2 = try await expression2() + return XCTAssertEqual(expr1, expr2, message(), file: file, line: line) + } catch { + return XCTAssertEqual(try { () -> Bool in throw error }(), false, message(), file: file, line: line) + } +} + +func XCTAssertThrowsErrorAsync( + _ expression: @autoclosure () async throws -> T, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, line: UInt = #line, + _ callback: (any Error) -> Void = { _ in } +) async { + do { + _ = try await expression() + XCTAssertThrowsError({}(), message(), file: file, line: line, callback) + } catch { + XCTAssertThrowsError(try { throw error }(), message(), file: file, line: line, callback) + } +} + +func XCTAssertNoThrowAsync( + _ expression: @autoclosure () async throws -> T, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, line: UInt = #line +) async { + do { + _ = try await expression() + } catch { + XCTAssertNoThrow(try { throw error }(), message(), file: file, line: line) } } diff --git a/Tests/SQLKitTests/Async/AsyncSQLKitTests.swift b/Tests/SQLKitTests/Async/AsyncSQLKitTests.swift deleted file mode 100644 index 6f0d060a..00000000 --- a/Tests/SQLKitTests/Async/AsyncSQLKitTests.swift +++ /dev/null @@ -1,916 +0,0 @@ -import SQLKit -import SQLKitBenchmark -import XCTest - -final class AsyncSQLKitTests: XCTestCase { - var db: TestDatabase! - - override func setUp() async throws { - try await super.setUp() - self.db = TestDatabase() - } - - // MARK: Basic Queries - - func testSelect_tableAllCols() async throws { - try await db.select().column(table: "planets", column: "*") - .from("planets") - .where("name", .equal, SQLBind("Earth")) - .run() - XCTAssertEqual(db.results[0], "SELECT `planets`.* FROM `planets` WHERE `name` = ?") - } - - func testSelect_whereEncodable() async throws { - try await db.select().column("*") - .from("planets") - .where("name", .equal, "Earth") - .orWhere("name", .equal, "Mars") - .run() - XCTAssertEqual(db.results[0], "SELECT * FROM `planets` WHERE `name` = ? OR `name` = ?") - } - - func testSelect_whereList() async throws { - try await db.select().column("*") - .from("planets") - .where("name", .in, ["Earth", "Mars"]) - .orWhere("name", .in, ["Venus", "Mercury"]) - .run() - XCTAssertEqual(db.results[0], "SELECT * FROM `planets` WHERE `name` IN (?, ?) OR `name` IN (?, ?)") - } - - func testSelect_whereGroup() async throws { - try await db.select().column("*") - .from("planets") - .where { - $0.where("name", .equal, "Earth") - .orWhere("name", .equal, "Mars") - } - .where("color", .equal, "blue") - .run() - XCTAssertEqual(db.results[0], "SELECT * FROM `planets` WHERE (`name` = ? OR `name` = ?) AND `color` = ?") - } - - func testSelect_whereColumn() async throws { - try await db.select().column("*") - .from("planets") - .where("name", .notEqual, column: "color") - .orWhere("name", .equal, column: "greekName") - .run() - XCTAssertEqual(db.results[0], "SELECT * FROM `planets` WHERE `name` <> `color` OR `name` = `greekName`") - } - - func testSelect_withoutFrom() async throws { - try await db.select() - .column(SQLAlias.init(SQLFunction("LAST_INSERT_ID"), as: SQLIdentifier.init("id"))) - .run() - XCTAssertEqual(db.results[0], "SELECT LAST_INSERT_ID() AS `id`") - } - - func testSelect_limitAndOrder() async throws { - try await db.select() - .column("*") - .from("planets") - .limit(3) - .offset(5) - .orderBy("name") - .run() - XCTAssertEqual(db.results[0], "SELECT * FROM `planets` ORDER BY `name` ASC LIMIT 3 OFFSET 5") - } - - func testUpdate() async throws { - try await db.update("planets") - .where("name", .equal, "Jpuiter") - .set("name", to: "Jupiter") - .run() - XCTAssertEqual(db.results[0], "UPDATE `planets` SET `name` = ? WHERE `name` = ?") - } - - func testDelete() async throws { - try await db.delete(from: "planets") - .where("name", .equal, "Jupiter") - .run() - XCTAssertEqual(db.results[0], "DELETE FROM `planets` WHERE `name` = ?") - } - - // MARK: Locking Clauses - - func testLockingClause_forUpdate() async throws { - try await db.select().column("*") - .from("planets") - .where("name", .equal, "Earth") - .for(.update) - .run() - XCTAssertEqual(db.results[0], "SELECT * FROM `planets` WHERE `name` = ? FOR UPDATE") - } - - func testLockingClause_forShare() async throws { - try await db.select().column("*") - .from("planets") - .where("name", .equal, "Earth") - .for(.share) - .run() - XCTAssertEqual(db.results[0], "SELECT * FROM `planets` WHERE `name` = ? FOR SHARE") - } - - func testLockingClause_raw() async throws { - try await db.select().column("*") - .from("planets") - .where("name", .equal, "Earth") - .lockingClause(SQLRaw("LOCK IN SHARE MODE")) - .run() - XCTAssertEqual(db.results[0], "SELECT * FROM `planets` WHERE `name` = ? LOCK IN SHARE MODE") - } - - // MARK: Group By/Having - - func testGroupByHaving() async throws { - try await db.select().column("*") - .from("planets") - .groupBy("color") - .having("color", .equal, "blue") - .run() - XCTAssertEqual(db.results[0], "SELECT * FROM `planets` GROUP BY `color` HAVING `color` = ?") - } - - // MARK: Dialect-Specific Behaviors - - func testIfExists() async throws { - try await db.drop(table: "planets").ifExists().run() - XCTAssertEqual(db.results[0], "DROP TABLE IF EXISTS `planets`") - - try await db.drop(index: "planets_name_idx").ifExists().run() - XCTAssertEqual(db.results[1], "DROP INDEX IF EXISTS `planets_name_idx`") - - db._dialect.supportsIfExists = false - - try await db.drop(table: "planets").ifExists().run() - XCTAssertEqual(db.results[2], "DROP TABLE `planets`") - - try await db.drop(index: "planets_name_idx").ifExists().run() - XCTAssertEqual(db.results[3], "DROP INDEX `planets_name_idx`") - } - - func testDropBehavior() async throws { - try await db.drop(table: "planets").run() - XCTAssertEqual(db.results[0], "DROP TABLE `planets`") - - try await db.drop(index: "planets_name_idx").run() - XCTAssertEqual(db.results[1], "DROP INDEX `planets_name_idx`") - - try await db.drop(table: "planets").behavior(.cascade).run() - XCTAssertEqual(db.results[2], "DROP TABLE `planets`") - - try await db.drop(index: "planets_name_idx").behavior(.cascade).run() - XCTAssertEqual(db.results[3], "DROP INDEX `planets_name_idx`") - - try await db.drop(table: "planets").behavior(.restrict).run() - XCTAssertEqual(db.results[4], "DROP TABLE `planets`") - - try await db.drop(index: "planets_name_idx").behavior(.restrict).run() - XCTAssertEqual(db.results[5], "DROP INDEX `planets_name_idx`") - - try await db.drop(table: "planets").cascade().run() - XCTAssertEqual(db.results[6], "DROP TABLE `planets`") - - try await db.drop(index: "planets_name_idx").cascade().run() - XCTAssertEqual(db.results[7], "DROP INDEX `planets_name_idx`") - - try await db.drop(table: "planets").restrict().run() - XCTAssertEqual(db.results[8], "DROP TABLE `planets`") - - try await db.drop(index: "planets_name_idx").restrict().run() - XCTAssertEqual(db.results[9], "DROP INDEX `planets_name_idx`") - - db._dialect.supportsDropBehavior = true - - try await db.drop(table: "planets").run() - XCTAssertEqual(db.results[10], "DROP TABLE `planets` RESTRICT") - - try await db.drop(index: "planets_name_idx").run() - XCTAssertEqual(db.results[11], "DROP INDEX `planets_name_idx` RESTRICT") - - try await db.drop(table: "planets").behavior(.cascade).run() - XCTAssertEqual(db.results[12], "DROP TABLE `planets` CASCADE") - - try await db.drop(index: "planets_name_idx").behavior(.cascade).run() - XCTAssertEqual(db.results[13], "DROP INDEX `planets_name_idx` CASCADE") - - try await db.drop(table: "planets").behavior(.restrict).run() - XCTAssertEqual(db.results[14], "DROP TABLE `planets` RESTRICT") - - try await db.drop(index: "planets_name_idx").behavior(.restrict).run() - XCTAssertEqual(db.results[15], "DROP INDEX `planets_name_idx` RESTRICT") - - try await db.drop(table: "planets").cascade().run() - XCTAssertEqual(db.results[16], "DROP TABLE `planets` CASCADE") - - try await db.drop(index: "planets_name_idx").cascade().run() - XCTAssertEqual(db.results[17], "DROP INDEX `planets_name_idx` CASCADE") - - try await db.drop(table: "planets").restrict().run() - XCTAssertEqual(db.results[18], "DROP TABLE `planets` RESTRICT") - - try await db.drop(index: "planets_name_idx").restrict().run() - XCTAssertEqual(db.results[19], "DROP INDEX `planets_name_idx` RESTRICT") - } - - func testDropTemporary() async throws { - try await db.drop(table: "normalized_planet_names").temporary().run() - XCTAssertEqual(db.results[0], "DROP TEMPORARY TABLE `normalized_planet_names`") - } - - func testAltering() async throws { - // SINGLE - try await db.alter(table: "alterable") - .column("hello", type: .text) - .run() - XCTAssertEqual(db.results[0], "ALTER TABLE `alterable` ADD `hello` TEXT") - - try await db.alter(table: "alterable") - .dropColumn("hello") - .run() - XCTAssertEqual(db.results[1], "ALTER TABLE `alterable` DROP `hello`") - - try await db.alter(table: "alterable") - .modifyColumn("hello", type: .text) - .run() - XCTAssertEqual(db.results[2], "ALTER TABLE `alterable` MODIFY `hello` TEXT") - - // BATCH - try await db.alter(table: "alterable") - .column("hello", type: .text) - .column("there", type: .text) - .run() - XCTAssertEqual(db.results[3], "ALTER TABLE `alterable` ADD `hello` TEXT , ADD `there` TEXT") - - try await db.alter(table: "alterable") - .dropColumn("hello") - .dropColumn("there") - .run() - XCTAssertEqual(db.results[4], "ALTER TABLE `alterable` DROP `hello` , DROP `there`") - - try await db.alter(table: "alterable") - .update(column: "hello", type: .text) - .update(column: "there", type: .text) - .run() - XCTAssertEqual(db.results[5], "ALTER TABLE `alterable` MODIFY `hello` TEXT , MODIFY `there` TEXT") - - // MIXED - try await db.alter(table: "alterable") - .column("hello", type: .text) - .dropColumn("there") - .update(column: "again", type: .text) - .run() - XCTAssertEqual(db.results[6], "ALTER TABLE `alterable` ADD `hello` TEXT , DROP `there` , MODIFY `again` TEXT") - } - - // MARK: Distinct - - func testDistinct() async throws { - try await db.select().column("*") - .from("planets") - .groupBy("color") - .having("color", .equal, "blue") - .distinct() - .run() - XCTAssertEqual(db.results[0], "SELECT DISTINCT * FROM `planets` GROUP BY `color` HAVING `color` = ?") - } - - func testDistinctColumns() async throws { - try await db.select() - .distinct(on: "name", "color") - .from("planets") - .run() - XCTAssertEqual(db.results[0], "SELECT DISTINCT `name`, `color` FROM `planets`") - } - - func testDistinctExpression() async throws { - try await db.select() - .column(SQLFunction("COUNT", args: SQLDistinct("name", "color"))) - .from("planets") - .run() - XCTAssertEqual(db.results[0], "SELECT COUNT(DISTINCT(`name`, `color`)) FROM `planets`") - } - - // MARK: Joins - - func testSimpleJoin() async throws { - try await db.select().column("*") - .from("planets") - .join("moons", on: "\(ident: "moons").\(ident: "planet_id")=\(ident: "planets").\(ident: "id")" as SQLQueryString) - .run() - - XCTAssertEqual(db.results[0], "SELECT * FROM `planets` INNER JOIN `moons` ON `moons`.`planet_id`=`planets`.`id`") - } - - func testMessyJoin() async throws { - try await db.select().column("*") - .from("planets") - .join( - SQLAlias(SQLGroupExpression( - db.select().column("name").from("stars").where(SQLColumn("orion"), .equal, SQLIdentifier("please space")).select - ), as: SQLIdentifier("star")), - method: SQLJoinMethod.outer, - on: SQLColumn(SQLIdentifier("planet_id"), table: SQLIdentifier("moons")), SQLBinaryOperator.isNot, SQLRaw("%%%%%%") - ) - .where(SQLLiteral.null) - .run() - - // Yes, this query is very much pure gibberish. - XCTAssertEqual(db.results[0], "SELECT * FROM `planets` OUTER JOIN (SELECT `name` FROM `stars` WHERE `orion` = `please space`) AS `star` ON `moons`.`planet_id` IS NOT %%%%%% WHERE NULL") - } - - // MARK: Operators - - func testBinaryOperators() async throws { - try await db - .update("planets") - .set(SQLIdentifier("moons"), - to: SQLBinaryExpression( - left: SQLIdentifier("moons"), - op: SQLBinaryOperator.add, - right: SQLLiteral.numeric("1") - ) - ) - .where("best_at_space", .greaterThanOrEqual, "yes") - .run() - - XCTAssertEqual(db.results[0], "UPDATE `planets` SET `moons` = `moons` + 1 WHERE `best_at_space` >= ?") - } - - // MARK: Returning - - func testReturning() async throws { - try await db.insert(into: "planets") - .columns("name") - .values("Jupiter") - .returning("id", "name") - .run() - XCTAssertEqual(db.results[0], "INSERT INTO `planets` (`name`) VALUES (?) RETURNING `id`, `name`") - - _ = try await db.update("planets") - .set("name", to: "Jupiter") - .returning(SQLColumn("name", table: "planets")) - .first() - XCTAssertEqual(db.results[1], "UPDATE `planets` SET `name` = ? RETURNING `planets`.`name`") - - _ = try await db.delete(from: "planets") - .returning("*") - .all() - XCTAssertEqual(db.results[2], "DELETE FROM `planets` RETURNING *") - } - - // MARK: Upsert - - func testUpsert() async throws { - // Test the thoroughly underpowered and inconvenient MySQL syntax first - db._dialect.upsertSyntax = .mysqlLike - - let cols = ["id", "serial_number", "star_id", "last_known_status"] - let vals = { (s: String) -> [SQLExpression] in [SQLLiteral.default, SQLBind(UUID()), SQLBind(1), SQLBind(s)] } - - try await db.insert(into: "jumpgates").columns(cols).values(vals("calibration")) - .run() - try await db.insert(into: "jumpgates").columns(cols).values(vals("unicorn dust application")) - .ignoringConflicts() - .run() - try await db.insert(into: "jumpgates").columns(cols).values(vals("planet-size snake oil jar purchasing")) - .onConflict() { $0 - .set("last_known_status", to: "Hooloovoo engineer refraction") - .set(excludedValueOf: "serial_number") - } - .run() - - XCTAssertEqual(db.results[0], "INSERT INTO `jumpgates` (`id`, `serial_number`, `star_id`, `last_known_status`) VALUES (DEFAULT, ?, ?, ?)") - XCTAssertEqual(db.results[1], "INSERT IGNORE INTO `jumpgates` (`id`, `serial_number`, `star_id`, `last_known_status`) VALUES (DEFAULT, ?, ?, ?)") - XCTAssertEqual(db.results[2], "INSERT INTO `jumpgates` (`id`, `serial_number`, `star_id`, `last_known_status`) VALUES (DEFAULT, ?, ?, ?) ON DUPLICATE KEY UPDATE `last_known_status` = ?, `serial_number` = VALUES(`serial_number`)") - - // Now the standard SQL syntax - db._dialect.upsertSyntax = .standard - - try await db.insert(into: "jumpgates").columns(cols).values(vals("calibration")) - .run() - try await db.insert(into: "jumpgates").columns(cols).values(vals("unicorn dust application")) - .ignoringConflicts() - .run() - try await db.insert(into: "jumpgates").columns(cols).values(vals("Vorlon pinching")) - .ignoringConflicts(with: ["serial_number", "star_id"]) - .run() - try await db.insert(into: "jumpgates").columns(cols).values(vals("planet-size snake oil jar purchasing")) - .onConflict() { $0 - .set("last_known_status", to: "Hooloovoo engineer refraction").set(excludedValueOf: "serial_number") - } - .run() - try await db.insert(into: "jumpgates").columns(cols).values(vals("slashfic writing")) - .onConflict(with: ["serial_number"]) { $0 - .set("last_known_status", to: "tachyon antitelephone dialing the").set(excludedValueOf: "star_id") - } - .run() - try await db.insert(into: "jumpgates").columns(cols).values(vals("protection racket payoff")) - .onConflict(with: ["id"]) { $0 - .set("last_known_status", to: "insurance fraud planning") - .where("last_known_status", .notEqual, "evidence disposal") - } - .run() - - XCTAssertEqual(db.results[3], "INSERT INTO `jumpgates` (`id`, `serial_number`, `star_id`, `last_known_status`) VALUES (DEFAULT, ?, ?, ?)") - XCTAssertEqual(db.results[4], "INSERT INTO `jumpgates` (`id`, `serial_number`, `star_id`, `last_known_status`) VALUES (DEFAULT, ?, ?, ?) ON CONFLICT DO NOTHING") - XCTAssertEqual(db.results[5], "INSERT INTO `jumpgates` (`id`, `serial_number`, `star_id`, `last_known_status`) VALUES (DEFAULT, ?, ?, ?) ON CONFLICT (`serial_number`, `star_id`) DO NOTHING") - XCTAssertEqual(db.results[6], "INSERT INTO `jumpgates` (`id`, `serial_number`, `star_id`, `last_known_status`) VALUES (DEFAULT, ?, ?, ?) ON CONFLICT DO UPDATE SET `last_known_status` = ?, `serial_number` = EXCLUDED.`serial_number`") - XCTAssertEqual(db.results[7], "INSERT INTO `jumpgates` (`id`, `serial_number`, `star_id`, `last_known_status`) VALUES (DEFAULT, ?, ?, ?) ON CONFLICT (`serial_number`) DO UPDATE SET `last_known_status` = ?, `star_id` = EXCLUDED.`star_id`") - XCTAssertEqual(db.results[8], "INSERT INTO `jumpgates` (`id`, `serial_number`, `star_id`, `last_known_status`) VALUES (DEFAULT, ?, ?, ?) ON CONFLICT (`id`) DO UPDATE SET `last_known_status` = ? WHERE `last_known_status` <> ?") - } - - // MARK: Codable Nullity - - func testCodableWithNillableColumnWithSomeValue() async throws { - struct Gas: Codable { - let name: String - let color: String? - } - let db = TestDatabase() - var serializer = SQLSerializer(database: db) - - let insertBuilder = try db.insert(into: "gasses").model(Gas(name: "iodine", color: "purple")) - insertBuilder.insert.serialize(to: &serializer) - - XCTAssertEqual(serializer.sql, "INSERT INTO `gasses` (`name`, `color`) VALUES (?, ?)") - XCTAssertEqual(serializer.binds.count, 2) - XCTAssertEqual(serializer.binds[0] as? String, "iodine") - XCTAssertEqual(serializer.binds[1] as? String, "purple") - } - - func testCodableWithNillableColumnWithNilValueWithoutNilEncodingStrategy() async throws { - struct Gas: Codable { - let name: String - let color: String? - } - let db = TestDatabase() - var serializer = SQLSerializer(database: db) - - let insertBuilder = try db.insert(into: "gasses").model(Gas(name: "oxygen", color: nil)) - insertBuilder.insert.serialize(to: &serializer) - - XCTAssertEqual(serializer.sql, "INSERT INTO `gasses` (`name`) VALUES (?)") - XCTAssertEqual(serializer.binds.count, 1) - XCTAssertEqual(serializer.binds[0] as? String, "oxygen") - } - - func testCodableWithNillableColumnWithNilValueAndNilEncodingStrategy() async throws { - struct Gas: Codable { - let name: String - let color: String? - } - let db = TestDatabase() - var serializer = SQLSerializer(database: db) - - let insertBuilder = try db.insert(into: "gasses").model(Gas(name: "oxygen", color: nil), nilEncodingStrategy: .asNil) - insertBuilder.insert.serialize(to: &serializer) - - XCTAssertEqual(serializer.sql, "INSERT INTO `gasses` (`name`, `color`) VALUES (?, NULL)") - XCTAssertEqual(serializer.binds.count, 1) - XCTAssertEqual(serializer.binds[0] as? String, "oxygen") - } - - func testRawCustomStringConvertible() async throws { - let field = "name" - let db = TestDatabase() - _ = try await db.raw("SELECT \(raw: field) FROM users").all() - XCTAssertEqual(db.results[0], "SELECT name FROM users") - } - - // MARK: Table Creation - - func testColumnConstraints() async throws { - try await db.create(table: "planets") - .column("id", type: .bigint, .primaryKey) - .column("name", type: .text, .default("unnamed")) - .column("galaxy_id", type: .bigint, .references("galaxies", "id")) - .column("diameter", type: .int, .check(SQLRaw("diameter > 0"))) - .column("important", type: .text, .notNull) - .column("special", type: .text, .unique) - .column("automatic", type: .text, .generated(SQLRaw("CONCAT(name, special)"))) - .column("collated", type: .text, .collate(name: "default")) - .run() - - XCTAssertEqual(db.results[0], -""" -CREATE TABLE `planets`(`id` BIGINT PRIMARY KEY AUTOINCREMENT, `name` TEXT DEFAULT 'unnamed', `galaxy_id` BIGINT REFERENCES `galaxies` (`id`), `diameter` INTEGER CHECK (diameter > 0), `important` TEXT NOT NULL, `special` TEXT UNIQUE, `automatic` TEXT GENERATED ALWAYS AS (CONCAT(name, special)) STORED, `collated` TEXT COLLATE `default`) -""" - ) - } - - func testConstraintLengthNormalization() { - // Default impl is to leave as-is - XCTAssertEqual( - (db.dialect.normalizeSQLConstraint(identifier: SQLIdentifier("fk:obnoxiously_long_table_name.other_table_name_id+other_table_name.id")) as! SQLIdentifier).string, - SQLIdentifier("fk:obnoxiously_long_table_name.other_table_name_id+other_table_name.id").string - ) - } - - func testMultipleColumnConstraintsPerRow() async throws { - try await db.create(table: "planets") - .column("id", type: .bigint, .notNull, .primaryKey) - .run() - - XCTAssertEqual(db.results[0], "CREATE TABLE `planets`(`id` BIGINT NOT NULL PRIMARY KEY AUTOINCREMENT)") - } - - func testPrimaryKeyColumnConstraintVariants() async throws { - try await db.create(table: "planets1") - .column("id", type: .bigint, .primaryKey) - .run() - - try await db.create(table: "planets2") - .column("id", type: .bigint, .primaryKey(autoIncrement: false)) - .run() - - XCTAssertEqual(db.results[0], "CREATE TABLE `planets1`(`id` BIGINT PRIMARY KEY AUTOINCREMENT)") - - XCTAssertEqual(db.results[1], "CREATE TABLE `planets2`(`id` BIGINT PRIMARY KEY)") - } - - func testPrimaryKeyAutoIncrementVariants() async throws { - db._dialect.supportsAutoIncrement = false - - try await db.create(table: "planets1") - .column("id", type: .bigint, .primaryKey) - .run() - - try await db.create(table: "planets2") - .column("id", type: .bigint, .primaryKey(autoIncrement: false)) - .run() - - db._dialect.supportsAutoIncrement = true - - try await db.create(table: "planets3") - .column("id", type: .bigint, .primaryKey) - .run() - - try await db.create(table: "planets4") - .column("id", type: .bigint, .primaryKey(autoIncrement: false)) - .run() - - db._dialect.supportsAutoIncrement = true - db._dialect.autoIncrementFunction = SQLRaw("NEXTUNIQUE") - - try await db.create(table: "planets5") - .column("id", type: .bigint, .primaryKey) - .run() - - try await db.create(table: "planets6") - .column("id", type: .bigint, .primaryKey(autoIncrement: false)) - .run() - - XCTAssertEqual(db.results[0], "CREATE TABLE `planets1`(`id` BIGINT PRIMARY KEY)") - - XCTAssertEqual(db.results[1], "CREATE TABLE `planets2`(`id` BIGINT PRIMARY KEY)") - - XCTAssertEqual(db.results[2], "CREATE TABLE `planets3`(`id` BIGINT PRIMARY KEY AUTOINCREMENT)") - - XCTAssertEqual(db.results[3], "CREATE TABLE `planets4`(`id` BIGINT PRIMARY KEY)") - - XCTAssertEqual(db.results[4], "CREATE TABLE `planets5`(`id` BIGINT DEFAULT NEXTUNIQUE PRIMARY KEY)") - - XCTAssertEqual(db.results[5], "CREATE TABLE `planets6`(`id` BIGINT PRIMARY KEY)") - } - - func testDefaultColumnConstraintVariants() async throws { - try await db.create(table: "planets1") - .column("name", type: .text, .default("unnamed")) - .run() - - try await db.create(table: "planets2") - .column("diameter", type: .int, .default(10)) - .run() - - try await db.create(table: "planets3") - .column("diameter", type: .real, .default(11.5)) - .run() - - try await db.create(table: "planets4") - .column("current", type: .custom(SQLRaw("BOOLEAN")), .default(false)) - .run() - - try await db.create(table: "planets5") - .column("current", type: .custom(SQLRaw("BOOLEAN")), .default(SQLLiteral.boolean(true))) - .run() - - XCTAssertEqual(db.results[0], "CREATE TABLE `planets1`(`name` TEXT DEFAULT 'unnamed')") - - XCTAssertEqual(db.results[1], "CREATE TABLE `planets2`(`diameter` INTEGER DEFAULT 10)") - - XCTAssertEqual(db.results[2], "CREATE TABLE `planets3`(`diameter` REAL DEFAULT 11.5)") - - XCTAssertEqual(db.results[3], "CREATE TABLE `planets4`(`current` BOOLEAN DEFAULT false)") - - XCTAssertEqual(db.results[4], "CREATE TABLE `planets5`(`current` BOOLEAN DEFAULT true)") - } - - func testForeignKeyColumnConstraintVariants() async throws { - try await db.create(table: "planets1") - .column("galaxy_id", type: .bigint, .references("galaxies", "id")) - .run() - - try await db.create(table: "planets2") - .column("galaxy_id", type: .bigint, .references("galaxies", "id", onDelete: .cascade, onUpdate: .restrict)) - .run() - - XCTAssertEqual(db.results[0], "CREATE TABLE `planets1`(`galaxy_id` BIGINT REFERENCES `galaxies` (`id`))") - - XCTAssertEqual(db.results[1], "CREATE TABLE `planets2`(`galaxy_id` BIGINT REFERENCES `galaxies` (`id`) ON DELETE CASCADE ON UPDATE RESTRICT)") - } - - func testTableConstraints() async throws { - try await db.create(table: "planets") - .column("id", type: .bigint) - .column("name", type: .text) - .column("diameter", type: .int) - .column("galaxy_name", type: .text) - .column("galaxy_id", type: .bigint) - .primaryKey("id") - .unique("name") - .check(SQLRaw("diameter > 0"), named: "non-zero-diameter") - .foreignKey( - ["galaxy_id", "galaxy_name"], - references: "galaxies", - ["id", "name"] - ).run() - - XCTAssertEqual(db.results[0], -""" -CREATE TABLE `planets`(`id` BIGINT, `name` TEXT, `diameter` INTEGER, `galaxy_name` TEXT, `galaxy_id` BIGINT, PRIMARY KEY (`id`), UNIQUE (`name`), CONSTRAINT `non-zero-diameter` CHECK (diameter > 0), FOREIGN KEY (`galaxy_id`, `galaxy_name`) REFERENCES `galaxies` (`id`, `name`)) -""" - ) - } - - func testCompositePrimaryKeyTableConstraint() async throws { - try await db.create(table: "planets1") - .column("id1", type: .bigint) - .column("id2", type: .bigint) - .primaryKey("id1", "id2") - .run() - - XCTAssertEqual(db.results[0], "CREATE TABLE `planets1`(`id1` BIGINT, `id2` BIGINT, PRIMARY KEY (`id1`, `id2`))") - } - - func testCompositeUniqueTableConstraint() async throws { - try await db.create(table: "planets1") - .column("id1", type: .bigint) - .column("id2", type: .bigint) - .unique("id1", "id2") - .run() - - XCTAssertEqual(db.results[0], "CREATE TABLE `planets1`(`id1` BIGINT, `id2` BIGINT, UNIQUE (`id1`, `id2`))") - } - - func testPrimaryKeyTableConstraintVariants() async throws { - try await db.create(table: "planets1") - .column("galaxy_name", type: .text) - .column("galaxy_id", type: .bigint) - .foreignKey( - ["galaxy_id", "galaxy_name"], - references: "galaxies", - ["id", "name"] - ).run() - - try await db.create(table: "planets2") - .column("galaxy_id", type: .bigint) - .foreignKey( - ["galaxy_id"], - references: "galaxies", - ["id"] - ).run() - - try await db.create(table: "planets3") - .column("galaxy_id", type: .bigint) - .foreignKey( - ["galaxy_id"], - references: "galaxies", - ["id"], - onDelete: .restrict, - onUpdate: .cascade - ).run() - - XCTAssertEqual(db.results[0], "CREATE TABLE `planets1`(`galaxy_name` TEXT, `galaxy_id` BIGINT, FOREIGN KEY (`galaxy_id`, `galaxy_name`) REFERENCES `galaxies` (`id`, `name`))") - - XCTAssertEqual(db.results[1], "CREATE TABLE `planets2`(`galaxy_id` BIGINT, FOREIGN KEY (`galaxy_id`) REFERENCES `galaxies` (`id`))") - - XCTAssertEqual(db.results[2], "CREATE TABLE `planets3`(`galaxy_id` BIGINT, FOREIGN KEY (`galaxy_id`) REFERENCES `galaxies` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE)") - } - - func testCreateTableAsSelectQuery() async throws { - try await db.create(table: "normalized_planet_names") - .column("id", type: .bigint, .primaryKey(autoIncrement: false), .notNull) - .column("name", type: .text, .unique, .notNull) - .select { $0 - .distinct() - .column("id", as: "id") - .column(SQLFunction("LOWER", args: SQLColumn("name")), as: "name") - .from("planets") - .where("galaxy_id", .equal, SQLBind(1)) - } - .run() - - XCTAssertEqual(db.results[0], "CREATE TABLE `normalized_planet_names`(`id` BIGINT PRIMARY KEY NOT NULL, `name` TEXT UNIQUE NOT NULL) AS SELECT DISTINCT `id` AS `id`, LOWER(`name`) AS `name` FROM `planets` WHERE `galaxy_id` = ?") - } - - // MARK: Row Decoder - - func testSQLRowDecoder() async throws { - struct Foo: Codable { - let id: UUID - let foo: Int - let bar: Double? - let baz: String - let waldoFred: Int? - } - - struct FooWithForeignKey: Codable { - let id: UUID - let foo: Int - let bar: Double? - let baz: String - let waldoFredID: Int - } - - do { - let row = TestRow(data: [ - "id": .some(UUID()), - "foo": .some(42), - "bar": .none, - "baz": .some("vapor"), - "waldoFred": .some(2015) - ]) - - let foo = try row.decode(model: Foo.self) - XCTAssertEqual(foo.foo, 42) - XCTAssertEqual(foo.bar, nil) - XCTAssertEqual(foo.baz, "vapor") - XCTAssertEqual(foo.waldoFred, 2015) - } catch { - XCTFail("Could not decode row \(error)") - } - do { - let row = TestRow(data: [ - "foos_id": .some(UUID()), - "foos_foo": .some(42), - "foos_bar": .none, - "foos_baz": .some("vapor"), - "foos_waldoFred": .some(2015) - ]) - - let foo = try row.decode(model: Foo.self, prefix: "foos_") - XCTAssertEqual(foo.foo, 42) - XCTAssertEqual(foo.bar, nil) - XCTAssertEqual(foo.baz, "vapor") - XCTAssertEqual(foo.waldoFred, 2015) - } catch { - XCTFail("Could not decode row with prefix \(error)") - } - do { - let row = TestRow(data: [ - "id": .some(UUID()), - "foo": .some(42), - "bar": .none, - "baz": .some("vapor"), - "waldo_fred": .some(2015) - ]) - - let foo = try row.decode(model: Foo.self, keyDecodingStrategy: .convertFromSnakeCase) - XCTAssertEqual(foo.foo, 42) - XCTAssertEqual(foo.bar, nil) - XCTAssertEqual(foo.baz, "vapor") - XCTAssertEqual(foo.waldoFred, 2015) - } catch { - XCTFail("Could not decode row with keyDecodingStrategy \(error)") - } - do { - let row = TestRow(data: [ - "id": .some(UUID()), - "foo": .some(42), - "bar": .none, - "baz": .some("vapor"), - "waldoFredID": .some(2015) - ]) - - /// An implementation of CodingKey that's useful for combining and transforming keys as strings. - struct AnyKey: CodingKey { - var stringValue: String - var intValue: Int? - - init?(stringValue: String) { - self.stringValue = stringValue - self.intValue = nil - } - - init?(intValue: Int) { - self.stringValue = String(intValue) - self.intValue = intValue - } - } - - func decodeIdToID(_ keys: [CodingKey]) -> CodingKey { - let keyString = keys.last!.stringValue - - if let range = keyString.range(of: "Id", options: [.anchored, .backwards]) { - return AnyKey(stringValue: keyString[.. String { - body.joined(separator: " ") - } - - func testDropTriggerOptions() async throws { - var dialect = GenericDialect() - dialect.setTriggerSyntax(drop: [.supportsCascade, .supportsTableName]) - debugPrint(dialect.triggerSyntax.drop) - db._dialect = dialect - - try await db.drop(trigger: "foo").table("planets").run() - XCTAssertEqual(db.results.popLast(), "DROP TRIGGER `foo` ON `planets`") - - try await db.drop(trigger: "foo").table("planets").ifExists().run() - XCTAssertEqual(db.results.popLast(), "DROP TRIGGER IF EXISTS `foo` ON `planets`") - - try await db.drop(trigger: "foo").table("planets").ifExists().cascade().run() - XCTAssertEqual(db.results.popLast(), "DROP TRIGGER IF EXISTS `foo` ON `planets` CASCADE") - - db._dialect.supportsIfExists = false - try await db.drop(trigger: "foo").table("planets").ifExists().run() - XCTAssertEqual(db.results.popLast(), "DROP TRIGGER `foo` ON `planets`") - - db._dialect.triggerSyntax.drop = .supportsCascade - try await db.drop(trigger: "foo").table("planets").run() - XCTAssertEqual(db.results.popLast(), "DROP TRIGGER `foo`") - - db._dialect.triggerSyntax.drop = [] - try await db.drop(trigger: "foo").table("planets").ifExists().cascade().run() - XCTAssertEqual(db.results.popLast(), "DROP TRIGGER `foo`") - } - - func testMySqlTriggerCreates() async throws { - var dialect = GenericDialect() - dialect.setTriggerSyntax(create: [.supportsBody, .requiresForEachRow, .supportsOrder]) - - db._dialect = dialect - - try await db.create(trigger: "foo", table: "planet", when: .before, event: .insert) - .body(self.body.map { SQLRaw($0) }) - .order(precedence: .precedes, otherTriggerName: "other") - .run() - XCTAssertEqual(db.results.popLast(), "CREATE TRIGGER `foo` BEFORE INSERT ON `planet` FOR EACH ROW PRECEDES `other` BEGIN \(bodyText()) END;") - } - - func testSqliteTriggerCreates() async throws { - var dialect = GenericDialect() - dialect.setTriggerSyntax(create: [.supportsBody, .supportsCondition]) - db._dialect = dialect - - try await db.create(trigger: "foo", table: "planet", when: .before, event: .insert) - .body(self.body.map { SQLRaw($0) }) - .condition("\(ident: "foo") = \(ident: "bar")" as SQLQueryString) - .run() - XCTAssertEqual(db.results.popLast(), "CREATE TRIGGER `foo` BEFORE INSERT ON `planet` WHEN `foo` = `bar` BEGIN \(bodyText()) END;") - } - - func testPostgreSqlTriggerCreates() async throws { - var dialect = GenericDialect() - dialect.setTriggerSyntax(create: [.supportsForEach, .postgreSQLChecks, .supportsCondition, .conditionRequiresParentheses, .supportsConstraints]) - - db._dialect = dialect - - try await db.create(trigger: "foo", table: "planet", when: .after, event: .insert) - .each(.row) - .isConstraint() - .timing(.initiallyDeferred) - .condition("\(ident: "foo") = \(ident: "bar")" as SQLQueryString) - .procedure("qwer") - .referencedTable(SQLIdentifier("galaxies")) - .run() - - XCTAssertEqual(db.results.popLast(), "CREATE CONSTRAINT TRIGGER `foo` AFTER INSERT ON `planet` FROM `galaxies` INITIALLY DEFERRED FOR EACH ROW WHEN (`foo` = `bar`) EXECUTE PROCEDURE `qwer`") - } -} diff --git a/Tests/SQLKitTests/AsyncTests.swift b/Tests/SQLKitTests/AsyncTests.swift new file mode 100644 index 00000000..e52f92f3 --- /dev/null +++ b/Tests/SQLKitTests/AsyncTests.swift @@ -0,0 +1,163 @@ +import SQLKit +import XCTest + +final class AsyncSQLKitTests: XCTestCase { + var db = TestDatabase() + + override class func setUp() { + XCTAssert(isLoggingConfigured) + } + + func testSQLDatabaseAsyncAndFutures() async throws { + try await self.db.execute(sql: SQLRaw("TEST"), { _ in XCTFail("Should not receive results") }).get() + XCTAssertEqual(self.db.results[0], "TEST") + + try await self.db.execute(sql: SQLRaw("TEST"), { _ in XCTFail("Should not receive results") }) + XCTAssertEqual(self.db.results[1], "TEST") + } + + func testSQLQueryBuilderAsyncAndFutures() async throws { + self.db.outputs = [TestRow(data: [:])] + try await self.db.update("a").set("b", to: "c").run().get() + XCTAssertEqual(self.db.results[0], "UPDATE ``a`` SET ``b`` = &1") + + self.db.outputs = [TestRow(data: [:])] + try await self.db.update("a").set("b", to: "c").run() + XCTAssertEqual(self.db.results[1], "UPDATE ``a`` SET ``b`` = &1") + } + + func testSQLQueryFetcherRunMethodsAsyncAndFutures() async throws { + try await self.db.select().column("a").from("b").run { _ in XCTFail("Should not receive results") } + XCTAssertEqual(self.db.results[0], "SELECT ``a`` FROM ``b``") + + try await self.db.select().column("a").from("b").run { _ in XCTFail("Should not receive results") }.get() + XCTAssertEqual(self.db.results[1], "SELECT ``a`` FROM ``b``") + + self.db.outputs = [TestRow(data: [:])] + try await self.db.select().column("a").from("b").run { XCTAssert($0.allColumns.isEmpty) } + XCTAssertEqual(self.db.results[2], "SELECT ``a`` FROM ``b``") + + self.db.outputs = [TestRow(data: [:])] + try await self.db.select().column("a").from("b").run { XCTAssert($0.allColumns.isEmpty) }.get() + XCTAssertEqual(self.db.results[3], "SELECT ``a`` FROM ``b``") + + try await self.db.select().column("a").from("b").run(decoding: [String: String].self, { _ in XCTFail("Should not receive results") }) + XCTAssertEqual(self.db.results[4], "SELECT ``a`` FROM ``b``") + + try await self.db.select().column("a").from("b").run(decoding: [String: String].self, { _ in XCTFail("Should not receive results") }).get() + XCTAssertEqual(self.db.results[5], "SELECT ``a`` FROM ``b``") + + self.db.outputs = [TestRow(data: [:])] + try await self.db.select().column("a").from("b").run(decoding: [String: String].self, { XCTAssertNotNil(try? $0.get()) }) + XCTAssertEqual(self.db.results[6], "SELECT ``a`` FROM ``b``") + + self.db.outputs = [TestRow(data: [:])] + try await self.db.select().column("a").from("b").run(decoding: [String: String].self, { XCTAssertNotNil(try? $0.get()) }).get() + XCTAssertEqual(self.db.results[7], "SELECT ``a`` FROM ``b``") + + try await self.db.select().column("a").from("b").run(decoding: [String: String].self, keyDecodingStrategy: .useDefaultKeys, { _ in XCTFail("Should not receive results") }) + XCTAssertEqual(self.db.results[8], "SELECT ``a`` FROM ``b``") + + try await self.db.select().column("a").from("b").run(decoding: [String: String].self, keyDecodingStrategy: .useDefaultKeys, { _ in XCTFail("Should not receive results") }).get() + XCTAssertEqual(self.db.results[9], "SELECT ``a`` FROM ``b``") + + self.db.outputs = [TestRow(data: [:])] + try await self.db.select().column("a").from("b").run(decoding: [String: String].self, keyDecodingStrategy: .useDefaultKeys, { XCTAssertNotNil(try? $0.get()) }) + XCTAssertEqual(self.db.results[10], "SELECT ``a`` FROM ``b``") + + self.db.outputs = [TestRow(data: [:])] + try await self.db.select().column("a").from("b").run(decoding: [String: String].self, keyDecodingStrategy: .useDefaultKeys, { XCTAssertNotNil(try? $0.get()) }).get() + XCTAssertEqual(self.db.results[11], "SELECT ``a`` FROM ``b``") + } + + func testSQLQueryFetcherAllMethodsAsyncAndFutures() async throws { + let res0 = try await self.db.select().column("a").from("b").all() + XCTAssert(res0.isEmpty) + XCTAssertEqual(self.db.results[0], "SELECT ``a`` FROM ``b``") + + self.db.outputs = [TestRow(data: [:])] + let res1 = try await self.db.select().column("a").from("b").all() + XCTAssertEqual(res1.count, 1) + XCTAssertEqual(self.db.results[1], "SELECT ``a`` FROM ``b``") + + self.db.outputs = [TestRow(data: [:])] + let res2 = try await self.db.select().column("a").from("b").all().get() + XCTAssertEqual(res2.count, 1) + XCTAssertEqual(self.db.results[2], "SELECT ``a`` FROM ``b``") + + self.db.outputs = [TestRow(data: [:])] + let res3 = try await self.db.select().column("a").from("b").all(decoding: [String: String].self) + XCTAssertEqual(res3.count, 1) + XCTAssertEqual(self.db.results[3], "SELECT ``a`` FROM ``b``") + + self.db.outputs = [TestRow(data: [:])] + let res4 = try await self.db.select().column("a").from("b").all(decoding: [String: String].self).get() + XCTAssertEqual(res4.count, 1) + XCTAssertEqual(self.db.results[4], "SELECT ``a`` FROM ``b``") + + self.db.outputs = [TestRow(data: [:])] + let res5 = try await self.db.select().column("a").from("b").all(decoding: [String: String].self, keyDecodingStrategy: .useDefaultKeys) + XCTAssertEqual(res5.count, 1) + XCTAssertEqual(self.db.results[5], "SELECT ``a`` FROM ``b``") + + self.db.outputs = [TestRow(data: [:])] + let res6 = try await self.db.select().column("a").from("b").all(decoding: [String: String].self, keyDecodingStrategy: .useDefaultKeys).get() + XCTAssertEqual(res6.count, 1) + XCTAssertEqual(self.db.results[6], "SELECT ``a`` FROM ``b``") + + self.db.outputs = [TestRow(data: ["a": "a"])] + let res7 = try await self.db.select().column("a").from("b").all(decodingColumn: "a", as: String.self) + XCTAssertEqual(res7.count, 1) + XCTAssertEqual(self.db.results[7], "SELECT ``a`` FROM ``b``") + + self.db.outputs = [TestRow(data: ["a": "a"])] + let res8 = try await self.db.select().column("a").from("b").all(decodingColumn: "a", as: String.self).get() + XCTAssertEqual(res8.count, 1) + XCTAssertEqual(self.db.results[8], "SELECT ``a`` FROM ``b``") + } + + func testSQLQueryFetcherFirstMethodsAsyncAndFutures() async throws { + let res0 = try await self.db.select().column("a").from("b").first() + XCTAssertNil(res0) + XCTAssertEqual(self.db.results[0], "SELECT ``a`` FROM ``b`` LIMIT 1") + + self.db.outputs = [TestRow(data: [:])] + let res1 = try await self.db.select().column("a").from("b").first() + XCTAssertNotNil(res1) + XCTAssertEqual(self.db.results[1], "SELECT ``a`` FROM ``b`` LIMIT 1") + + self.db.outputs = [TestRow(data: [:])] + let res2 = try await self.db.select().column("a").from("b").first(decoding: [String: String].self) + XCTAssertNotNil(res2) + XCTAssertEqual(self.db.results[2], "SELECT ``a`` FROM ``b`` LIMIT 1") + + self.db.outputs = [TestRow(data: [:])] + let res3 = try await self.db.select().column("a").from("b").first(decoding: [String: String].self, keyDecodingStrategy: .useDefaultKeys) + XCTAssertNotNil(res3) + XCTAssertEqual(self.db.results[3], "SELECT ``a`` FROM ``b`` LIMIT 1") + + self.db.outputs = [TestRow(data: ["a": "a"])] + let res4 = try await self.db.select().column("a").from("b").first(decodingColumn: "a", as: String.self) + XCTAssertNotNil(res4) + XCTAssertEqual(self.db.results[4], "SELECT ``a`` FROM ``b`` LIMIT 1") + + self.db.outputs = [TestRow(data: [:])] + let res5 = try await self.db.select().column("a").from("b").first().get() + XCTAssertNotNil(res5) + XCTAssertEqual(self.db.results[5], "SELECT ``a`` FROM ``b`` LIMIT 1") + + self.db.outputs = [TestRow(data: [:])] + let res6 = try await self.db.select().column("a").from("b").first(decoding: [String: String].self).get() + XCTAssertNotNil(res6) + XCTAssertEqual(self.db.results[6], "SELECT ``a`` FROM ``b`` LIMIT 1") + + self.db.outputs = [TestRow(data: [:])] + let res7 = try await self.db.select().column("a").from("b").first(decoding: [String: String].self, keyDecodingStrategy: .useDefaultKeys).get() + XCTAssertNotNil(res7) + XCTAssertEqual(self.db.results[7], "SELECT ``a`` FROM ``b`` LIMIT 1") + + let res8 = try await self.db.select().column("a").from("b").first(decodingColumn: "a", as: String.self).get() + XCTAssertNil(res8) + XCTAssertEqual(self.db.results[8], "SELECT ``a`` FROM ``b`` LIMIT 1") + } +} diff --git a/Tests/SQLKitTests/BaseTests.swift b/Tests/SQLKitTests/BaseTests.swift new file mode 100644 index 00000000..dc550e14 --- /dev/null +++ b/Tests/SQLKitTests/BaseTests.swift @@ -0,0 +1,247 @@ +@testable import SQLKit +import struct Logging.Logger +import protocol NIOCore.EventLoop +import class NIOCore.EventLoopFuture +import SQLKitBenchmark +import XCTest + +final class SQLKitTests: XCTestCase { + var db = TestDatabase() + + override class func setUp() { + XCTAssert(isLoggingConfigured) + } + + // MARK: SQLBenchmark + + func testBenchmark() async throws { + let benchmarker = SQLBenchmarker(on: db) + + try await benchmarker.runAllTests() + } + + // MARK: Operators + + func testBinaryOperators() { + XCTAssertSerialization( + of: self.db.update("planets") + .set(SQLIdentifier("moons"), to: SQLBinaryExpression( + left: SQLIdentifier("moons"), + op: SQLBinaryOperator.add, + right: SQLLiteral.numeric("1") + )) + .where("best_at_space", .greaterThanOrEqual, "yes"), + is: "UPDATE ``planets`` SET ``moons`` = ``moons`` + 1 WHERE ``best_at_space`` >= &1" + ) + } + + func testInsertWithArrayOfEncodable() { + func weird(_ builder: SQLInsertBuilder, values: some Sequence) -> SQLInsertBuilder { + builder.values(Array(values)) + } + + let output = XCTAssertNoThrowWithResult(weird( + self.db.insert(into: "planets").columns("name"), + values: ["Jupiter"] + ) + .advancedSerialize() + ) + XCTAssertEqual(output?.sql, "INSERT INTO ``planets`` (``name``) VALUES (&1)") + XCTAssertEqual(output?.binds as? [String], ["Jupiter"]) // instead of [["Jupiter"]] + } + + // MARK: JSON paths + + func testJSONPaths() { + XCTAssertSerialization( + of: self.db.select() + .column(SQLNestedSubpathExpression(column: "json", path: ["a"])) + .column(SQLNestedSubpathExpression(column: "json", path: ["a", "b"])) + .column(SQLNestedSubpathExpression(column: "json", path: ["a", "b", "c"])) + .column(SQLNestedSubpathExpression(column: SQLColumn("json", table: "table"), path: ["a", "b"])), + is: "SELECT (``json``-»»'a'), (``json``-»'a'-»»'b'), (``json``-»'a'-»'b'-»»'c'), (``table``.``json``-»'a'-»»'b')" + ) + } + + // MARK: Misc + + func testQuoting() { + XCTAssertSerialization(of: SQLRawBuilder("\(ident: "foo``bar``") \(literal: "foo'bar'")", on: self.db), is: "``foo````bar`````` 'foo''bar'''") + } + + func testStringHandlingUtilities() { + /// `encapitalized` + XCTAssertEqual("".encapitalized, "") + XCTAssertEqual("a".encapitalized, "A") + XCTAssertEqual("A".encapitalized, "A") + XCTAssertEqual("aa".encapitalized, "Aa") + XCTAssertEqual("Aa".encapitalized, "Aa") + XCTAssertEqual("aA".encapitalized, "AA") + XCTAssertEqual("AA".encapitalized, "AA") + + /// `decapitalized` + XCTAssertEqual("".decapitalized, "") + XCTAssertEqual("a".decapitalized, "a") + XCTAssertEqual("A".decapitalized, "a") + XCTAssertEqual("aa".decapitalized, "aa") + XCTAssertEqual("Aa".decapitalized, "aa") + XCTAssertEqual("aA".decapitalized, "aA") + XCTAssertEqual("AA".decapitalized, "aA") + + /// `convertedFromSnakeCase` + XCTAssertEqual("".convertedFromSnakeCase, "") + XCTAssertEqual("_".convertedFromSnakeCase, "_") + XCTAssertEqual("__".convertedFromSnakeCase, "__") + XCTAssertEqual("a".convertedFromSnakeCase, "a") + XCTAssertEqual("a_".convertedFromSnakeCase, "a_") + XCTAssertEqual("a_a".convertedFromSnakeCase, "aA") + XCTAssertEqual("aA_a".convertedFromSnakeCase, "aAA") + XCTAssertEqual("_a".convertedFromSnakeCase, "_a") + XCTAssertEqual("_a_".convertedFromSnakeCase, "_a_") + XCTAssertEqual("a_b_c".convertedFromSnakeCase, "aBC") + XCTAssertEqual("_a_b_c_".convertedFromSnakeCase, "_aBC_") + XCTAssertEqual("_a_b_bcc_".convertedFromSnakeCase, "_aBBcc_") + + /// `convertedToSnakeCase` + XCTAssertEqual("".convertedToSnakeCase, "") + XCTAssertEqual("_".convertedToSnakeCase, "_") + XCTAssertEqual("__".convertedToSnakeCase, "__") + XCTAssertEqual("a".convertedToSnakeCase, "a") + XCTAssertEqual("a_".convertedToSnakeCase, "a_") + XCTAssertEqual("aA".convertedToSnakeCase, "a_a") + XCTAssertEqual("aAA".convertedToSnakeCase, "a_aA") + XCTAssertEqual("_a".convertedToSnakeCase, "_a") + XCTAssertEqual("_a_".convertedToSnakeCase, "_a_") + XCTAssertEqual("aBC".convertedToSnakeCase, "a_bC") + XCTAssertEqual("_aBC_".convertedToSnakeCase, "_a_bC_") + XCTAssertEqual("aBBcc".convertedToSnakeCase, "a_b_bcc") + XCTAssertEqual("_aBBcc_".convertedToSnakeCase, "_a_b_bcc_") + + /// `sqlkit_firstRange(of:)` + XCTAssertEqual("a".sqlkit_firstRange(of: "abc"), nil) + XCTAssertEqual("abba".sqlkit_firstRange(of: "abc"), nil) + XCTAssertEqual("abc".sqlkit_firstRange(of: "abc"), "abc".startIndex ..< "abc".endIndex) + XCTAssertEqual("aabca".sqlkit_firstRange(of: "abc"), "aabca".index(after: "aabca".startIndex) ..< "aabca".index(before: "aabca".endIndex)) + XCTAssertEqual("abcabc".sqlkit_firstRange(of: "abc"), "abcabc".startIndex ..< "abcabc".index("abcabc".startIndex, offsetBy: 3)) + XCTAssertEqual("aabc_abca".sqlkit_firstRange(of: "abc"), "aabc_abca".index(after: "aabc_abca".startIndex) ..< "aabc_abca".index("aabc_abca".startIndex, offsetBy: 4)) + + /// `sqlkit_replacing(_:with:)` + XCTAssertEqual("abc".sqlkit_replacing("abc", with: "def"), "def") + XCTAssertEqual("aabca".sqlkit_replacing("abc", with: "def"), "adefa") + XCTAssertEqual("abcabc".sqlkit_replacing("abc", with: "def"), "defdef") + XCTAssertEqual("aabc_abca".sqlkit_replacing("abc", with: "def"), "adef_defa") + + /// `codingKeyValue` + XCTAssertEqual("a".codingKeyValue.stringValue, "a") + + /// `drop(prefix:)` + XCTAssertEqual("abcdef".drop(prefix: "abc"), "def") + XCTAssertEqual("acbdef".drop(prefix: "abc"), "acbdef") + XCTAssertEqual("abcdef".drop(prefix: String?.none), "abcdef") + } + + func testDatabaseDefaultProperties() { + XCTAssertNil(self.db.version) + XCTAssertEqual(self.db.queryLogLevel, .debug) + } + + func testDatabaseLoggerDatabase() async throws { + let db = self.db.logging(to: .init(label: "l")) + + XCTAssertNotNil(db.eventLoop) + XCTAssertNil(db.version) + XCTAssertEqual(db.dialect.name, self.db.dialect.name) + XCTAssertEqual(db.queryLogLevel, self.db.queryLogLevel) + await XCTAssertNotNilAsync(try await db.execute(sql: SQLRaw("TEST"), { _ in })) + await XCTAssertNotNilAsync(try await db.execute(sql: SQLRaw("TEST"), { _ in }).get()) + } + + func testDatabaseDefaultAsyncImpl() async throws { + struct TestNoAsyncDatabase: SQLDatabase { + func execute(sql query: any SQLExpression, _ onRow: @escaping @Sendable (any SQLRow) -> ()) -> EventLoopFuture { self.eventLoop.makeSucceededVoidFuture() } + var logger: Logger { .init(label: "l") } + var eventLoop: any EventLoop { FakeEventLoop() } + var dialect: any SQLDialect { GenericDialect() } + } + await XCTAssertNotNilAsync(try await TestNoAsyncDatabase().execute(sql: SQLRaw("TEST"), { _ in })) + } + + func testDatabaseVersion() { + struct TestVersion: SQLDatabaseReportedVersion { + let stringValue: String + } + struct AnotherTestVersion: SQLDatabaseReportedVersion { + let stringValue: String + } + + XCTAssert(TestVersion(stringValue: "a") == TestVersion(stringValue: "a")) + XCTAssertFalse(TestVersion(stringValue: "a").isEqual(to: AnotherTestVersion(stringValue: "a"))) + XCTAssert(TestVersion(stringValue: "a") != TestVersion(stringValue: "b")) + XCTAssert(TestVersion(stringValue: "a") < TestVersion(stringValue: "b")) + XCTAssertFalse(TestVersion(stringValue: "a").isOlder(than: AnotherTestVersion(stringValue: "a"))) + XCTAssert(TestVersion(stringValue: "a") <= TestVersion(stringValue: "a")) + XCTAssert(TestVersion(stringValue: "b") > TestVersion(stringValue: "a")) + XCTAssert(TestVersion(stringValue: "a") >= TestVersion(stringValue: "a")) + } + + func testDatabaseWithSession() async { + await XCTAssertAsync(try await self.db.withSession { + XCTAssertNotNil($0) + return true + }) + } + + func testDialectDefaultImpls() { + struct TestDialect: SQLDialect { + var name: String { "test" } + var identifierQuote: any SQLExpression { SQLRaw("`") } + var supportsAutoIncrement: Bool { false } + var autoIncrementClause: any SQLExpression { SQLRaw("") } + func bindPlaceholder(at position: Int) -> any SQLExpression { SQLLiteral.numeric("\(position)") } + func literalBoolean(_ value: Bool) -> any SQLExpression { SQLRaw("\(value)") } + } + + XCTAssertEqual((TestDialect().literalStringQuote as? SQLRaw)?.sql, "'") + XCTAssertNil(TestDialect().autoIncrementFunction) + XCTAssertEqual((TestDialect().literalDefault as? SQLRaw)?.sql, "DEFAULT") + XCTAssert(TestDialect().supportsIfExists) + XCTAssertEqual(TestDialect().enumSyntax, .unsupported) + XCTAssertFalse(TestDialect().supportsDropBehavior) + XCTAssertFalse(TestDialect().supportsReturning) + XCTAssertEqual(TestDialect().triggerSyntax.create, []) + XCTAssertEqual(TestDialect().triggerSyntax.drop, []) + XCTAssertNil(TestDialect().alterTableSyntax.alterColumnDefinitionClause) + XCTAssertNil(TestDialect().alterTableSyntax.alterColumnDefinitionTypeKeyword) + XCTAssert(TestDialect().alterTableSyntax.allowsBatch) + XCTAssertNil(TestDialect().customDataType(for: .int)) + XCTAssertEqual((TestDialect().normalizeSQLConstraint(identifier: SQLRaw("")) as? SQLRaw)?.sql, "") + XCTAssertEqual(TestDialect().upsertSyntax, .unsupported) + XCTAssertEqual(TestDialect().unionFeatures, [.union, .unionAll]) + XCTAssertNil(TestDialect().sharedSelectLockExpression) + XCTAssertNil(TestDialect().exclusiveSelectLockExpression) + XCTAssertNil(TestDialect().nestedSubpathExpression(in: SQLRaw(""), for: [""])) + } + + func testAdditionalStatementAPI() { + var serializer = SQLSerializer(database: self.db) + serializer.statement { + $0.append("a") + $0.append(SQLRaw("a")) + + $0.append("a", "b") + $0.append(SQLRaw("a"), "b") + $0.append("a", SQLRaw("b")) + $0.append(SQLRaw("a"), SQLRaw("b")) + + $0.append("a", "b", "c") + $0.append(SQLRaw("a"), "b", "c") + $0.append("a", SQLRaw("b"), "c") + $0.append("a", "b", SQLRaw("c")) + $0.append(SQLRaw("a"), SQLRaw("b"), "c") + $0.append(SQLRaw("a"), "b", SQLRaw("c")) + $0.append("a", SQLRaw("b"), SQLRaw("c")) + $0.append(SQLRaw("a"), SQLRaw("b"), SQLRaw("c")) + } + XCTAssertEqual(serializer.sql, "a a a b a b a b a b a b c a b c a b c a b c a b c a b c a b c a b c") + } +} diff --git a/Tests/SQLKitTests/BasicQueryTests.swift b/Tests/SQLKitTests/BasicQueryTests.swift new file mode 100644 index 00000000..feb22ad2 --- /dev/null +++ b/Tests/SQLKitTests/BasicQueryTests.swift @@ -0,0 +1,434 @@ +import SQLKit +import XCTest + +final class BasicQueryTests: XCTestCase { + var db = TestDatabase() + + override class func setUp() { + XCTAssert(isLoggingConfigured) + } + + // MARK: Select + + func testSelect_unqualifiedColums() { + XCTAssertSerialization( + of: self.db.select() + .column("name") + .column(SQLIdentifier("name")) + .columns("name") + .columns(["name"]) + .columns(SQLIdentifier("name")) + .columns([SQLIdentifier("name")]), + is: "SELECT ``name``, ``name``, ``name``, ``name``, ``name``, ``name``" + ) + } + + func testSelect_columnAliasing() { + XCTAssertSerialization( + of: self.db.select() + .column("name", as: "n") + .column(SQLIdentifier("name"), as: "n") + .column(SQLIdentifier("name"), as: SQLIdentifier("n")) + .column(SQLAlias("name", as: "n")) + .column(SQLAlias(SQLIdentifier("name"), as: "n")) + .column(SQLAlias(SQLIdentifier("name"), as: SQLIdentifier("n"))), + is: "SELECT ``name`` AS ``n``, ``name`` AS ``n``, ``name`` AS ``n``, ``name`` AS ``n``, ``name`` AS ``n``, ``name`` AS ``n``" + ) + } + + func testSelect_fromAliasing() { + XCTAssertSerialization( + of: self.db.select() + .from("planets", as: "p"), + is: "SELECT FROM ``planets`` AS ``p``" + ) + XCTAssertSerialization( + of: self.db.select() + .from(SQLIdentifier("planets"), as: SQLIdentifier("p")), + is: "SELECT FROM ``planets`` AS ``p``" + ) + } + + func testSelect_tableAllCols() { + XCTAssertSerialization( + of: self.db.select().columns(["*"]).from("planets").where("name", .equal, SQLBind("Earth")), + is: "SELECT * FROM ``planets`` WHERE ``name`` = &1" + ) + XCTAssertSerialization( + of: self.db.select().column(SQLColumn(SQLLiteral.all, table: SQLIdentifier("planets"))).from("planets").where("name", .equal, SQLBind("Earth")), + is: "SELECT ``planets``.* FROM ``planets`` WHERE ``name`` = &1" + ) + } + + func testSelect_whereEncodable() { + XCTAssertSerialization( + of: self.db.select().column("*").from("planets").where("name", .equal, "Earth").orWhere("name", .equal, "Mars"), + is: "SELECT * FROM ``planets`` WHERE ``name`` = &1 OR ``name`` = &2" + ) + } + + func testSelect_whereArrayEncodableWithString() { + XCTAssertSerialization( + of: self.db.select().column("*").from("planets").where("name", .in, ["Earth", "Mars"]).orWhere("name", .in, ["Venus", "Mercury"]), + is: "SELECT * FROM ``planets`` WHERE ``name`` IN (&1, &2) OR ``name`` IN (&3, &4)" + ) + } + + func testSelect_whereArrayEncodableWithIdentifier() { + XCTAssertSerialization( + of: self.db.select().column("*").from("planets").where(SQLIdentifier("name"), .in, ["Earth", "Mars"]).orWhere(SQLIdentifier("name"), .in, ["Venus", "Mercury"]), + is: "SELECT * FROM ``planets`` WHERE ``name`` IN (&1, &2) OR ``name`` IN (&3, &4)" + ) + } + + func testSelect_whereGroup() { + XCTAssertSerialization( + of: self.db.select().column("*").from("planets") + .where { $0.where("name", .equal, "Earth").orWhere("name", .equal, "Mars") } + .orWhere { $0.where("color", .notEqual, "yellow") } + .where("color", .equal, "blue"), + is: "SELECT * FROM ``planets`` WHERE (``name`` = &1 OR ``name`` = &2) OR (``color`` <> &3) AND ``color`` = &4" + ) + } + + func testSelect_whereEmptyGroup() { + XCTAssertSerialization( + of: self.db.select().column("*").from("planets").where { $0 }.orWhere { $0 }.where("color", .equal, "blue"), + is: "SELECT * FROM ``planets`` WHERE ``color`` = &1" + ) + } + + + func testSelect_whereColumn() { + XCTAssertSerialization( + of: self.db.select().column("*").from("planets").where("name", .notEqual, column: "color").orWhere("name", .equal, column: "greekName"), + is: "SELECT * FROM ``planets`` WHERE ``name`` <> ``color`` OR ``name`` = ``greekName``" + ) + } + + func testSelect_otherWheres() { + XCTAssertSerialization( + of: self.db.select() + .column("*") + .from("planets") + .where("name", .notEqual, SQLBind("color")) + .orWhere("name", .notEqual, SQLBind("color")), + is: "SELECT * FROM ``planets`` WHERE ``name`` <> &1 OR ``name`` <> &2" + ) + XCTAssertSerialization( + of: self.db.select() + .column("*") + .from("planets") + .where(SQLIdentifier("name"), .notEqual, column: SQLIdentifier("color")) + .orWhere(SQLIdentifier("name"), .equal, column: SQLIdentifier("greekName")), + is: "SELECT * FROM ``planets`` WHERE ``name`` <> ``color`` OR ``name`` = ``greekName``" + ) + XCTAssertSerialization( + of: self.db.select() + .column("*") + .from("planets") + .where(SQLIdentifier("name"), .notEqual, "color") + .orWhere(SQLIdentifier("name"), .equal, "greekName"), + is: "SELECT * FROM ``planets`` WHERE ``name`` <> &1 OR ``name`` = &2" + ) + XCTAssertSerialization( + of: self.db.select() + .column("*") + .from("planets") + .where(SQLIdentifier("name"), .notEqual, SQLBind("color")) + .orWhere(SQLIdentifier("name"), .equal, SQLBind("greekName")), + is: "SELECT * FROM ``planets`` WHERE ``name`` <> &1 OR ``name`` = &2" + ) + XCTAssertSerialization( + of: self.db.select() + .column("*") + .from("planets") + .orWhere(SQLIdentifier("name"), .equal, SQLBind("greekName")), + is: "SELECT * FROM ``planets`` WHERE ``name`` = &1" + ) + } + + func testSelect_havingEncodable() { + XCTAssertSerialization( + of: self.db.select().column("*").from("planets").having("name", .equal, "Earth").orHaving("name", .equal, "Mars"), + is: "SELECT * FROM ``planets`` HAVING ``name`` = &1 OR ``name`` = &2" + ) + } + + func testSelect_havingArrayEncodableWithString() { + XCTAssertSerialization( + of: self.db.select().column("*").from("planets").having("name", .in, ["Earth", "Mars"]).orHaving("name", .in, ["Venus", "Mercury"]), + is: "SELECT * FROM ``planets`` HAVING ``name`` IN (&1, &2) OR ``name`` IN (&3, &4)" + ) + } + + func testSelect_havingArrayEncodableWithIdentifier() { + XCTAssertSerialization( + of: self.db.select().column("*").from("planets").having(SQLIdentifier("name"), .in, ["Earth", "Mars"]).orHaving(SQLIdentifier("name"), .in, ["Venus", "Mercury"]), + is: "SELECT * FROM ``planets`` HAVING ``name`` IN (&1, &2) OR ``name`` IN (&3, &4)" + ) + } + + func testSelect_havingGroup() { + XCTAssertSerialization( + of: self.db.select().column("*").from("planets") + .having { $0.having("name", .equal, "Earth").orHaving("name", .equal, "Mars") } + .orHaving { $0.having("color", .notEqual, "yellow") } + .having("color", .equal, "blue"), + is: "SELECT * FROM ``planets`` HAVING (``name`` = &1 OR ``name`` = &2) OR (``color`` <> &3) AND ``color`` = &4" + ) + } + + func testSelect_havingEmptyGroup() { + XCTAssertSerialization( + of: self.db.select().column("*").from("planets").having { $0 }.orHaving { $0 }.having("color", .equal, "blue"), + is: "SELECT * FROM ``planets`` HAVING ``color`` = &1" + ) + } + + + func testSelect_havingColumn() { + XCTAssertSerialization( + of: self.db.select().column("*").from("planets").having("name", .notEqual, column: "color").orHaving("name", .equal, column: "greekName"), + is: "SELECT * FROM ``planets`` HAVING ``name`` <> ``color`` OR ``name`` = ``greekName``" + ) + } + + func testSelect_otherHavings() { + XCTAssertSerialization( + of: self.db.select() + .column("*") + .from("planets") + .having("name", .notEqual, SQLBind("color")) + .orHaving("name", .notEqual, SQLBind("color")), + is: "SELECT * FROM ``planets`` HAVING ``name`` <> &1 OR ``name`` <> &2" + ) + XCTAssertSerialization( + of: self.db.select() + .column("*") + .from("planets") + .having(SQLIdentifier("name"), .notEqual, column: SQLIdentifier("color")) + .orHaving(SQLIdentifier("name"), .equal, column: SQLIdentifier("greekName")), + is: "SELECT * FROM ``planets`` HAVING ``name`` <> ``color`` OR ``name`` = ``greekName``" + ) + XCTAssertSerialization( + of: self.db.select() + .column("*") + .from("planets") + .having(SQLIdentifier("name"), .notEqual, "color") + .orHaving(SQLIdentifier("name"), .equal, "greekName"), + is: "SELECT * FROM ``planets`` HAVING ``name`` <> &1 OR ``name`` = &2" + ) + XCTAssertSerialization( + of: self.db.select() + .column("*") + .from("planets") + .having(SQLIdentifier("name"), .notEqual, SQLBind("color")) + .orHaving(SQLIdentifier("name"), .equal, SQLBind("greekName")), + is: "SELECT * FROM ``planets`` HAVING ``name`` <> &1 OR ``name`` = &2" + ) + XCTAssertSerialization( + of: self.db.select() + .column("*") + .from("planets") + .orHaving(SQLIdentifier("name"), .equal, SQLBind("greekName")), + is: "SELECT * FROM ``planets`` HAVING ``name`` = &1" + ) + } + + func testSelect_withoutFrom() { + XCTAssertSerialization( + of: self.db.select().column(SQLAlias(SQLFunction("LAST_INSERT_ID"), as: SQLIdentifier("id"))), + is: "SELECT LAST_INSERT_ID() AS ``id``" + ) + } + + func testSelect_limitAndOrder() { + XCTAssertSerialization( + of: self.db.select() + .column("*") + .from("planets") + .limit(3) + .offset(5) + .orderBy("name"), + is: "SELECT * FROM ``planets`` ORDER BY ``name`` ASC LIMIT 3 OFFSET 5" + ) + + let builder = self.db.select().where(SQLLiteral.boolean(true)).limit(1).offset(2) + XCTAssertEqual(builder.limit, 1) + XCTAssertEqual(builder.offset, 2) + } + + // MARK: Update/delete + + func testUpdate() { + XCTAssertSerialization( + of: self.db.update("planets") + .where("name", .equal, "Jpuiter") + .set("name", to: "Jupiter") + .set(SQLIdentifier("name"), to: "Jupiter") + .set("name", to: SQLBind("Jupiter")), + is: "UPDATE ``planets`` SET ``name`` = &1, ``name`` = &2, ``name`` = &3 WHERE ``name`` = &4" + ) + + let builder = self.db.update("planets") + builder.returning = .init(.init("id")) + XCTAssertNotNil(builder.returning) + } + + func testDelete() { + XCTAssertSerialization( + of: self.db.delete(from: "planets") + .where("name", .equal, "Jupiter"), + is: "DELETE FROM ``planets`` WHERE ``name`` = &1" + ) + + let builder = self.db.delete(from: "planets") + builder.returning = .init(.init("id")) + XCTAssertNotNil(builder.returning) + } + + // MARK: Locking Clauses + + func testLockingClause_forUpdate() { + XCTAssertSerialization( + of: self.db.select() + .column("*") + .from("planets") + .where("name", .equal, "Earth") + .for(.update), + is: "SELECT * FROM ``planets`` WHERE ``name`` = &1 FOUR UPDATE" + ) + } + + func testLockingClause_forShare() { + XCTAssertSerialization( + of: self.db.select() + .column("*") + .from("planets") + .where("name", .equal, "Earth") + .for(.share), + is: "SELECT * FROM ``planets`` WHERE ``name`` = &1 FOUR SHAARE" + ) + } + + func testLockingClause_raw() { + XCTAssertSerialization( + of: self.db.select() + .column("*") + .from("planets") + .where("name", .equal, "Earth") + .lockingClause(SQLRaw("LOCK IN SHARE MODE")), + is: "SELECT * FROM ``planets`` WHERE ``name`` = &1 LOCK IN SHARE MODE" + ) + } + + // MARK: Group By/Having + + func testGroupByHaving() { + XCTAssertSerialization( + of: self.db.select() + .column("*") + .from("planets") + .groupBy("color") + .having("color", .equal, "blue"), + is: "SELECT * FROM ``planets`` GROUP BY ``color`` HAVING ``color`` = &1" + ) + } + + // MARK: Distinct + + func testDistinct() { + XCTAssertSerialization( + of: self.db.select() + .column("*") + .from("planets") + .groupBy("color") + .having("color", .equal, "blue") + .distinct(), + is: "SELECT DISTINCT * FROM ``planets`` GROUP BY ``color`` HAVING ``color`` = &1" + ) + } + + func testDistinctColumns() { + XCTAssertSerialization( + of: self.db.select() + .distinct(on: "name", "color") + .from("planets"), + is: "SELECT DISTINCT ``name``, ``color`` FROM ``planets``" + ) + XCTAssertSerialization( + of: self.db.select() + .distinct(on: SQLIdentifier("name"), SQLIdentifier("color")) + .from("planets"), + is: "SELECT DISTINCT ``name``, ``color`` FROM ``planets``" + ) + } + + func testDistinctExpression() { + XCTAssertSerialization( + of: self.db.select() + .column(SQLFunction("COUNT", args: SQLDistinct("name", "color"))) + .from("planets"), + is: "SELECT COUNT(DISTINCT ``name``, ``color``) FROM ``planets``" + ) + } + + // MARK: Joins + + func testSimpleJoin() { + XCTAssertSerialization( + of: self.db.select() + .column("*") + .from("planets") + .join("moons", on: SQLColumn("planet_id", table: "moons"), .equal, SQLColumn("id", table: "planets")), + is: "SELECT * FROM ``planets`` INNER JOIN ``moons`` ON ``moons``.``planet_id`` = ``planets``.``id``" + ) + } + + func testSimpleJoinWithSingleExpr() { + XCTAssertSerialization( + of: self.db.select() + .column("*") + .from("planets") + .join("moons", on: "\(ident: "moons").\(ident: "planet_id")=\(ident: "planets").\(ident: "id")" as SQLQueryString), + is: "SELECT * FROM ``planets`` INNER JOIN ``moons`` ON ``moons``.``planet_id``=``planets``.``id``" + ) + } + + func testMessyJoin() { + XCTAssertSerialization( + of: self.db.select() + .column("*") + .from("planets") + .join( + SQLAlias(SQLGroupExpression( + self.db.select().column("name").from("stars").where(SQLColumn("orion"), .equal, SQLIdentifier("please space")).select + ), as: SQLIdentifier("star")), + method: SQLJoinMethod.inner, + on: SQLColumn(SQLIdentifier("planet_id"), table: SQLIdentifier("moons")), SQLBinaryOperator.isNot, SQLRaw("%%%%%%") + ) + .where(SQLLiteral.null), + is: "SELECT * FROM ``planets`` INNER JOIN (SELECT ``name`` FROM ``stars`` WHERE ``orion`` = ``please space``) AS ``star`` ON ``moons``.``planet_id`` IS NOT %%%%%% WHERE NULL" + ) + } + + func testJoinWithUsingClause() { + XCTAssertSerialization( + of: self.db.select() + .column("*") + .from("stars") + .join(SQLIdentifier("black_holes"), using: SQLIdentifier("galaxy_id")), + is: "SELECT * FROM ``stars`` INNER JOIN ``black_holes`` USING (``galaxy_id``)" + ) + } + + // MARK: - Subquery + + func testBasicSubquery() { + XCTAssertSerialization( + of: self.db.select().column(SQLSubquery.select { $0.column("foo").from("bar").limit(1) }), + is: "SELECT (SELECT ``foo`` FROM ``bar`` LIMIT 1)" + ) + } +} diff --git a/Tests/SQLKitTests/DeprecatedTests.swift b/Tests/SQLKitTests/DeprecatedTests.swift new file mode 100644 index 00000000..83fef9c3 --- /dev/null +++ b/Tests/SQLKitTests/DeprecatedTests.swift @@ -0,0 +1,145 @@ +import SQLKit +import XCTest + +final class SQLDeprecatedTests: XCTestCase { + var db = TestDatabase() + + override class func setUp() { + XCTAssert(isLoggingConfigured) + } + + @available(*, deprecated, message: "Contains tests of deprecated functionality") + func testConcatOperator() { + XCTAssertSerialization(of: self.db.raw("\(SQLBinaryOperator.concatenate)"), is: "") + } + + @available(*, deprecated, message: "Contains tests of deprecated functionality") + func testSQLError() { + struct RidiculousError: SQLError { + var sqlErrorType: SQLErrorType + } + XCTAssertThrowsError(try { throw RidiculousError(sqlErrorType: .constraint) }()) { XCTAssertEqual(($0 as? any SQLError)?.sqlErrorType, .constraint) } + XCTAssertThrowsError(try { throw RidiculousError(sqlErrorType: .io) }()) { XCTAssertEqual(($0 as? any SQLError)?.sqlErrorType, .io) } + XCTAssertThrowsError(try { throw RidiculousError(sqlErrorType: .permission) }()) { XCTAssertEqual(($0 as? any SQLError)?.sqlErrorType, .permission) } + XCTAssertThrowsError(try { throw RidiculousError(sqlErrorType: .syntax) }()) { XCTAssertEqual(($0 as? any SQLError)?.sqlErrorType, .syntax) } + XCTAssertThrowsError(try { throw RidiculousError(sqlErrorType: .unknown) }()) { XCTAssertEqual(($0 as? any SQLError)?.sqlErrorType, .unknown) } + } + + @available(*, deprecated, message: "Contains tests of deprecated functionality") + func testOldTriggerTimingSpecifiers() { + XCTAssertEqual(SQLCreateTrigger.TimingSpecifier.initiallyImmediate, .deferrable) + XCTAssertEqual(SQLCreateTrigger.TimingSpecifier.initiallyDeferred, .deferredByDefault) + } + + @available(*, deprecated, message: "Contains tests of deprecated functionality") + func testDataTypeType() { + XCTAssertSerialization(of: self.db.raw("\(SQLDataType.type("FOO"))"), is: "``FOO``") + } + + @available(*, deprecated, message: "Contains tests of deprecated functionality") + func testOldCascadeProperties() { + var dropEnum = SQLDropEnum(name: SQLIdentifier("enum")) + + dropEnum.cascade = false + XCTAssertEqual(dropEnum.dropBehavior, .restrict) + XCTAssertEqual(dropEnum.cascade, false) + dropEnum.cascade = true + XCTAssertEqual(dropEnum.dropBehavior, .cascade) + XCTAssertEqual(dropEnum.cascade, true) + + var dropTrigger = SQLDropTrigger(name: SQLIdentifier("trigger")) + + dropTrigger.cascade = false + XCTAssertEqual(dropTrigger.dropBehavior, .restrict) + XCTAssertEqual(dropTrigger.cascade, false) + dropTrigger.cascade = true + XCTAssertEqual(dropTrigger.dropBehavior, .cascade) + XCTAssertEqual(dropTrigger.cascade, true) + } + + @available(*, deprecated, message: "Contains tests of deprecated functionality") + func testOldQueryStringInterpolations() { + XCTAssertSerialization(of: self.db.raw("\(raw: "X") \("x")"), is: "X x") + } + + @available(*, deprecated, message: "Contains tests of deprecated functionality") + func testRawBinds() { + let raw = SQLRaw("SQL", ["a", "b"]) + XCTAssertEqual(raw.sql, "SQL") + XCTAssertEqual(raw.binds[0] as? String, "a") + XCTAssertEqual(raw.binds[1] as? String, "b") + } + + @available(*, deprecated, message: "Contains tests of deprecated functionality") + func testOldUnionJoiner() { + XCTAssertEqual(SQLUnionJoiner(all: true).type, .unionAll) + XCTAssertEqual(SQLUnionJoiner(all: false).type, .union) + XCTAssertEqual(SQLUnionJoiner(all: true).all, true) + XCTAssertEqual(SQLUnionJoiner(all: false).all, false) + + var joiner1 = SQLUnionJoiner(type: .union) + joiner1.all = true + XCTAssertEqual(joiner1.type, .unionAll) + joiner1.all = true // This is not a copy-paste error; it adds coverage of the default case in the switch. + joiner1.all = false + XCTAssertEqual(joiner1.type, .union) + + var joiner2 = SQLUnionJoiner(type: .intersect) + joiner2.all = true + XCTAssertEqual(joiner2.type, .intersectAll) + joiner2.all = false + XCTAssertEqual(joiner2.type, .intersect) + + var joiner3 = SQLUnionJoiner(type: .except) + joiner3.all = true + XCTAssertEqual(joiner3.type, .exceptAll) + joiner3.all = false + XCTAssertEqual(joiner3.type, .except) + } + + @available(*, deprecated, message: "Contains tests of deprecated functionality") + func testColumnWithTable() { + XCTAssertSerialization(of: self.db.select().column(table: "a", column: "b"), is: "SELECT ``a``.``b``") + } + + @available(*, deprecated, message: "Contains tests of deprecated functionality") + func testAlterTableBuilderColumns() { + XCTAssertNotNil(self.db.alter(table: "foo").column("a", type: .bigint).columns.first) + let builder = self.db.alter(table: "foo").column("a", type: .bigint) + builder.columns = [SQLColumnDefinition("a", dataType: .blob)] + } + + @available(*, deprecated, message: "Contains tests of deprecated functionality") + func testCreateTriggerBuilderMethods() { + XCTAssertNotNil(self.db.create(trigger: "a", table: "b", when: .after, event: .delete).condition("a").body(["b"])) + } + + @available(*, deprecated, message: "Contains tests of deprecated functionality") + func testJoinBuilderMethod() { + XCTAssertNotNil(self.db.select().join("a", method: .inner, on: "a")) + } + + @available(*, deprecated, message: "Contains tests of deprecated functionality") + func testObsoleteVersionComparators() { + struct TestVersion: SQLDatabaseReportedVersion { let stringValue: String } + struct AnotherTestVersion: SQLDatabaseReportedVersion { let stringValue: String } + + /// `>` + XCTAssert(TestVersion(stringValue: "b").isNewer(than: TestVersion(stringValue: "a"))) + XCTAssertFalse(TestVersion(stringValue: "b").isNewer(than: AnotherTestVersion(stringValue: "a"))) + + /// `<=` + XCTAssert(TestVersion(stringValue: "a").isNotNewer(than: TestVersion(stringValue: "b"))) + XCTAssert(TestVersion(stringValue: "a").isNotNewer(than: TestVersion(stringValue: "a"))) + XCTAssertFalse(TestVersion(stringValue: "a").isNotNewer(than: AnotherTestVersion(stringValue: "a"))) + + /// `<` + XCTAssert(TestVersion(stringValue: "a").isOlder(than: TestVersion(stringValue: "b"))) + XCTAssertFalse(TestVersion(stringValue: "a").isOlder(than: AnotherTestVersion(stringValue: "b"))) + + /// `>=` + XCTAssert(TestVersion(stringValue: "b").isNotOlder(than: TestVersion(stringValue: "a"))) + XCTAssert(TestVersion(stringValue: "a").isNotOlder(than: TestVersion(stringValue: "a"))) + XCTAssertFalse(TestVersion(stringValue: "b").isNotOlder(than: AnotherTestVersion(stringValue: "a"))) + } +} diff --git a/Tests/SQLKitTests/SQLBetweenTests.swift b/Tests/SQLKitTests/SQLBetweenTests.swift new file mode 100644 index 00000000..e799d337 --- /dev/null +++ b/Tests/SQLKitTests/SQLBetweenTests.swift @@ -0,0 +1,77 @@ +import SQLKit +import XCTest + +final class SQLBetweenTests: XCTestCase { + var db = TestDatabase() + + override class func setUp() { + XCTAssert(isLoggingConfigured) + } + + func testBetween() { + XCTAssertSerialization(of: self.db.select().where(SQLBetween("a", between: "a", and: "b")), is: "SELECT WHERE &1 BETWEEN &2 AND &3") + XCTAssertSerialization(of: self.db.select().where(SQLBetween("a", between: SQLIdentifier("a"), and: "b")), is: "SELECT WHERE &1 BETWEEN ``a`` AND &2") + XCTAssertSerialization(of: self.db.select().where(SQLBetween("a", between: "a", and: SQLIdentifier("b"))), is: "SELECT WHERE &1 BETWEEN &2 AND ``b``") + XCTAssertSerialization(of: self.db.select().where(SQLBetween("a", between: SQLIdentifier("a"), and: SQLIdentifier("b"))), is: "SELECT WHERE &1 BETWEEN ``a`` AND ``b``") + XCTAssertSerialization(of: self.db.select().where(SQLBetween(SQLIdentifier("a"), between: "a", and: "b")), is: "SELECT WHERE ``a`` BETWEEN &1 AND &2") + XCTAssertSerialization(of: self.db.select().where(SQLBetween(SQLIdentifier("a"), between: SQLIdentifier("a"), and: "b")), is: "SELECT WHERE ``a`` BETWEEN ``a`` AND &1") + XCTAssertSerialization(of: self.db.select().where(SQLBetween(SQLIdentifier("a"), between: "a", and: SQLIdentifier("b"))), is: "SELECT WHERE ``a`` BETWEEN &1 AND ``b``") + XCTAssertSerialization(of: self.db.select().where(SQLBetween(operand: SQLIdentifier("a"), lowerBound: SQLIdentifier("a"), upperBound: SQLIdentifier("b"))), is: "SELECT WHERE ``a`` BETWEEN ``a`` AND ``b``") + XCTAssertSerialization(of: self.db.select().where(SQLBetween(column: "a", between: "a", and: "b")), is: "SELECT WHERE ``a`` BETWEEN &1 AND &2") + XCTAssertSerialization(of: self.db.select().where(SQLBetween(column: "a", between: SQLIdentifier("a"), and: "b")), is: "SELECT WHERE ``a`` BETWEEN ``a`` AND &1") + XCTAssertSerialization(of: self.db.select().where(SQLBetween(column: "a", between: "a", and: SQLIdentifier("b"))), is: "SELECT WHERE ``a`` BETWEEN &1 AND ``b``") + XCTAssertSerialization(of: self.db.select().where(SQLBetween(column: "a", between: SQLIdentifier("a"), and: SQLIdentifier("b"))), is: "SELECT WHERE ``a`` BETWEEN ``a`` AND ``b``") + + XCTAssertSerialization(of: self.db.select().where("a", between: "a", and: "b"), is: "SELECT WHERE &1 BETWEEN &2 AND &3") + XCTAssertSerialization(of: self.db.select().where("a", between: SQLIdentifier("a"), and: "b"), is: "SELECT WHERE &1 BETWEEN ``a`` AND &2") + XCTAssertSerialization(of: self.db.select().where("a", between: "a", and: SQLIdentifier("b")), is: "SELECT WHERE &1 BETWEEN &2 AND ``b``") + XCTAssertSerialization(of: self.db.select().where("a", between: SQLIdentifier("a"), and: SQLIdentifier("b")), is: "SELECT WHERE &1 BETWEEN ``a`` AND ``b``") + XCTAssertSerialization(of: self.db.select().where(SQLIdentifier("a"), between: "a", and: "b"), is: "SELECT WHERE ``a`` BETWEEN &1 AND &2") + XCTAssertSerialization(of: self.db.select().where(SQLIdentifier("a"), between: SQLIdentifier("a"), and: "b"), is: "SELECT WHERE ``a`` BETWEEN ``a`` AND &1") + XCTAssertSerialization(of: self.db.select().where(SQLIdentifier("a"), between: "a", and: SQLIdentifier("b")), is: "SELECT WHERE ``a`` BETWEEN &1 AND ``b``") + XCTAssertSerialization(of: self.db.select().where(SQLIdentifier("a"), between: SQLIdentifier("a"), and: SQLIdentifier("b")), is: "SELECT WHERE ``a`` BETWEEN ``a`` AND ``b``") + XCTAssertSerialization(of: self.db.select().where(column: "a", between: "a", and: "b"), is: "SELECT WHERE ``a`` BETWEEN &1 AND &2") + XCTAssertSerialization(of: self.db.select().where(column: "a", between: SQLIdentifier("a"), and: "b"), is: "SELECT WHERE ``a`` BETWEEN ``a`` AND &1") + XCTAssertSerialization(of: self.db.select().where(column: "a", between: "a", and: SQLIdentifier("b")), is: "SELECT WHERE ``a`` BETWEEN &1 AND ``b``") + XCTAssertSerialization(of: self.db.select().where(column: "a", between: SQLIdentifier("a"), and: SQLIdentifier("b")), is: "SELECT WHERE ``a`` BETWEEN ``a`` AND ``b``") + + XCTAssertSerialization(of: self.db.select().orWhere("a", between: "a", and: "b"), is: "SELECT WHERE &1 BETWEEN &2 AND &3") + XCTAssertSerialization(of: self.db.select().orWhere("a", between: SQLIdentifier("a"), and: "b"), is: "SELECT WHERE &1 BETWEEN ``a`` AND &2") + XCTAssertSerialization(of: self.db.select().orWhere("a", between: "a", and: SQLIdentifier("b")), is: "SELECT WHERE &1 BETWEEN &2 AND ``b``") + XCTAssertSerialization(of: self.db.select().orWhere("a", between: SQLIdentifier("a"), and: SQLIdentifier("b")), is: "SELECT WHERE &1 BETWEEN ``a`` AND ``b``") + XCTAssertSerialization(of: self.db.select().orWhere(SQLIdentifier("a"), between: "a", and: "b"), is: "SELECT WHERE ``a`` BETWEEN &1 AND &2") + XCTAssertSerialization(of: self.db.select().orWhere(SQLIdentifier("a"), between: SQLIdentifier("a"), and: "b"), is: "SELECT WHERE ``a`` BETWEEN ``a`` AND &1") + XCTAssertSerialization(of: self.db.select().orWhere(SQLIdentifier("a"), between: "a", and: SQLIdentifier("b")), is: "SELECT WHERE ``a`` BETWEEN &1 AND ``b``") + XCTAssertSerialization(of: self.db.select().orWhere(SQLIdentifier("a"), between: SQLIdentifier("a"), and: SQLIdentifier("b")), is: "SELECT WHERE ``a`` BETWEEN ``a`` AND ``b``") + XCTAssertSerialization(of: self.db.select().orWhere(column: "a", between: "a", and: "b"), is: "SELECT WHERE ``a`` BETWEEN &1 AND &2") + XCTAssertSerialization(of: self.db.select().orWhere(column: "a", between: SQLIdentifier("a"), and: "b"), is: "SELECT WHERE ``a`` BETWEEN ``a`` AND &1") + XCTAssertSerialization(of: self.db.select().orWhere(column: "a", between: "a", and: SQLIdentifier("b")), is: "SELECT WHERE ``a`` BETWEEN &1 AND ``b``") + XCTAssertSerialization(of: self.db.select().orWhere(column: "a", between: SQLIdentifier("a"), and: SQLIdentifier("b")), is: "SELECT WHERE ``a`` BETWEEN ``a`` AND ``b``") + + XCTAssertSerialization(of: self.db.select().having("a", between: "a", and: "b"), is: "SELECT HAVING &1 BETWEEN &2 AND &3") + XCTAssertSerialization(of: self.db.select().having("a", between: SQLIdentifier("a"), and: "b"), is: "SELECT HAVING &1 BETWEEN ``a`` AND &2") + XCTAssertSerialization(of: self.db.select().having("a", between: "a", and: SQLIdentifier("b")), is: "SELECT HAVING &1 BETWEEN &2 AND ``b``") + XCTAssertSerialization(of: self.db.select().having("a", between: SQLIdentifier("a"), and: SQLIdentifier("b")), is: "SELECT HAVING &1 BETWEEN ``a`` AND ``b``") + XCTAssertSerialization(of: self.db.select().having(SQLIdentifier("a"), between: "a", and: "b"), is: "SELECT HAVING ``a`` BETWEEN &1 AND &2") + XCTAssertSerialization(of: self.db.select().having(SQLIdentifier("a"), between: SQLIdentifier("a"), and: "b"), is: "SELECT HAVING ``a`` BETWEEN ``a`` AND &1") + XCTAssertSerialization(of: self.db.select().having(SQLIdentifier("a"), between: "a", and: SQLIdentifier("b")), is: "SELECT HAVING ``a`` BETWEEN &1 AND ``b``") + XCTAssertSerialization(of: self.db.select().having(SQLIdentifier("a"), between: SQLIdentifier("a"), and: SQLIdentifier("b")), is: "SELECT HAVING ``a`` BETWEEN ``a`` AND ``b``") + XCTAssertSerialization(of: self.db.select().having(column: "a", between: "a", and: "b"), is: "SELECT HAVING ``a`` BETWEEN &1 AND &2") + XCTAssertSerialization(of: self.db.select().having(column: "a", between: SQLIdentifier("a"), and: "b"), is: "SELECT HAVING ``a`` BETWEEN ``a`` AND &1") + XCTAssertSerialization(of: self.db.select().having(column: "a", between: "a", and: SQLIdentifier("b")), is: "SELECT HAVING ``a`` BETWEEN &1 AND ``b``") + XCTAssertSerialization(of: self.db.select().having(column: "a", between: SQLIdentifier("a"), and: SQLIdentifier("b")), is: "SELECT HAVING ``a`` BETWEEN ``a`` AND ``b``") + + XCTAssertSerialization(of: self.db.select().orHaving("a", between: "a", and: "b"), is: "SELECT HAVING &1 BETWEEN &2 AND &3") + XCTAssertSerialization(of: self.db.select().orHaving("a", between: SQLIdentifier("a"), and: "b"), is: "SELECT HAVING &1 BETWEEN ``a`` AND &2") + XCTAssertSerialization(of: self.db.select().orHaving("a", between: "a", and: SQLIdentifier("b")), is: "SELECT HAVING &1 BETWEEN &2 AND ``b``") + XCTAssertSerialization(of: self.db.select().orHaving("a", between: SQLIdentifier("a"), and: SQLIdentifier("b")), is: "SELECT HAVING &1 BETWEEN ``a`` AND ``b``") + XCTAssertSerialization(of: self.db.select().orHaving(SQLIdentifier("a"), between: "a", and: "b"), is: "SELECT HAVING ``a`` BETWEEN &1 AND &2") + XCTAssertSerialization(of: self.db.select().orHaving(SQLIdentifier("a"), between: SQLIdentifier("a"), and: "b"), is: "SELECT HAVING ``a`` BETWEEN ``a`` AND &1") + XCTAssertSerialization(of: self.db.select().orHaving(SQLIdentifier("a"), between: "a", and: SQLIdentifier("b")), is: "SELECT HAVING ``a`` BETWEEN &1 AND ``b``") + XCTAssertSerialization(of: self.db.select().orHaving(SQLIdentifier("a"), between: SQLIdentifier("a"), and: SQLIdentifier("b")), is: "SELECT HAVING ``a`` BETWEEN ``a`` AND ``b``") + XCTAssertSerialization(of: self.db.select().orHaving(column: "a", between: "a", and: "b"), is: "SELECT HAVING ``a`` BETWEEN &1 AND &2") + XCTAssertSerialization(of: self.db.select().orHaving(column: "a", between: SQLIdentifier("a"), and: "b"), is: "SELECT HAVING ``a`` BETWEEN ``a`` AND &1") + XCTAssertSerialization(of: self.db.select().orHaving(column: "a", between: "a", and: SQLIdentifier("b")), is: "SELECT HAVING ``a`` BETWEEN &1 AND ``b``") + XCTAssertSerialization(of: self.db.select().orHaving(column: "a", between: SQLIdentifier("a"), and: SQLIdentifier("b")), is: "SELECT HAVING ``a`` BETWEEN ``a`` AND ``b``") + } +} diff --git a/Tests/SQLKitTests/SQLCodingTests.swift b/Tests/SQLKitTests/SQLCodingTests.swift new file mode 100644 index 00000000..32de909a --- /dev/null +++ b/Tests/SQLKitTests/SQLCodingTests.swift @@ -0,0 +1,283 @@ +@testable @_spi(CodableUtilities) import SQLKit +import XCTest + +final class SQLCodingTests: XCTestCase { + var db = TestDatabase() + + override class func setUp() { + XCTAssert(isLoggingConfigured) + } + + // MARK: - Query encoder + + func testCodableWithNillableColumnWithSomeValue() { + let output = XCTAssertNoThrowWithResult(try self.db + .insert(into: "gasses") + .model(Gas(name: "iodine", color: "purple")) + .advancedSerialize() + ) + + XCTAssertEqual(output?.sql, "INSERT INTO ``gasses`` (``name``, ``color``) VALUES (&1, &2)") + XCTAssertEqual(output?.binds.count, 2) + XCTAssertEqual(output?.binds[0] as? String, "iodine") + XCTAssertEqual(output?.binds[1] as? String, "purple") + } + + func testCodableWithNillableColumnWithNilValueWithoutNilEncodingStrategy() throws { + let output = XCTAssertNoThrowWithResult(try self.db + .insert(into: "gasses") + .model(Gas(name: "oxygen", color: nil)) + .advancedSerialize() + ) + + XCTAssertEqual(output?.sql, "INSERT INTO ``gasses`` (``name``) VALUES (&1)") + XCTAssertEqual(output?.binds.count, 1) + XCTAssertEqual(output?.binds[0] as? String, "oxygen") + } + + func testCodableWithNillableColumnWithNilValueAndNilEncodingStrategy() throws { + let output = XCTAssertNoThrowWithResult(try self.db + .insert(into: "gasses") + .model(Gas(name: "oxygen", color: nil), nilEncodingStrategy: .asNil) + .advancedSerialize() + ) + + XCTAssertEqual(output?.sql, "INSERT INTO ``gasses`` (``name``, ``color``) VALUES (&1, NULL)") + XCTAssertEqual(output?.binds.count, 1) + XCTAssertEqual(output?.binds[0] as? String, "oxygen") + } + + // MARK: - Models + + func testInsertWithEncodableModel() { + struct TestModelPlain: Codable { + var id: Int? + var serial_number: UUID + var star_id: Int + var last_known_status: String + } + struct TestModelSnakeCase: Codable { + var id: Int? + var serialNumber: UUID + var starId: Int + var lastKnownStatus: String + } + struct TestModelSuperCase: Codable { + var Id: Int? + var SerialNumber: UUID + var StarId: Int + var LastKnownStatus: String + } + + @Sendable + func handleSuperCase(_ path: [any CodingKey]) -> any CodingKey { + SomeCodingKey(stringValue: path.last!.stringValue.decapitalized.convertedToSnakeCase) + } + + let snakeEncoder = SQLQueryEncoder(keyEncodingStrategy: .convertToSnakeCase, nilEncodingStrategy: .asNil) + let superEncoder = SQLQueryEncoder(prefix: "p_", keyEncodingStrategy: .custom({ handleSuperCase($0) }), nilEncodingStrategy: .asNil) + + db._dialect.upsertSyntax = .standard + + XCTAssertSerialization( + of: try self.db.insert(into: "jumpgates").model(TestModelPlain(serial_number: .init(), star_id: 0, last_known_status: ""), nilEncodingStrategy: .asNil), + is: "INSERT INTO ``jumpgates`` (``id``, ``serial_number``, ``star_id``, ``last_known_status``) VALUES (NULL, &1, &2, &3)" + ) + XCTAssertSerialization( + of: try self.db.insert(into: "jumpgates").model(TestModelSnakeCase(serialNumber: .init(), starId: 0, lastKnownStatus: ""), with: snakeEncoder), + is: "INSERT INTO ``jumpgates`` (``id``, ``serial_number``, ``star_id``, ``last_known_status``) VALUES (NULL, &1, &2, &3)" + ) + XCTAssertSerialization( + of: try self.db.insert(into: "jumpgates").model(TestModelSuperCase(SerialNumber: .init(), StarId: 0, LastKnownStatus: ""), with: superEncoder), + is: "INSERT INTO ``jumpgates`` (``p_id``, ``p_serial_number``, ``p_star_id``, ``p_last_known_status``) VALUES (NULL, &1, &2, &3)" + ) + + XCTAssertSerialization( + of: try self.db.insert(into: "jumpgates").model(TestModelPlain(serial_number: .init(), star_id: 0, last_known_status: ""), nilEncodingStrategy: .asNil).ignoringConflicts(with: "star_id"), + is: "INSERT INTO ``jumpgates`` (``id``, ``serial_number``, ``star_id``, ``last_known_status``) VALUES (NULL, &1, &2, &3) ON CONFLICT (``star_id``) DO NOTHING" + ) + XCTAssertSerialization( + of: try self.db.insert(into: "jumpgates").model(TestModelSnakeCase(serialNumber: .init(), starId: 0, lastKnownStatus: ""), with: snakeEncoder).ignoringConflicts(with: "star_id"), + is: "INSERT INTO ``jumpgates`` (``id``, ``serial_number``, ``star_id``, ``last_known_status``) VALUES (NULL, &1, &2, &3) ON CONFLICT (``star_id``) DO NOTHING" + ) + XCTAssertSerialization( + of: try self.db.insert(into: "jumpgates").model(TestModelSuperCase(SerialNumber: .init(), StarId: 0, LastKnownStatus: ""), with: superEncoder).ignoringConflicts(with: "star_id"), + is: "INSERT INTO ``jumpgates`` (``p_id``, ``p_serial_number``, ``p_star_id``, ``p_last_known_status``) VALUES (NULL, &1, &2, &3) ON CONFLICT (``star_id``) DO NOTHING" + ) + + XCTAssertSerialization( + of: try self.db.insert(into: "jumpgates") + .model(TestModelPlain(serial_number: .init(), star_id: 0, last_known_status: ""), nilEncodingStrategy: .asNil) + .onConflict(with: ["star_id"]) { try $0 + .set(excludedContentOf: TestModelPlain(serial_number: .init(), star_id: 0, last_known_status: ""), nilEncodingStrategy: .asNil) + }, + is: "INSERT INTO ``jumpgates`` (``id``, ``serial_number``, ``star_id``, ``last_known_status``) VALUES (NULL, &1, &2, &3) ON CONFLICT (``star_id``) DO UPDATE SET ``id`` = EXCLUDED.``id``, ``serial_number`` = EXCLUDED.``serial_number``, ``star_id`` = EXCLUDED.``star_id``, ``last_known_status`` = EXCLUDED.``last_known_status``" + ) + XCTAssertSerialization( + of: try self.db.insert(into: "jumpgates") + .model(TestModelSnakeCase(serialNumber: .init(), starId: 0, lastKnownStatus: ""), with: snakeEncoder) + .onConflict(with: ["star_id"]) { try $0 + .set(excludedContentOf: TestModelSnakeCase(serialNumber: .init(), starId: 0, lastKnownStatus: ""), with: snakeEncoder) + }, + is: "INSERT INTO ``jumpgates`` (``id``, ``serial_number``, ``star_id``, ``last_known_status``) VALUES (NULL, &1, &2, &3) ON CONFLICT (``star_id``) DO UPDATE SET ``id`` = EXCLUDED.``id``, ``serial_number`` = EXCLUDED.``serial_number``, ``star_id`` = EXCLUDED.``star_id``, ``last_known_status`` = EXCLUDED.``last_known_status``" + ) + XCTAssertSerialization( + of: try self.db.insert(into: "jumpgates") + .model(TestModelSuperCase(SerialNumber: .init(), StarId: 0, LastKnownStatus: ""), with: superEncoder) + .onConflict(with: ["p_star_id"]) { try $0 + .set(excludedContentOf: TestModelSuperCase(SerialNumber: .init(), StarId: 0, LastKnownStatus: ""), with: superEncoder) + }, + is: "INSERT INTO ``jumpgates`` (``p_id``, ``p_serial_number``, ``p_star_id``, ``p_last_known_status``) VALUES (NULL, &1, &2, &3) ON CONFLICT (``p_star_id``) DO UPDATE SET ``p_id`` = EXCLUDED.``p_id``, ``p_serial_number`` = EXCLUDED.``p_serial_number``, ``p_star_id`` = EXCLUDED.``p_star_id``, ``p_last_known_status`` = EXCLUDED.``p_last_known_status``" + ) + } + + func testInsertWithEncodableModels() { + struct TestModel: Codable, Equatable { + var id: Int? + var serial_number: UUID + var star_id: Int + var last_known_status: String + } + + XCTAssertSerialization( + of: try self.db.insert(into: "jumpgates") + .models([ + TestModel(serial_number: .init(), star_id: 0, last_known_status: ""), + TestModel(serial_number: .init(), star_id: 1, last_known_status: ""), + ]), + is: "INSERT INTO ``jumpgates`` (``serial_number``, ``star_id``, ``last_known_status``) VALUES (&1, &2, &3), (&4, &5, &6)" + ) + + let models = [ + TestModel(id: 0, serial_number: .init(), star_id: 0, last_known_status: ""), + TestModel(serial_number: .init(), star_id: 1, last_known_status: ""), + ] + + XCTAssertThrowsError(try self.db.insert(into: "jumpgates").models(models)) { + guard case let .invalidValue(value, context) = $0 as? EncodingError else { + return XCTFail("Expected EncodingError.invalidValue, but got \(String(reflecting: $0))") + } + XCTAssertEqual(value as? TestModel, models[1]) + XCTAssert(context.codingPath.isEmpty) + } + } + + func testUpdateWithEncodableModel() { + struct TestModelPlain: Codable { + var id: Int? + var serial_number: UUID + var star_id: Int + var last_known_status: String + } + struct TestModelSnakeCase: Codable { + var id: Int? + var serialNumber: UUID + var starId: Int + var lastKnownStatus: String + } + struct TestModelSuperCase: Codable { + var Id: Int? + var SerialNumber: UUID + var StarId: Int + var LastKnownStatus: String + } + + @Sendable + func handleSuperCase(_ path: [any CodingKey]) -> any CodingKey { + SomeCodingKey(stringValue: path.last!.stringValue.decapitalized.convertedToSnakeCase) + } + + let snakeEncoder = SQLQueryEncoder(keyEncodingStrategy: .convertToSnakeCase, nilEncodingStrategy: .asNil) + let superEncoder = SQLQueryEncoder(prefix: "p_", keyEncodingStrategy: .custom({ handleSuperCase($0) }), nilEncodingStrategy: .asNil) + + XCTAssertSerialization( + of: try self.db.update("jumpgates").set(model: TestModelPlain(serial_number: .init(), star_id: 0, last_known_status: "")), + is: "UPDATE ``jumpgates`` SET ``serial_number`` = &1, ``star_id`` = &2, ``last_known_status`` = &3" + ) + XCTAssertSerialization( + of: try self.db.update("jumpgates").set(model: TestModelPlain(serial_number: .init(), star_id: 0, last_known_status: ""), nilEncodingStrategy: .asNil), + is: "UPDATE ``jumpgates`` SET ``id`` = NULL, ``serial_number`` = &1, ``star_id`` = &2, ``last_known_status`` = &3" + ) + XCTAssertSerialization( + of: try self.db.update("jumpgates").set(model: TestModelSnakeCase(serialNumber: .init(), starId: 0, lastKnownStatus: ""), with: snakeEncoder), + is: "UPDATE ``jumpgates`` SET ``id`` = NULL, ``serial_number`` = &1, ``star_id`` = &2, ``last_known_status`` = &3" + ) + XCTAssertSerialization( + of: try self.db.update("jumpgates").set(model: TestModelSuperCase(SerialNumber: .init(), StarId: 0, LastKnownStatus: ""), with: superEncoder), + is: "UPDATE ``jumpgates`` SET ``p_id`` = NULL, ``p_serial_number`` = &1, ``p_star_id`` = &2, ``p_last_known_status`` = &3" + ) + } + + func testRowModelDecode() { + struct Foo: Codable, Equatable { + let a: String + } + let row = TestRow(data: ["a": "b"]) + XCTAssertEqual(try row.decode(model: Foo.self, keyDecodingStrategy: .useDefaultKeys), Foo(a: "b")) + } + + func testHandleCodeCoverageCompleteness() { + /// There are certain code paths which can never be executed under any meaningful circumstances, but the + /// compiler cannot determine this statically. This test performs deliberately pointless operations in order + /// to mark those paths as covered by tests. + XCTAssertNil(NeverKey.init(stringValue: "")) + XCTAssertNil(NeverKey.init(intValue: 0)) + XCTAssert(FailureEncoder(SQLCodingError.unsupportedOperation("", codingPath: [])).codingPath.isEmpty) + XCTAssert(FailureEncoder(SQLCodingError.unsupportedOperation("", codingPath: [])).userInfo.isEmpty) + XCTAssertEqual(FailureEncoder(SQLCodingError.unsupportedOperation("", codingPath: [])).count, 0) + XCTAssertThrowsError(try FailureEncoder(SQLCodingError.unsupportedOperation("", codingPath: [])).encodeNil()) + XCTAssertThrowsError(try FailureEncoder(SQLCodingError.unsupportedOperation("", codingPath: [])).encodeNil(forKey: .init(stringValue: ""))) + XCTAssertNotNil(FailureEncoder(SQLCodingError.unsupportedOperation("", codingPath: [])).superEncoder()) + XCTAssertNotNil(FailureEncoder(SQLCodingError.unsupportedOperation("", codingPath: [])).superEncoder(forKey: .init(stringValue: ""))) + XCTAssertNotNil(FailureEncoder(SQLCodingError.unsupportedOperation("", codingPath: [])).unkeyedContainer()) + XCTAssertNotNil(FailureEncoder(SQLCodingError.unsupportedOperation("", codingPath: [])).nestedUnkeyedContainer()) + XCTAssertNotNil(FailureEncoder(SQLCodingError.unsupportedOperation("", codingPath: [])).nestedUnkeyedContainer(forKey: .init(stringValue: ""))) + XCTAssertNotNil(FailureEncoder(SQLCodingError.unsupportedOperation("", codingPath: [])).container(keyedBy: NeverKey.self)) + XCTAssertNotNil(FailureEncoder(SQLCodingError.unsupportedOperation("", codingPath: [])).nestedContainer(keyedBy: NeverKey.self)) + XCTAssertNotNil(FailureEncoder(SQLCodingError.unsupportedOperation("", codingPath: [])).nestedContainer(keyedBy: NeverKey.self, forKey: .init(stringValue: ""))) + XCTAssertNotNil(DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "")).under(path: [])) + XCTAssertNotNil(DecodingError.typeMismatch(Void.self, .init(codingPath: [], debugDescription: "")).under(path: [])) + XCTAssertNotNil(DecodingError.keyNotFound(SomeCodingKey(stringValue: ""), .init(codingPath: [], debugDescription: "")).under(path: [])) + XCTAssertNoThrow(try JSONDecoder().decode(FakeSendableCodable.self, from: JSONEncoder().encode(FakeSendableCodable(true)))) + XCTAssertNotEqual(FakeSendableCodable(true), FakeSendableCodable(false)) + XCTAssertFalse(Set([FakeSendableCodable(true)]).isEmpty) + XCTAssertEqual(FakeSendableCodable(true).description, true.description) + XCTAssertEqual(FakeSendableCodable("").debugDescription, "".debugDescription) + XCTAssertFalse(SQLCodingError.unsupportedOperation("", codingPath: [SomeCodingKey(stringValue: "")]).description.isEmpty) + XCTAssertEqual(SomeCodingKey(intValue: 0).intValue, 0) + } +} + +struct Gas: Codable { + let name: String + let color: String? +} + +struct Foo: Codable { + let id: UUID + let foo: Int + let bar: Double? + let baz: String + let waldoFredID: Int? +} + +func superCase(_ path: [any CodingKey]) -> any CodingKey { + SomeCodingKey(stringValue: path.last!.stringValue.encapitalized) +} + +extension SQLLiteral: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + switch (lhs, rhs) { + case (.all, .all), (.`default`, .`default`), (.null, .null): return true + case (.boolean(let lbool), .boolean(let rbool)) where lbool == rbool: return true + case (.numeric(let lnum), .numeric(let rnum)) where lnum == rnum: return true + case (.string(let lstr), .string(let rstr)) where lstr == rstr: return true + default: return false + } + } +} + +extension SQLBind: 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 } +} diff --git a/Tests/SQLKitTests/SQLCreateDropTriggerTests.swift b/Tests/SQLKitTests/SQLCreateDropTriggerTests.swift new file mode 100644 index 00000000..c65f58ab --- /dev/null +++ b/Tests/SQLKitTests/SQLCreateDropTriggerTests.swift @@ -0,0 +1,143 @@ +import SQLKit +import XCTest + +final class SQLCreateDropTriggerTests: XCTestCase { + private let body = [ + "IF NEW.amount < 0 THEN", + "SET NEW.amount = 0;", + "END IF;", + ] + + private var db = TestDatabase() + + override class func setUp() { + XCTAssert(isLoggingConfigured) + } + + func testDropTriggerOptions() { + self.db._dialect.triggerSyntax = .init(drop: [.supportsCascade, .supportsTableName]) + XCTAssertSerialization(of: self.db.drop(trigger: "foo").table("planets"), is: "DROP TRIGGER ``foo`` ON ``planets`` RESTRICT") + XCTAssertSerialization(of: self.db.drop(trigger: "foo").table("planets").ifExists(), is: "DROP TRIGGER IF EXISTS ``foo`` ON ``planets`` RESTRICT") + XCTAssertSerialization(of: self.db.drop(trigger: "foo").table("planets").ifExists().restrict(), is: "DROP TRIGGER IF EXISTS ``foo`` ON ``planets`` RESTRICT") + XCTAssertSerialization(of: self.db.drop(trigger: "foo").table("planets").ifExists().cascade(), is: "DROP TRIGGER IF EXISTS ``foo`` ON ``planets`` CASCADE") + + self.db._dialect.supportsIfExists = false + XCTAssertSerialization(of: self.db.drop(trigger: "foo").table("planets").ifExists(), is: "DROP TRIGGER ``foo`` ON ``planets`` RESTRICT") + + self.db._dialect.triggerSyntax.drop = .supportsCascade + XCTAssertSerialization(of: self.db.drop(trigger: "foo").table("planets"), is: "DROP TRIGGER ``foo`` RESTRICT") + + self.db._dialect.triggerSyntax.drop = [] + XCTAssertSerialization(of: self.db.drop(trigger: "foo").table("planets").ifExists().restrict(), is: "DROP TRIGGER ``foo``") + XCTAssertSerialization(of: self.db.drop(trigger: "foo").table("planets").ifExists().cascade(), is: "DROP TRIGGER ``foo``") + } + + func testMySqlTriggerCreates() { + self.db._dialect.triggerSyntax = .init(create: [.supportsBody, .supportsOrder, .supportsDefiner, .requiresForEachRow]) + + let builder = self.db.create(trigger: "foo", table: "planet", when: .before, event: .insert) + .body(self.body.map { SQLRaw($0) }) + .order(precedence: .precedes, otherTriggerName: "other") + builder.createTrigger.definer = SQLLiteral.string("foo@bar") + + XCTAssertSerialization( + of: builder, + is: "CREATE DEFINER = 'foo@bar' TRIGGER ``foo`` BEFORE INSERT ON ``planet`` FOR EACH ROW PRECEDES ``other`` BEGIN \(self.body.joined(separator: " ")) END;" + ) + } + + func testSqliteTriggerCreates() { + self.db._dialect.triggerSyntax = .init(create: [.supportsBody, .supportsCondition]) + XCTAssertSerialization( + of: self.db.create(trigger: "foo", table: "planet", when: .before, event: .insert) + .body(self.body.map { SQLRaw($0) }) + .condition("\(ident: "foo") = \(ident: "bar")" as SQLQueryString), + is: "CREATE TRIGGER ``foo`` BEFORE INSERT ON ``planet`` WHEN ``foo`` = ``bar`` BEGIN \(self.body.joined(separator: " ")) END;" + ) + } + + func testPostgreSqlTriggerCreates() { + self.db._dialect.triggerSyntax = .init(create: [.supportsForEach, .postgreSQLChecks, .supportsCondition, .conditionRequiresParentheses, .supportsConstraints]) + XCTAssertSerialization( + of: self.db.create(trigger: "foo", table: "planet", when: .after, event: .insert) + .each(.row) + .isConstraint() + .timing(.deferredByDefault) + .condition("\(ident: "foo") = \(ident: "bar")" as SQLQueryString) + .procedure("qwer") + .referencedTable("galaxies"), + is: "CREATE CONSTRAINT TRIGGER ``foo`` AFTER INSERT ON ``planet`` FROM ``galaxies`` DEFERRABLE INITIALLY DEFERRED FOR EACH ROW WHEN (``foo`` = ``bar``) EXECUTE PROCEDURE ``qwer``" + ) + XCTAssertSerialization( + of: self.db.create(trigger: "foo", table: "planet", when: .instead, event: .insert) + .each(.row) + .procedure("qwer"), + is: "CREATE TRIGGER ``foo`` INSTEAD OF INSERT ON ``planet`` FOR EACH ROW EXECUTE PROCEDURE ``qwer``" + ) + XCTAssertSerialization( + of: self.db.create(trigger: "foo", table: "planet", when: .instead, event: .update) + .each(.row) + .procedure("qwer"), + is: "CREATE TRIGGER ``foo`` INSTEAD OF UPDATE ON ``planet`` FOR EACH ROW EXECUTE PROCEDURE ``qwer``" + ) + } + + func testPostgreSqlTriggerCreateWithColumns() { + self.db._dialect.triggerSyntax = .init(create: [.supportsForEach, .postgreSQLChecks, .supportsCondition, .conditionRequiresParentheses, .supportsConstraints, .supportsUpdateColumns]) + XCTAssertSerialization( + of: self.db.create(trigger: "foo", table: "planet", when: .after, event: .update) + .each(.row) + .columns(["foo"]) + .condition("\(ident: "foo") = \(ident: "bar")" as SQLQueryString) + .procedure("qwer") + .referencedTable("galaxies"), + is: "CREATE TRIGGER ``foo`` AFTER UPDATE OF ``foo`` ON ``planet`` FROM ``galaxies`` FOR EACH ROW WHEN (``foo`` = ``bar``) EXECUTE PROCEDURE ``qwer``" + ) + XCTAssertSerialization( + of: self.db.create(trigger: "foo", table: "planet", when: .after, event: .insert) + .each(.row) + .procedure("qwer"), + is: "CREATE TRIGGER ``foo`` AFTER INSERT ON ``planet`` FOR EACH ROW EXECUTE PROCEDURE ``qwer``" + ) + } + + func testAdditionalInitializer() { + self.db._dialect.triggerSyntax = .init(create: [.supportsBody, .supportsCondition]) + var query = SQLCreateTrigger(trigger: "t", table: "tab", when: .after, event: .delete) + query.body = self.body.map { SQLRaw($0) } + + XCTAssertSerialization(of: self.db.raw("\(query)"), is: "CREATE TRIGGER ``t`` AFTER DELETE ON ``tab`` BEGIN IF NEW.amount < 0 THEN SET NEW.amount = 0; END IF; END;") + } + + func testInvalidTriggerCreates() { + self.db._dialect.triggerSyntax = .init(create: [.postgreSQLChecks, .supportsUpdateColumns, .supportsCondition, .supportsConstraints], drop: []) + XCTAssertSerialization( + of: self.db.create(trigger: "foo", table: "planet", when: .instead, event: .update).columns(["foo"]).timing(.deferrable), + is: "CREATE TRIGGER ``foo`` INSTEAD OF UPDATE OF ``foo`` ON ``planet`` DEFERRABLE INITIALLY IMMEDIATE" + ) + XCTAssertSerialization( + of: self.db.create(trigger: "foo", table: "planet", when: .instead, event: .insert).columns(["foo"]).condition(SQLLiteral.boolean(true)), + is: "CREATE TRIGGER ``foo`` INSTEAD OF INSERT OF ``foo`` ON ``planet`` WHEN TROO" + ) + XCTAssertSerialization( + of: self.db.create(trigger: "foo", table: "planet", when: .before, event: .update).isConstraint().each(.statement), + is: "CREATE CONSTRAINT TRIGGER ``foo`` BEFORE UPDATE ON ``planet``" + ) + + let builder = self.db.create(trigger: "foo", table: "planet", when: .before, event: .insert) + .body(self.body.map { SQLRaw($0) }) + .order(precedence: .precedes, otherTriggerName: "other") + builder.createTrigger.definer = SQLLiteral.string("foo@bar") + + XCTAssertSerialization( + of: builder, + is: "CREATE TRIGGER ``foo`` BEFORE INSERT ON ``planet``" + ) + + self.db._dialect.triggerSyntax.create.insert(.supportsBody) + XCTAssertSerialization( + of: self.db.create(trigger: "foo", table: "planet", when: .before, event: .update).isConstraint().each(.statement).procedure("foo"), + is: "CREATE CONSTRAINT TRIGGER ``foo`` BEFORE UPDATE ON ``planet`` EXECUTE PROCEDURE ``foo``" + ) + } +} diff --git a/Tests/SQLKitTests/SQLCreateTableTests.swift b/Tests/SQLKitTests/SQLCreateTableTests.swift new file mode 100644 index 00000000..272e20d3 --- /dev/null +++ b/Tests/SQLKitTests/SQLCreateTableTests.swift @@ -0,0 +1,224 @@ +import SQLKit +import XCTest + +final class SQLCreateTableTests: XCTestCase { + var db = TestDatabase() + + override class func setUp() { + XCTAssert(isLoggingConfigured) + } + + // MARK: Table Creation + + func testColumnConstraints() { + XCTAssertSerialization( + of: self.db.create(table: "planets") + .column("id", type: .bigint, .primaryKey) + .column("name", type: .text, .default("unnamed")) + .column("galaxy_id", type: .bigint, .references("galaxies", "id")) + .column("diameter", type: .int, .check(SQLRaw("diameter > 0"))) + .column("important", type: .text, .notNull) + .column("special", type: .text, .unique) + .column("automatic", type: .text, .generated(SQLRaw("CONCAT(name, special)"))) + .column("collated", type: .text, .collate(name: "default")), + is: """ + CREATE TABLE ``planets`` (``id`` BIGINT PRIMARY KEY AWWTOEINCREMENT, ``name`` TEXT DEFAULT 'unnamed', ``galaxy_id`` BIGINT REFERENCES ``galaxies`` (``id``), ``diameter`` INTEGER CHECK (diameter > 0), ``important`` TEXT NOT NULL, ``special`` TEXT UNIQUE, ``automatic`` TEXT GENERATED ALWAYS AS (CONCAT(name, special)) STORED, ``collated`` TEXT COLLATE ``default``) + """ + ) + } + + func testConstraintLengthNormalization() { + // Default impl is to leave as-is + XCTAssertEqual( + (db.dialect.normalizeSQLConstraint(identifier: SQLIdentifier("fk:obnoxiously_long_table_name.other_table_name_id+other_table_name.id")) as! SQLIdentifier).string, + SQLIdentifier("fk:obnoxiously_long_table_name.other_table_name_id+other_table_name.id").string + ) + } + + func testMultipleColumnConstraintsPerRow() { + XCTAssertSerialization( + of: self.db.create(table: "planets").column("id", type: .bigint, .notNull, .primaryKey), + is: "CREATE TABLE ``planets`` (``id`` BIGINT NOT NULL PRIMARY KEY AWWTOEINCREMENT)" + ) + } + + func testPrimaryKeyColumnConstraintVariants() { + XCTAssertSerialization( + of: self.db.create(table: "planets1").column("id", type: .bigint, .primaryKey), + is: "CREATE TABLE ``planets1`` (``id`` BIGINT PRIMARY KEY AWWTOEINCREMENT)" + ) + XCTAssertSerialization( + of: self.db.create(table: "planets2").column("id", type: .bigint, .primaryKey(autoIncrement: false)), + is: "CREATE TABLE ``planets2`` (``id`` BIGINT PRIMARY KEY)" + ) + } + + func testPrimaryKeyAutoIncrementVariants() { + self.db._dialect.supportsAutoIncrement = false + + XCTAssertSerialization( + of: self.db.create(table: "planets1").column("id", type: .bigint, .primaryKey), + is: "CREATE TABLE ``planets1`` (``id`` BIGINT PRIMARY KEY)" + ) + XCTAssertSerialization( + of: self.db.create(table: "planets2").column("id", type: .bigint, .primaryKey(autoIncrement: false)), + is: "CREATE TABLE ``planets2`` (``id`` BIGINT PRIMARY KEY)" + ) + + self.db._dialect.supportsAutoIncrement = true + + XCTAssertSerialization( + of: self.db.create(table: "planets3").column("id", type: .bigint, .primaryKey), + is: "CREATE TABLE ``planets3`` (``id`` BIGINT PRIMARY KEY AWWTOEINCREMENT)" + ) + XCTAssertSerialization( + of: self.db.create(table: "planets4").column("id", type: .bigint, .primaryKey(autoIncrement: false)), + is: "CREATE TABLE ``planets4`` (``id`` BIGINT PRIMARY KEY)" + ) + + self.db._dialect.autoIncrementFunction = SQLRaw("NEXTUNIQUE") + + XCTAssertSerialization( + of: self.db.create(table: "planets5").column("id", type: .bigint, .primaryKey), + is: "CREATE TABLE ``planets5`` (``id`` BIGINT DEFAULT NEXTUNIQUE PRIMARY KEY)" + ) + XCTAssertSerialization( + of: self.db.create(table: "planets6").column("id", type: .bigint, .primaryKey(autoIncrement: false)), + is: "CREATE TABLE ``planets6`` (``id`` BIGINT PRIMARY KEY)" + ) + } + + func testDefaultColumnConstraintVariants() { + XCTAssertSerialization( + of: self.db.create(table: "planets1").column("name", type: .text, .default("unnamed")), + is: "CREATE TABLE ``planets1`` (``name`` TEXT DEFAULT 'unnamed')" + ) + XCTAssertSerialization( + of: self.db.create(table: "planets2").column("diameter", type: .int, .default(10)), + is: "CREATE TABLE ``planets2`` (``diameter`` INTEGER DEFAULT 10)" + ) + XCTAssertSerialization( + of: self.db.create(table: "planets3").column("diameter", type: .real, .default(11.5)), + is: "CREATE TABLE ``planets3`` (``diameter`` REAL DEFAULT 11.5)" + ) + XCTAssertSerialization( + of: self.db.create(table: "planets4").column("current", type: .custom(SQLRaw("BOOLEAN")), .default(false)), + is: "CREATE TABLE ``planets4`` (``current`` BOOLEAN DEFAULT FAALS)" + ) + XCTAssertSerialization( + of: self.db.create(table: "planets5").column("current", type: .custom(SQLRaw("BOOLEAN")), .default(SQLLiteral.boolean(true))), + is: "CREATE TABLE ``planets5`` (``current`` BOOLEAN DEFAULT TROO)" + ) + } + + func testForeignKeyColumnConstraintVariants() { + XCTAssertSerialization( + of: self.db.create(table: "planets1").column("galaxy_id", type: .bigint, .references("galaxies", "id")), + is: "CREATE TABLE ``planets1`` (``galaxy_id`` BIGINT REFERENCES ``galaxies`` (``id``))" + ) + XCTAssertSerialization( + of: self.db.create(table: "planets2").column("galaxy_id", type: .bigint, .references("galaxies", "id", onDelete: .cascade, onUpdate: .restrict)), + is: "CREATE TABLE ``planets2`` (``galaxy_id`` BIGINT REFERENCES ``galaxies`` (``id``) ON DELETE CASCADE ON UPDATE RESTRICT)" + ) + } + + func testTableConstraints() { + XCTAssertSerialization( + of: self.db.create(table: "planets") + .column("id", type: .bigint) + .column("name", type: .text) + .column("diameter", type: .int) + .column("galaxy_name", type: .text) + .column("galaxy_id", type: .bigint) + .primaryKey("id") + .unique("name") + .check(SQLRaw("diameter > 0"), named: "non-zero-diameter") + .foreignKey( + ["galaxy_id", "galaxy_name"], + references: "galaxies", + ["id", "name"] + ), + is: """ + CREATE TABLE ``planets`` (``id`` BIGINT, ``name`` TEXT, ``diameter`` INTEGER, ``galaxy_name`` TEXT, ``galaxy_id`` BIGINT, PRIMARY KEY (``id``), UNIQUE (``name``), CONSTRAINT ``non-zero-diameter`` CHECK (diameter > 0), FOREIGN KEY (``galaxy_id``, ``galaxy_name``) REFERENCES ``galaxies`` (``id``, ``name``)) + """ + ) + } + + func testCompositePrimaryKeyTableConstraint() { + XCTAssertSerialization( + of: self.db.create(table: "planets1").column("id1", type: .bigint).column("id2", type: .bigint).primaryKey("id1", "id2"), + is: "CREATE TABLE ``planets1`` (``id1`` BIGINT, ``id2`` BIGINT, PRIMARY KEY (``id1``, ``id2``))" + ) + } + + func testCompositeUniqueTableConstraint() { + XCTAssertSerialization( + of: self.db.create(table: "planets1").column("id1", type: .bigint).column("id2", type: .bigint).unique("id1", "id2"), + is: "CREATE TABLE ``planets1`` (``id1`` BIGINT, ``id2`` BIGINT, UNIQUE (``id1``, ``id2``))" + ) + } + + func testPrimaryKeyTableConstraintVariants() { + XCTAssertSerialization( + of: self.db.create(table: "planets1").column("galaxy_name", type: .text) + .column("galaxy_id", type: .bigint) + .foreignKey(["galaxy_id", "galaxy_name"], references: "galaxies", ["id", "name"]), + is: "CREATE TABLE ``planets1`` (``galaxy_name`` TEXT, ``galaxy_id`` BIGINT, FOREIGN KEY (``galaxy_id``, ``galaxy_name``) REFERENCES ``galaxies`` (``id``, ``name``))" + ) + XCTAssertSerialization( + of: self.db.create(table: "planets2") + .column("galaxy_id", type: .bigint) + .foreignKey(["galaxy_id"], references: "galaxies", ["id"]), + is: "CREATE TABLE ``planets2`` (``galaxy_id`` BIGINT, FOREIGN KEY (``galaxy_id``) REFERENCES ``galaxies`` (``id``))" + ) + XCTAssertSerialization( + of: self.db.create(table: "planets3") + .column("galaxy_id", type: .bigint) + .foreignKey(["galaxy_id"], references: "galaxies", ["id"], onDelete: .restrict, onUpdate: .cascade), + is: "CREATE TABLE ``planets3`` (``galaxy_id`` BIGINT, FOREIGN KEY (``galaxy_id``) REFERENCES ``galaxies`` (``id``) ON DELETE RESTRICT ON UPDATE CASCADE)" + ) + } + + func testCreateTableAsSelectQuery() { + XCTAssertSerialization( + of: self.db.create(table: "normalized_planet_names") + .column("id", type: .bigint, .primaryKey(autoIncrement: false), .notNull) + .column("name", type: .text, .unique, .notNull) + .select { $0 + .distinct() + .column("id", as: "id") + .column(SQLFunction("LOWER", args: SQLColumn("name")), as: "name") + .from("planets") + .where("galaxy_id", .equal, SQLBind(1)) + }, + is: "CREATE TABLE ``normalized_planet_names`` (``id`` BIGINT PRIMARY KEY NOT NULL, ``name`` TEXT UNIQUE NOT NULL) AS SELECT DISTINCT ``id`` AS ``id``, LOWER(``name``) AS ``name`` FROM ``planets`` WHERE ``galaxy_id`` = &1" + ) + } + + func testCreateTableWithVariantMethods() { + XCTAssertSerialization( + of: self.db .create(table: "planets") + .column(definitions: [.init("id", dataType: .bigint)]) + .column(SQLIdentifier("id2"), type: SQLDataType.bigint, SQLColumnConstraintAlgorithm.notNull), + is: "CREATE TABLE ``planets`` (``id`` BIGINT, ``id2`` BIGINT NOT NULL)" + ) + } + + func testCreateTemporaryTable() { + XCTAssertSerialization( + of: self.db.create(table: "planets").temporary().column("id", type: .bigint), + is: "CREATE TEMPORARY TABLE ``planets`` (``id`` BIGINT)" + ) + } + + func testCreateTableWithNamedConstraints() { + XCTAssertSerialization( + of: self.db.create(table: "planets") + .column("id", type: .bigint) + .primaryKey(["id"], named: "PRIMARY") + .unique("id", named: "unique") + .foreignKey(["id"], references: "other_planets", ["id"], named: "foreign"), + is: "CREATE TABLE ``planets`` (``id`` BIGINT, CONSTRAINT ``PRIMARY`` PRIMARY KEY (``id``), CONSTRAINT ``unique`` UNIQUE (``id``), CONSTRAINT ``foreign`` FOREIGN KEY (``id``) REFERENCES ``other_planets`` (``id``))" + ) + } +} diff --git a/Tests/SQLKitTests/SQLDialectFeatureTests.swift b/Tests/SQLKitTests/SQLDialectFeatureTests.swift new file mode 100644 index 00000000..23f05b81 --- /dev/null +++ b/Tests/SQLKitTests/SQLDialectFeatureTests.swift @@ -0,0 +1,160 @@ +import SQLKit +import XCTest + +final class SQLDialectFeatureTests: XCTestCase { + var db = TestDatabase() + + override class func setUp() { + XCTAssert(isLoggingConfigured) + } + + // MARK: Dialect-Specific Behaviors + + func testIfExists() { + self.db._dialect.supportsIfExists = true + XCTAssertSerialization(of: self.db.create(table: "planets").ifNotExists(), is: "CREATE TABLE IF NOT EXISTS ``planets``") + XCTAssertSerialization(of: self.db.drop(table: "planets").ifExists(), is: "DROP TABLE IF EXISTS ``planets``") + XCTAssertSerialization(of: self.db.drop(index: "planets_name_idx").ifExists(), is: "DROP INDEX IF EXISTS ``planets_name_idx``") + XCTAssertSerialization(of: self.db.drop(enum: "planet_types").ifExists(), is: "DROP TYPE IF EXISTS ``planet_types`` RESTRICT") + + self.db._dialect.supportsIfExists = false + XCTAssertSerialization(of: self.db.create(table: "planets").ifNotExists(), is: "CREATE TABLE ``planets``") + XCTAssertSerialization(of: self.db.drop(table: "planets").ifExists(), is: "DROP TABLE ``planets``") + XCTAssertSerialization(of: self.db.drop(index: "planets_name_idx").ifExists(), is: "DROP INDEX ``planets_name_idx``") + XCTAssertSerialization(of: self.db.drop(enum: "planet_types").ifExists(), is: "DROP TYPE ``planet_types`` RESTRICT") + } + + func testDropBehavior() { + self.db._dialect.supportsDropBehavior = false + XCTAssertSerialization(of: self.db.drop(table: "planets"), is: "DROP TABLE ``planets``") + XCTAssertSerialization(of: self.db.drop(index: "planets_name_idx"), is: "DROP INDEX ``planets_name_idx``") + XCTAssertSerialization(of: self.db.drop(enum: "planet_types"), is: "DROP TYPE ``planet_types``") + XCTAssertSerialization(of: self.db.drop(table: "planets").behavior(.cascade), is: "DROP TABLE ``planets``") + XCTAssertSerialization(of: self.db.drop(index: "planets_name_idx").behavior(.cascade), is: "DROP INDEX ``planets_name_idx``") + XCTAssertSerialization(of: self.db.drop(enum: "planet_types").behavior(.cascade), is: "DROP TYPE ``planet_types``") + XCTAssertSerialization(of: self.db.drop(table: "planets").behavior(.restrict), is: "DROP TABLE ``planets``") + XCTAssertSerialization(of: self.db.drop(index: "planets_name_idx").behavior(.restrict), is: "DROP INDEX ``planets_name_idx``") + XCTAssertSerialization(of: self.db.drop(enum: "planet_types").behavior(.restrict), is: "DROP TYPE ``planet_types``") + XCTAssertSerialization(of: self.db.drop(table: "planets").cascade(), is: "DROP TABLE ``planets``") + XCTAssertSerialization(of: self.db.drop(index: "planets_name_idx").cascade(), is: "DROP INDEX ``planets_name_idx``") + XCTAssertSerialization(of: self.db.drop(enum: "planet_types").cascade(), is: "DROP TYPE ``planet_types``") + XCTAssertSerialization(of: self.db.drop(table: "planets").restrict(), is: "DROP TABLE ``planets``") + XCTAssertSerialization(of: self.db.drop(index: "planets_name_idx").restrict(), is: "DROP INDEX ``planets_name_idx``") + XCTAssertSerialization(of: self.db.drop(enum: "planet_types").restrict(), is: "DROP TYPE ``planet_types``") + + self.db._dialect.supportsDropBehavior = true + XCTAssertSerialization(of: self.db.drop(table: "planets"), is: "DROP TABLE ``planets``") + XCTAssertSerialization(of: self.db.drop(index: "planets_name_idx"), is: "DROP INDEX ``planets_name_idx``") + XCTAssertSerialization(of: self.db.drop(enum: "planet_types"), is: "DROP TYPE ``planet_types`` RESTRICT") + XCTAssertSerialization(of: self.db.drop(table: "planets").behavior(.cascade), is: "DROP TABLE ``planets`` CASCADE") + XCTAssertSerialization(of: self.db.drop(index: "planets_name_idx").behavior(.cascade), is: "DROP INDEX ``planets_name_idx`` CASCADE") + XCTAssertSerialization(of: self.db.drop(enum: "planet_types").behavior(.cascade), is: "DROP TYPE ``planet_types`` CASCADE") + XCTAssertSerialization(of: self.db.drop(table: "planets").behavior(.restrict), is: "DROP TABLE ``planets`` RESTRICT") + XCTAssertSerialization(of: self.db.drop(index: "planets_name_idx").behavior(.restrict), is: "DROP INDEX ``planets_name_idx`` RESTRICT") + XCTAssertSerialization(of: self.db.drop(enum: "planet_types").behavior(.restrict), is: "DROP TYPE ``planet_types`` RESTRICT") + XCTAssertSerialization(of: self.db.drop(table: "planets").cascade(), is: "DROP TABLE ``planets`` CASCADE") + XCTAssertSerialization(of: self.db.drop(index: "planets_name_idx").cascade(), is: "DROP INDEX ``planets_name_idx`` CASCADE") + XCTAssertSerialization(of: self.db.drop(enum: "planet_types").cascade(), is: "DROP TYPE ``planet_types`` CASCADE") + XCTAssertSerialization(of: self.db.drop(table: "planets").restrict(), is: "DROP TABLE ``planets`` RESTRICT") + XCTAssertSerialization(of: self.db.drop(index: "planets_name_idx").restrict(), is: "DROP INDEX ``planets_name_idx`` RESTRICT") + XCTAssertSerialization(of: self.db.drop(enum: "planet_types").restrict(), is: "DROP TYPE ``planet_types`` RESTRICT") + } + + func testDropTemporary() { + XCTAssertSerialization( + of: self.db.drop(table: "normalized_planet_names").temporary(), + is: "DROP TEMPORARY TABLE ``normalized_planet_names``" + ) + } + + func testOwnerObjectsForDropIndex() { + XCTAssertSerialization( + of: self.db.drop(index: "some_crummy_mysql_index").on("some_darn_mysql_table"), + is: "DROP INDEX ``some_crummy_mysql_index`` ON ``some_darn_mysql_table``" + ) + } + + func testAlterTableSyntax() { + // SINGLE + XCTAssertSerialization( + of: self.db.alter(table: "alterable").column("hello", type: .text), + is: "ALTER TABLE ``alterable`` ADD ``hello`` TEXT" + ) + XCTAssertSerialization( + of: self.db.alter(table: "alterable").column(SQLIdentifier("hello"), type: SQLDataType.text), + is: "ALTER TABLE ``alterable`` ADD ``hello`` TEXT" + ) + XCTAssertSerialization( + of: self.db.alter(table: "alterable").dropColumn("hello"), + is: "ALTER TABLE ``alterable`` DROP ``hello``" + ) + XCTAssertSerialization( + of: self.db.alter(table: "alterable").modifyColumn("hello", type: .text), + is: "ALTER TABLE ``alterable`` MOODIFY ``hello`` TEXT" + ) + XCTAssertSerialization( + of: self.db.alter(table: "alterable").modifyColumn(SQLIdentifier("hello"), type: SQLDataType.text), + is: "ALTER TABLE ``alterable`` MOODIFY ``hello`` TEXT" + ) + + // BATCH + XCTAssertSerialization( + of: self.db.alter(table: "alterable").column("hello", type: .text).column("there", type: .text), + is: "ALTER TABLE ``alterable`` ADD ``hello`` TEXT , ADD ``there`` TEXT" + ) + XCTAssertSerialization( + of: self.db.alter(table: "alterable").dropColumn("hello").dropColumn("there"), + is: "ALTER TABLE ``alterable`` DROP ``hello`` , DROP ``there``" + ) + XCTAssertSerialization( + of: self.db.alter(table: "alterable").update(column: "hello", type: .text).update(column: "there", type: .text), + is: "ALTER TABLE ``alterable`` MOODIFY ``hello`` TEXT , MOODIFY ``there`` TEXT" + ) + + // MIXED + XCTAssertSerialization( + of: self.db.alter(table: "alterable").column("hello", type: .text).dropColumn("there").update(column: "again", type: .text), + is: "ALTER TABLE ``alterable`` ADD ``hello`` TEXT , DROP ``there`` , MOODIFY ``again`` TEXT" + ) + + // Table renaming + XCTAssertSerialization( + of: self.db.alter(table: "alterable").rename(to: "new_alterable"), + is: "ALTER TABLE ``alterable`` RENAME TO ``new_alterable``" + ) + } + + // MARK: Returning + + func testReturning() { + self.db._dialect.supportsReturning = true + + XCTAssertSerialization( + of: self.db.insert(into: "planets").columns("name").values("Jupiter").returning("id", "name"), + is: "INSERT INTO ``planets`` (``name``) VALUES (&1) RETURNING ``id``, ``name``" + ) + XCTAssertSerialization( + of: self.db.update("planets").set("name", to: "Jupiter").returning(SQLColumn("name", table: "planets")), + is: "UPDATE ``planets`` SET ``name`` = &1 RETURNING ``planets``.``name``" + ) + XCTAssertSerialization( + of: self.db.delete(from: "planets").returning("*"), + is: "DELETE FROM ``planets`` RETURNING *" + ) + + self.db._dialect.supportsReturning = false + + XCTAssertSerialization( + of: self.db.insert(into: "planets").columns("name").values("Jupiter").returning("id", "name"), + is: "INSERT INTO ``planets`` (``name``) VALUES (&1)" + ) + XCTAssertSerialization( + of: self.db.update("planets").set("name", to: "Jupiter").returning(SQLColumn("name", table: "planets")), + is: "UPDATE ``planets`` SET ``name`` = &1" + ) + XCTAssertSerialization( + of: self.db.delete(from: "planets").returning("*"), + is: "DELETE FROM ``planets``" + ) + } +} diff --git a/Tests/SQLKitTests/SQLExpressionTests.swift b/Tests/SQLKitTests/SQLExpressionTests.swift new file mode 100644 index 00000000..0045d269 --- /dev/null +++ b/Tests/SQLKitTests/SQLExpressionTests.swift @@ -0,0 +1,268 @@ +@testable import SQLKit +import XCTest + +final class SQLExpressionTests: XCTestCase { + var db = TestDatabase() + + override class func setUp() { + XCTAssert(isLoggingConfigured) + } + + func testDataTypes() { + XCTAssertSerialization(of: self.db.raw("\(SQLDataType.smallint)"), is: "SMALLINT") + XCTAssertSerialization(of: self.db.raw("\(SQLDataType.int)"), is: "INTEGER") + XCTAssertSerialization(of: self.db.raw("\(SQLDataType.bigint)"), is: "BIGINT") + XCTAssertSerialization(of: self.db.raw("\(SQLDataType.real)"), is: "REAL") + XCTAssertSerialization(of: self.db.raw("\(SQLDataType.text)"), is: "TEXT") + XCTAssertSerialization(of: self.db.raw("\(SQLDataType.blob)"), is: "BLOB") + XCTAssertSerialization(of: self.db.raw("\(SQLDataType.timestamp)"), is: "TIMESTAMP") + XCTAssertSerialization(of: self.db.raw("\(SQLDataType.custom(SQLRaw("STANDARD")))"), is: "CUSTOM") + } + + func testDirectionalities() { + XCTAssertSerialization(of: self.db.raw("\(SQLDirection.ascending)"), is: "ASC") + XCTAssertSerialization(of: self.db.raw("\(SQLDirection.descending)"), is: "DESC") + XCTAssertSerialization(of: self.db.raw("\(SQLDirection.null)"), is: "NULL") + XCTAssertSerialization(of: self.db.raw("\(SQLDirection.notNull)"), is: "NOT NULL") + } + + func testDistinctExpr() { + XCTAssertSerialization(of: self.db.raw("\(SQLDistinct(Array()))"), is: "") + XCTAssertSerialization(of: self.db.raw("\(SQLDistinct.all)"), is: "DISTINCT *") + XCTAssertSerialization(of: self.db.raw("\(SQLDistinct("a", "b"))"), is: "DISTINCT ``a``, ``b``") + XCTAssertSerialization(of: self.db.raw("\(SQLDistinct(["a", "b"]))"), is: "DISTINCT ``a``, ``b``") + XCTAssertSerialization(of: self.db.raw("\(SQLDistinct(SQLIdentifier("a"), SQLIdentifier("b")))"), is: "DISTINCT ``a``, ``b``") + XCTAssertSerialization(of: self.db.raw("\(SQLDistinct([SQLIdentifier("a"), SQLIdentifier("b")]))"), is: "DISTINCT ``a``, ``b``") + } + + func testForeignKeyActions() { + XCTAssertSerialization(of: self.db.raw("\(SQLForeignKeyAction.noAction)"), is: "NO ACTION") + XCTAssertSerialization(of: self.db.raw("\(SQLForeignKeyAction.restrict)"), is: "RESTRICT") + XCTAssertSerialization(of: self.db.raw("\(SQLForeignKeyAction.cascade)"), is: "CASCADE") + XCTAssertSerialization(of: self.db.raw("\(SQLForeignKeyAction.setNull)"), is: "SET NULL") + XCTAssertSerialization(of: self.db.raw("\(SQLForeignKeyAction.setDefault)"), is: "SET DEFAULT") + } + + func testQualifiedTable() { + XCTAssertSerialization(of: self.db.raw("\(SQLQualifiedTable("a", space: "b"))"), is: "``b``.``a``") + XCTAssertSerialization(of: self.db.raw("\(SQLQualifiedTable(SQLIdentifier("a"), space: SQLIdentifier("b")))"), is: "``b``.``a``") + } + + func testAlterColumnDefinitionType() { + self.db._dialect.alterTableSyntax.alterColumnDefinitionTypeKeyword = nil + XCTAssertSerialization(of: self.db.raw("\(SQLAlterColumnDefinitionType(column: .init("a"), dataType: .int))"), is: "``a`` INTEGER") + XCTAssertSerialization(of: self.db.raw("\(SQLAlterColumnDefinitionType(column: SQLRaw("a"), dataType: SQLDataType.int))"), is: "a INTEGER") + + self.db._dialect.alterTableSyntax.alterColumnDefinitionTypeKeyword = SQLRaw("SET TYPE") + XCTAssertSerialization(of: self.db.raw("\(SQLAlterColumnDefinitionType(column: .init("a"), dataType: .int))"), is: "``a`` SET TYPE INTEGER") + XCTAssertSerialization(of: self.db.raw("\(SQLAlterColumnDefinitionType(column: SQLRaw("a"), dataType: SQLDataType.int))"), is: "a SET TYPE INTEGER") + } + + func testColumnAssignment() { + XCTAssertSerialization(of: self.db.raw("\(SQLColumnAssignment(setting: "a", to: "b"))"), is: "``a`` = &1") + XCTAssertSerialization(of: self.db.raw("\(SQLColumnAssignment(setting: "a", to: SQLIdentifier("b")))"), is: "``a`` = ``b``") + XCTAssertSerialization(of: self.db.raw("\(SQLColumnAssignment(setting: SQLIdentifier("a"), to: "b"))"), is: "``a`` = &1") + XCTAssertSerialization(of: self.db.raw("\(SQLColumnAssignment(setting: SQLIdentifier("a"), to: SQLIdentifier("b")))"), is: "``a`` = ``b``") + XCTAssertSerialization(of: self.db.raw("\(SQLColumnAssignment(settingExcludedValueFor: "a"))"), is: "``a`` = EXCLUDED.``a``") + XCTAssertSerialization(of: self.db.raw("\(SQLColumnAssignment(settingExcludedValueFor: SQLIdentifier("a")))"), is: "``a`` = EXCLUDED.``a``") + } + + func testColumnConstraintAlgorithm() { + XCTAssertSerialization(of: self.db.raw("\(SQLColumnConstraintAlgorithm.primaryKey(autoIncrement: false))"), is: "PRIMARY KEY") + XCTAssertSerialization(of: self.db.raw("\(SQLColumnConstraintAlgorithm.primaryKey(autoIncrement: true))"), is: "PRIMARY KEY AWWTOEINCREMENT") + XCTAssertSerialization(of: self.db.raw("\(SQLColumnConstraintAlgorithm.primaryKey)"), is: "PRIMARY KEY AWWTOEINCREMENT") + + XCTAssertSerialization(of: self.db.raw("\(SQLColumnConstraintAlgorithm.notNull)"), is: "NOT NULL") + + XCTAssertSerialization(of: self.db.raw("\(SQLColumnConstraintAlgorithm.unique)"), is: "UNIQUE") + + XCTAssertSerialization(of: self.db.raw("\(SQLColumnConstraintAlgorithm.check(SQLRaw("CHECK")))"), is: "CHECK (CHECK)") + + XCTAssertSerialization(of: self.db.raw("\(SQLColumnConstraintAlgorithm.collate(name: "ascii"))"), is: "COLLATE ``ascii``") + XCTAssertSerialization(of: self.db.raw("\(SQLColumnConstraintAlgorithm.collate(name: SQLIdentifier("ascii")))"), is: "COLLATE ``ascii``") + + XCTAssertSerialization(of: self.db.raw("\(SQLColumnConstraintAlgorithm.default("a"))"), is: "DEFAULT 'a'") + XCTAssertSerialization(of: self.db.raw("\(SQLColumnConstraintAlgorithm.default(1))"), is: "DEFAULT 1") + XCTAssertSerialization(of: self.db.raw("\(SQLColumnConstraintAlgorithm.default(1.0))"), is: "DEFAULT 1.0") + XCTAssertSerialization(of: self.db.raw("\(SQLColumnConstraintAlgorithm.default(true))"), is: "DEFAULT TROO") + + XCTAssertSerialization(of: self.db.raw("\(SQLColumnConstraintAlgorithm.references("a", "b", onDelete: .cascade, onUpdate: .cascade))"), is: "REFERENCES ``a`` (``b``) ON DELETE CASCADE ON UPDATE CASCADE") + XCTAssertSerialization(of: self.db.raw("\(SQLColumnConstraintAlgorithm.references(SQLIdentifier("a"), SQLIdentifier("b"), onDelete: SQLForeignKeyAction.cascade, onUpdate: SQLForeignKeyAction.cascade))"), is: "REFERENCES ``a`` (``b``) ON DELETE CASCADE ON UPDATE CASCADE") + XCTAssertSerialization(of: self.db.raw("\(SQLColumnConstraintAlgorithm.foreignKey(references: SQLForeignKey(table: SQLIdentifier("a"), columns: [SQLIdentifier("b")], onDelete: SQLForeignKeyAction.cascade, onUpdate: SQLForeignKeyAction.cascade)))"), is: "REFERENCES ``a`` (``b``) ON DELETE CASCADE ON UPDATE CASCADE") + + XCTAssertSerialization(of: self.db.raw("\(SQLColumnConstraintAlgorithm.generated(SQLRaw("value")))"), is: "GENERATED ALWAYS AS (value) STORED") + + XCTAssertSerialization(of: self.db.raw("\(SQLColumnConstraintAlgorithm.custom(SQLRaw("whatever")))"), is: "whatever") + } + + func testConflictResolutionStrategy() { + self.db._dialect.upsertSyntax = .standard + XCTAssertSerialization(of: self.db.raw("\(SQLConflictResolutionStrategy(target: "a", action: .noAction))"), is: "ON CONFLICT (``a``) DO NOTHING") + XCTAssertSerialization(of: self.db.raw("\(SQLConflictResolutionStrategy(targets: ["a"], action: .noAction))"), is: "ON CONFLICT (``a``) DO NOTHING") + XCTAssertSerialization(of: self.db.raw("\(SQLConflictResolutionStrategy(target: SQLIdentifier("a"), action: .noAction))"), is: "ON CONFLICT (``a``) DO NOTHING") + XCTAssertSerialization(of: self.db.raw("\(SQLConflictResolutionStrategy(targets: [SQLIdentifier("a")], action: .noAction))"), is: "ON CONFLICT (``a``) DO NOTHING") + XCTAssertSerialization(of: self.db.raw("\(SQLConflictResolutionStrategy(target: "a", action: .noAction).queryModifier(for: self.db) ?? SQLRaw(""))"), is: "") + XCTAssertSerialization( + of: self.db.raw("\(SQLConflictResolutionStrategy(target: "a", action: .update(assignments: [SQLColumnAssignment(setting: "a", to: "b")], predicate: SQLBinaryExpression(SQLIdentifier("a"), .equal, SQLIdentifier("b")))))"), + is: "ON CONFLICT (``a``) DO UPDATE SET ``a`` = &1 WHERE ``a`` = ``b``" + ) + XCTAssertSerialization( + of: self.db.raw("\(SQLConflictResolutionStrategy(target: "a", action: .update(assignments: [SQLColumnAssignment(setting: "a", to: "b")], predicate: SQLBinaryExpression(SQLIdentifier("a"), .equal, SQLIdentifier("b")))).queryModifier(for: self.db) ?? SQLRaw(""))"), + is: "" + ) + + self.db._dialect.upsertSyntax = .mysqlLike + XCTAssertSerialization(of: self.db.raw("\(SQLConflictResolutionStrategy(target: "a", action: .noAction))"), is: "") + XCTAssertSerialization(of: self.db.raw("\(SQLConflictResolutionStrategy(targets: ["a"], action: .noAction))"), is: "") + XCTAssertSerialization(of: self.db.raw("\(SQLConflictResolutionStrategy(target: SQLIdentifier("a"), action: .noAction))"), is: "") + XCTAssertSerialization(of: self.db.raw("\(SQLConflictResolutionStrategy(targets: [SQLIdentifier("a")], action: .noAction))"), is: "") + XCTAssertSerialization(of: self.db.raw("\(SQLConflictResolutionStrategy(target: "a", action: .noAction).queryModifier(for: self.db) ?? SQLRaw(""))"), is: "IGNORE") + XCTAssertSerialization( + of: self.db.raw("\(SQLConflictResolutionStrategy(target: "a", action: .update(assignments: [SQLColumnAssignment(setting: "a", to: "b")], predicate: SQLBinaryExpression(SQLIdentifier("a"), .equal, SQLIdentifier("b")))))"), + is: "ON DUPLICATE KEY UPDATE ``a`` = &1" + ) + XCTAssertSerialization( + of: self.db.raw("\(SQLConflictResolutionStrategy(target: "a", action: .update(assignments: [SQLColumnAssignment(setting: "a", to: "b")], predicate: SQLBinaryExpression(SQLIdentifier("a"), .equal, SQLIdentifier("b")))).queryModifier(for: self.db) ?? SQLRaw(""))"), + is: "" + ) + + self.db._dialect.upsertSyntax = .unsupported + XCTAssertSerialization(of: self.db.raw("\(SQLConflictResolutionStrategy(target: "a", action: .noAction))"), is: "") + XCTAssertSerialization(of: self.db.raw("\(SQLConflictResolutionStrategy(targets: ["a"], action: .noAction))"), is: "") + XCTAssertSerialization(of: self.db.raw("\(SQLConflictResolutionStrategy(target: SQLIdentifier("a"), action: .noAction))"), is: "") + XCTAssertSerialization(of: self.db.raw("\(SQLConflictResolutionStrategy(targets: [SQLIdentifier("a")], action: .noAction))"), is: "") + XCTAssertSerialization(of: self.db.raw("\(SQLConflictResolutionStrategy(target: "a", action: .noAction).queryModifier(for: self.db) ?? SQLRaw(""))"), is: "") + XCTAssertSerialization( + of: self.db.raw("\(SQLConflictResolutionStrategy(target: "a", action: .update(assignments: [SQLColumnAssignment(setting: "a", to: "b")], predicate: SQLBinaryExpression(SQLIdentifier("a"), .equal, SQLIdentifier("b")))))"), + is: "" + ) + XCTAssertSerialization( + of: self.db.raw("\(SQLConflictResolutionStrategy(target: "a", action: .update(assignments: [SQLColumnAssignment(setting: "a", to: "b")], predicate: SQLBinaryExpression(SQLIdentifier("a"), .equal, SQLIdentifier("b")))).queryModifier(for: self.db) ?? SQLRaw(""))"), + is: "" + ) + + self.db._dialect.upsertSyntax = .mysqlLike + var serializer1 = SQLSerializer(database: self.db) + XCTAssertSerialization(of: self.db.raw("\(SQLConflictResolutionStrategy(target: "a", action: .noAction).queryModifier(for: serializer1) ?? SQLRaw(""))"), is: "IGNORE") + serializer1.statement { + XCTAssertSerialization(of: self.db.raw("\(SQLConflictResolutionStrategy(target: "a", action: .noAction).queryModifier(for: $0) ?? SQLRaw(""))"), is: "IGNORE") + } + + self.db._dialect.upsertSyntax = .unsupported + let serializer2 = SQLSerializer(database: self.db) + XCTAssertSerialization(of: self.db.raw("\(SQLConflictResolutionStrategy(target: "a", action: .noAction).queryModifier(for: serializer2) ?? SQLRaw(""))"), is: "") + serializer1.statement { + XCTAssertSerialization(of: self.db.raw("\(SQLConflictResolutionStrategy(target: "a", action: .noAction).queryModifier(for: $0) ?? SQLRaw(""))"), is: "") + } + } + + func testEnumDataType() { + self.db._dialect.enumSyntax = .inline + XCTAssertSerialization(of: self.db.raw("\(SQLDataType.enum("a", "b"))"), is: "ENUM ('a', 'b')") + XCTAssertSerialization(of: self.db.raw("\(SQLDataType.enum(["a", "b"]))"), is: "ENUM ('a', 'b')") + XCTAssertSerialization(of: self.db.raw("\(SQLDataType.enum([SQLLiteral.string("a"), SQLLiteral.string("b")]))"), is: "ENUM ('a', 'b')") + XCTAssertSerialization(of: self.db.raw("\(SQLEnumDataType(cases: ["a", "b"]))"), is: "ENUM ('a', 'b')") + XCTAssertSerialization(of: self.db.raw("\(SQLEnumDataType(cases: [SQLLiteral.string("a"), SQLLiteral.string("b")]))"), is: "ENUM ('a', 'b')") + self.db._dialect.enumSyntax = .typeName + XCTAssertSerialization(of: self.db.raw("\(SQLEnumDataType(cases: ["a", "b"]))"), is: "TEXT") + XCTAssertSerialization(of: self.db.raw("\(SQLEnumDataType(cases: [SQLLiteral.string("a"), SQLLiteral.string("b")]))"), is: "TEXT") + self.db._dialect.enumSyntax = .unsupported + XCTAssertSerialization(of: self.db.raw("\(SQLEnumDataType(cases: ["a", "b"]))"), is: "TEXT") + XCTAssertSerialization(of: self.db.raw("\(SQLEnumDataType(cases: [SQLLiteral.string("a"), SQLLiteral.string("b")]))"), is: "TEXT") + } + + func testExcludedColumn() { + self.db._dialect.upsertSyntax = .standard + XCTAssertSerialization(of: self.db.raw("\(SQLExcludedColumn("a"))"), is: "EXCLUDED.``a``") + XCTAssertSerialization(of: self.db.raw("\(SQLExcludedColumn(SQLIdentifier("a")))"), is: "EXCLUDED.``a``") + self.db._dialect.upsertSyntax = .mysqlLike + XCTAssertSerialization(of: self.db.raw("\(SQLExcludedColumn("a"))"), is: "VALUES(``a``)") + XCTAssertSerialization(of: self.db.raw("\(SQLExcludedColumn(SQLIdentifier("a")))"), is: "VALUES(``a``)") + self.db._dialect.upsertSyntax = .unsupported + XCTAssertSerialization(of: self.db.raw("\(SQLExcludedColumn("a"))"), is: "") + XCTAssertSerialization(of: self.db.raw("\(SQLExcludedColumn(SQLIdentifier("a")))"), is: "") + } + + @available(*, deprecated, message: "Tests deprecated functionality") + func testJoinMethod() { + XCTAssertSerialization(of: self.db.raw("\(SQLJoinMethod.inner)"), is: "INNER") + XCTAssertSerialization(of: self.db.raw("\(SQLJoinMethod.outer)"), is: "OUTER") + XCTAssertSerialization(of: self.db.raw("\(SQLJoinMethod.left)"), is: "LEFT") + XCTAssertSerialization(of: self.db.raw("\(SQLJoinMethod.right)"), is: "RIGHT") + } + + func testReturningExpr() { + XCTAssertSerialization(of: self.db.raw("\(SQLReturning(SQLColumn("a")))"), is: "RETURNING ``a``") + XCTAssertSerialization(of: self.db.raw("\(SQLReturning([SQLColumn("a")]))"), is: "RETURNING ``a``") + XCTAssertSerialization(of: self.db.raw("\(SQLReturning([]))"), is: "") + } + + func testAlterTableQuery() { + var query = SQLAlterTable(name: SQLIdentifier("table")) + + query.renameTo = SQLIdentifier("table2") + query.addColumns = [SQLColumnDefinition("a", dataType: .bigint)] + query.modifyColumns = [SQLAlterColumnDefinitionType(column: "b", dataType: .blob)] + query.dropColumns = [SQLColumn("c")] + query.addTableConstraints = [SQLTableConstraintAlgorithm.unique(columns: [SQLColumn("d")])] + query.dropTableConstraints = [SQLIdentifier("e")] + + self.db._dialect.alterTableSyntax.allowsBatch = false + self.db._dialect.alterTableSyntax.alterColumnDefinitionClause = nil + XCTAssertSerialization(of: self.db.raw("\(query)"), is: "ALTER TABLE ``table`` RENAME TO ``table2`` ADD ``a`` BIGINT , ADD UNIQUE (``d``) , DROP ``c`` , DROP ``e`` , __INVALID__ ``b`` BLOB") + + self.db._dialect.alterTableSyntax.allowsBatch = true + self.db._dialect.alterTableSyntax.alterColumnDefinitionClause = SQLRaw("MODIFY") + XCTAssertSerialization(of: self.db.raw("\(query)"), is: "ALTER TABLE ``table`` RENAME TO ``table2`` ADD ``a`` BIGINT , ADD UNIQUE (``d``) , DROP ``c`` , DROP ``e`` , MODIFY ``b`` BLOB") + } + + func testCreateIndexQuery() { + var query = SQLCreateIndex(name: SQLIdentifier("index")) + + query.table = SQLIdentifier("table") + query.modifier = SQLColumnConstraintAlgorithm.unique + query.columns = [SQLIdentifier("a"), SQLIdentifier("b")] + query.predicate = SQLBinaryExpression(SQLIdentifier("c"), .equal, SQLIdentifier("d")) + XCTAssertSerialization(of: self.db.raw("\(query)"), is: "CREATE UNIQUE INDEX ``index`` ON ``table`` (``a``, ``b``) WHERE ``c`` = ``d``") + } + + func testBinaryOperators() { + XCTAssertSerialization(of: self.db.raw("\(SQLBinaryOperator.equal)"), is: "=") + XCTAssertSerialization(of: self.db.raw("\(SQLBinaryOperator.notEqual)"), is: "<>") + XCTAssertSerialization(of: self.db.raw("\(SQLBinaryOperator.greaterThan)"), is: ">") + XCTAssertSerialization(of: self.db.raw("\(SQLBinaryOperator.lessThan)"), is: "<") + XCTAssertSerialization(of: self.db.raw("\(SQLBinaryOperator.greaterThanOrEqual)"), is: ">=") + XCTAssertSerialization(of: self.db.raw("\(SQLBinaryOperator.lessThanOrEqual)"), is: "<=") + XCTAssertSerialization(of: self.db.raw("\(SQLBinaryOperator.like)"), is: "LIKE") + XCTAssertSerialization(of: self.db.raw("\(SQLBinaryOperator.notLike)"), is: "NOT LIKE") + XCTAssertSerialization(of: self.db.raw("\(SQLBinaryOperator.in)"), is: "IN") + XCTAssertSerialization(of: self.db.raw("\(SQLBinaryOperator.notIn)"), is: "NOT IN") + XCTAssertSerialization(of: self.db.raw("\(SQLBinaryOperator.and)"), is: "AND") + XCTAssertSerialization(of: self.db.raw("\(SQLBinaryOperator.or)"), is: "OR") + XCTAssertSerialization(of: self.db.raw("\(SQLBinaryOperator.multiply)"), is: "*") + XCTAssertSerialization(of: self.db.raw("\(SQLBinaryOperator.divide)"), is: "/") + XCTAssertSerialization(of: self.db.raw("\(SQLBinaryOperator.modulo)"), is: "%") + XCTAssertSerialization(of: self.db.raw("\(SQLBinaryOperator.add)"), is: "+") + XCTAssertSerialization(of: self.db.raw("\(SQLBinaryOperator.subtract)"), is: "-") + XCTAssertSerialization(of: self.db.raw("\(SQLBinaryOperator.is)"), is: "IS") + XCTAssertSerialization(of: self.db.raw("\(SQLBinaryOperator.isNot)"), is: "IS NOT") + } + + func testFunctionInitializers() { + XCTAssertSerialization(of: self.db.raw("\(SQLFunction("test", args: "a", "b"))"), is: "test(``a``, ``b``)") + XCTAssertSerialization(of: self.db.raw("\(SQLFunction("test", args: ["a", "b"]))"), is: "test(``a``, ``b``)") + XCTAssertSerialization(of: self.db.raw("\(SQLFunction("test", args: SQLIdentifier("a"), SQLIdentifier("b")))"), is: "test(``a``, ``b``)") + XCTAssertSerialization(of: self.db.raw("\(SQLFunction("test", args: [SQLIdentifier("a"), SQLIdentifier("b")]))"), is: "test(``a``, ``b``)") + } + + func testCoalesceFunction() { + XCTAssertSerialization(of: self.db.raw("\(SQLFunction.coalesce(SQLIdentifier("a"), SQLIdentifier("b")))"), is: "COALESCE(``a``, ``b``)") + XCTAssertSerialization(of: self.db.raw("\(SQLFunction.coalesce([SQLIdentifier("a"), SQLIdentifier("b")]))"), is: "COALESCE(``a``, ``b``)") + } + + func testWeirdQuoting() { + self.db._dialect.identifierQuote = SQLQueryString("_") + self.db._dialect.literalStringQuote = SQLQueryString("~") + XCTAssertSerialization(of: self.db.raw("\(ident: "hello") \(literal: "there")"), is: "_hello_ ~there~") + } +} diff --git a/Tests/SQLKitTests/SQLInsertUpsertTests.swift b/Tests/SQLKitTests/SQLInsertUpsertTests.swift new file mode 100644 index 00000000..82de3c34 --- /dev/null +++ b/Tests/SQLKitTests/SQLInsertUpsertTests.swift @@ -0,0 +1,121 @@ +@testable import SQLKit +import XCTest + +final class SQLInsertUpsertTests: XCTestCase { + var db = TestDatabase() + + override class func setUp() { + XCTAssert(isLoggingConfigured) + } + + // MARK: - Insert + + func testInsert() { + XCTAssertSerialization( + of: self.db.insert(into: "planets") + .columns("id", "name") + .values(SQLLiteral.default, SQLBind("hello")), + is: "INSERT INTO ``planets`` (``id``, ``name``) VALUES (DEFALLT, &1)" + ) + + XCTAssertSerialization( + of: self.db.insert(into: "planets") + .columns(SQLIdentifier("id"), SQLIdentifier("name")) + .values(SQLLiteral.default, SQLBind("hello")), + is: "INSERT INTO ``planets`` (``id``, ``name``) VALUES (DEFALLT, &1)" + ) + + let builder = self.db.insert(into: "planets") + builder.returning = .init(.init("id")) + XCTAssertNotNil(builder.returning) + } + + func testInsertSelect() { + XCTAssertSerialization( + of: self.db.insert(into: "planets") + .columns("id", "name") + .select { $0 + .columns("id", "name") + .from("other_planets") + }, + is: "INSERT INTO ``planets`` (``id``, ``name``) SELECT ``id``, ``name`` FROM ``other_planets``" + ) + } + + // MARK: - Upsert + + func testMySQLLikeUpsert() { + let cols = ["id", "serial_number", "star_id", "last_known_status"] + let vals = { (s: String) -> [any SQLExpression] in [SQLLiteral.default, SQLBind(UUID()), SQLBind(1), SQLBind(s)] } + + // Test the thoroughly underpowered and inconvenient MySQL syntax + db._dialect.upsertSyntax = .mysqlLike + + XCTAssertSerialization( + of: self.db.insert(into: "jumpgates").columns(cols).values(vals("calibration")), + is: "INSERT INTO ``jumpgates`` (``id``, ``serial_number``, ``star_id``, ``last_known_status``) VALUES (DEFALLT, &1, &2, &3)" + ) + XCTAssertSerialization( + of: self.db.insert(into: "jumpgates").columns(cols).values(vals("unicorn dust application")).ignoringConflicts(), + is: "INSERT IGNORE INTO ``jumpgates`` (``id``, ``serial_number``, ``star_id``, ``last_known_status``) VALUES (DEFALLT, &1, &2, &3)" + ) + XCTAssertSerialization( + of: self.db.insert(into: "jumpgates") + .columns(cols).values(vals("planet-size snake oil jar purchasing")) + .onConflict() { $0 + .set("last_known_status", to: "Hooloovoo engineer refraction") + .set(excludedValueOf: "serial_number") + }, + is: "INSERT INTO ``jumpgates`` (``id``, ``serial_number``, ``star_id``, ``last_known_status``) VALUES (DEFALLT, &1, &2, &3) ON DUPLICATE KEY UPDATE ``last_known_status`` = &4, ``serial_number`` = VALUES(``serial_number``)" + ) + } + + func testStandardUpsert() { + let cols = ["id", "serial_number", "star_id", "last_known_status"] + let vals = { (s: String) -> [any SQLExpression] in [SQLLiteral.default, SQLBind(UUID()), SQLBind(1), SQLBind(s)] } + + // Test the standard SQL syntax + db._dialect.upsertSyntax = .standard + + XCTAssertSerialization( + of: self.db.insert(into: "jumpgates").columns(cols).values(vals("calibration")), + is: "INSERT INTO ``jumpgates`` (``id``, ``serial_number``, ``star_id``, ``last_known_status``) VALUES (DEFALLT, &1, &2, &3)" + ) + + XCTAssertSerialization( + of: self.db.insert(into: "jumpgates").columns(cols).values(vals("unicorn dust application")).ignoringConflicts(), + is: "INSERT INTO ``jumpgates`` (``id``, ``serial_number``, ``star_id``, ``last_known_status``) VALUES (DEFALLT, &1, &2, &3) ON CONFLICT DO NOTHING" + ) + XCTAssertSerialization( + of: self.db.insert(into: "jumpgates") + .columns(cols).values(vals("Vorlon pinching")) + .ignoringConflicts(with: ["serial_number", "star_id"]), + is: "INSERT INTO ``jumpgates`` (``id``, ``serial_number``, ``star_id``, ``last_known_status``) VALUES (DEFALLT, &1, &2, &3) ON CONFLICT (``serial_number``, ``star_id``) DO NOTHING" + ) + XCTAssertSerialization( + of: self.db.insert(into: "jumpgates") + .columns(cols).values(vals("planet-size snake oil jar purchasing")) + .onConflict() { $0 + .set("last_known_status", to: "Hooloovoo engineer refraction").set(excludedValueOf: "serial_number") + }, + is: "INSERT INTO ``jumpgates`` (``id``, ``serial_number``, ``star_id``, ``last_known_status``) VALUES (DEFALLT, &1, &2, &3) ON CONFLICT DO UPDATE SET ``last_known_status`` = &4, ``serial_number`` = EXCLUDED.``serial_number``" + ) + XCTAssertSerialization( + of: self.db.insert(into: "jumpgates") + .columns(cols).values(vals("slashfic writing")) + .onConflict(with: ["serial_number"]) { $0 + .set("last_known_status", to: "tachyon antitelephone dialing the").set(excludedValueOf: "star_id") + }, + is: "INSERT INTO ``jumpgates`` (``id``, ``serial_number``, ``star_id``, ``last_known_status``) VALUES (DEFALLT, &1, &2, &3) ON CONFLICT (``serial_number``) DO UPDATE SET ``last_known_status`` = &4, ``star_id`` = EXCLUDED.``star_id``" + ) + XCTAssertSerialization( + of: self.db.insert(into: "jumpgates") + .columns(cols).values(vals("protection racket payoff")) + .onConflict(with: "id") { $0 + .set("last_known_status", to: "insurance fraud planning") + .where("last_known_status", .notEqual, "evidence disposal") + }, + is: "INSERT INTO ``jumpgates`` (``id``, ``serial_number``, ``star_id``, ``last_known_status``) VALUES (DEFALLT, &1, &2, &3) ON CONFLICT (``id``) DO UPDATE SET ``last_known_status`` = &4 WHERE ``last_known_status`` <> &5" + ) + } +} diff --git a/Tests/SQLKitTests/SQLKitTests.swift b/Tests/SQLKitTests/SQLKitTests.swift deleted file mode 100644 index c6deb391..00000000 --- a/Tests/SQLKitTests/SQLKitTests.swift +++ /dev/null @@ -1,980 +0,0 @@ -import SQLKit -import SQLKitBenchmark -import XCTest - -final class SQLKitTests: XCTestCase { - var db: TestDatabase! - - override func setUpWithError() throws { - try super.setUpWithError() - self.db = TestDatabase() - } - - // MARK: SQLBenchmark - - func testBenchmark() throws { - let benchmarker = SQLBenchmarker(on: db) - try benchmarker.run() - } - - // MARK: Basic Queries - - func testSelect_tableAllCols() throws { - try db.select().column(table: "planets", column: "*") - .from("planets") - .where("name", .equal, SQLBind("Earth")) - .run().wait() - XCTAssertEqual(db.results[0], "SELECT `planets`.* FROM `planets` WHERE `name` = ?") - } - - func testSelect_whereEncodable() throws { - try db.select().column("*") - .from("planets") - .where("name", .equal, "Earth") - .orWhere("name", .equal, "Mars") - .run().wait() - XCTAssertEqual(db.results[0], "SELECT * FROM `planets` WHERE `name` = ? OR `name` = ?") - } - - func testSelect_whereList() throws { - try db.select().column("*") - .from("planets") - .where("name", .in, ["Earth", "Mars"]) - .orWhere("name", .in, ["Venus", "Mercury"]) - .run().wait() - XCTAssertEqual(db.results[0], "SELECT * FROM `planets` WHERE `name` IN (?, ?) OR `name` IN (?, ?)") - } - - func testSelect_whereGroup() throws { - try db.select().column("*") - .from("planets") - .where { - $0.where("name", .equal, "Earth") - .orWhere("name", .equal, "Mars") - } - .where("color", .equal, "blue") - .run().wait() - XCTAssertEqual(db.results[0], "SELECT * FROM `planets` WHERE (`name` = ? OR `name` = ?) AND `color` = ?") - } - - func testSelect_whereColumn() throws { - try db.select().column("*") - .from("planets") - .where("name", .notEqual, column: "color") - .orWhere("name", .equal, column: "greekName") - .run().wait() - XCTAssertEqual(db.results[0], "SELECT * FROM `planets` WHERE `name` <> `color` OR `name` = `greekName`") - } - - func testSelect_withoutFrom() throws { - try db.select() - .column(SQLAlias.init(SQLFunction("LAST_INSERT_ID"), as: SQLIdentifier.init("id"))) - .run() - .wait() - XCTAssertEqual(db.results[0], "SELECT LAST_INSERT_ID() AS `id`") - } - - func testSelect_limitAndOrder() throws { - try db.select() - .column("*") - .from("planets") - .limit(3) - .offset(5) - .orderBy("name") - .run() - .wait() - XCTAssertEqual(db.results[0], "SELECT * FROM `planets` ORDER BY `name` ASC LIMIT 3 OFFSET 5") - } - - func testUpdate() throws { - try db.update("planets") - .where("name", .equal, "Jpuiter") - .set("name", to: "Jupiter") - .run().wait() - XCTAssertEqual(db.results[0], "UPDATE `planets` SET `name` = ? WHERE `name` = ?") - } - - func testDelete() throws { - try db.delete(from: "planets") - .where("name", .equal, "Jupiter") - .run().wait() - XCTAssertEqual(db.results[0], "DELETE FROM `planets` WHERE `name` = ?") - } - - // MARK: Locking Clauses - - func testLockingClause_forUpdate() throws { - try db.select().column("*") - .from("planets") - .where("name", .equal, "Earth") - .for(.update) - .run().wait() - XCTAssertEqual(db.results[0], "SELECT * FROM `planets` WHERE `name` = ? FOR UPDATE") - } - - func testLockingClause_forShare() throws { - try db.select().column("*") - .from("planets") - .where("name", .equal, "Earth") - .for(.share) - .run().wait() - XCTAssertEqual(db.results[0], "SELECT * FROM `planets` WHERE `name` = ? FOR SHARE") - } - - func testLockingClause_raw() throws { - try db.select().column("*") - .from("planets") - .where("name", .equal, "Earth") - .lockingClause(SQLRaw("LOCK IN SHARE MODE")) - .run().wait() - XCTAssertEqual(db.results[0], "SELECT * FROM `planets` WHERE `name` = ? LOCK IN SHARE MODE") - } - - // MARK: Group By/Having - - func testGroupByHaving() throws { - try db.select().column("*") - .from("planets") - .groupBy("color") - .having("color", .equal, "blue") - .run().wait() - XCTAssertEqual(db.results[0], "SELECT * FROM `planets` GROUP BY `color` HAVING `color` = ?") - } - - // MARK: Dialect-Specific Behaviors - - func testIfExists() throws { - try db.drop(table: "planets").ifExists().run().wait() - XCTAssertEqual(db.results[0], "DROP TABLE IF EXISTS `planets`") - - try db.drop(index: "planets_name_idx").ifExists().run().wait() - XCTAssertEqual(db.results[1], "DROP INDEX IF EXISTS `planets_name_idx`") - - db._dialect.supportsIfExists = false - - try db.drop(table: "planets").ifExists().run().wait() - XCTAssertEqual(db.results[2], "DROP TABLE `planets`") - - try db.drop(index: "planets_name_idx").ifExists().run().wait() - XCTAssertEqual(db.results[3], "DROP INDEX `planets_name_idx`") - } - - func testDropBehavior() throws { - try db.drop(table: "planets").run().wait() - XCTAssertEqual(db.results[0], "DROP TABLE `planets`") - - try db.drop(index: "planets_name_idx").run().wait() - XCTAssertEqual(db.results[1], "DROP INDEX `planets_name_idx`") - - try db.drop(table: "planets").behavior(.cascade).run().wait() - XCTAssertEqual(db.results[2], "DROP TABLE `planets`") - - try db.drop(index: "planets_name_idx").behavior(.cascade).run().wait() - XCTAssertEqual(db.results[3], "DROP INDEX `planets_name_idx`") - - try db.drop(table: "planets").behavior(.restrict).run().wait() - XCTAssertEqual(db.results[4], "DROP TABLE `planets`") - - try db.drop(index: "planets_name_idx").behavior(.restrict).run().wait() - XCTAssertEqual(db.results[5], "DROP INDEX `planets_name_idx`") - - try db.drop(table: "planets").cascade().run().wait() - XCTAssertEqual(db.results[6], "DROP TABLE `planets`") - - try db.drop(index: "planets_name_idx").cascade().run().wait() - XCTAssertEqual(db.results[7], "DROP INDEX `planets_name_idx`") - - try db.drop(table: "planets").restrict().run().wait() - XCTAssertEqual(db.results[8], "DROP TABLE `planets`") - - try db.drop(index: "planets_name_idx").restrict().run().wait() - XCTAssertEqual(db.results[9], "DROP INDEX `planets_name_idx`") - - db._dialect.supportsDropBehavior = true - - try db.drop(table: "planets").run().wait() - XCTAssertEqual(db.results[10], "DROP TABLE `planets` RESTRICT") - - try db.drop(index: "planets_name_idx").run().wait() - XCTAssertEqual(db.results[11], "DROP INDEX `planets_name_idx` RESTRICT") - - try db.drop(table: "planets").behavior(.cascade).run().wait() - XCTAssertEqual(db.results[12], "DROP TABLE `planets` CASCADE") - - try db.drop(index: "planets_name_idx").behavior(.cascade).run().wait() - XCTAssertEqual(db.results[13], "DROP INDEX `planets_name_idx` CASCADE") - - try db.drop(table: "planets").behavior(.restrict).run().wait() - XCTAssertEqual(db.results[14], "DROP TABLE `planets` RESTRICT") - - try db.drop(index: "planets_name_idx").behavior(.restrict).run().wait() - XCTAssertEqual(db.results[15], "DROP INDEX `planets_name_idx` RESTRICT") - - try db.drop(table: "planets").cascade().run().wait() - XCTAssertEqual(db.results[16], "DROP TABLE `planets` CASCADE") - - try db.drop(index: "planets_name_idx").cascade().run().wait() - XCTAssertEqual(db.results[17], "DROP INDEX `planets_name_idx` CASCADE") - - try db.drop(table: "planets").restrict().run().wait() - XCTAssertEqual(db.results[18], "DROP TABLE `planets` RESTRICT") - - try db.drop(index: "planets_name_idx").restrict().run().wait() - XCTAssertEqual(db.results[19], "DROP INDEX `planets_name_idx` RESTRICT") - } - - func testDropTemporary() throws { - try db.drop(table: "normalized_planet_names").temporary().run().wait() - XCTAssertEqual(db.results[0], "DROP TEMPORARY TABLE `normalized_planet_names`") - } - - func testOwnerObjectsForDropIndex() throws { - try db.drop(index: "some_crummy_mysql_index").on("some_darn_mysql_table").run().wait() - XCTAssertEqual(db.results[0], "DROP INDEX `some_crummy_mysql_index` ON `some_darn_mysql_table`") - } - - func testAltering() throws { - // SINGLE - try db.alter(table: "alterable") - .column("hello", type: .text) - .run().wait() - XCTAssertEqual(db.results[0], "ALTER TABLE `alterable` ADD `hello` TEXT") - - try db.alter(table: "alterable") - .dropColumn("hello") - .run().wait() - XCTAssertEqual(db.results[1], "ALTER TABLE `alterable` DROP `hello`") - - try db.alter(table: "alterable") - .modifyColumn("hello", type: .text) - .run().wait() - XCTAssertEqual(db.results[2], "ALTER TABLE `alterable` MODIFY `hello` TEXT") - - // BATCH - try db.alter(table: "alterable") - .column("hello", type: .text) - .column("there", type: .text) - .run().wait() - XCTAssertEqual(db.results[3], "ALTER TABLE `alterable` ADD `hello` TEXT , ADD `there` TEXT") - - try db.alter(table: "alterable") - .dropColumn("hello") - .dropColumn("there") - .run().wait() - XCTAssertEqual(db.results[4], "ALTER TABLE `alterable` DROP `hello` , DROP `there`") - - try db.alter(table: "alterable") - .update(column: "hello", type: .text) - .update(column: "there", type: .text) - .run().wait() - XCTAssertEqual(db.results[5], "ALTER TABLE `alterable` MODIFY `hello` TEXT , MODIFY `there` TEXT") - - // MIXED - try db.alter(table: "alterable") - .column("hello", type: .text) - .dropColumn("there") - .update(column: "again", type: .text) - .run().wait() - XCTAssertEqual(db.results[6], "ALTER TABLE `alterable` ADD `hello` TEXT , DROP `there` , MODIFY `again` TEXT") - - // Table renaming - try db.alter(table: "alterable") - .rename(to: "new_alterable") - .run().wait() - XCTAssertEqual(db.results[7], "ALTER TABLE `alterable` RENAME TO `new_alterable`") - } - - // MARK: Distinct - - func testDistinct() throws { - try db.select().column("*") - .from("planets") - .groupBy("color") - .having("color", .equal, "blue") - .distinct() - .run().wait() - XCTAssertEqual(db.results[0], "SELECT DISTINCT * FROM `planets` GROUP BY `color` HAVING `color` = ?") - } - - func testDistinctColumns() throws { - try db.select() - .distinct(on: "name", "color") - .from("planets") - .run().wait() - XCTAssertEqual(db.results[0], "SELECT DISTINCT `name`, `color` FROM `planets`") - } - - func testDistinctExpression() throws { - try db.select() - .column(SQLFunction("COUNT", args: SQLDistinct("name", "color"))) - .from("planets") - .run().wait() - XCTAssertEqual(db.results[0], "SELECT COUNT(DISTINCT(`name`, `color`)) FROM `planets`") - } - - // MARK: Joins - - func testSimpleJoin() throws { - try db.select().column("*") - .from("planets") - .join("moons", on: "\(ident: "moons").\(ident: "planet_id")=\(ident: "planets").\(ident: "id")" as SQLQueryString) - .run().wait() - - XCTAssertEqual(db.results[0], "SELECT * FROM `planets` INNER JOIN `moons` ON `moons`.`planet_id`=`planets`.`id`") - } - - func testMessyJoin() throws { - try db.select().column("*") - .from("planets") - .join( - SQLAlias(SQLGroupExpression( - db.select().column("name").from("stars").where(SQLColumn("orion"), .equal, SQLIdentifier("please space")).select - ), as: SQLIdentifier("star")), - method: SQLJoinMethod.outer, - on: SQLColumn(SQLIdentifier("planet_id"), table: SQLIdentifier("moons")), SQLBinaryOperator.isNot, SQLRaw("%%%%%%") - ) - .where(SQLLiteral.null) - .run().wait() - - // Yes, this query is very much pure gibberish. - XCTAssertEqual(db.results[0], "SELECT * FROM `planets` OUTER JOIN (SELECT `name` FROM `stars` WHERE `orion` = `please space`) AS `star` ON `moons`.`planet_id` IS NOT %%%%%% WHERE NULL") - } - - // MARK: Operators - - func testBinaryOperators() throws { - try db - .update("planets") - .set(SQLIdentifier("moons"), - to: SQLBinaryExpression( - left: SQLIdentifier("moons"), - op: SQLBinaryOperator.add, - right: SQLLiteral.numeric("1") - ) - ) - .where("best_at_space", .greaterThanOrEqual, "yes") - .run().wait() - - XCTAssertEqual(db.results[0], "UPDATE `planets` SET `moons` = `moons` + 1 WHERE `best_at_space` >= ?") - } - - func testInsertWithArrayOfEncodable() throws { - func weird(_ builder: SQLInsertBuilder, values: S) -> SQLInsertBuilder where S.Element: Encodable { - builder.values(Array(values)) - } - - try weird(db.insert(into: "planets") - .columns("name"), - values: ["Jupiter"] - ).run().wait() - XCTAssertEqual(db.results[0], "INSERT INTO `planets` (`name`) VALUES (?)") - XCTAssertEqual(db.bindResults[0] as? [String], ["Jupiter"]) // instead of [["Jupiter"]] - } - - // MARK: Returning - - func testReturning() throws { - try db.insert(into: "planets") - .columns("name") - .values("Jupiter") - .returning("id", "name") - .run().wait() - XCTAssertEqual(db.results[0], "INSERT INTO `planets` (`name`) VALUES (?) RETURNING `id`, `name`") - - _ = try db.update("planets") - .set("name", to: "Jupiter") - .returning(SQLColumn("name", table: "planets")) - .first().wait() - XCTAssertEqual(db.results[1], "UPDATE `planets` SET `name` = ? RETURNING `planets`.`name`") - - _ = try db.delete(from: "planets") - .returning("*") - .all().wait() - XCTAssertEqual(db.results[2], "DELETE FROM `planets` RETURNING *") - } - - // MARK: Upsert - - func testUpsert() throws { - // Test the thoroughly underpowered and inconvenient MySQL syntax first - db._dialect.upsertSyntax = .mysqlLike - - let cols = ["id", "serial_number", "star_id", "last_known_status"] - let vals = { (s: String) -> [SQLExpression] in [SQLLiteral.default, SQLBind(UUID()), SQLBind(1), SQLBind(s)] } - - try db.insert(into: "jumpgates").columns(cols).values(vals("calibration")) - .run().wait() - try db.insert(into: "jumpgates").columns(cols).values(vals("unicorn dust application")) - .ignoringConflicts() - .run().wait() - try db.insert(into: "jumpgates").columns(cols).values(vals("planet-size snake oil jar purchasing")) - .onConflict() { $0 - .set("last_known_status", to: "Hooloovoo engineer refraction") - .set(excludedValueOf: "serial_number") - } - .run().wait() - - XCTAssertEqual(db.results[0], "INSERT INTO `jumpgates` (`id`, `serial_number`, `star_id`, `last_known_status`) VALUES (DEFAULT, ?, ?, ?)") - XCTAssertEqual(db.results[1], "INSERT IGNORE INTO `jumpgates` (`id`, `serial_number`, `star_id`, `last_known_status`) VALUES (DEFAULT, ?, ?, ?)") - XCTAssertEqual(db.results[2], "INSERT INTO `jumpgates` (`id`, `serial_number`, `star_id`, `last_known_status`) VALUES (DEFAULT, ?, ?, ?) ON DUPLICATE KEY UPDATE `last_known_status` = ?, `serial_number` = VALUES(`serial_number`)") - - // Now the standard SQL syntax - db._dialect.upsertSyntax = .standard - - try db.insert(into: "jumpgates").columns(cols).values(vals("calibration")) - .run().wait() - try db.insert(into: "jumpgates").columns(cols).values(vals("unicorn dust application")) - .ignoringConflicts() - .run().wait() - try db.insert(into: "jumpgates").columns(cols).values(vals("Vorlon pinching")) - .ignoringConflicts(with: ["serial_number", "star_id"]) - .run().wait() - try db.insert(into: "jumpgates").columns(cols).values(vals("planet-size snake oil jar purchasing")) - .onConflict() { $0 - .set("last_known_status", to: "Hooloovoo engineer refraction").set(excludedValueOf: "serial_number") - } - .run().wait() - try db.insert(into: "jumpgates").columns(cols).values(vals("slashfic writing")) - .onConflict(with: ["serial_number"]) { $0 - .set("last_known_status", to: "tachyon antitelephone dialing the").set(excludedValueOf: "star_id") - } - .run().wait() - try db.insert(into: "jumpgates").columns(cols).values(vals("protection racket payoff")) - .onConflict(with: ["id"]) { $0 - .set("last_known_status", to: "insurance fraud planning") - .where("last_known_status", .notEqual, "evidence disposal") - } - .run().wait() - - XCTAssertEqual(db.results[3], "INSERT INTO `jumpgates` (`id`, `serial_number`, `star_id`, `last_known_status`) VALUES (DEFAULT, ?, ?, ?)") - XCTAssertEqual(db.results[4], "INSERT INTO `jumpgates` (`id`, `serial_number`, `star_id`, `last_known_status`) VALUES (DEFAULT, ?, ?, ?) ON CONFLICT DO NOTHING") - XCTAssertEqual(db.results[5], "INSERT INTO `jumpgates` (`id`, `serial_number`, `star_id`, `last_known_status`) VALUES (DEFAULT, ?, ?, ?) ON CONFLICT (`serial_number`, `star_id`) DO NOTHING") - XCTAssertEqual(db.results[6], "INSERT INTO `jumpgates` (`id`, `serial_number`, `star_id`, `last_known_status`) VALUES (DEFAULT, ?, ?, ?) ON CONFLICT DO UPDATE SET `last_known_status` = ?, `serial_number` = EXCLUDED.`serial_number`") - XCTAssertEqual(db.results[7], "INSERT INTO `jumpgates` (`id`, `serial_number`, `star_id`, `last_known_status`) VALUES (DEFAULT, ?, ?, ?) ON CONFLICT (`serial_number`) DO UPDATE SET `last_known_status` = ?, `star_id` = EXCLUDED.`star_id`") - XCTAssertEqual(db.results[8], "INSERT INTO `jumpgates` (`id`, `serial_number`, `star_id`, `last_known_status`) VALUES (DEFAULT, ?, ?, ?) ON CONFLICT (`id`) DO UPDATE SET `last_known_status` = ? WHERE `last_known_status` <> ?") - } - - // MARK: Codable Nullity - - func testCodableWithNillableColumnWithSomeValue() throws { - struct Gas: Codable { - let name: String - let color: String? - } - let db = TestDatabase() - var serializer = SQLSerializer(database: db) - - let insertBuilder = try db.insert(into: "gasses").model(Gas(name: "iodine", color: "purple")) - insertBuilder.insert.serialize(to: &serializer) - - XCTAssertEqual(serializer.sql, "INSERT INTO `gasses` (`name`, `color`) VALUES (?, ?)") - XCTAssertEqual(serializer.binds.count, 2) - XCTAssertEqual(serializer.binds[0] as? String, "iodine") - XCTAssertEqual(serializer.binds[1] as? String, "purple") - } - - func testCodableWithNillableColumnWithNilValueWithoutNilEncodingStrategy() throws { - struct Gas: Codable { - let name: String - let color: String? - } - let db = TestDatabase() - var serializer = SQLSerializer(database: db) - - let insertBuilder = try db.insert(into: "gasses").model(Gas(name: "oxygen", color: nil)) - insertBuilder.insert.serialize(to: &serializer) - - XCTAssertEqual(serializer.sql, "INSERT INTO `gasses` (`name`) VALUES (?)") - XCTAssertEqual(serializer.binds.count, 1) - XCTAssertEqual(serializer.binds[0] as? String, "oxygen") - } - - func testCodableWithNillableColumnWithNilValueAndNilEncodingStrategy() throws { - struct Gas: Codable { - let name: String - let color: String? - } - let db = TestDatabase() - var serializer = SQLSerializer(database: db) - - let insertBuilder = try db.insert(into: "gasses").model(Gas(name: "oxygen", color: nil), nilEncodingStrategy: .asNil) - insertBuilder.insert.serialize(to: &serializer) - - XCTAssertEqual(serializer.sql, "INSERT INTO `gasses` (`name`, `color`) VALUES (?, NULL)") - XCTAssertEqual(serializer.binds.count, 1) - XCTAssertEqual(serializer.binds[0] as? String, "oxygen") - } - - func testRawCustomStringConvertible() throws { - let field = "name" - let db = TestDatabase() - _ = try db.raw("SELECT \(raw: field) FROM users").all().wait() - XCTAssertEqual(db.results[0], "SELECT name FROM users") - } - - // MARK: Table Creation - - func testColumnConstraints() throws { - try db.create(table: "planets") - .column("id", type: .bigint, .primaryKey) - .column("name", type: .text, .default("unnamed")) - .column("galaxy_id", type: .bigint, .references("galaxies", "id")) - .column("diameter", type: .int, .check(SQLRaw("diameter > 0"))) - .column("important", type: .text, .notNull) - .column("special", type: .text, .unique) - .column("automatic", type: .text, .generated(SQLRaw("CONCAT(name, special)"))) - .column("collated", type: .text, .collate(name: "default")) - .run().wait() - - XCTAssertEqual(db.results[0], -""" -CREATE TABLE `planets`(`id` BIGINT PRIMARY KEY AUTOINCREMENT, `name` TEXT DEFAULT 'unnamed', `galaxy_id` BIGINT REFERENCES `galaxies` (`id`), `diameter` INTEGER CHECK (diameter > 0), `important` TEXT NOT NULL, `special` TEXT UNIQUE, `automatic` TEXT GENERATED ALWAYS AS (CONCAT(name, special)) STORED, `collated` TEXT COLLATE `default`) -""" - ) - } - - func testConstraintLengthNormalization() { - // Default impl is to leave as-is - XCTAssertEqual( - (db.dialect.normalizeSQLConstraint(identifier: SQLIdentifier("fk:obnoxiously_long_table_name.other_table_name_id+other_table_name.id")) as! SQLIdentifier).string, - SQLIdentifier("fk:obnoxiously_long_table_name.other_table_name_id+other_table_name.id").string - ) - } - - func testMultipleColumnConstraintsPerRow() throws { - try db.create(table: "planets") - .column("id", type: .bigint, .notNull, .primaryKey) - .run().wait() - - XCTAssertEqual(db.results[0], "CREATE TABLE `planets`(`id` BIGINT NOT NULL PRIMARY KEY AUTOINCREMENT)") - } - - func testPrimaryKeyColumnConstraintVariants() throws { - try db.create(table: "planets1") - .column("id", type: .bigint, .primaryKey) - .run().wait() - - try db.create(table: "planets2") - .column("id", type: .bigint, .primaryKey(autoIncrement: false)) - .run().wait() - - XCTAssertEqual(db.results[0], "CREATE TABLE `planets1`(`id` BIGINT PRIMARY KEY AUTOINCREMENT)") - - XCTAssertEqual(db.results[1], "CREATE TABLE `planets2`(`id` BIGINT PRIMARY KEY)") - } - - func testPrimaryKeyAutoIncrementVariants() throws { - db._dialect.supportsAutoIncrement = false - - try db.create(table: "planets1") - .column("id", type: .bigint, .primaryKey) - .run().wait() - - try db.create(table: "planets2") - .column("id", type: .bigint, .primaryKey(autoIncrement: false)) - .run().wait() - - db._dialect.supportsAutoIncrement = true - - try db.create(table: "planets3") - .column("id", type: .bigint, .primaryKey) - .run().wait() - - try db.create(table: "planets4") - .column("id", type: .bigint, .primaryKey(autoIncrement: false)) - .run().wait() - - db._dialect.supportsAutoIncrement = true - db._dialect.autoIncrementFunction = SQLRaw("NEXTUNIQUE") - - try db.create(table: "planets5") - .column("id", type: .bigint, .primaryKey) - .run().wait() - - try db.create(table: "planets6") - .column("id", type: .bigint, .primaryKey(autoIncrement: false)) - .run().wait() - - XCTAssertEqual(db.results[0], "CREATE TABLE `planets1`(`id` BIGINT PRIMARY KEY)") - - XCTAssertEqual(db.results[1], "CREATE TABLE `planets2`(`id` BIGINT PRIMARY KEY)") - - XCTAssertEqual(db.results[2], "CREATE TABLE `planets3`(`id` BIGINT PRIMARY KEY AUTOINCREMENT)") - - XCTAssertEqual(db.results[3], "CREATE TABLE `planets4`(`id` BIGINT PRIMARY KEY)") - - XCTAssertEqual(db.results[4], "CREATE TABLE `planets5`(`id` BIGINT DEFAULT NEXTUNIQUE PRIMARY KEY)") - - XCTAssertEqual(db.results[5], "CREATE TABLE `planets6`(`id` BIGINT PRIMARY KEY)") - } - - func testDefaultColumnConstraintVariants() throws { - try db.create(table: "planets1") - .column("name", type: .text, .default("unnamed")) - .run().wait() - - try db.create(table: "planets2") - .column("diameter", type: .int, .default(10)) - .run().wait() - - try db.create(table: "planets3") - .column("diameter", type: .real, .default(11.5)) - .run().wait() - - try db.create(table: "planets4") - .column("current", type: .custom(SQLRaw("BOOLEAN")), .default(false)) - .run().wait() - - try db.create(table: "planets5") - .column("current", type: .custom(SQLRaw("BOOLEAN")), .default(SQLLiteral.boolean(true))) - .run().wait() - - XCTAssertEqual(db.results[0], "CREATE TABLE `planets1`(`name` TEXT DEFAULT 'unnamed')") - - XCTAssertEqual(db.results[1], "CREATE TABLE `planets2`(`diameter` INTEGER DEFAULT 10)") - - XCTAssertEqual(db.results[2], "CREATE TABLE `planets3`(`diameter` REAL DEFAULT 11.5)") - - XCTAssertEqual(db.results[3], "CREATE TABLE `planets4`(`current` BOOLEAN DEFAULT false)") - - XCTAssertEqual(db.results[4], "CREATE TABLE `planets5`(`current` BOOLEAN DEFAULT true)") - } - - func testForeignKeyColumnConstraintVariants() throws { - try db.create(table: "planets1") - .column("galaxy_id", type: .bigint, .references("galaxies", "id")) - .run().wait() - - try db.create(table: "planets2") - .column("galaxy_id", type: .bigint, .references("galaxies", "id", onDelete: .cascade, onUpdate: .restrict)) - .run().wait() - - XCTAssertEqual(db.results[0], "CREATE TABLE `planets1`(`galaxy_id` BIGINT REFERENCES `galaxies` (`id`))") - - XCTAssertEqual(db.results[1], "CREATE TABLE `planets2`(`galaxy_id` BIGINT REFERENCES `galaxies` (`id`) ON DELETE CASCADE ON UPDATE RESTRICT)") - } - - func testTableConstraints() throws { - try db.create(table: "planets") - .column("id", type: .bigint) - .column("name", type: .text) - .column("diameter", type: .int) - .column("galaxy_name", type: .text) - .column("galaxy_id", type: .bigint) - .primaryKey("id") - .unique("name") - .check(SQLRaw("diameter > 0"), named: "non-zero-diameter") - .foreignKey( - ["galaxy_id", "galaxy_name"], - references: "galaxies", - ["id", "name"] - ).run().wait() - - XCTAssertEqual(db.results[0], -""" -CREATE TABLE `planets`(`id` BIGINT, `name` TEXT, `diameter` INTEGER, `galaxy_name` TEXT, `galaxy_id` BIGINT, PRIMARY KEY (`id`), UNIQUE (`name`), CONSTRAINT `non-zero-diameter` CHECK (diameter > 0), FOREIGN KEY (`galaxy_id`, `galaxy_name`) REFERENCES `galaxies` (`id`, `name`)) -""" - ) - } - - func testCompositePrimaryKeyTableConstraint() throws { - try db.create(table: "planets1") - .column("id1", type: .bigint) - .column("id2", type: .bigint) - .primaryKey("id1", "id2") - .run().wait() - - XCTAssertEqual(db.results[0], "CREATE TABLE `planets1`(`id1` BIGINT, `id2` BIGINT, PRIMARY KEY (`id1`, `id2`))") - } - - func testCompositeUniqueTableConstraint() throws { - try db.create(table: "planets1") - .column("id1", type: .bigint) - .column("id2", type: .bigint) - .unique("id1", "id2") - .run().wait() - - XCTAssertEqual(db.results[0], "CREATE TABLE `planets1`(`id1` BIGINT, `id2` BIGINT, UNIQUE (`id1`, `id2`))") - } - - func testPrimaryKeyTableConstraintVariants() throws { - try db.create(table: "planets1") - .column("galaxy_name", type: .text) - .column("galaxy_id", type: .bigint) - .foreignKey( - ["galaxy_id", "galaxy_name"], - references: "galaxies", - ["id", "name"] - ).run().wait() - - try db.create(table: "planets2") - .column("galaxy_id", type: .bigint) - .foreignKey( - ["galaxy_id"], - references: "galaxies", - ["id"] - ).run().wait() - - try db.create(table: "planets3") - .column("galaxy_id", type: .bigint) - .foreignKey( - ["galaxy_id"], - references: "galaxies", - ["id"], - onDelete: .restrict, - onUpdate: .cascade - ).run().wait() - - XCTAssertEqual(db.results[0], "CREATE TABLE `planets1`(`galaxy_name` TEXT, `galaxy_id` BIGINT, FOREIGN KEY (`galaxy_id`, `galaxy_name`) REFERENCES `galaxies` (`id`, `name`))") - - XCTAssertEqual(db.results[1], "CREATE TABLE `planets2`(`galaxy_id` BIGINT, FOREIGN KEY (`galaxy_id`) REFERENCES `galaxies` (`id`))") - - XCTAssertEqual(db.results[2], "CREATE TABLE `planets3`(`galaxy_id` BIGINT, FOREIGN KEY (`galaxy_id`) REFERENCES `galaxies` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE)") - } - - func testCreateTableAsSelectQuery() throws { - try db.create(table: "normalized_planet_names") - .column("id", type: .bigint, .primaryKey(autoIncrement: false), .notNull) - .column("name", type: .text, .unique, .notNull) - .select { $0 - .distinct() - .column("id", as: "id") - .column(SQLFunction("LOWER", args: SQLColumn("name")), as: "name") - .from("planets") - .where("galaxy_id", .equal, SQLBind(1)) - } - .run().wait() - - XCTAssertEqual(db.results[0], "CREATE TABLE `normalized_planet_names`(`id` BIGINT PRIMARY KEY NOT NULL, `name` TEXT UNIQUE NOT NULL) AS SELECT DISTINCT `id` AS `id`, LOWER(`name`) AS `name` FROM `planets` WHERE `galaxy_id` = ?") - } - - // MARK: Row Decoder - - func testSQLRowDecoder() throws { - struct Foo: Codable { - let id: UUID - let foo: Int - let bar: Double? - let baz: String - let waldoFred: Int? - } - - struct FooWithForeignKey: Codable { - let id: UUID - let foo: Int - let bar: Double? - let baz: String - let waldoFredID: Int - } - - do { - let row = TestRow(data: [ - "id": .some(UUID()), - "foo": .some(42), - "bar": .none, - "baz": .some("vapor"), - "waldoFred": .some(2015) - ]) - - let foo = try row.decode(model: Foo.self) - XCTAssertEqual(foo.foo, 42) - XCTAssertEqual(foo.bar, nil) - XCTAssertEqual(foo.baz, "vapor") - XCTAssertEqual(foo.waldoFred, 2015) - } catch { - XCTFail("Could not decode row \(error)") - } - do { - let row = TestRow(data: [ - "foos_id": .some(UUID()), - "foos_foo": .some(42), - "foos_bar": .none, - "foos_baz": .some("vapor"), - "foos_waldoFred": .some(2015) - ]) - - let foo = try row.decode(model: Foo.self, prefix: "foos_") - XCTAssertEqual(foo.foo, 42) - XCTAssertEqual(foo.bar, nil) - XCTAssertEqual(foo.baz, "vapor") - XCTAssertEqual(foo.waldoFred, 2015) - } catch { - XCTFail("Could not decode row with prefix \(error)") - } - do { - let row = TestRow(data: [ - "id": .some(UUID()), - "foo": .some(42), - "bar": .none, - "baz": .some("vapor"), - "waldo_fred": .some(2015) - ]) - - let foo = try row.decode(model: Foo.self, keyDecodingStrategy: .convertFromSnakeCase) - XCTAssertEqual(foo.foo, 42) - XCTAssertEqual(foo.bar, nil) - XCTAssertEqual(foo.baz, "vapor") - XCTAssertEqual(foo.waldoFred, 2015) - } catch { - XCTFail("Could not decode row with keyDecodingStrategy \(error)") - } - do { - let row = TestRow(data: [ - "id": .some(UUID()), - "foo": .some(42), - "bar": .none, - "baz": .some("vapor"), - "waldoFredID": .some(2015) - ]) - - /// An implementation of CodingKey that's useful for combining and transforming keys as strings. - struct AnyKey: CodingKey { - var stringValue: String - var intValue: Int? - - init?(stringValue: String) { - self.stringValue = stringValue - self.intValue = nil - } - - init?(intValue: Int) { - self.stringValue = String(intValue) - self.intValue = intValue - } - } - - func decodeIdToID(_ keys: [CodingKey]) -> CodingKey { - let keyString = keys.last!.stringValue - - if let range = keyString.range(of: "Id", options: [.anchored, .backwards]) { - return AnyKey(stringValue: keyString[..>'a'), (`json`->'a'->>'b'), (`json`->'a'->'b'->>'c'), (`table`.`json`->'a'->>'b')") - } -} diff --git a/Tests/SQLKitTests/SQLKitTriggerTests.swift b/Tests/SQLKitTests/SQLKitTriggerTests.swift deleted file mode 100644 index 04c5d3b1..00000000 --- a/Tests/SQLKitTests/SQLKitTriggerTests.swift +++ /dev/null @@ -1,93 +0,0 @@ -import SQLKit -import SQLKitBenchmark -import XCTest - -final class SQLKitTriggerTests: XCTestCase { - private let body = [ - "IF NEW.amount < 0 THEN", - "SET NEW.amount = 0;", - "END IF;", - ] - - private var db: TestDatabase! - - override func setUp() { - super.setUp() - self.db = TestDatabase() - } - - private func bodyText() -> String { - body.joined(separator: " ") - } - - func testDropTriggerOptions() throws { - var dialect = GenericDialect() - dialect.setTriggerSyntax(drop: [.supportsCascade, .supportsTableName]) - debugPrint(dialect.triggerSyntax.drop) - db._dialect = dialect - - try db.drop(trigger: "foo").table("planets").run().wait() - XCTAssertEqual(db.results.popLast(), "DROP TRIGGER `foo` ON `planets`") - - try db.drop(trigger: "foo").table("planets").ifExists().run().wait() - XCTAssertEqual(db.results.popLast(), "DROP TRIGGER IF EXISTS `foo` ON `planets`") - - try db.drop(trigger: "foo").table("planets").ifExists().cascade().run().wait() - XCTAssertEqual(db.results.popLast(), "DROP TRIGGER IF EXISTS `foo` ON `planets` CASCADE") - - db._dialect.supportsIfExists = false - try db.drop(trigger: "foo").table("planets").ifExists().run().wait() - XCTAssertEqual(db.results.popLast(), "DROP TRIGGER `foo` ON `planets`") - - db._dialect.triggerSyntax.drop = .supportsCascade - try db.drop(trigger: "foo").table("planets").run().wait() - XCTAssertEqual(db.results.popLast(), "DROP TRIGGER `foo`") - - db._dialect.triggerSyntax.drop = [] - try db.drop(trigger: "foo").table("planets").ifExists().cascade().run().wait() - XCTAssertEqual(db.results.popLast(), "DROP TRIGGER `foo`") - } - - func testMySqlTriggerCreates() throws { - var dialect = GenericDialect() - dialect.setTriggerSyntax(create: [.supportsBody, .requiresForEachRow, .supportsOrder]) - - db._dialect = dialect - - try db.create(trigger: "foo", table: "planet", when: .before, event: .insert) - .body(self.body.map { SQLRaw($0) }) - .order(precedence: .precedes, otherTriggerName: "other") - .run().wait() - XCTAssertEqual(db.results.popLast(), "CREATE TRIGGER `foo` BEFORE INSERT ON `planet` FOR EACH ROW PRECEDES `other` BEGIN \(bodyText()) END;") - } - - func testSqliteTriggerCreates() throws { - var dialect = GenericDialect() - dialect.setTriggerSyntax(create: [.supportsBody, .supportsCondition]) - db._dialect = dialect - - try db.create(trigger: "foo", table: "planet", when: .before, event: .insert) - .body(self.body.map { SQLRaw($0) }) - .condition("\(ident: "foo") = \(ident: "bar")" as SQLQueryString) - .run().wait() - XCTAssertEqual(db.results.popLast(), "CREATE TRIGGER `foo` BEFORE INSERT ON `planet` WHEN `foo` = `bar` BEGIN \(bodyText()) END;") - } - - func testPostgreSqlTriggerCreates() throws { - var dialect = GenericDialect() - dialect.setTriggerSyntax(create: [.supportsForEach, .postgreSQLChecks, .supportsCondition, .conditionRequiresParentheses, .supportsConstraints]) - - db._dialect = dialect - - try db.create(trigger: "foo", table: "planet", when: .after, event: .insert) - .each(.row) - .isConstraint() - .timing(.initiallyDeferred) - .condition("\(ident: "foo") = \(ident: "bar")" as SQLQueryString) - .procedure("qwer") - .referencedTable(SQLIdentifier("galaxies")) - .run().wait() - - XCTAssertEqual(db.results.popLast(), "CREATE CONSTRAINT TRIGGER `foo` AFTER INSERT ON `planet` FROM `galaxies` INITIALLY DEFERRED FOR EACH ROW WHEN (`foo` = `bar`) EXECUTE PROCEDURE `qwer`") - } -} diff --git a/Tests/SQLKitTests/SQLQueryEncoderTests.swift b/Tests/SQLKitTests/SQLQueryEncoderTests.swift new file mode 100644 index 00000000..16d4daf0 --- /dev/null +++ b/Tests/SQLKitTests/SQLQueryEncoderTests.swift @@ -0,0 +1,297 @@ +@testable @_spi(CodableUtilities) import SQLKit +import XCTest + +final class SQLQueryEncoderTests: XCTestCase { + func testQueryEncoderBasicConfigurations() { + let model1 = BasicEncModel( + boolValue: true, optBoolValue: nil, stringValue: "hello", optStringValue: "olleh", + doubleValue: 1.0, optDoubleValue: nil, floatValue: 1.0, optFloatValue: 0.1, + int8Value: 1, optInt8Value: nil, int16Value: 2, optInt16Value: 3, + int32Value: 4, optInt32Value: nil, int64Value: 5, optInt64Value: 6, + uint8Value: 7, optUint8Value: nil, uint16Value: 8, optUint16Value: 9, + uint32Value: 10, optUint32Value: nil, uint64Value: 11, optUint64Value: 12, + intValue: 13, optIntValue: nil, uintValue: 14, optUintValue: 15 + ) + let model2 = BasicEncModel( + boolValue: true, optBoolValue: false, stringValue: "hello", optStringValue: nil, + doubleValue: 1.0, optDoubleValue: 0.1, floatValue: 1.0, optFloatValue: nil, + int8Value: 1, optInt8Value: 2, int16Value: 3, optInt16Value: nil, + int32Value: 4, optInt32Value: 5, int64Value: 6, optInt64Value: nil, + uint8Value: 7, optUint8Value: 8, uint16Value: 9, optUint16Value: nil, + uint32Value: 10, optUint32Value: 11, uint64Value: 12, optUint64Value: nil, + intValue: 13, optIntValue: 14, uintValue: 15, optUintValue: nil + ) + + // Model 1 with key strategies + XCTAssertEncoding( + model1, using: SQLQueryEncoder(keyEncodingStrategy: .useDefaultKeys), + outputs: model1.plainColumns(nulls: false), model1.valueExpressions(nulls: false) + ) + XCTAssertEncoding( + model1, using: SQLQueryEncoder(keyEncodingStrategy: .convertToSnakeCase), + outputs: model1.snakeColumns(nulls: false), model1.valueExpressions(nulls: false) + ) + XCTAssertEncoding( + model1, using: SQLQueryEncoder(keyEncodingStrategy: .custom({ superCase($0) })), + outputs: model1.supercaseColumns(nulls: false), model1.valueExpressions(nulls: false) + ) + + // Model 1 with key and nil strategies + XCTAssertEncoding( + model1, using: SQLQueryEncoder(keyEncodingStrategy: .useDefaultKeys, nilEncodingStrategy: .asNil), + outputs: model1.plainColumns(nulls: true), model1.valueExpressions(nulls: true) + ) + XCTAssertEncoding( + model1, using: SQLQueryEncoder(keyEncodingStrategy: .convertToSnakeCase, nilEncodingStrategy: .asNil), + outputs: model1.snakeColumns(nulls: true), model1.valueExpressions(nulls: true) + ) + XCTAssertEncoding( + model1, using: SQLQueryEncoder(keyEncodingStrategy: .custom({ superCase($0) }), nilEncodingStrategy: .asNil), + outputs: model1.supercaseColumns(nulls: true), model1.valueExpressions(nulls: true) + ) + + // Model 1 with prefix and key strategies + XCTAssertEncoding( + model1, using: SQLQueryEncoder(prefix: "p_", keyEncodingStrategy: .useDefaultKeys), + outputs: model1.plainColumns(nulls: false).map { "p_\($0)" }, model1.valueExpressions(nulls: false) + ) + XCTAssertEncoding( + model1, using: SQLQueryEncoder(prefix: "p_", keyEncodingStrategy: .convertToSnakeCase), + outputs: model1.snakeColumns(nulls: false).map { "p_\($0)" }, model1.valueExpressions(nulls: false) + ) + XCTAssertEncoding( + model1, using: SQLQueryEncoder(prefix: "p_", keyEncodingStrategy: .custom({ superCase($0) })), + outputs: model1.supercaseColumns(nulls: false).map { "p_\($0)" }, model1.valueExpressions(nulls: false) + ) + + + // Model 2 with key strategies + XCTAssertEncoding( + model2, using: SQLQueryEncoder(keyEncodingStrategy: .useDefaultKeys), + outputs: model2.plainColumns(nulls: false), model2.valueExpressions(nulls: false) + ) + XCTAssertEncoding( + model2, using: SQLQueryEncoder(keyEncodingStrategy: .convertToSnakeCase), + outputs: model2.snakeColumns(nulls: false), model2.valueExpressions(nulls: false) + ) + XCTAssertEncoding( + model2, using: SQLQueryEncoder(keyEncodingStrategy: .custom({ superCase($0) })), + outputs: model2.supercaseColumns(nulls: false), model2.valueExpressions(nulls: false) + ) + + // Model 2 with key and nil strategies + XCTAssertEncoding( + model2, using: SQLQueryEncoder(keyEncodingStrategy: .useDefaultKeys, nilEncodingStrategy: .asNil), + outputs: model2.plainColumns(nulls: true), model2.valueExpressions(nulls: true) + ) + XCTAssertEncoding( + model2, using: SQLQueryEncoder(keyEncodingStrategy: .convertToSnakeCase, nilEncodingStrategy: .asNil), + outputs: model2.snakeColumns(nulls: true), model2.valueExpressions(nulls: true) + ) + XCTAssertEncoding( + model2, using: SQLQueryEncoder(keyEncodingStrategy: .custom({ superCase($0) }), nilEncodingStrategy: .asNil), + outputs: model2.supercaseColumns(nulls: true), model2.valueExpressions(nulls: true) + ) + + // Model 2 with prefix and key strategies + XCTAssertEncoding( + model2, using: SQLQueryEncoder(prefix: "p_", keyEncodingStrategy: .useDefaultKeys), + outputs: model2.plainColumns(nulls: false).map { "p_\($0)" }, model2.valueExpressions(nulls: false) + ) + XCTAssertEncoding( + model2, using: SQLQueryEncoder(prefix: "p_", keyEncodingStrategy: .convertToSnakeCase), + outputs: model2.snakeColumns(nulls: false).map { "p_\($0)" }, model2.valueExpressions(nulls: false) + ) + XCTAssertEncoding( + model2, using: SQLQueryEncoder(prefix: "p_", keyEncodingStrategy: .custom({ superCase($0) })), + outputs: model2.supercaseColumns(nulls: false).map { "p_\($0)" }, model2.valueExpressions(nulls: false) + ) + } + + func testEncodeTopLevelOptional() { + let model1: BasicEncModel? = .some(BasicEncModel( + boolValue: true, optBoolValue: nil, stringValue: "hello", optStringValue: "olleh", + doubleValue: 1.0, optDoubleValue: nil, floatValue: 1.0, optFloatValue: 0.1, + int8Value: 1, optInt8Value: nil, int16Value: 2, optInt16Value: 3, + int32Value: 4, optInt32Value: nil, int64Value: 5, optInt64Value: 6, + uint8Value: 7, optUint8Value: nil, uint16Value: 8, optUint16Value: 9, + uint32Value: 10, optUint32Value: nil, uint64Value: 11, optUint64Value: 12, + intValue: 13, optIntValue: nil, uintValue: 14, optUintValue: 15 + )) + let model2: BasicEncModel? = nil + + XCTAssertEncoding(model1, using: SQLQueryEncoder(), outputs: model1?.plainColumns(nulls: false) ?? [], model1?.valueExpressions(nulls: false) ?? []) + XCTAssertThrowsError(try SQLQueryEncoder().encode(model2)) { + XCTAssert($0 is SQLCodingError, "Expected SQLCodingError, got \(String(reflecting: $0))") + } + } + + func testEncodeUnkeyedValues() { + XCTAssertThrowsError(try SQLQueryEncoder().encode([true])) { XCTAssert($0 is SQLCodingError, "Expected SQLCodingError, got \(String(reflecting: $0))") } + } + + func testEncodeTopLevelValues() { + XCTAssertThrowsError(try SQLQueryEncoder().encode(true)) { XCTAssert($0 is SQLCodingError, "Expected SQLCodingError, got \(String(reflecting: $0))") } + XCTAssertThrowsError(try SQLQueryEncoder().encode("hello")) { XCTAssert($0 is SQLCodingError, "Expected SQLCodingError, got \(String(reflecting: $0))") } + XCTAssertThrowsError(try SQLQueryEncoder().encode(1.0)) { XCTAssert($0 is SQLCodingError, "Expected SQLCodingError, got \(String(reflecting: $0))") } + XCTAssertThrowsError(try SQLQueryEncoder().encode(1.0 as Float)) { XCTAssert($0 is SQLCodingError, "Expected SQLCodingError, got \(String(reflecting: $0))") } + XCTAssertThrowsError(try SQLQueryEncoder().encode(1 as Int8)) { XCTAssert($0 is SQLCodingError, "Expected SQLCodingError, got \(String(reflecting: $0))") } + XCTAssertThrowsError(try SQLQueryEncoder().encode(1 as Int16)) { XCTAssert($0 is SQLCodingError, "Expected SQLCodingError, got \(String(reflecting: $0))") } + XCTAssertThrowsError(try SQLQueryEncoder().encode(1 as Int32)) { XCTAssert($0 is SQLCodingError, "Expected SQLCodingError, got \(String(reflecting: $0))") } + XCTAssertThrowsError(try SQLQueryEncoder().encode(1 as Int64)) { XCTAssert($0 is SQLCodingError, "Expected SQLCodingError, got \(String(reflecting: $0))") } + XCTAssertThrowsError(try SQLQueryEncoder().encode(1 as UInt8)) { XCTAssert($0 is SQLCodingError, "Expected SQLCodingError, got \(String(reflecting: $0))") } + XCTAssertThrowsError(try SQLQueryEncoder().encode(1 as UInt16)) { XCTAssert($0 is SQLCodingError, "Expected SQLCodingError, got \(String(reflecting: $0))") } + XCTAssertThrowsError(try SQLQueryEncoder().encode(1 as UInt32)) { XCTAssert($0 is SQLCodingError, "Expected SQLCodingError, got \(String(reflecting: $0))") } + XCTAssertThrowsError(try SQLQueryEncoder().encode(1 as UInt64)) { XCTAssert($0 is SQLCodingError, "Expected SQLCodingError, got \(String(reflecting: $0))") } + XCTAssertThrowsError(try SQLQueryEncoder().encode(1 as Int)) { XCTAssert($0 is SQLCodingError, "Expected SQLCodingError, got \(String(reflecting: $0))") } + XCTAssertThrowsError(try SQLQueryEncoder().encode(1 as UInt)) { XCTAssert($0 is SQLCodingError, "Expected SQLCodingError, got \(String(reflecting: $0))") } + } + + func testEncodeNestedKeyedValues() { + XCTAssertNoThrow(try SQLQueryEncoder().encode(TestEncodableIfPresent(foo: .init()))) + XCTAssertNoThrow(try SQLQueryEncoder().encode(TestEncodableIfPresent(foo: nil))) + XCTAssertNoThrow(try SQLQueryEncoder(nilEncodingStrategy: .asNil).encode(TestEncodableIfPresent(foo: nil))) + XCTAssertNoThrow(try SQLQueryEncoder().encode(["a": ["b": "c"]])) + XCTAssertNoThrow(try SQLQueryEncoder().encode(["a": ["b", "c"]])) + XCTAssertThrowsError(try SQLQueryEncoder().encode(TestEncNestedKeyedContainers(foo: (1, 1)))) { XCTAssert($0 is SQLCodingError, "Expected SQLCodingError, got \(String(reflecting: $0))") } + XCTAssertThrowsError(try SQLQueryEncoder().encode(TestEncNestedUnkeyedContainer(foo: true))) { XCTAssert($0 is SQLCodingError, "Expected SQLCodingError, got \(String(reflecting: $0))") } + XCTAssertThrowsError(try SQLQueryEncoder().encode(TestKeylessSuperEncoder(foo: true))) { XCTAssert($0 is SQLCodingError, "Expected SQLCodingError, got \(String(reflecting: $0))") } + XCTAssertNoThrow(try SQLQueryEncoder().encode(TestEncNestedSingleValueContainer(foo: (1, 1)))) + XCTAssertNoThrow(try SQLQueryEncoder().encode(TestEncNestedSingleValueContainer(foo: nil))) + XCTAssertNoThrow(try SQLQueryEncoder(nilEncodingStrategy: .asNil).encode(TestEncNestedSingleValueContainer(foo: nil))) + XCTAssertThrowsError(try SQLQueryEncoder().encode(TestEncEnum.foo(bar: true))) { XCTAssert($0 is SQLCodingError, "Expected SQLCodingError, got \(String(reflecting: $0))") } + } + + func testEncodeOfDirectlyCodableSQLExpression() { + XCTAssertEncoding(TestEncExpr(value: .foo), using: .init(), outputs: ["value"], [TestEncExpr.Enm.foo]) + } +} + +struct TestEncExpr: Codable { + enum Enm: Codable, SQLExpression, Equatable { + case foo + + func serialize(to serializer: inout SQLSerializer) { + serializer.write("FOO") + } + } + + let value: Enm +} + +enum TestEncEnum: Codable { + /// N.B.: Compiler autosynthesizes a call to `KeyedEncodingContainer.nestedContainer(keyedBy:forKey:)` for this. + case foo(bar: Bool) +} + +struct TestEncodableIfPresent: Encodable { + let foo: Date? +} + +struct TestKeylessSuperEncoder: Encodable { + let foo: Bool + + func encode(to encoder: any Encoder) throws { + XCTAssertNil(encoder.userInfo[.init(rawValue: "a")!]) // for completeness of code coverage + var container = encoder.container(keyedBy: SomeCodingKey.self) + let superEncoder = container.superEncoder() + var subcontainer = superEncoder.singleValueContainer() + try subcontainer.encode(self.foo) + } +} + +struct TestEncNestedUnkeyedContainer: Encodable { + let foo: Bool + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: SomeCodingKey.self) + var subcontainer = container.nestedUnkeyedContainer(forKey: .init(stringValue: "foo")) + try subcontainer.encode(self.foo) + } +} + +struct TestEncNestedKeyedContainers: Encodable { + let foo: (Int, Int) + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: SomeCodingKey.self) + let superEncoder = container.superEncoder(forKey: .init(stringValue: "foo")) + var subcontainer = superEncoder.container(keyedBy: SomeCodingKey.self) + + try subcontainer.encode(self.foo.0, forKey: .init(stringValue: "_0")) + try subcontainer.encode(self.foo.1, forKey: .init(stringValue: "_1")) + } +} + +struct TestEncNestedSingleValueContainer: Encodable { + let foo: (Int, Int)? + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: SomeCodingKey.self) + let superEncoder = container.superEncoder(forKey: .init(stringValue: "foo")) + var subcontainer = superEncoder.singleValueContainer() + + if let foo = self.foo { + try subcontainer.encode(["_0": foo.0, "_1": foo.1]) + } else { + try subcontainer.encodeNil() + } + } +} + +struct BasicEncModel: Codable { + var boolValue: Bool, optBoolValue: Bool?, stringValue: String, optStringValue: String? + var doubleValue: Double, optDoubleValue: Double?, floatValue: Float, optFloatValue: Float? + var int8Value: Int8, optInt8Value: Int8?, int16Value: Int16, optInt16Value: Int16? + var int32Value: Int32, optInt32Value: Int32?, int64Value: Int64, optInt64Value: Int64? + var uint8Value: UInt8, optUint8Value: UInt8?, uint16Value: UInt16, optUint16Value: UInt16? + var uint32Value: UInt32, optUint32Value: UInt32?, uint64Value: UInt64, optUint64Value: UInt64? + var intValue: Int, optIntValue: Int?, uintValue: UInt, optUintValue: UInt? + + func plainColumns(nulls: Bool) -> [String] { [ + "boolValue", nulls || self.optBoolValue != nil ? "optBoolValue" : nil, "stringValue", nulls || self.optStringValue != nil ? "optStringValue" : nil, + "doubleValue", nulls || self.optDoubleValue != nil ? "optDoubleValue" : nil, "floatValue", nulls || self.optFloatValue != nil ? "optFloatValue" : nil, + "int8Value", nulls || self.optInt8Value != nil ? "optInt8Value" : nil, "int16Value", nulls || self.optInt16Value != nil ? "optInt16Value" : nil, + "int32Value", nulls || self.optInt32Value != nil ? "optInt32Value" : nil, "int64Value", nulls || self.optInt64Value != nil ? "optInt64Value" : nil, + "uint8Value", nulls || self.optUint8Value != nil ? "optUint8Value" : nil, "uint16Value", nulls || self.optUint16Value != nil ? "optUint16Value" : nil, + "uint32Value", nulls || self.optUint32Value != nil ? "optUint32Value" : nil, "uint64Value", nulls || self.optUint64Value != nil ? "optUint64Value" : nil, + "intValue", nulls || self.optIntValue != nil ? "optIntValue" : nil, "uintValue", nulls || self.optUintValue != nil ? "optUintValue" : nil, + ].compactMap { $0 } } + + func snakeColumns(nulls: Bool) -> [String] { [ + "bool_value", nulls || self.optBoolValue != nil ? "opt_bool_value" : nil, "string_value", nulls || self.optStringValue != nil ? "opt_string_value" : nil, + "double_value", nulls || self.optDoubleValue != nil ? "opt_double_value" : nil, "float_value", nulls || self.optFloatValue != nil ? "opt_float_value" : nil, + "int8_value", nulls || self.optInt8Value != nil ? "opt_int8_value" : nil, "int16_value", nulls || self.optInt16Value != nil ? "opt_int16_value" : nil, + "int32_value", nulls || self.optInt32Value != nil ? "opt_int32_value" : nil, "int64_value", nulls || self.optInt64Value != nil ? "opt_int64_value" : nil, + "uint8_value", nulls || self.optUint8Value != nil ? "opt_uint8_value" : nil, "uint16_value", nulls || self.optUint16Value != nil ? "opt_uint16_value" : nil, + "uint32_value", nulls || self.optUint32Value != nil ? "opt_uint32_value" : nil, "uint64_value", nulls || self.optUint64Value != nil ? "opt_uint64_value" : nil, + "int_value", nulls || self.optIntValue != nil ? "opt_int_value" : nil, "uint_value", nulls || self.optUintValue != nil ? "opt_uint_value" : nil, + ].compactMap { $0 } } + + func supercaseColumns(nulls: Bool) -> [String] { [ + "BoolValue", nulls || self.optBoolValue != nil ? "OptBoolValue" : nil, "StringValue", nulls || self.optStringValue != nil ? "OptStringValue" : nil, + "DoubleValue", nulls || self.optDoubleValue != nil ? "OptDoubleValue" : nil, "FloatValue", nulls || self.optFloatValue != nil ? "OptFloatValue" : nil, + "Int8Value", nulls || self.optInt8Value != nil ? "OptInt8Value" : nil, "Int16Value", nulls || self.optInt16Value != nil ? "OptInt16Value" : nil, + "Int32Value", nulls || self.optInt32Value != nil ? "OptInt32Value" : nil, "Int64Value", nulls || self.optInt64Value != nil ? "OptInt64Value" : nil, + "Uint8Value", nulls || self.optUint8Value != nil ? "OptUint8Value" : nil, "Uint16Value", nulls || self.optUint16Value != nil ? "OptUint16Value" : nil, + "Uint32Value", nulls || self.optUint32Value != nil ? "OptUint32Value" : nil, "Uint64Value", nulls || self.optUint64Value != nil ? "OptUint64Value" : nil, + "IntValue", nulls || self.optIntValue != nil ? "OptIntValue" : nil, "UintValue", nulls || self.optUintValue != nil ? "OptUintValue" : nil, + ].compactMap { $0 } } + + func valueExpressions(nulls: Bool) -> [any SQLExpression] { [ + SQLBind(self.boolValue), (self.optBoolValue.map { SQLBind($0) } ?? SQLLiteral.null) as any SQLExpression, + SQLBind(self.stringValue), (self.optStringValue.map { SQLBind($0) } ?? SQLLiteral.null) as any SQLExpression, + SQLBind(self.doubleValue), (self.optDoubleValue.map { SQLBind($0) } ?? SQLLiteral.null) as any SQLExpression, + SQLBind(self.floatValue), (self.optFloatValue.map { SQLBind($0) } ?? SQLLiteral.null) as any SQLExpression, + SQLBind(self.int8Value), (self.optInt8Value.map { SQLBind($0) } ?? SQLLiteral.null) as any SQLExpression, + SQLBind(self.int16Value), (self.optInt16Value.map { SQLBind($0) } ?? SQLLiteral.null) as any SQLExpression, + SQLBind(self.int32Value), (self.optInt32Value.map { SQLBind($0) } ?? SQLLiteral.null) as any SQLExpression, + SQLBind(self.int64Value), (self.optInt64Value.map { SQLBind($0) } ?? SQLLiteral.null) as any SQLExpression, + SQLBind(self.uint8Value), (self.optUint8Value.map { SQLBind($0) } ?? SQLLiteral.null) as any SQLExpression, + SQLBind(self.uint16Value), (self.optUint16Value.map { SQLBind($0) } ?? SQLLiteral.null) as any SQLExpression, + SQLBind(self.uint32Value), (self.optUint32Value.map { SQLBind($0) } ?? SQLLiteral.null) as any SQLExpression, + SQLBind(self.uint64Value), (self.optUint64Value.map { SQLBind($0) } ?? SQLLiteral.null) as any SQLExpression, + SQLBind(self.intValue), (self.optIntValue.map { SQLBind($0) } ?? SQLLiteral.null) as any SQLExpression, + SQLBind(self.uintValue), (self.optUintValue.map { SQLBind($0) } ?? SQLLiteral.null) as any SQLExpression, + ].filter { nulls ? true : (($0 as? SQLLiteral).map { $0 != .null } ?? true) } } +} diff --git a/Tests/SQLKitTests/SQLQueryStringTests.swift b/Tests/SQLKitTests/SQLQueryStringTests.swift index be6a5407..84bdcce2 100644 --- a/Tests/SQLKitTests/SQLQueryStringTests.swift +++ b/Tests/SQLKitTests/SQLQueryStringTests.swift @@ -1,110 +1,109 @@ import SQLKit -import SQLKitBenchmark import XCTest final class SQLQueryStringTests: XCTestCase { - var db: TestDatabase! + var db = TestDatabase() - override func setUp() { - super.setUp() - self.db = TestDatabase() + override class func setUp() { + XCTAssert(isLoggingConfigured) + } + + func testWithLiteralString() { + XCTAssertSerialization(of: self.db.raw("TEST"), is: "TEST") + } + + func testRawCustomStringConvertible() { + let field = "name" + XCTAssertSerialization(of: self.db.raw("SELECT \(unsafeRaw: field) FROM users"), is: "SELECT name FROM users") } - func testRawQueryStringInterpolation() throws { + func testRawQueryStringInterpolation() { let (table, planet) = ("planets", "Earth") - let builder = db.raw("SELECT * FROM \(raw: table) WHERE name = \(bind: planet)") - var serializer = SQLSerializer(database: db) - builder.query.serialize(to: &serializer) - - XCTAssertEqual(serializer.sql, "SELECT * FROM planets WHERE name = ?") - XCTAssertEqual(serializer.binds.first! as! String, planet) + let output = XCTAssertNoThrowWithResult(self.db.raw("SELECT * FROM \(ident: table) WHERE name = \(bind: planet)").advancedSerialize()) + + XCTAssertEqual(output?.sql, "SELECT * FROM ``planets`` WHERE name = &1") + XCTAssertEqual(output?.binds.first as? String, planet) } func testRawQueryStringWithNonliteral() throws { let (table, planet) = ("planets", "Earth") - var serializer1 = SQLSerializer(database: db) - let query1 = "SELECT * FROM \(table) WHERE name = \(planet)" - let builder1 = db.raw(.init(query1)) - builder1.query.serialize(to: &serializer1) - XCTAssertEqual(serializer1.sql, "SELECT * FROM planets WHERE name = Earth") - - var serializer2 = SQLSerializer(database: db) - let query2: Substring = "|||SELECT * FROM staticTable WHERE name = uselessUnboundValue|||".dropFirst(3).dropLast(3) - let builder2 = db.raw(.init(query2)) - builder2.query.serialize(to: &serializer2) - XCTAssertEqual(serializer2.sql, "SELECT * FROM staticTable WHERE name = uselessUnboundValue") + XCTAssertSerialization( + of: self.db.raw(.init("SELECT * FROM \(table) WHERE name = \(planet)")), + is: "SELECT * FROM planets WHERE name = Earth" + ) + XCTAssertSerialization( + of: self.db.raw(.init(String("|||SELECT * FROM staticTable WHERE name = uselessUnboundValue|||".dropFirst(3).dropLast(3)))), + is: "SELECT * FROM staticTable WHERE name = uselessUnboundValue" + ) } - func testMakeQueryStringWithoutRawBuilder() throws { + func testMakeQueryStringWithoutRawBuilder() { let queryString = SQLQueryString("query with \(ident: "identifier") and stuff") - var serializer = SQLSerializer(database: db) - let builder = db.raw(queryString) - builder.query.serialize(to: &serializer) - XCTAssertEqual(serializer.sql, "query with `identifier` and stuff") + XCTAssertSerialization(of: self.db.raw(queryString), is: "query with ``identifier`` and stuff") } - func testAllQueryStringInterpolationTypes() throws { - var serializer = SQLSerializer(database: db) - let builder = db.raw(""" - Query string embeds: - \(raw: "plain string embed") - \(bind: "single bind embed") - \(binds: ["multi-bind embed one", "multi-bind embed two"]) - numeric literal embed \(literal: 1) - boolean literal embeds \(true) and \(false) - \(literal: "string literal embed") - \(literals: ["multi-literal embed one", "multi-literal embed two"], joinedBy: " || ") - \(ident: "string identifier embed") - \(idents: ["multi-ident embed one", "multi-ident embed two"], joinedBy: " + ") - expression embeds: \(SQLDropBehavior.restrict) and \(SQLDropBehavior.cascade) - """) - builder.query.serialize(to: &serializer) - XCTAssertEqual(serializer.sql, """ - Query string embeds: - plain string embed - ? - ?, ? - numeric literal embed 1 - boolean literal embeds true and false - 'string literal embed' - 'multi-literal embed one' || 'multi-literal embed two' - `string identifier embed` - `multi-ident embed one` + `multi-ident embed two` - expression embeds: RESTRICT and CASCADE - """) + func testAllQueryStringInterpolationTypes() { + XCTAssertSerialization( + of: self.db.raw(""" + Query string embeds: + \(unsafeRaw: "plain string embed") + \(bind: "single bind embed") + \(binds: ["multi-bind embed one", "multi-bind embed two"]) + numeric literal embed \(literal: 1) + numeric float literal embed \(literal: 1.0) + boolean literal embeds \(true) and \(false) + \(literal: "string literal embed") + \(literals: ["multi-literal embed one", "multi-literal embed two"], joinedBy: " || ") + \(ident: "string identifier embed") + \(idents: ["multi-ident embed one", "multi-ident embed two"], joinedBy: " + ") + expression embeds: \(SQLDropBehavior.restrict) and \(SQLDropBehavior.cascade) + """ + ), + is: """ + Query string embeds: + plain string embed + &1 + &2, &3 + numeric literal embed 1 + numeric float literal embed 1.0 + boolean literal embeds TROO and FAALS + 'string literal embed' + 'multi-literal embed one' || 'multi-literal embed two' + ``string identifier embed`` + ``multi-ident embed one`` + ``multi-ident embed two`` + expression embeds: RESTRICT and CASCADE + """ + ) } - func testAppendingQueryStringByOperatorPlus() throws { - var serializer = SQLSerializer(database: db) - let builder = db.raw( - "INSERT INTO \(ident: "anything") " as SQLQueryString + - "(\(idents: ["col1", "col2", "col3"], joinedBy: ",")) " as SQLQueryString + - "VALUES (\(binds: [1, 2, 3]))" as SQLQueryString + func testAppendingQueryStringByOperatorPlus() { + XCTAssertSerialization( + of: self.db.raw( + "INSERT INTO \(ident: "anything") " as SQLQueryString + + "(\(idents: ["col1", "col2", "col3"], joinedBy: ",")) " as SQLQueryString + + "VALUES (\(binds: [1, 2, 3]))" as SQLQueryString + ), + is: "INSERT INTO ``anything`` (``col1``,``col2``,``col3``) VALUES (&1, &2, &3)" ) - builder.query.serialize(to: &serializer) - XCTAssertEqual(serializer.sql, "INSERT INTO `anything` (`col1`,`col2`,`col3`) VALUES (?, ?, ?)") } - func testAppendingQueryStringByOperatorPlusEquals() throws { - var serializer = SQLSerializer(database: db) - + func testAppendingQueryStringByOperatorPlusEquals() { var query = "INSERT INTO \(ident: "anything") " as SQLQueryString query += "(\(idents: ["col1", "col2", "col3"], joinedBy: ",")) " as SQLQueryString query += "VALUES (\(binds: [1, 2, 3]))" as SQLQueryString - let builder = db.raw(query) - builder.query.serialize(to: &serializer) - XCTAssertEqual(serializer.sql, "INSERT INTO `anything` (`col1`,`col2`,`col3`) VALUES (?, ?, ?)") + XCTAssertSerialization(of: self.db.raw(query), is: "INSERT INTO ``anything`` (``col1``,``col2``,``col3``) VALUES (&1, &2, &3)") } - func testQueryStringArrayJoin() throws { - var serializer = SQLSerializer(database: db) - let builder = db.raw( - "INSERT INTO \(ident: "anything") " as SQLQueryString + - ((0..<5).map { "\(literal: "\($0)")" as SQLQueryString }).joined(separator: "..") + func testQueryStringArrayJoin() { + XCTAssertSerialization( + of: self.db.raw( + "INSERT INTO \(ident: "anything") " as SQLQueryString + + ((0..<5).map { "\(literal: "\($0)")" as SQLQueryString }).joined(separator: "..") + ), + is: "INSERT INTO ``anything`` '0'..'1'..'2'..'3'..'4'" ) - builder.query.serialize(to: &serializer) - XCTAssertEqual(serializer.sql, "INSERT INTO `anything` '0'..'1'..'2'..'3'..'4'") + XCTAssertSerialization(of: self.db.raw(Array().joined()), is: "") } } diff --git a/Tests/SQLKitTests/SQLRowDecoderTests.swift b/Tests/SQLKitTests/SQLRowDecoderTests.swift new file mode 100644 index 00000000..55ed7a82 --- /dev/null +++ b/Tests/SQLKitTests/SQLRowDecoderTests.swift @@ -0,0 +1,248 @@ +@testable @_spi(CodableUtilities) import SQLKit +import XCTest + +final class SQLRowDecoderTests: XCTestCase { + func testRowDecoderBasicConfigurations() { + func row1(nulls: Bool, xform: Bool?, prefix: String = "") -> TestRow { + let raw: [String: (any Codable & Sendable)?] = [ + "boolValue": true, "optBoolValue": Bool?.none, "stringValue": "hello", "optStringValue": "olleh", + "doubleValue": 1.0, "optDoubleValue": Double?.none, "floatValue": 1.0 as Float, "optFloatValue": 0.1 as Float?, + "int8Value": 1 as Int8, "optInt8Value": Int8?.none, "int16Value": 2 as Int16, "optInt16Value": 3 as Int16?, + "int32Value": 4 as Int32, "optInt32Value": Int32?.none, "int64Value": 5 as Int64, "optInt64Value": 6 as Int64?, + "uint8Value": 7 as UInt8, "optUint8Value": UInt8?.none, "uint16Value": 8 as UInt16, "optUint16Value": 9 as UInt16?, + "uint32Value": 10 as UInt32, "optUint32Value": UInt32?.none, "uint64Value": 11 as UInt64, "optUint64Value": 12 as UInt64?, + "intValue": 13 as Int, "optIntValue": Int?.none, "uintValue": 14 as UInt, "optUintValue": 15 as UInt? + ] + let all = nulls ? raw : raw.compactMapValues { $0 } + switch xform { + case .none: return TestRow(data: .init(uniqueKeysWithValues: all.map { ("\(prefix)\($0)", $1) })) + case .some(false): return TestRow(data: .init(uniqueKeysWithValues: all.map { ("\(prefix)\($0.convertedToSnakeCase)", $1) })) + case .some(true): return TestRow(data: .init(uniqueKeysWithValues: all.map { ("\(prefix)\(superCase([SomeCodingKey(stringValue: $0)]).stringValue)", $1) })) + } + } + func row2(nulls: Bool, xform: Bool?, prefix: String = "") -> TestRow { + let raw: [String: (any Codable & Sendable)?] = [ + "boolValue": true, "optBoolValue": false, "stringValue": "hello", "optStringValue": String?.none, + "doubleValue": 1.0, "optDoubleValue": 0.1, "floatValue": 1.0 as Float, "optFloatValue": Float?.none, + "int8Value": 1 as Int8, "optInt8Value": 2 as Int8?, "int16Value": 3 as Int16, "optInt16Value": Int16?.none, + "int32Value": 4 as Int32, "optInt32Value": 5 as Int32?, "int64Value": 6 as Int64, "optInt64Value": Int64?.none, + "uint8Value": 7 as UInt8, "optUint8Value": 8 as UInt8?, "uint16Value": 9 as UInt16, "optUint16Value": UInt16?.none, + "uint32Value": 10 as UInt32, "optUint32Value": 11 as UInt32?, "uint64Value": 12 as UInt64, "optUint64Value": UInt64?.none, + "intValue": 13 as Int, "optIntValue": 14 as Int?, "uintValue": 15 as UInt, "optUintValue": UInt?.none + ] + let all = nulls ? raw : raw.compactMapValues { $0 } + switch xform { + case .none: return TestRow(data: .init(uniqueKeysWithValues: all.map { ("\(prefix)\($0)", $1) })) + case .some(false): return TestRow(data: .init(uniqueKeysWithValues: all.map { ("\(prefix)\($0.convertedToSnakeCase)", $1) })) + case .some(true): return TestRow(data: .init(uniqueKeysWithValues: all.map { ("\(prefix)\(superCase([SomeCodingKey(stringValue: $0)]).stringValue)", $1) })) + } + } + + let model1 = BasicDecModel( + boolValue: true, optBoolValue: nil, stringValue: "hello", optStringValue: "olleh", + doubleValue: 1.0, optDoubleValue: nil, floatValue: 1.0, optFloatValue: 0.1, + int8Value: 1, optInt8Value: nil, int16Value: 2, optInt16Value: 3, + int32Value: 4, optInt32Value: nil, int64Value: 5, optInt64Value: 6, + uint8Value: 7, optUint8Value: nil, uint16Value: 8, optUint16Value: 9, + uint32Value: 10, optUint32Value: nil, uint64Value: 11, optUint64Value: 12, + intValue: 13, optIntValue: nil, uintValue: 14, optUintValue: 15 + ) + let model2 = BasicDecModel( + boolValue: true, optBoolValue: false, stringValue: "hello", optStringValue: nil, + doubleValue: 1.0, optDoubleValue: 0.1, floatValue: 1.0, optFloatValue: nil, + int8Value: 1, optInt8Value: 2, int16Value: 3, optInt16Value: nil, + int32Value: 4, optInt32Value: 5, int64Value: 6, optInt64Value: nil, + uint8Value: 7, optUint8Value: 8, uint16Value: 9, optUint16Value: nil, + uint32Value: 10, optUint32Value: 11, uint64Value: 12, optUint64Value: nil, + intValue: 13, optIntValue: 14, uintValue: 15, optUintValue: nil + ) + + // Model 1 with key strategies + XCTAssertDecoding(BasicDecModel.self, from: row1(nulls: false, xform: nil), using: SQLRowDecoder(keyDecodingStrategy: .useDefaultKeys), outputs: model1) + XCTAssertDecoding(BasicDecModel.self, from: row1(nulls: true, xform: nil), using: SQLRowDecoder(keyDecodingStrategy: .useDefaultKeys), outputs: model1) + + XCTAssertDecoding(BasicDecModel.self, from: row1(nulls: false, xform: false), using: SQLRowDecoder(keyDecodingStrategy: .convertFromSnakeCase), outputs: model1) + XCTAssertDecoding(BasicDecModel.self, from: row1(nulls: true, xform: false), using: SQLRowDecoder(keyDecodingStrategy: .convertFromSnakeCase), outputs: model1) + + XCTAssertDecoding(BasicDecModel.self, from: row1(nulls: false, xform: true), using: SQLRowDecoder(keyDecodingStrategy: .custom({ superCase($0) })), outputs: model1) + XCTAssertDecoding(BasicDecModel.self, from: row1(nulls: true, xform: true), using: SQLRowDecoder(keyDecodingStrategy: .custom({ superCase($0) })), outputs: model1) + + // Model 1 with prefix and key strategies + XCTAssertDecoding(BasicDecModel.self, from: row1(nulls: false, xform: nil, prefix: "p_"), using: SQLRowDecoder(prefix: "p_", keyDecodingStrategy: .useDefaultKeys), outputs: model1) + XCTAssertDecoding(BasicDecModel.self, from: row1(nulls: true, xform: nil, prefix: "p_"), using: SQLRowDecoder(prefix: "p_", keyDecodingStrategy: .useDefaultKeys), outputs: model1) + + XCTAssertDecoding(BasicDecModel.self, from: row1(nulls: false, xform: false, prefix: "p_"), using: SQLRowDecoder(prefix: "p_", keyDecodingStrategy: .convertFromSnakeCase), outputs: model1) + XCTAssertDecoding(BasicDecModel.self, from: row1(nulls: true, xform: false, prefix: "p_"), using: SQLRowDecoder(prefix: "p_", keyDecodingStrategy: .convertFromSnakeCase), outputs: model1) + + XCTAssertDecoding(BasicDecModel.self, from: row1(nulls: false, xform: true, prefix: "p_"), using: SQLRowDecoder(prefix: "p_", keyDecodingStrategy: .custom({ superCase($0) })), outputs: model1) + XCTAssertDecoding(BasicDecModel.self, from: row1(nulls: true, xform: true, prefix: "p_"), using: SQLRowDecoder(prefix: "p_", keyDecodingStrategy: .custom({ superCase($0) })), outputs: model1) + + // Model 2 with key strategies + XCTAssertDecoding(BasicDecModel.self, from: row2(nulls: false, xform: nil), using: SQLRowDecoder(keyDecodingStrategy: .useDefaultKeys), outputs: model2) + XCTAssertDecoding(BasicDecModel.self, from: row2(nulls: true, xform: nil), using: SQLRowDecoder(keyDecodingStrategy: .useDefaultKeys), outputs: model2) + + XCTAssertDecoding(BasicDecModel.self, from: row2(nulls: false, xform: false), using: SQLRowDecoder(keyDecodingStrategy: .convertFromSnakeCase), outputs: model2) + XCTAssertDecoding(BasicDecModel.self, from: row2(nulls: true, xform: false), using: SQLRowDecoder(keyDecodingStrategy: .convertFromSnakeCase), outputs: model2) + + XCTAssertDecoding(BasicDecModel.self, from: row2(nulls: false, xform: true), using: SQLRowDecoder(keyDecodingStrategy: .custom({ superCase($0) })), outputs: model2) + XCTAssertDecoding(BasicDecModel.self, from: row2(nulls: true, xform: true), using: SQLRowDecoder(keyDecodingStrategy: .custom({ superCase($0) })), outputs: model2) + + // Model 2 with prefix and key strategies + XCTAssertDecoding(BasicDecModel.self, from: row2(nulls: false, xform: nil, prefix: "p_"), using: SQLRowDecoder(prefix: "p_", keyDecodingStrategy: .useDefaultKeys), outputs: model2) + XCTAssertDecoding(BasicDecModel.self, from: row2(nulls: true, xform: nil, prefix: "p_"), using: SQLRowDecoder(prefix: "p_", keyDecodingStrategy: .useDefaultKeys), outputs: model2) + + XCTAssertDecoding(BasicDecModel.self, from: row2(nulls: false, xform: false, prefix: "p_"), using: SQLRowDecoder(prefix: "p_", keyDecodingStrategy: .convertFromSnakeCase), outputs: model2) + XCTAssertDecoding(BasicDecModel.self, from: row2(nulls: true, xform: false, prefix: "p_"), using: SQLRowDecoder(prefix: "p_", keyDecodingStrategy: .convertFromSnakeCase), outputs: model2) + + XCTAssertDecoding(BasicDecModel.self, from: row2(nulls: false, xform: true, prefix: "p_"), using: SQLRowDecoder(prefix: "p_", keyDecodingStrategy: .custom({ superCase($0) })), outputs: model2) + XCTAssertDecoding(BasicDecModel.self, from: row2(nulls: true, xform: true, prefix: "p_"), using: SQLRowDecoder(prefix: "p_", keyDecodingStrategy: .custom({ superCase($0) })), outputs: model2) + } + + func testDecodeUnkeyedValues() { + XCTAssertThrowsError(try SQLRowDecoder().decode(Array.self, from: TestRow(data: [:]))) { XCTAssert($0 is SQLCodingError, "Expected SQLCodingError, got \(String(reflecting: $0))") } + } + + func testDecodeTopLevelValues() { + XCTAssertThrowsError(try SQLRowDecoder().decode(Bool.self, from: TestRow(data: [:]))) { XCTAssert($0 is SQLCodingError, "Expected SQLCodingError, got \(String(reflecting: $0))") } + XCTAssertThrowsError(try SQLRowDecoder().decode(String.self, from: TestRow(data: [:]))) { XCTAssert($0 is SQLCodingError, "Expected SQLCodingError, got \(String(reflecting: $0))") } + XCTAssertThrowsError(try SQLRowDecoder().decode(Double.self, from: TestRow(data: [:]))) { XCTAssert($0 is SQLCodingError, "Expected SQLCodingError, got \(String(reflecting: $0))") } + XCTAssertThrowsError(try SQLRowDecoder().decode(Float.self, from: TestRow(data: [:]))) { XCTAssert($0 is SQLCodingError, "Expected SQLCodingError, got \(String(reflecting: $0))") } + XCTAssertThrowsError(try SQLRowDecoder().decode(Int8.self, from: TestRow(data: [:]))) { XCTAssert($0 is SQLCodingError, "Expected SQLCodingError, got \(String(reflecting: $0))") } + XCTAssertThrowsError(try SQLRowDecoder().decode(Int16.self, from: TestRow(data: [:]))) { XCTAssert($0 is SQLCodingError, "Expected SQLCodingError, got \(String(reflecting: $0))") } + XCTAssertThrowsError(try SQLRowDecoder().decode(Int32.self, from: TestRow(data: [:]))) { XCTAssert($0 is SQLCodingError, "Expected SQLCodingError, got \(String(reflecting: $0))") } + XCTAssertThrowsError(try SQLRowDecoder().decode(Int64.self, from: TestRow(data: [:]))) { XCTAssert($0 is SQLCodingError, "Expected SQLCodingError, got \(String(reflecting: $0))") } + XCTAssertThrowsError(try SQLRowDecoder().decode(UInt8.self, from: TestRow(data: [:]))) { XCTAssert($0 is SQLCodingError, "Expected SQLCodingError, got \(String(reflecting: $0))") } + XCTAssertThrowsError(try SQLRowDecoder().decode(UInt16.self, from: TestRow(data: [:]))) { XCTAssert($0 is SQLCodingError, "Expected SQLCodingError, got \(String(reflecting: $0))") } + XCTAssertThrowsError(try SQLRowDecoder().decode(UInt32.self, from: TestRow(data: [:]))) { XCTAssert($0 is SQLCodingError, "Expected SQLCodingError, got \(String(reflecting: $0))") } + XCTAssertThrowsError(try SQLRowDecoder().decode(UInt64.self, from: TestRow(data: [:]))) { XCTAssert($0 is SQLCodingError, "Expected SQLCodingError, got \(String(reflecting: $0))") } + XCTAssertThrowsError(try SQLRowDecoder().decode(Int.self, from: TestRow(data: [:]))) { XCTAssert($0 is SQLCodingError, "Expected SQLCodingError, got \(String(reflecting: $0))") } + XCTAssertThrowsError(try SQLRowDecoder().decode(UInt.self, from: TestRow(data: [:]))) { XCTAssert($0 is SQLCodingError, "Expected SQLCodingError, got \(String(reflecting: $0))") } + } + + func testDecodeNestedKeyedValues() { + XCTAssertNoThrow(try SQLRowDecoder().decode(TestDecodableIfPresent.self, from: TestRow(data: ["foo": Date()]))) + XCTAssertNoThrow(try SQLRowDecoder().decode(TestDecodableIfPresent.self, from: TestRow(data: ["foo": Date?.none]))) + XCTAssertNoThrow(try SQLRowDecoder().decode(Dictionary>.self, from: TestRow(data: ["a": ["b": "c"]]))) + XCTAssertNoThrow(try SQLRowDecoder(keyDecodingStrategy: .convertFromSnakeCase).decode(Dictionary>.self, from: TestRow(data: ["a": ["b": "c"]]))) + XCTAssertNoThrow(try SQLRowDecoder(keyDecodingStrategy: .custom({ superCase($0) })).decode(Dictionary>.self, from: TestRow(data: ["A": ["b": "c"]]))) + XCTAssertNoThrow(try SQLRowDecoder().decode(Dictionary>.self, from: TestRow(data: ["a": ["b", "c"]]))) + XCTAssertThrowsError(try SQLRowDecoder().decode(TestDecNestedKeyedContainers.self, from: TestRow(data: [:]))) { XCTAssert($0 is SQLCodingError, "Expected SQLCodingError, got \(String(reflecting: $0))") } + XCTAssertThrowsError(try SQLRowDecoder().decode(TestDecNestedUnkeyedContainer.self, from: TestRow(data: [:]))) { XCTAssert($0 is SQLCodingError, "Expected SQLCodingError, got \(String(reflecting: $0))") } + XCTAssertThrowsError(try SQLRowDecoder().decode(TestKeylessSuperDecoder.self, from: TestRow(data: [:]))) { XCTAssert($0 is SQLCodingError, "Expected SQLCodingError, got \(String(reflecting: $0))") } + XCTAssertNoThrow(try SQLRowDecoder().decode(TestDecNestedSingleValueContainer.self, from: TestRow(data: ["foo": ["_0": 1, "_1": 1]]))) + XCTAssertNoThrow(try SQLRowDecoder().decode(TestDecNestedSingleValueContainer.self, from: TestRow(data: ["foo": Dictionary?.none]))) + XCTAssertThrowsError(try SQLRowDecoder().decode(TestDecEnum.self, from: TestRow(data: ["foo": Dictionary()]))) { XCTAssert($0 is SQLCodingError, "Expected SQLCodingError, got \(String(reflecting: $0))") } + } + + func testDecoderMiscErrorHandling() { + struct ErroringRow: SQLRow { + let allColumns: [String] + func contains(column: String) -> Bool { column == "foo" } + func decodeNil(column: String) throws -> Bool { throw DecodingError.valueNotFound(Optional.self, .init(codingPath: [], debugDescription: "")) } + func decode(column: String, as: D.Type) throws -> D { throw DecodingError.valueNotFound(Optional.self, .init(codingPath: [], debugDescription: "")) } + } + XCTAssertThrowsError(try SQLRowDecoder().decode(TestDecodableIfPresent.self, from: ErroringRow(allColumns: ["foo"]))) { + guard case .valueNotFound(_, let context) = $0 as? DecodingError else { + return XCTFail("Expected DecodingError.valueNotFound(), got \(String(reflecting: $0))") + } + XCTAssertEqual(["foo"], context.codingPath.map(\.stringValue)) + } + XCTAssertThrowsError(try SQLRowDecoder().decode(TestDecNestedSingleValueContainer.self, from: ErroringRow(allColumns: ["foo"]))) { + guard case .valueNotFound(_, let context) = $0 as? DecodingError else { + return XCTFail("Expected DecodingError.valueNotFound(), got \(String(reflecting: $0))") + } + XCTAssertEqual(["foo", "foo"], context.codingPath.map(\.stringValue)) + } + XCTAssertThrowsError(try SQLRowDecoder().decode(TestDecNestedSingleValueContainer?.self, from: ErroringRow(allColumns: ["foo"]))) { + guard case .valueNotFound(_, let context) = $0 as? DecodingError else { + return XCTFail("Expected DecodingError.valueNotFound(), got \(String(reflecting: $0))") + } + XCTAssertEqual(["foo", "foo"], context.codingPath.map(\.stringValue)) + } + XCTAssertThrowsError(try SQLRowDecoder().decode([String: String].self, from: ErroringRow(allColumns: ["foo"]))) { + guard case .valueNotFound(_, let context) = $0 as? DecodingError else { + return XCTFail("Expected DecodingError.valueNotFound(), got \(String(reflecting: $0))") + } + XCTAssertEqual([SomeCodingKey(stringValue: "foo")].map(\.stringValue), context.codingPath.map(\.stringValue)) + } + XCTAssertThrowsError(try SQLRowDecoder().decode([String: String].self, from: ErroringRow(allColumns: ["b"]))) { + guard case .keyNotFound(_, let context) = $0 as? DecodingError else { + return XCTFail("Expected DecodingError.keyNotFound(), got \(String(reflecting: $0))") + } + XCTAssertEqual(Array().map(\.stringValue), context.codingPath.map(\.stringValue)) + } + } +} + +enum TestDecEnum: Codable { + /// N.B.: Compiler autosynthesizes a call to `KeyedDecodingContainer.nestedContainer(keyedBy:forKey:)` for this. + case foo(bar: Bool) +} + +struct TestDecodableIfPresent: Decodable { + let foo: Date? +} + +struct TestKeylessSuperDecoder: Decodable { + let foo: Bool + + init(from decoder: any Decoder) throws { + XCTAssertNil(decoder.userInfo[.init(rawValue: "a")!]) // for completeness of code coverage + let container = try decoder.container(keyedBy: SomeCodingKey.self) + let superDecoder = try container.superDecoder() + let subcontainer = try superDecoder.singleValueContainer() + self.foo = try subcontainer.decode(Bool.self) + } +} + +struct TestDecNestedUnkeyedContainer: Decodable { + let foo: Bool + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: SomeCodingKey.self) + var subcontainer = try container.nestedUnkeyedContainer(forKey: .init(stringValue: "foo")) + self.foo = try subcontainer.decode(Bool.self) + } +} + +struct TestDecNestedKeyedContainers: Decodable { + let foo: (Int, Int) + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: SomeCodingKey.self) + let superDecoder = try container.superDecoder(forKey: .init(stringValue: "foo")) + let subcontainer = try superDecoder.container(keyedBy: SomeCodingKey.self) + + self.foo = ( + try subcontainer.decode(Int.self, forKey: .init(stringValue: "_0")), + try subcontainer.decode(Int.self, forKey: .init(stringValue: "_1")) + ) + } +} + +struct TestDecNestedSingleValueContainer: Decodable { + let foo: (Int, Int)? + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: SomeCodingKey.self) + let superDecoder = try container.superDecoder(forKey: .init(stringValue: "foo")) + let subcontainer = try superDecoder.singleValueContainer() + + if subcontainer.decodeNil() { + self.foo = nil + } else { + let value = try subcontainer.decode([String: Int].self) + self.foo = (value["_0"]!, value["_1"]!) + } + } +} + +struct BasicDecModel: Codable, Equatable { + var boolValue: Bool, optBoolValue: Bool?, stringValue: String, optStringValue: String? + var doubleValue: Double, optDoubleValue: Double?, floatValue: Float, optFloatValue: Float? + var int8Value: Int8, optInt8Value: Int8?, int16Value: Int16, optInt16Value: Int16? + var int32Value: Int32, optInt32Value: Int32?, int64Value: Int64, optInt64Value: Int64? + var uint8Value: UInt8, optUint8Value: UInt8?, uint16Value: UInt16, optUint16Value: UInt16? + var uint32Value: UInt32, optUint32Value: UInt32?, uint64Value: UInt64, optUint64Value: UInt64? + var intValue: Int, optIntValue: Int?, uintValue: UInt, optUintValue: UInt? +} diff --git a/Tests/SQLKitTests/SQLUnionTests.swift b/Tests/SQLKitTests/SQLUnionTests.swift new file mode 100644 index 00000000..a04732ee --- /dev/null +++ b/Tests/SQLKitTests/SQLUnionTests.swift @@ -0,0 +1,202 @@ +import SQLKit +import XCTest + +final class SQLUnionTests: XCTestCase { + var db = TestDatabase() + + override class func setUp() { + XCTAssert(isLoggingConfigured) + } + + // MARK: Unions + + func testUnion_UNION() { + // Check that queries are explicitly malformed without the feature flags + self.db._dialect.unionFeatures = [] + + XCTAssertSerialization( + of: self.db.select().column("id").from("t1").union(distinct: { $0.column("id").from("t2") }), + is: "SELECT ``id`` FROM ``t1`` SELECT ``id`` FROM ``t2``" + ) + XCTAssertSerialization( + of: self.db.select().column("id").from("t1").union(all: { $0.column("id").from("t2") }), + is: "SELECT ``id`` FROM ``t1`` SELECT ``id`` FROM ``t2``" + ) + + // Test that queries are correctly formed with the feature flags + self.db._dialect.unionFeatures.formUnion([.union, .unionAll]) + + XCTAssertSerialization( + of: self.db.select().column("id").from("t1").union(distinct: { $0.column("id").from("t2") }), + is: "SELECT ``id`` FROM ``t1`` UNION SELECT ``id`` FROM ``t2``" + ) + XCTAssertSerialization( + of: self.db.select().column("id").from("t1").union(all: { $0.column("id").from("t2") }), + is: "SELECT ``id`` FROM ``t1`` UNION ALL SELECT ``id`` FROM ``t2``" + ) + + // Test that the explicit distinct flag is respected + self.db._dialect.unionFeatures.insert(.explicitDistinct) + XCTAssertSerialization( + of: self.db.select().column("id").from("t1").union(distinct: { $0.column("id").from("t2") }), + is: "SELECT ``id`` FROM ``t1`` UNION DISTINCT SELECT ``id`` FROM ``t2``" + ) + } + + func testUnion_INTERSECT() { + // Check that queries are explicitly malformed without the feature flags + self.db._dialect.unionFeatures = [] + + XCTAssertSerialization( + of: self.db.select().column("id").from("t1").intersect(distinct: { $0.column("id").from("t2") }), + is: "SELECT ``id`` FROM ``t1`` SELECT ``id`` FROM ``t2``" + ) + XCTAssertSerialization( + of: self.db.select().column("id").from("t1").intersect(all: { $0.column("id").from("t2") }), + is: "SELECT ``id`` FROM ``t1`` SELECT ``id`` FROM ``t2``" + ) + + // Test that queries are correctly formed with the feature flags + self.db._dialect.unionFeatures.formUnion([.intersect, .intersectAll]) + + XCTAssertSerialization( + of: self.db.select().column("id").from("t1").intersect(distinct: { $0.column("id").from("t2") }), + is: "SELECT ``id`` FROM ``t1`` INTERSECT SELECT ``id`` FROM ``t2``" + ) + XCTAssertSerialization( + of: self.db.select().column("id").from("t1").intersect(all: { $0.column("id").from("t2") }), + is: "SELECT ``id`` FROM ``t1`` INTERSECT ALL SELECT ``id`` FROM ``t2``" + ) + + // Test that the explicit distinct flag is respected + self.db._dialect.unionFeatures.insert(.explicitDistinct) + + XCTAssertSerialization( + of: self.db.select().column("id").from("t1").intersect(distinct: { $0.column("id").from("t2") }), + is: "SELECT ``id`` FROM ``t1`` INTERSECT DISTINCT SELECT ``id`` FROM ``t2``" + ) + } + + func testUnion_EXCEPT() { + // Check that queries are explicitly malformed without the feature flags + self.db._dialect.unionFeatures = [] + + XCTAssertSerialization( + of: self.db.select().column("id").from("t1").except(distinct: { $0.column("id").from("t2") }), + is: "SELECT ``id`` FROM ``t1`` SELECT ``id`` FROM ``t2``" + ) + XCTAssertSerialization( + of: self.db.select().column("id").from("t1").except(all: { $0.column("id").from("t2") }), + is: "SELECT ``id`` FROM ``t1`` SELECT ``id`` FROM ``t2``" + ) + + // Test that queries are correctly formed with the feature flags + self.db._dialect.unionFeatures.formUnion([.except, .exceptAll]) + + XCTAssertSerialization( + of: self.db.select().column("id").from("t1").except(distinct: { $0.column("id").from("t2") }), + is: "SELECT ``id`` FROM ``t1`` EXCEPT SELECT ``id`` FROM ``t2``" + ) + XCTAssertSerialization( + of: self.db.select().column("id").from("t1").except(all: { $0.column("id").from("t2") }), + is: "SELECT ``id`` FROM ``t1`` EXCEPT ALL SELECT ``id`` FROM ``t2``" + ) + + // Test that the explicit distinct flag is respected + self.db._dialect.unionFeatures.insert(.explicitDistinct) + + XCTAssertSerialization( + of: self.db.select().column("id").from("t1").except(distinct: { $0.column("id").from("t2") }), + is: "SELECT ``id`` FROM ``t1`` EXCEPT DISTINCT SELECT ``id`` FROM ``t2``" + ) + } + + func testUnionWithParenthesizedSubqueriesFlag() { + // Test that the parenthesized subqueries flag does as expected, including for multiple unions + self.db._dialect.unionFeatures = [.union, .unionAll, .parenthesizedSubqueries] + XCTAssertSerialization( + of: self.db.select().column("id").from("t1").union(distinct: { $0.column("id").from("t2") }), + is: "(SELECT ``id`` FROM ``t1``) UNION (SELECT ``id`` FROM ``t2``)" + ) + XCTAssertSerialization( + of: self.db.select().column("id").from("t1").union(distinct: { $0.column("id").from("t2") }).union(distinct: { $0.column("id").from("t3") }), + is: "(SELECT ``id`` FROM ``t1``) UNION (SELECT ``id`` FROM ``t2``) UNION (SELECT ``id`` FROM ``t3``)" + ) + } + + func testUnionChaining() { + // Test that chaining and mixing multiple union types works + self.db._dialect.unionFeatures = [.union, .unionAll, .intersect, .intersectAll, .except, .exceptAll, .explicitDistinct, .parenthesizedSubqueries] + XCTAssertSerialization( + of: self.db.select().column("id").from("t1") + .union(distinct: { $0.column("id").from("t2") }) + .union(all: { $0.column("id").from("t3") }) + .union( { $0.column("id").from("t23") }) + .intersect(distinct: { $0.column("id").from("t4") }) + .intersect(all: { $0.column("id").from("t5") }) + .intersect( { $0.column("id").from("t45") }) + .except(distinct: { $0.column("id").from("t6") }) + .except(all: { $0.column("id").from("t7") }) + .except( { $0.column("id").from("t67") }), + is: "(SELECT ``id`` FROM ``t1``) UNION DISTINCT (SELECT ``id`` FROM ``t2``) UNION ALL (SELECT ``id`` FROM ``t3``) UNION DISTINCT (SELECT ``id`` FROM ``t23``) INTERSECT DISTINCT (SELECT ``id`` FROM ``t4``) INTERSECT ALL (SELECT ``id`` FROM ``t5``) INTERSECT DISTINCT (SELECT ``id`` FROM ``t45``) EXCEPT DISTINCT (SELECT ``id`` FROM ``t6``) EXCEPT ALL (SELECT ``id`` FROM ``t7``) EXCEPT DISTINCT (SELECT ``id`` FROM ``t67``)" + ) + } + + func testOneQueryUnion() { + // Test that having a single entry in the union just executes that entry + XCTAssertSerialization( + of: self.db.union { $0.column("id").from("t1") }, + is: "SELECT ``id`` FROM ``t1``" + ) + } + + func testUnionSubtypesFromSelect() { + self.db._dialect.unionFeatures = [.union, .unionAll, .intersect, .intersectAll, .except, .exceptAll, .parenthesizedSubqueries] + XCTAssertSerialization( + of: self.db.select().column("id").from("t1").union({ $0.column("id").from("t2") }), + is: "(SELECT ``id`` FROM ``t1``) UNION (SELECT ``id`` FROM ``t2``)" + ) + + XCTAssertSerialization( + of: self.db.select().column("id").from("t1").intersect({ $0.column("id").from("t2") }), + is: "(SELECT ``id`` FROM ``t1``) INTERSECT (SELECT ``id`` FROM ``t2``)" + ) + + XCTAssertSerialization( + of: self.db.select().column("id").from("t1").except({ $0.column("id").from("t2") }), + is: "(SELECT ``id`` FROM ``t1``) EXCEPT (SELECT ``id`` FROM ``t2``)" + ) + } + + func testUnionOverallModifiers() { + // Test LIMIT and OFFSET + self.db._dialect.unionFeatures = [.union, .unionAll, .intersect, .intersectAll, .except, .exceptAll, .parenthesizedSubqueries] + XCTAssertSerialization( + of: self.db.select().column("id").from("t1").union({ $0.column("id").from("t2") }).limit(3).offset(5), + is: "(SELECT ``id`` FROM ``t1``) UNION (SELECT ``id`` FROM ``t2``) LIMIT 3 OFFSET 5" + ) + + // Cover the property getters + let builder = self.db.union({ $0.where(SQLLiteral.boolean(true)) }).limit(1).offset(2) + XCTAssertEqual(builder.limit, 1) + XCTAssertEqual(builder.offset, 2) + + // Test multiple ORDER BY statements + XCTAssertSerialization( + of: self.db.select().column("*").from("t1").union({ $0.column("*").from("t2") }).orderBy("id").orderBy("name", .descending), + is: "(SELECT * FROM ``t1``) UNION (SELECT * FROM ``t2``) ORDER BY ``id`` ASC, ``name`` DESC" + ) + } + + func testUnionAddMethod() { + var query = SQLUnion(initialQuery: self.db.select().columns("*").select) + query.add(self.db.select().columns("*").select, all: true) + query.add(self.db.select().columns("*").select, all: false) + + self.db._dialect.unionFeatures = [] + XCTAssertSerialization(of: self.db.raw("\(query)"), is: "SELECT * SELECT * SELECT *") + + self.db._dialect.unionFeatures = [.union, .unionAll] + XCTAssertSerialization(of: self.db.raw("\(query)"), is: "SELECT * UNION ALL SELECT * UNION SELECT *") + } +} diff --git a/Tests/SQLKitTests/TestMocks.swift b/Tests/SQLKitTests/TestMocks.swift new file mode 100644 index 00000000..5dc178b4 --- /dev/null +++ b/Tests/SQLKitTests/TestMocks.swift @@ -0,0 +1,153 @@ +import OrderedCollections +@testable import SQLKit +import NIOCore +import Logging +import Dispatch + +/// An extremely incorrect implementation of the bare minimum of the `EventLoop` protocol, 'cause we have to have +/// _something_ for a database's event loop property despite never doing anything async. +final class FakeEventLoop: EventLoop, @unchecked Sendable { + func shutdownGracefully(queue: DispatchQueue, _: @escaping @Sendable ((any Error)?) -> Void) {} + var inEventLoop: Bool = false + func execute(_ work: @escaping @Sendable () -> Void) { self.inEventLoop = true; work(); self.inEventLoop = false } + @discardableResult func scheduleTask(deadline: NIODeadline, _: @escaping @Sendable () throws -> T) -> Scheduled { fatalError() } + @discardableResult func scheduleTask(in: TimeAmount, _: @escaping @Sendable () throws -> T) -> Scheduled { fatalError() } +} + +extension SQLQueryBuilder { + /// Serialize this builder's query and return the textual SQL, discarding any bindings. + func simpleSerialize() -> String { + self.advancedSerialize().sql + } + + /// Serialize this builder's query and return the SQL and bindings (which conveniently can be done by just + /// returning the serializer). + func advancedSerialize() -> SQLSerializer { + var serializer = SQLSerializer(database: self.database) + + self.query.serialize(to: &serializer) + return serializer + } +} + +/// A very minimal mock `SQLDatabase` which implements `execut(sql:_:)` by saving the serialized SQL and bindings to +/// its internal arrays of accumulated "results". Most things about its dialect are mutable. +final class TestDatabase: SQLDatabase, @unchecked Sendable { + let logger: Logger = .init(label: "codes.vapor.sql.test") + let eventLoop: any EventLoop = FakeEventLoop() + var results: [String] = [] + var bindResults: [[any Encodable & Sendable]] = [] + var outputs: [any SQLRow] = [] + var dialect: any SQLDialect { self._dialect } + var _dialect: GenericDialect = .init() + + func execute(sql query: any SQLExpression, _ onRow: @escaping (any SQLRow) -> ()) -> EventLoopFuture { + let (sql, binds) = self.serialize(query) + + self.results.append(sql) + self.bindResults.append(binds) + while let row = self.outputs.popLast() { + onRow(row) + } + return self.eventLoop.makeSucceededFuture(()) + } + + func execute(sql query: any SQLExpression, _ onRow: @escaping (any SQLRow) -> ()) async throws { + let (sql, binds) = self.serialize(query) + + self.results.append(sql) + self.bindResults.append(binds) + while let row = self.outputs.popLast() { + onRow(row) + } + } +} + +/// A minimal but surprisingly complete mock `SQLRow` which correctly implements all required methods. +struct TestRow: SQLRow { + var data: OrderedDictionary + + var allColumns: [String] { + .init(self.data.keys) + } + + func contains(column: String) -> Bool { + self.data.keys.contains(column) + } + + func decodeNil(column: String) throws -> Bool { + self.data[column].map { $0.map { _ in false } ?? true } ?? true + } + + func decode(column: String, as: D.Type) throws -> D { + let key = SomeCodingKey(stringValue: column) + + /// Key not in dictionary? Key not found (no such column). + guard case let .some(maybeValue) = self.data[column] else { + throw DecodingError.keyNotFound(key, .init( + codingPath: [], debugDescription: "No value associated with key '\(column)'." + )) + } + /// Key exists but value is nil? Value not found (should have used decodeNil() instead). + guard case let .some(value) = maybeValue else { + throw DecodingError.valueNotFound(D.self, .init( + codingPath: [key], + debugDescription: "No value of type \(D.self) associated with key '\(column)'." + )) + } + /// Value given but is wrong type? Type mismatch. + guard let cast = value as? D else { + throw DecodingError.typeMismatch(D.self, .init( + codingPath: [key], + debugDescription: "Expected to decode \(D.self) but found \(type(of: value)) instead." + )) + } + return cast + } +} + +/// The mutable mock `SQLDialect` used by `TestDatabase`. +struct GenericDialect: SQLDialect { + var name: String { "generic" } + + func bindPlaceholder(at position: Int) -> any SQLExpression { SQLRaw("&\(position)") } + func literalBoolean(_ value: Bool) -> any SQLExpression { SQLRaw(value ? "TROO" : "FAALS") } + var literalDefault: any SQLExpression = SQLRaw("DEFALLT") + var supportsAutoIncrement = true + var supportsIfExists = true + var supportsReturning = true + var identifierQuote: any SQLExpression = SQLRaw("``") + var literalStringQuote: any SQLExpression = SQLRaw("'") + var enumSyntax = SQLEnumSyntax.typeName + var autoIncrementClause: any SQLExpression = SQLRaw("AWWTOEINCREMENT") + var autoIncrementFunction: (any SQLExpression)? = nil + var supportsDropBehavior = true + var triggerSyntax = SQLTriggerSyntax(create: [], drop: []) + var alterTableSyntax = SQLAlterTableSyntax(alterColumnDefinitionClause: SQLRaw("MOODIFY"), alterColumnDefinitionTypeKeyword: nil) + var upsertSyntax = SQLUpsertSyntax.standard + var unionFeatures = SQLUnionFeatures() + var sharedSelectLockExpression: (any SQLExpression)? = SQLRaw("FOUR SHAARE") + var exclusiveSelectLockExpression: (any SQLExpression)? = SQLRaw("FOUR UPDATE") + func nestedSubpathExpression(in column: any SQLExpression, for path: [String]) -> (any SQLExpression)? { + precondition(!path.isEmpty) + let descender = SQLList([column] + path.dropLast().map(SQLLiteral.string(_:)), separator: SQLRaw("-»")) + return SQLGroupExpression(SQLList([descender, SQLLiteral.string(path.last!)], separator: SQLRaw("-»»"))) + } + func customDataType(for dataType: SQLDataType) -> (any SQLExpression)? { + dataType == .custom(SQLRaw("STANDARD")) ? SQLRaw("CUSTOM") : nil + } +} + +extension SQLDataType: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + switch (lhs, rhs) { + case (.bigint, .bigint), (.blob, .blob), (.int, .int), (.real, .real), + (.smallint, .smallint), (.text, .text): + return true + case (.custom(let lhs as SQLRaw), .custom(let rhs as SQLRaw)) where lhs.sql == rhs.sql: + return true + default: + return false + } + } +} diff --git a/Tests/SQLKitTests/Utilities.swift b/Tests/SQLKitTests/Utilities.swift index b0270621..dd6cca8e 100644 --- a/Tests/SQLKitTests/Utilities.swift +++ b/Tests/SQLKitTests/Utilities.swift @@ -1,100 +1,76 @@ -import SQLKit -import NIOCore -import NIOEmbedded import Logging +import SQLKit +import XCTest -final class TestDatabase: SQLDatabase { - let logger: Logger - let eventLoop: any EventLoop - var results: [String] - var bindResults: [[any Encodable]] - var dialect: any SQLDialect { self._dialect } - var _dialect: GenericDialect - - init() { - self.logger = .init(label: "codes.vapor.sql.test") - self.eventLoop = EmbeddedEventLoop() - self.results = [] - self.bindResults = [] - self._dialect = GenericDialect() - } +func XCTAssertNoThrowWithResult( + _ expression: @autoclosure () throws -> T, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, line: UInt = #line +) -> T? { + var result: T? - func execute(sql query: any SQLExpression, _ onRow: @escaping (any SQLRow) -> ()) -> EventLoopFuture { - var serializer = SQLSerializer(database: self) - query.serialize(to: &serializer) - results.append(serializer.sql) - bindResults.append(serializer.binds) - return self.eventLoop.makeSucceededFuture(()) - } + XCTAssertNoThrow(result = try expression(), message(), file: file, line: line) + return result } -struct TestRow: SQLRow { - enum Datum { // yes, this is just Optional by another name - case some(any Encodable) - case none - } - - var data: [String: Datum] - - enum _Error: Error { - case missingColumn(String) - case typeMismatch(Any, Any.Type) - } - - var allColumns: [String] { - .init(self.data.keys) - } - - func contains(column: String) -> Bool { - self.data.keys.contains(column) - } - - func decodeNil(column: String) throws -> Bool { - if case .some(.none) = self.data[column] { return true } - return false - } +func XCTAssertSerialization( + of queryBuilder: @autoclosure () throws -> some SQLQueryBuilder, + is serialization: @autoclosure() throws -> String, + message: @autoclosure () -> String = "", file: StaticString = #filePath, line: UInt = #line +) { + XCTAssertEqual(try queryBuilder().simpleSerialize(), try serialization(), message(), file: file, line: line) +} - func decode(column: String, as type: D.Type) throws -> D - where D : Decodable - { - guard case let .some(.some(value)) = self.data[column] else { - throw _Error.missingColumn(column) - } - guard let cast = value as? D else { - throw _Error.typeMismatch(value, D.self) +func XCTAssertEncoding( + _ model: @autoclosure() throws -> any Encodable, + using encoder: @autoclosure () throws -> SQLQueryEncoder, + outputs columns: @autoclosure () throws -> [String], + _ values: @autoclosure () throws -> [any SQLExpression], + _ message: @autoclosure() -> String = "", file: StaticString = #filePath, line: UInt = #line +) { + guard let columns = XCTAssertNoThrowWithResult(try columns(), message(), file: file, line: line), + let values = XCTAssertNoThrowWithResult(try values(), message(), file: file, line: line), + let model = XCTAssertNoThrowWithResult(try model(), message(), file: file, line: line), + let encoder = XCTAssertNoThrowWithResult(try encoder(), message(), file: file, line: line), + let encodedData = XCTAssertNoThrowWithResult(try encoder.encode(model), message(), file: file, line: line) + else { return } + let encodedColumns = encodedData.map(\.0), encodedValues = encodedData.map(\.1) + + XCTAssertEqual(columns, encodedColumns, message(), file: file, line: line) + XCTAssertEqual(values.count, encodedValues.count, message(), file: file, line: line) + for (value, encValue) in zip(values, encodedValues) { + switch (value, encValue) { + case (let value as SQLLiteral, let encValue as SQLLiteral): XCTAssertEqual(value, encValue, message(), file: file, line: line) + case (let value as SQLBind, let encValue as SQLBind): XCTAssertEqual(value, encValue, message(), file: file, line: line) + case (let value as TestEncExpr.Enm, let encValue as TestEncExpr.Enm): XCTAssertEqual(value, encValue, message(), file: file, line: line) + default: XCTFail("Unexpected output (expected \(String(reflecting: value)), got \(String(reflecting: encValue))) \(message())", file: file, line: line) } - return cast } } -struct GenericDialect: SQLDialect { - var name: String { "generic" } +func XCTAssertDecoding( + _: D.Type, + from row: @autoclosure () throws -> some SQLRow, + using decoder: @autoclosure () throws -> SQLRowDecoder, + outputs model: @autoclosure () throws -> D, + _ message: @autoclosure() -> String = "", file: StaticString = #filePath, line: UInt = #line +) { + guard let row = XCTAssertNoThrowWithResult(try row(), message(), file: file, line: line), + let decoder = XCTAssertNoThrowWithResult(try decoder(), message(), file: file, line: line), + let model = XCTAssertNoThrowWithResult(try model(), message(), file: file, line: line), + let decodedModel = XCTAssertNoThrowWithResult(try decoder.decode(D.self, from: row), message(), file: file, line: line) + else { return } + + XCTAssertEqual(model, decodedModel, message(), file: file, line: line) +} - func bindPlaceholder(at position: Int) -> any SQLExpression { SQLRaw("?") } - func literalBoolean(_ value: Bool) -> any SQLExpression { SQLRaw("\(value)") } - var supportsAutoIncrement: Bool = true - var supportsIfExists: Bool = true - var supportsReturning: Bool = true - var identifierQuote: any SQLExpression = SQLRaw("`") - var literalStringQuote: any SQLExpression = SQLRaw("'") - var enumSyntax: SQLEnumSyntax = .inline - var autoIncrementClause: any SQLExpression = SQLRaw("AUTOINCREMENT") - var autoIncrementFunction: (any SQLExpression)? = nil - var supportsDropBehavior: Bool = false - var triggerSyntax = SQLTriggerSyntax(create: [], drop: []) - var alterTableSyntax = SQLAlterTableSyntax(alterColumnDefinitionClause: SQLRaw("MODIFY"), alterColumnDefinitionTypeKeyword: nil) - var upsertSyntax: SQLUpsertSyntax = .standard - var unionFeatures: SQLUnionFeatures = [] - var sharedSelectLockExpression: (any SQLExpression)? { SQLRaw("FOR SHARE") } - var exclusiveSelectLockExpression: (any SQLExpression)? { SQLRaw("FOR UPDATE") } - func nestedSubpathExpression(in column: SQLExpression, for path: [String]) -> (SQLExpression)? { - precondition(!path.isEmpty) - let descender = SQLList([column] + path.dropLast().map(SQLLiteral.string(_:)), separator: SQLRaw("->")) - return SQLGroupExpression(SQLList([descender, SQLLiteral.string(path.last!)], separator: SQLRaw("->>"))) +let isLoggingConfigured: Bool = { + LoggingSystem.bootstrap { label in + var handler = StreamLogHandler.standardOutput(label: label) + + handler.logLevel = ProcessInfo.processInfo.environment["LOG_LEVEL"].flatMap(Logger.Level.init(rawValue:)) ?? .info + return handler } + return true +}() - mutating func setTriggerSyntax(create: SQLTriggerSyntax.Create = [], drop: SQLTriggerSyntax.Drop = []) { - self.triggerSyntax.create = create - self.triggerSyntax.drop = drop - } -} diff --git a/Tests/SQLKitTests/XCTAsyncAssertions.swift b/Tests/SQLKitTests/XCTAsyncAssertions.swift new file mode 100644 index 00000000..1f8b6e1a --- /dev/null +++ b/Tests/SQLKitTests/XCTAsyncAssertions.swift @@ -0,0 +1,266 @@ +import XCTest + +// MARK: - Unwrap + +func XCTUnwrapAsync( + _ expression: @autoclosure () async throws -> T?, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, line: UInt = #line +) async throws -> T { + let result: T? + + do { + result = try await expression() + } catch { + return try XCTUnwrap(try { throw error }(), message(), file: file, line: line) + } + return try XCTUnwrap(result, message(), file: file, line: line) +} + +// MARK: - Equality + +func XCTAssertEqualAsync( + _ expression1: @autoclosure () async throws -> T, + _ expression2: @autoclosure () async throws -> T, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, line: UInt = #line +) async where T: Equatable { + do { + let expr1 = try await expression1(), expr2 = try await expression2() + return XCTAssertEqual(expr1, expr2, message(), file: file, line: line) + } catch { + return XCTAssertEqual(try { () -> Bool in throw error }(), false, message(), file: file, line: line) + } +} + +func XCTAssertNotEqualAsync( + _ expression1: @autoclosure () async throws -> T, + _ expression2: @autoclosure () async throws -> T, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, line: UInt = #line +) async where T: Equatable { + do { + let expr1 = try await expression1(), expr2 = try await expression2() + return XCTAssertNotEqual(expr1, expr2, message(), file: file, line: line) + } catch { + return XCTAssertNotEqual(try { () -> Bool in throw error }(), true, message(), file: file, line: line) + } +} + +// MARK: - Fuzzy equality + +func XCTAssertEqualAsync( + _ expression1: @autoclosure () async throws -> T, + _ expression2: @autoclosure () async throws -> T, + accuracy: T, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, line: UInt = #line +) async where T: Numeric { + do { + let expr1 = try await expression1(), expr2 = try await expression2() + return XCTAssertEqual(expr1, expr2, accuracy: accuracy, message(), file: file, line: line) + } catch { + return XCTAssertEqual(try { () -> Bool in throw error }(), false, message(), file: file, line: line) + } +} + +func XCTAssertNotEqualAsync( + _ expression1: @autoclosure () async throws -> T, + _ expression2: @autoclosure () async throws -> T, + accuracy: T, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, line: UInt = #line +) async where T: Numeric { + do { + let expr1 = try await expression1(), expr2 = try await expression2() + return XCTAssertNotEqual(expr1, expr2, accuracy: accuracy, message(), file: file, line: line) + } catch { + return XCTAssertNotEqual(try { () -> Bool in throw error }(), false, message(), file: file, line: line) + } +} + +func XCTAssertEqualAsync( + _ expression1: @autoclosure () async throws -> T, + _ expression2: @autoclosure () async throws -> T, + accuracy: T, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, line: UInt = #line +) async where T: FloatingPoint { + do { + let expr1 = try await expression1(), expr2 = try await expression2() + return XCTAssertEqual(expr1, expr2, accuracy: accuracy, message(), file: file, line: line) + } catch { + return XCTAssertEqual(try { () -> Bool in throw error }(), false, message(), file: file, line: line) + } +} + +func XCTAssertNotEqualAsync( + _ expression1: @autoclosure () async throws -> T, + _ expression2: @autoclosure () async throws -> T, + accuracy: T, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, line: UInt = #line +) async where T: FloatingPoint { + do { + let expr1 = try await expression1(), expr2 = try await expression2() + return XCTAssertNotEqual(expr1, expr2, accuracy: accuracy, message(), file: file, line: line) + } catch { + return XCTAssertNotEqual(try { () -> Bool in throw error }(), false, message(), file: file, line: line) + } +} + +// MARK: - Comparability + +func XCTAssertGreaterThanAsync( + _ expression1: @autoclosure () async throws -> T, + _ expression2: @autoclosure () async throws -> T, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, line: UInt = #line +) async where T: Comparable { + do { + let expr1 = try await expression1(), expr2 = try await expression2() + return XCTAssertGreaterThan(expr1, expr2, message(), file: file, line: line) + } catch { + return XCTAssertGreaterThan(try { () -> Int in throw error }(), 0, message(), file: file, line: line) + } +} + +func XCTAssertGreaterThanOrEqualAsync( + _ expression1: @autoclosure () async throws -> T, + _ expression2: @autoclosure () async throws -> T, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, line: UInt = #line +) async where T: Comparable { + do { + let expr1 = try await expression1(), expr2 = try await expression2() + return XCTAssertGreaterThanOrEqual(expr1, expr2, message(), file: file, line: line) + } catch { + return XCTAssertGreaterThanOrEqual(try { () -> Int in throw error }(), 0, message(), file: file, line: line) + } +} + + +func XCTAssertLessThanAsync( + _ expression1: @autoclosure () async throws -> T, + _ expression2: @autoclosure () async throws -> T, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, line: UInt = #line +) async where T: Comparable { + do { + let expr1 = try await expression1(), expr2 = try await expression2() + return XCTAssertLessThan(expr1, expr2, message(), file: file, line: line) + } catch { + return XCTAssertLessThan(try { () -> Int in throw error }(), 0, message(), file: file, line: line) + } +} + +func XCTAssertLessThanOrEqualAsync( + _ expression1: @autoclosure () async throws -> T, + _ expression2: @autoclosure () async throws -> T, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, line: UInt = #line +) async where T: Comparable { + do { + let expr1 = try await expression1(), expr2 = try await expression2() + return XCTAssertLessThanOrEqual(expr1, expr2, message(), file: file, line: line) + } catch { + return XCTAssertLessThanOrEqual(try { () -> Int in throw error }(), 0, message(), file: file, line: line) + } +} + +// MARK: - Truthiness + +func XCTAssertAsync( + _ predicate: @autoclosure () async throws -> Bool, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, line: UInt = #line +) async { + do { + let result = try await predicate() + XCTAssert(result, message(), file: file, line: line) + } catch { + return XCTAssert(try { throw error }(), message(), file: file, line: line) + } +} + +func XCTAssertTrueAsync( + _ predicate: @autoclosure () async throws -> Bool, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, line: UInt = #line +) async { + do { + let result = try await predicate() + XCTAssertTrue(result, message(), file: file, line: line) + } catch { + return XCTAssertTrue(try { throw error }(), message(), file: file, line: line) + } +} + +func XCTAssertFalseAsync( + _ predicate: @autoclosure () async throws -> Bool, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, line: UInt = #line +) async { + do { + let result = try await predicate() + XCTAssertFalse(result, message(), file: file, line: line) + } catch { + return XCTAssertFalse(try { throw error }(), message(), file: file, line: line) + } +} + +// MARK: - Existence + +func XCTAssertNilAsync( + _ expression: @autoclosure () async throws -> Any?, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, line: UInt = #line +) async { + do { + let result = try await expression() + return XCTAssertNil(result, message(), file: file, line: line) + } catch { + return XCTAssertNil(try { throw error }(), message(), file: file, line: line) + } +} + +func XCTAssertNotNilAsync( + _ expression: @autoclosure () async throws -> Any?, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, line: UInt = #line +) async { + do { + let result = try await expression() + XCTAssertNotNil(result, message(), file: file, line: line) + } catch { + return XCTAssertNotNil(try { throw error }(), message(), file: file, line: line) + } +} + +// MARK: - Exceptionality + +func XCTAssertThrowsErrorAsync( + _ expression: @autoclosure () async throws -> T, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, line: UInt = #line, + _ callback: (any Error) -> Void = { _ in } +) async { + do { + _ = try await expression() + XCTAssertThrowsError({}(), message(), file: file, line: line, callback) + } catch { + XCTAssertThrowsError(try { throw error }(), message(), file: file, line: line, callback) + } +} + +func XCTAssertNoThrowAsync( + _ expression: @autoclosure () async throws -> T, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, line: UInt = #line +) async { + do { + _ = try await expression() + } catch { + XCTAssertNoThrow(try { throw error }(), message(), file: file, line: line) + } +}