Skip to content

Commit e1de22e

Browse files
Merge pull request #459 from skryukov/association-source
[Feat] Add source option to associations
2 parents ef42149 + f473228 commit e1de22e

File tree

4 files changed

+272
-16
lines changed

4 files changed

+272
-16
lines changed

README.md

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -617,6 +617,34 @@ FooResource.new(foo, params: {expose_secret: true}).serialize # => '{"foo":{"bar
617617
FooResourceWithParamsOverride.new(foo, params: {expose_secret: true}).serialize # => '{"foo":{"bar":{"baz":{"data":1}}}}'
618618
```
619619

620+
#### Custom association source
621+
622+
You can specify a custom source for associations using the `source` option with a proc. The `source` proc is executed in the context of the target object and can receive `params` for dynamic behavior. This allows you to retrieve association data from methods other than the association name or access instance variables.
623+
624+
```ruby
625+
class User
626+
attr_accessor :id, :name, :metadata
627+
628+
def custom_profile
629+
{profile: {email: "#{name.downcase}@example.com"}}
630+
end
631+
end
632+
633+
class UserResource
634+
include Alba::Resource
635+
636+
attributes :id, :name
637+
638+
# Use a custom method as source
639+
one :profile, source: proc { custom_profile[:profile] }
640+
641+
# Access instance variables
642+
one :user_metadata, source: proc { @metadata }
643+
end
644+
```
645+
646+
647+
620648
### Nested Attribute
621649

622650
Alba supports nested attributes that makes it easy to build complex data structure from single object.
@@ -778,15 +806,15 @@ Alba.serialize(
778806
[foo1, bar1, foo2, bar2],
779807
# `with` option takes a lambda to return resource class
780808
with: lambda do |obj|
781-
case obj
782-
when Foo
783-
CustomFooResource
784-
when Bar
785-
BarResource
786-
else
787-
raise # Impossible in this case
788-
end
789-
end
809+
case obj
810+
when Foo
811+
CustomFooResource
812+
when Bar
813+
BarResource
814+
else
815+
raise # Impossible in this case
816+
end
817+
end
790818
)
791819
# => '[{"id":1},{"id":1,"address":"bar1"},{"id":2},{"id":2,"address":"bar2"}]'
792820
# Note `CustomFooResource` is used here
@@ -1520,8 +1548,8 @@ Alba supports serializing JSON in a layout. You need a file for layout and then
15201548

15211549
```erb
15221550
{
1523-
"header": "my_header",
1524-
"body": <%= serialized_json %>
1551+
"header": "my_header",
1552+
"body": <%= serialized_json %>
15251553
}
15261554
```
15271555

@@ -1909,7 +1937,7 @@ AuthorResource.new(
19091937
author,
19101938
params: {
19111939
index: author.books.map.with_index { |book, index| [book.id, index] }
1912-
.to_h
1940+
.to_h
19131941
}
19141942
).serialize
19151943
# => {"id":2,"books":[{"id":2,"name":"book2","index":0},{"id":3,"name":"book3","index":1},{"id":1,"name":"book1","index":2}]}

lib/alba/association.rb

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,29 @@ class << self
1616
# @param condition [Proc, nil] a proc filtering data
1717
# @param resource [Class<Alba::Resource>, Proc, String, Symbol, nil]
1818
# a resource class for the association, a proc returning a resource class or a name of the resource
19+
# @param source [Proc, nil] a proc to specify the source of the association
1920
# @param with_traits [Symbol, Array<Symbol>, nil] specified traits
2021
# @param params [Hash] params override for the association
2122
# @param nesting [String] a namespace where source class is inferred with
2223
# @param key_transformation [Symbol] key transformation type
2324
# @param helper [Module] helper module to include
2425
# @param block [Block] used to define resource when resource arg is absent
25-
def initialize(name:, condition: nil, resource: nil, with_traits: nil, params: {}, nesting: nil, key_transformation: :none, helper: nil, &block)
26+
def initialize(
27+
name:,
28+
condition: nil,
29+
resource: nil,
30+
source: nil,
31+
with_traits: nil,
32+
params: {},
33+
nesting: nil,
34+
key_transformation: :none,
35+
helper: nil,
36+
&block
37+
)
2638
@name = name
2739
@condition = condition
2840
@resource = resource
41+
@source = source
2942
@with_traits = with_traits
3043
@params = params
3144
return if @resource
@@ -64,7 +77,11 @@ def to_h(target, within: nil, params: {})
6477
private
6578

6679
def object_from(target, params)
67-
o = target.is_a?(Hash) ? target.fetch(@name) : target.__send__(@name)
80+
o = if @source
81+
target.instance_exec(params, &@source)
82+
else
83+
target.is_a?(Hash) ? target.fetch(@name) : target.__send__(@name)
84+
end
6885
o = @condition.call(o, params, target) if @condition
6986
o
7087
end

lib/alba/resource.rb

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,7 @@ def attribute(name, **options, &block)
429429
# @param condition [Proc, nil] a Proc to modify the association
430430
# @param resource [Class<Alba::Resource>, String, Proc, nil] representing resource for this association
431431
# @param serializer [Class<Alba::Resource>, String, Proc, nil] alias for `resource`
432+
# @param source [Proc, nil] a Proc to customize the association source
432433
# @param key [String, Symbol, nil] used as key when given
433434
# @param with_traits [Symbol, Array<Symbol>, nil] specified traits
434435
# @param params [Hash] params override for the association
@@ -437,11 +438,11 @@ def attribute(name, **options, &block)
437438
# @param block [Block]
438439
# @return [void]
439440
# @see Alba::Association#initialize
440-
def association(name, condition = nil, resource: nil, serializer: nil, key: nil, with_traits: nil, params: {}, **options, &block)
441+
def association(name, condition = nil, resource: nil, serializer: nil, source: nil, key: nil, with_traits: nil, params: {}, **options, &block)
441442
resource ||= serializer
442443
transformation = @_key_transformation_cascade ? @_transform_type : :none
443444
assoc = Association.new(
444-
name: name, condition: condition, resource: resource, with_traits: with_traits,
445+
name: name, condition: condition, resource: resource, source: source, with_traits: with_traits,
445446
params: params, nesting: nesting, key_transformation: transformation, helper: @_helper,
446447
&block
447448
)
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
# frozen_string_literal: true
2+
3+
require_relative '../test_helper'
4+
5+
class AssociationSourceTest < Minitest::Test
6+
class User
7+
attr_accessor :id, :name, :profile, :articles, :metadata
8+
9+
def initialize(id, name)
10+
@id = id
11+
@name = name
12+
@articles = []
13+
@metadata = {}
14+
end
15+
16+
def custom_profile_data
17+
{email: "#{name.downcase}@example.com", bio: "Bio for #{name}"}
18+
end
19+
20+
def filtered_articles(status = nil)
21+
return @articles unless status
22+
23+
@articles.select { |article| article.status == status }
24+
end
25+
end
26+
27+
class Profile
28+
attr_accessor :id, :email, :bio
29+
30+
def initialize(id, email, bio)
31+
@id = id
32+
@email = email
33+
@bio = bio
34+
end
35+
end
36+
37+
class Article
38+
attr_accessor :id, :title, :status
39+
40+
def initialize(id, title, status = 'published')
41+
@id = id
42+
@title = title
43+
@status = status
44+
end
45+
end
46+
47+
class ProfileResource
48+
include Alba::Resource
49+
50+
attributes :email, :bio
51+
end
52+
53+
class ArticleResource
54+
include Alba::Resource
55+
56+
attributes :id, :title, :status
57+
end
58+
59+
def setup
60+
@user = User.new(1, 'John')
61+
@user.profile = Profile.new(1, '[email protected]', 'Software developer')
62+
@user.articles << Article.new(1, 'First Post', 'published')
63+
@user.articles << Article.new(2, 'Draft Post', 'draft')
64+
@user.articles << Article.new(3, 'Another Post', 'published')
65+
@user.metadata = {role: 'admin', department: 'engineering'}
66+
end
67+
68+
# Test basic source functionality with one association
69+
class UserResourceWithSourceOne
70+
include Alba::Resource
71+
72+
attributes :id, :name
73+
74+
one :custom_profile,
75+
source: proc { custom_profile_data },
76+
resource: ProfileResource
77+
end
78+
79+
def test_one_association_with_basic_source
80+
expected = '{"id":1,"name":"John","custom_profile":{"email":"[email protected]","bio":"Bio for John"}}'
81+
assert_equal expected, UserResourceWithSourceOne.new(@user).serialize
82+
end
83+
84+
# Test source with params access
85+
class UserResourceWithSourceAndParams
86+
include Alba::Resource
87+
88+
attributes :id, :name
89+
90+
many :filtered_articles,
91+
source: proc { |params| filtered_articles(params[:status]) },
92+
resource: ArticleResource
93+
end
94+
95+
def test_many_association_with_source_using_params
96+
expected = '{"id":1,"name":"John","filtered_articles":[{"id":1,"title":"First Post","status":"published"},{"id":3,"title":"Another Post","status":"published"}]}' # rubocop: disable Layout/LineLength
97+
result = UserResourceWithSourceAndParams.new(@user, params: {status: 'published'}).serialize
98+
assert_equal expected, result
99+
end
100+
101+
def test_many_association_with_source_using_params_returns_all_when_no_status
102+
expected = '{"id":1,"name":"John","filtered_articles":[{"id":1,"title":"First Post","status":"published"},{"id":2,"title":"Draft Post","status":"draft"},{"id":3,"title":"Another Post","status":"published"}]}' # rubocop: disable Layout/LineLength
103+
result = UserResourceWithSourceAndParams.new(@user, params: {}).serialize
104+
assert_equal expected, result
105+
end
106+
107+
# Test source with custom key
108+
class UserResourceWithSourceAndKey
109+
include Alba::Resource
110+
111+
attributes :id, :name
112+
113+
one :profile_info,
114+
source: proc { custom_profile_data },
115+
key: :user_profile,
116+
resource: ProfileResource
117+
end
118+
119+
def test_association_with_source_and_custom_key
120+
expected = '{"id":1,"name":"John","user_profile":{"email":"[email protected]","bio":"Bio for John"}}'
121+
assert_equal expected, UserResourceWithSourceAndKey.new(@user).serialize
122+
end
123+
124+
# Test source with condition
125+
class UserResourceWithSourceAndCondition
126+
include Alba::Resource
127+
128+
attributes :id, :name
129+
130+
many :articles,
131+
proc { |articles, _params| articles.select { |a| a.status == 'published' } },
132+
source: proc { @articles },
133+
resource: ArticleResource
134+
end
135+
136+
def test_association_with_source_and_condition
137+
expected = '{"id":1,"name":"John","articles":[{"id":1,"title":"First Post","status":"published"},{"id":3,"title":"Another Post","status":"published"}]}'
138+
assert_equal expected, UserResourceWithSourceAndCondition.new(@user).serialize
139+
end
140+
141+
# Test source returning nil
142+
class UserResourceWithNilSource
143+
include Alba::Resource
144+
145+
attributes :id, :name
146+
147+
one :missing_profile,
148+
source: proc {},
149+
resource: ProfileResource
150+
end
151+
152+
class MetadataResource
153+
include Alba::Resource
154+
155+
attributes :role, :department
156+
end
157+
158+
def test_association_with_source_returning_nil
159+
expected = '{"id":1,"name":"John","missing_profile":null}'
160+
assert_equal expected, UserResourceWithNilSource.new(@user).serialize
161+
end
162+
163+
# Test source accessing instance variables
164+
class UserResourceWithMetadataSource
165+
include Alba::Resource
166+
167+
attributes :id, :name
168+
169+
one :metadata, source: proc { @metadata }, resource: MetadataResource
170+
end
171+
172+
def test_association_with_source_accessing_instance_variables
173+
expected = '{"id":1,"name":"John","metadata":{"role":"admin","department":"engineering"}}'
174+
assert_equal expected, UserResourceWithMetadataSource.new(@user).serialize
175+
end
176+
177+
# Test source with block resource definition
178+
class UserResourceWithSourceAndBlock
179+
include Alba::Resource
180+
181+
attributes :id, :name
182+
183+
one :profile_summary,
184+
source: proc { {email: custom_profile_data[:email], name: @name} } do
185+
attributes :email, :name
186+
end
187+
end
188+
189+
def test_association_with_source_and_block_resource
190+
expected = '{"id":1,"name":"John","profile_summary":{"email":"[email protected]","name":"John"}}'
191+
assert_equal expected, UserResourceWithSourceAndBlock.new(@user).serialize
192+
end
193+
194+
# Test error handling when source proc raises an exception
195+
class UserResourceWithErrorSource
196+
include Alba::Resource
197+
198+
attributes :id, :name
199+
200+
one :error_profile,
201+
source: proc { raise StandardError, 'Source error' },
202+
resource: ProfileResource
203+
end
204+
205+
def test_association_with_source_that_raises_error
206+
assert_raises(StandardError) do
207+
UserResourceWithErrorSource.new(@user).serialize
208+
end
209+
end
210+
end

0 commit comments

Comments
 (0)