Skip to content

Commit

Permalink
Add lazy_dig as serializer instance method
Browse files Browse the repository at this point in the history
  • Loading branch information
stokarenko committed Jan 9, 2020
1 parent e19c5cd commit 42f30bd
Show file tree
Hide file tree
Showing 6 changed files with 255 additions and 19 deletions.
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
44 changes: 35 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,82 @@ 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]
return unless lrm

# 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)
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
62 changes: 62 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,62 @@
# frozen_string_literal: true

module AmsLazyRelationships::Core
module LazyDigMethod
def lazy_dig(*relation_names)
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)

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(
: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
136 changes: 136 additions & 0 deletions spec/core_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -682,4 +682,140 @@ class CategorySerializer < BaseTestSerializer

include_examples "lazy loader for nested serializer"
end

describe '#lazy_dig' do
context 'collection association' do
let(:level0_serializer_class) do
module Serializer12
class CategorySerializer < BaseTestSerializer
lazy_has_many :category_followers
end

class BlogPostSerializer < BaseTestSerializer
lazy_belongs_to :category, serializer: CategorySerializer
end

class UserSerializer < BaseTestSerializer
lazy_has_many :blog_posts, serializer: BlogPostSerializer
end
end

Serializer12::UserSerializer
end

it 'does not fire unnecessary queries' do
expect { json }
.to make_database_queries(count: 0, matching: 'blog_posts')
end

context '1 level dig' do
let(:level0_serializer_class) do
Class.new(super()) do
attribute(:blog_post_ids) { lazy_dig(:blog_posts).map(&:id) }
end
end

it 'prevents N+1 queries' do
expect { json }
.to make_database_queries(count: 1, matching: 'blog_posts')
.and make_database_queries(count: 0, matching: 'categories')
end

it 'digs association properly' do
json_blog_post_ids = json.dig(:user, :blog_post_ids)
expect(json_blog_post_ids).to match_array(level1_records.map(&:id))
end
end

context '2 level dig' do
let(:level0_serializer_class) do
Class.new(super()) do
attribute(:category_ids) { lazy_dig(:blog_posts, :category).map(&:id) }
end
end

it 'prevents N+1 queries' do
expect { json }
.to make_database_queries(count: 1, matching: 'blog_posts')
.and make_database_queries(count: 1, matching: 'categories')
.and make_database_queries(count: 0, matching: 'category_followers')
end

it 'digs association properly' do
json_category_ids = json.dig(:user, :category_ids)
expect(json_category_ids).to match_array(level2_records.map(&:id))
end
end

context '3 level dig' do
let(:level0_serializer_class) do
Class.new(super()) do
attribute(:category_follower_ids) { lazy_dig(:blog_posts, :category, :category_followers).map(&:id) }
end
end

it 'prevents N+1 queries' do
expect { json }
.to make_database_queries(count: 1, matching: 'blog_posts')
.and make_database_queries(count: 1, matching: 'categories')
.and make_database_queries(count: 1, matching: 'category_followers')
end

it 'digs association properly' do
json_category_follower_ids = json.dig(:user, :category_follower_ids)
expect(json_category_follower_ids).to match_array(level3_records.map(&:id))
end
end
end

context 'singular association' do
let(:level0_serializer_class) do
module Serializer13
class CategorySerializer < BaseTestSerializer
lazy_has_many :category_followers
end

class BlogPostSerializer < BaseTestSerializer
lazy_belongs_to :category, serializer: CategorySerializer

attribute(:lazy_category_id) { lazy_dig(:category).id }
attribute(:lazy_category_follower_ids) { lazy_dig(:category, :category_followers).map(&:id) }
end

class UserSerializer < BaseTestSerializer
lazy_has_many :blog_posts, serializer: BlogPostSerializer
end
end

Serializer13::UserSerializer
end

let(:includes) { ["blog_posts"] }
let(:blog_post) { level1_records.first }
let(:json_blog_post) do
json
.dig(:user, :blog_posts)
.detect { |json_blog_post| json_blog_post[:id] == blog_post.id }
end

it 'prevents N+1 queries' do
expect { json }
.to make_database_queries(count: 1, matching: 'blog_posts')
.and make_database_queries(count: 1, matching: 'categories')
.and make_database_queries(count: 1, matching: 'category_followers')
end

it 'digs singular object for singular association' do
json_category_id = json_blog_post[:lazy_category_id]
expect(json_category_id).to eq(blog_post.category_id)
end

it 'digs collection of objects for nested collection association' do
json_lazy_category_follower_ids = json_blog_post[:lazy_category_follower_ids]
category_followers = level3_records.select { |cf| cf.category_id == blog_post.category_id }

expect(json_lazy_category_follower_ids).to match_array(category_followers.map(&:id))
end
end
end
end

0 comments on commit 42f30bd

Please sign in to comment.