nandi 0.8.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (80) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +483 -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 +21 -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/add_reference.rb +23 -0
  35. data/lib/nandi/instructions/change_column_default.rb +23 -0
  36. data/lib/nandi/instructions/create_table.rb +83 -0
  37. data/lib/nandi/instructions/drop_constraint.rb +22 -0
  38. data/lib/nandi/instructions/drop_table.rb +21 -0
  39. data/lib/nandi/instructions/irreversible_migration.rb +15 -0
  40. data/lib/nandi/instructions/remove_column.rb +23 -0
  41. data/lib/nandi/instructions/remove_index.rb +41 -0
  42. data/lib/nandi/instructions/remove_not_null_constraint.rb +22 -0
  43. data/lib/nandi/instructions/remove_reference.rb +23 -0
  44. data/lib/nandi/instructions/validate_constraint.rb +22 -0
  45. data/lib/nandi/lockfile.rb +58 -0
  46. data/lib/nandi/migration.rb +388 -0
  47. data/lib/nandi/renderers.rb +7 -0
  48. data/lib/nandi/renderers/active_record.rb +13 -0
  49. data/lib/nandi/renderers/active_record/generate.rb +59 -0
  50. data/lib/nandi/renderers/active_record/instructions.rb +146 -0
  51. data/lib/nandi/safe_migration_enforcer.rb +143 -0
  52. data/lib/nandi/timeout_policies.rb +38 -0
  53. data/lib/nandi/timeout_policies/access_exclusive.rb +54 -0
  54. data/lib/nandi/timeout_policies/concurrent.rb +64 -0
  55. data/lib/nandi/validation.rb +11 -0
  56. data/lib/nandi/validation/add_column_validator.rb +43 -0
  57. data/lib/nandi/validation/add_reference_validator.rb +38 -0
  58. data/lib/nandi/validation/each_validator.rb +34 -0
  59. data/lib/nandi/validation/failure_helpers.rb +35 -0
  60. data/lib/nandi/validation/remove_index_validator.rb +30 -0
  61. data/lib/nandi/validation/result.rb +30 -0
  62. data/lib/nandi/validation/timeout_validator.rb +37 -0
  63. data/lib/nandi/validator.rb +102 -0
  64. data/lib/templates/nandi/renderers/active_record/generate/show.rb.erb +27 -0
  65. data/lib/templates/nandi/renderers/active_record/instructions/add_check_constraint/show.rb.erb +7 -0
  66. data/lib/templates/nandi/renderers/active_record/instructions/add_column/show.rb.erb +6 -0
  67. data/lib/templates/nandi/renderers/active_record/instructions/add_foreign_key/show.rb.erb +5 -0
  68. data/lib/templates/nandi/renderers/active_record/instructions/add_index/show.rb.erb +5 -0
  69. data/lib/templates/nandi/renderers/active_record/instructions/add_reference/show.rb.erb +5 -0
  70. data/lib/templates/nandi/renderers/active_record/instructions/change_column_default/show.rb.erb +1 -0
  71. data/lib/templates/nandi/renderers/active_record/instructions/create_table/show.rb.erb +8 -0
  72. data/lib/templates/nandi/renderers/active_record/instructions/drop_constraint/show.rb.erb +4 -0
  73. data/lib/templates/nandi/renderers/active_record/instructions/drop_table/show.rb.erb +1 -0
  74. data/lib/templates/nandi/renderers/active_record/instructions/irreversible_migration/show.rb.erb +1 -0
  75. data/lib/templates/nandi/renderers/active_record/instructions/remove_column/show.rb.erb +5 -0
  76. data/lib/templates/nandi/renderers/active_record/instructions/remove_index/show.rb.erb +4 -0
  77. data/lib/templates/nandi/renderers/active_record/instructions/remove_not_null_constraint/show.rb.erb +1 -0
  78. data/lib/templates/nandi/renderers/active_record/instructions/remove_reference/show.rb.erb +5 -0
  79. data/lib/templates/nandi/renderers/active_record/instructions/validate_constraint/show.rb.erb +3 -0
  80. metadata +317 -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,146 @@
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 AddReferenceCell < Base
86
+ formatted_property :table
87
+ formatted_property :ref_name
88
+ formatted_property :extra_args
89
+ end
90
+
91
+ class RemoveReferenceCell < Base
92
+ formatted_property :table
93
+ formatted_property :ref_name
94
+ formatted_property :extra_args
95
+ end
96
+
97
+ class RemoveColumnCell < Base
98
+ formatted_property :table
99
+ formatted_property :name
100
+ formatted_property :extra_args
101
+ end
102
+
103
+ class ChangeColumnDefaultCell < Base
104
+ formatted_property :table
105
+ formatted_property :column
106
+ formatted_property :value
107
+ end
108
+
109
+ class RemoveNotNullConstraintCell < Base
110
+ formatted_property :table
111
+ formatted_property :column
112
+ end
113
+
114
+ class AddForeignKeyCell < Base
115
+ formatted_property :table
116
+ formatted_property :target
117
+ formatted_property :extra_args
118
+ end
119
+
120
+ class AddCheckConstraintCell < Base
121
+ # Because all this stuff goes into a SQL string, we don't need to format
122
+ # the values.
123
+ property :table
124
+ property :name
125
+ property :check
126
+ end
127
+
128
+ class ValidateConstraintCell < Base
129
+ # Because all this stuff goes into a SQL string, we don't need to format
130
+ # the values.
131
+ property :table
132
+ property :name
133
+ end
134
+
135
+ class DropConstraintCell < Base
136
+ # Because all this stuff goes into a SQL string, we don't need to format
137
+ # the values.
138
+ property :table
139
+ property :name
140
+ end
141
+
142
+ class IrreversibleMigrationCell < Base; end
143
+ end
144
+ end
145
+ end
146
+ 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