miss_hannigan 0.1.1 → 0.1.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +35 -35
- data/lib/miss_hannigan/miss_hannigan.rb +29 -23
- data/lib/miss_hannigan/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 3978b43b61c215d99d63cda3090f4112ada5899f5c5a1a633f397c12702fce29
|
4
|
+
data.tar.gz: bbd50537d9be7187e700cca4b5220f5202d43f08cb558fa02fc5896c3a80109d
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 6161f50401dd02989f211355bc30a13b7e6db01a6f981c43fd9889cdb2ea4c3de79059bdd3bdf3ae0671044575ffcb0d504db3449b343400a0326910d9cdf99e
|
7
|
+
data.tar.gz: f813c60fbf646f76c20952ce489b0c7da2757c5b7d2198d56742f663d8128c458918279b7e50c03563f8b3d5e0eba90e52e2c5fcef33ce61463780711bee880b
|
data/README.md
CHANGED
@@ -6,11 +6,11 @@
|
|
6
6
|
|
7
7
|
## What?
|
8
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.
|
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
10
|
|
11
11
|
```
|
12
12
|
class Parent < ApplicationRecord
|
13
|
-
|
13
|
+
has_many :children, dependent: :nullify_then_purge
|
14
14
|
end
|
15
15
|
```
|
16
16
|
|
@@ -19,13 +19,13 @@ end
|
|
19
19
|
1. Add `gem 'miss_hannigan'` to your Gemfile.
|
20
20
|
2. Run `bundle install`.
|
21
21
|
3. Restart your server
|
22
|
-
4. Add the new dependent option to your has_many relationships:
|
22
|
+
4. Add the new dependent option to your has_many relationships:
|
23
23
|
|
24
24
|
```
|
25
25
|
has_many :children, dependent: :nullify_then_purge
|
26
26
|
```
|
27
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:
|
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
29
|
|
30
30
|
```
|
31
31
|
class RemoveNullKeyConstraint < ActiveRecord::Migration[6.0]
|
@@ -35,41 +35,41 @@ class RemoveNullKeyConstraint < ActiveRecord::Migration[6.0]
|
|
35
35
|
end
|
36
36
|
```
|
37
37
|
|
38
|
-
miss_hannigan will raise an error if the foreign_key isn't configured appropriately.
|
38
|
+
miss_hannigan will raise an error if the foreign_key isn't configured appropriately.
|
39
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.
|
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
41
|
|
42
42
|
## Why?
|
43
43
|
|
44
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
45
|
|
46
|
-
To quickly catch beginners up, Rails has some great tooling to deal with parent-child relationships using has_many:
|
46
|
+
To quickly catch beginners up, Rails has some great tooling to deal with parent-child relationships using has_many:
|
47
47
|
|
48
48
|
```
|
49
49
|
class Parent < ApplicationRecord
|
50
|
-
|
50
|
+
has_many :children
|
51
51
|
end
|
52
52
|
```
|
53
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.
|
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
55
|
|
56
|
-
Normally, you consider two options then: destroy the children, or delete the children.
|
56
|
+
Normally, you consider two options then: destroy the children, or delete the children.
|
57
57
|
|
58
58
|
### dependent: :destroy
|
59
59
|
|
60
|
-
Destroying the children is ideal. You do that by setting `dependent: :destroy` on the has_many relationship. Like so:
|
60
|
+
Destroying the children is ideal. You do that by setting `dependent: :destroy` on the has_many relationship. Like so:
|
61
61
|
|
62
62
|
```
|
63
63
|
class Parent ApplicationRecord
|
64
|
-
|
64
|
+
has_many :children, dependent: :destroy
|
65
65
|
end
|
66
66
|
```
|
67
67
|
|
68
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
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.
|
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
71
|
|
72
|
-
So, many of us reach for the much faster option of using a `:delete_all ` dependency.
|
72
|
+
So, many of us reach for the much faster option of using a `:delete_all ` dependency.
|
73
73
|
|
74
74
|
### dependent: :delete_all
|
75
75
|
|
@@ -77,65 +77,65 @@ Going this route, Rails will delete all children of a parent in a single SQL cal
|
|
77
77
|
|
78
78
|
```
|
79
79
|
class Parent ApplicationRecord
|
80
|
-
|
80
|
+
has_many :children, dependent: :delete_all
|
81
81
|
end
|
82
82
|
```
|
83
83
|
|
84
|
-
However, `:delete` has plenty of problems because it doesn't go through the typical Rails destroy.
|
84
|
+
However, `:delete` has plenty of problems because it doesn't go through the typical Rails destroy.
|
85
85
|
|
86
86
|
For example, you can't automatically do any post-destroy cleanup (e.g. 3rd party API calls) when those children are destroyed.
|
87
87
|
|
88
|
-
And you can't use this approach if you are using foreign key constraints in your DB:
|
88
|
+
And you can't use this approach if you are using foreign key constraints in your DB:
|
89
89
|
|
90
90
|
![](https://github.com/sutrolabs/miss_hannigan/blob/master/foreign_key_error_example.png?raw=true)
|
91
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.
|
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
93
|
|
94
94
|
------------
|
95
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.
|
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
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.
|
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
99
|
|
100
|
-
So what do we do if neither of these approaches work for us?
|
100
|
+
So what do we do if neither of these approaches work for us?
|
101
101
|
|
102
|
-
We use an "orphan then later purge" approach. Which has some of the best of both :destroy and :delete_all worlds.
|
102
|
+
We use an "orphan then later purge" approach. Which has some of the best of both :destroy and :delete_all worlds.
|
103
103
|
|
104
|
-
dependent has a nifty but less often mentioned option of :nullify.
|
104
|
+
dependent has a nifty but less often mentioned option of :nullify.
|
105
105
|
|
106
106
|
```
|
107
107
|
class Parent < ApplicationRecord
|
108
|
-
|
108
|
+
has_many :children, dependent: :nullify
|
109
109
|
end
|
110
110
|
```
|
111
111
|
|
112
|
-
Using :nullify will simply issue a single UPDATE statement setting children's parent_id to NULL. Which is super fast.
|
112
|
+
Using :nullify will simply issue a single UPDATE statement setting children's parent_id to NULL. Which is super fast.
|
113
113
|
|
114
|
-
This sets up a bunch of orphaned children now that can easily be cleaned up in an asynchronous purge.
|
114
|
+
This sets up a bunch of orphaned children now that can easily be cleaned up in an asynchronous purge.
|
115
115
|
|
116
|
-
And now because we're destroying Children here, the normal callbacks are run also allowing Rails to cleanup and destroy GrandChildren.
|
116
|
+
And now because we're destroying Children here, the normal callbacks are run also allowing Rails to cleanup and destroy GrandChildren.
|
117
117
|
|
118
|
-
Fast AND thorough.
|
118
|
+
Fast AND thorough.
|
119
119
|
|
120
|
-
So we wrapped that pattern together into miss_hannigan:
|
120
|
+
So we wrapped that pattern together into miss_hannigan:
|
121
121
|
|
122
122
|
```
|
123
123
|
class Parent < ApplicationRecord
|
124
|
-
|
124
|
+
has_many :children, dependent: :nullify_then_purge
|
125
125
|
end
|
126
126
|
```
|
127
127
|
|
128
|
-
## Alternatives
|
128
|
+
## Alternatives
|
129
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:
|
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
131
|
|
132
132
|
```
|
133
133
|
add_foreign_key "children", "parents", on_delete: :cascade
|
134
134
|
```
|
135
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.
|
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
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.
|
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
139
|
|
140
140
|
|
141
141
|
Feedback
|
@@ -145,4 +145,4 @@ Feedback
|
|
145
145
|
|
146
146
|
From
|
147
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.
|
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.
|
@@ -2,43 +2,49 @@ module MissHannigan
|
|
2
2
|
extend ActiveSupport::Concern
|
3
3
|
|
4
4
|
module ClassMethods
|
5
|
+
def has_many(name, scope = nil, **options, &extension)
|
6
|
+
nullify_then_purge = detect_nullify_then_purge(options)
|
7
|
+
super.tap do |reflection|
|
8
|
+
connect_nullify_then_purge(reflection, name) if nullify_then_purge
|
9
|
+
end
|
10
|
+
end
|
5
11
|
|
6
|
-
def
|
7
|
-
nullify_then_purge =
|
12
|
+
def has_one(name, scope = nil, **options, &extension)
|
13
|
+
nullify_then_purge = detect_nullify_then_purge(options)
|
14
|
+
super.tap do |reflection|
|
15
|
+
connect_nullify_then_purge(reflection, name) if nullify_then_purge
|
16
|
+
end
|
17
|
+
end
|
8
18
|
|
9
|
-
|
19
|
+
def detect_nullify_then_purge(options)
|
10
20
|
if options[:dependent] == :nullify_then_purge
|
11
|
-
nullify_then_purge = true
|
12
21
|
options[:dependent] = :nullify
|
22
|
+
true
|
23
|
+
else
|
24
|
+
false
|
13
25
|
end
|
26
|
+
end
|
14
27
|
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
if nullify_then_purge
|
19
|
-
|
20
|
-
# has the details of the relation to Child
|
21
|
-
reflection_details = reflection[name.to_s]
|
28
|
+
def connect_nullify_then_purge(reflection, name)
|
29
|
+
# has the details of the relation to Child
|
30
|
+
reflection_details = reflection[name.to_s]
|
22
31
|
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
end
|
29
|
-
|
30
|
-
after_destroy do |this_object|
|
31
|
-
CleanupJob.perform_later(reflection_details.klass.to_s, reflection_details.foreign_key)
|
32
|
-
end
|
32
|
+
# I bet folks are going to forget to do the migration of foreign_keys to accept null. Rails defaults
|
33
|
+
# to not allow null.
|
34
|
+
if !reflection_details.klass.columns.find { |c| c.name == reflection_details.foreign_key }.null
|
35
|
+
raise "The foreign key must be nullable to support MissHannigan. You should create a migration to:
|
36
|
+
change_column_null :#{name.to_s}, :#{reflection_details.foreign_key}, true"
|
33
37
|
end
|
34
38
|
|
35
|
-
|
39
|
+
after_destroy do |this_object|
|
40
|
+
CleanupJob.perform_later(reflection_details.klass.to_s, reflection_details.foreign_key)
|
41
|
+
end
|
36
42
|
end
|
37
43
|
end
|
38
44
|
|
39
45
|
class CleanupJob < ActiveJob::Base
|
40
46
|
queue_as :default
|
41
|
-
|
47
|
+
|
42
48
|
def perform(klass_string, parent_foreign_key)
|
43
49
|
klass = klass_string.constantize
|
44
50
|
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: miss_hannigan
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.1.
|
4
|
+
version: 0.1.2
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- n8
|
@@ -13,7 +13,7 @@ authors:
|
|
13
13
|
autorequire:
|
14
14
|
bindir: bin
|
15
15
|
cert_chain: []
|
16
|
-
date: 2020-
|
16
|
+
date: 2020-08-24 00:00:00.000000000 Z
|
17
17
|
dependencies:
|
18
18
|
- !ruby/object:Gem::Dependency
|
19
19
|
name: rails
|