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.
- checksums.yaml +4 -4
- data/.github/workflows/ruby.yml +35 -4
- data/.gitignore +1 -0
- data/CHANGELOG.md +57 -0
- data/CONTRIBUTING.md +80 -0
- data/Gemfile.lock +25 -9
- data/README.md +160 -1
- data/Rakefile +45 -2
- data/convergence.gemspec +5 -1
- data/lib/convergence/cli.rb +10 -2
- data/lib/convergence/column.rb +3 -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 +46 -3
- 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 +32 -3
- data/lib/convergence/sql_generator/postgres_generator.rb +270 -0
- data/lib/convergence/sql_generator/sqlite_generator.rb +192 -0
- data/lib/convergence/table.rb +3 -2
- 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 +291 -44
- 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 +46 -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/rename_column_on_paper.schema +28 -0
- data/spec/fixtures/postgres/rename_table.schema +28 -0
- data/spec/fixtures/postgres_test_db.sql +41 -0
- data/spec/fixtures/rename_column_to_author.schema +26 -0
- data/spec/fixtures/rename_table.schema +26 -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/rename_column_on_paper.schema +28 -0
- data/spec/fixtures/sqlite/rename_table.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 +59 -2
- data/spec/integrations/command_export.rb +28 -0
- data/spec/postgres_integrations/command_dryrun.rb +101 -0
- data/spec/spec_helper.rb +13 -0
- data/spec/sqlite_integrations/command_dryrun.rb +92 -0
- metadata +135 -7
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
require 'convergence/sql_generator'
|
|
2
|
+
|
|
3
|
+
class SQLGenerator::PostgresGenerator < SQLGenerator
|
|
4
|
+
# Convergence's DSL type names follow MySQL terminology (kept for cross-adapter DSL
|
|
5
|
+
# compatibility). Map them to their closest native PostgreSQL type.
|
|
6
|
+
TYPE_MAPPING = {
|
|
7
|
+
tinyint: 'smallint',
|
|
8
|
+
smallint: 'smallint',
|
|
9
|
+
mediumint: 'integer',
|
|
10
|
+
int: 'integer',
|
|
11
|
+
bigint: 'bigint',
|
|
12
|
+
float: 'real',
|
|
13
|
+
double: 'double precision',
|
|
14
|
+
decimal: 'decimal',
|
|
15
|
+
char: 'char',
|
|
16
|
+
varchar: 'varchar',
|
|
17
|
+
tinyblob: 'bytea',
|
|
18
|
+
blob: 'bytea',
|
|
19
|
+
mediumblob: 'bytea',
|
|
20
|
+
longblob: 'bytea',
|
|
21
|
+
tinytext: 'text',
|
|
22
|
+
text: 'text',
|
|
23
|
+
mediumtext: 'text',
|
|
24
|
+
longtext: 'text',
|
|
25
|
+
date: 'date',
|
|
26
|
+
time: 'time',
|
|
27
|
+
datetime: 'timestamp',
|
|
28
|
+
timestamp: 'timestamp',
|
|
29
|
+
year: 'integer',
|
|
30
|
+
json: 'jsonb'
|
|
31
|
+
}.freeze
|
|
32
|
+
TYPES_WITH_LIMIT = [:char, :varchar].freeze
|
|
33
|
+
UNSUPPORTED_TYPES = [:enum, :set].freeze
|
|
34
|
+
|
|
35
|
+
attr_reader :original_table
|
|
36
|
+
|
|
37
|
+
def generate(to_table, delta, original_table, safe_migration: false)
|
|
38
|
+
@original_table = original_table
|
|
39
|
+
sqls = []
|
|
40
|
+
sqls << rename_table_sqls(delta)
|
|
41
|
+
sqls << change_table_sql(to_table, delta)
|
|
42
|
+
sqls << (safe_migration ? [] : drop_table_sqls(delta))
|
|
43
|
+
sqls << create_table_sqls(delta)
|
|
44
|
+
sqls.reject!(&:empty?)
|
|
45
|
+
sqls.join("\n")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def rename_table_sqls(delta)
|
|
51
|
+
results = delta[:rename_table].map { |old_name, new_name| %(ALTER TABLE "#{old_name}" RENAME TO "#{new_name}";) }
|
|
52
|
+
results << '' unless results.empty?
|
|
53
|
+
results
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# FIXME: multiple pk change not supported yet
|
|
57
|
+
def change_table_sql(to_table, delta)
|
|
58
|
+
change_table = delta[:change_table]
|
|
59
|
+
results = []
|
|
60
|
+
change_table.each do |table_name, table_delta|
|
|
61
|
+
unless table_delta[:remove_foreign_key].empty?
|
|
62
|
+
results << alter_remove_foreign_keys_sql(table_name, table_delta[:remove_foreign_key].keys)
|
|
63
|
+
end
|
|
64
|
+
table_delta[:remove_index].each do |index_name, _index|
|
|
65
|
+
results << alter_remove_index_sql(index_name)
|
|
66
|
+
end
|
|
67
|
+
table_delta[:rename_column].each do |old_name, new_name|
|
|
68
|
+
results << alter_rename_column_sql(table_name, old_name, new_name)
|
|
69
|
+
end
|
|
70
|
+
unless table_delta[:remove_column].empty?
|
|
71
|
+
results << alter_remove_columns_sql(table_name, table_delta[:remove_column].values)
|
|
72
|
+
end
|
|
73
|
+
unless table_delta[:add_column].empty?
|
|
74
|
+
results << alter_add_columns_sql(table_name, table_delta[:add_column].values)
|
|
75
|
+
end
|
|
76
|
+
table_delta[:change_column].each do |column_name, column|
|
|
77
|
+
results << alter_change_column_sql(table_name, column_name, column, to_table)
|
|
78
|
+
end
|
|
79
|
+
table_delta[:add_index].each do |_index_name, index|
|
|
80
|
+
results << alter_add_index_sql(table_name, index)
|
|
81
|
+
end
|
|
82
|
+
unless table_delta[:add_foreign_key].empty?
|
|
83
|
+
results << alter_add_foreign_keys_sql(table_name, table_delta[:add_foreign_key].values)
|
|
84
|
+
end
|
|
85
|
+
unless table_delta[:change_table_option].empty?
|
|
86
|
+
results << alter_change_table_sql(table_name, table_delta[:change_table_option])
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
results << '' unless results.empty?
|
|
90
|
+
results
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def alter_add_columns_sql(table_name, columns)
|
|
94
|
+
add_column_sqls = columns.map { |column| %(ALTER TABLE "#{table_name}" ADD COLUMN #{create_column_sql(column, output_primary_key: true)};) }
|
|
95
|
+
comment_sqls = columns.map { |column| comment_on_column_sql(table_name, column) }.compact
|
|
96
|
+
(add_column_sqls + comment_sqls).join("\n")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def alter_remove_columns_sql(table_name, columns)
|
|
100
|
+
columns.map { |column| %(ALTER TABLE "#{table_name}" DROP COLUMN "#{column.column_name}";) }.join("\n")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def alter_rename_column_sql(table_name, old_name, new_name)
|
|
104
|
+
%(ALTER TABLE "#{table_name}" RENAME COLUMN "#{old_name}" TO "#{new_name}";)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def alter_change_column_sql(table_name, column_name, change_column_option, to_table)
|
|
108
|
+
column = to_table[table_name].columns[column_name]
|
|
109
|
+
column.options.merge!(after: change_column_option[:after]) unless change_column_option[:after].nil?
|
|
110
|
+
sqls = []
|
|
111
|
+
sqls << %(ALTER TABLE "#{table_name}" ALTER COLUMN "#{column_name}" TYPE #{postgres_column_type(column)} USING "#{column_name}"::#{postgres_column_type(column)};)
|
|
112
|
+
if column.options[:null]
|
|
113
|
+
sqls << %(ALTER TABLE "#{table_name}" ALTER COLUMN "#{column_name}" DROP NOT NULL;)
|
|
114
|
+
else
|
|
115
|
+
sqls << %(ALTER TABLE "#{table_name}" ALTER COLUMN "#{column_name}" SET NOT NULL;)
|
|
116
|
+
end
|
|
117
|
+
if column.options[:default]
|
|
118
|
+
sqls << %(ALTER TABLE "#{table_name}" ALTER COLUMN "#{column_name}" SET DEFAULT #{quote_default_expression(column.options[:default])};)
|
|
119
|
+
else
|
|
120
|
+
sqls << %(ALTER TABLE "#{table_name}" ALTER COLUMN "#{column_name}" DROP DEFAULT;)
|
|
121
|
+
end
|
|
122
|
+
comment_sql = comment_on_column_sql(table_name, column)
|
|
123
|
+
sqls << comment_sql unless comment_sql.nil?
|
|
124
|
+
sqls.join("\n")
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def alter_change_table_sql(table_name, change_table_option)
|
|
128
|
+
return '' unless change_table_option.key?(:comment)
|
|
129
|
+
%(COMMENT ON TABLE "#{table_name}" IS '#{escape_quote(change_table_option[:comment])}';)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def alter_remove_index_sql(index_name)
|
|
133
|
+
%(DROP INDEX "#{index_name}";)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def alter_add_index_sql(table_name, index)
|
|
137
|
+
sql = 'CREATE'
|
|
138
|
+
sql += ' UNIQUE' if index.options[:unique]
|
|
139
|
+
sql += %( INDEX "#{index.index_name}" ON "#{table_name}" (#{quoted_index_columns(index).join(',')});)
|
|
140
|
+
sql
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def alter_remove_foreign_keys_sql(table_name, index_names)
|
|
144
|
+
index_names.map { |index_name| %(ALTER TABLE "#{table_name}" DROP CONSTRAINT "#{index_name}";) }.join("\n")
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def alter_add_foreign_keys_sql(table_name, foreign_keys)
|
|
148
|
+
foreign_keys.map { |foreign_key| %(ALTER TABLE "#{table_name}" ADD #{foreign_key_constraint_sql(foreign_key)};) }.join("\n")
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def foreign_key_constraint_sql(foreign_key)
|
|
152
|
+
sql = %(CONSTRAINT "#{foreign_key.key_name}" FOREIGN KEY )
|
|
153
|
+
sql += "(#{[foreign_key.from_columns].flatten.map { |v| %("#{v}") }.join(',')}) "
|
|
154
|
+
sql += %(REFERENCES "#{foreign_key.to_table}" )
|
|
155
|
+
sql += "(#{[foreign_key.to_columns].flatten.map { |v| %("#{v}") }.join(',')})"
|
|
156
|
+
sql
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def create_table_sqls(delta)
|
|
160
|
+
delta[:add_table].map do |table_name, table|
|
|
161
|
+
column_sql = (create_table_column_sql(table) << create_table_index_sql(table))
|
|
162
|
+
.flatten
|
|
163
|
+
.reject(&:empty?)
|
|
164
|
+
.join(",\n ")
|
|
165
|
+
sql = <<-SQL
|
|
166
|
+
CREATE TABLE "#{table_name}" (
|
|
167
|
+
#{column_sql}
|
|
168
|
+
);
|
|
169
|
+
SQL
|
|
170
|
+
sql = sql.strip
|
|
171
|
+
index_sqls = table.indexes.values.map { |index| alter_add_index_sql(table_name, index) }
|
|
172
|
+
comment_sqls = create_table_comment_sqls(table_name, table)
|
|
173
|
+
([sql] + index_sqls + comment_sqls).join("\n")
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def create_table_comment_sqls(table_name, table)
|
|
178
|
+
sqls = []
|
|
179
|
+
if table.table_options[:comment] && !table.table_options[:comment].to_s.empty?
|
|
180
|
+
sqls << %(COMMENT ON TABLE "#{table_name}" IS '#{escape_quote(table.table_options[:comment])}';)
|
|
181
|
+
end
|
|
182
|
+
table.columns.values.each do |column|
|
|
183
|
+
comment_sql = comment_on_column_sql(table_name, column)
|
|
184
|
+
sqls << comment_sql unless comment_sql.nil?
|
|
185
|
+
end
|
|
186
|
+
sqls
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def comment_on_column_sql(table_name, column)
|
|
190
|
+
return nil if column.options[:comment].nil? || column.options[:comment].to_s.empty?
|
|
191
|
+
%(COMMENT ON COLUMN "#{table_name}"."#{column.column_name}" IS '#{escape_quote(column.options[:comment])}';)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def drop_table_sqls(delta)
|
|
195
|
+
delta[:remove_table].map do |table_name, _|
|
|
196
|
+
%(DROP TABLE "#{table_name}";)
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def create_table_column_sql(table)
|
|
201
|
+
table.columns.values.map do |column|
|
|
202
|
+
create_column_sql(column)
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def create_column_sql(column, output_primary_key: false, output_identity: true)
|
|
207
|
+
fail NotImplementedError.new("#{column.type} is not supported on PostgreSQL adapter yet") if UNSUPPORTED_TYPES.include?(column.type)
|
|
208
|
+
sql = %("#{column.column_name}")
|
|
209
|
+
sql += " #{postgres_column_type(column)}"
|
|
210
|
+
if column.options[:null]
|
|
211
|
+
sql += ' DEFAULT NULL' unless column.options[:default]
|
|
212
|
+
else
|
|
213
|
+
sql += ' NOT NULL'
|
|
214
|
+
end
|
|
215
|
+
if column.options[:primary_key] && output_primary_key
|
|
216
|
+
sql += ' PRIMARY KEY'
|
|
217
|
+
end
|
|
218
|
+
if column.options[:default]
|
|
219
|
+
sql += " DEFAULT #{quote_default_expression(column.options[:default])}"
|
|
220
|
+
end
|
|
221
|
+
if auto_increment?(column) && output_identity
|
|
222
|
+
sql += ' GENERATED BY DEFAULT AS IDENTITY'
|
|
223
|
+
end
|
|
224
|
+
sql
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def postgres_column_type(column)
|
|
228
|
+
return 'boolean' if column.type == :tinyint && column.options[:limit].to_s == '1'
|
|
229
|
+
type = TYPE_MAPPING[column.type] || column.type.to_s
|
|
230
|
+
if TYPES_WITH_LIMIT.include?(column.type) && column.options[:limit]
|
|
231
|
+
"#{type}(#{column.options[:limit]})"
|
|
232
|
+
elsif column.type == :decimal && column.options[:precision] && column.options[:scale]
|
|
233
|
+
"#{type}(#{column.options[:precision]}, #{column.options[:scale]})"
|
|
234
|
+
else
|
|
235
|
+
type
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def auto_increment?(column)
|
|
240
|
+
extra = column.options[:extra]
|
|
241
|
+
!extra.nil? && extra.to_s.upcase.include?('AUTO_INCREMENT')
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def create_table_index_sql(table)
|
|
245
|
+
pkeys = table.columns.select { |_k, v| v.options[:primary_key] }
|
|
246
|
+
foreign_keys = table.foreign_keys.values
|
|
247
|
+
results = []
|
|
248
|
+
unless pkeys.empty?
|
|
249
|
+
results << %(PRIMARY KEY (#{pkeys.keys.map { |v| %("#{v}") }.join(',')}))
|
|
250
|
+
end
|
|
251
|
+
results << foreign_keys.map { |fk| foreign_key_constraint_sql(fk) }
|
|
252
|
+
results
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def quoted_index_columns(index)
|
|
256
|
+
[index.index_columns].flatten.map { |v| %("#{v}") }
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def escape_quote(value)
|
|
260
|
+
value.to_s.gsub("'", "''")
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def quote_default_expression(value)
|
|
264
|
+
if value.is_a?(Proc)
|
|
265
|
+
value.call
|
|
266
|
+
else
|
|
267
|
+
%('#{escape_quote(value)}')
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
require 'convergence/sql_generator'
|
|
2
|
+
|
|
3
|
+
# SQLite's ALTER TABLE support is intentionally limited: it can add/drop columns
|
|
4
|
+
# and create/drop indexes, but it cannot change a column's type/null/default,
|
|
5
|
+
# nor add/remove a foreign key or primary key on an existing table. Doing any of
|
|
6
|
+
# those requires SQLite's official "rebuild the table" procedure (create a new
|
|
7
|
+
# table, copy the data over, drop the old one, rename), which this adapter does
|
|
8
|
+
# not implement yet -- it raises NotImplementedError instead of generating SQL
|
|
9
|
+
# that SQLite would reject.
|
|
10
|
+
class SQLGenerator::SqliteGenerator < SQLGenerator
|
|
11
|
+
UNSUPPORTED_TYPES = [:enum, :set].freeze
|
|
12
|
+
|
|
13
|
+
attr_reader :original_table
|
|
14
|
+
|
|
15
|
+
def generate(to_table, delta, original_table, safe_migration: false)
|
|
16
|
+
@original_table = original_table
|
|
17
|
+
sqls = []
|
|
18
|
+
sqls << rename_table_sqls(delta)
|
|
19
|
+
sqls << change_table_sql(to_table, delta)
|
|
20
|
+
sqls << (safe_migration ? [] : drop_table_sqls(delta))
|
|
21
|
+
sqls << create_table_sqls(delta)
|
|
22
|
+
sqls.reject!(&:empty?)
|
|
23
|
+
sqls.join("\n")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def rename_table_sqls(delta)
|
|
29
|
+
results = delta[:rename_table].map { |old_name, new_name| %(ALTER TABLE "#{old_name}" RENAME TO "#{new_name}";) }
|
|
30
|
+
results << '' unless results.empty?
|
|
31
|
+
results
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def change_table_sql(to_table, delta)
|
|
35
|
+
change_table = delta[:change_table]
|
|
36
|
+
results = []
|
|
37
|
+
change_table.each do |table_name, table_delta|
|
|
38
|
+
unless table_delta[:remove_foreign_key].empty?
|
|
39
|
+
fail unsupported_alter_error(table_name, 'removing a foreign key')
|
|
40
|
+
end
|
|
41
|
+
unless table_delta[:add_foreign_key].empty?
|
|
42
|
+
fail unsupported_alter_error(table_name, 'adding a foreign key')
|
|
43
|
+
end
|
|
44
|
+
unless table_delta[:change_column].empty?
|
|
45
|
+
columns = table_delta[:change_column].keys.join(', ')
|
|
46
|
+
fail unsupported_alter_error(table_name, "changing column(s) #{columns} (type/null/default/primary key)")
|
|
47
|
+
end
|
|
48
|
+
table_delta[:remove_index].each do |index_name, _index|
|
|
49
|
+
results << alter_remove_index_sql(index_name)
|
|
50
|
+
end
|
|
51
|
+
table_delta[:rename_column].each do |old_name, new_name|
|
|
52
|
+
results << alter_rename_column_sql(table_name, old_name, new_name)
|
|
53
|
+
end
|
|
54
|
+
unless table_delta[:remove_column].empty?
|
|
55
|
+
results << alter_remove_columns_sql(table_name, table_delta[:remove_column].values)
|
|
56
|
+
end
|
|
57
|
+
unless table_delta[:add_column].empty?
|
|
58
|
+
results << alter_add_columns_sql(table_name, table_delta[:add_column].values)
|
|
59
|
+
end
|
|
60
|
+
table_delta[:add_index].each do |_index_name, index|
|
|
61
|
+
results << alter_add_index_sql(table_name, index)
|
|
62
|
+
end
|
|
63
|
+
# SQLite has no table-level options comparable to MySQL's engine/comment/
|
|
64
|
+
# charset, so any detected change_table_option diff is silently ignored.
|
|
65
|
+
end
|
|
66
|
+
results << '' unless results.empty?
|
|
67
|
+
results
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def unsupported_alter_error(table_name, action)
|
|
71
|
+
NotImplementedError.new(
|
|
72
|
+
"SQLite adapter does not support #{action} on an existing table (`#{table_name}`) via ALTER TABLE. " \
|
|
73
|
+
'This requires rebuilding the table (create new, copy data, drop old, rename), which is not supported yet.'
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def alter_add_columns_sql(table_name, columns)
|
|
78
|
+
columns.map { |column| %(ALTER TABLE "#{table_name}" ADD COLUMN #{create_column_sql(column)};) }.join("\n")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def alter_remove_columns_sql(table_name, columns)
|
|
82
|
+
columns.map { |column| %(ALTER TABLE "#{table_name}" DROP COLUMN "#{column.column_name}";) }.join("\n")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def alter_rename_column_sql(table_name, old_name, new_name)
|
|
86
|
+
%(ALTER TABLE "#{table_name}" RENAME COLUMN "#{old_name}" TO "#{new_name}";)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def alter_remove_index_sql(index_name)
|
|
90
|
+
%(DROP INDEX "#{index_name}";)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def alter_add_index_sql(table_name, index)
|
|
94
|
+
sql = 'CREATE'
|
|
95
|
+
sql += ' UNIQUE' if index.options[:unique]
|
|
96
|
+
sql += %( INDEX "#{index.index_name}" ON "#{table_name}" (#{quoted_index_columns(index).join(',')});)
|
|
97
|
+
sql
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def create_table_sqls(delta)
|
|
101
|
+
delta[:add_table].map do |table_name, table|
|
|
102
|
+
column_sql = (create_table_column_sql(table) << create_table_constraint_sql(table))
|
|
103
|
+
.flatten
|
|
104
|
+
.reject(&:empty?)
|
|
105
|
+
.join(",\n ")
|
|
106
|
+
sql = <<-SQL
|
|
107
|
+
CREATE TABLE "#{table_name}" (
|
|
108
|
+
#{column_sql}
|
|
109
|
+
);
|
|
110
|
+
SQL
|
|
111
|
+
index_sqls = table.indexes.values.map { |index| alter_add_index_sql(table_name, index) }
|
|
112
|
+
([sql.strip] + index_sqls).join("\n")
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def drop_table_sqls(delta)
|
|
117
|
+
delta[:remove_table].map do |table_name, _|
|
|
118
|
+
%(DROP TABLE "#{table_name}";)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def create_table_column_sql(table)
|
|
123
|
+
table.columns.values.map do |column|
|
|
124
|
+
create_column_sql(column)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def create_column_sql(column)
|
|
129
|
+
fail NotImplementedError.new("#{column.type} is not supported on the SQLite adapter yet") if UNSUPPORTED_TYPES.include?(column.type)
|
|
130
|
+
return %("#{column.column_name}" INTEGER PRIMARY KEY AUTOINCREMENT) if auto_increment?(column)
|
|
131
|
+
sql = %("#{column.column_name}")
|
|
132
|
+
sql += " #{sqlite_column_type(column)}"
|
|
133
|
+
if column.options[:primary_key]
|
|
134
|
+
sql += ' PRIMARY KEY'
|
|
135
|
+
end
|
|
136
|
+
sql += ' NOT NULL' unless column.options[:null]
|
|
137
|
+
if column.options[:default]
|
|
138
|
+
sql += " DEFAULT #{quote_default_expression(column.options[:default])}"
|
|
139
|
+
end
|
|
140
|
+
sql
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def sqlite_column_type(column)
|
|
144
|
+
type = column.type.to_s
|
|
145
|
+
if column.type == :decimal && column.options[:precision] && column.options[:scale]
|
|
146
|
+
"#{type}(#{column.options[:precision]}, #{column.options[:scale]})"
|
|
147
|
+
elsif column.options[:limit] && !column.options[:limit].to_s.empty?
|
|
148
|
+
"#{type}(#{column.options[:limit]})"
|
|
149
|
+
else
|
|
150
|
+
type
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def auto_increment?(column)
|
|
155
|
+
extra = column.options[:extra]
|
|
156
|
+
!extra.nil? && extra.to_s.upcase.include?('AUTO_INCREMENT')
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# A single-column INTEGER PRIMARY KEY AUTOINCREMENT already declares itself as
|
|
160
|
+
# the primary key inline, so it must not also appear in a table-level
|
|
161
|
+
# PRIMARY KEY (...) clause.
|
|
162
|
+
def create_table_constraint_sql(table)
|
|
163
|
+
pkeys = table.columns.reject { |_k, v| auto_increment?(v) }.select { |_k, v| v.options[:primary_key] }
|
|
164
|
+
foreign_keys = table.foreign_keys.values
|
|
165
|
+
results = []
|
|
166
|
+
unless pkeys.empty?
|
|
167
|
+
results << %(PRIMARY KEY (#{pkeys.keys.map { |v| %("#{v}") }.join(',')}))
|
|
168
|
+
end
|
|
169
|
+
results << foreign_keys.map { |fk| foreign_key_constraint_sql(fk) }
|
|
170
|
+
results
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def foreign_key_constraint_sql(foreign_key)
|
|
174
|
+
sql = %(CONSTRAINT "#{foreign_key.key_name}" FOREIGN KEY )
|
|
175
|
+
sql += "(#{[foreign_key.from_columns].flatten.map { |v| %("#{v}") }.join(',')}) "
|
|
176
|
+
sql += %(REFERENCES "#{foreign_key.to_table}" )
|
|
177
|
+
sql += "(#{[foreign_key.to_columns].flatten.map { |v| %("#{v}") }.join(',')})"
|
|
178
|
+
sql
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def quoted_index_columns(index)
|
|
182
|
+
[index.index_columns].flatten.map { |v| %("#{v}") }
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def quote_default_expression(value)
|
|
186
|
+
if value.is_a?(Proc)
|
|
187
|
+
value.call
|
|
188
|
+
else
|
|
189
|
+
%('#{value.to_s.gsub("'", "''")}')
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
data/lib/convergence/table.rb
CHANGED
|
@@ -3,7 +3,7 @@ require 'convergence/index'
|
|
|
3
3
|
require 'convergence/foreign_key'
|
|
4
4
|
|
|
5
5
|
class Convergence::Table
|
|
6
|
-
attr_accessor :table_name, :table_options, :columns, :indexes, :foreign_keys
|
|
6
|
+
attr_accessor :table_name, :table_options, :columns, :indexes, :foreign_keys, :renamed_from
|
|
7
7
|
|
|
8
8
|
Convergence::Column::COLUMN_TYPE.each do |column_type|
|
|
9
9
|
define_method "#{column_type}" do |column_name, options = {}|
|
|
@@ -46,7 +46,8 @@ class Convergence::Table
|
|
|
46
46
|
|
|
47
47
|
def initialize(table_name, options = {})
|
|
48
48
|
@table_name = table_name
|
|
49
|
-
@
|
|
49
|
+
@renamed_from = options[:renamed_from]&.to_s
|
|
50
|
+
@table_options = options.reject { |k| k == :id || k == :renamed_from }
|
|
50
51
|
@columns = {}
|
|
51
52
|
@indexes = {}
|
|
52
53
|
@foreign_keys = {}
|
data/lib/convergence/version.rb
CHANGED
|
@@ -5,3 +5,15 @@ mysql:
|
|
|
5
5
|
port: 3306
|
|
6
6
|
username: root
|
|
7
7
|
password: root
|
|
8
|
+
|
|
9
|
+
postgresql:
|
|
10
|
+
adapter: postgresql
|
|
11
|
+
database: convergence_test_db
|
|
12
|
+
host: 127.0.0.1
|
|
13
|
+
port: 5432
|
|
14
|
+
username: postgres
|
|
15
|
+
password: postgres
|
|
16
|
+
|
|
17
|
+
sqlite3:
|
|
18
|
+
adapter: sqlite3
|
|
19
|
+
database: sqlite_test_db.sqlite3
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
require 'convergence/config'
|
|
3
|
+
require 'tempfile'
|
|
4
|
+
|
|
5
|
+
describe Convergence::Config do
|
|
6
|
+
describe '#initialize' do
|
|
7
|
+
it 'sets the known attributes' do
|
|
8
|
+
config = Convergence::Config.new(
|
|
9
|
+
adapter: 'mysql2',
|
|
10
|
+
database: 'my_db',
|
|
11
|
+
host: '127.0.0.1',
|
|
12
|
+
port: 3306,
|
|
13
|
+
username: 'root',
|
|
14
|
+
password: 'secret'
|
|
15
|
+
)
|
|
16
|
+
expect(config.adapter).to eq('mysql2')
|
|
17
|
+
expect(config.database).to eq('my_db')
|
|
18
|
+
expect(config.host).to eq('127.0.0.1')
|
|
19
|
+
expect(config.port).to eq(3306)
|
|
20
|
+
expect(config.username).to eq('root')
|
|
21
|
+
expect(config.password).to eq('secret')
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it 'ignores unknown attributes instead of raising' do
|
|
25
|
+
expect { Convergence::Config.new(adapter: 'mysql2', unknown_key: 'value') }.not_to raise_error
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it 'ignores nil-valued attributes' do
|
|
29
|
+
config = Convergence::Config.new(adapter: 'mysql2', database: nil)
|
|
30
|
+
expect(config.database).to be_nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
context 'when the adapter is mysql/mysql2' do
|
|
34
|
+
it 'builds a nested MySQL SSL config object' do
|
|
35
|
+
config = Convergence::Config.new(adapter: 'mysql2', sslca: '/path/to/ca.pem', sslverify: true)
|
|
36
|
+
expect(config.mysql).to be_a(Convergence::Config::MySQL)
|
|
37
|
+
expect(config.mysql.ssl_options).to eq(sslca: '/path/to/ca.pem', sslverify: true)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it 'omits unset SSL options from ssl_options' do
|
|
41
|
+
config = Convergence::Config.new(adapter: 'mysql2')
|
|
42
|
+
expect(config.mysql.ssl_options).to eq({})
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
context 'when the adapter is not mysql/mysql2' do
|
|
47
|
+
it 'does not build a MySQL SSL config object' do
|
|
48
|
+
config = Convergence::Config.new(adapter: 'postgresql')
|
|
49
|
+
expect(config.mysql).to be_nil
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
describe '.load' do
|
|
55
|
+
it 'parses attributes from a YAML file' do
|
|
56
|
+
Tempfile.create(['database', '.yml']) do |file|
|
|
57
|
+
file.write("adapter: mysql2\ndatabase: my_db\nhost: 127.0.0.1\n")
|
|
58
|
+
file.flush
|
|
59
|
+
config = Convergence::Config.load(file.path)
|
|
60
|
+
expect(config.adapter).to eq('mysql2')
|
|
61
|
+
expect(config.database).to eq('my_db')
|
|
62
|
+
expect(config.host).to eq('127.0.0.1')
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
it 'evaluates ERB before parsing the YAML' do
|
|
67
|
+
Tempfile.create(['database', '.yml']) do |file|
|
|
68
|
+
file.write("adapter: mysql2\ndatabase: <%= 'interpolated_' + 'db' %>\n")
|
|
69
|
+
file.flush
|
|
70
|
+
config = Convergence::Config.load(file.path)
|
|
71
|
+
expect(config.database).to eq('interpolated_db')
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|