Skip to content

Commit a5b89d2

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 a5b89d2

6 files changed

Lines changed: 1213 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: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
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+
# Quacks like `Sequel::SimpleMigration`: Sequel's migrator only calls
20+
# `#apply(db, direction)` and `#use_transactions` on the loaded object
21+
# (transaction wrapping happens outside `#apply`, in the migrator's
22+
# `checked_transaction`). Unlike `SimpleMigration`, statements are held
23+
# as plain arrays and executed directly via `db.run` — no `instance_exec`
24+
# indirection — which gives us a seam to rewrite the backtrace of a
25+
# failing statement so it points back into the `.sql` file.
26+
#
27+
# @api private
28+
class SQLMigration < Dry::Struct
29+
Filename = Types::Coercible::String
30+
Settings = Types::Strict::Hash.default { {} }
31+
Direction = Types::Strict::Symbol.enum(:up, :down)
32+
Statements = Types::Array.of(Types.Instance(Statement))
33+
34+
attribute :filename, Filename
35+
attribute :settings, Settings
36+
37+
attribute :up, Statements
38+
attribute? :down, Statements
39+
40+
# Return a copy with additional settings merged in.
41+
#
42+
# @api private
43+
def with(**settings)
44+
new(settings: self.settings.merge(settings))
45+
end
46+
47+
# Whether to run inside a transaction.
48+
#
49+
# @return [Boolean, nil] nil leaves the choice to the database default
50+
#
51+
# @api private
52+
def use_transactions
53+
settings[:transaction]
54+
end
55+
56+
# Execute the statements for the given direction against the database.
57+
#
58+
# @param db [Sequel::Database]
59+
# @param direction [Symbol] :up or :down
60+
#
61+
# @api private
62+
def apply(db, direction)
63+
unless Direction.valid?(direction)
64+
raise ArgumentError, "Invalid migration direction specified (#{direction.inspect})"
65+
end
66+
67+
statements = public_send(direction)
68+
return unless statements
69+
70+
statements.each do |statement|
71+
db.run(statement.sql)
72+
rescue Sequel::DatabaseError => e
73+
raise annotate(e, statement, direction)
74+
end
75+
end
76+
77+
private
78+
79+
# Prepend a synthetic frame pointing at the failing statement's source
80+
# location, so the migration file appears at the top of the backtrace.
81+
#
82+
# @api private
83+
def annotate(error, statement, direction)
84+
frame = "#{filename}:#{statement.line}:in '#{direction}'"
85+
error.set_backtrace([frame, *(error.backtrace || [])])
86+
error
87+
end
88+
end
89+
end
90+
end
91+
end

0 commit comments

Comments
 (0)