-
Notifications
You must be signed in to change notification settings - Fork 1.4k
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
Conversation
@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 = { |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
notes: 5f4a3a5
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
and https://github.com/rails/rails/blob/master/activerecord/lib/active_record/reflection.rb has some interesting ideas in it
self.options = reflection.options.dup | ||
# Pass the parent's namespace onto the child serializer | ||
options[:namespace] ||= parent_serializer_options[:namespace] | ||
serializer |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
5f4a3a5
to
822d718
Compare
# @param reflection [Reflection] | ||
# @param association_options [Hash] required: include_slice | ||
# @return [Association] | ||
def reflect_association(reflection, association_options) |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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...
looks interesting, I'll review soonish(tm) |
Can you add to the description what you're trying to accomplish? |
@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 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,
The thing where it doesn't actually call the association is a The way this has been solved in JSONAPI-Resources In brief:
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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
- 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? - 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 calledSerializer
, I know, and that is doing things that are not responsibility of a serializer.
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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 private
so we know what can be changed later without side effects.
lib/active_model/serializer.rb
Outdated
@@ -91,6 +92,8 @@ def self.get_serializer_for(klass, namespace = nil) | |||
serializer_class | |||
elsif klass.superclass | |||
get_serializer_for(klass.superclass) | |||
else | |||
nil |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
873d678
to
19ecd4a
Compare
19ecd4a
to
474b2ad
Compare
# include_slice: ActiveModelSerializers.default_include_directive, | ||
# parent_serializer: comment_summary_serializer, | ||
# parent_serializer_options: comment_summary_serializer.send(:instance_options), | ||
# } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
example out of date
There was a problem hiding this comment.
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
f2a0045
to
d21c608
Compare
@kurko Can you take another look at this? I'd like to start fixing the as_json |
875b6a4
to
fad4ef1
Compare
There was a problem hiding this 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. |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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]) |
There was a problem hiding this comment.
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...
test/serializers/reflection_test.rb
Outdated
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) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
❗️ ❗️
test/serializers/reflection_test.rb
Outdated
@@ -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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
❗️ ❗️ 💀 ❗️ ❗️
test/serializers/reflection_test.rb
Outdated
@@ -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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
❗️ ❗️ 💀 ❗️ ❗️
test/serializers/reflection_test.rb
Outdated
@@ -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) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
❗️ ❗️
test/serializers/reflection_test.rb
Outdated
@@ -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) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
❗️ ❗️
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
andid
, 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.
In #1857 I described it as:
If the serializer has an association,
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:
has_many :authors
:author_ids
has_one :author
:author_id
belongs_to :author
:author_id
Caveat:
This will only work on active record objects
Includes:
Follows:
Related: