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,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+ require 'active_record'
5
+ require 'prune_ar/belongs_to_association_gatherer'
6
+ require 'prune_ar/orphaned_selection_builder'
7
+ require 'prune_ar/deleter_by_criteria'
8
+ require 'prune_ar/foreign_key_handler'
9
+
10
+ module PruneAr
11
+ # Core of this gem. Prunes records based on parameters given.
12
+ class Pruner
13
+ attr_reader :associations,
14
+ :deletion_criteria,
15
+ :full_delete_models,
16
+ :pre_queries_to_run,
17
+ :conjunctive_deletion_criteria,
18
+ :logger,
19
+ :foreign_key_handler,
20
+ :perform_sanity_check
21
+
22
+ def initialize(
23
+ models:,
24
+ deletion_criteria: {},
25
+ full_delete_models: [],
26
+ pre_queries_to_run: [],
27
+ conjunctive_deletion_criteria: {},
28
+ perform_sanity_check: true,
29
+ logger: Logger.new(STDOUT).tap { |l| l.level = Logger::WARN }
30
+ )
31
+ @associations = BelongsToAssociationGatherer.new(models, connection: connection).associations
32
+ @full_delete_models = full_delete_models
33
+ @pre_queries_to_run = pre_queries_to_run
34
+ @deletion_criteria = deletion_criteria
35
+ @conjunctive_deletion_criteria = conjunctive_deletion_criteria
36
+ @logger = logger
37
+ @perform_sanity_check = perform_sanity_check
38
+ @foreign_key_handler = ForeignKeyHandler.new(
39
+ models: models,
40
+ connection: connection,
41
+ logger: logger
42
+ )
43
+ end
44
+
45
+ def prune
46
+ # Can't wrap in transaction if DDL is not supported in transactions. We use ALTER TABLE
47
+ # => statements (which are DDL)
48
+ return prune_core unless connection.supports_ddl_transactions?
49
+
50
+ # This transaction is helpful when developing/working on this code. If a SQL statement errors,
51
+ # => it leaves the database untouched.
52
+ connection.transaction do
53
+ prune_core
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ def prune_core
60
+ drop_original_foreign_key_constraints
61
+
62
+ # Run any pre-queries we were told to run
63
+ pre_queries
64
+
65
+ # Delete using deletion_criteria before we sanitize anything
66
+ pre_delete
67
+
68
+ # Truncate tables we were told to wipe completely
69
+ full_delete
70
+
71
+ # Main deletion (conjunctive_deletion_criteria + orphaned records)
72
+ main_delete
73
+
74
+ # Now that there are no violations, create foreign key constraints on all :belongs_to
75
+ # => this is a sanity check that we eliminated violations
76
+ # => this ignores polymorphic relations since FKs cannot be set on polymorphic :belongs_to
77
+ # => these foreign key constraints are dropped right after since they're only for sanity
78
+ foreign_key_sanity_check if perform_sanity_check
79
+
80
+ recreate_original_foreign_key_constraints
81
+ end
82
+
83
+ def drop_original_foreign_key_constraints
84
+ logger.info('dropping existing foreign key constraints')
85
+ foreign_key_handler.drop(foreign_key_handler.original_foreign_keys)
86
+ end
87
+
88
+ def recreate_original_foreign_key_constraints
89
+ logger.info('recreating original foreign key constraints')
90
+ foreign_key_handler.create(foreign_key_handler.original_foreign_keys)
91
+ end
92
+
93
+ def pre_queries
94
+ logger.info('running pre_queries_to_run')
95
+ pre_queries_to_run.each do |sql|
96
+ logger.debug("running pre-query #{sql}")
97
+ connection.exec_query(sql)
98
+ end
99
+ end
100
+
101
+ def pre_delete
102
+ logger.info('deleting via deletion_criteria')
103
+ DeleterByCriteria.new(
104
+ flatten_deletion_criteria(deletion_criteria),
105
+ connection: connection,
106
+ logger: logger
107
+ ).delete
108
+ end
109
+
110
+ def full_delete
111
+ logger.info('truncating full_delete_models')
112
+ full_delete_models.each do |model|
113
+ logger.debug("truncating #{model}")
114
+ connection.exec_query("TRUNCATE #{model.table_name};")
115
+ end
116
+ end
117
+
118
+ def main_delete
119
+ logger.info('deleting via conjunctive_deletion_criteria & pruning orphaned records')
120
+ association_deletion_criteria = flat_associations_deletion_criteria(associations)
121
+ flat_conjunctive_criteria = flatten_deletion_criteria(conjunctive_deletion_criteria)
122
+ DeleterByCriteria.new(
123
+ (association_deletion_criteria + flat_conjunctive_criteria).sort,
124
+ connection: connection,
125
+ logger: logger
126
+ ).delete
127
+ end
128
+
129
+ def foreign_key_sanity_check
130
+ logger.info('sanity checking via foreign key constraints')
131
+ created_foreign_keys = foreign_key_handler.create_from_belongs_to_associations(
132
+ associations.reject(&:polymorphic?)
133
+ )
134
+
135
+ foreign_key_handler.drop(created_foreign_keys)
136
+ end
137
+
138
+ def flat_associations_deletion_criteria(associations)
139
+ associations.map do |assoc|
140
+ [assoc.source_table, orphaned_selection_builder.orphaned_selection(assoc)]
141
+ end
142
+ end
143
+
144
+ def flatten_deletion_criteria(criteria)
145
+ criteria.flat_map do |model, selections|
146
+ selections.map { |s| [model.table_name, s] }
147
+ end
148
+ end
149
+
150
+ def connection
151
+ @connection ||= ActiveRecord::Base.connection
152
+ end
153
+
154
+ def orphaned_selection_builder
155
+ @orphaned_selection_builder ||= OrphanedSelectionBuilder.new
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PruneAr
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ lib = File.expand_path('lib', __dir__)
4
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
+ require 'prune_ar/version'
6
+
7
+ Gem::Specification.new do |spec| # rubocop:disable Metrics/BlockLength
8
+ spec.name = 'prune_ar'
9
+ spec.version = PruneAr::VERSION
10
+ spec.authors = ['Anirban Mukhopadhyay']
11
+ spec.email = ['amukhopadhyay@contently.com']
12
+
13
+ spec.summary = 'Prunes database tables using ActiveRecord belongs_to relations.'
14
+ spec.description = %w[
15
+ Given an initial set of records to delete prune_ar deletes all other records (accessible via
16
+ ActiveRecord) that are now orphaned due to a belongs_to relation which is now non-existent.
17
+ This allows you to safely delete records that you want to delete without creating orphaned
18
+ records in another table (& without violating foreign key constraints if you use them). This
19
+ can be used to prune a production database (given deletion criteria for top level parent-less
20
+ independent entities) for use in a development environment without compromising customer data.
21
+ ].join(' ')
22
+ spec.homepage = 'https://github.com/contently/prune_ar'
23
+
24
+ # Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host'
25
+ # to allow pushing to a single host or delete this section to allow pushing to any host.
26
+ if spec.respond_to?(:metadata)
27
+ spec.metadata['allowed_push_host'] = 'https://rubygems.org'
28
+ else
29
+ raise 'RubyGems 2.0 or newer is required to protect against ' \
30
+ 'public gem pushes.'
31
+ end
32
+
33
+ # Specify which files should be added to the gem when it is released.
34
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
35
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
36
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
37
+ end
38
+ spec.files.reject! { |f| f.start_with?('.') }
39
+ spec.bindir = 'exe'
40
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
41
+ spec.require_paths = ['lib']
42
+
43
+ spec.add_dependency 'activerecord'
44
+
45
+ spec.add_development_dependency 'bundler', '~> 1.17'
46
+ spec.add_development_dependency 'database_cleaner', '~> 1.7'
47
+ spec.add_development_dependency 'mysql2', '~> 0.5'
48
+ spec.add_development_dependency 'pg', '~>1.1'
49
+ spec.add_development_dependency 'pry', '~> 0.12'
50
+ spec.add_development_dependency 'rake', '~> 10.0'
51
+ spec.add_development_dependency 'rspec', '~> 3.8'
52
+ spec.add_development_dependency 'rspec_junit_formatter', '~> 0.4'
53
+ spec.add_development_dependency 'rubocop', '~> 0.61'
54
+ spec.add_development_dependency 'rubocop-junit_formatter', '~> 0.2'
55
+ spec.add_development_dependency 'simplecov', '~> 0.12'
56
+ spec.add_development_dependency 'sqlite3', '~> 1.3'
57
+ end
metadata ADDED
@@ -0,0 +1,250 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: prune_ar
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Anirban Mukhopadhyay
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2018-12-21 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activerecord
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: bundler
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.17'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.17'
41
+ - !ruby/object:Gem::Dependency
42
+ name: database_cleaner
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.7'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.7'
55
+ - !ruby/object:Gem::Dependency
56
+ name: mysql2
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '0.5'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '0.5'
69
+ - !ruby/object:Gem::Dependency
70
+ name: pg
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '1.1'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '1.1'
83
+ - !ruby/object:Gem::Dependency
84
+ name: pry
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '0.12'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '0.12'
97
+ - !ruby/object:Gem::Dependency
98
+ name: rake
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '10.0'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '10.0'
111
+ - !ruby/object:Gem::Dependency
112
+ name: rspec
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '3.8'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '3.8'
125
+ - !ruby/object:Gem::Dependency
126
+ name: rspec_junit_formatter
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - "~>"
130
+ - !ruby/object:Gem::Version
131
+ version: '0.4'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - "~>"
137
+ - !ruby/object:Gem::Version
138
+ version: '0.4'
139
+ - !ruby/object:Gem::Dependency
140
+ name: rubocop
141
+ requirement: !ruby/object:Gem::Requirement
142
+ requirements:
143
+ - - "~>"
144
+ - !ruby/object:Gem::Version
145
+ version: '0.61'
146
+ type: :development
147
+ prerelease: false
148
+ version_requirements: !ruby/object:Gem::Requirement
149
+ requirements:
150
+ - - "~>"
151
+ - !ruby/object:Gem::Version
152
+ version: '0.61'
153
+ - !ruby/object:Gem::Dependency
154
+ name: rubocop-junit_formatter
155
+ requirement: !ruby/object:Gem::Requirement
156
+ requirements:
157
+ - - "~>"
158
+ - !ruby/object:Gem::Version
159
+ version: '0.2'
160
+ type: :development
161
+ prerelease: false
162
+ version_requirements: !ruby/object:Gem::Requirement
163
+ requirements:
164
+ - - "~>"
165
+ - !ruby/object:Gem::Version
166
+ version: '0.2'
167
+ - !ruby/object:Gem::Dependency
168
+ name: simplecov
169
+ requirement: !ruby/object:Gem::Requirement
170
+ requirements:
171
+ - - "~>"
172
+ - !ruby/object:Gem::Version
173
+ version: '0.12'
174
+ type: :development
175
+ prerelease: false
176
+ version_requirements: !ruby/object:Gem::Requirement
177
+ requirements:
178
+ - - "~>"
179
+ - !ruby/object:Gem::Version
180
+ version: '0.12'
181
+ - !ruby/object:Gem::Dependency
182
+ name: sqlite3
183
+ requirement: !ruby/object:Gem::Requirement
184
+ requirements:
185
+ - - "~>"
186
+ - !ruby/object:Gem::Version
187
+ version: '1.3'
188
+ type: :development
189
+ prerelease: false
190
+ version_requirements: !ruby/object:Gem::Requirement
191
+ requirements:
192
+ - - "~>"
193
+ - !ruby/object:Gem::Version
194
+ version: '1.3'
195
+ description: Given an initial set of records to delete prune_ar deletes all other
196
+ records (accessible via ActiveRecord) that are now orphaned due to a belongs_to
197
+ relation which is now non-existent. This allows you to safely delete records that
198
+ you want to delete without creating orphaned records in another table (& without
199
+ violating foreign key constraints if you use them). This can be used to prune a
200
+ production database (given deletion criteria for top level parent-less independent
201
+ entities) for use in a development environment without compromising customer data.
202
+ email:
203
+ - amukhopadhyay@contently.com
204
+ executables: []
205
+ extensions: []
206
+ extra_rdoc_files: []
207
+ files:
208
+ - CHANGELOG.md
209
+ - CONTRIBUTING.md
210
+ - Gemfile
211
+ - Gemfile.lock
212
+ - LICENSE
213
+ - README.md
214
+ - Rakefile
215
+ - bin/console
216
+ - bin/setup
217
+ - lib/prune_ar.rb
218
+ - lib/prune_ar/belongs_to_association.rb
219
+ - lib/prune_ar/belongs_to_association_gatherer.rb
220
+ - lib/prune_ar/deleter_by_criteria.rb
221
+ - lib/prune_ar/foreign_key_handler.rb
222
+ - lib/prune_ar/orphaned_selection_builder.rb
223
+ - lib/prune_ar/pruner.rb
224
+ - lib/prune_ar/version.rb
225
+ - prune_ar.gemspec
226
+ homepage: https://github.com/contently/prune_ar
227
+ licenses: []
228
+ metadata:
229
+ allowed_push_host: https://rubygems.org
230
+ post_install_message:
231
+ rdoc_options: []
232
+ require_paths:
233
+ - lib
234
+ required_ruby_version: !ruby/object:Gem::Requirement
235
+ requirements:
236
+ - - ">="
237
+ - !ruby/object:Gem::Version
238
+ version: '0'
239
+ required_rubygems_version: !ruby/object:Gem::Requirement
240
+ requirements:
241
+ - - ">="
242
+ - !ruby/object:Gem::Version
243
+ version: '0'
244
+ requirements: []
245
+ rubyforge_project:
246
+ rubygems_version: 2.7.7
247
+ signing_key:
248
+ specification_version: 4
249
+ summary: Prunes database tables using ActiveRecord belongs_to relations.
250
+ test_files: []