convergence 1.0.6 → 1.2.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/.github/workflows/ruby.yml +35 -4
- data/.gitignore +1 -0
- data/CHANGELOG.md +57 -0
- data/CONTRIBUTING.md +80 -0
- data/Gemfile.lock +25 -9
- data/README.md +160 -1
- data/Rakefile +45 -2
- data/convergence.gemspec +5 -1
- data/lib/convergence/cli.rb +10 -2
- data/lib/convergence/column.rb +3 -2
- data/lib/convergence/command/apply.rb +38 -9
- data/lib/convergence/command/dryrun.rb +5 -5
- data/lib/convergence/command/export.rb +19 -1
- data/lib/convergence/command.rb +6 -0
- data/lib/convergence/database_connector/postgres_connector.rb +25 -0
- data/lib/convergence/database_connector/sqlite_connector.rb +22 -0
- data/lib/convergence/database_connector.rb +6 -0
- data/lib/convergence/default_parameter/mysql_default_parameter.rb +1 -1
- data/lib/convergence/default_parameter/postgres_default_parameter.rb +57 -0
- data/lib/convergence/default_parameter/sqlite_default_parameter.rb +57 -0
- data/lib/convergence/default_parameter.rb +6 -0
- data/lib/convergence/diff.rb +46 -3
- data/lib/convergence/dsl.rb +18 -3
- data/lib/convergence/dumper/mysql_schema_dumper.rb +37 -9
- data/lib/convergence/dumper/postgres_schema_dumper.rb +193 -0
- data/lib/convergence/dumper/sqlite_schema_dumper.rb +132 -0
- data/lib/convergence/dumper.rb +113 -0
- data/lib/convergence/sql_generator/mysql_generator.rb +32 -3
- data/lib/convergence/sql_generator/postgres_generator.rb +270 -0
- data/lib/convergence/sql_generator/sqlite_generator.rb +192 -0
- data/lib/convergence/table.rb +3 -2
- data/lib/convergence/version.rb +1 -1
- data/spec/config/spec_database.yml +12 -0
- data/spec/convergence/config_spec.rb +75 -0
- data/spec/convergence/diff_spec.rb +291 -44
- data/spec/convergence/dsl_spec.rb +26 -0
- data/spec/convergence/dumper/mysql_schema_dumper_spec.rb +25 -2
- data/spec/convergence/dumper/postgres_schema_dumper_spec.rb +103 -0
- data/spec/convergence/dumper/sqlite_schema_dumper_spec.rb +85 -0
- data/spec/convergence/dumper_spec.rb +110 -16
- data/spec/convergence/foreign_key_spec.rb +33 -0
- data/spec/convergence/index_spec.rb +52 -0
- data/spec/convergence/pretty_diff_spec.rb +85 -0
- data/spec/convergence/table_spec.rb +46 -0
- data/spec/fixtures/add_table_with_enum_set.schema +5 -0
- data/spec/fixtures/change_table_comment_to_paper.schema +2 -0
- data/spec/fixtures/execute_raw_sql.schema +28 -0
- data/spec/fixtures/postgres/add_columns_to_paper.schema +29 -0
- data/spec/fixtures/postgres/add_table.schema +32 -0
- data/spec/fixtures/postgres/change_comment_columns_to_paper.schema +28 -0
- data/spec/fixtures/postgres/change_table_comment_to_paper.schema +28 -0
- data/spec/fixtures/postgres/drop_foreign_key.schema +25 -0
- data/spec/fixtures/postgres/drop_table.schema +19 -0
- data/spec/fixtures/postgres/remove_columns_to_paper.schema +27 -0
- data/spec/fixtures/postgres/rename_column_on_paper.schema +28 -0
- data/spec/fixtures/postgres/rename_table.schema +28 -0
- data/spec/fixtures/postgres_test_db.sql +41 -0
- data/spec/fixtures/rename_column_to_author.schema +26 -0
- data/spec/fixtures/rename_table.schema +26 -0
- data/spec/fixtures/sqlite/add_columns_to_paper.schema +29 -0
- data/spec/fixtures/sqlite/add_table.schema +32 -0
- data/spec/fixtures/sqlite/change_columns_to_paper.schema +28 -0
- data/spec/fixtures/sqlite/drop_foreign_key.schema +25 -0
- data/spec/fixtures/sqlite/drop_table.schema +19 -0
- data/spec/fixtures/sqlite/remove_columns_to_paper.schema +27 -0
- data/spec/fixtures/sqlite/rename_column_on_paper.schema +28 -0
- data/spec/fixtures/sqlite/rename_table.schema +27 -0
- data/spec/fixtures/sqlite_test_db.sql +31 -0
- data/spec/fixtures/test_db.sql +10 -0
- data/spec/integrations/command_diff.rb +35 -0
- data/spec/integrations/command_dryrun.rb +59 -2
- data/spec/integrations/command_export.rb +28 -0
- data/spec/postgres_integrations/command_dryrun.rb +101 -0
- data/spec/spec_helper.rb +13 -0
- data/spec/sqlite_integrations/command_dryrun.rb +92 -0
- metadata +135 -7
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
require 'convergence/dumper'
|
|
2
|
+
require 'convergence/table'
|
|
3
|
+
|
|
4
|
+
class Convergence::Dumper::PostgresSchemaDumper
|
|
5
|
+
# Convergence's DSL type names follow MySQL terminology. Map PostgreSQL's
|
|
6
|
+
# internal type name (udt_name) back to the closest DSL type.
|
|
7
|
+
TYPE_MAPPING = {
|
|
8
|
+
'int2' => :smallint,
|
|
9
|
+
'int4' => :int,
|
|
10
|
+
'int8' => :bigint,
|
|
11
|
+
'varchar' => :varchar,
|
|
12
|
+
'bpchar' => :char,
|
|
13
|
+
'text' => :text,
|
|
14
|
+
'bytea' => :blob,
|
|
15
|
+
'timestamp' => :datetime,
|
|
16
|
+
'timestamptz' => :datetime,
|
|
17
|
+
'date' => :date,
|
|
18
|
+
'time' => :time,
|
|
19
|
+
'numeric' => :decimal,
|
|
20
|
+
'float4' => :float,
|
|
21
|
+
'float8' => :double,
|
|
22
|
+
'json' => :json,
|
|
23
|
+
'jsonb' => :json,
|
|
24
|
+
# Convergence has no native boolean DSL type; store it as tinyint(1), the same
|
|
25
|
+
# representation Convergence::Table#boolean produces for the MySQL adapter.
|
|
26
|
+
'bool' => :tinyint
|
|
27
|
+
}.freeze
|
|
28
|
+
DEFAULT_VALUE_PATTERN = /\A'(?<value>(?:[^']|'')*)'(?:::[\w. ]+)?\z/.freeze
|
|
29
|
+
|
|
30
|
+
def initialize(connector)
|
|
31
|
+
@connector = connector
|
|
32
|
+
@target_database = connector.config.database
|
|
33
|
+
@tables = {}
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def dump
|
|
37
|
+
table_definitions = select_table_definitions
|
|
38
|
+
column_definitions = select_column_definitions.group_by { |r| r['table_name'] }
|
|
39
|
+
index_definitions = select_index_definitions.group_by { |r| r['table_name'] }
|
|
40
|
+
foreign_key_definitions = select_foreign_key_definitions.group_by { |r| r['table_name'] }
|
|
41
|
+
table_definitions.map { |r| r['table_name'] }.each do |table_name|
|
|
42
|
+
table = Convergence::Table.new(table_name)
|
|
43
|
+
parse_table_options(table, table_definitions.find { |r| r['table_name'] == table_name })
|
|
44
|
+
parse_columns(table, column_definitions[table_name])
|
|
45
|
+
parse_indexes(table, index_definitions[table_name])
|
|
46
|
+
parse_foreign_keys(table, foreign_key_definitions[table_name])
|
|
47
|
+
@tables[table_name] = table
|
|
48
|
+
end
|
|
49
|
+
@tables
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def pg
|
|
55
|
+
@connector.schema_client
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def select_table_definitions
|
|
59
|
+
pg.query("
|
|
60
|
+
SELECT
|
|
61
|
+
c.relname AS table_name,
|
|
62
|
+
obj_description(c.oid) AS table_comment
|
|
63
|
+
FROM pg_class c
|
|
64
|
+
INNER JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
65
|
+
WHERE c.relkind = 'r' AND n.nspname = 'public'
|
|
66
|
+
ORDER BY c.relname
|
|
67
|
+
").to_a
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def select_column_definitions
|
|
71
|
+
pg.query("
|
|
72
|
+
SELECT
|
|
73
|
+
c.table_name, c.column_name, c.ordinal_position, c.udt_name, c.is_nullable, c.column_default,
|
|
74
|
+
c.character_maximum_length, c.numeric_precision, c.numeric_scale, c.is_identity,
|
|
75
|
+
col_description(pgc.oid, c.ordinal_position) AS column_comment
|
|
76
|
+
FROM information_schema.columns c
|
|
77
|
+
INNER JOIN pg_class pgc ON pgc.relname = c.table_name
|
|
78
|
+
INNER JOIN pg_namespace pgn ON pgn.oid = pgc.relnamespace AND pgn.nspname = c.table_schema
|
|
79
|
+
WHERE c.table_schema = 'public'
|
|
80
|
+
ORDER BY c.table_name, c.ordinal_position
|
|
81
|
+
").to_a
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def select_index_definitions
|
|
85
|
+
pg.query("
|
|
86
|
+
SELECT
|
|
87
|
+
t.relname AS table_name,
|
|
88
|
+
i.relname AS index_name,
|
|
89
|
+
a.attname AS column_name,
|
|
90
|
+
ix.indisunique AS is_unique,
|
|
91
|
+
ix.indisprimary AS is_primary,
|
|
92
|
+
array_position(ix.indkey, a.attnum) AS seq_in_index
|
|
93
|
+
FROM pg_index ix
|
|
94
|
+
INNER JOIN pg_class t ON t.oid = ix.indrelid
|
|
95
|
+
INNER JOIN pg_class i ON i.oid = ix.indexrelid
|
|
96
|
+
INNER JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(ix.indkey)
|
|
97
|
+
INNER JOIN pg_namespace n ON n.oid = t.relnamespace
|
|
98
|
+
WHERE n.nspname = 'public'
|
|
99
|
+
ORDER BY t.relname, i.relname, seq_in_index
|
|
100
|
+
").to_a
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def select_foreign_key_definitions
|
|
104
|
+
pg.query("
|
|
105
|
+
SELECT
|
|
106
|
+
tc.table_name,
|
|
107
|
+
tc.constraint_name,
|
|
108
|
+
kcu.column_name,
|
|
109
|
+
ccu.table_name AS referenced_table_name,
|
|
110
|
+
ccu.column_name AS referenced_column_name
|
|
111
|
+
FROM information_schema.table_constraints tc
|
|
112
|
+
INNER JOIN information_schema.key_column_usage kcu
|
|
113
|
+
ON kcu.constraint_name = tc.constraint_name AND kcu.table_schema = tc.table_schema
|
|
114
|
+
INNER JOIN information_schema.constraint_column_usage ccu
|
|
115
|
+
ON ccu.constraint_name = tc.constraint_name AND ccu.table_schema = tc.table_schema
|
|
116
|
+
WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_schema = 'public'
|
|
117
|
+
").to_a
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def parse_table_options(table, table_option)
|
|
121
|
+
option = {}
|
|
122
|
+
option.merge!(comment: table_option['table_comment']) unless table_option['table_comment'].nil?
|
|
123
|
+
table.table_options = option
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def parse_columns(table, columns)
|
|
127
|
+
return if columns.nil?
|
|
128
|
+
columns.each do |column|
|
|
129
|
+
data_type, column_name, options = parse_column(column)
|
|
130
|
+
table.send(data_type, column_name, options)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def parse_column(column)
|
|
135
|
+
data_type = TYPE_MAPPING[column['udt_name']] || column['udt_name'].to_sym
|
|
136
|
+
column_name = column['column_name']
|
|
137
|
+
options = { null: column['is_nullable'] == 'YES' }
|
|
138
|
+
if column['is_identity'] == 'YES'
|
|
139
|
+
options.merge!(extra: 'auto_increment')
|
|
140
|
+
elsif !column['column_default'].nil?
|
|
141
|
+
options.merge!(default: column_default_expression(data_type, column['column_default']))
|
|
142
|
+
end
|
|
143
|
+
case data_type
|
|
144
|
+
when :decimal
|
|
145
|
+
options.merge!(precision: column['numeric_precision'], scale: column['numeric_scale'])
|
|
146
|
+
when :varchar, :char
|
|
147
|
+
options.merge!(limit: column['character_maximum_length']) unless column['character_maximum_length'].nil?
|
|
148
|
+
when :tinyint
|
|
149
|
+
options.merge!(limit: '1') if column['udt_name'] == 'bool'
|
|
150
|
+
end
|
|
151
|
+
options.merge!(comment: column['column_comment']) unless column['column_comment'].nil?
|
|
152
|
+
[data_type, column_name, options]
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def column_default_expression(data_type, value)
|
|
156
|
+
return -> { 'CURRENT_TIMESTAMP' } if [:datetime].include?(data_type) && value.start_with?('CURRENT_TIMESTAMP')
|
|
157
|
+
return (value == 'true' ? '1' : '0') if data_type == :tinyint && %w(true false).include?(value)
|
|
158
|
+
match = DEFAULT_VALUE_PATTERN.match(value)
|
|
159
|
+
return match[:value].gsub("''", "'") if match
|
|
160
|
+
value
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def parse_indexes(table, table_indexes)
|
|
164
|
+
return if table_indexes.nil?
|
|
165
|
+
table_indexes.group_by { |r| r['index_name'] }.each do |index_name, indexes|
|
|
166
|
+
columns = indexes.sort_by { |r| r['seq_in_index'].to_i }.map { |v| v['column_name'] }
|
|
167
|
+
if indexes.first['is_primary'] == 't'
|
|
168
|
+
columns.each do |column|
|
|
169
|
+
options = { primary_key: true }.merge(table.columns[column].options)
|
|
170
|
+
table.columns[column].options = options
|
|
171
|
+
end
|
|
172
|
+
else
|
|
173
|
+
options = { name: index_name, unique: indexes.first['is_unique'] == 't' }
|
|
174
|
+
table.index(columns, options)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def parse_foreign_keys(table, foreign_keys)
|
|
180
|
+
return if foreign_keys.nil?
|
|
181
|
+
foreign_keys.group_by { |r| r['constraint_name'] }.each do |constraint_name, rows|
|
|
182
|
+
columns = rows.map { |r| r['column_name'] }
|
|
183
|
+
to_table = rows.first['referenced_table_name']
|
|
184
|
+
to_columns = rows.map { |r| r['referenced_column_name'] }
|
|
185
|
+
options = {
|
|
186
|
+
reference: to_table,
|
|
187
|
+
reference_column: to_columns,
|
|
188
|
+
name: constraint_name
|
|
189
|
+
}
|
|
190
|
+
table.foreign_key(columns, options)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
require 'convergence/dumper'
|
|
2
|
+
require 'convergence/table'
|
|
3
|
+
|
|
4
|
+
class Convergence::Dumper::SqliteSchemaDumper
|
|
5
|
+
DEFAULT_VALUE_PATTERN = /\A'(?<value>(?:[^']|'')*)'\z/.freeze
|
|
6
|
+
# SQLite has no CONSTRAINT-name introspection pragma; the name is only
|
|
7
|
+
# preserved in the original CREATE TABLE text.
|
|
8
|
+
FOREIGN_KEY_NAME_PATTERN = /CONSTRAINT\s+"?(?<name>\w+)"?\s+FOREIGN KEY\s*\(\s*"?(?<column>\w+)"?\s*\)/i.freeze
|
|
9
|
+
|
|
10
|
+
def initialize(connector)
|
|
11
|
+
@connector = connector
|
|
12
|
+
@tables = {}
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def dump
|
|
16
|
+
table_names.each do |table_name|
|
|
17
|
+
table = Convergence::Table.new(table_name)
|
|
18
|
+
parse_columns(table, column_definitions(table_name))
|
|
19
|
+
parse_indexes(table, index_definitions(table_name))
|
|
20
|
+
parse_foreign_keys(table, table_name)
|
|
21
|
+
@tables[table_name] = table
|
|
22
|
+
end
|
|
23
|
+
@tables
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def db
|
|
29
|
+
@connector.schema_client
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def table_names
|
|
33
|
+
db.execute("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").map { |r| r['name'] }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def table_sql(table_name)
|
|
37
|
+
row = db.execute('SELECT sql FROM sqlite_master WHERE type = ? AND name = ?', ['table', table_name]).first
|
|
38
|
+
row ? row['sql'].to_s : ''
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def column_definitions(table_name)
|
|
42
|
+
db.execute(%(PRAGMA table_info("#{table_name}")))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def index_definitions(table_name)
|
|
46
|
+
# origin 'c' is an explicitly created index (CREATE [UNIQUE] INDEX). 'pk' and
|
|
47
|
+
# 'u' are auto-generated by a PRIMARY KEY/UNIQUE column constraint, already
|
|
48
|
+
# represented on the column itself, so they are excluded here.
|
|
49
|
+
db.execute(%(PRAGMA index_list("#{table_name}"))).select { |r| r['origin'] == 'c' }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def index_column_definitions(index_name)
|
|
53
|
+
db.execute(%(PRAGMA index_info("#{index_name}")))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def foreign_key_definitions(table_name)
|
|
57
|
+
db.execute(%(PRAGMA foreign_key_list("#{table_name}")))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def parse_columns(table, columns)
|
|
61
|
+
pk_columns = columns.select { |c| c['pk'].to_i > 0 }
|
|
62
|
+
single_integer_pk_cid = pk_columns.size == 1 ? pk_columns.first['cid'] : nil
|
|
63
|
+
columns.each do |column|
|
|
64
|
+
data_type, column_name, options = parse_column(column, single_integer_pk_cid)
|
|
65
|
+
table.send(data_type, column_name, options)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def parse_column(column, single_integer_pk_cid)
|
|
70
|
+
raw_type = column['type'].to_s
|
|
71
|
+
type_name = raw_type[/\A(\w+)/, 1].to_s
|
|
72
|
+
column_name = column['name']
|
|
73
|
+
is_auto_increment_pk = !single_integer_pk_cid.nil? && column['cid'] == single_integer_pk_cid && type_name.upcase == 'INTEGER'
|
|
74
|
+
data_type = if is_auto_increment_pk
|
|
75
|
+
:int
|
|
76
|
+
elsif type_name.empty?
|
|
77
|
+
:text
|
|
78
|
+
else
|
|
79
|
+
type_name.downcase.to_sym
|
|
80
|
+
end
|
|
81
|
+
options = { null: column['notnull'].to_i.zero? }
|
|
82
|
+
if is_auto_increment_pk
|
|
83
|
+
# SQLite's `notnull` pragma column doesn't reflect the implicit NOT NULL
|
|
84
|
+
# of an INTEGER PRIMARY KEY (rowid alias), so it must be forced here.
|
|
85
|
+
options.merge!(primary_key: true, extra: 'auto_increment', null: false)
|
|
86
|
+
else
|
|
87
|
+
options.merge!(primary_key: true) if column['pk'].to_i > 0
|
|
88
|
+
options.merge!(default: column_default_expression(data_type, column['dflt_value'])) unless column['dflt_value'].nil?
|
|
89
|
+
end
|
|
90
|
+
case data_type
|
|
91
|
+
when :decimal
|
|
92
|
+
precision, scale = raw_type.scan(/\d+/)
|
|
93
|
+
options.merge!(precision: precision, scale: scale) if precision
|
|
94
|
+
else
|
|
95
|
+
limit = raw_type[/\((\d+)\)/, 1]
|
|
96
|
+
options.merge!(limit: limit) unless limit.nil?
|
|
97
|
+
end
|
|
98
|
+
[data_type, column_name, options]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def column_default_expression(data_type, value)
|
|
102
|
+
return -> { 'CURRENT_TIMESTAMP' } if data_type == :datetime && value.to_s.upcase == 'CURRENT_TIMESTAMP'
|
|
103
|
+
match = DEFAULT_VALUE_PATTERN.match(value)
|
|
104
|
+
return match[:value].gsub("''", "'") if match
|
|
105
|
+
value
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def parse_indexes(table, indexes)
|
|
109
|
+
indexes.each do |index|
|
|
110
|
+
columns = index_column_definitions(index['name']).sort_by { |c| c['seqno'].to_i }.map { |c| c['name'] }
|
|
111
|
+
options = { name: index['name'], unique: index['unique'].to_i == 1 }
|
|
112
|
+
table.index(columns, options)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def parse_foreign_keys(table, table_name)
|
|
117
|
+
foreign_key_names = table_sql(table_name).scan(FOREIGN_KEY_NAME_PATTERN).to_h { |name, column| [column, name] }
|
|
118
|
+
foreign_key_definitions(table_name).group_by { |r| r['id'] }.each do |_id, rows|
|
|
119
|
+
sorted_rows = rows.sort_by { |r| r['seq'].to_i }
|
|
120
|
+
columns = sorted_rows.map { |r| r['from'] }
|
|
121
|
+
to_table = sorted_rows.first['table']
|
|
122
|
+
to_columns = sorted_rows.map { |r| r['to'] }
|
|
123
|
+
key_name = foreign_key_names[columns.first] || "fk_#{table.table_name}_#{columns.join('_')}"
|
|
124
|
+
options = {
|
|
125
|
+
reference: to_table,
|
|
126
|
+
reference_column: to_columns,
|
|
127
|
+
name: key_name
|
|
128
|
+
}
|
|
129
|
+
table.foreign_key(columns, options)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
data/lib/convergence/dumper.rb
CHANGED
|
@@ -1,4 +1,39 @@
|
|
|
1
1
|
class Convergence::Dumper
|
|
2
|
+
# Convergence's DSL type names follow MySQL terminology; map them to the
|
|
3
|
+
# closest ActiveRecord migration column type.
|
|
4
|
+
RAILS_TYPE_MAPPING = {
|
|
5
|
+
tinyint: :integer,
|
|
6
|
+
smallint: :integer,
|
|
7
|
+
mediumint: :integer,
|
|
8
|
+
int: :integer,
|
|
9
|
+
bigint: :bigint,
|
|
10
|
+
float: :float,
|
|
11
|
+
double: :float,
|
|
12
|
+
decimal: :decimal,
|
|
13
|
+
char: :string,
|
|
14
|
+
varchar: :string,
|
|
15
|
+
tinyblob: :binary,
|
|
16
|
+
blob: :binary,
|
|
17
|
+
mediumblob: :binary,
|
|
18
|
+
longblob: :binary,
|
|
19
|
+
tinytext: :text,
|
|
20
|
+
text: :text,
|
|
21
|
+
mediumtext: :text,
|
|
22
|
+
longtext: :text,
|
|
23
|
+
date: :date,
|
|
24
|
+
time: :time,
|
|
25
|
+
datetime: :datetime,
|
|
26
|
+
timestamp: :datetime,
|
|
27
|
+
year: :integer,
|
|
28
|
+
json: :json,
|
|
29
|
+
# ActiveRecord has no native enum/set column type; fall back to string.
|
|
30
|
+
enum: :string,
|
|
31
|
+
set: :string
|
|
32
|
+
}.freeze
|
|
33
|
+
# Options that only make sense for convergence's own SQL generators, with no
|
|
34
|
+
# equivalent in an ActiveRecord migration's create_table DSL.
|
|
35
|
+
RAILS_UNSUPPORTED_COLUMN_OPTIONS = %i[character_set collate extra after values].freeze
|
|
36
|
+
|
|
2
37
|
def dump_dsl(tables)
|
|
3
38
|
tables.map do |_, table|
|
|
4
39
|
dump_table_dsl(table)
|
|
@@ -24,8 +59,84 @@ class Convergence::Dumper
|
|
|
24
59
|
dsl
|
|
25
60
|
end
|
|
26
61
|
|
|
62
|
+
def dump_rails_migration(tables, class_name, rails_version: '7.0')
|
|
63
|
+
dsl = "class #{class_name} < ActiveRecord::Migration[#{rails_version}]\n"
|
|
64
|
+
dsl += " def change\n"
|
|
65
|
+
dsl += tables.map { |_, table| dump_table_rails_migration(table) }.join("\n\n")
|
|
66
|
+
dsl += "\n end\n"
|
|
67
|
+
dsl += 'end'
|
|
68
|
+
dsl
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def dump_table_rails_migration(table)
|
|
72
|
+
table_argument = [table.table_name.to_sym.inspect]
|
|
73
|
+
dsl = " create_table #{table_argument.join(', ')} do |t|\n"
|
|
74
|
+
body_lines = rails_column_lines(table.columns)
|
|
75
|
+
body_lines += table.indexes.map { |_, index| dump_rails_index(index) }
|
|
76
|
+
body_lines += table.foreign_keys.map { |_, key| dump_rails_foreign_key(key) }
|
|
77
|
+
dsl += body_lines.map { |line| " #{line}" }.join("\n")
|
|
78
|
+
dsl += "\n end"
|
|
79
|
+
dsl
|
|
80
|
+
end
|
|
81
|
+
|
|
27
82
|
private
|
|
28
83
|
|
|
84
|
+
# ActiveRecord's create_table implicitly adds an auto-incrementing `id`
|
|
85
|
+
# primary key, so an explicit column matching that shape is redundant.
|
|
86
|
+
def default_primary_key_column?(column_name, column)
|
|
87
|
+
column_name == 'id' &&
|
|
88
|
+
[:int, :bigint].include?(column.type) &&
|
|
89
|
+
column.options[:primary_key] &&
|
|
90
|
+
column.options[:extra].to_s.upcase.include?('AUTO_INCREMENT')
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def rails_timestamp_column?(column)
|
|
94
|
+
column.type == :datetime && Convergence::Dumper::RAILS_UNSUPPORTED_COLUMN_OPTIONS.none? { |k| column.options.key?(k) }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def rails_column_lines(columns)
|
|
98
|
+
columns = columns.reject { |name, column| default_primary_key_column?(name, column) }
|
|
99
|
+
if %w[created_at updated_at].all? { |name| columns.key?(name) && rails_timestamp_column?(columns[name]) } &&
|
|
100
|
+
columns['created_at'].options[:null] == columns['updated_at'].options[:null]
|
|
101
|
+
remaining = columns.reject { |name, _| %w[created_at updated_at].include?(name) }
|
|
102
|
+
lines = remaining.map { |_, column| dump_rails_column(column) }
|
|
103
|
+
lines << (columns['created_at'].options[:null] ? 't.timestamps' : 't.timestamps null: false')
|
|
104
|
+
lines
|
|
105
|
+
else
|
|
106
|
+
columns.map { |_, column| dump_rails_column(column) }
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def dump_rails_column(column)
|
|
111
|
+
argument = [column.column_name.to_sym.inspect]
|
|
112
|
+
options = column.options.reject { |k, _v| RAILS_UNSUPPORTED_COLUMN_OPTIONS.include?(k) }
|
|
113
|
+
if column.type == :tinyint && column.options[:limit].to_s == '1'
|
|
114
|
+
column_type = :boolean
|
|
115
|
+
options = options.reject { |k, _v| k == :limit }
|
|
116
|
+
options = options.merge(default: false) if options[:default].to_s == '0'
|
|
117
|
+
options = options.merge(default: true) if options[:default].to_s == '1'
|
|
118
|
+
else
|
|
119
|
+
column_type = RAILS_TYPE_MAPPING[column.type] || column.type
|
|
120
|
+
end
|
|
121
|
+
argument << options.map { |k, v| key_value_text(k, v) }
|
|
122
|
+
"t.#{column_type} #{argument.flatten.join(', ')}"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def dump_rails_index(index)
|
|
126
|
+
argument = [single_or_multiple_symbol(index.index_columns)]
|
|
127
|
+
options = index.options.select { |k, _v| %i[name unique].include?(k) }
|
|
128
|
+
argument << options.map { |k, v| key_value_text(k, v) }
|
|
129
|
+
"t.index #{argument.flatten.join(', ')}"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def dump_rails_foreign_key(foreign_key)
|
|
133
|
+
argument = [foreign_key.to_table.to_sym.inspect]
|
|
134
|
+
argument << "column: #{single_or_multiple_symbol(foreign_key.from_columns)}"
|
|
135
|
+
argument << "primary_key: #{single_or_multiple_symbol(foreign_key.to_columns)}" unless foreign_key.to_columns == ['id']
|
|
136
|
+
argument << key_value_text('name', foreign_key.key_name)
|
|
137
|
+
"t.foreign_key #{argument.join(', ')}"
|
|
138
|
+
end
|
|
139
|
+
|
|
29
140
|
def dump_column(column)
|
|
30
141
|
argument = [column.column_name.to_sym.inspect]
|
|
31
142
|
case [column.type, column.options[:limit]]
|
|
@@ -72,6 +183,8 @@ class Convergence::Dumper
|
|
|
72
183
|
def key_value_text(k, v)
|
|
73
184
|
value = if v.to_s == 'true' || v.to_s == 'false' || v.to_s =~ /^\d+$/
|
|
74
185
|
v
|
|
186
|
+
elsif v.is_a?(Proc)
|
|
187
|
+
%(-> { #{v.call.inspect} })
|
|
75
188
|
else
|
|
76
189
|
%(#{v.inspect})
|
|
77
190
|
end
|
|
@@ -13,11 +13,12 @@ class SQLGenerator::MysqlGenerator < SQLGenerator
|
|
|
13
13
|
|
|
14
14
|
attr_reader :original_table
|
|
15
15
|
|
|
16
|
-
def generate(to_table, delta, original_table)
|
|
16
|
+
def generate(to_table, delta, original_table, safe_migration: false)
|
|
17
17
|
@original_table = original_table
|
|
18
18
|
sqls = []
|
|
19
|
+
sqls << rename_table_sqls(delta)
|
|
19
20
|
sqls << change_table_sql(to_table, delta)
|
|
20
|
-
sqls << drop_table_sqls(delta)
|
|
21
|
+
sqls << (safe_migration ? [] : drop_table_sqls(delta))
|
|
21
22
|
sqls << create_table_sqls(delta)
|
|
22
23
|
sqls.reject!(&:empty?)
|
|
23
24
|
sqls.join("\n")
|
|
@@ -25,6 +26,12 @@ class SQLGenerator::MysqlGenerator < SQLGenerator
|
|
|
25
26
|
|
|
26
27
|
private
|
|
27
28
|
|
|
29
|
+
def rename_table_sqls(delta)
|
|
30
|
+
results = delta[:rename_table].map { |old_name, new_name| %(ALTER TABLE `#{old_name}` RENAME TO `#{new_name}`;) }
|
|
31
|
+
results << '' unless results.empty?
|
|
32
|
+
results
|
|
33
|
+
end
|
|
34
|
+
|
|
28
35
|
# FIXME: multiple pk change not supported yet
|
|
29
36
|
def change_table_sql(to_table, delta)
|
|
30
37
|
change_table = delta[:change_table]
|
|
@@ -36,6 +43,9 @@ class SQLGenerator::MysqlGenerator < SQLGenerator
|
|
|
36
43
|
table_delta[:remove_index].each do |index_name, _index|
|
|
37
44
|
results << alter_remove_index_sql(table_name, index_name)
|
|
38
45
|
end
|
|
46
|
+
table_delta[:rename_column].each do |old_name, new_name|
|
|
47
|
+
results << alter_rename_column_sql(table_name, old_name, new_name)
|
|
48
|
+
end
|
|
39
49
|
unless table_delta[:remove_column].empty?
|
|
40
50
|
results << alter_remove_columns_sql(table_name, table_delta[:remove_column].values)
|
|
41
51
|
end
|
|
@@ -73,6 +83,10 @@ class SQLGenerator::MysqlGenerator < SQLGenerator
|
|
|
73
83
|
sql
|
|
74
84
|
end
|
|
75
85
|
|
|
86
|
+
def alter_rename_column_sql(table_name, old_name, new_name)
|
|
87
|
+
%(ALTER TABLE `#{table_name}` RENAME COLUMN `#{old_name}` TO `#{new_name}`;)
|
|
88
|
+
end
|
|
89
|
+
|
|
76
90
|
def alter_change_column_sql(table_name, column_name, change_column_option, to_table)
|
|
77
91
|
column = to_table[table_name].columns[column_name]
|
|
78
92
|
column.options.merge!(after: change_column_option[:after]) unless change_column_option[:after].nil?
|
|
@@ -166,6 +180,9 @@ DROP TABLE `#{table_name}`;
|
|
|
166
180
|
def create_column_sql(column, output_primary_key: false, output_auto_increment: true)
|
|
167
181
|
sql = "`#{column.column_name}`"
|
|
168
182
|
sql += " #{column.type}"
|
|
183
|
+
if [:enum, :set].include?(column.type)
|
|
184
|
+
sql += "(#{quote_enum_or_set_values(column.options[:values])})"
|
|
185
|
+
end
|
|
169
186
|
sql += "(#{column.options[:limit]})" unless column.options[:limit].nil?
|
|
170
187
|
if column.options[:precision] && column.options[:scale]
|
|
171
188
|
sql += "(#{column.options[:precision]}, #{column.options[:scale]})"
|
|
@@ -188,7 +205,7 @@ DROP TABLE `#{table_name}`;
|
|
|
188
205
|
sql += ' PRIMARY KEY'
|
|
189
206
|
end
|
|
190
207
|
if column.options[:default]
|
|
191
|
-
sql += " DEFAULT
|
|
208
|
+
sql += " DEFAULT #{quote_default_expression(column.options[:default])}"
|
|
192
209
|
end
|
|
193
210
|
if column.options[:comment]
|
|
194
211
|
sql += " COMMENT '#{column.options[:comment]}'"
|
|
@@ -244,4 +261,16 @@ DROP TABLE `#{table_name}`;
|
|
|
244
261
|
end
|
|
245
262
|
end.join(' ')
|
|
246
263
|
end
|
|
264
|
+
|
|
265
|
+
def quote_enum_or_set_values(values)
|
|
266
|
+
Array(values).map { |v| "'#{v.to_s.gsub("'", "''")}'" }.join(',')
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def quote_default_expression(value)
|
|
270
|
+
if value.is_a?(Proc)
|
|
271
|
+
value.call
|
|
272
|
+
else
|
|
273
|
+
%('#{value}')
|
|
274
|
+
end
|
|
275
|
+
end
|
|
247
276
|
end
|