Skip to content

Commit e5b7ee9

Browse files
committed
Support plain SQL migrations
This introduces ROM::SQL::Migration::SQLMigration, which allows you to write migrations as a .sql file in the migration directory. SQL migrations have a very simple grammar: 1. Files are organized into two directions (up and down). Each direction may contain one or more `begin..end` sections; all sections for a direction execute sequentially in source order at apply time. Each section keeps its own `split=` setting, so a single direction may mix splitting strategies across sections. 2. Directives are written as SQL line comments using the `-- @migrate` sigil. The leading `--` keeps directive lines valid SQL syntax (so editors, syntax highlighters, and linters treat them as comments); the `@migrate` sigil disambiguates them from author-written `--` comments. Plain `-- ...` comments without the `@migrate` sigil are treated as part of the SQL body and pass through verbatim. 3. Optional settings may be provided in a pragma line before the up/down sections 4. Pragma and begin lines accept `key=value` settings separated by spaces or tabs 5. Each direction begins with a direction directive followed by one or more begin directives 6. The begin directive may itself carry settings (e.g. statement splitting strategy) 7. Each section concludes with an end directive 8. Down migrations are optional, but they always must follow the up migration 9. Empty sections (begin..end with no SQL body) are silently skipped
1 parent 7a1e806 commit e5b7ee9

6 files changed

Lines changed: 1196 additions & 0 deletions

File tree

lib/rom/sql/errors.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ module SQL
1919
MissingPrimaryKeyError = Class.new(StandardError)
2020
MigrationError = Class.new(StandardError)
2121
UnsupportedConversion = Class.new(MigrationError)
22+
SQLParseError = Class.new(MigrationError)
2223

2324
ERROR_MAP = {
2425
Sequel::DatabaseError => DatabaseError,

lib/rom/sql/migration/migrator.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
require 'rom/sql/migration/inline_runner'
1010
require 'rom/sql/migration/writer'
1111

12+
Sequel.extension :sql_migrations
13+
1214
module ROM
1315
module SQL
1416
module Migration
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# frozen_string_literal: true
2+
3+
require "dry/struct"
4+
5+
require "rom/sql/types"
6+
7+
module ROM
8+
module SQL
9+
module Migration
10+
# A single executable statement extracted from a `.sql` migration
11+
# section, paired with the source line where it begins. The line is
12+
# used to annotate backtraces when execution fails.
13+
#
14+
# @api private
15+
Statement = Data.define(:sql, :line)
16+
17+
# A migration translated from a `.sql` file by `SQLParser`.
18+
#
19+
# @api private
20+
class SQLMigration < Dry::Struct
21+
Filename = Types::Coercible::String
22+
Settings = Types::Strict::Hash.default { {} }
23+
Direction = Types::Strict::Symbol.enum(:up, :down)
24+
Statements = Types::Array.of(Types.Instance(Statement))
25+
26+
attribute :filename, Filename
27+
attribute :settings, Settings
28+
29+
attribute :up, Statements
30+
attribute? :down, Statements
31+
32+
# Return a copy with additional settings merged in.
33+
#
34+
# @api private
35+
def with(**settings)
36+
new(settings: self.settings.merge(settings))
37+
end
38+
39+
# Whether to run inside a transaction.
40+
#
41+
# @return [true, false, nil] nil leaves the choice to the database default
42+
#
43+
# @api private
44+
def use_transactions
45+
settings[:transaction]
46+
end
47+
48+
# Execute the statements for the given direction against the database.
49+
#
50+
# @param db [Sequel::Database]
51+
# @param direction [:up, :down]
52+
#
53+
# @api private
54+
def apply(db, direction)
55+
unless Direction.valid?(direction)
56+
raise ArgumentError, "Invalid migration direction specified (#{direction.inspect})"
57+
end
58+
59+
statements = public_send(direction)
60+
return unless statements
61+
62+
statements.each do |statement|
63+
db.run(statement.sql)
64+
rescue Sequel::DatabaseError => e
65+
raise annotate(e, statement, direction)
66+
end
67+
end
68+
69+
private
70+
71+
# Prepend a synthetic frame pointing at the failing statement's source
72+
# location, so the migration file appears at the top of the backtrace.
73+
#
74+
# @api private
75+
def annotate(error, statement, direction)
76+
frame = "#{filename}:#{statement.line}:in '#{direction}'"
77+
error.set_backtrace([frame, *(error.backtrace || [])])
78+
error
79+
end
80+
end
81+
end
82+
end
83+
end

0 commit comments

Comments
 (0)