Skip to content

Commit 7b01c9b

Browse files
committed
Let the user choose between the two possible update strategies for upsert
1 parent 7f9d150 commit 7b01c9b

9 files changed

Lines changed: 717 additions & 187 deletions

GRDB/QueryInterface/Request/QueryInterfaceRequest.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1872,6 +1872,18 @@ public struct ColumnAssignment {
18721872
}
18731873
}
18741874

1875+
/// A strategy for updating database rows in case of upsert conflict.
1876+
public struct UpsertUpdateStrategy: OptionSet, Sendable {
1877+
public let rawValue: Int
1878+
public init(rawValue: Int) { self.rawValue = rawValue }
1879+
1880+
/// Only the specified columns are updated in the existing row.
1881+
public static let noColumnUnlessSpecified = Self([])
1882+
1883+
/// All columns are updated in the existing row, unless specified otherwise.
1884+
public static let allColumns = Self(rawValue: 1 << 0)
1885+
}
1886+
18751887
extension ColumnExpression {
18761888
/// Returns an assignment of this column to an SQL expression.
18771889
///

GRDB/Record/MutablePersistableRecord+DAO.swift

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ final class DAO<Record: MutablePersistableRecord> {
4242
func upsertStatement(
4343
_ db: Database,
4444
onConflict conflictTargetColumns: [String],
45+
updating strategy: UpsertUpdateStrategy,
4546
doUpdate assignments: ((_ excluded: TableAlias<Record>) -> [ColumnAssignment])?,
4647
updateCondition: ((
4748
_ existing: TableAlias<Record>,
@@ -78,20 +79,22 @@ final class DAO<Record: MutablePersistableRecord> {
7879
sql += " DO UPDATE SET "
7980
let excluded = TableAlias<Record>(name: "excluded")
8081
var assignments = assignments?(excluded) ?? []
81-
let lowercaseExcludedColumns = Set(primaryKey.columns.map { $0.lowercased() })
82-
.union(conflictTargetColumns.map { $0.lowercased() })
83-
for column in persistenceContainer.columns {
84-
let lowercasedColumn = column.lowercased()
85-
if lowercaseExcludedColumns.contains(lowercasedColumn) {
86-
// excluded (primary key or conflict target)
87-
continue
82+
if strategy.contains(.allColumns) {
83+
let lowercaseExcludedColumns = Set(primaryKey.columns.map { $0.lowercased() })
84+
.union(conflictTargetColumns.map { $0.lowercased() })
85+
for column in persistenceContainer.columns {
86+
let lowercasedColumn = column.lowercased()
87+
if lowercaseExcludedColumns.contains(lowercasedColumn) {
88+
// excluded (primary key or conflict target)
89+
continue
90+
}
91+
if assignments.contains(where: { $0.columnName.lowercased() == lowercasedColumn }) {
92+
// already updated from the `assignments` argument
93+
continue
94+
}
95+
// overwrite
96+
assignments.append(Column(column).set(to: excluded[column]))
8897
}
89-
if assignments.contains(where: { $0.columnName.lowercased() == lowercasedColumn }) {
90-
// already updated from the `assignments` argument
91-
continue
92-
}
93-
// overwrite
94-
assignments.append(Column(column).set(to: excluded[column]))
9598
}
9699
let context = SQLGenerationContext(db)
97100
let updateSQL = try assignments

GRDB/Record/MutablePersistableRecord+Upsert.swift

Lines changed: 100 additions & 81 deletions
Large diffs are not rendered by default.

GRDB/Record/MutablePersistableRecord.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,9 @@
6262
/// - ``insertAndFetch(_:onConflict:as:)``
6363
/// - ``insertAndFetch(_:onConflict:selection:fetch:)``
6464
/// - ``insertAndFetch(_:onConflict:fetch:select:)``
65-
/// - ``upsertAndFetch(_:onConflict:doUpdate:)``
66-
/// - ``upsertAndFetch(_:as:onConflict:doUpdate:)``
65+
/// - ``upsertAndFetch(_:onConflict:updating:doUpdate:)``
66+
/// - ``upsertAndFetch(_:as:onConflict:updating:doUpdate:)``
67+
/// - ``UpsertUpdateStrategy``
6768
///
6869
/// ### Updating a Record
6970
///

GRDB/Record/PersistableRecord+Upsert.swift

Lines changed: 97 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -73,82 +73,89 @@ extension PersistableRecord {
7373
/// let upsertedPlayer = try player.upsertAndFetch(db)
7474
/// ```
7575
///
76-
/// With `conflictTarget` and `assignments` arguments, you can further
77-
/// control the upsert behavior. Make sure you check
76+
/// With the `conflictTarget`, `strategy`, and `assignments` arguments,
77+
/// you can further control the upsert behavior. Make sure you check
7878
/// <https://www.sqlite.org/lang_UPSERT.html> for detailed information.
7979
///
8080
/// The conflict target are the columns of the uniqueness constraint
8181
/// (primary key or unique index) that triggers the upsert. If empty, all
8282
/// uniqueness constraint are considered.
8383
///
84-
/// The assignments describe how to update columns in case of violation of
85-
/// a uniqueness constraint. In the next example, we insert the new
86-
/// vocabulary word "jovial" if that word is not already in the dictionary.
87-
/// If the word is already in the dictionary, it increments the counter,
88-
/// does not overwrite the tainted flag, and overwrites the
89-
/// remaining columns:
84+
/// The strategy controls which columns are updated in case of
85+
/// uniqueness constraint violation: all columns unless specified (the
86+
/// default), or only the specified columns.
87+
///
88+
/// For example, compare:
9089
///
9190
/// ```swift
92-
/// // CREATE TABLE vocabulary(
93-
/// // word TEXT PRIMARY KEY,
94-
/// // kind TEXT NOT NULL,
95-
/// // isTainted BOOLEAN DEFAULT 0,
96-
/// // count INT DEFAULT 1))
97-
/// struct Vocabulary: Encodable, PersistableRecord {
98-
/// var word: String
99-
/// var kind: String
100-
/// var isTainted: Bool
91+
/// // In case of conflict, updates all columns, including columns added
92+
/// // in a future migration.
93+
/// let upserted = try player.upsertAndFetch(db)
94+
///
95+
/// // In case of conflict, updates all columns, including future ones,
96+
/// // but 'score'.
97+
/// let upserted = try player.upsertAndFetch(db) { _ in
98+
/// [Column("score").noOverwrite]
10199
/// }
102100
///
103-
/// // INSERT INTO vocabulary(word, kind, isTainted)
104-
/// // VALUES('jovial', 'adjective', 0)
105-
/// // ON CONFLICT(word) DO UPDATE SET \
106-
/// // count = count + 1,
107-
/// // kind = excluded.kind
108-
/// // RETURNING *
109-
/// let vocabulary = Vocabulary(word: "jovial", kind: "adjective", isTainted: false)
110-
/// let upserted = try vocabulary.upsertAndFetch(
111-
/// db,
112-
/// onConflict: ["word"],
113-
/// doUpdate: { _ in
114-
/// [Column("count") += 1,
115-
/// Column("isTainted").noOverwrite]
116-
/// })
101+
/// // In case of conflict, do not update any column.
102+
/// let upserted = try player.upsertAndFetch(db, updating: .noColumnUnlessSpecified)
103+
///
104+
/// // In case of conflict, only update 'name'.
105+
/// let upserted = try player.upsertAndFetch(db, updating: .noColumnUnlessSpecified) { excluded in
106+
/// [Column("name").set(to: excluded[Column("name")])]
107+
/// }
117108
/// ```
118109
///
119110
/// - parameter db: A database connection.
120111
/// - parameter conflictTarget: The conflict target.
112+
/// - parameter strategy: The default strategy, `.allColumns`, updates
113+
/// all columns in case of conflict, unless specified otherwise in the
114+
/// `assignments` parameter. Use `.noColumnUnlessSpecified` to only
115+
/// update the columns assigned in the `assignments` parameter.
121116
/// - parameter assignments: An optional function that returns an array of
122117
/// ``ColumnAssignment``. In case of violation of a uniqueness
123-
/// constraints, these assignments are performed, and remaining columns
124-
/// are overwritten by inserted values.
118+
/// constraint, these assignments are performed, and remaining columns
119+
/// are overwritten by inserted values, or not, depending on the
120+
/// chosen `strategy`. To use the value that would have been inserted
121+
/// had the constraint not failed, use the `excluded` parameter.
125122
/// - returns: The upserted record.
126123
/// - throws: A ``DatabaseError`` whenever an SQLite error occurs, or any
127124
/// error thrown by the persistence callbacks defined by the record type.
128125
@inlinable // allow specialization so that empty callbacks are removed
129126
public func upsertAndFetch(
130127
_ db: Database,
131128
onConflict conflictTarget: [String] = [],
129+
updating strategy: UpsertUpdateStrategy = .allColumns,
132130
doUpdate assignments: ((_ excluded: TableAlias<Self>) -> [ColumnAssignment])? = nil)
133131
throws -> Self
134132
where Self: FetchableRecord
135133
{
136-
try upsertAndFetch(db, as: Self.self, onConflict: conflictTarget, doUpdate: assignments)
134+
try upsertAndFetch(
135+
db, as: Self.self, onConflict: conflictTarget,
136+
updating: strategy, doUpdate: assignments)
137137
}
138138

139139
/// Executes an `INSERT ON CONFLICT DO UPDATE RETURNING` statement, and
140140
/// returns the upserted record.
141141
///
142-
/// See `upsertAndFetch(_:onConflict:doUpdate:)` for more information about
143-
/// the `conflictTarget` and `assignments` parameters.
142+
/// See ``upsertAndFetch(_:onConflict:updating:doUpdate:)`` for more
143+
/// information about the `conflictTarget`, `strategy`, and
144+
/// `assignments` parameters.
144145
///
145146
/// - parameter db: A database connection.
146147
/// - parameter returnedType: The type of the returned record.
147148
/// - parameter conflictTarget: The conflict target.
149+
/// - parameter strategy: The default strategy, `.allColumns`, updates
150+
/// all columns in case of conflict, unless specified otherwise in the
151+
/// `assignments` parameter. Use `.noColumnUnlessSpecified` to only
152+
/// update the columns assigned in the `assignments` parameter.
148153
/// - parameter assignments: An optional function that returns an array of
149154
/// ``ColumnAssignment``. In case of violation of a uniqueness
150-
/// constraints, these assignments are performed, and remaining columns
151-
/// are overwritten by inserted values.
155+
/// constraint, these assignments are performed, and remaining columns
156+
/// are overwritten by inserted values, or not, depending on the
157+
/// chosen `strategy`. To use the value that would have been inserted
158+
/// had the constraint not failed, use the `excluded` parameter.
152159
/// - returns: A record of type `returnedType`.
153160
/// - throws: A ``DatabaseError`` whenever an SQLite error occurs, or any
154161
/// error thrown by the persistence callbacks defined by the record type.
@@ -157,6 +164,7 @@ extension PersistableRecord {
157164
_ db: Database,
158165
as returnedType: T.Type,
159166
onConflict conflictTarget: [String] = [],
167+
updating strategy: UpsertUpdateStrategy = .allColumns,
160168
doUpdate assignments: ((_ excluded: TableAlias<Self>) -> [ColumnAssignment])? = nil)
161169
throws -> T
162170
{
@@ -166,7 +174,7 @@ extension PersistableRecord {
166174
try aroundSave(db) {
167175
success = try upsertAndFetchWithCallbacks(
168176
db, onConflict: conflictTarget,
169-
doUpdate: assignments,
177+
updating: strategy, doUpdate: assignments,
170178
selection: T.databaseSelection,
171179
decode: { try T(row: $0) })
172180
return PersistenceSuccess(success!.inserted)
@@ -250,55 +258,52 @@ extension PersistableRecord {
250258
/// let upsertedPlayer = try player.upsertAndFetch(db)
251259
/// ```
252260
///
253-
/// With `conflictTarget` and `assignments` arguments, you can further
254-
/// control the upsert behavior. Make sure you check
261+
/// With the `conflictTarget`, `strategy`, and `assignments` arguments,
262+
/// you can further control the upsert behavior. Make sure you check
255263
/// <https://www.sqlite.org/lang_UPSERT.html> for detailed information.
256264
///
257265
/// The conflict target are the columns of the uniqueness constraint
258266
/// (primary key or unique index) that triggers the upsert. If empty, all
259267
/// uniqueness constraint are considered.
260268
///
261-
/// The assignments describe how to update columns in case of violation of
262-
/// a uniqueness constraint. In the next example, we insert the new
263-
/// vocabulary word "jovial" if that word is not already in the dictionary.
264-
/// If the word is already in the dictionary, it increments the counter,
265-
/// does not overwrite the tainted flag, and overwrites the
266-
/// remaining columns:
269+
/// The strategy controls which columns are updated in case of
270+
/// uniqueness constraint violation: all columns unless specified (the
271+
/// default), or only the specified columns.
272+
///
273+
/// For example, compare:
267274
///
268275
/// ```swift
269-
/// // CREATE TABLE vocabulary(
270-
/// // word TEXT PRIMARY KEY,
271-
/// // kind TEXT NOT NULL,
272-
/// // isTainted BOOLEAN DEFAULT 0,
273-
/// // count INT DEFAULT 1))
274-
/// struct Vocabulary: Encodable, PersistableRecord {
275-
/// var word: String
276-
/// var kind: String
277-
/// var isTainted: Bool
276+
/// // In case of conflict, updates all columns, including columns added
277+
/// // in a future migration.
278+
/// let upserted = try player.upsertAndFetch(db)
279+
///
280+
/// // In case of conflict, updates all columns, including future ones,
281+
/// // but 'score'.
282+
/// let upserted = try player.upsertAndFetch(db) { _ in
283+
/// [Column("score").noOverwrite]
278284
/// }
279285
///
280-
/// // INSERT INTO vocabulary(word, kind, isTainted)
281-
/// // VALUES('jovial', 'adjective', 0)
282-
/// // ON CONFLICT(word) DO UPDATE SET \
283-
/// // count = count + 1,
284-
/// // kind = excluded.kind
285-
/// // RETURNING *
286-
/// let vocabulary = Vocabulary(word: "jovial", kind: "adjective", isTainted: false)
287-
/// let upserted = try vocabulary.upsertAndFetch(
288-
/// db,
289-
/// onConflict: ["word"],
290-
/// doUpdate: { _ in
291-
/// [Column("count") += 1,
292-
/// Column("isTainted").noOverwrite]
293-
/// })
286+
/// // In case of conflict, do not update any column.
287+
/// let upserted = try player.upsertAndFetch(db, updating: .noColumnUnlessSpecified)
288+
///
289+
/// // In case of conflict, only update 'name'.
290+
/// let upserted = try player.upsertAndFetch(db, updating: .noColumnUnlessSpecified) { excluded in
291+
/// [Column("name").set(to: excluded[Column("name")])]
292+
/// }
294293
/// ```
295294
///
296295
/// - parameter db: A database connection.
297296
/// - parameter conflictTarget: The conflict target.
297+
/// - parameter strategy: The default strategy, `.allColumns`, updates
298+
/// all columns in case of conflict, unless specified otherwise in the
299+
/// `assignments` parameter. Use `.noColumnUnlessSpecified` to only
300+
/// update the columns assigned in the `assignments` parameter.
298301
/// - parameter assignments: An optional function that returns an array of
299302
/// ``ColumnAssignment``. In case of violation of a uniqueness
300-
/// constraints, these assignments are performed, and remaining columns
301-
/// are overwritten by inserted values.
303+
/// constraint, these assignments are performed, and remaining columns
304+
/// are overwritten by inserted values, or not, depending on the
305+
/// chosen `strategy`. To use the value that would have been inserted
306+
/// had the constraint not failed, use the `excluded` parameter.
302307
/// - returns: The upserted record.
303308
/// - throws: A ``DatabaseError`` whenever an SQLite error occurs, or any
304309
/// error thrown by the persistence callbacks defined by the record type.
@@ -307,26 +312,36 @@ extension PersistableRecord {
307312
public func upsertAndFetch(
308313
_ db: Database,
309314
onConflict conflictTarget: [String] = [],
315+
updating strategy: UpsertUpdateStrategy = .allColumns,
310316
doUpdate assignments: ((_ excluded: TableAlias<Self>) -> [ColumnAssignment])? = nil)
311317
throws -> Self
312318
where Self: FetchableRecord
313319
{
314-
try upsertAndFetch(db, as: Self.self, onConflict: conflictTarget, doUpdate: assignments)
320+
try upsertAndFetch(
321+
db, as: Self.self, onConflict: conflictTarget,
322+
updating: strategy, doUpdate: assignments)
315323
}
316324

317325
/// Executes an `INSERT ON CONFLICT DO UPDATE RETURNING` statement, and
318326
/// returns the upserted record.
319327
///
320-
/// See ``upsertAndFetch(_:onConflict:doUpdate:)`` for more information
321-
/// about the `conflictTarget` and `assignments` parameters.
328+
/// See ``upsertAndFetch(_:onConflict:updating:doUpdate:)`` for more
329+
/// information about the `conflictTarget`, `strategy`, and
330+
/// `assignments` parameters.
322331
///
323332
/// - parameter db: A database connection.
324333
/// - parameter returnedType: The type of the returned record.
325334
/// - parameter conflictTarget: The conflict target.
335+
/// - parameter strategy: The default strategy, `.allColumns`, updates
336+
/// all columns in case of conflict, unless specified otherwise in the
337+
/// `assignments` parameter. Use `.noColumnUnlessSpecified` to only
338+
/// update the columns assigned in the `assignments` parameter.
326339
/// - parameter assignments: An optional function that returns an array of
327340
/// ``ColumnAssignment``. In case of violation of a uniqueness
328-
/// constraints, these assignments are performed, and remaining columns
329-
/// are overwritten by inserted values.
341+
/// constraint, these assignments are performed, and remaining columns
342+
/// are overwritten by inserted values, or not, depending on the
343+
/// chosen `strategy`. To use the value that would have been inserted
344+
/// had the constraint not failed, use the `excluded` parameter.
330345
/// - returns: A record of type `returnedType`.
331346
/// - throws: A ``DatabaseError`` whenever an SQLite error occurs, or any
332347
/// error thrown by the persistence callbacks defined by the record type.
@@ -336,6 +351,7 @@ extension PersistableRecord {
336351
_ db: Database,
337352
as returnedType: T.Type,
338353
onConflict conflictTarget: [String] = [],
354+
updating strategy: UpsertUpdateStrategy = .allColumns,
339355
doUpdate assignments: ((_ excluded: TableAlias<Self>) -> [ColumnAssignment])? = nil)
340356
throws -> T
341357
{
@@ -345,7 +361,7 @@ extension PersistableRecord {
345361
try aroundSave(db) {
346362
success = try upsertAndFetchWithCallbacks(
347363
db, onConflict: conflictTarget,
348-
doUpdate: assignments,
364+
updating: strategy, doUpdate: assignments,
349365
selection: T.databaseSelection,
350366
decode: { try T(row: $0) })
351367
return PersistenceSuccess(success!.inserted)
@@ -369,6 +385,7 @@ extension PersistableRecord {
369385
{
370386
let (inserted, _) = try upsertAndFetchWithCallbacks(
371387
db, onConflict: [],
388+
updating: .allColumns,
372389
doUpdate: nil,
373390
selection: [],
374391
decode: { _ in /* Nothing to decode */ })
@@ -379,6 +396,7 @@ extension PersistableRecord {
379396
func upsertAndFetchWithCallbacks<T>(
380397
_ db: Database,
381398
onConflict conflictTarget: [String],
399+
updating strategy: UpsertUpdateStrategy,
382400
doUpdate assignments: ((_ excluded: TableAlias<Self>) -> [ColumnAssignment])?,
383401
selection: [any SQLSelectable],
384402
decode: (Row) throws -> T)
@@ -390,7 +408,7 @@ extension PersistableRecord {
390408
try aroundInsert(db) {
391409
success = try upsertAndFetchWithoutCallbacks(
392410
db, onConflict: conflictTarget,
393-
doUpdate: assignments,
411+
updating: strategy, doUpdate: assignments,
394412
selection: selection,
395413
decode: decode)
396414
return success!.inserted

0 commit comments

Comments
 (0)