db_schema 0.1

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 (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