db-purger 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5b7144cdb2cda2433339283a8ca98c35ace1015c30fce5642a4c064234f07e72
4
+ data.tar.gz: 8b199fe73457afb3dabf37a2e6746cc839d42514a0ff81b99e0bd0ab62a1d2b5
5
+ SHA512:
6
+ metadata.gz: 3c56fb34ca9276ad3e3e258825f703631f80ab6a1711a8e703feb4fb2b2d5676672c221f2f86ca8c310712928eb23705d86cb7fbdca8e01e56e2e0b653e859a3
7
+ data.tar.gz: 7283b99020d4164eadba078b9560921339c4e87d467bad5b0870991f6ed00f42be464db212949e6f9e083cc3d8d79e8bedada03bb3450bf36c2d57cfd5776d64
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DBPurger
4
+ # DBPurger::Config keeps track of global config options for the purge process
5
+ class Config
6
+ attr_writer :explain,
7
+ :explain_file
8
+
9
+ def explain?
10
+ @explain == true
11
+ end
12
+
13
+ def explain_file
14
+ (@explain_file || $stdout)
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DBPurger
4
+ # DBPurger::MetricSubscriber tracks the progress of the Plan#purge process
5
+ class MetricSubscriber < ActiveSupport::Subscriber
6
+ def self.metrics
7
+ @metrics ||= Metrics.new
8
+ end
9
+
10
+ def self.reset!
11
+ metrics.reset!
12
+ end
13
+
14
+ def purge(event)
15
+ self.class.metrics.update_purge_stats(
16
+ event.payload[:table_name],
17
+ event.duration,
18
+ event.payload[:deleted]
19
+ )
20
+ end
21
+
22
+ def delete_records(event)
23
+ self.class.metrics.update_delete_records_stats(
24
+ event.payload[:table_name],
25
+ event.duration,
26
+ event.payload[:records_deleted],
27
+ event.payload[:num_records]
28
+ )
29
+ end
30
+
31
+ def next_batch(event)
32
+ self.class.metrics.update_lookup_stats(
33
+ event.payload[:table_name],
34
+ event.duration,
35
+ event.payload[:num_records]
36
+ )
37
+ end
38
+
39
+ def search_filter(event)
40
+ self.class.metrics.update_search_filter_stats(
41
+ event.payload[:table_name],
42
+ event.duration,
43
+ event.payload[:num_records],
44
+ event.payload[:num_records_selected]
45
+ )
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DBPurger
4
+ # DBPurger::Metrics keeps track of each part of the purge process
5
+ class Metrics
6
+ attr_reader :started_at,
7
+ :finished_at,
8
+ :purge_stats,
9
+ :delete_stats,
10
+ :lookup_stats,
11
+ :filter_stats
12
+
13
+ def initialize
14
+ reset!
15
+ end
16
+
17
+ def reset!
18
+ @started_at = Time.now
19
+ @purge_stats = {}
20
+ @delete_stats = {}
21
+ @lookup_stats = {}
22
+ @filter_stats = {}
23
+ @finished_at = nil
24
+ end
25
+
26
+ def finished!
27
+ @finished_at = Time.now
28
+ end
29
+
30
+ def elapsed_time_in_seconds
31
+ (@finished_at || Time.now) - @started_at
32
+ end
33
+
34
+ def update_purge_stats(table_name, duration, num_records)
35
+ stats = (@purge_stats[table_name] ||= Hash.new(0))
36
+ stats[:duration] += duration
37
+ stats[:num_purges] += 1
38
+ stats[:num_records] += num_records
39
+ stats
40
+ end
41
+
42
+ def update_delete_records_stats(table_name, duration, num_deleted, num_expected_to_delete = nil)
43
+ stats = (@delete_stats[table_name] ||= Hash.new(0))
44
+ stats[:duration] += duration
45
+ stats[:num_delete_queries] += 1
46
+ stats[:num_deleted] += num_deleted
47
+ stats[:num_expected_to_delete] += num_expected_to_delete if num_expected_to_delete
48
+ stats
49
+ end
50
+
51
+ def update_lookup_stats(table_name, duration, records_found)
52
+ stats = (@lookup_stats[table_name] ||= Hash.new(0))
53
+ stats[:duration] += duration
54
+ stats[:num_lookups] += 1
55
+ stats[:records_found] += records_found
56
+ stats
57
+ end
58
+
59
+ def update_search_filter_stats(table_name, duration, records_found, records_selected)
60
+ stats = (@filter_stats[table_name] ||= Hash.new(0))
61
+ stats[:duration] += duration
62
+ stats[:num_lookups] += 1
63
+ stats[:records_found] += records_found
64
+ stats[:records_selected] += records_selected
65
+ stats
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DBPurger
4
+ # DBPurger::Plan is used to describe the relationship between tables
5
+ class Plan
6
+ attr_accessor :base_table
7
+
8
+ attr_reader :parent_tables,
9
+ :child_tables,
10
+ :ignore_tables,
11
+ :search_tables
12
+
13
+ def initialize
14
+ @parent_tables = []
15
+ @child_tables = []
16
+ @ignore_tables = []
17
+ @search_tables = []
18
+ end
19
+
20
+ def purge!(database, purge_value)
21
+ MetricSubscriber.reset!
22
+ PurgeTable.new(database, @base_table, @base_table.field, purge_value).purge!
23
+ end
24
+
25
+ def tables
26
+ all_tables = @base_table ? [@base_table] + @base_table.tables : []
27
+ all_tables += @parent_tables + @parent_tables.map(&:tables) +
28
+ @child_tables + @child_tables.map(&:tables) +
29
+ @search_tables + @search_tables.map(&:tables)
30
+ all_tables.flatten!
31
+ all_tables.compact!
32
+ all_tables
33
+ end
34
+
35
+ def table_names
36
+ tables.map(&:name)
37
+ end
38
+
39
+ def empty?
40
+ @base_table.nil? &&
41
+ @parent_tables.empty? &&
42
+ @child_tables.empty? &&
43
+ @search_tables.empty?
44
+ end
45
+
46
+ def ignore_table?(table_name)
47
+ @ignore_tables.any? do |ignore_table_name|
48
+ if ignore_table_name.is_a?(Regexp)
49
+ ignore_table_name.match(table_name)
50
+ else
51
+ ignore_table_name.to_s == table_name
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DBPurger
4
+ # DBPurger::PlanBuilder is used to build the relationships between tables in a convenient way
5
+ class PlanBuilder
6
+ def initialize(plan)
7
+ @plan = plan
8
+ end
9
+
10
+ def base_table(table_name, field, options = {}, &block)
11
+ @plan.base_table = create_table(table_name, field, options, &block)
12
+ end
13
+
14
+ def parent_table(table_name, field, options = {}, &block)
15
+ table = create_table(table_name, field, options, &block)
16
+ if @plan.base_table
17
+ @plan.base_table.nested_plan.parent_tables << table
18
+ else
19
+ @plan.parent_tables << table
20
+ end
21
+ table
22
+ end
23
+
24
+ def child_table(table_name, field, options = {}, &block)
25
+ table = create_table(table_name, field, options, &block)
26
+ if @plan.base_table
27
+ @plan.base_table.nested_plan.child_tables << table
28
+ else
29
+ @plan.child_tables << table
30
+ end
31
+ table
32
+ end
33
+
34
+ def ignore_table(table_name)
35
+ @plan.ignore_tables << table_name
36
+ end
37
+
38
+ def purge_table_search(table_name, field, options = {}, &block)
39
+ table = create_table(table_name, field, options)
40
+ table.search_proc = block || raise('no block given for search_proc')
41
+ if @plan.base_table
42
+ @plan.base_table.nested_plan.search_tables << table
43
+ else
44
+ @plan.search_tables << table
45
+ end
46
+ table
47
+ end
48
+
49
+ def self.build(&block)
50
+ plan = Plan.new
51
+ helper = new(plan)
52
+ helper.instance_eval(&block)
53
+ plan
54
+ end
55
+
56
+ def build_nested_plan(table, &block)
57
+ helper = self.class.new(table.nested_plan)
58
+ helper.instance_eval(&block)
59
+ end
60
+
61
+ private
62
+
63
+ def create_table(table_name, field, options, &block)
64
+ table = Table.new(table_name, field)
65
+ table.foreign_key = options[:foreign_key]
66
+ table.batch_size = options[:batch_size]
67
+ table.conditions = options[:conditions]
68
+ build_nested_plan(table, &block) if block
69
+ table
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DBPurger
4
+ # DBPurger::PlanValidator is used to ensure that all tables in the database are part of the purge plan.
5
+ # It verifies that all table definitions are correct as well, checking that child/parent fields are part
6
+ # of the table.
7
+ class PlanValidator
8
+ include ActiveModel::Validations
9
+
10
+ validate :validate_no_missing_tables
11
+ validate :validate_no_unknown_tables
12
+ validate :validate_tables
13
+
14
+ def initialize(database, plan)
15
+ @database = database
16
+ @plan = plan
17
+ end
18
+
19
+ # tables that are part of the database but not part of the plan
20
+ def missing_tables
21
+ database_table_names - @plan.table_names.map(&:to_s)
22
+ end
23
+
24
+ # tables that are part of the plan put not part of the database
25
+ def unknown_tables
26
+ @plan.table_names.map(&:to_s) - database_table_names
27
+ end
28
+
29
+ private
30
+
31
+ def validate_no_missing_tables
32
+ errors.add(:missing_tables, missing_tables.sort.join(',')) unless missing_tables.empty?
33
+ end
34
+
35
+ def validate_no_unknown_tables
36
+ errors.add(:unknown_tables, unknown_tables.sort.join(',')) unless unknown_tables.empty?
37
+ end
38
+
39
+ def validate_tables
40
+ @plan.tables.each { |table| validate_table_definition(table) }
41
+ end
42
+
43
+ def validate_table_definition(table)
44
+ unless (model = find_model_for_table(table))
45
+ errors.add(:table, "#{table.name} has no model")
46
+ return
47
+ end
48
+
49
+ table.fields.each do |field|
50
+ unless model.column_names.include?(field.to_s)
51
+ errors.add(:table, "#{table.name}.#{field} is missing in the database")
52
+ end
53
+ end
54
+ end
55
+
56
+ def find_model_for_table(table)
57
+ @database.models.detect { |model| model.table_name == table.name.to_s }
58
+ end
59
+
60
+ def filter_table_names(table_names)
61
+ table_names.reject { |table_name| @plan.ignore_table?(table_name) }
62
+ end
63
+
64
+ def database_table_names
65
+ @database_table_names ||= filter_table_names(@database.models.map(&:table_name))
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DBPurger
4
+ # DBPurger::PurgeTable is used to delete table from tables in batches if possible
5
+ class PurgeTable
6
+ include PurgeTableHelper
7
+
8
+ def initialize(database, table, purge_field, purge_value)
9
+ @database = database
10
+ @table = table
11
+ @purge_field = purge_field
12
+ @purge_value = purge_value
13
+ @num_deleted = 0
14
+ end
15
+
16
+ def model
17
+ @model ||= @database.models.detect { |m| m.table_name == @table.name.to_s }
18
+ end
19
+
20
+ def purge!
21
+ ActiveSupport::Notifications.instrument('purge.db_purger',
22
+ table_name: @table.name,
23
+ purge_field: @purge_field) do |payload|
24
+ if model.primary_key
25
+ purge_in_batches!
26
+ else
27
+ purge_all!
28
+ end
29
+ purge_search_tables
30
+ payload[:deleted] = @num_deleted
31
+ end
32
+ @num_deleted
33
+ end
34
+
35
+ private
36
+
37
+ def purge_all!
38
+ scope = model.where(@purge_field => @purge_value)
39
+ scope = scope.where(@table.conditions) if @table.conditions
40
+ delete_records_with_instrumentation(scope)
41
+ end
42
+
43
+ def purge_in_batches!
44
+ start_id = nil
45
+ until (batch = next_batch(start_id)).empty?
46
+ start_id = batch.last.send(model.primary_key)
47
+ purge_nested_tables(batch) if @table.nested_tables?
48
+ delete_records(batch)
49
+ end
50
+ end
51
+
52
+ def next_batch(start_id)
53
+ ActiveSupport::Notifications.instrument('next_batch.db_purger',
54
+ table_name: @table.name,
55
+ start_id: start_id) do |payload|
56
+ records = batch_scope(start_id).to_a
57
+ payload[:num_records] = records.size
58
+ records
59
+ end
60
+ end
61
+
62
+ # rubocop:disable Metrics/AbcSize
63
+ def batch_scope(start_id)
64
+ scope = model
65
+ .select([model.primary_key] + @table.foreign_keys)
66
+ .where(@purge_field => @purge_value)
67
+ .order(model.primary_key)
68
+ .limit(@table.batch_size)
69
+ scope = scope.where(@table.conditions) if @table.conditions
70
+ scope = scope.where("#{model.primary_key} > #{model.connection.quote(start_id)}") if start_id
71
+ scope
72
+ end
73
+ # rubocop:enable Metrics/AbcSize
74
+ end
75
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DBPurger
4
+ # DBPurger::PurgeTableHelper keeps track of common code between purgers
5
+ module PurgeTableHelper
6
+ def model
7
+ @model ||= @database.models.detect { |m| m.table_name == @table.name.to_s }
8
+ end
9
+
10
+ private
11
+
12
+ def purge_nested_tables(batch)
13
+ purge_child_tables(batch) unless @table.nested_plan.child_tables.empty?
14
+ purge_parent_tables
15
+ end
16
+
17
+ def purge_child_tables(batch)
18
+ ids = batch_values(batch, model.primary_key)
19
+
20
+ @table.nested_plan.child_tables.each do |table|
21
+ next if table.foreign_key
22
+
23
+ PurgeTable.new(@database, table, table.field, ids).purge!
24
+ end
25
+ end
26
+
27
+ def purge_parent_tables
28
+ @table.nested_plan.parent_tables.each do |table|
29
+ PurgeTable.new(@database, table, table.field, @purge_value).purge!
30
+ end
31
+ end
32
+
33
+ def purge_foreign_tables(batch)
34
+ @table.nested_plan.child_tables.each do |table|
35
+ next unless table.foreign_key
36
+ next if (purge_values = batch_values(batch, table.foreign_key)).empty?
37
+
38
+ PurgeTable.new(@database, table, table.field, purge_values).purge!
39
+ end
40
+ end
41
+
42
+ def batch_values(batch, field)
43
+ batch.map { |record| record.send(field) }.compact
44
+ end
45
+
46
+ def foreign_tables?
47
+ @table.nested_plan.child_tables.any?(&:foreign_key)
48
+ end
49
+
50
+ def delete_records_with_instrumentation(scope, num_records = nil)
51
+ @num_deleted ||= 0
52
+ ActiveSupport::Notifications.instrument('delete_records.db_purger',
53
+ table_name: @table.name,
54
+ num_records: num_records) do |payload|
55
+ records_deleted =
56
+ if ::DBPurger.config.explain?
57
+ delete_sql = scope.to_sql.sub(/SELECT .*?FROM/, 'DELETE FROM')
58
+ ::DBPurger.config.explain_file.puts(delete_sql)
59
+ scope.count
60
+ else
61
+ scope.delete_all
62
+ end
63
+ @num_deleted += records_deleted
64
+ payload[:records_deleted] = records_deleted
65
+ payload[:deleted] = @num_deleted
66
+ end
67
+ end
68
+
69
+ def delete_records(batch)
70
+ if foreign_tables?
71
+ delete_records_and_foreign_tables(batch)
72
+ else
73
+ delete_records_by_primary_key(batch)
74
+ end
75
+ end
76
+
77
+ def delete_records_and_foreign_tables(batch)
78
+ model.transaction do
79
+ delete_records_by_primary_key(batch)
80
+ purge_foreign_tables(batch)
81
+ end
82
+ end
83
+
84
+ def delete_records_by_primary_key(batch)
85
+ ids = batch_values(batch, model.primary_key)
86
+ scope = model.where(model.primary_key => ids)
87
+ delete_records_with_instrumentation(scope, ids.size)
88
+ end
89
+
90
+ def purge_search_tables
91
+ @table.nested_plan.search_tables.each do |table|
92
+ PurgeTableScanner.new(@database, table).purge!
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DBPurger
4
+ # DBPurger::PurgeTableScanner scans the entire table in batches use the search_proc to determine what ids to delete
5
+ class PurgeTableScanner
6
+ include PurgeTableHelper
7
+
8
+ def initialize(database, table)
9
+ @database = database
10
+ @table = table
11
+ @num_deleted = 0
12
+ end
13
+
14
+ def model
15
+ @model ||= @database.models.detect { |m| m.table_name == @table.name.to_s }
16
+ end
17
+
18
+ def purge!
19
+ ActiveSupport::Notifications.instrument('purge.db_purger',
20
+ table_name: @table.name) do |payload|
21
+ purge_in_batches!
22
+ purge_search_tables
23
+ payload[:deleted] = @num_deleted
24
+ end
25
+ @num_deleted
26
+ end
27
+
28
+ private
29
+
30
+ # rubocop:disable Metrics/AbcSize
31
+ # rubocop:disable Metrics/MethodLength
32
+ def purge_in_batches!
33
+ scope = model
34
+ scope = scope.where(@table.conditions) if @table.conditions
35
+
36
+ instrumentation_name = 'next_batch.db_purger'
37
+ start_instrumentation(instrumentation_name)
38
+
39
+ scope.find_in_batches(batch_size: @table.batch_size) do |batch|
40
+ finish_instrumentation(
41
+ instrumentation_name,
42
+ table_name: @table.name,
43
+ num_records: batch.size
44
+ )
45
+
46
+ batch = ActiveSupport::Notifications.instrument('search_filter.db_purger',
47
+ table_name: @table.name,
48
+ num_records: batch.size) do |payload|
49
+ records_selected = @table.search_proc.call(batch)
50
+ payload[:num_records_selected] = records_selected.size
51
+ records_selected
52
+ end
53
+
54
+ if batch.empty?
55
+ start_instrumentation(instrumentation_name)
56
+ next
57
+ end
58
+
59
+ purge_nested_tables(batch) if @table.nested_tables?
60
+ delete_records(batch)
61
+
62
+ start_instrumentation(instrumentation_name)
63
+ end
64
+
65
+ finish_instrumentation(
66
+ instrumentation_name,
67
+ table_name: @table.name,
68
+ num_records: 0,
69
+ num_selected: 0
70
+ )
71
+ end
72
+ # rubocop:enable Metrics/AbcSize
73
+ # rubocop:enable Metrics/MethodLength
74
+
75
+ def start_instrumentation(name)
76
+ @instrumenter = ActiveSupport::Notifications.instrumenter
77
+ @instrumenter.start(name, {})
78
+ end
79
+
80
+ def finish_instrumentation(name, payload)
81
+ @instrumenter.finish(name, payload)
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DBPurger
4
+ # DBPurger::Table is an entity to keep track of basic table data
5
+ class Table
6
+ DEFAULT_BATCH_SIZE = 10_000
7
+
8
+ attr_accessor :foreign_key,
9
+ :batch_size,
10
+ :conditions,
11
+ :search_proc
12
+
13
+ attr_reader :name,
14
+ :field
15
+
16
+ def initialize(name, field)
17
+ @name = name
18
+ @field = field
19
+ @batch_size = DEFAULT_BATCH_SIZE
20
+ end
21
+
22
+ def nested_plan(&block)
23
+ @nested_plan ||= Plan.new
24
+ PlanBuilder.new(@nested_plan).build_nested_plan(self, &block) if block
25
+ @nested_plan
26
+ end
27
+
28
+ def nested_tables?
29
+ @nested_plan != nil
30
+ end
31
+
32
+ def tables
33
+ @nested_plan ? @nested_plan.tables : []
34
+ end
35
+
36
+ def foreign_keys
37
+ @nested_plan ? @nested_plan.tables.map(&:foreign_key).compact : []
38
+ end
39
+
40
+ def fields
41
+ [@field] + foreign_keys
42
+ end
43
+ end
44
+ end
data/lib/db-purger.rb ADDED
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ # DBPurger is a tool to delete data from tables based on a initial purge value
4
+ module DBPurger
5
+ autoload :Config, 'db-purger/config'
6
+ autoload :Metrics, 'db-purger/metrics'
7
+ autoload :MetricSubscriber, 'db-purger/metric_subscriber'
8
+ autoload :PurgeTable, 'db-purger/purge_table'
9
+ autoload :PurgeTableHelper, 'db-purger/purge_table_helper'
10
+ autoload :PurgeTableScanner, 'db-purger/purge_table_scanner'
11
+ autoload :Plan, 'db-purger/plan'
12
+ autoload :PlanBuilder, 'db-purger/plan_builder'
13
+ autoload :PlanValidator, 'db-purger/plan_validator'
14
+ autoload :Table, 'db-purger/table'
15
+
16
+ def self.config
17
+ @config ||= Config.new
18
+ end
19
+ end
metadata ADDED
@@ -0,0 +1,80 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: db-purger
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Doug Youch
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2019-07-18 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: dynamic-active-model
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: inheritance-helper
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ description: Purge database tables by top level id in batches
42
+ email: dougyouch@gmail.com
43
+ executables: []
44
+ extensions: []
45
+ extra_rdoc_files: []
46
+ files:
47
+ - lib/db-purger.rb
48
+ - lib/db-purger/config.rb
49
+ - lib/db-purger/metric_subscriber.rb
50
+ - lib/db-purger/metrics.rb
51
+ - lib/db-purger/plan.rb
52
+ - lib/db-purger/plan_builder.rb
53
+ - lib/db-purger/plan_validator.rb
54
+ - lib/db-purger/purge_table.rb
55
+ - lib/db-purger/purge_table_helper.rb
56
+ - lib/db-purger/purge_table_scanner.rb
57
+ - lib/db-purger/table.rb
58
+ homepage: https://github.com/dougyouch/db-purger
59
+ licenses: []
60
+ metadata: {}
61
+ post_install_message:
62
+ rdoc_options: []
63
+ require_paths:
64
+ - lib
65
+ required_ruby_version: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: '0'
70
+ required_rubygems_version: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ requirements: []
76
+ rubygems_version: 3.0.3
77
+ signing_key:
78
+ specification_version: 4
79
+ summary: DB Purger by top level id
80
+ test_files: []