Skip to content

Commit 0887b40

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 0887b40

6 files changed

Lines changed: 1367 additions & 0 deletions

File tree

lib/rom/sql/errors.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ module SQL
1919
MissingPrimaryKeyError = Class.new(StandardError)
2020
MigrationError = Class.new(StandardError)
2121
UnsupportedConversion = Class.new(MigrationError)
22+
SQLParseError = Class.new(MigrationError)
23+
SQLEnvError = Class.new(MigrationError)
2224

2325
ERROR_MAP = {
2426
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: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
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+
# Matches `${NAME}` substitution references in statement bodies when the
27+
# `env=true` pragma is set. Only the braced form is recognized, leaving
28+
# `$$`-quoted bodies and `$1` positional params untouched.
29+
SUBSTITUTION = /\$\{(?<name>[A-Za-z_]\w*)\}/
30+
31+
attribute :filename, Filename
32+
attribute :settings, Settings
33+
34+
attribute :up, Statements
35+
attribute? :down, Statements
36+
37+
# Return a copy with additional settings merged in.
38+
#
39+
# @api private
40+
def with(**settings)
41+
new(settings: self.settings.merge(settings))
42+
end
43+
44+
# Whether to run inside a transaction.
45+
#
46+
# @return [true, false, nil] nil leaves the choice to the database default
47+
#
48+
# @api private
49+
def use_transactions
50+
settings[:transaction]
51+
end
52+
53+
# Execute the statements for the given direction against the database.
54+
#
55+
# @param db [Sequel::Database]
56+
# @param direction [:up, :down]
57+
#
58+
# @api private
59+
def apply(db, direction)
60+
unless Direction.valid?(direction)
61+
raise ArgumentError, "Invalid migration direction specified (#{direction.inspect})"
62+
end
63+
64+
statements = public_send(direction) or return
65+
66+
# Resolve every statement before running any, so a missing environment
67+
# variable aborts before producing side effects.
68+
finalized = statements.map { |statement| sub(statement) }
69+
70+
finalized.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+
# Return the statement with `${NAME}` environment-variable substitution
80+
# applied to its SQL when the `env` pragma is set. Raises `SQLEnvError`
81+
# for a referenced variable that is not present in `ENV`.
82+
#
83+
# @api private
84+
def sub(statement)
85+
return statement unless settings[:env]
86+
87+
sql = statement.sql.gsub(SUBSTITUTION) do
88+
name = Regexp.last_match[:name]
89+
ENV.fetch(name) do
90+
raise SQLEnvError,
91+
"Undefined environment variable ${#{name}} in #{filename}:#{statement.line}"
92+
end
93+
end
94+
95+
statement.with(sql:)
96+
end
97+
98+
# Prepend a synthetic frame pointing at the failing statement's source
99+
# location, so the migration file appears at the top of the backtrace.
100+
#
101+
# @api private
102+
def annotate(error, statement, direction)
103+
frame = "#{filename}:#{statement.line}:in '#{direction}'"
104+
error.set_backtrace([frame, *(error.backtrace || [])])
105+
error
106+
end
107+
end
108+
end
109+
end
110+
end

0 commit comments

Comments
 (0)