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

Lazy dig #44

Merged
merged 2 commits into from
Jan 11, 2020
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
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,24 @@ class BlogPostSerializer < BaseSerializer
end
```

#### Example 6: Lazy dig through relationships
In additional to previous example you may want to make use of nested lazy relationship without rendering of any nested record.
There is an `lazy_dig` method to be used for that:

```ruby
class AuthorSerializer < BaseSerializer
lazy_relationship :address
end

class BlogPostSerializer < BaseSerializer
lazy_relationship :author

attribute :author_address do
lazy_dig(:author, :address)&.full_address
end
end
```

## Performance comparison with vanilla AMS

In general the bigger and more complex your serialized records hierarchy is and the more latency you have in your DB the more you'll benefit from using this gem.
Expand Down
4 changes: 3 additions & 1 deletion lib/ams_lazy_relationships/core.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# frozen_string_literal: true

require "ams_lazy_relationships/core/lazy_relationship_method"
require "ams_lazy_relationships/core/lazy_dig_method"
require "ams_lazy_relationships/core/relationship_wrapper_methods"
require "ams_lazy_relationships/core/evaluation"

Expand All @@ -18,6 +19,7 @@ def self.ams_version

def self.included(klass)
klass.send :extend, ClassMethods
klass.send :include, LazyDigMethod
klass.send :prepend, Initializer

klass.send(:define_relationship_wrapper_methods)
Expand Down Expand Up @@ -48,7 +50,7 @@ module Initializer
def initialize(*)
super

self.class.send(:load_all_lazy_relationships, object)
self.class.send(:init_all_lazy_relationships, object)
end
end
end
46 changes: 37 additions & 9 deletions lib/ams_lazy_relationships/core/evaluation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,56 +8,84 @@ module Evaluation
LAZY_NESTING_LEVELS = 3
NESTING_START_LEVEL = 1

# Loads the lazy relationship
#
# @param relation_name [Symbol] relation name to be loaded
# @param object [Object] Lazy relationships will be loaded for this record.
def load_lazy_relationship(relation_name, object)
lrm = lazy_relationships[relation_name]
unless lrm
raise ArgumentError, "Undefined lazy '#{relation_name}' relationship for '#{name}' serializer"
end

# We need to evaluate the promise right before serializer tries
# to touch it. Otherwise the various side effects can happen:
# 1. AMS will attempt to serialize nil values with a specific V1 serializer
# 2. `lazy_association ? 'exists' : 'missing'` expression will always
# equal to 'exists'
# 3. `lazy_association&.id` expression can raise NullPointer exception
#
# Calling `__sync` will evaluate the promise.
init_lazy_relationship(lrm, object).__sync
end

# Recursively loads the tree of lazy relationships
# The nesting is limited to 3 levels.
#
# @param object [Object] Lazy relationships will be loaded for this record.
# @param level [Integer] Current nesting level
def load_all_lazy_relationships(object, level = NESTING_START_LEVEL)
def init_all_lazy_relationships(object, level = NESTING_START_LEVEL)
Copy link
Owner

Choose a reason for hiding this comment

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

Good idea with renaming this. I'd even consider renaming it to sth like collect_all_lazy_relationships or touch_all_lazy_relationships, but this is good enough as well 👍

return if level >= LAZY_NESTING_LEVELS
return unless object

return unless lazy_relationships

lazy_relationships.each_value do |lrm|
load_lazy_relationship(lrm, object, level)
init_lazy_relationship(lrm, object, level)
end
end

# @param lrm [LazyRelationshipMeta] relationship data
# @param object [Object] Object to load the relationship for
# @param level [Integer] Current nesting level
def load_lazy_relationship(lrm, object, level = NESTING_START_LEVEL)
def init_lazy_relationship(lrm, object, level = NESTING_START_LEVEL)
load_for_object = if lrm.load_for.present?
object.public_send(lrm.load_for)
else
object
end

lrm.loader.load(load_for_object) do |batch_records|
deep_load_for_yielded_records(
deep_init_for_yielded_records(
batch_records,
lrm,
level
)
end
end

def deep_load_for_yielded_records(batch_records, lrm, level)
def deep_init_for_yielded_records(batch_records, lrm, level)
# There'll be no more nesting if there's no
# reflection for this relationship. We can skip deeper lazy loading.
return unless lrm.reflection

Array.wrap(batch_records).each do |r|
deep_load_for_yielded_record(r, lrm, level)
deep_init_for_yielded_record(r, lrm, level)
end
end

def deep_load_for_yielded_record(batch_record, lrm, level)
serializer = serializer_for(batch_record, lrm.reflection.options)
def deep_init_for_yielded_record(batch_record, lrm, level)
serializer = lazy_serializer_for(batch_record, lrm: lrm)
return unless serializer

serializer.send(:load_all_lazy_relationships, batch_record, level + 1)
serializer.send(:init_all_lazy_relationships, batch_record, level + 1)
end

def lazy_serializer_for(object, lrm: nil, relation_name: nil)
lrm ||= lazy_relationships[relation_name]
return unless lrm&.reflection

serializer_for(object, lrm.reflection.options)
end
end
end
91 changes: 91 additions & 0 deletions lib/ams_lazy_relationships/core/lazy_dig_method.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# frozen_string_literal: true

module AmsLazyRelationships::Core
# Provides `lazy_dig` as an instance method for serializers, in order to make
# possible to dig relationships in depth just like `Hash#dig` do, keeping the
# laziness and N+1-free evaluation.
module LazyDigMethod
# @param relation_names [Array<Symbol>] the sequence of relation names
# to dig through.
# @return [ActiveRecord::Base, Array<ActiveRecord::Base>, nil] ActiveRecord
# objects found by digging through the sequence of nested relationships.
# Singular or plural nature of returned value depends from the
# singular/plural nature of the chain of relation_names.
#
# @example
# class AuthorSerializer < BaseSerializer
# lazy_belongs_to :address
# lazy_has_many :rewards
# end
#
# class BlogPostSerializer < BaseSerializer
# lazy_belongs_to :author
#
# attribute :author_address do
# # returns single AR object or nil
# lazy_dig(:author, :address)&.full_address
# end
#
# attribute :author_rewards do
# # returns an array of AR objects
# lazy_dig(:author, :rewards).map(&:description)
# end
# end
def lazy_dig(*relation_names)
Copy link
Owner

Choose a reason for hiding this comment

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

It'd be good to add some documentation to this method - what args it accepts, what it returns and an example of use

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Added! )

relationships = {
multiple: false,
data: [{
serializer: self.class,
object: object
}]
}

relation_names.each do |relation_name|
lazy_dig_relationship!(relation_name, relationships)
end

objects = relationships[:data].map { |r| r[:object] }

relationships[:multiple] ? objects : objects.first
end

private

def lazy_dig_relationship!(relation_name, relationships)
relationships[:data].map! do |serializer:, object:|
next_objects = lazy_dig_next_objects!(relation_name, serializer, object)
next unless next_objects

relationships[:multiple] ||= next_objects.respond_to?(:to_ary)
Copy link
Owner

Choose a reason for hiding this comment

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

Using ||= on boolean values is a bit risky, because it'll get overwritten even if the value was set previously but is false or nil.
Do we even need this :multiple key anyways?

Copy link
Collaborator Author

@stokarenko stokarenko Jan 10, 2020

Choose a reason for hiding this comment

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

well, we always initialize this flag by false at the beginning

    def lazy_dig(*relation_names)
      relationships = {
        multiple: false,
        # ...
      }

The idea is to maintain the singular/plural nature of lazy_dig returned value based on singular/plural nature of the chain of requested relation names. I.e.

lazy_dig(:company, :logo) # returns single AR record or nil
lazy_dig(:companies, :logo) # returns the array of AR records
lazy_dig(:company, :logos) # returns the array of AR records

In general, lazy_dig will return single AR object (or nil) if every relationship in a chain was a kind of singular association, and will return an array otherwise. So highlighted expression is hunting for any single plural relationship

relationships[:multiple] ||= next_objects.respond_to?(:to_ary)

. Then we decide what kind of return value will be as follows

      relationships[:multiple] ? objects : objects.first


lazy_dig_next_relationships!(relation_name, serializer, next_objects)
end

relationships[:data].flatten!
relationships[:data].compact!
end

def lazy_dig_next_objects!(relation_name, serializer, object)
serializer&.send(
Copy link
Owner

Choose a reason for hiding this comment

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

I'd even consider raising an ArgumentError exception here if there's no such (lazy) relationship in the serializer. It'd prevent nils, because of typos. On the other hand standard dig doesn't care neither 🤔

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Honestly I don't have a strong opinion on this, even don't know is that good or bad for me personally )

I just wanted to simulate the Hash#dig behavior which silently ignores any kind of misses )

My vote is to leave that as it is ) If we need more strict method (I'm not sure that we are though) - let it have a bang name like lazy_dig! )

Copy link
Collaborator Author

@stokarenko stokarenko Jan 10, 2020

Choose a reason for hiding this comment

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

I'd even consider raising an ArgumentError exception here if there's no such (lazy) relationship in the serializer.

Hm, thinking about raising the ArgumentError widely

module AmsLazyRelationships::Core
  module Evaluation
    def load_lazy_relationship(relation_name, object)
      lrm = lazy_relationships[relation_name]
      # Raise ArgumentError ?.
      return unless lrm

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

@Bajena Yeah, did that, added tests, please take a look )

:load_lazy_relationship,
relation_name,
object
)
end

def lazy_dig_next_relationships!(relation_name, serializer, next_objects)
Array.wrap(next_objects).map do |next_object|
next_serializer = serializer.send(
:lazy_serializer_for,
next_object,
relation_name: relation_name
)

{
serializer: next_serializer,
object: next_object
}
end
end
end
end
10 changes: 1 addition & 9 deletions lib/ams_lazy_relationships/core/lazy_relationship_method.rb
Original file line number Diff line number Diff line change
Expand Up @@ -39,15 +39,7 @@ def lazy_relationship(name, loader: nil, load_for: nil)
@lazy_relationships[name] = lrm

define_method :"lazy_#{name}" do
# We need to evaluate the promise right before serializer tries
# to touch it. Otherwise the various side effects can happen:
# 1. AMS will attempt to serialize nil values with a specific V1 serializer
# 2. `lazy_association ? 'exists' : 'missing'` expression will always
# equal to 'exists'
# 3. `lazy_association&.id` expression can raise NullPointer exception
#
# Calling `__sync` will evaluate the promise.
self.class.send(:load_lazy_relationship, lrm, object).__sync
self.class.send(:load_lazy_relationship, name, object)
end
end

Expand Down
Loading