lib/jsonapi/resource.rb in jsonapi-resources-0.9.12 vs lib/jsonapi/resource.rb in jsonapi-resources-0.10.0.beta1

- old
+ new

@@ -1,15 +1,12 @@ require 'jsonapi/callbacks' -require 'jsonapi/relationship_builder' +require 'jsonapi/configuration' module JSONAPI class Resource include Callbacks - DEFAULT_ATTRIBUTE_OPTIONS = { format: :default }.freeze - MODULE_PATH_REGEXP = /::[^:]+\Z/.freeze - attr_reader :context define_jsonapi_resources_callbacks :create, :update, :remove, @@ -37,12 +34,16 @@ def id _model.public_send(self.class._primary_key) end + def identity + JSONAPI::ResourceIdentity.new(self.class, id) + end + def cache_id - [id, _model.public_send(self.class._cache_field)] + [id, self.class.hash_cache_field(_model.public_send(self.class._cache_field))] end def is_new? id.nil? end @@ -119,16 +120,10 @@ # Override this on a resource instance to override the fetchable keys 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 # Add metadata to validation error objects. @@ -170,15 +165,10 @@ # the _options hash will contain the serializer and the serialization_options def custom_links(_options) {} end - def preloaded_fragments - # A hash of hashes - @preloaded_fragments ||= Hash.new - end - private def save run_callbacks :save do _save @@ -246,18 +236,11 @@ true end def _create_to_many_links(relationship_type, relationship_key_values, options) relationship = self.class._relationships[relationship_type] - - # check if relationship_key_values are already members of this relationship relation_name = relationship.relation_name(context: @context) - existing_relations = @model.public_send(relation_name).where(relationship.primary_key => relationship_key_values) - if existing_relations.count > 0 - # todo: obscure id so not to leak info - fail JSONAPI::Exceptions::HasManyRelationExists.new(existing_relations.first.id) - end if options[:reflected_source] @model.public_send(relation_name) << options[:reflected_source]._model return :completed end @@ -281,76 +264,59 @@ else related_resource.replace_to_one_link(relationship.inverse_relationship, id, reflected_source: self) end @reload_needed = true else - @model.public_send(relation_name) << related_resource._model + unless @model.public_send(relation_name).include?(related_resource._model) + @model.public_send(relation_name) << related_resource._model + end end end :completed end def _replace_to_many_links(relationship_type, relationship_key_values, options) - relationship = self.class._relationships[relationship_type] + relationship = self.class._relationship(relationship_type) reflect = reflect_relationship?(relationship, options) if reflect - existing = send("#{relationship.foreign_key}") + existing_rids = self.class.find_related_fragments([identity], relationship_type, options) + + existing = existing_rids.keys.collect { |rid| rid.id } + to_delete = existing - (relationship_key_values & existing) to_delete.each do |key| _remove_to_many_link(relationship_type, key, reflected_source: self) end to_add = relationship_key_values - (relationship_key_values & existing) _create_to_many_links(relationship_type, to_add, {}) @reload_needed = true - elsif relationship.polymorphic? - relationship_key_values.each do |relationship_key_value| - relationship_resource_klass = self.class.resource_for(relationship_key_value[:type]) - ids = relationship_key_value[:ids] - - related_records = relationship_resource_klass - .records(options) - .where({relationship_resource_klass._primary_key => ids}) - - missed_ids = ids - related_records.pluck(relationship_resource_klass._primary_key) - - if missed_ids.present? - fail JSONAPI::Exceptions::RecordNotFound.new(missed_ids) - end - - relation_name = relationship.relation_name(context: @context) - @model.send("#{relation_name}") << related_records - end - - @reload_needed = true else send("#{relationship.foreign_key}=", relationship_key_values) @save_needed = true end :completed end - def _replace_to_one_link(relationship_type, relationship_key_value, options) + def _replace_to_one_link(relationship_type, relationship_key_value, _options) relationship = self.class._relationships[relationship_type] send("#{relationship.foreign_key}=", relationship_key_value) @save_needed = true :completed end - def _replace_polymorphic_to_one_link(relationship_type, key_value, key_type, options) + def _replace_polymorphic_to_one_link(relationship_type, key_value, key_type, _options) relationship = self.class._relationships[relationship_type.to_sym] - _model.public_send("#{relationship.foreign_key}=", key_value) - _model.public_send("#{relationship.polymorphic_type}=", _model_class_name(key_type)) - + send("#{relationship.foreign_key}=", {type: key_type, id: key_value}) @save_needed = true :completed end @@ -373,22 +339,22 @@ end end @reload_needed = true else - @model.public_send(relationship.relation_name(context: @context)).delete(key) + @model.public_send(relationship.relation_name(context: @context)).destroy(key) end :completed rescue ActiveRecord::DeleteRestrictionError => e fail JSONAPI::Exceptions::RecordLocked.new(e.message) rescue ActiveRecord::RecordNotFound fail JSONAPI::Exceptions::RecordNotFound.new(key) end - def _remove_to_one_link(relationship_type, options) + def _remove_to_one_link(relationship_type, _options) relationship = self.class._relationships[relationship_type] send("#{relationship.foreign_key}=", nil) @save_needed = true @@ -425,63 +391,122 @@ end if field_data[:to_many] :completed end - def _model_class_name(key_type) - type_class_name = key_type.to_s.classify - resource = self.class.resource_for(type_class_name) - resource ? resource._model_name.to_s : type_class_name - end - class << self def inherited(subclass) subclass.abstract(false) subclass.immutable(false) - subclass.caching(false) - subclass.singleton(singleton?, (_singleton_options.dup || {})) - subclass.exclude_links(_exclude_links) + subclass.caching(_caching) + subclass.paginator(_paginator) subclass._attributes = (_attributes || {}).dup subclass._model_hints = (_model_hints || {}).dup - unless _model_name.empty? + unless _model_name.empty? || _immutable subclass.model_name(_model_name, add_model_hint: (_model_hints && !_model_hints[_model_name].nil?) == true) end subclass.rebuild_relationships(_relationships || {}) subclass._allowed_filters = (_allowed_filters || Set.new).dup + subclass._allowed_sort = _allowed_sort.dup + type = subclass.name.demodulize.sub(/Resource$/, '').underscore subclass._type = type.pluralize.to_sym unless subclass._attributes[:id] - subclass.attribute :id, format: :id + subclass.attribute :id, format: :id, readonly: true end check_reserved_resource_name(subclass._type, subclass.name) - subclass._routed = false - subclass._warned_missing_route = false + subclass.include JSONAPI.configuration.resource_finder if JSONAPI.configuration.resource_finder end + # A ResourceFinder is a mixin that adds functionality to find Resources and Resource Fragments + # to the core Resource class. + # + # Resource fragments are a hash with the following format: + # { + # identity: <required: a ResourceIdentity>, + # cache: <optional: the resource's cache value> + # attributes: <optional: attributes hash for attributes requested - currently unused> + # related: { + # <relationship_name>: <ResourceIdentity of a source resource in find_included_fragments> + # } + # } + # + # begin ResourceFinder Abstract methods + def find(_filters, _options = {}) + # :nocov: + raise 'Abstract ResourceFinder method called. Ensure that a ResourceFinder has been set.' + # :nocov: + end + + def count(_filters, _options = {}) + # :nocov: + raise 'Abstract ResourceFinder method called. Ensure that a ResourceFinder has been set.' + # :nocov: + end + + def find_by_keys(_keys, _options = {}) + # :nocov: + raise 'Abstract ResourceFinder method called. Ensure that a ResourceFinder has been set.' + # :nocov: + end + + def find_by_key(_key, _options = {}) + # :nocov: + raise 'Abstract ResourceFinder method called. Ensure that a ResourceFinder has been set.' + # :nocov: + end + + def find_fragments(_filters, _options = {}) + # :nocov: + raise 'Abstract ResourceFinder method called. Ensure that a ResourceFinder has been set.' + # :nocov: + end + + def find_included_fragments(_source_rids, _relationship_name, _options = {}) + # :nocov: + raise 'Abstract ResourceFinder method called. Ensure that a ResourceFinder has been set.' + # :nocov: + end + + def find_related_fragments(_source_rids, _relationship_name, _options = {}) + # :nocov: + raise 'Abstract ResourceFinder method called. Ensure that a ResourceFinder has been set.' + # :nocov: + end + + def count_related(_source_rid, _relationship_name, _options = {}) + # :nocov: + raise 'Abstract ResourceFinder method called. Ensure that a ResourceFinder has been set.' + # :nocov: + end + + #end ResourceFinder Abstract methods + def rebuild_relationships(relationships) original_relationships = relationships.deep_dup @_relationships = {} if original_relationships.is_a?(Hash) original_relationships.each_value do |relationship| options = relationship.options.dup options[:parent_resource] = self + options[:inverse_relationship] = relationship.inverse_relationship _add_relationship(relationship.class, relationship.name, options) end end end - def resource_for(type) + def resource_klass_for(type) type = type.underscore type_with_module = type.start_with?(module_path) ? type : module_path + type resource_name = _resource_name_from_type(type_with_module) resource = resource_name.safe_constantize if resource_name @@ -489,12 +514,12 @@ fail NameError, "JSONAPI: Could not find resource '#{type}'. (Class #{resource_name} not found)" end 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) "#{type.to_s.underscore.singularize}_resource".camelize end @@ -506,12 +531,12 @@ else model_name.rpartition('/').last end end - attr_accessor :_attributes, :_relationships, :_type, :_model_hints, :_routed, :_warned_missing_route - attr_writer :_allowed_filters, :_paginator + attr_accessor :_attributes, :_relationships, :_type, :_model_hints + attr_writer :_allowed_filters, :_paginator, :_allowed_sort def create(context) new(create_model, context) end @@ -553,14 +578,44 @@ end unless method_defined?(attr) define_method "#{attr}=" do |value| @model.public_send("#{options[:delegate] ? options[:delegate].to_sym : attr}=", value) end unless method_defined?("#{attr}=") + + if options.fetch(:sortable, true) && !_has_sort?(attr) + sort attr + end end + def attribute_to_model_field(attribute) + field_name = if attribute == :_cache_field + _cache_field + else + # Note: this will allow the returning of model attributes without a corresponding + # resource attribute, for example a belongs_to id such as `author_id` or bypassing + # the delegate. + attr = @_attributes[attribute] + attr && attr[:delegate] ? attr[:delegate].to_sym : attribute + end + if Rails::VERSION::MAJOR >= 5 + attribute_type = _model_class.attribute_types[field_name.to_s] + else + attribute_type = _model_class.column_types[field_name.to_s] + end + { name: field_name, type: attribute_type} + end + + def cast_to_attribute_type(value, type) + if Rails::VERSION::MAJOR >= 5 + return type.cast(value) + else + return type.type_cast_from_database(value) + end + end + def default_attribute_options - DEFAULT_ATTRIBUTE_OPTIONS + { format: :default } end def relationship(*attrs) options = attrs.extract_options! klass = case options[:to] @@ -591,18 +646,11 @@ def has_many(*attrs) _add_relationship(Relationship::ToMany, *attrs) end - # @model_class is inherited from superclass, and this causes some issues: - # ``` - # CarResource._model_class #=> Vehicle # it should be Car - # ``` - # so in order to invoke the right class from subclasses, - # we should call this method to override it. def model_name(model, options = {}) - @model_class = nil @_model_name = model.to_sym model_hint(model: @_model_name, resource: self) unless options[:add_model_hint] == false rebuild_relationships(_relationships) @@ -612,278 +660,79 @@ resource_type = ((resource.is_a?(Class)) && (resource < JSONAPI::Resource)) ? resource._type : resource.to_s _model_hints[model.to_s.gsub('::', '/').underscore] = resource_type.to_s end - def singleton(*attrs) - @_singleton = (!!attrs[0] == attrs[0]) ? attrs[0] : true - @_singleton_options = attrs.extract_options! - end - - def _singleton_options - @_singleton_options ||= {} - end - - def singleton? - @_singleton ||= false - end - def filters(*attrs) @_allowed_filters.merge!(attrs.inject({}) { |h, attr| h[attr] = {}; h }) end def filter(attr, *args) @_allowed_filters[attr.to_sym] = args.extract_options! end + def sort(sorting, options = {}) + self._allowed_sort[sorting.to_sym] = options + end + + def sorts(*args) + options = args.extract_options! + _allowed_sort.merge!(args.inject({}) { |h, sorting| h[sorting.to_sym] = options.dup; h }) + end + def primary_key(key) @_primary_key = key.to_sym end def cache_field(field) @_cache_field = field.to_sym end # Override in your resource to filter the updatable keys def updatable_fields(_context = nil) - _updatable_relationships | _attributes.keys - [:id] + _updatable_relationships | _updatable_attributes - [:id] end # Override in your resource to filter the creatable keys def creatable_fields(_context = nil) - _updatable_relationships | _attributes.keys - [:id] + _updatable_relationships | _updatable_attributes end # Override in your resource to filter the sortable keys def sortable_fields(_context = nil) - _attributes.keys + _allowed_sort.keys end + def sortable_field?(key, context = nil) + sortable_fields(context).include? key.to_sym + end + 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 + def records(options = {}) + _model_class.all 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) if model_includes.present? - end - - records + def retrieve_records(ids, options = {}) + _model_class.where(_primary_key => ids) 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. 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 - - 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 = _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 apply_included_resources_filters(records, options = {}) - include_directives = options[:include_directives] - return records unless include_directives - related_directives = include_directives.include_directives.fetch(:include_related) - related_directives.reduce(records) do |memo, (relationship_name, config)| - relationship = _relationship(relationship_name) - next memo unless relationship && relationship.is_a?(JSONAPI::Relationship::ToMany) - filtering_resource = relationship.resource_klass - - # Don't try to merge where clauses when relation isn't already being joined to query. - next memo unless config[:include_in_join] - - filters = config[:include_filters] - next memo unless filters - - rel_records = filtering_resource.apply_filters(filtering_resource.records(options), filters, options).references(relationship_name) - memo.merge(rel_records) - end - end - - def filter_records(filters, options, records = records(options)) - records = apply_filters(records, filters, options) - records = apply_includes(records, options) - apply_included_resources_filters(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)) - end - - def find(filters, options = {}) - resources_for(find_records(filters, options), options[:context]) - end - def resources_for(records, context) - records.collect do |model| - resource_class = self.resource_for_model(model) - resource_class.new(model, context) + records.collect do |record| + resource_for(record, 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}) - models.collect do |model| - self.resource_for_model(model).new(model, context) - end + def resource_for(model_record, context) + resource_klass = self.resource_klass_for_model(model_record) + resource_klass.new(model_record, context) 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) - 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 - 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 - end - def verify_filters(filters, context = nil) verified_filters = {} - - return verified_filters if filters.nil? - filters.each do |filter, raw_value| verified_filter = verify_filter(filter, raw_value, context) verified_filters[verified_filter[0]] = verified_filter[1] end verified_filters @@ -904,51 +753,37 @@ end strategy = _allowed_filters.fetch(filter, Hash.new)[:verify] if strategy - if strategy.is_a?(Symbol) || strategy.is_a?(String) - values = send(strategy, filter_values, context) - else - values = strategy.call(filter_values, context) - end + values = call_method_or_proc(strategy, filter_values, context) [filter, values] else if is_filter_relationship?(filter) verify_relationship_filter(filter, filter_values, context) else verify_custom_filter(filter, filter_values, context) end end end + def call_method_or_proc(strategy, *args) + if strategy.is_a?(Symbol) || strategy.is_a?(String) + send(strategy, *args) + else + strategy.call(*args) + end + end + def key_type(key_type) @_resource_key_type = key_type end def resource_key_type @_resource_key_type ||= JSONAPI.configuration.resource_key_type end - # override to all resolution of masked ids to actual ids. Because singleton routes do not specify the id this - # will be needed to allow lookup of singleton resources. Alternately singleton resources can override - # `verify_key` - def singleton_key(context) - if @_singleton_options && @_singleton_options[:singleton_key] - strategy = @_singleton_options[:singleton_key] - case strategy - when Proc - key = strategy.call(context) - when Symbol, String - key = send(strategy, context) - else - raise "singleton_key must be a proc or function name" - end - end - key - end - def verify_key(key, context = nil) key_type = resource_key_type case key_type when :integer @@ -980,32 +815,40 @@ return keys.collect do |key| verify_key(key, context) end end - # Either add a custom :verify labmda or override verify_custom_filter to allow for custom filters + # Either add a custom :verify lambda or override verify_custom_filter to allow for custom filters def verify_custom_filter(filter, value, _context = nil) [filter, value] end - # Either add a custom :verify labmda or override verify_relationship_filter to allow for custom + # Either add a custom :verify lambda or override verify_relationship_filter to allow for custom # relationship logic, such as uuids, multiple keys or permission checks on keys def verify_relationship_filter(filter, raw, _context = nil) [filter, raw] end # quasi private class methods def _attribute_options(attr) default_attribute_options.merge(@_attributes[attr]) end + def _attribute_delegated_name(attr) + @_attributes.fetch(attr.to_sym, {}).fetch(:delegate, attr) + end + def _has_attribute?(attr) @_attributes.keys.include?(attr.to_sym) end + def _updatable_attributes + _attributes.map { |key, options| key unless options[:readonly] }.compact + end + def _updatable_relationships - @_relationships.map { |key, _relationship| key } + @_relationships.map { |key, relationship| key unless relationship.readonly? }.compact end def _relationship(type) type = type.to_sym @_relationships[type] @@ -1022,13 +865,17 @@ return @_model_name.to_s end end def _primary_key - @_primary_key ||= _model_class.respond_to?(:primary_key) ? _model_class.primary_key : :id + @_primary_key ||= _default_primary_key end + def _default_primary_key + @_default_primary_key ||=_model_class.respond_to?(:primary_key) ? _model_class.primary_key : :id + end + def _cache_field @_cache_field ||= JSONAPI.configuration.default_resource_cache_field end def _table_name @@ -1041,10 +888,14 @@ def _allowed_filters defined?(@_allowed_filters) ? @_allowed_filters : { id: {} } end + def _allowed_sort + @_allowed_sort ||= {} + end + def _paginator @_paginator ||= JSONAPI.configuration.default_paginator end def paginator(paginator) @@ -1069,51 +920,35 @@ def mutable? !@immutable end - def exclude_links(exclude) - _resolve_exclude_links(exclude) - end - - def _exclude_links - @_exclude_links ||= _resolve_exclude_links(JSONAPI.configuration.default_exclude_links) - end - - def exclude_link?(link) - _exclude_links.include?(link.to_sym) - end - - def _resolve_exclude_links(exclude) - case exclude - when :default, "default" - @_exclude_links = [:self] - when :none, "none" - @_exclude_links = [] - when Array - @_exclude_links = exclude.collect {|link| link.to_sym} - else - fail "Invalid exclude_links" - end - end - def caching(val = true) @caching = val end def _caching @caching end def caching? - @caching && !JSONAPI.configuration.resource_cache.nil? + if @caching.nil? + !JSONAPI.configuration.resource_cache.nil? && JSONAPI.configuration.default_caching + else + @caching && !JSONAPI.configuration.resource_cache.nil? + end end - def attribute_caching_context(context) + def attribute_caching_context(_context) nil end + # Generate a hashcode from the value to be used as part of the cache lookup + def hash_cache_field(value) + value.hash + end + def _model_class return nil if _abstract return @model_class if @model_class @@ -1130,15 +965,19 @@ def _allowed_filter?(filter) !_allowed_filters[filter].nil? end + def _has_sort?(sorting) + !_allowed_sort[sorting.to_sym].nil? + end + def module_path if name == 'JSONAPI::Resource' '' else - name =~ MODULE_PATH_REGEXP ? ($`.freeze.gsub('::', '/') + '/').underscore : '' + name =~ /::[^:]+\Z/ ? ($`.freeze.gsub('::', '/') + '/').underscore : '' end end def default_sort [{field: 'id', direction: :asc}] @@ -1162,67 +1001,59 @@ attrs.each do |name| relationship_name = name.to_sym 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) - end + # ResourceBuilder methods + def define_relationship_methods(relationship_name, relationship_klass, options) + relationship = register_relationship( + relationship_name, + relationship_klass.new(relationship_name, options) + ) - def register_relationship(name, relationship_object) - @_relationships[name] = relationship_object + define_foreign_key_setter(relationship) end - private - - 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) + def define_foreign_key_setter(relationship) + if relationship.polymorphic? + define_on_resource "#{relationship.foreign_key}=" do |v| + _model.method("#{relationship.foreign_key}=").call(v[:id]) + _model.public_send("#{relationship.polymorphic_type}=", v[:type]) + end else - resources = resources_for(records, options[:context]).map{|r| [r.id, r] }.to_h + define_on_resource "#{relationship.foreign_key}=" do |value| + _model.method("#{relationship.foreign_key}=").call(value) + end end + end - preload_included_fragments(resources, records, serializer, options) - - resources.values + def define_on_resource(method_name, &block) + return if method_defined?(method_name) + define_method(method_name, block) end - def find_records(filters, options = {}) - context = options[:context] - - records = filter_records(filters, options) - - sort_criteria = options.fetch(:sort_criteria) { [] } - order_options = construct_order_options(sort_criteria) - records = sort_records(records, order_options, context) - - records = apply_pagination(records, options[:paginator], order_options) - - 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." return end end def check_reserved_attribute_name(name) # Allow :id since it can be used to specify the format. Since it is a method on the base Resource # an attribute method won't be created for it. - if [:type].include?(name.to_sym) + if [:type, :_cache_field, :cache_field].include?(name.to_sym) warn "[NAME COLLISION] `#{name}` is a reserved key in #{_resource_name_from_type(_type)}." end end def check_reserved_relationship_name(name) @@ -1239,140 +1070,9 @@ def check_duplicate_attribute_name(name) if _attributes.include?(name.to_sym) 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 - association_res = target_resources[klass.name].fetch(rel_id, nil) - assoc_rels[rel_id] = association_res if association_res - 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) - Arel.sql("#{quoted_table}.#{quoted_column}") - end - relation.pluck(*quoted_attrs) end end end end