prune_ar 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|