Skip to content

Commit 1b4b81b

Browse files
Lots of fixes around MySQL (#53)
Big thing is that insert behavior is deferred to the Grammar so that MySQL can do it custom. By default, MySQL now runs two queries, one to insert and one to pull the recent item. `insertAll()` also is now separate, consecutive queries in MySQL. The user can opt out of this (single query for insert and insertAll) by passing `returnItems: false` to `QueryBuilder.insert`. This also... Adds - `ON DELETE` and `ON UPDATE` to migration builders - bigInt type and `UNSIGNED` modifier to migration builders - Revamped column constraint building to add most constraints separately, at the end of a `CREATE TABLE` statement since the shortcuts I was using weren't all compatible across Postgres and MySQL Fixes - Issue with SQL columns not being populated if `.select` wasn't called in query builder (Postgres accepted it with no *, MySQL did not. That's why it didn't get caught) - Adds some missing date time types for parsing from MySQL - Fix constraint issues that were borked on MySQL. Tested all the Quickstart endpoints on Postgres/MySQL to confirm nothing else is missing. Also updated the quickstart migrations to use `.unsigned` and `.bigInt` for some columns by default, since even tho it works fine with Postgres without, MySQL requires them. Co-authored-by: Chris Anderson <hi@chrisanderson.io>
1 parent ab9fdfb commit 1b4b81b

File tree

13 files changed

+327
-121
lines changed

13 files changed

+327
-121
lines changed

Quickstarts/Backend/Sources/Backend/Migrations/_20210107155059CreateUsers.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ struct _20210107155059CreateUsers: Migration {
1616
$0.increments("id").primary()
1717
$0.string("value").notNull()
1818
$0.date("created_at").notNull()
19-
$0.int("user_id").references("id", on: "users").notNull()
19+
$0.bigInt("user_id").unsigned().references("id", on: "users").notNull()
2020
}
2121
}
2222

Quickstarts/Backend/Sources/Backend/Migrations/_20210107155107CreateTodos.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,22 +8,22 @@ struct _20210107155107CreateTodos: Migration {
88
$0.increments("id").primary()
99
$0.string("name").notNull()
1010
$0.bool("is_complete").notNull().default(val: false)
11-
$0.int("user_id").references("id", on: "users").notNull()
11+
$0.bigInt("user_id").unsigned().references("id", on: "users").notNull()
1212
}
1313

1414
// Create a table backing `Tag`.
1515
schema.create(table: "tags") {
1616
$0.increments("id").primary()
1717
$0.string("name").notNull()
1818
$0.int("color").notNull()
19-
$0.int("user_id").references("id", on: "users").notNull()
19+
$0.bigInt("user_id").unsigned().references("id", on: "users").notNull()
2020
}
2121

2222
// Create a table backing `TodoTag`.
2323
schema.create(table: "todo_tags") {
2424
$0.increments("id").primary()
25-
$0.int("todo_id").references("id", on: "todos").notNull()
26-
$0.int("tag_id").references("id", on: "tags").notNull()
25+
$0.bigInt("todo_id").unsigned().references("id", on: "todos").notNull()
26+
$0.bigInt("tag_id").unsigned().references("id", on: "tags").notNull()
2727
}
2828
}
2929

Quickstarts/Fullstack/Backend/Sources/Backend/Migrations/_20210107155059CreateUsers.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ struct _20210107155059CreateUsers: Migration {
1616
$0.increments("id").primary()
1717
$0.string("value").notNull()
1818
$0.date("created_at").notNull()
19-
$0.int("user_id").references("id", on: "users").notNull()
19+
$0.bigInt("user_id").unsigned().references("id", on: "users").notNull()
2020
}
2121
}
2222

Quickstarts/Fullstack/Backend/Sources/Backend/Migrations/_20210107155107CreateTodos.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,22 +8,22 @@ struct _20210107155107CreateTodos: Migration {
88
$0.increments("id").primary()
99
$0.string("name").notNull()
1010
$0.bool("is_complete").notNull().default(val: false)
11-
$0.int("user_id").references("id", on: "users").notNull()
11+
$0.bigInt("user_id").unsigned().references("id", on: "users").notNull()
1212
}
1313

1414
// Create a table backing `Tag`.
1515
schema.create(table: "tags") {
1616
$0.increments("id").primary()
1717
$0.string("name").notNull()
1818
$0.int("color").notNull()
19-
$0.int("user_id").references("id", on: "users").notNull()
19+
$0.bigInt("user_id").unsigned().references("id", on: "users").notNull()
2020
}
2121

2222
// Create a table backing `TodoTag`.
2323
schema.create(table: "todo_tags") {
2424
$0.increments("id").primary()
25-
$0.int("todo_id").references("id", on: "todos").notNull()
26-
$0.int("tag_id").references("id", on: "tags").notNull()
25+
$0.bigInt("todo_id").unsigned().references("id", on: "todos").notNull()
26+
$0.bigInt("tag_id").unsigned().references("id", on: "tags").notNull()
2727
}
2828
}
2929

Sources/Alchemy/SQL/Database/MySQL/MySQL+Database.swift

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,33 @@ public final class MySQLDatabase: Database {
4949
}
5050
}
5151

52+
/// MySQL doesn't have a way to return a row after inserting. This
53+
/// runs a query and if MySQL metadata contains a `lastInsertID`,
54+
/// fetches the row with that id from the given table.
55+
///
56+
/// - Parameters:
57+
/// - sql: The SQL to run.
58+
/// - table: The table from which `lastInsertID` should be
59+
/// fetched.
60+
/// - values: Any bindings for the query.
61+
/// - Returns: A future containing the result of fetching the last
62+
/// inserted id, or the result of the original query.
63+
func runAndReturnLastInsertedItem(_ sql: String, table: String, values: [DatabaseValue]) -> EventLoopFuture<[DatabaseRow]> {
64+
self.pool.withConnection(logger: Log.logger, on: Services.eventLoop) { conn in
65+
var lastInsertId: Int?
66+
return conn
67+
.query(sql, values.map(MySQLData.init), onMetadata: { lastInsertId = $0.lastInsertID.map(Int.init) })
68+
.flatMap { rows -> EventLoopFuture<[MySQLRow]> in
69+
if let lastInsertId = lastInsertId {
70+
return conn.query("select * from \(table) where id = ?;", [MySQLData(.int(lastInsertId))])
71+
} else {
72+
return .new(rows)
73+
}
74+
}
75+
.map { $0 }
76+
}
77+
}
78+
5279
public func shutdown() throws {
5380
try self.pool.syncShutdownGracefully()
5481
}

Sources/Alchemy/SQL/Database/MySQL/MySQL+DatabaseRow.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ extension MySQLData {
7575
case .varchar, .string, .varString:
7676
let value = DatabaseValue.string(try validateNil(self.string))
7777
return DatabaseField(column: column, value: value)
78-
case .date, .timestamp, .timestamp2:
78+
case .date, .timestamp, .timestamp2, .datetime, .datetime2:
7979
let value = DatabaseValue.date(try validateNil(self.time?.date))
8080
return DatabaseField(column: column, value: value)
8181
case .time:

Sources/Alchemy/SQL/Database/MySQL/MySQL+Grammar.swift

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,8 @@
1+
import NIO
2+
13
/// A MySQL specific Grammar for compiling QueryBuilder statements
24
/// into SQL strings.
35
final class MySQLGrammar: Grammar {
4-
override func compileInsert(_ query: Query, values: [OrderedDictionary<String, Parameter>]) throws -> SQL {
5-
var initial = try super.compileInsert(query, values: values)
6-
initial.query.append("; select * from table where Id=LAST_INSERT_ID();")
7-
return initial
8-
}
9-
106
override func compileDropIndex(table: String, indexName: String) -> SQL {
117
SQL("DROP INDEX \(indexName) ON \(table)")
128
}
@@ -20,9 +16,11 @@ final class MySQLGrammar: Grammar {
2016
case .double:
2117
return "double"
2218
case .increments:
23-
return "SERIAL"
19+
return "serial"
2420
case .int:
2521
return "int"
22+
case .bigInt:
23+
return "bigint"
2624
case .json:
2725
return "json"
2826
case .string(let length):
@@ -33,12 +31,37 @@ final class MySQLGrammar: Grammar {
3331
return "varchar(\(characters))"
3432
}
3533
case .uuid:
36-
// There isn't a MySQL UUID type; store UUIDs as a 36 length varchar.
34+
// There isn't a MySQL UUID type; store UUIDs as a 36
35+
// length varchar.
3736
return "varchar(36)"
3837
}
3938
}
4039

4140
override func jsonLiteral(from jsonString: String) -> String {
4241
"('\(jsonString)')"
4342
}
43+
44+
override func allowsUnsigned() -> Bool {
45+
true
46+
}
47+
48+
// MySQL needs custom insert behavior, since bulk inserting and
49+
// returning is not supported.
50+
override func insert(_ values: [OrderedDictionary<String, Parameter>], query: Query, returnItems: Bool) -> EventLoopFuture<[DatabaseRow]> {
51+
catchError {
52+
guard
53+
returnItems,
54+
let table = query.from,
55+
let database = query.database as? MySQLDatabase
56+
else {
57+
return super.insert(values, query: query, returnItems: returnItems)
58+
}
59+
60+
return try values
61+
.map { try self.compileInsert(query, values: [$0]) }
62+
.map { database.runAndReturnLastInsertedItem($0.query, table: table, values: $0.bindings) }
63+
.flatten(on: Services.eventLoop)
64+
.map { $0.flatMap { $0 } }
65+
}
66+
}
4467
}

Sources/Alchemy/SQL/Migrations/Builders/CreateColumnBuilder.swift

Lines changed: 72 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,41 @@ protocol ColumnBuilderErased {
44
func toCreate() -> CreateColumn
55
}
66

7+
/// Options for an `onDelete` or `onUpdate` reference constraint.
8+
public enum ReferenceOption: String {
9+
/// RESTRICT
10+
case restrict = "RESTRICT"
11+
/// CASCADE
12+
case cascade = "CASCADE"
13+
/// SET NULL
14+
case setNull = "SET NULL"
15+
/// NO ACTION
16+
case noAction = "NO ACTION"
17+
/// SET DEFAULT
18+
case setDefault = "SET DEFAULT"
19+
}
20+
21+
/// Various constraints for columns.
22+
enum ColumnConstraint {
23+
/// This column shouldn't be null.
24+
case notNull
25+
/// The default value for this column.
26+
case `default`(String)
27+
/// This column is the primary key of it's table.
28+
case primaryKey
29+
/// This column is unique on this table.
30+
case unique
31+
/// This column references a `column` on another `table`.
32+
case foreignKey(
33+
column: String,
34+
table: String,
35+
onDelete: ReferenceOption? = nil,
36+
onUpdate: ReferenceOption? = nil
37+
)
38+
/// This int column is unsigned.
39+
case unsigned
40+
}
41+
742
/// A builder for creating columns on a table in a relational database.
843
///
944
/// `Default` is a Swift type that can be used to add a default value
@@ -19,7 +54,7 @@ public final class CreateColumnBuilder<Default: Sequelizable>: ColumnBuilderEras
1954
private let type: ColumnType
2055

2156
/// Any modifiers of the column.
22-
private var modifiers: [String]
57+
private var constraints: [ColumnConstraint]
2358

2459
/// Create with a name, a type, and a modifier array.
2560
///
@@ -29,11 +64,11 @@ public final class CreateColumnBuilder<Default: Sequelizable>: ColumnBuilderEras
2964
/// - name: The name of the column to create.
3065
/// - type: The type of the column to create.
3166
/// - modifiers: Any modifiers of the column.
32-
init(grammar: Grammar, name: String, type: ColumnType, modifiers: [String] = []) {
67+
init(grammar: Grammar, name: String, type: ColumnType, constraints: [ColumnConstraint] = []) {
3368
self.grammar = grammar
3469
self.name = name
3570
self.type = type
36-
self.modifiers = modifiers
71+
self.constraints = constraints
3772
}
3873

3974
/// Adds an expression as the default value of this column.
@@ -42,28 +77,28 @@ public final class CreateColumnBuilder<Default: Sequelizable>: ColumnBuilderEras
4277
/// default value of this column.
4378
/// - Returns: This column builder.
4479
@discardableResult public func `default`(expression: String) -> Self {
45-
self.appending(modifier: "DEFAULT \(expression)")
80+
self.adding(constraint: .default(expression))
4681
}
4782

4883
/// Adds a value as the default for this column.
4984
///
5085
/// - Parameter expression: A default value for this column.
5186
/// - Returns: This column builder.
5287
@discardableResult public func `default`(val: Default) -> Self {
53-
// Janky, but MySQL requires parenthases around text (but not
88+
// Janky, but MySQL requires parentheses around text (but not
5489
// varchar...) literals.
5590
if case .string(.unlimited) = self.type, self.grammar is MySQLGrammar {
56-
return self.appending(modifier: "DEFAULT (\(val.toSQL().query))")
91+
return self.adding(constraint: .default("(\(val.toSQL().query))"))
5792
}
5893

59-
return self.appending(modifier: "DEFAULT \(val.toSQL().query)")
94+
return self.adding(constraint: .default(val.toSQL().query))
6095
}
6196

6297
/// Define this column as not nullable.
6398
///
6499
/// - Returns: This column builder.
65100
@discardableResult public func notNull() -> Self {
66-
self.appending(modifier: "NOT NULL")
101+
self.adding(constraint: .notNull)
67102
}
68103

69104
/// Defines this column as a reference to another column on a
@@ -72,38 +107,58 @@ public final class CreateColumnBuilder<Default: Sequelizable>: ColumnBuilderEras
72107
/// - Parameters:
73108
/// - column: The column name this column references.
74109
/// - table: The table of the column this column references.
110+
/// - onDelete: The `ON DELETE` reference option for this
111+
/// column. Defaults to nil.
112+
/// - onUpdate: The `ON UPDATE` reference option for this
113+
/// column. Defaults to nil.
75114
/// - Returns: This column builder.
76-
@discardableResult public func references(_ column: String, on table: String) -> Self {
77-
self.appending(modifier: "REFERENCES \(table)(\(column))")
115+
@discardableResult public func references(
116+
_ column: String,
117+
on table: String,
118+
onDelete: ReferenceOption? = nil,
119+
onUpdate: ReferenceOption? = nil
120+
) -> Self {
121+
self.adding(constraint: .foreignKey(column: column, table: table, onDelete: onDelete, onUpdate: onUpdate))
78122
}
79123

80124
/// Defines this column as a primary key.
81125
///
82126
/// - Returns: This column builder.
83127
@discardableResult public func primary() -> Self {
84-
self.appending(modifier: "PRIMARY KEY")
128+
self.adding(constraint: .primaryKey)
85129
}
86130

87131
/// Defines this column as unique.
88132
///
89133
/// - Returns: This column builder.
90134
@discardableResult public func unique() -> Self {
91-
self.appending(modifier: "UNIQUE")
135+
self.adding(constraint: .unique)
92136
}
93137

94138
/// Adds a modifier to `self.modifiers` and then returns `self`.
95139
///
96140
/// - Parameter modifier: The modifier to add.
97141
/// - Returns: This column builder.
98-
private func appending(modifier: String) -> Self {
99-
self.modifiers.append(modifier)
142+
private func adding(constraint: ColumnConstraint) -> Self {
143+
self.constraints.append(constraint)
100144
return self
101145
}
102146

103147
// MARK: ColumnBuilderErased
104148

105149
func toCreate() -> CreateColumn {
106-
CreateColumn(column: self.name, type: self.type, constraints: self.modifiers)
150+
CreateColumn(column: self.name, type: self.type, constraints: self.constraints)
151+
}
152+
}
153+
154+
extension CreateColumnBuilder where Default == Int {
155+
/// Defines this integer column as unsigned.
156+
///
157+
/// - Note: Ignored if the backing Database is `PostgresDatabase`.
158+
///
159+
/// - Returns: This column builder.
160+
@discardableResult public func unsigned() -> Self {
161+
self.adding(constraint: .unsigned)
107162
}
108163
}
109164

@@ -115,7 +170,7 @@ extension CreateColumnBuilder where Default == SQLJSON {
115170
/// for this column.
116171
/// - Returns: This column builder.
117172
@discardableResult public func `default`(jsonString: String) -> Self {
118-
self.appending(modifier: "DEFAULT \(self.grammar.jsonLiteral(from: jsonString))")
173+
self.adding(constraint: .default(self.grammar.jsonLiteral(from: jsonString)))
119174
}
120175

121176
/// Adds an `Encodable` as the default for this column.
@@ -135,7 +190,7 @@ extension CreateColumnBuilder where Default == SQLJSON {
135190
}
136191

137192
let jsonString = String(decoding: jsonData, as: UTF8.self)
138-
return self.appending(modifier: "DEFAULT \(self.grammar.jsonLiteral(from: jsonString))")
193+
return self.adding(constraint: .default(self.grammar.jsonLiteral(from: jsonString)))
139194
}
140195
}
141196

Sources/Alchemy/SQL/Migrations/Builders/CreateTableBuilder.swift

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,14 @@ public class CreateTableBuilder {
5555
self.appendAndReturn(builder: CreateColumnBuilder(grammar: self.grammar, name: column, type: .int))
5656
}
5757

58+
/// Adds a big int column.
59+
///
60+
/// - Parameter column: The name of the column to add.
61+
/// - Returns: A builder for adding modifiers to the column.
62+
@discardableResult public func bigInt(_ column: String) -> CreateColumnBuilder<Int> {
63+
self.appendAndReturn(builder: CreateColumnBuilder(grammar: self.grammar, name: column, type: .bigInt))
64+
}
65+
5866
/// Adds a `Double` column.
5967
///
6068
/// - Parameter column: The name of the column to add.
@@ -172,17 +180,5 @@ public struct CreateColumn {
172180
let type: ColumnType
173181

174182
/// Any constraints.
175-
let constraints: [String]
176-
177-
/// Convert this `CreateColumn` to a `String` for inserting into
178-
/// an SQL statement.
179-
///
180-
/// - Returns: The SQL `String` describing this column.
181-
func toSQL(with grammar: Grammar) -> String {
182-
var baseSQL = "\(self.column) \(grammar.typeString(for: self.type))"
183-
if !self.constraints.isEmpty {
184-
baseSQL.append(" \(self.constraints.joined(separator: " "))")
185-
}
186-
return baseSQL
187-
}
183+
let constraints: [ColumnConstraint]
188184
}

0 commit comments

Comments
 (0)