bulk_dependency_eraser 2.1.0 → 2.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e4bda32570c3319238a5ea03f6a71cbae1e20015caf5fe7bdba78cab3c7883a7
4
- data.tar.gz: 9e371750cd1a05b54bfe724873daaf2c7e9aa66942f168d6417505a1427d46c1
3
+ metadata.gz: 73ac2c6209f874175a8a1208c184a3fdaa73f2020a411f2f41bd069668b63fbc
4
+ data.tar.gz: 86df357905f75f34337d30446cfc6b34b9aa55fc1947d17a008329379f2beae7
5
5
  SHA512:
6
- metadata.gz: 33130d09c651a9757af826cbbd5e1363a8f136a3d6e3bf1e2a128dad776185b544fd75a493013cd28d279690094e90cc3dc0f3b23597704df9842eb80e215808
7
- data.tar.gz: 27f4e6014155961a1286f2d1f490a7ca2709fd2b9594a7c8ab3fb3164c26bbdf99e8609c5494e15e8595b0db9eee94d485a20a772f5a37d58554b497eaee5154
6
+ metadata.gz: 69fd26f7fcbd04f158adf4bc038181818136f17d878bad544bb888b1d0f547d557153204876a91b222154f7154d0f38b143f4db08f5081242c107488832fa682
7
+ data.tar.gz: b31893d532b1be190f5288ddfb495008615a3c54f2e5a9fb12344a733c4322345911597822c835277f9f4632657aeefe4ee7a77fe7eecea16cf64151dd63a797
@@ -1,5 +1,11 @@
1
+ require_relative 'utils'
2
+
1
3
  module BulkDependencyEraser
2
4
  class Base
5
+ POLY_KLASS_NAME = "<POLY>"
6
+ include BulkDependencyEraser::Utils
7
+ extend BulkDependencyEraser::Utils
8
+
3
9
  # Default Custom Scope for all classes, no effect.
4
10
  DEFAULT_SCOPE_WRAPPER = ->(query) { nil }
5
11
  # Default Custom Scope for mapped-by-name classes, no effect.
@@ -39,6 +45,32 @@ module BulkDependencyEraser
39
45
  proc_scopes_per_class_name: {},
40
46
  }.freeze
41
47
 
48
+ DEPENDENCY_NULLIFY = %i[
49
+ nullify
50
+ ].freeze
51
+
52
+ # Abort deletion if assoc dependency value is any of these.
53
+ # - exception if the :force_destroy_restricted option set true
54
+ DEPENDENCY_RESTRICT = %i[
55
+ restrict_with_error
56
+ restrict_with_exception
57
+ ].freeze
58
+
59
+ DEPENDENCY_DESTROY = (
60
+ %i[
61
+ destroy
62
+ delete_all
63
+ destroy_async
64
+ ] + self::DEPENDENCY_RESTRICT
65
+ ).freeze
66
+
67
+ DEPENDENCY_DESTROY_IGNORE_REFLECTION_TYPES = [
68
+ # Rails 6.1, when a has_and_delongs_to_many <assoc>, dependent: :destroy,
69
+ # will ignore the destroy. Will neither destroy the join table record nor the association record
70
+ # We will do the same, mirror the fuctionality, by ignoring any :dependent options on these types.
71
+ 'ActiveRecord::Reflection::HasAndBelongsToManyReflection'
72
+ ].freeze
73
+
42
74
  attr_reader :errors
43
75
 
44
76
  def initialize opts: {}
@@ -53,8 +85,44 @@ module BulkDependencyEraser
53
85
  raise NotImplementedError
54
86
  end
55
87
 
88
+ def report_error msg
89
+ # remove new lines, surrounding white space, replace with semicolon delimiters
90
+ n = msg.strip.gsub(/\s*\n\s*/, ' ')
91
+ @errors << n
92
+ end
93
+
94
+ def merge_errors errors, prefix = nil
95
+ local_errors = errors.dup
96
+
97
+ unless local_errors.any?
98
+ local_errors << '<NO ERRORS FOUND TO MERGE>'
99
+ end
100
+
101
+ if prefix
102
+ local_errors = errors.map { |error| prefix + error }
103
+ end
104
+ @errors += local_errors
105
+ end
106
+
56
107
  protected
57
108
 
109
+ def constantize(klass_name)
110
+ # circular dependencies have suffixes, shave them off
111
+ klass_name.sub(/\.\d+$/, '').constantize
112
+ end
113
+
114
+ # A dependent assoc may be through another association. Follow the throughs to find the correct assoc to destroy.
115
+ # @return [Symbol] - association's name
116
+ def find_root_association_from_through_assocs klass, association_name
117
+ reflection = klass.reflect_on_association(association_name)
118
+ options = reflection.options
119
+ if options.key?(:through)
120
+ return find_root_association_from_through_assocs(klass, options[:through])
121
+ else
122
+ association_name
123
+ end
124
+ end
125
+
58
126
  def custom_scope_for_query(query)
59
127
  klass = query.klass
60
128
  if opts_c.proc_scopes_per_class_name.key?(klass.name)
@@ -79,25 +147,6 @@ module BulkDependencyEraser
79
147
  ).freeze
80
148
  end
81
149
 
82
- def report_error msg
83
- # remove new lines, surrounding white space, replace with semicolon delimiters
84
- n = msg.strip.gsub(/\s*\n\s*/, ' ')
85
- @errors << n
86
- end
87
-
88
- def merge_errors errors, prefix = nil
89
- local_errors = errors.dup
90
-
91
- unless local_errors.any?
92
- local_errors << '<NO ERRORS FOUND TO MERGE>'
93
- end
94
-
95
- if prefix
96
- local_errors = errors.map { |error| prefix + error }
97
- end
98
- @errors += local_errors
99
- end
100
-
101
150
  def uniqify_errors!
102
151
  @errors.uniq!
103
152
  end
@@ -34,35 +34,12 @@ module BulkDependencyEraser
34
34
  reading_proc_scopes_per_class_name: {},
35
35
  }.freeze
36
36
 
37
- DEPENDENCY_NULLIFY = %i[
38
- nullify
39
- ].freeze
40
-
41
- # Abort deletion if assoc dependency value is any of these.
42
- # - exception if the :force_destroy_restricted option set true
43
- DEPENDENCY_RESTRICT = %i[
44
- restrict_with_error
45
- restrict_with_exception
46
- ].freeze
47
-
48
- DEPENDENCY_DESTROY = (
49
- %i[
50
- destroy
51
- delete_all
52
- destroy_async
53
- ] + self::DEPENDENCY_RESTRICT
54
- ).freeze
55
-
56
- DEPENDENCY_DESTROY_IGNORE_REFLECTION_TYPES = [
57
- # Rails 6.1, when a has_and_delongs_to_many <assoc>, dependent: :destroy,
58
- # will ignore the destroy. Will neither destroy the join table record nor the association record
59
- # We will do the same, mirror the fuctionality, by ignoring any :dependent options on these types.
60
- 'ActiveRecord::Reflection::HasAndBelongsToManyReflection'
61
- ].freeze
62
-
63
37
  # write access so that these can be edited in-place by end-users who might need to manually adjust deletion order.
64
38
  attr_accessor :deletion_list, :nullification_list
65
39
  attr_reader :ignore_table_deletion_list, :ignore_table_nullification_list
40
+ attr_reader :query_schema_parser
41
+
42
+ delegate :circular_dependency_klasses, :flat_dependencies_per_klass, to: :query_schema_parser
66
43
 
67
44
  def initialize query:, opts: {}
68
45
  @query = query
@@ -79,9 +56,15 @@ module BulkDependencyEraser
79
56
 
80
57
  @ignore_table_name_and_dependencies = opts_c.ignore_tables_and_dependencies.collect { |table_name| table_name }
81
58
  @ignore_klass_name_and_dependencies = opts_c.ignore_klass_names_and_dependencies.collect { |klass_name| klass_name }
59
+ @query_schema_parser = BulkDependencyEraser::QuerySchemaParser.new(query:, opts:)
82
60
  end
83
61
 
84
62
  def execute
63
+ unless query_schema_parser.execute
64
+ merge_errors(full_schema_parser.errors, 'QuerySchemaParser: ')
65
+ return false
66
+ end
67
+
85
68
  # go through deletion/nullification lists and remove any tables from 'ignore_tables' option
86
69
  build_result = build
87
70
 
@@ -89,8 +72,9 @@ module BulkDependencyEraser
89
72
  # - prior approach was to use table_name.classify, but we can't trust that approach.
90
73
  opts_c.ignore_tables.each do |table_name|
91
74
  table_names_to_parsed_klass_names.dig(table_name)&.each do |klass_name|
92
- ignore_table_deletion_list[klass_name] = deletion_list.delete(klass_name) if deletion_list.key?(klass_name)
93
- ignore_table_nullification_list[klass_name] = nullification_list.delete(klass_name) if nullification_list.key?(klass_name)
75
+ klass_index = deletion_index_key(klass_name.constantize)
76
+ ignore_table_deletion_list[klass_index] = deletion_list.delete(klass_index) if deletion_list.key?(klass_index)
77
+ ignore_table_nullification_list[klass_index] = nullification_list.delete(klass_index) if nullification_list.key?(klass_index)
94
78
  end
95
79
  end
96
80
 
@@ -111,15 +95,13 @@ module BulkDependencyEraser
111
95
  rescue StandardError => e
112
96
  if @query.is_a?(ActiveRecord::Relation)
113
97
  klass = @query.klass
114
- klass_name = @query.klass.name
115
98
  else
116
99
  # current_query is a normal rails class
117
100
  klass = @query
118
- klass_name = @query.name
119
101
  end
120
102
  report_error(
121
103
  "
122
- Error Encountered in 'execute' for '#{klass_name}':
104
+ Error Encountered in 'execute' for '#{klass.name}':
123
105
  #{e.class.name}
124
106
  #{e.message}
125
107
  "
@@ -190,17 +172,15 @@ module BulkDependencyEraser
190
172
 
191
173
  if query.is_a?(ActiveRecord::Relation)
192
174
  klass = query.klass
193
- klass_name = query.klass.name
194
175
  else
195
176
  # current_query is a normal rails class
196
177
  klass = query
197
- klass_name = query.name
198
178
  end
199
179
 
200
180
  table_names_to_parsed_klass_names[klass.table_name] ||= []
201
181
  # Need to populate this list here, so we can have access to it later for the :ignore_tables option
202
- unless table_names_to_parsed_klass_names[klass.table_name].include?(klass_name)
203
- table_names_to_parsed_klass_names[klass.table_name] << klass_name
182
+ unless table_names_to_parsed_klass_names[klass.table_name].include?(klass.name)
183
+ table_names_to_parsed_klass_names[klass.table_name] << klass.name
204
184
  end
205
185
 
206
186
  if ignore_table_name_and_dependencies.include?(klass.table_name)
@@ -208,22 +188,22 @@ module BulkDependencyEraser
208
188
  return
209
189
  end
210
190
 
211
- if ignore_klass_name_and_dependencies.include?(klass_name)
191
+ if ignore_klass_name_and_dependencies.include?(klass.name)
212
192
  # Not parsing, table and dependencies ignorable
213
193
  return
214
194
  end
215
195
 
216
196
  if opts_c.verbose
217
197
  if association_parent
218
- puts "Building #{association_parent}, association of #{klass_name}"
198
+ puts "Building #{association_parent}, association of #{klass.name}"
219
199
  else
220
- puts "Building #{klass_name}"
200
+ puts "Building #{klass.name}"
221
201
  end
222
202
  end
223
203
 
224
204
  if klass.primary_key != 'id'
225
205
  report_error(
226
- "#{klass_name} - does not use primary_key 'id'. Cannot use this tool to bulk delete."
206
+ "#{klass.name} - does not use primary_key 'id'. Cannot use this tool to bulk delete."
227
207
  )
228
208
  return
229
209
  end
@@ -231,26 +211,22 @@ module BulkDependencyEraser
231
211
  # Pluck IDs of the current query
232
212
  query_ids = pluck_from_query(query)
233
213
 
234
- deletion_list[klass_name] ||= []
214
+ klass_index = initialize_deletion_list_for_klass(klass)
235
215
 
236
216
  # prevent infinite recursion here.
237
217
  # - Remove any IDs that have been processed before
238
- query_ids = query_ids - deletion_list[klass_name]
218
+ query_ids = remove_already_deletion_processed_ids(klass, query_ids)
239
219
 
240
220
  # If ids are nil, let's find that error
241
221
  if query_ids.none? #|| query_ids.nil?
242
222
  # quick cleanup, if turns out was an empty class
243
- deletion_list.delete(klass_name) if deletion_list[klass_name].none?
223
+ deletion_list.delete(klass_index) if deletion_list[klass_index].none?
244
224
  return
245
225
  end
246
226
 
247
227
  # Use-case: We have more IDs to process
248
228
  # - can now safely add to the list, since we've prevented infinite recursion
249
- deletion_list[klass_name] += query_ids
250
-
251
- # Hard to test if not sorted
252
- # - if we had more advanced rspec matches, we could do away with this.
253
- # deletion_list[klass_name].sort! if Rails.env.test?
229
+ deletion_list[klass_index] += query_ids
254
230
 
255
231
  # ignore associations that aren't a dependent destroyable type
256
232
  destroy_associations = query.reflect_on_all_associations.select do |reflection|
@@ -446,15 +422,12 @@ module BulkDependencyEraser
446
422
  nullification_list[assoc_klass_name][specified_foreign_key] += assoc_ids
447
423
  nullification_list[assoc_klass_name][specified_foreign_key].uniq!
448
424
 
449
- # nullification_list[assoc_klass_name][specified_foreign_key].sort! if Rails.env.test?
450
425
 
451
426
  # Also nullify the 'type' field, if the association is polymorphic
452
427
  if specified_foreign_type
453
428
  nullification_list[assoc_klass_name][specified_foreign_type] ||= []
454
429
  nullification_list[assoc_klass_name][specified_foreign_type] += assoc_ids
455
430
  nullification_list[assoc_klass_name][specified_foreign_type].uniq!
456
-
457
- # nullification_list[assoc_klass_name][specified_foreign_type].sort! if Rails.env.test?
458
431
  end
459
432
  else
460
433
  raise "invalid parsing type: #{type}"
@@ -659,17 +632,6 @@ module BulkDependencyEraser
659
632
  end
660
633
  end
661
634
 
662
- # A dependent assoc may be through another association. Follow the throughs to find the correct assoc to destroy.
663
- def find_root_association_from_through_assocs klass, association_name
664
- reflection = klass.reflect_on_association(association_name)
665
- options = reflection.options
666
- if options.key?(:through)
667
- return find_root_association_from_through_assocs(klass, options[:through])
668
- else
669
- association_name
670
- end
671
- end
672
-
673
635
  # return [Boolean]
674
636
  # - true if valid
675
637
  # - false if not valid
@@ -711,5 +673,71 @@ module BulkDependencyEraser
711
673
  puts "Reading from DB..." if opts_c.verbose
712
674
  opts_c.db_read_wrapper.call(block)
713
675
  end
676
+
677
+ def remove_already_deletion_processed_ids(klass, new_ids)
678
+ already_processed_ids = []
679
+
680
+ if is_a_circular_dependency_klass?(klass)
681
+ klass_keys = find_circular_dependency_deletion_keys(klass)
682
+ klass_keys.each do |circular_class_key|
683
+ already_processed_ids += deletion_list[circular_class_key]
684
+ end
685
+ else
686
+ already_processed_ids = deletion_list[klass.name]
687
+ end
688
+
689
+ new_ids - already_processed_ids
690
+ end
691
+
692
+ # Initializes deletion_list index
693
+ # - increments the index for circular dependencies
694
+ def initialize_deletion_list_for_klass(klass)
695
+ klass_index = deletion_index_key(klass, increment_circular_index: true)
696
+
697
+ if is_a_circular_dependency_klass?(klass)
698
+ raise "circular_index already existed for klass: #{klass.name}" if deletion_list.key?(klass_index)
699
+
700
+ deletion_list[klass_index] = []
701
+ else
702
+ # Not a circular dependency, define as normal
703
+ deletion_list[klass_index] ||= []
704
+ end
705
+
706
+ klass_index
707
+ end
708
+
709
+ def is_a_circular_dependency_klass?(klass)
710
+ circular_dependency_klasses.values.flatten.include?(klass.name)
711
+ end
712
+
713
+ def find_circular_dependency_deletion_keys(klass)
714
+ escaped_prefix = Regexp.escape(klass.name)
715
+ # Define the key-matching regex: klass.name + '.' + <integer>
716
+ regex = /^#{escaped_prefix}\.\d+$/
717
+ # find the latest, indexed klass initialization
718
+ deletion_list.keys.select { |key| key.match?(regex) }
719
+ end
720
+
721
+ # If circular dependency, append a index suffix to the deletion hash key
722
+ # - they will be deleted in highest index to lowest index order.
723
+ def deletion_index_key(klass, increment_circular_index: false)
724
+ if is_a_circular_dependency_klass?(klass)
725
+ klass_keys = find_circular_dependency_deletion_keys(klass)
726
+ if klass_keys.none?
727
+ klass_index = "#{klass.name}.0" # first one, no need to consider increment
728
+ else
729
+ # Use map to extract the integers using a regular expression
730
+ circular_indexes = klass_keys.map { |s| s[/\.(\d+)$/, 1].to_i }
731
+ # Find the maximum value
732
+ current_circular_index = circular_indexes.max
733
+ current_circular_index += 1 if increment_circular_index
734
+ klass_index = "#{klass.name}.#{current_circular_index}"
735
+ end
736
+ else
737
+ klass_index = klass.name
738
+ end
739
+
740
+ return klass_index
741
+ end
714
742
  end
715
743
  end
@@ -1,6 +1,6 @@
1
1
  module BulkDependencyEraser
2
2
  class Deleter < Base
3
- DEFAULT_DB_DELETE_ALL_WRAPPER = ->(block) do
3
+ DEFAULT_DB_DELETE_ALL_WRAPPER = ->(deleter, block) do
4
4
  begin
5
5
  block.call
6
6
  rescue StandardError => e
@@ -63,7 +63,7 @@ module BulkDependencyEraser
63
63
  class_names_and_ids.keys.reverse.each do |class_name|
64
64
  current_class_name = class_name
65
65
  ids = class_names_and_ids[class_name].reverse
66
- klass = class_name.constantize
66
+ klass = constantize(class_name)
67
67
 
68
68
  if opts_c.enable_invalid_foreign_key_detection
69
69
  # delete with referential integrity
@@ -140,7 +140,7 @@ module BulkDependencyEraser
140
140
 
141
141
  def delete_all_in_db(&block)
142
142
  puts "Deleting all from DB..." if opts_c.verbose
143
- opts_c.db_delete_all_wrapper.call(block)
143
+ opts_c.db_delete_all_wrapper.call(self, block)
144
144
  puts "Deleting all from DB complete." if opts_c.verbose
145
145
  end
146
146
  end
@@ -0,0 +1,179 @@
1
+ module BulkDependencyEraser
2
+ # Create a flat map hash for each class that lists every dependency.
3
+ class FullSchemaParser < Base
4
+ DEFAULT_OPTS = {
5
+ verbose: false,
6
+ }
7
+
8
+ attr_reader :flat_dependencies_per_klass
9
+
10
+ @cached_flat_dependencies_per_klass = nil
11
+ def self.reset_cache
12
+ @cached_flat_dependencies_per_klass = nil
13
+ end
14
+ def self.set_cache(value)
15
+ @cached_flat_dependencies_per_klass = value.freeze
16
+ end
17
+ def self.get_cache
18
+ @cached_flat_dependencies_per_klass
19
+ end
20
+
21
+ def initialize(opts: {})
22
+ # @flat_dependencies_per_klass Structure
23
+ # {
24
+ # <class_name> => {
25
+ # has_dependencies: <Boolean>,
26
+ # foreign_keys: {
27
+ # <column_name>: <association_class_name>,
28
+ # ...
29
+ # },
30
+ # nullify_dependencies: {
31
+ # <association_name>: <association_class_name>,
32
+ # ...
33
+ # },
34
+ # destroy_dependencies: {
35
+ # <association_name>: <association_class_name>,
36
+ # ...
37
+ # }
38
+ # }
39
+ # }
40
+ @flat_dependencies_per_klass = {}
41
+ super(opts:)
42
+ end
43
+
44
+ def execute
45
+ unless self.class.get_cache.nil?
46
+ @flat_dependencies_per_klass = self.class.get_cache
47
+ return true
48
+ end
49
+
50
+ Rails.application.eager_load!
51
+
52
+ ActiveRecord::Base.descendants.each do |model|
53
+ begin
54
+ next if model.abstract_class? # Skip abstract classes like ApplicationRecord
55
+ next unless model.connection.table_exists?(model.table_name)
56
+ rescue Exception => e
57
+ report_error("EXECPTION ON #{model.name}; #{e.class}: #{e.message}")
58
+ next
59
+ end
60
+
61
+ flat_dependencies_parser(model)
62
+ end
63
+
64
+ deep_freeze(@flat_dependencies_per_klass)
65
+ self.class.set_cache(@flat_dependencies_per_klass)
66
+
67
+ return true
68
+ end
69
+
70
+ def reset
71
+ @flat_dependencies_per_klass = {}
72
+ self.class.reset_cache
73
+ end
74
+
75
+ protected
76
+
77
+ # @param klass [ActiveRecord::Base]
78
+ # @param dependency_path [Array<ActiveRecord::Base>] - previously parsed klasses
79
+ def flat_dependencies_parser klass
80
+ raise "invalid klass: #{klass}" unless klass < ActiveRecord::Base
81
+
82
+ if flat_dependencies_per_klass.include?(klass.name)
83
+ raise "@dependencies_per_klass already contains #{klass.name}"
84
+ end
85
+
86
+ @flat_dependencies_per_klass[klass.name] ||= {
87
+ nullify_dependencies: {},
88
+ destroy_dependencies: {},
89
+ has_many: {},
90
+ belongs_to: {},
91
+ }
92
+
93
+ # We're including :restricted dependencies
94
+ destroy_associations = klass.reflect_on_all_associations.select do |reflection|
95
+ dependency_type = reflection.options&.dig(:dependent)
96
+ dependency_type.to_sym if dependency_type.is_a?(String)
97
+ DEPENDENCY_DESTROY.include?(dependency_type)
98
+ end
99
+
100
+ nullify_associations = klass.reflect_on_all_associations.select do |reflection|
101
+ dependency_type = reflection.options&.dig(:dependent)
102
+ dependency_type.to_sym if dependency_type.is_a?(String)
103
+ DEPENDENCY_NULLIFY.include?(dependency_type)
104
+ end
105
+
106
+ # Iterate through the assoc names, if there are any :through assocs, then rename the association
107
+ # - Rails interpretation of any dependencies of a :through association is to apply it to
108
+ # the leaf association at the end of the :through chain(s)
109
+ destroy_association_names = destroy_associations.map(&:name).collect do |assoc_name|
110
+ find_root_association_from_through_assocs(klass, assoc_name)
111
+ end
112
+ nullify_association_names = nullify_associations.map(&:name).collect do |assoc_name|
113
+ find_root_association_from_through_assocs(klass, assoc_name)
114
+ end
115
+
116
+ destroy_association_names.uniq.each do |association_name|
117
+ add_deletion_dependency_to_flat_map(klass, association_name)
118
+ end
119
+
120
+ nullify_association_names.uniq.each do |association_name|
121
+ add_nullification_dependency_to_flat_map(klass, association_name)
122
+ end
123
+
124
+ # add has_many relationships
125
+ (
126
+ klass.reflect_on_all_associations(:has_many) +
127
+ klass.reflect_on_all_associations(:has_one) +
128
+ klass.reflect_on_all_associations(:has_and_belongs_to_many)
129
+ ).each do |reflection|
130
+ next if reflection.options[:through].present?
131
+
132
+ add_has_many_to_flat_map(klass, reflection)
133
+ end
134
+
135
+ # add belongs_to relationships
136
+ klass.reflect_on_all_associations(:belongs_to).each do |reflection|
137
+ next if reflection.options[:through].present?
138
+
139
+ add_belongs_to_to_flat_map(klass, reflection)
140
+ end
141
+ end
142
+
143
+ # @param klass [ActiveRecord::Base]
144
+ # @param association_name [Symbol] - name of the association
145
+ def add_has_many_to_flat_map(klass, reflection)
146
+ association_name = reflection.name
147
+ @flat_dependencies_per_klass[klass.name][:has_many][association_name] = reflection.klass.name
148
+ end
149
+
150
+ # @param klass [ActiveRecord::Base]
151
+ # @param association_name [Symbol] - name of the association
152
+ def add_belongs_to_to_flat_map(klass, reflection)
153
+ association_name = reflection.name
154
+ reflection_klass_name = is_reflection_polymorphic?(reflection) ? POLY_KLASS_NAME : reflection.klass.name
155
+ @flat_dependencies_per_klass[klass.name][:belongs_to][association_name] = reflection_klass_name
156
+ end
157
+
158
+ # @param klass [ActiveRecord::Base]
159
+ # @param association_name [Symbol] - name of the association
160
+ def add_deletion_dependency_to_flat_map(klass, association_name)
161
+ reflection = klass.reflect_on_association(association_name)
162
+ reflection_klass_name = is_reflection_polymorphic?(reflection) ? POLY_KLASS_NAME : reflection.klass.name
163
+ @flat_dependencies_per_klass[klass.name][:destroy_dependencies][association_name] = reflection_klass_name
164
+ end
165
+
166
+ # @param klass [ActiveRecord::Base]
167
+ # @param association_name [Symbol] - name of the association
168
+ def add_nullification_dependency_to_flat_map(klass, association_name)
169
+ reflection = klass.reflect_on_association(association_name)
170
+ # nullifications can't be poly klass
171
+ @flat_dependencies_per_klass[klass.name][:nullify_dependencies][association_name] = reflection.klass.name
172
+ end
173
+
174
+ # @param reflection [ActiveRecord::Reflection::AssociationReflection]
175
+ def is_reflection_polymorphic?(reflection)
176
+ reflection.options&.dig(:polymorphic) == true
177
+ end
178
+ end
179
+ end
@@ -9,6 +9,7 @@ module BulkDependencyEraser
9
9
 
10
10
  delegate :nullification_list, :deletion_list, to: :dependency_builder
11
11
  delegate :ignore_table_deletion_list, :ignore_table_nullification_list, to: :dependency_builder
12
+ delegate :circular_dependency_klasses, :flat_dependencies_per_klass, to: :dependency_builder
12
13
 
13
14
  # @param query [ActiveRecord::Base | ActiveRecord::Relation]
14
15
  def initialize query:, opts: {}
@@ -1,10 +1,10 @@
1
1
  module BulkDependencyEraser
2
2
  class Nullifier < Base
3
- DEFAULT_DB_NULLIFY_ALL_WRAPPER = lambda do |block|
3
+ DEFAULT_DB_NULLIFY_ALL_WRAPPER = ->(nullifier, block) do
4
4
  begin
5
5
  block.call
6
6
  rescue StandardError => e
7
- report_error("Issue attempting to nullify '#{current_class_name}' column '#{current_column}': #{e.class.name} - #{e.message}")
7
+ nullifier.report_error("Issue attempting to nullify: #{e.class.name} - #{e.message}")
8
8
  end
9
9
  end
10
10
 
@@ -211,7 +211,7 @@ module BulkDependencyEraser
211
211
 
212
212
  def nullify_all_in_db(&block)
213
213
  puts "Nullifying all from DB..." if opts_c.verbose
214
- opts_c.db_nullify_all_wrapper.call(block)
214
+ opts_c.db_nullify_all_wrapper.call(self, block)
215
215
  puts "Nullifying all from DB complete." if opts_c.verbose
216
216
  end
217
217
 
@@ -0,0 +1,281 @@
1
+ module BulkDependencyEraser
2
+ class QuerySchemaParser < Base
3
+ DEFAULT_OPTS = {
4
+ verbose: false,
5
+ # Some associations scopes take parameters.
6
+ # - We would have to instantiate if we wanted to apply that scope filter.
7
+ instantiate_if_assoc_scope_with_arity: false,
8
+ force_destroy_restricted: false,
9
+ }
10
+
11
+ # attr_accessor :deletion_list, :nullification_list
12
+ attr_reader :initial_class
13
+ attr_reader :dependencies_per_klass
14
+ attr_reader :circular_dependency_klasses
15
+ attr_reader :full_schema_parser
16
+
17
+ delegate :flat_dependencies_per_klass, to: :full_schema_parser
18
+
19
+ def initialize query:, opts: {}
20
+ if query.is_a?(ActiveRecord::Relation)
21
+ @initial_class = query.klass
22
+ else
23
+ # current_query is a normal rails class
24
+ @initial_class = query
25
+ end
26
+ # @dependencies_per_klass Structure
27
+ # {
28
+ # <ActiveRecord::Base> => {
29
+ # <ActiveRecord::Reflection::AssociationReflection> => <ActiveRecord::Base>
30
+ # }
31
+ # }
32
+ @dependencies_per_klass = {}
33
+ # @circular_dependency_klasses Structure
34
+ # {
35
+ # <ActiveRecord::Base> => [
36
+ # # Path of dependencies that start and end with the key class
37
+ # <ActiveRecord::Base>,
38
+ # <ActiveRecord::Base>,
39
+ # <ActiveRecord::Base>,
40
+ # ]
41
+ # }
42
+ @circular_dependency_klasses = {}
43
+ @full_schema_parser = BulkDependencyEraser::FullSchemaParser.new(opts:)
44
+ super(opts:)
45
+ end
46
+
47
+ def execute
48
+ unless full_schema_parser.execute
49
+ merge_errors(full_schema_parser.errors, 'FullSchemaParser: ')
50
+ return false
51
+ end
52
+ klass_dependencies_parser(initial_class, klass_action: :destroy)
53
+
54
+ @dependencies_per_klass.each do |key, values|
55
+ @dependencies_per_klass[key] = values.uniq
56
+ end
57
+
58
+ return true
59
+ end
60
+
61
+ # @param klass [ActiveRecord::Base, Array<ActiveRecord::Base>]
62
+ # - if was a dependency from a polymophic class, then iterate through the klasses.
63
+ # @param dependency_path [Array<ActiveRecord::Base>] - previously parsed klasses
64
+ def klass_dependencies_parser klass, klass_action:, dependency_path: []
65
+ if klass.is_a?(Array)
66
+ klass.each do |klass_subset|
67
+ klass_dependencies_parser(klass_subset, klass_action:, dependency_path:)
68
+ end
69
+ return
70
+ end
71
+
72
+ unless DEPENDENCY_DESTROY.include?(klass_action) || DEPENDENCY_NULLIFY.include?(klass_action)
73
+ raise "invalid klass action: #{klass_action}"
74
+ end
75
+ raise "invalid klass: #{klass}" unless klass < ActiveRecord::Base
76
+
77
+ # Not a circular dependency if the repetitious klass has a nullify action.
78
+ if DEPENDENCY_DESTROY.include?(klass_action) && dependency_path.include?(klass.name)
79
+ index = dependency_path.index(klass.name)
80
+ circular_dependency = dependency_path[index..] + [klass.name]
81
+ circular_dependency_klasses[klass.name] = circular_dependency
82
+ return
83
+ end
84
+
85
+ # We don't need to consider dependencies for a klass that is being nullified.
86
+ return if DEPENDENCY_NULLIFY.include?(klass_action)
87
+
88
+ # already parsed, doesn't need to be parsed again.
89
+ return if dependencies_per_klass.include?(klass.name)
90
+
91
+ @dependencies_per_klass[klass.name] = []
92
+
93
+ # We're including :restricted dependencies
94
+ destroy_associations = klass.reflect_on_all_associations.select do |reflection|
95
+ dependency_type = reflection.options&.dig(:dependent)&.to_sym
96
+ DEPENDENCY_DESTROY.include?(dependency_type)
97
+ end
98
+
99
+ nullify_associations = klass.reflect_on_all_associations.select do |reflection|
100
+ dependency_type = reflection.options&.dig(:dependent)&.to_sym
101
+ DEPENDENCY_NULLIFY.include?(dependency_type)
102
+ end
103
+
104
+ # Iterate through the assoc names, if there are any :through assocs, then rename the association
105
+ # - Rails interpretation of any dependencies of a :through association is to apply it to
106
+ # the leaf association at the end of the :through chain(s)
107
+ association_dependencies = {}
108
+ (
109
+ destroy_associations.map(&:name) +
110
+ nullify_associations.map(&:name)
111
+ ).collect do |assoc_name|
112
+ root_association_name = find_root_association_from_through_assocs(klass, assoc_name)
113
+ association_dependencies[root_association_name] = klass.reflect_on_association(assoc_name).options.dig(:dependent)
114
+ end
115
+
116
+ # Using association names as keys helps remove duplicates - from dependent options on through associations and root associations.
117
+ association_dependencies.each do |association_name, dependency_type|
118
+ association_parser(klass, association_name, dependency_type, dependency_path)
119
+ end
120
+ end
121
+
122
+ # Used to iterate through each destroyable association, and recursively call 'deletion_query_parser'.
123
+ # @param parent_class [ApplicationRecord]
124
+ # @param association_name [Symbol] - The association name from the parent_class
125
+ def association_parser(parent_class, association_name, dependency_type, dependency_path)
126
+ reflection = parent_class.reflect_on_association(association_name)
127
+ reflection_type = reflection.class.name
128
+
129
+ raise "No dependency set for #{parent_class} and it's association: #{association_name}" unless dependency_type
130
+
131
+ case reflection_type
132
+ when 'ActiveRecord::Reflection::HasManyReflection'
133
+ association_parser_has_many(parent_class, association_name, dependency_type, dependency_path)
134
+ when 'ActiveRecord::Reflection::HasOneReflection'
135
+ association_parser_has_many(parent_class, association_name, dependency_type, dependency_path)
136
+ when 'ActiveRecord::Reflection::BelongsToReflection'
137
+ association_parser_belongs_to(parent_class, association_name, dependency_type, dependency_path)
138
+ else
139
+ report_message("Unsupported association type for #{parent_class.name}'s association '#{association_name}': #{reflection_type}")
140
+ end
141
+ end
142
+
143
+ # Handles the :has_many association type
144
+ # - handles it's polymorphic associations internally (easier on the has_many)
145
+ def association_parser_has_many(parent_class, association_name, dependency_type, dependency_path)
146
+ raise "parent_class missing" unless parent_class
147
+ raise "#{parent_class} - association_name: missing" unless association_name
148
+ raise "#{parent_class} - dependency_type: missing" unless dependency_type
149
+ raise "#{parent_class} - dependency_path: nil" if dependency_path.nil?
150
+
151
+ reflection = parent_class.reflect_on_association(association_name)
152
+ reflection_type = reflection.class.name
153
+
154
+ assoc_klass = reflection.klass
155
+ assoc_klass_name = assoc_klass.name
156
+ @dependencies_per_klass[parent_class.name] << assoc_klass.name
157
+
158
+ # If there is an association scope present, check to see how many parameters it's using
159
+ # - if there's any parameter, we have to either skip it or instantiate it to find it's dependencies.
160
+ if reflection.scope&.arity&.nonzero? && opts_c.instantiate_if_assoc_scope_with_arity == false
161
+ report_error(
162
+ "#{parent_class.name} and '#{association_name}' - scope has instance parameters. Use :instantiate_if_assoc_scope_with_arity option?"
163
+ )
164
+ return
165
+ end
166
+
167
+ # Look for manually specified keys in the assocation first
168
+ specified_primary_key = reflection.options[:primary_key]&.to_s
169
+ specified_foreign_key = reflection.options[:foreign_key]&.to_s
170
+ # For polymorphic_associations
171
+ specified_foreign_type = nil
172
+
173
+ # handle foreign_key edge cases
174
+ if specified_foreign_key.nil?
175
+ if reflection.options[:as]
176
+ specified_foreign_type = "#{reflection.options[:as]}_type"
177
+ specified_foreign_key = "#{reflection.options[:as]}_id"
178
+ else
179
+ specified_foreign_key = parent_class.table_name.singularize + "_id"
180
+ end
181
+ end
182
+
183
+ # Check to see if foreign_key exists in association class's table
184
+ unless assoc_klass.column_names.include?(specified_foreign_key)
185
+ report_error(
186
+ "
187
+ For '#{assoc_klass.name}': Could not determine the assoc's foreign key.
188
+ Foreign key should have been '#{specified_foreign_key}', but did not exist on the #{assoc_klass.table_name} table.
189
+ "
190
+ )
191
+ return
192
+ end
193
+
194
+ unless specified_foreign_type.nil? || assoc_klass.column_names.include?(specified_foreign_type)
195
+ report_error(
196
+ "
197
+ For '#{assoc_klass.name}': Could not determine the assoc's foreign key type.
198
+ Foreign key type should have been '#{specified_foreign_type}', but did not exist on the #{assoc_klass.table_name} table.
199
+ "
200
+ )
201
+ end
202
+
203
+ if DEPENDENCY_RESTRICT.include?(dependency_type) && traverse_restricted_dependency?(parent_class, reflection)
204
+ klass_dependencies_parser(assoc_klass, klass_action: dependency_type, dependency_path: dependency_path.dup << parent_class.name)
205
+ else
206
+ klass_dependencies_parser(assoc_klass, klass_action: dependency_type, dependency_path: dependency_path.dup << parent_class.name)
207
+ end
208
+ end
209
+
210
+ def association_parser_belongs_to(parent_class, association_name, dependency_type, dependency_path)
211
+ raise "parent_class missing" unless parent_class
212
+ raise "#{parent_class} - association_name: missing" unless association_name
213
+ raise "#{parent_class} - dependency_type: missing" unless dependency_type
214
+ raise "#{parent_class} - dependency_path: nil" if dependency_path.nil?
215
+
216
+ reflection = parent_class.reflect_on_association(association_name)
217
+ reflection_type = reflection.class.name
218
+
219
+ is_polymorphic = reflection.options[:polymorphic]
220
+ if is_polymorphic
221
+ assoc_klass = find_klasses_from_polymorphic_dependency(parent_class).map(&:constantize)
222
+ @dependencies_per_klass[parent_class.name] += assoc_klass.map(&:name)
223
+ else
224
+ assoc_klass = reflection.klass
225
+ @dependencies_per_klass[parent_class.name] << assoc_klass.name
226
+ end
227
+
228
+ specified_primary_key = reflection.options[:primary_key] || 'id'
229
+ specified_foreign_key = reflection.options[:foreign_key] || "#{association_name}_id"
230
+
231
+ # Check to see if foreign_key exists in our parent table
232
+ unless parent_class.column_names.include?(specified_foreign_key)
233
+ report_error(
234
+ "
235
+ For #{parent_class.name}'s association '#{association_name}': Could not determine the assoc's foreign key.
236
+ Foreign key should have been '#{specified_foreign_key}', but did not exist on the #{parent_class.table_name} table.
237
+ "
238
+ )
239
+ return
240
+ end
241
+
242
+ if (
243
+ DEPENDENCY_DESTROY.include?(dependency_type) ||
244
+ DEPENDENCY_NULLIFY.include?(dependency_type) && traverse_restricted_dependency?(parent_class, reflection)
245
+ )
246
+ klass_dependencies_parser(assoc_klass, klass_action: dependency_type, dependency_path: dependency_path.dup << parent_class.name)
247
+ end
248
+ end
249
+
250
+ # In this example the klass would be the polymorphic klass
251
+ # - i.e. Attachment belongs_to: :attachable, dependent: :destroy
252
+ # We're looking for klasses in the flat map that have a has_many :attachments, as: :attachable
253
+ def find_klasses_from_polymorphic_dependency(klass)
254
+ found_klasses = []
255
+ flat_dependencies_per_klass.each do |flat_klass_name, klass_dependencies|
256
+ if klass_dependencies[:has_many].values.include?(klass.name)
257
+ found_klasses << flat_klass_name
258
+ end
259
+ end
260
+ found_klasses
261
+ end
262
+
263
+ # return [Boolean]
264
+ # - true if valid
265
+ # - false if not valid
266
+ def traverse_restricted_dependency? parent_class, reflection
267
+ # Return true if we're going to destroy all restricted
268
+ return true if opts_c.force_destroy_restricted
269
+
270
+ report_error(
271
+ "
272
+ #{parent_class.name}'s assoc '#{reflection.name}' has a restricted dependency type.
273
+ If you still wish to destroy, use the 'force_destroy_restricted: true' option
274
+ "
275
+ )
276
+
277
+ return false
278
+ end
279
+
280
+ end
281
+ end
@@ -0,0 +1,25 @@
1
+ module BulkDependencyEraser
2
+ module Utils
3
+ module Methods
4
+ # To freeze all nested structures including hashes, arrays, and strings
5
+ # Deep Freezing All Structures
6
+ def deep_freeze(obj)
7
+ case obj
8
+ when Hash
9
+ obj.each { |key, value| deep_freeze(key); deep_freeze(value) }
10
+ obj.freeze
11
+ when Array
12
+ obj.each { |value| deep_freeze(value) }
13
+ obj.freeze
14
+ when String
15
+ obj.freeze
16
+ else
17
+ obj.freeze if obj.respond_to?(:freeze)
18
+ end
19
+ end
20
+ end
21
+
22
+ include Methods
23
+ extend Methods
24
+ end
25
+ end
@@ -1,3 +1,3 @@
1
1
  module BulkDependencyEraser
2
- VERSION = "2.1.0".freeze
2
+ VERSION = "2.2.0".freeze
3
3
  end
@@ -1,6 +1,9 @@
1
1
  require_relative 'bulk_dependency_eraser/base'
2
2
  require_relative 'bulk_dependency_eraser/builder'
3
3
  require_relative 'bulk_dependency_eraser/deleter'
4
+ require_relative 'bulk_dependency_eraser/full_schema_parser'
4
5
  require_relative 'bulk_dependency_eraser/nullifier'
6
+ require_relative 'bulk_dependency_eraser/query_schema_parser'
5
7
  require_relative 'bulk_dependency_eraser/manager'
8
+ require_relative 'bulk_dependency_eraser/utils'
6
9
  require_relative 'bulk_dependency_eraser/version'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bulk_dependency_eraser
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.1.0
4
+ version: 2.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - benjamin.dana.software.dev@gmail.com
@@ -146,8 +146,11 @@ files:
146
146
  - lib/bulk_dependency_eraser/base.rb
147
147
  - lib/bulk_dependency_eraser/builder.rb
148
148
  - lib/bulk_dependency_eraser/deleter.rb
149
+ - lib/bulk_dependency_eraser/full_schema_parser.rb
149
150
  - lib/bulk_dependency_eraser/manager.rb
150
151
  - lib/bulk_dependency_eraser/nullifier.rb
152
+ - lib/bulk_dependency_eraser/query_schema_parser.rb
153
+ - lib/bulk_dependency_eraser/utils.rb
151
154
  - lib/bulk_dependency_eraser/version.rb
152
155
  homepage: https://github.com/danabr75/bulk_dependency_eraser
153
156
  licenses: