lib/jsonapi/resource.rb in jsonapi-resources-0.8.3 vs lib/jsonapi/resource.rb in jsonapi-resources-0.9.0.beta1
- old
+ new
@@ -34,10 +34,14 @@
def id
_model.public_send(self.class._primary_key)
end
+ def cache_id
+ [id, _model.public_send(self.class._cache_field)]
+ end
+
def is_new?
id.nil?
end
def change(callback)
@@ -163,10 +167,15 @@
# 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
@@ -313,11 +322,11 @@
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}=", key_type.to_s.classify)
+ _model.public_send("#{relationship.polymorphic_type}=", _model_class_name(key_type))
@save_needed = true
:completed
end
@@ -393,14 +402,21 @@
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._attributes = (_attributes || {}).dup
subclass._model_hints = (_model_hints || {}).dup
subclass._relationships = {}
# Add the relationships from the base class to the subclass using the original options
@@ -415,13 +431,11 @@
subclass._allowed_filters = (_allowed_filters || Set.new).dup
type = subclass.name.demodulize.sub(/Resource$/, '').underscore
subclass._type = type.pluralize.to_sym
- unless subclass._attributes[:id]
- subclass.attribute :id, format: :id
- end
+ subclass.attribute :id, format: :id
check_reserved_resource_name(subclass._type, subclass.name)
end
def resource_for(type)
@@ -451,11 +465,12 @@
else
model_name.rpartition('/').last
end
end
- attr_accessor :_attributes, :_relationships, :_allowed_filters, :_type, :_paginator, :_model_hints
+ attr_accessor :_attributes, :_relationships, :_type, :_model_hints
+ attr_writer :_allowed_filters, :_paginator
def create(context)
new(create_model, context)
end
@@ -557,33 +572,22 @@
def primary_key(key)
@_primary_key = key.to_sym
end
- # TODO: remove this after the createable_fields and updateable_fields are phased out
- # :nocov:
- def method_missing(method, *args)
- if method.to_s.match /createable_fields/
- ActiveSupport::Deprecation.warn('`createable_fields` is deprecated, please use `creatable_fields` instead')
- creatable_fields(*args)
- elsif method.to_s.match /updateable_fields/
- ActiveSupport::Deprecation.warn('`updateable_fields` is deprecated, please use `updatable_fields` instead')
- updatable_fields(*args)
- else
- super
- end
+ def cache_field(field)
+ @_cache_field = field.to_sym
end
- # :nocov:
# Override in your resource to filter the updatable keys
def updatable_fields(_context = nil)
_updatable_relationships | _attributes.keys - [:id]
end
# Override in your resource to filter the creatable keys
def creatable_fields(_context = nil)
- _updatable_relationships | _attributes.keys - [:id]
+ _updatable_relationships | _attributes.keys
end
# Override in your resource to filter the sortable keys
def sortable_fields(_context = nil)
_attributes.keys
@@ -727,23 +731,12 @@
def find_count(filters, options = {})
count_records(filter_records(filters, options))
end
- # Override this method if you have more complex requirements than this basic find method provides
def find(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)
-
- resources_for(records, context)
+ 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)
@@ -759,21 +752,43 @@
models.collect do |model|
self.resource_for_model(model).new(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)
+ end
+
def find_by_key(key, options = {})
context = options[:context]
- records = records(options)
- records = apply_includes(records, options)
- model = records.where({_primary_key => key}).first
+ 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)
+ # finder methods (find, find_by_key, find_serialized_with_caching)
def records(_options = {})
_model_class.all
end
def verify_filters(filters, context = nil)
@@ -880,27 +895,38 @@
type = type.to_sym
@_relationships[type]
end
def _model_name
- _abstract ? '' : @_model_name ||= name.demodulize.sub(/Resource$/, '')
+ if _abstract
+ return ''
+ else
+ return @_model_name if defined?(@_model_name)
+ class_name = self.name
+ return '' if class_name.nil?
+ return @_model_name = class_name.demodulize.sub(/Resource$/, '')
+ end
end
def _primary_key
@_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
@_table_name ||= _model_class.respond_to?(:table_name) ? _model_class.table_name : _model_name.tableize
end
def _as_parent_key
@_as_parent_key ||= "#{_type.to_s.singularize}_id"
end
def _allowed_filters
- !@_allowed_filters.nil? ? @_allowed_filters : { id: {} }
+ defined?(@_allowed_filters) ? @_allowed_filters : { id: {} }
end
def _paginator
@_paginator ||= JSONAPI.configuration.default_paginator
end
@@ -927,14 +953,31 @@
def mutable?
!@immutable
end
+ def caching(val = true)
+ @caching = val
+ end
+
+ def _caching
+ @caching
+ end
+
+ def caching?
+ @caching && !JSONAPI.configuration.resource_cache.nil?
+ end
+
+ def attribute_caching_context(context)
+ nil
+ end
+
def _model_class
return nil if _abstract
- return @model if @model
+ return @model if defined?(@model)
+ return nil if self.name.to_s.blank? && _model_name.to_s.blank?
@model = _model_name.to_s.safe_constantize
warn "[MODEL NOT FOUND] Model could not be found for #{self.name}. If this a base Resource declare it as abstract." if @model.nil?
@model
end
@@ -987,10 +1030,40 @@
@_relationships[name] = relationship_object
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)
+ else
+ resources = resources_for(records, options).map{|r| [r.id, r] }.to_h
+ end
+
+ preload_included_fragments(resources, records, serializer, options)
+
+ resources.values
+ 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
+ end
+
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
@@ -1018,9 +1091,132 @@
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
+
+ relevant_options = options.except(:include_directives, :order, :paginator)
+ 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
+ path.each do |elem|
+ relationship = klass._relationships[elem]
+ 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
+
+ # 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}, relevant_options)
+ 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