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.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ruby.yml +35 -4
  3. data/.gitignore +1 -0
  4. data/CHANGELOG.md +46 -0
  5. data/CONTRIBUTING.md +80 -0
  6. data/Gemfile.lock +23 -8
  7. data/README.md +131 -1
  8. data/Rakefile +45 -2
  9. data/convergence.gemspec +3 -1
  10. data/lib/convergence/cli.rb +10 -2
  11. data/lib/convergence/command/apply.rb +38 -9
  12. data/lib/convergence/command/dryrun.rb +5 -5
  13. data/lib/convergence/command/export.rb +19 -1
  14. data/lib/convergence/command.rb +6 -0
  15. data/lib/convergence/database_connector/postgres_connector.rb +25 -0
  16. data/lib/convergence/database_connector/sqlite_connector.rb +22 -0
  17. data/lib/convergence/database_connector.rb +6 -0
  18. data/lib/convergence/default_parameter/mysql_default_parameter.rb +1 -1
  19. data/lib/convergence/default_parameter/postgres_default_parameter.rb +57 -0
  20. data/lib/convergence/default_parameter/sqlite_default_parameter.rb +57 -0
  21. data/lib/convergence/default_parameter.rb +6 -0
  22. data/lib/convergence/diff.rb +9 -2
  23. data/lib/convergence/dsl.rb +18 -3
  24. data/lib/convergence/dumper/mysql_schema_dumper.rb +37 -9
  25. data/lib/convergence/dumper/postgres_schema_dumper.rb +193 -0
  26. data/lib/convergence/dumper/sqlite_schema_dumper.rb +132 -0
  27. data/lib/convergence/dumper.rb +113 -0
  28. data/lib/convergence/sql_generator/mysql_generator.rb +18 -3
  29. data/lib/convergence/sql_generator/postgres_generator.rb +256 -0
  30. data/lib/convergence/sql_generator/sqlite_generator.rb +178 -0
  31. data/lib/convergence/version.rb +1 -1
  32. data/spec/config/spec_database.yml +12 -0
  33. data/spec/convergence/config_spec.rb +75 -0
  34. data/spec/convergence/diff_spec.rb +125 -45
  35. data/spec/convergence/dsl_spec.rb +26 -0
  36. data/spec/convergence/dumper/mysql_schema_dumper_spec.rb +25 -2
  37. data/spec/convergence/dumper/postgres_schema_dumper_spec.rb +103 -0
  38. data/spec/convergence/dumper/sqlite_schema_dumper_spec.rb +85 -0
  39. data/spec/convergence/dumper_spec.rb +110 -16
  40. data/spec/convergence/foreign_key_spec.rb +33 -0
  41. data/spec/convergence/index_spec.rb +52 -0
  42. data/spec/convergence/pretty_diff_spec.rb +85 -0
  43. data/spec/convergence/table_spec.rb +23 -0
  44. data/spec/fixtures/add_table_with_enum_set.schema +5 -0
  45. data/spec/fixtures/change_table_comment_to_paper.schema +2 -0
  46. data/spec/fixtures/execute_raw_sql.schema +28 -0
  47. data/spec/fixtures/postgres/add_columns_to_paper.schema +29 -0
  48. data/spec/fixtures/postgres/add_table.schema +32 -0
  49. data/spec/fixtures/postgres/change_comment_columns_to_paper.schema +28 -0
  50. data/spec/fixtures/postgres/change_table_comment_to_paper.schema +28 -0
  51. data/spec/fixtures/postgres/drop_foreign_key.schema +25 -0
  52. data/spec/fixtures/postgres/drop_table.schema +19 -0
  53. data/spec/fixtures/postgres/remove_columns_to_paper.schema +27 -0
  54. data/spec/fixtures/postgres_test_db.sql +41 -0
  55. data/spec/fixtures/sqlite/add_columns_to_paper.schema +29 -0
  56. data/spec/fixtures/sqlite/add_table.schema +32 -0
  57. data/spec/fixtures/sqlite/change_columns_to_paper.schema +28 -0
  58. data/spec/fixtures/sqlite/drop_foreign_key.schema +25 -0
  59. data/spec/fixtures/sqlite/drop_table.schema +19 -0
  60. data/spec/fixtures/sqlite/remove_columns_to_paper.schema +27 -0
  61. data/spec/fixtures/sqlite_test_db.sql +31 -0
  62. data/spec/fixtures/test_db.sql +10 -0
  63. data/spec/integrations/command_diff.rb +35 -0
  64. data/spec/integrations/command_dryrun.rb +37 -2
  65. data/spec/integrations/command_export.rb +28 -0
  66. data/spec/postgres_integrations/command_dryrun.rb +79 -0
  67. data/spec/spec_helper.rb +13 -0
  68. data/spec/sqlite_integrations/command_dryrun.rb +70 -0
  69. metadata +96 -8
@@ -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,7 +2,11 @@ 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 = {}
@@ -76,14 +80,16 @@ class Convergence::Diff
76
80
  .map do |column_name, from_column|
77
81
  to_column = to.columns[column_name]
78
82
  if to_column
79
- to_column_option_with_type = (from_column.options.map { |k, _v| { k => nil } }.reduce { |a, e| a.merge(e) } || {})
83
+ to_column_option_with_type = (from_column.options.map { |k, _v| [k, nil] }.to_h)
80
84
  .merge(to_column.options)
81
85
  .merge(type: to_column.type)
86
+ .tap { |opt| opt[:default] = opt[:default].call if opt[:default].is_a?(Proc) }
82
87
  .map { |k, v| [k, case_sensitive_column?(k) ? v&.to_s : v&.to_s&.downcase] }
83
88
  .to_a
84
89
  from_column_option_with_type = from_column
85
90
  .options
86
91
  .merge(type: from_column.type)
92
+ .tap { |opt| opt[:default] = opt[:default].call if opt[:default].is_a?(Proc) }
87
93
  .map { |k, v| [k, case_sensitive_column?(k) ? v&.to_s : v&.to_s&.downcase] }
88
94
  .to_a
89
95
  { column_name => Hash[(to_column_option_with_type - from_column_option_with_type)] }
@@ -175,6 +181,7 @@ class Convergence::Diff
175
181
  end
176
182
 
177
183
  def remove_auto_increment_option?(from_value, to_value)
184
+ return true if @ignore_auto_increment
178
185
  return true if from_value.nil? || to_value.nil?
179
186
  from_value >= to_value
180
187
  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
@@ -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