nandi 0.9.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 (76) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +477 -0
  3. data/Rakefile +8 -0
  4. data/exe/nandi-enforce +36 -0
  5. data/lib/generators/nandi/check_constraint/USAGE +19 -0
  6. data/lib/generators/nandi/check_constraint/check_constraint_generator.rb +52 -0
  7. data/lib/generators/nandi/check_constraint/templates/add_check_constraint.rb +15 -0
  8. data/lib/generators/nandi/check_constraint/templates/validate_check_constraint.rb +9 -0
  9. data/lib/generators/nandi/compile/USAGE +19 -0
  10. data/lib/generators/nandi/compile/compile_generator.rb +62 -0
  11. data/lib/generators/nandi/foreign_key/USAGE +47 -0
  12. data/lib/generators/nandi/foreign_key/foreign_key_generator.rb +91 -0
  13. data/lib/generators/nandi/foreign_key/templates/add_foreign_key.rb +13 -0
  14. data/lib/generators/nandi/foreign_key/templates/add_reference.rb +11 -0
  15. data/lib/generators/nandi/foreign_key/templates/validate_foreign_key.rb +9 -0
  16. data/lib/generators/nandi/migration/USAGE +9 -0
  17. data/lib/generators/nandi/migration/migration_generator.rb +24 -0
  18. data/lib/generators/nandi/migration/templates/migration.rb +13 -0
  19. data/lib/generators/nandi/not_null_check/USAGE +19 -0
  20. data/lib/generators/nandi/not_null_check/not_null_check_generator.rb +56 -0
  21. data/lib/generators/nandi/not_null_check/templates/add_not_null_check.rb +11 -0
  22. data/lib/generators/nandi/not_null_check/templates/validate_not_null_check.rb +9 -0
  23. data/lib/nandi.rb +35 -0
  24. data/lib/nandi/compiled_migration.rb +86 -0
  25. data/lib/nandi/config.rb +126 -0
  26. data/lib/nandi/file_diff.rb +32 -0
  27. data/lib/nandi/file_matcher.rb +72 -0
  28. data/lib/nandi/formatting.rb +79 -0
  29. data/lib/nandi/instructions.rb +19 -0
  30. data/lib/nandi/instructions/add_check_constraint.rb +23 -0
  31. data/lib/nandi/instructions/add_column.rb +24 -0
  32. data/lib/nandi/instructions/add_foreign_key.rb +40 -0
  33. data/lib/nandi/instructions/add_index.rb +50 -0
  34. data/lib/nandi/instructions/change_column_default.rb +23 -0
  35. data/lib/nandi/instructions/create_table.rb +83 -0
  36. data/lib/nandi/instructions/drop_constraint.rb +22 -0
  37. data/lib/nandi/instructions/drop_table.rb +21 -0
  38. data/lib/nandi/instructions/irreversible_migration.rb +15 -0
  39. data/lib/nandi/instructions/remove_column.rb +23 -0
  40. data/lib/nandi/instructions/remove_index.rb +41 -0
  41. data/lib/nandi/instructions/remove_not_null_constraint.rb +22 -0
  42. data/lib/nandi/instructions/validate_constraint.rb +22 -0
  43. data/lib/nandi/lockfile.rb +58 -0
  44. data/lib/nandi/migration.rb +363 -0
  45. data/lib/nandi/renderers.rb +7 -0
  46. data/lib/nandi/renderers/active_record.rb +13 -0
  47. data/lib/nandi/renderers/active_record/generate.rb +59 -0
  48. data/lib/nandi/renderers/active_record/instructions.rb +134 -0
  49. data/lib/nandi/safe_migration_enforcer.rb +143 -0
  50. data/lib/nandi/timeout_policies.rb +38 -0
  51. data/lib/nandi/timeout_policies/access_exclusive.rb +54 -0
  52. data/lib/nandi/timeout_policies/concurrent.rb +64 -0
  53. data/lib/nandi/validation.rb +10 -0
  54. data/lib/nandi/validation/add_column_validator.rb +43 -0
  55. data/lib/nandi/validation/each_validator.rb +32 -0
  56. data/lib/nandi/validation/failure_helpers.rb +35 -0
  57. data/lib/nandi/validation/remove_index_validator.rb +30 -0
  58. data/lib/nandi/validation/result.rb +30 -0
  59. data/lib/nandi/validation/timeout_validator.rb +37 -0
  60. data/lib/nandi/validator.rb +102 -0
  61. data/lib/nandi/version.rb +5 -0
  62. data/lib/templates/nandi/renderers/active_record/generate/show.rb.erb +27 -0
  63. data/lib/templates/nandi/renderers/active_record/instructions/add_check_constraint/show.rb.erb +7 -0
  64. data/lib/templates/nandi/renderers/active_record/instructions/add_column/show.rb.erb +6 -0
  65. data/lib/templates/nandi/renderers/active_record/instructions/add_foreign_key/show.rb.erb +5 -0
  66. data/lib/templates/nandi/renderers/active_record/instructions/add_index/show.rb.erb +5 -0
  67. data/lib/templates/nandi/renderers/active_record/instructions/change_column_default/show.rb.erb +1 -0
  68. data/lib/templates/nandi/renderers/active_record/instructions/create_table/show.rb.erb +8 -0
  69. data/lib/templates/nandi/renderers/active_record/instructions/drop_constraint/show.rb.erb +4 -0
  70. data/lib/templates/nandi/renderers/active_record/instructions/drop_table/show.rb.erb +1 -0
  71. data/lib/templates/nandi/renderers/active_record/instructions/irreversible_migration/show.rb.erb +1 -0
  72. data/lib/templates/nandi/renderers/active_record/instructions/remove_column/show.rb.erb +5 -0
  73. data/lib/templates/nandi/renderers/active_record/instructions/remove_index/show.rb.erb +4 -0
  74. data/lib/templates/nandi/renderers/active_record/instructions/remove_not_null_constraint/show.rb.erb +1 -0
  75. data/lib/templates/nandi/renderers/active_record/instructions/validate_constraint/show.rb.erb +3 -0
  76. metadata +320 -0
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nandi
4
+ module Instructions
5
+ class RemoveNotNullConstraint
6
+ attr_reader :table, :column
7
+
8
+ def initialize(table:, column:)
9
+ @table = table
10
+ @column = column
11
+ end
12
+
13
+ def procedure
14
+ :remove_not_null_constraint
15
+ end
16
+
17
+ def lock
18
+ Nandi::Migration::LockWeights::ACCESS_EXCLUSIVE
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nandi
4
+ module Instructions
5
+ class ValidateConstraint
6
+ attr_reader :table, :name
7
+
8
+ def initialize(table:, name:)
9
+ @table = table
10
+ @name = name
11
+ end
12
+
13
+ def lock
14
+ Nandi::Migration::LockWeights::SHARE
15
+ end
16
+
17
+ def procedure
18
+ :validate_constraint
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/hash/indifferent_access"
4
+
5
+ module Nandi
6
+ class Lockfile
7
+ class << self
8
+ def file_present?
9
+ File.exist?(path)
10
+ end
11
+
12
+ def create!
13
+ return if file_present?
14
+
15
+ File.write(path, {}.to_yaml)
16
+ end
17
+
18
+ def add(file_name:, source_digest:, compiled_digest:)
19
+ load!
20
+
21
+ lockfile[file_name] = {
22
+ source_digest: source_digest,
23
+ compiled_digest: compiled_digest,
24
+ }
25
+ end
26
+
27
+ def get(file_name)
28
+ load!
29
+
30
+ {
31
+ source_digest: lockfile.dig(file_name, :source_digest),
32
+ compiled_digest: lockfile.dig(file_name, :compiled_digest),
33
+ }
34
+ end
35
+
36
+ def load!
37
+ return lockfile if lockfile
38
+
39
+ Nandi::Lockfile.create! unless Nandi::Lockfile.file_present?
40
+
41
+ @lockfile = YAML.safe_load(File.read(path)).with_indifferent_access
42
+ end
43
+
44
+ def persist!
45
+ File.write(path, lockfile.to_h.deep_stringify_keys.to_yaml)
46
+ end
47
+
48
+ def path
49
+ File.join(
50
+ Nandi.config.lockfile_directory,
51
+ ".nandilock.yml",
52
+ )
53
+ end
54
+
55
+ attr_accessor :lockfile
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,363 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nandi/instructions"
4
+ require "nandi/validator"
5
+ require "nandi/validation/failure_helpers"
6
+
7
+ module Nandi
8
+ # @abstract A migration must implement #up (the forward migration), and may
9
+ # also implement #down (the rollback sequence).
10
+ # The base class for migrations; Nandi's equivalent of ActiveRecord::Migration.
11
+ # All the statements in the migration are statically analysed together to rule
12
+ # out migrations with a high risk of causing availability issues. Additionally,
13
+ # our implementations of some statements will rule out certain common footguns
14
+ # (for example, creating an index without using the `CONCURRENTLY` parameter.)
15
+ # @example
16
+ # class CreateWidgetsTable < Nandi::Migration
17
+ # def up
18
+ # create_table :widgets do |t|
19
+ # t.column :weight, :number
20
+ # t.column :name, :text, default: "Unknown widget"
21
+ # end
22
+ # end
23
+ #
24
+ # def down
25
+ # drop_table :widgets
26
+ # end
27
+ # end
28
+ class Migration
29
+ include Nandi::Validation::FailureHelpers
30
+
31
+ module LockWeights
32
+ ACCESS_EXCLUSIVE = 1
33
+ SHARE = 0
34
+ end
35
+
36
+ class InstructionSet < SimpleDelegator
37
+ def strictest_lock
38
+ return LockWeights::SHARE if empty?
39
+
40
+ map { |i| i.respond_to?(:lock) ? i.lock : LockWeights::ACCESS_EXCLUSIVE }.max
41
+ end
42
+ end
43
+
44
+ class << self
45
+ attr_reader :lock_timeout, :statement_timeout
46
+ # For sake both of correspondence with Postgres syntax and familiarity
47
+ # with activerecord-safe_migrations's identically named macros, we
48
+ # disable this cop.
49
+
50
+ # rubocop:disable Naming/AccessorMethodName
51
+
52
+ # Override the default lock timeout for the duration of the migration.
53
+ # This may be helpful when making changes to very busy tables, when a
54
+ # lock is less likely to be immediately available.
55
+ # @param timeout [Integer] New lock timeout in ms
56
+ def set_lock_timeout(timeout)
57
+ @lock_timeout = timeout
58
+ end
59
+
60
+ # Override the default statement timeout for the duration of the migration.
61
+ # This may be helpful when making changes that are likely to take a lot
62
+ # of time, like adding a new index on a large table.
63
+ # @param timeout [Integer] New lock timeout in ms
64
+ def set_statement_timeout(timeout)
65
+ @statement_timeout = timeout
66
+ end
67
+ # rubocop:enable Naming/AccessorMethodName
68
+ end
69
+
70
+ # @param validator [Nandi::Validator]
71
+ def initialize(validator)
72
+ @validator = validator
73
+ @instructions = Hash.new { |h, k| h[k] = InstructionSet.new([]) }
74
+ validate
75
+ end
76
+
77
+ # @api private
78
+ def up_instructions
79
+ compile_instructions(:up)
80
+ end
81
+
82
+ # @api private
83
+ def down_instructions
84
+ compile_instructions(:down)
85
+ end
86
+
87
+ # The current lock timeout.
88
+ def lock_timeout
89
+ self.class.lock_timeout || default_lock_timeout
90
+ end
91
+
92
+ # The current statement timeout.
93
+ def statement_timeout
94
+ self.class.statement_timeout || default_statement_timeout
95
+ end
96
+
97
+ # @api private
98
+ def strictest_lock
99
+ @instructions.values.map(&:strictest_lock).max
100
+ end
101
+
102
+ # @abstract
103
+ def up
104
+ raise NotImplementedError
105
+ end
106
+
107
+ def down; end
108
+
109
+ # Adds a new index to the database.
110
+ #
111
+ # Nandi will:
112
+ # * add the `CONCURRENTLY` option, which means the change takes a less
113
+ # restrictive lock at the cost of not running in a DDL transaction
114
+ # * use the `BTREE` index type which is the safest to create.
115
+ #
116
+ # Because index creation is particularly failure-prone, and because
117
+ # we cannot run in a transaction and therefore risk partially applied
118
+ # migrations that (in a Rails environment) require manual intervention,
119
+ # Nandi Validates that, if there is a add_index statement in the
120
+ # migration, it must be the only statement.
121
+ # @param table [Symbol, String] The name of the table to add the index to
122
+ # @param fields [Symbol, String, Array] The field or fields to use in the
123
+ # index
124
+ # @param kwargs [Hash] Arbitrary options to pass to the backend adapter.
125
+ # Attempts to remove `CONCURRENTLY` or change the index type will be ignored.
126
+ def add_index(table, fields, **kwargs)
127
+ current_instructions << Instructions::AddIndex.new(
128
+ **kwargs,
129
+ table: table,
130
+ fields: fields,
131
+ )
132
+ end
133
+
134
+ # Drop an index from the database.
135
+ #
136
+ # Nandi will add the `CONCURRENTLY` option, which means the change
137
+ # takes a less restrictive lock at the cost of not running in a DDL
138
+ # transaction.
139
+ #
140
+ # Because we cannot run in a transaction and therefore risk partially
141
+ # applied migrations that (in a Rails environment) require manual
142
+ # intervention, Nandi Validates that, if there is a remove_index statement
143
+ # in the migration, it must be the only statement.
144
+ # @param table [Symbol, String] The name of the table to add the index to
145
+ # @param target [Symbol, String, Array, Hash] This can be either the field (or
146
+ # array of fields) in the index to be dropped, or a hash of options, which
147
+ # must include either a `column` key (which is the same: a field or list
148
+ # of fields) or a `name` key, which is the name of the index to be dropped.
149
+ def remove_index(table, target)
150
+ current_instructions << Instructions::RemoveIndex.new(table: table, field: target)
151
+ end
152
+
153
+ # Creates a new table. Yields a ColumnsReader object as a block, to allow adding
154
+ # columns.
155
+ # @example
156
+ # create_table :widgets do |t|
157
+ # t.text :foo, default: true
158
+ # end
159
+ # @param table [String, Symbol] The name of the new table
160
+ # @yieldparam columns_reader [Nandi::Instructions::CreateTable::ColumnsReader]
161
+ def create_table(table, **kwargs, &block)
162
+ current_instructions << Instructions::CreateTable.new(
163
+ **kwargs,
164
+ table: table,
165
+ columns_block: block,
166
+ )
167
+ end
168
+
169
+ # Drops an existing table
170
+ # @param table [String, Symbol] The name of the table to drop.
171
+ def drop_table(table)
172
+ current_instructions << Instructions::DropTable.new(table: table)
173
+ end
174
+
175
+ # Adds a new column. Nandi will explicitly set the column to be NULL,
176
+ # as validating a new NOT NULL constraint can be very expensive on large
177
+ # tables and cause availability issues.
178
+ # @param table [Symbol, String] The name of the table to add the column to
179
+ # @param name [Symbol, String] The name of the column
180
+ # @param type [Symbol, String] The type of the column
181
+ # @param kwargs [Hash] Arbitrary options to be passed to the backend.
182
+ def add_column(table, name, type, **kwargs)
183
+ current_instructions << Instructions::AddColumn.new(
184
+ table: table,
185
+ name: name,
186
+ type: type,
187
+ **kwargs,
188
+ )
189
+ end
190
+
191
+ # Remove an existing column.
192
+ # @param table [Symbol, String] The name of the table to remove the column
193
+ # from.
194
+ # @param name [Symbol, String] The name of the column
195
+ # @param extra_args [Hash] Arbitrary options to be passed to the backend.
196
+ def remove_column(table, name, **extra_args)
197
+ current_instructions << Instructions::RemoveColumn.new(
198
+ **extra_args,
199
+ table: table,
200
+ name: name,
201
+ )
202
+ end
203
+
204
+ # Add a foreign key constraint. The generated SQL will include the NOT VALID
205
+ # parameter, which will prevent immediate validation of the constraint, which
206
+ # locks the target table for writes potentially for a long time. Use the separate
207
+ # #validate_constraint method, in a separate migration; this only takes a row-level
208
+ # lock as it scans through.
209
+ # @param table [Symbol, String] The name of the table with the reference column
210
+ # @param target [Symbol, String] The name of the referenced table
211
+ # @param column [Symbol, String] The name of the reference column. If omitted, will
212
+ # default to the singular of target + "_id"
213
+ # @param name [Symbol, String] The name of the constraint to create. Defaults to
214
+ # table_target_fk
215
+ def add_foreign_key(table, target, column: nil, name: nil)
216
+ current_instructions << Instructions::AddForeignKey.new(
217
+ table: table,
218
+ target: target,
219
+ column: column,
220
+ name: name,
221
+ )
222
+ end
223
+
224
+ # Add a check constraint, in the NOT VALID state.
225
+ # @param table [Symbol, String] The name of the table with the column
226
+ # @param name [Symbol, String] The name of the constraint to create
227
+ # @param check [Symbol, String] The predicate to check
228
+ def add_check_constraint(table, name, check)
229
+ current_instructions << Instructions::AddCheckConstraint.new(
230
+ table: table,
231
+ name: name,
232
+ check: check,
233
+ )
234
+ end
235
+
236
+ # Validates an existing foreign key constraint.
237
+ # @param table [Symbol, String] The name of the table with the constraint
238
+ # @param name [Symbol, String] The name of the constraint
239
+ def validate_constraint(table, name)
240
+ current_instructions << Instructions::ValidateConstraint.new(
241
+ table: table,
242
+ name: name,
243
+ )
244
+ end
245
+
246
+ # Drops an existing constraint.
247
+ # @param table [Symbol, String] The name of the table with the constraint
248
+ # @param name [Symbol, String] The name of the constraint
249
+ def drop_constraint(table, name)
250
+ current_instructions << Instructions::DropConstraint.new(
251
+ table: table,
252
+ name: name,
253
+ )
254
+ end
255
+
256
+ # Drops an existing NOT NULL constraint. Please note that this migration is
257
+ # not safely reversible; to enforce NOT NULL like behaviour, use a CHECK
258
+ # constraint and validate it in a separate migration.
259
+ # @param table [Symbol, String] The name of the table with the constraint
260
+ # @param column [Symbol, String] The name of the column to remove NOT NULL
261
+ # constraint from
262
+ def remove_not_null_constraint(table, column)
263
+ current_instructions << Instructions::RemoveNotNullConstraint.new(
264
+ table: table,
265
+ column: column,
266
+ )
267
+ end
268
+
269
+ # Changes the default value for this column when new rows are inserted into
270
+ # the table.
271
+ # @param table [Symbol, String] The name of the table with the column
272
+ # @param column [Symbol, String] The name of the column to change
273
+ # @param value [Object] The new default value
274
+ def change_column_default(table, column, value)
275
+ current_instructions << Instructions::ChangeColumnDefault.new(
276
+ table: table,
277
+ column: column,
278
+ value: value,
279
+ )
280
+ end
281
+
282
+ # Raises an `ActiveRecord::IrreversibleMigration` error for use in
283
+ # irreversible migrations
284
+ def irreversible_migration
285
+ current_instructions << Instructions::IrreversibleMigration.new
286
+ end
287
+
288
+ # @api private
289
+ def compile_instructions(direction)
290
+ @direction = direction
291
+
292
+ public_send(direction) unless current_instructions.any?
293
+
294
+ current_instructions
295
+ end
296
+
297
+ # @api private
298
+ def validate
299
+ validator.call(self)
300
+ rescue NotImplementedError => e
301
+ Validation::Result.new << failure(e.message)
302
+ end
303
+
304
+ def disable_lock_timeout?
305
+ if self.class.lock_timeout.nil?
306
+ strictest_lock == LockWeights::SHARE
307
+ else
308
+ false
309
+ end
310
+ end
311
+
312
+ def disable_statement_timeout?
313
+ if self.class.statement_timeout.nil?
314
+ strictest_lock == LockWeights::SHARE
315
+ else
316
+ false
317
+ end
318
+ end
319
+
320
+ def name
321
+ self.class.name
322
+ end
323
+
324
+ def respond_to_missing?(name)
325
+ Nandi.config.custom_methods.key?(name) || super
326
+ end
327
+
328
+ def mixins
329
+ (up_instructions + down_instructions).inject([]) do |mixins, i|
330
+ i.respond_to?(:mixins) ? [*mixins, *i.mixins] : mixins
331
+ end.uniq
332
+ end
333
+
334
+ def method_missing(name, *args, &block)
335
+ if Nandi.config.custom_methods.key?(name)
336
+ invoke_custom_method(name, *args, &block)
337
+ else
338
+ super
339
+ end
340
+ end
341
+
342
+ private
343
+
344
+ attr_reader :validator
345
+
346
+ def current_instructions
347
+ @instructions[@direction]
348
+ end
349
+
350
+ def default_statement_timeout
351
+ Nandi.config.access_exclusive_statement_timeout
352
+ end
353
+
354
+ def default_lock_timeout
355
+ Nandi.config.access_exclusive_lock_timeout
356
+ end
357
+
358
+ def invoke_custom_method(name, *args, &block)
359
+ klass = Nandi.config.custom_methods[name]
360
+ current_instructions << klass.new(*args, &block)
361
+ end
362
+ end
363
+ end