Repository: okuramasafumi/alba (Ruby)

Issue:
Introducing `trait`, similar to `view` in blueprinter gem
## Is your feature request related to a problem? Please describe.

When using Alba, there is no simple way to group attributes and toggle it from outside of resource class.
Currently it's possible with `params` but it's not straightforward sometimes.

## Describe the solution you'd like

```ruby
class UserResource
  include Alba::Resource

  attributes :id

  trait :admin do
    attributes :some_attribute_for_admin_only, :and_else
  end
end

UserResource.new(user).serialize # => :id only
UserResource.new(admin, with_trait: :admin).serialize => :id, :some_attribute_for_admin_only, :and_else
```

This is similar to `views` feature from [blueprinter gem](https://github.com/procore-oss/blueprinter).

## Describe alternatives you've considered

Not alternative, but naming is a problem. Is `trait` and `with_trait` good enough?

## Additional context

This feature request is the one I got at RubyKaigi 2025 official party.



Discussion:

Patch:
diff --git a/README.md b/README.md
index 06cd6c6..bf3794b 100644
--- a/README.md
+++ b/README.md
@@ -1073,6 +1073,40 @@ class UserResource2
 end
 ```
 
+### Traits
+
+Traits is an easy way to a group of attributes and apply it to the resource.
+
+```ruby
+class User
+  attr_accessor :id, :name, :email
+
+  def initialize(id, name, email)
+    @id = id
+    @name = name
+    @email = email
+  end
+end
+
+class UserResource
+  include Alba::Resource
+
+  attributes :id
+
+  trait :additional do
+    attributes :name, :email
+  end
+end
+
+user = User.new(1, 'Foo', 'foo@example.org')
+UserResource.new(user).serialize # => '{"id":1}'
+UserResource.new(user, with_traits: :additional).serialize # => '{"id":1,"name":"Foo","email":"foo@example.com"}'
+```
+
+This way, we can keep the resource class simple and inject conditions from outside. We can get the same result with the combination of `if` and `params`, but using `traits` DSL can make the resource class readable.
+
+We can specify multiple traits at once with `with_traits: []` keyword argument.
+
 ### Default
 
 Alba doesn't support default value for attributes, but it's easy to set a default value.
diff --git a/lib/alba/resource.rb b/lib/alba/resource.rb
index 7854380..3dbd09d 100644
--- a/lib/alba/resource.rb
+++ b/lib/alba/resource.rb
@@ -14,7 +14,7 @@ module Alba
   module Resource
     # @!parse include InstanceMethods
     # @!parse extend ClassMethods
-    INTERNAL_VARIABLES = {_attributes: {}, _key: nil, _key_for_collection: nil, _meta: nil, _transform_type: :none, _transforming_root_key: false, _key_transformation_cascade: true, _on_error: nil, _on_nil: nil, _layout: nil, _collection_key: nil, _helper: nil, _resource_methods: [], _select_arity: nil}.freeze # rubocop:disable Layout/LineLength
+    INTERNAL_VARIABLES = {_attributes: {}, _key: nil, _key_for_collection: nil, _meta: nil, _transform_type: :none, _transforming_root_key: false, _key_transformation_cascade: true, _on_error: nil, _on_nil: nil, _layout: nil, _collection_key: nil, _helper: nil, _resource_methods: [], _select_arity: nil, _traits: {}}.freeze # rubocop:disable Layout/LineLength
     private_constant :INTERNAL_VARIABLES
 
     WITHIN_DEFAULT = Object.new.freeze
@@ -46,10 +46,12 @@ module InstanceMethods
       # @param object [Object] the object to be serialized
       # @param params [Hash] user-given Hash for arbitrary data
       # @param within [Object, nil, false, true] determines what associations to be serialized. If not set, it serializes all associations.
-      def initialize(object, params: {}, within: WITHIN_DEFAULT)
+      # @param with_traits [Symbol, Array<Symbol>, nil] specified traits
+      def initialize(object, params: {}, within: WITHIN_DEFAULT, with_traits: nil)
         @object = object
         @params = params
         @within = within
+        @with_traits = with_traits
         _setup
       end
 
@@ -106,6 +108,20 @@ def serializable_hash
 
       private
 
+      def hash_from_traits(obj)
+        h = {}
+        return h if @with_traits.nil?
+
+        Array(@with_traits).each do |trait|
+          body = @_traits.fetch(trait) { raise Alba::Error, "Trait not found: #{trait}" }
+
+          resource_class = Alba.resource_class
+          resource_class.class_eval(&body)
+          h.merge!(resource_class.new(obj, params: params, within: @within).serializable_hash)
+        end
+        h
+      end
+
       def deprecated_serializable_hash
         Alba.collection?(@object) ? serializable_hash_for_collection : converter.call(@object)
       end
@@ -212,7 +228,7 @@ def attributes_to_hash(obj, hash)
         rescue StandardError => e
           handle_error(e, obj, key, attribute, hash)
         end
-        hash
+        @with_traits.nil? ? hash : hash.merge!(hash_from_traits(obj))
       end
 
       # This is default behavior for getting attributes for serialization
@@ -455,6 +471,19 @@ def nested_attribute(name, **options, &block)
       end
       alias nested nested_attribute
 
+      # Set a trait
+      #
+      # @param name [String, Symbol] name of the trait
+      # @param block [Block] the "content" of the trait
+      # @raise [ArgumentError] if block is absent
+      # @return [void]
+      def trait(name, &block)
+        raise ArgumentError, 'No block given in trait method' unless block
+
+        name = name.to_sym
+        @_traits[name] = block
+      end
+
       # Set root key
       #
       # @param key [String, Symbol]


Test patch:
diff --git a/test/usecases/trait_test.rb b/test/usecases/trait_test.rb
new file mode 100644
index 0000000..9339bd9
--- /dev/null
+++ b/test/usecases/trait_test.rb
@@ -0,0 +1,91 @@
+# frozen_string_literal: true
+
+require_relative '../test_helper'
+
+class TraitTest < Minitest::Test
+  class User
+    attr_accessor :id, :name, :email
+
+    def initialize(id, name, email)
+      @id = id
+      @name = name
+      @email = email
+    end
+  end
+
+  class UserResource
+    include Alba::Resource
+
+    attributes :id
+
+    trait :name_and_email do
+      attributes :name, :email
+    end
+  end
+
+  def setup
+    @user = User.new(1, 'Masafumi OKURA', 'masafumi@example.com')
+  end
+
+  def test_it_does_not_return_in_trait_if_not_specified
+    assert_equal(
+      '{"id":1}',
+      UserResource.new(@user).serialize
+    )
+  end
+
+  def test_it_returns_in_trait_if_specified
+    assert_equal(
+      '{"id":1,"name":"Masafumi OKURA","email":"masafumi@example.com"}',
+      UserResource.new(@user, with_traits: :name_and_email).serialize
+    )
+    assert_equal(
+      '{"id":1,"name":"Masafumi OKURA","email":"masafumi@example.com"}',
+      UserResource.new(@user, with_traits: [:name_and_email]).serialize
+    )
+  end
+
+  def test_traits_for_collection
+    users = [
+      User.new(1, 'Foo', 'foo@example.org'),
+      User.new(2, 'Bar', 'bar@example.org')
+    ]
+    assert_equal(
+      '[{"id":1},{"id":2}]',
+      UserResource.new(users).serialize
+    )
+    assert_equal(
+      '[{"id":1,"name":"Foo","email":"foo@example.org"},{"id":2,"name":"Bar","email":"bar@example.org"}]',
+      UserResource.new(users, with_traits: :name_and_email).serialize
+    )
+    assert_equal(
+      '[{"id":1,"name":"Foo","email":"foo@example.org"},{"id":2,"name":"Bar","email":"bar@example.org"}]',
+      UserResource.new(users, with_traits: [:name_and_email]).serialize
+    )
+  end
+
+  class UserResource2 < UserResource
+    trait :another_trait do
+      attribute :special_attribute do
+        42
+      end
+    end
+  end
+
+  def test_it_returns_multiple_traits_including_inherited
+    assert_equal(
+      '{"id":1,"name":"Masafumi OKURA","email":"masafumi@example.com","special_attribute":42}',
+      UserResource2.new(@user, with_traits: [:name_and_email, :another_trait]).serialize
+    )
+    assert_equal(
+      '{"id":1,"special_attribute":42}',
+      UserResource2.new(@user, with_traits: :another_trait).serialize
+    )
+  end
+
+  def test_it_raises_error_if_trait_not_found
+    assert_raises(Alba::Error, 'Trait not found: not_found') do
+      UserResource.new(@user, with_traits: :not_found).serialize
+    end
+  end
+end