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