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.
Files changed (77) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ruby.yml +35 -4
  3. data/.gitignore +1 -0
  4. data/CHANGELOG.md +57 -0
  5. data/CONTRIBUTING.md +80 -0
  6. data/Gemfile.lock +25 -9
  7. data/README.md +160 -1
  8. data/Rakefile +45 -2
  9. data/convergence.gemspec +5 -1
  10. data/lib/convergence/cli.rb +10 -2
  11. data/lib/convergence/column.rb +3 -2
  12. data/lib/convergence/command/apply.rb +38 -9
  13. data/lib/convergence/command/dryrun.rb +5 -5
  14. data/lib/convergence/command/export.rb +19 -1
  15. data/lib/convergence/command.rb +6 -0
  16. data/lib/convergence/database_connector/postgres_connector.rb +25 -0
  17. data/lib/convergence/database_connector/sqlite_connector.rb +22 -0
  18. data/lib/convergence/database_connector.rb +6 -0
  19. data/lib/convergence/default_parameter/mysql_default_parameter.rb +1 -1
  20. data/lib/convergence/default_parameter/postgres_default_parameter.rb +57 -0
  21. data/lib/convergence/default_parameter/sqlite_default_parameter.rb +57 -0
  22. data/lib/convergence/default_parameter.rb +6 -0
  23. data/lib/convergence/diff.rb +46 -3
  24. data/lib/convergence/dsl.rb +18 -3
  25. data/lib/convergence/dumper/mysql_schema_dumper.rb +37 -9
  26. data/lib/convergence/dumper/postgres_schema_dumper.rb +193 -0
  27. data/lib/convergence/dumper/sqlite_schema_dumper.rb +132 -0
  28. data/lib/convergence/dumper.rb +113 -0
  29. data/lib/convergence/sql_generator/mysql_generator.rb +32 -3
  30. data/lib/convergence/sql_generator/postgres_generator.rb +270 -0
  31. data/lib/convergence/sql_generator/sqlite_generator.rb +192 -0
  32. data/lib/convergence/table.rb +3 -2
  33. data/lib/convergence/version.rb +1 -1
  34. data/spec/config/spec_database.yml +12 -0
  35. data/spec/convergence/config_spec.rb +75 -0
  36. data/spec/convergence/diff_spec.rb +291 -44
  37. data/spec/convergence/dsl_spec.rb +26 -0
  38. data/spec/convergence/dumper/mysql_schema_dumper_spec.rb +25 -2
  39. data/spec/convergence/dumper/postgres_schema_dumper_spec.rb +103 -0
  40. data/spec/convergence/dumper/sqlite_schema_dumper_spec.rb +85 -0
  41. data/spec/convergence/dumper_spec.rb +110 -16
  42. data/spec/convergence/foreign_key_spec.rb +33 -0
  43. data/spec/convergence/index_spec.rb +52 -0
  44. data/spec/convergence/pretty_diff_spec.rb +85 -0
  45. data/spec/convergence/table_spec.rb +46 -0
  46. data/spec/fixtures/add_table_with_enum_set.schema +5 -0
  47. data/spec/fixtures/change_table_comment_to_paper.schema +2 -0
  48. data/spec/fixtures/execute_raw_sql.schema +28 -0
  49. data/spec/fixtures/postgres/add_columns_to_paper.schema +29 -0
  50. data/spec/fixtures/postgres/add_table.schema +32 -0
  51. data/spec/fixtures/postgres/change_comment_columns_to_paper.schema +28 -0
  52. data/spec/fixtures/postgres/change_table_comment_to_paper.schema +28 -0
  53. data/spec/fixtures/postgres/drop_foreign_key.schema +25 -0
  54. data/spec/fixtures/postgres/drop_table.schema +19 -0
  55. data/spec/fixtures/postgres/remove_columns_to_paper.schema +27 -0
  56. data/spec/fixtures/postgres/rename_column_on_paper.schema +28 -0
  57. data/spec/fixtures/postgres/rename_table.schema +28 -0
  58. data/spec/fixtures/postgres_test_db.sql +41 -0
  59. data/spec/fixtures/rename_column_to_author.schema +26 -0
  60. data/spec/fixtures/rename_table.schema +26 -0
  61. data/spec/fixtures/sqlite/add_columns_to_paper.schema +29 -0
  62. data/spec/fixtures/sqlite/add_table.schema +32 -0
  63. data/spec/fixtures/sqlite/change_columns_to_paper.schema +28 -0
  64. data/spec/fixtures/sqlite/drop_foreign_key.schema +25 -0
  65. data/spec/fixtures/sqlite/drop_table.schema +19 -0
  66. data/spec/fixtures/sqlite/remove_columns_to_paper.schema +27 -0
  67. data/spec/fixtures/sqlite/rename_column_on_paper.schema +28 -0
  68. data/spec/fixtures/sqlite/rename_table.schema +27 -0
  69. data/spec/fixtures/sqlite_test_db.sql +31 -0
  70. data/spec/fixtures/test_db.sql +10 -0
  71. data/spec/integrations/command_diff.rb +35 -0
  72. data/spec/integrations/command_dryrun.rb +59 -2
  73. data/spec/integrations/command_export.rb +28 -0
  74. data/spec/postgres_integrations/command_dryrun.rb +101 -0
  75. data/spec/spec_helper.rb +13 -0
  76. data/spec/sqlite_integrations/command_dryrun.rb +92 -0
  77. 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
@@ -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 '#{column.options[: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