bulk_dependency_eraser 2.1.0 → 2.2.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.
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: