Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Breaking changes:
- [#1662](https://github.com/rails-api/active_model_serializers/pull/1662) Drop support for Rails 4.0 and Ruby 2.0.0. (@remear)

Features:
- [#1699](https://github.com/rails-api/active_model_serializers/pull/1699) String/Lambda support for conditional attributes/associations (@mtsmfm)
- [#1687](https://github.com/rails-api/active_model_serializers/pull/1687) Only calculate `_cache_digest` (in `cache_key`) when `skip_digest` is false. (@bf4)
- [#1647](https://github.com/rails-api/active_model_serializers/pull/1647) Restrict usage of `serializable_hash` options
to the ActiveModel::Serialization and ActiveModel::Serializers::JSON interface. (@bf4)
Expand Down
4 changes: 4 additions & 0 deletions docs/general/serializers.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ end

```ruby
has_one :blog, if: :show_blog?
# you can also use a string or lambda
# has_one :blog, if: 'scope.admin?'
# has_one :blog, if: -> (serializer) { serializer.scope.admin? }
# has_one :blog, if: -> { scope.admin? }

def show_blog?
scope.admin?
Expand Down
38 changes: 36 additions & 2 deletions lib/active_model/serializer/field.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ class Serializer
# specified in the ActiveModel::Serializer class.
# Notice that the field block is evaluated in the context of the serializer.
Field = Struct.new(:name, :options, :block) do
def initialize(*)
super

validate_condition!
end

# Compute the actual value of a field for a given serializer instance.
# @param [Serializer] The serializer instance for which the value is computed.
# @return [Object] value
Expand All @@ -27,16 +33,44 @@ def value(serializer)
def excluded?(serializer)
case condition_type
when :if
!serializer.public_send(condition)
!evaluate_condition(serializer)
when :unless
serializer.public_send(condition)
evaluate_condition(serializer)
else
false
end
end

private

def validate_condition!
return if condition_type == :none

case condition
when Symbol, String, Proc
# noop
else
fail TypeError, "#{condition_type.inspect} should be a Symbol, String or Proc"
end
end

def evaluate_condition(serializer)
case condition
when Symbol
serializer.public_send(condition)
when String
serializer.instance_eval(condition)
when Proc
if condition.arity.zero?
serializer.instance_exec(&condition)
else
serializer.instance_exec(serializer, &condition)
end
else
nil
end
end

def condition_type
@condition_type ||=
if options.key?(:if)
Expand Down
58 changes: 43 additions & 15 deletions test/serializers/associations_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -239,27 +239,55 @@ def test_associations_namespaced_resources
end
end

# rubocop:disable Metrics/AbcSize
def test_conditional_associations
serializer = Class.new(ActiveModel::Serializer) do
belongs_to :if_assoc_included, if: :true
belongs_to :if_assoc_excluded, if: :false
belongs_to :unless_assoc_included, unless: :false
belongs_to :unless_assoc_excluded, unless: :true

def true
true
end
model = ::Model.new(true: true, false: false)

scenarios = [
{ options: { if: :true }, included: true },
{ options: { if: :false }, included: false },
{ options: { unless: :false }, included: true },
{ options: { unless: :true }, included: false },
{ options: { if: 'object.true' }, included: true },
{ options: { if: 'object.false' }, included: false },
{ options: { unless: 'object.false' }, included: true },
{ options: { unless: 'object.true' }, included: false },
{ options: { if: -> { object.true } }, included: true },
{ options: { if: -> { object.false } }, included: false },
{ options: { unless: -> { object.false } }, included: true },
{ options: { unless: -> { object.true } }, included: false },
{ options: { if: -> (s) { s.object.true } }, included: true },
{ options: { if: -> (s) { s.object.false } }, included: false },
{ options: { unless: -> (s) { s.object.false } }, included: true },
{ options: { unless: -> (s) { s.object.true } }, included: false }
]

scenarios.each do |s|
serializer = Class.new(ActiveModel::Serializer) do
belongs_to :association, s[:options]

def true
true
end

def false
false
def false
false
end
end

hash = serializable(model, serializer: serializer).serializable_hash
assert_equal(s[:included], hash.key?(:association), "Error with #{s[:options]}")
end
end

model = ::Model.new
hash = serializable(model, serializer: serializer).serializable_hash
expected = { if_assoc_included: nil, unless_assoc_included: nil }
def test_illegal_conditional_associations
exception = assert_raises(TypeError) do
Class.new(ActiveModel::Serializer) do
belongs_to :x, if: nil
end
end

assert_equal(expected, hash)
assert_match(/:if should be a Symbol, String or Proc/, exception.message)
end
end
end
Expand Down
60 changes: 44 additions & 16 deletions test/serializers/attribute_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -96,27 +96,55 @@ def test_virtual_attribute_block
assert_equal(expected, hash)
end

def test_conditional_attributes
serializer = Class.new(ActiveModel::Serializer) do
attribute :if_attribute_included, if: :true
attribute :if_attribute_excluded, if: :false
attribute :unless_attribute_included, unless: :false
attribute :unless_attribute_excluded, unless: :true

def true
true
# rubocop:disable Metrics/AbcSize
def test_conditional_associations
model = ::Model.new(true: true, false: false)

scenarios = [
{ options: { if: :true }, included: true },
{ options: { if: :false }, included: false },
{ options: { unless: :false }, included: true },
{ options: { unless: :true }, included: false },
{ options: { if: 'object.true' }, included: true },
{ options: { if: 'object.false' }, included: false },
{ options: { unless: 'object.false' }, included: true },
{ options: { unless: 'object.true' }, included: false },
{ options: { if: -> { object.true } }, included: true },
{ options: { if: -> { object.false } }, included: false },
{ options: { unless: -> { object.false } }, included: true },
{ options: { unless: -> { object.true } }, included: false },
{ options: { if: -> (s) { s.object.true } }, included: true },
{ options: { if: -> (s) { s.object.false } }, included: false },
{ options: { unless: -> (s) { s.object.false } }, included: true },
{ options: { unless: -> (s) { s.object.true } }, included: false }
]

scenarios.each do |s|
serializer = Class.new(ActiveModel::Serializer) do
attribute :attribute, s[:options]

def true
true
end

def false
false
end
end

def false
false
end
hash = serializable(model, serializer: serializer).serializable_hash
assert_equal(s[:included], hash.key?(:attribute), "Error with #{s[:options]}")
end
end

model = ::Model.new
hash = serializable(model, serializer: serializer).serializable_hash
expected = { if_attribute_included: nil, unless_attribute_included: nil }
def test_illegal_conditional_attributes
exception = assert_raises(TypeError) do
Class.new(ActiveModel::Serializer) do
attribute :x, if: nil
end
end

assert_equal(expected, hash)
assert_match(/:if should be a Symbol, String or Proc/, exception.message)
end
end
end
Expand Down