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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3b973496c3658defdee08fb40292bad58fadbc743a732cface722c40f8b8152c
4
- data.tar.gz: c6a3758599c0f5ce22470c18c23c958ebf05827a7f52ae0d07507cbd4b8b6346
3
+ metadata.gz: ca7c634684913b4f2f55b96e54036df0590fe963e06beb15bf98a641a450c5e1
4
+ data.tar.gz: 279780001730e8d339d15dfa3f6659be157169d74a1e4949132c5d7a2b8e596a
5
5
  SHA512:
6
- metadata.gz: 57db29d169ee310087c539e1eb64ccce3bdeb6a98bdb7532723db4f90656140d521c9925efb3aa17782cd84b3e1906dfaaf0cd6dd72d69ad54e90b159a423547
7
- data.tar.gz: dca66954be7f3e84ea76010e7714366532c506eb3ab02c1174bd47122ecce0af5223656ee11ed425d962d86b5a443ce69b95a187d40318ff555f497103df88ff
6
+ metadata.gz: e4b86186ef2aa86f31535313a22adf2835694829ba3a53c3d6b5847429ab5a002c3e09f36e3718f756370ca12f3d335b7851b42a8939d234f8de00d71970e255
7
+ data.tar.gz: ff2ac40c52831018085167aa42f9212b4cecdd7cb5dac199d9f258278553752b7cf3b40df2295d240df65bd6cc685981dd82d710b947d32b8192b5b8d39ee3eb
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- active_record-associated_object (0.5.2)
4
+ active_record-associated_object (0.6.0)
5
5
  activerecord (>= 6.1)
6
6
 
7
7
  GEM
data/README.md CHANGED
@@ -1,81 +1,180 @@
1
1
  # ActiveRecord::AssociatedObject
2
2
 
3
- Associate a Ruby PORO with an Active Record class and have it quack like one. Build and extend your domain model relying on the Active Record association to make it unique.
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
- ## Usage
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
- # app/models/post.rb
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
- If you want Active Job, GlobalID and Kredis integration you can also have `Post::Publisher` inherit from `ActiveRecord::AssociatedObject`. This extends the standard PORO with details from the `Post::` namespace and the post primary key.
34
+ And then you can declare it in `Post`:
23
35
 
24
36
  ```ruby
25
- # app/models/post/publisher.rb
26
- class Post::Publisher < ActiveRecord::AssociatedObject
27
- # ActiveRecord::AssociatedObject defines initialize(post) automatically. It's derived from the `Post::` namespace.
37
+ # app/models/post.rb
38
+ class Post < ApplicationRecord
39
+ has_object :publisher
40
+ end
41
+ ```
28
42
 
29
- kredis_datetime :publish_at # Kredis integration generates a "post:publishers:<post_id>:publish_at" key.
43
+ There isn't anything super special happening yet. Here's essentially what's happening under the hood:
30
44
 
31
- # `performs` builds a `Post::Publisher::PublishJob` and routes configs over to it.
32
- performs :publish, queue_as: :important, discard_on: SomeError do
33
- retry_on TimeoutError, wait: :exponentially_longer
34
- end
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
- ### Namespaced models
79
+ ### Forwarding callbacks onto the associated object
48
80
 
49
- If you have a namespaced Active Record like this:
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
- # app/models/post/comment.rb
53
- class Post::Comment < ApplicationRecord
54
- belongs_to :post
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
- has_object :rating
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
- You can define the associated object in the same way it was done for `Post::Publisher` above, within the `Post::Comment` namespace:
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
- # app/models/post/comment/rating.rb
64
- class Post::Comment::Rating < ActiveRecord::AssociatedObject
65
- def great?
66
- # A `comment` method is generated to access the associated comment. There's also a `record` alias available.
67
- comment.author.subscriber_of? comment.post.author
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
- ### Composite primary keys
150
+ ### Active Job integration via GlobalID
73
151
 
74
- We support Active Record models with composite primary keys out of the box.
152
+ Associated Objects include `GlobalID::Identification` and have automatic Active Job serialization support that looks like this:
75
153
 
76
- Just setup the associated objects like the above examples and you've got GlobalID/Active Job and Kredis support automatically.
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
- ### Remove Active Job boilerplate with `performs`
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 associated object now has access to the `performs` macro, so you can do this:
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 is equivalent to this:
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
- def perform(publisher, *arguments, **options)
115
- # GlobalID integration means associated objects can be passed into jobs like Active Records, i.e. we don't have to do `post.publisher`.
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, *arguments, **options)
122
- publisher.retract(*arguments, **options)
123
- end
218
+ def perform(publisher, *, **) = publisher.retract(*, **)
124
219
  end
125
220
 
126
- def publish_later(*arguments, **options)
127
- PublishJob.perform_later(self, *arguments, **options)
128
- end
221
+ def publish_later(*, **) = PublishJob.perform_later(self, *, **)
222
+ def retract_later(*, **) = RetractJob.perform_later(self, *, **)
223
+ end
224
+ ```
129
225
 
130
- def retract_later(*arguments, **options)
131
- RetractJob.perform_later(self, *arguments, **options)
132
- end
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
- ### Passing callbacks onto the associated object
236
+ ### Namespaced models
139
237
 
140
- `has_object` accepts a hash of callbacks to pass.
238
+ If you have a namespaced Active Record like this:
141
239
 
142
240
  ```ruby
143
- class Post < ActiveRecord::Base
144
- # Callbacks can be passed too to a specific method.
145
- has_object :publisher, after_touch: true, before_destroy: :prevent_errant_post_destroy
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
- # The above is the same as writing:
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
- class Post::Publisher < ActiveRecord::AssociatedObject
153
- def after_touch
154
- # Respond to the after_touch on the Post.
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
- def prevent_errant_post_destroy
158
- # Passed callbacks can throw :abort too, and in this example prevent post.destroy.
159
- throw :abort if haha_business?
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 = ">= 2.7.0"
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module ActiveRecord
4
4
  class AssociatedObject
5
- VERSION = "0.5.2"
5
+ VERSION = "0.6.0"
6
6
  end
7
7
  end
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.5.2
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-13 00:00:00.000000000 Z
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: 2.7.0
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.19
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