Skip to content

Commit a78376a

Browse files
authored
Add Postgres constraint conflict resolution (#10)
This adds a Postgres specific `ConflictResolutionStrategy` to specify conflict resolution using constraints instead of column names
1 parent c863124 commit a78376a

3 files changed

Lines changed: 230 additions & 1 deletion

File tree

Package.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ let package = Package(
2828
dependencies: [
2929
.package(url: "https://github.com/apple/swift-algorithms.git", from: "1.2.0"),
3030
.package(url: "https://github.com/vapor/fluent-kit.git", from: "1.52.0"),
31-
.package(url: "https://github.com/vapor/sql-kit.git", from: "3.33.0"),
31+
.package(url: "https://github.com/vapor/sql-kit.git", from: "3.35.0"),
3232
],
3333
targets: [
3434
.target(
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import SQLKit
2+
3+
/// A PostgreSQL `ON CONFLICT ON CONSTRAINT` clause that targets a named constraint rather than
4+
/// a column list.
5+
///
6+
/// SQLKit's built-in `SQLConflictResolutionStrategy` only supports column-targeted upserts
7+
/// (`ON CONFLICT (col1, col2) ...`). This type serializes the named-constraint form instead:
8+
///
9+
/// ```sql
10+
/// ON CONFLICT ON CONSTRAINT uq_users_email DO NOTHING
11+
/// ON CONFLICT ON CONSTRAINT uq_users_email DO UPDATE SET name = EXCLUDED.name WHERE ...
12+
/// ```
13+
///
14+
/// Use it via the `SQLInsertBuilder` extensions rather than constructing it directly.
15+
public struct PostgreSQLConflictResolutionStrategy: SQLExpression {
16+
/// The constraint name or expression used to identify the conflict target.
17+
public let constraint: any SQLExpression
18+
19+
/// The action to take when the named constraint is violated.
20+
public let action: SQLConflictAction
21+
22+
/// Creates a strategy targeting a constraint by name.
23+
@inlinable
24+
public init(constraint: String, action: SQLConflictAction) {
25+
self.init(constraint: SQLIdentifier(constraint), action: action)
26+
}
27+
28+
/// Creates a strategy targeting a constraint by an arbitrary SQL expression.
29+
@inlinable
30+
public init(constraint: any SQLExpression, action: SQLConflictAction) {
31+
self.constraint = constraint
32+
self.action = action
33+
}
34+
35+
public func serialize(to serializer: inout SQLSerializer) {
36+
serializer.statement {
37+
$0.append("ON CONFLICT ON CONSTRAINT", self.constraint)
38+
switch self.action {
39+
case .noAction:
40+
$0.append("DO NOTHING")
41+
case .update(let assignments, let predicate):
42+
$0.append("DO UPDATE SET", SQLList(assignments))
43+
if let predicate {
44+
$0.append("WHERE", predicate)
45+
}
46+
}
47+
}
48+
}
49+
}
50+
51+
extension SQLInsertBuilder {
52+
/// Adds an `ON CONFLICT ON CONSTRAINT <name> DO NOTHING` clause, ignoring rows that violate the named constraint.
53+
/// Overrides `.onConflict` and `.ignoringConflicts`.
54+
///
55+
/// ```swift
56+
/// try await db.insert(into: "users")
57+
/// .columns("id", "email")
58+
/// .values(1, "alice@example.com")
59+
/// .psql_ignoringConflicts(withConstraint: "uq_users_email")
60+
/// .run()
61+
/// // INSERT INTO "users" ("id","email") VALUES ($1,$2)
62+
/// // ON CONFLICT ON CONSTRAINT "uq_users_email" DO NOTHING
63+
/// ```
64+
@discardableResult
65+
public func psql_ignoringConflicts(withConstraint constraint: some StringProtocol) -> Self {
66+
self.psql_ignoringConflicts(withConstraint: .identifier(constraint))
67+
}
68+
69+
/// Adds an `ON CONFLICT ON CONSTRAINT <name> DO NOTHING` clause, ignoring rows that violate the named constraint.
70+
/// Overrides `.onConflict` and `.ignoringConflicts`.
71+
///
72+
/// ```swift
73+
/// try await db.insert(into: "users")
74+
/// .columns("id", "email")
75+
/// .values(1, "alice@example.com")
76+
/// .psql_ignoringConflicts(withConstraint: .identifier("uq_users_email"))
77+
/// .run()
78+
/// // INSERT INTO "users" ("id","email") VALUES ($1,$2)
79+
/// // ON CONFLICT ON CONSTRAINT "uq_users_email" DO NOTHING
80+
/// ```
81+
@discardableResult
82+
public func psql_ignoringConflicts(withConstraint constraint: any SQLExpression) -> Self {
83+
self.insert.genericConflictStrategy = PostgreSQLConflictResolutionStrategy(constraint: constraint, action: .noAction)
84+
return self
85+
}
86+
87+
/// Adds an `ON CONFLICT ON CONSTRAINT <name>` clause with the given action.
88+
/// Overrides `.onConflict` and `.ignoringConflicts`.
89+
///
90+
/// ```swift
91+
/// try await db.insert(into: "users")
92+
/// .columns("id", "email", "name")
93+
/// .values(1, "alice@example.com", "Alice")
94+
/// .psql_onConflict(withConstraint: "uq_users_email") { $0
95+
/// .set(excludedValueOf: "name")
96+
/// }
97+
/// .run()
98+
/// // INSERT INTO "users" ("id","email","name") VALUES ($1,$2,$3)
99+
/// // ON CONFLICT ON CONSTRAINT "uq_users_email" DO UPDATE SET "name" = EXCLUDED."name"
100+
/// ```
101+
@discardableResult
102+
public func psql_onConflict(
103+
withConstraint constraint: some StringProtocol,
104+
`do` updatePredicate: (SQLConflictUpdateBuilder) throws -> SQLConflictUpdateBuilder
105+
) rethrows -> Self {
106+
try self.psql_onConflict(withConstraint: .identifier(constraint), do: updatePredicate)
107+
}
108+
109+
/// Adds an `ON CONFLICT ON CONSTRAINT <name>` clause with the given action.
110+
/// Overrides `.onConflict` and `.ignoringConflicts`.
111+
///
112+
/// ```swift
113+
/// try await db.insert(into: "users")
114+
/// .columns("id", "email", "name")
115+
/// .values(1, "alice@example.com", "Alice")
116+
/// .psql_onConflict(withConstraint: .identifier("uq_users_email") { $0
117+
/// .set(excludedValueOf: "name")
118+
/// }
119+
/// .run()
120+
/// // INSERT INTO "users" ("id","email","name") VALUES ($1,$2,$3)
121+
/// // ON CONFLICT ON CONSTRAINT "uq_users_email" DO UPDATE SET "name" = EXCLUDED."name"
122+
/// ```
123+
@discardableResult
124+
public func psql_onConflict(
125+
withConstraint constraint: any SQLExpression,
126+
`do` updatePredicate: (SQLConflictUpdateBuilder) throws -> SQLConflictUpdateBuilder
127+
) rethrows -> Self {
128+
let conflictBuilder = SQLConflictUpdateBuilder()
129+
_ = try updatePredicate(conflictBuilder)
130+
self.insert.genericConflictStrategy = PostgreSQLConflictResolutionStrategy(
131+
constraint: constraint, action: .update(assignments: conflictBuilder.values, predicate: conflictBuilder.predicate)
132+
)
133+
return self
134+
}
135+
}

Tests/SQLKitExtrasTests/PostgreSQLKitExtrasTests.swift

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,100 @@ struct PostgreSQLKitExtrasTests {
4141
#expect(serialize(.psql_values([.list(.literal("foo")), .list(.literal("bar"))])) == #"VALUES ('foo'), ('bar')"#)
4242
}
4343

44+
@Test
45+
func constraintConflictResolutionStrategy() {
46+
#expect(
47+
serialize(PostgreSQLConflictResolutionStrategy(constraint: "uq_users_email", action: .noAction))
48+
== #"ON CONFLICT ON CONSTRAINT "uq_users_email" DO NOTHING"#
49+
)
50+
51+
#expect(
52+
serialize(PostgreSQLConflictResolutionStrategy(constraint: .identifier("uq_users_email"), action: .noAction))
53+
== #"ON CONFLICT ON CONSTRAINT "uq_users_email" DO NOTHING"#
54+
)
55+
56+
#expect(
57+
serialize(PostgreSQLConflictResolutionStrategy(
58+
constraint: "uq_users_email",
59+
action: .update(assignments: [SQLColumnAssignment(settingExcludedValueFor: "email")], predicate: nil)
60+
))
61+
== #"ON CONFLICT ON CONSTRAINT "uq_users_email" DO UPDATE SET "email" = EXCLUDED."email""#
62+
)
63+
64+
#expect(
65+
serialize(PostgreSQLConflictResolutionStrategy(
66+
constraint: .identifier("uq_users_email"),
67+
action: .update(assignments: [SQLColumnAssignment(settingExcludedValueFor: "email")], predicate: nil)
68+
))
69+
== #"ON CONFLICT ON CONSTRAINT "uq_users_email" DO UPDATE SET "email" = EXCLUDED."email""#
70+
)
71+
72+
#expect(
73+
serialize(PostgreSQLConflictResolutionStrategy(
74+
constraint: "uq_users_email",
75+
action: .update(
76+
assignments: [SQLColumnAssignment(settingExcludedValueFor: "email")],
77+
predicate: SQLBinaryExpression(left: SQLColumn("active"), op: SQLBinaryOperator.equal, right: SQLLiteral.boolean(true))
78+
)
79+
))
80+
== #"ON CONFLICT ON CONSTRAINT "uq_users_email" DO UPDATE SET "email" = EXCLUDED."email" WHERE "active" = true"#
81+
)
82+
83+
#expect(
84+
serialize(PostgreSQLConflictResolutionStrategy(
85+
constraint: .identifier("uq_users_email"),
86+
action: .update(
87+
assignments: [SQLColumnAssignment(settingExcludedValueFor: "email")],
88+
predicate: SQLBinaryExpression(left: SQLColumn("active"), op: SQLBinaryOperator.equal, right: SQLLiteral.boolean(true))
89+
)
90+
))
91+
== #"ON CONFLICT ON CONSTRAINT "uq_users_email" DO UPDATE SET "email" = EXCLUDED."email" WHERE "active" = true"#
92+
)
93+
94+
#expect(
95+
serialize(MockSQLDatabase()
96+
.insert(into: "users")
97+
.columns("id", "email")
98+
.values(SQLBind(1), SQLBind("alice@example.com"))
99+
.psql_ignoringConflicts(withConstraint: "uq_users_email")
100+
)
101+
== #"INSERT INTO "users" ("id", "email") VALUES ($1, $2) ON CONFLICT ON CONSTRAINT "uq_users_email" DO NOTHING"#
102+
)
103+
104+
#expect(
105+
serialize(MockSQLDatabase()
106+
.insert(into: "users")
107+
.columns("id", "email")
108+
.values(SQLBind(1), SQLBind("alice@example.com"))
109+
.psql_ignoringConflicts(withConstraint: .identifier("uq_users_email"))
110+
)
111+
== #"INSERT INTO "users" ("id", "email") VALUES ($1, $2) ON CONFLICT ON CONSTRAINT "uq_users_email" DO NOTHING"#
112+
)
113+
114+
#expect(
115+
serialize(MockSQLDatabase()
116+
.insert(into: "users")
117+
.columns("id", "email")
118+
.values(SQLBind(1), SQLBind("alice@example.com"))
119+
.psql_onConflict(withConstraint: .identifier("uq_users_email")) { $0
120+
.set(excludedValueOf: "email")
121+
}
122+
)
123+
== #"INSERT INTO "users" ("id", "email") VALUES ($1, $2) ON CONFLICT ON CONSTRAINT "uq_users_email" DO UPDATE SET "email" = EXCLUDED."email""#
124+
)
125+
126+
#expect(
127+
serialize(MockSQLDatabase()
128+
.insert(into: "users")
129+
.columns("id", "email")
130+
.values(SQLBind(1), SQLBind("alice@example.com"))
131+
.onConflict { $0.set(excludedValueOf: "email") }
132+
.psql_ignoringConflicts(withConstraint: .identifier("uq_users_email"))
133+
)
134+
== #"INSERT INTO "users" ("id", "email") VALUES ($1, $2) ON CONFLICT ON CONSTRAINT "uq_users_email" DO NOTHING"#
135+
)
136+
}
137+
44138
#if FluentSQLKitExtras
45139
@Suite("FluentPostgreSQLKit Extras")
46140
struct FluentPostgreSQLKitExtrasTests {

0 commit comments

Comments
 (0)