prune_ar 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 +7 -0
- data/CHANGELOG.md +12 -0
- data/CONTRIBUTING.md +89 -0
- data/Gemfile +8 -0
- data/Gemfile.lock +101 -0
- data/LICENSE +7 -0
- data/README.md +240 -0
- data/Rakefile +8 -0
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/lib/prune_ar.rb +62 -0
- data/lib/prune_ar/belongs_to_association.rb +51 -0
- data/lib/prune_ar/belongs_to_association_gatherer.rb +130 -0
- data/lib/prune_ar/deleter_by_criteria.rb +63 -0
- data/lib/prune_ar/foreign_key_handler.rb +84 -0
- data/lib/prune_ar/orphaned_selection_builder.rb +53 -0
- data/lib/prune_ar/pruner.rb +158 -0
- data/lib/prune_ar/version.rb +5 -0
- data/prune_ar.gemspec +57 -0
- metadata +250 -0
data/Rakefile
ADDED
data/bin/console
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require 'bundler/setup'
|
5
|
+
require 'prune_ar'
|
6
|
+
|
7
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
8
|
+
# with your gem easier. You can also use a different console, if you like.
|
9
|
+
|
10
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
11
|
+
# require "pry"
|
12
|
+
# Pry.start
|
13
|
+
|
14
|
+
require 'irb'
|
15
|
+
IRB.start(__FILE__)
|
data/bin/setup
ADDED
data/lib/prune_ar.rb
ADDED
@@ -0,0 +1,62 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'active_record'
|
4
|
+
require 'prune_ar/version'
|
5
|
+
require 'prune_ar/pruner'
|
6
|
+
|
7
|
+
# Namespace for all prune_ar code
|
8
|
+
module PruneAr
|
9
|
+
# deletion_criteria
|
10
|
+
# => The core pruning criteria that you want to execute (will be executed up front)
|
11
|
+
# => {
|
12
|
+
# => Account => ['accounts.id NOT IN (1, 2)']
|
13
|
+
# => User => ["users.internal = 'f'", "users.active = 'f'"]
|
14
|
+
# => }
|
15
|
+
#
|
16
|
+
# full_delete_models
|
17
|
+
# => Models for which you want to purge all records
|
18
|
+
# => [Model1, Model2]
|
19
|
+
#
|
20
|
+
# pre_queries_to_run
|
21
|
+
# => Arbitrary SQL statements to execute before pruning
|
22
|
+
# => [ 'UPDATE users SET invited_by_id = NULL WHERE invited_by_id IS NOT NULL' ]
|
23
|
+
#
|
24
|
+
# conjunctive_deletion_criteria
|
25
|
+
# => Pruning criteria you want executed in conjunction with each iteration of pruning
|
26
|
+
# => of orphaned records (one case where this is useful if pruning entities which
|
27
|
+
# => don't have a belongs_to chain to the entities we pruned but instead are associated
|
28
|
+
# => via join tables)
|
29
|
+
# => {
|
30
|
+
# => Image => ['NOT EXISTS (SELECT 1 FROM imagings WHERE imagings.image_id = images.id)']
|
31
|
+
# => }
|
32
|
+
#
|
33
|
+
# perform_sanity_check
|
34
|
+
# => Determines whether `PruneAr` sanity checks it's own pruning by setting (& subsequently
|
35
|
+
# => removing) foreign key constraints for all belongs_to relations. This is to prove that
|
36
|
+
# => we maintained referential integrity.
|
37
|
+
def self.prune_all_models(
|
38
|
+
deletion_criteria: {},
|
39
|
+
full_delete_models: [],
|
40
|
+
pre_queries_to_run: [],
|
41
|
+
conjunctive_deletion_criteria: {},
|
42
|
+
perform_sanity_check: true,
|
43
|
+
logger: Logger.new(STDOUT).tap { |l| l.level = Logger::WARN }
|
44
|
+
)
|
45
|
+
Pruner.new(
|
46
|
+
models: all_models,
|
47
|
+
deletion_criteria: deletion_criteria,
|
48
|
+
full_delete_models: full_delete_models,
|
49
|
+
pre_queries_to_run: pre_queries_to_run,
|
50
|
+
conjunctive_deletion_criteria: conjunctive_deletion_criteria,
|
51
|
+
perform_sanity_check: perform_sanity_check,
|
52
|
+
logger: logger
|
53
|
+
).prune
|
54
|
+
end
|
55
|
+
|
56
|
+
def self.all_models
|
57
|
+
ActiveRecord::Base
|
58
|
+
.descendants
|
59
|
+
.reject { |c| ['ApplicationRecord'].any? { |start| c.name.start_with?(start) } }
|
60
|
+
.uniq(&:table_name)
|
61
|
+
end
|
62
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module PruneAr
|
4
|
+
# Represents a ActiveRecord belongs_to association
|
5
|
+
class BelongsToAssociation
|
6
|
+
attr_reader :source_model,
|
7
|
+
:destination_model,
|
8
|
+
:foreign_key_column,
|
9
|
+
:association_primary_key_column,
|
10
|
+
:foreign_type_column
|
11
|
+
|
12
|
+
def initialize(
|
13
|
+
source_model:,
|
14
|
+
destination_model:,
|
15
|
+
foreign_key_column:,
|
16
|
+
association_primary_key_column: 'id',
|
17
|
+
foreign_type_column: nil, # Indicates that relation is polymorphic
|
18
|
+
**_extra # Ignore extra
|
19
|
+
)
|
20
|
+
@source_model = source_model
|
21
|
+
@destination_model = destination_model
|
22
|
+
@foreign_key_column = foreign_key_column
|
23
|
+
@association_primary_key_column = association_primary_key_column
|
24
|
+
@foreign_type_column = foreign_type_column
|
25
|
+
end
|
26
|
+
|
27
|
+
def polymorphic?
|
28
|
+
!foreign_type_column.nil?
|
29
|
+
end
|
30
|
+
|
31
|
+
def source_table
|
32
|
+
source_model.table_name
|
33
|
+
end
|
34
|
+
|
35
|
+
def destination_table
|
36
|
+
destination_model.table_name
|
37
|
+
end
|
38
|
+
|
39
|
+
def destination_model_name
|
40
|
+
destination_model.name
|
41
|
+
end
|
42
|
+
|
43
|
+
def ==(other) # rubocop:disable Metrics/AbcSize
|
44
|
+
source_model == other.source_model &&
|
45
|
+
destination_model == other.destination_model &&
|
46
|
+
foreign_key_column == other.foreign_key_column &&
|
47
|
+
association_primary_key_column == other.association_primary_key_column &&
|
48
|
+
foreign_type_column == other.foreign_type_column
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,130 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'logger'
|
4
|
+
require 'active_record'
|
5
|
+
require 'prune_ar/belongs_to_association'
|
6
|
+
|
7
|
+
module PruneAr
|
8
|
+
# Given ActiveRecord models, produces BelongsToAssociation objects
|
9
|
+
class BelongsToAssociationGatherer
|
10
|
+
attr_reader :models, :connection, :logger
|
11
|
+
|
12
|
+
def initialize(
|
13
|
+
models,
|
14
|
+
connection: ActiveRecord::Base.connection,
|
15
|
+
logger: Logger.new(STDOUT).tap { |l| l.level = Logger::WARN }
|
16
|
+
)
|
17
|
+
@models = models
|
18
|
+
@connection = connection
|
19
|
+
@logger = logger
|
20
|
+
end
|
21
|
+
|
22
|
+
def associations
|
23
|
+
@associations ||= gather_belongs_to_for_models
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
def gather_belongs_to_for_models
|
29
|
+
models.flat_map(&method(:gather_belongs_to_for_model))
|
30
|
+
end
|
31
|
+
|
32
|
+
def gather_belongs_to_for_model(source_model)
|
33
|
+
source_model.reflect_on_all_associations.flat_map do |assoc|
|
34
|
+
next [] if assoc.macro != :belongs_to
|
35
|
+
|
36
|
+
build_belongs_to_associations(source_model, assoc)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def build_belongs_to_associations(source_model, assoc)
|
41
|
+
destination_models = gather_belongs_to_destination_models(source_model, assoc)
|
42
|
+
curried = method(:build_belongs_to_association).curry.call(source_model, assoc)
|
43
|
+
destination_models.flat_map(&curried)
|
44
|
+
end
|
45
|
+
|
46
|
+
def validate_belongs_to_association(foreign_key_column, source_model, destination_model)
|
47
|
+
unless destination_model.column_names.include?('id')
|
48
|
+
logger.warn("bad association? Column #{destination_model.table_name}.id doesn't exist")
|
49
|
+
return false
|
50
|
+
end
|
51
|
+
|
52
|
+
unless source_model.column_names.include?(foreign_key_column)
|
53
|
+
logger.warn("bad association? Column #{source_model.table_name}.#{foreign_key_column}"\
|
54
|
+
" doesn't exist")
|
55
|
+
return false
|
56
|
+
end
|
57
|
+
|
58
|
+
true
|
59
|
+
end
|
60
|
+
|
61
|
+
def build_belongs_to_association(source_model, assoc, destination_model)
|
62
|
+
foreign_key_column = get_foreign_key_column(assoc, destination_model)
|
63
|
+
unless validate_belongs_to_association(foreign_key_column, source_model, destination_model)
|
64
|
+
return []
|
65
|
+
end
|
66
|
+
|
67
|
+
[
|
68
|
+
BelongsToAssociation.new(
|
69
|
+
{
|
70
|
+
source_model: source_model,
|
71
|
+
destination_model: destination_model,
|
72
|
+
foreign_key_column: foreign_key_column
|
73
|
+
}.merge(assoc.polymorphic? ? { foreign_type_column: assoc.foreign_type.to_s } : {})
|
74
|
+
)
|
75
|
+
]
|
76
|
+
end
|
77
|
+
|
78
|
+
def gather_belongs_to_destination_models(source_model, assoc)
|
79
|
+
begin
|
80
|
+
return [assoc.klass] unless assoc.polymorphic?
|
81
|
+
rescue StandardError => e
|
82
|
+
logger.error("error encountered loading association class: #{e}")
|
83
|
+
return []
|
84
|
+
end
|
85
|
+
|
86
|
+
foreign_types = read_foreign_types(source_model, assoc)
|
87
|
+
foreign_types.flat_map(&method(:model_string_to_class))
|
88
|
+
end
|
89
|
+
|
90
|
+
def read_foreign_types(source_model, assoc)
|
91
|
+
foreign_type_column = assoc.foreign_type.to_s
|
92
|
+
|
93
|
+
sql = <<~SQL
|
94
|
+
SELECT DISTINCT #{foreign_type_column}
|
95
|
+
FROM #{source_model.table_name}
|
96
|
+
WHERE #{foreign_type_column} IS NOT NULL
|
97
|
+
SQL
|
98
|
+
|
99
|
+
sql = sql.gsub(/\s+/, ' ').strip
|
100
|
+
|
101
|
+
begin
|
102
|
+
foreign_types = connection.exec_query(sql).map { |t| t[foreign_type_column] }
|
103
|
+
rescue StandardError => e
|
104
|
+
logger.error("error encountered reading foreign types for #{source_model}: #{e}")
|
105
|
+
return []
|
106
|
+
end
|
107
|
+
|
108
|
+
foreign_types
|
109
|
+
end
|
110
|
+
|
111
|
+
def get_foreign_key_column(assoc, destination_model)
|
112
|
+
foreign_key = assoc.foreign_key.to_s
|
113
|
+
|
114
|
+
# Rails strangeness on HABTM. `assoc.foreign_key` shows up as `left_side_id` for one
|
115
|
+
# => field in the join table.
|
116
|
+
if foreign_key == 'left_side_id'
|
117
|
+
foreign_key = destination_model.table_name.gsub(/s\z/, '') + '_id'
|
118
|
+
end
|
119
|
+
|
120
|
+
foreign_key
|
121
|
+
end
|
122
|
+
|
123
|
+
def model_string_to_class(type_string)
|
124
|
+
[type_string.constantize]
|
125
|
+
rescue StandardError => e
|
126
|
+
logger.error("error encountered constantizing #{type_string}: #{e}")
|
127
|
+
[]
|
128
|
+
end
|
129
|
+
end
|
130
|
+
end
|
@@ -0,0 +1,63 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'logger'
|
4
|
+
require 'active_record'
|
5
|
+
|
6
|
+
module PruneAr
|
7
|
+
# Core of this gem. Prunes records based on parameters given.
|
8
|
+
class DeleterByCriteria
|
9
|
+
attr_reader :criteria, :connection, :logger
|
10
|
+
|
11
|
+
# criteria is of form [['users', "name = 'andrew'"], ['comments', 'id NOT IN (1, 2, 3)']]
|
12
|
+
def initialize(
|
13
|
+
criteria,
|
14
|
+
connection: ActiveRecord::Base.connection,
|
15
|
+
logger: Logger.new(STDOUT).tap { |l| l.level = Logger::WARN }
|
16
|
+
)
|
17
|
+
@criteria = criteria
|
18
|
+
@connection = connection
|
19
|
+
@logger = logger
|
20
|
+
end
|
21
|
+
|
22
|
+
def delete
|
23
|
+
i = 0
|
24
|
+
loop do
|
25
|
+
logger.info("deletion loop iteration #{i}")
|
26
|
+
i += 1
|
27
|
+
|
28
|
+
return unless anything_to_delete?
|
29
|
+
|
30
|
+
criteria.each do |table, selection|
|
31
|
+
delete_selection(table, selection)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
private
|
37
|
+
|
38
|
+
def anything_to_delete?
|
39
|
+
criteria.any? do |table, selection|
|
40
|
+
count = count_to_be_deleted(table, selection)
|
41
|
+
count.positive?.tap do |positive|
|
42
|
+
if positive
|
43
|
+
logger.info("found something to delete (#{count} records to delete from #{table}"\
|
44
|
+
" where #{selection})")
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
def count_to_be_deleted(table, selection)
|
51
|
+
results = connection.exec_query("SELECT COUNT(*) as count FROM #{table} WHERE #{selection};")
|
52
|
+
results.entries.first['count'].tap do |count|
|
53
|
+
logger.debug("found #{count} records to delete from #{table} where #{selection}")
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
def delete_selection(table, selection)
|
58
|
+
sql = "DELETE FROM #{table} WHERE #{selection};"
|
59
|
+
logger.debug("deleting all records from #{table} where #{selection}")
|
60
|
+
connection.exec_query(sql)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
@@ -0,0 +1,84 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'logger'
|
4
|
+
require 'active_record'
|
5
|
+
|
6
|
+
module PruneAr
|
7
|
+
# Core of this gem. Prunes records based on parameters given.
|
8
|
+
class ForeignKeyHandler
|
9
|
+
attr_reader :connection, :logger, :original_foreign_keys, :foreign_key_supported
|
10
|
+
|
11
|
+
def initialize(
|
12
|
+
models:,
|
13
|
+
connection: ActiveRecord::Base.connection,
|
14
|
+
logger: Logger.new(STDOUT).tap { |l| l.level = Logger::WARN }
|
15
|
+
)
|
16
|
+
@connection = connection
|
17
|
+
@logger = logger
|
18
|
+
@foreign_key_supported = connection.supports_foreign_keys?
|
19
|
+
@original_foreign_keys = snapshot_foreign_keys(models)
|
20
|
+
end
|
21
|
+
|
22
|
+
def drop(foreign_keys)
|
23
|
+
return unless foreign_key_supported
|
24
|
+
|
25
|
+
foreign_keys.each do |fk|
|
26
|
+
logger.debug("dropping #{fk.name} from #{fk.from_table} (#{fk.column})")
|
27
|
+
connection.remove_foreign_key(fk.from_table, name: fk.name)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def create(foreign_keys)
|
32
|
+
return unless foreign_key_supported
|
33
|
+
|
34
|
+
foreign_keys.each do |fk|
|
35
|
+
logger.debug("creating #{fk.name} on #{fk.from_table} (#{fk.column})")
|
36
|
+
connection.add_foreign_key(fk.from_table, fk.to_table, fk.options)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def create_from_belongs_to_associations(associations)
|
41
|
+
return [] unless foreign_key_supported
|
42
|
+
|
43
|
+
associations.map do |assoc|
|
44
|
+
constraint_name = generate_belongs_to_foreign_key_name(assoc)
|
45
|
+
create_from_belongs_to_association(constraint_name, assoc)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
private
|
50
|
+
|
51
|
+
def snapshot_foreign_keys(models)
|
52
|
+
return [] unless foreign_key_supported
|
53
|
+
|
54
|
+
models.flat_map do |model|
|
55
|
+
connection.foreign_keys(model.table_name)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def create_from_belongs_to_association(name, assoc)
|
60
|
+
fk = ActiveRecord::ConnectionAdapters::ForeignKeyDefinition.new(
|
61
|
+
assoc.source_table,
|
62
|
+
assoc.destination_table,
|
63
|
+
name: name,
|
64
|
+
column: assoc.foreign_key_column,
|
65
|
+
primary_key: assoc.association_primary_key_column,
|
66
|
+
on_delete: :restrict,
|
67
|
+
on_update: :restrict,
|
68
|
+
validate: true
|
69
|
+
)
|
70
|
+
|
71
|
+
logger.debug("creating #{name} on #{fk.from_table} (#{fk.column})")
|
72
|
+
connection.add_foreign_key(fk.from_table, fk.to_table, fk.options)
|
73
|
+
fk
|
74
|
+
end
|
75
|
+
|
76
|
+
# Limited to 64 characters
|
77
|
+
def generate_belongs_to_foreign_key_name(assoc)
|
78
|
+
source = assoc.source_table[0..7]
|
79
|
+
column = assoc.foreign_key_column[0..7]
|
80
|
+
destination = assoc.destination_table[0..7]
|
81
|
+
"fk_#{source}_#{column}_#{destination}_#{SecureRandom.hex}"
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module PruneAr
|
4
|
+
# Builds SQL selection to select orphaned records based on a BelongsToAssociation
|
5
|
+
class OrphanedSelectionBuilder
|
6
|
+
def initialize
|
7
|
+
@cache = {}
|
8
|
+
end
|
9
|
+
|
10
|
+
def orphaned_selection(assoc)
|
11
|
+
@cache[assoc] ||= self.class.build_orphaned_selection(assoc)
|
12
|
+
end
|
13
|
+
|
14
|
+
def self.build_orphaned_selection(assoc)
|
15
|
+
if assoc.polymorphic?
|
16
|
+
build_orphaned_selection_polymorphic(assoc)
|
17
|
+
else
|
18
|
+
build_orphaned_selection_simple(assoc)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.build_orphaned_selection_simple(assoc)
|
23
|
+
src = assoc.source_table
|
24
|
+
dst = assoc.destination_table
|
25
|
+
sql = <<~SQL
|
26
|
+
#{src}.#{assoc.foreign_key_column} IS NOT NULL
|
27
|
+
AND NOT EXISTS (
|
28
|
+
SELECT 1
|
29
|
+
FROM #{dst} dst
|
30
|
+
WHERE dst.#{assoc.association_primary_key_column} = #{src}.#{assoc.foreign_key_column}
|
31
|
+
)
|
32
|
+
SQL
|
33
|
+
|
34
|
+
sql.gsub(/\s+/, ' ').strip
|
35
|
+
end
|
36
|
+
|
37
|
+
def self.build_orphaned_selection_polymorphic(assoc)
|
38
|
+
src = assoc.source_table
|
39
|
+
dst = assoc.destination_table
|
40
|
+
sql = <<~SQL
|
41
|
+
#{src}.#{assoc.foreign_type_column} = '#{assoc.destination_model_name}'
|
42
|
+
AND #{src}.#{assoc.foreign_key_column} IS NOT NULL
|
43
|
+
AND NOT EXISTS (
|
44
|
+
SELECT 1
|
45
|
+
FROM #{dst} dst
|
46
|
+
WHERE dst.#{assoc.association_primary_key_column} = #{src}.#{assoc.foreign_key_column}
|
47
|
+
)
|
48
|
+
SQL
|
49
|
+
|
50
|
+
sql.gsub(/\s+/, ' ').strip
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|