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.
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
@@ -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__)
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -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