active_record-associated_object 0.8.2 → 0.9.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: 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