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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nandi/renderers/active_record"
4
+
5
+ module Nandi
6
+ module Renderers; end
7
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nandi/renderers/active_record/generate"
4
+
5
+ module Nandi
6
+ module Renderers
7
+ module ActiveRecord
8
+ def self.generate(migration)
9
+ Generate.call(migration)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+ require "cell"
5
+ require "tilt"
6
+ require "nandi/renderers/active_record/instructions"
7
+
8
+ module Nandi
9
+ module Renderers
10
+ module ActiveRecord
11
+ class Generate < ::Cell::ViewModel
12
+ def self.call(*args)
13
+ super.call
14
+ end
15
+
16
+ def partials_base
17
+ "nandi/renderers/active_record/instructions"
18
+ end
19
+
20
+ def template_options_for(_options)
21
+ {
22
+ suffix: "rb.erb",
23
+ template_class: Tilt,
24
+ }
25
+ end
26
+
27
+ self.view_paths = [
28
+ File.expand_path("../../../templates", __dir__),
29
+ ]
30
+
31
+ def should_disable_ddl_transaction?
32
+ [*up_instructions, *down_instructions].
33
+ select { |i| i.procedure =~ /index/ }.any?
34
+ end
35
+
36
+ def activerecord_version
37
+ ::ActiveRecord::Migration.current_version
38
+ end
39
+
40
+ def render_partial(instruction)
41
+ if instruction.respond_to?(:template)
42
+ cell(instruction.template, instruction)
43
+ else
44
+ cell("#{partials_base}/#{instruction.procedure}", instruction)
45
+ end
46
+ end
47
+
48
+ property :up_instructions
49
+ property :down_instructions
50
+ property :name
51
+ property :mixins
52
+ property :disable_lock_timeout?
53
+ property :disable_statement_timeout?
54
+ property :lock_timeout
55
+ property :statement_timeout
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cells"
4
+ require "tilt"
5
+ require "nandi/formatting"
6
+ require "ostruct"
7
+
8
+ module Nandi
9
+ module Renderers
10
+ module ActiveRecord
11
+ module Instructions
12
+ class Base < ::Cell::ViewModel
13
+ include Nandi::Formatting
14
+
15
+ def template_options_for(_options)
16
+ {
17
+ suffix: "rb.erb",
18
+ template_class: Tilt,
19
+ }
20
+ end
21
+
22
+ self.view_paths = [
23
+ File.join(__dir__, "../../../templates"),
24
+ ]
25
+ end
26
+
27
+ class RemoveIndexCell < Base
28
+ formatted_property :table
29
+ formatted_property :extra_args
30
+ end
31
+
32
+ class AddIndexCell < Base
33
+ formatted_property :table
34
+ formatted_property :fields
35
+ formatted_property :extra_args
36
+ end
37
+
38
+ class CreateTableCell < Base
39
+ formatted_property :table
40
+ formatted_property :timestamps_args
41
+
42
+ def timestamps?
43
+ !model.timestamps_args.nil?
44
+ end
45
+
46
+ def timestamps_args?
47
+ !model.timestamps_args&.empty?
48
+ end
49
+
50
+ def extra_args?
51
+ model.extra_args&.any?
52
+ end
53
+
54
+ def timestamps_args
55
+ format_value(model.timestamps_args, as_argument: true)
56
+ end
57
+
58
+ def extra_args
59
+ format_value(model.extra_args, as_argument: true)
60
+ end
61
+
62
+ def columns
63
+ model.columns.map do |c|
64
+ OpenStruct.new(
65
+ name: format_value(c.name),
66
+ type: format_value(c.type),
67
+ ).tap do |col|
68
+ col.args = format_value(c.args, as_argument: true) unless c.args.empty?
69
+ end
70
+ end
71
+ end
72
+ end
73
+
74
+ class DropTableCell < Base
75
+ formatted_property :table
76
+ end
77
+
78
+ class AddColumnCell < Base
79
+ formatted_property :table
80
+ formatted_property :name
81
+ formatted_property :type
82
+ formatted_property :extra_args
83
+ end
84
+
85
+ class RemoveColumnCell < Base
86
+ formatted_property :table
87
+ formatted_property :name
88
+ formatted_property :extra_args
89
+ end
90
+
91
+ class ChangeColumnDefaultCell < Base
92
+ formatted_property :table
93
+ formatted_property :column
94
+ formatted_property :value
95
+ end
96
+
97
+ class RemoveNotNullConstraintCell < Base
98
+ formatted_property :table
99
+ formatted_property :column
100
+ end
101
+
102
+ class AddForeignKeyCell < Base
103
+ formatted_property :table
104
+ formatted_property :target
105
+ formatted_property :extra_args
106
+ end
107
+
108
+ class AddCheckConstraintCell < Base
109
+ # Because all this stuff goes into a SQL string, we don't need to format
110
+ # the values.
111
+ property :table
112
+ property :name
113
+ property :check
114
+ end
115
+
116
+ class ValidateConstraintCell < Base
117
+ # Because all this stuff goes into a SQL string, we don't need to format
118
+ # the values.
119
+ property :table
120
+ property :name
121
+ end
122
+
123
+ class DropConstraintCell < Base
124
+ # Because all this stuff goes into a SQL string, we don't need to format
125
+ # the values.
126
+ property :table
127
+ property :name
128
+ end
129
+
130
+ class IrreversibleMigrationCell < Base; end
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "rails"
5
+ require "rails/generators"
6
+
7
+ require "nandi/file_diff"
8
+ require "nandi/file_matcher"
9
+ require "nandi/lockfile"
10
+
11
+ module Nandi
12
+ class SafeMigrationEnforcer
13
+ class MigrationLintingFailed < StandardError; end
14
+
15
+ DEFAULT_SAFE_MIGRATION_DIR = "db/safe_migrations"
16
+ DEFAULT_AR_MIGRATION_DIR = "db/migrate"
17
+ DEFAULT_FILE_SPEC = "all"
18
+
19
+ def initialize(require_path: nil,
20
+ safe_migration_dir: DEFAULT_SAFE_MIGRATION_DIR,
21
+ ar_migration_dir: DEFAULT_AR_MIGRATION_DIR,
22
+ files: DEFAULT_FILE_SPEC)
23
+ @safe_migration_dir = safe_migration_dir
24
+ @ar_migration_dir = ar_migration_dir
25
+ @files = files
26
+
27
+ require require_path unless require_path.nil?
28
+
29
+ Nandi.configure do |c|
30
+ c.migration_directory = @safe_migration_dir
31
+ c.output_directory = @ar_migration_dir
32
+ end
33
+ end
34
+
35
+ def run
36
+ safe_migrations = matching_migrations(@safe_migration_dir)
37
+ ar_migrations = matching_migrations(@ar_migration_dir)
38
+
39
+ return true if safe_migrations.none? && ar_migrations.none?
40
+
41
+ enforce_no_ungenerated_migrations!(safe_migrations, ar_migrations)
42
+ enforce_no_hand_written_migrations!(safe_migrations, ar_migrations)
43
+ enforce_no_hand_edited_migrations!(ar_migrations)
44
+ enforce_no_out_of_date_migrations!(safe_migrations)
45
+
46
+ true
47
+ end
48
+
49
+ private
50
+
51
+ def matching_migrations(dir)
52
+ names = Dir.glob(File.join(dir, "*.rb")).map { |path| File.basename(path) }
53
+ FileMatcher.call(files: names, spec: @files)
54
+ end
55
+
56
+ def enforce_no_ungenerated_migrations!(safe_migrations, ar_migrations)
57
+ ungenerated_migrations = safe_migrations - ar_migrations
58
+ if ungenerated_migrations.any?
59
+ error = <<~ERROR
60
+ The following migrations are pending generation:
61
+
62
+ - #{ungenerated_migrations.sort.join("\n - ")}
63
+
64
+ Please run `rails generate nandi:compile` to generate your migrations.
65
+ ERROR
66
+
67
+ raise MigrationLintingFailed, error
68
+ end
69
+ end
70
+
71
+ def enforce_no_hand_written_migrations!(safe_migrations, ar_migrations)
72
+ handwritten_migrations = ar_migrations - safe_migrations
73
+ handwritten_migration_paths = names_to_paths(handwritten_migrations)
74
+
75
+ if handwritten_migration_paths.any?
76
+ error = <<~ERROR
77
+ The following migrations have been written by hand, not generated:
78
+
79
+ - #{handwritten_migration_paths.sort.join("\n - ")}
80
+
81
+ Please use Nandi to generate your migrations. In exeptional cases, hand-written
82
+ ActiveRecord migrations can be added to the .nandiignore file. Doing so will
83
+ require additional review that will slow your PR down.
84
+ ERROR
85
+
86
+ raise MigrationLintingFailed, error
87
+ end
88
+ end
89
+
90
+ def enforce_no_out_of_date_migrations!(safe_migrations)
91
+ out_of_date_migrations = safe_migrations.
92
+ map { |m| [m, Nandi::Lockfile.get(m)] }.
93
+ select do |filename, digests|
94
+ Nandi::FileDiff.new(
95
+ file_path: File.join(@safe_migration_dir, filename),
96
+ known_digest: digests[:source_digest],
97
+ ).changed?
98
+ end
99
+
100
+ if out_of_date_migrations.any?
101
+ error = <<~ERROR
102
+ The following migrations have changed but not been recompiled:
103
+
104
+ - #{out_of_date_migrations.sort.join("\n - ")}
105
+
106
+ Please recompile your migrations to make sure that the changes you expect are
107
+ applied.
108
+ ERROR
109
+
110
+ raise MigrationLintingFailed, error
111
+ end
112
+ end
113
+
114
+ def enforce_no_hand_edited_migrations!(ar_migrations)
115
+ hand_altered_migrations = ar_migrations.
116
+ map { |m| [m, Nandi::Lockfile.get(m)] }.
117
+ select do |filename, digests|
118
+ Nandi::FileDiff.new(
119
+ file_path: File.join(@ar_migration_dir, filename),
120
+ known_digest: digests[:compiled_digest],
121
+ ).changed?
122
+ end
123
+
124
+ if hand_altered_migrations.any?
125
+ error = <<~ERROR
126
+ The following migrations have had their generated content altered:
127
+
128
+ - #{hand_altered_migrations.sort.join("\n - ")}
129
+
130
+ Please don't hand-edit generated migrations. If you want to write a regular
131
+ ActiveRecord::Migration, please do so and add it to .nandiignore. Note that
132
+ this will require additional review that will slow your PR down.
133
+ ERROR
134
+
135
+ raise MigrationLintingFailed, error
136
+ end
137
+ end
138
+
139
+ def names_to_paths(names)
140
+ names.map { |name| File.join(@ar_migration_dir, name) }
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nandi/validation/failure_helpers"
4
+ require "nandi/migration"
5
+ require "nandi/timeout_policies/access_exclusive"
6
+ require "nandi/timeout_policies/concurrent"
7
+
8
+ module Nandi
9
+ module TimeoutPolicies
10
+ CONCURRENT_OPERATIONS = %i[add_index remove_index].freeze
11
+ class Noop
12
+ class << self
13
+ include Nandi::Validation::FailureHelpers
14
+
15
+ def validate(_)
16
+ success
17
+ end
18
+ end
19
+ end
20
+
21
+ def self.policy_for(instruction)
22
+ case instruction.lock
23
+ when Nandi::Migration::LockWeights::ACCESS_EXCLUSIVE
24
+ AccessExclusive
25
+ else
26
+ share_policy_for(instruction)
27
+ end
28
+ end
29
+
30
+ def self.share_policy_for(instruction)
31
+ if CONCURRENT_OPERATIONS.include?(instruction.procedure)
32
+ Concurrent
33
+ else
34
+ Noop
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nandi"
4
+ require "nandi/validation/failure_helpers"
5
+
6
+ module Nandi
7
+ module TimeoutPolicies
8
+ class AccessExclusive
9
+ include Nandi::Validation::FailureHelpers
10
+
11
+ def self.validate(migration)
12
+ new(migration).validate
13
+ end
14
+
15
+ def initialize(migration)
16
+ @migration = migration
17
+ end
18
+
19
+ def validate
20
+ collect_errors(validate_statement_timeout, validate_lock_timeout)
21
+ end
22
+
23
+ private
24
+
25
+ attr_reader :migration
26
+
27
+ def validate_statement_timeout
28
+ assert(
29
+ !migration.disable_statement_timeout? &&
30
+ migration.statement_timeout <= statement_timeout_maximum,
31
+ "statement timeout must be at most #{statement_timeout_maximum}ms" \
32
+ " as it takes an ACCESS EXCLUSIVE lock",
33
+ )
34
+ end
35
+
36
+ def validate_lock_timeout
37
+ assert(
38
+ !migration.disable_lock_timeout? &&
39
+ migration.lock_timeout <= lock_timeout_maximum,
40
+ "lock timeout must be at most #{lock_timeout_maximum}ms" \
41
+ " as it takes an ACCESS EXCLUSIVE lock",
42
+ )
43
+ end
44
+
45
+ def statement_timeout_maximum
46
+ Nandi.config.access_exclusive_statement_timeout_limit
47
+ end
48
+
49
+ def lock_timeout_maximum
50
+ Nandi.config.access_exclusive_lock_timeout_limit
51
+ end
52
+ end
53
+ end
54
+ end