db_schema 0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +9 -0
  3. data/.rspec +2 -0
  4. data/.travis.yml +4 -0
  5. data/Gemfile +4 -0
  6. data/Guardfile +17 -0
  7. data/LICENSE.txt +21 -0
  8. data/README.md +522 -0
  9. data/Rakefile +6 -0
  10. data/bin/console +10 -0
  11. data/bin/setup +8 -0
  12. data/db_schema.gemspec +35 -0
  13. data/lib/db_schema.rb +125 -0
  14. data/lib/db_schema/awesome_print.rb +246 -0
  15. data/lib/db_schema/changes.rb +396 -0
  16. data/lib/db_schema/configuration.rb +29 -0
  17. data/lib/db_schema/definitions.rb +122 -0
  18. data/lib/db_schema/definitions/field.rb +38 -0
  19. data/lib/db_schema/definitions/field/array.rb +19 -0
  20. data/lib/db_schema/definitions/field/base.rb +90 -0
  21. data/lib/db_schema/definitions/field/binary.rb +9 -0
  22. data/lib/db_schema/definitions/field/bit_string.rb +15 -0
  23. data/lib/db_schema/definitions/field/boolean.rb +9 -0
  24. data/lib/db_schema/definitions/field/character.rb +19 -0
  25. data/lib/db_schema/definitions/field/custom.rb +22 -0
  26. data/lib/db_schema/definitions/field/datetime.rb +30 -0
  27. data/lib/db_schema/definitions/field/geometric.rb +33 -0
  28. data/lib/db_schema/definitions/field/json.rb +13 -0
  29. data/lib/db_schema/definitions/field/monetary.rb +9 -0
  30. data/lib/db_schema/definitions/field/network.rb +17 -0
  31. data/lib/db_schema/definitions/field/numeric.rb +30 -0
  32. data/lib/db_schema/definitions/field/range.rb +29 -0
  33. data/lib/db_schema/definitions/field/text_search.rb +13 -0
  34. data/lib/db_schema/definitions/field/uuid.rb +9 -0
  35. data/lib/db_schema/dsl.rb +145 -0
  36. data/lib/db_schema/reader.rb +270 -0
  37. data/lib/db_schema/runner.rb +220 -0
  38. data/lib/db_schema/utils.rb +50 -0
  39. data/lib/db_schema/validator.rb +89 -0
  40. data/lib/db_schema/version.rb +3 -0
  41. metadata +239 -0
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require 'bundler/gem_tasks'
2
+ require 'rspec/core/rake_task'
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task default: :spec
data/bin/console ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'bundler/setup'
4
+ require 'db_schema'
5
+
6
+ require 'pry'
7
+ require 'awesome_print'
8
+ AwesomePrint.pry!
9
+
10
+ Pry.start
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ createdb db_schema_test
data/db_schema.gemspec ADDED
@@ -0,0 +1,35 @@
1
+ lib = File.expand_path('../lib', __FILE__)
2
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
+ require 'db_schema/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'db_schema'
7
+ spec.version = DbSchema::VERSION
8
+ spec.authors = ['Vsevolod Romashov']
9
+ spec.email = ['7@7vn.ru']
10
+
11
+ spec.summary = 'Declarative database schema definition.'
12
+ spec.description = 'A database schema management tool that reads a "single-source-of-truth" schema definition from a ruby file and auto-migrates the database to conform to it.'
13
+ spec.homepage = 'https://github.com/7even/db_schema'
14
+ spec.license = 'MIT'
15
+
16
+ spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r(^spec/)) }
17
+ spec.bindir = 'exe'
18
+ spec.executables = spec.files.grep(%r(^exe/)) { |f| File.basename(f) }
19
+ spec.require_paths = ['lib']
20
+
21
+ spec.add_runtime_dependency 'sequel'
22
+ spec.add_runtime_dependency 'dry-equalizer', '~> 0.2'
23
+
24
+ spec.add_development_dependency 'bundler', '~> 1.11'
25
+ spec.add_development_dependency 'rake', '~> 10.0'
26
+ spec.add_development_dependency 'pry'
27
+ spec.add_development_dependency 'awesome_print', '~> 1.7'
28
+
29
+ spec.add_development_dependency 'rspec', '~> 3.0'
30
+ spec.add_development_dependency 'guard-rspec'
31
+ spec.add_development_dependency 'terminal-notifier'
32
+ spec.add_development_dependency 'terminal-notifier-guard'
33
+
34
+ spec.add_development_dependency 'pg'
35
+ end
data/lib/db_schema.rb ADDED
@@ -0,0 +1,125 @@
1
+ require 'sequel'
2
+ require 'yaml'
3
+
4
+ require 'db_schema/configuration'
5
+ require 'db_schema/utils'
6
+ require 'db_schema/definitions'
7
+ require 'db_schema/awesome_print'
8
+ require 'db_schema/dsl'
9
+ require 'db_schema/validator'
10
+ require 'db_schema/reader'
11
+ require 'db_schema/changes'
12
+ require 'db_schema/runner'
13
+ require 'db_schema/version'
14
+
15
+ module DbSchema
16
+ class << self
17
+ def describe(&block)
18
+ desired_schema = DSL.new(block).schema
19
+ validate(desired_schema)
20
+
21
+ actual_schema = Reader.read_schema
22
+ changes = Changes.between(desired_schema, actual_schema)
23
+ return if changes.empty?
24
+
25
+ log_changes(changes) if configuration.log_changes?
26
+ return if configuration.dry_run?
27
+
28
+ Runner.new(changes).run!
29
+
30
+ if configuration.post_check_enabled?
31
+ perform_post_check(desired_schema)
32
+ end
33
+ end
34
+
35
+ def connection
36
+ @connection ||= Sequel.connect(
37
+ adapter: configuration.adapter,
38
+ host: configuration.host,
39
+ port: configuration.port,
40
+ database: configuration.database,
41
+ user: configuration.user,
42
+ password: configuration.password
43
+ ).tap do |db|
44
+ db.extension :pg_enum
45
+ db.extension :pg_array
46
+ end
47
+ end
48
+
49
+ def configure(connection_parameters)
50
+ @configuration = Configuration.new(connection_parameters)
51
+ @connection = nil
52
+ end
53
+
54
+ def configure_from_yaml(yaml_path, environment, **other_options)
55
+ data = Utils.symbolize_keys(YAML.load_file(yaml_path))
56
+ filtered_data = Utils.filter_by_keys(
57
+ data[environment.to_sym],
58
+ *%i(adapter host port database username password)
59
+ )
60
+ renamed_data = Utils.rename_keys(filtered_data, username: :user)
61
+
62
+ configure(renamed_data.merge(other_options))
63
+ end
64
+
65
+ def configuration
66
+ raise 'You must call DbSchema.configure in order to connect to the database.' if @configuration.nil?
67
+
68
+ @configuration
69
+ end
70
+
71
+ def reset!
72
+ @configuration = nil
73
+ @connection = nil
74
+ end
75
+
76
+ private
77
+ def validate(schema)
78
+ validation_result = Validator.validate(schema)
79
+
80
+ unless validation_result.valid?
81
+ message = "Requested schema is invalid:\n\n"
82
+
83
+ validation_result.errors.each do |error|
84
+ message << "* #{error}\n"
85
+ end
86
+
87
+ raise InvalidSchemaError, message
88
+ end
89
+ end
90
+
91
+ def log_changes(changes)
92
+ return if changes.empty?
93
+
94
+ puts 'DbSchema is applying these changes to the database:'
95
+ if changes.respond_to?(:ai)
96
+ puts changes.ai
97
+ else
98
+ puts changes.inspect
99
+ end
100
+ end
101
+
102
+ def perform_post_check(desired_schema)
103
+ unapplied_changes = Changes.between(desired_schema, Reader.read_schema)
104
+ return if unapplied_changes.empty?
105
+
106
+ readable_changes = if unapplied_changes.respond_to?(:ai)
107
+ unapplied_changes.ai
108
+ else
109
+ unapplied_changes.inspect
110
+ end
111
+
112
+ message = <<-ERROR
113
+ Your database still differs from the expected schema after applying it; if you are 100% sure this is ok you can turn these checks off with DbSchema.configure(post_check: false). Here are the differences:
114
+
115
+ #{readable_changes}
116
+ ERROR
117
+
118
+ raise SchemaMismatch, message
119
+ end
120
+ end
121
+
122
+ class InvalidSchemaError < ArgumentError; end
123
+ class SchemaMismatch < RuntimeError; end
124
+ class UnsupportedOperation < ArgumentError; end
125
+ end
@@ -0,0 +1,246 @@
1
+ begin
2
+ require 'awesome_print'
3
+ rescue LoadError
4
+ end
5
+
6
+ if defined?(AwesomePrint)
7
+ module AwesomePrint
8
+ module DbSchema
9
+ def self.included(base)
10
+ base.send :alias_method, :cast_without_dbschema, :cast
11
+ base.send :alias_method, :cast, :cast_with_dbschema
12
+ end
13
+
14
+ def cast_with_dbschema(object, type)
15
+ case object
16
+ when ::DbSchema::Definitions::Table
17
+ :dbschema_table
18
+ when ::DbSchema::Definitions::Field::Custom
19
+ :dbschema_custom_field
20
+ when ::DbSchema::Definitions::Field::Base
21
+ :dbschema_field
22
+ when ::DbSchema::Definitions::Index
23
+ :dbschema_index
24
+ when ::DbSchema::Definitions::Index::Field
25
+ :dbschema_index_field
26
+ when ::DbSchema::Definitions::CheckConstraint
27
+ :dbschema_check_constraint
28
+ when ::DbSchema::Definitions::ForeignKey
29
+ :dbschema_foreign_key
30
+ when ::DbSchema::Definitions::Enum
31
+ :dbschema_enum
32
+ when ::DbSchema::Changes::CreateTable
33
+ :dbschema_create_table
34
+ when ::DbSchema::Changes::DropTable
35
+ :dbschema_drop_table
36
+ when ::DbSchema::Changes::AlterTable
37
+ :dbschema_alter_table
38
+ when ::DbSchema::Changes::CreateColumn
39
+ :dbschema_create_column
40
+ when ::DbSchema::Changes::DropColumn
41
+ :dbschema_column_operation
42
+ when ::DbSchema::Changes::RenameColumn
43
+ :dbschema_rename_column
44
+ when ::DbSchema::Changes::AlterColumnType
45
+ :dbschema_alter_column_type
46
+ when ::DbSchema::Changes::CreatePrimaryKey,
47
+ ::DbSchema::Changes::DropPrimaryKey,
48
+ ::DbSchema::Changes::AllowNull,
49
+ ::DbSchema::Changes::DisallowNull
50
+ :dbschema_column_operation
51
+ when ::DbSchema::Changes::AlterColumnDefault
52
+ :dbschema_alter_column_default
53
+ when ::DbSchema::Changes::CreateIndex
54
+ :dbschema_index
55
+ when ::DbSchema::Changes::DropIndex
56
+ :dbschema_column_operation
57
+ when ::DbSchema::Changes::CreateCheckConstraint
58
+ :dbschema_check_constraint
59
+ when ::DbSchema::Changes::DropCheckConstraint
60
+ :dbschema_column_operation
61
+ when ::DbSchema::Changes::CreateForeignKey
62
+ :dbschema_create_foreign_key
63
+ when ::DbSchema::Changes::DropForeignKey
64
+ :dbschema_drop_foreign_key
65
+ when ::DbSchema::Changes::CreateEnum
66
+ :dbschema_enum
67
+ when ::DbSchema::Changes::DropEnum
68
+ :dbschema_column_operation
69
+ when ::DbSchema::Changes::AddValueToEnum
70
+ :dbschema_add_value_to_enum
71
+ else
72
+ cast_without_dbschema(object, type)
73
+ end
74
+ end
75
+
76
+ private
77
+ def awesome_dbschema_table(object)
78
+ data = ["fields: #{object.fields.ai}"]
79
+ data << "indices: #{object.indices.ai}" if object.indices.any?
80
+ data << "checks: #{object.checks.ai}" if object.checks.any?
81
+ data << "foreign_keys: #{object.foreign_keys.ai}" if object.foreign_keys.any?
82
+
83
+ data_string = indent_lines(data.join(', '))
84
+ "#<DbSchema::Definitions::Table #{object.name.ai} #{data_string}>"
85
+ end
86
+
87
+ def awesome_dbschema_field(object)
88
+ options = object.options.map do |k, v|
89
+ key = colorize("#{k}:", :symbol)
90
+ "#{key} #{v.ai}"
91
+ end.unshift(nil).join(', ')
92
+
93
+ primary_key = if object.primary_key?
94
+ ', ' + colorize('primary key', :nilclass)
95
+ else
96
+ ''
97
+ end
98
+
99
+ "#<#{object.class} #{object.name.ai}#{options}#{primary_key}>"
100
+ end
101
+
102
+ def awesome_dbschema_custom_field(object)
103
+ options = object.options.map do |k, v|
104
+ key = colorize("#{k}:", :symbol)
105
+ "#{key} #{v.ai}"
106
+ end.unshift(nil).join(', ')
107
+
108
+ primary_key = if object.primary_key?
109
+ ', ' + colorize('primary key', :nilclass)
110
+ else
111
+ ''
112
+ end
113
+
114
+ "#<#{object.class} (#{object.type.ai}) #{object.name.ai}#{options}#{primary_key}>"
115
+ end
116
+
117
+ def awesome_dbschema_index(object)
118
+ fields = format_dbschema_fields(object.fields)
119
+ using = ' using ' + colorize(object.type.to_s, :symbol) unless object.btree?
120
+
121
+ data = [nil]
122
+ data << colorize('unique', :nilclass) if object.unique?
123
+ data << colorize('condition: ', :symbol) + object.condition.ai unless object.condition.nil?
124
+
125
+ "#<#{object.class} #{object.name.ai} on #{fields}#{using}#{data.join(', ')}>"
126
+ end
127
+
128
+ def awesome_dbschema_index_field(object)
129
+ data = [object.name.ai]
130
+
131
+ if object.desc?
132
+ data << colorize('desc', :nilclass)
133
+ data << colorize('nulls last', :symbol) if object.nulls == :last
134
+ else
135
+ data << colorize('nulls first', :symbol) if object.nulls == :first
136
+ end
137
+
138
+ data.join(' ')
139
+ end
140
+
141
+ def awesome_dbschema_check_constraint(object)
142
+ "#<#{object.class} #{object.name.ai} #{object.condition.ai}>"
143
+ end
144
+
145
+ def awesome_dbschema_foreign_key(object)
146
+ fields = format_dbschema_fields(object.fields)
147
+ references = "#{colorize('references', :class)} #{object.table.ai}"
148
+ references << ' ' + format_dbschema_fields(object.keys) unless object.references_primary_key?
149
+
150
+ data = [nil]
151
+ data << colorize("on_update:", :symbol) + " #{object.on_update.ai}" unless object.on_update == :no_action
152
+ data << colorize("on_delete:", :symbol) + " #{object.on_delete.ai}" unless object.on_delete == :no_action
153
+ data << colorize('deferrable', :nilclass) if object.deferrable?
154
+
155
+ "#<#{object.class} #{object.name.ai} on #{fields} #{references}#{data.join(', ')}>"
156
+ end
157
+
158
+ def awesome_dbschema_enum(object)
159
+ values = object.values.map do |value|
160
+ colorize(value.to_s, :string)
161
+ end.join(', ')
162
+
163
+ "#<#{object.class} #{object.name.ai} (#{values})>"
164
+ end
165
+
166
+ def awesome_dbschema_create_table(object)
167
+ data = ["fields: #{object.fields.ai}"]
168
+ data << "indices: #{object.indices.ai}" if object.indices.any?
169
+ data << "checks: #{object.checks.ai}" if object.checks.any?
170
+
171
+ data_string = indent_lines(data.join(', '))
172
+ "#<DbSchema::Changes::CreateTable #{object.name.ai} #{data_string}>"
173
+ end
174
+
175
+ def awesome_dbschema_drop_table(object)
176
+ "#<DbSchema::Changes::DropTable #{object.name.ai}>"
177
+ end
178
+
179
+ def awesome_dbschema_alter_table(object)
180
+ data = ["fields: #{object.fields.ai}"]
181
+ data << "indices: #{object.indices.ai}" if object.indices.any?
182
+ data << "checks: #{object.checks.ai}" if object.checks.any?
183
+
184
+ data_string = indent_lines(data.join(', '))
185
+ "#<DbSchema::Changes::AlterTable #{object.name.ai} #{data_string}>"
186
+ end
187
+
188
+ def awesome_dbschema_create_column(object)
189
+ "#<DbSchema::Changes::CreateColumn #{object.field.ai}>"
190
+ end
191
+
192
+ def awesome_dbschema_drop_column(object)
193
+ "#<DbSchema::Changes::DropColumn #{object.name.ai}>"
194
+ end
195
+
196
+ def awesome_dbschema_rename_column(object)
197
+ "#<DbSchema::Changes::RenameColumn #{object.old_name.ai} => #{object.new_name.ai}>"
198
+ end
199
+
200
+ def awesome_dbschema_alter_column_type(object)
201
+ attributes = object.new_attributes.map do |k, v|
202
+ key = colorize("#{k}:", :symbol)
203
+ "#{key} #{v.ai}"
204
+ end.unshift(nil).join(', ')
205
+
206
+ "#<DbSchema::Changes::AlterColumnType #{object.name.ai}, #{object.new_type.ai}#{attributes}>"
207
+ end
208
+
209
+ def awesome_dbschema_alter_column_default(object)
210
+ "#<DbSchema::Changes::AlterColumnDefault #{object.name.ai}, #{object.new_default.ai}>"
211
+ end
212
+
213
+ def awesome_dbschema_create_foreign_key(object)
214
+ "#<DbSchema::Changes::CreateForeignKey #{object.foreign_key.ai} on #{object.table_name.ai}>"
215
+ end
216
+
217
+ def awesome_dbschema_drop_foreign_key(object)
218
+ "#<DbSchema::Changes::DropForeignKey #{object.fkey_name.ai} on #{object.table_name.ai}>"
219
+ end
220
+
221
+ def awesome_dbschema_column_operation(object)
222
+ "#<#{object.class} #{object.name.ai}>"
223
+ end
224
+
225
+ def awesome_dbschema_add_value_to_enum(object)
226
+ before = " before #{object.before.ai}" unless object.add_to_the_end?
227
+
228
+ "#<DbSchema::Changes::AddValueToEnum #{object.new_value.ai} to #{object.enum_name.ai}#{before}>"
229
+ end
230
+
231
+ def format_dbschema_fields(fields)
232
+ if fields.one?
233
+ fields.first.ai
234
+ else
235
+ '[' + fields.map(&:ai).join(', ') + ']'
236
+ end
237
+ end
238
+
239
+ def indent_lines(text, indent_level = 4)
240
+ text.gsub(/(?<!\A)^/, ' ' * indent_level)
241
+ end
242
+ end
243
+ end
244
+
245
+ AwesomePrint::Formatter.send(:include, AwesomePrint::DbSchema)
246
+ end
@@ -0,0 +1,396 @@
1
+ require 'db_schema/definitions'
2
+ require 'dry/equalizer'
3
+
4
+ module DbSchema
5
+ module Changes
6
+ class << self
7
+ def between(desired_schema, actual_schema)
8
+ desired_tables = extract_tables(desired_schema)
9
+ actual_tables = extract_tables(actual_schema)
10
+
11
+ table_names = [desired_tables, actual_tables].flatten.map(&:name).uniq
12
+
13
+ table_changes = table_names.each.with_object([]) do |table_name, changes|
14
+ desired = desired_tables.find { |table| table.name == table_name }
15
+ actual = actual_tables.find { |table| table.name == table_name }
16
+
17
+ if desired && !actual
18
+ changes << CreateTable.new(
19
+ table_name,
20
+ fields: desired.fields,
21
+ indices: desired.indices,
22
+ checks: desired.checks
23
+ )
24
+
25
+ fkey_operations = desired.foreign_keys.map do |fkey|
26
+ CreateForeignKey.new(table_name, fkey)
27
+ end
28
+ changes.concat(fkey_operations)
29
+ elsif actual && !desired
30
+ changes << DropTable.new(table_name)
31
+
32
+ actual.foreign_keys.each do |fkey|
33
+ changes << DropForeignKey.new(table_name, fkey.name)
34
+ end
35
+ elsif actual != desired
36
+ field_operations = field_changes(desired.fields, actual.fields)
37
+ index_operations = index_changes(desired.indices, actual.indices)
38
+ check_operations = check_changes(desired.checks, actual.checks)
39
+ fkey_operations = foreign_key_changes(table_name, desired.foreign_keys, actual.foreign_keys)
40
+
41
+ if field_operations.any? || index_operations.any? || check_operations.any?
42
+ changes << AlterTable.new(
43
+ table_name,
44
+ fields: field_operations,
45
+ indices: index_operations,
46
+ checks: check_operations
47
+ )
48
+ end
49
+
50
+ changes.concat(fkey_operations)
51
+ end
52
+ end
53
+
54
+ desired_enums = extract_enums(desired_schema)
55
+ actual_enums = extract_enums(actual_schema)
56
+
57
+ enum_names = [desired_enums, actual_enums].flatten.map(&:name).uniq
58
+
59
+ enum_changes = enum_names.each_with_object([]) do |enum_name, changes|
60
+ desired = desired_enums.find { |enum| enum.name == enum_name }
61
+ actual = actual_enums.find { |enum| enum.name == enum_name }
62
+
63
+ if desired && !actual
64
+ changes << CreateEnum.new(enum_name, desired.values)
65
+ elsif actual && !desired
66
+ changes << DropEnum.new(enum_name)
67
+ elsif actual != desired
68
+ new_values = desired.values - actual.values
69
+ dropped_values = actual.values - desired.values
70
+
71
+ if dropped_values.any?
72
+ raise UnsupportedOperation, "Enum #{enum_name.inspect} doesn't describe values #{dropped_values.inspect} that are present in the database; dropping values from enums is not supported."
73
+ end
74
+
75
+ if desired.values - new_values != actual.values
76
+ raise UnsupportedOperation, "Enum #{enum_name.inspect} describes values #{(desired.values - new_values).inspect} that are present in the database in a different order (#{actual.values.inspect}); reordering values in enums is not supported."
77
+ end
78
+
79
+ new_values.reverse.each do |value|
80
+ value_index = desired.values.index(value)
81
+
82
+ if value_index == desired.values.count - 1
83
+ changes << AddValueToEnum.new(enum_name, value)
84
+ else
85
+ next_value = desired.values[value_index + 1]
86
+ changes << AddValueToEnum.new(enum_name, value, before: next_value)
87
+ end
88
+ end
89
+ end
90
+ end
91
+
92
+ table_changes + enum_changes
93
+ end
94
+
95
+ private
96
+ def field_changes(desired_fields, actual_fields)
97
+ field_names = [desired_fields, actual_fields].flatten.map(&:name).uniq
98
+
99
+ field_names.each.with_object([]) do |field_name, table_changes|
100
+ desired = desired_fields.find { |field| field.name == field_name }
101
+ actual = actual_fields.find { |field| field.name == field_name }
102
+
103
+ if desired && !actual
104
+ table_changes << CreateColumn.new(desired)
105
+ elsif actual && !desired
106
+ table_changes << DropColumn.new(field_name)
107
+ elsif actual != desired
108
+ if (actual.type != desired.type) || (actual.attributes != desired.attributes)
109
+ table_changes << AlterColumnType.new(
110
+ field_name,
111
+ new_type: desired.type,
112
+ **desired.attributes
113
+ )
114
+ end
115
+
116
+ if desired.primary_key? && !actual.primary_key?
117
+ table_changes << CreatePrimaryKey.new(field_name)
118
+ end
119
+
120
+ if actual.primary_key? && !desired.primary_key?
121
+ table_changes << DropPrimaryKey.new(field_name)
122
+ end
123
+
124
+ if desired.null? && !actual.null?
125
+ table_changes << AllowNull.new(field_name)
126
+ end
127
+
128
+ if actual.null? && !desired.null?
129
+ table_changes << DisallowNull.new(field_name)
130
+ end
131
+
132
+ if actual.default != desired.default
133
+ table_changes << AlterColumnDefault.new(field_name, new_default: desired.default)
134
+ end
135
+ end
136
+ end
137
+ end
138
+
139
+ def index_changes(desired_indices, actual_indices)
140
+ index_names = [desired_indices, actual_indices].flatten.map(&:name).uniq
141
+
142
+ index_names.each.with_object([]) do |index_name, table_changes|
143
+ desired = desired_indices.find { |index| index.name == index_name }
144
+ actual = actual_indices.find { |index| index.name == index_name }
145
+
146
+ if desired && !actual
147
+ table_changes << CreateIndex.new(
148
+ name: index_name,
149
+ fields: desired.fields,
150
+ unique: desired.unique?,
151
+ type: desired.type,
152
+ condition: desired.condition
153
+ )
154
+ elsif actual && !desired
155
+ table_changes << DropIndex.new(index_name)
156
+ elsif actual != desired
157
+ table_changes << DropIndex.new(index_name)
158
+ table_changes << CreateIndex.new(
159
+ name: index_name,
160
+ fields: desired.fields,
161
+ unique: desired.unique?,
162
+ type: desired.type,
163
+ condition: desired.condition
164
+ )
165
+ end
166
+ end
167
+ end
168
+
169
+ def check_changes(desired_checks, actual_checks)
170
+ check_names = [desired_checks, actual_checks].flatten.map(&:name).uniq
171
+
172
+ check_names.each.with_object([]) do |check_name, table_changes|
173
+ desired = desired_checks.find { |check| check.name == check_name }
174
+ actual = actual_checks.find { |check| check.name == check_name }
175
+
176
+ if desired && !actual
177
+ table_changes << CreateCheckConstraint.new(
178
+ name: check_name,
179
+ condition: desired.condition
180
+ )
181
+ elsif actual && !desired
182
+ table_changes << DropCheckConstraint.new(check_name)
183
+ elsif actual != desired
184
+ table_changes << DropCheckConstraint.new(check_name)
185
+ table_changes << CreateCheckConstraint.new(
186
+ name: check_name,
187
+ condition: desired.condition
188
+ )
189
+ end
190
+ end
191
+ end
192
+
193
+ def foreign_key_changes(table_name, desired_foreign_keys, actual_foreign_keys)
194
+ key_names = [desired_foreign_keys, actual_foreign_keys].flatten.map(&:name).uniq
195
+
196
+ key_names.each.with_object([]) do |key_name, table_changes|
197
+ desired = desired_foreign_keys.find { |key| key.name == key_name }
198
+ actual = actual_foreign_keys.find { |key| key.name == key_name }
199
+
200
+ foreign_key = Definitions::ForeignKey.new(
201
+ name: key_name,
202
+ fields: desired.fields,
203
+ table: desired.table,
204
+ keys: desired.keys,
205
+ on_delete: desired.on_delete,
206
+ on_update: desired.on_update,
207
+ deferrable: desired.deferrable?
208
+ ) if desired
209
+
210
+ if desired && !actual
211
+ table_changes << CreateForeignKey.new(table_name, foreign_key)
212
+ elsif actual && !desired
213
+ table_changes << DropForeignKey.new(table_name, key_name)
214
+ elsif actual != desired
215
+ table_changes << DropForeignKey.new(table_name, key_name)
216
+ table_changes << CreateForeignKey.new(table_name, foreign_key)
217
+ end
218
+ end
219
+ end
220
+
221
+ def extract_tables(schema)
222
+ Utils.filter_by_class(schema, Definitions::Table)
223
+ end
224
+
225
+ def extract_enums(schema)
226
+ Utils.filter_by_class(schema, Definitions::Enum)
227
+ end
228
+ end
229
+
230
+ class CreateTable
231
+ include Dry::Equalizer(:name, :fields, :indices, :checks)
232
+ attr_reader :name, :fields, :indices, :checks
233
+
234
+ def initialize(name, fields: [], indices: [], checks: [])
235
+ @name = name
236
+ @fields = fields
237
+ @indices = indices
238
+ @checks = checks
239
+ end
240
+ end
241
+
242
+ class DropTable
243
+ include Dry::Equalizer(:name)
244
+ attr_reader :name
245
+
246
+ def initialize(name)
247
+ @name = name
248
+ end
249
+ end
250
+
251
+ class AlterTable
252
+ include Dry::Equalizer(:name, :fields, :indices, :checks)
253
+ attr_reader :name, :fields, :indices, :checks
254
+
255
+ def initialize(name, fields: [], indices: [], checks: [])
256
+ @name = name
257
+ @fields = fields
258
+ @indices = indices
259
+ @checks = checks
260
+ end
261
+ end
262
+
263
+ # Abstract base class for single-column toggle operations.
264
+ class ColumnOperation
265
+ include Dry::Equalizer(:name)
266
+ attr_reader :name
267
+
268
+ def initialize(name)
269
+ @name = name
270
+ end
271
+ end
272
+
273
+ class CreateColumn
274
+ include Dry::Equalizer(:field)
275
+ attr_reader :field
276
+
277
+ def initialize(field)
278
+ @field = field
279
+ end
280
+
281
+ def name
282
+ field.name
283
+ end
284
+
285
+ def type
286
+ field.type
287
+ end
288
+
289
+ def primary_key?
290
+ field.primary_key?
291
+ end
292
+
293
+ def options
294
+ field.options
295
+ end
296
+ end
297
+
298
+ class DropColumn < ColumnOperation
299
+ end
300
+
301
+ class RenameColumn
302
+ attr_reader :old_name, :new_name
303
+
304
+ def initialize(old_name:, new_name:)
305
+ @old_name = old_name
306
+ @new_name = new_name
307
+ end
308
+ end
309
+
310
+ class AlterColumnType
311
+ include Dry::Equalizer(:name, :new_type, :new_attributes)
312
+ attr_reader :name, :new_type, :new_attributes
313
+
314
+ def initialize(name, new_type:, **new_attributes)
315
+ @name = name
316
+ @new_type = new_type
317
+ @new_attributes = new_attributes
318
+ end
319
+ end
320
+
321
+ class CreatePrimaryKey < ColumnOperation
322
+ end
323
+
324
+ class DropPrimaryKey < ColumnOperation
325
+ end
326
+
327
+ class AllowNull < ColumnOperation
328
+ end
329
+
330
+ class DisallowNull < ColumnOperation
331
+ end
332
+
333
+ class AlterColumnDefault
334
+ include Dry::Equalizer(:name, :new_default)
335
+ attr_reader :name, :new_default
336
+
337
+ def initialize(name, new_default:)
338
+ @name = name
339
+ @new_default = new_default
340
+ end
341
+ end
342
+
343
+ class CreateIndex < Definitions::Index
344
+ end
345
+
346
+ class DropIndex < ColumnOperation
347
+ end
348
+
349
+ class CreateCheckConstraint < Definitions::CheckConstraint
350
+ end
351
+
352
+ class DropCheckConstraint < ColumnOperation
353
+ end
354
+
355
+ class CreateForeignKey
356
+ include Dry::Equalizer(:table_name, :foreign_key)
357
+ attr_reader :table_name, :foreign_key
358
+
359
+ def initialize(table_name, foreign_key)
360
+ @table_name = table_name
361
+ @foreign_key = foreign_key
362
+ end
363
+ end
364
+
365
+ class DropForeignKey
366
+ include Dry::Equalizer(:table_name, :fkey_name)
367
+ attr_reader :table_name, :fkey_name
368
+
369
+ def initialize(table_name, fkey_name)
370
+ @table_name = table_name
371
+ @fkey_name = fkey_name
372
+ end
373
+ end
374
+
375
+ class CreateEnum < Definitions::Enum
376
+ end
377
+
378
+ class DropEnum < ColumnOperation
379
+ end
380
+
381
+ class AddValueToEnum
382
+ include Dry::Equalizer(:enum_name, :new_value, :before)
383
+ attr_reader :enum_name, :new_value, :before
384
+
385
+ def initialize(enum_name, new_value, before: nil)
386
+ @enum_name = enum_name
387
+ @new_value = new_value
388
+ @before = before
389
+ end
390
+
391
+ def add_to_the_end?
392
+ before.nil?
393
+ end
394
+ end
395
+ end
396
+ end