Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add inline syntax for attributes and associations #1356

Merged
merged 14 commits into from
Dec 10, 2015

Conversation

bf4
Copy link
Member

@bf4 bf4 commented Nov 30, 2015

Goals:

  1. Allow defining attributes so that they don't conflict with existing methods
  2. Allow customizing associations e.g. has_many :comments do last(5) end
    • compare to using virtual_value such as has_many :reviews, virtual_value: [{ id: 1 }, { id: 2 }]
      and how association virtual values differ from attribute values.
  3. Remove dynamically defined methods on the serializer

Based on work in #1262, #1354

Ref: #1263, #1261, #1311

This PR is intended as a starting point for further work improving how
serializers map their resources attributes and associations

Introduced, in support of this:

  • Serializer#_attribute_mappings
  • Extracted Serializer attribute methods to an Attributes module. I'd consider a class but this is a simpler first step and doesn't block changing it to an Attributes class than has many Attribute objects in the future

Possible regression:

removed referring to object attributes in the serializer without calling them directly on object. (I didn't even know this was possible. We could add a respond_to_missing / method_missing for this)

   def slug
-    "#{name}-#{id}"
+    "#{object.name}-#{object.id}"
  • Discuss

Associations as 'includes'

It seems like we might benefit from thinking of associations, at least in the JSON API adapter, as includes, and only including them when requested (which is also similar to earlier naming and usage in the library, which I like). This PR made me think of it, in part, because the difference between a reflection, an association, and associations, I think should be more clear (i.e. just an include).

  • Discuss

Blocks in serializer attribute definitions

I like this. They remove the need for defining methods on the serializer, and make these attributes seem more like transformations. I don't mind that they require a call to object, but would be okay eval'ing them in the context of the object, instead of the serializer.

  • attribute :name do 'AMS' end

Blocks in serializer association definitions

Since there may be more options to pass in here, and to distinguish the context of an association block, which is on the association, from the attribute block, which is on the serializer, I'm tossing around the idea of having a scope option the receives a proc or lambda. e.g.

  • has_many :comments, ->{ last(5) } (NOT ->{ comments.last(5) }
    • consider has_many :comments, scope: ->{ last(5) }

serialized_associations[reflection_name] = ->(instance) { instance.object.send(reflection_name) }
end

define_method reflection_name do
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there still a need/reason to define such methods on the serializer?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, except that it breaks some tests, and I didn't want to go and fix 'em :)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, we can do it in two steps

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah that's what was holding me back, because even after fixing the tests, there are caching issues (I believe caching is broken tbh).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh, and re having a serializer/attributes.rb file, like in your prs, .. I'm pro moving more stuff in there (though I think it should be a class), but even the current Struct isn't really necessary now. we can put that off to future work

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The idea behind serializer/attributes.rb was mainly to separate various serializer concerns into their own modules/files. The goal was that serializer.rb would contain as little stuff as possible and that the logic for handling attributes-related stuff would be in one file (class and instance methods).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I started on it, but it wasn't worth the extra effort in this PR. That file is sitting on my file-system

if block_given?
serialized_attributes[key] = Attribute.new(->(instance) { instance.instance_eval(&block) })
else
serialized_attributes[key] = Attribute.new(->(instance) { instance.object.read_attribute_for_serialization(attr) })
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why encapsulate the lambda in an Attribute object? (Especially considering we're not doing the same for associations)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no reason right now. I was thinking I'd do more with it, but I didn't. So, can be removed.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I think we talked about it and I agree that at some point it would be nice, but at the moment I think it just adds complexity.

@bf4 bf4 force-pushed the attribute_objects branch from 1de7489 to 011b589 Compare December 1, 2015 00:34
@bf4
Copy link
Member Author

bf4 commented Dec 1, 2015

rebased and force pushed. I don't think I broke anything...

@bf4 bf4 force-pushed the attribute_objects branch from 011b589 to 4bdf44a Compare December 1, 2015 00:36
bf4 referenced this pull request in bf4/active_model_serializers Dec 1, 2015
@noahsilas
Copy link
Contributor

It's not clear to me that this PR satisfies goal 1 (Allow defining attributes so that they don't conflict with existing methods). It looks like trying to add a key whose name is already a method name on the serializer will add an entry to serialized_attributes, but that it still delegates to the existing method. I tried to get around this in #1354 by avoiding the usage of the key name as the method name, but it seems like there is room for improvement over that here in evaluating the proc directly instead of (or in addition to?) adding methods to the serializer.

attribute :name do
"#{object.first_name} #{object.last_name}"
end
end
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@noahsilas I do want to remove the dynamic methods on the serializer, just not in this PR.

However, I do think it's a good idea for there to be a test against attribute names/keys that conflict with methods on the serializer. I think the read_attribute_for_serialization should get around that

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean, right now it is

self.class._fragmented ? self.class._fragmented.public_send(key) : send(key)

but it could be

self.class._fragmented ? self.class._fragmented.public_send(key) : object.read_attribute_for_serialization(key)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that the problem here is that the key may not be the name of the attribute that we want to read, as in:

class MyThingSerializer < ActiveModel::Serializer
  attribute :name, key: :object
end

This should render something like { "object": "my name" }, but to get that, we actually need to invoke the name method on the object read the name attribute for serialization.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I should add a case here using the virtual_value option. e.g. the poro in our fixtures file

class VirtualValueSerializer < ActiveModel::Serializer
  attributes :id

  has_many :reviews, virtual_value: [{ id: 1 }, { id: 2 }]
end

@noahsilas
Copy link
Contributor

If you want, I can rebuild #1354 on top of this change and submit a new PR, separating the goal of block definition from the goal of avoiding namespace conflicts.

@bf4
Copy link
Member Author

bf4 commented Dec 1, 2015

You can re-use this pr

Take it away (please)!

B mobile phone

On Dec 1, 2015, at 2:19 PM, Noah Silas [email protected] wrote:

If you want, I can rebuild #1354 on top of this change and submit a new PR, separating the goal of block definition from the goal of avoiding namespace conflicts.


Reply to this email directly or view it on GitHub.

@noahsilas
Copy link
Contributor

I'm not sure that I can push onto this PR, but rebasing #1354 onto this branch ends up looking like this: https://github.com/brigade/active_model_serializers/commits/attribute_objects (specifically, see brigade/active_model_serializers@ca76be1a46 ).

There is still some weird interplay with defining methods on the serializer so that the old style method overrides still work, which is a little disappointing, but a major break in the API, so I didn't feel comfortable removing it.

@bf4
Copy link
Member Author

bf4 commented Dec 2, 2015

@noahsilas re: 'you can re-use this pr', my mistake. I was replying from email and thought it was the one you authored.

I was thinking that a way to get rid of the dynamic methods would be to pretend they're there. Maybe with a respond_to_missing, or in the 'reader'

@bf4 bf4 force-pushed the attribute_objects branch from 33513fc to eb6850e Compare December 2, 2015 06:18
@bf4
Copy link
Member Author

bf4 commented Dec 2, 2015

@noahsilas I added your commit from that branch and made one major and a few minor changes

  1. removed dynamically define methods (yay!)

    • added the _fragment attributes to the mapping. This handles the _fragmented.respond_to?(attr) the prevented the dynamic method from being defined
    • added Serializer#read_attribute_for_serialization (part of the ActiveModel::Serialization API) to handle first getting the attribute from a serializer method, then from the object.
      • Checking if the attribute is defined on the serializer handles the dynamic method check for method_defined?(attr), except now it just checks if there's a public instance method by that name not defined in Object.
      • possible regression: removed referring to object attributes in the serializer without calling them directly on object. (I didn't even know this was possible. We could add a respond_to_missing / method_missing for this)
  2. changed _attributes_map to _attribute_mappings so that the collection element could be referred to as each of an _attribute_mapping.

    • Changed the mapping from a hash to an object
    -      _attributes_map[key] = { attr: attr, reader: reader }
    +      _attribute_mappings[key] = Attribute.new(attr, reader)
    • Defined the object as a callable that knows the attribute name it represents
    +    Attribute = Struct.new(:name, :reader) do
    +      delegate :call, to: :reader
    +    end

The PR description is now out of date

@@ -14,6 +14,9 @@ class Serializer
include Configuration
include Associations
require 'active_model/serializer/adapter'
Attribute = Struct.new(:name, :reader) do
delegate :call, to: :reader
end
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See #1356 (comment)

I could go either way on this.

class << base
attr_accessor :_reflections
included do
with_options instance_writer: false, instance_reader: true do |serializer|
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know if there's a difference between the block variables base and serializer in what we want here.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unclear to me as well.

@bf4
Copy link
Member Author

bf4 commented Dec 3, 2015

@groyoh @beauby Updated removing all dynamic methods on attributes and associations with full backwards compatibility

Anything you think is needed in this PR to support future work?

@noahsilas
Copy link
Contributor

@groyoh @beauby The relationship links work seems like it is primarily applicable to the JsonApi adapter; is there a way to satisfy this desire that doesn't introduce overhead for all other adapters? (I may be misunderstanding, but having to specify the relationship via a data method seems confusing for the AttributesAdapter, for instance).

@bf4
Copy link
Member Author

bf4 commented Dec 4, 2015

@noahsilas

The relationship links work seems like it is primarily applicable to the JsonApi adapter;

True, but it's not in this PR :)


private

def _serializer_instance_methods
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't that be better as a class method performance wise?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you're right. I was working on the instance-level and got tunnel-vision


def self.build_reader(name, block)
if block
->(instance) { instance.instance_eval(&block) }
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm looking at 0.8 and how JSON API considers associations 'includes' and am thinking that association block should be applied to the association, rather than the serializer. Otherwise, there's really nothing that ties the block to an association.

So, usage from current impl would be

has_many :comments do
-  comments.limit(5)
+  limit(5)
end

which is also more inline with how the scope method works in Rails. I think this makes the api a lot more clear. If you want a custom attribute, use the attribute block. If you want a scoped association, I think it the instance_eval should be on the association.

See

      def associate(klass, attrs) #:nodoc:
        options = attrs.extract_options!
        self._associations = _associations.dup

        attrs.each do |attr|
          unless method_defined?(attr)
            define_method attr do
              object.send attr
            end
          end

          define_include_method attr

          self._associations[attr] = klass.refine(attr, options)
        end
      end

      def define_include_method(name)
        method = "include_#{name}?".to_sym

        INCLUDE_METHODS[name] = method

        unless method_defined?(method)
          define_method method do
            true
          end
        end
      end
    def include_associations!
      _associations.each_key do |name|
        include!(name) if include?(name)
      end
    end

    def include?(name)
      return false if @options.key?(:only) && !Array(@options[:only]).include?(name)
      return false if @options.key?(:except) && Array(@options[:except]).include?(name)
      send INCLUDE_METHODS[name]
    end

    def include!(name, options={})
      EMBED_IN_ROOT_OPTIONS = [
        :include,
      ]
    def associations(options={})
      associations = self.class._associations
      included_associations = filter(associations.keys)
      associations.each_with_object({}) do |(name, association), hash|
        if included_associations.include? name
        # ...
  end
    def filter(keys)
      if @only
        keys & @only
      elsif @except
        keys - @except
      else
        keys
      end
    end

# Notice that the association block is evaluated in the context of the association.
# Specifically, the association 'comments' is evaluated two different ways:
# 1) as 'comments' and named 'comments'.
# 2) as 'comments.last(1)' and named 'last_comments'.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See comment https://github.com/rails-api/active_model_serializers/pull/1356/files#r46723777 about how we should think of association blocks (and how we might want to also consider them as only used when included under JSON API) and see #1325 (comment)

@groyoh
Copy link
Member

groyoh commented Dec 4, 2015

@noahsilas I'm pretty sure we can work around this issue. For example, making sure that meta, and link methods return a certain object (e.g. a symbol as in @beauby's example) and serialize the relationship depending on the block return value:

  • Use object.comments
has_many :comments
  • Use the block return value
has_many :comments do
  object.special_comments
end
  • With link but use object.comments
has_many :comments do
  link :self do
    #something
  end # => :SpecialSymbolToPreventInlineAssociation
end
  • With link and use block return value
has_many :comments do
  link :related do
    #something
  end
  object.association # or data object.association
end

The first two would work pretty well with any adapter while the last one would only fit JSONAPI and everybody is happy 😁 .

Of course, this probably require more thoughts and maybe have some flaws too.

@@ -17,7 +25,28 @@ class Serializer
#
# So you can inspect reflections in your Adapters.
#
Reflection = Struct.new(:name, :options) do
Reflection = Struct.new(:name, :options, :block) do
delegate :call, to: :reader
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why delegating it rather than calling @reader.call in value?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just think it reads better. It might be a performance issue.

I think there's definitely room for refactoring and revising this, but I don't think it should be a blocker. I don't really like how the reflection differs from the attribute, but figure we can kick that can down the road a bit more into its own PR.

@bf4
Copy link
Member Author

bf4 commented Dec 9, 2015

@rails-api/ams Anything blocking merging this? I'll merge in 24 hours unless I hear otherwise, since it seems pretty well-discussed, and we can always make changes later

@NullVoxPopuli
Copy link
Contributor

@bf4 I think it'll be a good addition :-)

@bf4 bf4 force-pushed the attribute_objects branch from d3837f9 to bf8270b Compare December 10, 2015 21:08
bf4 added a commit that referenced this pull request Dec 10, 2015
Add inline syntax for attributes and associations
@bf4 bf4 merged commit f562449 into rails-api:master Dec 10, 2015
@bf4 bf4 deleted the attribute_objects branch December 10, 2015 21:10
joaomdmoura pushed a commit to joaomdmoura/active_model_serializers that referenced this pull request Jan 19, 2016
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants