Skip to content

Commit cdc2fb4

Browse files
authored
Merge pull request groue#1858 from groue/dev/issue-1855
Fix upsert for WITHOUT ROWID tables
2 parents 36e30a6 + 47cb0b8 commit cdc2fb4

4 files changed

Lines changed: 318 additions & 51 deletions

File tree

GRDB/Record/MutablePersistableRecord+Upsert.swift

Lines changed: 184 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -383,13 +383,10 @@ extension MutablePersistableRecord {
383383
mutating func upsertWithCallbacks(_ db: Database)
384384
throws -> InsertionSuccess
385385
{
386-
let (inserted, _) = try upsertAndFetchWithCallbacks(
386+
try upsertWithCallbacks(
387387
db, onConflict: [],
388388
updating: .allColumns,
389-
doUpdate: nil,
390-
selection: [],
391-
decode: { _ in /* Nothing to decode */ })
392-
return inserted
389+
doUpdate: nil)
393390
}
394391

395392
@inlinable // allow specialization so that empty callbacks are removed
@@ -421,6 +418,109 @@ extension MutablePersistableRecord {
421418
return success
422419
}
423420

421+
@inlinable // allow specialization so that empty callbacks are removed
422+
mutating func upsertWithCallbacks(
423+
_ db: Database,
424+
onConflict conflictTarget: [String],
425+
updating strategy: UpsertUpdateStrategy,
426+
doUpdate assignments: ((_ excluded: TableAlias<Self>) -> [ColumnAssignment])?
427+
) throws -> InsertionSuccess {
428+
try willInsert(db)
429+
430+
var inserted: InsertionSuccess?
431+
try aroundInsert(db) {
432+
inserted = try upsertWithoutCallbacks(
433+
db, onConflict: conflictTarget,
434+
updating: strategy, doUpdate: assignments)
435+
return inserted!
436+
}
437+
438+
guard let inserted else {
439+
try persistenceCallbackMisuse("aroundInsert")
440+
}
441+
didInsert(inserted)
442+
return inserted
443+
}
444+
445+
/// Executes an `INSERT` statement, and DOES NOT run
446+
/// insertion callbacks.
447+
@usableFromInline
448+
func upsertWithoutCallbacks(
449+
_ db: Database,
450+
onConflict conflictTarget: [String],
451+
updating strategy: UpsertUpdateStrategy,
452+
doUpdate assignments: ((_ excluded: TableAlias<Self>) -> [ColumnAssignment])?)
453+
throws -> InsertionSuccess
454+
{
455+
let primaryKey = try db.primaryKey(Self.databaseTableName)
456+
457+
if primaryKey.tableHasRowID {
458+
let dao = try DAO(db, self)
459+
let statement = try dao.upsertStatement(
460+
db, onConflict: conflictTarget,
461+
updating: strategy,
462+
doUpdate: assignments,
463+
updateCondition: nil,
464+
returning: [Column.rowID])
465+
let cursor = try Row.fetchCursor(statement)
466+
467+
// Keep cursor alive until we can process the fetched row
468+
let rowid: Int64 = try withExtendedLifetime(cursor) { cursor in
469+
guard let row = try cursor.next() else {
470+
throw DatabaseError(message: "Insertion failed")
471+
}
472+
473+
let rowid: Int64 = row[0]
474+
475+
// Now that we have fetched the values we need, we could stop
476+
// there. But let's make sure we fully consume the cursor
477+
// anyway, until SQLITE_DONE. This is necessary, for example,
478+
// for upserts in tables that are synchronized with an
479+
// FTS5 table.
480+
// See <https://github.com/groue/GRDB.swift/issues/1390>
481+
while try cursor.next() != nil { }
482+
483+
return rowid
484+
}
485+
486+
// Update the persistenceContainer with the inserted rowid.
487+
// This allows the Record class to set its `hasDatabaseChanges` property
488+
// to false in its `aroundInsert` callback.
489+
var persistenceContainer = dao.persistenceContainer
490+
let rowIDColumn = dao.primaryKey.rowIDColumn
491+
if let rowIDColumn {
492+
persistenceContainer[rowIDColumn] = rowid
493+
}
494+
495+
let inserted = InsertionSuccess(
496+
rowID: rowid,
497+
rowIDColumn: rowIDColumn,
498+
persistenceContainer: persistenceContainer)
499+
return inserted
500+
} else {
501+
let dao = try DAO(db, self)
502+
let statement = try dao.upsertStatement(
503+
db, onConflict: conflictTarget,
504+
updating: strategy,
505+
doUpdate: assignments,
506+
updateCondition: nil,
507+
returning: [])
508+
let cursor = try Row.fetchCursor(statement)
509+
510+
// Let's make sure we fully consume the cursor, until SQLITE_DONE.
511+
// This is necessary, for example, for upserts in tables that
512+
// are synchronized with an FTS5 table.
513+
// See <https://github.com/groue/GRDB.swift/issues/1390>
514+
while try cursor.next() != nil { }
515+
516+
let inserted = InsertionSuccess(
517+
rowID: 0, // Meaningless in WITHOUT ROWID tables.
518+
rowIDColumn: nil,
519+
persistenceContainer: dao.persistenceContainer)
520+
return inserted
521+
}
522+
}
523+
424524
/// Executes an `INSERT RETURNING` statement, and DOES NOT run
425525
/// insertion callbacks.
426526
@usableFromInline
@@ -433,52 +533,90 @@ extension MutablePersistableRecord {
433533
decode: (Row) throws -> T)
434534
throws -> (InsertionSuccess, T)
435535
{
436-
// Append the rowID to the returned columns
437-
let selection = selection + [Column.rowID]
438-
439-
let dao = try DAO(db, self)
440-
let statement = try dao.upsertStatement(
441-
db, onConflict: conflictTarget,
442-
updating: strategy,
443-
doUpdate: assignments,
444-
updateCondition: nil,
445-
returning: selection)
446-
let cursor = try Row.fetchCursor(statement)
536+
let primaryKey = try db.primaryKey(Self.databaseTableName)
447537

448-
// Keep cursor alive until we can process the fetched row
449-
let (rowid, returned): (Int64, T) = try withExtendedLifetime(cursor) { cursor in
450-
guard let row = try cursor.next() else {
451-
throw DatabaseError(message: "Insertion failed")
538+
if primaryKey.tableHasRowID {
539+
// Append the rowID to the returned columns
540+
let selection = selection + [Column.rowID]
541+
542+
let dao = try DAO(db, self)
543+
let statement = try dao.upsertStatement(
544+
db, onConflict: conflictTarget,
545+
updating: strategy,
546+
doUpdate: assignments,
547+
updateCondition: nil,
548+
returning: selection)
549+
let cursor = try Row.fetchCursor(statement)
550+
551+
// Keep cursor alive until we can process the fetched row
552+
let (rowid, returned): (Int64, T) = try withExtendedLifetime(cursor) { cursor in
553+
guard let row = try cursor.next() else {
554+
throw DatabaseError(message: "Insertion failed")
555+
}
556+
557+
// Rowid is the last column
558+
let rowid: Int64 = row[row.count - 1]
559+
let returned = try decode(row)
560+
561+
// Now that we have fetched the values we need, we could stop
562+
// there. But let's make sure we fully consume the cursor
563+
// anyway, until SQLITE_DONE. This is necessary, for example,
564+
// for upserts in tables that are synchronized with an
565+
// FTS5 table.
566+
// See <https://github.com/groue/GRDB.swift/issues/1390>
567+
while try cursor.next() != nil { }
568+
569+
return (rowid, returned)
452570
}
453571

454-
// Rowid is the last column
455-
let rowid: Int64 = row[row.count - 1]
456-
let returned = try decode(row)
572+
// Update the persistenceContainer with the inserted rowid.
573+
// This allows the Record class to set its `hasDatabaseChanges` property
574+
// to false in its `aroundInsert` callback.
575+
var persistenceContainer = dao.persistenceContainer
576+
let rowIDColumn = dao.primaryKey.rowIDColumn
577+
if let rowIDColumn {
578+
persistenceContainer[rowIDColumn] = rowid
579+
}
457580

458-
// Now that we have fetched the values we need, we could stop
459-
// there. But let's make sure we fully consume the cursor
460-
// anyway, until SQLITE_DONE. This is necessary, for example,
461-
// for upserts in tables that are synchronized with an
462-
// FTS5 table.
463-
// See <https://github.com/groue/GRDB.swift/issues/1390>
464-
while try cursor.next() != nil { }
581+
let inserted = InsertionSuccess(
582+
rowID: rowid,
583+
rowIDColumn: rowIDColumn,
584+
persistenceContainer: persistenceContainer)
585+
return (inserted, returned)
586+
} else {
587+
let dao = try DAO(db, self)
588+
let statement = try dao.upsertStatement(
589+
db, onConflict: conflictTarget,
590+
updating: strategy,
591+
doUpdate: assignments,
592+
updateCondition: nil,
593+
returning: selection)
594+
let cursor = try Row.fetchCursor(statement)
465595

466-
return (rowid, returned)
467-
}
468-
469-
// Update the persistenceContainer with the inserted rowid.
470-
// This allows the Record class to set its `hasDatabaseChanges` property
471-
// to false in its `aroundInsert` callback.
472-
var persistenceContainer = dao.persistenceContainer
473-
let rowIDColumn = dao.primaryKey.rowIDColumn
474-
if let rowIDColumn {
475-
persistenceContainer[rowIDColumn] = rowid
596+
// Keep cursor alive until we can process the fetched row
597+
let returned: T = try withExtendedLifetime(cursor) { cursor in
598+
guard let row = try cursor.next() else {
599+
throw DatabaseError(message: "Insertion failed")
600+
}
601+
602+
let returned = try decode(row)
603+
604+
// Now that we have fetched the values we need, we could stop
605+
// there. But let's make sure we fully consume the cursor
606+
// anyway, until SQLITE_DONE. This is necessary, for example,
607+
// for upserts in tables that are synchronized with an
608+
// FTS5 table.
609+
// See <https://github.com/groue/GRDB.swift/issues/1390>
610+
while try cursor.next() != nil { }
611+
612+
return returned
613+
}
614+
615+
let inserted = InsertionSuccess(
616+
rowID: 0, // Meaningless in WITHOUT ROWID tables.
617+
rowIDColumn: nil,
618+
persistenceContainer: dao.persistenceContainer)
619+
return (inserted, returned)
476620
}
477-
478-
let inserted = InsertionSuccess(
479-
rowID: rowid,
480-
rowIDColumn: rowIDColumn,
481-
persistenceContainer: persistenceContainer)
482-
return (inserted, returned)
483621
}
484622
}

GRDB/Record/PersistableRecord+Upsert.swift

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -383,13 +383,10 @@ extension PersistableRecord {
383383
func upsertWithCallbacks(_ db: Database)
384384
throws -> InsertionSuccess
385385
{
386-
let (inserted, _) = try upsertAndFetchWithCallbacks(
386+
try upsertWithCallbacks(
387387
db, onConflict: [],
388388
updating: .allColumns,
389-
doUpdate: nil,
390-
selection: [],
391-
decode: { _ in /* Nothing to decode */ })
392-
return inserted
389+
doUpdate: nil)
393390
}
394391

395392
@inlinable // allow specialization so that empty callbacks are removed
@@ -420,4 +417,28 @@ extension PersistableRecord {
420417
didInsert(success.inserted)
421418
return success
422419
}
420+
421+
@inlinable // allow specialization so that empty callbacks are removed
422+
func upsertWithCallbacks(
423+
_ db: Database,
424+
onConflict conflictTarget: [String],
425+
updating strategy: UpsertUpdateStrategy,
426+
doUpdate assignments: ((_ excluded: TableAlias<Self>) -> [ColumnAssignment])?
427+
) throws -> InsertionSuccess {
428+
try willInsert(db)
429+
430+
var inserted: InsertionSuccess?
431+
try aroundInsert(db) {
432+
inserted = try upsertWithoutCallbacks(
433+
db, onConflict: conflictTarget,
434+
updating: strategy, doUpdate: assignments)
435+
return inserted!
436+
}
437+
438+
guard let inserted else {
439+
try persistenceCallbackMisuse("aroundInsert")
440+
}
441+
didInsert(inserted)
442+
return inserted
443+
}
423444
}

Tests/GRDBTests/MutablePersistableRecordTests.swift

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2334,6 +2334,60 @@ extension MutablePersistableRecordTests {
23342334
}
23352335
}
23362336

2337+
func test_upsert_WITHOUT_ROWID_optimization() throws {
2338+
#if GRDBCUSTOMSQLITE || SQLITE_HAS_CODEC
2339+
guard Database.sqliteLibVersionNumber >= 3035000 else {
2340+
throw XCTSkip("UPSERT is not available")
2341+
}
2342+
#else
2343+
guard #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) else {
2344+
throw XCTSkip("UPSERT is not available")
2345+
}
2346+
#endif
2347+
2348+
struct MyRecord: Codable, FetchableRecord, MutablePersistableRecord {
2349+
var id: String
2350+
var name: String
2351+
var score: Int?
2352+
}
2353+
2354+
try makeDatabaseQueue().write { db in
2355+
try db.execute(sql: """
2356+
CREATE TABLE myRecord(
2357+
id TEXT NOT NULL PRIMARY KEY,
2358+
name TEXT NOT NULL UNIQUE,
2359+
score INTEGER NOT NULL
2360+
) WITHOUT ROWID;
2361+
""")
2362+
2363+
do {
2364+
var record = MyRecord(id: "1", name: "foo", score: 1000)
2365+
try record.upsert(db)
2366+
2367+
XCTAssertEqual(lastSQLQuery, """
2368+
INSERT INTO "myRecord" ("id", "name", "score") \
2369+
VALUES ('1','foo',1000) \
2370+
ON CONFLICT DO UPDATE SET "name" = "excluded"."name", "score" = "excluded"."score"
2371+
""")
2372+
}
2373+
do {
2374+
var record = MyRecord(id: "2", name: "foo", score: 2000)
2375+
let upserted = try record.upsertAndFetch(db)
2376+
2377+
XCTAssertEqual(lastSQLQuery, """
2378+
INSERT INTO "myRecord" ("id", "name", "score") \
2379+
VALUES ('2','foo',2000) \
2380+
ON CONFLICT DO UPDATE SET "name" = "excluded"."name", "score" = "excluded"."score" \
2381+
RETURNING *
2382+
""")
2383+
2384+
XCTAssertEqual(upserted.id, "1")
2385+
XCTAssertEqual(upserted.name, "foo")
2386+
XCTAssertEqual(upserted.score, 2000)
2387+
}
2388+
}
2389+
}
2390+
23372391
func test_upsertAndFetch_do_update_set_where_with_default_strategy() throws {
23382392
#if GRDBCUSTOMSQLITE || SQLITE_HAS_CODEC
23392393
guard Database.sqliteLibVersionNumber >= 3035000 else {

0 commit comments

Comments
 (0)