schema_ferry 0.2.0 → 0.3.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 +4 -4
- data/CHANGELOG.md +20 -1
- data/README.md +34 -32
- data/lib/schema_ferry/cli.rb +19 -10
- data/lib/schema_ferry/config.rb +109 -0
- data/lib/schema_ferry/{converter → core}/column_converter.rb +4 -4
- data/lib/schema_ferry/{converter → core}/enum_check_builder.rb +8 -6
- data/lib/schema_ferry/{converter/identifier_shortener.rb → core/identifier_shortenable.rb} +4 -14
- data/lib/schema_ferry/core/schema_model.rb +60 -0
- data/lib/schema_ferry/core/schemafile_renderer.rb +109 -0
- data/lib/schema_ferry/core/table_converter.rb +159 -0
- data/lib/schema_ferry/core/translator.rb +53 -0
- data/lib/schema_ferry/{converter → core}/type_mapper.rb +2 -2
- data/lib/schema_ferry/errors.rb +2 -0
- data/lib/schema_ferry/io/mysql_reader.rb +80 -0
- data/lib/schema_ferry/{target/ridgepole_runner.rb → io/postgres_writer.rb} +11 -6
- data/lib/schema_ferry/pipeline.rb +24 -7
- data/lib/schema_ferry/support/drop_detectable.rb +17 -0
- data/lib/schema_ferry/support/warnings.rb +13 -0
- data/lib/schema_ferry/version.rb +1 -1
- data/lib/schema_ferry.rb +14 -14
- metadata +20 -14
- data/lib/schema_ferry/converter/schema_converter.rb +0 -172
- data/lib/schema_ferry/dsl/config.rb +0 -77
- data/lib/schema_ferry/dsl/table_rule.rb +0 -34
- data/lib/schema_ferry/schema_model.rb +0 -56
- data/lib/schema_ferry/source/connection_registry.rb +0 -29
- data/lib/schema_ferry/source/mysql_reader.rb +0 -88
- data/lib/schema_ferry/target/schemafile_renderer.rb +0 -143
- data/lib/schema_ferry/warnings.rb +0 -13
|
@@ -1,172 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module SchemaFerry
|
|
4
|
-
module Converter
|
|
5
|
-
class SchemaConverter
|
|
6
|
-
include Warnings
|
|
7
|
-
|
|
8
|
-
# PostgreSQL has no FULLTEXT/SPATIAL equivalent that ridgepole can express.
|
|
9
|
-
UNSUPPORTED_INDEX_TYPES = %i[fulltext spatial].freeze
|
|
10
|
-
|
|
11
|
-
# MySQL spatial column types have no PostgreSQL equivalent (that would
|
|
12
|
-
# require PostGIS, which schema_ferry does not manage). Most of these
|
|
13
|
-
# already raise TypeMapper's ConversionError, since ActiveRecord's
|
|
14
|
-
# mysql2 adapter reports them as an unrecognized type (nil). POINT is
|
|
15
|
-
# the sole exception: AR matches sql_type against an unanchored /int/i
|
|
16
|
-
# regex (see register_class_with_limit in
|
|
17
|
-
# ActiveRecord::ConnectionAdapters::AbstractAdapter#initialize_type_map),
|
|
18
|
-
# which matches "point" as a substring — so it's misreported as plain
|
|
19
|
-
# :integer instead. Without this check it would sail through as a
|
|
20
|
-
# meaningless integer column instead of raising. It must be caught
|
|
21
|
-
# here, by sql_type, and raised explicitly — the same failure mode as
|
|
22
|
-
# any other unsupported type, not a warning that's easy to miss in a
|
|
23
|
-
# cron log.
|
|
24
|
-
MISDETECTED_SPATIAL_SQL_TYPE = /\Apoint\b/i
|
|
25
|
-
|
|
26
|
-
def initialize(config)
|
|
27
|
-
@column_converter = ColumnConverter.new(TypeMapper.new(config.global_type_overrides))
|
|
28
|
-
@table_rules = config.table_rules
|
|
29
|
-
@ignored_tables = config.ignored_tables
|
|
30
|
-
@enum_check = (EnumCheckBuilder.new if config.enum_mode == :check)
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
def convert(raw_tables)
|
|
34
|
-
kept_tables = raw_tables.reject { |t| @ignored_tables.include?(t[:name]) }
|
|
35
|
-
fk_columns = collect_fk_columns(kept_tables)
|
|
36
|
-
kept_tables.map { |t| convert_table(t, fk_columns.fetch(t[:name], [])) }
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
private
|
|
40
|
-
|
|
41
|
-
def convert_table(raw, fk_columns)
|
|
42
|
-
rule = @table_rules[raw[:name]]
|
|
43
|
-
ignored = rule&.ignored_columns || []
|
|
44
|
-
IdentifierShortener.warn_long_table_name(raw[:name])
|
|
45
|
-
|
|
46
|
-
TableSchema.new(
|
|
47
|
-
name: raw[:name],
|
|
48
|
-
primary_key: raw[:primary_key],
|
|
49
|
-
pk_type: convert_pk_type(raw),
|
|
50
|
-
pk_limit: raw[:pk_limit],
|
|
51
|
-
comment: raw[:comment],
|
|
52
|
-
columns: convert_columns(raw[:columns], raw[:name], rule, ignored, fk_columns),
|
|
53
|
-
indexes: convert_indexes(raw[:indexes], raw[:name], rule, ignored),
|
|
54
|
-
foreign_keys: convert_foreign_keys(raw),
|
|
55
|
-
check_constraints: build_check_constraints(raw, rule, ignored)
|
|
56
|
-
)
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
# Columns on either side of a surviving foreign key must land in
|
|
60
|
-
# PostgreSQL's integer type family: a numeric(20, 0) column cannot
|
|
61
|
-
# reference a bigint key.
|
|
62
|
-
def collect_fk_columns(raw_tables)
|
|
63
|
-
raw_tables.each_with_object({}) do |raw, acc|
|
|
64
|
-
surviving_foreign_keys(raw).each do |fk|
|
|
65
|
-
(acc[raw[:name]] ||= []) << fk[:column]
|
|
66
|
-
(acc[fk[:to_table]] ||= []) << fk[:primary_key]
|
|
67
|
-
end
|
|
68
|
-
end
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
def surviving_foreign_keys(raw)
|
|
72
|
-
ignored = @table_rules[raw[:name]]&.ignored_columns || []
|
|
73
|
-
raw[:foreign_keys]
|
|
74
|
-
.reject { |fk| @ignored_tables.include?(fk[:to_table]) }
|
|
75
|
-
.reject { |fk| ignored.include?(fk[:column]) }
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
# MySQL BIGINT comes through AR as :integer with limit 8, so the sql_type
|
|
79
|
-
# is the only reliable source for the id: option.
|
|
80
|
-
def convert_pk_type(raw)
|
|
81
|
-
return raw[:pk_type] unless raw[:pk_type] == :integer
|
|
82
|
-
|
|
83
|
-
sql_type = raw[:pk_sql_type].to_s
|
|
84
|
-
if sql_type.include?("unsigned")
|
|
85
|
-
if sql_type.start_with?("bigint")
|
|
86
|
-
emit_warning "table #{raw[:name]}: BIGINT UNSIGNED primary key has no PostgreSQL " \
|
|
87
|
-
"equivalent; using signed bigint (values above 2^63-1 will not fit)."
|
|
88
|
-
end
|
|
89
|
-
:bigint
|
|
90
|
-
elsif sql_type.start_with?("bigint")
|
|
91
|
-
:bigint
|
|
92
|
-
else
|
|
93
|
-
:integer
|
|
94
|
-
end
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
def convert_columns(raw_columns, table_name, rule, ignored, fk_columns)
|
|
98
|
-
raw_columns
|
|
99
|
-
.reject { |c| ignored.include?(c[:name]) }
|
|
100
|
-
.map do |c|
|
|
101
|
-
check_misdetected_spatial_type!(c, table_name, rule)
|
|
102
|
-
@column_converter.call(c, table_name, rule, fk_columns)
|
|
103
|
-
end
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
# map_column already lets a rule override any column's type before it
|
|
107
|
-
# ever reaches this check (ColumnConverter#call skips TypeMapper
|
|
108
|
-
# entirely when an override is present), same as for any other type.
|
|
109
|
-
def check_misdetected_spatial_type!(raw, table_name, rule)
|
|
110
|
-
return unless raw[:sql_type].to_s.match?(MISDETECTED_SPATIAL_SQL_TYPE)
|
|
111
|
-
return if rule&.column_type_overrides&.key?(raw[:name])
|
|
112
|
-
|
|
113
|
-
raise ConversionError, "#{table_name}.#{raw[:name]}: MySQL #{raw[:sql_type]} columns have no " \
|
|
114
|
-
"PostgreSQL equivalent without PostGIS, which schema_ferry does not " \
|
|
115
|
-
"manage. Exclude it with ignore_column :#{raw[:name]}."
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
def convert_indexes(raw_indexes, table_name, rule, ignored)
|
|
119
|
-
raw_indexes
|
|
120
|
-
.reject { |idx| rule&.ignored_indexes&.include?(idx[:name]) }
|
|
121
|
-
.reject { |idx| idx[:columns].intersect?(ignored) }
|
|
122
|
-
.map do |idx|
|
|
123
|
-
check_unsupported_index_type!(idx, table_name)
|
|
124
|
-
build_index_schema(idx, table_name)
|
|
125
|
-
end
|
|
126
|
-
end
|
|
127
|
-
|
|
128
|
-
def convert_foreign_keys(raw)
|
|
129
|
-
surviving_foreign_keys(raw).map { |fk| build_fk_schema(fk) }
|
|
130
|
-
end
|
|
131
|
-
|
|
132
|
-
# A warning here would be easy to miss in an unattended cron run.
|
|
133
|
-
# Raising makes this failure show up the same way as any other error:
|
|
134
|
-
# a non-zero exit status a monitor can catch.
|
|
135
|
-
def check_unsupported_index_type!(idx, table_name)
|
|
136
|
-
return unless UNSUPPORTED_INDEX_TYPES.include?(idx[:type])
|
|
137
|
-
|
|
138
|
-
raise ConversionError, "#{table_name}: #{idx[:type].to_s.upcase} index #{idx[:name].inspect} " \
|
|
139
|
-
"has no PostgreSQL equivalent. Exclude it with ignore_index :#{idx[:name]}."
|
|
140
|
-
end
|
|
141
|
-
|
|
142
|
-
def build_index_schema(raw, table_name)
|
|
143
|
-
IndexSchema.new(
|
|
144
|
-
name: IdentifierShortener.shorten(raw[:name], kind: "index", table: table_name),
|
|
145
|
-
columns: raw[:columns],
|
|
146
|
-
unique: raw[:unique],
|
|
147
|
-
using: raw[:using],
|
|
148
|
-
lengths: raw[:lengths],
|
|
149
|
-
orders: raw[:orders]
|
|
150
|
-
)
|
|
151
|
-
end
|
|
152
|
-
|
|
153
|
-
def build_fk_schema(raw)
|
|
154
|
-
ForeignKeySchema.new(
|
|
155
|
-
from_table: raw[:from_table],
|
|
156
|
-
to_table: raw[:to_table],
|
|
157
|
-
column: raw[:column],
|
|
158
|
-
primary_key: raw[:primary_key],
|
|
159
|
-
on_update: raw[:on_update],
|
|
160
|
-
on_delete: raw[:on_delete],
|
|
161
|
-
name: IdentifierShortener.shorten(raw[:name], kind: "foreign key", table: raw[:from_table])
|
|
162
|
-
)
|
|
163
|
-
end
|
|
164
|
-
|
|
165
|
-
def build_check_constraints(raw, rule, ignored)
|
|
166
|
-
return [] unless @enum_check
|
|
167
|
-
|
|
168
|
-
@enum_check.call(raw, rule, ignored)
|
|
169
|
-
end
|
|
170
|
-
end
|
|
171
|
-
end
|
|
172
|
-
end
|
|
@@ -1,77 +0,0 @@
|
|
|
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
|
|
@@ -1,34 +0,0 @@
|
|
|
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
|
-
attr_reader :table_name, :column_type_overrides,
|
|
10
|
-
:column_default_overrides, :ignored_columns, :ignored_indexes
|
|
11
|
-
|
|
12
|
-
def initialize(table_name)
|
|
13
|
-
@table_name = table_name.to_s
|
|
14
|
-
@column_type_overrides = {}
|
|
15
|
-
@column_default_overrides = {}
|
|
16
|
-
@ignored_columns = []
|
|
17
|
-
@ignored_indexes = []
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
def map_column(column_name, type:, default: UNSET)
|
|
21
|
-
@column_type_overrides[column_name.to_s] = type.to_sym
|
|
22
|
-
@column_default_overrides[column_name.to_s] = default unless default.equal?(UNSET)
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
def ignore_column(column_name)
|
|
26
|
-
@ignored_columns << column_name.to_s
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
def ignore_index(index_name)
|
|
30
|
-
@ignored_indexes << index_name.to_s
|
|
31
|
-
end
|
|
32
|
-
end
|
|
33
|
-
end
|
|
34
|
-
end
|
|
@@ -1,56 +0,0 @@
|
|
|
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
|
-
:lengths, # Integer | Hash | nil
|
|
36
|
-
:orders, # Symbol | Hash | nil
|
|
37
|
-
keyword_init: true
|
|
38
|
-
)
|
|
39
|
-
|
|
40
|
-
CheckConstraintSchema = Struct.new(
|
|
41
|
-
:expression, # String (e.g. "kind IN ('a', 'b')")
|
|
42
|
-
:name, # String
|
|
43
|
-
keyword_init: true
|
|
44
|
-
)
|
|
45
|
-
|
|
46
|
-
ForeignKeySchema = Struct.new(
|
|
47
|
-
:from_table, # String
|
|
48
|
-
:to_table, # String
|
|
49
|
-
:column, # String
|
|
50
|
-
:primary_key, # String
|
|
51
|
-
:on_update, # Symbol | nil
|
|
52
|
-
:on_delete, # Symbol | nil
|
|
53
|
-
:name, # String | nil
|
|
54
|
-
keyword_init: true
|
|
55
|
-
)
|
|
56
|
-
end
|
|
@@ -1,29 +0,0 @@
|
|
|
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
|
|
@@ -1,88 +0,0 @@
|
|
|
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
|
-
lengths: idx.lengths.presence,
|
|
68
|
-
orders: idx.orders.presence
|
|
69
|
-
}
|
|
70
|
-
end
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
def serialize_foreign_keys(fkeys, from_table)
|
|
74
|
-
fkeys.map do |fk|
|
|
75
|
-
{
|
|
76
|
-
from_table: from_table,
|
|
77
|
-
to_table: fk.to_table,
|
|
78
|
-
column: fk.column,
|
|
79
|
-
primary_key: fk.primary_key,
|
|
80
|
-
on_update: fk.on_update,
|
|
81
|
-
on_delete: fk.on_delete,
|
|
82
|
-
name: fk.name
|
|
83
|
-
}
|
|
84
|
-
end
|
|
85
|
-
end
|
|
86
|
-
end
|
|
87
|
-
end
|
|
88
|
-
end
|
|
@@ -1,143 +0,0 @@
|
|
|
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 << "order: #{idx.orders.inspect}" if idx.orders
|
|
117
|
-
" t.index #{cols}, #{parts.join(", ")}"
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
def render_check_constraint(chk)
|
|
121
|
-
" t.check_constraint #{chk.expression.inspect}, name: #{chk.name.inspect}"
|
|
122
|
-
end
|
|
123
|
-
|
|
124
|
-
def render_foreign_key(foreign_key)
|
|
125
|
-
parts = [foreign_key.from_table.inspect, foreign_key.to_table.inspect]
|
|
126
|
-
parts << "column: #{foreign_key.column.inspect}" if custom_fk_column?(foreign_key)
|
|
127
|
-
non_default_pk = foreign_key.primary_key && foreign_key.primary_key != "id"
|
|
128
|
-
parts << "primary_key: #{foreign_key.primary_key.inspect}" if non_default_pk
|
|
129
|
-
parts << "name: #{foreign_key.name.inspect}" if foreign_key.name
|
|
130
|
-
parts << "on_update: #{foreign_key.on_update.inspect}" if foreign_key.on_update
|
|
131
|
-
parts << "on_delete: #{foreign_key.on_delete.inspect}" if foreign_key.on_delete
|
|
132
|
-
"add_foreign_key #{parts.join(", ")}"
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
# ridgepole's PostgreSQL export omits column: when it follows the Rails
|
|
136
|
-
# convention, and it compares foreign keys by their literal options —
|
|
137
|
-
# emitting column: for conventional names re-creates the FK every run.
|
|
138
|
-
def custom_fk_column?(foreign_key)
|
|
139
|
-
foreign_key.column && foreign_key.column != "#{foreign_key.to_table.singularize}_id"
|
|
140
|
-
end
|
|
141
|
-
end
|
|
142
|
-
end
|
|
143
|
-
end
|
|
@@ -1,13 +0,0 @@
|
|
|
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
|