Skip to content

Support creating tables in schema changer #1315

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 12 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions Documentation/Index.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
- [Renaming Columns](#renaming-columns)
- [Dropping Columns](#dropping-columns)
- [Renaming/Dropping Tables](#renamingdropping-tables)
- [Creating Tables](#creating-tables)
- [Indexes](#indexes)
- [Creating Indexes](#creating-indexes)
- [Dropping Indexes](#dropping-indexes)
Expand Down Expand Up @@ -1583,6 +1584,16 @@ try schemaChanger.rename(table: "users", to: "users_new")
try schemaChanger.drop(table: "emails", ifExists: false)
```

#### Creating Tables

```swift
let schemaChanger = SchemaChanger(connection: db)

try schemaChanger.create(table: "users") { table in
table.add(column: .init(name: "id", primaryKey: .init(autoIncrement: true), type: .INTEGER))
table.add(column: .init(name: "name", type: .TEXT, nullable: false))
}

### Indexes


Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ lint: $(SWIFTLINT)
$< --strict

lint-fix: $(SWIFTLINT)
$< lint fix
$< --fix

clean:
$(XCODEBUILD) $(BUILD_ARGUMENTS) clean
Expand Down
92 changes: 91 additions & 1 deletion Sources/SQLite/Schema/SchemaChanger.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,19 +43,30 @@ public class SchemaChanger: CustomStringConvertible {

public enum Operation {
case addColumn(ColumnDefinition)
case addIndex(IndexDefinition, ifNotExists: Bool)
case dropColumn(String)
case dropIndex(String, ifExists: Bool)
case renameColumn(String, String)
case renameTable(String)
case createTable(columns: [ColumnDefinition], ifNotExists: Bool)

/// Returns non-nil if the operation can be executed with a simple SQL statement
func toSQL(_ table: String, version: SQLiteVersion) -> String? {
switch self {
case .addColumn(let definition):
return "ALTER TABLE \(table.quote()) ADD COLUMN \(definition.toSQL())"
case .addIndex(let definition, let ifNotExists):
return definition.toSQL(ifNotExists: ifNotExists)
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 SQLiteFeature.dropColumn.isSupported(by: version):
return "ALTER TABLE \(table.quote()) DROP COLUMN \(column.quote())"
case .dropIndex(let name, let ifExists):
return "DROP INDEX \(ifExists ? " IF EXISTS " : "") \(name.quote())"
case .createTable(let columns, let ifNotExists):
return "CREATE TABLE \(ifNotExists ? " IF NOT EXISTS " : "") \(table.quote()) (" +
columns.map { $0.toSQL() }.joined(separator: ", ") +
")"
default: return nil
}
}
Expand Down Expand Up @@ -89,7 +100,7 @@ public class SchemaChanger: CustomStringConvertible {
public class AlterTableDefinition {
fileprivate var operations: [Operation] = []

let name: String
public let name: String

init(name: String) {
self.name = name
Expand All @@ -99,21 +110,73 @@ public class SchemaChanger: CustomStringConvertible {
operations.append(.addColumn(column))
}

public func add(index: IndexDefinition, ifNotExists: Bool = false) {
operations.append(.addIndex(index, ifNotExists: ifNotExists))
}

public func drop(column: String) {
operations.append(.dropColumn(column))
}

public func drop(index: String, ifExists: Bool = false) {
operations.append(.dropIndex(index, ifExists: ifExists))
}

public func rename(column: String, to: String) {
operations.append(.renameColumn(column, to))
}
}

public class CreateTableDefinition {
fileprivate var columnDefinitions: [ColumnDefinition] = []
fileprivate var indexDefinitions: [IndexDefinition] = []

let name: String
let ifNotExists: Bool

init(name: String, ifNotExists: Bool) {
self.name = name
self.ifNotExists = ifNotExists
}

public func add(column: ColumnDefinition) {
columnDefinitions.append(column)
}

public func add<T>(expression: Expression<T>) where T: Value {
add(column: .init(name: columnName(for: expression), type: .init(expression: expression), nullable: false))
}

public func add<T>(expression: Expression<T?>) where T: Value {
add(column: .init(name: columnName(for: expression), type: .init(expression: expression), nullable: true))
}

public func add(index: IndexDefinition) {
indexDefinitions.append(index)
}

var operations: [Operation] {
precondition(!columnDefinitions.isEmpty)
return [
.createTable(columns: columnDefinitions, ifNotExists: ifNotExists)
] + indexDefinitions.map { .addIndex($0, ifNotExists: ifNotExists) }
}

private func columnName<T>(for expression: Expression<T>) -> String {
switch LiteralValue(expression.template) {
case .stringLiteral(let string): return string
default: fatalError("expression is not a literal string value")
}
}
}

private let connection: Connection
private let schemaReader: SchemaReader
private let version: SQLiteVersion
static let tempPrefix = "tmp_"
typealias Block = () throws -> Void
public typealias AlterTableDefinitionBlock = (AlterTableDefinition) -> Void
public typealias CreateTableDefinitionBlock = (CreateTableDefinition) -> Void

struct Options: OptionSet {
let rawValue: Int
Expand Down Expand Up @@ -141,6 +204,15 @@ public class SchemaChanger: CustomStringConvertible {
}
}

public func create(table: String, ifNotExists: Bool = false, block: CreateTableDefinitionBlock) throws {
let createTableDefinition = CreateTableDefinition(name: table, ifNotExists: ifNotExists)
block(createTableDefinition)

for operation in createTableDefinition.operations {
try run(table: table, operation: operation)
}
}

public func drop(table: String, ifExists: Bool = true) throws {
try dropTable(table, ifExists: ifExists)
}
Expand All @@ -151,6 +223,12 @@ public class SchemaChanger: CustomStringConvertible {
try connection.run("ALTER TABLE \(table.quote()) RENAME TO \(to.quote())")
}

// Runs arbitrary SQL. Should only be used if no predefined operations exist.
@discardableResult
public func run(_ sql: String, _ bindings: Binding?...) throws -> Statement {
return try connection.run(sql, bindings)
}

private func run(table: String, operation: Operation) throws {
try operation.validate()

Expand Down Expand Up @@ -263,7 +341,9 @@ extension TableDefinition {
func apply(_ operation: SchemaChanger.Operation?) -> TableDefinition {
switch operation {
case .none: return self
case .createTable, .addIndex, .dropIndex: fatalError()
case .addColumn: fatalError("Use 'ALTER TABLE ADD COLUMN (...)'")

case .dropColumn(let column):
return TableDefinition(name: name,
columns: columns.filter { $0.name != column },
Expand All @@ -280,3 +360,13 @@ extension TableDefinition {
}
}
}

extension ColumnDefinition.Affinity {
init<T>(expression: Expression<T>) where T: Value {
self.init(T.declaredDatatype)
}

init<T>(expression: Expression<T?>) where T: Value {
self.init(T.declaredDatatype)
}
}
55 changes: 46 additions & 9 deletions Sources/SQLite/Schema/SchemaDefinitions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ public struct ColumnDefinition: Equatable {
// swiftlint:disable:next force_try
static let pattern = try! NSRegularExpression(pattern: "PRIMARY KEY\\s*(?:ASC|DESC)?\\s*(?:ON CONFLICT (\\w+)?)?\\s*(AUTOINCREMENT)?")

init(autoIncrement: Bool = true, onConflict: OnConflict? = nil) {
public init(autoIncrement: Bool = true, onConflict: OnConflict? = nil) {
self.autoIncrement = autoIncrement
self.onConflict = onConflict
}
Expand All @@ -117,30 +117,46 @@ public struct ColumnDefinition: Equatable {
}

public struct ForeignKey: Equatable {
let table: String
let column: String
let primaryKey: String?
let fromColumn: String
let toTable: String
// when null, use primary key of "toTable"
let toColumn: String?
let onUpdate: String?
let onDelete: String?

public init(toTable: String, toColumn: String? = nil, onUpdate: String? = nil, onDelete: String? = nil) {
self.init(fromColumn: "", toTable: toTable, toColumn: toColumn, onUpdate: onUpdate, onDelete: onDelete)
}

public init(fromColumn: String, toTable: String, toColumn: String? = nil, onUpdate: String? = nil, onDelete: String? = nil) {
self.fromColumn = fromColumn
self.toTable = toTable
self.toColumn = toColumn
self.onUpdate = onUpdate
self.onDelete = onDelete
}
}

public let name: String
public let primaryKey: PrimaryKey?
public let type: Affinity
public let nullable: Bool
public let unique: Bool
public let defaultValue: LiteralValue
public let references: ForeignKey?

public init(name: String,
primaryKey: PrimaryKey? = nil,
type: Affinity,
nullable: Bool = true,
unique: Bool = false,
defaultValue: LiteralValue = .NULL,
references: ForeignKey? = nil) {
self.name = name
self.primaryKey = primaryKey
self.type = type
self.nullable = nullable
self.unique = unique
self.defaultValue = defaultValue
self.references = references
}
Expand Down Expand Up @@ -244,16 +260,18 @@ public struct IndexDefinition: Equatable {

public enum Order: String { case ASC, DESC }

public init(table: String, name: String, unique: Bool = false, columns: [String], `where`: String? = nil, orders: [String: Order]? = nil) {
public init(table: String, name: String, unique: Bool = false, columns: [String], `where`: String? = nil,
orders: [String: Order]? = nil, origin: Origin? = nil) {
self.table = table
self.name = name
self.unique = unique
self.columns = columns
self.where = `where`
self.orders = orders
self.origin = origin
}

init (table: String, name: String, unique: Bool, columns: [String], indexSQL: String?) {
init (table: String, name: String, unique: Bool, columns: [String], indexSQL: String?, origin: Origin? = nil) {
func wherePart(sql: String) -> String? {
IndexDefinition.whereRe.firstMatch(in: sql, options: [], range: NSRange(location: 0, length: sql.count)).map {
(sql as NSString).substring(with: $0.range(at: 1))
Expand All @@ -270,12 +288,16 @@ public struct IndexDefinition: Equatable {
return memo2
}
}

let orders = indexSQL.flatMap(orders)

self.init(table: table,
name: name,
unique: unique,
columns: columns,
where: indexSQL.flatMap(wherePart),
orders: indexSQL.flatMap(orders))
orders: (orders?.isEmpty ?? false) ? nil : orders,
origin: origin)
}

public let table: String
Expand All @@ -284,6 +306,13 @@ public struct IndexDefinition: Equatable {
public let columns: [String]
public let `where`: String?
public let orders: [String: Order]?
public let origin: Origin?

public enum Origin: String {
case uniqueConstraint = "u" // index created from a "CREATE TABLE (... UNIQUE)" column constraint
case createIndex = "c" // index created explicitly via "CREATE INDEX ..."
case primaryKey = "pk" // index created from a "CREATE TABLE PRIMARY KEY" column constraint
}

enum IndexError: LocalizedError {
case tooLong(String, String)
Expand All @@ -297,6 +326,13 @@ public struct IndexDefinition: Equatable {
}
}

// Indices with names of the form "sqlite_autoindex_TABLE_N" that are used to implement UNIQUE and PRIMARY KEY
// constraints on ordinary tables.
// https://sqlite.org/fileformat2.html#intschema
var isInternal: Bool {
name.starts(with: "sqlite_autoindex_")
}

func validate() throws {
if name.count > IndexDefinition.maxIndexLength {
throw IndexError.tooLong(name, table)
Expand Down Expand Up @@ -345,6 +381,7 @@ extension ColumnDefinition {
defaultValue.map { "DEFAULT \($0)" },
primaryKey.map { $0.toSQL() },
nullable ? nil : "NOT NULL",
unique ? "UNIQUE" : nil,
references.map { $0.toSQL() }
].compactMap { $0 }
.joined(separator: " ")
Expand Down Expand Up @@ -376,8 +413,8 @@ extension ColumnDefinition.ForeignKey {
func toSQL() -> String {
([
"REFERENCES",
table.quote(),
primaryKey.map { "(\($0.quote()))" },
toTable.quote(),
toColumn.map { "(\($0.quote()))" },
onUpdate.map { "ON UPDATE \($0)" },
onDelete.map { "ON DELETE \($0)" }
] as [String?]).compactMap { $0 }
Expand Down
Loading