bulk_dependency_eraser 1.0.1 → 1.0.4

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: 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: