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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +8 -0
- data/LICENSE.txt +21 -0
- data/README.md +202 -0
- data/exe/schema_ferry +7 -0
- data/lib/schema_ferry/cli.rb +98 -0
- data/lib/schema_ferry/converter/column_converter.rb +74 -0
- data/lib/schema_ferry/converter/enum_check_builder.rb +48 -0
- data/lib/schema_ferry/converter/identifier_shortener.rb +37 -0
- data/lib/schema_ferry/converter/schema_converter.rb +144 -0
- data/lib/schema_ferry/converter/type_mapper.rb +67 -0
- data/lib/schema_ferry/dsl/config.rb +77 -0
- data/lib/schema_ferry/dsl/table_rule.rb +51 -0
- data/lib/schema_ferry/errors.rb +11 -0
- data/lib/schema_ferry/pipeline.rb +23 -0
- data/lib/schema_ferry/schema_model.rb +58 -0
- data/lib/schema_ferry/source/connection_registry.rb +29 -0
- data/lib/schema_ferry/source/mysql_reader.rb +89 -0
- data/lib/schema_ferry/target/ridgepole_runner.rb +44 -0
- data/lib/schema_ferry/target/schemafile_renderer.rb +138 -0
- data/lib/schema_ferry/version.rb +5 -0
- data/lib/schema_ferry/warnings.rb +13 -0
- data/lib/schema_ferry.rb +26 -0
- metadata +120 -0
|
@@ -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,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
|
data/lib/schema_ferry.rb
ADDED
|
@@ -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
|