From 837b5623f8eb2060755e660a66c61b430bbb8755 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Tue, 7 Feb 2017 14:56:38 -0500 Subject: [PATCH] Record Accessors Removing AR specific code from Resource and moving to new RecordAccessor class. --- lib/jsonapi-resources.rb | 2 + lib/jsonapi/active_record_accessor.rb | 501 ++++++++++++++++++++++++ lib/jsonapi/cached_resource_fragment.rb | 2 +- lib/jsonapi/configuration.rb | 13 + lib/jsonapi/operation_result.rb | 10 + lib/jsonapi/processor.rb | 61 ++- lib/jsonapi/record_accessor.rb | 66 ++++ lib/jsonapi/relationship.rb | 2 +- lib/jsonapi/relationship_builder.rb | 167 -------- lib/jsonapi/request_parser.rb | 16 +- lib/jsonapi/resource.rb | 439 +++++---------------- lib/jsonapi/routing_ext.rb | 18 +- test/controllers/controller_test.rb | 11 +- test/fixtures/active_record.rb | 2 +- test/unit/resource/resource_test.rb | 86 +++- 15 files changed, 812 insertions(+), 584 deletions(-) create mode 100644 lib/jsonapi/active_record_accessor.rb create mode 100644 lib/jsonapi/record_accessor.rb delete mode 100644 lib/jsonapi/relationship_builder.rb diff --git a/lib/jsonapi-resources.rb b/lib/jsonapi-resources.rb index 194de869a..e16022ff6 100644 --- a/lib/jsonapi-resources.rb +++ b/lib/jsonapi-resources.rb @@ -24,3 +24,5 @@ require 'jsonapi/operation_result' require 'jsonapi/callbacks' require 'jsonapi/link_builder' +require 'jsonapi/record_accessor' +require 'jsonapi/active_record_accessor' diff --git a/lib/jsonapi/active_record_accessor.rb b/lib/jsonapi/active_record_accessor.rb new file mode 100644 index 000000000..a92b39b53 --- /dev/null +++ b/lib/jsonapi/active_record_accessor.rb @@ -0,0 +1,501 @@ +require 'jsonapi/record_accessor' + +module JSONAPI + class ActiveRecordAccessor < RecordAccessor + + # RecordAccessor methods + + def find_resource(filters, options = {}) + if options[:caching] && options[:caching][:cache_serializer_output] + find_serialized_with_caching(filters, options[:caching][:serializer], options) + else + _resource_klass.resources_for(find_records(filters, options), options[:context]) + end + end + + def find_resource_by_key(key, options = {}) + if options[:caching] && options[:caching][:cache_serializer_output] + find_by_key_serialized_with_caching(key, options[:caching][:serializer], options) + else + records = find_records({ _resource_klass._primary_key => key }, options.except(:paginator, :sort_criteria)) + model = records.first + fail JSONAPI::Exceptions::RecordNotFound.new(key) if model.nil? + _resource_klass.resource_for(model, options[:context]) + end + end + + def find_resources_by_keys(keys, options = {}) + records = records(options) + records = apply_includes(records, options) + records = records.where({ _resource_klass._primary_key => keys }) + + _resource_klass.resources_for(records, options[:context]) + end + + def find_count(filters, options = {}) + count_records(filter_records(filters, options)) + end + + def related_resource(resource, relationship_name, options = {}) + relationship = resource.class._relationships[relationship_name.to_sym] + + if relationship.polymorphic? + associated_model = records_for_relationship(resource, relationship_name, options) + resource_klass = resource.class.resource_klass_for_model(associated_model) if associated_model + return resource_klass.new(associated_model, resource.context) if resource_klass && associated_model + else + resource_klass = relationship.resource_klass + if resource_klass + associated_model = records_for_relationship(resource, relationship_name, options) + return associated_model ? resource_klass.new(associated_model, resource.context) : nil + end + end + end + + def related_resources(resource, relationship_name, options = {}) + relationship = resource.class._relationships[relationship_name.to_sym] + relationship_resource_klass = relationship.resource_klass + + if options[:caching] && options[:caching][:cache_serializer_output] + scope = relationship_resource_klass._record_accessor.records_for_relationship(resource, relationship_name, options) + relationship_resource_klass._record_accessor.find_serialized_with_caching(scope, options[:caching][:serializer], options) + else + records = records_for_relationship(resource, relationship_name, options) + return records.collect do |record| + klass = relationship.polymorphic? ? resource.class.resource_klass_for_model(record) : relationship_resource_klass + klass.new(record, resource.context) + end + end + end + + def count_for_relationship(resource, relationship_name, options = {}) + relationship = resource.class._relationships[relationship_name.to_sym] + + context = resource.context + + relation_name = relationship.relation_name(context: context) + records = records_for(resource, relation_name) + + resource_klass = relationship.resource_klass + + filters = options.fetch(:filters, {}) + unless filters.nil? || filters.empty? + records = resource_klass._record_accessor.apply_filters(records, filters, options) + end + + records.count(:all) + end + + def foreign_key(resource, relationship_name, options = {}) + relationship = resource.class._relationships[relationship_name.to_sym] + + if relationship.belongs_to? + resource._model.method(relationship.foreign_key).call + else + records = records_for_relationship(resource, relationship_name, options) + return nil if records.nil? + records.public_send(relationship.resource_klass._primary_key) + end + end + + def foreign_keys(resource, relationship_name, options = {}) + relationship = resource.class._relationships[relationship_name.to_sym] + + records = records_for_relationship(resource, relationship_name, options) + records.collect do |record| + record.public_send(relationship.resource_klass._primary_key) + end + end + + # protected-ish methods left public for tests and what not + + def find_serialized_with_caching(filters_or_source, serializer, options = {}) + if filters_or_source.is_a?(ActiveRecord::Relation) + return cached_resources_for(filters_or_source, serializer, options) + elsif _resource_klass._model_class.respond_to?(:all) && _resource_klass._model_class.respond_to?(:arel_table) + records = find_records(filters_or_source, options.except(:include_directives)) + return cached_resources_for(records, serializer, options) + else + # :nocov: + warn('Caching enabled on model that does not support ActiveRelation') + # :nocov: + end + end + + def find_by_key_serialized_with_caching(key, serializer, options = {}) + if _resource_klass._model_class.respond_to?(:all) && _resource_klass._model_class.respond_to?(:arel_table) + results = find_serialized_with_caching({ _resource_klass._primary_key => key }, serializer, options) + result = results.first + fail JSONAPI::Exceptions::RecordNotFound.new(key) if result.nil? + return result + else + # :nocov: + warn('Caching enabled on model that does not support ActiveRelation') + # :nocov: + end + end + + def records_for_relationship(resource, relationship_name, options = {}) + relationship = resource.class._relationships[relationship_name.to_sym] + + context = resource.context + + relation_name = relationship.relation_name(context: context) + records = records_for(resource, relation_name) + + resource_klass = relationship.resource_klass + + filters = options.fetch(:filters, {}) + unless filters.nil? || filters.empty? + records = resource_klass._record_accessor.apply_filters(records, filters, options) + end + + sort_criteria = options.fetch(:sort_criteria, {}) + unless sort_criteria.nil? || sort_criteria.empty? + order_options = relationship.resource_klass.construct_order_options(sort_criteria) + records = apply_sort(records, order_options, context) + end + + paginator = options[:paginator] + if paginator + records = apply_pagination(records, paginator, order_options) + end + + records + end + + # Implement self.records on the resource if you want to customize the relation for + # finder methods (find, find_by_key, find_serialized_with_caching) + def records(_options = {}) + if defined?(_resource_klass.records) + _resource_klass.records(_options) + else + _resource_klass._model_class.all + end + end + + # Implement records_for on the resource to customize how the associated records + # are fetched for a model. Particularly helpful for authorization. + def records_for(resource, relation_name) + if resource.respond_to?(:records_for) + return resource.records_for(relation_name) + end + + relationship = resource.class._relationships[relation_name] + + if relationship.is_a?(JSONAPI::Relationship::ToMany) + if resource.respond_to?(:"records_for_#{relation_name}") + return resource.method(:"records_for_#{relation_name}").call + end + else + if resource.respond_to?(:"record_for_#{relation_name}") + return resource.method(:"record_for_#{relation_name}").call + end + end + + resource._model.public_send(relation_name) + end + + def apply_includes(records, options = {}) + include_directives = options[:include_directives] + if include_directives + model_includes = resolve_relationship_names_to_relations(_resource_klass, include_directives.model_includes, options) + records = records.includes(model_includes) + end + + records + end + + def apply_pagination(records, paginator, order_options) + records = paginator.apply(records, order_options) if paginator + records + end + + def apply_sort(records, order_options, context = {}) + if defined?(_resource_klass.apply_sort) + _resource_klass.apply_sort(records, order_options, context) + else + if order_options.any? + order_options.each_pair do |field, direction| + if field.to_s.include?(".") + *model_names, column_name = field.split(".") + + associations = _lookup_association_chain([records.model.to_s, *model_names]) + joins_query = _build_joins([records.model, *associations]) + + # _sorting is appended to avoid name clashes with manual joins eg. overridden filters + order_by_query = "#{associations.last.name}_sorting.#{column_name} #{direction}" + records = records.joins(joins_query).order(order_by_query) + else + records = records.order(field => direction) + end + end + end + + records + end + end + + def _lookup_association_chain(model_names) + associations = [] + model_names.inject do |prev, current| + association = prev.classify.constantize.reflect_on_all_associations.detect do |assoc| + assoc.name.to_s.downcase == current.downcase + end + associations << association + association.class_name + end + + associations + end + + def _build_joins(associations) + joins = [] + + associations.inject do |prev, current| + joins << "LEFT JOIN #{current.table_name} AS #{current.name}_sorting ON #{current.name}_sorting.id = #{prev.table_name}.#{current.foreign_key}" + current + end + joins.join("\n") + end + + def apply_filter(records, filter, value, options = {}) + strategy = _resource_klass._allowed_filters.fetch(filter.to_sym, Hash.new)[:apply] + + if strategy + if strategy.is_a?(Symbol) || strategy.is_a?(String) + _resource_klass.send(strategy, records, value, options) + else + strategy.call(records, value, options) + end + else + records.where(filter => value) + end + end + + # Assumes ActiveRecord's counting. Override if you need a different counting method + def count_records(records) + records.count(:all) + end + + def resolve_relationship_names_to_relations(resource_klass, model_includes, options = {}) + case model_includes + when Array + return model_includes.map do |value| + resolve_relationship_names_to_relations(resource_klass, value, options) + end + when Hash + model_includes.keys.each do |key| + relationship = resource_klass._relationships[key] + value = model_includes[key] + model_includes.delete(key) + model_includes[relationship.relation_name(options)] = resolve_relationship_names_to_relations(relationship.resource_klass, value, options) + end + return model_includes + when Symbol + relationship = resource_klass._relationships[model_includes] + return relationship.relation_name(options) + end + end + + def apply_filters(records, filters, options = {}) + required_includes = [] + + if filters + filters.each do |filter, value| + if _resource_klass._relationships.include?(filter) + if _resource_klass._relationships[filter].belongs_to? + records = apply_filter(records, _resource_klass._relationships[filter].foreign_key, value, options) + else + required_includes.push(filter.to_s) + records = apply_filter(records, "#{_resource_klass._relationships[filter].table_name}.#{_resource_klass._relationships[filter].primary_key}", value, options) + end + else + records = apply_filter(records, filter, value, options) + end + end + end + + if required_includes.any? + records = apply_includes(records, options.merge(include_directives: IncludeDirectives.new(_resource_klass, required_includes, force_eager_load: true))) + end + + records + end + + def filter_records(filters, options, records = records(options)) + records = apply_filters(records, filters, options) + apply_includes(records, options) + end + + def sort_records(records, order_options, context = {}) + apply_sort(records, order_options, context) + end + + def cached_resources_for(records, serializer, options) + if _resource_klass.caching? + t = _resource_klass._model_class.arel_table + cache_ids = pluck_arel_attributes(records, t[_resource_klass._primary_key], t[_resource_klass._cache_field]) + resources = CachedResourceFragment.fetch_fragments(_resource_klass, serializer, options[:context], cache_ids) + else + resources = _resource_klass.resources_for(records, options[:context]).map { |r| [r.id, r] }.to_h + end + + preload_included_fragments(resources, records, serializer, options) + + resources.values + end + + def find_records(filters, options = {}) + if defined?(_resource_klass.find_records) + ActiveSupport::Deprecation.warn "In #{_resource_klass.name} you overrode `find_records`. "\ + "`find_records` has been deprecated in favor of using `apply` "\ + "and `verify` callables on the filter." + + _resource_klass.find_records(filters, options) + else + context = options[:context] + + records = filter_records(filters, options) + + sort_criteria = options.fetch(:sort_criteria) { [] } + order_options = _resource_klass.construct_order_options(sort_criteria) + records = sort_records(records, order_options, context) + + records = apply_pagination(records, options[:paginator], order_options) + + records + end + end + + def preload_included_fragments(resources, records, serializer, options) + return if resources.empty? + res_ids = resources.keys + + include_directives = options[:include_directives] + return unless include_directives + + context = options[:context] + + # For each association, including indirect associations, find the target record ids. + # Even if a target class doesn't have caching enabled, we still have to look up + # and match the target ids here, because we can't use ActiveRecord#includes. + # + # Note that `paths` returns partial paths before complete paths, so e.g. the partial + # fragments for posts.comments will exist before we start working with posts.comments.author + target_resources = {} + include_directives.paths.each do |path| + # If path is [:posts, :comments, :author], then... + pluck_attrs = [] # ...will be [posts.id, comments.id, authors.id, authors.updated_at] + pluck_attrs << _resource_klass._model_class.arel_table[_resource_klass._primary_key] + + relation = records + .except(:limit, :offset, :order) + .where({ _resource_klass._primary_key => res_ids }) + + # These are updated as we iterate through the association path; afterwards they will + # refer to the final resource on the path, i.e. the actual resource to find in the cache. + # So e.g. if path is [:posts, :comments, :author], then after iteration... + parent_klass = nil # Comment + klass = _resource_klass # Person + relationship = nil # JSONAPI::Relationship::ToOne for CommentResource.author + table = nil # people + assocs_path = [] # [ :posts, :approved_comments, :author ] + ar_hash = nil # { :posts => { :approved_comments => :author } } + + # For each step on the path, figure out what the actual table name/alias in the join + # will be, and include the primary key of that table in our list of fields to select + non_polymorphic = true + path.each do |elem| + relationship = klass._relationships[elem] + if relationship.polymorphic + # Can't preload through a polymorphic belongs_to association, ResourceSerializer + # will just have to bypass the cache and load the real Resource. + non_polymorphic = false + break + end + assocs_path << relationship.relation_name(options).to_sym + # Converts [:a, :b, :c] to Rails-style { :a => { :b => :c }} + ar_hash = assocs_path.reverse.reduce { |memo, step| { step => memo } } + # We can't just look up the table name from the resource class, because Arel could + # have used a table alias if the relation includes a self-reference. + join_source = relation.joins(ar_hash).arel.source.right.reverse.find do |arel_node| + arel_node.is_a?(Arel::Nodes::InnerJoin) + end + table = join_source.left + parent_klass = klass + klass = relationship.resource_klass + pluck_attrs << table[klass._primary_key] + end + next unless non_polymorphic + + # Pre-fill empty hashes for each resource up to the end of the path. + # This allows us to later distinguish between a preload that returned nothing + # vs. a preload that never ran. + prefilling_resources = resources.values + path.each do |rel_name| + rel_name = serializer.key_formatter.format(rel_name) + prefilling_resources.map! do |res| + res.preloaded_fragments[rel_name] ||= {} + res.preloaded_fragments[rel_name].values + end + prefilling_resources.flatten!(1) + end + + pluck_attrs << table[klass._cache_field] if klass.caching? + relation = relation.joins(ar_hash) + if relationship.is_a?(JSONAPI::Relationship::ToMany) + # Rails doesn't include order clauses in `joins`, so we have to add that manually here. + # FIXME Should find a better way to reflect on relationship ordering. :-( + relation = relation.order(parent_klass._model_class.new.send(assocs_path.last).arel.orders) + end + + # [[post id, comment id, author id, author updated_at], ...] + id_rows = pluck_arel_attributes(relation.joins(ar_hash), *pluck_attrs) + + target_resources[klass.name] ||= {} + + if klass.caching? + sub_cache_ids = id_rows + .map { |row| row.last(2) } + .reject { |row| target_resources[klass.name].has_key?(row.first) } + .uniq + target_resources[klass.name].merge! CachedResourceFragment.fetch_fragments( + klass, serializer, context, sub_cache_ids + ) + else + sub_res_ids = id_rows + .map(&:last) + .reject { |id| target_resources[klass.name].has_key?(id) } + .uniq + found = klass.find({ klass._primary_key => sub_res_ids }, context: options[:context]) + target_resources[klass.name].merge! found.map { |r| [r.id, r] }.to_h + end + + id_rows.each do |row| + res = resources[row.first] + path.each_with_index do |rel_name, index| + rel_name = serializer.key_formatter.format(rel_name) + rel_id = row[index+1] + assoc_rels = res.preloaded_fragments[rel_name] + if index == path.length - 1 + assoc_rels[rel_id] = target_resources[klass.name].fetch(rel_id) + else + res = assoc_rels[rel_id] + end + end + end + end + end + + def pluck_arel_attributes(relation, *attrs) + conn = relation.connection + quoted_attrs = attrs.map do |attr| + quoted_table = conn.quote_table_name(attr.relation.table_alias || attr.relation.name) + quoted_column = conn.quote_column_name(attr.name) + "#{quoted_table}.#{quoted_column}" + end + relation.pluck(*quoted_attrs) + end + end +end diff --git a/lib/jsonapi/cached_resource_fragment.rb b/lib/jsonapi/cached_resource_fragment.rb index 67df1d9a5..a8ed301b2 100644 --- a/lib/jsonapi/cached_resource_fragment.rb +++ b/lib/jsonapi/cached_resource_fragment.rb @@ -65,7 +65,7 @@ def to_cache_value end def to_real_resource - rs = Resource.resource_for(self.type).find_by_keys([self.id], {context: self.context}) + rs = Resource.resource_klass_for(self.type).find_by_keys([self.id], {context: self.context}) return rs.try(:first) end diff --git a/lib/jsonapi/configuration.rb b/lib/jsonapi/configuration.rb index 4601bb351..52c34ab81 100644 --- a/lib/jsonapi/configuration.rb +++ b/lib/jsonapi/configuration.rb @@ -1,5 +1,7 @@ require 'jsonapi/formatter' require 'jsonapi/processor' +require 'jsonapi/record_accessor' +require 'jsonapi/active_record_accessor' require 'concurrent' module JSONAPI @@ -14,6 +16,7 @@ class Configuration :default_paginator, :default_page_size, :maximum_page_size, + :default_record_accessor_klass, :default_processor_klass, :use_text_errors, :top_level_links_include_pagination, @@ -92,6 +95,12 @@ def initialize self.always_include_to_one_linkage_data = false self.always_include_to_many_linkage_data = false + # Record Accessor + # The default Record Accessor is the ActiveRecordAccessor which provides + # caching access to ActiveRecord backed models. Custom Accessors can be specified + # in order to support other models. + self.default_record_accessor_klass = JSONAPI::ActiveRecordAccessor + # The default Operation Processor to use if one is not defined specifically # for a Resource. self.default_processor_klass = JSONAPI::Processor @@ -201,6 +210,10 @@ def default_processor_klass=(default_processor_klass) @default_processor_klass = default_processor_klass end + def default_record_accessor_klass=(default_record_accessor_klass) + @default_record_accessor_klass = default_record_accessor_klass + end + attr_writer :allow_include, :allow_sort, :allow_filter attr_writer :default_paginator diff --git a/lib/jsonapi/operation_result.rb b/lib/jsonapi/operation_result.rb index eed2916d4..3ea7f892f 100644 --- a/lib/jsonapi/operation_result.rb +++ b/lib/jsonapi/operation_result.rb @@ -30,7 +30,9 @@ def initialize(code, errors, options = {}) def to_hash(serializer = nil) { errors: errors.collect do |error| + # :nocov: error.to_hash + # :nocov: end } end @@ -48,7 +50,9 @@ def to_hash(serializer = nil) if serializer serializer.serialize_to_hash(resource) else + # :nocov: {} + # :nocov: end end end @@ -68,7 +72,9 @@ def to_hash(serializer) if serializer serializer.serialize_to_hash(resources) else + # :nocov: {} + # :nocov: end end end @@ -86,7 +92,9 @@ def to_hash(serializer = nil) if serializer serializer.serialize_to_hash(resources) else + # :nocov: {} + # :nocov: end end end @@ -104,7 +112,9 @@ def to_hash(serializer = nil) if serializer serializer.serialize_to_links_hash(parent_resource, relationship) else + # :nocov: {} + # :nocov: end end end diff --git a/lib/jsonapi/processor.rb b/lib/jsonapi/processor.rb index f7ab30b19..e4279bfd1 100644 --- a/lib/jsonapi/processor.rb +++ b/lib/jsonapi/processor.rb @@ -73,16 +73,14 @@ def find include_directives: include_directives, sort_criteria: sort_criteria, paginator: paginator, - fields: fields + fields: fields, + caching: { + cache_serializer_output: params[:cache_serializer_output], + serializer: params[:serializer] + } } - resource_records = if params[:cache_serializer_output] - resource_klass.find_serialized_with_caching(verified_filters, - params[:serializer], - find_options) - else - resource_klass.find(verified_filters, find_options) - end + resources = resource_klass.find(verified_filters, find_options) page_options = result_options if (JSONAPI.configuration.top_level_meta_include_record_count || @@ -93,14 +91,14 @@ def find end if (JSONAPI.configuration.top_level_meta_include_page_count && page_options[:record_count]) - page_options[:page_count] = paginator.calculate_page_count(page_options[:record_count]) + page_options[:page_count] = paginator ? paginator.calculate_page_count(page_options[:record_count]) : 1 end if JSONAPI.configuration.top_level_links_include_pagination && paginator page_options[:pagination_params] = paginator.links_page_params(page_options) end - return JSONAPI::ResourcesOperationResult.new(:ok, resource_records, page_options) + return JSONAPI::ResourcesOperationResult.new(:ok, resources, page_options) end def show @@ -113,18 +111,16 @@ def show find_options = { context: context, include_directives: include_directives, - fields: fields + fields: fields, + caching: { + cache_serializer_output: params[:cache_serializer_output], + serializer: params[:serializer] + } } - resource_record = if params[:cache_serializer_output] - resource_klass.find_by_key_serialized_with_caching(key, - params[:serializer], - find_options) - else - resource_klass.find_by_key(key, find_options) - end + resource = resource_klass.find_by_key(key, find_options) - return JSONAPI::ResourceOperationResult.new(:ok, resource_record, result_options) + return JSONAPI::ResourceOperationResult.new(:ok, resource, result_options) end def show_relationship @@ -171,32 +167,19 @@ def show_related_resources paginator: paginator, fields: fields, context: context, - include_directives: include_directives + include_directives: include_directives, + caching: { + cache_serializer_output: params[:cache_serializer_output], + serializer: params[:serializer] + } } - if params[:cache_serializer_output] - # TODO Could also avoid instantiating source_resource as actual Resource by - # allowing LinkBuilder to accept CachedResourceFragment as source in - # relationships_related_link - scope = source_resource.public_send(:"records_for_#{relationship_type}", rel_opts) - relationship = source_klass._relationship(relationship_type) - related_resources = relationship.resource_klass.find_serialized_with_caching( - scope, - params[:serializer], - rel_opts - ) - else - related_resources = source_resource.public_send(relationship_type, rel_opts) - end + related_resources = source_resource.public_send(relationship_type, rel_opts) if ((JSONAPI.configuration.top_level_meta_include_record_count) || (paginator && paginator.class.requires_record_count) || (JSONAPI.configuration.top_level_meta_include_page_count)) - related_resource_records = source_resource.public_send("records_for_" + relationship_type) - records = resource_klass.filter_records(filters, { context: context }, - related_resource_records) - - record_count = resource_klass.count_records(records) + record_count = source_resource.count_for_relationship(relationship_type, rel_opts) end if (JSONAPI.configuration.top_level_meta_include_page_count && record_count) diff --git a/lib/jsonapi/record_accessor.rb b/lib/jsonapi/record_accessor.rb new file mode 100644 index 000000000..3cf39ee99 --- /dev/null +++ b/lib/jsonapi/record_accessor.rb @@ -0,0 +1,66 @@ +module JSONAPI + class RecordAccessor + attr_reader :_resource_klass + + def initialize(resource_klass) + @_resource_klass = resource_klass + end + + # Resource records + def find_resource(_filters, _options = {}) + # :nocov: + raise 'Abstract method called' + # :nocov: + end + + def find_resource_by_key(_key, options = {}) + # :nocov: + raise 'Abstract method called' + # :nocov: + end + + def find_resources_by_keys(_keys, options = {}) + # :nocov: + raise 'Abstract method called' + # :nocov: + end + + def find_count(_filters, _options = {}) + # :nocov: + raise 'Abstract method called' + # :nocov: + end + + # Relationship records + def related_resource(_resource, _relationship_name, _options = {}) + # :nocov: + raise 'Abstract method called' + # :nocov: + end + + def related_resources(_resource, _relationship_name, _options = {}) + # :nocov: + raise 'Abstract method called' + # :nocov: + end + + def count_for_relationship(_resource, _relationship_name, _options = {}) + # :nocov: + raise 'Abstract method called' + # :nocov: + end + + # Keys + def foreign_key(_resource, _relationship_name, options = {}) + # :nocov: + raise 'Abstract method called' + # :nocov: + end + + def foreign_keys(_resource, _relationship_name, _options = {}) + # :nocov: + raise 'Abstract method called' + # :nocov: + end + end +end \ No newline at end of file diff --git a/lib/jsonapi/relationship.rb b/lib/jsonapi/relationship.rb index 978d1606e..b56bfaa01 100644 --- a/lib/jsonapi/relationship.rb +++ b/lib/jsonapi/relationship.rb @@ -23,7 +23,7 @@ def primary_key end def resource_klass - @resource_klass ||= @parent_resource.resource_for(@class_name) + @resource_klass ||= @parent_resource.resource_klass_for(@class_name) end def table_name diff --git a/lib/jsonapi/relationship_builder.rb b/lib/jsonapi/relationship_builder.rb deleted file mode 100644 index 9c7364d2f..000000000 --- a/lib/jsonapi/relationship_builder.rb +++ /dev/null @@ -1,167 +0,0 @@ -module JSONAPI - class RelationshipBuilder - attr_reader :model_class, :options, :relationship_class - delegate :register_relationship, to: :@resource_class - - def initialize(relationship_class, model_class, options) - @relationship_class = relationship_class - @model_class = model_class - @resource_class = options[:parent_resource] - @options = options - end - - def define_relationship_methods(relationship_name) - # Initialize from an ActiveRecord model's properties - if model_class && model_class.ancestors.collect{|ancestor| ancestor.name}.include?('ActiveRecord::Base') - model_association = model_class.reflect_on_association(relationship_name) - if model_association - options[:class_name] ||= model_association.class_name - end - end - - relationship = register_relationship( - relationship_name, - relationship_class.new(relationship_name, options) - ) - - foreign_key = define_foreign_key_setter(relationship.foreign_key) - - case relationship - when JSONAPI::Relationship::ToOne - associated = define_resource_relationship_accessor(:one, relationship_name) - args = [relationship, foreign_key, associated, relationship_name] - - relationship.belongs_to? ? build_belongs_to(*args) : build_has_one(*args) - when JSONAPI::Relationship::ToMany - associated = define_resource_relationship_accessor(:many, relationship_name) - - build_to_many(relationship, foreign_key, associated, relationship_name) - end - end - - def define_foreign_key_setter(foreign_key) - define_on_resource "#{foreign_key}=" do |value| - @model.method("#{foreign_key}=").call(value) - end - foreign_key - end - - def define_resource_relationship_accessor(type, relationship_name) - associated_records_method_name = { - one: "record_for_#{relationship_name}", - many: "records_for_#{relationship_name}" - } - .fetch(type) - - define_on_resource associated_records_method_name do |options = {}| - relationship = self.class._relationships[relationship_name] - relation_name = relationship.relation_name(context: @context) - records = records_for(relation_name) - - resource_klass = relationship.resource_klass - - filters = options.fetch(:filters, {}) - unless filters.nil? || filters.empty? - records = resource_klass.apply_filters(records, filters, options) - end - - sort_criteria = options.fetch(:sort_criteria, {}) - unless sort_criteria.nil? || sort_criteria.empty? - order_options = relationship.resource_klass.construct_order_options(sort_criteria) - records = resource_klass.apply_sort(records, order_options, @context) - end - - paginator = options[:paginator] - if paginator - records = resource_klass.apply_pagination(records, paginator, order_options) - end - - records - end - - associated_records_method_name - end - - def build_belongs_to(relationship, foreign_key, associated_records_method_name, relationship_name) - # Calls method matching foreign key name on model instance - define_on_resource foreign_key do - @model.method(foreign_key).call - end - - # Returns instantiated related resource object or nil - define_on_resource relationship_name do |options = {}| - relationship = self.class._relationships[relationship_name] - - if relationship.polymorphic? - associated_model = public_send(associated_records_method_name) - resource_klass = self.class.resource_for_model(associated_model) if associated_model - return resource_klass.new(associated_model, @context) if resource_klass - else - resource_klass = relationship.resource_klass - if resource_klass - associated_model = public_send(associated_records_method_name) - return associated_model ? resource_klass.new(associated_model, @context) : nil - end - end - end - end - - def build_has_one(relationship, foreign_key, associated_records_method_name, relationship_name) - # Returns primary key name of related resource class - define_on_resource foreign_key do - relationship = self.class._relationships[relationship_name] - - record = public_send(associated_records_method_name) - return nil if record.nil? - record.public_send(relationship.resource_klass._primary_key) - end - - # Returns instantiated related resource object or nil - define_on_resource relationship_name do |options = {}| - relationship = self.class._relationships[relationship_name] - - if relationship.polymorphic? - associated_model = public_send(associated_records_method_name) - resource_klass = self.class.resource_for_model(associated_model) if associated_model - return resource_klass.new(associated_model, @context) if resource_klass && associated_model - else - resource_klass = relationship.resource_klass - if resource_klass - associated_model = public_send(associated_records_method_name) - return associated_model ? resource_klass.new(associated_model, @context) : nil - end - end - end - end - - def build_to_many(relationship, foreign_key, associated_records_method_name, relationship_name) - # Returns array of primary keys of related resource classes - define_on_resource foreign_key do - records = public_send(associated_records_method_name) - return records.collect do |record| - record.public_send(relationship.resource_klass._primary_key) - end - end - - # Returns array of instantiated related resource objects - define_on_resource relationship_name do |options = {}| - relationship = self.class._relationships[relationship_name] - - resource_klass = relationship.resource_klass - records = public_send(associated_records_method_name, options) - - return records.collect do |record| - if relationship.polymorphic? - resource_klass = self.class.resource_for_model(record) - end - resource_klass.new(record, @context) - end - end - end - - def define_on_resource(method_name, &block) - return if @resource_class.method_defined?(method_name) - @resource_class.inject_method_definition(method_name, block) - end - end -end diff --git a/lib/jsonapi/request_parser.rb b/lib/jsonapi/request_parser.rb index 3e7388819..14d2ca487 100644 --- a/lib/jsonapi/request_parser.rb +++ b/lib/jsonapi/request_parser.rb @@ -47,7 +47,7 @@ def transactional? def setup_base_op(params) return if params.nil? - resource_klass = Resource.resource_for(params[:controller]) if params[:controller] + resource_klass = Resource.resource_klass_for(params[:controller]) if params[:controller] setup_action_method_name = "setup_#{params[:action]}_action" if respond_to?(setup_action_method_name) @@ -81,7 +81,7 @@ def setup_index_action(params, resource_klass) end def setup_get_related_resource_action(params, resource_klass) - source_klass = Resource.resource_for(params.require(:source)) + source_klass = Resource.resource_klass_for(params.require(:source)) source_id = source_klass.verify_key(params.require(source_klass._as_parent_key), @context) fields = parse_fields(resource_klass, params[:fields]) @@ -102,7 +102,7 @@ def setup_get_related_resource_action(params, resource_klass) end def setup_get_related_resources_action(params, resource_klass) - source_klass = Resource.resource_for(params.require(:source)) + source_klass = Resource.resource_klass_for(params.require(:source)) source_id = source_klass.verify_key(params.require(source_klass._as_parent_key), @context) fields = parse_fields(resource_klass, params[:fields]) @@ -291,7 +291,7 @@ def parse_fields(resource_klass, fields) if type != format_key(type) fail JSONAPI::Exceptions::InvalidResource.new(type, error_object_overrides) end - type_resource = Resource.resource_for(resource_klass.module_path + underscored_type.to_s) + type_resource = Resource.resource_klass_for(resource_klass.module_path + underscored_type.to_s) rescue NameError errors.concat(JSONAPI::Exceptions::InvalidResource.new(type, error_object_overrides).errors) rescue JSONAPI::Exceptions::InvalidResource => e @@ -327,7 +327,7 @@ def check_include(resource_klass, include_parts) relationship = resource_klass._relationship(relationship_name) if relationship && format_key(relationship_name) == include_parts.first unless include_parts.last.empty? - check_include(Resource.resource_for(resource_klass.module_path + relationship.class_name.to_s.underscore), + check_include(Resource.resource_klass_for(resource_klass.module_path + relationship.class_name.to_s.underscore), include_parts.last.partition('.')) end else @@ -352,7 +352,7 @@ def parse_include_directives(resource_klass, raw_include) return if included_resources.nil? - result = included_resources.map do |included_resource| + result = included_resources.compact.map do |included_resource| check_include(resource_klass, included_resource.partition('.')) unformat_key(included_resource).to_s end @@ -530,7 +530,7 @@ def parse_to_one_relationship(resource_klass, link_value, relationship) unless links_object[:id].nil? resource = resource_klass || Resource - relationship_resource = resource.resource_for(unformat_key(links_object[:type]).to_s) + relationship_resource = resource.resource_klass_for(unformat_key(links_object[:type]).to_s) relationship_id = relationship_resource.verify_key(links_object[:id], @context) if relationship.polymorphic? { id: relationship_id, type: unformat_key(links_object[:type].to_s) } @@ -565,7 +565,7 @@ def parse_to_many_relationship(resource_klass, link_value, relationship, &add_re end links_object.each_pair do |type, keys| - relationship_resource = Resource.resource_for(resource_klass.module_path + unformat_key(type).to_s) + relationship_resource = Resource.resource_klass_for(resource_klass.module_path + unformat_key(type).to_s) add_result.call relationship_resource.verify_keys(keys, @context) end end diff --git a/lib/jsonapi/resource.rb b/lib/jsonapi/resource.rb index f2a81a6d4..68c5a6f9e 100644 --- a/lib/jsonapi/resource.rb +++ b/lib/jsonapi/resource.rb @@ -1,5 +1,4 @@ require 'jsonapi/callbacks' -require 'jsonapi/relationship_builder' module JSONAPI class Resource @@ -118,12 +117,6 @@ def fetchable_fields self.class.fields end - # Override this on a resource to customize how the associated records - # are fetched for a model. Particularly helpful for authorization. - def records_for(relation_name) - _model.public_send relation_name - end - def model_error_messages _model.errors.messages end @@ -174,6 +167,10 @@ def preloaded_fragments @preloaded_fragments ||= Hash.new end + def count_for_relationship(relationship_name, options) + self.class._record_accessor.count_for_relationship(self, relationship_name, options) + end + private def save @@ -429,6 +426,8 @@ def inherited(subclass) end check_reserved_resource_name(subclass._type, subclass.name) + + subclass.record_accessor = @_record_accessor_klass end def rebuild_relationships(relationships) @@ -445,7 +444,7 @@ def rebuild_relationships(relationships) end end - def resource_for(type) + def resource_klass_for(type) type = type.underscore type_with_module = type.include?('/') ? type : module_path + type @@ -457,8 +456,8 @@ def resource_for(type) resource end - def resource_for_model(model) - resource_for(resource_type_for(model)) + def resource_klass_for_model(model) + resource_klass_for(resource_type_for(model)) end def _resource_name_from_type(type) @@ -476,8 +475,8 @@ def resource_type_for(model) def model_name_for_type(key_type) type_class_name = key_type.to_s.classify - resource = resource_for(type_class_name) - resource ? resource._model_name.to_s : type_class_name + resource_klass = resource_klass_for(type_class_name) + resource_klass ? resource_klass._model_name.to_s : type_class_name end attr_accessor :_attributes, :_relationships, :_type, :_model_hints @@ -612,62 +611,6 @@ def fields _relationships.keys | _attributes.keys end - def resolve_relationship_names_to_relations(resource_klass, model_includes, options = {}) - case model_includes - when Array - return model_includes.map do |value| - resolve_relationship_names_to_relations(resource_klass, value, options) - end - when Hash - model_includes.keys.each do |key| - relationship = resource_klass._relationships[key] - value = model_includes[key] - model_includes.delete(key) - model_includes[relationship.relation_name(options)] = resolve_relationship_names_to_relations(relationship.resource_klass, value, options) - end - return model_includes - when Symbol - relationship = resource_klass._relationships[model_includes] - return relationship.relation_name(options) - end - end - - def apply_includes(records, 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) - end - - records - end - - def apply_pagination(records, paginator, order_options) - records = paginator.apply(records, order_options) if paginator - records - end - - def apply_sort(records, order_options, _context = {}) - if order_options.any? - order_options.each_pair do |field, direction| - if field.to_s.include?(".") - *model_names, column_name = field.split(".") - - associations = _lookup_association_chain([records.model.to_s, *model_names]) - joins_query = _build_joins([records.model, *associations]) - - # _sorting is appended to avoid name clashes with manual joins eg. overriden filters - order_by_query = "#{associations.last.name}_sorting.#{column_name} #{direction}" - records = records.joins(joins_query).order(order_by_query) - else - records = records.order(field => direction) - end - end - end - - records - end - def _lookup_association_chain(model_names) associations = [] model_names.inject do |prev, current| @@ -681,129 +624,31 @@ def _lookup_association_chain(model_names) associations end - def _build_joins(associations) - joins = [] - - associations.inject do |prev, current| - joins << "LEFT JOIN #{current.table_name} AS #{current.name}_sorting ON #{current.name}_sorting.id = #{prev.table_name}.#{current.foreign_key}" - current - end - joins.join("\n") - end - - def apply_filter(records, filter, value, options = {}) - strategy = _allowed_filters.fetch(filter.to_sym, Hash.new)[:apply] - - if strategy - if strategy.is_a?(Symbol) || strategy.is_a?(String) - send(strategy, records, value, options) - else - strategy.call(records, value, options) - end - else - records.where(filter => value) - end - end - - def apply_filters(records, filters, options = {}) - required_includes = [] - - if filters - filters.each do |filter, value| - if _relationships.include?(filter) - if _relationships[filter].belongs_to? - records = apply_filter(records, _relationships[filter].foreign_key, value, options) - else - required_includes.push(filter.to_s) - records = apply_filter(records, "#{_relationships[filter].table_name}.#{_relationships[filter].primary_key}", value, options) - end - else - records = apply_filter(records, filter, value, options) - end - end - end - - if required_includes.any? - records = apply_includes(records, options.merge(include_directives: IncludeDirectives.new(self, required_includes, force_eager_load: true))) - end - - records - end - - def filter_records(filters, options, records = records(options)) - records = apply_filters(records, filters, options) - apply_includes(records, options) - end - - def sort_records(records, order_options, context = {}) - apply_sort(records, order_options, context) - end - - # Assumes ActiveRecord's counting. Override if you need a different counting method - def count_records(records) - records.count(:all) - end - def find_count(filters, options = {}) - count_records(filter_records(filters, options)) + _record_accessor.find_count(filters, options) end def find(filters, options = {}) - resources_for(find_records(filters, options), options[:context]) + _record_accessor.find_resource(filters, options) end - def resources_for(records, context) - records.collect do |model| - resource_class = self.resource_for_model(model) - resource_class.new(model, context) - end - end - - def find_by_keys(keys, options = {}) - context = options[:context] - records = records(options) - records = apply_includes(records, options) - models = records.where({_primary_key => keys}) + def resources_for(models, context) models.collect do |model| - self.resource_for_model(model).new(model, context) + resource_for(model, context) end end - def find_serialized_with_caching(filters_or_source, serializer, options = {}) - if filters_or_source.is_a?(ActiveRecord::Relation) - records = filters_or_source - elsif _model_class.respond_to?(:all) && _model_class.respond_to?(:arel_table) - records = find_records(filters_or_source, options.except(:include_directives)) - else - records = find(filters_or_source, options) - end - cached_resources_for(records, serializer, options) + def resource_for(model, context) + resource_klass = self.resource_klass_for_model(model) + resource_klass.new(model, context) end - def find_by_key(key, options = {}) - context = options[:context] - records = find_records({_primary_key => key}, options.except(:paginator, :sort_criteria)) - model = records.first - fail JSONAPI::Exceptions::RecordNotFound.new(key) if model.nil? - self.resource_for_model(model).new(model, context) - end - - def find_by_key_serialized_with_caching(key, serializer, options = {}) - if _model_class.respond_to?(:all) && _model_class.respond_to?(:arel_table) - results = find_serialized_with_caching({_primary_key => key}, serializer, options) - result = results.first - fail JSONAPI::Exceptions::RecordNotFound.new(key) if result.nil? - return result - else - resource = find_by_key(key, options) - return cached_resources_for([resource], serializer, options).first - end + def find_by_keys(keys, options = {}) + _record_accessor.find_resources_by_keys(keys, options) end - # Override this method if you want to customize the relation for - # finder methods (find, find_by_key, find_serialized_with_caching) - def records(_options = {}) - _model_class.all + def find_by_key(key, options = {}) + _record_accessor.find_resource_by_key(key, options) end def verify_filters(filters, context = nil) @@ -959,6 +804,18 @@ def paginator(paginator) @_paginator = paginator end + def _record_accessor + @_record_accessor = _record_accessor_klass.new(self) + end + + def record_accessor=(record_accessor_klass) + @record_accessor_klass = record_accessor_klass + end + + def _record_accessor_klass + @_record_accessor_klass ||= JSONAPI.configuration.default_record_accessor_klass + end + def abstract(val = true) @abstract = val end @@ -1046,52 +903,96 @@ def _add_relationship(klass, *attrs) check_reserved_relationship_name(relationship_name) check_duplicate_relationship_name(relationship_name) - JSONAPI::RelationshipBuilder.new(klass, _model_class, options) - .define_relationship_methods(relationship_name.to_sym) + define_relationship_methods(relationship_name.to_sym, klass, options) end end - # Allows JSONAPI::RelationshipBuilder to access metaprogramming hooks - def inject_method_definition(name, body) - define_method(name, body) + # ResourceBuilder methods + def define_relationship_methods(relationship_name, relationship_klass, options) + # Initialize from an ActiveRecord model's properties + if _model_class && _model_class.ancestors.collect { |ancestor| ancestor.name }.include?('ActiveRecord::Base') + model_association = _model_class.reflect_on_association(relationship_name) + if model_association + options[:class_name] ||= model_association.class_name + end + end + + relationship = register_relationship( + relationship_name, + relationship_klass.new(relationship_name, options) + ) + + define_foreign_key_setter(relationship) + + case relationship + when JSONAPI::Relationship::ToOne + if relationship.belongs_to? + build_belongs_to(relationship) + else + build_has_one(relationship) + end + when JSONAPI::Relationship::ToMany + build_to_many(relationship) + end end - def register_relationship(name, relationship_object) - @_relationships[name] = relationship_object + def define_foreign_key_setter(relationship) + define_on_resource "#{relationship.foreign_key}=" do |value| + _model.method("#{relationship.foreign_key}=").call(value) + end end - private + def build_belongs_to(relationship) + foreign_key = relationship.foreign_key + define_on_resource foreign_key do + self.class._record_accessor.foreign_key(self, relationship.name) + end - def cached_resources_for(records, serializer, options) - if records.is_a?(Array) && records.all?{|rec| rec.is_a?(JSONAPI::Resource)} - resources = records.map{|r| [r.id, r] }.to_h - elsif self.caching? - t = _model_class.arel_table - cache_ids = pluck_arel_attributes(records, t[_primary_key], t[_cache_field]) - resources = CachedResourceFragment.fetch_fragments(self, serializer, options[:context], cache_ids) - else - resources = resources_for(records, options[:context]).map{|r| [r.id, r] }.to_h + # Returns instantiated related resource object or nil + define_on_resource relationship.name do |options = {}| + self.class._record_accessor.related_resource(self, relationship.name, options) end + end - preload_included_fragments(resources, records, serializer, options) + def build_has_one(relationship) + foreign_key = relationship.foreign_key - resources.values + # Returns primary key name of related resource class + define_on_resource foreign_key do + self.class._record_accessor.foreign_key(self, relationship.name) + end + + # Returns instantiated related resource object or nil + define_on_resource relationship.name do |options = {}| + self.class._record_accessor.related_resource(self, relationship.name, options) + end end - def find_records(filters, options = {}) - context = options[:context] + def build_to_many(relationship) + foreign_key = relationship.foreign_key - records = filter_records(filters, options) + # Returns array of primary keys of related resource classes + define_on_resource foreign_key do + self.class._record_accessor.foreign_keys(self, relationship.name) + end - sort_criteria = options.fetch(:sort_criteria) { [] } - order_options = construct_order_options(sort_criteria) - records = sort_records(records, order_options, context) + # Returns array of instantiated related resource objects + define_on_resource relationship.name do |options = {}| + self.class._record_accessor.related_resources(self, relationship.name, options) + end + end - records = apply_pagination(records, options[:paginator], order_options) + def define_on_resource(method_name, &block) + return if method_defined?(method_name) + define_method(method_name, block) + end - records + def register_relationship(name, relationship_object) + @_relationships[name] = relationship_object end + private + def check_reserved_resource_name(type, name) if [:ids, :types, :hrefs, :links].include?(type) warn "[NAME COLLISION] `#{name}` is a reserved resource name." @@ -1124,136 +1025,6 @@ def check_duplicate_attribute_name(name) warn "[DUPLICATE ATTRIBUTE] `#{name}` has already been defined in #{_resource_name_from_type(_type)}." end end - - def preload_included_fragments(resources, records, serializer, options) - return if resources.empty? - res_ids = resources.keys - - include_directives = options[:include_directives] - return unless include_directives - - context = options[:context] - - # For each association, including indirect associations, find the target record ids. - # Even if a target class doesn't have caching enabled, we still have to look up - # and match the target ids here, because we can't use ActiveRecord#includes. - # - # Note that `paths` returns partial paths before complete paths, so e.g. the partial - # fragments for posts.comments will exist before we start working with posts.comments.author - target_resources = {} - include_directives.paths.each do |path| - # If path is [:posts, :comments, :author], then... - pluck_attrs = [] # ...will be [posts.id, comments.id, authors.id, authors.updated_at] - pluck_attrs << self._model_class.arel_table[self._primary_key] - - relation = records - .except(:limit, :offset, :order) - .where({_primary_key => res_ids}) - - # These are updated as we iterate through the association path; afterwards they will - # refer to the final resource on the path, i.e. the actual resource to find in the cache. - # So e.g. if path is [:posts, :comments, :author], then after iteration... - parent_klass = nil # Comment - klass = self # Person - relationship = nil # JSONAPI::Relationship::ToOne for CommentResource.author - table = nil # people - assocs_path = [] # [ :posts, :approved_comments, :author ] - ar_hash = nil # { :posts => { :approved_comments => :author } } - - # For each step on the path, figure out what the actual table name/alias in the join - # will be, and include the primary key of that table in our list of fields to select - non_polymorphic = true - path.each do |elem| - relationship = klass._relationships[elem] - if relationship.polymorphic - # Can't preload through a polymorphic belongs_to association, ResourceSerializer - # will just have to bypass the cache and load the real Resource. - non_polymorphic = false - break - end - assocs_path << relationship.relation_name(options).to_sym - # Converts [:a, :b, :c] to Rails-style { :a => { :b => :c }} - ar_hash = assocs_path.reverse.reduce{|memo, step| { step => memo } } - # We can't just look up the table name from the resource class, because Arel could - # have used a table alias if the relation includes a self-reference. - join_source = relation.joins(ar_hash).arel.source.right.reverse.find do |arel_node| - arel_node.is_a?(Arel::Nodes::InnerJoin) - end - table = join_source.left - parent_klass = klass - klass = relationship.resource_klass - pluck_attrs << table[klass._primary_key] - end - next unless non_polymorphic - - # Pre-fill empty hashes for each resource up to the end of the path. - # This allows us to later distinguish between a preload that returned nothing - # vs. a preload that never ran. - prefilling_resources = resources.values - path.each do |rel_name| - rel_name = serializer.key_formatter.format(rel_name) - prefilling_resources.map! do |res| - res.preloaded_fragments[rel_name] ||= {} - res.preloaded_fragments[rel_name].values - end - prefilling_resources.flatten!(1) - end - - pluck_attrs << table[klass._cache_field] if klass.caching? - relation = relation.joins(ar_hash) - if relationship.is_a?(JSONAPI::Relationship::ToMany) - # Rails doesn't include order clauses in `joins`, so we have to add that manually here. - # FIXME Should find a better way to reflect on relationship ordering. :-( - relation = relation.order(parent_klass._model_class.new.send(assocs_path.last).arel.orders) - end - - # [[post id, comment id, author id, author updated_at], ...] - id_rows = pluck_arel_attributes(relation.joins(ar_hash), *pluck_attrs) - - target_resources[klass.name] ||= {} - - if klass.caching? - sub_cache_ids = id_rows - .map{|row| row.last(2) } - .reject{|row| target_resources[klass.name].has_key?(row.first) } - .uniq - target_resources[klass.name].merge! CachedResourceFragment.fetch_fragments( - klass, serializer, context, sub_cache_ids - ) - else - sub_res_ids = id_rows - .map(&:last) - .reject{|id| target_resources[klass.name].has_key?(id) } - .uniq - found = klass.find({klass._primary_key => sub_res_ids}, context: options[:context]) - target_resources[klass.name].merge! found.map{|r| [r.id, r] }.to_h - end - - id_rows.each do |row| - res = resources[row.first] - path.each_with_index do |rel_name, index| - rel_name = serializer.key_formatter.format(rel_name) - rel_id = row[index+1] - assoc_rels = res.preloaded_fragments[rel_name] - if index == path.length - 1 - assoc_rels[rel_id] = target_resources[klass.name].fetch(rel_id) - else - res = assoc_rels[rel_id] - end - end - end - end - end - - def pluck_arel_attributes(relation, *attrs) - conn = relation.connection - quoted_attrs = attrs.map do |attr| - quoted_table = conn.quote_table_name(attr.relation.table_alias || attr.relation.name) - quoted_column = conn.quote_column_name(attr.name) - "#{quoted_table}.#{quoted_column}" - end - relation.pluck(*quoted_attrs) - end end end end diff --git a/lib/jsonapi/routing_ext.rb b/lib/jsonapi/routing_ext.rb index facbc1c6f..045090cc4 100644 --- a/lib/jsonapi/routing_ext.rb +++ b/lib/jsonapi/routing_ext.rb @@ -18,7 +18,7 @@ def format_route(route) def jsonapi_resource(*resources, &_block) @resource_type = resources.first - res = JSONAPI::Resource.resource_for(resource_type_with_module_prefix(@resource_type)) + res = JSONAPI::Resource.resource_klass_for(resource_type_with_module_prefix(@resource_type)) options = resources.extract_options!.dup options[:controller] ||= @resource_type @@ -64,7 +64,7 @@ def jsonapi_resource(*resources, &_block) end def jsonapi_relationships(options = {}) - res = JSONAPI::Resource.resource_for(resource_type_with_module_prefix(@resource_type)) + res = JSONAPI::Resource.resource_klass_for(resource_type_with_module_prefix(@resource_type)) res._relationships.each do |relationship_name, relationship| if relationship.is_a?(JSONAPI::Relationship::ToMany) jsonapi_links(relationship_name, options) @@ -78,7 +78,7 @@ def jsonapi_relationships(options = {}) def jsonapi_resources(*resources, &_block) @resource_type = resources.first - res = JSONAPI::Resource.resource_for(resource_type_with_module_prefix(@resource_type)) + res = JSONAPI::Resource.resource_klass_for(resource_type_with_module_prefix(@resource_type)) options = resources.extract_options!.dup options[:controller] ||= @resource_type @@ -147,7 +147,7 @@ def jsonapi_link(*links) formatted_relationship_name = format_route(link_type) options = links.extract_options!.dup - res = JSONAPI::Resource.resource_for(resource_type_with_module_prefix) + res = JSONAPI::Resource.resource_klass_for(resource_type_with_module_prefix) options[:controller] ||= res._type.to_s methods = links_methods(options) @@ -175,7 +175,7 @@ def jsonapi_links(*links) formatted_relationship_name = format_route(link_type) options = links.extract_options!.dup - res = JSONAPI::Resource.resource_for(resource_type_with_module_prefix) + res = JSONAPI::Resource.resource_klass_for(resource_type_with_module_prefix) options[:controller] ||= res._type.to_s methods = links_methods(options) @@ -204,7 +204,7 @@ def jsonapi_links(*links) end def jsonapi_related_resource(*relationship) - source = JSONAPI::Resource.resource_for(resource_type_with_module_prefix) + source = JSONAPI::Resource.resource_klass_for(resource_type_with_module_prefix) options = relationship.extract_options!.dup relationship_name = relationship.first @@ -215,7 +215,7 @@ def jsonapi_related_resource(*relationship) if relationship.polymorphic? options[:controller] ||= relationship.class_name.underscore.pluralize else - related_resource = JSONAPI::Resource.resource_for(resource_type_with_module_prefix(relationship.class_name.underscore.pluralize)) + related_resource = JSONAPI::Resource.resource_klass_for(resource_type_with_module_prefix(relationship.class_name.underscore.pluralize)) options[:controller] ||= related_resource._type.to_s end @@ -225,14 +225,14 @@ def jsonapi_related_resource(*relationship) end def jsonapi_related_resources(*relationship) - source = JSONAPI::Resource.resource_for(resource_type_with_module_prefix) + source = JSONAPI::Resource.resource_klass_for(resource_type_with_module_prefix) options = relationship.extract_options!.dup relationship_name = relationship.first relationship = source._relationships[relationship_name] formatted_relationship_name = format_route(relationship.name) - related_resource = JSONAPI::Resource.resource_for(resource_type_with_module_prefix(relationship.class_name.underscore)) + related_resource = JSONAPI::Resource.resource_klass_for(resource_type_with_module_prefix(relationship.class_name.underscore)) options[:controller] ||= related_resource._type.to_s match formatted_relationship_name, diff --git a/test/controllers/controller_test.rb b/test/controllers/controller_test.rb index 82abc5863..7f67f7672 100644 --- a/test/controllers/controller_test.rb +++ b/test/controllers/controller_test.rb @@ -2488,7 +2488,7 @@ def test_destroy_relationship_has_and_belongs_to_many JSONAPI.configuration.use_relationship_reflection = false end - def test_destroy_relationship_has_and_belongs_to_many_refect + def test_destroy_relationship_has_and_belongs_to_many_reflect JSONAPI.configuration.use_relationship_reflection = true assert_equal 2, Book.find(2).authors.count @@ -2609,14 +2609,14 @@ class BreedsControllerTest < ActionController::TestCase # Note: Breed names go through the TitleValueFormatter def test_poro_index - assert_cacheable_get :index + get :index assert_response :success assert_equal '0', json_response['data'][0]['id'] assert_equal 'Persian', json_response['data'][0]['attributes']['name'] end def test_poro_show - assert_cacheable_get :show, params: {id: '0'} + get :show, params: {id: '0'} assert_response :success assert json_response['data'].is_a?(Hash) assert_equal '0', json_response['data']['id'] @@ -3629,6 +3629,11 @@ def test_complex_includes_base assert_response :success end + def test_complex_includes_filters_nil_includes + assert_cacheable_get :index, params: {include: ',,'} + assert_response :success + end + def test_complex_includes_two_level assert_cacheable_get :index, params: {include: 'things,things.user'} diff --git a/test/fixtures/active_record.rb b/test/fixtures/active_record.rb index 8d0fe7f46..7a12cb117 100644 --- a/test/fixtures/active_record.rb +++ b/test/fixtures/active_record.rb @@ -1614,7 +1614,7 @@ class CommentResource < CommentResource; end class PostResource < PostResource # Test caching with SQL fragments def self.records(options = {}) - super.joins('INNER JOIN people on people.id = author_id') + _model_class.all.joins('INNER JOIN people on people.id = author_id') end end diff --git a/test/unit/resource/resource_test.rb b/test/unit/resource/resource_test.rb index 01e921127..48a07c87a 100644 --- a/test/unit/resource/resource_test.rb +++ b/test/unit/resource/resource_test.rb @@ -129,30 +129,30 @@ def test_module_path def test_resource_for_root_resource assert_raises NameError do - JSONAPI::Resource.resource_for('related') + JSONAPI::Resource.resource_klass_for('related') end end def test_resource_for_resource_does_not_exist_at_root assert_raises NameError do - ArticleResource.resource_for('related') + ArticleResource.resource_klass_for('related') end end def test_resource_for_with_underscored_namespaced_paths - assert_equal(JSONAPI::Resource.resource_for('my_module/related'), MyModule::RelatedResource) - assert_equal(PostResource.resource_for('my_module/related'), MyModule::RelatedResource) - assert_equal(MyModule::MyNamespacedResource.resource_for('my_module/related'), MyModule::RelatedResource) + assert_equal(JSONAPI::Resource.resource_klass_for('my_module/related'), MyModule::RelatedResource) + assert_equal(PostResource.resource_klass_for('my_module/related'), MyModule::RelatedResource) + assert_equal(MyModule::MyNamespacedResource.resource_klass_for('my_module/related'), MyModule::RelatedResource) end def test_resource_for_with_camelized_namespaced_paths - assert_equal(JSONAPI::Resource.resource_for('MyModule::Related'), MyModule::RelatedResource) - assert_equal(PostResource.resource_for('MyModule::Related'), MyModule::RelatedResource) - assert_equal(MyModule::MyNamespacedResource.resource_for('MyModule::Related'), MyModule::RelatedResource) + assert_equal(JSONAPI::Resource.resource_klass_for('MyModule::Related'), MyModule::RelatedResource) + assert_equal(PostResource.resource_klass_for('MyModule::Related'), MyModule::RelatedResource) + assert_equal(MyModule::MyNamespacedResource.resource_klass_for('MyModule::Related'), MyModule::RelatedResource) end def test_resource_for_namespaced_resource - assert_equal(MyModule::MyNamespacedResource.resource_for('related'), MyModule::RelatedResource) + assert_equal(MyModule::MyNamespacedResource.resource_klass_for('related'), MyModule::RelatedResource) end def test_relationship_parent_point_to_correct_resource @@ -266,28 +266,32 @@ def test_records_for_meta_method_for_to_one author = Person.find(1) author.update! preferences: Preferences.first author_resource = PersonWithCustomRecordsForRelationshipsResource.new(author, nil) - assert_equal(author_resource.record_for_preferences, :record_for_preferences) + assert_equal(author_resource.class._record_accessor.records_for( + author_resource, :preferences), :record_for_preferences) end def test_records_for_meta_method_for_to_one_calling_records_for author = Person.find(1) author.update! preferences: Preferences.first author_resource = PersonWithCustomRecordsForResource.new(author, nil) - assert_equal(author_resource.record_for_preferences, :records_for) + assert_equal(author_resource.class._record_accessor.records_for( + author_resource, :preferences), :records_for) end def test_associated_records_meta_method_for_to_many author = Person.find(1) author.posts << Post.find(1) author_resource = PersonWithCustomRecordsForRelationshipsResource.new(author, nil) - assert_equal(author_resource.records_for_posts, :records_for_posts) + assert_equal(author_resource.class._record_accessor.records_for( + author_resource, :posts), :records_for_posts) end def test_associated_records_meta_method_for_to_many_calling_records_for author = Person.find(1) author.posts << Post.find(1) author_resource = PersonWithCustomRecordsForResource.new(author, nil) - assert_equal(author_resource.records_for_posts, :records_for) + assert_equal(author_resource.class._record_accessor.records_for( + author_resource, :posts), :records_for) end def test_find_by_key_with_customized_base_records @@ -347,7 +351,28 @@ def apply_filters(records, filters, options) PostResource.instance_eval do def apply_filters(records, filters, options) # :nocov: - super + required_includes = [] + + if filters + filters.each do |filter, value| + if _relationships.include?(filter) + if _relationships[filter].belongs_to? + records = apply_filter(records, _relationships[filter].foreign_key, value, options) + else + required_includes.push(filter.to_s) + records = apply_filter(records, "#{_relationships[filter].table_name}.#{_relationships[filter].primary_key}", value, options) + end + else + records = apply_filter(records, filter, value, options) + end + end + end + + if required_includes.any? + records = apply_includes(records, options.merge(include_directives: IncludeDirectives.new(self, required_includes, force_eager_load: true))) + end + + records # :nocov: end end @@ -358,11 +383,12 @@ def test_to_many_relationship_sorts comment_ids = post_resource.comments.map{|c| c._model.id } assert_equal [1,2], comment_ids - # define apply_filters method on post resource to not respect filters + # define apply_filters method on post resource to sort descending PostResource.instance_eval do def apply_sort(records, criteria, context = {}) # :nocov: - records + order_by_query = 'id desc' + records.order(order_by_query) # :nocov: end end @@ -373,9 +399,26 @@ def apply_sort(records, criteria, context = {}) ensure # reset method to original implementation PostResource.instance_eval do - def apply_sort(records, criteria, context = {}) + def apply_sort(records, order_options, _context = {}) # :nocov: - super + if order_options.any? + order_options.each_pair do |field, direction| + if field.to_s.include?(".") + *model_names, column_name = field.split(".") + + associations = _lookup_association_chain([records.model.to_s, *model_names]) + joins_query = _record_accessor._build_joins([records.model, *associations]) + + # _sorting is appended to avoid name clashes with manual joins eg. overriden filters + order_by_query = "#{associations.last.name}_sorting.#{column_name} #{direction}" + records = records.joins(joins_query).order(order_by_query) + else + records = records.order(field => direction) + end + end + end + + records # :nocov: end end @@ -400,7 +443,7 @@ def test_lookup_association_chain def test_build_joins model_names = %w(person posts parent_post author) associations = PostResource._lookup_association_chain(model_names) - result = PostResource._build_joins(associations) + result = PostResource._record_accessor._build_joins(associations) assert_equal "LEFT JOIN posts AS parent_post_sorting ON parent_post_sorting.id = posts.parent_post_id LEFT JOIN people AS author_sorting ON author_sorting.id = posts.author_id", result @@ -439,7 +482,8 @@ def apply(relation, order_options) PostResource.instance_eval do def apply_pagination(records, criteria, order_options) # :nocov: - super + records = paginator.apply(records, order_options) if paginator + records # :nocov: end end @@ -621,7 +665,7 @@ def test_resource_for_model_use_hint special_person = Person.create!(name: 'Special', date_joined: Date.today, special: true) special_resource = SpecialPersonResource.new(special_person, nil) resource_model = SpecialPersonResource.records({}).first # simulate a find - assert_equal(SpecialPersonResource, SpecialPersonResource.resource_for_model(resource_model)) + assert_equal(SpecialPersonResource, SpecialPersonResource.resource_klass_for_model(resource_model)) end def test_resource_performs_validations_in_custom_context