miss_hannigan 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 28c9793e4a1c2d84ab1e77478eec6c6df117a63ba75651ed231414ac9744e46b
4
+ data.tar.gz: fbc963f9e14e9bd7d16f0b00dd7136e1fefb29897cfdd2df74bd6b6cad9af239
5
+ SHA512:
6
+ metadata.gz: 0e304742dfaac4db849f9659d3c234f4bfd4d6440a23b451df6578987092b39ef189dbfbb399103938274d8be397dd877017f9429b62e83817dccf5acb9a7a30
7
+ data.tar.gz: 6965f0f0a3fdcb57977e5cfbc13969341293fcdfdf1b9dd4c4c761f7dc0ee798d05d9476dd1c6143e18ddb34e7958289e392ea1b92cd1cbc674671e2967143e8
@@ -0,0 +1,20 @@
1
+ Copyright 2020 n8
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,148 @@
1
+ # Miss Hannigan
2
+
3
+ > Daddy Warbucks : You lock the orphans in the closet.
4
+ >
5
+ > Miss Hannigan : They love it!
6
+
7
+ ## What?
8
+
9
+ miss_hannigan provides an alternative (and in some cases, better) way to do cascading deletes/destroys in Rails. With it, you can now define a :dependent has_many behavior of :nullify_then_purge which will quickly and synchronously nullify (orphan) children from their parent, and then asynchronously purge those child records (the orphans) from the database.
10
+
11
+ ```
12
+ class Parent < ApplicationRecord
13
+ has_many :children, dependent: :nullify_then_purge
14
+ end
15
+ ```
16
+
17
+ ## Installation
18
+
19
+ 1. Add `gem 'miss_hannigan'` to your Gemfile.
20
+ 2. Run `bundle install`.
21
+ 3. Restart your server
22
+ 4. Add the new dependent option to your has_many relationships:
23
+
24
+ ```
25
+ has_many :children, dependent: :nullify_then_purge
26
+ ```
27
+
28
+ Note: If your `child` has a foreign_key relationship with the `parent`, you'll need to make sure the foreign_key in the `child` allows for nulls. For example, you might have to create migrations like this:
29
+
30
+ ```
31
+ class RemoveNullKeyConstraint < ActiveRecord::Migration[6.0]
32
+ def change
33
+ change_column_null(:children, :parent_id, true)
34
+ end
35
+ end
36
+ ```
37
+
38
+ miss_hannigan will raise an error if the foreign_key isn't configured appropriately.
39
+
40
+ miss_hannigan also assumes you're using ActiveJob with an active queue system in place - that's how orphans get asynchronously destroyed after all.
41
+
42
+ ## Why?
43
+
44
+ Whether you are a Rails expert or just getting started with the framework, you've most likely had to make smart choices on how cascading deletes work in your system. And often in large systems, you're forced with a compromise...
45
+
46
+ To quickly catch beginners up, Rails has some great tooling to deal with parent-child relationships using has_many:
47
+
48
+ ```
49
+ class Parent < ApplicationRecord
50
+ has_many :children
51
+ end
52
+ ```
53
+
54
+ By default, what happens to `children` when you delete an instance of Parent? Nothing. Children just sit tight or in our more typical vernacular, they're orphaned.
55
+
56
+ Normally, you consider two options then: destroy the children, or delete the children.
57
+
58
+ ### dependent: :destroy
59
+
60
+ Destroying the children is ideal. You do that by setting `dependent: :destroy` on the has_many relationship. Like so:
61
+
62
+ ```
63
+ class Parent ApplicationRecord
64
+ has_many :children, dependent: :destroy
65
+ end
66
+ ```
67
+
68
+ Rails, when attempting to destroy an instance of the Parent, will also iteratively go through each child of the parent calling destroy on the child. The benefit of this is that any callbacks and validation on those children are given their day in the sun. If you're using a foreign_key constraint between Parent -> Child, this path will also keep your DB happy. (The children are deleted first, then the parent, avoiding the DB complaining about a foreign key being invalid.)
69
+
70
+ But the main drawback is that destroying a ton of children can be time consuming, especially if those children have their own children (and those have more children, etc.). So time consuming that you simply can't have a user wait that long do even do a delete. And with some hosting platforms, the deletes won't even work as you'll face Timeout errors instead.
71
+
72
+ So, many of us reach for the much faster option of using a `:delete_all ` dependency.
73
+
74
+ ### dependent: :delete_all
75
+
76
+ Going this route, Rails will delete all children of a parent in a single SQL call without going through the Rails instantiations and callbacks.
77
+
78
+ ```
79
+ class Parent ApplicationRecord
80
+ has_many :children, dependent: :delete_all
81
+ end
82
+ ```
83
+
84
+ However, `:delete` has plenty of problems because it doesn't go through the typical Rails destroy.
85
+
86
+ For example, you can't automatically do any post-destroy cleanup (e.g. 3rd party API calls) when those children are destroyed.
87
+
88
+ And you can't use this approach if you are using foreign key constraints in your DB:
89
+
90
+ ![](https://github.com/sutrolabs/miss_hannigan/blob/master/foreign_key_error_example.png?raw=true)
91
+
92
+ Another catch is that if you have a Parent -> Child -> Grandchild relationship, and it uses `dependent: :delete_all` down the tree, destroying a Parent, will stop with deleting the Children. Grandchildren won't even get deleted/destroyed.
93
+
94
+ ------------
95
+
96
+ Here at Census this became a problem. We have quite a lot of children of parent objects. And children have children have children... We had users experiencing timeouts during deletions.
97
+
98
+ Well, we can't reach for dependent: :delete_all since we have a multiple layered hierarchy of objects that all need destroying. We also have foreign_key constraints we'd like to keep using.
99
+
100
+ So what do we do if neither of these approaches work for us?
101
+
102
+ We use an "orphan then later purge" approach. Which has some of the best of both :destroy and :delete_all worlds.
103
+
104
+ dependent has a nifty but less often mentioned option of :nullify.
105
+
106
+ ```
107
+ class Parent < ApplicationRecord
108
+ has_many :children, dependent: :nullify
109
+ end
110
+ ```
111
+
112
+ Using :nullify will simply issue a single UPDATE statement setting children's parent_id to NULL. Which is super fast.
113
+
114
+ This sets up a bunch of orphaned children now that can easily be cleaned up in an asynchronous purge.
115
+
116
+ And now because we're destroying Children here, the normal callbacks are run also allowing Rails to cleanup and destroy GrandChildren.
117
+
118
+ Fast AND thorough.
119
+
120
+ So we wrapped that pattern together into miss_hannigan:
121
+
122
+ ```
123
+ class Parent < ApplicationRecord
124
+ has_many :children, dependent: :nullify_then_purge
125
+ end
126
+ ```
127
+
128
+ ## Alternatives
129
+
130
+ It's worth noting there are other strategies like allowing your DB handle its own cascading deletes. For example, adding foreign keys on a Postgres DB from a Rails migration like so:
131
+
132
+ ```
133
+ add_foreign_key "children", "parents", on_delete: :cascade
134
+ ```
135
+
136
+ Doing that will have Postgres automatically delete children rows when a parent is deleted. But that removes itself from Rails-land where we have other cleanup hooks and tooling we'd like to keep running.
137
+
138
+ Another alternative would be to use a pattern like acts_as_paranoid to "soft delete" a parent record and later destroy it asynchronously.
139
+
140
+
141
+ Feedback
142
+ --------
143
+ [Source code available on Github](https://github.com/sutrolabs/miss_hannigan). Feedback and pull requests are greatly appreciated. Let us know if we can improve this.
144
+
145
+
146
+ From
147
+ -----------
148
+ :wave: The folks at [Census](http://getcensus.com) originally put this together. Have data? We'll sync your data warehouse with your CRM and the customer success apps critical to your team.
@@ -0,0 +1,27 @@
1
+ begin
2
+ require 'bundler/setup'
3
+ rescue LoadError
4
+ puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
5
+ end
6
+
7
+ require 'rdoc/task'
8
+
9
+ RDoc::Task.new(:rdoc) do |rdoc|
10
+ rdoc.rdoc_dir = 'rdoc'
11
+ rdoc.title = 'MissHannigan'
12
+ rdoc.options << '--line-numbers'
13
+ rdoc.rdoc_files.include('README.md')
14
+ rdoc.rdoc_files.include('lib/**/*.rb')
15
+ end
16
+
17
+ require 'bundler/gem_tasks'
18
+
19
+ require 'rake/testtask'
20
+
21
+ Rake::TestTask.new(:test) do |t|
22
+ t.libs << 'test'
23
+ t.pattern = 'test/**/*_test.rb'
24
+ t.verbose = false
25
+ end
26
+
27
+ task default: :test
@@ -0,0 +1,2 @@
1
+ require "miss_hannigan/miss_hannigan"
2
+ require 'miss_hannigan/railtie'
@@ -0,0 +1,48 @@
1
+ module MissHannigan
2
+ extend ActiveSupport::Concern
3
+
4
+ module ClassMethods
5
+
6
+ def has_many(name, scope = nil, **options, &extension)
7
+ nullify_then_purge = false
8
+
9
+ # we're really just relying on :nullify. so just return our dependent option to that
10
+ if options[:dependent] == :nullify_then_purge
11
+ nullify_then_purge = true
12
+ options[:dependent] = :nullify
13
+ end
14
+
15
+ # get our normal has_many reflection to get setup
16
+ reflection = super
17
+
18
+ if nullify_then_purge
19
+
20
+ # has the details of the relation to Child
21
+ reflection_details = reflection[name.to_s]
22
+
23
+ # I bet folks are going to forget to do the migration of foreign_keys to accept null. Rails defaults
24
+ # to not allow null.
25
+ if !reflection_details.klass.columns.find { |c| c.name == reflection_details.foreign_key }.null
26
+ raise "The foreign key must be nullable to support MissHannigan. You should create a migration to:
27
+ change_column_null :#{name.to_s}, :#{reflection_details.foreign_key}, true"
28
+ end
29
+
30
+ after_destroy do |this_object|
31
+ CleanupJob.perform_later(reflection_details.klass.to_s, reflection_details.foreign_key)
32
+ end
33
+ end
34
+
35
+ return reflection
36
+ end
37
+ end
38
+
39
+ class CleanupJob < ActiveJob::Base
40
+ queue_as :default
41
+
42
+ def perform(klass_string, parent_foreign_key)
43
+ klass = klass_string.constantize
44
+
45
+ klass.where(parent_foreign_key => nil).find_each(&:destroy)
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,11 @@
1
+ require 'rails'
2
+
3
+ module MissHannigan
4
+ class Railtie < Rails::Railtie
5
+ initializer 'miss_hannigan.initialize' do
6
+ ActiveSupport.on_load(:active_record) do
7
+ ActiveRecord::Base.send :include, MissHannigan
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,3 @@
1
+ module MissHannigan
2
+ VERSION = '0.1.0'
3
+ end
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :miss_hannigan do
3
+ # # Task goes here
4
+ # end
metadata ADDED
@@ -0,0 +1,85 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: miss_hannigan
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - n8
8
+ - bradleybuda
9
+ - avaynshtok
10
+ - sean-lynch
11
+ - davehughes
12
+ - bjabes
13
+ autorequire:
14
+ bindir: bin
15
+ cert_chain: []
16
+ date: 2020-02-28 00:00:00.000000000 Z
17
+ dependencies:
18
+ - !ruby/object:Gem::Dependency
19
+ name: rails
20
+ requirement: !ruby/object:Gem::Requirement
21
+ requirements:
22
+ - - ">="
23
+ - !ruby/object:Gem::Version
24
+ version: '5.1'
25
+ type: :runtime
26
+ prerelease: false
27
+ version_requirements: !ruby/object:Gem::Requirement
28
+ requirements:
29
+ - - ">="
30
+ - !ruby/object:Gem::Version
31
+ version: '5.1'
32
+ - !ruby/object:Gem::Dependency
33
+ name: sqlite3
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '0'
39
+ type: :development
40
+ prerelease: false
41
+ version_requirements: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - ">="
44
+ - !ruby/object:Gem::Version
45
+ version: '0'
46
+ description: If neither :destroy or :delete_all work for you when deleting children
47
+ in Rails, maybe this is the right combination for you.
48
+ email:
49
+ - nate.kontny@gmail.com
50
+ executables: []
51
+ extensions: []
52
+ extra_rdoc_files: []
53
+ files:
54
+ - MIT-LICENSE
55
+ - README.md
56
+ - Rakefile
57
+ - lib/miss_hannigan.rb
58
+ - lib/miss_hannigan/miss_hannigan.rb
59
+ - lib/miss_hannigan/railtie.rb
60
+ - lib/miss_hannigan/version.rb
61
+ - lib/tasks/miss_hannigan_tasks.rake
62
+ homepage: https://rubygems.org/gems/miss_hannigan
63
+ licenses:
64
+ - MIT
65
+ metadata: {}
66
+ post_install_message:
67
+ rdoc_options: []
68
+ require_paths:
69
+ - lib
70
+ required_ruby_version: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ required_rubygems_version: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - ">="
78
+ - !ruby/object:Gem::Version
79
+ version: '0'
80
+ requirements: []
81
+ rubygems_version: 3.1.2
82
+ signing_key:
83
+ specification_version: 4
84
+ summary: An alternative way to do cascading deletes in Rails.
85
+ test_files: []