From 3f37bb8c39ea5db5c497e509a2196488183718b9 Mon Sep 17 00:00:00 2001 From: Gwynne Raskind Date: Mon, 16 Mar 2026 10:27:57 -0500 Subject: [PATCH 1/3] Clean up all the docs warnings, update CI --- .github/.codecov.yml | 1 - .github/dependabot.yml | 8 +++ .github/workflows/test.yml | 25 +++++--- .../SQLCommonTableExpressionBuilder.swift | 2 +- .../Prototypes/SQLCommonUnionBuilder.swift | 18 +++--- Sources/SQLKit/Docs.docc/BasicUsage.md | 6 +- .../Docs.docc/Resources/vapor-sqlkit-logo.svg | 53 +++++++++++----- .../Docs.docc/SQLDatabase+ExtensionDocs.md | 61 ++++++++++--------- .../SQLQueryFetcher+ExtensionDocs.md | 56 ++++++++--------- Sources/SQLKit/Docs.docc/theme-settings.json | 2 +- 10 files changed, 137 insertions(+), 95 deletions(-) diff --git a/.github/.codecov.yml b/.github/.codecov.yml index 220b77a6..ab69a043 100644 --- a/.github/.codecov.yml +++ b/.github/.codecov.yml @@ -34,4 +34,3 @@ ignore: - ^Tests/.* - ^.build/.* slack_app: false - diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 998a0ebe..9f8d71ff 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,3 +8,11 @@ updates: dependencies: patterns: - "*" + - package-ecosystem: "swift" + directory: "/" + schedule: + interval: "daily" + groups: + dependencies: + patterns: + - "*" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 334a2f5c..1af6d106 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,13 +5,11 @@ concurrency: on: pull_request: { types: [opened, reopened, synchronize, ready_for_review] } push: { branches: [ main ] } -env: - LOG_LEVEL: info -permissions: - contents: read jobs: unit-tests: + permissions: + contents: read uses: vapor/ci/.github/workflows/run-unit-tests.yml@main secrets: inherit with: @@ -20,6 +18,8 @@ jobs: pure-fluent-integration-test: if: ${{ !(github.event.pull_request.draft || false) }} + permissions: + contents: read runs-on: ubuntu-latest container: swift:6.2-noble steps: @@ -36,19 +36,21 @@ jobs: integration-tests: if: ${{ !(github.event.pull_request.draft || false) }} + permissions: + contents: read services: mysql-a: image: mysql:latest - env: { MYSQL_USER: test_username, MYSQL_PASSWORD: test_password, MYSQL_DATABASE: test_database, MYSQL_ALLOW_EMPTY_PASSWORD: true } + env: &common_mysql_env { MYSQL_USER: test_username, MYSQL_PASSWORD: test_password, MYSQL_DATABASE: test_database, MYSQL_ALLOW_EMPTY_PASSWORD: true } mysql-b: image: mysql:latest - env: { MYSQL_USER: test_username, MYSQL_PASSWORD: test_password, MYSQL_DATABASE: test_database, MYSQL_ALLOW_EMPTY_PASSWORD: true } + env: *common_mysql_env psql-a: image: postgres:latest - env: { POSTGRES_USER: test_username, POSTGRES_PASSWORD: test_password, POSTGRES_DB: test_database } + env: &common_psql_env { POSTGRES_USER: test_username, POSTGRES_PASSWORD: test_password, POSTGRES_DB: test_database } psql-b: image: postgres:latest - env: { POSTGRES_USER: test_username, POSTGRES_PASSWORD: test_password, POSTGRES_DB: test_database } + env: *common_psql_env strategy: fail-fast: false matrix: @@ -90,3 +92,10 @@ jobs: run: | swift package --package-path "${FLUENT_DRIVER}" edit --path sql-kit sql-kit swift test --package-path "${FLUENT_DRIVER}" --sanitize=thread + + submit-dependencies: + permissions: + contents: write + if: ${{ github.event_name == 'push' }} + uses: vapor/ci/.github/workflows/submit-deps.yml@main + secrets: inherit diff --git a/Sources/SQLKit/Builders/Prototypes/SQLCommonTableExpressionBuilder.swift b/Sources/SQLKit/Builders/Prototypes/SQLCommonTableExpressionBuilder.swift index 2b8de270..78f25244 100644 --- a/Sources/SQLKit/Builders/Prototypes/SQLCommonTableExpressionBuilder.swift +++ b/Sources/SQLKit/Builders/Prototypes/SQLCommonTableExpressionBuilder.swift @@ -284,7 +284,7 @@ extension SQLCommonTableExpressionBuilder { /// This is the common "funnel" method invoked by all other methods provided by /// ``SQLCommonTableExpressionBuilder``. Most users will not need to call this method directly. /// - /// See ``with(_:columns:as:)-28k4r`` and ``with(recursive:columns:as:)-6yef`` for usage examples. + /// See ``SQLCommonTableExpressionBuilder/with(_:columns:as:)-28k4r`` and ``SQLCommonTableExpressionBuilder/with(recursive:columns:as:)-6yef`` for usage examples. /// /// > Warning: As with ``SQLCommonTableExpression``, ``SQLCommonTableExpressionBuilder`` does _NOT_ validate /// > that a recursive CTE's query takes the proper form, nor that a non-recursive CTE's query is not diff --git a/Sources/SQLKit/Builders/Prototypes/SQLCommonUnionBuilder.swift b/Sources/SQLKit/Builders/Prototypes/SQLCommonUnionBuilder.swift index 05d5d2db..4c2fd8ef 100644 --- a/Sources/SQLKit/Builders/Prototypes/SQLCommonUnionBuilder.swift +++ b/Sources/SQLKit/Builders/Prototypes/SQLCommonUnionBuilder.swift @@ -77,55 +77,55 @@ extension SQLCommonUnionBuilder { return self } - /// Call ``union(distinct:)-15xs8`` with a query generated by a builder. + /// Call ``SQLCommonUnionBuilder/union(distinct:)-15xs8`` with a query generated by a builder. @inlinable public func union(distinct predicate: (any SQLSubqueryClauseBuilder) throws -> any SQLSubqueryClauseBuilder) rethrows -> Self { try self.union(distinct: predicate(SQLSubqueryBuilder()).select) } - /// Call ``union(all:)-56f28`` with a query generated by a builder. + /// Call ``SQLCommonUnionBuilder/union(all:)-56f28`` with a query generated by a builder. @inlinable public func union(all predicate: (any SQLSubqueryClauseBuilder) throws -> any SQLSubqueryClauseBuilder) rethrows -> Self { try self.union(all: predicate(SQLSubqueryBuilder()).select) } - /// Alias ``union(distinct:)-921p6`` so it acts as the "default". + /// Alias ``SQLCommonUnionBuilder/union(distinct:)-921p6`` so it acts as the "default". @inlinable public func union(_ predicate: (any SQLSubqueryClauseBuilder) throws -> any SQLSubqueryClauseBuilder) rethrows -> Self { try self.union(distinct: predicate) } - /// Call ``intersect(distinct:)-161s9`` with a query generated by a builder. + /// Call ``SQLCommonUnionBuilder/intersect(distinct:)-161s9`` with a query generated by a builder. @inlinable public func intersect(distinct predicate: (any SQLSubqueryClauseBuilder) throws -> any SQLSubqueryClauseBuilder) rethrows -> Self { try self.intersect(distinct: predicate(SQLSubqueryBuilder()).select) } - /// Call ``intersect(all:)-1wiow`` with a query generated by a builder. + /// Call ``SQLCommonUnionBuilder/intersect(all:)-1wiow`` with a query generated by a builder. @inlinable public func intersect(all predicate: (any SQLSubqueryClauseBuilder) throws -> any SQLSubqueryClauseBuilder) rethrows -> Self { try self.intersect(all: predicate(SQLSubqueryBuilder()).select) } - /// Alias ``intersect(distinct:)-8f71m`` so it acts as the "default". + /// Alias ``SQLCommonUnionBuilder/intersect(distinct:)-8f71m`` so it acts as the "default". @inlinable public func intersect(_ predicate: (any SQLSubqueryClauseBuilder) throws -> any SQLSubqueryClauseBuilder) rethrows -> Self { try self.intersect(distinct: predicate) } - /// Call ``except(distinct:)-2ygq0`` with a query generated by a builder. + /// Call ``SQLCommonUnionBuilder/except(distinct:)-2ygq0`` with a query generated by a builder. @inlinable public func except(distinct predicate: (any SQLSubqueryClauseBuilder) throws -> any SQLSubqueryClauseBuilder) rethrows -> Self { try self.except(distinct: predicate(SQLSubqueryBuilder()).select) } - /// Call ``except(all:)-5exbl`` with a query generated by a builder. + /// Call ``SQLCommonUnionBuilder/except(all:)-5exbl`` with a query generated by a builder. @inlinable public func except(all predicate: (any SQLSubqueryClauseBuilder) throws -> any SQLSubqueryClauseBuilder) rethrows -> Self { try self.except(all: predicate(SQLSubqueryBuilder()).select) } - /// Alias ``except(distinct:)-62w7q`` so it acts as the "default". + /// Alias ``SQLCommonUnionBuilder/except(distinct:)-62w7q`` so it acts as the "default". @inlinable public func except(_ predicate: (any SQLSubqueryClauseBuilder) throws -> any SQLSubqueryClauseBuilder) rethrows -> Self { try self.except(distinct: predicate) diff --git a/Sources/SQLKit/Docs.docc/BasicUsage.md b/Sources/SQLKit/Docs.docc/BasicUsage.md index e7ccf19b..e7dace3d 100644 --- a/Sources/SQLKit/Docs.docc/BasicUsage.md +++ b/Sources/SQLKit/Docs.docc/BasicUsage.md @@ -206,7 +206,7 @@ WHERE "name" <> NULL AND ("name" = ?1 OR "name" = ?2) -- bindings: ["Milky Way", ### Insert -The ``SQLDatabase/insert(into:)-67oqt`` and ``SQLDatabase/insert(into:)-5n3gh`` methods create an `INSERT` query builder: +The ``SQLDatabase/insert(into:)-(String)`` and ``SQLDatabase/insert(into:)-(SQLExpression)`` methods create an `INSERT` query builder: ```swift try await db.insert(into: "galaxies") @@ -236,7 +236,7 @@ This code generates the same SQL as would `builder.columns("name").values("Milky ### Update -The ``SQLDatabase/update(_:)-2tf1c`` and ``SQLDatabase/update(_:)-80964`` methods create an `UPDATE` query builder: +The ``SQLDatabase/update(_:)-(String)`` and ``SQLDatabase/update(_:)-(SQLExpression)`` methods create an `UPDATE` query builder: ```swift try await db.update("planets") @@ -255,7 +255,7 @@ The update builder supports the same `where()` and `orWhere()` methods as the se ### Delete -The ``SQLDatabase/delete(from:)-3tx4f`` and ``SQLDatabase/delete(from:)-4bqlu`` methods create a `DELETE` query builder: +The ``SQLDatabase/delete(from:)-(String)`` and ``SQLDatabase/delete(from:)-(SQLExpression)`` methods create a `DELETE` query builder: ```swift try await db.delete(from: "planets") diff --git a/Sources/SQLKit/Docs.docc/Resources/vapor-sqlkit-logo.svg b/Sources/SQLKit/Docs.docc/Resources/vapor-sqlkit-logo.svg index 053016d0..0916a41d 100644 --- a/Sources/SQLKit/Docs.docc/Resources/vapor-sqlkit-logo.svg +++ b/Sources/SQLKit/Docs.docc/Resources/vapor-sqlkit-logo.svg @@ -1,22 +1,47 @@ - - - + + + - - - + + + - - - + + + - - + + - diff --git a/Sources/SQLKit/Docs.docc/SQLDatabase+ExtensionDocs.md b/Sources/SQLKit/Docs.docc/SQLDatabase+ExtensionDocs.md index 888ef775..34e0f199 100644 --- a/Sources/SQLKit/Docs.docc/SQLDatabase+ExtensionDocs.md +++ b/Sources/SQLKit/Docs.docc/SQLDatabase+ExtensionDocs.md @@ -17,40 +17,41 @@ ### DML queries -- ``SQLDatabase/delete(from:)-3tx4f`` -- ``SQLDatabase/delete(from:)-4bqlu`` -- ``SQLDatabase/insert(into:)-67oqt`` -- ``SQLDatabase/insert(into:)-5n3gh`` +- ``SQLDatabase/delete(from:)-(String)`` +- ``SQLDatabase/delete(from:)-(SQLExpression)`` +- ``SQLDatabase/insert(into:)-(String)`` +- ``SQLDatabase/insert(into:)-(SQLExpression)`` - ``SQLDatabase/select()`` - ``SQLDatabase/union(_:)`` -- ``SQLDatabase/update(_:)-2tf1c`` -- ``SQLDatabase/update(_:)-80964`` +- ``SQLDatabase/update(_:)-(String)`` +- ``SQLDatabase/update(_:)-(SQLExpression)`` ### 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`` +- ``SQLDatabase/alter(table:)-(String)`` +- ``SQLDatabase/alter(table:)-(SQLIdentifier)`` +- ``SQLDatabase/alter(table:)-(SQLExpression)`` +- ``SQLDatabase/create(table:)-(String)`` +- ``SQLDatabase/create(table:)-(SQLExpression)`` +- ``SQLDatabase/drop(table:)-(String)`` +- ``SQLDatabase/drop(table:)-(SQLExpression)`` + +- ``SQLDatabase/alter(enum:)-(String)`` +- ``SQLDatabase/alter(enum:)-(SQLExpression)`` +- ``SQLDatabase/create(enum:)-(String)`` +- ``SQLDatabase/create(enum:)-(SQLExpression)`` +- ``SQLDatabase/drop(enum:)-(String)`` +- ``SQLDatabase/drop(enum:)-(SQLExpression)`` + +- ``SQLDatabase/create(index:)-(String)`` +- ``SQLDatabase/create(index:)-(SQLExpression)`` +- ``SQLDatabase/drop(index:)-(String)`` +- ``SQLDatabase/drop(index:)-(SQLExpression)`` + +- ``SQLDatabase/create(trigger:table:when:event:)-(String,_,_,_)`` +- ``SQLDatabase/create(trigger:table:when:event:)-(SQLExpression,_,_,_)`` +- ``SQLDatabase/drop(trigger:)-(String)`` +- ``SQLDatabase/drop(trigger:)-(SQLExpression)`` ### Raw queries @@ -63,4 +64,4 @@ ### Legacy query interface -- ``SQLDatabase/execute(sql:_:)-90wi9`` +- ``SQLDatabase/execute(sql:_:)->_`` diff --git a/Sources/SQLKit/Docs.docc/SQLQueryFetcher+ExtensionDocs.md b/Sources/SQLKit/Docs.docc/SQLQueryFetcher+ExtensionDocs.md index 589f326e..94696311 100644 --- a/Sources/SQLKit/Docs.docc/SQLQueryFetcher+ExtensionDocs.md +++ b/Sources/SQLKit/Docs.docc/SQLQueryFetcher+ExtensionDocs.md @@ -4,40 +4,40 @@ ### Getting Rows -- ``SQLQueryFetcher/run(_:)-40swz`` -- ``SQLQueryFetcher/run(decoding:_:)-476q1`` -- ``SQLQueryFetcher/run(decoding:prefix:keyDecodingStrategy:userInfo:_:)-583ot`` -- ``SQLQueryFetcher/run(decoding:with:_:)-8y7ux`` +- ``SQLQueryFetcher/run(_:)->()`` +- ``SQLQueryFetcher/run(decoding:_:)->()`` +- ``SQLQueryFetcher/run(decoding:prefix:keyDecodingStrategy:userInfo:_:)->()`` +- ``SQLQueryFetcher/run(decoding:with:_:)->()`` ### 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`` +- ``SQLQueryFetcher/all(decoding:with:)->[D]`` +- ``SQLQueryFetcher/all(decoding:)->[D]`` +- ``SQLQueryFetcher/all(decoding:prefix:keyDecodingStrategy:userInfo:)->[D]`` +- ``SQLQueryFetcher/all(decoding:with:)->[D]`` +- ``SQLQueryFetcher/all(decodingColumn:as:)->[D]`` ### 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`` +- ``SQLQueryFetcher/first()->EventLoopFuture<(SQLRow)?>`` +- ``SQLQueryFetcher/first(decoding:)->D?`` +- ``SQLQueryFetcher/first(decoding:prefix:keyDecodingStrategy:userInfo:)->D?`` +- ``SQLQueryFetcher/first(decoding:with:)->D?`` +- ``SQLQueryFetcher/first(decodingColumn:as:)->D?`` ### Legacy `EventLoopFuture` Interfaces -- ``SQLQueryFetcher/run(_:)-542bs`` -- ``SQLQueryFetcher/run(decoding:_:)-6z89k`` -- ``SQLQueryFetcher/run(decoding:prefix:keyDecodingStrategy:userInfo:_:)-2cp56`` -- ``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`` +- ``SQLQueryFetcher/run(_:)->_`` +- ``SQLQueryFetcher/run(decoding:_:)->_`` +- ``SQLQueryFetcher/run(decoding:prefix:keyDecodingStrategy:userInfo:_:)->_`` +- ``SQLQueryFetcher/run(decoding:with:_:)->_`` +- ``SQLQueryFetcher/all()->EventLoopFuture<[SQLRow]>`` +- ``SQLQueryFetcher/all(decoding:)->EventLoopFuture<[D]>`` +- ``SQLQueryFetcher/all(decoding:prefix:keyDecodingStrategy:userInfo:)->EventLoopFuture<[D]>`` +- ``SQLQueryFetcher/all(decoding:with:)->EventLoopFuture<[D]>`` +- ``SQLQueryFetcher/all(decodingColumn:as:)->EventLoopFuture<[D]>`` +- ``SQLQueryFetcher/first()->EventLoopFuture<(SQLRow)?>`` +- ``SQLQueryFetcher/first(decoding:)->EventLoopFuture`` +- ``SQLQueryFetcher/first(decoding:prefix:keyDecodingStrategy:userInfo:)->EventLoopFuture`` +- ``SQLQueryFetcher/first(decoding:with:)->EventLoopFuture`` +- ``SQLQueryFetcher/first(decodingColumn:as:)->EventLoopFuture`` diff --git a/Sources/SQLKit/Docs.docc/theme-settings.json b/Sources/SQLKit/Docs.docc/theme-settings.json index 4dce41b4..67e6b5da 100644 --- a/Sources/SQLKit/Docs.docc/theme-settings.json +++ b/Sources/SQLKit/Docs.docc/theme-settings.json @@ -1,6 +1,6 @@ { "theme": { - "aside": { "border-radius": "16px", "border-style": "double", "border-width": "3px" }, + "aside": { "border-radius": "16px", "border-width": "3px", "border-style": "double" }, "border-radius": "0", "button": { "border-radius": "16px", "border-width": "1px", "border-style": "solid" }, "code": { "border-radius": "16px", "border-width": "1px", "border-style": "solid" }, From 7ba574061279313779902afbe77ad828f0f48704 Mon Sep 17 00:00:00 2001 From: Gwynne Raskind Date: Mon, 16 Mar 2026 10:28:17 -0500 Subject: [PATCH 2/3] Enable specifying an INSERT's conflict resolution strategy as a generic SQLExpression --- .../SQLConflictResolutionStrategy.swift | 2 +- .../Expressions/Queries/SQLInsert.swift | 22 +++++-- Tests/SQLKitTests/SQLInsertUpsertTests.swift | 60 +++++++++++++++++++ 3 files changed, 78 insertions(+), 6 deletions(-) diff --git a/Sources/SQLKit/Expressions/Clauses/SQLConflictResolutionStrategy.swift b/Sources/SQLKit/Expressions/Clauses/SQLConflictResolutionStrategy.swift index efeb35ab..5c8f85ef 100644 --- a/Sources/SQLKit/Expressions/Clauses/SQLConflictResolutionStrategy.swift +++ b/Sources/SQLKit/Expressions/Clauses/SQLConflictResolutionStrategy.swift @@ -88,7 +88,7 @@ public struct SQLConflictResolutionStrategy: SQLExpression { $0.append(SQLGroupExpression(self.targetColumns)) } $0.append("DO UPDATE SET", SQLList(assignments)) - if let predicate = predicate { $0.append("WHERE", predicate) } + if let predicate { $0.append("WHERE", predicate) } case (.mysqlLike, .noAction): break case (.mysqlLike, .update(let assignments, _)): diff --git a/Sources/SQLKit/Expressions/Queries/SQLInsert.swift b/Sources/SQLKit/Expressions/Queries/SQLInsert.swift index 220f6ab9..7ae05044 100644 --- a/Sources/SQLKit/Expressions/Queries/SQLInsert.swift +++ b/Sources/SQLKit/Expressions/Queries/SQLInsert.swift @@ -52,10 +52,21 @@ public struct SQLInsert: SQLExpression { /// is generated. public var valueQuery: (any SQLExpression)? = nil - /// If not `nil`, a strategy for resolving conflicts created by violations of applicable constraints. + /// The actual conflict resolution strategy serialized by this query. This property is provided to enable easier use + /// of database-specific conflict resolution syntax, as changing ``conflictStrategy`` to have the more generic type + /// would be API-breaking. + public var genericConflictStrategy: (any SQLExpression)? = nil + + /// If not `nil`, a database-agnostic strategy for resolving conflicts created by violations of applicable constraints. + /// + /// If ``genericConflictStrategy`` is set to an expression which is not castable to ``SQLConflictResolutionStrategy``, + /// this property will be `nil`. Setting this property unconditionally overwrites ``genericConflictStrategy``. /// - /// See ``SQLConflictResolutionStrategy``. - public var conflictStrategy: SQLConflictResolutionStrategy? = nil + /// See ``SQLConflictResolutionStrategy`` and ``genericConflictStrategy``. + public var conflictStrategy: SQLConflictResolutionStrategy? { + get { self.genericConflictStrategy as? SQLConflictResolutionStrategy } + set { self.genericConflictStrategy = newValue } + } /// An optional ``SQLReturning`` clause specifying data to return from the inserted rows. /// @@ -75,7 +86,7 @@ public struct SQLInsert: SQLExpression { serializer.statement { $0.append(self.tableExpressionGroup) $0.append("INSERT") - $0.append(self.conflictStrategy?.queryModifier(for: $0)) + $0.append(self.conflictStrategy?.queryModifier(for: $0)) // will be `nil` if genericConflictStrategy is in use $0.append("INTO", self.table) $0.append(SQLGroupExpression(self.columns)) if !self.values.isEmpty { @@ -83,8 +94,9 @@ public struct SQLInsert: SQLExpression { } else if let subquery = self.valueQuery { $0.append(subquery) } - $0.append(self.conflictStrategy) + $0.append(self.genericConflictStrategy) $0.append(self.returning) } } } + diff --git a/Tests/SQLKitTests/SQLInsertUpsertTests.swift b/Tests/SQLKitTests/SQLInsertUpsertTests.swift index 4935c0f6..6a1f3d69 100644 --- a/Tests/SQLKitTests/SQLInsertUpsertTests.swift +++ b/Tests/SQLKitTests/SQLInsertUpsertTests.swift @@ -204,4 +204,64 @@ final class SQLInsertUpsertTests: XCTestCase { 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" ) } + + func testGenericNonstandardUpsert() { + 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)] } + + db._dialect.upsertSyntax = .standard + + XCTAssertSerialization( + of: self.db.insert(into: "jumpgates") + .columns(cols).values(vals("Vorlon pinching")) + .ignoringConflicts(withThing: SQLIdentifier("serial_number")), + is: "INSERT INTO ``jumpgates`` (``id``, ``serial_number``, ``star_id``, ``last_known_status``) VALUES (DEFALLT, &1, &2, &3) ON CONFLICT ON CONSTRAINT ``serial_number`` DO NOTHING" + ) + XCTAssertSerialization( + of: self.db.insert(into: "jumpgates") + .columns(cols).values(vals("slashfic writing")) + .onConflict(withThing: SQLIdentifier("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 ON CONSTRAINT ``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(withThing: SQLIdentifier("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 ON CONSTRAINT ``id`` DO UPDATE SET ``last_known_status`` = &4 WHERE ``last_known_status`` <> &5" + ) + } +} + +extension SQLInsertBuilder { + struct SQLCustomConflictResolutionStrategy: SQLExpression { + var thing: any SQLExpression, action: SQLConflictAction + func serialize(to serializer: inout SQLSerializer) { + serializer.statement { + $0.append("ON CONFLICT ON CONSTRAINT", self.thing) + switch self.action { + case .noAction: $0.append("DO NOTHING") + case .update(let assignments, let predicate): + $0.append("DO UPDATE SET", SQLList(assignments)) + if let predicate { $0.append("WHERE", predicate) } + } + } + } + } + @discardableResult + func ignoringConflicts(withThing thing: any SQLExpression) -> Self { + self.insert.genericConflictStrategy = SQLCustomConflictResolutionStrategy(thing: thing, action: .noAction) + return self + } + @discardableResult + func onConflict(withThing thing: any SQLExpression, `do` updatePredicate: (SQLConflictUpdateBuilder) throws -> SQLConflictUpdateBuilder) rethrows -> Self { + let conflictBuilder = SQLConflictUpdateBuilder() + _ = try updatePredicate(conflictBuilder) + self.insert.genericConflictStrategy = SQLCustomConflictResolutionStrategy(thing: thing, action: .update(assignments: conflictBuilder.values, predicate: conflictBuilder.predicate)) + return self + } } From 951845fe8e9393aa0d6aa494a35149fbc1ca9a46 Mon Sep 17 00:00:00 2001 From: Gwynne Raskind Date: Mon, 16 Mar 2026 11:15:03 -0500 Subject: [PATCH 3/3] Make SQLConflictUpdateBuilder's initializer public so it can be reused. --- .../Builders/Implementations/SQLConflictUpdateBuilder.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/SQLKit/Builders/Implementations/SQLConflictUpdateBuilder.swift b/Sources/SQLKit/Builders/Implementations/SQLConflictUpdateBuilder.swift index 3ed7dada..e1924bb6 100644 --- a/Sources/SQLKit/Builders/Implementations/SQLConflictUpdateBuilder.swift +++ b/Sources/SQLKit/Builders/Implementations/SQLConflictUpdateBuilder.swift @@ -8,8 +8,8 @@ public final class SQLConflictUpdateBuilder: SQLColumnUpdateBuilder, SQLPredicat public var predicate: (any SQLExpression)? = nil /// Create a conflict update builder. - @usableFromInline - init() {} + @inlinable + public 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.