Skip to content

Commit 5c66460

Browse files
committed
Adds SchemaChanger to perform database schema changes
1 parent c94aca2 commit 5c66460

File tree

7 files changed

+1350
-0
lines changed

7 files changed

+1350
-0
lines changed

SQLite.xcodeproj/project.pbxproj

Lines changed: 70 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import Foundation
2+
3+
extension Connection {
4+
// Changing the foreign_keys setting affects the execution of all statements prepared using the database
5+
// connection, including those prepared before the setting was changed.
6+
//
7+
// https://sqlite.org/pragma.html#pragma_foreign_keys
8+
var foreignKeys: Bool {
9+
get { getBoolPragma("foreign_keys") }
10+
set { setBoolPragma("foreign_keys", newValue) }
11+
}
12+
13+
var deferForeignKeys: Bool {
14+
get { getBoolPragma("defer_foreign_keys") }
15+
set { setBoolPragma("defer_foreign_keys", newValue) }
16+
}
17+
18+
// https://sqlite.org/pragma.html#pragma_foreign_key_check
19+
20+
// There are four columns in each result row.
21+
// The first column is the name of the table that
22+
// contains the REFERENCES clause.
23+
// The second column is the rowid of the row that contains the
24+
// invalid REFERENCES clause, or NULL if the child table is a WITHOUT ROWID table.
25+
// The third column is the name of the table that is referred to.
26+
// The fourth column is the index of the specific foreign key constraint that failed.
27+
func foreignKeyCheck() throws -> [ForeignKeyError] {
28+
try run("PRAGMA foreign_key_check").compactMap { row -> ForeignKeyError? in
29+
guard let table = row[0] as? String,
30+
let rowId = row[1] as? Int64,
31+
let target = row[2] as? String else { return nil }
32+
33+
return ForeignKeyError(from: table, rowId: rowId, to: target)
34+
}
35+
}
36+
37+
// https://sqlite.org/pragma.html#pragma_table_info
38+
//
39+
// This pragma returns one row for each column in the named table. Columns in the result set include the
40+
// column name, data type, whether or not the column can be NULL, and the default value for the column. The
41+
// "pk" column in the result set is zero for columns that are not part of the primary key, and is the
42+
// index of the column in the primary key for columns that are part of the primary key.
43+
func columnInfo(table: String) throws -> [ColumnDefinition] {
44+
func parsePrimaryKey(column: String) throws -> ColumnDefinition.PrimaryKey? {
45+
try createTableSQL(name: table).flatMap { .init(sql: $0) }
46+
}
47+
48+
let foreignKeys: [String: [ForeignKeyDefinition]] =
49+
Dictionary(grouping: try foreignKeyInfo(table: table), by: { $0.column })
50+
51+
return try run("PRAGMA table_info(\(table.quote()))").compactMap { row -> ColumnDefinition? in
52+
guard let name = row[1] as? String,
53+
let type = row[2] as? String,
54+
let notNull = row[3] as? Int64,
55+
let defaultValue = row[4] as? String?,
56+
let primaryKey = row[5] as? Int64 else { return nil }
57+
return ColumnDefinition(name: name,
58+
primaryKey: primaryKey == 1 ? try parsePrimaryKey(column: name) : nil,
59+
type: ColumnDefinition.Affinity.from(type),
60+
null: notNull == 0,
61+
defaultValue: LiteralValue.from(defaultValue),
62+
references: foreignKeys[name]?.first)
63+
}
64+
}
65+
66+
func indexInfo(table: String) throws -> [IndexDefinition] {
67+
func indexSQL(name: String) throws -> String? {
68+
try run("""
69+
SELECT sql FROM sqlite_master WHERE name=? AND type='index'
70+
UNION ALL
71+
SELECT sql FROM sqlite_temp_master WHERE name=? AND type='index'
72+
""", name, name)
73+
.compactMap { row in row[0] as? String }
74+
.first
75+
}
76+
77+
func columns(name: String) throws -> [String] {
78+
try run("PRAGMA index_info(\(name.quote()))").compactMap { row in
79+
row[2] as? String
80+
}
81+
}
82+
83+
return try run("PRAGMA index_list(\(table.quote()))").compactMap { row -> IndexDefinition? in
84+
guard let name = row[1] as? String,
85+
let unique = row[2] as? Int64,
86+
// Indexes SQLite creates implicitly for internal use start with "sqlite_".
87+
// See https://www.sqlite.org/fileformat2.html#intschema
88+
!name.starts(with: "sqlite_") else {
89+
return nil
90+
}
91+
return .init(table: table,
92+
name: name,
93+
unique: unique == 1,
94+
columns: try columns(name: name),
95+
indexSQL: try indexSQL(name: name))
96+
}
97+
}
98+
99+
func tableInfo() throws -> [String] {
100+
try run("SELECT tbl_name FROM sqlite_master WHERE type = 'table'").compactMap { row in
101+
if let name = row[0] as? String, !name.starts(with: "sqlite_") {
102+
return name
103+
} else {
104+
return nil
105+
}
106+
}
107+
}
108+
109+
func foreignKeyInfo(table: String) throws -> [ForeignKeyDefinition] {
110+
try run("PRAGMA foreign_key_list(\(table.quote()))").compactMap { row in
111+
if let table = row[2] as? String, // table
112+
let column = row[3] as? String, // from
113+
let primaryKey = row[4] as? String, // to
114+
let onUpdate = row[5] as? String,
115+
let onDelete = row[6] as? String {
116+
return ForeignKeyDefinition(table: table, column: column, primaryKey: primaryKey,
117+
onUpdate: onUpdate == TableBuilder.Dependency.noAction.rawValue ? nil : onUpdate,
118+
onDelete: onDelete == TableBuilder.Dependency.noAction.rawValue ? nil : onDelete
119+
)
120+
} else {
121+
return nil
122+
}
123+
}
124+
}
125+
126+
private func createTableSQL(name: String) throws -> String? {
127+
try run("""
128+
SELECT sql FROM sqlite_master WHERE name=? AND type='table'
129+
UNION ALL
130+
SELECT sql FROM sqlite_temp_master WHERE name=? AND type='table'
131+
""", name, name)
132+
.compactMap { row in row[0] as? String }
133+
.first
134+
}
135+
136+
private func getBoolPragma(_ key: String) -> Bool {
137+
guard let binding = try? scalar("PRAGMA \(key)"),
138+
let intBinding = binding as? Int64 else { return false }
139+
return intBinding == 1
140+
}
141+
142+
private func setBoolPragma(_ key: String, _ newValue: Bool) {
143+
_ = try? run("PRAGMA \(key) = \(newValue ? "1" : "0")")
144+
}
145+
}
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
import Foundation
2+
3+
/*
4+
https://www.sqlite.org/lang_altertable.html
5+
6+
The only schema altering commands directly supported by SQLite are the "rename table" and "add column"
7+
commands shown above.
8+
9+
(SQLite 3.25.0: RENAME COLUMN)
10+
(SQLite 3.35.0: DROP COLUMN)
11+
12+
However, applications can make other arbitrary changes to the format of a table using a
13+
simple sequence of operations. The steps to make arbitrary changes to the schema design of some table X are as follows:
14+
15+
1. If foreign key constraints are enabled, disable them using PRAGMA foreign_keys=OFF.
16+
2. Start a transaction.
17+
3. Remember the format of all indexes and triggers associated with table X
18+
(SELECT sql FROM sqlite_master WHERE tbl_name='X' AND type='index')
19+
4. Use CREATE TABLE to construct a new table "new_X" that is in the desired revised format of table X.
20+
5. Transfer content from X into new_X using a statement like: INSERT INTO new_X SELECT ... FROM X.
21+
6. Drop the old table X: DROP TABLE X.
22+
7. Change the name of new_X to X using: ALTER TABLE new_X RENAME TO X.
23+
8. Use CREATE INDEX and CREATE TRIGGER to reconstruct indexes and triggers associated with table X.
24+
9. If any views refer to table X in a way that is affected by the schema change, then drop those views using DROP VIEW
25+
10. If foreign key constraints were originally enabled then run PRAGMA foreign_key_check
26+
11. Commit the transaction started in step 2.
27+
12. If foreign keys constraints were originally enabled, reenable them now.
28+
*/
29+
public class SchemaChanger: CustomStringConvertible {
30+
enum SchemaChangeError: LocalizedError {
31+
case foreignKeyError([ForeignKeyError])
32+
33+
var errorDescription: String? {
34+
switch self {
35+
case .foreignKeyError(let errors):
36+
return "Foreign key errors: \(errors)"
37+
}
38+
}
39+
}
40+
41+
public enum Operation {
42+
case none
43+
case add(ColumnDefinition)
44+
case remove(String)
45+
case renameColumn(String, String)
46+
case renameTable(String)
47+
48+
/// Returns non-nil if the operation can be executed with a simple SQL statement
49+
func toSQL(_ table: String) -> String? {
50+
switch self {
51+
case .add(let definition): return "ALTER TABLE \(table.quote()) ADD COLUMN \(definition.toSQL())"
52+
default: return nil
53+
}
54+
}
55+
}
56+
57+
public class AlterTableDefinition {
58+
fileprivate var operations: [Operation] = []
59+
60+
let name: String
61+
62+
init(name: String) {
63+
self.name = name
64+
}
65+
66+
public func add(_ column: ColumnDefinition) {
67+
operations.append(.add(column))
68+
}
69+
70+
public func remove(_ column: String) {
71+
operations.append(.remove(column))
72+
}
73+
74+
public func rename(_ column: String, to: String) {
75+
operations.append(.renameColumn(column, to))
76+
}
77+
}
78+
79+
private let connection: Connection
80+
static let tempPrefix = "tmp_"
81+
typealias Block = () throws -> Void
82+
public typealias AlterTableDefinitionBlock = (AlterTableDefinition) -> Void
83+
84+
struct Options: OptionSet {
85+
let rawValue: Int
86+
static let `default`: Options = []
87+
static let temp = Options(rawValue: 1)
88+
}
89+
90+
public init(connection: Connection) {
91+
self.connection = connection
92+
}
93+
94+
public func alter(table: String, block: AlterTableDefinitionBlock) throws {
95+
let alterTableDefinition = AlterTableDefinition(name: table)
96+
block(alterTableDefinition)
97+
98+
for operation in alterTableDefinition.operations {
99+
try run(table: table, operation: operation)
100+
}
101+
}
102+
103+
public func drop(table: String) throws {
104+
try dropTable(table)
105+
}
106+
107+
private func run(table: String, operation: Operation) throws {
108+
if let sql = operation.toSQL(table) {
109+
try connection.run(sql)
110+
} else {
111+
try doTheTableDance(table: table, operation: operation)
112+
}
113+
}
114+
115+
private func doTheTableDance(table: String, operation: Operation) throws {
116+
try connection.transaction {
117+
try disableRefIntegrity {
118+
let tempTable = "\(SchemaChanger.tempPrefix)\(table)"
119+
try moveTable(from: table, to: tempTable, options: [.temp], operation: operation)
120+
try moveTable(from: tempTable, to: table)
121+
let foreignKeyErrors = try connection.foreignKeyCheck()
122+
if foreignKeyErrors.count > 0 {
123+
throw SchemaChangeError.foreignKeyError(foreignKeyErrors)
124+
}
125+
}
126+
}
127+
}
128+
129+
private func disableRefIntegrity(block: Block) throws {
130+
let oldForeignKeys = connection.foreignKeys
131+
let oldDeferForeignKeys = connection.deferForeignKeys
132+
133+
connection.deferForeignKeys = true
134+
connection.foreignKeys = false
135+
136+
defer {
137+
connection.deferForeignKeys = oldDeferForeignKeys
138+
connection.foreignKeys = oldForeignKeys
139+
}
140+
141+
try block()
142+
}
143+
144+
private func moveTable(from: String, to: String, options: Options = .default, operation: Operation = .none) throws {
145+
try copyTable(from: from, to: to, options: options, operation: operation)
146+
try dropTable(from)
147+
}
148+
149+
private func copyTable(from: String, to: String, options: Options = .default, operation: Operation) throws {
150+
let fromDefinition = TableDefinition(
151+
name: from,
152+
columns: try connection.columnInfo(table: from),
153+
indexes: try connection.indexInfo(table: from)
154+
)
155+
let toDefinition = fromDefinition.apply(.renameTable(to)).apply(operation)
156+
157+
try createTable(definition: toDefinition, options: options)
158+
try createTableIndexes(definition: toDefinition)
159+
if case .remove = operation {
160+
try copyTableContents(from: fromDefinition.apply(operation), to: toDefinition)
161+
} else {
162+
try copyTableContents(from: fromDefinition, to: toDefinition)
163+
}
164+
}
165+
166+
private func createTable(definition: TableDefinition, options: Options) throws {
167+
try connection.run(definition.toSQL(temporary: options.contains(.temp)))
168+
}
169+
170+
private func createTableIndexes(definition: TableDefinition) throws {
171+
for index in definition.indexes {
172+
try index.validate()
173+
try connection.run(index.toSQL())
174+
}
175+
}
176+
177+
private func dropTable(_ table: String) throws {
178+
try connection.run("DROP TABLE IF EXISTS \(table.quote())")
179+
}
180+
181+
private func copyTableContents(from: TableDefinition, to: TableDefinition) throws {
182+
try connection.run(from.copySQL(to: to))
183+
}
184+
185+
public var description: String {
186+
"SQLiteSchemaChanger: \(connection.description)"
187+
}
188+
}
189+
190+
extension IndexDefinition {
191+
func renameTable(to: String) -> IndexDefinition {
192+
func indexName() -> String {
193+
if to.starts(with: SchemaChanger.tempPrefix) {
194+
return "\(SchemaChanger.tempPrefix)\(name)"
195+
} else if table.starts(with: SchemaChanger.tempPrefix) {
196+
return name.replacingOccurrences(of: SchemaChanger.tempPrefix, with: "")
197+
} else {
198+
return name
199+
}
200+
}
201+
return IndexDefinition(table: to, name: indexName(), unique: unique, columns: columns, where: `where`, orders: orders)
202+
}
203+
204+
func renameColumn(from: String, to: String) -> IndexDefinition {
205+
IndexDefinition(table: table, name: name, unique: unique, columns: columns.map {
206+
$0 == from ? to : $0
207+
}, where: `where`, orders: orders)
208+
}
209+
}
210+
211+
extension TableDefinition {
212+
func apply(_ operation: SchemaChanger.Operation) -> TableDefinition {
213+
switch operation {
214+
case .none: return self
215+
case .add: fatalError("Use 'ALTER TABLE ADD COLUMN (...)'")
216+
case .remove(let column):
217+
return TableDefinition(name: name,
218+
columns: columns.filter { $0.name != column },
219+
indexes: indexes.filter { !$0.columns.contains(column) }
220+
)
221+
case .renameColumn(let from, let to):
222+
return TableDefinition(
223+
name: name,
224+
columns: columns.map { $0.rename(from: from, to: to) },
225+
indexes: indexes.map { $0.renameColumn(from: from, to: to) }
226+
)
227+
case .renameTable(let to):
228+
return TableDefinition(name: to, columns: columns, indexes: indexes.map { $0.renameTable(to: to) })
229+
}
230+
}
231+
}

0 commit comments

Comments
 (0)