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
@@ -8,40 +8,69 @@ require 'convergence/diff'
8
8
  class Convergence::Command::Apply < Convergence::Command
9
9
  def execute
10
10
  current_dir_path = Pathname.new(@opts[:input]).realpath.dirname
11
- input_tables = Convergence::DSL.parse(File.open(@opts[:input]).read, current_dir_path)
11
+ dsl = Convergence::DSL.parse_dsl(File.open(@opts[:input]).read, current_dir_path)
12
12
  current_tables = dumper.dump
13
- execute_sql(input_tables, current_tables)
13
+ execute_sql(dsl.tables, current_tables, dsl.raw_sqls)
14
14
  end
15
15
 
16
- def generate_sql(input_tables, current_tables)
16
+ def generate_sql(input_tables, current_tables, raw_sqls = [])
17
17
  current_tables_with_full_option =
18
18
  Convergence::DefaultParameter.append_database_default_parameter(current_tables, database_adapter)
19
19
  input_tables_with_full_option =
20
20
  Convergence::DefaultParameter.append_database_default_parameter(input_tables, database_adapter)
21
- delta = Convergence::Diff.new.diff(current_tables_with_full_option, input_tables_with_full_option)
22
- sql_generator.generate(input_tables_with_full_option, delta, current_tables_with_full_option)
21
+ delta = Convergence::Diff
22
+ .new(ignore_auto_increment: @opts[:ignore_auto_increment])
23
+ .diff(current_tables_with_full_option, input_tables_with_full_option)
24
+ sql = sql_generator.generate(
25
+ input_tables_with_full_option,
26
+ delta,
27
+ current_tables_with_full_option,
28
+ safe_migration: @opts[:safe_migration]
29
+ )
30
+ append_raw_sqls(sql, raw_sqls)
23
31
  end
24
32
 
25
33
  private
26
34
 
35
+ def append_raw_sqls(sql, raw_sqls)
36
+ return sql if raw_sqls.empty?
37
+ raw_sql_block = raw_sqls.map { |q| q.strip.end_with?(';') ? q.strip : "#{q.strip};" }.join("\n")
38
+ [sql, raw_sql_block].reject(&:empty?).join("\n")
39
+ end
40
+
27
41
  def sql_generator
28
42
  @sql_generator ||= case database_adapter
29
43
  when 'mysql', 'mysql2'
30
44
  require 'convergence/sql_generator/mysql_generator'
31
45
  SQLGenerator::MysqlGenerator.new
46
+ when 'postgresql', 'postgres', 'pg'
47
+ require 'convergence/sql_generator/postgres_generator'
48
+ SQLGenerator::PostgresGenerator.new
49
+ when 'sqlite3', 'sqlite'
50
+ require 'convergence/sql_generator/sqlite_generator'
51
+ SQLGenerator::SqliteGenerator.new
32
52
  else
33
53
  fail NotImplementedError.new('unknown database adapter')
34
54
  end
35
55
  end
36
56
 
37
- def execute_sql(input_tables, current_tables)
38
- sql = generate_sql(input_tables, current_tables)
39
- unless sql.strip.empty?
40
- sql = <<-SQL
57
+ def wrap_with_constraint_pragma(sql)
58
+ case database_adapter
59
+ when 'mysql', 'mysql2'
60
+ <<-SQL
41
61
  SET FOREIGN_KEY_CHECKS=0;
42
62
  #{sql}
43
63
  SET FOREIGN_KEY_CHECKS=1;
44
64
  SQL
65
+ else
66
+ sql
67
+ end
68
+ end
69
+
70
+ def execute_sql(input_tables, current_tables, raw_sqls = [])
71
+ sql = generate_sql(input_tables, current_tables, raw_sqls)
72
+ unless sql.strip.empty?
73
+ sql = wrap_with_constraint_pragma(sql)
45
74
  end
46
75
  sql.split(';').each do |q2|
47
76
  q = q2.strip
@@ -8,11 +8,11 @@ require 'convergence/pretty_diff'
8
8
  class Convergence::Command::Dryrun < Convergence::Command
9
9
  def execute
10
10
  current_dir_path = Pathname.new(@opts[:input]).realpath.dirname
11
- input_tables = Convergence::DSL.parse(File.open(@opts[:input]).read, current_dir_path)
11
+ dsl = Convergence::DSL.parse_dsl(File.open(@opts[:input]).read, current_dir_path)
12
12
  current_tables = dumper.dump
13
13
  # -- maybe it's redundant output
14
- # output_diff(input_tables, current_tables)
15
- output_sql(input_tables, current_tables)
14
+ # output_diff(dsl.tables, current_tables)
15
+ output_sql(dsl.tables, current_tables, dsl.raw_sqls)
16
16
  end
17
17
 
18
18
  private
@@ -30,10 +30,10 @@ class Convergence::Command::Dryrun < Convergence::Command
30
30
  msg
31
31
  end
32
32
 
33
- def output_sql(input_tables, current_tables)
33
+ def output_sql(input_tables, current_tables, raw_sqls = [])
34
34
  msg = Convergence::Command::Apply
35
35
  .new(@opts, config: @config)
36
- .generate_sql(input_tables, current_tables)
36
+ .generate_sql(input_tables, current_tables, raw_sqls)
37
37
  .split("\n")
38
38
  .map { |v| '# ' + v }
39
39
  .join("\n")
@@ -5,8 +5,26 @@ require 'convergence/default_parameter'
5
5
  class Convergence::Command::Export < Convergence::Command
6
6
  def execute
7
7
  tables = Convergence::DefaultParameter.remove_database_default_parameter(dumper.dump, database_adapter)
8
- msg = Convergence::Dumper.new.dump_dsl(tables)
8
+ msg = if @opts[:dump_rails_migration]
9
+ dump_rails_migration(tables)
10
+ else
11
+ Convergence::Dumper.new.dump_dsl(tables)
12
+ end
9
13
  logger.output(msg)
10
14
  msg
11
15
  end
16
+
17
+ private
18
+
19
+ def dump_rails_migration(tables)
20
+ filename = @opts[:filename] || 'convergence_migration'
21
+ class_name = camelize(filename)
22
+ migration_filename = "#{Time.now.strftime('%Y%m%d%H%M%S')}_#{filename}.rb"
23
+ body = Convergence::Dumper.new.dump_rails_migration(tables, class_name)
24
+ "# Filename: #{migration_filename}\n#{body}"
25
+ end
26
+
27
+ def camelize(name)
28
+ name.to_s.split(/[_-]/).reject(&:empty?).map { |part| part[0].upcase + part[1..].to_s }.join
29
+ end
12
30
  end
@@ -27,6 +27,12 @@ class Convergence::Command
27
27
  when 'mysql', 'mysql2'
28
28
  require 'convergence/dumper/mysql_schema_dumper'
29
29
  Convergence::Dumper::MysqlSchemaDumper.new(connector)
30
+ when 'postgresql', 'postgres', 'pg'
31
+ require 'convergence/dumper/postgres_schema_dumper'
32
+ Convergence::Dumper::PostgresSchemaDumper.new(connector)
33
+ when 'sqlite3', 'sqlite'
34
+ require 'convergence/dumper/sqlite_schema_dumper'
35
+ Convergence::Dumper::SqliteSchemaDumper.new(connector)
30
36
  else
31
37
  fail NotImplementedError.new('unknown database adapter')
32
38
  end
@@ -0,0 +1,25 @@
1
+ require 'pg'
2
+ require 'convergence/database_connector'
3
+
4
+ class Convergence::DatabaseConnector::PostgresConnector
5
+ attr_reader :config
6
+
7
+ def initialize(config)
8
+ @config = config
9
+ end
10
+
11
+ def client(database_name = @config.database)
12
+ @clients ||= {}
13
+ @clients[database_name] ||= PG::Connection.new(
14
+ host: @config.host,
15
+ port: @config.port,
16
+ user: @config.username,
17
+ password: @config.password,
18
+ dbname: database_name
19
+ )
20
+ end
21
+
22
+ def schema_client
23
+ client
24
+ end
25
+ end
@@ -0,0 +1,22 @@
1
+ require 'sqlite3'
2
+ require 'convergence/database_connector'
3
+
4
+ class Convergence::DatabaseConnector::SqliteConnector
5
+ attr_reader :config
6
+
7
+ def initialize(config)
8
+ @config = config
9
+ end
10
+
11
+ def client(database_name = @config.database)
12
+ @clients ||= {}
13
+ @clients[database_name] ||= SQLite3::Database.new(database_name).tap do |db|
14
+ db.results_as_hash = true
15
+ db.execute('PRAGMA foreign_keys = ON')
16
+ end
17
+ end
18
+
19
+ def schema_client
20
+ client
21
+ end
22
+ end
@@ -7,6 +7,12 @@ class Convergence::DatabaseConnector
7
7
  when 'mysql', 'mysql2'
8
8
  require 'convergence/database_connector/mysql_connector'
9
9
  Convergence::DatabaseConnector::MysqlConnector.new(config)
10
+ when 'postgresql', 'postgres', 'pg'
11
+ require 'convergence/database_connector/postgres_connector'
12
+ Convergence::DatabaseConnector::PostgresConnector.new(config)
13
+ when 'sqlite3', 'sqlite'
14
+ require 'convergence/database_connector/sqlite_connector'
15
+ Convergence::DatabaseConnector::SqliteConnector.new(config)
10
16
  else
11
17
  fail NotImplementedError.new("#{config.adapter} not supported yet")
12
18
  end
@@ -51,7 +51,7 @@ class Convergence::DefaultParameter::MysqlDefaultParameter
51
51
  DEFAULT_COLUMN_PARAMETERS = {
52
52
  null: false
53
53
  }
54
- TEXT_TYPE = [:varchar, :char, :tiny_text, :text, :mediumtext, :longtext]
54
+ TEXT_TYPE = [:varchar, :char, :tiny_text, :text, :mediumtext, :longtext, :enum, :set]
55
55
  DEFAULT_COLUMN_TYPE_PARAMETERS = {
56
56
  tinyint: {
57
57
  limit: 4
@@ -0,0 +1,57 @@
1
+ require 'convergence/default_parameter'
2
+
3
+ class Convergence::DefaultParameter::PostgresDefaultParameter
4
+ DEFAULT_COLUMN_PARAMETERS = {
5
+ null: false
6
+ }
7
+ DEFAULT_INDEX_PARAMETERS = { type: 'btree', unique: false }
8
+
9
+ def initialize
10
+ end
11
+
12
+ def remove_default_parameter(table)
13
+ remove_column_default_parameter(table)
14
+ remove_index_default_parameter(table)
15
+ table
16
+ end
17
+
18
+ def append_default_parameter(table)
19
+ append_column_default_parameter(table)
20
+ append_index_default_parameter(table)
21
+ table
22
+ end
23
+
24
+ private
25
+
26
+ def remove_column_default_parameter(table)
27
+ table.columns.each do |_column_name, column|
28
+ DEFAULT_COLUMN_PARAMETERS.each do |k, v|
29
+ if !column.options[k].nil? && column.options[k].to_s.downcase == v.to_s.downcase
30
+ column.options.delete(k)
31
+ end
32
+ end
33
+ end
34
+ end
35
+
36
+ def remove_index_default_parameter(table)
37
+ table.indexes.each do |_, va|
38
+ va.options.each do |k, v|
39
+ if !DEFAULT_INDEX_PARAMETERS[k].nil? && DEFAULT_INDEX_PARAMETERS[k].to_s.downcase == v.to_s.downcase
40
+ va.options.delete(k)
41
+ end
42
+ end
43
+ end
44
+ end
45
+
46
+ def append_column_default_parameter(table)
47
+ table.columns.each do |_column_name, column|
48
+ column.options = DEFAULT_COLUMN_PARAMETERS.merge(column.options)
49
+ end
50
+ end
51
+
52
+ def append_index_default_parameter(table)
53
+ table.indexes.each do |_column_name, column|
54
+ column.options = DEFAULT_INDEX_PARAMETERS.merge(column.options)
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,57 @@
1
+ require 'convergence/default_parameter'
2
+
3
+ class Convergence::DefaultParameter::SqliteDefaultParameter
4
+ DEFAULT_COLUMN_PARAMETERS = {
5
+ null: false
6
+ }
7
+ DEFAULT_INDEX_PARAMETERS = { unique: false }
8
+
9
+ def initialize
10
+ end
11
+
12
+ def remove_default_parameter(table)
13
+ remove_column_default_parameter(table)
14
+ remove_index_default_parameter(table)
15
+ table
16
+ end
17
+
18
+ def append_default_parameter(table)
19
+ append_column_default_parameter(table)
20
+ append_index_default_parameter(table)
21
+ table
22
+ end
23
+
24
+ private
25
+
26
+ def remove_column_default_parameter(table)
27
+ table.columns.each do |_column_name, column|
28
+ DEFAULT_COLUMN_PARAMETERS.each do |k, v|
29
+ if !column.options[k].nil? && column.options[k].to_s.downcase == v.to_s.downcase
30
+ column.options.delete(k)
31
+ end
32
+ end
33
+ end
34
+ end
35
+
36
+ def remove_index_default_parameter(table)
37
+ table.indexes.each do |_, va|
38
+ va.options.each do |k, v|
39
+ if !DEFAULT_INDEX_PARAMETERS[k].nil? && DEFAULT_INDEX_PARAMETERS[k].to_s.downcase == v.to_s.downcase
40
+ va.options.delete(k)
41
+ end
42
+ end
43
+ end
44
+ end
45
+
46
+ def append_column_default_parameter(table)
47
+ table.columns.each do |_column_name, column|
48
+ column.options = DEFAULT_COLUMN_PARAMETERS.merge(column.options)
49
+ end
50
+ end
51
+
52
+ def append_index_default_parameter(table)
53
+ table.indexes.each do |_column_name, column|
54
+ column.options = DEFAULT_INDEX_PARAMETERS.merge(column.options)
55
+ end
56
+ end
57
+ end
@@ -4,6 +4,12 @@ class Convergence::DefaultParameter
4
4
  when 'mysql', 'mysql2'
5
5
  require 'convergence/default_parameter/mysql_default_parameter'
6
6
  @parameter_klass = Convergence::DefaultParameter::MysqlDefaultParameter.new
7
+ when 'postgresql', 'postgres', 'pg'
8
+ require 'convergence/default_parameter/postgres_default_parameter'
9
+ @parameter_klass = Convergence::DefaultParameter::PostgresDefaultParameter.new
10
+ when 'sqlite3', 'sqlite'
11
+ require 'convergence/default_parameter/sqlite_default_parameter'
12
+ @parameter_klass = Convergence::DefaultParameter::SqliteDefaultParameter.new
7
13
  else
8
14
  fail NotImplementedError.new("unknown adapter #{config.adapter}.")
9
15
  end
@@ -2,11 +2,18 @@ require 'diff/lcs'
2
2
 
3
3
  class Convergence::Diff
4
4
  CASE_SENSITIVE_TABLE_OPTIONS = %i(comment)
5
- CASE_SENSITIVE_COLUMNS = %i(default comment)
5
+ CASE_SENSITIVE_COLUMNS = %i(default comment values)
6
+
7
+ def initialize(ignore_auto_increment: false)
8
+ @ignore_auto_increment = ignore_auto_increment
9
+ end
6
10
 
7
11
  def diff(from_database, to_database)
8
12
  delta = {}
9
13
  from_database = {} if from_database.nil?
14
+ delta[:rename_table] = scan_rename_table(from_database, to_database)
15
+ from_database = from_database.reject { |name, _| delta[:rename_table].key?(name) }
16
+ to_database = to_database.reject { |name, _| delta[:rename_table].value?(name) }
10
17
  delta[:add_table] = scan_add_table(from_database, to_database)
11
18
  delta[:remove_table] = scan_remove_table(from_database, to_database)
12
19
  change_table = scan_change_table(from_database, to_database)
@@ -20,8 +27,11 @@ class Convergence::Diff
20
27
  from = from_table.dup
21
28
  to = to_table.dup
22
29
  delta = {}
30
+ delta[:rename_column] = scan_rename_column(from, to)
31
+ from.columns = from.columns.reject { |name, _| delta[:rename_column].key?(name) }
32
+ to.columns = to.columns.reject { |name, _| delta[:rename_column].value?(name) }
23
33
  delta[:remove_column] = scan_remove_column(from, to)
24
- return delta if removed_all_columns?(from, delta)
34
+ return delta if removed_all_columns?(from_table, delta)
25
35
  delta[:add_column] = scan_add_column(from, to)
26
36
  delta[:change_column] = scan_change_column(from, to)
27
37
  scan_change_order_column(from, to, delta)
@@ -35,6 +45,36 @@ class Convergence::Diff
35
45
 
36
46
  private
37
47
 
48
+ def scan_rename_table(from, to)
49
+ renames = {}
50
+ to.each do |new_name, to_table|
51
+ old_name = to_table.renamed_from
52
+ next if old_name.nil?
53
+ next unless from.key?(old_name)
54
+ next if from.key?(new_name)
55
+ if renames.key?(old_name)
56
+ fail ArgumentError.new("renamed_from '#{old_name}' is specified for multiple tables")
57
+ end
58
+ renames[old_name] = new_name
59
+ end
60
+ renames
61
+ end
62
+
63
+ def scan_rename_column(from, to)
64
+ renames = {}
65
+ to.columns.each do |new_name, to_column|
66
+ old_name = to_column.renamed_from
67
+ next if old_name.nil?
68
+ next unless from.columns.key?(old_name)
69
+ next if from.columns.key?(new_name)
70
+ if renames.key?(old_name)
71
+ fail ArgumentError.new("#{to.table_name}: renamed_from '#{old_name}' is specified for multiple columns")
72
+ end
73
+ renames[old_name] = new_name
74
+ end
75
+ renames
76
+ end
77
+
38
78
  def scan_add_table(from, to)
39
79
  to.reject { |table_name, _| from.map { |k, _| k }.include?(table_name) }
40
80
  end
@@ -76,14 +116,16 @@ class Convergence::Diff
76
116
  .map do |column_name, from_column|
77
117
  to_column = to.columns[column_name]
78
118
  if to_column
79
- to_column_option_with_type = (from_column.options.map { |k, _v| { k => nil } }.reduce { |a, e| a.merge(e) } || {})
119
+ to_column_option_with_type = (from_column.options.map { |k, _v| [k, nil] }.to_h)
80
120
  .merge(to_column.options)
81
121
  .merge(type: to_column.type)
122
+ .tap { |opt| opt[:default] = opt[:default].call if opt[:default].is_a?(Proc) }
82
123
  .map { |k, v| [k, case_sensitive_column?(k) ? v&.to_s : v&.to_s&.downcase] }
83
124
  .to_a
84
125
  from_column_option_with_type = from_column
85
126
  .options
86
127
  .merge(type: from_column.type)
128
+ .tap { |opt| opt[:default] = opt[:default].call if opt[:default].is_a?(Proc) }
87
129
  .map { |k, v| [k, case_sensitive_column?(k) ? v&.to_s : v&.to_s&.downcase] }
88
130
  .to_a
89
131
  { column_name => Hash[(to_column_option_with_type - from_column_option_with_type)] }
@@ -175,6 +217,7 @@ class Convergence::Diff
175
217
  end
176
218
 
177
219
  def remove_auto_increment_option?(from_value, to_value)
220
+ return true if @ignore_auto_increment
178
221
  return true if from_value.nil? || to_value.nil?
179
222
  from_value >= to_value
180
223
  end
@@ -1,10 +1,11 @@
1
1
  require 'convergence/table'
2
2
 
3
3
  class Convergence::DSL
4
- attr_accessor :tables, :current_dir_path
4
+ attr_accessor :tables, :raw_sqls, :current_dir_path
5
5
 
6
6
  def initialize
7
7
  @tables = {}
8
+ @raw_sqls = []
8
9
  end
9
10
 
10
11
  def create_table(table_name, options = {}, &block)
@@ -14,15 +15,29 @@ class Convergence::DSL
14
15
  table
15
16
  end
16
17
 
18
+ # Execute an arbitrary SQL statement that the DSL has no dedicated syntax for
19
+ # (e.g. triggers, stored procedures, data backfills). Unlike create_table,
20
+ # this is not diffed against the current schema: it runs every time the
21
+ # schema file is applied, so the SQL itself must be idempotent.
22
+ def execute(sql)
23
+ @raw_sqls << sql
24
+ end
25
+
17
26
  def include(path)
18
27
  next_dir_path = File.dirname("#{@current_dir_path}/#{path}")
19
- @tables.merge!(Convergence::DSL.parse(File.open("#{current_dir_path}/#{path}").read, next_dir_path))
28
+ included = Convergence::DSL.parse_dsl(File.open("#{current_dir_path}/#{path}").read, next_dir_path)
29
+ @tables.merge!(included.tables)
30
+ @raw_sqls.concat(included.raw_sqls)
20
31
  end
21
32
 
22
33
  def self.parse(code, current_dir_path)
34
+ parse_dsl(code, current_dir_path).tables
35
+ end
36
+
37
+ def self.parse_dsl(code, current_dir_path)
23
38
  parser = new
24
39
  parser.current_dir_path = current_dir_path
25
40
  parser.instance_eval(code)
26
- parser.tables
41
+ parser
27
42
  end
28
43
  end
@@ -110,8 +110,8 @@ class Convergence::Dumper::MysqlSchemaDumper
110
110
  option.merge!(engine: table_option['ENGINE'])
111
111
  row_format = table_option['CREATE_OPTIONS'].scan(/=(.*)/).flatten[0] || table_option['ROW_FORMAT']
112
112
  option.merge!(row_format: row_format)
113
- option.merge!(default_charset: table_option['CHARACTER_SET_NAME'])
114
- option.merge!(collate: table_option['TABLE_COLLATION'])
113
+ option.merge!(default_charset: normalize_charset(table_option['CHARACTER_SET_NAME']))
114
+ option.merge!(collate: normalize_charset(table_option['TABLE_COLLATION']))
115
115
  option.merge!(comment: table_option['TABLE_COMMENT'])
116
116
  option.merge!(auto_increment: table_option['AUTO_INCREMENT']) if table_option['AUTO_INCREMENT']
117
117
  table.table_options = option
@@ -125,16 +125,19 @@ class Convergence::Dumper::MysqlSchemaDumper
125
125
  end
126
126
 
127
127
  def parse_column(column)
128
- data_type = column['DATA_TYPE']
128
+ data_type = column['DATA_TYPE'].to_sym
129
129
  column_name = column['COLUMN_NAME']
130
130
  options = { null: column['IS_NULLABLE'] == 'YES' ? true : false }
131
- options.merge!(default: column['COLUMN_DEFAULT']) unless column['COLUMN_DEFAULT'].nil?
132
- options.merge!(character_set: column['CHARACTER_SET_NAME']) unless column['CHARACTER_SET_NAME'].nil?
133
- options.merge!(collate: column['COLLATION_NAME']) unless column['COLLATION_NAME'].nil?
131
+ unless column['COLUMN_DEFAULT'].nil?
132
+ options.merge!(default: column_default_expression(data_type, column['COLUMN_DEFAULT']))
133
+ end
134
+ options.merge!(character_set: normalize_charset(column['CHARACTER_SET_NAME'])) unless column['CHARACTER_SET_NAME'].nil?
135
+ options.merge!(collate: normalize_charset(column['COLLATION_NAME'])) unless column['COLLATION_NAME'].nil?
134
136
  column_type = column['COLUMN_TYPE']
135
- if data_type == 'enum' || data_type == 'set'
136
- # TODO: implement
137
- elsif data_type == 'decimal'
137
+ case data_type
138
+ when :enum, :set
139
+ options.merge!(values: parse_enum_or_set_values(column_type))
140
+ when :decimal
138
141
  precision, scale = column_type.scan(/\d+/)
139
142
  options.merge!(precision: precision, scale: scale)
140
143
  else
@@ -179,4 +182,29 @@ class Convergence::Dumper::MysqlSchemaDumper
179
182
  end
180
183
  end
181
184
  end
185
+
186
+ # column_type looks like "enum('a','b','c')" or "set('a','b','c')"
187
+ def parse_enum_or_set_values(column_type)
188
+ column_type[/\A(?:enum|set)\((.*)\)\z/i, 1]
189
+ .to_s
190
+ .scan(/'((?:[^']|'')*)'/)
191
+ .flatten
192
+ .map { |v| v.gsub("''", "'") }
193
+ end
194
+
195
+ # MySQL 8.0.24+ reports the `utf8` charset/collation as `utf8mb3` (e.g. `utf8mb3_general_ci`).
196
+ # Normalize it back to `utf8` so it still matches schemas written with the traditional name.
197
+ def normalize_charset(value)
198
+ return value if value.nil?
199
+ value.sub(/\Autf8mb3/, 'utf8')
200
+ end
201
+
202
+ def column_default_expression(data_type, value)
203
+ case [data_type, value]
204
+ when [:datetime, "CURRENT_TIMESTAMP"], [:timestamp, "CURRENT_TIMESTAMP"]
205
+ -> { value }
206
+ else
207
+ value
208
+ end
209
+ end
182
210
  end