active_record-associated_object 0.8.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5816013890fc0f6868149661030643f47028edbb6a188d5014e7fd699e0138bd
4
- data.tar.gz: ef0c4be8afb96727e5f379c988632ec44f0f77d105b8aadde944ac48362b7385
3
+ metadata.gz: 1876318d0052119a7e4080257d42bff11fee22385ac25a5d78023ce958d46fb9
4
+ data.tar.gz: d1089ada8474d59399331a71ec0815afd5a4a792191530fd273d25f0f328a916
5
5
  SHA512:
6
- metadata.gz: 5f80f20322bf4661515fb6235c9d034e7ac68a22bc17e269971c8b2a1f5782affb28471316b65402bab66deedc9f63f322032fa216100c6966cd514c7421a100
7
- data.tar.gz: 9def815025bf60d33b06ba6f7c9c185a59fca016f3e88a72d65fb64408dd0b4e7a34aea63c5a75de61004a64a80b86bd26e368ed021d105ed32bf6cca3a3f037
6
+ metadata.gz: f8200ebd8b229a4077af91fe9ab7b3af686de67c844a208cf843b8d828cb1d5a19fd65560bf4d911a02a67525f08541e31ad21d701fdb452f7a0113a01792cd3
7
+ data.tar.gz: d88d3a02dc2e9fbecef0a1060d1bdb35798ca8280ff860800e1c89219247b815e3ff16a7d7c5b0c193fa1474900b18aa96b23ce6ffe89c1c1adb68d135095f2d
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- active_record-associated_object (0.8.2)
4
+ active_record-associated_object (0.9.0)
5
5
  activerecord (>= 6.1)
6
6
 
7
7
  GEM
data/README.md CHANGED
@@ -60,7 +60,7 @@ We've fixed this so you don't need to care, but this is what's happening.
60
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`.
61
61
 
62
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.
63
+ > You can pass multiple names too: `has_object :seats, :entitlements, :publisher, :classified, :fortification`. I recommend `-s`, `-[i]er`, `-[i]ed` and `-ion` as the general naming conventions for your Associated Objects.
64
64
 
65
65
  > [!TIP]
66
66
  > Plural Associated Object names are also supported: `Account.has_object :seats` will look up `Account::Seats`.
@@ -87,6 +87,17 @@ end
87
87
 
88
88
  ### See Associated Objects in action
89
89
 
90
+ #### RubyVideo.dev
91
+
92
+ The team at https://www.rubyvideo.dev has been using Associated Objects to clarify the boundaries of their Active Records and collaborator Associated Objects.
93
+
94
+ See the usage in the source here:
95
+
96
+ - [`ActiveRecord::AssociatedObject` instances](https://github.com/search?q=repo%3Aadrienpoly%2Frubyvideo%20ActiveRecord%3A%3AAssociatedObject&type=code)
97
+ - [`has_object` calls](https://github.com/search?q=repo%3Aadrienpoly%2Frubyvideo+has_object&type=code)
98
+
99
+ #### Flipper
100
+
90
101
  The team at [Flipper](https://www.flippercloud.io) used Associated Objects to help keep their new billing structure clean.
91
102
 
92
103
  You can see real life examples in these blog posts:
@@ -140,7 +151,17 @@ end
140
151
  ### Extending the Active Record from within the Associated Object
141
152
 
142
153
  Since `has_object` eager-loads the Associated Object class, you can also move
143
- any integrating code into a provided `extension` block:
154
+ any integrating code into the Associated Object.
155
+
156
+ If you've got a few extensions, you can use `record` to access the Active Record class:
157
+
158
+ ```ruby
159
+ class Post::Publisher < ActiveRecord::AssociatedObject
160
+ record.has_many :contracts, dependent: :destroy # `record` returns `Post` here.
161
+ end
162
+ ```
163
+
164
+ Alternatively, if you have many extensions, use the `extension` block:
144
165
 
145
166
  > [!NOTE]
146
167
  > Technically, `extension` is just `Post.class_eval` but with syntactic sugar.
@@ -220,7 +241,7 @@ Here's what [@nshki](https://github.com/nshki) found when they tried it:
220
241
 
221
242
  Let's look at testing, then we'll get to passing these POROs to jobs like the quotes mentioned!
222
243
 
223
- ### A Quick Aside: Testing Associated Objects
244
+ ### Testing Associated Objects
224
245
 
225
246
  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`.
226
247
 
@@ -240,6 +261,149 @@ class Post::PublisherTest < ActiveSupport::TestCase
240
261
  end
241
262
  ```
242
263
 
264
+ ### Active Model integration
265
+
266
+ Associated Objects quack like `ActiveModel`s because we:
267
+
268
+ - [`extend ActiveModel::Naming`](https://api.rubyonrails.org/classes/ActiveModel/Naming.html)
269
+ - [`include ActiveModel::Conversion`](https://api.rubyonrails.org/classes/ActiveModel/Conversion.html)
270
+
271
+ This means you can pass them to helpers like `form_with` and route helpers like `url_for` too.
272
+
273
+ > [!NOTE]
274
+ > We don't `include ActiveModel::Model` since we don't need `assign_attributes` and validations really.
275
+
276
+ ```ruby
277
+ # app/controllers/post/publishers_controller.rb
278
+ class Post::PublishersController < ApplicationController
279
+ before_action :set_publisher
280
+
281
+ def new
282
+ end
283
+
284
+ def create
285
+ @publisher.publish params.expect(publisher: :toast)
286
+ redirect_back_or_to root_url, notice: "Out it goes!"
287
+ end
288
+
289
+ private
290
+ def set_publisher
291
+ # Associated Objects are POROs, so behind the scenes we're really doing `Post.find(…).publisher`.
292
+ @publisher = Post::Publisher.find(params[:id])
293
+ end
294
+ end
295
+ ```
296
+
297
+ And then on the view side, you can pass it into `form_with`:
298
+
299
+ ```erb
300
+ <%# app/views/post/publishers/new.html.erb %>
301
+ <%# Here `form_with` calls `url_for(@publisher)` which calls `post_publisher_path(@publisher)`. %>
302
+ <%= form_with model: @publisher do |form| %>
303
+ <%= form.text_field :toast %>
304
+ <%= form.submit "Publish with toast" %>
305
+ <% end %>
306
+ ```
307
+
308
+ Finally, the routing is pretty standard fare:
309
+
310
+ ```ruby
311
+ namespace :post do
312
+ resources :publishers
313
+ end
314
+ ```
315
+
316
+ #### Rendering Associated Objects
317
+
318
+ Associated Objects respond to `to_partial_path`, so you can pass them directly to `render`.
319
+
320
+ We're using Rails' conventions here, so view paths look like this:
321
+
322
+ ```erb
323
+ <%# With a Post::Publisher, this renders app/views/post/publishers/_publisher.html.erb %>
324
+ <%= render publisher %>
325
+
326
+ <%# With a Post::Comment::Rating, this renders app/views/post/comment/ratings/_rating.html.erb %>
327
+ <%= render rating %>
328
+ ```
329
+
330
+ We've also got full support for fragment caching, so this is possible:
331
+
332
+ ```erb
333
+ <%# app/views/post/publishers/_publisher.html.erb %>
334
+ <%= cache publisher do %>
335
+ <%# More publishing specific view logic. %>
336
+ <% end %>
337
+ ```
338
+
339
+ > [!NOTE]
340
+ > We only support recyclable cache keys which has been the default since Rails 5.2.
341
+ > This means the Active Record you associate with must have `SomeModel.cache_versioning = true` enabled.
342
+ >
343
+ > Associated Objects respond to `cache_key`, `cache_version` and `cache_key_with_version` like Active Records.
344
+
345
+ ### Polymorphic Associated Objects
346
+
347
+ If you want to share logic between associated objects, you can do so via standard Ruby modules:
348
+
349
+ ```ruby
350
+ # app/models/pricing.rb
351
+ module Pricing
352
+ # If you need to share an `extension` across associated objects you can override `Module::included` like this:
353
+ def self.included(object) = object.extension do
354
+ # Add common integration methods onto `Account`/`User` when the module is included.
355
+ # See the `extension` block in the `Extending` section above for an example.
356
+ end
357
+
358
+ def price_set?
359
+ # Instead of referring to `account` or `user`, use the `record` method to target either.
360
+ record.price_cents.positive?
361
+ end
362
+ end
363
+
364
+ # app/models/account/pricing.rb
365
+ class Account::Pricing < ActiveRecord::AssociatedObject
366
+ include ::Pricing
367
+ end
368
+
369
+ # app/models/user/pricing.rb
370
+ class User::Pricing < ActiveRecord::AssociatedObject
371
+ include ::Pricing
372
+ end
373
+ ```
374
+
375
+ Now we can call `account.pricing.price_set?` & `user.pricing.price_set?`.
376
+
377
+ > [!NOTE]
378
+ > Polymorphic Associated Objects are definitely a more advanced topic,
379
+ > so you need to know your Ruby module hierarchy and how to track what `self` changes to fairly well.
380
+
381
+ #### Using `ActiveSupport::Concern` as an alternative
382
+
383
+ If you prefer the look of Active Support concerns, here's the equivalent to the above Ruby module:
384
+
385
+ ```ruby
386
+ # app/models/pricing.rb
387
+ module Pricing
388
+ extend ActiveSupport::Concern
389
+
390
+ included do
391
+ extension do
392
+ # Add common integration methods onto `Account`/`User` when the concern is included.
393
+ end
394
+ end
395
+
396
+ def price_set?
397
+ # Instead of referring to `account` or `user`, use the `record` method to target either.
398
+ record.price_cents.positive?
399
+ end
400
+ end
401
+ ```
402
+
403
+ Active Support concerns have some extra features that standard Ruby modules don't, like support for deeply-nested concerns and `class_methods do`.
404
+
405
+ In this case, if you're reaching for those, you're probably building something too intricate and potentially brittle.
406
+
243
407
  ### Active Job integration via GlobalID
244
408
 
245
409
  Associated Objects include `GlobalID::Identification` and have automatic Active Job serialization support that looks like this:
@@ -6,14 +6,14 @@ class ActiveRecord::AssociatedObject::Railtie < Rails::Railtie
6
6
  end
7
7
  end
8
8
 
9
- initializer "object_association.setup" do
10
- ActiveSupport.on_load :active_job do
11
- require "active_job/performs"
12
- ActiveRecord::AssociatedObject.extend ActiveJob::Performs
13
- rescue LoadError
14
- # We haven't bundled active_job-performs, so we're continuing without it.
15
- end
9
+ initializer "active_job.performs" do
10
+ require "active_job/performs"
11
+ ActiveRecord::AssociatedObject.extend ActiveJob::Performs if defined?(ActiveJob::Performs)
12
+ rescue LoadError
13
+ # We haven't bundled active_job-performs, so we're continuing without it.
14
+ end
16
15
 
16
+ initializer "object_association.setup" do
17
17
  ActiveSupport.on_load :active_record do
18
18
  require "active_record/associated_object/object_association"
19
19
  include ActiveRecord::AssociatedObject::ObjectAssociation
@@ -2,6 +2,6 @@
2
2
 
3
3
  module ActiveRecord
4
4
  class AssociatedObject
5
- VERSION = "0.8.2"
5
+ VERSION = "0.9.0"
6
6
  end
7
7
  end
@@ -1,38 +1,63 @@
1
+ # frozen_string_literal: true
2
+
1
3
  class ActiveRecord::AssociatedObject
4
+ extend ActiveModel::Naming
5
+ include ActiveModel::Conversion
6
+
2
7
  class << self
3
- def inherited(klass)
4
- record_klass = klass.module_parent
5
- record_name = klass.module_parent_name.demodulize.underscore
6
- attribute_name = klass.to_s.demodulize.underscore.to_sym
8
+ def inherited(new_object)
9
+ new_object.associated_via(new_object.module_parent)
10
+ end
7
11
 
8
- unless record_klass.respond_to?(:descends_from_active_record?) && record_klass.descends_from_active_record?
9
- raise ArgumentError, "#{record_klass} isn't valid; can only associate with ActiveRecord::Base subclasses"
12
+ def associated_via(record)
13
+ unless record.respond_to?(:descends_from_active_record?) && record.descends_from_active_record?
14
+ raise ArgumentError, "#{record} isn't a valid namespace; can only associate with ActiveRecord::Base subclasses"
10
15
  end
11
16
 
12
- klass.alias_method record_name, :record
13
- klass.define_singleton_method(:record_klass) { record_klass }
14
- klass.define_singleton_method(:attribute_name) { attribute_name }
15
- klass.delegate :record_klass, :attribute_name, to: :class
17
+ @record, @attribute_name = record, model_name.element.to_sym
18
+ alias_method record.model_name.element, :record
16
19
  end
17
20
 
21
+ attr_reader :record, :attribute_name
22
+ delegate :primary_key, :unscoped, :transaction, to: :record
23
+
18
24
  def extension(&block)
19
- record_klass.class_eval(&block)
25
+ record.class_eval(&block)
20
26
  end
21
27
 
22
- def respond_to_missing?(...) = record_klass.respond_to?(...) || super
23
- delegate :unscoped, :transaction, :primary_key, to: :record_klass
24
-
25
28
  def method_missing(method, ...)
26
- if !record_klass.respond_to?(method) then super else
27
- record_klass.public_send(method, ...).then do |value|
29
+ if !record.respond_to?(method) then super else
30
+ record.public_send(method, ...).then do |value|
28
31
  value.respond_to?(:each) ? value.map(&attribute_name) : value&.public_send(attribute_name)
29
32
  end
30
33
  end
31
34
  end
35
+ def respond_to_missing?(...) = record.respond_to?(...) || super
36
+ end
37
+
38
+ module Caching
39
+ def cache_key_with_version
40
+ "#{cache_key}-#{cache_version}".tap { _1.delete_suffix!("-") }
41
+ end
42
+ delegate :cache_version, to: :record
43
+
44
+ def cache_key
45
+ case
46
+ when !record.cache_versioning?
47
+ raise "ActiveRecord::AssociatedObject#cache_key only supports #{record_klass}.cache_versioning = true"
48
+ when new_record?
49
+ "#{model_name.cache_key}/new"
50
+ else
51
+ "#{model_name.cache_key}/#{id}"
52
+ end
53
+ end
32
54
  end
55
+ include Caching
33
56
 
34
57
  attr_reader :record
35
- delegate :id, :transaction, to: :record
58
+ delegate :id, :new_record?, :persisted?, to: :record
59
+ delegate :updated_at, :updated_on, to: :record # Helpful when passing to `fresh_when`/`stale?`
60
+ delegate :transaction, to: :record
36
61
 
37
62
  def initialize(record)
38
63
  @record = record
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.8.2
4
+ version: 0.9.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: 2024-10-11 00:00:00.000000000 Z
11
+ date: 2025-02-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord