From a6e6e687b47944b4c338e23bb054fd925998222e Mon Sep 17 00:00:00 2001 From: Jan Berkel Date: Tue, 26 Jul 2022 00:44:55 +0200 Subject: [PATCH 01/12] WIP --- Sources/SQLite/Schema/Connection+Schema.swift | 2 +- Sources/SQLite/Schema/SchemaChanger.swift | 73 ++++++++++++++----- Sources/SQLite/Schema/SchemaDefinitions.swift | 20 +++-- .../Schema/Connection+SchemaTests.swift | 18 ++--- .../Schema/SchemaChangerTests.swift | 30 +++++++- .../Schema/SchemaDefinitionsTests.swift | 26 +++---- 6 files changed, 119 insertions(+), 50 deletions(-) diff --git a/Sources/SQLite/Schema/Connection+Schema.swift b/Sources/SQLite/Schema/Connection+Schema.swift index c3b24f38..378fad68 100644 --- a/Sources/SQLite/Schema/Connection+Schema.swift +++ b/Sources/SQLite/Schema/Connection+Schema.swift @@ -24,7 +24,7 @@ extension Connection { return ColumnDefinition(name: name, primaryKey: primaryKey == 1 ? try parsePrimaryKey(column: name) : nil, type: ColumnDefinition.Affinity.from(type), - null: notNull == 0, + nullable: notNull == 0, defaultValue: .from(defaultValue), references: foreignKeys[name]?.first) } diff --git a/Sources/SQLite/Schema/SchemaChanger.swift b/Sources/SQLite/Schema/SchemaChanger.swift index c6529fbe..daba2f60 100644 --- a/Sources/SQLite/Schema/SchemaChanger.swift +++ b/Sources/SQLite/Schema/SchemaChanger.swift @@ -27,36 +27,63 @@ import Foundation 12. If foreign keys constraints were originally enabled, reenable them now. */ public class SchemaChanger: CustomStringConvertible { - enum SchemaChangeError: LocalizedError { + public enum Error: LocalizedError { + case invalidColumnDefinition(String) case foreignKeyError([ForeignKeyError]) - var errorDescription: String? { + public var errorDescription: String? { switch self { case .foreignKeyError(let errors): return "Foreign key errors: \(errors)" + case .invalidColumnDefinition(let message): + return "Invalid column definition: \(message)" } } } public enum Operation { - case none - case add(ColumnDefinition) - case remove(String) + case addColumn(ColumnDefinition) + case dropColumn(String) case renameColumn(String, String) case renameTable(String) /// Returns non-nil if the operation can be executed with a simple SQL statement func toSQL(_ table: String, version: SQLiteVersion) -> String? { switch self { - case .add(let definition): + case .addColumn(let definition): return "ALTER TABLE \(table.quote()) ADD COLUMN \(definition.toSQL())" case .renameColumn(let from, let to) where version >= (3, 25, 0): return "ALTER TABLE \(table.quote()) RENAME COLUMN \(from.quote()) TO \(to.quote())" - case .remove(let column) where version >= (3, 35, 0): + case .dropColumn(let column) where version >= (3, 35, 0): return "ALTER TABLE \(table.quote()) DROP COLUMN \(column.quote())" default: return nil } } + + func validate() throws { + switch self { + case .addColumn(let definition): + // The new column may take any of the forms permissible in a CREATE TABLE statement, with the following restrictions: + // - The column may not have a PRIMARY KEY or UNIQUE constraint. + // - The column may not have a default value of CURRENT_TIME, CURRENT_DATE, CURRENT_TIMESTAMP, or an expression in parentheses + // - If a NOT NULL constraint is specified, then the column must have a default value other than NULL. + guard definition.primaryKey == nil else { + throw Error.invalidColumnDefinition("can not add primary key column") + } + let invalidValues: [LiteralValue] = [.CURRENT_TIME, .CURRENT_DATE, .CURRENT_TIMESTAMP] + if invalidValues.contains(definition.defaultValue) { + throw Error.invalidColumnDefinition("Invalid default value") + } + if !definition.nullable && definition.defaultValue == .NULL { + throw Error.invalidColumnDefinition("NOT NULL columns must have a default value other than NULL") + } + case .dropColumn: + // The DROP COLUMN command only works if the column is not referenced by any other parts of the schema + // and is not a PRIMARY KEY and does not have a UNIQUE constraint + break + default: break + } + } } public class AlterTableDefinition { @@ -69,11 +96,11 @@ public class SchemaChanger: CustomStringConvertible { } public func add(_ column: ColumnDefinition) { - operations.append(.add(column)) + operations.append(.addColumn(column)) } public func remove(_ column: String) { - operations.append(.remove(column)) + operations.append(.dropColumn(column)) } public func rename(_ column: String, to: String) { @@ -116,7 +143,15 @@ public class SchemaChanger: CustomStringConvertible { try dropTable(table) } + // Beginning with release 3.25.0 (2018-09-15), references to the table within trigger bodies and + // view definitions are also renamed. + public func rename(table: String, to: String) throws { + try connection.run("ALTER TABLE \(table.quote()) RENAME TO \(to.quote())") + } + private func run(table: String, operation: Operation) throws { + try operation.validate() + if let sql = operation.toSQL(table, version: version) { try connection.run(sql) } else { @@ -129,10 +164,10 @@ public class SchemaChanger: CustomStringConvertible { try disableRefIntegrity { let tempTable = "\(SchemaChanger.tempPrefix)\(table)" try moveTable(from: table, to: tempTable, options: [.temp], operation: operation) - try moveTable(from: tempTable, to: table) + try rename(table: tempTable, to: table) let foreignKeyErrors = try connection.foreignKeyCheck() if foreignKeyErrors.count > 0 { - throw SchemaChangeError.foreignKeyError(foreignKeyErrors) + throw Error.foreignKeyError(foreignKeyErrors) } } } @@ -153,22 +188,24 @@ public class SchemaChanger: CustomStringConvertible { try block() } - private func moveTable(from: String, to: String, options: Options = .default, operation: Operation = .none) throws { + private func moveTable(from: String, to: String, options: Options = .default, operation: Operation? = nil) throws { try copyTable(from: from, to: to, options: options, operation: operation) try dropTable(from) } - private func copyTable(from: String, to: String, options: Options = .default, operation: Operation) throws { + private func copyTable(from: String, to: String, options: Options = .default, operation: Operation?) throws { let fromDefinition = TableDefinition( name: from, columns: try connection.columnInfo(table: from), indexes: try connection.indexInfo(table: from) ) - let toDefinition = fromDefinition.apply(.renameTable(to)).apply(operation) + let toDefinition = fromDefinition + .apply(.renameTable(to)) + .apply(operation) try createTable(definition: toDefinition, options: options) try createTableIndexes(definition: toDefinition) - if case .remove = operation { + if case .dropColumn = operation { try copyTableContents(from: fromDefinition.apply(operation), to: toDefinition) } else { try copyTableContents(from: fromDefinition, to: toDefinition) @@ -221,11 +258,11 @@ extension IndexDefinition { } extension TableDefinition { - func apply(_ operation: SchemaChanger.Operation) -> TableDefinition { + func apply(_ operation: SchemaChanger.Operation?) -> TableDefinition { switch operation { case .none: return self - case .add: fatalError("Use 'ALTER TABLE ADD COLUMN (...)'") - case .remove(let column): + case .addColumn: fatalError("Use 'ALTER TABLE ADD COLUMN (...)'") + case .dropColumn(let column): return TableDefinition(name: name, columns: columns.filter { $0.name != column }, indexes: indexes.filter { !$0.columns.contains(column) } diff --git a/Sources/SQLite/Schema/SchemaDefinitions.swift b/Sources/SQLite/Schema/SchemaDefinitions.swift index 156a06fb..a06487b2 100644 --- a/Sources/SQLite/Schema/SchemaDefinitions.swift +++ b/Sources/SQLite/Schema/SchemaDefinitions.swift @@ -84,23 +84,27 @@ public struct ColumnDefinition: Equatable { public let name: String public let primaryKey: PrimaryKey? public let type: Affinity - public let null: Bool + public let nullable: Bool public let defaultValue: LiteralValue public let references: ForeignKey? - public init(name: String, primaryKey: PrimaryKey?, type: Affinity, null: Bool, defaultValue: LiteralValue, - references: ForeignKey?) { + public init(name: String, + primaryKey: PrimaryKey? = nil, + type: Affinity, + nullable: Bool = false, + defaultValue: LiteralValue = .NULL, + references: ForeignKey? = nil) { self.name = name self.primaryKey = primaryKey self.type = type - self.null = null + self.nullable = nullable self.defaultValue = defaultValue self.references = references } func rename(from: String, to: String) -> ColumnDefinition { guard from == name else { return self } - return ColumnDefinition(name: to, primaryKey: primaryKey, type: type, null: null, defaultValue: defaultValue, references: references) + return ColumnDefinition(name: to, primaryKey: primaryKey, type: type, nullable: nullable, defaultValue: defaultValue, references: references) } } @@ -254,12 +258,12 @@ public struct IndexDefinition: Equatable { } } -struct ForeignKeyError: CustomStringConvertible { +public struct ForeignKeyError: CustomStringConvertible { let from: String let rowId: Int64 let to: String - var description: String { + public var description: String { "\(from) [\(rowId)] => \(to)" } } @@ -294,7 +298,7 @@ extension ColumnDefinition { type.rawValue, defaultValue.map { "DEFAULT \($0)" }, primaryKey.map { $0.toSQL() }, - null ? nil : "NOT NULL", + nullable ? nil : "NOT NULL", references.map { $0.toSQL() } ].compactMap { $0 } .joined(separator: " ") diff --git a/Tests/SQLiteTests/Schema/Connection+SchemaTests.swift b/Tests/SQLiteTests/Schema/Connection+SchemaTests.swift index 56e79734..6eded6b2 100644 --- a/Tests/SQLiteTests/Schema/Connection+SchemaTests.swift +++ b/Tests/SQLiteTests/Schema/Connection+SchemaTests.swift @@ -14,42 +14,42 @@ class ConnectionSchemaTests: SQLiteTestCase { ColumnDefinition(name: "id", primaryKey: .init(autoIncrement: false, onConflict: nil), type: .INTEGER, - null: true, + nullable: true, defaultValue: .NULL, references: nil), ColumnDefinition(name: "email", primaryKey: nil, type: .TEXT, - null: false, + nullable: false, defaultValue: .NULL, references: nil), ColumnDefinition(name: "age", primaryKey: nil, type: .INTEGER, - null: true, + nullable: true, defaultValue: .NULL, references: nil), ColumnDefinition(name: "salary", primaryKey: nil, type: .REAL, - null: true, + nullable: true, defaultValue: .NULL, references: nil), ColumnDefinition(name: "admin", primaryKey: nil, type: .TEXT, - null: false, + nullable: false, defaultValue: .numericLiteral("0"), references: nil), ColumnDefinition(name: "manager_id", primaryKey: nil, type: .INTEGER, - null: true, + nullable: true, defaultValue: .NULL, references: .init(table: "users", column: "manager_id", primaryKey: "id", onUpdate: nil, onDelete: nil)), ColumnDefinition(name: "created_at", primaryKey: nil, type: .TEXT, - null: true, + nullable: true, defaultValue: .NULL, references: nil) ]) @@ -64,7 +64,7 @@ class ConnectionSchemaTests: SQLiteTestCase { name: "id", primaryKey: .init(autoIncrement: true, onConflict: .IGNORE), type: .INTEGER, - null: true, + nullable: true, defaultValue: .NULL, references: nil) ] @@ -80,7 +80,7 @@ class ConnectionSchemaTests: SQLiteTestCase { name: "id", primaryKey: .init(autoIncrement: false), type: .INTEGER, - null: true, + nullable: true, defaultValue: .NULL, references: nil) ] diff --git a/Tests/SQLiteTests/Schema/SchemaChangerTests.swift b/Tests/SQLiteTests/Schema/SchemaChangerTests.swift index 4d4f8d50..038a8844 100644 --- a/Tests/SQLiteTests/Schema/SchemaChangerTests.swift +++ b/Tests/SQLiteTests/Schema/SchemaChangerTests.swift @@ -90,7 +90,11 @@ class SchemaChangerTests: SQLiteTestCase { } func test_add_column() throws { - let newColumn = ColumnDefinition(name: "new_column", primaryKey: nil, type: .TEXT, null: true, defaultValue: .NULL, references: nil) + let column = Expression("new_column") + let newColumn = ColumnDefinition(name: "new_column", + type: .TEXT, + nullable: true, + defaultValue: .stringLiteral("foo")) try schemaChanger.alter(table: "users") { table in table.add(newColumn) @@ -98,6 +102,24 @@ class SchemaChangerTests: SQLiteTestCase { let columns = try db.columnInfo(table: "users") XCTAssertTrue(columns.contains(newColumn)) + + XCTAssertEqual(try db.pluck(users.select(column))?[column], "foo") + } + + func test_add_column_primary_key_fails() throws { + let newColumn = ColumnDefinition(name: "new_column", + primaryKey: .init(autoIncrement: false, onConflict: nil), + type: .TEXT) + + XCTAssertThrowsError(try schemaChanger.alter(table: "users") { table in + table.add(newColumn) + }) { error in + if case SchemaChanger.Error.invalidColumnDefinition(_) = error { + XCTAssertEqual("Invalid column definition: can not add primary key column", error.localizedDescription) + } else { + XCTFail("invalid error: \(error)") + } + } } func test_drop_table() throws { @@ -110,4 +132,10 @@ class SchemaChangerTests: SQLiteTestCase { } } } + + func test_rename_table() throws { + try schemaChanger.rename(table: "users", to: "users_new") + let users_new = Table("users_new") + XCTAssertEqual((try db.scalar(users_new.count)) as Int, 1) + } } diff --git a/Tests/SQLiteTests/Schema/SchemaDefinitionsTests.swift b/Tests/SQLiteTests/Schema/SchemaDefinitionsTests.swift index 645bfc34..9c8c3041 100644 --- a/Tests/SQLiteTests/Schema/SchemaDefinitionsTests.swift +++ b/Tests/SQLiteTests/Schema/SchemaDefinitionsTests.swift @@ -6,26 +6,26 @@ class ColumnDefinitionTests: XCTestCase { var expected: String! static let definitions: [(ColumnDefinition, String)] = [ - (ColumnDefinition(name: "id", primaryKey: .init(), type: .INTEGER, null: false, defaultValue: .NULL, references: nil), + (ColumnDefinition(name: "id", primaryKey: .init(), type: .INTEGER, nullable: false, defaultValue: .NULL, references: nil), "\"id\" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL"), - (ColumnDefinition(name: "other_id", primaryKey: nil, type: .INTEGER, null: false, defaultValue: .NULL, + (ColumnDefinition(name: "other_id", primaryKey: nil, type: .INTEGER, nullable: false, defaultValue: .NULL, references: .init(table: "other_table", column: "", primaryKey: "some_id", onUpdate: nil, onDelete: nil)), "\"other_id\" INTEGER NOT NULL REFERENCES \"other_table\" (\"some_id\")"), - (ColumnDefinition(name: "text", primaryKey: nil, type: .TEXT, null: true, defaultValue: .NULL, references: nil), + (ColumnDefinition(name: "text", primaryKey: nil, type: .TEXT, nullable: true, defaultValue: .NULL, references: nil), "\"text\" TEXT"), - (ColumnDefinition(name: "text", primaryKey: nil, type: .TEXT, null: false, defaultValue: .NULL, references: nil), + (ColumnDefinition(name: "text", primaryKey: nil, type: .TEXT, nullable: false, defaultValue: .NULL, references: nil), "\"text\" TEXT NOT NULL"), - (ColumnDefinition(name: "text_column", primaryKey: nil, type: .TEXT, null: true, defaultValue: .stringLiteral("fo\"o"), references: nil), + (ColumnDefinition(name: "text_column", primaryKey: nil, type: .TEXT, nullable: true, defaultValue: .stringLiteral("fo\"o"), references: nil), "\"text_column\" TEXT DEFAULT 'fo\"o'"), - (ColumnDefinition(name: "integer_column", primaryKey: nil, type: .INTEGER, null: true, defaultValue: .numericLiteral("123"), references: nil), + (ColumnDefinition(name: "integer_column", primaryKey: nil, type: .INTEGER, nullable: true, defaultValue: .numericLiteral("123"), references: nil), "\"integer_column\" INTEGER DEFAULT 123"), - (ColumnDefinition(name: "real_column", primaryKey: nil, type: .REAL, null: true, defaultValue: .numericLiteral("123.123"), references: nil), + (ColumnDefinition(name: "real_column", primaryKey: nil, type: .REAL, nullable: true, defaultValue: .numericLiteral("123.123"), references: nil), "\"real_column\" REAL DEFAULT 123.123") ] @@ -184,8 +184,8 @@ class ForeignKeyDefinitionTests: XCTestCase { class TableDefinitionTests: XCTestCase { func test_quoted_columnList() { let definition = TableDefinition(name: "foo", columns: [ - ColumnDefinition(name: "id", primaryKey: .init(), type: .INTEGER, null: false, defaultValue: .NULL, references: nil), - ColumnDefinition(name: "baz", primaryKey: nil, type: .INTEGER, null: false, defaultValue: .NULL, references: nil) + ColumnDefinition(name: "id", primaryKey: .init(), type: .INTEGER, nullable: false, defaultValue: .NULL, references: nil), + ColumnDefinition(name: "baz", primaryKey: nil, type: .INTEGER, nullable: false, defaultValue: .NULL, references: nil) ], indexes: []) XCTAssertEqual(definition.quotedColumnList, """ @@ -195,7 +195,7 @@ class TableDefinitionTests: XCTestCase { func test_toSQL() { let definition = TableDefinition(name: "foo", columns: [ - ColumnDefinition(name: "id", primaryKey: .init(), type: .INTEGER, null: false, defaultValue: .NULL, references: nil) + ColumnDefinition(name: "id", primaryKey: .init(), type: .INTEGER, nullable: false, defaultValue: .NULL, references: nil) ], indexes: []) XCTAssertEqual(definition.toSQL(), """ @@ -205,7 +205,7 @@ class TableDefinitionTests: XCTestCase { func test_toSQL_temp_table() { let definition = TableDefinition(name: "foo", columns: [ - ColumnDefinition(name: "id", primaryKey: .init(), type: .INTEGER, null: false, defaultValue: .NULL, references: nil) + ColumnDefinition(name: "id", primaryKey: .init(), type: .INTEGER, nullable: false, defaultValue: .NULL, references: nil) ], indexes: []) XCTAssertEqual(definition.toSQL(temporary: true), """ @@ -222,11 +222,11 @@ class TableDefinitionTests: XCTestCase { func test_copySQL() { let from = TableDefinition(name: "from_table", columns: [ - ColumnDefinition(name: "id", primaryKey: .init(), type: .INTEGER, null: false, defaultValue: .NULL, references: nil) + ColumnDefinition(name: "id", primaryKey: .init(), type: .INTEGER, nullable: false, defaultValue: .NULL, references: nil) ], indexes: []) let to = TableDefinition(name: "to_table", columns: [ - ColumnDefinition(name: "id", primaryKey: .init(), type: .INTEGER, null: false, defaultValue: .NULL, references: nil) + ColumnDefinition(name: "id", primaryKey: .init(), type: .INTEGER, nullable: false, defaultValue: .NULL, references: nil) ], indexes: []) XCTAssertEqual(from.copySQL(to: to), """ From 17a2cb8985b7188a61ddee5af1f9d549b23ab54d Mon Sep 17 00:00:00 2001 From: Jan Berkel Date: Tue, 26 Jul 2022 00:47:57 +0200 Subject: [PATCH 02/12] Lint --- Tests/SQLiteTests/Schema/SchemaDefinitionsTests.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Tests/SQLiteTests/Schema/SchemaDefinitionsTests.swift b/Tests/SQLiteTests/Schema/SchemaDefinitionsTests.swift index 9c8c3041..75321440 100644 --- a/Tests/SQLiteTests/Schema/SchemaDefinitionsTests.swift +++ b/Tests/SQLiteTests/Schema/SchemaDefinitionsTests.swift @@ -22,10 +22,12 @@ class ColumnDefinitionTests: XCTestCase { (ColumnDefinition(name: "text_column", primaryKey: nil, type: .TEXT, nullable: true, defaultValue: .stringLiteral("fo\"o"), references: nil), "\"text_column\" TEXT DEFAULT 'fo\"o'"), - (ColumnDefinition(name: "integer_column", primaryKey: nil, type: .INTEGER, nullable: true, defaultValue: .numericLiteral("123"), references: nil), + (ColumnDefinition(name: "integer_column", primaryKey: nil, type: .INTEGER, nullable: true, + defaultValue: .numericLiteral("123"), references: nil), "\"integer_column\" INTEGER DEFAULT 123"), - (ColumnDefinition(name: "real_column", primaryKey: nil, type: .REAL, nullable: true, defaultValue: .numericLiteral("123.123"), references: nil), + (ColumnDefinition(name: "real_column", primaryKey: nil, type: .REAL, nullable: true, + defaultValue: .numericLiteral("123.123"), references: nil), "\"real_column\" REAL DEFAULT 123.123") ] From f25798f3d047a923976d55f4ba9b8ab9e25e0428 Mon Sep 17 00:00:00 2001 From: Jan Berkel Date: Sun, 18 Sep 2022 23:51:49 +0200 Subject: [PATCH 03/12] SQLiteVersion type --- SQLite.xcodeproj/project.pbxproj | 10 +++++++++ Sources/SQLite/Core/Connection+Pragmas.swift | 5 ++--- Sources/SQLite/Core/SQLiteVersion.swift | 22 +++++++++++++++++++ Sources/SQLite/Schema/Connection+Schema.swift | 16 ++++++++++++++ Sources/SQLite/Schema/SchemaChanger.swift | 4 ++-- .../Core/Connection+PragmaTests.swift | 2 +- .../Schema/SchemaChangerTests.swift | 4 ++-- 7 files changed, 55 insertions(+), 8 deletions(-) create mode 100644 Sources/SQLite/Core/SQLiteVersion.swift diff --git a/SQLite.xcodeproj/project.pbxproj b/SQLite.xcodeproj/project.pbxproj index bc79473e..d2005575 100644 --- a/SQLite.xcodeproj/project.pbxproj +++ b/SQLite.xcodeproj/project.pbxproj @@ -199,6 +199,10 @@ 997DF2AF287FC06D00F8DF95 /* Query+with.swift in Sources */ = {isa = PBXBuildFile; fileRef = 997DF2AD287FC06D00F8DF95 /* Query+with.swift */; }; 997DF2B0287FC06D00F8DF95 /* Query+with.swift in Sources */ = {isa = PBXBuildFile; fileRef = 997DF2AD287FC06D00F8DF95 /* Query+with.swift */; }; 997DF2B1287FC06D00F8DF95 /* Query+with.swift in Sources */ = {isa = PBXBuildFile; fileRef = 997DF2AD287FC06D00F8DF95 /* Query+with.swift */; }; + DB7C5DA628D7C9B6006395CF /* SQLiteVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB7C5DA528D7C9B6006395CF /* SQLiteVersion.swift */; }; + DB7C5DA728D7C9B6006395CF /* SQLiteVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB7C5DA528D7C9B6006395CF /* SQLiteVersion.swift */; }; + DB7C5DA828D7C9B6006395CF /* SQLiteVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB7C5DA528D7C9B6006395CF /* SQLiteVersion.swift */; }; + DB7C5DA928D7C9B6006395CF /* SQLiteVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB7C5DA528D7C9B6006395CF /* SQLiteVersion.swift */; }; EE247AD71C3F04ED00AE3E12 /* SQLite.h in Headers */ = {isa = PBXBuildFile; fileRef = EE247AD61C3F04ED00AE3E12 /* SQLite.h */; settings = {ATTRIBUTES = (Public, ); }; }; EE247ADE1C3F04ED00AE3E12 /* SQLite.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EE247AD31C3F04ED00AE3E12 /* SQLite.framework */; }; EE247B031C3F06E900AE3E12 /* Blob.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE247AEE1C3F06E900AE3E12 /* Blob.swift */; }; @@ -325,6 +329,7 @@ 49EB68C31F7B3CB400D89D40 /* Coding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Coding.swift; sourceTree = ""; }; 997DF2AD287FC06D00F8DF95 /* Query+with.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Query+with.swift"; sourceTree = ""; }; A121AC451CA35C79005A31D1 /* SQLite.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = SQLite.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + DB7C5DA528D7C9B6006395CF /* SQLiteVersion.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SQLiteVersion.swift; sourceTree = ""; }; EE247AD31C3F04ED00AE3E12 /* SQLite.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = SQLite.framework; sourceTree = BUILT_PRODUCTS_DIR; }; EE247AD61C3F04ED00AE3E12 /* SQLite.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SQLite.h; sourceTree = ""; }; EE247AD81C3F04ED00AE3E12 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -569,6 +574,7 @@ 19A175A9CB446640AE6F2200 /* Connection+Aggregation.swift */, 3DF7B78728842972005DD8CA /* Connection+Attach.swift */, 3DF7B790288449BA005DD8CA /* URIQueryParameter.swift */, + DB7C5DA528D7C9B6006395CF /* SQLiteVersion.swift */, 19A17F285B767BFACD96714B /* Connection+Pragmas.swift */, ); path = Core; @@ -954,6 +960,7 @@ 02A43A9A22738CF100FEC494 /* Backup.swift in Sources */, 19A17FF4A10B44D3937C8CAC /* Errors.swift in Sources */, 19A1737286A74F3CF7412906 /* DateAndTimeFunctions.swift in Sources */, + DB7C5DA828D7C9B6006395CF /* SQLiteVersion.swift in Sources */, 19A17073552293CA063BEA66 /* Result.swift in Sources */, 997DF2B0287FC06D00F8DF95 /* Query+with.swift in Sources */, 19A179B59450FE7C4811AB8A /* Connection+Aggregation.swift in Sources */, @@ -1012,6 +1019,7 @@ 3D67B3F71DB246D700A4F4C6 /* Foundation.swift in Sources */, 3D67B3F81DB246D700A4F4C6 /* Helpers.swift in Sources */, 3D67B3E91DB246D100A4F4C6 /* Statement.swift in Sources */, + DB7C5DA928D7C9B6006395CF /* SQLiteVersion.swift in Sources */, 3D67B3EA1DB246D100A4F4C6 /* Value.swift in Sources */, 3D67B3EB1DB246D100A4F4C6 /* FTS4.swift in Sources */, 3D67B3EC1DB246D100A4F4C6 /* RTree.swift in Sources */, @@ -1069,6 +1077,7 @@ 02A43A9822738CF100FEC494 /* Backup.swift in Sources */, 19A1792C0520D4E83C2EB075 /* Errors.swift in Sources */, 19A17E29278A12BC4F542506 /* DateAndTimeFunctions.swift in Sources */, + DB7C5DA628D7C9B6006395CF /* SQLiteVersion.swift in Sources */, 19A173EFEF0B3BD0B3ED406C /* Result.swift in Sources */, 997DF2AE287FC06D00F8DF95 /* Query+with.swift in Sources */, 19A176376CB6A94759F7980A /* Connection+Aggregation.swift in Sources */, @@ -1146,6 +1155,7 @@ 02A43A9922738CF100FEC494 /* Backup.swift in Sources */, 19A17490543609FCED53CACC /* Errors.swift in Sources */, 19A17152E32A9585831E3FE0 /* DateAndTimeFunctions.swift in Sources */, + DB7C5DA728D7C9B6006395CF /* SQLiteVersion.swift in Sources */, 19A17F1B3F0A3C96B5ED6D64 /* Result.swift in Sources */, 997DF2AF287FC06D00F8DF95 /* Query+with.swift in Sources */, 19A170ACC97B19730FB7BA4D /* Connection+Aggregation.swift in Sources */, diff --git a/Sources/SQLite/Core/Connection+Pragmas.swift b/Sources/SQLite/Core/Connection+Pragmas.swift index 43ff9610..8f6d854b 100644 --- a/Sources/SQLite/Core/Connection+Pragmas.swift +++ b/Sources/SQLite/Core/Connection+Pragmas.swift @@ -1,7 +1,6 @@ import Foundation public typealias UserVersion = Int32 -public typealias SQLiteVersion = (Int, Int, Int) public extension Connection { /// The user version of the database. @@ -21,9 +20,9 @@ public extension Connection { guard let version = (try? scalar("SELECT sqlite_version()")) as? String, let splits = .some(version.split(separator: ".", maxSplits: 3)), splits.count == 3, let major = Int(splits[0]), let minor = Int(splits[1]), let point = Int(splits[2]) else { - return (0, 0, 0) + return .zero } - return (major, minor, point) + return .init(major: major, minor: minor, point: point) } // Changing the foreign_keys setting affects the execution of all statements prepared using the database diff --git a/Sources/SQLite/Core/SQLiteVersion.swift b/Sources/SQLite/Core/SQLiteVersion.swift new file mode 100644 index 00000000..fa7358da --- /dev/null +++ b/Sources/SQLite/Core/SQLiteVersion.swift @@ -0,0 +1,22 @@ +import Foundation + +public struct SQLiteVersion: Comparable, CustomStringConvertible { + public let major: Int + public let minor: Int + public var point: Int = 0 + + public var description: String { + "SQLite \(major).\(minor).\(point)" + } + + public static func <(lhs: SQLiteVersion, rhs: SQLiteVersion) -> Bool { + lhs.tuple < rhs.tuple + } + + public static func ==(lhs: SQLiteVersion, rhs: SQLiteVersion) -> Bool { + lhs.tuple == rhs.tuple + } + + static var zero: SQLiteVersion = .init(major: 0, minor: 0) + private var tuple: (Int, Int, Int) { (major, minor, point) } +} diff --git a/Sources/SQLite/Schema/Connection+Schema.swift b/Sources/SQLite/Schema/Connection+Schema.swift index 378fad68..d92236ee 100644 --- a/Sources/SQLite/Schema/Connection+Schema.swift +++ b/Sources/SQLite/Schema/Connection+Schema.swift @@ -80,6 +80,16 @@ extension Connection { } } + func tableInfo() throws -> [String] { + try run("SELECT tbl_name FROM sqlite_master WHERE type = 'table'").compactMap { row in + if let name = row[0] as? String, !name.starts(with: "sqlite_") { + return name + } else { + return nil + } + } + } + // https://sqlite.org/pragma.html#pragma_foreign_key_check // There are four columns in each result row. @@ -99,6 +109,12 @@ extension Connection { } } + // https://sqlite.org/pragma.html#pragma_integrity_check + func integrityCheck() throws -> [String] { + try run("PRAGMA integrity_check").compactMap { $0[0] as? String }.filter { $0 != "ok" } + } + + private func createTableSQL(name: String) throws -> String? { try run(""" SELECT sql FROM sqlite_master WHERE name=? AND type='table' diff --git a/Sources/SQLite/Schema/SchemaChanger.swift b/Sources/SQLite/Schema/SchemaChanger.swift index daba2f60..991e3338 100644 --- a/Sources/SQLite/Schema/SchemaChanger.swift +++ b/Sources/SQLite/Schema/SchemaChanger.swift @@ -52,9 +52,9 @@ public class SchemaChanger: CustomStringConvertible { switch self { case .addColumn(let definition): return "ALTER TABLE \(table.quote()) ADD COLUMN \(definition.toSQL())" - case .renameColumn(let from, let to) where version >= (3, 25, 0): + case .renameColumn(let from, let to) where version >= .init(major: 3, minor: 25): return "ALTER TABLE \(table.quote()) RENAME COLUMN \(from.quote()) TO \(to.quote())" - case .dropColumn(let column) where version >= (3, 35, 0): + case .dropColumn(let column) where version >= .init(major: 3, minor: 35): return "ALTER TABLE \(table.quote()) DROP COLUMN \(column.quote())" default: return nil } diff --git a/Tests/SQLiteTests/Core/Connection+PragmaTests.swift b/Tests/SQLiteTests/Core/Connection+PragmaTests.swift index 2d0742c9..2bcdb6af 100644 --- a/Tests/SQLiteTests/Core/Connection+PragmaTests.swift +++ b/Tests/SQLiteTests/Core/Connection+PragmaTests.swift @@ -19,7 +19,7 @@ class ConnectionPragmaTests: SQLiteTestCase { } func test_sqlite_version() { - XCTAssertTrue(db.sqliteVersion >= (3, 0, 0)) + XCTAssertTrue(db.sqliteVersion >= .init(major: 3, minor: 0)) } func test_foreignKeys_defaults_to_false() { diff --git a/Tests/SQLiteTests/Schema/SchemaChangerTests.swift b/Tests/SQLiteTests/Schema/SchemaChangerTests.swift index 038a8844..738588fd 100644 --- a/Tests/SQLiteTests/Schema/SchemaChangerTests.swift +++ b/Tests/SQLiteTests/Schema/SchemaChangerTests.swift @@ -58,7 +58,7 @@ class SchemaChangerTests: SQLiteTestCase { } func test_remove_column_legacy() throws { - schemaChanger = .init(connection: db, version: (3, 24, 0)) // DROP COLUMN introduced in 3.35.0 + schemaChanger = .init(connection: db, version: .init(major: 3, minor: 24)) // DROP COLUMN introduced in 3.35.0 try schemaChanger.alter(table: "users") { table in table.remove("age") @@ -78,7 +78,7 @@ class SchemaChangerTests: SQLiteTestCase { } func test_rename_column_legacy() throws { - schemaChanger = .init(connection: db, version: (3, 24, 0)) // RENAME COLUMN introduced in 3.25.0 + schemaChanger = .init(connection: db, version: .init(major: 3, minor: 24)) // RENAME COLUMN introduced in 3.25.0 try schemaChanger.alter(table: "users") { table in table.rename("age", to: "age2") From ec35c7ea060f08a3f36382564f2a69206da7aa2b Mon Sep 17 00:00:00 2001 From: Jan Berkel Date: Sun, 18 Sep 2022 23:57:43 +0200 Subject: [PATCH 04/12] Lint --- Sources/SQLite/Schema/Connection+Schema.swift | 1 - Sources/SQLite/Typed/Query.swift | 8 +++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/Sources/SQLite/Schema/Connection+Schema.swift b/Sources/SQLite/Schema/Connection+Schema.swift index d92236ee..9c945e18 100644 --- a/Sources/SQLite/Schema/Connection+Schema.swift +++ b/Sources/SQLite/Schema/Connection+Schema.swift @@ -114,7 +114,6 @@ extension Connection { try run("PRAGMA integrity_check").compactMap { $0[0] as? String }.filter { $0 != "ok" } } - private func createTableSQL(name: String) throws -> String? { try run(""" SELECT sql FROM sqlite_master WHERE name=? AND type='table' diff --git a/Sources/SQLite/Typed/Query.swift b/Sources/SQLite/Typed/Query.swift index cfa7544e..7cb2aef3 100644 --- a/Sources/SQLite/Typed/Query.swift +++ b/Sources/SQLite/Typed/Query.swift @@ -1035,11 +1035,9 @@ extension Connection { select.clauses.select = (false, [Expression(literal: "*") as Expressible]) let queries = [select] + query.clauses.join.map { $0.query } if !namespace.isEmpty { - for q in queries { - if q.tableName().expression.template == namespace { - try expandGlob(true)(q) - continue column - } + for q in queries where q.tableName().expression.template == namespace { + try expandGlob(true)(q) + continue column } throw QueryError.noSuchTable(name: namespace) } From d7c26353330ea851691f3f0359aa13e7dbedced5 Mon Sep 17 00:00:00 2001 From: Jan Berkel Date: Sun, 16 Oct 2022 02:24:31 +0200 Subject: [PATCH 05/12] Split into classes, improve queries --- Makefile | 2 + SQLite.xcodeproj/project.pbxproj | 10 + Sources/SQLite/Schema/Connection+Schema.swift | 129 ++----------- Sources/SQLite/Schema/SchemaChanger.swift | 6 +- Sources/SQLite/Schema/SchemaDefinitions.swift | 107 ++++++---- Sources/SQLite/Schema/SchemaReader.swift | 182 ++++++++++++++++++ Sources/SQLite/Typed/Query.swift | 17 ++ Tests/SQLiteTests/Core/ConnectionTests.swift | 2 +- .../Schema/Connection+SchemaTests.swift | 140 +++----------- .../Schema/SchemaChangerTests.swift | 24 +-- .../Schema/SchemaDefinitionsTests.swift | 132 ++++++++----- .../Schema/SchemaReaderTests.swift | 180 +++++++++++++++++ 12 files changed, 603 insertions(+), 328 deletions(-) create mode 100644 Sources/SQLite/Schema/SchemaReader.swift create mode 100644 Tests/SQLiteTests/Schema/SchemaReaderTests.swift diff --git a/Makefile b/Makefile index c62da000..74bf5d18 100644 --- a/Makefile +++ b/Makefile @@ -18,6 +18,8 @@ build: lint: swiftlint --strict +lint-fix: + swiftlint lint fix test: ifdef XCPRETTY diff --git a/SQLite.xcodeproj/project.pbxproj b/SQLite.xcodeproj/project.pbxproj index d2005575..a58e0887 100644 --- a/SQLite.xcodeproj/project.pbxproj +++ b/SQLite.xcodeproj/project.pbxproj @@ -199,6 +199,10 @@ 997DF2AF287FC06D00F8DF95 /* Query+with.swift in Sources */ = {isa = PBXBuildFile; fileRef = 997DF2AD287FC06D00F8DF95 /* Query+with.swift */; }; 997DF2B0287FC06D00F8DF95 /* Query+with.swift in Sources */ = {isa = PBXBuildFile; fileRef = 997DF2AD287FC06D00F8DF95 /* Query+with.swift */; }; 997DF2B1287FC06D00F8DF95 /* Query+with.swift in Sources */ = {isa = PBXBuildFile; fileRef = 997DF2AD287FC06D00F8DF95 /* Query+with.swift */; }; + DB58B21128FB864300F8EEA4 /* SchemaReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB58B21028FB864300F8EEA4 /* SchemaReader.swift */; }; + DB58B21228FB864300F8EEA4 /* SchemaReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB58B21028FB864300F8EEA4 /* SchemaReader.swift */; }; + DB58B21328FB864300F8EEA4 /* SchemaReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB58B21028FB864300F8EEA4 /* SchemaReader.swift */; }; + DB58B21428FB864300F8EEA4 /* SchemaReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB58B21028FB864300F8EEA4 /* SchemaReader.swift */; }; DB7C5DA628D7C9B6006395CF /* SQLiteVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB7C5DA528D7C9B6006395CF /* SQLiteVersion.swift */; }; DB7C5DA728D7C9B6006395CF /* SQLiteVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB7C5DA528D7C9B6006395CF /* SQLiteVersion.swift */; }; DB7C5DA828D7C9B6006395CF /* SQLiteVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB7C5DA528D7C9B6006395CF /* SQLiteVersion.swift */; }; @@ -329,6 +333,7 @@ 49EB68C31F7B3CB400D89D40 /* Coding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Coding.swift; sourceTree = ""; }; 997DF2AD287FC06D00F8DF95 /* Query+with.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Query+with.swift"; sourceTree = ""; }; A121AC451CA35C79005A31D1 /* SQLite.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = SQLite.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + DB58B21028FB864300F8EEA4 /* SchemaReader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SchemaReader.swift; sourceTree = ""; }; DB7C5DA528D7C9B6006395CF /* SQLiteVersion.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SQLiteVersion.swift; sourceTree = ""; }; EE247AD31C3F04ED00AE3E12 /* SQLite.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = SQLite.framework; sourceTree = BUILT_PRODUCTS_DIR; }; EE247AD61C3F04ED00AE3E12 /* SQLite.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SQLite.h; sourceTree = ""; }; @@ -429,6 +434,7 @@ 19A1792D261C689FC988A90A /* Schema */ = { isa = PBXGroup; children = ( + DB58B21028FB864300F8EEA4 /* SchemaReader.swift */, 19A171B262DDE8718513CFDA /* SchemaChanger.swift */, 19A17268AE67B746B96AC125 /* SchemaDefinitions.swift */, 19A170A97B51DC5EE365F3C5 /* Connection+Schema.swift */, @@ -968,6 +974,7 @@ 19A1740EACD47904AA24B8DC /* SchemaDefinitions.swift in Sources */, 19A1750EF4A5F92954A451FF /* Connection+Schema.swift in Sources */, 19A17986405D9A875698408F /* Connection+Pragmas.swift in Sources */, + DB58B21328FB864300F8EEA4 /* SchemaReader.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1018,6 +1025,7 @@ 997DF2B1287FC06D00F8DF95 /* Query+with.swift in Sources */, 3D67B3F71DB246D700A4F4C6 /* Foundation.swift in Sources */, 3D67B3F81DB246D700A4F4C6 /* Helpers.swift in Sources */, + DB58B21428FB864300F8EEA4 /* SchemaReader.swift in Sources */, 3D67B3E91DB246D100A4F4C6 /* Statement.swift in Sources */, DB7C5DA928D7C9B6006395CF /* SQLiteVersion.swift in Sources */, 3D67B3EA1DB246D100A4F4C6 /* Value.swift in Sources */, @@ -1085,6 +1093,7 @@ 19A17BA13FD35F058787B7D3 /* SchemaDefinitions.swift in Sources */, 19A174506543905D71BF0518 /* Connection+Schema.swift in Sources */, 19A17018F250343BD0F9F4B0 /* Connection+Pragmas.swift in Sources */, + DB58B21128FB864300F8EEA4 /* SchemaReader.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1163,6 +1172,7 @@ 19A17B0DF1DDB6BBC9C95D64 /* SchemaDefinitions.swift in Sources */, 19A17F0BF02896E1664F4090 /* Connection+Schema.swift in Sources */, 19A1760CE25615CA015E2E5F /* Connection+Pragmas.swift in Sources */, + DB58B21228FB864300F8EEA4 /* SchemaReader.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Sources/SQLite/Schema/Connection+Schema.swift b/Sources/SQLite/Schema/Connection+Schema.swift index 9c945e18..868b40d5 100644 --- a/Sources/SQLite/Schema/Connection+Schema.swift +++ b/Sources/SQLite/Schema/Connection+Schema.swift @@ -1,96 +1,7 @@ import Foundation -extension Connection { - // https://sqlite.org/pragma.html#pragma_table_info - // - // This pragma returns one row for each column in the named table. Columns in the result set include the - // column name, data type, whether or not the column can be NULL, and the default value for the column. The - // "pk" column in the result set is zero for columns that are not part of the primary key, and is the - // index of the column in the primary key for columns that are part of the primary key. - func columnInfo(table: String) throws -> [ColumnDefinition] { - func parsePrimaryKey(column: String) throws -> ColumnDefinition.PrimaryKey? { - try createTableSQL(name: table).flatMap { .init(sql: $0) } - } - - let foreignKeys: [String: [ColumnDefinition.ForeignKey]] = - Dictionary(grouping: try foreignKeyInfo(table: table), by: { $0.column }) - - return try run("PRAGMA table_info(\(table.quote()))").compactMap { row -> ColumnDefinition? in - guard let name = row[1] as? String, - let type = row[2] as? String, - let notNull = row[3] as? Int64, - let defaultValue = row[4] as? String?, - let primaryKey = row[5] as? Int64 else { return nil } - return ColumnDefinition(name: name, - primaryKey: primaryKey == 1 ? try parsePrimaryKey(column: name) : nil, - type: ColumnDefinition.Affinity.from(type), - nullable: notNull == 0, - defaultValue: .from(defaultValue), - references: foreignKeys[name]?.first) - } - } - - func indexInfo(table: String) throws -> [IndexDefinition] { - func indexSQL(name: String) throws -> String? { - try run(""" - SELECT sql FROM sqlite_master WHERE name=? AND type='index' - UNION ALL - SELECT sql FROM sqlite_temp_master WHERE name=? AND type='index' - """, name, name) - .compactMap { row in row[0] as? String } - .first - } - - func columns(name: String) throws -> [String] { - try run("PRAGMA index_info(\(name.quote()))").compactMap { row in - row[2] as? String - } - } - - return try run("PRAGMA index_list(\(table.quote()))").compactMap { row -> IndexDefinition? in - guard let name = row[1] as? String, - let unique = row[2] as? Int64, - // Indexes SQLite creates implicitly for internal use start with "sqlite_". - // See https://www.sqlite.org/fileformat2.html#intschema - !name.starts(with: "sqlite_") else { - return nil - } - return .init(table: table, - name: name, - unique: unique == 1, - columns: try columns(name: name), - indexSQL: try indexSQL(name: name)) - } - } - - func foreignKeyInfo(table: String) throws -> [ColumnDefinition.ForeignKey] { - try run("PRAGMA foreign_key_list(\(table.quote()))").compactMap { row in - if let table = row[2] as? String, // table - let column = row[3] as? String, // from - let primaryKey = row[4] as? String, // to - let onUpdate = row[5] as? String, - let onDelete = row[6] as? String { - return .init(table: table, column: column, primaryKey: primaryKey, - onUpdate: onUpdate == TableBuilder.Dependency.noAction.rawValue ? nil : onUpdate, - onDelete: onDelete == TableBuilder.Dependency.noAction.rawValue ? nil : onDelete - ) - } else { - return nil - } - } - } - - func tableInfo() throws -> [String] { - try run("SELECT tbl_name FROM sqlite_master WHERE type = 'table'").compactMap { row in - if let name = row[0] as? String, !name.starts(with: "sqlite_") { - return name - } else { - return nil - } - } - } - - // https://sqlite.org/pragma.html#pragma_foreign_key_check +public extension Connection { + var schemaReader: SchemaReader { SchemaReader(connection: self) } // There are four columns in each result row. // The first column is the name of the table that @@ -99,28 +10,24 @@ extension Connection { // invalid REFERENCES clause, or NULL if the child table is a WITHOUT ROWID table. // The third column is the name of the table that is referred to. // The fourth column is the index of the specific foreign key constraint that failed. - func foreignKeyCheck() throws -> [ForeignKeyError] { - try run("PRAGMA foreign_key_check").compactMap { row -> ForeignKeyError? in - guard let table = row[0] as? String, - let rowId = row[1] as? Int64, - let target = row[2] as? String else { return nil } - - return ForeignKeyError(from: table, rowId: rowId, to: target) - } + // + // https://sqlite.org/pragma.html#pragma_foreign_key_check + func foreignKeyCheck(table: String? = nil) throws -> [ForeignKeyError] { + try run("PRAGMA foreign_key_check" + (table.map { "(\($0.quote()))" } ?? "")) + .compactMap { row -> ForeignKeyError? in + guard let table = row[0] as? String, + let rowId = row[1] as? Int64, + let target = row[2] as? String else { return nil } + + return ForeignKeyError(from: table, rowId: rowId, to: target) + } } + // This pragma does a low-level formatting and consistency check of the database. // https://sqlite.org/pragma.html#pragma_integrity_check - func integrityCheck() throws -> [String] { - try run("PRAGMA integrity_check").compactMap { $0[0] as? String }.filter { $0 != "ok" } - } - - private func createTableSQL(name: String) throws -> String? { - try run(""" - SELECT sql FROM sqlite_master WHERE name=? AND type='table' - UNION ALL - SELECT sql FROM sqlite_temp_master WHERE name=? AND type='table' - """, name, name) - .compactMap { row in row[0] as? String } - .first + func integrityCheck(table: String? = nil, maxErrors: Int? = nil) throws -> [String] { + try run("PRAGMA integrity_check" + (table.map { "(\($0.quote()))" } ?? "")) + .compactMap { $0[0] as? String } + .filter { $0 != "ok" } } } diff --git a/Sources/SQLite/Schema/SchemaChanger.swift b/Sources/SQLite/Schema/SchemaChanger.swift index 991e3338..b557da0a 100644 --- a/Sources/SQLite/Schema/SchemaChanger.swift +++ b/Sources/SQLite/Schema/SchemaChanger.swift @@ -109,6 +109,7 @@ public class SchemaChanger: CustomStringConvertible { } private let connection: Connection + private let schemaReader: SchemaReader private let version: SQLiteVersion static let tempPrefix = "tmp_" typealias Block = () throws -> Void @@ -127,6 +128,7 @@ public class SchemaChanger: CustomStringConvertible { init(connection: Connection, version: SQLiteVersion) { self.connection = connection + schemaReader = connection.schemaReader self.version = version } @@ -196,8 +198,8 @@ public class SchemaChanger: CustomStringConvertible { private func copyTable(from: String, to: String, options: Options = .default, operation: Operation?) throws { let fromDefinition = TableDefinition( name: from, - columns: try connection.columnInfo(table: from), - indexes: try connection.indexInfo(table: from) + columns: try schemaReader.columnDefinitions(table: from), + indexes: try schemaReader.indexDefinitions(table: from) ) let toDefinition = fromDefinition .apply(.renameTable(to)) diff --git a/Sources/SQLite/Schema/SchemaDefinitions.swift b/Sources/SQLite/Schema/SchemaDefinitions.swift index a06487b2..fe2e3931 100644 --- a/Sources/SQLite/Schema/SchemaDefinitions.swift +++ b/Sources/SQLite/Schema/SchemaDefinitions.swift @@ -10,6 +10,29 @@ struct TableDefinition: Equatable { } } +// https://sqlite.org/schematab.html#interpretation_of_the_schema_table +public struct ObjectDefinition: Equatable { + public enum ObjectType: String { + case table, index, view, trigger + } + public let type: ObjectType + + // The name of the object + public let name: String + + // The name of a table or view that the object is associated with. + // * For a table or view, a copy of the name column. + // * For an index, the name of the table that is indexed + // * For a trigger, the column stores the name of the table or view that causes the trigger to fire. + public let tableName: String + + // The page number of the root b-tree page for tables and indexes, otherwise 0 or NULL + public let rootpage: Int64 + + // SQL text that describes the object (NULL for the internal indexes) + public let sql: String? +} + // https://sqlite.org/syntax/column-def.html // column-name -> type-name -> column-constraint* public struct ColumnDefinition: Equatable { @@ -29,8 +52,8 @@ public struct ColumnDefinition: Equatable { rawValue } - static func from(_ string: String) -> Affinity { - Affinity.allCases.first { $0.rawValue.lowercased() == string.lowercased() } ?? TEXT + init(_ string: String) { + self = Affinity.allCases.first { $0.rawValue.lowercased() == string.lowercased() } ?? .TEXT } } @@ -41,8 +64,9 @@ public struct ColumnDefinition: Equatable { case IGNORE case REPLACE - static func from(_ string: String) -> OnConflict? { - OnConflict.allCases.first { $0.rawValue == string } + init?(_ string: String) { + guard let value = (OnConflict.allCases.first { $0.rawValue == string }) else { return nil } + self = value } } @@ -59,17 +83,20 @@ public struct ColumnDefinition: Equatable { } init?(sql: String) { - if let match = PrimaryKey.pattern.firstMatch(in: sql, range: NSRange(location: 0, length: sql.count)) { - let conflict = match.range(at: 1) - var onConflict: ColumnDefinition.OnConflict? - if conflict.location != NSNotFound { - onConflict = .from((sql as NSString).substring(with: conflict)) - } - let autoIncrement = match.range(at: 2).location != NSNotFound - self.init(autoIncrement: autoIncrement, onConflict: onConflict) - } else { + guard let match = PrimaryKey.pattern.firstMatch( + in: sql, + range: NSRange(location: 0, length: sql.count)) else { return nil } + let conflict = match.range(at: 1) + let onConflict: ColumnDefinition.OnConflict? + if conflict.location != NSNotFound { + onConflict = OnConflict((sql as NSString).substring(with: conflict)) + } else { + onConflict = nil + } + let autoIncrement = match.range(at: 2).location != NSNotFound + self.init(autoIncrement: autoIncrement, onConflict: onConflict) } } @@ -138,28 +165,19 @@ public enum LiteralValue: Equatable, CustomStringConvertible { case CURRENT_TIMESTAMP // swiftlint:enable identifier_name - static func from(_ string: String?) -> LiteralValue { - func parse(_ value: String) -> LiteralValue { - if let match = singleQuote.firstMatch(in: value, range: NSRange(location: 0, length: value.count)) { - return stringLiteral((value as NSString).substring(with: match.range(at: 1)).replacingOccurrences(of: "''", with: "'")) - } else if let match = doubleQuote.firstMatch(in: value, range: NSRange(location: 0, length: value.count)) { - return stringLiteral((value as NSString).substring(with: match.range(at: 1)).replacingOccurrences(of: "\"\"", with: "\"")) - } else if let match = blob.firstMatch(in: value, range: NSRange(location: 0, length: value.count)) { - return blobLiteral((value as NSString).substring(with: match.range(at: 1))) - } else { - return numericLiteral(value) - } + init(_ string: String?) { + guard let string = string else { + self = .NULL + return } - guard let string = string else { return NULL } - switch string { - case "NULL": return NULL - case "TRUE": return TRUE - case "FALSE": return FALSE - case "CURRENT_TIME": return CURRENT_TIME - case "CURRENT_TIMESTAMP": return CURRENT_TIMESTAMP - case "CURRENT_DATE": return CURRENT_DATE - default: return parse(string) + case "NULL": self = .NULL + case "TRUE": self = .TRUE + case "FALSE": self = .FALSE + case "CURRENT_TIME": self = .CURRENT_TIME + case "CURRENT_TIMESTAMP": self = .CURRENT_TIMESTAMP + case "CURRENT_DATE": self = .CURRENT_DATE + default: self = LiteralValue.parse(string) } } @@ -184,6 +202,17 @@ public enum LiteralValue: Equatable, CustomStringConvertible { return block(self) } } + private static func parse(_ string: String) -> LiteralValue { + if let match = LiteralValue.singleQuote.firstMatch(in: string, range: NSRange(location: 0, length: string.count)) { + return .stringLiteral((string as NSString).substring(with: match.range(at: 1)).replacingOccurrences(of: "''", with: "'")) + } else if let match = LiteralValue.doubleQuote.firstMatch(in: string, range: NSRange(location: 0, length: string.count)) { + return .stringLiteral((string as NSString).substring(with: match.range(at: 1)).replacingOccurrences(of: "\"\"", with: "\"")) + } else if let match = LiteralValue.blob.firstMatch(in: string, range: NSRange(location: 0, length: string.count)) { + return .blobLiteral((string as NSString).substring(with: match.range(at: 1))) + } else { + return .numericLiteral(string) + } + } } // https://sqlite.org/lang_createindex.html @@ -259,9 +288,9 @@ public struct IndexDefinition: Equatable { } public struct ForeignKeyError: CustomStringConvertible { - let from: String - let rowId: Int64 - let to: String + public let from: String + public let rowId: Int64 + public let to: String public var description: String { "\(from) [\(rowId)] => \(to)" @@ -270,7 +299,7 @@ public struct ForeignKeyError: CustomStringConvertible { extension TableDefinition { func toSQL(temporary: Bool = false) -> String { - assert(columns.count > 0, "no columns to create") + precondition(columns.count > 0, "no columns to create") return ([ "CREATE", @@ -285,8 +314,8 @@ extension TableDefinition { } func copySQL(to: TableDefinition) -> String { - assert(columns.count > 0, "no columns to copy") - assert(columns.count == to.columns.count, "column counts don't match") + precondition(columns.count > 0) + precondition(columns.count == to.columns.count, "column counts don't match") return "INSERT INTO \(to.name.quote()) (\(to.quotedColumnList)) SELECT \(quotedColumnList) FROM \(name.quote())" } } diff --git a/Sources/SQLite/Schema/SchemaReader.swift b/Sources/SQLite/Schema/SchemaReader.swift new file mode 100644 index 00000000..3f02eeae --- /dev/null +++ b/Sources/SQLite/Schema/SchemaReader.swift @@ -0,0 +1,182 @@ +import Foundation + +public class SchemaReader { + private let connection: Connection + + init(connection: Connection) { + self.connection = connection + } + + // https://sqlite.org/pragma.html#pragma_table_info + // + // This pragma returns one row for each column in the named table. Columns in the result set include the + // column name, data type, whether or not the column can be NULL, and the default value for the column. The + // "pk" column in the result set is zero for columns that are not part of the primary key, and is the + // index of the column in the primary key for columns that are part of the primary key. + public func columnDefinitions(table: String) throws -> [ColumnDefinition] { + func parsePrimaryKey(column: String) throws -> ColumnDefinition.PrimaryKey? { + try createTableSQL(name: table).flatMap { .init(sql: $0) } + } + + let foreignKeys: [String: [ColumnDefinition.ForeignKey]] = + Dictionary(grouping: try foreignKeys(table: table), by: { $0.column }) + + return try connection.prepareRowIterator("PRAGMA table_info(\(table.quote()))") + .map { (row: Row) -> ColumnDefinition in + ColumnDefinition( + name: row[TableInfoTable.nameColumn], + primaryKey: row[TableInfoTable.primaryKeyColumn] == 1 ? + try parsePrimaryKey(column: row[TableInfoTable.nameColumn]) : nil, + type: ColumnDefinition.Affinity(row[TableInfoTable.typeColumn]), + nullable: row[TableInfoTable.notNullColumn] == 0, + defaultValue: LiteralValue(row[TableInfoTable.defaultValueColumn]), + references: foreignKeys[row[TableInfoTable.nameColumn]]?.first + ) + } + } + + public func objectDefinitions(name: String? = nil, + type: ObjectDefinition.ObjectType? = nil, + temp: Bool = false) throws -> [ObjectDefinition] { + var query: QueryType = temp ? SchemaTable.tempName : SchemaTable.name + if let name = name { + query = query.where(SchemaTable.nameColumn == name) + } + if let type = type { + query = query.where(SchemaTable.typeColumn == type.rawValue) + } + return try connection.prepare(query).map { row -> ObjectDefinition in + guard let type = ObjectDefinition.ObjectType(rawValue: row[SchemaTable.typeColumn]) else { + fatalError("unexpected type") + } + return ObjectDefinition( + type: type, + name: row[SchemaTable.nameColumn], + tableName: row[SchemaTable.nameColumn], + rootpage: row[SchemaTable.rootPageColumn] ?? 0, + sql: row[SchemaTable.sqlColumn] + ) + } + } + + public func indexDefinitions(table: String) throws -> [IndexDefinition] { + func indexSQL(name: String) throws -> String? { + try objectDefinitions(name: name, type: .index) + .compactMap(\.sql) + .first + } + + func columns(name: String) throws -> [String] { + try connection.prepareRowIterator("PRAGMA index_info(\(name.quote()))") + .compactMap { row in + row[IndexInfoTable.nameColumn] + } + } + + return try connection.prepareRowIterator("PRAGMA index_list(\(table.quote()))") + .compactMap { row -> IndexDefinition? in + let name = row[IndexListTable.nameColumn] + guard !name.starts(with: "sqlite_") else { + // Indexes SQLite creates implicitly for internal use start with "sqlite_". + // See https://www.sqlite.org/fileformat2.html#intschema + return nil + } + return IndexDefinition( + table: table, + name: name, + unique: row[IndexListTable.uniqueColumn] == 1, + columns: try columns(name: name), + indexSQL: try indexSQL(name: name) + ) + } + } + + func foreignKeys(table: String) throws -> [ColumnDefinition.ForeignKey] { + try connection.prepareRowIterator("PRAGMA foreign_key_list(\(table.quote()))") + .map { row in + ColumnDefinition.ForeignKey( + table: row[ForeignKeyListTable.tableColumn], + column: row[ForeignKeyListTable.fromColumn], + primaryKey: row[ForeignKeyListTable.toColumn], + onUpdate: row[ForeignKeyListTable.onUpdateColumn] == TableBuilder.Dependency.noAction.rawValue + ? nil : row[ForeignKeyListTable.onUpdateColumn], + onDelete: row[ForeignKeyListTable.onDeleteColumn] == TableBuilder.Dependency.noAction.rawValue + ? nil : row[ForeignKeyListTable.onDeleteColumn] + ) + } + } + + func tableDefinitions() throws -> [TableDefinition] { + try objectDefinitions(type: .table) + .map { table in + TableDefinition( + name: table.name, + columns: try columnDefinitions(table: table.name), + indexes: try indexDefinitions(table: table.name) + ) + } + } + + private func createTableSQL(name: String) throws -> String? { + try ( + objectDefinitions(name: name, type: .table) + + objectDefinitions(name: name, type: .table, temp: true) + ).compactMap(\.sql).first + } +} + +private class SchemaTable { + internal static let name = Table("sqlite_schema", database: "main") + internal static let tempName = Table("sqlite_schema", database: "temp") + + static let typeColumn = Expression("type") + static let nameColumn = Expression("name") + static let tableNameColumn = Expression("tbl_name") + static let rootPageColumn = Expression("rootpage") + static let sqlColumn = Expression("sql") +} + +private class TableInfoTable { + static let idColumn = Expression("cid") + static let nameColumn = Expression("name") + static let typeColumn = Expression("type") + static let notNullColumn = Expression("notnull") + static let defaultValueColumn = Expression("dflt_value") + static let primaryKeyColumn = Expression("pk") +} + +private class IndexInfoTable { + // The rank of the column within the index. (0 means left-most.) + static let seqnoColumn = Expression("seqno") + // The rank of the column within the table being indexed. + // A value of -1 means rowid and a value of -2 means that an expression is being used. + static let cidColumn = Expression("cid") + // The name of the column being indexed. This columns is NULL if the column is the rowid or an expression. + static let nameColumn = Expression("name") +} + +private class IndexListTable { + // A sequence number assigned to each index for internal tracking purposes. + static let seqColumn = Expression("seq") + // The name of the index + static let nameColumn = Expression("name") + // "1" if the index is UNIQUE and "0" if not. + static let uniqueColumn = Expression("unique") + // "c" if the index was created by a CREATE INDEX statement, + // "u" if the index was created by a UNIQUE constraint, or + // "pk" if the index was created by a PRIMARY KEY constraint. + static let originColumn = Expression("origin") + // "1" if the index is a partial index and "0" if not. + static let partialColumn = Expression("partial") +} + +private class ForeignKeyListTable { + static let idColumn = Expression("id") + static let seqColumn = Expression("seq") + static let tableColumn = Expression("table") + static let fromColumn = Expression("from") + static let toColumn = Expression("to") + static let onUpdateColumn = Expression("on_update") + static let onDeleteColumn = Expression("on_delete") + static let matchColumn = Expression("match") +} diff --git a/Sources/SQLite/Typed/Query.swift b/Sources/SQLite/Typed/Query.swift index 7cb2aef3..04665f39 100644 --- a/Sources/SQLite/Typed/Query.swift +++ b/Sources/SQLite/Typed/Query.swift @@ -991,6 +991,15 @@ public struct RowIterator: FailableIterator { } return elements } + + public func compactMap(_ transform: (Element) throws -> T?) throws -> [T] { + var elements = [T]() + while let row = try failableNext() { + guard let element = try transform(row) else { continue } + elements.append(element) + } + return elements + } } extension Connection { @@ -1012,6 +1021,14 @@ extension Connection { return RowIterator(statement: statement, columnNames: try columnNamesForQuery(query)) } + public func prepareRowIterator(_ statement: String, bindings: Binding?...) throws -> RowIterator { + try prepare(statement, bindings).prepareRowIterator() + } + + public func prepareRowIterator(_ statement: String, bindings: [Binding?]) throws -> RowIterator { + try prepare(statement, bindings).prepareRowIterator() + } + private func columnNamesForQuery(_ query: QueryType) throws -> [String: Int] { var (columnNames, idx) = ([String: Int](), 0) column: for each in query.clauses.select.columns { diff --git a/Tests/SQLiteTests/Core/ConnectionTests.swift b/Tests/SQLiteTests/Core/ConnectionTests.swift index e9ceff08..9d623f3f 100644 --- a/Tests/SQLiteTests/Core/ConnectionTests.swift +++ b/Tests/SQLiteTests/Core/ConnectionTests.swift @@ -399,7 +399,7 @@ class ConnectionTests: SQLiteTestCase { XCTAssertEqual(1, try db.scalar("SELECT ? = ? COLLATE \"NO DIACRITIC\"", "cafe", "café") as? Int64) } - func test_interrupt_interruptsLongRunningQuery() throws { + func XXX_test_interrupt_interruptsLongRunningQuery() throws { let semaphore = DispatchSemaphore(value: 0) db.createFunction("sleep") { _ in DispatchQueue.global(qos: .background).async { diff --git a/Tests/SQLiteTests/Schema/Connection+SchemaTests.swift b/Tests/SQLiteTests/Schema/Connection+SchemaTests.swift index 6eded6b2..96300529 100644 --- a/Tests/SQLiteTests/Schema/Connection+SchemaTests.swift +++ b/Tests/SQLiteTests/Schema/Connection+SchemaTests.swift @@ -8,127 +8,43 @@ class ConnectionSchemaTests: SQLiteTestCase { try createUsersTable() } - func test_column_info() throws { - let columns = try db.columnInfo(table: "users") - XCTAssertEqual(columns, [ - ColumnDefinition(name: "id", - primaryKey: .init(autoIncrement: false, onConflict: nil), - type: .INTEGER, - nullable: true, - defaultValue: .NULL, - references: nil), - ColumnDefinition(name: "email", - primaryKey: nil, - type: .TEXT, - nullable: false, - defaultValue: .NULL, - references: nil), - ColumnDefinition(name: "age", - primaryKey: nil, - type: .INTEGER, - nullable: true, - defaultValue: .NULL, - references: nil), - ColumnDefinition(name: "salary", - primaryKey: nil, - type: .REAL, - nullable: true, - defaultValue: .NULL, - references: nil), - ColumnDefinition(name: "admin", - primaryKey: nil, - type: .TEXT, - nullable: false, - defaultValue: .numericLiteral("0"), - references: nil), - ColumnDefinition(name: "manager_id", - primaryKey: nil, type: .INTEGER, - nullable: true, - defaultValue: .NULL, - references: .init(table: "users", column: "manager_id", primaryKey: "id", onUpdate: nil, onDelete: nil)), - ColumnDefinition(name: "created_at", - primaryKey: nil, - type: .TEXT, - nullable: true, - defaultValue: .NULL, - references: nil) - ]) + func test_foreignKeyCheck() throws { + let errors = try db.foreignKeyCheck() + XCTAssert(errors.isEmpty) } - func test_column_info_parses_conflict_modifier() throws { - try db.run("CREATE TABLE t (\"id\" INTEGER PRIMARY KEY ON CONFLICT IGNORE AUTOINCREMENT)") - - XCTAssertEqual( - try db.columnInfo(table: "t"), [ - ColumnDefinition( - name: "id", - primaryKey: .init(autoIncrement: true, onConflict: .IGNORE), - type: .INTEGER, - nullable: true, - defaultValue: .NULL, - references: nil) - ] - ) + func test_foreignKeyCheck_with_table() throws { + let errors = try db.foreignKeyCheck(table: "users") + XCTAssert(errors.isEmpty) } - func test_column_info_detects_missing_autoincrement() throws { - try db.run("CREATE TABLE t (\"id\" INTEGER PRIMARY KEY)") - - XCTAssertEqual( - try db.columnInfo(table: "t"), [ - ColumnDefinition( - name: "id", - primaryKey: .init(autoIncrement: false), - type: .INTEGER, - nullable: true, - defaultValue: .NULL, - references: nil) - ] - ) + func test_foreignKeyCheck_table_not_found() throws { + XCTAssertThrowsError(try db.foreignKeyCheck(table: "xxx")) { error in + guard case Result.error(let message, _, _) = error else { + assertionFailure("invalid error type") + return + } + XCTAssertEqual(message, "no such table: xxx") + } } - func test_index_info_no_index() throws { - let indexes = try db.indexInfo(table: "users") - XCTAssertTrue(indexes.isEmpty) + func test_integrityCheck_global() throws { + let results = try db.integrityCheck() + XCTAssert(results.isEmpty) } - func test_index_info_with_index() throws { - try db.run("CREATE UNIQUE INDEX index_users ON users (age DESC) WHERE age IS NOT NULL") - let indexes = try db.indexInfo(table: "users") - - XCTAssertEqual(indexes, [ - IndexDefinition( - table: "users", - name: "index_users", - unique: true, - columns: ["age"], - where: "age IS NOT NULL", - orders: ["age": .DESC] - ) - ]) + func test_integrityCheck_table() throws { + let results = try db.integrityCheck(table: "users") + XCTAssert(results.isEmpty) } - func test_foreign_key_info_empty() throws { - try db.run("CREATE TABLE t (\"id\" INTEGER PRIMARY KEY)") - - let foreignKeys = try db.foreignKeyInfo(table: "t") - XCTAssertTrue(foreignKeys.isEmpty) - } - - func test_foreign_key_info() throws { - let linkTable = Table("test_links") - - let idColumn = SQLite.Expression("id") - let testIdColumn = SQLite.Expression("test_id") - - try db.run(linkTable.create(block: { definition in - definition.column(idColumn, primaryKey: .autoincrement) - definition.column(testIdColumn, unique: false, check: nil, references: users, Expression("id")) - })) - - let foreignKeys = try db.foreignKeyInfo(table: "test_links") - XCTAssertEqual(foreignKeys, [ - .init(table: "users", column: "test_id", primaryKey: "id", onUpdate: nil, onDelete: nil) - ]) + func test_integrityCheck_table_not_found() throws { + XCTAssertThrowsError(try db.integrityCheck(table: "xxx")) { error in + guard case Result.error(let message, _, _) = error else { + assertionFailure("invalid error type") + return + } + XCTAssertEqual(message, "no such table: xxx") + } } } diff --git a/Tests/SQLiteTests/Schema/SchemaChangerTests.swift b/Tests/SQLiteTests/Schema/SchemaChangerTests.swift index 738588fd..af8b6565 100644 --- a/Tests/SQLiteTests/Schema/SchemaChangerTests.swift +++ b/Tests/SQLiteTests/Schema/SchemaChangerTests.swift @@ -3,6 +3,7 @@ import XCTest class SchemaChangerTests: SQLiteTestCase { var schemaChanger: SchemaChanger! + var schemaReader: SchemaReader! override func setUpWithError() throws { try super.setUpWithError() @@ -10,32 +11,33 @@ class SchemaChangerTests: SQLiteTestCase { try insertUsers("bob") + schemaReader = SchemaReader(connection: db) schemaChanger = SchemaChanger(connection: db) } func test_empty_migration_does_not_change_column_definitions() throws { - let previous = try db.columnInfo(table: "users") + let previous = try schemaReader.columnDefinitions(table: "users") try schemaChanger.alter(table: "users") { _ in } - let current = try db.columnInfo(table: "users") + let current = try schemaReader.columnDefinitions(table: "users") XCTAssertEqual(previous, current) } func test_empty_migration_does_not_change_index_definitions() throws { - let previous = try db.indexInfo(table: "users") + let previous = try schemaReader.indexDefinitions(table: "users") try schemaChanger.alter(table: "users") { _ in } - let current = try db.indexInfo(table: "users") + let current = try schemaReader.indexDefinitions(table: "users") XCTAssertEqual(previous, current) } func test_empty_migration_does_not_change_foreign_key_definitions() throws { - let previous = try db.foreignKeyInfo(table: "users") + let previous = try schemaReader.foreignKeys(table: "users") try schemaChanger.alter(table: "users") { _ in } - let current = try db.foreignKeyInfo(table: "users") + let current = try schemaReader.foreignKeys(table: "users") XCTAssertEqual(previous, current) } @@ -53,7 +55,7 @@ class SchemaChangerTests: SQLiteTestCase { try schemaChanger.alter(table: "users") { table in table.remove("age") } - let columns = try db.columnInfo(table: "users").map(\.name) + let columns = try schemaReader.columnDefinitions(table: "users").map(\.name) XCTAssertFalse(columns.contains("age")) } @@ -63,7 +65,7 @@ class SchemaChangerTests: SQLiteTestCase { try schemaChanger.alter(table: "users") { table in table.remove("age") } - let columns = try db.columnInfo(table: "users").map(\.name) + let columns = try schemaReader.columnDefinitions(table: "users").map(\.name) XCTAssertFalse(columns.contains("age")) } @@ -72,7 +74,7 @@ class SchemaChangerTests: SQLiteTestCase { table.rename("age", to: "age2") } - let columns = try db.columnInfo(table: "users").map(\.name) + let columns = try schemaReader.columnDefinitions(table: "users").map(\.name) XCTAssertFalse(columns.contains("age")) XCTAssertTrue(columns.contains("age2")) } @@ -84,7 +86,7 @@ class SchemaChangerTests: SQLiteTestCase { table.rename("age", to: "age2") } - let columns = try db.columnInfo(table: "users").map(\.name) + let columns = try schemaReader.columnDefinitions(table: "users").map(\.name) XCTAssertFalse(columns.contains("age")) XCTAssertTrue(columns.contains("age2")) } @@ -100,7 +102,7 @@ class SchemaChangerTests: SQLiteTestCase { table.add(newColumn) } - let columns = try db.columnInfo(table: "users") + let columns = try schemaReader.columnDefinitions(table: "users") XCTAssertTrue(columns.contains(newColumn)) XCTAssertEqual(try db.pluck(users.select(column))?[column], "foo") diff --git a/Tests/SQLiteTests/Schema/SchemaDefinitionsTests.swift b/Tests/SQLiteTests/Schema/SchemaDefinitionsTests.swift index 75321440..384aab3f 100644 --- a/Tests/SQLiteTests/Schema/SchemaDefinitionsTests.swift +++ b/Tests/SQLiteTests/Schema/SchemaDefinitionsTests.swift @@ -5,37 +5,38 @@ class ColumnDefinitionTests: XCTestCase { var definition: ColumnDefinition! var expected: String! - static let definitions: [(ColumnDefinition, String)] = [ - (ColumnDefinition(name: "id", primaryKey: .init(), type: .INTEGER, nullable: false, defaultValue: .NULL, references: nil), - "\"id\" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL"), + static let definitions: [(String, ColumnDefinition)] = [ + ("\"id\" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL", + ColumnDefinition(name: "id", primaryKey: .init(), type: .INTEGER, nullable: false, defaultValue: .NULL, references: nil)), - (ColumnDefinition(name: "other_id", primaryKey: nil, type: .INTEGER, nullable: false, defaultValue: .NULL, - references: .init(table: "other_table", column: "", primaryKey: "some_id", onUpdate: nil, onDelete: nil)), - "\"other_id\" INTEGER NOT NULL REFERENCES \"other_table\" (\"some_id\")"), + ("\"other_id\" INTEGER NOT NULL REFERENCES \"other_table\" (\"some_id\")", + ColumnDefinition(name: "other_id", primaryKey: nil, type: .INTEGER, nullable: false, defaultValue: .NULL, + references: .init(table: "other_table", column: "", primaryKey: "some_id", onUpdate: nil, onDelete: nil))), - (ColumnDefinition(name: "text", primaryKey: nil, type: .TEXT, nullable: true, defaultValue: .NULL, references: nil), - "\"text\" TEXT"), + ("\"text\" TEXT", + ColumnDefinition(name: "text", primaryKey: nil, type: .TEXT, nullable: true, defaultValue: .NULL, references: nil)), - (ColumnDefinition(name: "text", primaryKey: nil, type: .TEXT, nullable: false, defaultValue: .NULL, references: nil), - "\"text\" TEXT NOT NULL"), + ("\"text\" TEXT NOT NULL", + ColumnDefinition(name: "text", primaryKey: nil, type: .TEXT, nullable: false, defaultValue: .NULL, references: nil)), - (ColumnDefinition(name: "text_column", primaryKey: nil, type: .TEXT, nullable: true, defaultValue: .stringLiteral("fo\"o"), references: nil), - "\"text_column\" TEXT DEFAULT 'fo\"o'"), + ("\"text_column\" TEXT DEFAULT 'fo\"o'", + ColumnDefinition(name: "text_column", primaryKey: nil, type: .TEXT, nullable: true, + defaultValue: .stringLiteral("fo\"o"), references: nil)), - (ColumnDefinition(name: "integer_column", primaryKey: nil, type: .INTEGER, nullable: true, - defaultValue: .numericLiteral("123"), references: nil), - "\"integer_column\" INTEGER DEFAULT 123"), + ("\"integer_column\" INTEGER DEFAULT 123", + ColumnDefinition(name: "integer_column", primaryKey: nil, type: .INTEGER, nullable: true, + defaultValue: .numericLiteral("123"), references: nil)), - (ColumnDefinition(name: "real_column", primaryKey: nil, type: .REAL, nullable: true, - defaultValue: .numericLiteral("123.123"), references: nil), - "\"real_column\" REAL DEFAULT 123.123") + ("\"real_column\" REAL DEFAULT 123.123", + ColumnDefinition(name: "real_column", primaryKey: nil, type: .REAL, nullable: true, + defaultValue: .numericLiteral("123.123"), references: nil)) ] #if !os(Linux) override class var defaultTestSuite: XCTestSuite { let suite = XCTestSuite(forTestCaseClass: ColumnDefinitionTests.self) - for (column, expected) in ColumnDefinitionTests.definitions { + for (expected, column) in ColumnDefinitionTests.definitions { let test = ColumnDefinitionTests(selector: #selector(verify)) test.definition = column test.expected = expected @@ -51,14 +52,17 @@ class ColumnDefinitionTests: XCTestCase { } class AffinityTests: XCTestCase { - func test_from() { - XCTAssertEqual(ColumnDefinition.Affinity.from("TEXT"), .TEXT) - XCTAssertEqual(ColumnDefinition.Affinity.from("text"), .TEXT) - XCTAssertEqual(ColumnDefinition.Affinity.from("INTEGER"), .INTEGER) + func test_init() { + XCTAssertEqual(ColumnDefinition.Affinity("TEXT"), .TEXT) + XCTAssertEqual(ColumnDefinition.Affinity("text"), .TEXT) + XCTAssertEqual(ColumnDefinition.Affinity("INTEGER"), .INTEGER) + XCTAssertEqual(ColumnDefinition.Affinity("BLOB"), .BLOB) + XCTAssertEqual(ColumnDefinition.Affinity("REAL"), .REAL) + XCTAssertEqual(ColumnDefinition.Affinity("NUMERIC"), .NUMERIC) } func test_returns_TEXT_for_unknown_type() { - XCTAssertEqual(ColumnDefinition.Affinity.from("baz"), .TEXT) + XCTAssertEqual(ColumnDefinition.Affinity("baz"), .TEXT) } } @@ -215,13 +219,6 @@ class TableDefinitionTests: XCTestCase { """) } - /* - func test_throws_an_error_when_columns_are_empty() { - let empty = TableDefinition(name: "empty", columns: [], indexes: []) - XCTAssertThrowsError(empty.toSQL()) - } - */ - func test_copySQL() { let from = TableDefinition(name: "from_table", columns: [ ColumnDefinition(name: "id", primaryKey: .init(), type: .INTEGER, nullable: false, defaultValue: .NULL, references: nil) @@ -239,74 +236,105 @@ class TableDefinitionTests: XCTestCase { class PrimaryKeyTests: XCTestCase { func test_toSQL() { - XCTAssertEqual(ColumnDefinition.PrimaryKey(autoIncrement: false).toSQL(), - "PRIMARY KEY") + XCTAssertEqual( + ColumnDefinition.PrimaryKey(autoIncrement: false).toSQL(), + "PRIMARY KEY" + ) } func test_toSQL_autoincrement() { - XCTAssertEqual(ColumnDefinition.PrimaryKey(autoIncrement: true).toSQL(), - "PRIMARY KEY AUTOINCREMENT") + XCTAssertEqual( + ColumnDefinition.PrimaryKey(autoIncrement: true).toSQL(), + "PRIMARY KEY AUTOINCREMENT" + ) } func test_toSQL_on_conflict() { - XCTAssertEqual(ColumnDefinition.PrimaryKey(autoIncrement: false, onConflict: .ROLLBACK).toSQL(), - "PRIMARY KEY ON CONFLICT ROLLBACK") + XCTAssertEqual( + ColumnDefinition.PrimaryKey(autoIncrement: false, onConflict: .ROLLBACK).toSQL(), + "PRIMARY KEY ON CONFLICT ROLLBACK" + ) + } + + func test_fromSQL() { + XCTAssertEqual( + ColumnDefinition.PrimaryKey(sql: "PRIMARY KEY"), + ColumnDefinition.PrimaryKey(autoIncrement: false) + ) + } + + func test_fromSQL_invalid_sql_is_nil() { + XCTAssertNil(ColumnDefinition.PrimaryKey(sql: "FOO")) + } + + func test_fromSQL_autoincrement() { + XCTAssertEqual( + ColumnDefinition.PrimaryKey(sql: "PRIMARY KEY AUTOINCREMENT"), + ColumnDefinition.PrimaryKey(autoIncrement: true) + ) + } + + func test_fromSQL_on_conflict() { + XCTAssertEqual( + ColumnDefinition.PrimaryKey(sql: "PRIMARY KEY ON CONFLICT ROLLBACK"), + ColumnDefinition.PrimaryKey(autoIncrement: false, onConflict: .ROLLBACK) + ) } } class LiteralValueTests: XCTestCase { func test_recognizes_TRUE() { - XCTAssertEqual(LiteralValue.from("TRUE"), .TRUE) + XCTAssertEqual(LiteralValue("TRUE"), .TRUE) } func test_recognizes_FALSE() { - XCTAssertEqual(LiteralValue.from("FALSE"), .FALSE) + XCTAssertEqual(LiteralValue("FALSE"), .FALSE) } func test_recognizes_NULL() { - XCTAssertEqual(LiteralValue.from("NULL"), .NULL) + XCTAssertEqual(LiteralValue("NULL"), .NULL) } func test_recognizes_nil() { - XCTAssertEqual(LiteralValue.from(nil), .NULL) + XCTAssertEqual(LiteralValue(nil), .NULL) } func test_recognizes_CURRENT_TIME() { - XCTAssertEqual(LiteralValue.from("CURRENT_TIME"), .CURRENT_TIME) + XCTAssertEqual(LiteralValue("CURRENT_TIME"), .CURRENT_TIME) } func test_recognizes_CURRENT_TIMESTAMP() { - XCTAssertEqual(LiteralValue.from("CURRENT_TIMESTAMP"), .CURRENT_TIMESTAMP) + XCTAssertEqual(LiteralValue("CURRENT_TIMESTAMP"), .CURRENT_TIMESTAMP) } func test_recognizes_CURRENT_DATE() { - XCTAssertEqual(LiteralValue.from("CURRENT_DATE"), .CURRENT_DATE) + XCTAssertEqual(LiteralValue("CURRENT_DATE"), .CURRENT_DATE) } func test_recognizes_double_quote_string_literals() { - XCTAssertEqual(LiteralValue.from("\"foo\""), .stringLiteral("foo")) + XCTAssertEqual(LiteralValue("\"foo\""), .stringLiteral("foo")) } func test_recognizes_single_quote_string_literals() { - XCTAssertEqual(LiteralValue.from("\'foo\'"), .stringLiteral("foo")) + XCTAssertEqual(LiteralValue("\'foo\'"), .stringLiteral("foo")) } func test_unquotes_double_quote_string_literals() { - XCTAssertEqual(LiteralValue.from("\"fo\"\"o\""), .stringLiteral("fo\"o")) + XCTAssertEqual(LiteralValue("\"fo\"\"o\""), .stringLiteral("fo\"o")) } func test_unquotes_single_quote_string_literals() { - XCTAssertEqual(LiteralValue.from("'fo''o'"), .stringLiteral("fo'o")) + XCTAssertEqual(LiteralValue("'fo''o'"), .stringLiteral("fo'o")) } func test_recognizes_numeric_literals() { - XCTAssertEqual(LiteralValue.from("1.2"), .numericLiteral("1.2")) - XCTAssertEqual(LiteralValue.from("0xdeadbeef"), .numericLiteral("0xdeadbeef")) + XCTAssertEqual(LiteralValue("1.2"), .numericLiteral("1.2")) + XCTAssertEqual(LiteralValue("0xdeadbeef"), .numericLiteral("0xdeadbeef")) } func test_recognizes_blob_literals() { - XCTAssertEqual(LiteralValue.from("X'deadbeef'"), .blobLiteral("deadbeef")) - XCTAssertEqual(LiteralValue.from("x'deadbeef'"), .blobLiteral("deadbeef")) + XCTAssertEqual(LiteralValue("X'deadbeef'"), .blobLiteral("deadbeef")) + XCTAssertEqual(LiteralValue("x'deadbeef'"), .blobLiteral("deadbeef")) } func test_description_TRUE() { diff --git a/Tests/SQLiteTests/Schema/SchemaReaderTests.swift b/Tests/SQLiteTests/Schema/SchemaReaderTests.swift new file mode 100644 index 00000000..165dbc28 --- /dev/null +++ b/Tests/SQLiteTests/Schema/SchemaReaderTests.swift @@ -0,0 +1,180 @@ +import XCTest +@testable import SQLite + +class SchemaReaderTests: SQLiteTestCase { + private var schemaReader: SchemaReader! + + override func setUpWithError() throws { + try super.setUpWithError() + try createUsersTable() + + schemaReader = db.schemaReader + } + + func test_columnDefinitions() throws { + let columns = try schemaReader.columnDefinitions(table: "users") + XCTAssertEqual(columns, [ + ColumnDefinition(name: "id", + primaryKey: .init(autoIncrement: false, onConflict: nil), + type: .INTEGER, + nullable: true, + defaultValue: .NULL, + references: nil), + ColumnDefinition(name: "email", + primaryKey: nil, + type: .TEXT, + nullable: false, + defaultValue: .NULL, + references: nil), + ColumnDefinition(name: "age", + primaryKey: nil, + type: .INTEGER, + nullable: true, + defaultValue: .NULL, + references: nil), + ColumnDefinition(name: "salary", + primaryKey: nil, + type: .REAL, + nullable: true, + defaultValue: .NULL, + references: nil), + ColumnDefinition(name: "admin", + primaryKey: nil, + type: .TEXT, + nullable: false, + defaultValue: .numericLiteral("0"), + references: nil), + ColumnDefinition(name: "manager_id", + primaryKey: nil, type: .INTEGER, + nullable: true, + defaultValue: .NULL, + references: .init(table: "users", column: "manager_id", primaryKey: "id", onUpdate: nil, onDelete: nil)), + ColumnDefinition(name: "created_at", + primaryKey: nil, + type: .TEXT, + nullable: true, + defaultValue: .NULL, + references: nil) + ]) + } + + func test_columnDefinitions_parses_conflict_modifier() throws { + try db.run("CREATE TABLE t (\"id\" INTEGER PRIMARY KEY ON CONFLICT IGNORE AUTOINCREMENT)") + + XCTAssertEqual( + try schemaReader.columnDefinitions(table: "t"), [ + ColumnDefinition( + name: "id", + primaryKey: .init(autoIncrement: true, onConflict: .IGNORE), + type: .INTEGER, + nullable: true, + defaultValue: .NULL, + references: nil) + ] + ) + } + + func test_columnDefinitions_detects_missing_autoincrement() throws { + try db.run("CREATE TABLE t (\"id\" INTEGER PRIMARY KEY)") + + XCTAssertEqual( + try schemaReader.columnDefinitions(table: "t"), [ + ColumnDefinition( + name: "id", + primaryKey: .init(autoIncrement: false), + type: .INTEGER, + nullable: true, + defaultValue: .NULL, + references: nil) + ] + ) + } + + func test_indexDefinitions_no_index() throws { + let indexes = try schemaReader.indexDefinitions(table: "users") + XCTAssertTrue(indexes.isEmpty) + } + + func test_indexDefinitions_with_index() throws { + try db.run("CREATE UNIQUE INDEX index_users ON users (age DESC) WHERE age IS NOT NULL") + let indexes = try schemaReader.indexDefinitions(table: "users") + + XCTAssertEqual(indexes, [ + IndexDefinition( + table: "users", + name: "index_users", + unique: true, + columns: ["age"], + where: "age IS NOT NULL", + orders: ["age": .DESC] + ) + ]) + } + + func test_foreignKeys_info_empty() throws { + try db.run("CREATE TABLE t (\"id\" INTEGER PRIMARY KEY)") + + let foreignKeys = try schemaReader.foreignKeys(table: "t") + XCTAssertTrue(foreignKeys.isEmpty) + } + + func test_foreignKeys() throws { + let linkTable = Table("test_links") + + let idColumn = SQLite.Expression("id") + let testIdColumn = SQLite.Expression("test_id") + + try db.run(linkTable.create(block: { definition in + definition.column(idColumn, primaryKey: .autoincrement) + definition.column(testIdColumn, unique: false, check: nil, references: users, Expression("id")) + })) + + let foreignKeys = try schemaReader.foreignKeys(table: "test_links") + XCTAssertEqual(foreignKeys, [ + .init(table: "users", column: "test_id", primaryKey: "id", onUpdate: nil, onDelete: nil) + ]) + } + + func test_tableDefinitions() throws { + let tables = try schemaReader.tableDefinitions() + XCTAssertEqual(tables.count, 1) + XCTAssertEqual(tables.first?.name, "users") + } + + func test_objectDefinitions() throws { + let tables = try schemaReader.objectDefinitions() + + XCTAssertEqual(tables.map { table in [table.name, table.tableName, table.type.rawValue]}, [ + ["users", "users", "table"], + ["sqlite_autoindex_users_1", "sqlite_autoindex_users_1", "index"] + ]) + } + + func test_objectDefinitions_temporary() throws { + let tables = try schemaReader.objectDefinitions(temp: true) + XCTAssert(tables.isEmpty) + + try db.run("CREATE TEMPORARY TABLE foo (bar TEXT)") + + let tables2 = try schemaReader.objectDefinitions(temp: true) + XCTAssertEqual(tables2.map { table in [table.name, table.tableName, table.type.rawValue]}, [ + ["foo", "foo", "table"] + ]) + } + + func test_objectDefinitionsFilterByType() throws { + let tables = try schemaReader.objectDefinitions(type: .table) + + XCTAssertEqual(tables.map { table in [table.name, table.tableName, table.type.rawValue]}, [ + ["users", "users", "table"] + ]) + } + + func test_objectDefinitionsFilterByName() throws { + let tables = try schemaReader.objectDefinitions(name: "users") + + XCTAssertEqual(tables.map { table in [table.name, table.tableName, table.type.rawValue]}, [ + ["users", "users", "table"] + ]) + } +} From 2fc0decf41a26787c4d3b2b23ef07786b44f801b Mon Sep 17 00:00:00 2001 From: Jan Berkel Date: Sun, 16 Oct 2022 19:54:06 +0200 Subject: [PATCH 06/12] Fix tests on older version of SQLite --- .swiftlint.yml | 1 + SQLite.xcodeproj/project.pbxproj | 10 +++++++ Sources/SQLite/Core/SQLiteFeature.swift | 19 ++++++++++++++ Sources/SQLite/Schema/Connection+Schema.swift | 6 +++-- Sources/SQLite/Schema/SchemaReader.swift | 26 ++++++++++++++----- .../Schema/Connection+SchemaTests.swift | 4 ++- 6 files changed, 57 insertions(+), 9 deletions(-) create mode 100644 Sources/SQLite/Core/SQLiteFeature.swift diff --git a/.swiftlint.yml b/.swiftlint.yml index 34bb253b..d40be28e 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -3,6 +3,7 @@ disabled_rules: # rule identifiers to exclude from running - operator_whitespace - large_tuple - closure_parameter_position + - inclusive_language # sqlite_master etc. included: # paths to include during linting. `--path` is ignored if present. takes precendence over `excluded`. - Sources - Tests diff --git a/SQLite.xcodeproj/project.pbxproj b/SQLite.xcodeproj/project.pbxproj index a58e0887..6fe89283 100644 --- a/SQLite.xcodeproj/project.pbxproj +++ b/SQLite.xcodeproj/project.pbxproj @@ -203,6 +203,10 @@ DB58B21228FB864300F8EEA4 /* SchemaReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB58B21028FB864300F8EEA4 /* SchemaReader.swift */; }; DB58B21328FB864300F8EEA4 /* SchemaReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB58B21028FB864300F8EEA4 /* SchemaReader.swift */; }; DB58B21428FB864300F8EEA4 /* SchemaReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB58B21028FB864300F8EEA4 /* SchemaReader.swift */; }; + DB58B21628FC7C4600F8EEA4 /* SQLiteFeature.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB58B21528FC7C4600F8EEA4 /* SQLiteFeature.swift */; }; + DB58B21728FC7C4600F8EEA4 /* SQLiteFeature.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB58B21528FC7C4600F8EEA4 /* SQLiteFeature.swift */; }; + DB58B21828FC7C4600F8EEA4 /* SQLiteFeature.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB58B21528FC7C4600F8EEA4 /* SQLiteFeature.swift */; }; + DB58B21928FC7C4600F8EEA4 /* SQLiteFeature.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB58B21528FC7C4600F8EEA4 /* SQLiteFeature.swift */; }; DB7C5DA628D7C9B6006395CF /* SQLiteVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB7C5DA528D7C9B6006395CF /* SQLiteVersion.swift */; }; DB7C5DA728D7C9B6006395CF /* SQLiteVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB7C5DA528D7C9B6006395CF /* SQLiteVersion.swift */; }; DB7C5DA828D7C9B6006395CF /* SQLiteVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB7C5DA528D7C9B6006395CF /* SQLiteVersion.swift */; }; @@ -334,6 +338,7 @@ 997DF2AD287FC06D00F8DF95 /* Query+with.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Query+with.swift"; sourceTree = ""; }; A121AC451CA35C79005A31D1 /* SQLite.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = SQLite.framework; sourceTree = BUILT_PRODUCTS_DIR; }; DB58B21028FB864300F8EEA4 /* SchemaReader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SchemaReader.swift; sourceTree = ""; }; + DB58B21528FC7C4600F8EEA4 /* SQLiteFeature.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SQLiteFeature.swift; sourceTree = ""; }; DB7C5DA528D7C9B6006395CF /* SQLiteVersion.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SQLiteVersion.swift; sourceTree = ""; }; EE247AD31C3F04ED00AE3E12 /* SQLite.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = SQLite.framework; sourceTree = BUILT_PRODUCTS_DIR; }; EE247AD61C3F04ED00AE3E12 /* SQLite.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SQLite.h; sourceTree = ""; }; @@ -580,6 +585,7 @@ 19A175A9CB446640AE6F2200 /* Connection+Aggregation.swift */, 3DF7B78728842972005DD8CA /* Connection+Attach.swift */, 3DF7B790288449BA005DD8CA /* URIQueryParameter.swift */, + DB58B21528FC7C4600F8EEA4 /* SQLiteFeature.swift */, DB7C5DA528D7C9B6006395CF /* SQLiteVersion.swift */, 19A17F285B767BFACD96714B /* Connection+Pragmas.swift */, ); @@ -970,6 +976,7 @@ 19A17073552293CA063BEA66 /* Result.swift in Sources */, 997DF2B0287FC06D00F8DF95 /* Query+with.swift in Sources */, 19A179B59450FE7C4811AB8A /* Connection+Aggregation.swift in Sources */, + DB58B21828FC7C4600F8EEA4 /* SQLiteFeature.swift in Sources */, 19A17FC07731779C1B8506FA /* SchemaChanger.swift in Sources */, 19A1740EACD47904AA24B8DC /* SchemaDefinitions.swift in Sources */, 19A1750EF4A5F92954A451FF /* Connection+Schema.swift in Sources */, @@ -1030,6 +1037,7 @@ DB7C5DA928D7C9B6006395CF /* SQLiteVersion.swift in Sources */, 3D67B3EA1DB246D100A4F4C6 /* Value.swift in Sources */, 3D67B3EB1DB246D100A4F4C6 /* FTS4.swift in Sources */, + DB58B21928FC7C4600F8EEA4 /* SQLiteFeature.swift in Sources */, 3D67B3EC1DB246D100A4F4C6 /* RTree.swift in Sources */, 3D67B3ED1DB246D100A4F4C6 /* FTS5.swift in Sources */, 3D67B3EE1DB246D100A4F4C6 /* AggregateFunctions.swift in Sources */, @@ -1089,6 +1097,7 @@ 19A173EFEF0B3BD0B3ED406C /* Result.swift in Sources */, 997DF2AE287FC06D00F8DF95 /* Query+with.swift in Sources */, 19A176376CB6A94759F7980A /* Connection+Aggregation.swift in Sources */, + DB58B21628FC7C4600F8EEA4 /* SQLiteFeature.swift in Sources */, 19A1773A335CAB9D0AE14E8E /* SchemaChanger.swift in Sources */, 19A17BA13FD35F058787B7D3 /* SchemaDefinitions.swift in Sources */, 19A174506543905D71BF0518 /* Connection+Schema.swift in Sources */, @@ -1168,6 +1177,7 @@ 19A17F1B3F0A3C96B5ED6D64 /* Result.swift in Sources */, 997DF2AF287FC06D00F8DF95 /* Query+with.swift in Sources */, 19A170ACC97B19730FB7BA4D /* Connection+Aggregation.swift in Sources */, + DB58B21728FC7C4600F8EEA4 /* SQLiteFeature.swift in Sources */, 19A177290558991BCC60E4E3 /* SchemaChanger.swift in Sources */, 19A17B0DF1DDB6BBC9C95D64 /* SchemaDefinitions.swift in Sources */, 19A17F0BF02896E1664F4090 /* Connection+Schema.swift in Sources */, diff --git a/Sources/SQLite/Core/SQLiteFeature.swift b/Sources/SQLite/Core/SQLiteFeature.swift new file mode 100644 index 00000000..36b5b31b --- /dev/null +++ b/Sources/SQLite/Core/SQLiteFeature.swift @@ -0,0 +1,19 @@ +import Foundation + +enum SQLiteFeature { + case partialIntegrityCheck // PRAGMA integrity_check(table) + case sqliteSchemaTable // sqlite_master => sqlite_schema + + func isSupported(by version: SQLiteVersion) -> Bool { + switch self { + case .partialIntegrityCheck, .sqliteSchemaTable: + return version > SQLiteVersion(major: 3, minor: 33) + } + } +} + +extension Connection { + func supports(_ feature: SQLiteFeature) -> Bool { + feature.isSupported(by: sqliteVersion) + } +} diff --git a/Sources/SQLite/Schema/Connection+Schema.swift b/Sources/SQLite/Schema/Connection+Schema.swift index 868b40d5..d36812eb 100644 --- a/Sources/SQLite/Schema/Connection+Schema.swift +++ b/Sources/SQLite/Schema/Connection+Schema.swift @@ -25,8 +25,10 @@ public extension Connection { // This pragma does a low-level formatting and consistency check of the database. // https://sqlite.org/pragma.html#pragma_integrity_check - func integrityCheck(table: String? = nil, maxErrors: Int? = nil) throws -> [String] { - try run("PRAGMA integrity_check" + (table.map { "(\($0.quote()))" } ?? "")) + func integrityCheck(table: String? = nil) throws -> [String] { + precondition(table == nil || supports(.partialIntegrityCheck), "partial integrity check not supported") + + return try run("PRAGMA integrity_check" + (table.map { "(\($0.quote()))" } ?? "")) .compactMap { $0[0] as? String } .filter { $0 != "ok" } } diff --git a/Sources/SQLite/Schema/SchemaReader.swift b/Sources/SQLite/Schema/SchemaReader.swift index 3f02eeae..bfa2e887 100644 --- a/Sources/SQLite/Schema/SchemaReader.swift +++ b/Sources/SQLite/Schema/SchemaReader.swift @@ -38,7 +38,7 @@ public class SchemaReader { public func objectDefinitions(name: String? = nil, type: ObjectDefinition.ObjectType? = nil, temp: Bool = false) throws -> [ObjectDefinition] { - var query: QueryType = temp ? SchemaTable.tempName : SchemaTable.name + var query: QueryType = connection.schemaTable(temp: temp) if let name = name { query = query.where(SchemaTable.nameColumn == name) } @@ -99,9 +99,9 @@ public class SchemaReader { column: row[ForeignKeyListTable.fromColumn], primaryKey: row[ForeignKeyListTable.toColumn], onUpdate: row[ForeignKeyListTable.onUpdateColumn] == TableBuilder.Dependency.noAction.rawValue - ? nil : row[ForeignKeyListTable.onUpdateColumn], + ? nil : row[ForeignKeyListTable.onUpdateColumn], onDelete: row[ForeignKeyListTable.onDeleteColumn] == TableBuilder.Dependency.noAction.rawValue - ? nil : row[ForeignKeyListTable.onDeleteColumn] + ? nil : row[ForeignKeyListTable.onDeleteColumn] ) } } @@ -110,9 +110,9 @@ public class SchemaReader { try objectDefinitions(type: .table) .map { table in TableDefinition( - name: table.name, - columns: try columnDefinitions(table: table.name), - indexes: try indexDefinitions(table: table.name) + name: table.name, + columns: try columnDefinitions(table: table.name), + indexes: try indexDefinitions(table: table.name) ) } } @@ -129,6 +129,10 @@ private class SchemaTable { internal static let name = Table("sqlite_schema", database: "main") internal static let tempName = Table("sqlite_schema", database: "temp") + // legacy table names + internal static let masterName = Table("sqlite_master") + internal static let tempMasterName = Table("sqlite_temp_master") + static let typeColumn = Expression("type") static let nameColumn = Expression("name") static let tableNameColumn = Expression("tbl_name") @@ -180,3 +184,13 @@ private class ForeignKeyListTable { static let onDeleteColumn = Expression("on_delete") static let matchColumn = Expression("match") } + +private extension Connection { + func schemaTable(temp: Bool = false) -> Table { + if supports(.sqliteSchemaTable) { + return temp ? SchemaTable.tempName : SchemaTable.name + } else { + return temp ? SchemaTable.tempMasterName : SchemaTable.masterName + } + } +} diff --git a/Tests/SQLiteTests/Schema/Connection+SchemaTests.swift b/Tests/SQLiteTests/Schema/Connection+SchemaTests.swift index 96300529..57e2726b 100644 --- a/Tests/SQLiteTests/Schema/Connection+SchemaTests.swift +++ b/Tests/SQLiteTests/Schema/Connection+SchemaTests.swift @@ -33,12 +33,14 @@ class ConnectionSchemaTests: SQLiteTestCase { XCTAssert(results.isEmpty) } - func test_integrityCheck_table() throws { + func test_partial_integrityCheck_table() throws { + guard db.supports(.partialIntegrityCheck) else { return } let results = try db.integrityCheck(table: "users") XCTAssert(results.isEmpty) } func test_integrityCheck_table_not_found() throws { + guard db.supports(.partialIntegrityCheck) else { return } XCTAssertThrowsError(try db.integrityCheck(table: "xxx")) { error in guard case Result.error(let message, _, _) = error else { assertionFailure("invalid error type") From a6a151b3395c83f845d6d88d7d7565df824ef239 Mon Sep 17 00:00:00 2001 From: Jan Berkel Date: Sun, 16 Oct 2022 20:57:25 +0200 Subject: [PATCH 07/12] Reuse SQLiteFeature in SchemaChanger --- Sources/SQLite/Core/SQLiteFeature.swift | 8 +++- Sources/SQLite/Schema/Connection+Schema.swift | 4 +- Sources/SQLite/Schema/SchemaChanger.swift | 6 +-- Sources/SQLite/Schema/SchemaDefinitions.swift | 5 ++- Sources/SQLite/Schema/SchemaReader.swift | 40 +++++++++---------- .../Schema/SchemaReaderTests.swift | 4 +- 6 files changed, 37 insertions(+), 30 deletions(-) diff --git a/Sources/SQLite/Core/SQLiteFeature.swift b/Sources/SQLite/Core/SQLiteFeature.swift index 36b5b31b..50d910a3 100644 --- a/Sources/SQLite/Core/SQLiteFeature.swift +++ b/Sources/SQLite/Core/SQLiteFeature.swift @@ -3,11 +3,17 @@ import Foundation enum SQLiteFeature { case partialIntegrityCheck // PRAGMA integrity_check(table) case sqliteSchemaTable // sqlite_master => sqlite_schema + case renameColumn // ALTER TABLE ... RENAME COLUMN + case dropColumn // ALTER TABLE ... DROP COLUMN func isSupported(by version: SQLiteVersion) -> Bool { switch self { case .partialIntegrityCheck, .sqliteSchemaTable: - return version > SQLiteVersion(major: 3, minor: 33) + return version >= .init(major: 3, minor: 33) + case .renameColumn: + return version >= .init(major: 3, minor: 25) + case .dropColumn: + return version >= .init(major: 3, minor: 35) } } } diff --git a/Sources/SQLite/Schema/Connection+Schema.swift b/Sources/SQLite/Schema/Connection+Schema.swift index d36812eb..2977af17 100644 --- a/Sources/SQLite/Schema/Connection+Schema.swift +++ b/Sources/SQLite/Schema/Connection+Schema.swift @@ -1,7 +1,7 @@ import Foundation public extension Connection { - var schemaReader: SchemaReader { SchemaReader(connection: self) } + var schema: SchemaReader { SchemaReader(connection: self) } // There are four columns in each result row. // The first column is the name of the table that @@ -14,7 +14,7 @@ public extension Connection { // https://sqlite.org/pragma.html#pragma_foreign_key_check func foreignKeyCheck(table: String? = nil) throws -> [ForeignKeyError] { try run("PRAGMA foreign_key_check" + (table.map { "(\($0.quote()))" } ?? "")) - .compactMap { row -> ForeignKeyError? in + .compactMap { (row: [Binding?]) -> ForeignKeyError? in guard let table = row[0] as? String, let rowId = row[1] as? Int64, let target = row[2] as? String else { return nil } diff --git a/Sources/SQLite/Schema/SchemaChanger.swift b/Sources/SQLite/Schema/SchemaChanger.swift index b557da0a..4e1b42c5 100644 --- a/Sources/SQLite/Schema/SchemaChanger.swift +++ b/Sources/SQLite/Schema/SchemaChanger.swift @@ -52,9 +52,9 @@ public class SchemaChanger: CustomStringConvertible { switch self { case .addColumn(let definition): return "ALTER TABLE \(table.quote()) ADD COLUMN \(definition.toSQL())" - case .renameColumn(let from, let to) where version >= .init(major: 3, minor: 25): + case .renameColumn(let from, let to) where SQLiteFeature.renameColumn.isSupported(by: version): return "ALTER TABLE \(table.quote()) RENAME COLUMN \(from.quote()) TO \(to.quote())" - case .dropColumn(let column) where version >= .init(major: 3, minor: 35): + case .dropColumn(let column) where SQLiteFeature.dropColumn.isSupported(by: version): return "ALTER TABLE \(table.quote()) DROP COLUMN \(column.quote())" default: return nil } @@ -128,7 +128,7 @@ public class SchemaChanger: CustomStringConvertible { init(connection: Connection, version: SQLiteVersion) { self.connection = connection - schemaReader = connection.schemaReader + schemaReader = connection.schema self.version = version } diff --git a/Sources/SQLite/Schema/SchemaDefinitions.swift b/Sources/SQLite/Schema/SchemaDefinitions.swift index fe2e3931..4159b35b 100644 --- a/Sources/SQLite/Schema/SchemaDefinitions.swift +++ b/Sources/SQLite/Schema/SchemaDefinitions.swift @@ -245,8 +245,9 @@ public struct IndexDefinition: Equatable { } func orders(sql: String) -> [String: IndexDefinition.Order] { - IndexDefinition.orderRe.matches(in: sql, range: NSRange(location: 0, length: sql.count)) - .reduce([String: IndexDefinition.Order]()) { (memo, result) in + IndexDefinition.orderRe + .matches(in: sql, range: NSRange(location: 0, length: sql.count)) + .reduce([String: IndexDefinition.Order]()) { (memo, result) in var memo2 = memo let column = (sql as NSString).substring(with: result.range(at: 1)) memo2[column] = .DESC diff --git a/Sources/SQLite/Schema/SchemaReader.swift b/Sources/SQLite/Schema/SchemaReader.swift index bfa2e887..b1292471 100644 --- a/Sources/SQLite/Schema/SchemaReader.swift +++ b/Sources/SQLite/Schema/SchemaReader.swift @@ -38,7 +38,7 @@ public class SchemaReader { public func objectDefinitions(name: String? = nil, type: ObjectDefinition.ObjectType? = nil, temp: Bool = false) throws -> [ObjectDefinition] { - var query: QueryType = connection.schemaTable(temp: temp) + var query: QueryType = SchemaTable.get(for: connection, temp: temp) if let name = name { query = query.where(SchemaTable.nameColumn == name) } @@ -125,14 +125,22 @@ public class SchemaReader { } } -private class SchemaTable { - internal static let name = Table("sqlite_schema", database: "main") - internal static let tempName = Table("sqlite_schema", database: "temp") +private enum SchemaTable { + private static let name = Table("sqlite_schema", database: "main") + private static let tempName = Table("sqlite_schema", database: "temp") + // legacy names (< 3.33.0) + private static let masterName = Table("sqlite_master") + private static let tempMasterName = Table("sqlite_temp_master") - // legacy table names - internal static let masterName = Table("sqlite_master") - internal static let tempMasterName = Table("sqlite_temp_master") + static func get(for connection: Connection, temp: Bool = false) -> Table { + if connection.supports(.sqliteSchemaTable) { + return temp ? SchemaTable.tempName : SchemaTable.name + } else { + return temp ? SchemaTable.tempMasterName : SchemaTable.masterName + } + } + // columns static let typeColumn = Expression("type") static let nameColumn = Expression("name") static let tableNameColumn = Expression("tbl_name") @@ -140,7 +148,7 @@ private class SchemaTable { static let sqlColumn = Expression("sql") } -private class TableInfoTable { +private enum TableInfoTable { static let idColumn = Expression("cid") static let nameColumn = Expression("name") static let typeColumn = Expression("type") @@ -149,7 +157,7 @@ private class TableInfoTable { static let primaryKeyColumn = Expression("pk") } -private class IndexInfoTable { +private enum IndexInfoTable { // The rank of the column within the index. (0 means left-most.) static let seqnoColumn = Expression("seqno") // The rank of the column within the table being indexed. @@ -159,7 +167,7 @@ private class IndexInfoTable { static let nameColumn = Expression("name") } -private class IndexListTable { +private enum IndexListTable { // A sequence number assigned to each index for internal tracking purposes. static let seqColumn = Expression("seq") // The name of the index @@ -174,7 +182,7 @@ private class IndexListTable { static let partialColumn = Expression("partial") } -private class ForeignKeyListTable { +private enum ForeignKeyListTable { static let idColumn = Expression("id") static let seqColumn = Expression("seq") static let tableColumn = Expression("table") @@ -184,13 +192,3 @@ private class ForeignKeyListTable { static let onDeleteColumn = Expression("on_delete") static let matchColumn = Expression("match") } - -private extension Connection { - func schemaTable(temp: Bool = false) -> Table { - if supports(.sqliteSchemaTable) { - return temp ? SchemaTable.tempName : SchemaTable.name - } else { - return temp ? SchemaTable.tempMasterName : SchemaTable.masterName - } - } -} diff --git a/Tests/SQLiteTests/Schema/SchemaReaderTests.swift b/Tests/SQLiteTests/Schema/SchemaReaderTests.swift index 165dbc28..95dba921 100644 --- a/Tests/SQLiteTests/Schema/SchemaReaderTests.swift +++ b/Tests/SQLiteTests/Schema/SchemaReaderTests.swift @@ -8,7 +8,7 @@ class SchemaReaderTests: SQLiteTestCase { try super.setUpWithError() try createUsersTable() - schemaReader = db.schemaReader + schemaReader = db.schema } func test_columnDefinitions() throws { @@ -168,6 +168,7 @@ class SchemaReaderTests: SQLiteTestCase { XCTAssertEqual(tables.map { table in [table.name, table.tableName, table.type.rawValue]}, [ ["users", "users", "table"] ]) + XCTAssertTrue((try schemaReader.objectDefinitions(type: .trigger)).isEmpty) } func test_objectDefinitionsFilterByName() throws { @@ -176,5 +177,6 @@ class SchemaReaderTests: SQLiteTestCase { XCTAssertEqual(tables.map { table in [table.name, table.tableName, table.type.rawValue]}, [ ["users", "users", "table"] ]) + XCTAssertTrue((try schemaReader.objectDefinitions(name: "xxx")).isEmpty) } } From 0f6c3e30245d09bdd1fd50d5e0ac0d162131426c Mon Sep 17 00:00:00 2001 From: Jan Berkel Date: Tue, 18 Oct 2022 01:11:08 +0200 Subject: [PATCH 08/12] Add documentation --- Documentation/Index.md | 82 ++++++++++++++++++- Sources/SQLite/Schema/SchemaChanger.swift | 2 +- Sources/SQLite/Schema/SchemaDefinitions.swift | 4 + Sources/SQLite/Schema/SchemaReader.swift | 2 +- .../Schema/SchemaChangerTests.swift | 34 ++++---- .../Schema/SchemaReaderTests.swift | 34 +++++++- 6 files changed, 134 insertions(+), 24 deletions(-) diff --git a/Documentation/Index.md b/Documentation/Index.md index 29b18c96..e40f786d 100644 --- a/Documentation/Index.md +++ b/Documentation/Index.md @@ -1371,6 +1371,37 @@ try db.transaction { > _Note:_ Transactions run in a serial queue. +## Querying the Schema + +We can obtain generic information about objects in the current schema with a `SchemaReader`: + +```swift +let schema = db.schema +``` + +To query the data: + +```swift +let indexes = try schema.objectDefinitions(type: .index) +let tables = try schema.objectDefinitions(type: .table) +let triggers = try schema.objectDefinitions(type: .trigger) +``` + +### Indexes and Columns + +Specialized methods are available to get more detailed information: + +```swift +let indexes = try schema.indexDefinitions("users") +let columns = try schema.columnDefinitions("users") + +for index in indexes { + print("\(index.name) columns:\(index.columns))") +} +for column in columns { + print("\(column.name) pk:\(column.primaryKey) nullable: \(column.nullable)") +} +``` ## Altering the Schema @@ -1454,11 +1485,56 @@ tables](#creating-a-table). ### Renaming Columns -Added in SQLite 3.25.0, not exposed yet. [#1073](https://github.com/stephencelis/SQLite.swift/issues/1073) +We can rename columns with the help of the `SchemaChanger` class: + +```swift +let schemaChanger = SchemaChanger(connection: db) +try schemaChanger.alter(table: "users") { table in + table.rename("old_name", to: "new_name") +} +``` ### Dropping Columns -Added in SQLite 3.35.0, not exposed yet. [#1073](https://github.com/stephencelis/SQLite.swift/issues/1073) +```swift +let schemaChanger = SchemaChanger(connection: db) +try schemaChanger.alter(table: "users") { table in + table.drop("column") +} +``` + +These operations will work with all versions of SQLite and use modern SQL +operations such as `DROP COLUMN` when available. + +### Adding Columns (SchemaChanger) + +The `SchemaChanger` provides an alternative API to add new columns: + +```swift +let newColumn = ColumnDefinition( + name: "new_text_column", + type: .TEXT, + nullable: true, + defaultValue: .stringLiteral("foo") +) + +let schemaChanger = SchemaChanger(connection: db) + +try schemaChanger.alter(table: "users") { table in + table.add(newColumn) +} +``` + +### Renaming/dropping Tables (SchemaChanger) + +The `SchemaChanger` provides an alternative API to rename and drop tables: + +```swift +let schemaChanger = SchemaChanger(connection: db) + +try schemaChanger.rename(table: "users", to: "users_new") +try schemaChanger.drop(table: "emails") +``` ### Indexes @@ -1515,7 +1591,6 @@ try db.run(users.dropIndex(email, ifExists: true)) // DROP INDEX IF EXISTS "index_users_on_email" ``` - ### Dropping Tables We can build @@ -1535,7 +1610,6 @@ try db.run(users.drop(ifExists: true)) // DROP TABLE IF EXISTS "users" ``` - ### Migrations and Schema Versioning You can use the convenience property on `Connection` to query and set the diff --git a/Sources/SQLite/Schema/SchemaChanger.swift b/Sources/SQLite/Schema/SchemaChanger.swift index 4e1b42c5..d2ada22c 100644 --- a/Sources/SQLite/Schema/SchemaChanger.swift +++ b/Sources/SQLite/Schema/SchemaChanger.swift @@ -99,7 +99,7 @@ public class SchemaChanger: CustomStringConvertible { operations.append(.addColumn(column)) } - public func remove(_ column: String) { + public func drop(_ column: String) { operations.append(.dropColumn(column)) } diff --git a/Sources/SQLite/Schema/SchemaDefinitions.swift b/Sources/SQLite/Schema/SchemaDefinitions.swift index 4159b35b..a2700dd2 100644 --- a/Sources/SQLite/Schema/SchemaDefinitions.swift +++ b/Sources/SQLite/Schema/SchemaDefinitions.swift @@ -31,6 +31,10 @@ public struct ObjectDefinition: Equatable { // SQL text that describes the object (NULL for the internal indexes) public let sql: String? + + public var isInternal: Bool { + name.starts(with: "sqlite_") || sql == nil + } } // https://sqlite.org/syntax/column-def.html diff --git a/Sources/SQLite/Schema/SchemaReader.swift b/Sources/SQLite/Schema/SchemaReader.swift index b1292471..1c26fdd6 100644 --- a/Sources/SQLite/Schema/SchemaReader.swift +++ b/Sources/SQLite/Schema/SchemaReader.swift @@ -52,7 +52,7 @@ public class SchemaReader { return ObjectDefinition( type: type, name: row[SchemaTable.nameColumn], - tableName: row[SchemaTable.nameColumn], + tableName: row[SchemaTable.tableNameColumn], rootpage: row[SchemaTable.rootPageColumn] ?? 0, sql: row[SchemaTable.sqlColumn] ) diff --git a/Tests/SQLiteTests/Schema/SchemaChangerTests.swift b/Tests/SQLiteTests/Schema/SchemaChangerTests.swift index af8b6565..d023b8e5 100644 --- a/Tests/SQLiteTests/Schema/SchemaChangerTests.swift +++ b/Tests/SQLiteTests/Schema/SchemaChangerTests.swift @@ -3,7 +3,7 @@ import XCTest class SchemaChangerTests: SQLiteTestCase { var schemaChanger: SchemaChanger! - var schemaReader: SchemaReader! + var schema: SchemaReader! override func setUpWithError() throws { try super.setUpWithError() @@ -11,33 +11,33 @@ class SchemaChangerTests: SQLiteTestCase { try insertUsers("bob") - schemaReader = SchemaReader(connection: db) + schema = SchemaReader(connection: db) schemaChanger = SchemaChanger(connection: db) } func test_empty_migration_does_not_change_column_definitions() throws { - let previous = try schemaReader.columnDefinitions(table: "users") + let previous = try schema.columnDefinitions(table: "users") try schemaChanger.alter(table: "users") { _ in } - let current = try schemaReader.columnDefinitions(table: "users") + let current = try schema.columnDefinitions(table: "users") XCTAssertEqual(previous, current) } func test_empty_migration_does_not_change_index_definitions() throws { - let previous = try schemaReader.indexDefinitions(table: "users") + let previous = try schema.indexDefinitions(table: "users") try schemaChanger.alter(table: "users") { _ in } - let current = try schemaReader.indexDefinitions(table: "users") + let current = try schema.indexDefinitions(table: "users") XCTAssertEqual(previous, current) } func test_empty_migration_does_not_change_foreign_key_definitions() throws { - let previous = try schemaReader.foreignKeys(table: "users") + let previous = try schema.foreignKeys(table: "users") try schemaChanger.alter(table: "users") { _ in } - let current = try schemaReader.foreignKeys(table: "users") + let current = try schema.foreignKeys(table: "users") XCTAssertEqual(previous, current) } @@ -51,21 +51,21 @@ class SchemaChangerTests: SQLiteTestCase { XCTAssertEqual(previous, current) } - func test_remove_column() throws { + func test_drop_column() throws { try schemaChanger.alter(table: "users") { table in - table.remove("age") + table.drop("age") } - let columns = try schemaReader.columnDefinitions(table: "users").map(\.name) + let columns = try schema.columnDefinitions(table: "users").map(\.name) XCTAssertFalse(columns.contains("age")) } - func test_remove_column_legacy() throws { + func test_drop_column_legacy() throws { schemaChanger = .init(connection: db, version: .init(major: 3, minor: 24)) // DROP COLUMN introduced in 3.35.0 try schemaChanger.alter(table: "users") { table in - table.remove("age") + table.drop("age") } - let columns = try schemaReader.columnDefinitions(table: "users").map(\.name) + let columns = try schema.columnDefinitions(table: "users").map(\.name) XCTAssertFalse(columns.contains("age")) } @@ -74,7 +74,7 @@ class SchemaChangerTests: SQLiteTestCase { table.rename("age", to: "age2") } - let columns = try schemaReader.columnDefinitions(table: "users").map(\.name) + let columns = try schema.columnDefinitions(table: "users").map(\.name) XCTAssertFalse(columns.contains("age")) XCTAssertTrue(columns.contains("age2")) } @@ -86,7 +86,7 @@ class SchemaChangerTests: SQLiteTestCase { table.rename("age", to: "age2") } - let columns = try schemaReader.columnDefinitions(table: "users").map(\.name) + let columns = try schema.columnDefinitions(table: "users").map(\.name) XCTAssertFalse(columns.contains("age")) XCTAssertTrue(columns.contains("age2")) } @@ -102,7 +102,7 @@ class SchemaChangerTests: SQLiteTestCase { table.add(newColumn) } - let columns = try schemaReader.columnDefinitions(table: "users") + let columns = try schema.columnDefinitions(table: "users") XCTAssertTrue(columns.contains(newColumn)) XCTAssertEqual(try db.pluck(users.select(column))?[column], "foo") diff --git a/Tests/SQLiteTests/Schema/SchemaReaderTests.swift b/Tests/SQLiteTests/Schema/SchemaReaderTests.swift index 95dba921..dd5ae103 100644 --- a/Tests/SQLiteTests/Schema/SchemaReaderTests.swift +++ b/Tests/SQLiteTests/Schema/SchemaReaderTests.swift @@ -146,7 +146,7 @@ class SchemaReaderTests: SQLiteTestCase { XCTAssertEqual(tables.map { table in [table.name, table.tableName, table.type.rawValue]}, [ ["users", "users", "table"], - ["sqlite_autoindex_users_1", "sqlite_autoindex_users_1", "index"] + ["sqlite_autoindex_users_1", "users", "index"] ]) } @@ -162,6 +162,38 @@ class SchemaReaderTests: SQLiteTestCase { ]) } + func test_objectDefinitions_indexes() throws { + let emailIndex = users.createIndex(Expression("email"), unique: false, ifNotExists: true) + try db.run(emailIndex) + + let indexes = try schemaReader.objectDefinitions(type: .index) + .filter { !$0.isInternal } + + XCTAssertEqual(indexes.map { index in [index.name, index.tableName, index.type.rawValue, index.sql]}, [ + ["index_users_on_email", + "users", + "index", + "CREATE INDEX \"index_users_on_email\" ON \"users\" (\"email\")"] + ]) + } + + func test_objectDefinitions_triggers() throws { + let trigger = """ + CREATE TRIGGER test_trigger + AFTER INSERT ON users BEGIN + UPDATE USERS SET name = "update" WHERE id = NEW.rowid; + END; + """ + + try db.run(trigger) + + let triggers = try schemaReader.objectDefinitions(type: .trigger) + + XCTAssertEqual(triggers.map { trigger in [trigger.name, trigger.tableName, trigger.type.rawValue]}, [ + ["test_trigger", "users", "trigger"] + ]) + } + func test_objectDefinitionsFilterByType() throws { let tables = try schemaReader.objectDefinitions(type: .table) From 137788e332f07e04d0a7d07150a043cc04c0d5e5 Mon Sep 17 00:00:00 2001 From: Jan Berkel Date: Tue, 18 Oct 2022 01:17:31 +0200 Subject: [PATCH 09/12] Named param for rename/drop --- Documentation/Index.md | 4 ++-- Sources/SQLite/Schema/SchemaChanger.swift | 4 ++-- Tests/SQLiteTests/Schema/SchemaChangerTests.swift | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Documentation/Index.md b/Documentation/Index.md index e40f786d..68e66457 100644 --- a/Documentation/Index.md +++ b/Documentation/Index.md @@ -1490,7 +1490,7 @@ We can rename columns with the help of the `SchemaChanger` class: ```swift let schemaChanger = SchemaChanger(connection: db) try schemaChanger.alter(table: "users") { table in - table.rename("old_name", to: "new_name") + table.rename(column: "old_name", to: "new_name") } ``` @@ -1499,7 +1499,7 @@ try schemaChanger.alter(table: "users") { table in ```swift let schemaChanger = SchemaChanger(connection: db) try schemaChanger.alter(table: "users") { table in - table.drop("column") + table.drop(column: "email") } ``` diff --git a/Sources/SQLite/Schema/SchemaChanger.swift b/Sources/SQLite/Schema/SchemaChanger.swift index d2ada22c..67f2b0d3 100644 --- a/Sources/SQLite/Schema/SchemaChanger.swift +++ b/Sources/SQLite/Schema/SchemaChanger.swift @@ -99,11 +99,11 @@ public class SchemaChanger: CustomStringConvertible { operations.append(.addColumn(column)) } - public func drop(_ column: String) { + public func drop(column: String) { operations.append(.dropColumn(column)) } - public func rename(_ column: String, to: String) { + public func rename(column: String, to: String) { operations.append(.renameColumn(column, to)) } } diff --git a/Tests/SQLiteTests/Schema/SchemaChangerTests.swift b/Tests/SQLiteTests/Schema/SchemaChangerTests.swift index d023b8e5..2894e5ce 100644 --- a/Tests/SQLiteTests/Schema/SchemaChangerTests.swift +++ b/Tests/SQLiteTests/Schema/SchemaChangerTests.swift @@ -53,7 +53,7 @@ class SchemaChangerTests: SQLiteTestCase { func test_drop_column() throws { try schemaChanger.alter(table: "users") { table in - table.drop("age") + table.drop(column: "age") } let columns = try schema.columnDefinitions(table: "users").map(\.name) XCTAssertFalse(columns.contains("age")) @@ -63,7 +63,7 @@ class SchemaChangerTests: SQLiteTestCase { schemaChanger = .init(connection: db, version: .init(major: 3, minor: 24)) // DROP COLUMN introduced in 3.35.0 try schemaChanger.alter(table: "users") { table in - table.drop("age") + table.drop(column: "age") } let columns = try schema.columnDefinitions(table: "users").map(\.name) XCTAssertFalse(columns.contains("age")) @@ -71,7 +71,7 @@ class SchemaChangerTests: SQLiteTestCase { func test_rename_column() throws { try schemaChanger.alter(table: "users") { table in - table.rename("age", to: "age2") + table.rename(column: "age", to: "age2") } let columns = try schema.columnDefinitions(table: "users").map(\.name) @@ -83,7 +83,7 @@ class SchemaChangerTests: SQLiteTestCase { schemaChanger = .init(connection: db, version: .init(major: 3, minor: 24)) // RENAME COLUMN introduced in 3.25.0 try schemaChanger.alter(table: "users") { table in - table.rename("age", to: "age2") + table.rename(column: "age", to: "age2") } let columns = try schema.columnDefinitions(table: "users").map(\.name) From f2c8bdaf71c57a5087efabf447da33f874ae5787 Mon Sep 17 00:00:00 2001 From: Jan Berkel Date: Tue, 18 Oct 2022 01:25:48 +0200 Subject: [PATCH 10/12] Changelog --- CHANGELOG.md | 4 ++++ Documentation/Index.md | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b3f78b4..3a12f377 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ 0.14.0 (tbd), [diff][diff-0.14.0] ======================================== +* Support more complex schema changes and queries ([#1073][], [#1146][] [#1148][]) * Support `ATTACH`/`DETACH` ([#30][], [#1142][]) * Support `WITH` clause ([#1139][]) * Add `Value` conformance for `NSURL` ([#1110][], [#1141][]) @@ -160,6 +161,7 @@ [#866]: https://github.com/stephencelis/SQLite.swift/pull/866 [#881]: https://github.com/stephencelis/SQLite.swift/pull/881 [#919]: https://github.com/stephencelis/SQLite.swift/pull/919 +[#1073]: https://github.com/stephencelis/SQLite.swift/issues/1073 [#1075]: https://github.com/stephencelis/SQLite.swift/pull/1075 [#1077]: https://github.com/stephencelis/SQLite.swift/issues/1077 [#1094]: https://github.com/stephencelis/SQLite.swift/pull/1094 @@ -185,3 +187,5 @@ [#1141]: https://github.com/stephencelis/SQLite.swift/pull/1141 [#1142]: https://github.com/stephencelis/SQLite.swift/pull/1142 [#1144]: https://github.com/stephencelis/SQLite.swift/pull/1144 +[#1146]: https://github.com/stephencelis/SQLite.swift/pull/1146 +[#1148]: https://github.com/stephencelis/SQLite.swift/pull/1148 diff --git a/Documentation/Index.md b/Documentation/Index.md index 68e66457..d275db8e 100644 --- a/Documentation/Index.md +++ b/Documentation/Index.md @@ -1514,7 +1514,7 @@ The `SchemaChanger` provides an alternative API to add new columns: let newColumn = ColumnDefinition( name: "new_text_column", type: .TEXT, - nullable: true, + nullable: true, defaultValue: .stringLiteral("foo") ) From 140134d68fd82190972f30232e33363c8ae58a79 Mon Sep 17 00:00:00 2001 From: Jan Berkel Date: Tue, 18 Oct 2022 02:06:21 +0200 Subject: [PATCH 11/12] Add playground examples --- SQLite.playground/Contents.swift | 30 ++++++++++++++++++- Sources/SQLite/Schema/SchemaDefinitions.swift | 2 +- .../Schema/SchemaDefinitionsTests.swift | 10 +++++++ 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/SQLite.playground/Contents.swift b/SQLite.playground/Contents.swift index c089076d..73091735 100644 --- a/SQLite.playground/Contents.swift +++ b/SQLite.playground/Contents.swift @@ -99,5 +99,33 @@ db.createAggregation("customConcat", initialValue: "users:", reduce: reduce, result: { $0 }) -let result = db.prepare("SELECT customConcat(email) FROM users").scalar() as! String +let result = try db.prepare("SELECT customConcat(email) FROM users").scalar() as! String print(result) + +/// schema queries +let schema = db.schema +let objects = try schema.objectDefinitions() +print(objects) + +let columns = try schema.columnDefinitions(table: "users") +print(columns) + +/// schema alteration + +let schemaChanger = SchemaChanger(connection: db) +try schemaChanger.alter(table: "users") { table in + table.add(.init(name: "age", type: .INTEGER)) + table.rename(column: "email", to: "electronic_mail") + table.drop(column: "name") +} + +let changedColumns = try schema.columnDefinitions(table: "users") +print(changedColumns) + +let age = Expression("age") +let electronicMail = Expression("electronic_mail") + +let newRowid = try db.run(users.insert( + electronicMail <- "carol@mac.com", + age <- 33 +)) diff --git a/Sources/SQLite/Schema/SchemaDefinitions.swift b/Sources/SQLite/Schema/SchemaDefinitions.swift index a2700dd2..b06ddfc7 100644 --- a/Sources/SQLite/Schema/SchemaDefinitions.swift +++ b/Sources/SQLite/Schema/SchemaDefinitions.swift @@ -122,7 +122,7 @@ public struct ColumnDefinition: Equatable { public init(name: String, primaryKey: PrimaryKey? = nil, type: Affinity, - nullable: Bool = false, + nullable: Bool = true, defaultValue: LiteralValue = .NULL, references: ForeignKey? = nil) { self.name = name diff --git a/Tests/SQLiteTests/Schema/SchemaDefinitionsTests.swift b/Tests/SQLiteTests/Schema/SchemaDefinitionsTests.swift index 384aab3f..ef97b981 100644 --- a/Tests/SQLiteTests/Schema/SchemaDefinitionsTests.swift +++ b/Tests/SQLiteTests/Schema/SchemaDefinitionsTests.swift @@ -49,6 +49,16 @@ class ColumnDefinitionTests: XCTestCase { XCTAssertEqual(definition.toSQL(), expected) } #endif + + func testNullableByDefault() { + let test = ColumnDefinition(name: "test", type: .REAL) + XCTAssertEqual(test.name, "test") + XCTAssertTrue(test.nullable) + XCTAssertEqual(test.defaultValue, .NULL) + XCTAssertEqual(test.type, .REAL) + XCTAssertNil(test.references) + XCTAssertNil(test.primaryKey) + } } class AffinityTests: XCTestCase { From 020ec7aabbda32a384d29fcc4ecf42e52ef48190 Mon Sep 17 00:00:00 2001 From: Jan Berkel Date: Tue, 18 Oct 2022 02:40:18 +0200 Subject: [PATCH 12/12] Better example --- SQLite.playground/Contents.swift | 2 +- Sources/SQLite/Schema/SchemaDefinitions.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/SQLite.playground/Contents.swift b/SQLite.playground/Contents.swift index 73091735..5a7ea379 100644 --- a/SQLite.playground/Contents.swift +++ b/SQLite.playground/Contents.swift @@ -114,7 +114,7 @@ print(columns) let schemaChanger = SchemaChanger(connection: db) try schemaChanger.alter(table: "users") { table in - table.add(.init(name: "age", type: .INTEGER)) + table.add(ColumnDefinition(name: "age", type: .INTEGER)) table.rename(column: "email", to: "electronic_mail") table.drop(column: "name") } diff --git a/Sources/SQLite/Schema/SchemaDefinitions.swift b/Sources/SQLite/Schema/SchemaDefinitions.swift index b06ddfc7..284fc4c3 100644 --- a/Sources/SQLite/Schema/SchemaDefinitions.swift +++ b/Sources/SQLite/Schema/SchemaDefinitions.swift @@ -154,7 +154,6 @@ public enum LiteralValue: Equatable, CustomStringConvertible { // If there is no explicit DEFAULT clause attached to a column definition, then the default value of the // column is NULL - // swiftlint:disable identifier_name case NULL // Beginning with SQLite 3.23.0 (2018-04-02), SQLite recognizes the identifiers "TRUE" and @@ -164,6 +163,7 @@ public enum LiteralValue: Equatable, CustomStringConvertible { // The boolean identifiers TRUE and FALSE are usually just aliases for the integer values 1 and 0, respectively. case TRUE case FALSE + // swiftlint:disable identifier_name case CURRENT_TIME case CURRENT_DATE case CURRENT_TIMESTAMP