prune_ar 0.1.0

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