lib/jsonapi/resource.rb in jsonapi-resources-0.7.1.beta2 vs lib/jsonapi/resource.rb in jsonapi-resources-0.8.0.beta1
- old
+ new
@@ -1,6 +1,7 @@
require 'jsonapi/callbacks'
+require 'jsonapi/relationship_builder'
module JSONAPI
class Resource
include Callbacks
@@ -20,10 +21,13 @@
:replace_fields
def initialize(model, context)
@model = model
@context = context
+ @reload_needed = false
+ @changing = false
+ @save_needed = false
end
def _model
@model
end
@@ -61,43 +65,43 @@
run_callbacks :remove do
_remove
end
end
- def create_to_many_links(relationship_type, relationship_key_values)
+ def create_to_many_links(relationship_type, relationship_key_values, options = {})
change :create_to_many_link do
- _create_to_many_links(relationship_type, relationship_key_values)
+ _create_to_many_links(relationship_type, relationship_key_values, options)
end
end
- def replace_to_many_links(relationship_type, relationship_key_values)
+ def replace_to_many_links(relationship_type, relationship_key_values, options = {})
change :replace_to_many_links do
- _replace_to_many_links(relationship_type, relationship_key_values)
+ _replace_to_many_links(relationship_type, relationship_key_values, options)
end
end
- def replace_to_one_link(relationship_type, relationship_key_value)
+ def replace_to_one_link(relationship_type, relationship_key_value, options = {})
change :replace_to_one_link do
- _replace_to_one_link(relationship_type, relationship_key_value)
+ _replace_to_one_link(relationship_type, relationship_key_value, options)
end
end
- def replace_polymorphic_to_one_link(relationship_type, relationship_key_value, relationship_key_type)
+ def replace_polymorphic_to_one_link(relationship_type, relationship_key_value, relationship_key_type, options = {})
change :replace_polymorphic_to_one_link do
- _replace_polymorphic_to_one_link(relationship_type, relationship_key_value, relationship_key_type)
+ _replace_polymorphic_to_one_link(relationship_type, relationship_key_value, relationship_key_type, options)
end
end
- def remove_to_many_link(relationship_type, key)
+ def remove_to_many_link(relationship_type, key, options = {})
change :remove_to_many_link do
- _remove_to_many_link(relationship_type, key)
+ _remove_to_many_link(relationship_type, key, options)
end
end
- def remove_to_one_link(relationship_type)
+ def remove_to_one_link(relationship_type, options = {})
change :remove_to_one_link do
- _remove_to_one_link(relationship_type)
+ _remove_to_one_link(relationship_type, options)
end
end
def replace_fields(field_data)
change :replace_fields do
@@ -187,20 +191,23 @@
fail JSONAPI::Exceptions::ValidationErrors.new(self)
end
if defined? @model.save
saved = @model.save(validate: false)
+
unless saved
if @model.errors.present?
fail JSONAPI::Exceptions::ValidationErrors.new(self)
else
fail JSONAPI::Exceptions::SaveFailed.new
end
end
else
saved = true
end
+ @model.reload if @reload_needed
+ @reload_needed = false
@save_needed = !saved
:completed
end
@@ -213,71 +220,143 @@
rescue ActiveRecord::DeleteRestrictionError => e
fail JSONAPI::Exceptions::RecordLocked.new(e.message)
end
- def _create_to_many_links(relationship_type, relationship_key_values)
+ def reflect_relationship?(relationship, options)
+ return false if !relationship.reflect ||
+ (!JSONAPI.configuration.use_relationship_reflection || options[:reflected_source])
+
+ inverse_relationship = relationship.resource_klass._relationships[relationship.inverse_relationship]
+ if inverse_relationship.nil?
+ warn "Inverse relationship could not be found for #{self.class.name}.#{relationship.name}. Relationship reflection disabled."
+ return false
+ end
+ true
+ end
+
+ def _create_to_many_links(relationship_type, relationship_key_values, options)
relationship = self.class._relationships[relationship_type]
- relationship_key_values.each do |relationship_key_value|
- related_resource = relationship.resource_klass.find_by_key(relationship_key_value, context: @context)
+ # 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
- relation_name = relationship.relation_name(context: @context)
- # TODO: Add option to skip relations that already exist instead of returning an error?
- relation = @model.public_send(relation_name).where(relationship.primary_key => relationship_key_value).first
- if relation.nil?
- @model.public_send(relation_name) << related_resource._model
+ if options[:reflected_source]
+ @model.public_send(relation_name) << options[:reflected_source]._model
+ return :completed
+ end
+
+ # load requested related resources
+ # make sure they all exist (also based on context) and add them to relationship
+
+ related_resources = relationship.resource_klass.find_by_keys(relationship_key_values, context: @context)
+
+ if related_resources.count != relationship_key_values.count
+ # todo: obscure id so not to leak info
+ fail JSONAPI::Exceptions::RecordNotFound.new('unspecified')
+ end
+
+ reflect = reflect_relationship?(relationship, options)
+
+ related_resources.each do |related_resource|
+ if reflect
+ if related_resource.class._relationships[relationship.inverse_relationship].is_a?(JSONAPI::Relationship::ToMany)
+ related_resource.create_to_many_links(relationship.inverse_relationship, [id], reflected_source: self)
+ else
+ related_resource.replace_to_one_link(relationship.inverse_relationship, id, reflected_source: self)
+ end
+ @reload_needed = true
else
- fail JSONAPI::Exceptions::HasManyRelationExists.new(relationship_key_value)
+ @model.public_send(relation_name) << related_resource._model
end
end
:completed
end
- def _replace_to_many_links(relationship_type, relationship_key_values)
+ def _replace_to_many_links(relationship_type, relationship_key_values, options)
relationship = self.class._relationships[relationship_type]
- send("#{relationship.foreign_key}=", relationship_key_values)
- @save_needed = true
+ reflect = reflect_relationship?(relationship, options)
+
+ if reflect
+ existing = send("#{relationship.foreign_key}")
+ 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
+ else
+ send("#{relationship.foreign_key}=", relationship_key_values)
+ @save_needed = true
+ end
+
:completed
end
- def _replace_to_one_link(relationship_type, relationship_key_value)
+ 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)
+ 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)
@save_needed = true
:completed
end
- def _remove_to_many_link(relationship_type, key)
- relation_name = self.class._relationships[relationship_type].relation_name(context: @context)
+ def _remove_to_many_link(relationship_type, key, options)
+ relationship = self.class._relationships[relationship_type]
- @model.public_send(relation_name).delete(key)
+ reflect = reflect_relationship?(relationship, options)
+ if reflect
+
+ related_resource = relationship.resource_klass.find_by_key(key, context: @context)
+
+ if related_resource.nil?
+ fail JSONAPI::Exceptions::RecordNotFound.new(key)
+ else
+ if related_resource.class._relationships[relationship.inverse_relationship].is_a?(JSONAPI::Relationship::ToMany)
+ related_resource.remove_to_many_link(relationship.inverse_relationship, id, reflected_source: self)
+ else
+ related_resource.remove_to_one_link(relationship.inverse_relationship, reflected_source: self)
+ end
+ end
+
+ @reload_needed = true
+ else
+ @model.public_send(relationship.relation_name(context: @context)).delete(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)
+ def _remove_to_one_link(relationship_type, options)
relationship = self.class._relationships[relationship_type]
send("#{relationship.foreign_key}=", nil)
@save_needed = true
@@ -402,10 +481,12 @@
if (attr.to_sym == :id) && (options[:format].nil?)
ActiveSupport::Deprecation.warn('Id without format is no longer supported. Please remove ids from attributes, or specify a format.')
end
+ check_duplicate_attribute_name(attr) if options[:format].nil?
+
@_attributes ||= {}
@_attributes[attr] = options
define_method attr do
@model.public_send(options[:delegate] ? options[:delegate].to_sym : attr)
end unless method_defined?(attr)
@@ -436,10 +517,19 @@
def has_one(*attrs)
_add_relationship(Relationship::ToOne, *attrs)
end
+ def belongs_to(*attrs)
+ ActiveSupport::Deprecation.warn "In #{name} you exposed a `has_one` relationship "\
+ " using the `belongs_to` class method. We think `has_one`" \
+ " is more appropriate. If you know what you're doing," \
+ " and don't want to see this warning again, override the" \
+ " `belongs_to` class method on your resource."
+ _add_relationship(Relationship::ToOne, *attrs)
+ end
+
def has_many(*attrs)
_add_relationship(Relationship::ToMany, *attrs)
end
def model_name(model, options = {})
@@ -611,11 +701,11 @@
end
end
end
if required_includes.any?
- records = apply_includes(records, options.merge(include_directives: IncludeDirectives.new(required_includes)))
+ records = apply_includes(records, options.merge(include_directives: IncludeDirectives.new(self, required_includes, force_eager_load: true)))
end
records
end
@@ -651,18 +741,24 @@
resources_for(records, context)
end
def resources_for(records, context)
- resources = []
- resource_classes = {}
- records.each do |model|
- resource_class = resource_classes[model.class] ||= self.resource_for_model(model)
- resources.push resource_class.new(model, context)
+ records.collect do |model|
+ resource_class = self.resource_for_model(model)
+ resource_class.new(model, context)
end
+ end
- resources
+ 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
end
def find_by_key(key, options = {})
context = options[:context]
records = records(options)
@@ -850,134 +946,45 @@
else
name =~ /::[^:]+\Z/ ? ($`.freeze.gsub('::', '/') + '/').underscore : ''
end
end
+ def default_sort
+ [{field: 'id', direction: :asc}]
+ end
+
def construct_order_options(sort_params)
+ sort_params ||= default_sort
+
return {} unless sort_params
sort_params.each_with_object({}) do |sort, order_hash|
- field = sort[:field] == 'id' ? _primary_key : sort[:field]
+ field = sort[:field].to_s == 'id' ? _primary_key : sort[:field].to_s
order_hash[field] = sort[:direction]
end
end
def _add_relationship(klass, *attrs)
options = attrs.extract_options!
options[:parent_resource] = self
- attrs.each do |attr|
- relationship_name = attr.to_sym
-
+ attrs.each do |relationship_name|
check_reserved_relationship_name(relationship_name)
+ check_duplicate_relationship_name(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
+ JSONAPI::RelationshipBuilder.new(klass, _model_class, options)
+ .define_relationship_methods(relationship_name.to_sym)
+ end
+ end
- @_relationships[relationship_name] = relationship = klass.new(relationship_name, options)
+ # Allows JSONAPI::RelationshipBuilder to access metaprogramming hooks
+ def inject_method_definition(name, body)
+ define_method(name, body)
+ end
- associated_records_method_name = case relationship
- when JSONAPI::Relationship::ToOne then "record_for_#{relationship_name}"
- when JSONAPI::Relationship::ToMany then "records_for_#{relationship_name}"
- end
-
- foreign_key = relationship.foreign_key
-
- define_method "#{foreign_key}=" do |value|
- @model.method("#{foreign_key}=").call(value)
- end unless method_defined?("#{foreign_key}=")
-
- define_method associated_records_method_name do
- relationship = self.class._relationships[relationship_name]
- relation_name = relationship.relation_name(context: @context)
- records_for(relation_name)
- end unless method_defined?(associated_records_method_name)
-
- if relationship.is_a?(JSONAPI::Relationship::ToOne)
- if relationship.belongs_to?
- define_method foreign_key do
- @model.method(foreign_key).call
- end unless method_defined?(foreign_key)
-
- define_method 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 unless method_defined?(relationship_name)
- else
- define_method 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 unless method_defined?(foreign_key)
-
- define_method relationship_name do |options = {}|
- relationship = self.class._relationships[relationship_name]
-
- 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 unless method_defined?(relationship_name)
- end
- elsif relationship.is_a?(JSONAPI::Relationship::ToMany)
- define_method 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 unless method_defined?(foreign_key)
-
- define_method relationship_name do |options = {}|
- relationship = self.class._relationships[relationship_name]
-
- resource_klass = relationship.resource_klass
- records = public_send(associated_records_method_name)
-
- 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
-
- return records.collect do |record|
- if relationship.polymorphic?
- resource_klass = self.class.resource_for_model(record)
- end
- resource_klass.new(record, @context)
- end
- end unless method_defined?(relationship_name)
- end
- end
+ def register_relationship(name, relationship_object)
+ @_relationships[name] = relationship_object
end
private
def check_reserved_resource_name(type, name)
@@ -996,9 +1003,21 @@
end
def check_reserved_relationship_name(name)
if [:id, :ids, :type, :types].include?(name.to_sym)
warn "[NAME COLLISION] `#{name}` is a reserved relationship name in #{_resource_name_from_type(_type)}."
+ end
+ end
+
+ def check_duplicate_relationship_name(name)
+ if _relationships.include?(name.to_sym)
+ warn "[DUPLICATE RELATIONSHIP] `#{name}` has already been defined in #{_resource_name_from_type(_type)}."
+ end
+ end
+
+ 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
end
end
end