miss_hannigan 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/MIT-LICENSE +20 -0
- data/README.md +148 -0
- data/Rakefile +27 -0
- data/lib/miss_hannigan.rb +2 -0
- data/lib/miss_hannigan/miss_hannigan.rb +48 -0
- data/lib/miss_hannigan/railtie.rb +11 -0
- data/lib/miss_hannigan/version.rb +3 -0
- data/lib/tasks/miss_hannigan_tasks.rake +4 -0
- metadata +85 -0
checksums.yaml
ADDED
@@ -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
|
data/MIT-LICENSE
ADDED
@@ -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.
|
data/README.md
ADDED
@@ -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.
|
data/Rakefile
ADDED
@@ -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,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
|
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: []
|