Skip to content

Commit d70032c

Browse files
committed
Merge branch 'development'
2 parents 6bf346c + 14321e4 commit d70032c

16 files changed

+1204
-221
lines changed

CHANGELOG.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ GRDB adheres to [Semantic Versioning](https://semver.org/), with one exception:
77

88
#### 6.x Releases
99

10-
- `6.0.0` Betas - [6.0.0-beta](#600-beta)
10+
- `6.0.0` Betas - [6.0.0-beta](#600-beta) | [6.0.0-beta.2](#600-beta2)
1111

1212
#### 5.x Releases
1313

@@ -97,6 +97,13 @@ GRDB adheres to [Semantic Versioning](https://semver.org/), with one exception:
9797

9898
---
9999

100+
## 6.0.0-beta.2
101+
102+
Released August 23, 2022 • [diff](https://github.com/groue/GRDB.swift/compare/v6.0.0-beta...v6.0.0-beta.2)
103+
104+
- **New**: Extended UPSERT apis with the ability to define the conflict target, and the assignments performed in case of conflicts.
105+
- **Documentation**: A new [Upsert](README.md#upsert) chapter describes upserts in detail.
106+
100107
## 6.0.0-beta
101108

102109
Released August 21, 2022 • [diff](https://github.com/groue/GRDB.swift/compare/v5.26.0...v6.0.0-beta)

Documentation/FullTextSearch.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -304,7 +304,7 @@ let pattern = FTS3Pattern(matchingAnyTokenIn: "") // nil
304304
let pattern = FTS3Pattern(matchingAnyTokenIn: "*") // nil
305305
```
306306

307-
FTS3Pattern are regular [values](../README.md#values). You can use them as query [arguments](http://groue.github.io/GRDB.swift/docs/6.0.0-beta/Structs/StatementArguments.html):
307+
FTS3Pattern are regular [values](../README.md#values). You can use them as query [arguments](http://groue.github.io/GRDB.swift/docs/6.0.0-beta.2/Structs/StatementArguments.html):
308308

309309
```swift
310310
let documents = try Document.fetchAll(db,
@@ -587,7 +587,7 @@ let pattern = FTS5Pattern(matchingAnyTokenIn: "") // nil
587587
let pattern = FTS5Pattern(matchingAnyTokenIn: "*") // nil
588588
```
589589

590-
FTS5Pattern are regular [values](../README.md#values). You can use them as query [arguments](http://groue.github.io/GRDB.swift/docs/6.0.0-beta/Structs/StatementArguments.html):
590+
FTS5Pattern are regular [values](../README.md#values). You can use them as query [arguments](http://groue.github.io/GRDB.swift/docs/6.0.0-beta.2/Structs/StatementArguments.html):
591591

592592
```swift
593593
let documents = try Document.fetchAll(db,

Documentation/Migrations.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ try dbQueue.read { db in
7777
}
7878
```
7979

80-
See the [DatabaseMigrator reference](http://groue.github.io/GRDB.swift/docs/6.0.0-beta/Structs/DatabaseMigrator.html) for more migrator methods.
80+
See the [DatabaseMigrator reference](http://groue.github.io/GRDB.swift/docs/6.0.0-beta.2/Structs/DatabaseMigrator.html) for more migrator methods.
8181

8282

8383
## The `eraseDatabaseOnSchemaChange` Option

GRDB.swift.podspec

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
Pod::Spec.new do |s|
22
s.name = 'GRDB.swift'
3-
s.version = '6.0.0-beta'
3+
s.version = '6.0.0-beta.2'
44

55
s.license = { :type => 'MIT', :file => 'LICENSE' }
66
s.summary = 'A toolkit for SQLite databases, with a focus on application development.'

GRDB/Core/Database+Schema.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1045,7 +1045,7 @@ public struct PrimaryKeyInfo {
10451045
///
10461046
/// Returns nil for WITHOUT ROWID tables with a multi-columns primary key
10471047
var fastPrimaryKeyColumn: String? {
1048-
if let rowIDColumn = rowIDColumn {
1048+
if let rowIDColumn {
10491049
// Prefer the user-provided name of the rowid
10501050
//
10511051
// // CREATE TABLE player (id INTEGER PRIMARY KEY, ...)

GRDB/QueryInterface/Request/QueryInterfaceRequest.swift

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1017,10 +1017,19 @@ extension QueryInterfaceRequest {
10171017
/// }
10181018
public struct ColumnAssignment {
10191019
var columnName: String
1020-
var value: SQLExpression
10211020

1022-
func sql(_ context: SQLGenerationContext) throws -> String {
1023-
try Column(columnName).sqlExpression.sql(context) + " = " + value.sql(context)
1021+
/// If nil, this is a "don't assign" assignment.
1022+
var value: SQLExpression?
1023+
1024+
init(columnName: String, value: SQLExpression? = nil) {
1025+
self.columnName = columnName
1026+
self.value = value
1027+
}
1028+
1029+
/// If nil, there's nothing to assign to.
1030+
func sql(_ context: SQLGenerationContext) throws -> String? {
1031+
guard let value else { return nil }
1032+
return try Column(columnName).sqlExpression.sql(context) + " = " + value.sql(context)
10241033
}
10251034
}
10261035

@@ -1039,6 +1048,11 @@ extension ColumnExpression {
10391048
public func set(to value: (any SQLExpressible)?) -> ColumnAssignment {
10401049
ColumnAssignment(columnName: name, value: value?.sqlExpression ?? .null)
10411050
}
1051+
1052+
/// Returns an assignment that does not modify this column.
1053+
public var noOverwrite: ColumnAssignment {
1054+
ColumnAssignment(columnName: name, value: nil)
1055+
}
10421056
}
10431057

10441058
/// Creates an assignment that adds a value

GRDB/QueryInterface/SQLGeneration/SQLQueryGenerator.swift

Lines changed: 12 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -288,12 +288,6 @@ struct SQLQueryGenerator: Refinable {
288288
selection: selection)
289289
}
290290

291-
// Check for empty assignments after all programmer errors have
292-
// been checked.
293-
if assignments.isEmpty {
294-
return nil
295-
}
296-
297291
let context = SQLGenerationContext(db, aliases: relation.allAliases, ctes: relation.ctes)
298292

299293
var sql = try commonTableExpressionsPrefix(context)
@@ -305,10 +299,13 @@ struct SQLQueryGenerator: Refinable {
305299

306300
sql += try relation.source.sql(context)
307301

308-
sql += " SET "
309-
sql += try assignments
310-
.map { try $0.sql(context) }
302+
let updateSQL = try assignments
303+
.compactMap { try $0.sql(context) }
311304
.joined(separator: ", ")
305+
if updateSQL.isEmpty {
306+
return nil
307+
}
308+
sql += " SET \(updateSQL)"
312309

313310
if let filter = try relation.filterPromise?.resolve(db) {
314311
sql += " WHERE "
@@ -351,12 +348,6 @@ struct SQLQueryGenerator: Refinable {
351348
selection: [any SQLSelectable])
352349
throws -> Statement?
353350
{
354-
// Check for empty assignments after all programmer errors have
355-
// been checked.
356-
if assignments.isEmpty {
357-
return nil
358-
}
359-
360351
let tableName = relation.source.tableName
361352
let alias = TableAlias(tableName: tableName)
362353
let context = SQLGenerationContext(db, aliases: [alias])
@@ -374,10 +365,13 @@ struct SQLQueryGenerator: Refinable {
374365
sql += tableName.quotedDatabaseIdentifier
375366

376367
// SET column = value...
377-
sql += " SET "
378-
sql += try assignments
379-
.map { try $0.sql(context) }
368+
let updateSQL = try assignments
369+
.compactMap { try $0.sql(context) }
380370
.joined(separator: ", ")
371+
if updateSQL.isEmpty {
372+
return nil
373+
}
374+
sql += " SET \(updateSQL)"
381375

382376
// WHERE id IN (SELECT id FROM ...)
383377
sql += " WHERE "

GRDB/Record/MutablePersistableRecord+DAO.swift

Lines changed: 77 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -41,34 +41,93 @@ final class DAO<Record: MutablePersistableRecord> {
4141

4242
func upsertStatement(
4343
_ db: Database,
44-
conflictTarget conflictTargetColumns: [String],
44+
onConflict conflictTargetColumns: [String],
45+
doUpdate assignments: ((_ excluded: TableAlias) -> [ColumnAssignment])?,
46+
updateCondition: ((_ existing: TableAlias, _ excluded: TableAlias) -> any SQLExpressible)? = nil,
4547
returning selection: [any SQLSelectable])
4648
throws -> Statement
4749
{
48-
// Don't update columns not present in the persistenceContainer
49-
// Don't update columns not present in conflictTargetColumns
50-
// Don't update primary key columns
51-
let lowercaseUpdatedColumns = Set(persistenceContainer.columns.map { $0.lowercased() })
52-
.subtracting(primaryKey.columns.map { $0.lowercased() })
53-
.subtracting(conflictTargetColumns.map { $0.lowercased() })
54-
55-
var updatedColumns: [String] = []
50+
// INSERT
51+
let insertedColumns = persistenceContainer.columns
52+
let columnsSQL = insertedColumns.map(\.quotedDatabaseIdentifier).joined(separator: ", ")
53+
let valuesSQL = databaseQuestionMarks(count: insertedColumns.count)
54+
var sql = """
55+
INSERT INTO \(databaseTableName.quotedDatabaseIdentifier) (\(columnsSQL)) \
56+
VALUES (\(valuesSQL))
57+
"""
5658
var arguments = StatementArguments(persistenceContainer.values)
59+
60+
// ON CONFLICT
61+
if conflictTargetColumns.isEmpty {
62+
sql += " ON CONFLICT"
63+
} else {
64+
let targetSQL = conflictTargetColumns
65+
.map { $0.quotedDatabaseIdentifier }
66+
.joined(separator: ", ")
67+
sql += " ON CONFLICT(\(targetSQL))"
68+
}
69+
70+
// DO UPDATE SET
71+
// We update explicit assignments from the `assignments` parameter.
72+
// Other columns are overwritten by inserted values. This makes sure
73+
// that no information stored in the record is lost, unless explicitly
74+
// requested by the user.
75+
sql += " DO UPDATE SET "
76+
let excluded = TableAlias(name: "excluded")
77+
var assignments = assignments?(excluded) ?? []
78+
let lowercaseExcludedColumns = Set(primaryKey.columns.map { $0.lowercased() })
79+
.union(conflictTargetColumns.map { $0.lowercased() })
5780
for column in persistenceContainer.columns {
58-
if lowercaseUpdatedColumns.contains(column.lowercased()) {
59-
updatedColumns.append(column)
60-
arguments += [persistenceContainer.databaseValue(at: column)]
81+
let lowercasedColumn = column.lowercased()
82+
if lowercaseExcludedColumns.contains(lowercasedColumn) {
83+
// excluded (primary key or conflict target)
84+
continue
85+
}
86+
if assignments.contains(where: { $0.columnName.lowercased() == lowercasedColumn }) {
87+
// already updated from the `assignments` argument
88+
continue
6189
}
90+
// overwrite
91+
assignments.append(Column(column).set(to: excluded[column]))
92+
}
93+
let context = SQLGenerationContext(db)
94+
let updateSQL = try assignments
95+
.compactMap { try $0.sql(context) }
96+
.joined(separator: ", ")
97+
if updateSQL.isEmpty {
98+
if !selection.isEmpty {
99+
// User has asked that no column was overwritten or updated.
100+
// In case of conflict, the upsert would do nothing, and return
101+
// nothing: <https://sqlite.org/forum/forumpost/1ead75e2c45de9a5>.
102+
//
103+
// But we have a RETURNING clause, so we WANT values to be
104+
// returned, and we MUST prevent the upsert statement from
105+
// return nothing. The RETURNING clause is how, for example, we
106+
// fetch the rowid of the upserted record, and feed record
107+
// callbacks such as `didInsert`. Not returning any value would
108+
// be a GRDB bug.
109+
//
110+
// So let's make SURE something is returned, and to do so, let's
111+
// update one column. The first column of the primary key should
112+
// be ok.
113+
let column = primaryKey.columns[0].quotedDatabaseIdentifier
114+
sql += "\(column) = \(column)"
115+
}
116+
} else {
117+
sql += updateSQL
118+
arguments += context.arguments
62119
}
63120

64-
let query = UpsertQuery(
65-
tableName: databaseTableName,
66-
insertedColumns: persistenceContainer.columns,
67-
conflictTargetColumns: conflictTargetColumns,
68-
updatedColumns: updatedColumns)
121+
// WHERE
122+
let existing = TableAlias(name: databaseTableName)
123+
if let condition = updateCondition?(existing, excluded) {
124+
let context = SQLGenerationContext(db)
125+
sql += try " WHERE " + condition.sqlExpression.sql(context)
126+
arguments += context.arguments
127+
}
69128

70129
return try makeStatement(
71-
sql: query.sql,
130+
sql: sql,
72131
checkedArguments: arguments,
73132
returning: selection)
74133
}
@@ -240,47 +299,6 @@ extension InsertQuery {
240299
}
241300
}
242301

243-
// MARK: - UpsertQuery
244-
245-
private struct UpsertQuery: Hashable {
246-
let tableName: String
247-
let insertedColumns: [String]
248-
let conflictTargetColumns: [String]
249-
let updatedColumns: [String]
250-
}
251-
252-
extension UpsertQuery {
253-
@ReadWriteBox private static var sqlCache: [UpsertQuery: String] = [:]
254-
var sql: String {
255-
if let sql = Self.sqlCache[self] {
256-
return sql
257-
}
258-
259-
let columnsSQL = insertedColumns.map(\.quotedDatabaseIdentifier).joined(separator: ", ")
260-
let valuesSQL = databaseQuestionMarks(count: insertedColumns.count)
261-
262-
let onConflictSQL: String
263-
if conflictTargetColumns.isEmpty {
264-
onConflictSQL = "ON CONFLICT"
265-
} else {
266-
let targetSQL = conflictTargetColumns
267-
.map { $0.quotedDatabaseIdentifier }
268-
.joined(separator: ", ")
269-
onConflictSQL = "ON CONFLICT(\(targetSQL))"
270-
}
271-
272-
let updateSQL = updatedColumns.map { "\($0.quotedDatabaseIdentifier)=?" }.joined(separator: ", ")
273-
274-
let sql = """
275-
INSERT INTO \(tableName.quotedDatabaseIdentifier) (\(columnsSQL)) \
276-
VALUES (\(valuesSQL)) \
277-
\(onConflictSQL) DO UPDATE SET \(updateSQL)
278-
"""
279-
Self.sqlCache[self] = sql
280-
return sql
281-
}
282-
}
283-
284302
// MARK: - UpdateQuery
285303

286304
private struct UpdateQuery: Hashable {

GRDB/Record/MutablePersistableRecord+Insert.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -460,7 +460,7 @@ extension MutablePersistableRecord {
460460
// This allows the Record class to set its `hasDatabaseChanges` property
461461
// to false in its `aroundInsert` callback.
462462
var persistenceContainer = dao.persistenceContainer
463-
if let rowIDColumn = rowIDColumn {
463+
if let rowIDColumn {
464464
persistenceContainer[caseInsensitive: rowIDColumn] = rowid
465465
}
466466

0 commit comments

Comments
 (0)