convergence 1.0.6 → 1.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 +4 -4
- data/.github/workflows/ruby.yml +35 -4
- data/.gitignore +1 -0
- data/CHANGELOG.md +46 -0
- data/CONTRIBUTING.md +80 -0
- data/Gemfile.lock +23 -8
- data/README.md +131 -1
- data/Rakefile +45 -2
- data/convergence.gemspec +3 -1
- data/lib/convergence/cli.rb +10 -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 +9 -2
- 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 +18 -3
- data/lib/convergence/sql_generator/postgres_generator.rb +256 -0
- data/lib/convergence/sql_generator/sqlite_generator.rb +178 -0
- 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 +125 -45
- 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 +23 -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_test_db.sql +41 -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_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 +37 -2
- data/spec/integrations/command_export.rb +28 -0
- data/spec/postgres_integrations/command_dryrun.rb +79 -0
- data/spec/spec_helper.rb +13 -0
- data/spec/sqlite_integrations/command_dryrun.rb +70 -0
- metadata +96 -8
|
@@ -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,11 @@ 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
19
|
sqls << change_table_sql(to_table, delta)
|
|
20
|
-
sqls << drop_table_sqls(delta)
|
|
20
|
+
sqls << (safe_migration ? [] : drop_table_sqls(delta))
|
|
21
21
|
sqls << create_table_sqls(delta)
|
|
22
22
|
sqls.reject!(&:empty?)
|
|
23
23
|
sqls.join("\n")
|
|
@@ -166,6 +166,9 @@ DROP TABLE `#{table_name}`;
|
|
|
166
166
|
def create_column_sql(column, output_primary_key: false, output_auto_increment: true)
|
|
167
167
|
sql = "`#{column.column_name}`"
|
|
168
168
|
sql += " #{column.type}"
|
|
169
|
+
if [:enum, :set].include?(column.type)
|
|
170
|
+
sql += "(#{quote_enum_or_set_values(column.options[:values])})"
|
|
171
|
+
end
|
|
169
172
|
sql += "(#{column.options[:limit]})" unless column.options[:limit].nil?
|
|
170
173
|
if column.options[:precision] && column.options[:scale]
|
|
171
174
|
sql += "(#{column.options[:precision]}, #{column.options[:scale]})"
|
|
@@ -188,7 +191,7 @@ DROP TABLE `#{table_name}`;
|
|
|
188
191
|
sql += ' PRIMARY KEY'
|
|
189
192
|
end
|
|
190
193
|
if column.options[:default]
|
|
191
|
-
sql += " DEFAULT
|
|
194
|
+
sql += " DEFAULT #{quote_default_expression(column.options[:default])}"
|
|
192
195
|
end
|
|
193
196
|
if column.options[:comment]
|
|
194
197
|
sql += " COMMENT '#{column.options[:comment]}'"
|
|
@@ -244,4 +247,16 @@ DROP TABLE `#{table_name}`;
|
|
|
244
247
|
end
|
|
245
248
|
end.join(' ')
|
|
246
249
|
end
|
|
250
|
+
|
|
251
|
+
def quote_enum_or_set_values(values)
|
|
252
|
+
Array(values).map { |v| "'#{v.to_s.gsub("'", "''")}'" }.join(',')
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def quote_default_expression(value)
|
|
256
|
+
if value.is_a?(Proc)
|
|
257
|
+
value.call
|
|
258
|
+
else
|
|
259
|
+
%('#{value}')
|
|
260
|
+
end
|
|
261
|
+
end
|
|
247
262
|
end
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
require 'convergence/sql_generator'
|
|
2
|
+
|
|
3
|
+
class SQLGenerator::PostgresGenerator < SQLGenerator
|
|
4
|
+
# Convergence's DSL type names follow MySQL terminology (kept for cross-adapter DSL
|
|
5
|
+
# compatibility). Map them to their closest native PostgreSQL type.
|
|
6
|
+
TYPE_MAPPING = {
|
|
7
|
+
tinyint: 'smallint',
|
|
8
|
+
smallint: 'smallint',
|
|
9
|
+
mediumint: 'integer',
|
|
10
|
+
int: 'integer',
|
|
11
|
+
bigint: 'bigint',
|
|
12
|
+
float: 'real',
|
|
13
|
+
double: 'double precision',
|
|
14
|
+
decimal: 'decimal',
|
|
15
|
+
char: 'char',
|
|
16
|
+
varchar: 'varchar',
|
|
17
|
+
tinyblob: 'bytea',
|
|
18
|
+
blob: 'bytea',
|
|
19
|
+
mediumblob: 'bytea',
|
|
20
|
+
longblob: 'bytea',
|
|
21
|
+
tinytext: 'text',
|
|
22
|
+
text: 'text',
|
|
23
|
+
mediumtext: 'text',
|
|
24
|
+
longtext: 'text',
|
|
25
|
+
date: 'date',
|
|
26
|
+
time: 'time',
|
|
27
|
+
datetime: 'timestamp',
|
|
28
|
+
timestamp: 'timestamp',
|
|
29
|
+
year: 'integer',
|
|
30
|
+
json: 'jsonb'
|
|
31
|
+
}.freeze
|
|
32
|
+
TYPES_WITH_LIMIT = [:char, :varchar].freeze
|
|
33
|
+
UNSUPPORTED_TYPES = [:enum, :set].freeze
|
|
34
|
+
|
|
35
|
+
attr_reader :original_table
|
|
36
|
+
|
|
37
|
+
def generate(to_table, delta, original_table, safe_migration: false)
|
|
38
|
+
@original_table = original_table
|
|
39
|
+
sqls = []
|
|
40
|
+
sqls << change_table_sql(to_table, delta)
|
|
41
|
+
sqls << (safe_migration ? [] : drop_table_sqls(delta))
|
|
42
|
+
sqls << create_table_sqls(delta)
|
|
43
|
+
sqls.reject!(&:empty?)
|
|
44
|
+
sqls.join("\n")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
# FIXME: multiple pk change not supported yet
|
|
50
|
+
def change_table_sql(to_table, delta)
|
|
51
|
+
change_table = delta[:change_table]
|
|
52
|
+
results = []
|
|
53
|
+
change_table.each do |table_name, table_delta|
|
|
54
|
+
unless table_delta[:remove_foreign_key].empty?
|
|
55
|
+
results << alter_remove_foreign_keys_sql(table_name, table_delta[:remove_foreign_key].keys)
|
|
56
|
+
end
|
|
57
|
+
table_delta[:remove_index].each do |index_name, _index|
|
|
58
|
+
results << alter_remove_index_sql(index_name)
|
|
59
|
+
end
|
|
60
|
+
unless table_delta[:remove_column].empty?
|
|
61
|
+
results << alter_remove_columns_sql(table_name, table_delta[:remove_column].values)
|
|
62
|
+
end
|
|
63
|
+
unless table_delta[:add_column].empty?
|
|
64
|
+
results << alter_add_columns_sql(table_name, table_delta[:add_column].values)
|
|
65
|
+
end
|
|
66
|
+
table_delta[:change_column].each do |column_name, column|
|
|
67
|
+
results << alter_change_column_sql(table_name, column_name, column, to_table)
|
|
68
|
+
end
|
|
69
|
+
table_delta[:add_index].each do |_index_name, index|
|
|
70
|
+
results << alter_add_index_sql(table_name, index)
|
|
71
|
+
end
|
|
72
|
+
unless table_delta[:add_foreign_key].empty?
|
|
73
|
+
results << alter_add_foreign_keys_sql(table_name, table_delta[:add_foreign_key].values)
|
|
74
|
+
end
|
|
75
|
+
unless table_delta[:change_table_option].empty?
|
|
76
|
+
results << alter_change_table_sql(table_name, table_delta[:change_table_option])
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
results << '' unless results.empty?
|
|
80
|
+
results
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def alter_add_columns_sql(table_name, columns)
|
|
84
|
+
add_column_sqls = columns.map { |column| %(ALTER TABLE "#{table_name}" ADD COLUMN #{create_column_sql(column, output_primary_key: true)};) }
|
|
85
|
+
comment_sqls = columns.map { |column| comment_on_column_sql(table_name, column) }.compact
|
|
86
|
+
(add_column_sqls + comment_sqls).join("\n")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def alter_remove_columns_sql(table_name, columns)
|
|
90
|
+
columns.map { |column| %(ALTER TABLE "#{table_name}" DROP COLUMN "#{column.column_name}";) }.join("\n")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def alter_change_column_sql(table_name, column_name, change_column_option, to_table)
|
|
94
|
+
column = to_table[table_name].columns[column_name]
|
|
95
|
+
column.options.merge!(after: change_column_option[:after]) unless change_column_option[:after].nil?
|
|
96
|
+
sqls = []
|
|
97
|
+
sqls << %(ALTER TABLE "#{table_name}" ALTER COLUMN "#{column_name}" TYPE #{postgres_column_type(column)} USING "#{column_name}"::#{postgres_column_type(column)};)
|
|
98
|
+
if column.options[:null]
|
|
99
|
+
sqls << %(ALTER TABLE "#{table_name}" ALTER COLUMN "#{column_name}" DROP NOT NULL;)
|
|
100
|
+
else
|
|
101
|
+
sqls << %(ALTER TABLE "#{table_name}" ALTER COLUMN "#{column_name}" SET NOT NULL;)
|
|
102
|
+
end
|
|
103
|
+
if column.options[:default]
|
|
104
|
+
sqls << %(ALTER TABLE "#{table_name}" ALTER COLUMN "#{column_name}" SET DEFAULT #{quote_default_expression(column.options[:default])};)
|
|
105
|
+
else
|
|
106
|
+
sqls << %(ALTER TABLE "#{table_name}" ALTER COLUMN "#{column_name}" DROP DEFAULT;)
|
|
107
|
+
end
|
|
108
|
+
comment_sql = comment_on_column_sql(table_name, column)
|
|
109
|
+
sqls << comment_sql unless comment_sql.nil?
|
|
110
|
+
sqls.join("\n")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def alter_change_table_sql(table_name, change_table_option)
|
|
114
|
+
return '' unless change_table_option.key?(:comment)
|
|
115
|
+
%(COMMENT ON TABLE "#{table_name}" IS '#{escape_quote(change_table_option[:comment])}';)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def alter_remove_index_sql(index_name)
|
|
119
|
+
%(DROP INDEX "#{index_name}";)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def alter_add_index_sql(table_name, index)
|
|
123
|
+
sql = 'CREATE'
|
|
124
|
+
sql += ' UNIQUE' if index.options[:unique]
|
|
125
|
+
sql += %( INDEX "#{index.index_name}" ON "#{table_name}" (#{quoted_index_columns(index).join(',')});)
|
|
126
|
+
sql
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def alter_remove_foreign_keys_sql(table_name, index_names)
|
|
130
|
+
index_names.map { |index_name| %(ALTER TABLE "#{table_name}" DROP CONSTRAINT "#{index_name}";) }.join("\n")
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def alter_add_foreign_keys_sql(table_name, foreign_keys)
|
|
134
|
+
foreign_keys.map { |foreign_key| %(ALTER TABLE "#{table_name}" ADD #{foreign_key_constraint_sql(foreign_key)};) }.join("\n")
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def foreign_key_constraint_sql(foreign_key)
|
|
138
|
+
sql = %(CONSTRAINT "#{foreign_key.key_name}" FOREIGN KEY )
|
|
139
|
+
sql += "(#{[foreign_key.from_columns].flatten.map { |v| %("#{v}") }.join(',')}) "
|
|
140
|
+
sql += %(REFERENCES "#{foreign_key.to_table}" )
|
|
141
|
+
sql += "(#{[foreign_key.to_columns].flatten.map { |v| %("#{v}") }.join(',')})"
|
|
142
|
+
sql
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def create_table_sqls(delta)
|
|
146
|
+
delta[:add_table].map do |table_name, table|
|
|
147
|
+
column_sql = (create_table_column_sql(table) << create_table_index_sql(table))
|
|
148
|
+
.flatten
|
|
149
|
+
.reject(&:empty?)
|
|
150
|
+
.join(",\n ")
|
|
151
|
+
sql = <<-SQL
|
|
152
|
+
CREATE TABLE "#{table_name}" (
|
|
153
|
+
#{column_sql}
|
|
154
|
+
);
|
|
155
|
+
SQL
|
|
156
|
+
sql = sql.strip
|
|
157
|
+
index_sqls = table.indexes.values.map { |index| alter_add_index_sql(table_name, index) }
|
|
158
|
+
comment_sqls = create_table_comment_sqls(table_name, table)
|
|
159
|
+
([sql] + index_sqls + comment_sqls).join("\n")
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def create_table_comment_sqls(table_name, table)
|
|
164
|
+
sqls = []
|
|
165
|
+
if table.table_options[:comment] && !table.table_options[:comment].to_s.empty?
|
|
166
|
+
sqls << %(COMMENT ON TABLE "#{table_name}" IS '#{escape_quote(table.table_options[:comment])}';)
|
|
167
|
+
end
|
|
168
|
+
table.columns.values.each do |column|
|
|
169
|
+
comment_sql = comment_on_column_sql(table_name, column)
|
|
170
|
+
sqls << comment_sql unless comment_sql.nil?
|
|
171
|
+
end
|
|
172
|
+
sqls
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def comment_on_column_sql(table_name, column)
|
|
176
|
+
return nil if column.options[:comment].nil? || column.options[:comment].to_s.empty?
|
|
177
|
+
%(COMMENT ON COLUMN "#{table_name}"."#{column.column_name}" IS '#{escape_quote(column.options[:comment])}';)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def drop_table_sqls(delta)
|
|
181
|
+
delta[:remove_table].map do |table_name, _|
|
|
182
|
+
%(DROP TABLE "#{table_name}";)
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def create_table_column_sql(table)
|
|
187
|
+
table.columns.values.map do |column|
|
|
188
|
+
create_column_sql(column)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def create_column_sql(column, output_primary_key: false, output_identity: true)
|
|
193
|
+
fail NotImplementedError.new("#{column.type} is not supported on PostgreSQL adapter yet") if UNSUPPORTED_TYPES.include?(column.type)
|
|
194
|
+
sql = %("#{column.column_name}")
|
|
195
|
+
sql += " #{postgres_column_type(column)}"
|
|
196
|
+
if column.options[:null]
|
|
197
|
+
sql += ' DEFAULT NULL' unless column.options[:default]
|
|
198
|
+
else
|
|
199
|
+
sql += ' NOT NULL'
|
|
200
|
+
end
|
|
201
|
+
if column.options[:primary_key] && output_primary_key
|
|
202
|
+
sql += ' PRIMARY KEY'
|
|
203
|
+
end
|
|
204
|
+
if column.options[:default]
|
|
205
|
+
sql += " DEFAULT #{quote_default_expression(column.options[:default])}"
|
|
206
|
+
end
|
|
207
|
+
if auto_increment?(column) && output_identity
|
|
208
|
+
sql += ' GENERATED BY DEFAULT AS IDENTITY'
|
|
209
|
+
end
|
|
210
|
+
sql
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def postgres_column_type(column)
|
|
214
|
+
return 'boolean' if column.type == :tinyint && column.options[:limit].to_s == '1'
|
|
215
|
+
type = TYPE_MAPPING[column.type] || column.type.to_s
|
|
216
|
+
if TYPES_WITH_LIMIT.include?(column.type) && column.options[:limit]
|
|
217
|
+
"#{type}(#{column.options[:limit]})"
|
|
218
|
+
elsif column.type == :decimal && column.options[:precision] && column.options[:scale]
|
|
219
|
+
"#{type}(#{column.options[:precision]}, #{column.options[:scale]})"
|
|
220
|
+
else
|
|
221
|
+
type
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def auto_increment?(column)
|
|
226
|
+
extra = column.options[:extra]
|
|
227
|
+
!extra.nil? && extra.to_s.upcase.include?('AUTO_INCREMENT')
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def create_table_index_sql(table)
|
|
231
|
+
pkeys = table.columns.select { |_k, v| v.options[:primary_key] }
|
|
232
|
+
foreign_keys = table.foreign_keys.values
|
|
233
|
+
results = []
|
|
234
|
+
unless pkeys.empty?
|
|
235
|
+
results << %(PRIMARY KEY (#{pkeys.keys.map { |v| %("#{v}") }.join(',')}))
|
|
236
|
+
end
|
|
237
|
+
results << foreign_keys.map { |fk| foreign_key_constraint_sql(fk) }
|
|
238
|
+
results
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def quoted_index_columns(index)
|
|
242
|
+
[index.index_columns].flatten.map { |v| %("#{v}") }
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def escape_quote(value)
|
|
246
|
+
value.to_s.gsub("'", "''")
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def quote_default_expression(value)
|
|
250
|
+
if value.is_a?(Proc)
|
|
251
|
+
value.call
|
|
252
|
+
else
|
|
253
|
+
%('#{escape_quote(value)}')
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|