Skip to content

Commit 7a340cc

Browse files
authored
Integrate with Active Model + cache_key support (#36)
We're bringing a lot more potential power to Associated Objects here. Now objects can be passed to forms, used in route helpers and are cacheable via `cache_key`.
1 parent 1890eca commit 7a340cc

File tree

6 files changed

+185
-4
lines changed

6 files changed

+185
-4
lines changed

README.md

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@ Here's what [@nshki](https://github.com/nshki) found when they tried it:
241241
242242
Let's look at testing, then we'll get to passing these POROs to jobs like the quotes mentioned!
243243
244-
### A Quick Aside: Testing Associated Objects
244+
### Testing Associated Objects
245245
246246
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`.
247247
@@ -261,6 +261,87 @@ class Post::PublisherTest < ActiveSupport::TestCase
261261
end
262262
```
263263
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+
264345
### Polymorphic Associated Objects
265346

266347
If you want to share logic between associated objects, you can do so via standard Ruby modules:

lib/active_record/associated_object.rb

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
# frozen_string_literal: true
2+
13
class ActiveRecord::AssociatedObject
24
extend ActiveModel::Naming
5+
include ActiveModel::Conversion
36

47
class << self
58
def inherited(new_object)
@@ -32,8 +35,29 @@ def method_missing(method, ...)
3235
def respond_to_missing?(...) = record.respond_to?(...) || super
3336
end
3437

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
54+
end
55+
include Caching
56+
3557
attr_reader :record
36-
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
3761

3862
def initialize(record)
3963
@record = record
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
require "test_helper"
2+
require "action_view"
3+
4+
class ActiveRecord::AssociatedObject::IntegrationTest < ActionView::TestCase
5+
Rails.application.routes.draw do
6+
namespace :post do
7+
resources :publishers
8+
end
9+
end
10+
11+
include Rails.application.routes.url_helpers
12+
13+
ActionController::Base.cache_store = :memory_store
14+
15+
setup do
16+
@post = Post.first
17+
@publisher = @post.publisher
18+
end
19+
20+
test "url helper" do
21+
assert_equal "/post/publishers/#{@post.id}", post_publisher_path(@publisher)
22+
end
23+
24+
test "form_with" do
25+
concat form_with(model: @publisher)
26+
assert_select "form[action='/post/publishers/#{@post.id}']"
27+
end
28+
29+
test "cache" do
30+
cache(@publisher) { concat "initial" }
31+
assert_equal "initial", fragment_for(@publisher, {}) { "second" }
32+
end
33+
end

test/active_record/associated_object_test.rb

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,42 @@ class ActiveRecord::AssociatedObjectTest < ActiveSupport::TestCase
8686
assert_equal Post.new.instance_variable_get(:@new_record), true
8787
end
8888

89+
test "active model conversion integration" do
90+
assert_equal @publisher, @publisher.to_model
91+
assert_equal [@post.id], @publisher.to_key
92+
assert_equal @post.id.to_s, @publisher.to_param
93+
assert_equal "post/publishers/publisher", @publisher.to_partial_path
94+
95+
assert_equal @rating, @rating.to_model
96+
assert_equal @comment.id, @rating.to_key
97+
assert_equal @comment.id.join("-"), @rating.to_param
98+
assert_equal "post/comment/ratings/rating", @rating.to_partial_path
99+
end
100+
101+
test "cache_key integration" do
102+
assert_equal "post/publishers/new", Post.new.publisher.cache_key
103+
assert_equal "post/publishers/#{@post.id}", @publisher.cache_key
104+
105+
assert_match /\d+/, @publisher.cache_version
106+
assert_equal @post.cache_version, @publisher.cache_version
107+
assert_match %r(post/publishers/#{@post.id}-\d+), @publisher.cache_key_with_version
108+
109+
110+
assert_equal "post/comment/ratings/new", Post::Comment.new.rating.cache_key
111+
assert_equal "post/comment/ratings/#{@comment.id}", @rating.cache_key
112+
113+
assert_match /\d+/, @rating.cache_version
114+
assert_equal @comment.cache_version, @rating.cache_version
115+
assert_match %r(post/comment/ratings/.*?-\d+), @rating.cache_key_with_version
116+
end
117+
118+
test "cache_key integration without cache_versioning" do
119+
previous_versioning, Post.cache_versioning = Post.cache_versioning, false
120+
assert_raises { @publisher.cache_key }
121+
ensure
122+
Post.cache_versioning = previous_versioning
123+
end
124+
89125
test "kredis integration" do
90126
Time.new(2022, 4, 20, 1).tap do |publish_at|
91127
@publisher.publish_at.value = publish_at

test/boot/active_record.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,27 @@
33

44
ActiveRecord::Schema.define do
55
create_table :authors, force: true do |t|
6+
t.timestamps
67
end
78

89
create_table :posts, force: true do |t|
910
t.string :title
1011
t.integer :author_id
12+
t.timestamps
1113
end
1214

1315
create_table :post_comments, primary_key: [:post_id, :author_id] do |t|
1416
t.integer :post_id, null: false
1517
t.integer :author_id, null: false
1618
t.string :body
19+
t.timestamps
1720
end
1821
end
1922

2023
# Shim what an app integration would look like.
2124
class ApplicationRecord < ActiveRecord::Base
2225
self.abstract_class = true
26+
self.cache_versioning = true # Rails sets this during application booting, so we need to do it manually here.
2327
end
2428

2529
class Author < ApplicationRecord

test/test_helper.rb

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
$LOAD_PATH.unshift File.expand_path("../lib", __dir__)
44

5-
require "rails/railtie"
5+
require "rails"
66
require "kredis"
77
require "debug"
88
require "logger"
@@ -17,8 +17,11 @@
1717
require "minitest/autorun"
1818

1919
# Simulate Rails app boot and run the railtie initializers manually.
20+
class ActiveRecord::AssociatedObject::Application < Rails::Application
21+
end
22+
2023
ActiveRecord::AssociatedObject::Railtie.run_initializers
21-
ActiveSupport.run_load_hooks :after_initialize, Rails::Railtie
24+
ActiveSupport.run_load_hooks :after_initialize, Rails.application
2225

2326
Kredis.configurator = Class.new do
2427
def config_for(name) = { db: "1" }

0 commit comments

Comments
 (0)