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

Refactor Association to make it eval reflection JIT #2026

Merged
merged 14 commits into from
Apr 30, 2017

Conversation

bf4
Copy link
Member

@bf4 bf4 commented Jan 13, 2017

Should replace much of #1857

See some comments in #2026 (comment) pasted below

Brief stream of thoughts:

Basically, prior to this PR, as soon as the reflection builds an association, it immediately evaluates the associated object, tries to figure out its serializer, and creates a serializer instance. This was noticed to be causing unexpected N+1 queries when the JSON API adapter is serializing a 'belongs_to' relationship resource object identifier. i.e. the reflection/association just needs the type and id, and can get the type from the reflection, and the id in belongs to is on the original object itself. Addressing this means that we only want the association to instantiate the associated object when it is actually going to be used. So, before we can tell the adapter it doesn't need to instantiate the associated object, we need to make it not instantiate the associated object until asked.

Specifically, it would address the failing test in #1100 and close various related issues.

      class BelongsToTestPostSerializer < ActiveModel::Serializer
        belongs_to :blog
      end

      def test_belongs_to_doesnt_load_not_included_record_for_jsonapi
        post = Post.new(blog_id: 'the_blog_id')
        def post.blog
          flunk 'Called post.blog, but should use post.blog_id'
        end

        actual = serializable(post, adapter: :json_api, serializer: BelongsToTestPostSerializer).as_json
        expected = { data: { id: 'post', type: 'posts', relationships: { blog: { data: { id: 'the_blog_id', type: 'blogs' } } } } }

        assert_equal expected, actual
      end

In #1857 I described it as:

If the serializer has an association,

  • If adapter is not JSON API, serialize it.
  • else if it is JSON API and the related resource
    • is not included, serialize just the ids
    • is included, serialize the all fields, or the requested fields

The thing where it doesn't actually call the association is a
special case where AMS needs to know it can get the relationship id
from the resource itself, so that userland code doesn't need to do any fancy association preloading.

The way this has been solved in JSONAPI-Resources
is to have special rules for looking up association ids.
And see JSONAPI::Relationship

In brief:

association foreign_key method loads relation (i.e. when foreign key is on relation )
has_many :authors :author_ids true
has_one :author :author_id true
belongs_to :author :author_id false

Caveat:

This will only work on active record objects

Includes:

Follows:

Related:

@mention-bot
Copy link

@bf4, thanks for your PR! By analyzing the history of the files in this pull request, we identified @bolshakov, @richmolj and @beauby to be potential reviewers.

@@ -10,24 +10,111 @@ class Serializer
# Association.new(:comments, { serializer: CommentSummarySerializer })
#
class Association < Field
# association_options = {
Copy link
Member Author

Choose a reason for hiding this comment

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

temporary comment: required params

# parent_serializer_options:
# include_slice:
# }
# Build association. This method is used internally to
Copy link
Member Author

Choose a reason for hiding this comment

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

temporary comment: original method docs

# comments_reflection.build_association(post_serializer, foo: 'bar')
#
# @api private
def self.from_reflection(reflection, association_options)
Copy link
Member Author

Choose a reason for hiding this comment

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

maybe this should move back into the Reflection...

@parent_serializer = association_options.fetch(:parent_serializer)
@parent_serializer_options = association_options.fetch(:parent_serializer_options)
@include_slice = association_options.fetch(:include_slice)
@reflection = association_options.fetch(:reflection)
Copy link
Member Author

Choose a reason for hiding this comment

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

temporary while I separate out what needs to be mutated and when

Copy link
Member Author

Choose a reason for hiding this comment

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

notes: 5f4a3a5

Copy link
Member Author

Choose a reason for hiding this comment

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

self.options = reflection.options.dup
# Pass the parent's namespace onto the child serializer
options[:namespace] ||= parent_serializer_options[:namespace]
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.

for serializer to be eager loaded, JIT doesn't work yet

end
end
end

def reflect_association(reflection, association_options)
Copy link
Member Author

Choose a reason for hiding this comment

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

not sure how valuable this is.

@@ -93,10 +93,16 @@ def associations(include_directive = ActiveModelSerializers.default_include_dire
key = reflection.options.fetch(:key, reflection.name)
next unless include_directive.key?(key)

y.yield reflection.build_association(self, instance_options, include_slice)
y.yield reflect_association(reflection, key: key, include_slice: include_slice)
Copy link
Member Author

Choose a reason for hiding this comment

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

association doesn't currently use 'key', but maybe it should

:nil
end

def meta(value = nil, &block)
@_meta = block || value
options[:association_meta] = block || 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.

using options[:meta] conflicted downstream. I haven't cleaned that up yet

@_meta = nil
options[:links] = {}
options[:include_data_value] = Serializer.config.include_data_default
options[:association_meta] = nil
Copy link
Member Author

@bf4 bf4 Jan 13, 2017

Choose a reason for hiding this comment

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

adding these to options here and below makes these things effectively accessible via public api. previously each instance variable had to be passed to the association

def serialize_object!(object)
serializer = instantiate_serializer(object)
if serializer.nil?
options[:virtual_value] = object.try(:as_json) || object
Copy link
Member Author

Choose a reason for hiding this comment

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

BUG originates here per #2027 (comment) that an association without a serializer should NOT be serialized like this for JSON API, since that has a required format.

# @param reflection [Reflection]
# @param association_options [Hash] required: include_slice
# @return [Association]
def reflect_association(reflection, association_options)
Copy link
Member Author

Choose a reason for hiding this comment

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

since this is mixed into the serializer, we probably want to prefix such methods with _ as a general practice

Copy link
Member

Choose a reason for hiding this comment

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

Rails concerns/mixins/inheritance should be avoided in favour of composition. Mixins seem attractive at first but just increase the surface area of objects, making it virtually impossible to maintain them when it get to a point. With composition you keep logic in different places and you decrease cognitive load.

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 agree with preferring composition. I'd love to get there.

I believe the mixins were originally extracted to make the serializer easier to understand, but ultimately just made the complexity harder to see.

My comment about the _ was specifically about going out of our way to define instance methods on the serializer that do not conflict with user-defined methods.

@@ -85,18 +85,26 @@ def associate(reflection)
#
def associations(include_directive = ActiveModelSerializers.default_include_directive, include_slice = nil)
Copy link
Member Author

Choose a reason for hiding this comment

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

This method is called in 3 places in the lib, and I think it should be called once. The serializer should yield the association serialized per include/field options to the adapter for processing...

@NullVoxPopuli
Copy link
Contributor

looks interesting, I'll review soonish(tm)

@kurko
Copy link
Member

kurko commented Jan 20, 2017

Can you add to the description what you're trying to accomplish?

@bf4
Copy link
Member Author

bf4 commented Jan 21, 2017

@kurko Brief stream of thoughts:

Basically, prior to this PR, as soon as the reflection builds an association, it immediately evaluates the associated object, tries to figure out its serializer, and creates a serializer instance. This was noticed to be causing unexpected N+1 queries when the JSON API adapter is serializing a 'belongs_to' relationship resource object identifier. i.e. the reflection/association just needs the type and id, and can get the type from the reflection, and the id in belongs to is on the original object itself. Addressing this means that we only want the association to instantiate the associated object when it is actually going to be used. So, before we can tell the adapter it doesn't need to instantiate the associated object, we need to make it not instantiate the associated object until asked.

Specifically, it would address the failing test in #1100 and close various related issues.

      class BelongsToTestPostSerializer < ActiveModel::Serializer
        belongs_to :blog
      end

      def test_belongs_to_doesnt_load_not_included_record_for_jsonapi
        post = Post.new(blog_id: 'the_blog_id')
        def post.blog
          flunk 'Called post.blog, but should use post.blog_id'
        end

        actual = serializable(post, adapter: :json_api, serializer: BelongsToTestPostSerializer).as_json
        expected = { data: { id: 'post', type: 'posts', relationships: { blog: { data: { id: 'the_blog_id', type: 'blogs' } } } } }

        assert_equal expected, actual
      end

I'll clean this up later, but wanted to share my thoughts right now.

In #1857 I described it as:

If the serializer has an association,

  • If adapter is not JSON API, serialize it.
  • else if it is JSON API and the related resource
    • is not included, serialize just the ids
    • is included, serialize the all fields, or the requested fields

The thing where it doesn't actually call the association is a
special case where AMS needs to know it can get the relationship id
from the resource itself, so that userland code doesn't need to do any fancy association preloading.

The way this has been solved in JSONAPI-Resources
is to have special rules for looking up association ids.
And see JSONAPI::Relationship

In brief:

association foreign_key method loads relation (i.e. when foreign key is on relation )
has_many :authors :author_ids true
has_one :author :author_id true
belongs_to :author :author_id false

Caveat:

This will only work on active record objects

Includes:

Follows:

Related:

@@ -10,24 +10,86 @@ class Serializer
# Association.new(:comments, { serializer: CommentSummarySerializer })
#
class Association < Field
# TECHDEBT: remove unneeded accessors
Copy link
Member

Choose a reason for hiding this comment

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

Move unneeded accessors below private, so we know they're not meant to be used publicly.

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'll do that if we decide to keep them. Part of the issue is that we're passing through options but in some place it only needs one option or two, and in others, it mutates the options on e.g. the reflection.. so I'd like to make these more explicit and add sanity.. which make obviate the need for as many accessors as are now in this section.

return @serializer_class if defined?(@serializer_class)
serializer_for_options = { namespace: options[:namespace] }
serializer_for_options[:serializer] = options[:serializer] if options.key?(:serializer)
@serializer_class = parent_serializer.class.serializer_for(object, serializer_for_options)
Copy link
Member

Choose a reason for hiding this comment

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

  1. This class is doing a lot more than it should. It has business logic (key/meta/links) and it also looks like a factory (serializer/serialize_object/instantiate_serializer/serializer_class). It's hard to maintain classes that you can understand what it's really meant to be doing, so it ends up having all sorts of methods. Perhaps we could replace all these methods with a class called... Serializer. See how association is doing the job a serializer again?
  2. I've seen code like this in many other places. Perhaps we should just have it all in one place: Serializer. We already have a class called Serializer, I know, and that is doing things that are not responsibility of a 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.

yes! related to #2026 (comment)

@serializer = options[:serializer]
end

def serialize_object!(object)
Copy link
Member

Choose a reason for hiding this comment

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

These methods should all be privateso we know what can be changed later without side effects.

@@ -91,6 +92,8 @@ def self.get_serializer_for(klass, namespace = nil)
serializer_class
elsif klass.superclass
get_serializer_for(klass.superclass)
else
nil
Copy link
Member

Choose a reason for hiding this comment

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

What does this mean for end users?

Copy link
Member Author

Choose a reason for hiding this comment

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

this is really just making the current behavior explicit. it means no serializer could be found. is it a good design? maybe not. has it been like this for a long time? yes.

@bf4 bf4 force-pushed the refactor_association branch from 873d678 to 19ecd4a Compare February 16, 2017 05:34
@bf4 bf4 force-pushed the refactor_association branch from 19ecd4a to 474b2ad Compare February 28, 2017 04:15
# include_slice: ActiveModelSerializers.default_include_directive,
# parent_serializer: comment_summary_serializer,
# parent_serializer_options: comment_summary_serializer.send(:instance_options),
# }
Copy link
Member Author

Choose a reason for hiding this comment

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

example out of date

Copy link
Member Author

Choose a reason for hiding this comment

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

but really, this is an internal and maybe shouldn't have an example

@bf4 bf4 force-pushed the refactor_association branch from f2a0045 to d21c608 Compare March 12, 2017 21:35
@bf4
Copy link
Member Author

bf4 commented Mar 12, 2017

@kurko Can you take another look at this? I'd like to start fixing the as_json

@bf4 bf4 force-pushed the refactor_association branch from 875b6a4 to fad4ef1 Compare April 23, 2017 19:19
Copy link
Member Author

@bf4 bf4 left a comment

Choose a reason for hiding this comment

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

Some thoughts on changes in here and changes to make before merge

# 2. I've seen code like this in many other places.
# Perhaps we should just have it all in one place: Serializer.
# We already have a class called Serializer, I know,
# and that is doing things that are not responsibility of a 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.

✂️ before merge

#
# Should be reflection_options[:virtual_value] or adapter needs to figure out what to do
# with an object that is non-nil and has no defined serializer.
cached_result[:virtual_value] = object.try(:as_json) || object
Copy link
Member Author

Choose a reason for hiding this comment

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

🏥

end

# NOTE(BF): This serializer throw/catch should only happen when the serializer is a collection
# serializer. This is a good reason for the reflection to have a to_many? type method.
Copy link
Member Author

Choose a reason for hiding this comment

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

✂️

parent_serializer_options: parent_serializer_options,
include_slice: include_slice
}
Association.new(self, association_options)
Copy link
Member Author

Choose a reason for hiding this comment

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

possible small perf hit, my laptop isn't exactly scientific...

more objects created.

before (master)

caching on: caching serializers: gc off 758.9804086635526/ips; 1334 objects
caching on: fragment caching serializers: gc off 715.1655146249818/ips; 1433 objects
caching on: non-caching serializers: gc off 746.1775451036974/ips; 1272 objects
caching off: caching serializers: gc off 658.8422328569378/ips; 1334 objects
caching off: fragment caching serializers: gc off 585.0270057062692/ips; 1433 objects
caching off: non-caching serializers: gc off 655.8819069178346/ips; 1272 objects

after

caching on: caching serializers: gc off 769.7810682942697/ips; 1373 objects
caching on: fragment caching serializers: gc off 673.7590114261305/ips; 1474 objects
caching on: non-caching serializers: gc off 719.5062737459915/ips; 1311 objects
caching off: caching serializers: gc off 662.8574322081319/ips; 1373 objects
caching off: fragment caching serializers: gc off 621.4628380003202/ips; 1474 objects
caching off: non-caching serializers: gc off 642.868991887932/ips; 1311 objects

@@ -257,7 +257,7 @@ def process_resource(serializer, primary, include_slice = {})

def process_relationships(serializer, include_slice)
serializer.associations(include_slice).each do |association|
process_relationship(association.serializer, include_slice[association.key])
process_relationship(association.lazy_association.serializer, include_slice[association.key])
Copy link
Member Author

Choose a reason for hiding this comment

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

obviously, this isn't lazy, but that's kind of the point here...

assert_equal @empty_links, reflection.options.fetch(:links)
assert_equal @empty_links, association.links
association.object # eager eval association

assert_equal @expected_links, association.links
assert_equal @expected_links, reflection.options.fetch(:links)
Copy link
Member Author

Choose a reason for hiding this comment

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

❗️ ❗️

@@ -279,7 +290,8 @@ def test_reflection_block_with_meta_in_link_block_mutates_the_reflection_meta
# Assert after instance_eval link
assert_equal 'no_uri_validation', reflection.instance_eval(&link)
assert_equal @expected_meta, reflection.options.fetch(:meta)
assert_nil association.meta
return # oh no, need to figure this out
assert_nil association.meta # rubocop:disable Lint/UnreachableCode
Copy link
Member Author

Choose a reason for hiding this comment

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

❗️ ❗️ 💀 ❗️ ❗️

@@ -317,7 +330,8 @@ def test_reflection_block_with_meta_block_in_link_block_mutates_the_reflection_m
assert_respond_to association.links.fetch(:self), :call
# Assert before instance_eval link meta
assert_respond_to reflection.options.fetch(:meta), :call
assert_nil association.meta
return # oh no, need to figure this out
assert_nil association.meta # rubocop:disable Lint/UnreachableCode
Copy link
Member Author

Choose a reason for hiding this comment

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

❗️ ❗️ 💀 ❗️ ❗️

@@ -380,6 +396,7 @@ def test_mutating_reflection_block_is_not_thread_safe
assert_equal model1_meta, reflection.options.fetch(:meta)

association = reflection.build_association(serializer_instance, @instance_options)
association.object # eager eval required
assert_equal model2_meta, association.meta
assert_equal model2_meta, reflection.options.fetch(:meta)
Copy link
Member Author

Choose a reason for hiding this comment

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

❗️ ❗️

@@ -365,6 +380,7 @@ def test_mutating_reflection_block_is_not_thread_safe
reflection = serializer_class._reflections.fetch(:blog)
assert_nil reflection.options.fetch(:meta)
association = reflection.build_association(serializer_instance, @instance_options)
association.object # eager eval required
assert_equal model1_meta, association.meta
assert_equal model1_meta, reflection.options.fetch(:meta)
Copy link
Member Author

Choose a reason for hiding this comment

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

❗️ ❗️

@bf4 bf4 added the V: 0.10.x label Apr 24, 2017
@bf4 bf4 merged commit 0f59d64 into rails-api:master Apr 30, 2017
@bf4 bf4 deleted the refactor_association branch April 30, 2017 22:41
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.

Serializer pulls relations when belongs_to declared but not included
4 participants