diff --git a/CHANGELOG.md b/CHANGELOG.md index 99cf7e5ef..ec9b7446b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/docs/general/serializers.md b/docs/general/serializers.md index b166d9d0a..664799d0d 100644 --- a/docs/general/serializers.md +++ b/docs/general/serializers.md @@ -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? diff --git a/lib/active_model/serializer/field.rb b/lib/active_model/serializer/field.rb index 35e6fe263..6299b0990 100644 --- a/lib/active_model/serializer/field.rb +++ b/lib/active_model/serializer/field.rb @@ -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 @@ -27,9 +33,9 @@ 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 @@ -37,6 +43,34 @@ def excluded?(serializer) 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) diff --git a/test/serializers/associations_test.rb b/test/serializers/associations_test.rb index ed5ce1e05..218e0d727 100644 --- a/test/serializers/associations_test.rb +++ b/test/serializers/associations_test.rb @@ -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 diff --git a/test/serializers/attribute_test.rb b/test/serializers/attribute_test.rb index b4a441c69..5a914495e 100644 --- a/test/serializers/attribute_test.rb +++ b/test/serializers/attribute_test.rb @@ -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