bulk_dependency_eraser 1.0.1 → 1.0.4

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: 97a8415296925a84d544fa6a4deddcd0d5f2162116ed9c7bb210bcc16c2e74e6
4
- data.tar.gz: f3dca162d1a357c87a305151f087a007005cff8f70656d0e4e514a0c6b419079
3
+ metadata.gz: cc034a38ab80a2e277860a5a69f6a654e1e461f691f54d6a510814b55279cebd
4
+ data.tar.gz: 88055bcbcb73bc260ef4718006f3149d95ea216badb4bd4ac142506e756c1882
5
5
  SHA512:
6
- metadata.gz: 8a8fdc09856d111bbcf8a27057515e637b3e29866d9e5963b1851d0678f71c585331aab6cf63e9d8534cf7ad47e624f71035a5a09ba26dd168b10c0cf030f956
7
- data.tar.gz: 8b3ce6e5e3391f639f9007a1990e21011a5173bc092f7e0eab234751130a3b6d5594f238609014b7e4ddff49b937af7c3773b0c79cbda1e4e4d191060e7fa5f2
6
+ metadata.gz: 0c9d1bc7851eaa2f51a853a99b24ff3842940f792938b2eac86ebfcc6471653c0d14de735d837f108ca59118a6661c086566b48475b87f1c98870cd80544dd79
7
+ data.tar.gz: 7e09f55e2baf0db2eefd7f2dfc028fda58b7f9ec2469a6bdbcfa56855bd4cf1ddc0b1502468554f56726fcbd08135daecc5e20940d18b2d9a2f49dc2558c5971
@@ -30,7 +30,9 @@ module BulkDependencyEraser
30
30
  end
31
31
 
32
32
  def report_error msg
33
- @errors << msg
33
+ # remove new lines, surrounding white space, replace with semicolon delimiters
34
+ n = msg.strip.gsub(/\s*\n\s*/, ' ')
35
+ @errors << n
34
36
  end
35
37
 
36
38
  def merge_errors errors, prefix = nil
@@ -13,6 +13,7 @@ module BulkDependencyEraser
13
13
  # Won't parse any table in this list
14
14
  ignore_tables_and_dependencies: [],
15
15
  ignore_klass_names_and_dependencies: [],
16
+ batching_size_limit: 500,
16
17
  }.freeze
17
18
 
18
19
  DEFAULT_DB_WRAPPER = ->(block) do
@@ -106,11 +107,18 @@ module BulkDependencyEraser
106
107
  #{e.message}
107
108
  "
108
109
  )
110
+ raise e
109
111
 
110
112
  return false
111
113
  end
112
114
  end
113
115
 
116
+ protected
117
+
118
+ attr_reader :ignore_klass_and_dependencies
119
+ attr_reader :table_names_to_parsed_klass_names
120
+ attr_reader :ignore_table_name_and_dependencies, :ignore_klass_name_and_dependencies
121
+
114
122
  def deletion_query_parser query, association_parent = nil
115
123
  # necessary for "ActiveRecord::Reflection::ThroughReflection" use-case
116
124
  # force_through_destroy_chains = options[:force_destroy_chain] || {}
@@ -145,7 +153,7 @@ module BulkDependencyEraser
145
153
  if association_parent
146
154
  puts "Building #{klass_name}"
147
155
  else
148
- puts "Building #{association_parent} => #{klass_name}"
156
+ puts "Building #{association_parent}, assocation of #{klass_name}"
149
157
  end
150
158
  end
151
159
 
@@ -184,19 +192,12 @@ module BulkDependencyEraser
184
192
  # ignore associations that aren't a dependent destroyable type
185
193
  destroy_associations = query.reflect_on_all_associations.select do |reflection|
186
194
  assoc_dependent_type = reflection.options&.dig(:dependent)&.to_sym
187
- if DEPENDENCY_DESTROY_IGNORE_REFLECTION_TYPES.include?(reflection.class.name)
188
- # Ignore those types of associations.
189
- false
190
- elsif DEPENDENCY_RESTRICT.include?(assoc_dependent_type) && opts_c.force_destroy_restricted != true
191
- # If the dependency_type is restricted_with_..., and we're not supposed to destroy those, report errork
192
- report_error(
193
- "#{klass_name}'s assoc '#{reflection.name}' has a 'dependent: :#{assoc_dependent_type}' set. " \
194
- "If you still wish to destroy, use the 'force_destroy_restricted: true' option"
195
- )
196
- false
197
- else
198
- DEPENDENCY_DESTROY.include?(assoc_dependent_type)
199
- end
195
+ DEPENDENCY_DESTROY.include?(assoc_dependent_type) && !DEPENDENCY_RESTRICT.include?(assoc_dependent_type)
196
+ end
197
+
198
+ restricted_associations = query.reflect_on_all_associations.select do |reflection|
199
+ assoc_dependent_type = reflection.options&.dig(:dependent)&.to_sym
200
+ DEPENDENCY_RESTRICT.include?(assoc_dependent_type)
200
201
  end
201
202
 
202
203
  nullify_associations = query.reflect_on_all_associations.select do |reflection|
@@ -204,16 +205,28 @@ module BulkDependencyEraser
204
205
  DEPENDENCY_NULLIFY.include?(assoc_dependent_type)
205
206
  end
206
207
 
207
- destroy_association_names = destroy_associations.map(&:name)
208
- nullify_association_names = nullify_associations.map(&:name)
208
+ destroy_association_names = destroy_associations.map(&:name)
209
+ nullify_association_names = nullify_associations.map(&:name)
210
+ restricted_association_names = restricted_associations.map(&:name)
209
211
 
210
- # Iterate through the assoc names, if there are any :through assocs, then remap
212
+ # Iterate through the assoc names, if there are any :through assocs, then rename the association
213
+ # - Rails interpretation of any dependencies of a :through association is to apply it to
214
+ # the leaf association at the end of the :through chain(s)
211
215
  destroy_association_names = destroy_association_names.collect do |assoc_name|
212
216
  find_root_association_from_through_assocs(klass, assoc_name)
213
217
  end
214
218
  nullify_association_names = nullify_association_names.collect do |assoc_name|
215
219
  find_root_association_from_through_assocs(klass, assoc_name)
216
220
  end
221
+ restricted_association_names = restricted_association_names.collect do |assoc_name|
222
+ find_root_association_from_through_assocs(klass, assoc_name)
223
+ end
224
+
225
+ if opts_c.verbose
226
+ puts "Destroyable Associations: #{destroy_association_names}"
227
+ puts "Nullifiable Associations: #{nullify_association_names}"
228
+ puts " Restricted Associations: #{restricted_association_names}"
229
+ end
217
230
 
218
231
  destroy_association_names.each do |destroy_association_name|
219
232
  association_parser(klass, query, query_ids, destroy_association_name, :delete)
@@ -222,12 +235,49 @@ module BulkDependencyEraser
222
235
  nullify_association_names.each do |nullify_association_name|
223
236
  association_parser(klass, query, query_ids, nullify_association_name, :nullify)
224
237
  end
238
+
239
+ restricted_association_names.each do |restricted_association_name|
240
+ association_parser(klass, query, query_ids, restricted_association_name, :restricted)
241
+ end
225
242
  end
226
243
 
227
- # Iterate through each destroyable association, and recursively call 'deletion_query_parser'.
228
- def association_parser parent_class, query, query_ids, association_name, type
244
+ # Used to iterate through each destroyable association, and recursively call 'deletion_query_parser'.
245
+ # @param parent_class [ApplicationRecord]
246
+ # @param query [ActiveRecord_Relation] - We need the 'query' in case associations are tied to column other than 'id'
247
+ # @param query_ids [Array<Int | String>] - Array of parent's IDs (or UUIDs)
248
+ # @param association_name [Symbol] - The association name from the parent_class
249
+ # @param type [Symbol] - either :delete or :nullify or :restricted
250
+ def association_parser(parent_class, query, query_ids, association_name, type)
229
251
  reflection = parent_class.reflect_on_association(association_name)
230
252
  reflection_type = reflection.class.name
253
+
254
+ if self.class::DEPENDENCY_DESTROY_IGNORE_REFLECTION_TYPES.include?(reflection_type)
255
+ report_error("Dependency detected on #{parent_class.name}'s '#{association_name}' - association doesn't support dependency")
256
+ return
257
+ end
258
+
259
+ case reflection_type
260
+ when 'ActiveRecord::Reflection::HasManyReflection'
261
+ association_parser_has_many(parent_class, query, query_ids, association_name, type)
262
+ when 'ActiveRecord::Reflection::HasOneReflection'
263
+ association_parser_has_many(parent_class, query, query_ids, association_name, type, { limit: 1 })
264
+ when 'ActiveRecord::Reflection::BelongsToReflection'
265
+ if type == :nullify
266
+ report_error("#{parent_class.name}'s association '#{association_name}' - dependent 'nullify' invalid for 'belongs_to'")
267
+ else
268
+ association_parser_belongs_to(parent_class, query, query_ids, association_name, type)
269
+ end
270
+ else
271
+ report_message("Unsupported association type for #{parent_class.name}'s association '#{association_name}': #{reflection_type}")
272
+ end
273
+ end
274
+
275
+ # Handles the :has_many association type
276
+ # - handles it's polymorphic associations internally (easier on the has_many)
277
+ def association_parser_has_many(parent_class, query, query_ids, association_name, type, opts = {})
278
+ reflection = parent_class.reflect_on_association(association_name)
279
+ reflection_type = reflection.class.name
280
+
231
281
  assoc_klass = reflection.klass
232
282
  assoc_klass_name = assoc_klass.name
233
283
 
@@ -241,9 +291,9 @@ module BulkDependencyEraser
241
291
  # If there is an association scope present, check to see how many parameters it's using
242
292
  # - if there's any parameter, we have to either skip it or instantiate it to find it's dependencies.
243
293
  if reflection.scope&.arity&.nonzero?
244
- # TODO!
245
294
  if opts_c.instantiate_if_assoc_scope_with_arity
246
- raise "TODO: instantiate and apply scope!"
295
+ association_parser_has_many_instantiation(parent_class, query, query_ids, association_name, type, opts)
296
+ return
247
297
  else
248
298
  report_error(
249
299
  "#{parent_class.name} and '#{association_name}' - scope has instance parameters. Use :instantiate_if_assoc_scope_with_arity option?"
@@ -252,21 +302,23 @@ module BulkDependencyEraser
252
302
  end
253
303
  elsif reflection.scope
254
304
  # I saw this used somewhere, too bad I didn't save the source for it.
255
- s = parent_class.reflect_on_association(association_name).scope
256
- assoc_query = assoc_query.instance_exec(&s)
305
+ assoc_scope = parent_class.reflect_on_association(association_name).scope
306
+ assoc_query = assoc_query.instance_exec(&assoc_scope)
257
307
  end
258
308
 
309
+ # Look for manually specified keys in the assocation first
259
310
  specified_primary_key = reflection.options[:primary_key]&.to_s
260
311
  specified_foreign_key = reflection.options[:foreign_key]&.to_s
312
+ # For polymorphic_associations
313
+ specified_foreign_type = nil
261
314
 
262
315
  # handle foreign_key edge cases
263
316
  if specified_foreign_key.nil?
264
- if reflection.options[:polymorphic]
265
- assoc_query = assoc_query.where({(association_name.singularize + '_type').to_sym => parent_class.table_name.classify})
266
- specified_foreign_key = association_name.singularize + "_id"
267
- elsif reflection.options[:as]
268
- assoc_query = assoc_query.where({(reflection.options[:as].to_s + '_type').to_sym => parent_class.table_name.classify})
269
- specified_foreign_key = reflection.options[:as].to_s + "_id"
317
+ if reflection.options[:as]
318
+ specified_foreign_type = "#{reflection.options[:as]}_type"
319
+ specified_foreign_key = "#{reflection.options[:as]}_id"
320
+ # Only filtering by type here, the extra work for a poly assoc. We filter by IDs later
321
+ assoc_query = assoc_query.where({ specified_foreign_type.to_sym => parent_class.name })
270
322
  else
271
323
  specified_foreign_key = parent_class.table_name.singularize + "_id"
272
324
  end
@@ -277,7 +329,7 @@ module BulkDependencyEraser
277
329
  report_error(
278
330
  "
279
331
  For #{parent_class.name}'s assoc '#{assoc_klass.name}': Could not determine the assoc's foreign key.
280
- Generated '#{specified_foreign_key}', but did not exist on the association table.
332
+ Foreign key should have been '#{specified_foreign_key}', but did not exist on the #{assoc_klass.table_name} table.
281
333
  "
282
334
  )
283
335
  return
@@ -295,9 +347,18 @@ module BulkDependencyEraser
295
347
  assoc_query = assoc_query.where(specified_foreign_key.to_sym => query_ids)
296
348
  end
297
349
 
350
+ # Apply limit if has_one assocation (subset of has_many)
351
+ if opts[:limit]
352
+ assoc_query = assoc_query.limit(opts[:limit])
353
+ end
354
+
298
355
  if type == :delete
299
356
  # Recursively call 'deletion_query_parser' on association query, to delete any if the assoc's dependencies
300
357
  deletion_query_parser(assoc_query, parent_class)
358
+ elsif type == :restricted
359
+ if traverse_restricted_dependency?(parent_class, reflection, assoc_query)
360
+ deletion_query_parser(assoc_query, parent_class)
361
+ end
301
362
  elsif type == :nullify
302
363
  # No need for recursion here.
303
364
  # - we're not destroying these assocs (just nullifying foreign_key columns) so we don't need to parse their dependencies.
@@ -312,16 +373,193 @@ module BulkDependencyEraser
312
373
  nullification_list[assoc_klass_name][specified_foreign_key] ||= []
313
374
  nullification_list[assoc_klass_name][specified_foreign_key] += assoc_ids
314
375
  nullification_list[assoc_klass_name][specified_foreign_key].uniq!
376
+
377
+ nullification_list[assoc_klass_name][specified_foreign_key].sort! if Rails.env.test?
378
+
379
+ # Also nullify the 'type' field, if the association is polymorphic
380
+ if specified_foreign_type
381
+ nullification_list[assoc_klass_name][specified_foreign_type] ||= []
382
+ nullification_list[assoc_klass_name][specified_foreign_type] += assoc_ids
383
+ nullification_list[assoc_klass_name][specified_foreign_type].uniq!
384
+
385
+ nullification_list[assoc_klass_name][specified_foreign_type].sort! if Rails.env.test?
386
+ end
315
387
  else
316
388
  raise "invalid parsing type: #{type}"
317
389
  end
318
390
  end
319
391
 
320
- protected
392
+ # So you've decided to attempt instantiation...
393
+ # This will be a lot slower than the rest of our logic here, but if needs must.
394
+ #
395
+ # This method will replicate association_parser, but instantiate and iterate in batches
396
+ def association_parser_has_many_instantiation(parent_class, query, query_ids, association_name, type, opts)
397
+ raise "Invalid State! Not ready to instantiate!"
398
+ reflection = parent_class.reflect_on_association(association_name)
399
+ reflection_type = reflection.class.name
400
+ assoc_klass = reflection.klass
401
+ assoc_klass_name = assoc_klass.name
321
402
 
322
- attr_reader :ignore_klass_and_dependencies
323
- attr_reader :table_names_to_parsed_klass_names
324
- attr_reader :ignore_table_name_and_dependencies, :ignore_klass_name_and_dependencies
403
+
404
+ # specified_primary_key = reflection.options[:primary_key]&.to_s
405
+ # specified_foreign_key = reflection.options[:foreign_key]&.to_s
406
+
407
+ # assoc_query = assoc_klass.unscoped
408
+ # query.in_batches
409
+
410
+ assoc_klass.in_batches(of: opts_c.batching_size_limit) do |batch|
411
+ batch.each do |record|
412
+ record.send(association_name)
413
+ end
414
+ end
415
+ end
416
+
417
+ def association_parser_belongs_to(parent_class, query, query_ids, association_name, type)
418
+ reflection = parent_class.reflect_on_association(association_name)
419
+ reflection_type = reflection.class.name
420
+
421
+ # Can't run certain checks on a polymorphic association, no definitive klass to use.
422
+ # - Usually, the polymorphic class is the leaf in a dependency tree.
423
+ # - In this case, i.e.: a `belongs_to :polymorphicable, polymorphic: true, dependent: :destroy` use-case
424
+ if reflection.options[:polymorphic]
425
+ # We'd have to pluck our various types, iterate through each, using each type as the assoc_query starting point
426
+ association_parser_belongs_to_polymorphic(parent_class, query, query_ids, association_name, type)
427
+ return
428
+ end
429
+
430
+ assoc_klass = reflection.klass
431
+ assoc_klass_name = assoc_klass.name
432
+
433
+ assoc_query = assoc_klass.unscoped
434
+
435
+ unless assoc_klass.primary_key == 'id'
436
+ report_error("#{parent_class.name}'s association '#{association_name}' - assoc class does not use 'id' as a primary_key")
437
+ return
438
+ end
439
+
440
+ # If there is an association scope present, check to see how many parameters it's using
441
+ # - if there's any parameter, we have to either skip it or instantiate it to find it's dependencies.
442
+ if reflection.scope&.arity&.nonzero?
443
+ # TODO: PENDING:
444
+ # if opts_c.instantiate_if_assoc_scope_with_arity
445
+ # association_parser_belongs_to_instantiation(parent_class, query, query_ids, association_name, type)
446
+ # return
447
+ # else
448
+ # report_error(
449
+ # "#{parent_class.name} and '#{association_name}' - scope has instance parameters. Use :instantiate_if_assoc_scope_with_arity option?"
450
+ # )
451
+ # return
452
+ # end
453
+ elsif reflection.scope
454
+ # I saw this used somewhere, too bad I didn't save the source for it.
455
+ assoc_scope = parent_class.reflect_on_association(association_name).scope
456
+ assoc_query = assoc_query.instance_exec(&assoc_scope)
457
+ end
458
+
459
+ specified_primary_key = reflection.options[:primary_key] || 'id'
460
+ specified_foreign_key = reflection.options[:foreign_key] || "#{association_name}_id"
461
+
462
+ # Check to see if foreign_key exists in our parent table
463
+ unless parent_class.column_names.include?(specified_foreign_key)
464
+ report_error(
465
+ "
466
+ For #{parent_class.name}'s association '#{association_name}': Could not determine the assoc's foreign key.
467
+ Foreign key should have been '#{specified_foreign_key}', but did not exist on the #{parent_class.table_name} table.
468
+ "
469
+ )
470
+ return
471
+ end
472
+
473
+ assoc_query = read_from_db do
474
+ assoc_query.where(
475
+ specified_primary_key.to_sym => query.pluck(specified_foreign_key)
476
+ )
477
+ end
478
+
479
+ if type == :delete
480
+ # Recursively call 'deletion_query_parser' on association query, to delete any if the assoc's dependencies
481
+ deletion_query_parser(assoc_query, parent_class)
482
+ elsif type == :restricted
483
+ if traverse_restricted_dependency?(parent_class, reflection, assoc_query)
484
+ deletion_query_parser(assoc_query, parent_class)
485
+ end
486
+ else
487
+ raise "invalid parsing type: #{type}"
488
+ end
489
+ end
490
+
491
+ # So you've decided to attempt instantiation...
492
+ # This will be a lot slower than the rest of our logic here, but if needs must.
493
+ #
494
+ # This method will replicate association_parser, but instantiate and iterate in batches
495
+ def association_parser_belongs_to_instantiation(parent_class, query, query_ids, association_name, type)
496
+ # pending
497
+ end
498
+
499
+ # In this case, it's like a `belongs_to :polymorphicable, polymorphic: true, dependent: :destroy` use-case
500
+ # - it's unusual, but valid use-case
501
+ def association_parser_belongs_to_polymorphic(parent_class, query, query_ids, association_name, type)
502
+ reflection = parent_class.reflect_on_association(association_name)
503
+ reflection_type = reflection.class.name
504
+
505
+ # If there is an association scope present, check to see how many parameters it's using
506
+ # - if there's any parameter, we have to either skip it or instantiate it to find it's dependencies.
507
+ if reflection.scope&.arity&.nonzero?
508
+ raise 'PENDING'
509
+ elsif reflection.scope
510
+ # I saw this used somewhere, too bad I didn't save the source for it.
511
+ assoc_scope = parent_class.reflect_on_association(association_name).scope
512
+ assoc_query = assoc_query.instance_exec(&assoc_scope)
513
+ end
514
+
515
+ specified_primary_key = reflection.options[:primary_key] || 'id'
516
+ specified_foreign_key = reflection.options[:foreign_key] || "#{association_name}_id"
517
+ specified_foreign_type = specified_foreign_key.sub(/_id$/, '_type')
518
+
519
+ # Check to see if foreign_key exists in our parent table
520
+ unless parent_class.column_names.include?(specified_foreign_key)
521
+ report_error(
522
+ "
523
+ For #{parent_class.name}'s association '#{association_name}': Could not determine the class's foreign key.
524
+ Foreign key should have been '#{specified_foreign_key}', but did not exist on the #{parent_class.table_name} table.
525
+ "
526
+ )
527
+ return
528
+ end
529
+ unless parent_class.column_names.include?(specified_foreign_type)
530
+ report_error(
531
+ "
532
+ For #{parent_class.name}'s association '#{association_name}': Could not determine the class's polymorphic type.
533
+ Foreign key should have been '#{specified_foreign_type}', but did not exist on the #{parent_class.table_name} table.
534
+ "
535
+ )
536
+ return
537
+ end
538
+
539
+ foreign_ids_by_type = read_from_db do
540
+ query.pluck(specified_foreign_key, specified_foreign_type).each_with_object({}) do |(id, type), hash|
541
+ hash.key?(type) ? hash[type] << id : hash[type] = [id]
542
+ end
543
+ end
544
+
545
+ if type == :delete
546
+ # Recursively call 'deletion_query_parser' on association query, to delete any if the assoc's dependencies
547
+ foreign_ids_by_type.each do |type, ids|
548
+ assoc_klass = type.constantize
549
+ deletion_query_parser(assoc_klass.where(id: ids), assoc_klass)
550
+ end
551
+ elsif type == :restricted
552
+ if traverse_restricted_dependency_for_belongs_to_poly?(parent_class, reflection, foreign_ids_by_type)
553
+ # Recursively call 'deletion_query_parser' on association query, to delete any if the assoc's dependencies
554
+ foreign_ids_by_type.each do |type, ids|
555
+ assoc_klass = type.constantize
556
+ deletion_query_parser(assoc_klass.where(id: ids), assoc_klass)
557
+ end
558
+ end
559
+ else
560
+ raise "invalid parsing type: #{type}"
561
+ end
562
+ end
325
563
 
326
564
  # A dependent assoc may be through another association. Follow the throughs to find the correct assoc to destroy.
327
565
  def find_root_association_from_through_assocs klass, association_name
@@ -334,6 +572,43 @@ module BulkDependencyEraser
334
572
  end
335
573
  end
336
574
 
575
+ # return [Boolean]
576
+ # - true if valid
577
+ # - false if not valid
578
+ def traverse_restricted_dependency? parent_class, reflection, association_query
579
+ # Return true if we're going to destroy all restricted
580
+ return true if opts_c.force_destroy_restricted
581
+
582
+ if association_query.any?
583
+ report_error(
584
+ "
585
+ #{parent_class.name}'s assoc '#{reflection.name}' has a restricted dependency type.
586
+ If you still wish to destroy, use the 'force_destroy_restricted: true' option
587
+ "
588
+ )
589
+ end
590
+
591
+ return false
592
+ end
593
+
594
+ # special use-case to detect restricted dependency for 'belongs_to polymorphic: true' use-case
595
+ def traverse_restricted_dependency_for_belongs_to_poly? parent_class, reflection, ids_by_type
596
+ # Return true if we're going to destroy all restricted
597
+ return true if opts_c.force_destroy_restricted
598
+
599
+ ids = ids_by_type.values.flatten
600
+ if ids.any?
601
+ report_error(
602
+ "
603
+ #{parent_class.name}'s assoc '#{reflection.name}' has a restricted dependency type.
604
+ If you still wish to destroy, use the 'force_destroy_restricted: true' option
605
+ "
606
+ )
607
+ end
608
+
609
+ return false
610
+ end
611
+
337
612
  def read_from_db(&block)
338
613
  puts "Reading from DB..." if opts_c.verbose
339
614
  opts_c.db_read_wrapper.call(block)
@@ -44,8 +44,8 @@ module BulkDependencyEraser
44
44
  end
45
45
  end
46
46
  end
47
- rescue Exception => e
48
- report_error("Issue attempting to delete '#{current_class_name}': #{e.name} - #{e.message}")
47
+ rescue StandardError => e
48
+ report_error("Issue attempting to delete '#{current_class_name}': #{e.class.name} - #{e.message}")
49
49
  raise ActiveRecord::Rollback
50
50
  end
51
51
  end
@@ -24,8 +24,7 @@ module BulkDependencyEraser
24
24
  return false unless build
25
25
  end
26
26
 
27
- delete!
28
- nullify!
27
+ nullify! && delete!
29
28
 
30
29
  return errors.none?
31
30
  end
@@ -2,7 +2,11 @@ module BulkDependencyEraser
2
2
  class Nullifier < Base
3
3
  DEFAULT_OPTS = {
4
4
  verbose: false,
5
- db_nullify_wrapper: self::DEFAULT_DB_WRAPPER
5
+ db_nullify_wrapper: self::DEFAULT_DB_WRAPPER,
6
+ # Set to true if you want 'ActiveRecord::InvalidForeignKey' errors raised during nullifications
7
+ # - I can't think of a use-case where a nullification would generate an invalid key error
8
+ # - Not hurting anything to leave it in, but might remove it in the future.
9
+ enable_invalid_foreign_key_detection: false
6
10
  }.freeze
7
11
 
8
12
  DEFAULT_DB_WRAPPER = ->(block) do
@@ -22,6 +26,53 @@ module BulkDependencyEraser
22
26
  def initialize class_names_columns_and_ids:, opts: {}
23
27
  @class_names_columns_and_ids = class_names_columns_and_ids
24
28
  super(opts:)
29
+
30
+ if opts_c.verbose
31
+ puts "Combining nullification column groups (if groupable)"
32
+ puts "Before Combination: #{@class_names_columns_and_ids}"
33
+ end
34
+
35
+ @class_names_columns_and_ids = combine_matching_columns(@class_names_columns_and_ids)
36
+
37
+ if opts_c.verbose
38
+ puts "After Combination: #{@class_names_columns_and_ids}"
39
+ end
40
+ end
41
+
42
+ # Combine columns if the IDs are the same
43
+ # - will do one SQL call instead of several
44
+ def combine_matching_columns(nullification_hash)
45
+ return {} if nullification_hash.none?
46
+
47
+ merged_hash = {}
48
+
49
+ nullification_hash.each do |klass_name, columns_and_ids|
50
+ merged_hash[klass_name] = {}
51
+ columns_and_ids.each do |key, array|
52
+ sorted_array = array.sort
53
+
54
+ # Find any existing key in merged_hash that has the same sorted array
55
+ matching_key = merged_hash[klass_name].keys.find { |k| merged_hash[klass_name][k].sort == sorted_array }
56
+
57
+ if matching_key
58
+ # Concatenate the matching keys and update the hash
59
+ new_key = key.is_a?(Array) ? key : [key]
60
+ if matching_key.is_a?(Array)
61
+ new_key += matching_key
62
+ else
63
+ new_key << matching_key
64
+ end
65
+
66
+ merged_hash[klass_name][new_key] = sorted_array
67
+ merged_hash[klass_name].delete(matching_key)
68
+ else
69
+ # Otherwise, just add the current key-value pair
70
+ merged_hash[klass_name][key] = sorted_array
71
+ end
72
+ end
73
+ end
74
+
75
+ merged_hash
25
76
  end
26
77
 
27
78
  def execute
@@ -34,21 +85,25 @@ module BulkDependencyEraser
34
85
  klass = class_name.constantize
35
86
 
36
87
  columns_and_ids = class_names_columns_and_ids[class_name]
37
-
38
88
  columns_and_ids.each do |column, ids|
39
89
  current_column = column
40
- # Disable any ActiveRecord::InvalidForeignKey raised errors.
41
- # src https://stackoverflow.com/questions/41005849/rails-migrations-temporarily-ignore-foreign-key-constraint
42
- # https://apidock.com/rails/ActiveRecord/ConnectionAdapters/AbstractAdapter/disable_referential_integrity
43
- ActiveRecord::Base.connection.disable_referential_integrity do
44
- nullify_in_db do
45
- klass.unscoped.where(id: ids).update_all(column => nil)
90
+
91
+ if opts_c.enable_invalid_foreign_key_detection
92
+ # nullify with referential integrity
93
+ nullify_by_klass_column_and_ids(klass, column, ids)
94
+ else
95
+ # nullify without referential integrity
96
+ # Disable any ActiveRecord::InvalidForeignKey raised errors.
97
+ # - src: https://stackoverflow.com/questions/41005849/rails-migrations-temporarily-ignore-foreign-key-constraint
98
+ # https://apidock.com/rails/ActiveRecord/ConnectionAdapters/AbstractAdapter/disable_referential_integrity
99
+ ActiveRecord::Base.connection.disable_referential_integrity do
100
+ nullify_by_klass_column_and_ids(klass, column, ids)
46
101
  end
47
102
  end
48
103
  end
49
104
  end
50
- rescue Exception => e
51
- report_error("Issue attempting to nullify '#{current_class_name}' column '#{current_column}': #{e.name} - #{e.message}")
105
+ rescue StandardError => e
106
+ report_error("Issue attempting to nullify '#{current_class_name}' column '#{current_column}': #{e.class.name} - #{e.message}")
52
107
  raise ActiveRecord::Rollback
53
108
  end
54
109
  end
@@ -60,6 +115,23 @@ module BulkDependencyEraser
60
115
 
61
116
  attr_reader :class_names_columns_and_ids
62
117
 
118
+ def nullify_by_klass_column_and_ids klass, columns, ids
119
+ nullify_columns = {}
120
+
121
+ # supporting nullification of groups of columns simultaneously
122
+ if columns.is_a?(Array)
123
+ columns.each do |column|
124
+ nullify_columns[column] = nil
125
+ end
126
+ else
127
+ nullify_columns[columns] = nil
128
+ end
129
+
130
+ nullify_in_db do
131
+ klass.unscoped.where(id: ids).update_all(nullify_columns)
132
+ end
133
+ end
134
+
63
135
  def nullify_in_db(&block)
64
136
  puts "Nullifying from DB..." if opts_c.verbose
65
137
  opts_c.db_nullify_wrapper.call(block)
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: 1.0.1
4
+ version: 1.0.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - benjamin.dana.software.dev@gmail.com
@@ -108,6 +108,34 @@ dependencies:
108
108
  - - "~>"
109
109
  - !ruby/object:Gem::Version
110
110
  version: '1.4'
111
+ - !ruby/object:Gem::Dependency
112
+ name: factory_bot
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '6.4'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '6.4'
125
+ - !ruby/object:Gem::Dependency
126
+ name: faker
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - "~>"
130
+ - !ruby/object:Gem::Version
131
+ version: '3.4'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - "~>"
137
+ - !ruby/object:Gem::Version
138
+ version: '3.4'
111
139
  description:
112
140
  email:
113
141
  executables: []
@@ -123,7 +151,8 @@ files:
123
151
  homepage: https://github.com/danabr75/bulk_dependency_eraser
124
152
  licenses:
125
153
  - LGPL-3.0-only
126
- metadata: {}
154
+ metadata:
155
+ source_code_uri: https://github.com/danabr75/bulk_dependency_eraser
127
156
  post_install_message:
128
157
  rdoc_options: []
129
158
  require_paths: