convergence 1.0.6 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ruby.yml +35 -4
  3. data/.gitignore +1 -0
  4. data/CHANGELOG.md +57 -0
  5. data/CONTRIBUTING.md +80 -0
  6. data/Gemfile.lock +25 -9
  7. data/README.md +160 -1
  8. data/Rakefile +45 -2
  9. data/convergence.gemspec +5 -1
  10. data/lib/convergence/cli.rb +10 -2
  11. data/lib/convergence/column.rb +3 -2
  12. data/lib/convergence/command/apply.rb +38 -9
  13. data/lib/convergence/command/dryrun.rb +5 -5
  14. data/lib/convergence/command/export.rb +19 -1
  15. data/lib/convergence/command.rb +6 -0
  16. data/lib/convergence/database_connector/postgres_connector.rb +25 -0
  17. data/lib/convergence/database_connector/sqlite_connector.rb +22 -0
  18. data/lib/convergence/database_connector.rb +6 -0
  19. data/lib/convergence/default_parameter/mysql_default_parameter.rb +1 -1
  20. data/lib/convergence/default_parameter/postgres_default_parameter.rb +57 -0
  21. data/lib/convergence/default_parameter/sqlite_default_parameter.rb +57 -0
  22. data/lib/convergence/default_parameter.rb +6 -0
  23. data/lib/convergence/diff.rb +46 -3
  24. data/lib/convergence/dsl.rb +18 -3
  25. data/lib/convergence/dumper/mysql_schema_dumper.rb +37 -9
  26. data/lib/convergence/dumper/postgres_schema_dumper.rb +193 -0
  27. data/lib/convergence/dumper/sqlite_schema_dumper.rb +132 -0
  28. data/lib/convergence/dumper.rb +113 -0
  29. data/lib/convergence/sql_generator/mysql_generator.rb +32 -3
  30. data/lib/convergence/sql_generator/postgres_generator.rb +270 -0
  31. data/lib/convergence/sql_generator/sqlite_generator.rb +192 -0
  32. data/lib/convergence/table.rb +3 -2
  33. data/lib/convergence/version.rb +1 -1
  34. data/spec/config/spec_database.yml +12 -0
  35. data/spec/convergence/config_spec.rb +75 -0
  36. data/spec/convergence/diff_spec.rb +291 -44
  37. data/spec/convergence/dsl_spec.rb +26 -0
  38. data/spec/convergence/dumper/mysql_schema_dumper_spec.rb +25 -2
  39. data/spec/convergence/dumper/postgres_schema_dumper_spec.rb +103 -0
  40. data/spec/convergence/dumper/sqlite_schema_dumper_spec.rb +85 -0
  41. data/spec/convergence/dumper_spec.rb +110 -16
  42. data/spec/convergence/foreign_key_spec.rb +33 -0
  43. data/spec/convergence/index_spec.rb +52 -0
  44. data/spec/convergence/pretty_diff_spec.rb +85 -0
  45. data/spec/convergence/table_spec.rb +46 -0
  46. data/spec/fixtures/add_table_with_enum_set.schema +5 -0
  47. data/spec/fixtures/change_table_comment_to_paper.schema +2 -0
  48. data/spec/fixtures/execute_raw_sql.schema +28 -0
  49. data/spec/fixtures/postgres/add_columns_to_paper.schema +29 -0
  50. data/spec/fixtures/postgres/add_table.schema +32 -0
  51. data/spec/fixtures/postgres/change_comment_columns_to_paper.schema +28 -0
  52. data/spec/fixtures/postgres/change_table_comment_to_paper.schema +28 -0
  53. data/spec/fixtures/postgres/drop_foreign_key.schema +25 -0
  54. data/spec/fixtures/postgres/drop_table.schema +19 -0
  55. data/spec/fixtures/postgres/remove_columns_to_paper.schema +27 -0
  56. data/spec/fixtures/postgres/rename_column_on_paper.schema +28 -0
  57. data/spec/fixtures/postgres/rename_table.schema +28 -0
  58. data/spec/fixtures/postgres_test_db.sql +41 -0
  59. data/spec/fixtures/rename_column_to_author.schema +26 -0
  60. data/spec/fixtures/rename_table.schema +26 -0
  61. data/spec/fixtures/sqlite/add_columns_to_paper.schema +29 -0
  62. data/spec/fixtures/sqlite/add_table.schema +32 -0
  63. data/spec/fixtures/sqlite/change_columns_to_paper.schema +28 -0
  64. data/spec/fixtures/sqlite/drop_foreign_key.schema +25 -0
  65. data/spec/fixtures/sqlite/drop_table.schema +19 -0
  66. data/spec/fixtures/sqlite/remove_columns_to_paper.schema +27 -0
  67. data/spec/fixtures/sqlite/rename_column_on_paper.schema +28 -0
  68. data/spec/fixtures/sqlite/rename_table.schema +27 -0
  69. data/spec/fixtures/sqlite_test_db.sql +31 -0
  70. data/spec/fixtures/test_db.sql +10 -0
  71. data/spec/integrations/command_diff.rb +35 -0
  72. data/spec/integrations/command_dryrun.rb +59 -2
  73. data/spec/integrations/command_export.rb +28 -0
  74. data/spec/postgres_integrations/command_dryrun.rb +101 -0
  75. data/spec/spec_helper.rb +13 -0
  76. data/spec/sqlite_integrations/command_dryrun.rb +92 -0
  77. metadata +135 -7
@@ -0,0 +1,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
@@ -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
- @table_options = options.reject { |k| k == :id }
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 = {}
@@ -1,3 +1,3 @@
1
1
  module Convergence
2
- VERSION = '1.0.6'
2
+ VERSION = '1.2.0'
3
3
  end
@@ -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