active_record-associated_object 0.5.2 → 0.7.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/Gemfile.lock +43 -41
- data/README.md +264 -65
- data/active_record-associated_object.gemspec +1 -1
- data/lib/active_record/associated_object/object_association.rb +16 -7
- data/lib/active_record/associated_object/railtie.rb +1 -1
- data/lib/active_record/associated_object/version.rb +1 -1
- data/lib/active_record/associated_object.rb +4 -0
- metadata +4 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: d24bf37946a6b4e5ddcb1626e90eb91e27932ae8d9f8f29c10c095ce3c53897c
|
4
|
+
data.tar.gz: 7f7d23788109d020fb1b010e8e6f3956880dfeb012c517dfc43e1f5c7862fd6c
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 72675998afa72ea4117fd3601e9df6c9dbd2f62ae1541e1b6677903dd5c32109bd06c281e9be98d7742b92dcf6e48f2792764a5c661729ef2df95d6ce54cd2c4
|
7
|
+
data.tar.gz: 3d4244067099adec3ac04a185019bd99aba3078578ede15315893eb6c8f6b9a98cf8cd6356e74e37a395f39ec20013e83529ab6f9766dd014f154d3d03591718
|
data/Gemfile.lock
CHANGED
@@ -1,39 +1,40 @@
|
|
1
1
|
PATH
|
2
2
|
remote: .
|
3
3
|
specs:
|
4
|
-
active_record-associated_object (0.
|
4
|
+
active_record-associated_object (0.7.0)
|
5
5
|
activerecord (>= 6.1)
|
6
6
|
|
7
7
|
GEM
|
8
8
|
remote: https://rubygems.org/
|
9
9
|
specs:
|
10
|
-
actionpack (7.1.
|
11
|
-
actionview (= 7.1.
|
12
|
-
activesupport (= 7.1.
|
10
|
+
actionpack (7.1.2)
|
11
|
+
actionview (= 7.1.2)
|
12
|
+
activesupport (= 7.1.2)
|
13
13
|
nokogiri (>= 1.8.5)
|
14
|
+
racc
|
14
15
|
rack (>= 2.2.4)
|
15
16
|
rack-session (>= 1.0.1)
|
16
17
|
rack-test (>= 0.6.3)
|
17
18
|
rails-dom-testing (~> 2.2)
|
18
19
|
rails-html-sanitizer (~> 1.6)
|
19
|
-
actionview (7.1.
|
20
|
-
activesupport (= 7.1.
|
20
|
+
actionview (7.1.2)
|
21
|
+
activesupport (= 7.1.2)
|
21
22
|
builder (~> 3.1)
|
22
23
|
erubi (~> 1.11)
|
23
24
|
rails-dom-testing (~> 2.2)
|
24
25
|
rails-html-sanitizer (~> 1.6)
|
25
|
-
active_job-performs (0.
|
26
|
+
active_job-performs (0.3.0)
|
26
27
|
activejob (>= 6.1)
|
27
|
-
activejob (7.1.
|
28
|
-
activesupport (= 7.1.
|
28
|
+
activejob (7.1.2)
|
29
|
+
activesupport (= 7.1.2)
|
29
30
|
globalid (>= 0.3.6)
|
30
|
-
activemodel (7.1.
|
31
|
-
activesupport (= 7.1.
|
32
|
-
activerecord (7.1.
|
33
|
-
activemodel (= 7.1.
|
34
|
-
activesupport (= 7.1.
|
31
|
+
activemodel (7.1.2)
|
32
|
+
activesupport (= 7.1.2)
|
33
|
+
activerecord (7.1.2)
|
34
|
+
activemodel (= 7.1.2)
|
35
|
+
activesupport (= 7.1.2)
|
35
36
|
timeout (>= 0.4.0)
|
36
|
-
activesupport (7.1.
|
37
|
+
activesupport (7.1.2)
|
37
38
|
base64
|
38
39
|
bigdecimal
|
39
40
|
concurrent-ruby (~> 1.0, >= 1.0.2)
|
@@ -43,45 +44,45 @@ GEM
|
|
43
44
|
minitest (>= 5.1)
|
44
45
|
mutex_m
|
45
46
|
tzinfo (~> 2.0)
|
46
|
-
base64 (0.
|
47
|
-
bigdecimal (3.1.
|
47
|
+
base64 (0.2.0)
|
48
|
+
bigdecimal (3.1.5)
|
48
49
|
builder (3.2.4)
|
49
50
|
concurrent-ruby (1.2.2)
|
50
51
|
connection_pool (2.4.1)
|
51
52
|
crass (1.0.6)
|
52
|
-
debug (1.
|
53
|
-
irb (
|
54
|
-
reline (>= 0.3.
|
55
|
-
drb (2.
|
53
|
+
debug (1.9.1)
|
54
|
+
irb (~> 1.10)
|
55
|
+
reline (>= 0.3.8)
|
56
|
+
drb (2.2.0)
|
56
57
|
ruby2_keywords
|
57
58
|
erubi (1.12.0)
|
58
59
|
globalid (1.2.1)
|
59
60
|
activesupport (>= 6.1)
|
60
61
|
i18n (1.14.1)
|
61
62
|
concurrent-ruby (~> 1.0)
|
62
|
-
io-console (0.
|
63
|
-
irb (1.
|
63
|
+
io-console (0.7.1)
|
64
|
+
irb (1.11.0)
|
64
65
|
rdoc
|
65
66
|
reline (>= 0.3.8)
|
66
|
-
kredis (1.
|
67
|
+
kredis (1.7.0)
|
67
68
|
activemodel (>= 6.0.0)
|
68
69
|
activesupport (>= 6.0.0)
|
69
70
|
redis (>= 4.2, < 6)
|
70
|
-
loofah (2.
|
71
|
+
loofah (2.22.0)
|
71
72
|
crass (~> 1.0.2)
|
72
73
|
nokogiri (>= 1.12.0)
|
73
74
|
minitest (5.20.0)
|
74
75
|
minitest-sprint (1.2.2)
|
75
76
|
path_expander (~> 1.1)
|
76
|
-
mutex_m (0.
|
77
|
-
nokogiri (1.
|
77
|
+
mutex_m (0.2.0)
|
78
|
+
nokogiri (1.16.0-arm64-darwin)
|
78
79
|
racc (~> 1.4)
|
79
|
-
nokogiri (1.
|
80
|
+
nokogiri (1.16.0-x86_64-linux)
|
80
81
|
racc (~> 1.4)
|
81
82
|
path_expander (1.1.1)
|
82
|
-
psych (5.1.
|
83
|
+
psych (5.1.2)
|
83
84
|
stringio
|
84
|
-
racc (1.7.
|
85
|
+
racc (1.7.3)
|
85
86
|
rack (3.0.8)
|
86
87
|
rack-session (2.0.0)
|
87
88
|
rack (>= 3.0.0)
|
@@ -97,29 +98,29 @@ GEM
|
|
97
98
|
rails-html-sanitizer (1.6.0)
|
98
99
|
loofah (~> 2.21)
|
99
100
|
nokogiri (~> 1.14)
|
100
|
-
railties (7.1.
|
101
|
-
actionpack (= 7.1.
|
102
|
-
activesupport (= 7.1.
|
101
|
+
railties (7.1.2)
|
102
|
+
actionpack (= 7.1.2)
|
103
|
+
activesupport (= 7.1.2)
|
103
104
|
irb
|
104
105
|
rackup (>= 1.0.0)
|
105
106
|
rake (>= 12.2)
|
106
107
|
thor (~> 1.0, >= 1.2.2)
|
107
108
|
zeitwerk (~> 2.6)
|
108
109
|
rake (13.1.0)
|
109
|
-
rdoc (6.
|
110
|
+
rdoc (6.6.2)
|
110
111
|
psych (>= 4.0.0)
|
111
112
|
redis (5.0.8)
|
112
113
|
redis-client (>= 0.17.0)
|
113
|
-
redis-client (0.
|
114
|
+
redis-client (0.19.1)
|
114
115
|
connection_pool
|
115
|
-
reline (0.
|
116
|
+
reline (0.4.2)
|
116
117
|
io-console (~> 0.5)
|
117
118
|
ruby2_keywords (0.0.5)
|
118
|
-
sqlite3 (1.
|
119
|
-
sqlite3 (1.
|
120
|
-
stringio (3.0
|
119
|
+
sqlite3 (1.7.0-arm64-darwin)
|
120
|
+
sqlite3 (1.7.0-x86_64-linux)
|
121
|
+
stringio (3.1.0)
|
121
122
|
thor (1.3.0)
|
122
|
-
timeout (0.4.
|
123
|
+
timeout (0.4.1)
|
123
124
|
tzinfo (2.0.6)
|
124
125
|
concurrent-ruby (~> 1.0)
|
125
126
|
webrick (1.8.1)
|
@@ -129,6 +130,7 @@ PLATFORMS
|
|
129
130
|
arm64-darwin-20
|
130
131
|
arm64-darwin-21
|
131
132
|
arm64-darwin-22
|
133
|
+
arm64-darwin-23
|
132
134
|
x86_64-linux
|
133
135
|
|
134
136
|
DEPENDENCIES
|
@@ -144,4 +146,4 @@ DEPENDENCIES
|
|
144
146
|
sqlite3
|
145
147
|
|
146
148
|
BUNDLED WITH
|
147
|
-
2.4
|
149
|
+
2.5.4
|
data/README.md
CHANGED
@@ -1,81 +1,249 @@
|
|
1
1
|
# ActiveRecord::AssociatedObject
|
2
2
|
|
3
|
-
|
3
|
+
Rails applications can end up with models that get way too big, and so far, the
|
4
|
+
Ruby community response has been Service Objects. But sometimes `app/services`
|
5
|
+
can turn into another junk drawer that doesn't help you build and make concepts for your Domain Model.
|
4
6
|
|
5
|
-
|
7
|
+
`ActiveRecord::AssociatedObject` takes that head on. Associated Objects are a new domain concept, a context object, that's meant to
|
8
|
+
help you tease out collaborator objects for your Active Record models.
|
9
|
+
|
10
|
+
They're essentially POROs that you associate with an Active Record model to get benefits both in simpler code as well as automatic `app/models` organization.
|
11
|
+
|
12
|
+
Let's look at an example. Say you have a `Post` model that encapsulates a blog post in a Content-Management-System:
|
13
|
+
|
14
|
+
```ruby
|
15
|
+
class Post < ApplicationRecord
|
16
|
+
end
|
17
|
+
```
|
18
|
+
|
19
|
+
You've identified that several things need to happen when a post gets published.
|
20
|
+
But where does that behavior live; in `Post`? That might get messy.
|
21
|
+
|
22
|
+
If we put it in a classic Service Object, we've got access to a `def call` method and that's it — what if we need other methods that operate on the state? And then having `PublishPost` or a similar ad-hoc name in `app/services` can pollute that folder over time.
|
23
|
+
|
24
|
+
What if we instead identified a `Publisher` collaborator object, a Ruby class that handles publishing? What if we required it to be placed within `Post::` to automatically help connote the object as belonging to and collaborating with `Post`? Then we'd get `app/models/post/publisher.rb` which guides naming and gives more organization in your app automatically through that convention — and helps prevent a junk drawer from forming.
|
25
|
+
|
26
|
+
This is what Associated Objects are! We'd define it like this:
|
27
|
+
|
28
|
+
```ruby
|
29
|
+
# app/models/post/publisher.rb
|
30
|
+
class Post::Publisher < ActiveRecord::AssociatedObject
|
31
|
+
end
|
32
|
+
```
|
33
|
+
|
34
|
+
And then you can declare it in `Post`:
|
6
35
|
|
7
36
|
```ruby
|
8
37
|
# app/models/post.rb
|
9
|
-
class Post <
|
10
|
-
# `has_object` defines a `publisher` method that calls Post::Publisher.new(post).
|
38
|
+
class Post < ApplicationRecord
|
11
39
|
has_object :publisher
|
12
40
|
end
|
41
|
+
```
|
13
42
|
|
14
|
-
|
43
|
+
There isn't anything super special happening yet. Here's essentially what's happening under the hood:
|
44
|
+
|
45
|
+
```ruby
|
15
46
|
class Post::Publisher
|
16
|
-
|
17
|
-
|
18
|
-
|
47
|
+
attr_reader :post
|
48
|
+
def initialize(post) = @post = post
|
49
|
+
end
|
50
|
+
|
51
|
+
class Post < ApplicationRecord
|
52
|
+
def publisher = (@associated_objects ||= {})[:publisher] ||= Post::Publisher.new(self)
|
19
53
|
end
|
20
54
|
```
|
21
55
|
|
22
|
-
|
56
|
+
Note: due to Ruby's Object Shapes, we use a single `@associated_objects` instance variable that's assigned to `nil` on `Post.new`. This prevents Active Record's from ballooning into many different shapes in Ruby's internals.
|
57
|
+
We've fixed this so you don't need to care, but this is what's happening.
|
23
58
|
|
24
|
-
|
25
|
-
|
26
|
-
class Post::Publisher < ActiveRecord::AssociatedObject
|
27
|
-
# ActiveRecord::AssociatedObject defines initialize(post) automatically. It's derived from the `Post::` namespace.
|
59
|
+
> [!TIP]
|
60
|
+
> `has_object` only requires a namespace and an initializer that takes a single argument. The above `Post::Publisher` is perfectly valid as an Associated Object — same goes for `class Post::Publisher < Data.define(:post); end`.
|
28
61
|
|
29
|
-
|
62
|
+
> [!TIP]
|
63
|
+
> You can pass multiple names too: `has_object :publisher, :classified, :fortification`. I recommend `-[i]er`, `-[i]ed` and `-ion` as the general naming conventions for your Associated Objects.
|
30
64
|
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
end
|
65
|
+
See how we're always expecting a link to the model, here `post`?
|
66
|
+
|
67
|
+
Because of that, you can rely on `post` from the associated object:
|
35
68
|
|
69
|
+
```ruby
|
70
|
+
class Post::Publisher < ActiveRecord::AssociatedObject
|
36
71
|
def publish
|
37
72
|
# `transaction` is syntactic sugar for `post.transaction` here.
|
38
73
|
transaction do
|
39
|
-
# A `post` method is generated to access the associated post. There's also a `record` alias available.
|
40
74
|
post.update! published: true
|
41
75
|
post.subscribers.post_published post
|
76
|
+
|
77
|
+
# There's also a `record` alias available if you prefer the more general reading version:
|
78
|
+
# record.update! published: true
|
79
|
+
# record.subscribers.post_published record
|
42
80
|
end
|
43
81
|
end
|
44
82
|
end
|
45
83
|
```
|
46
84
|
|
47
|
-
###
|
85
|
+
### Forwarding callbacks onto the associated object
|
48
86
|
|
49
|
-
|
87
|
+
To further help illustrate how your collaborator Associated Objects interact with your domain model, you can forward callbacks.
|
88
|
+
|
89
|
+
Say we wanted to have our `publisher` automatically publish posts after they're created. Or we need to refresh a publishing after a post has been touched. Or what if we don't want posts to be destroyed if they're published due to HAHA BUSINESS rules?
|
90
|
+
|
91
|
+
So `has_object` can state this and forward those callbacks onto the Associated Object:
|
50
92
|
|
51
93
|
```ruby
|
52
|
-
|
53
|
-
|
54
|
-
|
94
|
+
class Post < ActiveRecord::Base
|
95
|
+
# Passing `true` forwards the same name, e.g. `after_touch`.
|
96
|
+
has_object :publisher, after_touch: true, after_create_commit: :publish,
|
97
|
+
before_destroy: :prevent_errant_post_destroy
|
55
98
|
|
56
|
-
|
99
|
+
# The above is the same as writing:
|
100
|
+
after_create_commit { publisher.publish }
|
101
|
+
after_touch { publisher.after_touch }
|
102
|
+
before_destroy { publisher.prevent_errant_post_destroy }
|
103
|
+
end
|
104
|
+
|
105
|
+
class Post::Publisher < ActiveRecord::AssociatedObject
|
106
|
+
def publish
|
107
|
+
end
|
108
|
+
|
109
|
+
def after_touch
|
110
|
+
# Respond to the after_touch on the Post.
|
111
|
+
end
|
112
|
+
|
113
|
+
def prevent_errant_post_destroy
|
114
|
+
# Passed callbacks can throw :abort too, and in this example prevent post.destroy.
|
115
|
+
throw :abort if haha_business?
|
116
|
+
end
|
57
117
|
end
|
58
118
|
```
|
59
119
|
|
60
|
-
|
120
|
+
### Extending the Active Record from within the Associated Object
|
121
|
+
|
122
|
+
Since `has_object` eager-loads the Associated Object class, you can also move
|
123
|
+
any integrating code into a provided `extension` block:
|
124
|
+
|
125
|
+
> [!NOTE]
|
126
|
+
> Technically, `extension` is just `Post.class_eval` but with syntactic sugar.
|
61
127
|
|
62
128
|
```ruby
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
129
|
+
class Post::Publisher < ActiveRecord::AssociatedObject
|
130
|
+
extension do
|
131
|
+
# Here we're within Post and can extend it:
|
132
|
+
has_many :contracts, dependent: :destroy do
|
133
|
+
def signed? = all?(&:signed?)
|
134
|
+
end
|
135
|
+
|
136
|
+
def self.with_contracts = includes(:contracts)
|
137
|
+
|
138
|
+
after_create_commit :publish_later, if: -> { contracts.signed? }
|
139
|
+
|
140
|
+
# An integrating method that operates on `publisher`.
|
141
|
+
private def publish_later = publisher.publish_later
|
68
142
|
end
|
69
143
|
end
|
70
144
|
```
|
71
145
|
|
72
|
-
|
146
|
+
This is meant as an alternative to having a wrapping `ActiveSupport::Concern` in yet-another file like this:
|
73
147
|
|
74
|
-
|
148
|
+
```ruby
|
149
|
+
class Post < ApplicationRecord
|
150
|
+
include Published
|
151
|
+
end
|
75
152
|
|
76
|
-
|
153
|
+
# app/models/post/published.rb
|
154
|
+
module Post::Published
|
155
|
+
extend ActiveSupport::Concern
|
156
|
+
|
157
|
+
included do
|
158
|
+
has_many :contracts, dependent: :destroy do
|
159
|
+
def signed? = all?(&:signed?)
|
160
|
+
end
|
161
|
+
|
162
|
+
has_object :publisher
|
163
|
+
after_create_commit :publish_later, if: -> { contracts.signed? }
|
164
|
+
end
|
165
|
+
|
166
|
+
class_methods do
|
167
|
+
def with_contracts = includes(:contracts)
|
168
|
+
end
|
77
169
|
|
78
|
-
|
170
|
+
# An integrating method that operates on `publisher`.
|
171
|
+
private def publish_later = publisher.publish_later
|
172
|
+
end
|
173
|
+
```
|
174
|
+
|
175
|
+
> [!NOTE]
|
176
|
+
> Notice how in the `extension` version you don't need to:
|
177
|
+
>
|
178
|
+
> - have a naming convention for Concerns and where to place them.
|
179
|
+
> - look up two files to read the feature (the concern and the associated object).
|
180
|
+
> - wrap integrating code in an `included` block.
|
181
|
+
> - wrap class methods in a `class_methods` block.
|
182
|
+
|
183
|
+
### Primary Benefit: Organization through Convention
|
184
|
+
|
185
|
+
The primary benefit for right now is that by focusing the concept of namespaced Collaborator Objects through Associated Objects, you will start seeing them when you're modelling new features and it'll change how you structure and write your apps.
|
186
|
+
|
187
|
+
This is what [@natematykiewicz](https://github.com/natematykiewicz) found when they started using the gem (we'll get to `ActiveJob::Performs` soon):
|
188
|
+
|
189
|
+
> We're running `ActiveRecord::AssociatedObject` and `ActiveJob::Performs` (via the associated object) in 3 spots in production so far. It massively improved how I was architecting a new feature. I put a PR up for review and a coworker loved how organized and easy to follow the large PR was because of those 2 gems. I'm now working on another PR in our app where I'm using them again. I keep seeing use-cases for them now. I love it. Thank you for these gems!
|
190
|
+
>
|
191
|
+
> Anyone reading this, if you haven't checked them out yet, I highly recommend it.
|
192
|
+
|
193
|
+
And about a month later it was still holding up:
|
194
|
+
|
195
|
+
> Just checking in to say we've added like another 4 associated objects to production since my last message. `ActiveRecord::AssociatedObject` + `ActiveJob::Performs` is like a 1-2 punch super power. I'm a bit surprised that this isn't Rails core to be honest. I want to migrate so much of our code over to this. It feels much more organized and sane. Then my app/jobs folder won't have much in it because most jobs will actually be via some associated object's _later method. app/jobs will then basically be cron-type things (deactivate any expired subscriptions).
|
196
|
+
|
197
|
+
Let's look at testing, then we'll get to passing these POROs to jobs like the quotes mentioned!
|
198
|
+
|
199
|
+
### A Quick Aside: Testing Associated Objects
|
200
|
+
|
201
|
+
Follow the `app/models/post.rb` and `app/models/post/publisher.rb` naming structure in your tests and add `test/models/post/publisher_test.rb`.
|
202
|
+
|
203
|
+
Then test it like any other object:
|
204
|
+
|
205
|
+
```ruby
|
206
|
+
# test/models/post/publisher_test.rb
|
207
|
+
class Post::PublisherTest < ActiveSupport::TestCase
|
208
|
+
# You can use Fixtures/FactoryBot to get a `post` and then extract its `publisher`:
|
209
|
+
setup { @publisher = posts(:one).publisher }
|
210
|
+
setup { @publisher = FactoryBot.build(:post).publisher }
|
211
|
+
|
212
|
+
test "publish updates the post" do
|
213
|
+
@publisher.publish
|
214
|
+
assert @publisher.post.reload.published?
|
215
|
+
end
|
216
|
+
end
|
217
|
+
```
|
218
|
+
|
219
|
+
### Active Job integration via GlobalID
|
220
|
+
|
221
|
+
Associated Objects include `GlobalID::Identification` and have automatic Active Job serialization support that looks like this:
|
222
|
+
|
223
|
+
```ruby
|
224
|
+
class Post::Publisher < ActiveRecord::AssociatedObject
|
225
|
+
class PublishJob < ApplicationJob
|
226
|
+
def perform(publisher) = publisher.publish
|
227
|
+
end
|
228
|
+
|
229
|
+
def publish_later
|
230
|
+
PublishJob.perform_later self # We're passing this PORO to the job!
|
231
|
+
end
|
232
|
+
|
233
|
+
def publish
|
234
|
+
# …
|
235
|
+
end
|
236
|
+
end
|
237
|
+
```
|
238
|
+
|
239
|
+
> [!NOTE]
|
240
|
+
> Internally, Active Job serializes Active Records as GlobalIDs. Active Record also includes `GlobalID::Identification`, which requires the `find` and `where(id:)` class methods.
|
241
|
+
>
|
242
|
+
> We've added `Post::Publisher.find` & `Post::Publisher.where(id:)` that calls `Post.find(id).publisher` and `Post.where(id:).map(&:publisher)` respectively.
|
243
|
+
|
244
|
+
This pattern of a job `perform` consisting of calling an instance method on a sole domain object is ripe for a convention, here's how to do that.
|
245
|
+
|
246
|
+
#### Remove Active Job boilerplate with `performs`
|
79
247
|
|
80
248
|
If you also bundle [`active_job-performs`](https://github.com/kaspth/active_job-performs) in your Gemfile like this:
|
81
249
|
|
@@ -84,7 +252,7 @@ gem "active_job-performs"
|
|
84
252
|
gem "active_record-associated_object"
|
85
253
|
```
|
86
254
|
|
87
|
-
Every
|
255
|
+
Every Associated Object (and Active Records too) now has access to the `performs` macro, so you can do this:
|
88
256
|
|
89
257
|
```ruby
|
90
258
|
class Post::Publisher < ActiveRecord::AssociatedObject
|
@@ -100,7 +268,7 @@ class Post::Publisher < ActiveRecord::AssociatedObject
|
|
100
268
|
end
|
101
269
|
```
|
102
270
|
|
103
|
-
which
|
271
|
+
which spares you writing all this:
|
104
272
|
|
105
273
|
```ruby
|
106
274
|
class Post::Publisher < ActiveRecord::AssociatedObject
|
@@ -111,56 +279,87 @@ class Post::Publisher < ActiveRecord::AssociatedObject
|
|
111
279
|
|
112
280
|
# Individual method jobs inherit from the `Post::Publisher::Job` defined above.
|
113
281
|
class PublishJob < Job
|
114
|
-
|
115
|
-
|
116
|
-
publisher.publish(*arguments, **options)
|
117
|
-
end
|
282
|
+
# Here's the GlobalID integration again, i.e. we don't have to do `post.publisher`.
|
283
|
+
def perform(publisher, *, **) = publisher.publish(*, **)
|
118
284
|
end
|
119
285
|
|
120
286
|
class RetractJob < Job
|
121
|
-
def perform(publisher,
|
122
|
-
publisher.retract(*arguments, **options)
|
123
|
-
end
|
287
|
+
def perform(publisher, *, **) = publisher.retract(*, **)
|
124
288
|
end
|
125
289
|
|
126
|
-
def publish_later(
|
127
|
-
|
128
|
-
|
290
|
+
def publish_later(*, **) = PublishJob.perform_later(self, *, **)
|
291
|
+
def retract_later(*, **) = RetractJob.perform_later(self, *, **)
|
292
|
+
end
|
293
|
+
```
|
129
294
|
|
130
|
-
|
131
|
-
|
132
|
-
|
295
|
+
Note: you can also pass more complex configuration like this:
|
296
|
+
|
297
|
+
```ruby
|
298
|
+
performs :publish, queue_as: :important, discard_on: SomeError do
|
299
|
+
retry_on TimeoutError, wait: :exponentially_longer
|
133
300
|
end
|
134
301
|
```
|
135
302
|
|
136
|
-
See the `ActiveJob::Performs` README for more details.
|
303
|
+
See [the `ActiveJob::Performs` README](https://github.com/kaspth/active_job-performs) for more details.
|
137
304
|
|
138
|
-
###
|
305
|
+
### Automatic Kredis integration
|
139
306
|
|
140
|
-
`
|
307
|
+
We've got automatic Kredis integration for Associated Objects, so you can use any `kredis_*` type just like in Active Record classes:
|
141
308
|
|
142
309
|
```ruby
|
143
|
-
class Post < ActiveRecord::
|
144
|
-
|
145
|
-
|
310
|
+
class Post::Publisher < ActiveRecord::AssociatedObject
|
311
|
+
kredis_datetime :publish_at # Uses a namespaced "post:publishers:<post_id>:publish_at" key.
|
312
|
+
end
|
313
|
+
```
|
146
314
|
|
147
|
-
|
148
|
-
|
149
|
-
|
315
|
+
> [!NOTE]
|
316
|
+
> Under the hood, this reuses the same info we needed for automatic Active Job support. Namely, the Active Record class, here `Post`, and its `id`.
|
317
|
+
|
318
|
+
### Namespaced models
|
319
|
+
|
320
|
+
If you have a namespaced Active Record like this:
|
321
|
+
|
322
|
+
```ruby
|
323
|
+
# app/models/post/comment.rb
|
324
|
+
class Post::Comment < ApplicationRecord
|
325
|
+
belongs_to :post
|
326
|
+
belongs_to :creator, class_name: "User"
|
327
|
+
|
328
|
+
has_object :rating
|
150
329
|
end
|
330
|
+
```
|
151
331
|
|
152
|
-
|
153
|
-
|
154
|
-
|
332
|
+
You can define the associated object in the same way it was done for `Post::Publisher` above, within the `Post::Comment` namespace:
|
333
|
+
|
334
|
+
```ruby
|
335
|
+
# app/models/post/comment/rating.rb
|
336
|
+
class Post::Comment::Rating < ActiveRecord::AssociatedObject
|
337
|
+
def good?
|
338
|
+
# A `comment` method is generated to access the associated comment. There's also a `record` alias available.
|
339
|
+
comment.creator.subscriber_of? comment.post.creator
|
155
340
|
end
|
341
|
+
end
|
342
|
+
```
|
156
343
|
|
157
|
-
|
158
|
-
|
159
|
-
|
344
|
+
And then test it in `test/models/post/comment/rating_test.rb`:
|
345
|
+
|
346
|
+
```ruby
|
347
|
+
class Post::Comment::RatingTest < ActiveSupport::TestCase
|
348
|
+
setup { @rating = posts(:one).comments.first.rating }
|
349
|
+
setup { @rating = FactoryBot.build(:post_comment).rating }
|
350
|
+
|
351
|
+
test "pretty, pretty, pretty, pretty good" do
|
352
|
+
assert @rating.good?
|
160
353
|
end
|
161
354
|
end
|
162
355
|
```
|
163
356
|
|
357
|
+
### Composite primary keys
|
358
|
+
|
359
|
+
We support Active Record models with composite primary keys out of the box.
|
360
|
+
|
361
|
+
Just setup the associated objects like the above examples and you've got GlobalID/Active Job and Kredis support automatically.
|
362
|
+
|
164
363
|
## Risks of depending on this gem
|
165
364
|
|
166
365
|
This gem is relatively tiny and I'm not expecting more significant changes on it, for right now. It's unofficial and not affiliated with Rails core.
|
@@ -11,7 +11,7 @@ Gem::Specification.new do |spec|
|
|
11
11
|
spec.summary = "Associate a Ruby PORO with an Active Record class and have it quack like one."
|
12
12
|
spec.homepage = "https://github.com/kaspth/active_record-associated_object"
|
13
13
|
spec.license = "MIT"
|
14
|
-
spec.required_ruby_version = ">=
|
14
|
+
spec.required_ruby_version = ">= 3.0.0"
|
15
15
|
|
16
16
|
spec.metadata["homepage_uri"] = spec.homepage
|
17
17
|
spec.metadata["source_code_uri"] = spec.homepage
|
@@ -1,4 +1,6 @@
|
|
1
1
|
module ActiveRecord::AssociatedObject::ObjectAssociation
|
2
|
+
def self.included(klass) = klass.extend(ClassMethods)
|
3
|
+
|
2
4
|
using Module.new {
|
3
5
|
refine Module do
|
4
6
|
def extend_source_from(chunks, &block)
|
@@ -9,15 +11,22 @@ module ActiveRecord::AssociatedObject::ObjectAssociation
|
|
9
11
|
end
|
10
12
|
}
|
11
13
|
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
14
|
+
module ClassMethods
|
15
|
+
def has_object(*names, **callbacks)
|
16
|
+
extend_source_from(names) do |name|
|
17
|
+
"def #{name}; (@associated_objects ||= {})[:#{name}] ||= #{name.to_s.classify}.new(self); end"
|
18
|
+
end
|
16
19
|
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
+
extend_source_from(names) do |name|
|
21
|
+
callbacks.map do |callback, method|
|
22
|
+
"#{callback} { #{name}.#{method == true ? callback : method} }"
|
23
|
+
end
|
20
24
|
end
|
21
25
|
end
|
22
26
|
end
|
27
|
+
|
28
|
+
def init_internals
|
29
|
+
@associated_objects = nil
|
30
|
+
super
|
31
|
+
end
|
23
32
|
end
|
@@ -16,7 +16,7 @@ class ActiveRecord::AssociatedObject::Railtie < Rails::Railtie
|
|
16
16
|
|
17
17
|
ActiveSupport.on_load :active_record do
|
18
18
|
require "active_record/associated_object/object_association"
|
19
|
-
|
19
|
+
include ActiveRecord::AssociatedObject::ObjectAssociation
|
20
20
|
end
|
21
21
|
end
|
22
22
|
end
|
@@ -15,6 +15,10 @@ class ActiveRecord::AssociatedObject
|
|
15
15
|
klass.delegate :record_klass, :attribute_name, to: :class
|
16
16
|
end
|
17
17
|
|
18
|
+
def extension(&block)
|
19
|
+
record_klass.class_eval(&block)
|
20
|
+
end
|
21
|
+
|
18
22
|
def respond_to_missing?(...) = record_klass.respond_to?(...) || super
|
19
23
|
delegate :unscoped, :transaction, :primary_key, to: :record_klass
|
20
24
|
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: active_record-associated_object
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.7.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Kasper Timm Hansen
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2024-01-07 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activerecord
|
@@ -58,14 +58,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
58
58
|
requirements:
|
59
59
|
- - ">="
|
60
60
|
- !ruby/object:Gem::Version
|
61
|
-
version:
|
61
|
+
version: 3.0.0
|
62
62
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
63
63
|
requirements:
|
64
64
|
- - ">="
|
65
65
|
- !ruby/object:Gem::Version
|
66
66
|
version: '0'
|
67
67
|
requirements: []
|
68
|
-
rubygems_version: 3.
|
68
|
+
rubygems_version: 3.5.3
|
69
69
|
signing_key:
|
70
70
|
specification_version: 4
|
71
71
|
summary: Associate a Ruby PORO with an Active Record class and have it quack like
|