active_record-associated_object 0.5.2 → 0.6.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 +1 -1
- data/README.md +182 -65
- data/active_record-associated_object.gemspec +1 -1
- data/lib/active_record/associated_object/version.rb +1 -1
- 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: ca7c634684913b4f2f55b96e54036df0590fe963e06beb15bf98a641a450c5e1
|
4
|
+
data.tar.gz: 279780001730e8d339d15dfa3f6659be157169d74a1e4949132c5d7a2b8e596a
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: e4b86186ef2aa86f31535313a22adf2835694829ba3a53c3d6b5847429ab5a002c3e09f36e3718f756370ca12f3d335b7851b42a8939d234f8de00d71970e255
|
7
|
+
data.tar.gz: ff2ac40c52831018085167aa42f9212b4cecdd7cb5dac199d9f258278553752b7cf3b40df2295d240df65bd6cc685981dd82d710b947d32b8192b5b8d39ee3eb
|
data/Gemfile.lock
CHANGED
data/README.md
CHANGED
@@ -1,81 +1,180 @@
|
|
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:
|
6
13
|
|
7
14
|
```ruby
|
8
|
-
|
9
|
-
class Post < ActiveRecord::Base
|
10
|
-
# `has_object` defines a `publisher` method that calls Post::Publisher.new(post).
|
11
|
-
has_object :publisher
|
15
|
+
class Post < ApplicationRecord
|
12
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.
|
13
25
|
|
26
|
+
This is what Associated Objects are! We'd define it like this:
|
27
|
+
|
28
|
+
```ruby
|
14
29
|
# app/models/post/publisher.rb
|
15
|
-
class Post::Publisher
|
16
|
-
def initialize(post)
|
17
|
-
@post = post
|
18
|
-
end
|
30
|
+
class Post::Publisher < ActiveRecord::AssociatedObject
|
19
31
|
end
|
20
32
|
```
|
21
33
|
|
22
|
-
|
34
|
+
And then you can declare it in `Post`:
|
23
35
|
|
24
36
|
```ruby
|
25
|
-
# app/models/post
|
26
|
-
class Post
|
27
|
-
|
37
|
+
# app/models/post.rb
|
38
|
+
class Post < ApplicationRecord
|
39
|
+
has_object :publisher
|
40
|
+
end
|
41
|
+
```
|
28
42
|
|
29
|
-
|
43
|
+
There isn't anything super special happening yet. Here's essentially what's happening under the hood:
|
30
44
|
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
45
|
+
```ruby
|
46
|
+
class Post::Publisher
|
47
|
+
attr_reader :post
|
48
|
+
def initialize(post) = @post = post
|
49
|
+
end
|
50
|
+
|
51
|
+
class Post < ApplicationRecord
|
52
|
+
def publisher = @publisher ||= Post::Publisher.new(self)
|
53
|
+
end
|
54
|
+
```
|
55
|
+
|
56
|
+
> [!TIP]
|
57
|
+
> `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`.
|
58
|
+
|
59
|
+
See how we're always expecting a link to the model, here `post`?
|
35
60
|
|
61
|
+
Because of that, you can rely on `post` from the associated object:
|
62
|
+
|
63
|
+
```ruby
|
64
|
+
class Post::Publisher < ActiveRecord::AssociatedObject
|
36
65
|
def publish
|
37
66
|
# `transaction` is syntactic sugar for `post.transaction` here.
|
38
67
|
transaction do
|
39
|
-
# A `post` method is generated to access the associated post. There's also a `record` alias available.
|
40
68
|
post.update! published: true
|
41
69
|
post.subscribers.post_published post
|
70
|
+
|
71
|
+
# There's also a `record` alias available if you prefer the more general reading version:
|
72
|
+
# record.update! published: true
|
73
|
+
# record.subscribers.post_published record
|
42
74
|
end
|
43
75
|
end
|
44
76
|
end
|
45
77
|
```
|
46
78
|
|
47
|
-
###
|
79
|
+
### Forwarding callbacks onto the associated object
|
48
80
|
|
49
|
-
|
81
|
+
To further help illustrate how your collaborator Associated Objects interact with your domain model, you can forward callbacks.
|
82
|
+
|
83
|
+
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?
|
84
|
+
|
85
|
+
So `has_object` can state this and forward those callbacks onto the Associated Object:
|
50
86
|
|
51
87
|
```ruby
|
52
|
-
|
53
|
-
|
54
|
-
|
88
|
+
class Post < ActiveRecord::Base
|
89
|
+
# Passing `true`
|
90
|
+
has_object :publisher, after_create_commit: :publish,
|
91
|
+
after_touch: true, before_destroy: :prevent_errant_post_destroy
|
55
92
|
|
56
|
-
|
93
|
+
# The above is the same as writing:
|
94
|
+
after_create_commit { publisher.publish }
|
95
|
+
after_touch { publisher.after_touch }
|
96
|
+
before_destroy { publisher.prevent_errant_post_destroy }
|
97
|
+
end
|
98
|
+
|
99
|
+
class Post::Publisher < ActiveRecord::AssociatedObject
|
100
|
+
def publish
|
101
|
+
end
|
102
|
+
|
103
|
+
def after_touch
|
104
|
+
# Respond to the after_touch on the Post.
|
105
|
+
end
|
106
|
+
|
107
|
+
def prevent_errant_post_destroy
|
108
|
+
# Passed callbacks can throw :abort too, and in this example prevent post.destroy.
|
109
|
+
throw :abort if haha_business?
|
110
|
+
end
|
57
111
|
end
|
58
112
|
```
|
59
113
|
|
60
|
-
|
114
|
+
### Primary Benefit: Organization through Convention
|
115
|
+
|
116
|
+
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.
|
117
|
+
|
118
|
+
This is what [@natematykiewicz](https://github.com/natematykiewicz) found when they started using the gem (we'll get to `ActiveJob::Performs` soon):
|
119
|
+
|
120
|
+
> 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!
|
121
|
+
>
|
122
|
+
> Anyone reading this, if you haven't checked them out yet, I highly recommend it.
|
123
|
+
|
124
|
+
And about a month later it was still holding up:
|
125
|
+
|
126
|
+
> 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).
|
127
|
+
|
128
|
+
Let's look at testing, then we'll get to passing these POROs to jobs like the quotes mentioned!
|
129
|
+
|
130
|
+
### A Quick Aside: Testing Associated Objects
|
131
|
+
|
132
|
+
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`.
|
133
|
+
|
134
|
+
Then test it like any other object:
|
61
135
|
|
62
136
|
```ruby
|
63
|
-
#
|
64
|
-
class Post::
|
65
|
-
|
66
|
-
|
67
|
-
|
137
|
+
# test/models/post/publisher_test.rb
|
138
|
+
class Post::PublisherTest < ActiveSupport::TestCase
|
139
|
+
# You can use Fixtures/FactoryBot to get a `post` and then extract its `publisher`:
|
140
|
+
setup { @publisher = posts(:one).publisher }
|
141
|
+
setup { @publisher = FactoryBot.build(:post).publisher }
|
142
|
+
|
143
|
+
test "publish updates the post" do
|
144
|
+
@publisher.publish
|
145
|
+
assert @publisher.post.reload.published?
|
68
146
|
end
|
69
147
|
end
|
70
148
|
```
|
71
149
|
|
72
|
-
###
|
150
|
+
### Active Job integration via GlobalID
|
73
151
|
|
74
|
-
|
152
|
+
Associated Objects include `GlobalID::Identification` and have automatic Active Job serialization support that looks like this:
|
75
153
|
|
76
|
-
|
154
|
+
```ruby
|
155
|
+
class Post::Publisher < ActiveRecord::AssociatedObject
|
156
|
+
class PublishJob < ApplicationJob
|
157
|
+
def perform(publisher) = publisher.publish
|
158
|
+
end
|
159
|
+
|
160
|
+
def publish_later
|
161
|
+
PublishJob.perform_later self # We're passing this PORO to the job!
|
162
|
+
end
|
163
|
+
|
164
|
+
def publish
|
165
|
+
# …
|
166
|
+
end
|
167
|
+
end
|
168
|
+
```
|
77
169
|
|
78
|
-
|
170
|
+
> [!NOTE]
|
171
|
+
> Internally, Active Job serializes Active Records as GlobalIDs. Active Record also includes `GlobalID::Identification`, which requires the `find` and `where(id:)` class methods.
|
172
|
+
>
|
173
|
+
> We've added `Post::Publisher.find` & `Post::Publisher.where(id:)` that calls `Post.find(id).publisher` and `Post.where(id:).map(&:publisher)` respectively.
|
174
|
+
|
175
|
+
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.
|
176
|
+
|
177
|
+
#### Remove Active Job boilerplate with `performs`
|
79
178
|
|
80
179
|
If you also bundle [`active_job-performs`](https://github.com/kaspth/active_job-performs) in your Gemfile like this:
|
81
180
|
|
@@ -84,7 +183,7 @@ gem "active_job-performs"
|
|
84
183
|
gem "active_record-associated_object"
|
85
184
|
```
|
86
185
|
|
87
|
-
Every
|
186
|
+
Every Associated Object (and Active Records too) now has access to the `performs` macro, so you can do this:
|
88
187
|
|
89
188
|
```ruby
|
90
189
|
class Post::Publisher < ActiveRecord::AssociatedObject
|
@@ -100,7 +199,7 @@ class Post::Publisher < ActiveRecord::AssociatedObject
|
|
100
199
|
end
|
101
200
|
```
|
102
201
|
|
103
|
-
which
|
202
|
+
which spares you writing all this:
|
104
203
|
|
105
204
|
```ruby
|
106
205
|
class Post::Publisher < ActiveRecord::AssociatedObject
|
@@ -111,56 +210,74 @@ class Post::Publisher < ActiveRecord::AssociatedObject
|
|
111
210
|
|
112
211
|
# Individual method jobs inherit from the `Post::Publisher::Job` defined above.
|
113
212
|
class PublishJob < Job
|
114
|
-
|
115
|
-
|
116
|
-
publisher.publish(*arguments, **options)
|
117
|
-
end
|
213
|
+
# Here's the GlobalID integration again, i.e. we don't have to do `post.publisher`.
|
214
|
+
def perform(publisher, *, **) = publisher.publish(*, **)
|
118
215
|
end
|
119
216
|
|
120
217
|
class RetractJob < Job
|
121
|
-
def perform(publisher,
|
122
|
-
publisher.retract(*arguments, **options)
|
123
|
-
end
|
218
|
+
def perform(publisher, *, **) = publisher.retract(*, **)
|
124
219
|
end
|
125
220
|
|
126
|
-
def publish_later(
|
127
|
-
|
128
|
-
|
221
|
+
def publish_later(*, **) = PublishJob.perform_later(self, *, **)
|
222
|
+
def retract_later(*, **) = RetractJob.perform_later(self, *, **)
|
223
|
+
end
|
224
|
+
```
|
129
225
|
|
130
|
-
|
131
|
-
|
132
|
-
|
226
|
+
Note: you can also pass more complex configuration like this:
|
227
|
+
|
228
|
+
```ruby
|
229
|
+
performs :publish, queue_as: :important, discard_on: SomeError do
|
230
|
+
retry_on TimeoutError, wait: :exponentially_longer
|
133
231
|
end
|
134
232
|
```
|
135
233
|
|
136
234
|
See the `ActiveJob::Performs` README for more details.
|
137
235
|
|
138
|
-
###
|
236
|
+
### Namespaced models
|
139
237
|
|
140
|
-
|
238
|
+
If you have a namespaced Active Record like this:
|
141
239
|
|
142
240
|
```ruby
|
143
|
-
|
144
|
-
|
145
|
-
|
241
|
+
# app/models/post/comment.rb
|
242
|
+
class Post::Comment < ApplicationRecord
|
243
|
+
belongs_to :post
|
244
|
+
belongs_to :creator, class_name: "User"
|
146
245
|
|
147
|
-
|
148
|
-
after_touch { publisher.after_touch }
|
149
|
-
before_destroy { publisher.prevent_errant_post_destroy }
|
246
|
+
has_object :rating
|
150
247
|
end
|
248
|
+
```
|
151
249
|
|
152
|
-
|
153
|
-
|
154
|
-
|
250
|
+
You can define the associated object in the same way it was done for `Post::Publisher` above, within the `Post::Comment` namespace:
|
251
|
+
|
252
|
+
```ruby
|
253
|
+
# app/models/post/comment/rating.rb
|
254
|
+
class Post::Comment::Rating < ActiveRecord::AssociatedObject
|
255
|
+
def good?
|
256
|
+
# A `comment` method is generated to access the associated comment. There's also a `record` alias available.
|
257
|
+
comment.creator.subscriber_of? comment.post.creator
|
155
258
|
end
|
259
|
+
end
|
260
|
+
```
|
156
261
|
|
157
|
-
|
158
|
-
|
159
|
-
|
262
|
+
And then test it in `test/models/post/comment/rating_test.rb`:
|
263
|
+
|
264
|
+
```ruby
|
265
|
+
class Post::Comment::RatingTest < ActiveSupport::TestCase
|
266
|
+
setup { @rating = posts(:one).comments.first.rating }
|
267
|
+
setup { @rating = FactoryBot.build(:post_comment).rating }
|
268
|
+
|
269
|
+
test "pretty, pretty, pretty, pretty good" do
|
270
|
+
assert @rating.good?
|
160
271
|
end
|
161
272
|
end
|
162
273
|
```
|
163
274
|
|
275
|
+
### Composite primary keys
|
276
|
+
|
277
|
+
We support Active Record models with composite primary keys out of the box.
|
278
|
+
|
279
|
+
Just setup the associated objects like the above examples and you've got GlobalID/Active Job and Kredis support automatically.
|
280
|
+
|
164
281
|
## Risks of depending on this gem
|
165
282
|
|
166
283
|
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
|
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.6.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: 2023-
|
11
|
+
date: 2023-12-18 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.4.
|
68
|
+
rubygems_version: 3.4.22
|
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
|