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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3b973496c3658defdee08fb40292bad58fadbc743a732cface722c40f8b8152c
4
- data.tar.gz: c6a3758599c0f5ce22470c18c23c958ebf05827a7f52ae0d07507cbd4b8b6346
3
+ metadata.gz: d24bf37946a6b4e5ddcb1626e90eb91e27932ae8d9f8f29c10c095ce3c53897c
4
+ data.tar.gz: 7f7d23788109d020fb1b010e8e6f3956880dfeb012c517dfc43e1f5c7862fd6c
5
5
  SHA512:
6
- metadata.gz: 57db29d169ee310087c539e1eb64ccce3bdeb6a98bdb7532723db4f90656140d521c9925efb3aa17782cd84b3e1906dfaaf0cd6dd72d69ad54e90b159a423547
7
- data.tar.gz: dca66954be7f3e84ea76010e7714366532c506eb3ab02c1174bd47122ecce0af5223656ee11ed425d962d86b5a443ce69b95a187d40318ff555f497103df88ff
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.5.2)
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.1)
11
- actionview (= 7.1.1)
12
- activesupport (= 7.1.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.1)
20
- activesupport (= 7.1.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.2.0)
26
+ active_job-performs (0.3.0)
26
27
  activejob (>= 6.1)
27
- activejob (7.1.1)
28
- activesupport (= 7.1.1)
28
+ activejob (7.1.2)
29
+ activesupport (= 7.1.2)
29
30
  globalid (>= 0.3.6)
30
- activemodel (7.1.1)
31
- activesupport (= 7.1.1)
32
- activerecord (7.1.1)
33
- activemodel (= 7.1.1)
34
- activesupport (= 7.1.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.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.1.1)
47
- bigdecimal (3.1.4)
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.8.0)
53
- irb (>= 1.5.0)
54
- reline (>= 0.3.1)
55
- drb (2.1.1)
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.6.0)
63
- irb (1.8.3)
63
+ io-console (0.7.1)
64
+ irb (1.11.0)
64
65
  rdoc
65
66
  reline (>= 0.3.8)
66
- kredis (1.6.0)
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.21.4)
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.1.2)
77
- nokogiri (1.15.4-arm64-darwin)
77
+ mutex_m (0.2.0)
78
+ nokogiri (1.16.0-arm64-darwin)
78
79
  racc (~> 1.4)
79
- nokogiri (1.15.4-x86_64-linux)
80
+ nokogiri (1.16.0-x86_64-linux)
80
81
  racc (~> 1.4)
81
82
  path_expander (1.1.1)
82
- psych (5.1.1.1)
83
+ psych (5.1.2)
83
84
  stringio
84
- racc (1.7.1)
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.1)
101
- actionpack (= 7.1.1)
102
- activesupport (= 7.1.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.5.0)
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.18.0)
114
+ redis-client (0.19.1)
114
115
  connection_pool
115
- reline (0.3.9)
116
+ reline (0.4.2)
116
117
  io-console (~> 0.5)
117
118
  ruby2_keywords (0.0.5)
118
- sqlite3 (1.6.7-arm64-darwin)
119
- sqlite3 (1.6.7-x86_64-linux)
120
- stringio (3.0.8)
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.0)
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.21
149
+ 2.5.4
data/README.md CHANGED
@@ -1,81 +1,249 @@
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:
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 < ActiveRecord::Base
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
- # app/models/post/publisher.rb
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
- def initialize(post)
17
- @post = post
18
- end
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
- 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.
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
- ```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.
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
- kredis_datetime :publish_at # Kredis integration generates a "post:publishers:<post_id>:publish_at" key.
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
- # `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
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
- ### Namespaced models
85
+ ### Forwarding callbacks onto the associated object
48
86
 
49
- If you have a namespaced Active Record like this:
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
- # app/models/post/comment.rb
53
- class Post::Comment < ApplicationRecord
54
- belongs_to :post
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
- has_object :rating
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
- You can define the associated object in the same way it was done for `Post::Publisher` above, within the `Post::Comment` namespace:
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
- # 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
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
- ### Composite primary keys
146
+ This is meant as an alternative to having a wrapping `ActiveSupport::Concern` in yet-another file like this:
73
147
 
74
- We support Active Record models with composite primary keys out of the box.
148
+ ```ruby
149
+ class Post < ApplicationRecord
150
+ include Published
151
+ end
75
152
 
76
- Just setup the associated objects like the above examples and you've got GlobalID/Active Job and Kredis support automatically.
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
- ### Remove Active Job boilerplate with `performs`
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 associated object now has access to the `performs` macro, so you can do this:
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 is equivalent to this:
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
- 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
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, *arguments, **options)
122
- publisher.retract(*arguments, **options)
123
- end
287
+ def perform(publisher, *, **) = publisher.retract(*, **)
124
288
  end
125
289
 
126
- def publish_later(*arguments, **options)
127
- PublishJob.perform_later(self, *arguments, **options)
128
- end
290
+ def publish_later(*, **) = PublishJob.perform_later(self, *, **)
291
+ def retract_later(*, **) = RetractJob.perform_later(self, *, **)
292
+ end
293
+ ```
129
294
 
130
- def retract_later(*arguments, **options)
131
- RetractJob.perform_later(self, *arguments, **options)
132
- end
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
- ### Passing callbacks onto the associated object
305
+ ### Automatic Kredis integration
139
306
 
140
- `has_object` accepts a hash of callbacks to pass.
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::Base
144
- # Callbacks can be passed too to a specific method.
145
- has_object :publisher, after_touch: true, before_destroy: :prevent_errant_post_destroy
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
- # The above is the same as writing:
148
- after_touch { publisher.after_touch }
149
- before_destroy { publisher.prevent_errant_post_destroy }
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
- class Post::Publisher < ActiveRecord::AssociatedObject
153
- def after_touch
154
- # Respond to the after_touch on the Post.
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
- 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?
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 = ">= 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
@@ -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
- def has_object(*names, **callbacks)
13
- extend_source_from(names) do |name|
14
- "def #{name}; @#{name} ||= #{self.name}::#{name.to_s.classify}.new(self); end"
15
- end
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
- extend_source_from(names) do |name|
18
- callbacks.map do |callback, method|
19
- "#{callback} { #{name}.#{method == true ? callback : method} }"
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
- extend ActiveRecord::AssociatedObject::ObjectAssociation
19
+ include ActiveRecord::AssociatedObject::ObjectAssociation
20
20
  end
21
21
  end
22
22
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module ActiveRecord
4
4
  class AssociatedObject
5
- VERSION = "0.5.2"
5
+ VERSION = "0.7.0"
6
6
  end
7
7
  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.5.2
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: 2023-11-13 00:00:00.000000000 Z
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: 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.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