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
|
@@ -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
|
-
|
|
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(
|
|
15
|
-
output_sql(
|
|
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 =
|
|
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
|
data/lib/convergence/command.rb
CHANGED
|
@@ -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
|
data/lib/convergence/diff.rb
CHANGED
|
@@ -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|
|
|
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
|
data/lib/convergence/dsl.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|