Skip to content

Commit 89cf577

Browse files
committed
migrations start at 0
1 parent 6b4fd37 commit 89cf577

5 files changed

Lines changed: 161 additions & 38 deletions

File tree

Sources/Compiler/Project.swift

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,13 +62,14 @@ public struct Project {
6262
fatalError("Failed to get blank string data")
6363
}
6464

65-
let latestMigration = try fileSystem.files(at: migrationsDirectory)
65+
let nextMigration = try fileSystem.files(at: migrationsDirectory)
6666
.compactMap { $0.split(separator: ".").first }
6767
.compactMap { Int($0) }
6868
.sorted(by: >)
69-
.first ?? 0
69+
.first
70+
.map { $0 + 1 } ?? 0
7071

71-
let fileUrl = migrationsDirectory.appendingPathComponent("\(latestMigration + 1).sql")
72+
let fileUrl = migrationsDirectory.appendingPathComponent("\(nextMigration).sql")
7273

7374
fileSystem.write(data, to: fileUrl)
7475
}

Sources/Otter/ConnectionPool.swift

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,14 +50,10 @@ public actor ConnectionPool: Sendable {
5050

5151
// Turn on WAL mode
5252
try connection.execute(sql: "PRAGMA journal_mode=WAL;")
53-
54-
let tx = try Transaction(connection: connection, kind: .write)
55-
try MigrationRunner.execute(migrations: migrations, tx: tx)
56-
try tx.commit()
57-
53+
try MigrationRunner.execute(migrations: migrations, connection: connection)
5854
self.availableConnections = [connection]
5955
}
60-
56+
6157
/// Whether or not we have created all the connections we are allowed too
6258
var isAtConnectionLimit: Bool {
6359
return count >= limit

Sources/Otter/Migration.swift

Lines changed: 42 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -5,53 +5,67 @@
55
// Created by Wes Wickwire on 2/16/25.
66
//
77

8-
public enum MigrationRunner {
8+
/// Executes the migrations and ensures it is up to date.
9+
enum MigrationRunner {
910
static let migrationTableName = "__otterMigrations"
10-
11-
public static func execute(migrations: [String], pool: ConnectionPool) async throws {
12-
try await pool.begin(.write) { tx in
13-
try execute(migrations: migrations, tx: tx)
14-
}
15-
}
16-
17-
public static func execute(migrations: [String], tx: borrowing Transaction) throws {
18-
try createTableIfNeeded(tx: tx)
19-
20-
let lastMigration = try lastMigration(tx: tx)
11+
12+
static func execute(migrations: [String], connection: SQLiteConnection) throws {
13+
let previouslyRunMigrations = try runMigrations(connection: connection)
14+
let lastMigration = previouslyRunMigrations.last ?? Int.min
2115

2216
let pendingMigrations = migrations.enumerated()
23-
.map { (number: $0.offset + 1, migration: $0.element) }
17+
.map { (number: $0.offset, migration: $0.element) }
2418
.filter { $0.number > lastMigration }
25-
.sorted { $0.number < $1.number }
2619

2720
for (number, migration) in pendingMigrations {
28-
try execute(migration: migration, number: number, tx: tx)
21+
// Run each migration in it's own transaction.
22+
let tx = try Transaction(connection: connection, kind: .write)
23+
24+
let result = Result {
25+
try execute(migration: migration, number: number, tx: tx)
26+
}
27+
28+
switch result {
29+
case .success:
30+
try tx.commit()
31+
case .failure(let error):
32+
try tx.commitOrRollback()
33+
throw error
34+
}
2935
}
3036
}
3137

32-
static func createTableIfNeeded(tx: borrowing Transaction) throws(OtterError) {
33-
try tx.execute(sql: """
34-
CREATE TABLE IF NOT EXISTS \(migrationTableName)(
35-
number INTEGER PRIMARY KEY
36-
) STRICT;
37-
""")
38-
}
39-
40-
/// Executes the migration, if the `number` exists it will be recorded in the migrations table.
41-
static func execute(migration: String, number: Int?, tx: borrowing Transaction) throws {
38+
/// Executes the migration, if the `number` exists it will be
39+
/// recorded in the migrations table.
40+
private static func execute(
41+
migration: String,
42+
number: Int?,
43+
tx: borrowing Transaction
44+
) throws {
4245
try tx.execute(sql: migration)
4346

4447
if let number {
4548
try insertMigration(version: number, tx: tx)
4649
}
4750
}
4851

49-
private static func lastMigration(tx: borrowing Transaction) throws -> Int {
52+
/// Creates the migrations table and gets the last migration that ran.
53+
private static func runMigrations(connection: SQLiteConnection) throws -> [Int] {
54+
let tx = try Transaction(connection: connection, kind: .write)
55+
56+
// Create the migration table if need be.
57+
try tx.execute(sql: """
58+
CREATE TABLE IF NOT EXISTS \(migrationTableName)(
59+
number INTEGER PRIMARY KEY
60+
) STRICT;
61+
""")
62+
5063
let statement = try Statement(in: tx) {
51-
"SELECT MAX(number) FROM \(MigrationRunner.migrationTableName)"
64+
"SELECT * FROM \(MigrationRunner.migrationTableName) ORDER BY number ASC"
5265
}
5366

54-
return try statement.fetchOne(of: Int.self) ?? 0
67+
try tx.commit()
68+
return try statement.fetchAll()
5569
}
5670

5771
private static func insertMigration(version: Int, tx: borrowing Transaction) throws {

Sources/Otter/SQLiteConnection.swift

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,15 @@ class SQLiteConnection: @unchecked Sendable {
3333
}
3434

3535
func execute(sql: String) throws(OtterError) {
36-
try throwing(sqlite3_exec(sqliteConnection, sql, nil, nil, nil))
36+
var error: UnsafeMutablePointer<CChar>?
37+
let rc = sqlite3_exec(sqliteConnection, sql, nil, nil, &error)
38+
39+
if rc == SQLITE_OK {
40+
return
41+
}
42+
43+
let message = error.map { String(cString: $0) }
44+
throw .sqlite(SQLiteCode(rc), message)
3745
}
3846

3947
deinit {
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
//
2+
// MigrationRunnerTests.swift
3+
// Otter
4+
//
5+
// Created by Wes Wickwire on 6/24/25.
6+
//
7+
8+
import Testing
9+
10+
@testable import Otter
11+
12+
@Suite
13+
struct MigrationRunnerTests: ~Copyable {
14+
let connection = try! SQLiteConnection(path: ":memory:")
15+
16+
@Test func migrationTableIsCreated() async throws {
17+
try MigrationRunner.execute(migrations: [], connection: connection)
18+
#expect(try runMigrations().isEmpty)
19+
let tableNames = try tableNames()
20+
#expect(tableNames.contains(MigrationRunner.migrationTableName))
21+
}
22+
23+
@Test func userTableIsCreated() async throws {
24+
try MigrationRunner.execute(migrations: ["CREATE TABLE foo (bar INTEGER)"], connection: connection)
25+
#expect(try runMigrations() == [0])
26+
let tableNames = try tableNames()
27+
#expect(tableNames.contains("foo"))
28+
}
29+
30+
@Test func migrationFailsWithErrorIfMigrationHasAnError() async throws {
31+
#expect(throws: OtterError.self) {
32+
try MigrationRunner.execute(migrations: [
33+
"CREATE TABLE foo (bar INTEGER)",
34+
"CREATE TABLE foo (bar INTEGER)"
35+
], connection: connection)
36+
}
37+
}
38+
39+
@Test func migrationsAreRunInOrder() async throws {
40+
try MigrationRunner.execute(
41+
migrations: [
42+
"CREATE TABLE migrationOrder (value TEXT)",
43+
"INSERT INTO migrationOrder (value) VALUES ('first')",
44+
"UPDATE migrationOrder SET value = value || ', second'",
45+
"UPDATE migrationOrder SET value = value || ', third'",
46+
],
47+
connection: connection
48+
)
49+
50+
let value: String? = try query("SELECT * FROM migrationOrder") { try $0.fetchOne() }
51+
#expect(value == "first, second, third")
52+
}
53+
54+
@Test func failedMigrationRollsbackChanges() async throws {
55+
#expect(throws: OtterError.self) {
56+
try MigrationRunner.execute(
57+
migrations: [
58+
"""
59+
CREATE TABLE foo (bar INTEGER);
60+
CREATE TABLE foo (bar INTEGER); -- Fails
61+
"""
62+
],
63+
connection: connection
64+
)
65+
}
66+
67+
let tables = try tableNames()
68+
#expect(!tables.contains("foo"))
69+
}
70+
71+
@Test func migrationsBeforeAFailureAreCommited() async throws {
72+
#expect(throws: OtterError.self) {
73+
try MigrationRunner.execute(
74+
migrations: [
75+
"CREATE TABLE foo (bar INTEGER);",
76+
"CREATE TABLE foo (bar INTEGER); -- Fails"
77+
],
78+
connection: connection
79+
)
80+
}
81+
82+
let tables = try tableNames()
83+
#expect(tables.contains("foo"))
84+
}
85+
86+
private func runMigrations() throws -> [Int] {
87+
return try query("SELECT * FROM \(MigrationRunner.migrationTableName) ORDER BY number ASC") { try $0.fetchAll() }
88+
}
89+
90+
private func tableNames() throws -> Set<String> {
91+
return try query("SELECT name FROM sqlite_master") { try Set($0.fetchAll()) }
92+
}
93+
94+
private func query<Output>(
95+
_ stmt: String,
96+
execute: (consuming Statement) throws -> Output
97+
) throws -> Output {
98+
let tx = try Transaction(connection: connection, kind: .read)
99+
let statement = try Statement(in: tx) { stmt }
100+
let result = try execute(statement)
101+
try tx.commit()
102+
return result
103+
}
104+
}

0 commit comments

Comments
 (0)