diff --git a/README.md b/README.md index 63b389a51..03fbcdb3b 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ backed by ActiveRecord models or by custom objects. * [Filters] (#filters) * [Pagination] (#pagination) * [Included relationships (side-loading resources)] (#included-relationships-side-loading-resources) + * [Loaded relationships (eager-loading resources)] (#loaded-relationships-eager-loading-resources) * [Resource meta] (#resource-meta) * [Custom Links] (#custom-links) * [Callbacks] (#callbacks) @@ -1036,6 +1037,40 @@ Will get you the following payload by default: } ``` +#### Loaded relationships (eager-loading resources) + +To avoid N+1 queries when a resource attribute relies on attributes of related model records to calculate it `always_load` can be used. [Included relationships](#included-relationships-side-loading-resources) have their `always_load` relationships loaded. + +```ruby +class CompanyResource + attribute :plan_start_at + + always_load :subscription + + def plan_start_at + @model.subscription.created_at + end +end +``` + +Nested records to eager load can be declared. + +```ruby +class CompanyResource + attribute :plan_start_at + + always_load subscription: [:settings] + + def plan_start_at + @model.subscription.created_at + end + + def needs_tokens + @model.subscription.settings.needs_tokens + end +end +``` + #### Resource Meta Meta information can be included for each resource using the meta method in the resource declaration. For example: diff --git a/lib/jsonapi/processor.rb b/lib/jsonapi/processor.rb index 82566568a..e97f29da5 100644 --- a/lib/jsonapi/processor.rb +++ b/lib/jsonapi/processor.rb @@ -148,7 +148,7 @@ def show_related_resource fields = params[:fields] # TODO Should fetch related_resource from cache if caching enabled - source_resource = source_klass.find_by_key(source_id, context: context, fields: fields) + source_resource = source_klass.find_by_key(source_id, context: context, fields: fields, relationship_type: relationship_type) related_resource = source_resource.public_send(relationship_type) @@ -165,7 +165,7 @@ def show_related_resources fields = params[:fields] include_directives = params[:include_directives] - source_resource ||= source_klass.find_by_key(source_id, context: context, fields: fields) + source_resource ||= source_klass.find_by_key(source_id, context: context, fields: fields, relationship_type: relationship_type) rel_opts = { filters: filters, diff --git a/lib/jsonapi/resource.rb b/lib/jsonapi/resource.rb index 25697c536..39c1bada8 100644 --- a/lib/jsonapi/resource.rb +++ b/lib/jsonapi/resource.rb @@ -461,7 +461,7 @@ def resource_type_for(model) end end - attr_accessor :_attributes, :_relationships, :_type, :_model_hints + attr_accessor :_attributes, :_relationships, :_type, :_model_hints, :always_loads attr_writer :_allowed_filters, :_paginator def create(context) @@ -572,6 +572,10 @@ def cache_field(field) @_cache_field = field.to_sym end + def always_load(*relationships) + _add_always_loads(*relationships) + end + # Override in your resource to filter the updatable keys def updatable_fields(_context = nil) _updatable_relationships | _attributes.keys - [:id] @@ -611,14 +615,153 @@ def resolve_relationship_names_to_relations(resource_klass, model_includes, opti end end - def apply_includes(records, options = {}) + def merge_always_loads(resource_klass, model_include, options) + if resource_klass.present? + merge_loads(resource_klass.always_loads, model_include) + else + model_include + end + end + + def relationship_klass_from(resource_klass, key) + reflection = resource_klass._model_class._reflections[key.to_s] + if reflection.present? + relationship_klass = resource_klass.resource_for(reflection.class_name) + else + nil + end + rescue NameError => ex + nil + end + + def resolve_always_loads(resource_klass, model_includes, options = {}) + case model_includes + when Array + model_includes.uniq.map do |value| + resolve_always_loads(resource_klass, value, options) + end + when Hash + Hash[model_includes.map do |key, value| + relationship_klass = relationship_klass_from(resource_klass, key) + if relationship_klass.present? + [key, resolve_always_loads(relationship_klass, merge_always_loads(relationship_klass, value, options), options)] + end + end.compact] + when Symbol + relationship_klass = relationship_klass_from(resource_klass, model_includes.to_s) + if relationship_klass.present? && relationship_klass.always_loads.present? + { model_includes => relationship_klass.always_loads } + else + model_includes + end + end + end + + def model_includes(options) include_directives = options[:include_directives] if include_directives - model_includes = resolve_relationship_names_to_relations(self, include_directives.model_includes, options) - records = records.includes(model_includes) + return resolve_relationship_names_to_relations(self, include_directives.model_includes, options) end + [] + end - records + def get_loads(options = {}) + model_includes = resolve_always_loads(self, model_includes(options), options) + includes = merge_loads(always_loads, model_includes) + filter_for_relationship_type(includes, options) + end + + def merge_loads(loads_a, loads_b) + loads_a = normalize_loads(loads_a) + loads_b = normalize_loads(loads_b) + + hash_a = loads_a.detect{|inc| inc.is_a? Hash} + hash_b = loads_b.detect{|inc| inc.is_a? Hash} + + merged_hash = merge_loads_hashes(hash_a, hash_b) + if merged_hash.empty? + (loads_a + loads_b).uniq + else + without_hash_keys = loads_a.reject{|inc| inc.is_a? Hash} + loads_b.reject{|inc| inc.is_a? Hash} + without_hash_keys = without_hash_keys.uniq - merged_hash.keys + without_hash_keys << merged_hash + end + end + + def merge_loads_hashes(hash_a, hash_b) + if hash_a && hash_b + same_keys(hash_a, hash_b).inject(uniq_hash(hash_a, hash_b)) do |hsh, key| + hsh[key] = merge_loads(hash_a[key], hash_b[key]) + hsh + end + else + hash_a || hash_b || {} + end + end + + def uniq_keys(hash_a, hash_b) + (hash_a.keys - hash_b.keys) + (hash_b.keys - hash_a.keys) + end + + def same_keys(hash_a, hash_b) + hash_a.keys - uniq_keys(hash_a, hash_b) + end + + def uniq_hash(hash_a, hash_b) + uniq_keys(hash_a, hash_b).inject({}) do |hsh, key| + hsh[key] = hash_a[key] || hash_b[key] + hsh + end + end + + def normalize_loads(loads) + case loads + when Array + hsh = {} + arr = loads.inject([]) do |arr, inc| + case inc + when Hash + hsh.merge!(inc) + when Symbol + arr << inc + end + arr + end + arr << hsh unless hsh.empty? + arr + when Symbol + Array(loads) + end + end + + def filter_for_relationship_type(loads, options) + if options[:relationship_type].present? + relationship = self._relationships[options[:relationship_type].to_sym] + relation_name = relationship.relation_name(options) + + loads = loads.reduce([]) do |arr, val| + if val == relation_name + arr << val + elsif val.try(:fetch, relation_name, nil).present? + arr << { relation_name => val[relation_name] } + end + arr + end + + if loads.empty? + if relationship.resource_klass.always_load.present? + loads << { relation_name => relationship.resource_klass.always_load } + else + loads << relation_name + end + end + end + + loads + end + + def apply_loads(records, options = {}) + records.includes(get_loads(options)) end def apply_pagination(records, paginator, order_options) @@ -703,7 +846,7 @@ def apply_filters(records, filters, options = {}) end if required_includes.any? - records = apply_includes(records, options.merge(include_directives: IncludeDirectives.new(self, required_includes, force_eager_load: true))) + records = apply_loads(records, options.merge(include_directives: IncludeDirectives.new(self, required_includes, force_eager_load: true))) end records @@ -711,7 +854,7 @@ def apply_filters(records, filters, options = {}) def filter_records(filters, options, records = records(options)) records = apply_filters(records, filters, options) - apply_includes(records, options) + apply_loads(records, options) end def sort_records(records, order_options, context = {}) @@ -741,7 +884,7 @@ def resources_for(records, context) def find_by_keys(keys, options = {}) context = options[:context] records = records(options) - records = apply_includes(records, options) + records = apply_loads(records, options) models = records.where({_primary_key => keys}) models.collect do |model| self.resource_for_model(model).new(model, context) @@ -1015,6 +1158,14 @@ def _add_relationship(klass, *attrs) end end + def always_loads + @always_loads ||= [] + end + + def _add_always_loads(*relationships) + always_loads.concat(Array(relationships)) + end + # Allows JSONAPI::RelationshipBuilder to access metaprogramming hooks def inject_method_definition(name, body) define_method(name, body) diff --git a/test/unit/resource/resource_test.rb b/test/unit/resource/resource_test.rb index 2fd8b68f8..2a1eb9bef 100644 --- a/test/unit/resource/resource_test.rb +++ b/test/unit/resource/resource_test.rb @@ -98,6 +98,156 @@ class RelatedResource < MyModule::RelatedResource end end +class AlwaysIncludeTests < ActiveSupport::TestCase + module Ai + class PersonResource < ::PersonResource + always_load :hair_cut, :comments + end + + class CommentResource < ::CommentResource + end + + class TagResource < ::TagResource + always_load :posts + end + + class PostResource < ::PostResource + end + + class VehicleResource < ::VehicleResource + end + + class HairCutResource < ::HairCutResource + end + + class SectionResource < ::SectionResource + + end + end + + def test_always_loads + assert_equal([:hair_cut, :comments], Ai::PersonResource.always_loads) + end + + def test_resolve_always_loads + result = JSONAPI::Resource.resolve_always_loads(Ai::PersonResource, [:vehicles, :comments]) + assert_equal([:vehicles, :comments], result) + end + + def test_resolve_always_loads_nested + expected = {comments: [{tags: [:posts]}]} + + result = JSONAPI::Resource.resolve_always_loads(Ai::PersonResource, comments: [:tags]) + assert_equal(expected, result) + + result = JSONAPI::Resource.resolve_always_loads(Ai::PersonResource, comments: [{tags: [:posts]}]) + assert_equal(expected, result) + end + + def test_get_loads + include_directive = JSONAPI::IncludeDirectives.new(Ai::PersonResource, ['comments']) + expected = [:hair_cut, :comments] + + result = Ai::PersonResource.get_loads(include_directives: include_directive) + assert_equal(expected, result) + end + + def test_get_loads_again + include_directive = JSONAPI::IncludeDirectives.new(Ai::PersonResource, ['comments.tags', 'posts.section']) + expected = [:hair_cut, {comments: [{tags: [:posts]}], posts: [:section]}] + + result = Ai::PersonResource.get_loads(include_directives: include_directive) + assert_equal(expected, result) + end + + def test_get_loads_nested + include_directive = JSONAPI::IncludeDirectives.new(Ai::PersonResource, ['comments.tags']) + expected = [:hair_cut, {comments: [{tags: [:posts]}]}] + + result = Ai::PersonResource.get_loads(include_directives: include_directive) + assert_equal(expected, result) + end + + def test_get_loads_nested_more_complex + include_directive = JSONAPI::IncludeDirectives.new(Ai::PersonResource, ['comments.tags', 'posts.section']) + expected = [:hair_cut, {comments: [{tags: [:posts]}], posts: [:section]}] + + result = Ai::PersonResource.get_loads(include_directives: include_directive) + assert_equal(expected, result) + end + + def test_normalize_loads + result = JSONAPI::Resource.normalize_loads([{comments: [{tags: [:posts]}]}, {posts: [:section]}, :hair_cut]) + assert_equal([:hair_cut, {comments: [{tags: [:posts]}], posts: [:section]}], result) + end + + def test_merge_loads + loads_a = [:hair_cut, {comments: [{tags: [:posts]}]}, :posts] + loads_b = [{comments: :tags, posts: [:section]}, :hair_cut] + expected = [:hair_cut, {comments: [{tags: [:posts]}], posts: [:section]}] + result = JSONAPI::Resource.merge_loads(loads_a, loads_b) + assert_equal(expected, result) + end +end + +class AlwaysIncludeRelationshipTest < ActiveSupport::TestCase + module AiRel + class PersonResource < ::PersonResource + always_load :hair_cut, :comments + end + + class CommentResource < ::CommentResource + end + + class TagResource < ::TagResource + always_load posts: [:section] + end + + class PostResource < ::PostResource + end + + class SectionResource < ::SectionResource + end + end + + def test_get_loads_for_relationship + include_directive = JSONAPI::IncludeDirectives.new(AiRel::PersonResource, ['comments.tags', 'posts.section']) + expected = [{comments: [{tags: [{posts: [:section]}]}]}] + + result = AiRel::PersonResource.get_loads(include_directives: include_directive, relationship_type: :comments) + assert_equal(expected, result) + end + + def test_get_loads_for_relationship_2 + include_directive = JSONAPI::IncludeDirectives.new(AiRel::PersonResource, ['posts.section']) + expected = [:comments] + + result = AiRel::PersonResource.get_loads(include_directives: include_directive, relationship_type: :comments) + assert_equal(expected, result) + end + + def test_get_loads_for_relationship_no_model_includes + include_directive = JSONAPI::IncludeDirectives.new(AiRel::CommentResource, ['post']) + expected = [{tags: [{posts: [:section]}]}] + + result = AiRel::CommentResource.get_loads(include_directives: include_directive, relationship_type: :tags) + assert_equal(expected, result) + end +end + +class AlwaysIncludeNoResourceTest < ActiveSupport::TestCase + module NoResource + class MoonResource < ::MoonResource + always_load :craters + end + end + + def test_always_include_no_resource_no_error_raised + result = JSONAPI::Resource.resolve_always_loads(NoResource::MoonResource, [:craters]) + assert_equal([:craters], result) + end +end + class ResourceTest < ActiveSupport::TestCase def setup @post = Post.first