schema_ferry 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SchemaFerry
4
+ module Converter
5
+ class TypeMapper
6
+ # PG has no limit concept for text/binary/bigint; drop the MySQL-derived
7
+ # limits.
8
+ LIMIT_STRIPPED_TYPES = %i[text binary bigint].freeze
9
+
10
+ # PG's default timestamp precision is 6. Spelling it out makes ridgepole
11
+ # see a diff against the PG export (which omits it) on every run.
12
+ DEFAULT_PRECISION_TYPES = %i[datetime timestamptz time].freeze
13
+ PG_DEFAULT_PRECISION = 6
14
+
15
+ DEFAULTS = {
16
+ json: :jsonb
17
+ }.freeze
18
+
19
+ KNOWN_TYPES = %i[
20
+ string text integer bigint float decimal
21
+ datetime date time boolean binary json jsonb
22
+ ].freeze
23
+
24
+ def initialize(global_overrides = {})
25
+ @overrides = DEFAULTS.merge(global_overrides)
26
+ end
27
+
28
+ # Returns [pg_type_sym, adjusted_options_hash]
29
+ def call(ar_type, options = {})
30
+ unless KNOWN_TYPES.include?(ar_type)
31
+ raise ConversionError, "Unknown MySQL AR type: #{ar_type.inspect}. " \
32
+ "Use map_type or map_column to specify a PostgreSQL type."
33
+ end
34
+
35
+ pg_type = @overrides.fetch(ar_type, ar_type)
36
+ adjusted = options.dup
37
+ pg_type, adjusted = normalize_integer(adjusted) if pg_type == :integer
38
+ adjusted.delete(:limit) if LIMIT_STRIPPED_TYPES.include?(pg_type)
39
+ strip_default_precision(pg_type, adjusted)
40
+ # PG numeric(20) equals numeric(20,0) and is exported without scale.
41
+ adjusted[:scale] = nil if pg_type == :decimal && adjusted[:scale]&.zero?
42
+
43
+ [pg_type, adjusted]
44
+ end
45
+
46
+ private
47
+
48
+ # MySQL reports every integer flavor as :integer with a byte limit; PG only
49
+ # has smallint(2) / integer(4) / bigint(8). Emit the shape ridgepole
50
+ # exports for PG so repeated runs see no diff.
51
+ def normalize_integer(options)
52
+ case options[:limit]
53
+ when nil then [:integer, options]
54
+ when 1, 2 then [:integer, options.merge(limit: 2)]
55
+ when 3, 4 then [:integer, options.merge(limit: nil)]
56
+ else [:bigint, options.merge(limit: nil)]
57
+ end
58
+ end
59
+
60
+ def strip_default_precision(pg_type, options)
61
+ return unless DEFAULT_PRECISION_TYPES.include?(pg_type)
62
+
63
+ options[:precision] = nil if options[:precision] == PG_DEFAULT_PRECISION
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SchemaFerry
4
+ module DSL
5
+ class Config
6
+ ENUM_MODES = %i[string check].freeze
7
+
8
+ attr_reader :source_url, :target_url,
9
+ :global_type_overrides,
10
+ :table_rules,
11
+ :ignored_tables,
12
+ :enum_mode
13
+
14
+ # Evaluates a definition block and returns a validated Config.
15
+ def self.build(&block)
16
+ new.tap do |config|
17
+ config.instance_eval(&block)
18
+ config.validate!
19
+ end
20
+ end
21
+
22
+ # Evaluates a definition file (the DSL body, without the
23
+ # SchemaFerry.define wrapper) and returns a validated Config.
24
+ def self.load_file(path)
25
+ new.tap do |config|
26
+ config.instance_eval(File.read(path), path.to_s, 1)
27
+ config.validate!
28
+ end
29
+ end
30
+
31
+ def initialize
32
+ @global_type_overrides = {}
33
+ @table_rules = {}
34
+ @ignored_tables = []
35
+ @enum_mode = :string
36
+ end
37
+
38
+ def source(url)
39
+ @source_url = url
40
+ end
41
+
42
+ def target(url)
43
+ @target_url = url
44
+ end
45
+
46
+ def map_type(mysql_type, to:)
47
+ @global_type_overrides[mysql_type.to_sym] = to.to_sym
48
+ end
49
+
50
+ def table(table_name, &)
51
+ rule = TableRule.new(table_name)
52
+ rule.instance_eval(&)
53
+ @table_rules[table_name.to_s] = rule
54
+ end
55
+
56
+ def ignore_table(table_name)
57
+ @ignored_tables << table_name.to_s
58
+ end
59
+
60
+ # How to convert MySQL enum columns:
61
+ # :string — plain varchar, values not enforced (default)
62
+ # :check — varchar plus a CHECK constraint restricting the values
63
+ def enum_as(mode)
64
+ unless ENUM_MODES.include?(mode.to_sym)
65
+ raise ConfigError, "enum_as accepts #{ENUM_MODES.map(&:inspect).join(" or ")}, got #{mode.inspect}"
66
+ end
67
+
68
+ @enum_mode = mode.to_sym
69
+ end
70
+
71
+ def validate!
72
+ raise ConfigError, 'source is not configured. Add: source "mysql2://..."' unless @source_url
73
+ raise ConfigError, 'target is not configured. Add: target "postgresql://..."' unless @target_url
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SchemaFerry
4
+ module DSL
5
+ class TableRule
6
+ UNSET = Object.new.freeze
7
+ private_constant :UNSET
8
+
9
+ EXTRA_INDEX_OPTIONS = %i[name unique using where opclass order].freeze
10
+
11
+ attr_reader :table_name, :column_type_overrides,
12
+ :column_default_overrides, :ignored_columns, :ignored_indexes,
13
+ :extra_indexes
14
+
15
+ def initialize(table_name)
16
+ @table_name = table_name.to_s
17
+ @column_type_overrides = {}
18
+ @column_default_overrides = {}
19
+ @ignored_columns = []
20
+ @ignored_indexes = []
21
+ @extra_indexes = []
22
+ end
23
+
24
+ def map_column(column_name, type:, default: UNSET)
25
+ @column_type_overrides[column_name.to_s] = type.to_sym
26
+ @column_default_overrides[column_name.to_s] = default unless default.equal?(UNSET)
27
+ end
28
+
29
+ def ignore_column(column_name)
30
+ @ignored_columns << column_name.to_s
31
+ end
32
+
33
+ def ignore_index(index_name)
34
+ @ignored_indexes << index_name.to_s
35
+ end
36
+
37
+ # Declares a PostgreSQL-side index that does not exist in MySQL (e.g. a
38
+ # pg_trgm GIN index replacing a skipped FULLTEXT index). Declared indexes
39
+ # are part of the generated schema, so sync keeps them.
40
+ def add_index(*columns, **options)
41
+ unknown = options.keys - EXTRA_INDEX_OPTIONS
42
+ unless unknown.empty?
43
+ raise ConfigError, "add_index: unknown option(s) #{unknown.map(&:inspect).join(", ")} " \
44
+ "(allowed: #{EXTRA_INDEX_OPTIONS.map(&:inspect).join(", ")})"
45
+ end
46
+
47
+ @extra_indexes << { columns: columns.map(&:to_s), options: options }
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SchemaFerry
4
+ class Error < StandardError; end
5
+ class ConfigError < Error; end
6
+ class ConnectionError < Error; end
7
+ class ReadError < Error; end
8
+ class ConversionError < Error; end
9
+ class RidgepoleNotFoundError < Error; end
10
+ class RidgepoleError < Error; end
11
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SchemaFerry
4
+ class Pipeline
5
+ def initialize(config)
6
+ @config = config
7
+ end
8
+
9
+ def dry_run
10
+ Target::RidgepoleRunner.new(@config.target_url).run(schemafile, dry_run: true)
11
+ end
12
+
13
+ def apply!
14
+ Target::RidgepoleRunner.new(@config.target_url).run(schemafile, dry_run: false)
15
+ end
16
+
17
+ def schemafile
18
+ mysql_tables = Source::MysqlReader.new(@config.source_url).read_all
19
+ pg_tables = Converter::SchemaConverter.new(@config).convert(mysql_tables)
20
+ Target::SchemafileRenderer.new.render(pg_tables)
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SchemaFerry
4
+ TableSchema = Struct.new(
5
+ :name, # String
6
+ :primary_key, # String | Array<String> | nil
7
+ :pk_type, # Symbol: :bigint, :integer, :string, etc. (single-column PK only)
8
+ :pk_limit, # Integer | nil (limit of a string PK column)
9
+ :comment, # String | nil
10
+ :columns, # Array<ColumnSchema>
11
+ :indexes, # Array<IndexSchema>
12
+ :foreign_keys, # Array<ForeignKeySchema>
13
+ :check_constraints, # Array<CheckConstraintSchema>
14
+ keyword_init: true
15
+ )
16
+
17
+ ColumnSchema = Struct.new(
18
+ :name, # String
19
+ :type, # Symbol (:string, :integer, :jsonb, …)
20
+ :limit, # Integer | nil
21
+ :precision, # Integer | nil
22
+ :scale, # Integer | nil
23
+ :null, # Boolean
24
+ :default, # Object | nil
25
+ :default_function, # String | nil (e.g. "CURRENT_TIMESTAMP")
26
+ :comment, # String | nil
27
+ keyword_init: true
28
+ )
29
+
30
+ IndexSchema = Struct.new(
31
+ :name, # String
32
+ :columns, # Array<String>
33
+ :unique, # Boolean
34
+ :using, # Symbol | nil
35
+ :opclass, # Symbol | Hash | nil (PostgreSQL operator class, e.g. :gin_trgm_ops)
36
+ :where, # String | nil
37
+ :lengths, # Integer | Hash | nil
38
+ :orders, # Symbol | Hash | nil
39
+ keyword_init: true
40
+ )
41
+
42
+ CheckConstraintSchema = Struct.new(
43
+ :expression, # String (e.g. "kind IN ('a', 'b')")
44
+ :name, # String
45
+ keyword_init: true
46
+ )
47
+
48
+ ForeignKeySchema = Struct.new(
49
+ :from_table, # String
50
+ :to_table, # String
51
+ :column, # String
52
+ :primary_key, # String
53
+ :on_update, # Symbol | nil
54
+ :on_delete, # Symbol | nil
55
+ :name, # String | nil
56
+ keyword_init: true
57
+ )
58
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SchemaFerry
4
+ module Source
5
+ module ConnectionRegistry
6
+ # Uses a fresh ConnectionHandler per call so the pool is fully isolated
7
+ # from ActiveRecord::Base and any host Rails app connections.
8
+ # AR 7.2+ banned anonymous AR subclasses, so we bypass that path entirely.
9
+ def self.with_connection(url)
10
+ handler = ActiveRecord::ConnectionAdapters::ConnectionHandler.new
11
+ begin
12
+ pool = handler.establish_connection(url, owner_name: "schema_ferry")
13
+ conn = pool.checkout
14
+ conn.verify!
15
+ rescue ActiveRecord::ActiveRecordError => e
16
+ raise ConnectionError, e.message
17
+ end
18
+
19
+ begin
20
+ yield conn
21
+ ensure
22
+ pool.checkin(conn)
23
+ end
24
+ ensure
25
+ handler&.clear_all_connections!
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SchemaFerry
4
+ module Source
5
+ class MysqlReader
6
+ def initialize(url)
7
+ @url = url
8
+ end
9
+
10
+ def read_all
11
+ ConnectionRegistry.with_connection(@url) do |conn|
12
+ conn.tables.sort.map { |table_name| read_table(conn, table_name) }
13
+ end
14
+ rescue ConnectionError
15
+ raise
16
+ rescue StandardError => e
17
+ raise ReadError, e.message
18
+ end
19
+
20
+ private
21
+
22
+ def read_table(conn, name)
23
+ columns = conn.columns(name)
24
+ pk = conn.primary_key(name)
25
+ pk_col = pk.is_a?(String) ? columns.find { |c| c.name == pk } : nil
26
+ {
27
+ name: name,
28
+ primary_key: pk,
29
+ pk_type: pk_col&.type,
30
+ pk_limit: pk_col&.type == :string ? pk_col.limit : nil,
31
+ pk_sql_type: pk_col&.sql_type,
32
+ comment: conn.table_comment(name).presence,
33
+ columns: serialize_columns(columns, pk),
34
+ indexes: serialize_indexes(conn.indexes(name)),
35
+ foreign_keys: serialize_foreign_keys(conn.foreign_keys(name), name)
36
+ }
37
+ end
38
+
39
+ # A single-column PK is rendered via create_table's id: option, so it is
40
+ # excluded here. Composite PK columns must stay: primary_key: [...] does
41
+ # not create them.
42
+ def serialize_columns(columns, primary_key)
43
+ columns.reject { |c| c.name == primary_key }.map do |c|
44
+ {
45
+ name: c.name,
46
+ type: c.type,
47
+ sql_type: c.sql_type,
48
+ limit: c.limit,
49
+ precision: c.precision,
50
+ scale: c.scale,
51
+ null: c.null,
52
+ default: c.default,
53
+ default_function: c.default_function,
54
+ comment: c.comment
55
+ }
56
+ end
57
+ end
58
+
59
+ def serialize_indexes(indexes)
60
+ indexes.map do |idx|
61
+ {
62
+ name: idx.name,
63
+ columns: Array(idx.columns),
64
+ unique: idx.unique,
65
+ using: idx.using,
66
+ type: idx.type, # :fulltext | :spatial | nil
67
+ where: idx.where,
68
+ lengths: idx.lengths.presence,
69
+ orders: idx.orders.presence
70
+ }
71
+ end
72
+ end
73
+
74
+ def serialize_foreign_keys(fkeys, from_table)
75
+ fkeys.map do |fk|
76
+ {
77
+ from_table: from_table,
78
+ to_table: fk.to_table,
79
+ column: fk.column,
80
+ primary_key: fk.primary_key,
81
+ on_update: fk.on_update,
82
+ on_delete: fk.on_delete,
83
+ name: fk.name
84
+ }
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "tempfile"
5
+
6
+ module SchemaFerry
7
+ module Target
8
+ # Runs ridgepole as a subprocess rather than via Ridgepole::Client:
9
+ # the client calls ActiveRecord::Base.establish_connection and patches AR
10
+ # in-process, which would hijack a host Rails app's database connection.
11
+ # The tempfile exists because the CLI only reads the schema from -f FILE.
12
+ class RidgepoleRunner
13
+ def initialize(target_url)
14
+ @target_url = target_url
15
+ end
16
+
17
+ def run(schema_content, dry_run: false)
18
+ bin = find_ridgepole!
19
+
20
+ Tempfile.create(["schema_ferry_", ".rb"]) do |f|
21
+ f.write(schema_content)
22
+ f.flush
23
+
24
+ cmd = [bin, "--apply", "-c", @target_url, "-f", f.path]
25
+ cmd << "--dry-run" if dry_run
26
+
27
+ stdout, stderr, status = Open3.capture3(*cmd)
28
+ raise RidgepoleError, [stdout, stderr].reject { |s| s.strip.empty? }.join("\n") unless status.success?
29
+
30
+ stdout
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def find_ridgepole!
37
+ Gem.bin_path("ridgepole", "ridgepole")
38
+ rescue Gem::GemNotFoundException
39
+ raise RidgepoleNotFoundError,
40
+ "ridgepole binary not found. Add gem 'ridgepole' to your Gemfile."
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SchemaFerry
4
+ module Target
5
+ class SchemafileRenderer
6
+ TIMESTAMP_COLS = %w[created_at updated_at].freeze
7
+
8
+ def render(tables)
9
+ table_blocks = tables.map { |t| render_table(t) }
10
+ fkey_lines = tables.flat_map { |t| t.foreign_keys.map { |fk| render_foreign_key(fk) } }
11
+
12
+ parts = table_blocks
13
+ parts += fkey_lines unless fkey_lines.empty?
14
+ parts.join("\n\n")
15
+ end
16
+
17
+ private
18
+
19
+ def render_table(table)
20
+ lines = []
21
+ lines << "create_table #{table.name.inspect}#{pk_options(table)} do |t|"
22
+ lines.concat(render_columns(table.columns))
23
+ table.indexes.each { |idx| lines << render_index(idx) }
24
+ Array(table.check_constraints).each { |chk| lines << render_check_constraint(chk) }
25
+ lines << "end"
26
+ lines.join("\n")
27
+ end
28
+
29
+ def pk_options(table)
30
+ opts = []
31
+ case table.primary_key
32
+ when nil
33
+ opts << "id: false"
34
+ when String
35
+ opts << "primary_key: #{table.primary_key.inspect}" if table.primary_key != "id"
36
+ if table.pk_type && table.pk_type != :bigint
37
+ opts << "id: #{table.pk_type.inspect}"
38
+ opts << "limit: #{table.pk_limit.inspect}" if table.pk_limit
39
+ end
40
+ when Array
41
+ opts << "primary_key: #{table.primary_key.inspect}"
42
+ end
43
+ opts.unshift("force: :cascade")
44
+ opts << "comment: #{table.comment.inspect}" if table.comment
45
+ ", #{opts.join(", ")}"
46
+ end
47
+
48
+ def render_columns(columns)
49
+ ts_created = columns.find { |c| c.name == "created_at" }
50
+ ts_updated = columns.find { |c| c.name == "updated_at" }
51
+ use_timestamps = collapsible_timestamps?(ts_created, ts_updated)
52
+
53
+ lines = []
54
+ emitted_ts = false
55
+
56
+ columns.each do |col|
57
+ if TIMESTAMP_COLS.include?(col.name) && use_timestamps
58
+ unless emitted_ts
59
+ lines << render_timestamps(ts_created)
60
+ emitted_ts = true
61
+ end
62
+ next
63
+ end
64
+ lines << render_column(col)
65
+ end
66
+
67
+ lines
68
+ end
69
+
70
+ def collapsible_timestamps?(created_at, updated_at)
71
+ return false unless created_at && updated_at
72
+ return false unless created_at.type == :datetime && updated_at.type == :datetime
73
+
74
+ created_at.null == updated_at.null &&
75
+ created_at.precision == updated_at.precision &&
76
+ created_at.default.nil? && updated_at.default.nil? &&
77
+ created_at.default_function.nil? && updated_at.default_function.nil?
78
+ end
79
+
80
+ def render_timestamps(col)
81
+ opts = []
82
+ opts << "null: false" if col.null == false
83
+ opts << "precision: #{col.precision}" if col.precision
84
+ opts.empty? ? " t.timestamps" : " t.timestamps #{opts.join(", ")}"
85
+ end
86
+
87
+ def render_column(col)
88
+ parts = [col.name.inspect]
89
+ opts = column_options(col)
90
+ parts << opts unless opts.empty?
91
+ " t.#{col.type} #{parts.join(", ")}"
92
+ end
93
+
94
+ def column_options(col)
95
+ pairs = []
96
+ pairs << "limit: #{col.limit.inspect}" if col.limit
97
+ pairs << "precision: #{col.precision.inspect}" if col.precision
98
+ pairs << "scale: #{col.scale.inspect}" if col.scale
99
+ pairs << render_default(col) if render_default(col)
100
+ pairs << "null: false" if col.null == false
101
+ pairs << "comment: #{col.comment.inspect}" if col.comment
102
+ pairs.join(", ")
103
+ end
104
+
105
+ def render_default(col)
106
+ return "default: #{col.default.inspect}" unless col.default.nil?
107
+
108
+ "default: -> { #{col.default_function.inspect} }" if col.default_function
109
+ end
110
+
111
+ def render_index(idx)
112
+ cols = idx.columns.inspect
113
+ parts = ["name: #{idx.name.inspect}"]
114
+ parts << "unique: true" if idx.unique
115
+ parts << "using: #{idx.using.inspect}" if idx.using
116
+ parts << "opclass: #{idx.opclass.inspect}" if idx.opclass
117
+ parts << "where: #{idx.where.inspect}" if idx.where
118
+ parts << "order: #{idx.orders.inspect}" if idx.orders
119
+ " t.index #{cols}, #{parts.join(", ")}"
120
+ end
121
+
122
+ def render_check_constraint(chk)
123
+ " t.check_constraint #{chk.expression.inspect}, name: #{chk.name.inspect}"
124
+ end
125
+
126
+ def render_foreign_key(foreign_key)
127
+ parts = [foreign_key.from_table.inspect, foreign_key.to_table.inspect]
128
+ parts << "column: #{foreign_key.column.inspect}" if foreign_key.column
129
+ non_default_pk = foreign_key.primary_key && foreign_key.primary_key != "id"
130
+ parts << "primary_key: #{foreign_key.primary_key.inspect}" if non_default_pk
131
+ parts << "name: #{foreign_key.name.inspect}" if foreign_key.name
132
+ parts << "on_update: #{foreign_key.on_update.inspect}" if foreign_key.on_update
133
+ parts << "on_delete: #{foreign_key.on_delete.inspect}" if foreign_key.on_delete
134
+ "add_foreign_key #{parts.join(", ")}"
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SchemaFerry
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SchemaFerry
4
+ # Mix in (include for classes, extend for module_function modules) to emit
5
+ # "[schema_ferry]"-prefixed warnings to stderr.
6
+ module Warnings
7
+ private
8
+
9
+ def emit_warning(message)
10
+ Kernel.warn("[schema_ferry] #{message}")
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+
5
+ require_relative "schema_ferry/version"
6
+ require_relative "schema_ferry/errors"
7
+ require_relative "schema_ferry/warnings"
8
+ require_relative "schema_ferry/schema_model"
9
+ require_relative "schema_ferry/dsl/table_rule"
10
+ require_relative "schema_ferry/dsl/config"
11
+ require_relative "schema_ferry/source/connection_registry"
12
+ require_relative "schema_ferry/source/mysql_reader"
13
+ require_relative "schema_ferry/converter/type_mapper"
14
+ require_relative "schema_ferry/converter/identifier_shortener"
15
+ require_relative "schema_ferry/converter/column_converter"
16
+ require_relative "schema_ferry/converter/enum_check_builder"
17
+ require_relative "schema_ferry/converter/schema_converter"
18
+ require_relative "schema_ferry/target/schemafile_renderer"
19
+ require_relative "schema_ferry/target/ridgepole_runner"
20
+ require_relative "schema_ferry/pipeline"
21
+
22
+ module SchemaFerry
23
+ def self.define(&)
24
+ Pipeline.new(DSL::Config.build(&))
25
+ end
26
+ end