bulk_dependency_eraser 2.1.0 → 3.0.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 +4 -4
- data/lib/bulk_dependency_eraser/base.rb +70 -19
- data/lib/bulk_dependency_eraser/builder.rb +129 -85
- data/lib/bulk_dependency_eraser/deleter.rb +26 -17
- data/lib/bulk_dependency_eraser/errors/base_error.rb +5 -0
- data/lib/bulk_dependency_eraser/errors/builder_error.rb +13 -0
- data/lib/bulk_dependency_eraser/errors/deleter_error.rb +13 -0
- data/lib/bulk_dependency_eraser/errors/nullifier_error.rb +14 -0
- data/lib/bulk_dependency_eraser/full_schema_parser.rb +179 -0
- data/lib/bulk_dependency_eraser/manager.rb +1 -0
- data/lib/bulk_dependency_eraser/nullifier.rb +18 -4
- data/lib/bulk_dependency_eraser/query_schema_parser.rb +281 -0
- data/lib/bulk_dependency_eraser/utils.rb +25 -0
- data/lib/bulk_dependency_eraser/version.rb +1 -1
- data/lib/bulk_dependency_eraser.rb +8 -1
- metadata +8 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 954e4ce7b9c7d42a0cdcdc1403592dce3e61c6c196e72858a5c2f66f5be3e3df
|
4
|
+
data.tar.gz: 18233fefb36153a49d43d4aed40e3f44ca0f3123d346429a0a0b50a355577a16
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: e57be3e7ab4203123f919530710dd4f63842abbf588b49896fa78b2cb3fcf0c58d7e64e2881b165396cb919a897acfe3279d2c3d740a3c8f0ace8dfd923eb3c0
|
7
|
+
data.tar.gz: 3b05c7edc262d1cf0b663d451084698b52f2e11fb8ec03a057ac70551f630d35ad67530921f24535288adf1761377981e69803bb7519e8f4449977dab8ec56ca
|
@@ -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,46 @@ 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
|
+
|
126
|
+
# We're supporting custom query scopes on by klass name.
|
127
|
+
# - apply them here
|
58
128
|
def custom_scope_for_query(query)
|
59
129
|
klass = query.klass
|
60
130
|
if opts_c.proc_scopes_per_class_name.key?(klass.name)
|
@@ -79,25 +149,6 @@ module BulkDependencyEraser
|
|
79
149
|
).freeze
|
80
150
|
end
|
81
151
|
|
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
152
|
def uniqify_errors!
|
102
153
|
@errors.uniq!
|
103
154
|
end
|
@@ -1,8 +1,22 @@
|
|
1
1
|
module BulkDependencyEraser
|
2
2
|
class Builder < Base
|
3
|
+
DEFAULT_DB_BUILD_ALL_WRAPPER = ->(builder, block) do
|
4
|
+
begin
|
5
|
+
block.call
|
6
|
+
rescue StandardError => e
|
7
|
+
builder.report_error(
|
8
|
+
<<~STRING.strip
|
9
|
+
Issue attempting to build deletion query for '#{e.building_klass_name}'
|
10
|
+
=> #{e.original_error_klass.name}: #{e.message}
|
11
|
+
STRING
|
12
|
+
)
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
3
16
|
DEFAULT_OPTS = {
|
4
17
|
force_destroy_restricted: false,
|
5
18
|
verbose: false,
|
19
|
+
db_build_all_wrapper: self::DEFAULT_DB_BUILD_ALL_WRAPPER,
|
6
20
|
# Some associations scopes take parameters.
|
7
21
|
# - We would have to instantiate if we wanted to apply that scope filter.
|
8
22
|
instantiate_if_assoc_scope_with_arity: false,
|
@@ -34,35 +48,13 @@ module BulkDependencyEraser
|
|
34
48
|
reading_proc_scopes_per_class_name: {},
|
35
49
|
}.freeze
|
36
50
|
|
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
51
|
# write access so that these can be edited in-place by end-users who might need to manually adjust deletion order.
|
64
52
|
attr_accessor :deletion_list, :nullification_list
|
65
53
|
attr_reader :ignore_table_deletion_list, :ignore_table_nullification_list
|
54
|
+
attr_reader :query_schema_parser
|
55
|
+
attr_reader :current_klass_name
|
56
|
+
|
57
|
+
delegate :circular_dependency_klasses, :flat_dependencies_per_klass, to: :query_schema_parser
|
66
58
|
|
67
59
|
def initialize query:, opts: {}
|
68
60
|
@query = query
|
@@ -79,9 +71,17 @@ module BulkDependencyEraser
|
|
79
71
|
|
80
72
|
@ignore_table_name_and_dependencies = opts_c.ignore_tables_and_dependencies.collect { |table_name| table_name }
|
81
73
|
@ignore_klass_name_and_dependencies = opts_c.ignore_klass_names_and_dependencies.collect { |klass_name| klass_name }
|
74
|
+
@query_schema_parser = BulkDependencyEraser::QuerySchemaParser.new(query:, opts:)
|
75
|
+
# Moving pointer, points to the current class that is being queries
|
76
|
+
@current_klass_name = query.is_a?(ActiveRecord::Relation) ? query.klass.name : query.name
|
82
77
|
end
|
83
78
|
|
84
79
|
def execute
|
80
|
+
unless query_schema_parser.execute
|
81
|
+
merge_errors(full_schema_parser.errors, 'QuerySchemaParser: ')
|
82
|
+
return false
|
83
|
+
end
|
84
|
+
|
85
85
|
# go through deletion/nullification lists and remove any tables from 'ignore_tables' option
|
86
86
|
build_result = build
|
87
87
|
|
@@ -89,8 +89,9 @@ module BulkDependencyEraser
|
|
89
89
|
# - prior approach was to use table_name.classify, but we can't trust that approach.
|
90
90
|
opts_c.ignore_tables.each do |table_name|
|
91
91
|
table_names_to_parsed_klass_names.dig(table_name)&.each do |klass_name|
|
92
|
-
|
93
|
-
|
92
|
+
klass_index = deletion_index_key(klass_name.constantize)
|
93
|
+
ignore_table_deletion_list[klass_index] = deletion_list.delete(klass_index) if deletion_list.key?(klass_index)
|
94
|
+
ignore_table_nullification_list[klass_index] = nullification_list.delete(klass_index) if nullification_list.key?(klass_index)
|
94
95
|
end
|
95
96
|
end
|
96
97
|
|
@@ -98,35 +99,20 @@ module BulkDependencyEraser
|
|
98
99
|
end
|
99
100
|
|
100
101
|
def build
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
102
|
+
build_all_in_db do
|
103
|
+
begin
|
104
|
+
if opts_c.verbose
|
105
|
+
puts "Starting build for #{@query.is_a?(ActiveRecord::Relation) ? @query.klass.name : @query.name}"
|
106
|
+
end
|
105
107
|
|
106
|
-
|
108
|
+
deletion_query_parser(@query)
|
107
109
|
|
108
|
-
|
110
|
+
uniqify_errors!
|
109
111
|
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
klass = @query.klass
|
114
|
-
klass_name = @query.klass.name
|
115
|
-
else
|
116
|
-
# current_query is a normal rails class
|
117
|
-
klass = @query
|
118
|
-
klass_name = @query.name
|
112
|
+
return errors.none?
|
113
|
+
rescue StandardError => e
|
114
|
+
raise BulkDependencyEraser::Errors::BuilderError.new(e.class, e.message, building_klass_name: current_klass_name)
|
119
115
|
end
|
120
|
-
report_error(
|
121
|
-
"
|
122
|
-
Error Encountered in 'execute' for '#{klass_name}':
|
123
|
-
#{e.class.name}
|
124
|
-
#{e.message}
|
125
|
-
"
|
126
|
-
)
|
127
|
-
raise e
|
128
|
-
|
129
|
-
return false
|
130
116
|
end
|
131
117
|
end
|
132
118
|
|
@@ -145,7 +131,8 @@ module BulkDependencyEraser
|
|
145
131
|
end
|
146
132
|
end
|
147
133
|
|
148
|
-
def pluck_from_query
|
134
|
+
def pluck_from_query(query, column = :id)
|
135
|
+
set_current_klass_name(query)
|
149
136
|
# ordering shouldn't matter in these queries, and would slow it down
|
150
137
|
# - we're ignoring default_scope ordering, but assoc-defined ordering would still take effect
|
151
138
|
query = query.reorder('')
|
@@ -190,17 +177,15 @@ module BulkDependencyEraser
|
|
190
177
|
|
191
178
|
if query.is_a?(ActiveRecord::Relation)
|
192
179
|
klass = query.klass
|
193
|
-
klass_name = query.klass.name
|
194
180
|
else
|
195
181
|
# current_query is a normal rails class
|
196
182
|
klass = query
|
197
|
-
klass_name = query.name
|
198
183
|
end
|
199
184
|
|
200
185
|
table_names_to_parsed_klass_names[klass.table_name] ||= []
|
201
186
|
# 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?(
|
203
|
-
table_names_to_parsed_klass_names[klass.table_name] <<
|
187
|
+
unless table_names_to_parsed_klass_names[klass.table_name].include?(klass.name)
|
188
|
+
table_names_to_parsed_klass_names[klass.table_name] << klass.name
|
204
189
|
end
|
205
190
|
|
206
191
|
if ignore_table_name_and_dependencies.include?(klass.table_name)
|
@@ -208,22 +193,22 @@ module BulkDependencyEraser
|
|
208
193
|
return
|
209
194
|
end
|
210
195
|
|
211
|
-
if ignore_klass_name_and_dependencies.include?(
|
196
|
+
if ignore_klass_name_and_dependencies.include?(klass.name)
|
212
197
|
# Not parsing, table and dependencies ignorable
|
213
198
|
return
|
214
199
|
end
|
215
200
|
|
216
201
|
if opts_c.verbose
|
217
202
|
if association_parent
|
218
|
-
puts "Building #{association_parent}, association of #{
|
203
|
+
puts "Building #{association_parent}, association of #{klass.name}"
|
219
204
|
else
|
220
|
-
puts "Building #{
|
205
|
+
puts "Building #{klass.name}"
|
221
206
|
end
|
222
207
|
end
|
223
208
|
|
224
209
|
if klass.primary_key != 'id'
|
225
210
|
report_error(
|
226
|
-
"#{
|
211
|
+
"#{klass.name} - does not use primary_key 'id'. Cannot use this tool to bulk delete."
|
227
212
|
)
|
228
213
|
return
|
229
214
|
end
|
@@ -231,26 +216,22 @@ module BulkDependencyEraser
|
|
231
216
|
# Pluck IDs of the current query
|
232
217
|
query_ids = pluck_from_query(query)
|
233
218
|
|
234
|
-
|
219
|
+
klass_index = initialize_deletion_list_for_klass(klass)
|
235
220
|
|
236
221
|
# prevent infinite recursion here.
|
237
222
|
# - Remove any IDs that have been processed before
|
238
|
-
query_ids = query_ids
|
223
|
+
query_ids = remove_already_deletion_processed_ids(klass, query_ids)
|
239
224
|
|
240
225
|
# If ids are nil, let's find that error
|
241
226
|
if query_ids.none? #|| query_ids.nil?
|
242
227
|
# quick cleanup, if turns out was an empty class
|
243
|
-
deletion_list.delete(
|
228
|
+
deletion_list.delete(klass_index) if deletion_list[klass_index].none?
|
244
229
|
return
|
245
230
|
end
|
246
231
|
|
247
232
|
# Use-case: We have more IDs to process
|
248
233
|
# - can now safely add to the list, since we've prevented infinite recursion
|
249
|
-
deletion_list[
|
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?
|
234
|
+
deletion_list[klass_index] += query_ids
|
254
235
|
|
255
236
|
# ignore associations that aren't a dependent destroyable type
|
256
237
|
destroy_associations = query.reflect_on_all_associations.select do |reflection|
|
@@ -317,6 +298,7 @@ module BulkDependencyEraser
|
|
317
298
|
is_polymorphic = reflection.options[:polymorphic]
|
318
299
|
unless is_polymorphic
|
319
300
|
klass = reflection.klass
|
301
|
+
set_current_klass_name(reflection.klass)
|
320
302
|
|
321
303
|
if ignore_table_name_and_dependencies.include?(klass.table_name)
|
322
304
|
# Not parsing, table and dependencies ignorable
|
@@ -446,15 +428,12 @@ module BulkDependencyEraser
|
|
446
428
|
nullification_list[assoc_klass_name][specified_foreign_key] += assoc_ids
|
447
429
|
nullification_list[assoc_klass_name][specified_foreign_key].uniq!
|
448
430
|
|
449
|
-
# nullification_list[assoc_klass_name][specified_foreign_key].sort! if Rails.env.test?
|
450
431
|
|
451
432
|
# Also nullify the 'type' field, if the association is polymorphic
|
452
433
|
if specified_foreign_type
|
453
434
|
nullification_list[assoc_klass_name][specified_foreign_type] ||= []
|
454
435
|
nullification_list[assoc_klass_name][specified_foreign_type] += assoc_ids
|
455
436
|
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
437
|
end
|
459
438
|
else
|
460
439
|
raise "invalid parsing type: #{type}"
|
@@ -472,7 +451,6 @@ module BulkDependencyEraser
|
|
472
451
|
assoc_klass = reflection.klass
|
473
452
|
assoc_klass_name = assoc_klass.name
|
474
453
|
|
475
|
-
|
476
454
|
# specified_primary_key = reflection.options[:primary_key]&.to_s
|
477
455
|
# specified_foreign_key = reflection.options[:foreign_key]&.to_s
|
478
456
|
|
@@ -659,17 +637,6 @@ module BulkDependencyEraser
|
|
659
637
|
end
|
660
638
|
end
|
661
639
|
|
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
640
|
# return [Boolean]
|
674
641
|
# - true if valid
|
675
642
|
# - false if not valid
|
@@ -711,5 +678,82 @@ module BulkDependencyEraser
|
|
711
678
|
puts "Reading from DB..." if opts_c.verbose
|
712
679
|
opts_c.db_read_wrapper.call(block)
|
713
680
|
end
|
681
|
+
|
682
|
+
def remove_already_deletion_processed_ids(klass, new_ids)
|
683
|
+
already_processed_ids = []
|
684
|
+
|
685
|
+
if is_a_circular_dependency_klass?(klass)
|
686
|
+
klass_keys = find_circular_dependency_deletion_keys(klass)
|
687
|
+
klass_keys.each do |circular_class_key|
|
688
|
+
already_processed_ids += deletion_list[circular_class_key]
|
689
|
+
end
|
690
|
+
else
|
691
|
+
already_processed_ids = deletion_list[klass.name]
|
692
|
+
end
|
693
|
+
|
694
|
+
new_ids - already_processed_ids
|
695
|
+
end
|
696
|
+
|
697
|
+
# Initializes deletion_list index
|
698
|
+
# - increments the index for circular dependencies
|
699
|
+
def initialize_deletion_list_for_klass(klass)
|
700
|
+
klass_index = deletion_index_key(klass, increment_circular_index: true)
|
701
|
+
|
702
|
+
if is_a_circular_dependency_klass?(klass)
|
703
|
+
raise "circular_index already existed for klass: #{klass.name}" if deletion_list.key?(klass_index)
|
704
|
+
|
705
|
+
deletion_list[klass_index] = []
|
706
|
+
else
|
707
|
+
# Not a circular dependency, define as normal
|
708
|
+
deletion_list[klass_index] ||= []
|
709
|
+
end
|
710
|
+
|
711
|
+
klass_index
|
712
|
+
end
|
713
|
+
|
714
|
+
def is_a_circular_dependency_klass?(klass)
|
715
|
+
circular_dependency_klasses.values.flatten.include?(klass.name)
|
716
|
+
end
|
717
|
+
|
718
|
+
def find_circular_dependency_deletion_keys(klass)
|
719
|
+
escaped_prefix = Regexp.escape(klass.name)
|
720
|
+
# Define the key-matching regex: klass.name + '.' + <integer>
|
721
|
+
regex = /^#{escaped_prefix}\.\d+$/
|
722
|
+
# find the latest, indexed klass initialization
|
723
|
+
deletion_list.keys.select { |key| key.match?(regex) }
|
724
|
+
end
|
725
|
+
|
726
|
+
# If circular dependency, append a index suffix to the deletion hash key
|
727
|
+
# - they will be deleted in highest index to lowest index order.
|
728
|
+
def deletion_index_key(klass, increment_circular_index: false)
|
729
|
+
if is_a_circular_dependency_klass?(klass)
|
730
|
+
klass_keys = find_circular_dependency_deletion_keys(klass)
|
731
|
+
if klass_keys.none?
|
732
|
+
klass_index = "#{klass.name}.0" # first one, no need to consider increment
|
733
|
+
else
|
734
|
+
# Use map to extract the integers using a regular expression
|
735
|
+
circular_indexes = klass_keys.map { |s| s[/\.(\d+)$/, 1].to_i }
|
736
|
+
# Find the maximum value
|
737
|
+
current_circular_index = circular_indexes.max
|
738
|
+
current_circular_index += 1 if increment_circular_index
|
739
|
+
klass_index = "#{klass.name}.#{current_circular_index}"
|
740
|
+
end
|
741
|
+
else
|
742
|
+
klass_index = klass.name
|
743
|
+
end
|
744
|
+
|
745
|
+
return klass_index
|
746
|
+
end
|
747
|
+
|
748
|
+
def build_all_in_db(&block)
|
749
|
+
puts "Building all from DB..." if opts_c.verbose
|
750
|
+
opts_c.db_build_all_wrapper.call(self, block)
|
751
|
+
puts "Building all from DB complete." if opts_c.verbose
|
752
|
+
end
|
753
|
+
|
754
|
+
def set_current_klass_name(query_or_klass)
|
755
|
+
klass = query_or_klass.is_a?(ActiveRecord::Relation) ? query_or_klass.klass : query_or_klass
|
756
|
+
@current_klass_name = klass.name
|
757
|
+
end
|
714
758
|
end
|
715
759
|
end
|
@@ -1,10 +1,15 @@
|
|
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
|
-
rescue
|
7
|
-
report_error(
|
6
|
+
rescue BulkDependencyEraser::Errors::DeleterError => e
|
7
|
+
deleter.report_error(
|
8
|
+
<<~STRING.strip
|
9
|
+
Issue attempting to delete klass '#{e.deleting_klass_name}'
|
10
|
+
=> #{e.original_error_klass.name}: #{e.message}
|
11
|
+
STRING
|
12
|
+
)
|
8
13
|
end
|
9
14
|
end
|
10
15
|
|
@@ -60,23 +65,27 @@ module BulkDependencyEraser
|
|
60
65
|
|
61
66
|
current_class_name = 'N/A'
|
62
67
|
delete_all_in_db do
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
68
|
+
begin
|
69
|
+
class_names_and_ids.keys.reverse.each do |class_name|
|
70
|
+
current_class_name = class_name
|
71
|
+
ids = class_names_and_ids[class_name].reverse
|
72
|
+
klass = constantize(class_name)
|
67
73
|
|
68
|
-
|
69
|
-
|
70
|
-
delete_by_klass_and_ids(klass, ids)
|
71
|
-
else
|
72
|
-
# delete without referential integrity
|
73
|
-
# Disable any ActiveRecord::InvalidForeignKey raised errors.
|
74
|
-
# - src: https://stackoverflow.com/questions/41005849/rails-migrations-temporarily-ignore-foreign-key-constraint
|
75
|
-
# https://apidock.com/rails/ActiveRecord/ConnectionAdapters/AbstractAdapter/disable_referential_integrity
|
76
|
-
ActiveRecord::Base.connection.disable_referential_integrity do
|
74
|
+
if opts_c.enable_invalid_foreign_key_detection
|
75
|
+
# delete with referential integrity
|
77
76
|
delete_by_klass_and_ids(klass, ids)
|
77
|
+
else
|
78
|
+
# delete without referential integrity
|
79
|
+
# Disable any ActiveRecord::InvalidForeignKey raised errors.
|
80
|
+
# - src: https://stackoverflow.com/questions/41005849/rails-migrations-temporarily-ignore-foreign-key-constraint
|
81
|
+
# https://apidock.com/rails/ActiveRecord/ConnectionAdapters/AbstractAdapter/disable_referential_integrity
|
82
|
+
ActiveRecord::Base.connection.disable_referential_integrity do
|
83
|
+
delete_by_klass_and_ids(klass, ids)
|
84
|
+
end
|
78
85
|
end
|
79
86
|
end
|
87
|
+
rescue StandardError => e
|
88
|
+
raise BulkDependencyEraser::Errors::DeleterError.new(e.class, e.message, deleting_klass_name: current_class_name)
|
80
89
|
end
|
81
90
|
end
|
82
91
|
|
@@ -140,7 +149,7 @@ module BulkDependencyEraser
|
|
140
149
|
|
141
150
|
def delete_all_in_db(&block)
|
142
151
|
puts "Deleting all from DB..." if opts_c.verbose
|
143
|
-
opts_c.db_delete_all_wrapper.call(block)
|
152
|
+
opts_c.db_delete_all_wrapper.call(self, block)
|
144
153
|
puts "Deleting all from DB complete." if opts_c.verbose
|
145
154
|
end
|
146
155
|
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
module BulkDependencyEraser
|
2
|
+
module Errors
|
3
|
+
class BuilderError < BaseError
|
4
|
+
attr_reader :original_error_klass, :building_klass_name
|
5
|
+
|
6
|
+
def initialize(original_error_klass, message, building_klass_name:)
|
7
|
+
@original_error_klass = original_error_klass
|
8
|
+
@building_klass_name = building_klass_name
|
9
|
+
super(message)
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
module BulkDependencyEraser
|
2
|
+
module Errors
|
3
|
+
class DeleterError < BaseError
|
4
|
+
attr_reader :original_error_klass, :deleting_klass_name
|
5
|
+
|
6
|
+
def initialize(original_error_klass, message, deleting_klass_name:)
|
7
|
+
@original_error_klass = original_error_klass
|
8
|
+
@deleting_klass_name = deleting_klass_name
|
9
|
+
super(message)
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
module BulkDependencyEraser
|
2
|
+
module Errors
|
3
|
+
class NullifierError < BaseError
|
4
|
+
attr_reader :original_error_klass, :nullifying_klass_name, :nullifying_columns
|
5
|
+
|
6
|
+
def initialize(original_error_klass, message, nullifying_klass_name:, nullifying_columns:)
|
7
|
+
@original_error_klass = original_error_klass
|
8
|
+
@nullifying_klass_name = nullifying_klass_name
|
9
|
+
@nullifying_columns = nullifying_columns
|
10
|
+
super(message)
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
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,15 @@
|
|
1
1
|
module BulkDependencyEraser
|
2
2
|
class Nullifier < Base
|
3
|
-
DEFAULT_DB_NULLIFY_ALL_WRAPPER =
|
3
|
+
DEFAULT_DB_NULLIFY_ALL_WRAPPER = ->(nullifier, block) do
|
4
4
|
begin
|
5
5
|
block.call
|
6
|
-
rescue
|
7
|
-
report_error(
|
6
|
+
rescue BulkDependencyEraser::Errors::NullifierError => e
|
7
|
+
nullifier.report_error(
|
8
|
+
<<~STRING.strip
|
9
|
+
Issue attempting to nullify klass '#{e.nullifying_klass_name}' on column(s) '#{e.nullifying_columns}'
|
10
|
+
=> #{e.original_error_klass.name}: #{e.message}
|
11
|
+
STRING
|
12
|
+
)
|
8
13
|
end
|
9
14
|
end
|
10
15
|
|
@@ -114,6 +119,7 @@ module BulkDependencyEraser
|
|
114
119
|
current_class_name = 'N/A'
|
115
120
|
current_column = 'N/A'
|
116
121
|
nullify_all_in_db do
|
122
|
+
begin
|
117
123
|
# column_and_ids should have already been reversed in builder
|
118
124
|
class_names_columns_and_ids.keys.reverse.each do |class_name|
|
119
125
|
current_class_name = class_name
|
@@ -139,6 +145,14 @@ module BulkDependencyEraser
|
|
139
145
|
end
|
140
146
|
end
|
141
147
|
end
|
148
|
+
rescue StandardError => e
|
149
|
+
raise BulkDependencyEraser::Errors::NullifierError.new(
|
150
|
+
e.class,
|
151
|
+
e.message,
|
152
|
+
nullifying_klass_name: current_class_name,
|
153
|
+
nullifying_columns: current_column.to_s # could be an array, string, or symbol
|
154
|
+
)
|
155
|
+
end
|
142
156
|
end
|
143
157
|
|
144
158
|
return errors.none?
|
@@ -211,7 +225,7 @@ module BulkDependencyEraser
|
|
211
225
|
|
212
226
|
def nullify_all_in_db(&block)
|
213
227
|
puts "Nullifying all from DB..." if opts_c.verbose
|
214
|
-
opts_c.db_nullify_all_wrapper.call(block)
|
228
|
+
opts_c.db_nullify_all_wrapper.call(self, block)
|
215
229
|
puts "Nullifying all from DB complete." if opts_c.verbose
|
216
230
|
end
|
217
231
|
|
@@ -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,6 +1,13 @@
|
|
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'
|
6
|
-
require_relative 'bulk_dependency_eraser/
|
8
|
+
require_relative 'bulk_dependency_eraser/utils'
|
9
|
+
require_relative 'bulk_dependency_eraser/version'
|
10
|
+
require_relative 'bulk_dependency_eraser/errors/base_error'
|
11
|
+
require_relative 'bulk_dependency_eraser/errors/builder_error'
|
12
|
+
require_relative 'bulk_dependency_eraser/errors/deleter_error'
|
13
|
+
require_relative 'bulk_dependency_eraser/errors/nullifier_error'
|
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:
|
4
|
+
version: 3.0.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- benjamin.dana.software.dev@gmail.com
|
@@ -146,8 +146,15 @@ 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/errors/base_error.rb
|
150
|
+
- lib/bulk_dependency_eraser/errors/builder_error.rb
|
151
|
+
- lib/bulk_dependency_eraser/errors/deleter_error.rb
|
152
|
+
- lib/bulk_dependency_eraser/errors/nullifier_error.rb
|
153
|
+
- lib/bulk_dependency_eraser/full_schema_parser.rb
|
149
154
|
- lib/bulk_dependency_eraser/manager.rb
|
150
155
|
- lib/bulk_dependency_eraser/nullifier.rb
|
156
|
+
- lib/bulk_dependency_eraser/query_schema_parser.rb
|
157
|
+
- lib/bulk_dependency_eraser/utils.rb
|
151
158
|
- lib/bulk_dependency_eraser/version.rb
|
152
159
|
homepage: https://github.com/danabr75/bulk_dependency_eraser
|
153
160
|
licenses:
|