From ee64f8ff9dc09854c4dd222422a83bbc329ece1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gwendal=20Roue=CC=81?= Date: Wed, 4 Mar 2020 08:43:31 +0100 Subject: [PATCH 01/10] Rename statement argument methods --- GRDB/Core/Database+Statements.swift | 2 +- GRDB/Core/SQLRequest.swift | 2 +- GRDB/Core/Statement.swift | 117 +++++++++- GRDB/Record/PersistableRecord.swift | 8 +- README.md | 9 +- Tests/GRDBTests/StatementArgumentsTests.swift | 210 ++++++++++++++---- 6 files changed, 279 insertions(+), 69 deletions(-) diff --git a/GRDB/Core/Database+Statements.swift b/GRDB/Core/Database+Statements.swift index 8c6515b7c3..6384c87519 100644 --- a/GRDB/Core/Database+Statements.swift +++ b/GRDB/Core/Database+Statements.swift @@ -201,7 +201,7 @@ extension Database { // Extract statement arguments let bindings = try arguments.extractBindings(forStatement: statement, allowingRemainingValues: true) // unsafe is OK because we just extracted the correct number of arguments - statement.unsafeSetArguments(StatementArguments(bindings)) + statement.setUncheckedArguments(StatementArguments(bindings)) // Execute try statement.execute() diff --git a/GRDB/Core/SQLRequest.swift b/GRDB/Core/SQLRequest.swift index 74c76903d2..eb68926439 100644 --- a/GRDB/Core/SQLRequest.swift +++ b/GRDB/Core/SQLRequest.swift @@ -157,7 +157,7 @@ public struct SQLRequest: FetchRequest { case .internal?: statement = try db.internalCachedSelectStatement(sql: sql) } - try statement.setArgumentsWithValidation(context.arguments) + try statement.setArguments(context.arguments) return PreparedRequest(statement: statement, adapter: adapter) } } diff --git a/GRDB/Core/Statement.swift b/GRDB/Core/Statement.swift index 95a95ae433..4e30a651c5 100644 --- a/GRDB/Core/Statement.swift +++ b/GRDB/Core/Statement.swift @@ -128,19 +128,74 @@ public class Statement { set { // Force arguments validity: it is a programmer error to provide // arguments that do not match the statement. - try! setArgumentsWithValidation(newValue) + try! setArguments(newValue) } } /// Throws a DatabaseError of code SQLITE_ERROR if arguments don't fill all /// statement arguments. - public func validate(arguments: StatementArguments) throws { + /// + /// For example: + /// + /// let statement = try db.makeUpdateArgument(sql: """ + /// INSERT INTO player (id, name) VALUES (?, ?) + /// """) + /// + /// // OK + /// statement.validateArguments([1, "Arthur"]) + /// + /// // Throws + /// statement.validateArguments([1]) + /// + /// See also setArguments(_:) + public func validateArguments(_ arguments: StatementArguments) throws { var arguments = arguments _ = try arguments.extractBindings(forStatement: self, allowingRemainingValues: false) } + /// Throws a DatabaseError of code SQLITE_ERROR if arguments don't fill all + /// statement arguments. + /// + /// For example: + /// + /// let statement = try db.makeUpdateArgument(sql: """ + /// INSERT INTO player (id, name) VALUES (?, ?) + /// """) + /// + /// // OK + /// statement.validate([1, "Arthur"]) + /// + /// // Throws + /// statement.validate([1]) + /// + /// See also setArguments(_:) + @available(*, deprecated, renamed: "validateArguments(_:)") + public func validate(arguments: StatementArguments) throws { + try validateArguments(arguments) + } + /// Set arguments without any validation. Trades safety for performance. - public func unsafeSetArguments(_ arguments: StatementArguments) { + /// + /// Only call this method if you are sure input arguments match all expected + /// arguments of the statement. + /// + /// For example: + /// + /// let statement = try db.makeUpdateArgument(sql: """ + /// INSERT INTO player (id, name) VALUES (?, ?) + /// """) + /// + /// // OK + /// statement.setUncheckedArguments([1, "Arthur"]) + /// + /// // OK + /// let arguments: StatementArguments = ... // some untrusted arguments + /// try statement.validateArguments(arguments) + /// statement.setUncheckedArguments(arguments) + /// + /// // NOT OK + /// statement.setUncheckedArguments([1]) + public func setUncheckedArguments(_ arguments: StatementArguments) { _arguments = arguments argumentsNeedValidation = false @@ -159,14 +214,56 @@ public class Statement { } } - func setArgumentsWithValidation(_ arguments: StatementArguments) throws { + /// Set arguments without any validation. Trades safety for performance. + /// + /// Only call this method if you are sure input arguments match all expected + /// arguments of the statement. + /// + /// For example: + /// + /// let statement = try db.makeUpdateArgument(sql: """ + /// INSERT INTO player (id, name) VALUES (?, ?) + /// """) + /// + /// // OK + /// statement.unsafeSetArguments([1, "Arthur"]) + /// + /// // OK + /// let arguments: StatementArguments = ... // some untrusted arguments + /// try statement.validateArguments(arguments) + /// statement.unsafeSetArguments(arguments) + /// + /// // NOT OK + /// statement.unsafeSetArguments([1]) + /// + /// See also setArguments(_:) + @available(*, deprecated, renamed: "setUncheckedArguments(_:)") + public func unsafeSetArguments(_ arguments: StatementArguments) { + setUncheckedArguments(arguments) + } + + /// Set the statement arguments, or throws a DatabaseError of code + /// SQLITE_ERROR if arguments don't fill all statement arguments. + /// + /// For example: + /// + /// let statement = try db.makeUpdateArgument(sql: """ + /// INSERT INTO player (id, name) VALUES (?, ?) + /// """) + /// + /// // OK + /// try statement.setArguments([1, "Arthur"]) + /// + /// // Throws an error + /// try statement.setArguments([1]) + public func setArguments(_ arguments: StatementArguments) throws { // Validate - _arguments = arguments - var arguments = arguments - let bindings = try arguments.extractBindings(forStatement: self, allowingRemainingValues: false) - argumentsNeedValidation = false + var consumedArguments = arguments + let bindings = try consumedArguments.extractBindings(forStatement: self, allowingRemainingValues: false) // Apply + _arguments = arguments + argumentsNeedValidation = false try reset() clearBindings() for (index, dbValue) in zip(Int32(1)..., bindings) { @@ -220,9 +317,9 @@ public class Statement { // Force arguments validity: it is a programmer error to provide // arguments that do not match the statement. if let arguments = arguments { - try! setArgumentsWithValidation(arguments) + try! setArguments(arguments) } else if argumentsNeedValidation { - try! validate(arguments: self.arguments) + try! validateArguments(self.arguments) } } } diff --git a/GRDB/Record/PersistableRecord.swift b/GRDB/Record/PersistableRecord.swift index 56795f70d7..aba8604a66 100644 --- a/GRDB/Record/PersistableRecord.swift +++ b/GRDB/Record/PersistableRecord.swift @@ -859,7 +859,7 @@ final class DAO { tableName: databaseTableName, insertedColumns: persistenceContainer.columns) let statement = try db.internalCachedUpdateStatement(sql: query.sql) - statement.unsafeSetArguments(StatementArguments(persistenceContainer.values)) + statement.setUncheckedArguments(StatementArguments(persistenceContainer.values)) return statement } @@ -909,7 +909,7 @@ final class DAO { updatedColumns: updatedColumns, conditionColumns: primaryKeyColumns) let statement = try db.internalCachedUpdateStatement(sql: query.sql) - statement.unsafeSetArguments(StatementArguments(updatedValues + primaryKeyValues)) + statement.setUncheckedArguments(StatementArguments(updatedValues + primaryKeyValues)) return statement } @@ -929,7 +929,7 @@ final class DAO { tableName: databaseTableName, conditionColumns: primaryKeyColumns) let statement = try db.internalCachedUpdateStatement(sql: query.sql) - statement.unsafeSetArguments(StatementArguments(primaryKeyValues)) + statement.setUncheckedArguments(StatementArguments(primaryKeyValues)) return statement } @@ -949,7 +949,7 @@ final class DAO { tableName: databaseTableName, conditionColumns: primaryKeyColumns) let statement = try db.internalCachedSelectStatement(sql: query.sql) - statement.unsafeSetArguments(StatementArguments(primaryKeyValues)) + statement.setUncheckedArguments(StatementArguments(primaryKeyValues)) return statement } diff --git a/README.md b/README.md index 54957d9c3a..1cf1b846ca 100644 --- a/README.md +++ b/README.md @@ -7019,8 +7019,7 @@ In such a situation, you can still avoid fatal errors by exposing and handling e // Untrusted arguments if let arguments = StatementArguments(arguments) { let statement = try db.makeSelectStatement(sql: sql) - try statement.validate(arguments: arguments) - statement.unsafeSetArguments(arguments) + try statement.setArguments(arguments) var cursor = try Row.fetchCursor(statement) while let row = try iterator.next() { @@ -7981,12 +7980,12 @@ for player in players { } // Prepared statement -let insertStatement = db.prepareStatement("INSERT INTO player (name, email) VALUES (?, ?)") +let insertStatement = db.makeUpdateStatement(sql: "INSERT INTO player (name, email) VALUES (?, ?)") for player in players { - // Only use the unsafe arguments setter if you are sure that you provide + // Only use the unchecked arguments setter if you are sure that you provide // all statement arguments. A mistake can store unexpected values in // the database. - insertStatement.unsafeSetArguments([player.name, player.email]) + insertStatement.setUncheckedArguments([player.name, player.email]) try insertStatement.execute() } ``` diff --git a/Tests/GRDBTests/StatementArgumentsTests.swift b/Tests/GRDBTests/StatementArgumentsTests.swift index cfb887c278..ff6b21b8d9 100644 --- a/Tests/GRDBTests/StatementArgumentsTests.swift +++ b/Tests/GRDBTests/StatementArgumentsTests.swift @@ -1,12 +1,12 @@ import XCTest #if GRDBCUSTOMSQLITE - import GRDBCustomSQLite +import GRDBCustomSQLite #else - import GRDB +import GRDB #endif class StatementArgumentsTests: GRDBTestCase { - + override func setup(_ dbWriter: DatabaseWriter) throws { var migrator = DatabaseMigrator() migrator.registerMigration("createPersons") { db in @@ -28,14 +28,14 @@ class StatementArgumentsTests: GRDBTestCase { do { // Correct number of arguments - try statement.validate(arguments: ["foo", 1]) + try statement.validateArguments(["foo", 1]) } catch { XCTFail("Unexpected error: \(error)") } do { // Missing arguments - try statement.validate(arguments: []) + try statement.validateArguments([]) XCTFail("Expected error") } catch is DatabaseError { } catch { @@ -44,7 +44,7 @@ class StatementArgumentsTests: GRDBTestCase { do { // Two few arguments - try statement.validate(arguments: ["foo"]) + try statement.validateArguments(["foo"]) XCTFail("Expected error") } catch is DatabaseError { } catch { @@ -53,7 +53,7 @@ class StatementArgumentsTests: GRDBTestCase { do { // Two many arguments - try statement.validate(arguments: ["foo", 1, "bar"]) + try statement.validateArguments(["foo", 1, "bar"]) XCTFail("Expected error") } catch is DatabaseError { } catch { @@ -62,7 +62,7 @@ class StatementArgumentsTests: GRDBTestCase { do { // Missing arguments - try statement.validate(arguments: [:]) + try statement.validateArguments([:]) XCTFail("Expected error") } catch is DatabaseError { } catch { @@ -71,7 +71,7 @@ class StatementArgumentsTests: GRDBTestCase { do { // Unmappable arguments - try statement.validate(arguments: ["firstName": "foo", "age": 1]) + try statement.validateArguments(["firstName": "foo", "age": 1]) XCTFail("Expected error") } catch is DatabaseError { } catch { @@ -79,7 +79,7 @@ class StatementArgumentsTests: GRDBTestCase { } } } - + func testPositionalStatementArguments() throws { let dbQueue = try makeDatabaseQueue() try dbQueue.inDatabase { db in @@ -99,8 +99,8 @@ class StatementArgumentsTests: GRDBTestCase { XCTAssertEqual(row["age"] as Int, age) } } - - func testUnsafePositionalStatementArguments() throws { + + func testCheckedPositionalStatementArguments() throws { let dbQueue = try makeDatabaseQueue() try dbQueue.inDatabase { db in let name = "Arthur" @@ -108,18 +108,56 @@ class StatementArgumentsTests: GRDBTestCase { let arguments = StatementArguments([name, age] as [DatabaseValueConvertible?]) let updateStatement = try db.makeUpdateStatement(sql: "INSERT INTO persons (firstName, age) VALUES (?, ?)") - updateStatement.unsafeSetArguments(arguments) + try updateStatement.setArguments(arguments) try updateStatement.execute() let selectStatement = try db.makeSelectStatement(sql: "SELECT * FROM persons WHERE firstName = ? AND age = ?") - selectStatement.unsafeSetArguments(arguments) + try selectStatement.setArguments(arguments) let row = try Row.fetchOne(selectStatement)! XCTAssertEqual(row["firstName"] as String, name) XCTAssertEqual(row["age"] as Int, age) + + do { + try updateStatement.setArguments([1]) + XCTFail("Expected error") + } catch is DatabaseError { + XCTAssertEqual(updateStatement.arguments, arguments) + } catch { + XCTFail("Unexpected error: \(error)") + } + + do { + try selectStatement.setArguments([1]) + XCTFail("Expected error") + } catch is DatabaseError { + XCTAssertEqual(selectStatement.arguments, arguments) + } catch { + XCTFail("Unexpected error: \(error)") + } } } - + + func testUncheckedPositionalStatementArguments() throws { + let dbQueue = try makeDatabaseQueue() + try dbQueue.inDatabase { db in + let name = "Arthur" + let age = 42 + let arguments = StatementArguments([name, age] as [DatabaseValueConvertible?]) + + let updateStatement = try db.makeUpdateStatement(sql: "INSERT INTO persons (firstName, age) VALUES (?, ?)") + updateStatement.setUncheckedArguments(arguments) + try updateStatement.execute() + + let selectStatement = try db.makeSelectStatement(sql: "SELECT * FROM persons WHERE firstName = ? AND age = ?") + selectStatement.setUncheckedArguments(arguments) + let row = try Row.fetchOne(selectStatement)! + + XCTAssertEqual(row["firstName"] as String, name) + XCTAssertEqual(row["age"] as Int, age) + } + } + func testNamedStatementArgumentsValidation() throws { let dbQueue = try makeDatabaseQueue() try dbQueue.inDatabase { db in @@ -127,28 +165,28 @@ class StatementArgumentsTests: GRDBTestCase { do { // Correct number of arguments - try statement.validate(arguments: ["foo", 1]) + try statement.validateArguments(["foo", 1]) } catch { XCTFail("Unexpected error: \(error)") } do { // All arguments are mapped - try statement.validate(arguments: ["firstName": "foo", "age": 1]) + try statement.validateArguments(["firstName": "foo", "age": 1]) } catch { XCTFail("Unexpected error: \(error)") } do { // All arguments are mapped - try statement.validate(arguments: ["firstName": "foo", "age": 1, "bar": "baz"]) + try statement.validateArguments(["firstName": "foo", "age": 1, "bar": "baz"]) } catch { XCTFail("Unexpected error: \(error)") } do { // Missing arguments - try statement.validate(arguments: []) + try statement.validateArguments([]) XCTFail("Expected error") } catch is DatabaseError { } catch { @@ -157,7 +195,7 @@ class StatementArgumentsTests: GRDBTestCase { do { // Missing arguments - try statement.validate(arguments: ["foo"]) + try statement.validateArguments(["foo"]) XCTFail("Expected error") } catch is DatabaseError { } catch { @@ -166,7 +204,7 @@ class StatementArgumentsTests: GRDBTestCase { do { // Too many arguments - try statement.validate(arguments: ["foo", 1, "baz"]) + try statement.validateArguments(["foo", 1, "baz"]) XCTFail("Expected error") } catch is DatabaseError { } catch { @@ -175,7 +213,7 @@ class StatementArgumentsTests: GRDBTestCase { do { // Missing arguments - try statement.validate(arguments: [:]) + try statement.validateArguments([:]) XCTFail("Expected error") } catch is DatabaseError { } catch { @@ -184,7 +222,7 @@ class StatementArgumentsTests: GRDBTestCase { do { // Missing arguments - try statement.validate(arguments: ["firstName": "foo"]) + try statement.validateArguments(["firstName": "foo"]) XCTFail("Expected error") } catch is DatabaseError { } catch { @@ -192,7 +230,7 @@ class StatementArgumentsTests: GRDBTestCase { } } } - + func testNamedStatementArguments() throws { let dbQueue = try makeDatabaseQueue() try dbQueue.inDatabase { db in @@ -212,8 +250,8 @@ class StatementArgumentsTests: GRDBTestCase { XCTAssertEqual(row["age"] as Int, age) } } - - func testUnsafeNamedStatementArguments() throws { + + func testCheckedNamedStatementArguments() throws { let dbQueue = try makeDatabaseQueue() try dbQueue.inDatabase { db in let name = "Arthur" @@ -221,18 +259,56 @@ class StatementArgumentsTests: GRDBTestCase { let arguments = StatementArguments(["name": name, "age": age] as [String: DatabaseValueConvertible?]) let updateStatement = try db.makeUpdateStatement(sql: "INSERT INTO persons (firstName, age) VALUES (:name, :age)") - updateStatement.unsafeSetArguments(arguments) + try updateStatement.setArguments(arguments) try updateStatement.execute() let selectStatement = try db.makeSelectStatement(sql: "SELECT * FROM persons WHERE firstName = :name AND age = :age") - selectStatement.unsafeSetArguments(arguments) + try selectStatement.setArguments(arguments) let row = try Row.fetchOne(selectStatement)! XCTAssertEqual(row["firstName"] as String, name) XCTAssertEqual(row["age"] as Int, age) + + do { + try updateStatement.setArguments(["name": name]) + XCTFail("Expected error") + } catch is DatabaseError { + XCTAssertEqual(updateStatement.arguments, arguments) + } catch { + XCTFail("Unexpected error: \(error)") + } + + do { + try selectStatement.setArguments(["name": name]) + XCTFail("Expected error") + } catch is DatabaseError { + XCTAssertEqual(selectStatement.arguments, arguments) + } catch { + XCTFail("Unexpected error: \(error)") + } } } - + + func testUncheckedNamedStatementArguments() throws { + let dbQueue = try makeDatabaseQueue() + try dbQueue.inDatabase { db in + let name = "Arthur" + let age = 42 + let arguments = StatementArguments(["name": name, "age": age] as [String: DatabaseValueConvertible?]) + + let updateStatement = try db.makeUpdateStatement(sql: "INSERT INTO persons (firstName, age) VALUES (:name, :age)") + updateStatement.setUncheckedArguments(arguments) + try updateStatement.execute() + + let selectStatement = try db.makeSelectStatement(sql: "SELECT * FROM persons WHERE firstName = :name AND age = :age") + selectStatement.setUncheckedArguments(arguments) + let row = try Row.fetchOne(selectStatement)! + + XCTAssertEqual(row["firstName"] as String, name) + XCTAssertEqual(row["age"] as Int, age) + } + } + func testReusedNamedStatementArgumentsValidation() throws { let dbQueue = try makeDatabaseQueue() try dbQueue.inDatabase { db in @@ -248,28 +324,28 @@ class StatementArgumentsTests: GRDBTestCase { do { // Correct number of arguments - try statement.validate(arguments: ["foo", 1]) + try statement.validateArguments(["foo", 1]) } catch { XCTFail("Unexpected error: \(error)") } do { // All arguments are mapped - try statement.validate(arguments: ["name": "foo", "age": 1]) + try statement.validateArguments(["name": "foo", "age": 1]) } catch { XCTFail("Unexpected error: \(error)") } do { // All arguments are mapped - try statement.validate(arguments: ["name": "foo", "age": 1, "bar": "baz"]) + try statement.validateArguments(["name": "foo", "age": 1, "bar": "baz"]) } catch { XCTFail("Unexpected error: \(error)") } do { // Missing arguments - try statement.validate(arguments: []) + try statement.validateArguments([]) XCTFail("Expected error") } catch is DatabaseError { } catch { @@ -278,7 +354,7 @@ class StatementArgumentsTests: GRDBTestCase { do { // Missing arguments - try statement.validate(arguments: ["foo"]) + try statement.validateArguments(["foo"]) XCTFail("Expected error") } catch is DatabaseError { } catch { @@ -287,7 +363,7 @@ class StatementArgumentsTests: GRDBTestCase { do { // Too many arguments - try statement.validate(arguments: ["foo", 1, "baz"]) + try statement.validateArguments(["foo", 1, "baz"]) XCTFail("Expected error") } catch is DatabaseError { } catch { @@ -296,7 +372,7 @@ class StatementArgumentsTests: GRDBTestCase { do { // Missing arguments - try statement.validate(arguments: [:]) + try statement.validateArguments([:]) XCTFail("Expected error") } catch is DatabaseError { } catch { @@ -305,7 +381,7 @@ class StatementArgumentsTests: GRDBTestCase { do { // Missing arguments - try statement.validate(arguments: ["name": "foo"]) + try statement.validateArguments(["name": "foo"]) XCTFail("Expected error") } catch is DatabaseError { } catch { @@ -313,8 +389,8 @@ class StatementArgumentsTests: GRDBTestCase { } } } - - + + func testReusedNamedStatementArguments() throws { let dbQueue = try makeDatabaseQueue() try dbQueue.inDatabase { db in @@ -334,8 +410,8 @@ class StatementArgumentsTests: GRDBTestCase { XCTAssertEqual(row["age"] as Int, age) } } - - func testUnsafeReusedNamedStatementArguments() throws { + + func testCheckedReusedNamedStatementArguments() throws { let dbQueue = try makeDatabaseQueue() try dbQueue.inDatabase { db in let name = "Arthur" @@ -343,18 +419,56 @@ class StatementArgumentsTests: GRDBTestCase { let arguments = StatementArguments(["name": name, "age": age] as [String: DatabaseValueConvertible?]) let updateStatement = try db.makeUpdateStatement(sql: "INSERT INTO persons (firstName, lastName, age) VALUES (:name, :name, :age)") - updateStatement.unsafeSetArguments(arguments) + try updateStatement.setArguments(arguments) try updateStatement.execute() let selectStatement = try db.makeSelectStatement(sql: "SELECT * FROM persons WHERE firstName = :name AND lastName = :name AND age = :age") - selectStatement.unsafeSetArguments(arguments) + try selectStatement.setArguments(arguments) let row = try Row.fetchOne(selectStatement)! XCTAssertEqual(row["firstName"] as String, name) XCTAssertEqual(row["age"] as Int, age) + + do { + try updateStatement.setArguments(["name": name]) + XCTFail("Expected error") + } catch is DatabaseError { + XCTAssertEqual(updateStatement.arguments, arguments) + } catch { + XCTFail("Unexpected error: \(error)") + } + + do { + try selectStatement.setArguments(["name": name]) + XCTFail("Expected error") + } catch is DatabaseError { + XCTAssertEqual(selectStatement.arguments, arguments) + } catch { + XCTFail("Unexpected error: \(error)") + } } } - + + func testUncheckedReusedNamedStatementArguments() throws { + let dbQueue = try makeDatabaseQueue() + try dbQueue.inDatabase { db in + let name = "Arthur" + let age = 42 + let arguments = StatementArguments(["name": name, "age": age] as [String: DatabaseValueConvertible?]) + + let updateStatement = try db.makeUpdateStatement(sql: "INSERT INTO persons (firstName, lastName, age) VALUES (:name, :name, :age)") + updateStatement.setUncheckedArguments(arguments) + try updateStatement.execute() + + let selectStatement = try db.makeSelectStatement(sql: "SELECT * FROM persons WHERE firstName = :name AND lastName = :name AND age = :age") + selectStatement.setUncheckedArguments(arguments) + let row = try Row.fetchOne(selectStatement)! + + XCTAssertEqual(row["firstName"] as String, name) + XCTAssertEqual(row["age"] as Int, age) + } + } + func testMixedArguments() throws { let dbQueue = try makeDatabaseQueue() try dbQueue.inDatabase { db in @@ -363,7 +477,7 @@ class StatementArgumentsTests: GRDBTestCase { XCTAssertEqual(row, ["two": 2, "foo": "foo", "one": 1, "foo2": "foo", "bar": "bar"]) } } - + func testAppendContentsOf() throws { let dbQueue = try makeDatabaseQueue() try dbQueue.inDatabase { db in @@ -395,7 +509,7 @@ class StatementArgumentsTests: GRDBTestCase { } } } - + func testPlusOperator() throws { let dbQueue = try makeDatabaseQueue() try dbQueue.inDatabase { db in @@ -419,7 +533,7 @@ class StatementArgumentsTests: GRDBTestCase { } } } - + func testOverflowPlusOperator() throws { let dbQueue = try makeDatabaseQueue() try dbQueue.inDatabase { db in @@ -443,7 +557,7 @@ class StatementArgumentsTests: GRDBTestCase { } } } - + func testPlusEqualOperator() throws { let dbQueue = try makeDatabaseQueue() try dbQueue.inDatabase { db in From e9dd8b2b09f0591ed83a179ecae22568b3a47954 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gwendal=20Roue=CC=81?= Date: Sat, 7 Mar 2020 13:59:46 +0100 Subject: [PATCH 02/10] Enhance DatabaseMigrator querying methods // New dbQueue.read(migrator.hasCompletedMigrations) dbQueue.read(migrator.completedMigrations).contains("v2") dbQueue.read(migrator.completedMigrations).last == "v2" dbQueue.read(migrator.appliedMigrations) // Deprecated migrator.hasCompletedMigrations(in: dbQueue) migrator.hasCompletedMigrations(in: dbQueue, through: "v2") migrator.lastCompletedMigration(in: dbQueue) == "v2" migrator.appliedMigrations(in: dbQueue) --- CHANGELOG.md | 22 ++- Documentation/AppGroupContainers.md | 2 +- GRDB/Migration/DatabaseMigrator.swift | 119 +++++++------ README.md | 15 +- Tests/GRDBTests/DatabaseMigratorTests.swift | 188 ++++++++++++++------ 5 files changed, 226 insertions(+), 120 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bf95822e5..14cd76cefc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,7 @@ All notable changes to this project will be documented in this file. GRDB adheres to [Semantic Versioning](https://semver.org/), with one exception: APIs flagged [**:fire: EXPERIMENTAL**](README.md#what-are-experimental-features). Those are unstable, and may break between any two minor releases of the library. - #### 4.x Releases @@ -64,9 +62,25 @@ GRDB adheres to [Semantic Versioning](https://semver.org/), with one exception: - [0.110.0](#01100), ... - + +**New** + +- DatabaseMigrator querying methods have been enhanced: + + ```swift + // New + dbQueue.read(migrator.hasCompletedMigrations) + dbQueue.read(migrator.completedMigrations).contains("v2") + dbQueue.read(migrator.completedMigrations).last == "v2" + dbQueue.read(migrator.appliedMigrations) + + // Deprecated + migrator.hasCompletedMigrations(in: dbQueue) + migrator.hasCompletedMigrations(in: dbQueue, through: "v2") + migrator.lastCompletedMigration(in: dbQueue) == "v2" + migrator.appliedMigrations(in: dbQueue) + ``` ## 4.11.0 diff --git a/Documentation/AppGroupContainers.md b/Documentation/AppGroupContainers.md index 1ca3683a1e..266349a9ea 100644 --- a/Documentation/AppGroupContainers.md +++ b/Documentation/AppGroupContainers.md @@ -88,7 +88,7 @@ Since several processes may open the database at the same time, protect the crea // Check here if the database schema is correct, for example // with a DatabaseMigrator. - if try migrator.hasCompletedMigrations(in: dbPool) { + if try dbPool.read(migrator.hasCompletedMigrations) { return dbPool } else { return nil diff --git a/GRDB/Migration/DatabaseMigrator.swift b/GRDB/Migration/DatabaseMigrator.swift index 88d5f0f245..b34618f592 100644 --- a/GRDB/Migration/DatabaseMigrator.swift +++ b/GRDB/Migration/DatabaseMigrator.swift @@ -73,6 +73,8 @@ public struct DatabaseMigrator { public init() { } + // MARK: - Registering Migrations + /// Registers a migration. /// /// migrator.registerMigration("createAuthors") { db in @@ -113,6 +115,8 @@ public struct DatabaseMigrator { registerMigration(identifier, migrate: migrate) } + // MARK: - Applying Migrations + /// Iterate migrations in the same order as they were registered. If a /// migration has not yet been applied, its block is executed in /// a transaction. @@ -141,11 +145,7 @@ public struct DatabaseMigrator { // Fetch information about current database state var info: (lastAppliedIdentifier: String, schema: SchemaInfo)? try db.inTransaction(.deferred) { - let identifiers = try appliedIdentifiers(db) - if let lastAppliedIdentifier = migrations - .last(where: { identifiers.contains($0.identifier) })? - .identifier - { + if let lastAppliedIdentifier = try appliedMigrations(db).last { info = try (lastAppliedIdentifier: lastAppliedIdentifier, schema: db.schema()) } return .commit @@ -174,24 +174,63 @@ public struct DatabaseMigrator { } } + // MARK: - Querying Migrations + /// Returns the set of applied migration identifiers. /// /// - parameter reader: A DatabaseReader (DatabaseQueue or DatabasePool). - /// - parameter targetIdentifier: The identifier of a registered migration. /// - throws: An eventual database error. + @available(*, deprecated, message: "Wrap this method: reader.read(migrator.appliedMigrations) }") public func appliedMigrations(in reader: DatabaseReader) throws -> Set { - return try reader.read(appliedIdentifiers) + return try Set(reader.read(appliedMigrations)) + } + + /// Returns the applied migration identifiers, in the same order as + /// registered migrations. + /// + /// - parameter db: A database connection. + /// - throws: An eventual database error. + public func appliedMigrations(_ db: Database) throws -> [String] { + do { + let appliedIdentifiers = try Set(String.fetchCursor(db, sql: "SELECT identifier FROM grdb_migrations")) + return migrations.map { $0.identifier }.filter { appliedIdentifiers.contains($0) } + } catch { + // Rethrow if we can't prove grdb_migrations does not exist yet + if (try? !db.tableExists("grdb_migrations")) ?? false { + return [] + } + throw error + } + } + + /// Returns the identifiers of completed migrations, of which all previous + /// migrations have been applied. + /// + /// - parameter db: A database connection. + /// - throws: An eventual database error. + public func completedMigrations(_ db: Database) throws -> [String] { + let appliedIdentifiers = try appliedMigrations(db) + let knownIdentifiers = migrations.map { $0.identifier } + return Array(zip(appliedIdentifiers, knownIdentifiers) + .prefix(while: { $0 == $1 }) + .map { $0.0 }) } /// Returns true if all migrations are applied. /// /// - parameter reader: A DatabaseReader (DatabaseQueue or DatabasePool). /// - throws: An eventual database error. + @available(*, deprecated, message: "Wrap this method: reader.read(migrator.hasCompletedMigrations) }") public func hasCompletedMigrations(in reader: DatabaseReader) throws -> Bool { - guard let lastMigration = migrations.last else { - return true - } - return try hasCompletedMigrations(in: reader, through: lastMigration.identifier) + return try reader.read(hasCompletedMigrations) + } + + /// Returns true if all migrations are applied. + /// + /// - parameter db: A database connection. + /// - throws: An eventual database error. + public func hasCompletedMigrations(_ db: Database) throws -> Bool { + return try completedMigrations(db).last == migrations.last?.identifier } /// Returns true if all migrations up to the provided target are applied, @@ -200,14 +239,9 @@ public struct DatabaseMigrator { /// - parameter reader: A DatabaseReader (DatabaseQueue or DatabasePool). /// - parameter targetIdentifier: The identifier of a registered migration. /// - throws: An eventual database error. + @available(*, deprecated, message: "Prefer reader.read(migrator.completedMigrations).contains(targetIdentifier)") public func hasCompletedMigrations(in reader: DatabaseReader, through targetIdentifier: String) throws -> Bool { - return try reader.read { db in - let appliedIdentifiers = try self.appliedIdentifiers(db) - let unappliedMigrations = self.unappliedMigrations( - upTo: targetIdentifier, - appliedIdentifiers: appliedIdentifiers) - return unappliedMigrations.isEmpty - } + return try reader.read(completedMigrations).contains(targetIdentifier) } /// Returns the identifier of the last migration for which all predecessors @@ -216,26 +250,11 @@ public struct DatabaseMigrator { /// - parameter reader: A DatabaseReader (DatabaseQueue or DatabasePool). /// - returns: An eventual migration identifier. /// - throws: An eventual database error. + @available(*, deprecated, message: "Prefer reader.read(migrator.completedMigrations).last") public func lastCompletedMigration(in reader: DatabaseReader) throws -> String? { - return try reader.read { db in - let appliedIdentifiers = try self.appliedIdentifiers(db) - if appliedIdentifiers.isEmpty { - return nil - } - let lastAppliedIdentifier = migrations - .last { appliedIdentifiers.contains($0.identifier) }! - .identifier - let unappliedMigrations = self.unappliedMigrations( - upTo: lastAppliedIdentifier, - appliedIdentifiers: appliedIdentifiers) - if unappliedMigrations.isEmpty { - return lastAppliedIdentifier - } else { - return nil - } - } + return try reader.read(completedMigrations).last } - + // MARK: - Non public private mutating func registerMigration(_ migration: Migration) { @@ -245,19 +264,8 @@ public struct DatabaseMigrator { migrations.append(migration) } - private func appliedIdentifiers(_ db: Database) throws -> Set { - let tableExists = try Bool.fetchOne(db, sql: """ - SELECT EXISTS (SELECT 1 FROM sqlite_master WHERE type='table' AND name='grdb_migrations') - """)! - guard tableExists else { - return [] - } - return try Set(String.fetchAll(db, sql: "SELECT identifier FROM grdb_migrations")) - .intersection(migrations.map { $0.identifier }) - } - /// Returns unapplied migration identifier, - private func unappliedMigrations(upTo targetIdentifier: String, appliedIdentifiers: Set) -> [Migration] { + private func unappliedMigrations(upTo targetIdentifier: String, appliedIdentifiers: [String]) -> [Migration] { var expectedMigrations: [Migration] = [] for migration in migrations { expectedMigrations.append(migration) @@ -275,13 +283,13 @@ public struct DatabaseMigrator { } private func runMigrations(_ db: Database, upTo targetIdentifier: String) throws { - try db.execute(sql: "CREATE TABLE IF NOT EXISTS grdb_migrations (identifier TEXT NOT NULL PRIMARY KEY)") - - let appliedIdentifiers = try self.appliedIdentifiers(db) + let appliedIdentifiers = try self.appliedMigrations(db) // Subsequent migration must not be applied - if let index = migrations.firstIndex(where: { $0.identifier == targetIdentifier }), - migrations[(index + 1)...].contains(where: { appliedIdentifiers.contains($0.identifier) }) + if let targetIndex = migrations.firstIndex(where: { $0.identifier == targetIdentifier }), + let lastAppliedIdentifier = appliedIdentifiers.last, + let lastAppliedIndex = migrations.firstIndex(where: { $0.identifier == lastAppliedIdentifier }), + targetIndex < lastAppliedIndex { fatalError("database is already migrated beyond migration \(String(reflecting: targetIdentifier))") } @@ -290,6 +298,11 @@ public struct DatabaseMigrator { upTo: targetIdentifier, appliedIdentifiers: appliedIdentifiers) + if unappliedMigrations.isEmpty { + return + } + + try db.execute(sql: "CREATE TABLE IF NOT EXISTS grdb_migrations (identifier TEXT NOT NULL PRIMARY KEY)") for migration in unappliedMigrations { try migration.run(db) } diff --git a/README.md b/README.md index 1cf1b846ca..5306cff3fc 100644 --- a/README.md +++ b/README.md @@ -4945,22 +4945,21 @@ try migrator.migrate(dbQueue, upTo: "v1") Check if consecutive migrations have been applied: ```swift -if try migrator.hasCompletedMigrations(in: dbQueue) { +if try dbQueue.read(migrator.hasCompletedMigrations) { // All migrations have been applied, up to the last one. } -if try migrator.hasCompletedMigrations(in: dbQueue, through: "v2") { - // All migrations have been applied up to "v2", and maybe further. +if try dbQueue.read(migrator.completedMigrations).last == "v2" { + // All migrations up to "v2" have been applied, and no further. } -if try migrator.lastCompletedMigration(in: dbQueue) == "v2" { - // All migrations have been applied up to "v2", and no further. +if try dbQueue.read(migrator.completedMigrations).contains("v2") { + // All migrations up to "v2" have been applied, and maybe further. } ``` -Check which migrations have been applied: +Check if individual migrations have been applied: ```swift -let appliedMigrations = try migrator.appliedMigrations(in: dbQueue) // Set -if appliedMigrations.contains("v2") { +if try dbQueue.read(migrator.appliedMigrations).contains("v2") { // "v2" migration has been applied } ``` diff --git a/Tests/GRDBTests/DatabaseMigratorTests.swift b/Tests/GRDBTests/DatabaseMigratorTests.swift index 729377b05f..fdfc06236a 100644 --- a/Tests/GRDBTests/DatabaseMigratorTests.swift +++ b/Tests/GRDBTests/DatabaseMigratorTests.swift @@ -1,8 +1,8 @@ import XCTest #if GRDBCUSTOMSQLITE - import GRDBCustomSQLite +import GRDBCustomSQLite #else - import GRDB +import GRDB #endif class DatabaseMigratorTests : GRDBTestCase { @@ -51,7 +51,7 @@ class DatabaseMigratorTests : GRDBTestCase { XCTAssertFalse(try db.tableExists("pets")) } } - + func testMigratorDatabasePool() throws { let dbPool = try makeDatabasePool() @@ -90,7 +90,7 @@ class DatabaseMigratorTests : GRDBTestCase { XCTAssertFalse(try db.tableExists("pets")) } } - + func testMigrateUpTo() throws { let dbQueue = try makeDatabaseQueue() @@ -174,9 +174,9 @@ class DatabaseMigratorTests : GRDBTestCase { func testForeignKeyViolation() throws { #if !GRDBCUSTOMSQLITE && !GRDBCIPHER - guard #available(iOS 8.2, OSX 10.10, *) else { - return - } + guard #available(iOS 8.2, OSX 10.10, *) else { + return + } #endif var migrator = DatabaseMigrator() migrator.registerMigration("createPersons") { db in @@ -229,12 +229,12 @@ class DatabaseMigratorTests : GRDBTestCase { } } } - + func testMigrationWithDeferredForeignKeyChecksDeprecated() throws { #if !GRDBCUSTOMSQLITE && !GRDBCIPHER - guard #available(iOS 8.2, OSX 10.10, *) else { - return - } + guard #available(iOS 8.2, OSX 10.10, *) else { + return + } #endif var migrator = DatabaseMigrator() migrator.registerMigration("createPersons") { db in @@ -293,8 +293,8 @@ class DatabaseMigratorTests : GRDBTestCase { } } } - - func testAppliedMigrations() throws { + + func testAppliedMigrationsDeprecated() throws { var migrator = DatabaseMigrator() // No migration @@ -304,7 +304,7 @@ class DatabaseMigratorTests : GRDBTestCase { } // One migration - + migrator.registerMigration("1") { db in try db.create(table: "player") { t in t.autoIncrementedPrimaryKey("id") @@ -319,9 +319,9 @@ class DatabaseMigratorTests : GRDBTestCase { try migrator.migrate(dbQueue, upTo: "1") try XCTAssertEqual(migrator.appliedMigrations(in: dbQueue), ["1"]) } - + // Two migrations - + migrator.registerMigration("2") { db in try db.execute(sql: "INSERT INTO player (id, name, score) VALUES (NULL, 'Arthur', 1000)") } @@ -336,7 +336,49 @@ class DatabaseMigratorTests : GRDBTestCase { } } - func testHasCompletedMigrations() throws { + func testAppliedMigrations() throws { + var migrator = DatabaseMigrator() + + // No migration + do { + let dbQueue = try makeDatabaseQueue() + try XCTAssertEqual(dbQueue.read(migrator.appliedMigrations), []) + } + + // One migration + + migrator.registerMigration("1") { db in + try db.create(table: "player") { t in + t.autoIncrementedPrimaryKey("id") + t.column("name", .text) + t.column("score", .integer) + } + } + + do { + let dbQueue = try makeDatabaseQueue() + try XCTAssertEqual(dbQueue.read(migrator.appliedMigrations), []) + try migrator.migrate(dbQueue, upTo: "1") + try XCTAssertEqual(dbQueue.read(migrator.appliedMigrations), ["1"]) + } + + // Two migrations + + migrator.registerMigration("2") { db in + try db.execute(sql: "INSERT INTO player (id, name, score) VALUES (NULL, 'Arthur', 1000)") + } + + do { + let dbQueue = try makeDatabaseQueue() + try XCTAssertEqual(dbQueue.read(migrator.appliedMigrations), []) + try migrator.migrate(dbQueue, upTo: "1") + try XCTAssertEqual(dbQueue.read(migrator.appliedMigrations), ["1"]) + try migrator.migrate(dbQueue, upTo: "2") + try XCTAssertEqual(dbQueue.read(migrator.appliedMigrations), ["1", "2"]) + } + } + + func testHasCompletedMigrationsDeprecated() throws { var migrator = DatabaseMigrator() // No migration @@ -346,7 +388,7 @@ class DatabaseMigratorTests : GRDBTestCase { } // One migration - + migrator.registerMigration("1") { db in try db.create(table: "player") { t in t.autoIncrementedPrimaryKey("id") @@ -363,9 +405,9 @@ class DatabaseMigratorTests : GRDBTestCase { try XCTAssertTrue(migrator.hasCompletedMigrations(in: dbQueue, through: "1")) try XCTAssertTrue(migrator.hasCompletedMigrations(in: dbQueue)) } - + // Two migrations - + migrator.registerMigration("2") { db in try db.execute(sql: "INSERT INTO player (id, name, score) VALUES (NULL, 'Arthur', 1000)") } @@ -386,7 +428,7 @@ class DatabaseMigratorTests : GRDBTestCase { } } - func testLastCompletedMigration() throws { + func testLastCompletedMigrationDeprecated() throws { var migrator = DatabaseMigrator() // No migration @@ -396,7 +438,7 @@ class DatabaseMigratorTests : GRDBTestCase { } // One migration - + migrator.registerMigration("1") { db in try db.create(table: "player") { t in t.autoIncrementedPrimaryKey("id") @@ -411,9 +453,9 @@ class DatabaseMigratorTests : GRDBTestCase { try migrator.migrate(dbQueue, upTo: "1") try XCTAssertEqual(migrator.lastCompletedMigration(in: dbQueue), "1") } - + // Two migrations - + migrator.registerMigration("2") { db in try db.execute(sql: "INSERT INTO player (id, name, score) VALUES (NULL, 'Arthur', 1000)") } @@ -428,6 +470,54 @@ class DatabaseMigratorTests : GRDBTestCase { } } + func testCompletedMigrations() throws { + var migrator = DatabaseMigrator() + + // No migration + do { + let dbQueue = try makeDatabaseQueue() + try XCTAssertEqual(dbQueue.read(migrator.completedMigrations), []) + try XCTAssertTrue(dbQueue.read(migrator.hasCompletedMigrations)) + } + + // One migration + + migrator.registerMigration("1") { db in + try db.create(table: "player") { t in + t.autoIncrementedPrimaryKey("id") + t.column("name", .text) + t.column("score", .integer) + } + } + + do { + let dbQueue = try makeDatabaseQueue() + try XCTAssertEqual(dbQueue.read(migrator.completedMigrations), []) + try XCTAssertFalse(dbQueue.read(migrator.hasCompletedMigrations)) + try migrator.migrate(dbQueue, upTo: "1") + try XCTAssertEqual(dbQueue.read(migrator.completedMigrations), ["1"]) + try XCTAssertTrue(dbQueue.read(migrator.hasCompletedMigrations)) + } + + // Two migrations + + migrator.registerMigration("2") { db in + try db.execute(sql: "INSERT INTO player (id, name, score) VALUES (NULL, 'Arthur', 1000)") + } + + do { + let dbQueue = try makeDatabaseQueue() + try XCTAssertEqual(dbQueue.read(migrator.completedMigrations), []) + try XCTAssertFalse(dbQueue.read(migrator.hasCompletedMigrations)) + try migrator.migrate(dbQueue, upTo: "1") + try XCTAssertEqual(dbQueue.read(migrator.completedMigrations), ["1"]) + try XCTAssertFalse(dbQueue.read(migrator.hasCompletedMigrations)) + try migrator.migrate(dbQueue, upTo: "2") + try XCTAssertEqual(dbQueue.read(migrator.completedMigrations), ["1", "2"]) + try XCTAssertTrue(dbQueue.read(migrator.hasCompletedMigrations)) + } + } + func testMergedMigrators() throws { // Migrate a database var oldMigrator = DatabaseMigrator() @@ -437,11 +527,9 @@ class DatabaseMigratorTests : GRDBTestCase { let dbQueue = try makeDatabaseQueue() try oldMigrator.migrate(dbQueue) - try XCTAssertEqual(oldMigrator.appliedMigrations(in: dbQueue), ["1", "3"]) - try XCTAssertTrue(oldMigrator.hasCompletedMigrations(in: dbQueue)) - try XCTAssertTrue(oldMigrator.hasCompletedMigrations(in: dbQueue, through: "1")) - try XCTAssertTrue(oldMigrator.hasCompletedMigrations(in: dbQueue, through: "3")) - try XCTAssertEqual(oldMigrator.lastCompletedMigration(in: dbQueue), "3") + try XCTAssertEqual(dbQueue.read(oldMigrator.appliedMigrations), ["1", "3"]) + try XCTAssertEqual(dbQueue.read(oldMigrator.completedMigrations), ["1", "3"]) + try XCTAssertTrue(dbQueue.read(oldMigrator.hasCompletedMigrations)) // A source code merge inserts a migration between "1" and "3" var newMigrator = DatabaseMigrator() @@ -449,30 +537,22 @@ class DatabaseMigratorTests : GRDBTestCase { newMigrator.registerMigration("2", migrate: { _ in }) newMigrator.registerMigration("3", migrate: { _ in }) - try XCTAssertEqual(newMigrator.appliedMigrations(in: dbQueue), ["1", "3"]) - try XCTAssertFalse(newMigrator.hasCompletedMigrations(in: dbQueue)) - try XCTAssertTrue(newMigrator.hasCompletedMigrations(in: dbQueue, through: "1")) - try XCTAssertFalse(newMigrator.hasCompletedMigrations(in: dbQueue, through: "2")) - try XCTAssertFalse(newMigrator.hasCompletedMigrations(in: dbQueue, through: "3")) - try XCTAssertNil(newMigrator.lastCompletedMigration(in: dbQueue)) + try XCTAssertEqual(dbQueue.read(newMigrator.appliedMigrations), ["1", "3"]) + try XCTAssertEqual(dbQueue.read(newMigrator.completedMigrations), ["1"]) + try XCTAssertFalse(dbQueue.read(newMigrator.hasCompletedMigrations)) // The new source code migrates the database try newMigrator.migrate(dbQueue) - try XCTAssertEqual(oldMigrator.appliedMigrations(in: dbQueue), ["1", "3"]) - try XCTAssertTrue(oldMigrator.hasCompletedMigrations(in: dbQueue)) - try XCTAssertTrue(oldMigrator.hasCompletedMigrations(in: dbQueue, through: "1")) - try XCTAssertTrue(oldMigrator.hasCompletedMigrations(in: dbQueue, through: "3")) - try XCTAssertEqual(oldMigrator.lastCompletedMigration(in: dbQueue), "3") - - try XCTAssertEqual(newMigrator.appliedMigrations(in: dbQueue), ["1", "2", "3"]) - try XCTAssertTrue(newMigrator.hasCompletedMigrations(in: dbQueue)) - try XCTAssertTrue(newMigrator.hasCompletedMigrations(in: dbQueue, through: "1")) - try XCTAssertTrue(newMigrator.hasCompletedMigrations(in: dbQueue, through: "2")) - try XCTAssertTrue(newMigrator.hasCompletedMigrations(in: dbQueue, through: "3")) - try XCTAssertEqual(newMigrator.lastCompletedMigration(in: dbQueue), "3") + try XCTAssertEqual(dbQueue.read(oldMigrator.appliedMigrations), ["1", "3"]) + try XCTAssertEqual(dbQueue.read(oldMigrator.completedMigrations), ["1", "3"]) + try XCTAssertTrue(dbQueue.read(oldMigrator.hasCompletedMigrations)) + + try XCTAssertEqual(dbQueue.read(newMigrator.appliedMigrations), ["1", "2", "3"]) + try XCTAssertEqual(dbQueue.read(newMigrator.completedMigrations), ["1", "2", "3"]) + try XCTAssertTrue(dbQueue.read(newMigrator.hasCompletedMigrations)) } - + func testEraseDatabaseOnSchemaChange() throws { // 1st version of the migrator var migrator1 = DatabaseMigrator() @@ -508,12 +588,12 @@ class DatabaseMigratorTests : GRDBTestCase { XCTAssertEqual(error.resultCode, .SQLITE_ERROR) XCTAssertEqual(error.message, "table player has no column named score") } - try XCTAssertEqual(migrator2.appliedMigrations(in: dbQueue), ["1"]) - + try XCTAssertEqual(dbQueue.read(migrator2.appliedMigrations), ["1"]) + // ... unless databaase gets erased migrator2.eraseDatabaseOnSchemaChange = true try migrator2.migrate(dbQueue) - try XCTAssertEqual(migrator2.appliedMigrations(in: dbQueue), ["1", "2"]) + try XCTAssertEqual(dbQueue.read(migrator2.appliedMigrations), ["1", "2"]) } func testEraseDatabaseOnSchemaChangeWithConfiguration() throws { @@ -557,12 +637,12 @@ class DatabaseMigratorTests : GRDBTestCase { XCTAssertEqual(error.resultCode, .SQLITE_ERROR) XCTAssertEqual(error.message, "table player has no column named score") } - try XCTAssertEqual(migrator2.appliedMigrations(in: dbQueue), ["1"]) - + try XCTAssertEqual(dbQueue.read(migrator2.appliedMigrations), ["1"]) + // ... unless databaase gets erased migrator2.eraseDatabaseOnSchemaChange = true try migrator2.migrate(dbQueue) - try XCTAssertEqual(migrator2.appliedMigrations(in: dbQueue), ["1", "2"]) + try XCTAssertEqual(dbQueue.read(migrator2.appliedMigrations), ["1", "2"]) } func testEraseDatabaseOnSchemaChangeDoesNotEraseDatabaseOnAddedMigration() throws { From 435f5ff559cb4493dbf9f984b74ebe6378813634 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gwendal=20Rou=C3=A9?= Date: Tue, 10 Mar 2020 07:41:15 +0100 Subject: [PATCH 03/10] TODO: fix throwingFirstError --- GRDB/Utils/Utils.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/GRDB/Utils/Utils.swift b/GRDB/Utils/Utils.swift index 75f426d495..94b1dbdf06 100644 --- a/GRDB/Utils/Utils.swift +++ b/GRDB/Utils/Utils.swift @@ -139,7 +139,7 @@ func throwingFirstError(execute: () throws -> T, finally: () throws -> Void) try finally() return result } catch { - try? finally() + try? finally() // FIXME: finally is called twice if it throws throw error } } From 3973b66ab39931f02c713e6a31eb732177fadeba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gwendal=20Roue=CC=81?= Date: Tue, 10 Mar 2020 09:16:24 +0100 Subject: [PATCH 04/10] Fix and test throwingFirstError --- GRDB.xcodeproj/project.pbxproj | 10 ++++ GRDB/Utils/Utils.swift | 18 ++++-- GRDBCustom.xcodeproj/project.pbxproj | 6 ++ Tests/GRDBTests/UtilsTests.swift | 85 ++++++++++++++++++++++++++++ 4 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 Tests/GRDBTests/UtilsTests.swift diff --git a/GRDB.xcodeproj/project.pbxproj b/GRDB.xcodeproj/project.pbxproj index 1330609dc2..5cec16ecfc 100755 --- a/GRDB.xcodeproj/project.pbxproj +++ b/GRDB.xcodeproj/project.pbxproj @@ -631,6 +631,10 @@ 56B964BF1DA51D0A0002DA19 /* FTS5Pattern.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56B964B81DA51D0A0002DA19 /* FTS5Pattern.swift */; }; 56BB6EA91D3009B100A1CA52 /* SchedulingWatchdog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56BB6EA81D3009B100A1CA52 /* SchedulingWatchdog.swift */; }; 56BB6EAC1D3009B100A1CA52 /* SchedulingWatchdog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56BB6EA81D3009B100A1CA52 /* SchedulingWatchdog.swift */; }; + 56BF2282241781C5003D86EB /* UtilsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56BF2281241781C5003D86EB /* UtilsTests.swift */; }; + 56BF2283241781C5003D86EB /* UtilsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56BF2281241781C5003D86EB /* UtilsTests.swift */; }; + 56BF2284241781C5003D86EB /* UtilsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56BF2281241781C5003D86EB /* UtilsTests.swift */; }; + 56BF2285241781C5003D86EB /* UtilsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56BF2281241781C5003D86EB /* UtilsTests.swift */; }; 56C3F7561CF9F12400F6A361 /* DatabaseSavepointTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56C3F7521CF9F12400F6A361 /* DatabaseSavepointTests.swift */; }; 56CC922C201DFFB900CB597E /* DropWhileCursorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56CC922B201DFFB900CB597E /* DropWhileCursorTests.swift */; }; 56CC922D201DFFB900CB597E /* DropWhileCursorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56CC922B201DFFB900CB597E /* DropWhileCursorTests.swift */; }; @@ -1475,6 +1479,7 @@ 56B964C11DA521450002DA19 /* FTS5RecordTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FTS5RecordTests.swift; sourceTree = ""; }; 56B964C21DA521450002DA19 /* FTS5TableBuilderTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FTS5TableBuilderTests.swift; sourceTree = ""; }; 56BB6EA81D3009B100A1CA52 /* SchedulingWatchdog.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SchedulingWatchdog.swift; sourceTree = ""; }; + 56BF2281241781C5003D86EB /* UtilsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UtilsTests.swift; sourceTree = ""; }; 56C3F7521CF9F12400F6A361 /* DatabaseSavepointTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DatabaseSavepointTests.swift; sourceTree = ""; }; 56C48E731C9A9923005DF1D9 /* module.modulemap */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.module-map"; path = module.modulemap; sourceTree = ""; }; 56C494401ED7255500CC72AF /* GRDBDeploymentTarget.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = GRDBDeploymentTarget.xcconfig; sourceTree = ""; }; @@ -2039,6 +2044,7 @@ 56915781231BF28B00E1D237 /* PoolTests.swift */, 56FF45551D2CDA5200F21EF9 /* RecordUniqueIndexTests.swift */, 56A4CDAF1D4234B200B1A9B9 /* SQLExpressionLiteralTests.swift */, + 56BF2281241781C5003D86EB /* UtilsTests.swift */, ); name = Private; sourceTree = ""; @@ -2870,6 +2876,7 @@ 5695310C1C9067DC00CF1A2B /* MigrationCrashTests.swift in Sources */, 569531091C9067DC00CF1A2B /* DatabaseQueueCrashTests.swift in Sources */, 5695310D1C9067DC00CF1A2B /* RecordCrashTests.swift in Sources */, + 56BF2283241781C5003D86EB /* UtilsTests.swift in Sources */, 5695310E1C9067DC00CF1A2B /* StatementColumnConvertibleCrashTests.swift in Sources */, 5695310A1C9067DC00CF1A2B /* DatabaseValueConvertibleCrashTests.swift in Sources */, 5695310F1C9067DC00CF1A2B /* StatementCrashTests.swift in Sources */, @@ -3040,6 +3047,7 @@ 56A2384C1B9C74A90082EB20 /* UpdateStatementTests.swift in Sources */, 56A2384E1B9C74A90082EB20 /* DatabaseMigratorTests.swift in Sources */, 563DE4F4231A91E2005081B7 /* DatabaseConfigurationTests.swift in Sources */, + 56BF2284241781C5003D86EB /* UtilsTests.swift in Sources */, 56CC9244201E034D00CB597E /* PrefixWhileCursorTests.swift in Sources */, 560714E4227DD0810091BB10 /* AssociationPrefetchingSQLTests.swift in Sources */, 5657AB4A1D108BA9006283EF /* FoundationNSNullTests.swift in Sources */, @@ -3246,6 +3254,7 @@ 562206061E420EA4005860AC /* DatabasePoolBackupTests.swift in Sources */, 56D496B51D813413008276D7 /* DatabaseCollationTests.swift in Sources */, 563DE4F3231A91E2005081B7 /* DatabaseConfigurationTests.swift in Sources */, + 56BF2282241781C5003D86EB /* UtilsTests.swift in Sources */, 56CC9243201E034D00CB597E /* PrefixWhileCursorTests.swift in Sources */, 560714E3227DD0810091BB10 /* AssociationPrefetchingSQLTests.swift in Sources */, 56D496841D813147008276D7 /* SelectStatementTests.swift in Sources */, @@ -3586,6 +3595,7 @@ AAA4DD1F230F262000C74B15 /* UpdateStatementTests.swift in Sources */, AAA4DD20230F262000C74B15 /* DatabaseMigratorTests.swift in Sources */, 563DE4F5231A91E2005081B7 /* DatabaseConfigurationTests.swift in Sources */, + 56BF2285241781C5003D86EB /* UtilsTests.swift in Sources */, AAA4DD21230F262000C74B15 /* PrefixWhileCursorTests.swift in Sources */, AAA4DD22230F262000C74B15 /* AssociationPrefetchingSQLTests.swift in Sources */, AAA4DD23230F262000C74B15 /* FoundationNSNullTests.swift in Sources */, diff --git a/GRDB/Utils/Utils.swift b/GRDB/Utils/Utils.swift index 94b1dbdf06..0442330ff1 100644 --- a/GRDB/Utils/Utils.swift +++ b/GRDB/Utils/Utils.swift @@ -134,12 +134,22 @@ extension Character { /// finally: cleanup) @inline(__always) func throwingFirstError(execute: () throws -> T, finally: () throws -> Void) throws -> T { + var result: T? + var firstError: Error? + do { + result = try execute() + } catch { + firstError = error + } do { - let result = try execute() try finally() - return result } catch { - try? finally() // FIXME: finally is called twice if it throws - throw error + if firstError == nil { + firstError = error + } + } + if let firstError = firstError { + throw firstError } + return result! } diff --git a/GRDBCustom.xcodeproj/project.pbxproj b/GRDBCustom.xcodeproj/project.pbxproj index 0c0f43cca4..53db7b4243 100755 --- a/GRDBCustom.xcodeproj/project.pbxproj +++ b/GRDBCustom.xcodeproj/project.pbxproj @@ -417,6 +417,8 @@ 56B964DB1DA5216B0002DA19 /* FTS5RecordTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56B964C11DA521450002DA19 /* FTS5RecordTests.swift */; }; 56BB6EAB1D3009B100A1CA52 /* SchedulingWatchdog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56BB6EA81D3009B100A1CA52 /* SchedulingWatchdog.swift */; }; 56BB6EAE1D3009B100A1CA52 /* SchedulingWatchdog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56BB6EA81D3009B100A1CA52 /* SchedulingWatchdog.swift */; }; + 56BF22882417821F003D86EB /* UtilsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56BF22862417821F003D86EB /* UtilsTests.swift */; }; + 56BF22892417821F003D86EB /* UtilsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56BF22862417821F003D86EB /* UtilsTests.swift */; }; 56C0539122ACEECD0029D27D /* CompactMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56C0538922ACEECD0029D27D /* CompactMap.swift */; }; 56C0539222ACEECD0029D27D /* CompactMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56C0538922ACEECD0029D27D /* CompactMap.swift */; }; 56C0539322ACEECD0029D27D /* Fetch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56C0538A22ACEECD0029D27D /* Fetch.swift */; }; @@ -1007,6 +1009,7 @@ 56B964C11DA521450002DA19 /* FTS5RecordTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FTS5RecordTests.swift; sourceTree = ""; }; 56B964C21DA521450002DA19 /* FTS5TableBuilderTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FTS5TableBuilderTests.swift; sourceTree = ""; }; 56BB6EA81D3009B100A1CA52 /* SchedulingWatchdog.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SchedulingWatchdog.swift; sourceTree = ""; }; + 56BF22862417821F003D86EB /* UtilsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UtilsTests.swift; sourceTree = ""; }; 56C0538922ACEECD0029D27D /* CompactMap.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CompactMap.swift; sourceTree = ""; }; 56C0538A22ACEECD0029D27D /* Fetch.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Fetch.swift; sourceTree = ""; }; 56C0538B22ACEECD0029D27D /* ValueObservation+Row.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ValueObservation+Row.swift"; sourceTree = ""; }; @@ -1546,6 +1549,7 @@ 5691578B231BF2BE00E1D237 /* PoolTests.swift */, 56FF45551D2CDA5200F21EF9 /* RecordUniqueIndexTests.swift */, 56A4CDAF1D4234B200B1A9B9 /* SQLExpressionLiteralTests.swift */, + 56BF22862417821F003D86EB /* UtilsTests.swift */, ); name = Private; sourceTree = ""; @@ -2317,6 +2321,7 @@ 5657AB4D1D108BA9006283EF /* FoundationNSNullTests.swift in Sources */, 5627564A1E963AAC0035B653 /* DatabaseWriterTests.swift in Sources */, 563DE4F9231A91F6005081B7 /* DatabaseConfigurationTests.swift in Sources */, + 56BF22892417821F003D86EB /* UtilsTests.swift in Sources */, 56CC923C201E033400CB597E /* PrefixWhileCursorTests.swift in Sources */, F3BA80E51CFB3011003DC1BA /* DatabaseFunctionTests.swift in Sources */, 560714EE227DD10F0091BB10 /* AssociationPrefetchingSQLTests.swift in Sources */, @@ -2657,6 +2662,7 @@ 566AD8C91D531BEB002EC1A8 /* TableDefinitionTests.swift in Sources */, 56071A501DB54ED300CA6E47 /* FetchedRecordsControllerTests.swift in Sources */, 563DE4F8231A91F6005081B7 /* DatabaseConfigurationTests.swift in Sources */, + 56BF22882417821F003D86EB /* UtilsTests.swift in Sources */, F3BA80AC1CFB2FA6003DC1BA /* DatabaseQueueSchemaCacheTests.swift in Sources */, 56CC923B201E033400CB597E /* PrefixWhileCursorTests.swift in Sources */, 560714ED227DD10F0091BB10 /* AssociationPrefetchingSQLTests.swift in Sources */, diff --git a/Tests/GRDBTests/UtilsTests.swift b/Tests/GRDBTests/UtilsTests.swift new file mode 100644 index 0000000000..0be60de9da --- /dev/null +++ b/Tests/GRDBTests/UtilsTests.swift @@ -0,0 +1,85 @@ +import XCTest + +#if GRDBCUSTOMSQLITE + @testable import GRDBCustomSQLite +#else + @testable import GRDB +#endif + +class UtilsTests: XCTestCase { + + func testThrowingFirstError() { + struct ExecuteError: Error { } + struct FinallyError: Error { } + var actions: [String] + + do { + actions = [] + let result = try throwingFirstError( + execute: { () -> String in + actions.append("execute") + return "foo" + }, + finally: { + actions.append("finally") + }) + XCTAssertEqual(result, "foo") + XCTAssertEqual(actions, ["execute", "finally"]) + } catch { + XCTFail("Unexpected error: \(error)") + } + + do { + actions = [] + _ = try throwingFirstError( + execute: { () -> String in + actions.append("execute") + throw ExecuteError() + }, + finally: { + actions.append("finally") + }) + XCTFail("Expected error") + } catch is ExecuteError { + XCTAssertEqual(actions, ["execute", "finally"]) + } catch { + XCTFail("Unexpected error: \(error)") + } + + do { + actions = [] + _ = try throwingFirstError( + execute: { () -> String in + actions.append("execute") + return "foo" + }, + finally: { + actions.append("finally") + throw FinallyError() + }) + XCTFail("Expected error") + } catch is FinallyError { + XCTAssertEqual(actions, ["execute", "finally"]) + } catch { + XCTFail("Unexpected error: \(error)") + } + + do { + actions = [] + _ = try throwingFirstError( + execute: { () -> String in + actions.append("execute") + throw ExecuteError() + }, + finally: { + actions.append("finally") + throw FinallyError() + }) + XCTFail("Expected error") + } catch is ExecuteError { + XCTAssertEqual(actions, ["execute", "finally"]) + } catch { + XCTFail("Unexpected error: \(error)") + } + } +} From a0faf6aa6840ea8a04f383a32d50e8d86b262ef9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gwendal=20Roue=CC=81?= Date: Wed, 18 Mar 2020 19:34:43 +0100 Subject: [PATCH 05/10] Support for nil assignment --- CHANGELOG.md | 7 +++++++ .../Request/QueryInterfaceRequest.swift | 16 ++++++++++++++-- .../SQLGeneration/SQLQueryGenerator.swift | 12 ++---------- README.md | 4 ++-- .../MutablePersistableRecordUpdateTests.swift | 11 +++++++++++ 5 files changed, 36 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14cd76cefc..9198d105ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,13 @@ GRDB adheres to [Semantic Versioning](https://semver.org/), with one exception: **New** +- Batch updates now accept nil assignments: + + ```swift + // UPDATE player SET score = NULL + try Player.updateAll(db, scoreColumn <- nil) + ``` + - DatabaseMigrator querying methods have been enhanced: ```swift diff --git a/GRDB/QueryInterface/Request/QueryInterfaceRequest.swift b/GRDB/QueryInterface/Request/QueryInterfaceRequest.swift index f0cc36f576..1bb0fcd03f 100644 --- a/GRDB/QueryInterface/Request/QueryInterfaceRequest.swift +++ b/GRDB/QueryInterface/Request/QueryInterfaceRequest.swift @@ -644,20 +644,32 @@ infix operator <- : ColumnAssignment /// } public struct ColumnAssignment { var column: ColumnExpression - var value: SQLExpressible + var value: SQLExpressible? + + func sql(_ context: inout SQLGenerationContext) -> String { + if let value = value { + return column.expressionSQL(&context, wrappedInParenthesis: false) + + " = " + + value.sqlExpression.expressionSQL(&context, wrappedInParenthesis: false) + } else { + return column.expressionSQL(&context, wrappedInParenthesis: false) + + " = NULL" + } + } } /// Creates an assignment to a value. /// /// Column("valid") <- true /// Column("score") <- 0 +/// Column("score") <- nil /// Column("score") <- Column("score") + Column("bonus") /// /// try dbQueue.write { db in /// // UPDATE player SET score = 0 /// try Player.updateAll(db, Column("score") <- 0) /// } -public func <- (column: ColumnExpression, value: SQLExpressible) -> ColumnAssignment { +public func <- (column: ColumnExpression, value: SQLExpressible?) -> ColumnAssignment { return ColumnAssignment(column: column, value: value) } diff --git a/GRDB/QueryInterface/SQLGeneration/SQLQueryGenerator.swift b/GRDB/QueryInterface/SQLGeneration/SQLQueryGenerator.swift index 7114af533c..bc017284ab 100644 --- a/GRDB/QueryInterface/SQLGeneration/SQLQueryGenerator.swift +++ b/GRDB/QueryInterface/SQLGeneration/SQLQueryGenerator.swift @@ -228,11 +228,7 @@ struct SQLQueryGenerator { sql += try relation.source.sql(db, &context) let assignmentsSQL = assignments - .map({ assignment in - assignment.column.expressionSQL(&context, wrappedInParenthesis: false) + - " = " + - assignment.value.sqlExpression.expressionSQL(&context, wrappedInParenthesis: false) - }) + .map { $0.sql(&context) } .joined(separator: ", ") sql += " SET " + assignmentsSQL @@ -297,11 +293,7 @@ struct SQLQueryGenerator { sql += tableName.quotedDatabaseIdentifier let assignmentsSQL = assignments - .map({ assignment in - assignment.column.expressionSQL(&context, wrappedInParenthesis: false) + - " = " + - assignment.value.sqlExpression.expressionSQL(&context, wrappedInParenthesis: false) - }) + .map { $0.sql(&context) } .joined(separator: ", ") sql += " SET " + assignmentsSQL sql += " WHERE rowid IN (\(selectSQL))" diff --git a/README.md b/README.md index 669793c33a..55093ff56b 100644 --- a/README.md +++ b/README.md @@ -4699,8 +4699,8 @@ Player.deleteOne(db, key: ["email": "arthur@example.com"]) **Requests can batch update records**. The `updateAll()` method accepts *column assignments* defined with the `<-` operator: ```swift -// UPDATE player SET score = 0, isHealthy = 1 -try Player.updateAll(db, scoreColumn <- 0, isHealthyColumn <- true) +// UPDATE player SET score = 0, isHealthy = 1, bonus = NULL +try Player.updateAll(db, scoreColumn <- 0, isHealthyColumn <- true, bonus <- nil) // UPDATE player SET score = 0 WHERE team = 'red' try Player diff --git a/Tests/GRDBTests/MutablePersistableRecordUpdateTests.swift b/Tests/GRDBTests/MutablePersistableRecordUpdateTests.swift index 7c85037e5e..b024d590d7 100644 --- a/Tests/GRDBTests/MutablePersistableRecordUpdateTests.swift +++ b/Tests/GRDBTests/MutablePersistableRecordUpdateTests.swift @@ -106,6 +106,17 @@ class MutablePersistableRecordUpdateTests: GRDBTestCase { } } + func testNilAssignment() throws { + try makeDatabaseQueue().write { db in + try Player.createTable(db) + + try Player.updateAll(db, Columns.score <- nil) + XCTAssertEqual(self.lastSQLQuery, """ + UPDATE "player" SET "score" = NULL + """) + } + } + func testComplexAssignment() throws { try makeDatabaseQueue().write { db in try Player.createTable(db) From c1d1797576977e1b1af1a1346b005cf189b9569a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gwendal=20Roue=CC=81?= Date: Wed, 18 Mar 2020 20:36:56 +0100 Subject: [PATCH 06/10] Address #725 --- GRDB/Migration/DatabaseMigrator.swift | 80 +++++++++++++-------- Tests/GRDBTests/DatabaseMigratorTests.swift | 27 +++++++ 2 files changed, 78 insertions(+), 29 deletions(-) diff --git a/GRDB/Migration/DatabaseMigrator.swift b/GRDB/Migration/DatabaseMigrator.swift index b34618f592..9884de4c97 100644 --- a/GRDB/Migration/DatabaseMigrator.swift +++ b/GRDB/Migration/DatabaseMigrator.swift @@ -142,30 +142,44 @@ public struct DatabaseMigrator { public func migrate(_ writer: DatabaseWriter, upTo targetIdentifier: String) throws { try writer.barrierWriteWithoutTransaction { db in if eraseDatabaseOnSchemaChange { - // Fetch information about current database state - var info: (lastAppliedIdentifier: String, schema: SchemaInfo)? + var needsErase = false try db.inTransaction(.deferred) { - if let lastAppliedIdentifier = try appliedMigrations(db).last { - info = try (lastAppliedIdentifier: lastAppliedIdentifier, schema: db.schema()) + let appliedIdentifiers = try self.appliedIdentifiers(db) + let knownIdentifiers = Set(migrations.map { $0.identifier }) + if !appliedIdentifiers.isSubset(of: knownIdentifiers) { + // Database contains an unknown migration + needsErase = true + return .commit } - return .commit - } - - if let info = info { - // Create a temporary witness database (on disk, just in case - // migrations would involve a lot of data). - let witness = try DatabaseQueue(path: "", configuration: writer.configuration) - // Grab schema of migrated witness database - let witnessSchema: SchemaInfo = try witness.writeWithoutTransaction { db in - try runMigrations(db, upTo: info.lastAppliedIdentifier) - return try db.schema() + if let lastAppliedIdentifier = migrations.lazy + .map({ $0.identifier }) + .last(where: { appliedIdentifiers.contains($0) }) + { + // Database has been partially migrated. + // + // Create a temporary witness database (on disk, just in case + // migrations would involve a lot of data). + let witness = try DatabaseQueue(path: "", configuration: writer.configuration) + + // Grab schema of migrated witness database + let witnessSchema: SchemaInfo = try witness.writeWithoutTransaction { db in + try runMigrations(db, upTo: lastAppliedIdentifier) + return try db.schema() + } + + // Erase database if we detect a schema change + if try db.schema() != witnessSchema { + needsErase = true + return .commit + } } - // Erase database if we detect a schema change - if info.schema != witnessSchema { - try db.erase() - } + return .commit + } + + if needsErase { + try db.erase() } } @@ -191,16 +205,8 @@ public struct DatabaseMigrator { /// - parameter db: A database connection. /// - throws: An eventual database error. public func appliedMigrations(_ db: Database) throws -> [String] { - do { - let appliedIdentifiers = try Set(String.fetchCursor(db, sql: "SELECT identifier FROM grdb_migrations")) - return migrations.map { $0.identifier }.filter { appliedIdentifiers.contains($0) } - } catch { - // Rethrow if we can't prove grdb_migrations does not exist yet - if (try? !db.tableExists("grdb_migrations")) ?? false { - return [] - } - throw error - } + let appliedIdentifiers = try self.appliedIdentifiers(db) + return migrations.map { $0.identifier }.filter { appliedIdentifiers.contains($0) } } /// Returns the identifiers of completed migrations, of which all previous @@ -264,6 +270,22 @@ public struct DatabaseMigrator { migrations.append(migration) } + /// Returns the applied migration identifiers, even unregistered ones + /// + /// - parameter db: A database connection. + /// - throws: An eventual database error. + public func appliedIdentifiers(_ db: Database) throws -> Set { + do { + return try Set(String.fetchCursor(db, sql: "SELECT identifier FROM grdb_migrations")) + } catch { + // Rethrow if we can't prove grdb_migrations does not exist yet + if (try? !db.tableExists("grdb_migrations")) ?? false { + return [] + } + throw error + } + } + /// Returns unapplied migration identifier, private func unappliedMigrations(upTo targetIdentifier: String, appliedIdentifiers: [String]) -> [Migration] { var expectedMigrations: [Migration] = [] diff --git a/Tests/GRDBTests/DatabaseMigratorTests.swift b/Tests/GRDBTests/DatabaseMigratorTests.swift index fdfc06236a..ce8e8d8c58 100644 --- a/Tests/GRDBTests/DatabaseMigratorTests.swift +++ b/Tests/GRDBTests/DatabaseMigratorTests.swift @@ -674,4 +674,31 @@ class DatabaseMigratorTests : GRDBTestCase { try XCTAssertEqual(dbQueue.read { try Int.fetchOne($0, sql: "SELECT id FROM t1") }, 1) try XCTAssertTrue(dbQueue.read { try $0.tableExists("t2") }) } + + func testEraseDatabaseOnSchemaChangeWithRenamedMigration() throws { + let dbQueue = try makeDatabaseQueue() + + // 1st migration + var migrator1 = DatabaseMigrator() + migrator1.registerMigration("1") { db in + try db.execute(sql: """ + CREATE TABLE t1(id INTEGER PRIMARY KEY); + INSERT INTO t1(id) VALUES (1) + """) + } + try migrator1.migrate(dbQueue) + try XCTAssertEqual(dbQueue.read { try Int.fetchOne($0, sql: "SELECT id FROM t1") }, 1) + + // 2nd migration does not erase database + var migrator2 = DatabaseMigrator() + migrator2.eraseDatabaseOnSchemaChange = true + migrator2.registerMigration("2") { db in + try db.execute(sql: """ + CREATE TABLE t1(id INTEGER PRIMARY KEY); + INSERT INTO t1(id) VALUES (2) + """) + } + try migrator2.migrate(dbQueue) + try XCTAssertEqual(dbQueue.read { try Int.fetchOne($0, sql: "SELECT id FROM t1") }, 2) + } } From cda254bb2d9d25958911b2b08b8399e9bbb55f92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gwendal=20Roue=CC=81?= Date: Wed, 18 Mar 2020 20:43:09 +0100 Subject: [PATCH 07/10] Update documentation for eraseDatabaseOnSchemaChange --- README.md | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 55093ff56b..aa978b8b39 100644 --- a/README.md +++ b/README.md @@ -4966,32 +4966,31 @@ if try dbQueue.read(migrator.appliedMigrations).contains("v2") { ### The `eraseDatabaseOnSchemaChange` Option -A DatabaseMigrator can automatically wipe out the full database content, and recreate the whole database from scratch, if it detects that a migration has changed its definition: +A DatabaseMigrator can automatically wipe out the full database content, and recreate the whole database from scratch, if it detects that migrations have changed their definition: ```swift var migrator = DatabaseMigrator() migrator.eraseDatabaseOnSchemaChange = true ``` -Beware! This flag can destroy your precious users' data! +> :warning: **Warning**: This option can destroy your precious users' data! -Yet it may be useful in those two situations: +Setting `eraseDatabaseOnSchemaChange` is useful during application development, as you are still designing migrations, and the schema changes often. -1. During application development, as you are still designing migrations, and the schema changes often. - - In this case, it is recommended that this flag does not ship in the distributed application: - - ```swift - var migrator = DatabaseMigrator() - #if DEBUG - // Speed up development by nuking the database when migrations change - migrator.eraseDatabaseOnSchemaChange = true - #endif - ``` +It is recommended that this option does not ship in the released application: + +```swift +var migrator = DatabaseMigrator() +#if DEBUG +// Speed up development by nuking the database when migrations change +migrator.eraseDatabaseOnSchemaChange = true +#endif +``` -2. When the database content can easily be recreated, such as a cache for some downloaded data. +The `eraseDatabaseOnSchemaChange` option triggers a recreation of the database if and only if: -The `eraseDatabaseOnSchemaChange` option triggers a recreation of the database if the migrator detects a *schema change*. A schema change is any difference in the `sqlite_master` table, which contains the SQL used to create database tables, indexes, triggers, and views. +- A migration has been removed, or renamed. +- A *schema change* is detected. A schema change is any difference in the `sqlite_master` table, which contains the SQL used to create database tables, indexes, triggers, and views. ### Advanced Database Schema Changes From f04efea09c823e73c74872a89dd93a711040688b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gwendal=20Roue=CC=81?= Date: Wed, 18 Mar 2020 20:47:04 +0100 Subject: [PATCH 08/10] CHANGELOG --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9198d105ec..00fcbdf3c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,8 @@ GRDB adheres to [Semantic Versioning](https://semver.org/), with one exception: // UPDATE player SET score = NULL try Player.updateAll(db, scoreColumn <- nil) ``` + +- DatabaseMigrator can now recreate the database if a migration has been removed, or renamed (addresses [#725](https://github.com/groue/GRDB.swift/issues/725)). - DatabaseMigrator querying methods have been enhanced: From 24872373c4e2d095f658ef22f302c12f6146d407 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gwendal=20Roue=CC=81?= Date: Sat, 21 Mar 2020 12:26:35 +0100 Subject: [PATCH 09/10] TODO --- TODO.md | 1 + 1 file changed, 1 insertion(+) diff --git a/TODO.md b/TODO.md index e41d29cfa5..098b6552cf 100644 --- a/TODO.md +++ b/TODO.md @@ -20,6 +20,7 @@ ## Features +- [ ] request.exists(db) as an alternative to fetchOne(db) != nil. Can generate optimized SQL. - [ ] Measure the duration of transactions - [ ] Improve SQL generation for `Player.....fetchCount(db)`, especially with distinct. Try to avoid `SELECT COUNT(*) FROM (SELECT DISTINCT player.* ...)` - [ ] Alternative technique for custom SQLite builds: see the Podfile at https://github.com/CocoaPods/CocoaPods/issues/9104, and https://github.com/clemensg/sqlite3pod From 50f64dc45d7eafa0378d6fa123e21e0216c9e7e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gwendal=20Roue=CC=81?= Date: Sat, 21 Mar 2020 12:54:30 +0100 Subject: [PATCH 10/10] v4.12.0 --- CHANGELOG.md | 9 +++++++++ GRDB.swift.podspec | 2 +- Makefile | 6 +++--- README.md | 42 +++++++++++++++++++++--------------------- Support/Info.plist | 2 +- 5 files changed, 35 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00fcbdf3c2..14ebd92e84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,14 @@ All notable changes to this project will be documented in this file. GRDB adheres to [Semantic Versioning](https://semver.org/), with one exception: APIs flagged [**:fire: EXPERIMENTAL**](README.md#what-are-experimental-features). Those are unstable, and may break between any two minor releases of the library. + #### 4.x Releases +- `4.12.x` Releases - [4.12.0](#4120) - `4.11.x` Releases - [4.11.0](#4110) - `4.10.x` Releases - [4.10.0](#4100) - `4.9.x` Releases - [4.9.0](#490) @@ -62,7 +65,13 @@ GRDB adheres to [Semantic Versioning](https://semver.org/), with one exception: - [0.110.0](#01100), ... + + +## 4.12.0 + +Released March 21, 2020 • [diff](https://github.com/groue/GRDB.swift/compare/v4.11.0...v4.12.0) **New** diff --git a/GRDB.swift.podspec b/GRDB.swift.podspec index cef26d10a5..43f36d5598 100644 --- a/GRDB.swift.podspec +++ b/GRDB.swift.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'GRDB.swift' - s.version = '4.11.0' + s.version = '4.12.0' s.license = { :type => 'MIT', :file => 'LICENSE' } s.summary = 'A toolkit for SQLite databases, with a focus on application development.' diff --git a/Makefile b/Makefile index b47878ecf6..a770e0599f 100644 --- a/Makefile +++ b/Makefile @@ -466,10 +466,10 @@ ifdef JAZZY --author 'Gwendal Roué' \ --author_url https://github.com/groue \ --github_url https://github.com/groue/GRDB.swift \ - --github-file-prefix https://github.com/groue/GRDB.swift/tree/v4.11.0 \ - --module-version 4.11.0 \ + --github-file-prefix https://github.com/groue/GRDB.swift/tree/v4.12.0 \ + --module-version 4.12.0 \ --module GRDB \ - --root-url http://groue.github.io/GRDB.swift/docs/4.11/ \ + --root-url http://groue.github.io/GRDB.swift/docs/4.12/ \ --output Documentation/Reference \ --podspec GRDB.swift.podspec else diff --git a/README.md b/README.md index aa978b8b39..52e3e1c8bb 100644 --- a/README.md +++ b/README.md @@ -12,14 +12,14 @@ --- -**Latest release**: March 2, 2020 • version 4.11.0 • [CHANGELOG](CHANGELOG.md) • [Migrating From GRDB 3 to GRDB 4](Documentation/GRDB3MigrationGuide.md) +**Latest release**: March 21, 2020 • version 4.12.0 • [CHANGELOG](CHANGELOG.md) • [Migrating From GRDB 3 to GRDB 4](Documentation/GRDB3MigrationGuide.md) **Requirements**: iOS 9.0+ / macOS 10.9+ / tvOS 9.0+ / watchOS 2.0+ • Swift 4.2+ / Xcode 10.0+ | Swift version | GRDB version | | ------------- | ----------------------------------------------------------- | -| **Swift 5** | **v4.11.0** | -| **Swift 4.2** | **v4.11.0** | +| **Swift 5** | **v4.12.0** | +| **Swift 4.2** | **v4.12.0** | | Swift 4.1 | [v3.7.0](https://github.com/groue/GRDB.swift/tree/v3.7.0) | | Swift 4 | [v2.10.0](https://github.com/groue/GRDB.swift/tree/v2.10.0) | | Swift 3.2 | [v1.3.0](https://github.com/groue/GRDB.swift/tree/v1.3.0) | @@ -263,7 +263,7 @@ Documentation #### Reference -- [GRDB Reference](http://groue.github.io/GRDB.swift/docs/4.11/index.html) (generated by [Jazzy](https://github.com/realm/jazzy)) +- [GRDB Reference](http://groue.github.io/GRDB.swift/docs/4.12/index.html) (generated by [Jazzy](https://github.com/realm/jazzy)) #### Getting Started @@ -478,7 +478,7 @@ let dbQueue = try DatabaseQueue( configuration: config) ``` -See [Configuration](http://groue.github.io/GRDB.swift/docs/4.11/Structs/Configuration.html) for more details. +See [Configuration](http://groue.github.io/GRDB.swift/docs/4.12/Structs/Configuration.html) for more details. ## Database Pools @@ -559,7 +559,7 @@ let dbPool = try DatabasePool( configuration: config) ``` -See [Configuration](http://groue.github.io/GRDB.swift/docs/4.11/Structs/Configuration.html) for more details. +See [Configuration](http://groue.github.io/GRDB.swift/docs/4.12/Structs/Configuration.html) for more details. Database pools are more memory-hungry than database queues. See [Memory Management](#memory-management) for more information. @@ -619,7 +619,7 @@ try dbQueue.write { db in } ``` -The `?` and colon-prefixed keys like `:score` in the SQL query are the **statements arguments**. You pass arguments with arrays or dictionaries, as in the example above. See [Values](#values) for more information on supported arguments types (Bool, Int, String, Date, Swift enums, etc.), and [StatementArguments](http://groue.github.io/GRDB.swift/docs/4.11/Structs/StatementArguments.html) for a detailed documentation of SQLite arguments. +The `?` and colon-prefixed keys like `:score` in the SQL query are the **statements arguments**. You pass arguments with arrays or dictionaries, as in the example above. See [Values](#values) for more information on supported arguments types (Bool, Int, String, Date, Swift enums, etc.), and [StatementArguments](http://groue.github.io/GRDB.swift/docs/4.12/Structs/StatementArguments.html) for a detailed documentation of SQLite arguments. In Swift 5, you can embed query arguments right into your SQL queries, with the `literal` argument label, as in the example below. See [SQL Interpolation] for more details. @@ -820,7 +820,7 @@ Both arrays and cursors can iterate over database results. How do you choose one - **Cursors are granted with direct access to SQLite,** unlike arrays that have to take the time to copy database values. If you look after extra performance, you may prefer cursors over arrays. -- **Cursors adopt the [Cursor](http://groue.github.io/GRDB.swift/docs/4.11/Protocols/Cursor.html) protocol, which looks a lot like standard [lazy sequences](https://developer.apple.com/reference/swift/lazysequenceprotocol) of Swift.** As such, cursors come with many convenience methods: `compactMap`, `contains`, `dropFirst`, `dropLast`, `drop(while:)`, `enumerated`, `filter`, `first`, `flatMap`, `forEach`, `joined`, `joined(separator:)`, `max`, `max(by:)`, `min`, `min(by:)`, `map`, `prefix`, `prefix(while:)`, `reduce`, `reduce(into:)`, `suffix`: +- **Cursors adopt the [Cursor](http://groue.github.io/GRDB.swift/docs/4.12/Protocols/Cursor.html) protocol, which looks a lot like standard [lazy sequences](https://developer.apple.com/reference/swift/lazysequenceprotocol) of Swift.** As such, cursors come with many convenience methods: `compactMap`, `contains`, `dropFirst`, `dropLast`, `drop(while:)`, `enumerated`, `filter`, `first`, `flatMap`, `forEach`, `joined`, `joined(separator:)`, `max`, `max(by:)`, `min`, `min(by:)`, `map`, `prefix`, `prefix(while:)`, `reduce`, `reduce(into:)`, `suffix`: ```swift // Prints all Github links @@ -902,7 +902,7 @@ let rows = try Row.fetchAll(db, arguments: ["name": "Arthur"]) ``` -See [Values](#values) for more information on supported arguments types (Bool, Int, String, Date, Swift enums, etc.), and [StatementArguments](http://groue.github.io/GRDB.swift/docs/4.11/Structs/StatementArguments.html) for a detailed documentation of SQLite arguments. +See [Values](#values) for more information on supported arguments types (Bool, Int, String, Date, Swift enums, etc.), and [StatementArguments](http://groue.github.io/GRDB.swift/docs/4.12/Structs/StatementArguments.html) for a detailed documentation of SQLite arguments. Unlike row arrays that contain copies of the database rows, row cursors are close to the SQLite metal, and require a little care: @@ -1185,7 +1185,7 @@ GRDB ships with built-in support for the following value types: - Generally speaking, all types that adopt the [DatabaseValueConvertible](#custom-value-types) protocol. -Values can be used as [statement arguments](http://groue.github.io/GRDB.swift/docs/4.11/Structs/StatementArguments.html): +Values can be used as [statement arguments](http://groue.github.io/GRDB.swift/docs/4.12/Structs/StatementArguments.html): ```swift let url: URL = ... @@ -1592,7 +1592,7 @@ try dbQueue.inDatabase { db in // or dbPool.writeWithoutTransaction } ``` -Transactions can't be left opened unless you set the [allowsUnsafeTransactions](http://groue.github.io/GRDB.swift/docs/4.11/Structs/Configuration.html) configuration flag: +Transactions can't be left opened unless you set the [allowsUnsafeTransactions](http://groue.github.io/GRDB.swift/docs/4.12/Structs/Configuration.html) configuration flag: ```swift // fatal error: A transaction has been left opened at the end of a database access @@ -1706,7 +1706,7 @@ try dbQueue.write { db in } ``` -The `?` and colon-prefixed keys like `:name` in the SQL query are the statement arguments. You set them with arrays or dictionaries (arguments are actually of type [StatementArguments](http://groue.github.io/GRDB.swift/docs/4.11/Structs/StatementArguments.html), which happens to adopt the ExpressibleByArrayLiteral and ExpressibleByDictionaryLiteral protocols). +The `?` and colon-prefixed keys like `:name` in the SQL query are the statement arguments. You set them with arrays or dictionaries (arguments are actually of type [StatementArguments](http://groue.github.io/GRDB.swift/docs/4.12/Structs/StatementArguments.html), which happens to adopt the ExpressibleByArrayLiteral and ExpressibleByDictionaryLiteral protocols). ```swift updateStatement.arguments = ["name": "Arthur", "score": 1000] @@ -1965,7 +1965,7 @@ row["consumed"] // "Hello" row["produced"] // nil ``` -Row adapters are values that adopt the [RowAdapter](http://groue.github.io/GRDB.swift/docs/4.11/Protocols/RowAdapter.html) protocol. You can implement your own custom adapters ([**:fire: EXPERIMENTAL**](#what-are-experimental-features)), or use one of the four built-in adapters, described below. +Row adapters are values that adopt the [RowAdapter](http://groue.github.io/GRDB.swift/docs/4.12/Protocols/RowAdapter.html) protocol. You can implement your own custom adapters ([**:fire: EXPERIMENTAL**](#what-are-experimental-features)), or use one of the four built-in adapters, described below. To see how row adapters can be used, see [Joined Queries Support](#joined-queries-support). @@ -2427,7 +2427,7 @@ try Place.fetchAll(db, sql: "SELECT ...", arguments:...) // [Place] try Place.fetchOne(db, sql: "SELECT ...", arguments:...) // Place? ``` -See [fetching methods](#fetching-methods) for information about the `fetchCursor`, `fetchAll` and `fetchOne` methods. See [StatementArguments](http://groue.github.io/GRDB.swift/docs/4.11/Structs/StatementArguments.html) for more information about the query arguments. +See [fetching methods](#fetching-methods) for information about the `fetchCursor`, `fetchAll` and `fetchOne` methods. See [StatementArguments](http://groue.github.io/GRDB.swift/docs/4.12/Structs/StatementArguments.html) for more information about the query arguments. > :point_up: **Note**: for performance reasons, the same row argument to `init(row:)` is reused during the iteration of a fetch query. If you want to keep the row for later use, make sure to store a copy: `self.row = row.copy()`. @@ -2808,7 +2808,7 @@ protocol EncodableRecord { } ``` -See [DatabaseDateDecodingStrategy](https://groue.github.io/GRDB.swift/docs/4.11/Enums/DatabaseDateDecodingStrategy.html), [DatabaseDateEncodingStrategy](https://groue.github.io/GRDB.swift/docs/4.11/Enums/DatabaseDateEncodingStrategy.html), and [DatabaseUUIDEncodingStrategy](https://groue.github.io/GRDB.swift/docs/4.11/Enums/DatabaseUUIDEncodingStrategy.html) to learn about all available strategies. +See [DatabaseDateDecodingStrategy](https://groue.github.io/GRDB.swift/docs/4.12/Enums/DatabaseDateDecodingStrategy.html), [DatabaseDateEncodingStrategy](https://groue.github.io/GRDB.swift/docs/4.12/Enums/DatabaseDateEncodingStrategy.html), and [DatabaseUUIDEncodingStrategy](https://groue.github.io/GRDB.swift/docs/4.12/Enums/DatabaseUUIDEncodingStrategy.html) to learn about all available strategies. > :point_up: **Note**: there is no customization of uuid decoding, because UUID can already decode all its encoded variants (16-bytes blobs, and uuid strings). @@ -4197,7 +4197,7 @@ Player // SELECT * FROM player ``` -Raw SQL snippets are also accepted, with eventual [arguments](http://groue.github.io/GRDB.swift/docs/4.11/Structs/StatementArguments.html): +Raw SQL snippets are also accepted, with eventual [arguments](http://groue.github.io/GRDB.swift/docs/4.12/Structs/StatementArguments.html): ```swift // SELECT DATE(creationDate), COUNT(*) FROM player WHERE name = 'Arthur' GROUP BY date(creationDate) @@ -4812,7 +4812,7 @@ let request = Player.all() **To build custom requests**, you can use one of the built-in requests, derive requests from other requests, or create your own request type that adopts the [FetchRequest](#fetchrequest-protocol) protocol. -- [SQLRequest](http://groue.github.io/GRDB.swift/docs/4.11/Structs/SQLRequest.html) is a fetch request built from raw SQL. For example: +- [SQLRequest](http://groue.github.io/GRDB.swift/docs/4.12/Structs/SQLRequest.html) is a fetch request built from raw SQL. For example: ```swift extension Player { @@ -4856,7 +4856,7 @@ let request = Player.all() - The `adapted(_:)` method eases the consumption of complex rows with [row adapters](#row-adapters). See [Joined Queries Support](#joined-queries-support) for some sample code that uses this method. -- [AnyFetchRequest](http://groue.github.io/GRDB.swift/docs/4.11/Structs/AnyFetchRequest.html): a [type-erased](http://chris.eidhof.nl/post/type-erasers-in-swift/) request. +- [AnyFetchRequest](http://groue.github.io/GRDB.swift/docs/4.12/Structs/AnyFetchRequest.html): a [type-erased](http://chris.eidhof.nl/post/type-erasers-in-swift/) request. ### Fetching From Custom Requests @@ -6401,7 +6401,7 @@ After `stopObservingDatabaseChangesUntilNextTransaction()`, the `databaseDidChan ### DatabaseRegion -**[DatabaseRegion](https://groue.github.io/GRDB.swift/docs/4.11/Structs/DatabaseRegion.html) is a type that helps observing changes in the results of a database [request](#requests)**. +**[DatabaseRegion](https://groue.github.io/GRDB.swift/docs/4.12/Structs/DatabaseRegion.html) is a type that helps observing changes in the results of a database [request](#requests)**. A request knows which database modifications can impact its results. It can communicate this information to [transaction observers](#transactionobserver-protocol) by the way of a DatabaseRegion. @@ -6411,7 +6411,7 @@ DatabaseRegion fuels, for example, [ValueObservation and DatabaseRegionObservati For example, if you observe the region of `Player.select(max(Column("score")))`, then you'll get be notified of all changes performed on the `score` column of the `player` table (updates, insertions and deletions), even if they do not modify the value of the maximum score. However, you will not get any notification for changes performed on other database tables, or updates to other columns of the player table. -For more details, see the [reference](http://groue.github.io/GRDB.swift/docs/4.11/Structs/DatabaseRegion.html#/s:4GRDB14DatabaseRegionV10isModified2bySbAA0B5EventV_tF). +For more details, see the [reference](http://groue.github.io/GRDB.swift/docs/4.12/Structs/DatabaseRegion.html#/s:4GRDB14DatabaseRegionV10isModified2bySbAA0B5EventV_tF). #### The DatabaseRegionConvertible Protocol @@ -7509,7 +7509,7 @@ try snapshot2.read { db in ### DatabaseWriter and DatabaseReader Protocols -Both DatabaseQueue and DatabasePool adopt the [DatabaseReader](http://groue.github.io/GRDB.swift/docs/4.11/Protocols/DatabaseReader.html) and [DatabaseWriter](http://groue.github.io/GRDB.swift/docs/4.11/Protocols/DatabaseWriter.html) protocols. DatabaseSnapshot adopts DatabaseReader only. +Both DatabaseQueue and DatabasePool adopt the [DatabaseReader](http://groue.github.io/GRDB.swift/docs/4.12/Protocols/DatabaseReader.html) and [DatabaseWriter](http://groue.github.io/GRDB.swift/docs/4.12/Protocols/DatabaseWriter.html) protocols. DatabaseSnapshot adopts DatabaseReader only. These protocols provide a unified API that let you write generic code that targets all concurrency modes. They fuel, for example: diff --git a/Support/Info.plist b/Support/Info.plist index 739e622608..a8d32d6b43 100644 --- a/Support/Info.plist +++ b/Support/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType FMWK CFBundleShortVersionString - 4.11.0 + 4.12.0 CFBundleSignature ???? CFBundleVersion