From 854873c6ae12775f4336dfbc49c619b1efa88751 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Thu, 27 Oct 2016 16:44:16 -0400 Subject: [PATCH 001/237] Add facility to override error object members in JR Exceptions --- lib/jsonapi/exceptions.rb | 456 +++++++++++++++++++++----------------- lib/jsonapi/paginator.rb | 4 +- 2 files changed, 254 insertions(+), 206 deletions(-) diff --git a/lib/jsonapi/exceptions.rb b/lib/jsonapi/exceptions.rb index 2d220e756..c6d113bf9 100644 --- a/lib/jsonapi/exceptions.rb +++ b/lib/jsonapi/exceptions.rb @@ -1,6 +1,16 @@ module JSONAPI module Exceptions class Error < RuntimeError + attr :error_object_overrides + + def initialize(error_object_overrides = {}) + @error_object_overrides = error_object_overrides + end + + def create_error_object(error_defaults) + JSONAPI::Error.new(error_defaults.merge(error_object_overrides)) + end + def errors # :nocov: raise NotImplementedError, "Subclass of Error must implement errors method" @@ -11,8 +21,9 @@ def errors class InternalServerError < Error attr_accessor :exception - def initialize(exception) + def initialize(exception, error_object_overrides = {}) @exception = exception + super(error_object_overrides) end def errors @@ -22,300 +33,327 @@ def errors meta[:backtrace] = exception.backtrace end - [JSONAPI::Error.new(code: JSONAPI::INTERNAL_SERVER_ERROR, - status: :internal_server_error, - title: I18n.t('jsonapi-resources.exceptions.internal_server_error.title', - default: 'Internal Server Error'), - detail: I18n.t('jsonapi-resources.exceptions.internal_server_error.detail', + [create_error_object(code: JSONAPI::INTERNAL_SERVER_ERROR, + status: :internal_server_error, + title: I18n.t('jsonapi-resources.exceptions.internal_server_error.title', default: 'Internal Server Error'), - meta: meta)] + detail: I18n.t('jsonapi-resources.exceptions.internal_server_error.detail', + default: 'Internal Server Error'), + meta: meta)] end end class InvalidResource < Error attr_accessor :resource - def initialize(resource) + + def initialize(resource, error_object_overrides = {}) @resource = resource + super(error_object_overrides) end def errors - [JSONAPI::Error.new(code: JSONAPI::INVALID_RESOURCE, - status: :bad_request, - title: I18n.t('jsonapi-resources.exceptions.invalid_resource.title', - default: 'Invalid resource'), - detail: I18n.t('jsonapi-resources.exceptions.invalid_resource.detail', - default: "#{resource} is not a valid resource.", resource: resource))] + [create_error_object(code: JSONAPI::INVALID_RESOURCE, + status: :bad_request, + title: I18n.t('jsonapi-resources.exceptions.invalid_resource.title', + default: 'Invalid resource'), + detail: I18n.t('jsonapi-resources.exceptions.invalid_resource.detail', + default: "#{resource} is not a valid resource.", resource: resource))] end end class RecordNotFound < Error attr_accessor :id - def initialize(id) + + def initialize(id, error_object_overrides = {}) @id = id + super(error_object_overrides) end def errors - [JSONAPI::Error.new(code: JSONAPI::RECORD_NOT_FOUND, - status: :not_found, - title: I18n.translate('jsonapi-resources.exceptions.record_not_found.title', - default: 'Record not found'), - detail: I18n.translate('jsonapi-resources.exceptions.record_not_found.detail', - default: "The record identified by #{id} could not be found.", id: id))] + [create_error_object(code: JSONAPI::RECORD_NOT_FOUND, + status: :not_found, + title: I18n.translate('jsonapi-resources.exceptions.record_not_found.title', + default: 'Record not found'), + detail: I18n.translate('jsonapi-resources.exceptions.record_not_found.detail', + default: "The record identified by #{id} could not be found.", id: id))] end end class UnsupportedMediaTypeError < Error attr_accessor :media_type - def initialize(media_type) + + def initialize(media_type, error_object_overrides = {}) @media_type = media_type + super(error_object_overrides) end def errors - [JSONAPI::Error.new(code: JSONAPI::UNSUPPORTED_MEDIA_TYPE, - status: :unsupported_media_type, - title: I18n.translate('jsonapi-resources.exceptions.unsupported_media_type.title', - default: 'Unsupported media type'), - detail: I18n.translate('jsonapi-resources.exceptions.unsupported_media_type.detail', - default: "All requests that create or update must use the '#{JSONAPI::MEDIA_TYPE}' Content-Type. This request specified '#{media_type}'.", - needed_media_type: JSONAPI::MEDIA_TYPE, - media_type: media_type))] + [create_error_object(code: JSONAPI::UNSUPPORTED_MEDIA_TYPE, + status: :unsupported_media_type, + title: I18n.translate('jsonapi-resources.exceptions.unsupported_media_type.title', + default: 'Unsupported media type'), + detail: I18n.translate('jsonapi-resources.exceptions.unsupported_media_type.detail', + default: "All requests that create or update must use the '#{JSONAPI::MEDIA_TYPE}' Content-Type. This request specified '#{media_type}'.", + needed_media_type: JSONAPI::MEDIA_TYPE, + media_type: media_type))] end end class NotAcceptableError < Error attr_accessor :media_type - def initialize(media_type) + def initialize(media_type, error_object_overrides = {}) @media_type = media_type + super(error_object_overrides) end def errors - [JSONAPI::Error.new(code: JSONAPI::NOT_ACCEPTABLE, - status: :not_acceptable, - title: I18n.translate('jsonapi-resources.exceptions.not_acceptable.title', - default: 'Not acceptable'), - detail: I18n.translate('jsonapi-resources.exceptions.not_acceptable.detail', - default: "All requests must use the '#{JSONAPI::MEDIA_TYPE}' Accept without media type parameters. This request specified '#{media_type}'.", - needed_media_type: JSONAPI::MEDIA_TYPE, - media_type: media_type))] + [create_error_object(code: JSONAPI::NOT_ACCEPTABLE, + status: :not_acceptable, + title: I18n.translate('jsonapi-resources.exceptions.not_acceptable.title', + default: 'Not acceptable'), + detail: I18n.translate('jsonapi-resources.exceptions.not_acceptable.detail', + default: "All requests must use the '#{JSONAPI::MEDIA_TYPE}' Accept without media type parameters. This request specified '#{media_type}'.", + needed_media_type: JSONAPI::MEDIA_TYPE, + media_type: media_type))] end end class HasManyRelationExists < Error attr_accessor :id - def initialize(id) + + def initialize(id, error_object_overrides = {}) @id = id + super(error_object_overrides) end def errors - [JSONAPI::Error.new(code: JSONAPI::RELATION_EXISTS, - status: :bad_request, - title: I18n.translate('jsonapi-resources.exceptions.has_many_relation.title', - default: 'Relation exists'), - detail: I18n.translate('jsonapi-resources.exceptions.has_many_relation.detail', - default: "The relation to #{id} already exists.", - id: id))] + [create_error_object(code: JSONAPI::RELATION_EXISTS, + status: :bad_request, + title: I18n.translate('jsonapi-resources.exceptions.has_many_relation.title', + default: 'Relation exists'), + detail: I18n.translate('jsonapi-resources.exceptions.has_many_relation.detail', + default: "The relation to #{id} already exists.", + id: id))] end end class ToManySetReplacementForbidden < Error def errors - [JSONAPI::Error.new(code: JSONAPI::FORBIDDEN, - status: :forbidden, - title: I18n.translate('jsonapi-resources.exceptions.to_many_set_replacement_forbidden.title', - default: 'Complete replacement forbidden'), - detail: I18n.translate('jsonapi-resources.exceptions.to_many_set_replacement_forbidden.detail', - default: 'Complete replacement forbidden for this relationship'))] + [create_error_object(code: JSONAPI::FORBIDDEN, + status: :forbidden, + title: I18n.translate('jsonapi-resources.exceptions.to_many_set_replacement_forbidden.title', + default: 'Complete replacement forbidden'), + detail: I18n.translate('jsonapi-resources.exceptions.to_many_set_replacement_forbidden.detail', + default: 'Complete replacement forbidden for this relationship'))] end end class InvalidFiltersSyntax < Error attr_accessor :filters - def initialize(filters) + + def initialize(filters, error_object_overrides = {}) @filters = filters + super(error_object_overrides) end def errors - [JSONAPI::Error.new(code: JSONAPI::INVALID_FILTERS_SYNTAX, - status: :bad_request, - title: I18n.translate('jsonapi-resources.exceptions.invalid_filter_syntax.title', - default: 'Invalid filters syntax'), - detail: I18n.translate('jsonapi-resources.exceptions.invalid_filter_syntax.detail', - default: "#{filters} is not a valid syntax for filtering.", - filters: filters))] + [create_error_object(code: JSONAPI::INVALID_FILTERS_SYNTAX, + status: :bad_request, + title: I18n.translate('jsonapi-resources.exceptions.invalid_filter_syntax.title', + default: 'Invalid filters syntax'), + detail: I18n.translate('jsonapi-resources.exceptions.invalid_filter_syntax.detail', + default: "#{filters} is not a valid syntax for filtering.", + filters: filters))] end end class FilterNotAllowed < Error attr_accessor :filter - def initialize(filter) + + def initialize(filter, error_object_overrides = {}) @filter = filter + super(error_object_overrides) end def errors - [JSONAPI::Error.new(code: JSONAPI::FILTER_NOT_ALLOWED, - status: :bad_request, - title: I18n.translate('jsonapi-resources.exceptions.filter_not_allowed.title', - default: 'Filter not allowed'), - detail: I18n.translate('jsonapi-resources.exceptions.filter_not_allowed.detail', - default: "#{filter} is not allowed.", filter: filter))] + [create_error_object(code: JSONAPI::FILTER_NOT_ALLOWED, + status: :bad_request, + title: I18n.translate('jsonapi-resources.exceptions.filter_not_allowed.title', + default: 'Filter not allowed'), + detail: I18n.translate('jsonapi-resources.exceptions.filter_not_allowed.detail', + default: "#{filter} is not allowed.", filter: filter))] end end class InvalidFilterValue < Error attr_accessor :filter, :value - def initialize(filter, value) + + def initialize(filter, value, error_object_overrides = {}) @filter = filter @value = value + super(error_object_overrides) end def errors - [JSONAPI::Error.new(code: JSONAPI::INVALID_FILTER_VALUE, - status: :bad_request, - title: I18n.translate('jsonapi-resources.exceptions.invalid_filter_value.title', - default: 'Invalid filter value'), - detail: I18n.translate('jsonapi-resources.exceptions.invalid_filter_value.detail', - default: "#{value} is not a valid value for #{filter}.", - value: value, filter: filter))] + [create_error_object(code: JSONAPI::INVALID_FILTER_VALUE, + status: :bad_request, + title: I18n.translate('jsonapi-resources.exceptions.invalid_filter_value.title', + default: 'Invalid filter value'), + detail: I18n.translate('jsonapi-resources.exceptions.invalid_filter_value.detail', + default: "#{value} is not a valid value for #{filter}.", + value: value, filter: filter))] end end class InvalidFieldValue < Error attr_accessor :field, :value - def initialize(field, value) + + def initialize(field, value, error_object_overrides = {}) @field = field @value = value + super(error_object_overrides) end def errors - [JSONAPI::Error.new(code: JSONAPI::INVALID_FIELD_VALUE, - status: :bad_request, - title: I18n.translate('jsonapi-resources.exceptions.invalid_field_value.title', - default: 'Invalid field value'), - detail: I18n.translate('jsonapi-resources.exceptions.invalid_field_value.detail', - default: "#{value} is not a valid value for #{field}.", - value: value, field: field))] + [create_error_object(code: JSONAPI::INVALID_FIELD_VALUE, + status: :bad_request, + title: I18n.translate('jsonapi-resources.exceptions.invalid_field_value.title', + default: 'Invalid field value'), + detail: I18n.translate('jsonapi-resources.exceptions.invalid_field_value.detail', + default: "#{value} is not a valid value for #{field}.", + value: value, field: field))] end end class InvalidFieldFormat < Error def errors - [JSONAPI::Error.new(code: JSONAPI::INVALID_FIELD_FORMAT, - status: :bad_request, - title: I18n.translate('jsonapi-resources.exceptions.invalid_field_format.title', - default: 'Invalid field format'), - detail: I18n.translate('jsonapi-resources.exceptions.invalid_field_format.detail', - default: 'Fields must specify a type.'))] + [create_error_object(code: JSONAPI::INVALID_FIELD_FORMAT, + status: :bad_request, + title: I18n.translate('jsonapi-resources.exceptions.invalid_field_format.title', + default: 'Invalid field format'), + detail: I18n.translate('jsonapi-resources.exceptions.invalid_field_format.detail', + default: 'Fields must specify a type.'))] end end class InvalidDataFormat < Error def errors - [JSONAPI::Error.new(code: JSONAPI::INVALID_DATA_FORMAT, - status: :bad_request, - title: I18n.translate('jsonapi-resources.exceptions.invalid_data_format.title', - default: 'Invalid data format'), - detail: I18n.translate('jsonapi-resources.exceptions.invalid_data_format.detail', - default: 'Data must be a hash.'))] + [create_error_object(code: JSONAPI::INVALID_DATA_FORMAT, + status: :bad_request, + title: I18n.translate('jsonapi-resources.exceptions.invalid_data_format.title', + default: 'Invalid data format'), + detail: I18n.translate('jsonapi-resources.exceptions.invalid_data_format.detail', + default: 'Data must be a hash.'))] end end class InvalidLinksObject < Error def errors - [JSONAPI::Error.new(code: JSONAPI::INVALID_LINKS_OBJECT, - status: :bad_request, - title: I18n.translate('jsonapi-resources.exceptions.invalid_links_object.title', - default: 'Invalid Links Object'), - detail: I18n.translate('jsonapi-resources.exceptions.invalid_links_object.detail', - default: 'Data is not a valid Links Object.'))] + [create_error_object(code: JSONAPI::INVALID_LINKS_OBJECT, + status: :bad_request, + title: I18n.translate('jsonapi-resources.exceptions.invalid_links_object.title', + default: 'Invalid Links Object'), + detail: I18n.translate('jsonapi-resources.exceptions.invalid_links_object.detail', + default: 'Data is not a valid Links Object.'))] end end class TypeMismatch < Error attr_accessor :type - def initialize(type) + + def initialize(type, error_object_overrides = {}) @type = type + super(error_object_overrides) end def errors - [JSONAPI::Error.new(code: JSONAPI::TYPE_MISMATCH, - status: :bad_request, - title: I18n.translate('jsonapi-resources.exceptions.type_mismatch.title', - default: 'Type Mismatch'), - detail: I18n.translate('jsonapi-resources.exceptions.type_mismatch.detail', - default: "#{type} is not a valid type for this operation.", type: type))] + [create_error_object(code: JSONAPI::TYPE_MISMATCH, + status: :bad_request, + title: I18n.translate('jsonapi-resources.exceptions.type_mismatch.title', + default: 'Type Mismatch'), + detail: I18n.translate('jsonapi-resources.exceptions.type_mismatch.detail', + default: "#{type} is not a valid type for this operation.", type: type))] end end class InvalidField < Error attr_accessor :field, :type - def initialize(type, field) + + def initialize(type, field, error_object_overrides = {}) @field = field @type = type + super(error_object_overrides) end def errors - [JSONAPI::Error.new(code: JSONAPI::INVALID_FIELD, - status: :bad_request, - title: I18n.translate('jsonapi-resources.exceptions.invalid_field.title', - default: 'Invalid field'), - detail: I18n.translate('jsonapi-resources.exceptions.invalid_field.detail', - default: "#{field} is not a valid field for #{type}.", - field: field, type: type))] + [create_error_object(code: JSONAPI::INVALID_FIELD, + status: :bad_request, + title: I18n.translate('jsonapi-resources.exceptions.invalid_field.title', + default: 'Invalid field'), + detail: I18n.translate('jsonapi-resources.exceptions.invalid_field.detail', + default: "#{field} is not a valid field for #{type}.", + field: field, type: type))] end end class InvalidInclude < Error attr_accessor :relationship, :resource - def initialize(resource, relationship) + + def initialize(resource, relationship, error_object_overrides = {}) @resource = resource @relationship = relationship + super(error_object_overrides) end def errors - [JSONAPI::Error.new(code: JSONAPI::INVALID_INCLUDE, - status: :bad_request, - title: I18n.translate('jsonapi-resources.exceptions.invalid_include.title', - default: 'Invalid field'), - detail: I18n.translate('jsonapi-resources.exceptions.invalid_include.detail', - default: "#{relationship} is not a valid relationship of #{resource}", - relationship: relationship, resource: resource))] + [create_error_object(code: JSONAPI::INVALID_INCLUDE, + status: :bad_request, + title: I18n.translate('jsonapi-resources.exceptions.invalid_include.title', + default: 'Invalid field'), + detail: I18n.translate('jsonapi-resources.exceptions.invalid_include.detail', + default: "#{relationship} is not a valid relationship of #{resource}", + relationship: relationship, resource: resource))] end end class InvalidSortCriteria < Error attr_accessor :sort_criteria, :resource - def initialize(resource, sort_criteria) + + def initialize(resource, sort_criteria, error_object_overrides = {}) @resource = resource @sort_criteria = sort_criteria + super(error_object_overrides) end def errors - [JSONAPI::Error.new(code: JSONAPI::INVALID_SORT_CRITERIA, - status: :bad_request, - title: I18n.translate('jsonapi-resources.exceptions.invalid_sort_criteria.title', - default: 'Invalid sort criteria'), - detail: I18n.translate('jsonapi-resources.exceptions.invalid_sort_criteria.detail', - default: "#{sort_criteria} is not a valid sort criteria for #{resource}", - sort_criteria: sort_criteria, resource: resource))] + [create_error_object(code: JSONAPI::INVALID_SORT_CRITERIA, + status: :bad_request, + title: I18n.translate('jsonapi-resources.exceptions.invalid_sort_criteria.title', + default: 'Invalid sort criteria'), + detail: I18n.translate('jsonapi-resources.exceptions.invalid_sort_criteria.detail', + default: "#{sort_criteria} is not a valid sort criteria for #{resource}", + sort_criteria: sort_criteria, resource: resource))] end end class ParametersNotAllowed < Error attr_accessor :params - def initialize(params) + + def initialize(params, error_object_overrides = {}) @params = params + super(error_object_overrides) end def errors params.collect do |param| - JSONAPI::Error.new(code: JSONAPI::PARAM_NOT_ALLOWED, - status: :bad_request, - title: I18n.translate('jsonapi-resources.exceptions.parameters_not_allowed.title', - default: 'Param not allowed'), - detail: I18n.translate('jsonapi-resources.exceptions.parameters_not_allowed.detail', - default: "#{param} is not allowed.", param: param)) + create_error_object(code: JSONAPI::PARAM_NOT_ALLOWED, + status: :bad_request, + title: I18n.translate('jsonapi-resources.exceptions.parameters_not_allowed.title', + default: 'Param not allowed'), + detail: I18n.translate('jsonapi-resources.exceptions.parameters_not_allowed.detail', + default: "#{param} is not allowed.", param: param)) end end @@ -323,71 +361,78 @@ def errors class ParameterMissing < Error attr_accessor :param - def initialize(param) + + def initialize(param, error_object_overrides = {}) @param = param + super(error_object_overrides) end def errors - [JSONAPI::Error.new(code: JSONAPI::PARAM_MISSING, - status: :bad_request, - title: I18n.translate('jsonapi-resources.exceptions.parameter_missing.title', - default: 'Missing Parameter'), - detail: I18n.translate('jsonapi-resources.exceptions.parameter_missing.detail', - default: "The required parameter, #{param}, is missing.", param: param))] + [create_error_object(code: JSONAPI::PARAM_MISSING, + status: :bad_request, + title: I18n.translate('jsonapi-resources.exceptions.parameter_missing.title', + default: 'Missing Parameter'), + detail: I18n.translate('jsonapi-resources.exceptions.parameter_missing.detail', + default: "The required parameter, #{param}, is missing.", param: param))] end end class KeyNotIncludedInURL < Error attr_accessor :key - def initialize(key) + + def initialize(key, error_object_overrides = {}) @key = key + super(error_object_overrides) end def errors - [JSONAPI::Error.new(code: JSONAPI::KEY_NOT_INCLUDED_IN_URL, - status: :bad_request, - title: I18n.translate('jsonapi-resources.exceptions.key_not_included_in_url.title', - default: 'Key is not included in URL'), - detail: I18n.translate('jsonapi-resources.exceptions.key_not_included_in_url.detail', - default: "The URL does not support the key #{key}", - key: key))] + [create_error_object(code: JSONAPI::KEY_NOT_INCLUDED_IN_URL, + status: :bad_request, + title: I18n.translate('jsonapi-resources.exceptions.key_not_included_in_url.title', + default: 'Key is not included in URL'), + detail: I18n.translate('jsonapi-resources.exceptions.key_not_included_in_url.detail', + default: "The URL does not support the key #{key}", + key: key))] end end class MissingKey < Error def errors - [JSONAPI::Error.new(code: JSONAPI::KEY_ORDER_MISMATCH, - status: :bad_request, - title: I18n.translate('jsonapi-resources.exceptions.missing_key.title', - default: 'A key is required'), - detail: I18n.translate('jsonapi-resources.exceptions.missing_key.detail', - default: 'The resource object does not contain a key.'))] + [create_error_object(code: JSONAPI::KEY_ORDER_MISMATCH, + status: :bad_request, + title: I18n.translate('jsonapi-resources.exceptions.missing_key.title', + default: 'A key is required'), + detail: I18n.translate('jsonapi-resources.exceptions.missing_key.detail', + default: 'The resource object does not contain a key.'))] end end class RecordLocked < Error attr_accessor :message - def initialize(message) + + def initialize(message, error_object_overrides = {}) @message = message + super(error_object_overrides) end def errors - [JSONAPI::Error.new(code: JSONAPI::LOCKED, - status: :locked, - title: I18n.translate('jsonapi-resources.exceptions.record_locked.title', - default: 'Locked resource'), - detail: "#{message}")] + [create_error_object(code: JSONAPI::LOCKED, + status: :locked, + title: I18n.translate('jsonapi-resources.exceptions.record_locked.title', + default: 'Locked resource'), + detail: "#{message}")] end end class ValidationErrors < Error attr_reader :error_messages, :error_metadata, :resource_relationships - def initialize(resource) + def initialize(resource, error_object_overrides = {}) @error_messages = resource.model_error_messages @error_metadata = resource.validation_error_metadata @resource_relationships = resource.class._relationships.keys @key_formatter = JSONAPI.configuration.key_formatter + super(error_object_overrides) end def format_key(key) @@ -403,17 +448,17 @@ def errors private def json_api_error(attr_key, message) - JSONAPI::Error.new(code: JSONAPI::VALIDATION_ERROR, - status: :unprocessable_entity, - title: message, - detail: "#{format_key(attr_key)} - #{message}", - source: { pointer: pointer(attr_key) }, - meta: metadata_for(attr_key, message)) + create_error_object(code: JSONAPI::VALIDATION_ERROR, + status: :unprocessable_entity, + title: message, + detail: "#{format_key(attr_key)} - #{message}", + source: { pointer: pointer(attr_key) }, + meta: metadata_for(attr_key, message)) end def metadata_for(attr_key, message) return if error_metadata.nil? - error_metadata[attr_key] ? error_metadata[attr_key][message] : nil + error_metadata[attr_key] ? error_metadata[attr_key][message] : nil end def pointer(attr_or_relationship_name) @@ -428,61 +473,64 @@ def pointer(attr_or_relationship_name) class SaveFailed < Error def errors - [JSONAPI::Error.new(code: JSONAPI::SAVE_FAILED, - status: :unprocessable_entity, - title: I18n.translate('jsonapi-resources.exceptions.save_failed.title', - default: 'Save failed or was cancelled'), - detail: I18n.translate('jsonapi-resources.exceptions.save_failed.detail', - default: 'Save failed or was cancelled'))] + [create_error_object(code: JSONAPI::SAVE_FAILED, + status: :unprocessable_entity, + title: I18n.translate('jsonapi-resources.exceptions.save_failed.title', + default: 'Save failed or was cancelled'), + detail: I18n.translate('jsonapi-resources.exceptions.save_failed.detail', + default: 'Save failed or was cancelled'))] end end class InvalidPageObject < Error def errors - [JSONAPI::Error.new(code: JSONAPI::INVALID_PAGE_OBJECT, - status: :bad_request, - title: I18n.translate('jsonapi-resources.exceptions.invalid_page_object.title', - default: 'Invalid Page Object'), - detail: I18n.translate('jsonapi-resources.exceptions.invalid_page_object.detail', - default: 'Invalid Page Object.'))] + [create_error_object(code: JSONAPI::INVALID_PAGE_OBJECT, + status: :bad_request, + title: I18n.translate('jsonapi-resources.exceptions.invalid_page_object.title', + default: 'Invalid Page Object'), + detail: I18n.translate('jsonapi-resources.exceptions.invalid_page_object.detail', + default: 'Invalid Page Object.'))] end end class PageParametersNotAllowed < Error attr_accessor :params - def initialize(params) + + def initialize(params, error_object_overrides = {}) @params = params + super(error_object_overrides) end def errors params.collect do |param| - JSONAPI::Error.new(code: JSONAPI::PARAM_NOT_ALLOWED, - status: :bad_request, - title: I18n.translate('jsonapi-resources.exceptions.page_parameters_not_allowed.title', - default: 'Page parameter not allowed'), - detail: I18n.translate('jsonapi-resources.exceptions.page_parameters_not_allowed.detail', - default: "#{param} is not an allowed page parameter.", - param: param)) + create_error_object(code: JSONAPI::PARAM_NOT_ALLOWED, + status: :bad_request, + title: I18n.translate('jsonapi-resources.exceptions.page_parameters_not_allowed.title', + default: 'Page parameter not allowed'), + detail: I18n.translate('jsonapi-resources.exceptions.page_parameters_not_allowed.detail', + default: "#{param} is not an allowed page parameter.", + param: param)) end end end class InvalidPageValue < Error attr_accessor :page, :value - def initialize(page, value, msg = nil) + + def initialize(page, value, error_object_overrides = {}) @page = page @value = value - @msg = msg || I18n.translate('jsonapi-resources.exceptions.invalid_page_value.detail', - default: "#{value} is not a valid value for #{page} page parameter.", - value: value, page: page) + super(error_object_overrides) end def errors - [JSONAPI::Error.new(code: JSONAPI::INVALID_PAGE_VALUE, - status: :bad_request, - title: I18n.translate('jsonapi-resources.exceptions.invalid_page_value.title', - default: 'Invalid page value'), - detail: @msg)] + [create_error_object(code: JSONAPI::INVALID_PAGE_VALUE, + status: :bad_request, + title: I18n.translate('jsonapi-resources.exceptions.invalid_page_value.title', + default: 'Invalid page value'), + detail: I18n.translate('jsonapi-resources.exceptions.invalid_page_value.detail', + default: "#{value} is not a valid value for #{page} page parameter.", + value: value, page: page))] end end end diff --git a/lib/jsonapi/paginator.rb b/lib/jsonapi/paginator.rb index 3ad00abbe..53f8fbbe4 100644 --- a/lib/jsonapi/paginator.rb +++ b/lib/jsonapi/paginator.rb @@ -110,7 +110,7 @@ def verify_pagination_params fail JSONAPI::Exceptions::InvalidPageValue.new(:limit, @limit) elsif @limit > JSONAPI.configuration.maximum_page_size fail JSONAPI::Exceptions::InvalidPageValue.new(:limit, @limit, - "Limit exceeds maximum page size of #{JSONAPI.configuration.maximum_page_size}.") + detail: "Limit exceeds maximum page size of #{JSONAPI.configuration.maximum_page_size}.") end if @offset < 0 @@ -199,7 +199,7 @@ def verify_pagination_params fail JSONAPI::Exceptions::InvalidPageValue.new(:size, @size) elsif @size > JSONAPI.configuration.maximum_page_size fail JSONAPI::Exceptions::InvalidPageValue.new(:size, @size, - "size exceeds maximum page size of #{JSONAPI.configuration.maximum_page_size}.") + detail: "size exceeds maximum page size of #{JSONAPI.configuration.maximum_page_size}.") end if @number < 1 From fd0826a5210142266edba7a2a2ccfb9a4439c4ba Mon Sep 17 00:00:00 2001 From: Brandon Blaylock Date: Sat, 12 Nov 2016 11:40:50 -0600 Subject: [PATCH 002/237] Fixed the indentation of meta example in README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 02c6b30cf..ddbfafbb6 100644 --- a/README.md +++ b/README.md @@ -1053,7 +1053,7 @@ class BookResource < JSONAPI::Resource computed_copyright: options[:serialization_options][:copyright], last_updated_at: _model.updated_at } - end + end end ``` From 2f46f58bfbc6da2cc02474f06303c86a6c38a3be Mon Sep 17 00:00:00 2001 From: Hugh Barrigan Date: Mon, 14 Nov 2016 12:52:52 -0500 Subject: [PATCH 003/237] Allow includes to follow namespacing --- lib/jsonapi/request_parser.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/jsonapi/request_parser.rb b/lib/jsonapi/request_parser.rb index eb0dbf1ee..2929a696a 100644 --- a/lib/jsonapi/request_parser.rb +++ b/lib/jsonapi/request_parser.rb @@ -201,7 +201,7 @@ def check_include(resource_klass, include_parts) relationship = resource_klass._relationship(relationship_name) if relationship && format_key(relationship_name) == include_parts.first unless include_parts.last.empty? - check_include(Resource.resource_for(@resource_klass.module_path + relationship.class_name.to_s.underscore), include_parts.last.partition('.')) + check_include(Resource.resource_for(resource_klass.module_path + relationship.class_name.to_s.underscore), include_parts.last.partition('.')) end else @errors.concat(JSONAPI::Exceptions::InvalidInclude.new(format_key(resource_klass._type), From 0936916f0519f78caa4d639e8db5b7b2e4ab0495 Mon Sep 17 00:00:00 2001 From: Austen Ito Date: Tue, 15 Nov 2016 17:33:38 -0500 Subject: [PATCH 004/237] Fix issue where resources received context nested in hash * If caching has been enabled, however disabled for a resource, the context values are nested in a a 'context' key. For example: { context: { foo: :bar } } vs { foo: :bar } --- lib/jsonapi/resource.rb | 2 +- test/controllers/controller_test.rb | 6 ++++++ test/fixtures/active_record.rb | 9 +++++++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/lib/jsonapi/resource.rb b/lib/jsonapi/resource.rb index 14c34d417..af4ded28e 100644 --- a/lib/jsonapi/resource.rb +++ b/lib/jsonapi/resource.rb @@ -1040,7 +1040,7 @@ def cached_resources_for(records, serializer, options) 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 + resources = resources_for(records, options[:context]).map{|r| [r.id, r] }.to_h end preload_included_fragments(resources, records, serializer, options) diff --git a/test/controllers/controller_test.rb b/test/controllers/controller_test.rb index 83c0bdfac..38baf07c6 100644 --- a/test/controllers/controller_test.rb +++ b/test/controllers/controller_test.rb @@ -2451,6 +2451,12 @@ def test_destroy_relationship_has_and_belongs_to_many_refect ensure JSONAPI.configuration.use_relationship_reflection = false end + + def test_index_with_caching_enabled_uses_context + assert_cacheable_get :index + assert_response :success + assert json_response['data'][0]['attributes']['title'] = 'Title' + end end class Api::V5::AuthorsControllerTest < ActionController::TestCase diff --git a/test/fixtures/active_record.rb b/test/fixtures/active_record.rb index 778c72538..469e81f34 100644 --- a/test/fixtures/active_record.rb +++ b/test/fixtures/active_record.rb @@ -717,6 +717,9 @@ class BoatsController < JSONAPI::ResourceController end class BooksController < JSONAPI::ResourceController + def context + { title: 'Title' } + end end ### CONTROLLERS @@ -1246,7 +1249,13 @@ class AuthorResource < JSONAPI::Resource end class BookResource < JSONAPI::Resource + attribute :title + has_many :authors, class_name: 'Author', inverse_relationship: :books + + def title + context[:title] + end end class AuthorDetailResource < JSONAPI::Resource From d9d9d3fc926d084259db35e7313a81a6b9d64b03 Mon Sep 17 00:00:00 2001 From: David Simon Date: Mon, 28 Nov 2016 12:15:55 -0500 Subject: [PATCH 005/237] Skip preloading of polymorphic relations, better handling of serialization with partially preloaded relations, resolves #889 --- lib/jsonapi/cached_resource_fragment.rb | 48 ++++++++++++++----------- lib/jsonapi/resource.rb | 8 +++++ lib/jsonapi/resource_serializer.rb | 17 +++++++-- test/controllers/controller_test.rb | 30 ++++++++++++++++ 4 files changed, 81 insertions(+), 22 deletions(-) diff --git a/lib/jsonapi/cached_resource_fragment.rb b/lib/jsonapi/cached_resource_fragment.rb index 8b37cc053..67df1d9a5 100644 --- a/lib/jsonapi/cached_resource_fragment.rb +++ b/lib/jsonapi/cached_resource_fragment.rb @@ -6,14 +6,14 @@ def self.fetch_fragments(resource_klass, serializer, context, cache_ids) context_b64 = JSONAPI.configuration.resource_cache_digest_function.call(context_json) context_key = "ATTR-CTX-#{context_b64.gsub("/", "_")}" - results = self.lookup(resource_klass, serializer_config_key, context_key, cache_ids) + results = self.lookup(resource_klass, serializer_config_key, context, context_key, cache_ids) miss_ids = results.select{|k,v| v.nil? }.keys unless miss_ids.empty? find_filters = {resource_klass._primary_key => miss_ids.uniq} find_options = {context: context} resource_klass.find(find_filters, find_options).each do |resource| - (id, cr) = write(resource_klass, resource, serializer, serializer_config_key, context_key) + (id, cr) = write(resource_klass, resource, serializer, serializer_config_key, context, context_key) results[id] = cr end end @@ -29,28 +29,16 @@ def self.fetch_fragments(resource_klass, serializer, context, cache_ids) return results end - def self.from_cache_value(resource_klass, h) - new( - resource_klass, - h.fetch(:id), - h.fetch(:type), - h.fetch(:fetchable), - h.fetch(:rels, nil), - h.fetch(:links, nil), - h.fetch(:attrs, nil), - h.fetch(:meta, nil) - ) - end - - attr_reader :resource_klass, :id, :type, :fetchable_fields, :relationships, + attr_reader :resource_klass, :id, :type, :context, :fetchable_fields, :relationships, :links_json, :attributes_json, :meta_json, :preloaded_fragments - def initialize(resource_klass, id, type, fetchable_fields, relationships, + def initialize(resource_klass, id, type, context, fetchable_fields, relationships, links_json, attributes_json, meta_json) @resource_klass = resource_klass @id = id @type = type + @context = context @fetchable_fields = Set.new(fetchable_fields) # Relationships left uncompiled because we'll often want to insert included ids on retrieval @@ -76,9 +64,14 @@ def to_cache_value } end + def to_real_resource + rs = Resource.resource_for(self.type).find_by_keys([self.id], {context: self.context}) + return rs.try(:first) + end + private - def self.lookup(resource_klass, serializer_config_key, context_key, cache_ids) + def self.lookup(resource_klass, serializer_config_key, context, context_key, cache_ids) type = resource_klass._type keys = cache_ids.map do |(id, cache_key)| @@ -89,20 +82,35 @@ def self.lookup(resource_klass, serializer_config_key, context_key, cache_ids) return keys.each_with_object({}) do |key, hash| (_, id, _, _) = key if hits.has_key?(key) - hash[id] = self.from_cache_value(resource_klass, hits[key]) + hash[id] = self.from_cache_value(resource_klass, context, hits[key]) else hash[id] = nil end end end - def self.write(resource_klass, resource, serializer, serializer_config_key, context_key) + def self.from_cache_value(resource_klass, context, h) + new( + resource_klass, + h.fetch(:id), + h.fetch(:type), + context, + h.fetch(:fetchable), + h.fetch(:rels, nil), + h.fetch(:links, nil), + h.fetch(:attrs, nil), + h.fetch(:meta, nil) + ) + end + + def self.write(resource_klass, resource, serializer, serializer_config_key, context, context_key) (id, cache_key) = resource.cache_id json = serializer.object_hash(resource) # No inclusions passed to object_hash cr = self.new( resource_klass, json['id'], json['type'], + context, resource.fetchable_fields, json['relationships'], json['links'], diff --git a/lib/jsonapi/resource.rb b/lib/jsonapi/resource.rb index af4ded28e..a5bb1d791 100644 --- a/lib/jsonapi/resource.rb +++ b/lib/jsonapi/resource.rb @@ -1133,8 +1133,15 @@ def preload_included_fragments(resources, records, serializer, options) # 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 } } @@ -1148,6 +1155,7 @@ def preload_included_fragments(resources, records, serializer, options) 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 diff --git a/lib/jsonapi/resource_serializer.rb b/lib/jsonapi/resource_serializer.rb index 1bde2ab0d..28e52f3f9 100644 --- a/lib/jsonapi/resource_serializer.rb +++ b/lib/jsonapi/resource_serializer.rb @@ -298,8 +298,11 @@ def cached_relationships_hash(source, include_directives) h = source.relationships || {} return h unless include_directives.has_key?(:include_related) - relationships = source.resource_klass._relationships.select{|k,v| source.fetchable_fields.include?(k) } + relationships = source.resource_klass._relationships.select do |k,v| + source.fetchable_fields.include?(k) + end + real_res = nil relationships.each do |rel_name, relationship| key = @key_formatter.format(rel_name) to_many = relationship.is_a? JSONAPI::Relationship::ToMany @@ -310,7 +313,17 @@ def cached_relationships_hash(source, include_directives) h[key][:data] = to_many ? [] : nil end - source.preloaded_fragments[key].each do |id, f| + fragments = source.preloaded_fragments[key] + if fragments.nil? + # The resources we want were not preloaded, we'll have to bypass the cache. + # This happens when including through belongs_to polymorphic relationships + if real_res.nil? + real_res = source.to_real_resource + end + relation_resources = [real_res.public_send(rel_name)].flatten(1).compact + fragments = relation_resources.map{|r| [r.id, r]}.to_h + end + fragments.each do |id, f| add_resource(f, ia) if h.has_key?(key) diff --git a/test/controllers/controller_test.rb b/test/controllers/controller_test.rb index 38baf07c6..b6e59f537 100644 --- a/test/controllers/controller_test.rb +++ b/test/controllers/controller_test.rb @@ -1923,6 +1923,36 @@ def test_tags_show_multiple_with_nonexistent_ids_at_the_beginning end end +class PicturesControllerTest < ActionController::TestCase + def test_pictures_index + assert_cacheable_get :index + assert_response :success + assert_equal 3, json_response['data'].size + end + + def test_pictures_index_with_polymorphic_include_one_level + assert_cacheable_get :index, params: {include: 'imageable'} + assert_response :success + assert_equal 3, json_response['data'].size + assert_equal 2, json_response['included'].size + end +end + +class DocumentsControllerTest < ActionController::TestCase + def test_documents_index + assert_cacheable_get :index + assert_response :success + assert_equal 1, json_response['data'].size + end + + def test_documents_index_with_polymorphic_include_one_level + assert_cacheable_get :index, params: {include: 'pictures'} + assert_response :success + assert_equal 1, json_response['data'].size + assert_equal 1, json_response['included'].size + end +end + class ExpenseEntriesControllerTest < ActionController::TestCase def setup JSONAPI.configuration.json_key_format = :camelized_key From aefcbacfdc7fa210d3727a4ad7dfd865ba152d05 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Tue, 29 Nov 2016 08:34:41 -0500 Subject: [PATCH 006/237] Update README to use Doc Site --- README.md | 2134 +---------------------------------------------------- 1 file changed, 9 insertions(+), 2125 deletions(-) diff --git a/README.md b/README.md index ddbfafbb6..8213112a3 100644 --- a/README.md +++ b/README.md @@ -2,60 +2,18 @@ [![Join the chat at https://gitter.im/cerebris/jsonapi-resources](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/cerebris/jsonapi-resources?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) -**NOTE:** This README is the documentation for `JSONAPI::Resources`. If you are viewing this at the -[project page on Github](https://github.com/cerebris/jsonapi-resources) you are viewing the documentation for the `master` -branch. This may contain information that is not relevant to the release you are using. Please see the README for the -[version](https://github.com/cerebris/jsonapi-resources/releases) you are using. +`JSONAPI::Resources`, or "JR", provides a framework for developing an API server that complies with the +[JSON:API](http://jsonapi.org/) specification. - --- - -`JSONAPI::Resources`, or "JR", provides a framework for developing a server that complies with the -[JSON API](http://jsonapi.org/) specification. - -Like JSON API itself, JR's design is focused on the resources served by an API. JR needs little more than a definition +Like JSON:API itself, JR's design is focused on the resources served by an API. JR needs little more than a definition of your resources, including their attributes and relationships, to make your server compliant with JSON API. -JR is designed to work with Rails 4.0+, and provides custom routes, controllers, and serializers. JR's resources may be +JR is designed to work with Rails 4.2+, and provides custom routes, controllers, and serializers. JR's resources may be backed by ActiveRecord models or by custom objects. -## Table of Contents +## Documentation -* [Demo App] (#demo-app) -* [Client Libraries] (#client-libraries) -* [Installation] (#installation) -* [Usage] (#usage) - * [Resources] (#resources) - * [JSONAPI::Resource] (#jsonapiresource) - * [Context] (#context) - * [Attributes] (#attributes) - * [Primary Key] (#primary-key) - * [Model Name] (#model-name) - * [Model Hints] (#model-hints) - * [Relationships] (#relationships) - * [Filters] (#filters) - * [Pagination] (#pagination) - * [Included relationships (side-loading resources)] (#included-relationships-side-loading-resources) - * [Resource meta] (#resource-meta) - * [Custom Links] (#custom-links) - * [Callbacks] (#callbacks) - * [Controllers] (#controllers) - * [Namespaces] (#namespaces) - * [Error Codes] (#error-codes) - * [Handling Exceptions] (#handling-exceptions) - * [Action Callbacks] (#action-callbacks) - * [Operation Processors] (#operation-processors) - * [Serializer] (#serializer) - * [Serializer options] (#serializer-options) - * [Formatting] (#formatting) - * [Key Format] (#key-format) - * [Routing] (#routing) - * [Nested Routes] (#nested-routes) - * [Authorization](#authorization) - * [Resource Caching] (#resource-caching) - * [Caching Caveats] (#caching-caveats) -* [Configuration] (#configuration) -* [Contributing] (#contributing) -* [License] (#license) +Full documentation can be found at [http://jsonapi-resources.com](http://jsonapi-resources.com), including the [v0.10 alpha Guide](http://jsonapi-resources.com/v0.10/guide/) specific to this version. ## Demo App @@ -63,8 +21,8 @@ We have a simple demo app, called [Peeps](https://github.com/cerebris/peeps), av ## Client Libraries -JSON API maintains a (non-verified) listing of [client libraries](http://jsonapi.org/implementations/#client-libraries) -which *should* be compatible with JSON API compliant server implementations such as JR. +JSON:API maintains a (non-verified) listing of [client libraries](http://jsonapi.org/implementations/#client-libraries) +which *should* be compatible with JSON:API compliant server implementations such as JR. ## Installation @@ -80,2071 +38,7 @@ Or install it yourself as: $ gem install jsonapi-resources -## Usage - -### Resources - -Resources define the public interface to your API. A resource defines which attributes are exposed, as well as -relationships to other resources. - -Resource definitions should by convention be placed in a directory under app named resources, `app/resources`. The file name should be the single underscored name of the model that backs the resource with `_resource.rb` appended. For example, -a `Contact` model's resource should have a class named `ContactResource` defined in a file named `contact_resource.rb`. - -#### JSONAPI::Resource - -Resources must be derived from `JSONAPI::Resource`, or a class that is itself derived from `JSONAPI::Resource`. - -For example: - -```ruby -class ContactResource < JSONAPI::Resource -end -``` - -A jsonapi-resource generator is available -``` -rails generate jsonapi:resource contact -``` - -##### Abstract Resources - -Resources that are not backed by a model (purely used as base classes for other resources) should be declared as -abstract. - -Because abstract resources do not expect to be backed by a model, they won't attempt to discover the model class -or any of its relationships. - -```ruby -class BaseResource < JSONAPI::Resource - abstract - - has_one :creator -end - -class ContactResource < BaseResource -end -``` - -##### Immutable Resources - -Resources that are immutable should be declared as such with the `immutable` method. Immutable resources will only -generate routes for `index`, `show` and `show_relationship`. - -###### Immutable for Readonly - -Some resources are read-only and are not to be modified through the API. Declaring a resource as immutable prevents -creation of routes that allow modification of the resource. - -###### Immutable Heterogeneous Collections - -Immutable resources can be used as the basis for a heterogeneous collection. Resources in heterogeneous collections can -still be mutated through their own type-specific endpoints. - -```ruby -class VehicleResource < JSONAPI::Resource - immutable - - has_one :owner - attributes :make, :model, :serial_number -end - -class CarResource < VehicleResource - attributes :drive_layout - has_one :driver -end - -class BoatResource < VehicleResource - attributes :length_at_water_line - has_one :captain -end - -# routes - jsonapi_resources :vehicles - jsonapi_resources :cars - jsonapi_resources :boats - -``` - -In the above example vehicles are immutable. A call to `/vehicles` or `/vehicles/1` will return vehicles with types -of either `car` or `boat`. But calls to PUT or POST a `car` must be made to `/cars`. The rails models backing the above -code use Single Table Inheritance. - -#### Context - -Sometimes you will want to access things such as the current logged in user (and other state only available within your controllers) from within your resource classes. To make this state available to a resource class you need to put it into the context hash - this can be done via a `context` method on one of your controllers or across all controllers using ApplicationController. - -For example: - -```ruby -class ApplicationController < JSONAPI::ResourceController - def context - {current_user: current_user} - end -end - -# Specific resource controllers derive from ApplicationController -# and share its context -class PeopleController < ApplicationController - -end - -# Assuming you don't permit user_id (so the client won't assign a wrong user to own the object) -# you can ensure the current user is assigned the record by using the controller's context hash. -class PeopleResource < JSONAPI::Resource - before_save do - @model.user_id = context[:current_user].id if @model.new_record? - end -end -``` - -You can put things that affect serialization and resource configuration into the context. - -#### Attributes - -Any of a resource's attributes that are accessible must be explicitly declared. Single attributes can be declared using -the `attribute` method, and multiple attributes can be declared with the `attributes` method on the resource class. - -For example: - -```ruby -class ContactResource < JSONAPI::Resource - attribute :name_first - attributes :name_last, :email, :twitter -end -``` - -This resource has 4 defined attributes: `name_first`, `name_last`, `email`, `twitter`, as well as the automatically -defined attributes `id` and `type`. By default these attributes must exist on the model that is handled by the resource. - -A resource object wraps a Ruby object, usually an `ActiveModel` record, which is available as the `@model` variable. -This allows a resource's methods to access the underlying model. - -For example, a computed attribute for `full_name` could be defined as such: - -```ruby -class ContactResource < JSONAPI::Resource - attributes :name_first, :name_last, :email, :twitter - attribute :full_name - - def full_name - "#{@model.name_first}, #{@model.name_last}" - end -end -``` - -##### Attribute Delegation - -Normally resource attributes map to an attribute on the model of the same name. Using the `delegate` option allows a resource -attribute to map to a differently named model attribute. For example: - -```ruby -class ContactResource < JSONAPI::Resource - attribute :name_first, delegate: :first_name - attribute :name_last, delegate: :last_name -end -``` - -##### Fetchable Attributes - -By default all attributes are assumed to be fetchable. The list of fetchable attributes can be filtered by overriding -the `fetchable_fields` method. - -Here's an example that prevents guest users from seeing the `email` field: - -```ruby -class AuthorResource < JSONAPI::Resource - attributes :name, :email - model_name 'Person' - has_many :posts - - def fetchable_fields - if (context[:current_user].guest) - super - [:email] - else - super - end - end -end -``` - -Context flows through from the controller to the resource and can be used to control the attributes based on the -current user (or other value). - -##### Creatable and Updatable Attributes - -By default all attributes are assumed to be updatable and creatable. To prevent some attributes from being accepted by -the `update` or `create` methods, override the `self.updatable_fields` and `self.creatable_fields` methods on a resource. - -This example prevents `full_name` from being set: - -```ruby -class ContactResource < JSONAPI::Resource - attributes :name_first, :name_last, :full_name - - def full_name - "#{@model.name_first}, #{@model.name_last}" - end - - def self.updatable_fields(context) - super - [:full_name] - end - - def self.creatable_fields(context) - super - [:full_name] - end -end -``` - -The `context` is not by default used by the `ResourceController`, but may be used if you override the controller methods. -By using the context you have the option to determine the creatable and updatable fields based on the user. - -##### Sortable Attributes - -JR supports [sorting primary resources by multiple sort criteria](http://jsonapi.org/format/#fetching-sorting). - -By default all attributes are assumed to be sortable. To prevent some attributes from being sortable, override the -`self.sortable_fields` method on a resource. - -Here's an example that prevents sorting by post's `body`: - -```ruby -class PostResource < JSONAPI::Resource - attributes :title, :body - - def self.sortable_fields(context) - super(context) - [:body] - end -end -``` - -JR also supports sorting primary resources by fields on relationships. - -Here's an example of sorting books by the author name: - -```ruby -class Book < ActiveRecord::Base - belongs_to :author -end - -class Author < ActiveRecord::Base - has_many :books -end - -class BookResource < JSONAPI::Resource - attributes :title, :body - - def self.sortable_fields(context) - super(context) << :"author.name" - end -end -``` -The request will look something like: -``` -GET /books?include=author&sort=author.name -``` - -###### Default sorting - -By default JR sorts ascending on the `id` of the primary resource, unless the request specifies an alternate sort order. -To override this you may override the `self.default_sort` on a `resource`. `default_sort` should return an array of -`sort_param` hashes. A `sort_param` hash contains a `field` and a `direction`, with `direction` being either `:asc` or -`:desc`. - -For example: - -```ruby - def self.default_sort - [{field: 'name_last', direction: :desc}, {field: 'name_first', direction: :desc}] - end -``` - -##### Attribute Formatting - -Attributes can have a `Format`. By default all attributes use the default formatter. If an attribute has the `format` -option set the system will attempt to find a formatter based on this name. In the following example the `last_login_time` -will be returned formatted to a certain time zone: - -```ruby -class PersonResource < JSONAPI::Resource - attributes :name, :email - attribute :last_login_time, format: :date_with_timezone -end -``` - -The system will lookup a value formatter named `DateWithTimezoneValueFormatter` and will use this when serializing and -updating the attribute. See the [Value Formatters](#value-formatters) section for more details. - -##### Flattening a Rails relationship - -It is possible to flatten Rails relationships into attributes by using getters and setters. This can become handy if a relation needs to be created alongside the creation of the main object which can be the case if there is a bi-directional presence validation. For example: - -```ruby -# Given Models -class Person < ActiveRecord::Base - has_many :spoken_languages - validates :name, :email, :spoken_languages, presence: true -end - -class SpokenLanguage < ActiveRecord::Base - belongs_to :person, inverse_of: :spoken_languages - validates :person, :language_code, presence: true -end - -# Resource with getters and setter -class PersonResource < JSONAPI::Resource - attributes :name, :email, :spoken_languages - - # Getter - def spoken_languages - @model.spoken_languages.pluck(:language_code) - end - - # Setter (because spoken_languages needed for creation) - def spoken_languages=(new_spoken_language_codes) - @model.spoken_languages.destroy_all - new_spoken_language_codes.each do |new_lang_code| - @model.spoken_languages.build(language_code: new_lang_code) - end - end -end -``` - -#### Primary Key - -Resources are always represented using a key of `id`. The resource will interrogate the model to find the primary key. -If the underlying model does not use `id` as the primary key _and_ does not support the `primary_key` method you -must use the `primary_key` method to tell the resource which field on the model to use as the primary key. **Note:** -this _must_ be the actual primary key of the model. - -By default only integer values are allowed for primary key. To change this behavior you can set the `resource_key_type` -configuration option: - -```ruby -JSONAPI.configure do |config| - # Allowed values are :integer(default), :uuid, :string, or a proc - config.resource_key_type = :uuid -end -``` - -##### Override key type on a resource - -You can override the default resource key type on a per-resource basis by calling `key_type` in the resource class, -with the same allowed values as the `resource_key_type` configuration option. - -```ruby -class ContactResource < JSONAPI::Resource - attribute :id - attributes :name_first, :name_last, :email, :twitter - key_type :uuid -end -``` - -##### Custom resource key validators - -If you need more control over the key, you can override the #verify_key method on your resource, or set a lambda that -accepts key and context arguments in `config/initializers/jsonapi_resources.rb`: - -```ruby -JSONAPI.configure do |config| - config.resource_key_type = -> (key, context) { key && String(key) } -end -``` - -#### Model Name - -The name of the underlying model is inferred from the Resource name. It can be overridden by use of the `model_name` -method. For example: - -```ruby -class AuthorResource < JSONAPI::Resource - attribute :name - model_name 'Person' - has_many :posts -end -``` - -#### Model Hints - -Resource instances are created from model records. The determination of the correct resource type is performed using a -simple rule based on the model's name. The name is used to find a resource in the same module (as the originating -resource) that matches the name. This usually works quite well, however it can fail when model names do not match -resource names. It can also fail when using namespaced models. In this case a `model_hint` can be created to map model -names to resources. For example: - -```ruby -class AuthorResource < JSONAPI::Resource - attribute :name - model_name 'Person' - model_hint model: Commenter, resource: :special_person - - has_many :posts - has_many :commenters -end -``` - -Note that when `model_name` is set a corresponding `model_hint` is also added. This can be skipped by using the -`add_model_hint` option set to false. For example: - -```ruby -class AuthorResource < JSONAPI::Resource - model_name 'Legacy::Person', add_model_hint: false -end -``` - -Model hints inherit from parent resources, but are not global in scope. The `model_hint` method accepts `model` and -`resource` named parameters. `model` takes an ActiveRecord class or class name (defaults to the model name), and -`resource` takes a resource type or a resource class (defaults to the current resource's type). - -#### Relationships - -Related resources need to be specified in the resource. These may be declared with the `relationship` or the `has_one` -and the `has_many` methods. - -Here's a simple example using the `relationship` method where a post has a single author and an author can have many -posts: - -```ruby -class PostResource < JSONAPI::Resource - attributes :title, :body - - relationship :author, to: :one -end -``` - -And the corresponding author: - -```ruby -class AuthorResource < JSONAPI::Resource - attribute :name - - relationship :posts, to: :many -end -``` - -And here's the equivalent resources using the `has_one` and `has_many` methods: - -```ruby -class PostResource < JSONAPI::Resource - attributes :title, :body - - has_one :author -end -``` - -And the corresponding author: - -```ruby -class AuthorResource < JSONAPI::Resource - attribute :name - - has_many :posts -end -``` - -##### Options - -The relationship methods (`relationship`, `has_one`, and `has_many`) support the following options: - - * `class_name` - a string specifying the underlying class for the related resource. Defaults to the `class_name` property on the underlying model. - * `foreign_key` - the method on the resource used to fetch the related resource. Defaults to `_id` for has_one and `_ids` for has_many relationships. - * `acts_as_set` - allows the entire set of related records to be replaced in one operation. Defaults to false if not set. - * `polymorphic` - set to true to identify relationships that are polymorphic. - * `relation_name` - the name of the relation to use on the model. A lambda may be provided which allows conditional selection of the relation based on the context. - * `always_include_linkage_data` - if set to true, the relationship includes linkage data. Defaults to false if not set. - * `eager_load_on_include` - if set to false, will not include this relationship in join SQL when requested via an include. You usually want to leave this on, but it will break 'relationships' which are not active record, for example if you want to expose a tree using the `ancestry` gem or similar, or the SQL query becomes too large to handle. Defaults to true if not set. - -`to_one` relationships support the additional option: - * `foreign_key_on` - defaults to `:self`. To indicate that the foreign key is on the related resource specify `:related`. - -`to_many` relationships support the additional option: - * `reflect` - defaults to `true`. To indicate that updates to the relationship are performed on the related resource, if relationship reflection is turned on. See [Configuration] (#configuration) - -Examples: - -```ruby -class CommentResource < JSONAPI::Resource - attributes :body - has_one :post - has_one :author, class_name: 'Person' - has_many :tags, acts_as_set: true -end - -class ExpenseEntryResource < JSONAPI::Resource - attributes :cost, :transaction_date - - has_one :currency, class_name: 'Currency', foreign_key: 'currency_code' - has_one :employee -end - -class TagResource < JSONAPI::Resource - attributes :name - has_one :taggable, polymorphic: true -end -``` - -```ruby -class BookResource < JSONAPI::Resource - - # Only book_admins may see unapproved comments for a book. Using - # a lambda to select the correct relation on the model - has_many :book_comments, relation_name: -> (options = {}) { - context = options[:context] - current_user = context ? context[:current_user] : nil - - unless current_user && current_user.book_admin - :approved_book_comments - else - :book_comments - end - } - ... -end -``` - -The polymorphic relationship will require the resource and controller to exist, although routing to them will cause an -error. - -```ruby -class TaggableResource < JSONAPI::Resource; end -class TaggablesController < JSONAPI::ResourceController; end -``` - -#### Filters - -Filters for locating objects of the resource type are specified in the resource definition. Single filters can be -declared using the `filter` method, and multiple filters can be declared with the `filters` method on the resource -class. - -For example: - -```ruby -class ContactResource < JSONAPI::Resource - attributes :name_first, :name_last, :email, :twitter - - filter :id - filters :name_first, :name_last -end -``` - -Then a request could pass in a filter for example `http://example.com/contacts?filter[name_last]=Smith` and the system -will find all people where the last name exactly matches Smith. - -##### Default Filters - -A default filter may be defined for a resource using the `default` option on the `filter` method. This default is used -unless the request overrides this value. - -For example: - -```ruby - class CommentResource < JSONAPI::Resource - attributes :body, :status - has_one :post - has_one :author - - filter :status, default: 'published,pending' -end -``` - -The default value is used as if it came from the request. - -##### Applying Filters - -You may customize how a filter behaves by supplying a callable to the `:apply` option. This callable will be used to -apply that filter. The callable is passed the `records`, which is an `ActiveRecord::Relation`, the `value`, and an -`_options` hash. It is expected to return an `ActiveRecord::Relation`. - -Note: When a filter is not supplied a `verify` callable to modify the `value` that the `apply` callable receives, -`value` defaults to an array of the string values provided to the filter parameter. - -This example shows how you can implement different approaches for different filters. - -```ruby -# When given the following parameter:'filter[visibility]': 'public' - -filter :visibility, apply: ->(records, value, _options) { - records.where('users.publicly_visible = ?', value[0] == 'public') -} -``` - -If you omit the `apply` callable the filter will be applied as `records.where(filter => value)`. - -Note: It is also possible to override the `self.apply_filter` method, though this approach is now deprecated: - -```ruby -def self.apply_filter(records, filter, value, options) - case filter - when :last_name, :first_name, :name - if value.is_a?(Array) - value.each do |val| - records = records.where(_model_class.arel_table[filter].matches(val)) - end - records - else - records.where(_model_class.arel_table[filter].matches(value)) - end - else - super(records, filter, value) - end -end -``` - -##### Verifying Filters - -Because filters typically come straight from the request, it's prudent to verify their values. To do so, provide a -callable to the `verify` option. This callable will be passed the `value` and the `context`. Verify should return the -verified value, which may be modified. - -```ruby - filter :ids, - verify: ->(values, context) { - verify_keys(values, context) - values - }, - apply: ->(records, value, _options) { - records.where('id IN (?)', value) - } -``` - -```ruby -# A more complex example, showing how to filter for any overlap between the -# value array and the possible_ids, using both verify and apply callables. - - filter :possible_ids, - verify: ->(values, context) { - values.map {|value| value.to_i} - }, - apply: ->(records, value, _options) { - records.where('possible_ids && ARRAY[?]', value) - } -``` - -##### Finders - -Basic finding by filters is supported by resources. This is implemented in the `find` and `find_by_key` finder methods. -Currently this is implemented for `ActiveRecord` based resources. The finder methods rely on the `records` method to get -an `ActiveRecord::Relation` relation. It is therefore possible to override `records` to affect the three find related -methods. - -###### Customizing base records for finder methods - -If you need to change the base records on which `find` and `find_by_key` operate, you can override the `records` method -on the resource class. - -For example to allow a user to only retrieve his own posts you can do the following: - -```ruby -class PostResource < JSONAPI::Resource - attributes :title, :body - - def self.records(options = {}) - context = options[:context] - context[:current_user].posts - end -end -``` - -When you create a relationship, a method is created to fetch record(s) for that relationship, using the relation name -for the relationship. - -```ruby -class PostResource < JSONAPI::Resource - has_one :author - has_many :comments - - # def record_for_author - # relationship = self.class._relationship(:author) - # relation_name = relationship.relation_name(context: @context) - # records_for(relation_name) - # end - - # def records_for_comments - # relationship = self.class._relationship(:comments) - # relation_name = relationship.relation_name(context: @context) - # records_for(relation_name) - # end -end - -``` - -For example, you may want to raise an error if the user is not authorized to view the related records. See the next -section for additional details on raising errors. - -```ruby -class BaseResource < JSONAPI::Resource - def records_for(relation_name) - context = options[:context] - records = _model.public_send(relation_name) - - unless context[:current_user].can_view?(records) - raise NotAuthorizedError - end - - records - end -end -``` - -###### Raising Errors - -Inside the finder methods (like `records_for`) or inside of resource callbacks -(like `before_save`) you can `raise` an error to halt processing. JSONAPI::Resources -has some built in errors that will return appropriate error codes. By -default any other error that you raise will return a `500` status code -for a general internal server error. - -To return useful error codes that represent application errors you -should set the `exception_class_whitelist` config variable, and then you -should use the Rails `rescue_from` macro to render a status code. - -For example, this config setting allows the `NotAuthorizedError` to bubble up out of -JSONAPI::Resources and into your application. - -```ruby -# config/initializer/jsonapi-resources.rb -JSONAPI.configure do |config| - config.exception_class_whitelist = [NotAuthorizedError] -end -``` - -Handling the error and rendering the appropriate code is now the responsibility of the -application and could be handled like this: - -```ruby -class ApiController < ApplicationController - rescue_from NotAuthorizedError, with: :reject_forbidden_request - def reject_forbidden_request - render json: {error: 'Forbidden'}, :status => 403 - end -end -``` - - -###### Applying Filters - -The `apply_filter` method is called to apply each filter to the `Arel` relation. You may override this method to gain -control over how the filters are applied to the `Arel` relation. - -This example shows how you can implement different approaches for different filters. - -```ruby -def self.apply_filter(records, filter, value, options) - case filter - when :visibility - records.where('users.publicly_visible = ?', value == :public) - when :last_name, :first_name, :name - if value.is_a?(Array) - value.each do |val| - records = records.where(_model_class.arel_table[filter].matches(val)) - end - records - else - records.where(_model_class.arel_table[filter].matches(value)) - end - else - super(records, filter, value) - end -end -``` - - -###### Applying Sorting - -You can override the `apply_sort` method to gain control over how the sorting is done. This may be useful in case you'd -like to base the sorting on variables in your context. - -Example: - -```ruby -def self.apply_sort(records, order_options, context = {}) - if order_options.has?(:trending) - records = records.order_by_trending_scope - order_options - [:trending] - end - - super(records, order_options, context) -end -``` - - -###### Override finder methods - -Finally if you have more complex requirements for finding you can override the `find` and `find_by_key` methods on the -resource class. - -Here's an example that defers the `find` operation to a `current_user` set on the `context` option: - -```ruby -class AuthorResource < JSONAPI::Resource - attribute :name - model_name 'Person' - has_many :posts - - filter :name - - def self.find(filters, options = {}) - context = options[:context] - authors = context[:current_user].find_authors(filters) - - return authors.map do |author| - self.new(author, context) - end - end -end -``` - -#### Pagination - -Pagination is performed using a `paginator`, which is a class responsible for parsing the `page` request parameters and -applying the pagination logic to the results. - -##### Paginators - -`JSONAPI::Resource` supports several pagination methods by default, and allows you to implement a custom system if the -defaults do not meet your needs. - -###### Paged Paginator - -The `paged` `paginator` returns results based on pages of a fixed size. Valid `page` parameters are `number` and `size`. -If `number` is omitted the first page is returned. If `size` is omitted the `default_page_size` from the configuration -settings is used. - -``` -GET /articles?page%5Bnumber%5D=10&page%5Bsize%5D=10 HTTP/1.1 -Accept: application/vnd.api+json -``` - -###### Offset Paginator - -The `offset` `paginator` returns results based on an offset from the beginning of the resultset. Valid `page` parameters -are `offset` and `limit`. If `offset` is omitted a value of 0 will be used. If `limit` is omitted the `default_page_size` -from the configuration settings is used. - -``` -GET /articles?page%5Blimit%5D=10&page%5Boffset%5D=10 HTTP/1.1 -Accept: application/vnd.api+json -``` - -###### Custom Paginators - -Custom `paginators` can be used. These should derive from `Paginator`. The `apply` method takes a `relation` and -`order_options` and is expected to return a `relation`. The `initialize` method receives the parameters from the `page` -request parameters. It is up to the paginator author to parse and validate these parameters. - -For example, here is a very simple single record at a time paginator: - -```ruby -class SingleRecordPaginator < JSONAPI::Paginator - def initialize(params) - # param parsing and validation here - @page = params.to_i - end - - def apply(relation, order_options) - relation.offset(@page).limit(1) - end -end -``` - -##### Paginator Configuration - -The default paginator, which will be used for all resources, is set using `JSONAPI.configure`. For example, in your -`config/initializers/jsonapi_resources.rb`: - -```ruby -JSONAPI.configure do |config| - # built in paginators are :none, :offset, :paged - config.default_paginator = :offset - - config.default_page_size = 10 - config.maximum_page_size = 20 -end -``` - -If no `default_paginator` is configured, pagination will be disabled by default. - -Paginators can also be set at the resource-level, which will override the default setting. This is done using the -`paginator` method: - -```ruby -class BookResource < JSONAPI::Resource - attribute :title - attribute :isbn - - paginator :offset -end -``` - -To disable pagination in a resource, specify `:none` for `paginator`. - -#### Included relationships (side-loading resources) - -JR supports [request include params](http://jsonapi.org/format/#fetching-includes) out of the box, for side loading related resources. - -Here's an example from the spec: - -``` -GET /articles/1?include=comments HTTP/1.1 -Accept: application/vnd.api+json -``` - -Will get you the following payload by default: - -``` -{ - "data": { - "type": "articles", - "id": "1", - "attributes": { - "title": "JSON API paints my bikeshed!" - }, - "links": { - "self": "http://example.com/articles/1" - }, - "relationships": { - "comments": { - "links": { - "self": "http://example.com/articles/1/relationships/comments", - "related": "http://example.com/articles/1/comments" - }, - "data": [ - { "type": "comments", "id": "5" }, - { "type": "comments", "id": "12" } - ] - } - } - }, - "included": [{ - "type": "comments", - "id": "5", - "attributes": { - "body": "First!" - }, - "links": { - "self": "http://example.com/comments/5" - } - }, { - "type": "comments", - "id": "12", - "attributes": { - "body": "I like XML better" - }, - "links": { - "self": "http://example.com/comments/12" - } - }] -} -``` - -Note: When passing `include` and `fields` params together, relationships not included in the `fields` parameter will not be serialized. This will have the side effect of not serializing the included resources. To ensure the related resources are properly side loaded specify them in the `fields`, like `fields[posts]=comments,title&include=comments`. - -#### Resource Meta - -Meta information can be included for each resource using the meta method in the resource declaration. For example: - -```ruby -class BookResource < JSONAPI::Resource - attribute :title - attribute :isbn - - def meta(options) - { - copyright: 'API Copyright 2015 - XYZ Corp.', - computed_copyright: options[:serialization_options][:copyright], - last_updated_at: _model.updated_at - } - end -end - -``` - -The `meta` method will be called for each resource instance. Override the `meta` method on a resource class to control -the meta information for the resource. If a non empty hash is returned from `meta` this will be serialized. The `meta` -method is called with an `options` hash. The `options` hash will contain the following: - - * `:serializer` -> the serializer instance - * `:serialization_options` -> the contents of the `serialization_options` method on the controller. - -#### Custom Links - -Custom links can be included for each resource by overriding the `custom_links` method. If a non empty hash is returned from `custom_links`, it will be merged with the default links hash containing the resource's `self` link. The `custom_links` method is called with the same `options` hash used by for [resource meta information](#resource-meta). The `options` hash contains the following: - - * `:serializer` -> the serializer instance - * `:serialization_options` -> the contents of the `serialization_options` method on the controller. - -For example: - -```ruby -class CityCouncilMeeting < JSONAPI::Resource - attribute :title, :location, :approved - - def custom_links(options) - { minutes: options[:serializer].link_builder.self_link(self) + "/minutes" } - end -end -``` - -This will create a custom link with the key `minutes`, which will be merged with the default `self` link, like so: - -```json -{ - "data": [ - { - "id": "1", - "type": "cityCouncilMeetings", - "links": { - "self": "http://city.gov/api/city-council-meetings/1", - "minutes": "http://city.gov/api/city-council-meetings/1/minutes" - }, - "attributes": {...} - }, - //... - ] -} -``` - -Of course, the `custom_links` method can include logic to include links only when relevant: - -````ruby -class CityCouncilMeeting < JSONAPI::Resource - attribute :title, :location, :approved - - delegate :approved?, to: :model - - def custom_links(options) - extra_links = {} - if approved? - extra_links[:minutes] = options[:serializer].link_builder.self_link(self) + "/minutes" - end - extra_links - end -end -``` - -It's also possibly to suppress the default `self` link by returning a hash with `{self: nil}`: - -````ruby -class Selfless < JSONAPI::Resource - def custom_links(options) - {self: nil} - end -end -``` - -#### Callbacks - -`ActiveSupport::Callbacks` is used to provide callback functionality, so the behavior is very similar to what you may be -used to from `ActiveRecord`. - -For example, you might use a callback to perform authorization on your resource before an action. - -```ruby -class BaseResource < JSONAPI::Resource - before_create :authorize_create - - def authorize_create - # ... - end -end -``` - -The types of supported callbacks are: -- `before` -- `after` -- `around` - -##### `JSONAPI::Resource` Callbacks - -Callbacks can be defined for the following `JSONAPI::Resource` events: - -- `:create` -- `:update` -- `:remove` -- `:save` -- `:create_to_many_link` -- `:replace_to_many_links` -- `:create_to_one_link` -- `:replace_to_one_link` -- `:remove_to_many_link` -- `:remove_to_one_link` -- `:replace_fields` - -###### Relationship Reflection - -By default updates to relationships only invoke callbacks on the primary -Resource. By setting the `use_relationship_reflection` [Configuration] (#configuration) option -updates to `has_many` relationships will occur on the related resource, triggering -callbacks on both resources. - -##### `JSONAPI::Processor` Callbacks - -Callbacks can also be defined for `JSONAPI::Processor` events: -- `:operation`: Any individual operation. -- `:find`: A `find` operation is being processed. -- `:show`: A `show` operation is being processed. -- `:show_relationship`: A `show_relationship` operation is being processed. -- `:show_related_resource`: A `show_related_resource` operation is being processed. -- `:show_related_resources`: A `show_related_resources` operation is being processed. -- `:create_resource`: A `create_resource` operation is being processed. -- `:remove_resource`: A `remove_resource` operation is being processed. -- `:replace_fields`: A `replace_fields` operation is being processed. -- `:replace_to_one_relationship`: A `replace_to_one_relationship` operation is being processed. -- `:create_to_many_relationship`: A `create_to_many_relationship` operation is being processed. -- `:replace_to_many_relationship`: A `replace_to_many_relationship` operation is being processed. -- `:remove_to_many_relationship`: A `remove_to_many_relationship` operation is being processed. -- `:remove_to_one_relationship`: A `remove_to_one_relationship` operation is being processed. - -See [Operation Processors] (#operation-processors) for details on using OperationProcessors - -##### `JSONAPI::OperationsProcessor` Callbacks (a removed feature) - -Note: The `JSONAPI::OperationsProcessor` has been removed and replaced with the `JSONAPI::OperationDispatcher` -and `Processor` classes per resource. The callbacks have been renamed and moved to the -`Processor`s, with the exception of the `operations` callback which is now on the controller. - -### Controllers - -There are two ways to implement a controller for your resources. Either derive from `ResourceController` or import -the `ActsAsResourceController` module. - -##### ResourceController - -`JSONAPI::Resources` provides a class, `ResourceController`, that can be used as the base class for your controllers. -`ResourceController` supports `index`, `show`, `create`, `update`, and `destroy` methods. Just deriving your controller -from `ResourceController` will give you a fully functional controller. - -For example: - -```ruby -class PeopleController < JSONAPI::ResourceController - -end -``` - -Of course you are free to extend this as needed and override action handlers or other methods. - -A jsonapi-controller generator is avaliable - -``` -rails generate jsonapi:controller contact -``` - -###### ResourceControllerMetal - -`JSONAPI::Resources` also provides an alternative class to `ResourceController` called `ResourceControllerMetal`. -In order to provide a lighter weight controller option this strips the controller down to just the classes needed -to work with `JSONAPI::Resources`. - -For example: - -```ruby -class PeopleController < JSONAPI::ResourceControllerMetal - -end -``` - -Note: This may not provide all of the expected controller capabilities if you are using additional gems such as DoorKeeper. - -###### Serialization Options - -Additional options can be passed to the serializer using the `serialization_options` method. - -For example: - -```ruby -class ApplicationController < JSONAPI::ResourceController - def serialization_options - {copyright: 'Copyright 2015'} - end -end -``` - -These `serialization_options` are passed to the `meta` method used to generate resource `meta` values. - -##### ActsAsResourceController - -`JSONAPI::Resources` also provides a module, `JSONAPI::ActsAsResourceController`. You can include this module to -mix in all the features of `ResourceController` into your existing controller class. - -For example: - -```ruby -class PostsController < ActionController::Base - include JSONAPI::ActsAsResourceController -end -``` - -#### Namespaces - -JSONAPI::Resources supports namespacing of controllers and resources. With namespacing you can version your API. - -If you namespace your controller it will require a namespaced resource. - -In the following example we have a `resource` that isn't namespaced, and one that has now been namespaced. There are -slight differences between the two resources, as might be seen in a new version of an API: - -```ruby -class PostResource < JSONAPI::Resource - attribute :title - attribute :body - attribute :subject - - has_one :author, class_name: 'Person' - has_one :section - has_many :tags, acts_as_set: true - has_many :comments, acts_as_set: false - def subject - @model.title - end - - filters :title, :author, :tags, :comments - filter :id -end - -... - -module Api - module V1 - class PostResource < JSONAPI::Resource - # V1 replaces the non-namespaced resource - # V1 no longer supports tags and now calls author 'writer' - attribute :title - attribute :body - attribute :subject - - has_one :writer, foreign_key: 'author_id' - has_one :section - has_many :comments, acts_as_set: false - - def subject - @model.title - end - - filters :writer - end - - class WriterResource < JSONAPI::Resource - attributes :name, :email - model_name 'Person' - has_many :posts - - filter :name - end - end -end -``` - -The following controllers are used: - -```ruby -class PostsController < JSONAPI::ResourceController -end - -module Api - module V1 - class PostsController < JSONAPI::ResourceController - end - end -end -``` - -You will also need to namespace your routes: - -```ruby -Rails.application.routes.draw do - - jsonapi_resources :posts - - namespace :api do - namespace :v1 do - jsonapi_resources :posts - end - end -end -``` - -When a namespaced `resource` is used, any related `resources` must also be in the same namespace. - -#### Error codes - -Error codes are provided for each error object returned, based on the error. These errors are: - -```ruby -module JSONAPI - VALIDATION_ERROR = '100' - INVALID_RESOURCE = '101' - FILTER_NOT_ALLOWED = '102' - INVALID_FIELD_VALUE = '103' - INVALID_FIELD = '104' - PARAM_NOT_ALLOWED = '105' - PARAM_MISSING = '106' - INVALID_FILTER_VALUE = '107' - COUNT_MISMATCH = '108' - KEY_ORDER_MISMATCH = '109' - KEY_NOT_INCLUDED_IN_URL = '110' - INVALID_INCLUDE = '112' - RELATION_EXISTS = '113' - INVALID_SORT_CRITERIA = '114' - INVALID_LINKS_OBJECT = '115' - TYPE_MISMATCH = '116' - INVALID_PAGE_OBJECT = '117' - INVALID_PAGE_VALUE = '118' - INVALID_FIELD_FORMAT = '119' - INVALID_FILTERS_SYNTAX = '120' - SAVE_FAILED = '121' - FORBIDDEN = '403' - RECORD_NOT_FOUND = '404' - NOT_ACCEPTABLE = '406' - UNSUPPORTED_MEDIA_TYPE = '415' - LOCKED = '423' -end -``` - -These codes can be customized in your app by creating an initializer to override any or all of the codes. - -In addition textual error codes can be returned by setting the configuration option `use_text_errors = true`. For -example: - -```ruby -JSONAPI.configure do |config| - config.use_text_errors = true -end -``` - - -#### Handling Exceptions - -By default, all exceptions raised downstream from a resource controller will be caught, logged, and a ```500 Internal Server Error``` will be rendered. Exceptions can be whitelisted in the config to pass through the handler and be caught manually, or you can pass a callback from a resource controller to insert logic into the rescue block without interrupting the control flow. This can be particularly useful for additional logging or monitoring without the added work of rendering responses. - -Pass a block, refer to controller class methods, or both. Note that methods must be defined as class methods on a controller and accept one parameter, which is passed the exception object that was rescued. - -```ruby - class ApplicationController < JSONAPI::ResourceController - - on_server_error :first_callback - - #or - - # on_server_error do |error| - #do things - #end - - def self.first_callback(error) - #env["airbrake.error_id"] = notify_airbrake(error) - end - end - -``` - -#### Action Callbacks - -##### verify_content_type_header - -By default, when controllers extend functionalities from `jsonapi-resources`, the `ActsAsResourceController#verify_content_type_header` -method will be triggered before `create`, `update`, `create_relationship` and `update_relationship` actions. This method is responsible -for checking if client's request corresponds to the correct media type required by [JSON API](http://jsonapi.org/format/#content-negotiation-clients): `application/vnd.api+json`. - -In case you need to check the media type for custom actions, just make sure to call the method in your controller's `before_action`: - -```ruby -class UsersController < JSONAPI::ResourceController - before_action :verify_content_type_header, only: [:auth] - - def auth - # some crazy auth code goes here - end -end -``` - -### Operation Processors - -Operation Processors are called to perform the operation(s) that make up a request. The controller (through the `OperationDispatcher`), creates an `OperatorProcessor` to handle each operation. The processor is created based on the resource name, including the namespace. If a processor does not exist for a resource (namespace matters) the default operation processor is used instead. The default processor can be changed by a configuration setting. - -Defining a custom `Processor` allows for custom callback handling of each operation type for each resource type. For example: - -```ruby -class Api::V4::BookProcessor < JSONAPI::Processor - after_find do - unless @result.is_a?(JSONAPI::ErrorsOperationResult) - @result.meta[:total_records_found] = @result.record_count - end - end -end -``` - -This simple example uses a callback to update the result's meta property with the total count of records (a redundant -feature only for example purposes), if there wasn't an error in the operation. It is also possible to override the -`find` method as well if a different behavior is needed, for example: - -```ruby -class Api::V4::BookProcessor < JSONAPI::Processor - def find - filters = params[:filters] - include_directives = params[:include_directives] - sort_criteria = params.fetch(:sort_criteria, []) - paginator = params[:paginator] - - verified_filters = resource_klass.verify_filters(filters, context) - resource_records = resource_klass.find(verified_filters, - context: context, - include_directives: include_directives, - sort_criteria: sort_criteria, - paginator: paginator) - - page_options = {} - # Overriding the default record count logic to always include it in the meta - #if (JSONAPI.configuration.top_level_meta_include_record_count || - # (paginator && paginator.class.requires_record_count)) - page_options[:record_count] = resource_klass.find_count(verified_filters, - context: context, - include_directives: include_directives) - #end -end -``` - -Note: The authors of this gem expect the most common uses cases to be handled using the callbacks. It is likely that the -internal functionality of the operation processing methods will change, at least for several revisions. Effort will be -made to call this out in release notes. You have been warned. - -### Serializer - -The `ResourceSerializer` can be used to serialize a resource into JSON API compliant JSON. `ResourceSerializer` must be - initialized with the primary resource type it will be serializing. `ResourceSerializer` has a `serialize_to_hash` - method that takes a resource instance or array of resource instances to serialize. For example: - -```ruby -post = Post.find(1) -JSONAPI::ResourceSerializer.new(PostResource).serialize_to_hash(PostResource.new(post, nil)) -``` - -Note: If your resource needs to access to state from a context hash, make sure to pass the context hash as the second argument of -the resource class new method. For example: - -```ruby -post = Post.find(1) -context = { current_user: current_user } -JSONAPI::ResourceSerializer.new(PostResource).serialize_to_hash(PostResource.new(post, context)) -``` - -This returns results like this: - -```json -{ - "data": { - "type": "posts", - "id": "1", - "links": { - "self": "http://example.com/posts/1" - }, - "attributes": { - "title": "New post", - "body": "A body!!!", - "subject": "New post" - }, - "relationships": { - "section": { - "links": { - "self": "http://example.com/posts/1/relationships/section", - "related": "http://example.com/posts/1/section" - }, - "data": null - }, - "author": { - "links": { - "self": "http://example.com/posts/1/relationships/author", - "related": "http://example.com/posts/1/author" - }, - "data": { - "type": "people", - "id": "1" - } - }, - "tags": { - "links": { - "self": "http://example.com/posts/1/relationships/tags", - "related": "http://example.com/posts/1/tags" - } - }, - "comments": { - "links": { - "self": "http://example.com/posts/1/relationships/comments", - "related": "http://example.com/posts/1/comments" - } - } - } - } -} -``` - -#### Serializer options - -The `ResourceSerializer` can be initialized with some optional parameters: - -##### `include` - -An array of resources. Nested resources can be specified with dot notation. - - *Purpose*: determines which objects will be side loaded with the source objects in an `included` section - - *Example*: ```include: ['comments','author','comments.tags','author.posts']``` - -##### `fields` - -A hash of resource types and arrays of fields for each resource type. - - *Purpose*: determines which fields are serialized for a resource type. This encompasses both attributes and - relationship ids in the links section for a resource. Fields are global for a resource type. - - *Example*: ```fields: { people: [:email, :comments], posts: [:title, :author], comments: [:body, :post]}``` - -```ruby -post = Post.find(1) -include_resources = ['comments','author','comments.tags','author.posts'] - -JSONAPI::ResourceSerializer.new(PostResource, include: include_resources, - fields: { - people: [:email, :comments], - posts: [:title, :author], - tags: [:name], - comments: [:body, :post] - } -).serialize_to_hash(PostResource.new(post, nil)) -``` - -#### Formatting - -JR by default uses some simple rules to format (and unformat) an attribute for (de-)serialization. Strings and Integers are output to JSON -as is, and all other values have `.to_s` applied to them. This outputs something in all cases, but it is certainly not -correct for every situation. - -If you want to change the way an attribute is (de-)serialized you have a couple of ways. The simplest method is to create a -getter (and setter) method on the resource which overrides the attribute and apply the (un-)formatting there. For example: - -```ruby -class PersonResource < JSONAPI::Resource - attributes :name, :email, :last_login_time - - # Setter example - def email=(new_email) - @model.email = new_email.downcase - end - - # Getter example - def last_login_time - @model.last_login_time.in_time_zone(@context[:current_user].time_zone).to_s - end -end -``` - -This is simple to implement for a one off situation, but not for example if you want to apply the same formatting rules -to all DateTime fields in your system. Another issue is the attribute on the resource will always return a formatted -response, whether you want it or not. - -##### Value Formatters - -To overcome the above limitations JR uses Value Formatters. Value Formatters allow you to control the way values are -handled for an attribute. The `format` can be set per attribute as it is declared in the resource. For example: - -```ruby -class PersonResource < JSONAPI::Resource - attributes :name, :email, :spoken_languages - attribute :last_login_time, format: :date_with_utc_timezone - - # Getter/Setter for spoken_languages ... -end -``` - -A Value formatter has a `format` and an `unformat` method. Here's the base ValueFormatter and DefaultValueFormatter for -reference: - -```ruby -module JSONAPI - class ValueFormatter < Formatter - class << self - def format(raw_value) - super(raw_value) - end - - def unformat(value) - super(value) - end - ... - end - end -end - -class DefaultValueFormatter < JSONAPI::ValueFormatter - class << self - def format(raw_value) - case raw_value - when Date, Time, DateTime, ActiveSupport::TimeWithZone, BigDecimal - # Use the as_json methods added to various base classes by ActiveSupport - return raw_value.as_json - else - return raw_value - end - end - end -end -``` - -You can also create your own Value Formatter. Value Formatters must be named with the `format` name followed by -`ValueFormatter`, i.e. `DateWithUTCTimezoneValueFormatter` and derive from `JSONAPI::ValueFormatter`. It is -recommended that you create a directory for your formatters, called `formatters`. - -The `format` method is called by the `ResourceSerializer` as is serializing a resource. The format method takes the -`raw_value` parameter. `raw_value` is the value as read from the model. - -The `unformat` method is called when processing the request. Each incoming attribute (except `links`) are run through -the `unformat` method. The `unformat` method takes a `value`, which is the value as it comes in on the -request. This allows you process the incoming value to alter its state before it is stored in the model. - -###### Use a Different Default Value Formatter - -Another way to handle formatting is to set a different default value formatter. This will affect all attributes that do -not have a `format` set. You can do this by overriding the `default_attribute_options` method for a resource (or a base -resource for a system wide change). - -```ruby - def self.default_attribute_options - {format: :my_default} - end -``` - -and - -```ruby -class MyDefaultValueFormatter < DefaultValueFormatter - class << self - def format(raw_value) - case raw_value - when DateTime - return super(raw_value.in_time_zone('UTC')) - else - return super - end - end - end -end -``` - -This way all DateTime values will be formatted to display in the UTC timezone. - -#### Key Format - -By default JR uses dasherized keys as per the -[JSON API naming recommendations](http://jsonapi.org/recommendations/#naming). This can be changed by specifying a -different key formatter. - -For example, to use camel cased keys with an initial lowercase character (JSON's default) create an initializer and add -the following: - -```ruby -JSONAPI.configure do |config| - # built in key format options are :underscored_key, :camelized_key and :dasherized_key - config.json_key_format = :camelized_key -end -``` - -This will cause the serializer to use the `CamelizedKeyFormatter`. You can also create your own `KeyFormatter`, for -example: - -```ruby -class UpperCamelizedKeyFormatter < JSONAPI::KeyFormatter - class << self - def format(key) - super.camelize(:upper) - end - end -end -``` - -You would specify this in `JSONAPI.configure` as `:upper_camelized`. - -### Routing - -JR has a couple of helper methods available to assist you with setting up routes. - -##### `jsonapi_resources` - -Like `resources` in `ActionDispatch`, `jsonapi_resources` provides resourceful routes mapping between HTTP verbs and URLs -and controller actions. This will also setup mappings for relationship URLs for a resource's relationships. For example: - -```ruby -Rails.application.routes.draw do - jsonapi_resources :contacts - jsonapi_resources :phone_numbers -end -``` - -gives the following routes - -``` - Prefix Verb URI Pattern Controller#Action -contact_relationships_phone_numbers GET /contacts/:contact_id/relationships/phone-numbers(.:format) contacts#show_relationship {:relationship=>"phone_numbers"} - POST /contacts/:contact_id/relationships/phone-numbers(.:format) contacts#create_relationship {:relationship=>"phone_numbers"} - DELETE /contacts/:contact_id/relationships/phone-numbers/:keys(.:format) contacts#destroy_relationship {:relationship=>"phone_numbers"} - contact_phone_numbers GET /contacts/:contact_id/phone-numbers(.:format) phone_numbers#get_related_resources {:relationship=>"phone_numbers", :source=>"contacts"} - contacts GET /contacts(.:format) contacts#index - POST /contacts(.:format) contacts#create - contact GET /contacts/:id(.:format) contacts#show - PATCH /contacts/:id(.:format) contacts#update - PUT /contacts/:id(.:format) contacts#update - DELETE /contacts/:id(.:format) contacts#destroy - phone_number_relationships_contact GET /phone-numbers/:phone_number_id/relationships/contact(.:format) phone_numbers#show_relationship {:relationship=>"contact"} - PUT|PATCH /phone-numbers/:phone_number_id/relationships/contact(.:format) phone_numbers#update_relationship {:relationship=>"contact"} - DELETE /phone-numbers/:phone_number_id/relationships/contact(.:format) phone_numbers#destroy_relationship {:relationship=>"contact"} - phone_number_contact GET /phone-numbers/:phone_number_id/contact(.:format) contacts#get_related_resource {:relationship=>"contact", :source=>"phone_numbers"} - phone_numbers GET /phone-numbers(.:format) phone_numbers#index - POST /phone-numbers(.:format) phone_numbers#create - phone_number GET /phone-numbers/:id(.:format) phone_numbers#show - PATCH /phone-numbers/:id(.:format) phone_numbers#update - PUT /phone-numbers/:id(.:format) phone_numbers#update - DELETE /phone-numbers/:id(.:format) phone_numbers#destroy -``` - -##### `jsonapi_resource` - -Like `jsonapi_resources`, but for resources you lookup without an id. - -#### Nested Routes - -By default nested routes are created for getting related resources and manipulating relationships. You can control the -nested routes by passing a block into `jsonapi_resources` or `jsonapi_resource`. An empty block will not create -any nested routes. For example: - -```ruby -Rails.application.routes.draw do - jsonapi_resources :contacts do - end -end -``` - -gives routes that are only related to the primary resource, and none for its relationships: - -``` - Prefix Verb URI Pattern Controller#Action - contacts GET /contacts(.:format) contacts#index - POST /contacts(.:format) contacts#create - contact GET /contacts/:id(.:format) contacts#show - PATCH /contacts/:id(.:format) contacts#update - PUT /contacts/:id(.:format) contacts#update - DELETE /contacts/:id(.:format) contacts#destroy -``` - -To manually add in the nested routes you can use the `jsonapi_links`, `jsonapi_related_resources` and -`jsonapi_related_resource` inside the block. Or, you can add the default set of nested routes using the -`jsonapi_relationships` method. For example: - -```ruby -Rails.application.routes.draw do - jsonapi_resources :contacts do - jsonapi_relationships - end -end -``` - -###### `jsonapi_links` - -You can add relationship routes in with `jsonapi_links`, for example: - -```ruby -Rails.application.routes.draw do - jsonapi_resources :contacts do - jsonapi_links :phone_numbers - end -end -``` - -Gives the following routes: - -``` -contact_relationships_phone_numbers GET /contacts/:contact_id/relationships/phone-numbers(.:format) contacts#show_relationship {:relationship=>"phone_numbers"} - POST /contacts/:contact_id/relationships/phone-numbers(.:format) contacts#create_relationship {:relationship=>"phone_numbers"} - DELETE /contacts/:contact_id/relationships/phone-numbers/:keys(.:format) contacts#destroy_relationship {:relationship=>"phone_numbers"} - contacts GET /contacts(.:format) contacts#index - POST /contacts(.:format) contacts#create - contact GET /contacts/:id(.:format) contacts#show - PATCH /contacts/:id(.:format) contacts#update - PUT /contacts/:id(.:format) contacts#update - DELETE /contacts/:id(.:format) contacts#destroy - -``` - -The new routes allow you to show, create and destroy the relationships between resources. - -###### `jsonapi_related_resources` - -Creates a nested route to GET the related has_many resources. For example: - -```ruby -Rails.application.routes.draw do - jsonapi_resources :contacts do - jsonapi_related_resources :phone_numbers - end -end - -``` - -gives the following routes: - -``` - Prefix Verb URI Pattern Controller#Action -contact_phone_numbers GET /contacts/:contact_id/phone-numbers(.:format) phone_numbers#get_related_resources {:relationship=>"phone_numbers", :source=>"contacts"} - contacts GET /contacts(.:format) contacts#index - POST /contacts(.:format) contacts#create - contact GET /contacts/:id(.:format) contacts#show - PATCH /contacts/:id(.:format) contacts#update - PUT /contacts/:id(.:format) contacts#update - DELETE /contacts/:id(.:format) contacts#destroy - -``` - -A single additional route was created to allow you GET the phone numbers through the contact. - -###### `jsonapi_related_resource` - -Like `jsonapi_related_resources`, but for has_one related resources. - -```ruby -Rails.application.routes.draw do - jsonapi_resources :phone_numbers do - jsonapi_related_resource :contact - end -end -``` - -gives the following routes: - -``` - Prefix Verb URI Pattern Controller#Action -phone_number_contact GET /phone-numbers/:phone_number_id/contact(.:format) contacts#get_related_resource {:relationship=>"contact", :source=>"phone_numbers"} - phone_numbers GET /phone-numbers(.:format) phone_numbers#index - POST /phone-numbers(.:format) phone_numbers#create - phone_number GET /phone-numbers/:id(.:format) phone_numbers#show - PATCH /phone-numbers/:id(.:format) phone_numbers#update - PUT /phone-numbers/:id(.:format) phone_numbers#update - DELETE /phone-numbers/:id(.:format) phone_numbers#destroy - -``` - -### Authorization - -Currently `json-api-resources` doesn't come with built-in primitives for authorization. However multiple users of the framework have come up with different approaches, check out: - -- [jsonapi-authorization](https://github.com/venuu/jsonapi-authorization) -- [pundit-resources](https://github.com/togglepro/pundit-resources) - -Refer to the comments/discussion [here](https://github.com/cerebris/jsonapi-resources/issues/16#issuecomment-222438975) for the differences between approaches - -### Resource Caching - -To improve the response time of GET requests, JR can cache the generated JSON fragments for -Resources which are suitable. First, set `config.resource_cache` to an ActiveSupport cache store: - -```ruby -JSONAPI.configure do |config| - config.resource_cache = Rails.cache -end -``` - -Then, on each Resource you want to cache, call the `caching` method: - -```ruby -class PostResource < JSONAPI::Resource - caching -end -``` - -See the caveats section below for situations where you might not want to enable caching on particular -Resources. - -The Resource model must also have a field that is updated whenever any of the model's data changes. -The default Rails timestamps handle this pretty well, and the default cache key field is `updated_at` for this reason. -You can use an alternate field (which you are then responsible for updating) by calling the `cache_field` method: - -```ruby -class PostResource < JSONAPI::Resource - caching - cache_field :change_counter - - before_save do - if self.change_counter.nil? - self.change_counter = 1 - elsif self.changed? - self.change_counter += 1 - end - end - - after_touch do - update_attribute(:change_counter, self.change_counter + 1) - end -end -``` - -If context affects the content of the serialized result, you must define a class method `attribute_caching_context` on that Resource, which should return a different value for contexts that produce different results. In particular, if the `meta` or `fetchable_fields` methods, or any method providing the actual content of an attribute, changes depending on context, then you must provide `attribute_caching_context`. The actual value it -returns isn't important, what matters is that the value must be different if any relevant part of the context is different. - -```ruby -class PostResource < JSONAPI::Resource - caching - - attributes :title, :body, :secret_field - - def fetchable_fields - return super if context.user.superuser? - return super - [:secret_field] - end - - def meta - if context.user.can_see_creation_dates? - return { created: _model.created_at } - else - return {} - end - end - - def self.attribute_caching_context(context) - return { - admin: context.user.superuser?, - creation_date_viewer: context.user.can_see_creation_dates? - } - end -end -``` - -#### Caching Caveats - -* Models for cached Resources must update a cache key field whenever their data changes. However, if you bypass Rails and e.g. alter the database row directly without changing the `updated_at` field, the cached entry for that resource will be inaccurate. Also, `updated_at` provides a narrow race condition window; if a resource is updated twice in the same second, it's possible that only the first update will be cached. If you're concerned about this, you will need to find a way to make sure your models' cache fields change on every update, e.g. by using a unique random value or a monotonic clock. -* If an attribute's value is affected by related resources, e.g. the `spoken_languages` example above, then changes to the related resource must also touch the cache field on the resource that uses it. The `belongs_to` relation in ActiveRecord provides a `:touch` option for this purpose. -* JR does not actively clean the cache, so you must use an ActiveSupport cache that automatically expires old entries, or you will leak resources. The MemoryCache built in to Rails does this by default, but other caches will have to be configured with an `:expires_in` option and/or a cache-specific clearing mechanism. -* Similarly, if you make a substantial code change that affects a lot of serialized representations (i.e. changing the way an attribute is shown), you'll have to clear out all relevant cache entries yourself. The simplest way to do this is to run `JSONAPI.configuration.resource_cache.clear` from the console. You do not have to do this after merely adding or removing attributes; only changes that affect the actual content of attributes require manual cache clearing. -* If resource caching is enabled at all, then custom relationship methods on any resource might not always be used, even resources that are not cached. For example, if you manually define a `comments` method or `records_for_comments` method on a Resource that `has_many :comments`, you cannot expect it to be used when caching is enabled, even if you never call `caching` on that particular Resource. Instead, you should use relationship name lambdas. -* The above also applies to custom `find` or `find_by_key` methods. Instead, if you are using resource caching anywhere in your app, try overriding the `find_records` method to return an appropriate `ActiveRecord::Relation`. -* Caching relies on ActiveRecord features; you cannot enable caching on resources based on non-AR models, e.g. PORO objects or singleton resources. -* If you write a custom `ResourceSerializer` which takes new options, then you must define `config_description` to include those options if they might impact the serialized value: - -```ruby -class MySerializer < JSONAPI::ResourceSerializer - def initialize(primary_resource_klass, options = {}) - @my_special_option = options.delete(:my_special_option) - super - end - - def config_description(resource_klass) - super.merge({my_special_option: @my_special_option}) - end -end -``` - -## Configuration - -JR has a few configuration options. Some have already been mentioned above. To set configuration options create an -initializer and add the options you wish to set. All options have defaults, so you only need to set the options that -are different. The default options are shown below. - -If using custom classes (such as a CustomPaginator), be sure to require them at the top of the initializer before usage. - -```ruby -JSONAPI.configure do |config| - #:underscored_key, :camelized_key, :dasherized_key, or custom - config.json_key_format = :dasherized_key - - #:underscored_route, :camelized_route, :dasherized_route, or custom - config.route_format = :dasherized_route - - # Default Processor, used if a resource specific one is not defined. - # Must be a class - config.default_processor_klass = JSONAPI::Processor - - #:integer, :uuid, :string, or custom (provide a proc) - config.resource_key_type = :integer - - # optional request features - config.allow_include = true - config.allow_sort = true - config.allow_filter = true - - # How to handle unsupported attributes and relationships which are provided in the request - # true => raises an error - # false => allows the request to continue. A warning is included in the response meta data indicating - # the fields which were ignored. This is useful for client libraries which send extra parameters. - config.raise_if_parameters_not_allowed = true - - # :none, :offset, :paged, or a custom paginator name - config.default_paginator = :none - - # Output pagination links at top level - config.top_level_links_include_pagination = true - - config.default_page_size = 10 - config.maximum_page_size = 20 - - # Output the record count in top level meta data for find operations - config.top_level_meta_include_record_count = false - config.top_level_meta_record_count_key = :record_count - - # For :paged paginators, the following are also available - config.top_level_meta_include_page_count = false - config.top_level_meta_page_count_key = :page_count - - config.use_text_errors = false - - # List of classes that should not be rescued by the operations processor. - # For example, if you use Pundit for authorization, you might - # raise a Pundit::NotAuthorizedError at some point during operations - # processing. If you want to use Rails' `rescue_from` macro to - # catch this error and render a 403 status code, you should add - # the `Pundit::NotAuthorizedError` to the `exception_class_whitelist`. - # Subclasses of the whitelisted classes will also be whitelisted. - config.exception_class_whitelist = [] - - # If enabled, will override configuration option `exception_class_whitelist` - # and whitelist all exceptions. - config.whitelist_all_exceptions = false - - # Resource Linkage - # Controls the serialization of resource linkage for non compound documents - # NOTE: always_include_to_many_linkage_data is not currently implemented - config.always_include_to_one_linkage_data = false - - # Relationship reflection invokes the related resource when updates - # are made to a has_many relationship. By default relationship_reflection - # is turned off because it imposes a small performance penalty. - config.use_relationship_reflection = false - - # Allows transactions for creating and updating records - # Set this to false if your backend does not support transactions (e.g. Mongodb) - config.allow_transactions = true - - # Formatter Caching - # Set to false to disable caching of string operations on keys and links. - # Note that unlike the resource cache, formatter caching is always done - # internally in-memory and per-thread; no ActiveSupport::Cache is used. - config.cache_formatters = true - - # Resource cache - # An ActiveSupport::Cache::Store or similar, used by Resources with caching enabled. - # Set to `nil` (the default) to disable caching, or to `Rails.cache` to use the - # Rails cache store. - config.resource_cache = nil - - # Default resource cache field - # On Resources with caching enabled, this field will be used to check for out-of-date - # cache entries, unless overridden on a specific Resource. Defaults to "updated_at". - config.default_resource_cache_field = :updated_at - - # Resource cache digest function - # Provide a callable that returns a unique value for string inputs with - # low chance of collision. The default is SHA256 base64. - config.resource_cache_digest_function = Digest::SHA2.new.method(:base64digest) - - # Resource cache usage reporting - # Optionally provide a callable which JSONAPI will call with information about cache - # performance. Should accept three arguments: resource name, hits count, misses count. - config.resource_cache_usage_report_function = nil -end -``` +**For further usage see the [v0.10 alpha Guide](http://jsonapi-resources.com/v0.10/guide/)** ## Contributing @@ -2154,16 +48,6 @@ end 4. Push to the branch (`git push origin my-new-feature`) 5. Create a new Pull Request -### Running Tests - -To run the tests for this project: - -- `rake test` or `bundle exec rake test` - -To run a single test: - -- `bundle exec ruby -I test test/controllers/controller_test.rb -n test_type_formatting` - ## License Copyright 2014-2016 Cerebris Corporation. MIT License (see LICENSE for details). From 923a24925bfbeacd5822e6a9974a7185c39db799 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Wed, 30 Nov 2016 14:06:48 -0500 Subject: [PATCH 007/237] Pr/895 (#914) * Rescue JSON parsing error and concat JSONAPI exception. * Add two new exception classes with more detail regarding the error and address CR comments. * Address CR comment and use fail call and be explicit with rescue blocks. * Return all exceptions from the parser in `_parser_exception` and raise them to the controller --- lib/jsonapi/error_codes.rb | 1 + lib/jsonapi/exceptions.rb | 26 ++++++++++++++++++ lib/jsonapi/mime_types.rb | 17 +++++++++--- lib/jsonapi/request_parser.rb | 1 + test/integration/requests/request_test.rb | 33 +++++++++++++++++++++++ 5 files changed, 75 insertions(+), 3 deletions(-) diff --git a/lib/jsonapi/error_codes.rb b/lib/jsonapi/error_codes.rb index 290ee6189..35f309bc7 100644 --- a/lib/jsonapi/error_codes.rb +++ b/lib/jsonapi/error_codes.rb @@ -20,6 +20,7 @@ module JSONAPI INVALID_FILTERS_SYNTAX = '120' SAVE_FAILED = '121' INVALID_DATA_FORMAT = '122' + BAD_REQUEST = '400' FORBIDDEN = '403' RECORD_NOT_FOUND = '404' NOT_ACCEPTABLE = '406' diff --git a/lib/jsonapi/exceptions.rb b/lib/jsonapi/exceptions.rb index 2d220e756..29b66b766 100644 --- a/lib/jsonapi/exceptions.rb +++ b/lib/jsonapi/exceptions.rb @@ -119,6 +119,32 @@ def errors end end + class BadRequest < Error + def initialize(exception) + @exception = exception + end + + def errors + [JSONAPI::Error.new(code: JSONAPI::BAD_REQUEST, + status: :bad_request, + title: I18n.translate('jsonapi-resources.exceptions.bad_request.title', + default: 'Bad Request'), + detail: I18n.translate('jsonapi-resources.exceptions.bad_request.detail', + default: @exception))] + end + end + + class InvalidRequestFormat < Error + def errors + [JSONAPI::Error.new(code: JSONAPI::BAD_REQUEST, + status: :bad_request, + title: I18n.translate('jsonapi-resources.exceptions.invalid_request_format.title', + default: 'Bad Request'), + detail: I18n.translate('jsonapi-resources.exceptions.invalid_request_format.detail', + default: 'Request must be a hash'))] + end + end + class ToManySetReplacementForbidden < Error def errors [JSONAPI::Error.new(code: JSONAPI::FORBIDDEN, diff --git a/lib/jsonapi/mime_types.rb b/lib/jsonapi/mime_types.rb index f8bde565e..78e8f1d4f 100644 --- a/lib/jsonapi/mime_types.rb +++ b/lib/jsonapi/mime_types.rb @@ -1,3 +1,5 @@ +require 'json' + module JSONAPI MEDIA_TYPE = 'application/vnd.api+json' @@ -19,9 +21,18 @@ def self.install def self.parser lambda do |body| - data = JSON.parse(body) - data = {:_json => data} unless data.is_a?(Hash) - data.with_indifferent_access + begin + data = JSON.parse(body) + if data.is_a?(Hash) + data.with_indifferent_access + else + fail JSONAPI::Exceptions::InvalidRequestFormat.new + end + rescue JSON::ParserError => e + { _parser_exception: JSONAPI::Exceptions::BadRequest.new(e.to_s) } + rescue => e + { _parser_exception: e } + end end end end diff --git a/lib/jsonapi/request_parser.rb b/lib/jsonapi/request_parser.rb index 2929a696a..2e0faacfa 100644 --- a/lib/jsonapi/request_parser.rb +++ b/lib/jsonapi/request_parser.rb @@ -34,6 +34,7 @@ def setup_action(params) setup_action_method_name = "setup_#{params[:action]}_action" if respond_to?(setup_action_method_name) + raise params[:_parser_exception] if params[:_parser_exception] send(setup_action_method_name, params) end rescue ActionController::ParameterMissing => e diff --git a/test/integration/requests/request_test.rb b/test/integration/requests/request_test.rb index 99885de21..dc10b78e8 100644 --- a/test/integration/requests/request_test.rb +++ b/test/integration/requests/request_test.rb @@ -353,6 +353,39 @@ def test_put_content_type assert_match JSONAPI::MEDIA_TYPE, headers['Content-Type'] end + def test_put_valid_json + put '/posts/3', params: '{"data": { "type": "posts", "id": "3", "attributes": { "title": "A great new Post" } } }', + headers: { + 'CONTENT_TYPE' => JSONAPI::MEDIA_TYPE, + 'Accept' => JSONAPI::MEDIA_TYPE + } + + assert_equal 200, status + end + + def test_put_invalid_json + put '/posts/3', params: '{"data": { "type": "posts", "id": "3" "attributes": { "title": "A great new Post" } } }', + headers: { + 'CONTENT_TYPE' => JSONAPI::MEDIA_TYPE, + 'Accept' => JSONAPI::MEDIA_TYPE + } + + assert_equal 400, status + assert_equal 'Bad Request', json_response['errors'][0]['title'] + assert_match 'unexpected token at', json_response['errors'][0]['detail'] + end + + def test_put_valid_json_but_array + put '/posts/3', params: '[{"data": { "type": "posts", "id": "3", "attributes": { "title": "A great new Post" } } }]', + headers: { + 'CONTENT_TYPE' => JSONAPI::MEDIA_TYPE, + 'Accept' => JSONAPI::MEDIA_TYPE + } + + assert_equal 400, status + assert_equal 'Request must be a hash', json_response['errors'][0]['detail'] + end + def test_patch_content_type patch '/posts/3', params: { From fa07000ac8f33f1318e227da52afc81cb0d6646e Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Wed, 30 Nov 2016 14:39:02 -0500 Subject: [PATCH 008/237] Revert "Pr/895" --- lib/jsonapi/error_codes.rb | 1 - lib/jsonapi/exceptions.rb | 26 ------------------ lib/jsonapi/mime_types.rb | 17 +++--------- lib/jsonapi/request_parser.rb | 1 - test/integration/requests/request_test.rb | 33 ----------------------- 5 files changed, 3 insertions(+), 75 deletions(-) diff --git a/lib/jsonapi/error_codes.rb b/lib/jsonapi/error_codes.rb index 35f309bc7..290ee6189 100644 --- a/lib/jsonapi/error_codes.rb +++ b/lib/jsonapi/error_codes.rb @@ -20,7 +20,6 @@ module JSONAPI INVALID_FILTERS_SYNTAX = '120' SAVE_FAILED = '121' INVALID_DATA_FORMAT = '122' - BAD_REQUEST = '400' FORBIDDEN = '403' RECORD_NOT_FOUND = '404' NOT_ACCEPTABLE = '406' diff --git a/lib/jsonapi/exceptions.rb b/lib/jsonapi/exceptions.rb index 29b66b766..2d220e756 100644 --- a/lib/jsonapi/exceptions.rb +++ b/lib/jsonapi/exceptions.rb @@ -119,32 +119,6 @@ def errors end end - class BadRequest < Error - def initialize(exception) - @exception = exception - end - - def errors - [JSONAPI::Error.new(code: JSONAPI::BAD_REQUEST, - status: :bad_request, - title: I18n.translate('jsonapi-resources.exceptions.bad_request.title', - default: 'Bad Request'), - detail: I18n.translate('jsonapi-resources.exceptions.bad_request.detail', - default: @exception))] - end - end - - class InvalidRequestFormat < Error - def errors - [JSONAPI::Error.new(code: JSONAPI::BAD_REQUEST, - status: :bad_request, - title: I18n.translate('jsonapi-resources.exceptions.invalid_request_format.title', - default: 'Bad Request'), - detail: I18n.translate('jsonapi-resources.exceptions.invalid_request_format.detail', - default: 'Request must be a hash'))] - end - end - class ToManySetReplacementForbidden < Error def errors [JSONAPI::Error.new(code: JSONAPI::FORBIDDEN, diff --git a/lib/jsonapi/mime_types.rb b/lib/jsonapi/mime_types.rb index 78e8f1d4f..f8bde565e 100644 --- a/lib/jsonapi/mime_types.rb +++ b/lib/jsonapi/mime_types.rb @@ -1,5 +1,3 @@ -require 'json' - module JSONAPI MEDIA_TYPE = 'application/vnd.api+json' @@ -21,18 +19,9 @@ def self.install def self.parser lambda do |body| - begin - data = JSON.parse(body) - if data.is_a?(Hash) - data.with_indifferent_access - else - fail JSONAPI::Exceptions::InvalidRequestFormat.new - end - rescue JSON::ParserError => e - { _parser_exception: JSONAPI::Exceptions::BadRequest.new(e.to_s) } - rescue => e - { _parser_exception: e } - end + data = JSON.parse(body) + data = {:_json => data} unless data.is_a?(Hash) + data.with_indifferent_access end end end diff --git a/lib/jsonapi/request_parser.rb b/lib/jsonapi/request_parser.rb index 2e0faacfa..2929a696a 100644 --- a/lib/jsonapi/request_parser.rb +++ b/lib/jsonapi/request_parser.rb @@ -34,7 +34,6 @@ def setup_action(params) setup_action_method_name = "setup_#{params[:action]}_action" if respond_to?(setup_action_method_name) - raise params[:_parser_exception] if params[:_parser_exception] send(setup_action_method_name, params) end rescue ActionController::ParameterMissing => e diff --git a/test/integration/requests/request_test.rb b/test/integration/requests/request_test.rb index dc10b78e8..99885de21 100644 --- a/test/integration/requests/request_test.rb +++ b/test/integration/requests/request_test.rb @@ -353,39 +353,6 @@ def test_put_content_type assert_match JSONAPI::MEDIA_TYPE, headers['Content-Type'] end - def test_put_valid_json - put '/posts/3', params: '{"data": { "type": "posts", "id": "3", "attributes": { "title": "A great new Post" } } }', - headers: { - 'CONTENT_TYPE' => JSONAPI::MEDIA_TYPE, - 'Accept' => JSONAPI::MEDIA_TYPE - } - - assert_equal 200, status - end - - def test_put_invalid_json - put '/posts/3', params: '{"data": { "type": "posts", "id": "3" "attributes": { "title": "A great new Post" } } }', - headers: { - 'CONTENT_TYPE' => JSONAPI::MEDIA_TYPE, - 'Accept' => JSONAPI::MEDIA_TYPE - } - - assert_equal 400, status - assert_equal 'Bad Request', json_response['errors'][0]['title'] - assert_match 'unexpected token at', json_response['errors'][0]['detail'] - end - - def test_put_valid_json_but_array - put '/posts/3', params: '[{"data": { "type": "posts", "id": "3", "attributes": { "title": "A great new Post" } } }]', - headers: { - 'CONTENT_TYPE' => JSONAPI::MEDIA_TYPE, - 'Accept' => JSONAPI::MEDIA_TYPE - } - - assert_equal 400, status - assert_equal 'Request must be a hash', json_response['errors'][0]['detail'] - end - def test_patch_content_type patch '/posts/3', params: { From e144f80015b390702f6df38d522f68c4a9aeb1ff Mon Sep 17 00:00:00 2001 From: Pete Cruz Date: Sun, 30 Oct 2016 11:26:11 -0700 Subject: [PATCH 009/237] Rescue JSON parsing error and return all exceptions from the parser in `_parser_exception` --- lib/jsonapi/error_codes.rb | 1 + lib/jsonapi/exceptions.rb | 26 ++++++++++++++++++ lib/jsonapi/mime_types.rb | 17 +++++++++--- lib/jsonapi/request_parser.rb | 1 + test/integration/requests/request_test.rb | 33 +++++++++++++++++++++++ 5 files changed, 75 insertions(+), 3 deletions(-) diff --git a/lib/jsonapi/error_codes.rb b/lib/jsonapi/error_codes.rb index 290ee6189..35f309bc7 100644 --- a/lib/jsonapi/error_codes.rb +++ b/lib/jsonapi/error_codes.rb @@ -20,6 +20,7 @@ module JSONAPI INVALID_FILTERS_SYNTAX = '120' SAVE_FAILED = '121' INVALID_DATA_FORMAT = '122' + BAD_REQUEST = '400' FORBIDDEN = '403' RECORD_NOT_FOUND = '404' NOT_ACCEPTABLE = '406' diff --git a/lib/jsonapi/exceptions.rb b/lib/jsonapi/exceptions.rb index 2d220e756..29b66b766 100644 --- a/lib/jsonapi/exceptions.rb +++ b/lib/jsonapi/exceptions.rb @@ -119,6 +119,32 @@ def errors end end + class BadRequest < Error + def initialize(exception) + @exception = exception + end + + def errors + [JSONAPI::Error.new(code: JSONAPI::BAD_REQUEST, + status: :bad_request, + title: I18n.translate('jsonapi-resources.exceptions.bad_request.title', + default: 'Bad Request'), + detail: I18n.translate('jsonapi-resources.exceptions.bad_request.detail', + default: @exception))] + end + end + + class InvalidRequestFormat < Error + def errors + [JSONAPI::Error.new(code: JSONAPI::BAD_REQUEST, + status: :bad_request, + title: I18n.translate('jsonapi-resources.exceptions.invalid_request_format.title', + default: 'Bad Request'), + detail: I18n.translate('jsonapi-resources.exceptions.invalid_request_format.detail', + default: 'Request must be a hash'))] + end + end + class ToManySetReplacementForbidden < Error def errors [JSONAPI::Error.new(code: JSONAPI::FORBIDDEN, diff --git a/lib/jsonapi/mime_types.rb b/lib/jsonapi/mime_types.rb index f8bde565e..78e8f1d4f 100644 --- a/lib/jsonapi/mime_types.rb +++ b/lib/jsonapi/mime_types.rb @@ -1,3 +1,5 @@ +require 'json' + module JSONAPI MEDIA_TYPE = 'application/vnd.api+json' @@ -19,9 +21,18 @@ def self.install def self.parser lambda do |body| - data = JSON.parse(body) - data = {:_json => data} unless data.is_a?(Hash) - data.with_indifferent_access + begin + data = JSON.parse(body) + if data.is_a?(Hash) + data.with_indifferent_access + else + fail JSONAPI::Exceptions::InvalidRequestFormat.new + end + rescue JSON::ParserError => e + { _parser_exception: JSONAPI::Exceptions::BadRequest.new(e.to_s) } + rescue => e + { _parser_exception: e } + end end end end diff --git a/lib/jsonapi/request_parser.rb b/lib/jsonapi/request_parser.rb index eb0dbf1ee..8b34fc9fa 100644 --- a/lib/jsonapi/request_parser.rb +++ b/lib/jsonapi/request_parser.rb @@ -34,6 +34,7 @@ def setup_action(params) setup_action_method_name = "setup_#{params[:action]}_action" if respond_to?(setup_action_method_name) + raise params[:_parser_exception] if params[:_parser_exception] send(setup_action_method_name, params) end rescue ActionController::ParameterMissing => e diff --git a/test/integration/requests/request_test.rb b/test/integration/requests/request_test.rb index 99885de21..dc10b78e8 100644 --- a/test/integration/requests/request_test.rb +++ b/test/integration/requests/request_test.rb @@ -353,6 +353,39 @@ def test_put_content_type assert_match JSONAPI::MEDIA_TYPE, headers['Content-Type'] end + def test_put_valid_json + put '/posts/3', params: '{"data": { "type": "posts", "id": "3", "attributes": { "title": "A great new Post" } } }', + headers: { + 'CONTENT_TYPE' => JSONAPI::MEDIA_TYPE, + 'Accept' => JSONAPI::MEDIA_TYPE + } + + assert_equal 200, status + end + + def test_put_invalid_json + put '/posts/3', params: '{"data": { "type": "posts", "id": "3" "attributes": { "title": "A great new Post" } } }', + headers: { + 'CONTENT_TYPE' => JSONAPI::MEDIA_TYPE, + 'Accept' => JSONAPI::MEDIA_TYPE + } + + assert_equal 400, status + assert_equal 'Bad Request', json_response['errors'][0]['title'] + assert_match 'unexpected token at', json_response['errors'][0]['detail'] + end + + def test_put_valid_json_but_array + put '/posts/3', params: '[{"data": { "type": "posts", "id": "3", "attributes": { "title": "A great new Post" } } }]', + headers: { + 'CONTENT_TYPE' => JSONAPI::MEDIA_TYPE, + 'Accept' => JSONAPI::MEDIA_TYPE + } + + assert_equal 400, status + assert_equal 'Request must be a hash', json_response['errors'][0]['detail'] + end + def test_patch_content_type patch '/posts/3', params: { From f5f8444a53ae22531c801960a4bc254658f3b77e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Kwa=C5=9Bniak?= Date: Thu, 8 Dec 2016 21:49:06 +0100 Subject: [PATCH 010/237] Public model_name_for_type method (#924) --- lib/jsonapi/resource.rb | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/jsonapi/resource.rb b/lib/jsonapi/resource.rb index a5bb1d791..94c0c097c 100644 --- a/lib/jsonapi/resource.rb +++ b/lib/jsonapi/resource.rb @@ -324,7 +324,7 @@ def _replace_polymorphic_to_one_link(relationship_type, key_value, key_type, opt 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)) + _model.public_send("#{relationship.polymorphic_type}=", self.class.model_name_for_type(key_type)) @save_needed = true @@ -404,12 +404,6 @@ def _replace_fields(field_data) :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) @@ -467,6 +461,12 @@ def resource_type_for(model) end end + def model_name_for_type(key_type) + type_class_name = key_type.to_s.classify + resource = resource_for(type_class_name) + resource ? resource._model_name.to_s : type_class_name + end + attr_accessor :_attributes, :_relationships, :_type, :_model_hints attr_writer :_allowed_filters, :_paginator From 31d22d1c1a8041fd6dbdefd9ec783c9f763377ed Mon Sep 17 00:00:00 2001 From: Greg Fisher Date: Tue, 13 Dec 2016 10:28:08 -0300 Subject: [PATCH 011/237] Catch LoadError when checkin for engine --- lib/jsonapi/link_builder.rb | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/jsonapi/link_builder.rb b/lib/jsonapi/link_builder.rb index 1799152fc..61604496c 100644 --- a/lib/jsonapi/link_builder.rb +++ b/lib/jsonapi/link_builder.rb @@ -57,8 +57,12 @@ def self_link(source) def build_engine_name scopes = module_scopes_from_class(primary_resource_klass) - unless scopes.empty? - "#{ scopes.first.to_s.camelize }::Engine".safe_constantize + begin + unless scopes.empty? + "#{ scopes.first.to_s.camelize }::Engine".safe_constantize + end + rescue LoadError => e + nil end end From 022bd5c8d6d69f2495e2ef91f43d6b324a42f27e Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Mon, 19 Dec 2016 14:20:06 -0500 Subject: [PATCH 012/237] Fix 933 - Sort with has one include (cherry picked from commit 506c860) --- lib/jsonapi/resource.rb | 3 +-- lib/jsonapi/resource_serializer.rb | 34 +++++++++++++++++++++++------ test/controllers/controller_test.rb | 21 +++++++++++++++++- test/fixtures/active_record.rb | 4 ---- 4 files changed, 48 insertions(+), 14 deletions(-) diff --git a/lib/jsonapi/resource.rb b/lib/jsonapi/resource.rb index 94c0c097c..05b098b88 100644 --- a/lib/jsonapi/resource.rb +++ b/lib/jsonapi/resource.rb @@ -1102,7 +1102,6 @@ def preload_included_fragments(resources, records, serializer, options) 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. @@ -1196,7 +1195,7 @@ def preload_included_fragments(resources, records, serializer, options) .map(&:last) .reject{|id| target_resources[klass.name].has_key?(id) } .uniq - found = klass.find({klass._primary_key => sub_res_ids}, relevant_options) + 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 diff --git a/lib/jsonapi/resource_serializer.rb b/lib/jsonapi/resource_serializer.rb index 28e52f3f9..62a23c1d7 100644 --- a/lib/jsonapi/resource_serializer.rb +++ b/lib/jsonapi/resource_serializer.rb @@ -50,15 +50,35 @@ def serialize_to_hash(source) @included_objects = {} - process_primary(source, @include_directives.include_directives) + process_source_objects(source, @include_directives.include_directives) - included_objects = [] primary_objects = [] + + # pull the processed objects corresponding to the source objects. Ensures we preserve order. + if is_resource_collection + source.each do |primary| + if primary.id + case primary + when CachedResourceFragment then primary_objects.push(@included_objects[primary.type][primary.id][:object_hash]) + when Resource then primary_objects.push(@included_objects[primary.class._type][primary.id][:object_hash]) + else raise "Unknown source type #{primary.inspect}" + end + end + end + else + if source.try(:id) + case source + when CachedResourceFragment then primary_objects.push(@included_objects[source.type][source.id][:object_hash]) + when Resource then primary_objects.push(@included_objects[source.class._type][source.id][:object_hash]) + else raise "Unknown source type #{source.inspect}" + end + end + end + + included_objects = [] @included_objects.each_value do |objects| objects.each_value do |object| - if object[:primary] - primary_objects.push(object[:object_hash]) - else + unless object[:primary] included_objects.push(object[:object_hash]) end end @@ -168,9 +188,9 @@ def object_hash(source, include_directives = {}) # requested includes. Fields are controlled fields option for each resource type, such # as fields: { people: [:id, :email, :comments], posts: [:id, :title, :author], comments: [:id, :body, :post]} # The fields options controls both fields and included links references. - def process_primary(source, include_directives) + def process_source_objects(source, include_directives) if source.respond_to?(:to_ary) - source.each { |resource| process_primary(resource, include_directives) } + source.each { |resource| process_source_objects(resource, include_directives) } else return {} if source.nil? add_resource(source, include_directives, true) diff --git a/test/controllers/controller_test.rb b/test/controllers/controller_test.rb index b6e59f537..adea95ea4 100644 --- a/test/controllers/controller_test.rb +++ b/test/controllers/controller_test.rb @@ -423,7 +423,7 @@ def test_sorting_by_relationship_field assert_cacheable_get :index, params: {sort: 'author.name'} assert_response :success - assert json_response['data'].length > 10, 'there are enough recordsto show sort' + assert json_response['data'].length > 10, 'there are enough records to show sort' assert_equal '17', json_response['data'][0]['id'], 'nil is at the top' assert_equal post.id.to_s, json_response['data'][1]['id'], 'alphabetically first user is second' end @@ -438,6 +438,16 @@ def test_desc_sorting_by_relationship_field assert_equal post.id.to_s, json_response['data'][-2]['id'], 'alphabetically first user is second last' end + def test_sorting_by_relationship_field_include + post = create_alphabetically_first_user_and_post + assert_cacheable_get :index, params: {include: 'author', sort: 'author.name'} + + assert_response :success + assert json_response['data'].length > 10, 'there are enough records to show sort' + assert_equal '17', json_response['data'][0]['id'], 'nil is at the top' + assert_equal post.id.to_s, json_response['data'][1]['id'], 'alphabetically first user is second' + end + def test_invalid_sort_param assert_cacheable_get :index, params: {sort: 'asdfg'} @@ -1921,6 +1931,15 @@ def test_tags_show_multiple_with_nonexistent_ids_at_the_beginning assert_response :bad_request assert_match /99,9,100 is not a valid value for id/, response.body end + + def test_nested_includes_sort + assert_cacheable_get :index, params: {filter: {id: '6,7,8,9'}, + include: 'posts.tags,posts.author.posts', + sort: 'name'} + assert_response :success + assert_equal 4, json_response['data'].size + assert_equal 3, json_response['included'].size + end end class PicturesControllerTest < ActionController::TestCase diff --git a/test/fixtures/active_record.rb b/test/fixtures/active_record.rb index 469e81f34..1a279d6d7 100644 --- a/test/fixtures/active_record.rb +++ b/test/fixtures/active_record.rb @@ -1138,10 +1138,6 @@ class PlanetResource < JSONAPI::Resource has_one :planet_type has_many :tags, acts_as_set: true - - def records_for_moons(opts = {}) - Moon.joins(:craters).select('moons.*, craters.code').distinct - end end class PropertyResource < JSONAPI::Resource From 1dd7102b01217a85615e50f05c73af68ecbb662f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Scott=20Gonz=C3=A1lez?= Date: Wed, 21 Dec 2016 16:06:39 -0500 Subject: [PATCH 013/237] Use assert_nil instead of assert_equal --- test/controllers/controller_test.rb | 34 +++++++++---------- .../operation/operation_dispatcher_test.rb | 2 +- test/unit/serializer/link_builder_test.rb | 5 ++- 3 files changed, 20 insertions(+), 21 deletions(-) diff --git a/test/controllers/controller_test.rb b/test/controllers/controller_test.rb index adea95ea4..bde924807 100644 --- a/test/controllers/controller_test.rb +++ b/test/controllers/controller_test.rb @@ -483,7 +483,7 @@ def test_show_does_not_include_records_count_in_meta JSONAPI.configuration.top_level_meta_include_record_count = true assert_cacheable_get :show, params: { id: Post.first.id } assert_response :success - assert_equal json_response['meta'], nil + assert_nil json_response['meta'] ensure JSONAPI.configuration.top_level_meta_include_record_count = false end @@ -492,7 +492,7 @@ def test_show_does_not_include_pages_count_in_meta JSONAPI.configuration.top_level_meta_include_page_count = true assert_cacheable_get :show, params: { id: Post.first.id } assert_response :success - assert_equal json_response['meta'], nil + assert_nil json_response['meta'] ensure JSONAPI.configuration.top_level_meta_include_page_count = false end @@ -596,7 +596,7 @@ def test_create_simple_id_not_allowed assert_response :bad_request assert_match /id is not allowed/, response.body - assert_equal nil,response.location + assert_nil response.location end def test_create_link_to_missing_object @@ -618,7 +618,7 @@ def test_create_link_to_missing_object assert_response :unprocessable_entity # TODO: check if this validation is working assert_match /author - can't be blank/, response.body - assert_equal nil, response.location + assert_nil response.location end def test_create_extra_param @@ -640,7 +640,7 @@ def test_create_extra_param assert_response :bad_request assert_match /asdfg is not allowed/, response.body - assert_equal nil,response.location + assert_nil response.location end def test_create_extra_param_allow_extra_params @@ -707,7 +707,7 @@ def test_create_with_invalid_data assert_equal "/data/attributes/title", json_response['errors'][1]['source']['pointer'] assert_equal "is too long (maximum is 35 characters)", json_response['errors'][1]['title'] assert_equal "title - is too long (maximum is 35 characters)", json_response['errors'][1]['detail'] - assert_equal nil, response.location + assert_nil response.location end def test_create_multiple @@ -760,7 +760,7 @@ def test_create_simple_missing_posts assert_response :bad_request assert_match /The required parameter, data, is missing./, json_response['errors'][0]['detail'] - assert_equal nil, response.location + assert_nil response.location end def test_create_simple_wrong_type @@ -781,7 +781,7 @@ def test_create_simple_wrong_type assert_response :bad_request assert_match /posts_spelled_wrong is not a valid resource./, json_response['errors'][0]['detail'] - assert_equal nil, response.location + assert_nil response.location end def test_create_simple_missing_type @@ -801,7 +801,7 @@ def test_create_simple_missing_type assert_response :bad_request assert_match /The required parameter, type, is missing./, json_response['errors'][0]['detail'] - assert_equal nil, response.location + assert_nil response.location end def test_create_simple_unpermitted_attributes @@ -822,7 +822,7 @@ def test_create_simple_unpermitted_attributes assert_response :bad_request assert_match /subject/, json_response['errors'][0]['detail'] - assert_equal nil, response.location + assert_nil response.location end def test_create_simple_unpermitted_attributes_allow_extra_params @@ -1086,7 +1086,7 @@ def test_update_remove_links assert_response :success assert json_response['data'].is_a?(Hash) assert_equal '3', json_response['data']['relationships']['author']['data']['id'] - assert_equal nil, json_response['data']['relationships']['section']['data'] + assert_nil json_response['data']['relationships']['section']['data'] assert_equal 'A great new Post', json_response['data']['attributes']['title'] assert_equal 'AAAA', json_response['data']['attributes']['body'] assert matches_array?([], @@ -1116,7 +1116,7 @@ def test_update_relationship_to_one_nil assert_response :no_content post_object = Post.find(4) - assert_equal nil, post_object.section_id + assert_nil post_object.section_id end def test_update_relationship_to_one_invalid_links_hash_keys_ids @@ -1233,7 +1233,7 @@ def test_update_relationship_to_one_singular_param_id_nil put :update_relationship, params: {post_id: 3, relationship: 'section', data: {type: 'sections', id: nil}} assert_response :no_content - assert_equal nil, post_object.reload.section_id + assert_nil post_object.reload.section_id end def test_update_relationship_to_one_data_nil @@ -1246,7 +1246,7 @@ def test_update_relationship_to_one_data_nil put :update_relationship, params: {post_id: 3, relationship: 'section', data: nil} assert_response :no_content - assert_equal nil, post_object.reload.section_id + assert_nil post_object.reload.section_id end def test_remove_relationship_to_one @@ -1260,7 +1260,7 @@ def test_remove_relationship_to_one assert_response :no_content post_object = Post.find(3) - assert_equal nil, post_object.section_id + assert_nil post_object.section_id end def test_update_relationship_to_one_singular_param @@ -2516,7 +2516,7 @@ def test_get_person_as_author assert_equal '1', json_response['data'][0]['id'] assert_equal 'authors', json_response['data'][0]['type'] assert_equal 'Joe Author', json_response['data'][0]['attributes']['name'] - assert_equal nil, json_response['data'][0]['attributes']['email'] + assert_nil json_response['data'][0]['attributes']['email'] end def test_show_person_as_author @@ -2525,7 +2525,7 @@ def test_show_person_as_author assert_equal '1', json_response['data']['id'] assert_equal 'authors', json_response['data']['type'] assert_equal 'Joe Author', json_response['data']['attributes']['name'] - assert_equal nil, json_response['data']['attributes']['email'] + assert_nil json_response['data']['attributes']['email'] end def test_get_person_as_author_by_name_filter diff --git a/test/unit/operation/operation_dispatcher_test.rb b/test/unit/operation/operation_dispatcher_test.rb index 278ebd6dd..f7816e583 100644 --- a/test/unit/operation/operation_dispatcher_test.rb +++ b/test/unit/operation/operation_dispatcher_test.rb @@ -85,7 +85,7 @@ def test_replace_to_one_relationship op.process(operations) saturn.reload - assert_equal(saturn.planet_type_id, nil) + assert_nil(saturn.planet_type_id) # Reset operations = [ diff --git a/test/unit/serializer/link_builder_test.rb b/test/unit/serializer/link_builder_test.rb index e1062280b..a91238a7b 100644 --- a/test/unit/serializer/link_builder_test.rb +++ b/test/unit/serializer/link_builder_test.rb @@ -39,9 +39,8 @@ def test_engine_name primary_resource_klass: ApiV2Engine::PersonResource ).engine_name - assert_equal nil, - JSONAPI::LinkBuilder.new( - primary_resource_klass: Api::V1::PersonResource + assert_nil JSONAPI::LinkBuilder.new( + primary_resource_klass: Api::V1::PersonResource ).engine_name end From fcfa6194c85ac2b5d528d63afdd1b48deec08171 Mon Sep 17 00:00:00 2001 From: Hugh Barrigan Date: Wed, 21 Dec 2016 19:35:22 -0500 Subject: [PATCH 014/237] Allow context to be passed through to filter calls --- lib/jsonapi/processor.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/jsonapi/processor.rb b/lib/jsonapi/processor.rb index 82566568a..7e34e5ed6 100644 --- a/lib/jsonapi/processor.rb +++ b/lib/jsonapi/processor.rb @@ -196,7 +196,7 @@ def show_related_resources (paginator && paginator.class.requires_record_count) || (JSONAPI.configuration.top_level_meta_include_page_count)) related_resource_records = source_resource.public_send("records_for_" + relationship_type) - records = resource_klass.filter_records(filters, {}, + records = resource_klass.filter_records(filters, { context: context }, related_resource_records) record_count = resource_klass.count_records(records) From 57032fa7dcdcc4b2f5b77ba77809621d6a84b989 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Thu, 22 Dec 2016 18:26:23 -0500 Subject: [PATCH 015/237] Add a test for recursive includes. --- test/controllers/controller_test.rb | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/test/controllers/controller_test.rb b/test/controllers/controller_test.rb index bde924807..a55b1956b 100644 --- a/test/controllers/controller_test.rb +++ b/test/controllers/controller_test.rb @@ -3606,6 +3606,23 @@ def test_caching_with_join_to_resource_with_sql_fragment end end +class AuthorsControllerTest < ActionController::TestCase + def test_show_author_recursive + get :show, params: {id: '2', include: 'books.authors'} + assert_response :success + assert_equal '2', json_response['data']['id'] + assert_equal 'authors', json_response['data']['type'] + assert_equal 'Fred Reader', json_response['data']['attributes']['name'] + + # The test is hardcoded with the include order. This should be changed at some + # point since either thing could come first and still be valid + assert_equal '1', json_response['included'][0]['id'] + assert_equal 'authors', json_response['included'][0]['type'] + assert_equal '2', json_response['included'][1]['id'] + assert_equal 'books', json_response['included'][1]['type'] + end +end + class Api::BoxesControllerTest < ActionController::TestCase def test_complex_includes_base assert_cacheable_get :index @@ -3617,7 +3634,8 @@ def test_complex_includes_two_level assert_response :success - # The test is hardcoded with the include order. This should be changed at some point since either thing could come first and still be valid + # The test is hardcoded with the include order. This should be changed at some + # point since either thing could come first and still be valid assert_equal '1', json_response['included'][0]['id'] assert_equal 'things', json_response['included'][0]['type'] assert_equal '1', json_response['included'][0]['relationships']['user']['data']['id'] @@ -3638,7 +3656,8 @@ def test_complex_includes_things_nested_things assert_response :success - # The test is hardcoded with the include order. This should be changed at some point since either thing could come first and still be valid + # The test is hardcoded with the include order. This should be changed at some + # point since either thing could come first and still be valid assert_equal '2', json_response['included'][0]['id'] assert_equal 'things', json_response['included'][0]['type'] assert_nil json_response['included'][0]['relationships']['user']['data'] @@ -3655,7 +3674,8 @@ def test_complex_includes_nested_things_secondary_users assert_response :success - # The test is hardcoded with the include order. This should be changed at some point since either thing could come first and still be valid + # The test is hardcoded with the include order. This should be changed at some + # point since either thing could come first and still be valid assert_equal '1', json_response['included'][2]['id'] assert_equal 'users', json_response['included'][2]['type'] assert_nil json_response['included'][2]['relationships']['things']['data'] From 34b1fc55c59d21e65286414b61abdab868a4fc7e Mon Sep 17 00:00:00 2001 From: Olle Jonsson Date: Fri, 23 Dec 2016 15:25:14 +0100 Subject: [PATCH 016/237] Rename local variable include (#944) * Rename local variable include - this avoids collision with a keyword, which can be confusing * Local rename renamed variable * parse_include_directives: Protect against falsy values --- lib/jsonapi/request_parser.rb | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/lib/jsonapi/request_parser.rb b/lib/jsonapi/request_parser.rb index 2e0faacfa..fb80e5072 100644 --- a/lib/jsonapi/request_parser.rb +++ b/lib/jsonapi/request_parser.rb @@ -210,23 +210,22 @@ def check_include(resource_klass, include_parts) end end - def parse_include_directives(include) - return if include.nil? + def parse_include_directives(raw_include) + return unless raw_include unless JSONAPI.configuration.allow_include fail JSONAPI::Exceptions::ParametersNotAllowed.new([:include]) end - included_resources = CSV.parse_line(include) + included_resources = CSV.parse_line(raw_include) return if included_resources.nil? - include = [] - included_resources.each do |included_resource| + result = included_resources.map do |included_resource| check_include(@resource_klass, included_resource.partition('.')) - include.push(unformat_key(included_resource).to_s) + unformat_key(included_resource).to_s end - @include_directives = JSONAPI::IncludeDirectives.new(@resource_klass, include) + @include_directives = JSONAPI::IncludeDirectives.new(@resource_klass, result) end def parse_filters(filters) From 9ad4d91c22db45503294a8ee406dc559cada7628 Mon Sep 17 00:00:00 2001 From: Hugh Barrigan Date: Thu, 29 Dec 2016 17:00:08 -0500 Subject: [PATCH 017/237] Fix has_one polymorphism (#945) * Fix has_one polymorphism * Add tests for has one polymorphic serialization --- lib/jsonapi/relationship_builder.rb | 12 ++- test/fixtures/active_record.rb | 77 +++++++++++++++ test/fixtures/answers.yml | 12 +++ test/fixtures/doctors.yml | 3 + test/fixtures/patients.yml | 3 + test/fixtures/questions.yml | 6 ++ test/test_helper.rb | 5 + .../serializer/polymorphic_serializer_test.rb | 97 ++++++++++++++++++- 8 files changed, 211 insertions(+), 4 deletions(-) create mode 100644 test/fixtures/answers.yml create mode 100644 test/fixtures/doctors.yml create mode 100644 test/fixtures/patients.yml create mode 100644 test/fixtures/questions.yml diff --git a/lib/jsonapi/relationship_builder.rb b/lib/jsonapi/relationship_builder.rb index c5774e6f4..9c7364d2f 100644 --- a/lib/jsonapi/relationship_builder.rb +++ b/lib/jsonapi/relationship_builder.rb @@ -120,10 +120,16 @@ def build_has_one(relationship, foreign_key, associated_records_method_name, rel define_on_resource relationship_name do |options = {}| relationship = self.class._relationships[relationship_name] - resource_klass = relationship.resource_klass - if resource_klass + if relationship.polymorphic? associated_model = public_send(associated_records_method_name) - return associated_model ? resource_klass.new(associated_model, @context) : nil + resource_klass = self.class.resource_for_model(associated_model) if associated_model + return resource_klass.new(associated_model, @context) if resource_klass && associated_model + else + resource_klass = relationship.resource_klass + if resource_klass + associated_model = public_send(associated_records_method_name) + return associated_model ? resource_klass.new(associated_model, @context) : nil + end end end end diff --git a/test/fixtures/active_record.rb b/test/fixtures/active_record.rb index 1a279d6d7..e4b4ad5eb 100644 --- a/test/fixtures/active_record.rb +++ b/test/fixtures/active_record.rb @@ -291,6 +291,25 @@ t.timestamps null: false end + create_table :questions, force: true do |t| + t.string :text + end + + create_table :answers, force: true do |t| + t.references :question + t.integer :respondent_id + t.string :respondent_type + t.string :text + end + + create_table :patients, force: true do |t| + t.string :name + end + + create_table :doctors, force: true do |t| + t.string :name + end + # special cases end @@ -606,6 +625,25 @@ class RelatedThing < ActiveRecord::Base belongs_to :to, class_name: Thing, foreign_key: :to_id end +class Question < ActiveRecord::Base + has_one :answer + + def respondent + answer.try(:respondent) + end +end + +class Answer < ActiveRecord::Base + belongs_to :question + belongs_to :respondent, polymorphic: true +end + +class Patient < ActiveRecord::Base +end + +class Doctor < ActiveRecord::Base +end + module Api module V7 class Client < Customer @@ -882,6 +920,21 @@ class BoxesController < JSONAPI::ResourceController end end +class QuestionsController < JSONAPI::ResourceController +end + +class AnswersController < JSONAPI::ResourceController +end + +class PatientsController < JSONAPI::ResourceController +end + +class DoctorsController < JSONAPI::ResourceController +end + +class RespondentController < JSONAPI::ResourceController +end + ### RESOURCES class BaseResource < JSONAPI::Resource abstract @@ -1795,6 +1848,30 @@ class UserResource < JSONAPI::Resource end end +class QuestionResource < JSONAPI::Resource + has_one :answer + has_one :respondent, polymorphic: true, class_name: "Respondent", foreign_key_on: :related + + attributes :text +end + +class AnswerResource < JSONAPI::Resource + has_one :question + has_one :respondent, polymorphic: true +end + +class PatientResource < JSONAPI::Resource + attributes :name +end + +class DoctorResource < JSONAPI::Resource + attributes :name +end + +class RespondentResource < JSONAPI::Resource + abstract +end + ### PORO Data - don't do this in a production app $breed_data = BreedData.new $breed_data.add(Breed.new(0, 'persian')) diff --git a/test/fixtures/answers.yml b/test/fixtures/answers.yml new file mode 100644 index 000000000..197d8d6a8 --- /dev/null +++ b/test/fixtures/answers.yml @@ -0,0 +1,12 @@ +answer1: + id: 1 + question_id: 1 + text: Great thanks + respondent_id: 1 + respondent_type: Patient +answer2: + id: 2 + question_id: 2 + text: Better than last week + respondent_id: 1 + respondent_type: Doctor \ No newline at end of file diff --git a/test/fixtures/doctors.yml b/test/fixtures/doctors.yml new file mode 100644 index 000000000..c9a53c919 --- /dev/null +++ b/test/fixtures/doctors.yml @@ -0,0 +1,3 @@ +doctor1: + id: 1 + name: Henry Jones Jr \ No newline at end of file diff --git a/test/fixtures/patients.yml b/test/fixtures/patients.yml new file mode 100644 index 000000000..a75987574 --- /dev/null +++ b/test/fixtures/patients.yml @@ -0,0 +1,3 @@ +patient1: + id: 1 + name: Bob Smith \ No newline at end of file diff --git a/test/fixtures/questions.yml b/test/fixtures/questions.yml new file mode 100644 index 000000000..caabed013 --- /dev/null +++ b/test/fixtures/questions.yml @@ -0,0 +1,6 @@ +question1: + id: 1 + text: How are you feeling today? +question2: + id: 2 + text: How does the patient look today? \ No newline at end of file diff --git a/test/test_helper.rb b/test/test_helper.rb index 9ce9a60af..51ba0459d 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -255,6 +255,11 @@ class CatResource < JSONAPI::Resource jsonapi_resources :books jsonapi_resources :authors + jsonapi_resources :questions + jsonapi_resources :answers + jsonapi_resources :doctors + jsonapi_resources :patients + namespace :api do jsonapi_resources :boxes diff --git a/test/unit/serializer/polymorphic_serializer_test.rb b/test/unit/serializer/polymorphic_serializer_test.rb index 347fc8d33..bb905fde8 100644 --- a/test/unit/serializer/polymorphic_serializer_test.rb +++ b/test/unit/serializer/polymorphic_serializer_test.rb @@ -7,6 +7,8 @@ def setup @pictures = Picture.all @person = Person.find(1) + @questions = Question.all + JSONAPI.configuration.json_key_format = :camelized_key JSONAPI.configuration.route_format = :camelized_route end @@ -128,7 +130,7 @@ def test_sti_polymorphic_to_many_serialization ) end - def test_polymorphic_to_one_serialization + def test_polymorphic_belongs_to_serialization serialized_data = JSONAPI::ResourceSerializer.new( PictureResource, include: %w(imageable) @@ -249,6 +251,99 @@ def test_polymorphic_to_one_serialization ) end + def test_polymorphic_has_one_serialization + serialized_data = JSONAPI::ResourceSerializer.new( + QuestionResource, + include: %w(respondent) + ).serialize_to_hash(@questions.map { |p| QuestionResource.new p, nil }) + + assert_hash_equals( + { + data: [ + { + id: '1', + type: 'questions', + links: { + self: '/questions/1' + }, + attributes: { + text: 'How are you feeling today?' + }, + relationships: { + answer: { + links: { + self: '/questions/1/relationships/answer', + related: '/questions/1/answer' + } + }, + respondent: { + links: { + self: '/questions/1/relationships/respondent', + related: '/questions/1/respondent' + }, + data: { + type: 'patients', + id: '1' + } + } + } + }, + { + id: '2', + type: 'questions', + links: { + self: '/questions/2' + }, + attributes: { + text: 'How does the patient look today?' + }, + relationships: { + answer: { + links: { + self: '/questions/2/relationships/answer', + related: '/questions/2/answer' + } + }, + respondent: { + links: { + self: '/questions/2/relationships/respondent', + related: '/questions/2/respondent' + }, + data: { + type: 'doctors', + id: '1' + } + } + } + } + ], + :included => [ + { + id: '1', + type: 'patients', + links: { + self: '/patients/1' + }, + attributes: { + name: 'Bob Smith' + }, + }, + { + id: '1', + type: 'doctors', + links: { + self: '/doctors/1' + }, + attributes: { + name: 'Henry Jones Jr' + }, + } + ] + }, + serialized_data + ) + end + def test_polymorphic_get_related_resource get '/pictures/1/imageable', headers: { 'Accept' => JSONAPI::MEDIA_TYPE } serialized_data = JSON.parse(response.body) From 9306ec08cda7bc883ae8a5cc2cc64dccd3c412bd Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Mon, 2 Jan 2017 11:29:40 -0500 Subject: [PATCH 018/237] Fix 628 CSV parsing errors (#937) * Added test and potential fix for #628. * Add handling for CSV parsing errors. --- lib/jsonapi/request_parser.rb | 20 +++++++++++++--- lib/jsonapi/resource.rb | 6 ++++- test/integration/requests/request_test.rb | 29 +++++++++++++++++++++++ 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/lib/jsonapi/request_parser.rb b/lib/jsonapi/request_parser.rb index fb80e5072..09ce48712 100644 --- a/lib/jsonapi/request_parser.rb +++ b/lib/jsonapi/request_parser.rb @@ -217,8 +217,14 @@ def parse_include_directives(raw_include) fail JSONAPI::Exceptions::ParametersNotAllowed.new([:include]) end - included_resources = CSV.parse_line(raw_include) - return if included_resources.nil? + included_resources = [] + begin + included_resources += CSV.parse_line(raw_include) + rescue CSV::MalformedCSVError + fail JSONAPI::Exceptions::InvalidInclude.new(format_key(@resource_klass._type), raw_include) + end + + return if included_resources.empty? result = included_resources.map do |included_resource| check_include(@resource_klass, included_resource.partition('.')) @@ -264,7 +270,15 @@ def parse_sort_criteria(sort_criteria) fail JSONAPI::Exceptions::ParametersNotAllowed.new([:sort]) end - @sort_criteria = CSV.parse_line(URI.unescape(sort_criteria)).collect do |sort| + sorts = [] + begin + raw = URI.unescape(sort_criteria) + sorts += CSV.parse_line(raw) + rescue CSV::MalformedCSVError + fail JSONAPI::Exceptions::InvalidSortCriteria.new(format_key(@resource_klass._type), raw) + end + + @sort_criteria = sorts.collect do |sort| if sort.start_with?('-') sort_criteria = { field: unformat_key(sort[1..-1]).to_s } sort_criteria[:direction] = :desc diff --git a/lib/jsonapi/resource.rb b/lib/jsonapi/resource.rb index 05b098b88..74ad21016 100644 --- a/lib/jsonapi/resource.rb +++ b/lib/jsonapi/resource.rb @@ -807,7 +807,11 @@ def is_filter_relationship?(filter) def verify_filter(filter, raw, context = nil) filter_values = [] if raw.present? - filter_values += raw.is_a?(String) ? CSV.parse_line(raw) : [raw] + begin + filter_values += raw.is_a?(String) ? CSV.parse_line(raw) : [raw] + rescue CSV::MalformedCSVError + filter_values << raw + end end strategy = _allowed_filters.fetch(filter, Hash.new)[:verify] diff --git a/test/integration/requests/request_test.rb b/test/integration/requests/request_test.rb index dc10b78e8..31821f8fd 100644 --- a/test/integration/requests/request_test.rb +++ b/test/integration/requests/request_test.rb @@ -45,6 +45,15 @@ def test_get_underscored_key JSONAPI.configuration = original_config end + def test_filter_with_value_containing_double_quote + original_config = JSONAPI.configuration.dup + JSONAPI.configuration.json_key_format = :underscored_key + get '/iso_currencies?filter[country_name]=%22' + assert_jsonapi_response 200 + ensure + JSONAPI.configuration = original_config + end + def test_get_underscored_key_filtered original_config = JSONAPI.configuration.dup JSONAPI.configuration.json_key_format = :underscored_key @@ -1063,6 +1072,26 @@ def test_sort_parameter_not_allowed JSONAPI.configuration.allow_sort = true end + def test_sort_parameter_quoted + get '/api/v2/books?sort=%22title%22', headers: { 'Accept' => JSONAPI::MEDIA_TYPE } + assert_jsonapi_response 200 + end + + def test_sort_parameter_openquoted + get '/api/v2/books?sort=%22title', headers: { 'Accept' => JSONAPI::MEDIA_TYPE } + assert_jsonapi_response 400 + end + + def test_include_parameter_quoted + get '/api/v2/posts?include=%22author%22', headers: { 'Accept' => JSONAPI::MEDIA_TYPE } + assert_jsonapi_response 200 + end + + def test_include_parameter_openquoted + get '/api/v2/posts?include=%22author', headers: { 'Accept' => JSONAPI::MEDIA_TYPE } + assert_jsonapi_response 400 + end + def test_getting_different_resources_when_sti assert_cacheable_jsonapi_get '/vehicles' types = json_response['data'].map{|r| r['type']}.sort From 12185420cbbf6fbf5533581a614657ee30917a93 Mon Sep 17 00:00:00 2001 From: Hidde-Jan Jongsma Date: Wed, 4 Jan 2017 13:18:11 +0100 Subject: [PATCH 019/237] Add :readonly option to attributes and relationships This commit adds the :readonly flag to both the attribute method and the relationship method on JSONAPI::Resource. Flagging an attribute or relationship as readonly prevents it from being included in either the `creatable_fields` or `updatable_fields`. --- lib/jsonapi/relationship.rb | 4 ++++ lib/jsonapi/resource.rb | 10 +++++++--- test/unit/resource/resource_test.rb | 13 +++++++++++++ 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/lib/jsonapi/relationship.rb b/lib/jsonapi/relationship.rb index ee0dda3d7..978d1606e 100644 --- a/lib/jsonapi/relationship.rb +++ b/lib/jsonapi/relationship.rb @@ -60,6 +60,10 @@ def belongs_to? false end + def readonly? + @options[:readonly] + end + class ToOne < Relationship attr_reader :foreign_key_on diff --git a/lib/jsonapi/resource.rb b/lib/jsonapi/resource.rb index 74ad21016..1cda2a20a 100644 --- a/lib/jsonapi/resource.rb +++ b/lib/jsonapi/resource.rb @@ -580,12 +580,12 @@ def cache_field(field) # 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 + _updatable_relationships | _updatable_attributes end # Override in your resource to filter the sortable keys @@ -891,8 +891,12 @@ def _attribute_options(attr) default_attribute_options.merge(@_attributes[attr]) 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) diff --git a/test/unit/resource/resource_test.rb b/test/unit/resource/resource_test.rb index 4c8e94daa..a794ef8d5 100644 --- a/test/unit/resource/resource_test.rb +++ b/test/unit/resource/resource_test.rb @@ -98,6 +98,11 @@ class RelatedResource < MyModule::RelatedResource end end +class PostWithReadonlyAttributesResource < JSONAPI::Resource + attribute :title, readonly: true + has_one :author, readonly: true +end + class ResourceTest < ActiveSupport::TestCase def setup @post = Post.first @@ -629,4 +634,12 @@ def test_resources_for_transforms_records_into_resources resources = PostResource.resources_for([Post.first], {}) assert_equal(PostResource, resources.first.class) end + + def test_readonly_attribute + refute_includes(PostWithReadonlyAttributesResource.creatable_fields, :title) + refute_includes(PostWithReadonlyAttributesResource.updatable_fields, :title) + + refute_includes(PostWithReadonlyAttributesResource.creatable_fields, :author) + refute_includes(PostWithReadonlyAttributesResource.updatable_fields, :author) + end end From 53a246e331fede76fcafb1c6ad600a7003b54e21 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Thu, 29 Dec 2016 10:39:19 -0500 Subject: [PATCH 020/237] Refactor for operations support All base spec requests now are handled in a single operation OperationResults functions moved to the ResponseDocument OperationDispatcher functions moved to ActsAsResourceController --- lib/jsonapi-resources.rb | 3 +- lib/jsonapi/acts_as_resource_controller.rb | 210 ++--- lib/jsonapi/error.rb | 23 + lib/jsonapi/exceptions.rb | 66 +- lib/jsonapi/operation.rb | 4 - lib/jsonapi/operation_dispatcher.rb | 88 --- lib/jsonapi/operation_result.rb | 48 +- lib/jsonapi/operation_results.rb | 35 - lib/jsonapi/processor.rb | 57 +- lib/jsonapi/request_parser.rb | 717 +++++++++--------- lib/jsonapi/resource_serializer.rb | 77 +- lib/jsonapi/response_document.rb | 184 +++-- lib/jsonapi/routing_ext.rb | 36 +- locales/en.yml | 4 +- test/controllers/controller_test.rb | 10 +- test/fixtures/active_record.rb | 11 +- test/fixtures/posts.yml | 20 +- .../jsonapi_request/jsonapi_request_test.rb | 47 +- .../operation/operation_dispatcher_test.rb | 434 ----------- .../unit/serializer/response_document_test.rb | 56 -- test/unit/serializer/serializer_test.rb | 40 +- 21 files changed, 843 insertions(+), 1327 deletions(-) delete mode 100644 lib/jsonapi/operation_dispatcher.rb delete mode 100644 lib/jsonapi/operation_results.rb delete mode 100644 test/unit/operation/operation_dispatcher_test.rb delete mode 100644 test/unit/serializer/response_document_test.rb diff --git a/lib/jsonapi-resources.rb b/lib/jsonapi-resources.rb index 035c4515f..194de869a 100644 --- a/lib/jsonapi-resources.rb +++ b/lib/jsonapi-resources.rb @@ -17,11 +17,10 @@ require 'jsonapi/error' require 'jsonapi/error_codes' require 'jsonapi/request_parser' -require 'jsonapi/operation_dispatcher' require 'jsonapi/processor' require 'jsonapi/relationship' require 'jsonapi/include_directives' +require 'jsonapi/operation' require 'jsonapi/operation_result' -require 'jsonapi/operation_results' require 'jsonapi/callbacks' require 'jsonapi/link_builder' diff --git a/lib/jsonapi/acts_as_resource_controller.rb b/lib/jsonapi/acts_as_resource_controller.rb index 6dd0e3f2e..bc0229ddc 100644 --- a/lib/jsonapi/acts_as_resource_controller.rb +++ b/lib/jsonapi/acts_as_resource_controller.rb @@ -9,9 +9,13 @@ def self.included(base) base.extend ClassMethods base.include Callbacks base.cattr_reader :server_error_callbacks - base.define_jsonapi_resources_callbacks :process_operations + base.define_jsonapi_resources_callbacks :process_operations, + :transaction, + :rollback end + attr_reader :response_document + def index process_request end @@ -25,22 +29,18 @@ def show_relationship end def create - return unless verify_content_type_header process_request end def create_relationship - return unless verify_content_type_header process_request end def update_relationship - return unless verify_content_type_header process_request end def update - return unless verify_content_type_header process_request end @@ -61,50 +61,89 @@ def get_related_resources end def process_request - return unless verify_accept_header + @response_document = create_response_document - @request = JSONAPI::RequestParser.new(params, context: context, - key_formatter: key_formatter, - server_error_callbacks: (self.class.server_error_callbacks || [])) + unless verify_content_type_header && verify_accept_header + render_response_document + return + end - unless @request.errors.empty? - render_errors(@request.errors) - else - operations = @request.operations - unless JSONAPI.configuration.resource_cache.nil? - operations.each {|op| op.options[:cache_serializer] = resource_serializer } + request_parser = JSONAPI::RequestParser.new( + params, + context: context, + key_formatter: key_formatter, + server_error_callbacks: (self.class.server_error_callbacks || [])) + + transactional = request_parser.transactional? + + force_rollback = false + run_in_transaction(transactional) do + begin + run_callbacks :process_operations do + begin + request_parser.each(response_document) do |op| + op.options[:serializer] = resource_serializer_klass.new( + op.resource_klass, + include_directives: op.options[:include_directives], + fields: op.options[:fields], + base_url: base_url, + key_formatter: key_formatter, + route_formatter: route_formatter, + serialization_options: serialization_options + ) + op.options[:cache_serializer_output] = !JSONAPI.configuration.resource_cache.nil? + + process_operation(op) + end + rescue => e + handle_exceptions(e) + end + end + rescue => e + force_rollback = true + raise e + ensure + if response_document.has_errors? || force_rollback + rollback_transaction(transactional) + end end - results = process_operations(operations) - render_results(results) end - rescue => e - handle_exceptions(e) + render_response_document end - def process_operations(operations) - run_callbacks :process_operations do - operation_dispatcher.process(operations) + def run_in_transaction(transactional) + if transactional + run_callbacks :transaction do + transaction do + yield + end + end + else + yield end end - def transaction - lambda { |&block| - ActiveRecord::Base.transaction do - block.yield + def rollback_transaction(transactional) + if transactional + run_callbacks :rollback do + rollback end - } + end end - def rollback - lambda { - fail ActiveRecord::Rollback - } + def process_operation(operation) + result = operation.process + response_document.add_result(result, operation) end - def operation_dispatcher - @operation_dispatcher ||= JSONAPI::OperationDispatcher.new(transaction: transaction, - rollback: rollback, - server_error_callbacks: @request.server_error_callbacks) + def transaction + ActiveRecord::Base.transaction do + yield + end + end + + def rollback + fail ActiveRecord::Rollback end private @@ -117,19 +156,6 @@ def resource_serializer_klass @resource_serializer_klass ||= JSONAPI::ResourceSerializer end - def resource_serializer - @resource_serializer ||= resource_serializer_klass.new( - resource_klass, - include_directives: @request ? @request.include_directives : nil, - fields: @request ? @request.fields : {}, - base_url: base_url, - key_formatter: key_formatter, - route_formatter: route_formatter, - serialization_options: serialization_options - ) - @resource_serializer - end - def base_url @base_url ||= request.protocol + request.host_with_port end @@ -139,8 +165,10 @@ def resource_klass_name end def verify_content_type_header - unless request.content_type == JSONAPI::MEDIA_TYPE - fail JSONAPI::Exceptions::UnsupportedMediaTypeError.new(request.content_type) + if ['create', 'create_relationship', 'update_relationship', 'update'].include?(params[:action]) + unless request.content_type == JSONAPI::MEDIA_TYPE + fail JSONAPI::Exceptions::UnsupportedMediaTypeError.new(request.content_type) + end end true rescue => e @@ -161,13 +189,12 @@ def verify_accept_header def valid_accept_media_type? media_types = media_types_for('Accept') - media_types.blank? || - media_types.any? do |media_type| - (media_type == JSONAPI::MEDIA_TYPE || media_type.start_with?(ALL_MEDIA_TYPES)) - end + media_types.blank? || media_types.any? do |media_type| + (media_type == JSONAPI::MEDIA_TYPE || media_type.start_with?(ALL_MEDIA_TYPES)) + end end - def media_types_for(header) + def media_types_for(header) (request.headers[header] || '') .scan(MEDIA_TYPE_MATCHER) .to_a @@ -202,57 +229,40 @@ def base_response_meta end def base_meta - if @request.nil? || @request.warnings.empty? - base_response_meta - else - base_response_meta.merge(warnings: @request.warnings) - end + base_response_meta end def base_response_links {} end - def render_errors(errors) - operation_results = JSONAPI::OperationResults.new - result = JSONAPI::ErrorsOperationResult.new(errors[0].status, errors) - operation_results.add_result(result) - - render_results(operation_results) - end - - def render_results(operation_results) - response_doc = create_response_document(operation_results) - content = response_doc.contents + def render_response_document + content = response_document.contents render_options = {} - if operation_results.has_errors? + if response_document.has_errors? render_options[:json] = content else # Bypasing ActiveSupport allows us to use CompiledJson objects for cached response fragments render_options[:body] = JSON.generate(content) - end - render_options[:location] = content[:data]["links"][:self] if ( - response_doc.status == :created && content[:data].class != Array - ) + render_options[:location] = content['data']['links']['self'] if (response_document.status == 201 && content[:data].class != Array) + end # For whatever reason, `render` ignores :status and :content_type when :body is set. # But, we can just set those values directly in the Response object instead. - response.status = response_doc.status + response.status = response_document.status response.headers['Content-Type'] = JSONAPI::MEDIA_TYPE render(render_options) end - def create_response_document(operation_results) + def create_response_document JSONAPI::ResponseDocument.new( - operation_results, - operation_results.has_errors? ? nil : resource_serializer, - key_formatter: key_formatter, - base_meta: base_meta, - base_links: base_response_links, - request: @request + key_formatter: key_formatter, + base_meta: base_meta, + base_links: base_response_links, + request: request ) end @@ -260,21 +270,27 @@ def create_response_document(operation_results) # Note: Be sure to either call super(e) or handle JSONAPI::Exceptions::Error and raise unhandled exceptions def handle_exceptions(e) case e - when JSONAPI::Exceptions::Error - render_errors(e.errors) - else - if JSONAPI.configuration.exception_class_whitelisted?(e) - fail e + when JSONAPI::Exceptions::Error + errors = e.errors + when ActionController::ParameterMissing + errors = JSONAPI::Exceptions::ParameterMissing.new(e.param).errors else - (self.class.server_error_callbacks || []).each { |callback| - safe_run_callback(callback, e) - } + if JSONAPI.configuration.exception_class_whitelisted?(e) + fail e + else + if self.class.server_error_callbacks + self.class.server_error_callbacks.each { |callback| + safe_run_callback(callback, e) + } + end - internal_server_error = JSONAPI::Exceptions::InternalServerError.new(e) - Rails.logger.error { "Internal Server Error: #{e.message} #{e.backtrace.join("\n")}" } - render_errors(internal_server_error.errors) - end + internal_server_error = JSONAPI::Exceptions::InternalServerError.new(e) + Rails.logger.error { "Internal Server Error: #{e.message} #{e.backtrace.join("\n")}" } + errors = internal_server_error.errors + end end + + response_document.add_result(JSONAPI::ErrorsOperationResult.new(errors[0].status, errors), nil) end def safe_run_callback(callback, error) @@ -283,7 +299,7 @@ def safe_run_callback(callback, error) rescue => e Rails.logger.error { "Error in error handling callback: #{e.message} #{e.backtrace.join("\n")}" } internal_server_error = JSONAPI::Exceptions::InternalServerError.new(e) - render_errors(internal_server_error.errors) + return JSONAPI::ErrorsOperationResult.new(internal_server_error.errors[0].code, internal_server_error.errors) end end diff --git a/lib/jsonapi/error.rb b/lib/jsonapi/error.rb index 41545c13e..354af7adc 100644 --- a/lib/jsonapi/error.rb +++ b/lib/jsonapi/error.rb @@ -24,6 +24,29 @@ def to_hash instance_variables.each {|var| hash[var.to_s.delete('@')] = instance_variable_get(var) unless instance_variable_get(var).nil? } hash end + + def update_with_overrides(error_object_overrides) + @title = error_object_overrides[:title] || @title + @detail = error_object_overrides[:detail] || @detail + @id = error_object_overrides[:id] || @id + @href = error_object_overrides[:href] || href + + if error_object_overrides[:code] + @code = if JSONAPI.configuration.use_text_errors + TEXT_ERRORS[error_object_overrides[:code]] + else + error_object_overrides[:code] + end + end + + @source = error_object_overrides[:source] || @source + @links = error_object_overrides[:links] || @links + + if error_object_overrides[:status] + @status = Rack::Utils::SYMBOL_TO_STATUS_CODE[error_object_overrides[:status]].to_s + end + @meta = error_object_overrides[:meta] || @meta + end end class Warning diff --git a/lib/jsonapi/exceptions.rb b/lib/jsonapi/exceptions.rb index cf77dfd12..2bc206177 100644 --- a/lib/jsonapi/exceptions.rb +++ b/lib/jsonapi/exceptions.rb @@ -18,6 +18,22 @@ def errors end end + class Errors < Error + def initialize(errors, error_object_overrides = {}) + @errors = errors + + @errors.each do |error| + error.update_with_overrides(error_object_overrides) + end + + super(error_object_overrides) + end + + def errors + @errors + end + end + class InternalServerError < Error attr_accessor :exception @@ -140,28 +156,29 @@ def errors end class BadRequest < Error - def initialize(exception) + def initialize(exception, error_object_overrides = {}) @exception = exception + super(error_object_overrides) end def errors - [JSONAPI::Error.new(code: JSONAPI::BAD_REQUEST, - status: :bad_request, - title: I18n.translate('jsonapi-resources.exceptions.bad_request.title', - default: 'Bad Request'), - detail: I18n.translate('jsonapi-resources.exceptions.bad_request.detail', - default: @exception))] + [create_error_object(code: JSONAPI::BAD_REQUEST, + status: :bad_request, + title: I18n.translate('jsonapi-resources.exceptions.bad_request.title', + default: 'Bad Request'), + detail: I18n.translate('jsonapi-resources.exceptions.bad_request.detail', + default: @exception))] end end class InvalidRequestFormat < Error def errors - [JSONAPI::Error.new(code: JSONAPI::BAD_REQUEST, - status: :bad_request, - title: I18n.translate('jsonapi-resources.exceptions.invalid_request_format.title', - default: 'Bad Request'), - detail: I18n.translate('jsonapi-resources.exceptions.invalid_request_format.detail', - default: 'Request must be a hash'))] + [create_error_object(code: JSONAPI::BAD_REQUEST, + status: :bad_request, + title: I18n.translate('jsonapi-resources.exceptions.invalid_request_format.title', + default: 'Bad Request'), + detail: I18n.translate('jsonapi-resources.exceptions.invalid_request_format.detail', + default: 'Request must be a hash'))] end end @@ -364,24 +381,21 @@ def errors end end - class ParametersNotAllowed < Error - attr_accessor :params + class ParameterNotAllowed < Error + attr_accessor :param - def initialize(params, error_object_overrides = {}) - @params = params + def initialize(param, error_object_overrides = {}) + @param = param super(error_object_overrides) end def errors - params.collect do |param| - create_error_object(code: JSONAPI::PARAM_NOT_ALLOWED, - status: :bad_request, - title: I18n.translate('jsonapi-resources.exceptions.parameters_not_allowed.title', - default: 'Param not allowed'), - detail: I18n.translate('jsonapi-resources.exceptions.parameters_not_allowed.detail', - default: "#{param} is not allowed.", param: param)) - - end + [create_error_object(code: JSONAPI::PARAM_NOT_ALLOWED, + status: :bad_request, + title: I18n.translate('jsonapi-resources.exceptions.parameter_not_allowed.title', + default: 'Param not allowed'), + detail: I18n.translate('jsonapi-resources.exceptions.parameters_not_allowed.detail', + default: "#{param} is not allowed.", param: param))] end end diff --git a/lib/jsonapi/operation.rb b/lib/jsonapi/operation.rb index 239345fa6..80897fd92 100644 --- a/lib/jsonapi/operation.rb +++ b/lib/jsonapi/operation.rb @@ -8,10 +8,6 @@ def initialize(operation_type, resource_klass, options) @options = options end - def transactional? - JSONAPI::Processor._processor_from_resource_type(resource_klass).transactional_operation_type?(operation_type) - end - def process processor.process end diff --git a/lib/jsonapi/operation_dispatcher.rb b/lib/jsonapi/operation_dispatcher.rb deleted file mode 100644 index 999f37dc9..000000000 --- a/lib/jsonapi/operation_dispatcher.rb +++ /dev/null @@ -1,88 +0,0 @@ -module JSONAPI - class OperationDispatcher - - def initialize(transaction: lambda { |&block| block.yield }, - rollback: lambda { }, - server_error_callbacks: []) - - @transaction = transaction - @rollback = rollback - @server_error_callbacks = server_error_callbacks - end - - def process(operations) - results = JSONAPI::OperationResults.new - - # Use transactions if more than one operation and if one of the operations can be transactional - # Even if transactional transactions won't be used unless the derived OperationsProcessor supports them. - transactional = false - - operations.each do |operation| - transactional |= operation.transactional? - end if JSONAPI.configuration.allow_transactions - - transaction(transactional) do - # Links and meta data global to the set of operations - operations_meta = {} - operations_links = {} - operations.each do |operation| - results.add_result(process_operation(operation)) - rollback(transactional) if results.has_errors? - end - results.meta = operations_meta - results.links = operations_links - end - results - end - - private - - def transaction(transactional) - if transactional - @transaction.call do - yield - end - else - yield - end - end - - def rollback(transactional) - if transactional - @rollback.call - end - end - - def process_operation(operation) - with_default_handling do - operation.process - end - end - - def with_default_handling(&block) - block.yield - rescue => e - if JSONAPI.configuration.exception_class_whitelisted?(e) - raise e - else - @server_error_callbacks.each { |callback| - safe_run_callback(callback, e) - } - - internal_server_error = JSONAPI::Exceptions::InternalServerError.new(e) - Rails.logger.error { "Internal Server Error: #{e.message} #{e.backtrace.join("\n")}" } - return JSONAPI::ErrorsOperationResult.new(internal_server_error.errors[0].code, internal_server_error.errors) - end - end - - def safe_run_callback(callback, error) - begin - callback.call(error) - rescue => e - Rails.logger.error { "Error in error handling callback: #{e.message} #{e.backtrace.join("\n")}" } - internal_server_error = JSONAPI::Exceptions::InternalServerError.new(e) - return JSONAPI::ErrorsOperationResult.new(internal_server_error.errors[0].code, internal_server_error.errors) - end - end - end -end diff --git a/lib/jsonapi/operation_result.rb b/lib/jsonapi/operation_result.rb index 43e996a15..eed2916d4 100644 --- a/lib/jsonapi/operation_result.rb +++ b/lib/jsonapi/operation_result.rb @@ -4,12 +4,18 @@ class OperationResult attr_accessor :meta attr_accessor :links attr_accessor :options + attr_accessor :warnings def initialize(code, options = {}) - @code = code + @code = Rack::Utils.status_code(code) @options = options @meta = options.fetch(:meta, {}) @links = options.fetch(:links, {}) + @warnings = options.fetch(:warnings, {}) + end + + def to_hash(serializer = nil) + {} end end @@ -20,6 +26,14 @@ def initialize(code, errors, options = {}) @errors = errors super(code, options) end + + def to_hash(serializer = nil) + { + errors: errors.collect do |error| + error.to_hash + end + } + end end class ResourceOperationResult < OperationResult @@ -29,6 +43,14 @@ def initialize(code, resource, options = {}) @resource = resource super(code, options) end + + def to_hash(serializer = nil) + if serializer + serializer.serialize_to_hash(resource) + else + {} + end + end end class ResourcesOperationResult < OperationResult @@ -41,6 +63,14 @@ def initialize(code, resources, options = {}) @page_count = options[:page_count] super(code, options) end + + def to_hash(serializer) + if serializer + serializer.serialize_to_hash(resources) + else + {} + end + end end class RelatedResourcesOperationResult < ResourcesOperationResult @@ -51,6 +81,14 @@ def initialize(code, source_resource, type, resources, options = {}) @_type = type super(code, resources, options) end + + def to_hash(serializer = nil) + if serializer + serializer.serialize_to_hash(resources) + else + {} + end + end end class LinksObjectOperationResult < OperationResult @@ -61,5 +99,13 @@ def initialize(code, parent_resource, relationship, options = {}) @relationship = relationship super(code, options) end + + def to_hash(serializer = nil) + if serializer + serializer.serialize_to_links_hash(parent_resource, relationship) + else + {} + end + end end end diff --git a/lib/jsonapi/operation_results.rb b/lib/jsonapi/operation_results.rb deleted file mode 100644 index 22363fd8a..000000000 --- a/lib/jsonapi/operation_results.rb +++ /dev/null @@ -1,35 +0,0 @@ -module JSONAPI - class OperationResults - attr_accessor :results - attr_accessor :meta - attr_accessor :links - - def initialize - @results = [] - @has_errors = false - @meta = {} - @links = {} - end - - def add_result(result) - @has_errors = true if result.is_a?(JSONAPI::ErrorsOperationResult) - @results.push(result) - end - - def has_errors? - @has_errors - end - - def all_errors - errors = [] - if @has_errors - @results.each do |result| - if result.is_a?(JSONAPI::ErrorsOperationResult) - errors.concat(result.errors) - end - end - end - errors - end - end -end diff --git a/lib/jsonapi/processor.rb b/lib/jsonapi/processor.rb index 7e34e5ed6..f7ab30b19 100644 --- a/lib/jsonapi/processor.rb +++ b/lib/jsonapi/processor.rb @@ -30,15 +30,6 @@ def _processor_from_resource_type(resource_klass) return processor end - - def transactional_operation_type?(operation_type) - case operation_type - when :find, :show, :show_related_resource, :show_related_resources - return false - else - return true - end - end end attr_reader :resource_klass, :operation_type, :params, :context, :result, :result_options @@ -63,6 +54,12 @@ def process @result = JSONAPI::ErrorsOperationResult.new(e.errors[0].code, e.errors) end + def result_options + options = {} + options[:warnings] = params[:warnings] if params[:warnings] + options + end + def find filters = params[:filters] include_directives = params[:include_directives] @@ -79,15 +76,15 @@ def find fields: fields } - resource_records = if params[:cache_serializer] + resource_records = if params[:cache_serializer_output] resource_klass.find_serialized_with_caching(verified_filters, - params[:cache_serializer], + params[:serializer], find_options) else resource_klass.find(verified_filters, find_options) end - page_options = {} + page_options = result_options if (JSONAPI.configuration.top_level_meta_include_record_count || (paginator && paginator.class.requires_record_count)) page_options[:record_count] = resource_klass.find_count(verified_filters, @@ -119,15 +116,15 @@ def show fields: fields } - resource_record = if params[:cache_serializer] + resource_record = if params[:cache_serializer_output] resource_klass.find_by_key_serialized_with_caching(key, - params[:cache_serializer], + params[:serializer], find_options) else resource_klass.find_by_key(key, find_options) end - return JSONAPI::ResourceOperationResult.new(:ok, resource_record) + return JSONAPI::ResourceOperationResult.new(:ok, resource_record, result_options) end def show_relationship @@ -138,7 +135,8 @@ def show_relationship return JSONAPI::LinksObjectOperationResult.new(:ok, parent_resource, - resource_klass._relationship(relationship_type)) + resource_klass._relationship(relationship_type), + result_options) end def show_related_resource @@ -152,7 +150,7 @@ def show_related_resource related_resource = source_resource.public_send(relationship_type) - return JSONAPI::ResourceOperationResult.new(:ok, related_resource) + return JSONAPI::ResourceOperationResult.new(:ok, related_resource, result_options) end def show_related_resources @@ -176,8 +174,7 @@ def show_related_resources include_directives: include_directives } - related_resources = nil - if params[:cache_serializer] + if params[:cache_serializer_output] # TODO Could also avoid instantiating source_resource as actual Resource by # allowing LinkBuilder to accept CachedResourceFragment as source in # relationships_related_link @@ -185,7 +182,7 @@ def show_related_resources relationship = source_klass._relationship(relationship_type) related_resources = relationship.resource_klass.find_serialized_with_caching( scope, - params[:cache_serializer], + params[:serializer], rel_opts ) else @@ -214,7 +211,7 @@ def show_related_resources {} end - opts = {} + opts = result_options opts.merge!(pagination_params: pagination_params) if JSONAPI.configuration.top_level_links_include_pagination opts.merge!(record_count: record_count) if JSONAPI.configuration.top_level_meta_include_record_count opts.merge!(page_count: page_count) if JSONAPI.configuration.top_level_meta_include_page_count @@ -231,7 +228,7 @@ def create_resource resource = resource_klass.create(context) result = resource.replace_fields(data) - return JSONAPI::ResourceOperationResult.new((result == :completed ? :created : :accepted), resource) + return JSONAPI::ResourceOperationResult.new((result == :completed ? :created : :accepted), resource, result_options) end def remove_resource @@ -240,7 +237,7 @@ def remove_resource resource = resource_klass.find_by_key(resource_id, context: context) result = resource.remove - return JSONAPI::OperationResult.new(result == :completed ? :no_content : :accepted) + return JSONAPI::OperationResult.new(result == :completed ? :no_content : :accepted, result_options) end def replace_fields @@ -250,7 +247,7 @@ def replace_fields resource = resource_klass.find_by_key(resource_id, context: context) result = resource.replace_fields(data) - return JSONAPI::ResourceOperationResult.new(result == :completed ? :ok : :accepted, resource) + return JSONAPI::ResourceOperationResult.new(result == :completed ? :ok : :accepted, resource, result_options) end def replace_to_one_relationship @@ -261,7 +258,7 @@ def replace_to_one_relationship resource = resource_klass.find_by_key(resource_id, context: context) result = resource.replace_to_one_link(relationship_type, key_value) - return JSONAPI::OperationResult.new(result == :completed ? :no_content : :accepted) + return JSONAPI::OperationResult.new(result == :completed ? :no_content : :accepted, result_options) end def replace_polymorphic_to_one_relationship @@ -273,7 +270,7 @@ def replace_polymorphic_to_one_relationship resource = resource_klass.find_by_key(resource_id, context: context) result = resource.replace_polymorphic_to_one_link(relationship_type, key_value, key_type) - return JSONAPI::OperationResult.new(result == :completed ? :no_content : :accepted) + return JSONAPI::OperationResult.new(result == :completed ? :no_content : :accepted, result_options) end def create_to_many_relationships @@ -284,7 +281,7 @@ def create_to_many_relationships resource = resource_klass.find_by_key(resource_id, context: context) result = resource.create_to_many_links(relationship_type, data) - return JSONAPI::OperationResult.new(result == :completed ? :no_content : :accepted) + return JSONAPI::OperationResult.new(result == :completed ? :no_content : :accepted, result_options) end def replace_to_many_relationships @@ -295,7 +292,7 @@ def replace_to_many_relationships resource = resource_klass.find_by_key(resource_id, context: context) result = resource.replace_to_many_links(relationship_type, data) - return JSONAPI::OperationResult.new(result == :completed ? :no_content : :accepted) + return JSONAPI::OperationResult.new(result == :completed ? :no_content : :accepted, result_options) end def remove_to_many_relationships @@ -312,7 +309,7 @@ def remove_to_many_relationships complete = false end end - return JSONAPI::OperationResult.new(complete ? :no_content : :accepted) + return JSONAPI::OperationResult.new(complete ? :no_content : :accepted, result_options) end def remove_to_one_relationship @@ -322,7 +319,7 @@ def remove_to_one_relationship resource = resource_klass.find_by_key(resource_id, context: context) result = resource.remove_to_one_link(relationship_type) - return JSONAPI::OperationResult.new(result == :completed ? :no_content : :accepted) + return JSONAPI::OperationResult.new(result == :completed ? :no_content : :accepted, result_options) end end end diff --git a/lib/jsonapi/request_parser.rb b/lib/jsonapi/request_parser.rb index 09ce48712..3e7388819 100644 --- a/lib/jsonapi/request_parser.rb +++ b/lib/jsonapi/request_parser.rb @@ -1,121 +1,244 @@ -require 'jsonapi/operation' -require 'jsonapi/paginator' - module JSONAPI class RequestParser - attr_accessor :fields, :include, :filters, :sort_criteria, :errors, :operations, - :resource_klass, :context, :paginator, :source_klass, :source_id, + attr_accessor :fields, :include, :filters, :sort_criteria, :errors, :controller_module_path, + :context, :paginator, :source_klass, :source_id, :include_directives, :params, :warnings, :server_error_callbacks def initialize(params = nil, options = {}) @params = params + if params + controller_path = params.fetch(:controller, '') + @controller_module_path = controller_path.include?('/') ? controller_path.rpartition('/').first + '/' : '' + else + @controller_module_path = '' + end + @context = options[:context] @key_formatter = options.fetch(:key_formatter, JSONAPI.configuration.key_formatter) @errors = [] @warnings = [] - @operations = [] - @fields = {} - @filters = {} - @sort_criteria = nil - @source_klass = nil - @source_id = nil - @include_directives = nil - @paginator = nil - @id = nil @server_error_callbacks = options.fetch(:server_error_callbacks, []) + end + + def error_object_overrides + {} + end + + def each(response_document) + operation = setup_base_op(params) + if @errors.any? + fail JSONAPI::Exceptions::Errors.new(@errors) + else + yield operation + end + rescue ActionController::ParameterMissing => e + fail JSONAPI::Exceptions::ParameterMissing.new(e.param, error_object_overrides) + end - setup_action(@params) + def transactional? + case params[:action] + when 'index', 'get_related_resource', 'get_related_resources', 'show', 'show_relationship' + return false + else + return true + end end - def setup_action(params) + def setup_base_op(params) return if params.nil? - @resource_klass ||= Resource.resource_for(params[:controller]) if params[:controller] + resource_klass = Resource.resource_for(params[:controller]) if params[:controller] setup_action_method_name = "setup_#{params[:action]}_action" if respond_to?(setup_action_method_name) raise params[:_parser_exception] if params[:_parser_exception] - send(setup_action_method_name, params) + send(setup_action_method_name, params, resource_klass) end rescue ActionController::ParameterMissing => e - @errors.concat(JSONAPI::Exceptions::ParameterMissing.new(e.param).errors) + @errors.concat(JSONAPI::Exceptions::ParameterMissing.new(e.param, error_object_overrides).errors) + rescue JSONAPI::Exceptions::Error => e + e.error_object_overrides.merge! error_object_overrides + @errors.concat(e.errors) end - def setup_index_action(params) - parse_fields(params[:fields]) - parse_include_directives(params[:include]) - set_default_filters - parse_filters(params[:filter]) - parse_sort_criteria(params[:sort]) - parse_pagination(params[:page]) - add_find_operation + def setup_index_action(params, resource_klass) + fields = parse_fields(resource_klass, params[:fields]) + include_directives = parse_include_directives(resource_klass, params[:include]) + filters = parse_filters(resource_klass, params[:filter]) + sort_criteria = parse_sort_criteria(resource_klass, params[:sort]) + paginator = parse_pagination(resource_klass, params[:page]) + + JSONAPI::Operation.new( + :find, + resource_klass, + context: context, + filters: filters, + include_directives: include_directives, + sort_criteria: sort_criteria, + paginator: paginator, + fields: fields + ) end - def setup_get_related_resource_action(params) - initialize_source(params) - parse_fields(params[:fields]) - parse_include_directives(params[:include]) - set_default_filters - parse_filters(params[:filter]) - parse_sort_criteria(params[:sort]) - parse_pagination(params[:page]) - add_show_related_resource_operation(params[:relationship]) + def setup_get_related_resource_action(params, resource_klass) + source_klass = Resource.resource_for(params.require(:source)) + source_id = source_klass.verify_key(params.require(source_klass._as_parent_key), @context) + + fields = parse_fields(resource_klass, params[:fields]) + include_directives = parse_include_directives(resource_klass, params[:include]) + + relationship_type = params[:relationship].to_sym + + JSONAPI::Operation.new( + :show_related_resource, + resource_klass, + context: @context, + relationship_type: relationship_type, + source_klass: source_klass, + source_id: source_id, + fields: fields, + include_directives: include_directives + ) end - def setup_get_related_resources_action(params) - initialize_source(params) - parse_fields(params[:fields]) - parse_include_directives(params[:include]) - set_default_filters - parse_filters(params[:filter]) - parse_sort_criteria(params[:sort]) - parse_pagination(params[:page]) - add_show_related_resources_operation(params[:relationship]) + def setup_get_related_resources_action(params, resource_klass) + source_klass = Resource.resource_for(params.require(:source)) + source_id = source_klass.verify_key(params.require(source_klass._as_parent_key), @context) + + fields = parse_fields(resource_klass, params[:fields]) + include_directives = parse_include_directives(resource_klass, params[:include]) + filters = parse_filters(resource_klass, params[:filter]) + sort_criteria = parse_sort_criteria(resource_klass, params[:sort]) + paginator = parse_pagination(resource_klass, params[:page]) + relationship_type = params[:relationship] + + JSONAPI::Operation.new( + :show_related_resources, + resource_klass, + context: @context, + relationship_type: relationship_type, + source_klass: source_klass, + source_id: source_id, + filters: source_klass.verify_filters(filters, @context), + sort_criteria: sort_criteria, + paginator: paginator, + fields: fields, + include_directives: include_directives + ) end - def setup_show_action(params) - parse_fields(params[:fields]) - parse_include_directives(params[:include]) - @id = params[:id] - add_show_operation + def setup_show_action(params, resource_klass) + fields = parse_fields(resource_klass, params[:fields]) + include_directives = parse_include_directives(resource_klass, params[:include]) + id = params[:id] + + JSONAPI::Operation.new( + :show, + resource_klass, + context: @context, + id: id, + include_directives: include_directives, + fields: fields, + allowed_resources: params[:allowed_resources] + ) end - def setup_show_relationship_action(params) - add_show_relationship_operation(params[:relationship], params.require(@resource_klass._as_parent_key)) + def setup_show_relationship_action(params, resource_klass) + relationship_type = params[:relationship] + parent_key = params.require(resource_klass._as_parent_key) + + JSONAPI::Operation.new( + :show_relationship, + resource_klass, + context: @context, + relationship_type: relationship_type, + parent_key: resource_klass.verify_key(parent_key) + ) end - def setup_create_action(params) - parse_fields(params[:fields]) - parse_include_directives(params[:include]) - parse_add_operation(params.require(:data)) + def setup_create_action(params, resource_klass) + fields = parse_fields(resource_klass, params[:fields]) + include_directives = parse_include_directives(resource_klass, params[:include]) + + data = params.require(:data) + + unless data.respond_to?(:each_pair) + fail JSONAPI::Exceptions::InvalidDataFormat.new(error_object_overrides) + end + + verify_type(data[:type], resource_klass) + + data = parse_params(resource_klass, data, resource_klass.creatable_fields(@context)) + + JSONAPI::Operation.new( + :create_resource, + resource_klass, + context: @context, + data: data, + fields: fields, + include_directives: include_directives, + warnings: @warnings + ) end - def setup_create_relationship_action(params) - parse_modify_relationship_action(params, :add) + def setup_create_relationship_action(params, resource_klass) + parse_modify_relationship_action(:add, params, resource_klass) end - def setup_update_relationship_action(params) - parse_modify_relationship_action(params, :update) + def setup_update_relationship_action(params, resource_klass) + parse_modify_relationship_action(:update, params, resource_klass) end - def setup_update_action(params) - parse_fields(params[:fields]) - parse_include_directives(params[:include]) - parse_replace_operation(params.require(:data), params[:id]) + def setup_update_action(params, resource_klass) + fields = parse_fields(resource_klass, params[:fields]) + include_directives = parse_include_directives(resource_klass, params[:include]) + + data = params.require(:data) + key = params[:id] + + fail JSONAPI::Exceptions::InvalidDataFormat.new(error_object_overrides) unless data.respond_to?(:each_pair) + + fail JSONAPI::Exceptions::MissingKey.new(error_object_overrides) if data[:id].nil? + + resource_id = data.require(:id) + # Singlton resources may not have the ID set in the URL + if key + fail JSONAPI::Exceptions::KeyNotIncludedInURL.new(resource_id) if key.to_s != resource_id.to_s + end + + data.delete(:id) + + verify_type(data[:type], resource_klass) + + JSONAPI::Operation.new( + :replace_fields, + resource_klass, + context: @context, + resource_id: resource_id, + data: parse_params(resource_klass, data, resource_klass.updatable_fields(@context)), + fields: fields, + include_directives: include_directives, + warnings: @warnings + ) end - def setup_destroy_action(params) - parse_remove_operation(params) + def setup_destroy_action(params, resource_klass) + JSONAPI::Operation.new( + :remove_resource, + resource_klass, + context: @context, + resource_id: resource_klass.verify_key(params.require(:id), @context)) end - def setup_destroy_relationship_action(params) - parse_modify_relationship_action(params, :remove) + def setup_destroy_relationship_action(params, resource_klass) + parse_modify_relationship_action(:remove, params, resource_klass) end - def parse_modify_relationship_action(params, modification_type) + def parse_modify_relationship_action(modification_type, params, resource_klass) relationship_type = params.require(:relationship) - parent_key = params.require(@resource_klass._as_parent_key) - relationship = @resource_klass._relationship(relationship_type) + + parent_key = params.require(resource_klass._as_parent_key) + relationship = resource_klass._relationship(relationship_type) # Removals of to-one relationships are done implicitly and require no specification of data data_required = !(modification_type == :remove && relationship.is_a?(JSONAPI::Relationship::ToOne)) @@ -123,77 +246,79 @@ def parse_modify_relationship_action(params, modification_type) if data_required data = params.fetch(:data) object_params = { relationships: { format_key(relationship.name) => { data: data } } } - verified_params = parse_params(object_params, @resource_klass.updatable_fields(@context)) - parse_arguments = [verified_params, relationship, parent_key] + verified_params = parse_params(resource_klass, object_params, resource_klass.updatable_fields(@context)) + + parse_arguments = [resource_klass, verified_params, relationship, parent_key] else - parse_arguments = [params, relationship, parent_key] + parse_arguments = [resource_klass, params, relationship, parent_key] end send(:"parse_#{modification_type}_relationship_operation", *parse_arguments) end - def initialize_source(params) - @source_klass = Resource.resource_for(params.require(:source)) - @source_id = @source_klass.verify_key(params.require(@source_klass._as_parent_key), @context) + def parse_pagination(resource_klass, page) + paginator_name = resource_klass._paginator + JSONAPI::Paginator.paginator_for(paginator_name).new(page) unless paginator_name == :none end - def parse_pagination(page) - paginator_name = @resource_klass._paginator - @paginator = JSONAPI::Paginator.paginator_for(paginator_name).new(page) unless paginator_name == :none - rescue JSONAPI::Exceptions::Error => e - @errors.concat(e.errors) - end - - def parse_fields(fields) - return if fields.nil? - + def parse_fields(resource_klass, fields) extracted_fields = {} + return extracted_fields if fields.nil? + # Extract the fields for each type from the fields parameters if fields.is_a?(ActionController::Parameters) fields.each do |field, value| - resource_fields = value.split(',') unless value.nil? || value.empty? + if value.is_a?(Array) + resource_fields = value + else + resource_fields = value.split(',') unless value.nil? || value.empty? + end extracted_fields[field] = resource_fields end else - fail JSONAPI::Exceptions::InvalidFieldFormat.new + fail JSONAPI::Exceptions::InvalidFieldFormat.new(error_object_overrides) end + errors = [] # Validate the fields + validated_fields = {} extracted_fields.each do |type, values| underscored_type = unformat_key(type) - extracted_fields[type] = [] + validated_fields[type] = [] begin if type != format_key(type) - fail JSONAPI::Exceptions::InvalidResource.new(type) + fail JSONAPI::Exceptions::InvalidResource.new(type, error_object_overrides) end - type_resource = Resource.resource_for(@resource_klass.module_path + underscored_type.to_s) + type_resource = Resource.resource_for(resource_klass.module_path + underscored_type.to_s) rescue NameError - @errors.concat(JSONAPI::Exceptions::InvalidResource.new(type).errors) + errors.concat(JSONAPI::Exceptions::InvalidResource.new(type, error_object_overrides).errors) rescue JSONAPI::Exceptions::InvalidResource => e - @errors.concat(e.errors) + errors.concat(e.errors) end if type_resource.nil? - @errors.concat(JSONAPI::Exceptions::InvalidResource.new(type).errors) + errors.concat(JSONAPI::Exceptions::InvalidResource.new(type, error_object_overrides).errors) else unless values.nil? valid_fields = type_resource.fields.collect { |key| format_key(key) } values.each do |field| if valid_fields.include?(field) - extracted_fields[type].push unformat_key(field) + validated_fields[type].push unformat_key(field) else - @errors.concat(JSONAPI::Exceptions::InvalidField.new(type, field).errors) + errors.concat(JSONAPI::Exceptions::InvalidField.new(type, field, error_object_overrides).errors) end end else - @errors.concat(JSONAPI::Exceptions::InvalidField.new(type, 'nil').errors) + errors.concat(JSONAPI::Exceptions::InvalidField.new(type, 'nil', error_object_overrides).errors) end end end - @fields = extracted_fields.deep_transform_keys { |key| unformat_key(key) } + fail JSONAPI::Exceptions::Errors.new(errors) unless errors.empty? + + validated_fields.deep_transform_keys { |key| unformat_key(key) } end def check_include(resource_klass, include_parts) @@ -202,7 +327,8 @@ def check_include(resource_klass, include_parts) relationship = resource_klass._relationship(relationship_name) if relationship && format_key(relationship_name) == include_parts.first unless include_parts.last.empty? - check_include(Resource.resource_for(resource_klass.module_path + relationship.class_name.to_s.underscore), include_parts.last.partition('.')) + check_include(Resource.resource_for(resource_klass.module_path + relationship.class_name.to_s.underscore), + include_parts.last.partition('.')) end else @errors.concat(JSONAPI::Exceptions::InvalidInclude.new(format_key(resource_klass._type), @@ -210,85 +336,91 @@ def check_include(resource_klass, include_parts) end end - def parse_include_directives(raw_include) + def parse_include_directives(resource_klass, raw_include) return unless raw_include unless JSONAPI.configuration.allow_include - fail JSONAPI::Exceptions::ParametersNotAllowed.new([:include]) + fail JSONAPI::Exceptions::ParameterNotAllowed.new(:include) end included_resources = [] begin - included_resources += CSV.parse_line(raw_include) + included_resources += raw_include.is_a?(Array) ? raw_include : CSV.parse_line(raw_include) rescue CSV::MalformedCSVError - fail JSONAPI::Exceptions::InvalidInclude.new(format_key(@resource_klass._type), raw_include) + fail JSONAPI::Exceptions::InvalidInclude.new(format_key(resource_klass._type), raw_include) end - return if included_resources.empty? + return if included_resources.nil? result = included_resources.map do |included_resource| - check_include(@resource_klass, included_resource.partition('.')) + check_include(resource_klass, included_resource.partition('.')) unformat_key(included_resource).to_s end - @include_directives = JSONAPI::IncludeDirectives.new(@resource_klass, result) + JSONAPI::IncludeDirectives.new(resource_klass, result) end - def parse_filters(filters) - return unless filters + def parse_filters(resource_klass, filters) + parsed_filters = {} - unless JSONAPI.configuration.allow_filter - fail JSONAPI::Exceptions::ParametersNotAllowed.new([:filter]) + # apply default filters + resource_klass._allowed_filters.each do |filter, opts| + next if opts[:default].nil? || !parsed_filters[filter].nil? + parsed_filters[filter] = opts[:default] end + return parsed_filters unless filters + unless filters.class.method_defined?(:each) @errors.concat(JSONAPI::Exceptions::InvalidFiltersSyntax.new(filters).errors) - return + return {} + end + + unless JSONAPI.configuration.allow_filter + fail JSONAPI::Exceptions::ParameterNotAllowed.new(:filter) end filters.each do |key, value| filter = unformat_key(key) - if @resource_klass._allowed_filter?(filter) - @filters[filter] = value + if resource_klass._allowed_filter?(filter) + parsed_filters[filter] = value else @errors.concat(JSONAPI::Exceptions::FilterNotAllowed.new(filter).errors) end end - end - def set_default_filters - @resource_klass._allowed_filters.each do |filter, opts| - next if opts[:default].nil? || !@filters[filter].nil? - @filters[filter] = opts[:default] - end + parsed_filters end - def parse_sort_criteria(sort_criteria) + def parse_sort_criteria(resource_klass, sort_criteria) return unless sort_criteria.present? unless JSONAPI.configuration.allow_sort - fail JSONAPI::Exceptions::ParametersNotAllowed.new([:sort]) + fail JSONAPI::Exceptions::ParameterNotAllowed.new(:sort) end - sorts = [] - begin - raw = URI.unescape(sort_criteria) - sorts += CSV.parse_line(raw) - rescue CSV::MalformedCSVError - fail JSONAPI::Exceptions::InvalidSortCriteria.new(format_key(@resource_klass._type), raw) + if sort_criteria.is_a?(Array) + sorts = sort_criteria + elsif sort_criteria.is_a?(String) + begin + raw = URI.unescape(sort_criteria) + sorts = CSV.parse_line(raw) + rescue CSV::MalformedCSVError + fail JSONAPI::Exceptions::InvalidSortCriteria.new(format_key(resource_klass._type), raw) + end end @sort_criteria = sorts.collect do |sort| if sort.start_with?('-') - sort_criteria = { field: unformat_key(sort[1..-1]).to_s } - sort_criteria[:direction] = :desc + criteria = { field: unformat_key(sort[1..-1]).to_s } + criteria[:direction] = :desc else - sort_criteria = { field: unformat_key(sort).to_s } - sort_criteria[:direction] = :asc + criteria = { field: unformat_key(sort).to_s } + criteria[:direction] = :asc end - check_sort_criteria(@resource_klass, sort_criteria) - sort_criteria + check_sort_criteria(resource_klass, criteria) + criteria end end @@ -298,114 +430,39 @@ def check_sort_criteria(resource_klass, sort_criteria) unless sortable_fields.include? sort_field.to_sym @errors.concat(JSONAPI::Exceptions::InvalidSortCriteria - .new(format_key(resource_klass._type), sort_field).errors) + .new(format_key(resource_klass._type), sort_field).errors) end end - def add_find_operation - @operations.push JSONAPI::Operation.new(:find, - @resource_klass, - context: @context, - filters: @filters, - include_directives: @include_directives, - sort_criteria: @sort_criteria, - paginator: @paginator, - fields: @fields - ) - end - - def add_show_operation - @operations.push JSONAPI::Operation.new(:show, - @resource_klass, - context: @context, - id: @id, - include_directives: @include_directives, - fields: @fields - ) - end - - def add_show_relationship_operation(relationship_type, parent_key) - @operations.push JSONAPI::Operation.new(:show_relationship, - @resource_klass, - context: @context, - relationship_type: relationship_type, - parent_key: @resource_klass.verify_key(parent_key) - ) - end - - def add_show_related_resource_operation(relationship_type) - @operations.push JSONAPI::Operation.new(:show_related_resource, - @resource_klass, - context: @context, - relationship_type: relationship_type, - source_klass: @source_klass, - source_id: @source_id, - fields: @fields, - include_directives: @include_directives - ) - end - - def add_show_related_resources_operation(relationship_type) - @operations.push JSONAPI::Operation.new(:show_related_resources, - @resource_klass, - context: @context, - relationship_type: relationship_type, - source_klass: @source_klass, - source_id: @source_id, - filters: @source_klass.verify_filters(@filters, @context), - sort_criteria: @sort_criteria, - paginator: @paginator, - fields: @fields, - include_directives: @include_directives - ) - end - - def parse_add_operation(params) - fail JSONAPI::Exceptions::InvalidDataFormat unless params.respond_to?(:each_pair) - - verify_type(params[:type]) - - data = parse_params(params, @resource_klass.creatable_fields(@context)) - @operations.push JSONAPI::Operation.new(:create_resource, - @resource_klass, - context: @context, - data: data, - fields: @fields, - include_directives: @include_directives - ) - rescue JSONAPI::Exceptions::Error => e - @errors.concat(e.errors) - end - - def verify_type(type) + def verify_type(type, resource_klass) if type.nil? fail JSONAPI::Exceptions::ParameterMissing.new(:type) - elsif unformat_key(type).to_sym != @resource_klass._type - fail JSONAPI::Exceptions::InvalidResource.new(type) + elsif unformat_key(type).to_sym != resource_klass._type + fail JSONAPI::Exceptions::InvalidResource.new(type, error_object_overrides) end end def parse_to_one_links_object(raw) if raw.nil? return { - type: nil, - id: nil + type: nil, + id: nil } end if !(raw.is_a?(Hash) || raw.is_a?(ActionController::Parameters)) || - raw.keys.length != 2 || !(raw.key?('type') && raw.key?('id')) - fail JSONAPI::Exceptions::InvalidLinksObject.new + raw.keys.length != 2 || !(raw.key?('type') && raw.key?('id')) + fail JSONAPI::Exceptions::InvalidLinksObject.new(error_object_overrides) end { - type: unformat_key(raw['type']).to_s, - id: raw['id'] + type: unformat_key(raw['type']).to_s, + id: raw['id'] } end def parse_to_many_links_object(raw) - fail JSONAPI::Exceptions::InvalidLinksObject.new if raw.nil? + fail JSONAPI::Exceptions::InvalidLinksObject.new(error_object_overrides) if raw.nil? links_object = {} if raw.is_a?(Array) @@ -415,12 +472,12 @@ def parse_to_many_links_object(raw) links_object[link_object[:type]].push(link_object[:id]) end else - fail JSONAPI::Exceptions::InvalidLinksObject.new + fail JSONAPI::Exceptions::InvalidLinksObject.new(error_object_overrides) end links_object end - def parse_params(params, allowed_fields) + def parse_params(resource_klass, params, allowed_fields) verify_permitted_params(params, allowed_fields) checked_attributes = {} @@ -429,37 +486,37 @@ def parse_params(params, allowed_fields) params.each do |key, value| case key.to_s - when 'relationships' - value.each do |link_key, link_value| - param = unformat_key(link_key) - relationship = @resource_klass._relationship(param) - - if relationship.is_a?(JSONAPI::Relationship::ToOne) - checked_to_one_relationships[param] = parse_to_one_relationship(link_value, relationship) - elsif relationship.is_a?(JSONAPI::Relationship::ToMany) - parse_to_many_relationship(link_value, relationship) do |result_val| - checked_to_many_relationships[param] = result_val + when 'relationships' + value.each do |link_key, link_value| + param = unformat_key(link_key) + relationship = resource_klass._relationship(param) + + if relationship.is_a?(JSONAPI::Relationship::ToOne) + checked_to_one_relationships[param] = parse_to_one_relationship(resource_klass, link_value, relationship) + elsif relationship.is_a?(JSONAPI::Relationship::ToMany) + parse_to_many_relationship(resource_klass, link_value, relationship) do |result_val| + checked_to_many_relationships[param] = result_val + end end end - end - when 'id' - checked_attributes['id'] = unformat_value(:id, value) - when 'attributes' - value.each do |key, value| - param = unformat_key(key) - checked_attributes[param] = unformat_value(param, value) - end + when 'id' + checked_attributes['id'] = unformat_value(resource_klass, :id, value) + when 'attributes' + value.each do |key, value| + param = unformat_key(key) + checked_attributes[param] = unformat_value(resource_klass, param, value) + end end end return { - 'attributes' => checked_attributes, - 'to_one' => checked_to_one_relationships, - 'to_many' => checked_to_many_relationships + 'attributes' => checked_attributes, + 'to_one' => checked_to_one_relationships, + 'to_many' => checked_to_many_relationships }.deep_transform_keys { |key| unformat_key(key) } end - def parse_to_one_relationship(link_value, relationship) + def parse_to_one_relationship(resource_klass, link_value, relationship) if link_value.nil? linkage = nil else @@ -468,11 +525,11 @@ def parse_to_one_relationship(link_value, relationship) links_object = parse_to_one_links_object(linkage) if !relationship.polymorphic? && links_object[:type] && (links_object[:type].to_s != relationship.type.to_s) - fail JSONAPI::Exceptions::TypeMismatch.new(links_object[:type]) + fail JSONAPI::Exceptions::TypeMismatch.new(links_object[:type], error_object_overrides) end unless links_object[:id].nil? - resource = self.resource_klass || Resource + resource = resource_klass || Resource relationship_resource = resource.resource_for(unformat_key(links_object[:type]).to_s) relationship_id = relationship_resource.verify_key(links_object[:id], @context) if relationship.polymorphic? @@ -485,13 +542,13 @@ def parse_to_one_relationship(link_value, relationship) end end - def parse_to_many_relationship(link_value, relationship, &add_result) + def parse_to_many_relationship(resource_klass, link_value, relationship, &add_result) if link_value.is_a?(Array) && link_value.length == 0 linkage = [] elsif (link_value.is_a?(Hash) || link_value.is_a?(ActionController::Parameters)) linkage = link_value[:data] else - fail JSONAPI::Exceptions::InvalidLinksObject.new + fail JSONAPI::Exceptions::InvalidLinksObject.new(error_object_overrides) end links_object = parse_to_many_links_object(linkage) @@ -504,89 +561,104 @@ def parse_to_many_relationship(link_value, relationship, &add_result) add_result.call([]) else if links_object.length > 1 || !links_object.has_key?(unformat_key(relationship.type).to_s) - fail JSONAPI::Exceptions::TypeMismatch.new(links_object[:type]) + fail JSONAPI::Exceptions::TypeMismatch.new(links_object[:type], error_object_overrides) end links_object.each_pair do |type, keys| - relationship_resource = Resource.resource_for(@resource_klass.module_path + unformat_key(type).to_s) + relationship_resource = Resource.resource_for(resource_klass.module_path + unformat_key(type).to_s) add_result.call relationship_resource.verify_keys(keys, @context) end end end - def unformat_value(attribute, value) - value_formatter = JSONAPI::ValueFormatter.value_formatter_for(@resource_klass._attribute_options(attribute)[:format]) + def unformat_value(resource_klass, attribute, value) + value_formatter = JSONAPI::ValueFormatter.value_formatter_for(resource_klass._attribute_options(attribute)[:format]) value_formatter.unformat(value) end def verify_permitted_params(params, allowed_fields) formatted_allowed_fields = allowed_fields.collect { |field| format_key(field).to_sym } params_not_allowed = [] + param_errors = [] params.each do |key, value| case key.to_s - when 'relationships' - value.keys.each do |links_key| - unless formatted_allowed_fields.include?(links_key.to_sym) - params_not_allowed.push(links_key) - unless JSONAPI.configuration.raise_if_parameters_not_allowed - value.delete links_key + when 'relationships' + value.keys.each do |links_key| + unless formatted_allowed_fields.include?(links_key.to_sym) + if JSONAPI.configuration.raise_if_parameters_not_allowed + param_errors.concat JSONAPI::Exceptions::ParameterNotAllowed.new( + links_key, error_object_overrides).errors + else + params_not_allowed.push(links_key) + value.delete links_key + end end end - end - when 'attributes' - value.each do |attr_key, attr_value| - unless formatted_allowed_fields.include?(attr_key.to_sym) - params_not_allowed.push(attr_key) - unless JSONAPI.configuration.raise_if_parameters_not_allowed - value.delete attr_key + when 'attributes' + value.each do |attr_key, attr_value| + unless formatted_allowed_fields.include?(attr_key.to_sym) + if JSONAPI.configuration.raise_if_parameters_not_allowed + param_errors.concat JSONAPI::Exceptions::ParameterNotAllowed.new( + attr_key, error_object_overrides).errors + else + params_not_allowed.push(attr_key) + value.delete attr_key + end end end - end - when 'type' - when 'id' - unless formatted_allowed_fields.include?(:id) - params_not_allowed.push(:id) - unless JSONAPI.configuration.raise_if_parameters_not_allowed - params.delete :id + when 'type' + when 'id' + unless formatted_allowed_fields.include?(:id) + if JSONAPI.configuration.raise_if_parameters_not_allowed + param_errors.concat JSONAPI::Exceptions::ParameterNotAllowed.new( + :id, error_object_overrides).errors + else + params_not_allowed.push(:id) + params.delete :id + end + end + else + if JSONAPI.configuration.raise_if_parameters_not_allowed + param_errors += JSONAPI::Exceptions::ParameterNotAllowed.new( + key, error_object_overrides).errors + else + params_not_allowed.push(key) + params.delete key end - end - else - params_not_allowed.push(key) end end - if params_not_allowed.length > 0 - if JSONAPI.configuration.raise_if_parameters_not_allowed - fail JSONAPI::Exceptions::ParametersNotAllowed.new(params_not_allowed) - else - params_not_allowed_warnings = params_not_allowed.map do |key| - JSONAPI::Warning.new(code: JSONAPI::PARAM_NOT_ALLOWED, - title: 'Param not allowed', - detail: "#{key} is not allowed.") - end - self.warnings.concat(params_not_allowed_warnings) + if param_errors.length > 0 + fail JSONAPI::Exceptions::Errors.new(param_errors) + elsif params_not_allowed.length > 0 + params_not_allowed_warnings = params_not_allowed.map do |param| + JSONAPI::Warning.new(code: JSONAPI::PARAM_NOT_ALLOWED, + title: 'Param not allowed', + detail: "#{param} is not allowed.") end + self.warnings.concat(params_not_allowed_warnings) end end - def parse_add_relationship_operation(verified_params, relationship, parent_key) + def parse_add_relationship_operation(resource_klass, verified_params, relationship, parent_key) if relationship.is_a?(JSONAPI::Relationship::ToMany) - @operations.push JSONAPI::Operation.new(:create_to_many_relationships, - resource_klass, - context: @context, - resource_id: parent_key, - relationship_type: relationship.name, - data: verified_params[:to_many].values[0] + return JSONAPI::Operation.new( + :create_to_many_relationships, + resource_klass, + context: @context, + resource_id: parent_key, + relationship_type: relationship.name, + data: verified_params[:to_many].values[0] ) end end - def parse_update_relationship_operation(verified_params, relationship, parent_key) + def parse_update_relationship_operation(resource_klass, verified_params, relationship, parent_key) options = { - context: @context, - resource_id: parent_key, - relationship_type: relationship.name + context: @context, + resource_id: parent_key, + relationship_type: relationship.name } if relationship.is_a?(JSONAPI::Relationship::ToOne) @@ -607,62 +679,23 @@ def parse_update_relationship_operation(verified_params, relationship, parent_ke operation_type = :replace_to_many_relationships end - @operations.push JSONAPI::Operation.new(operation_type, resource_klass, options) + JSONAPI::Operation.new(operation_type, resource_klass, options) end - def parse_single_replace_operation(data, keys, id_key_presence_check_required: true) - fail JSONAPI::Exceptions::InvalidDataFormat unless data.respond_to?(:each_pair) - - fail JSONAPI::Exceptions::MissingKey.new if data[:id].nil? - - key = data[:id].to_s - if id_key_presence_check_required && !keys.include?(key) - fail JSONAPI::Exceptions::KeyNotIncludedInURL.new(key) - end - - data.delete(:id) unless keys.include?(:id) - - verify_type(data[:type]) - - @operations.push JSONAPI::Operation.new(:replace_fields, - @resource_klass, - context: @context, - resource_id: key, - data: parse_params(data, @resource_klass.updatable_fields(@context)), - fields: @fields, - include_directives: @include_directives - ) - end - - def parse_replace_operation(data, keys) - parse_single_replace_operation(data, [keys], id_key_presence_check_required: keys.present?) - rescue JSONAPI::Exceptions::Error => e - @errors.concat(e.errors) - end - - def parse_remove_operation(params) - @operations.push JSONAPI::Operation.new(:remove_resource, - @resource_klass, - context: @context, - resource_id: @resource_klass.verify_key(params.require(:id), context)) - rescue JSONAPI::Exceptions::Error => e - @errors.concat(e.errors) - end - - def parse_remove_relationship_operation(params, relationship, parent_key) + def parse_remove_relationship_operation(resource_klass, params, relationship, parent_key) operation_base_args = [resource_klass].push( - context: @context, - resource_id: parent_key, - relationship_type: relationship.name + context: @context, + resource_id: parent_key, + relationship_type: relationship.name ) if relationship.is_a?(JSONAPI::Relationship::ToMany) operation_args = operation_base_args.dup keys = params[:to_many].values[0] operation_args[1] = operation_args[1].merge(associated_keys: keys) - @operations.push JSONAPI::Operation.new(:remove_to_many_relationships, *operation_args) + JSONAPI::Operation.new(:remove_to_many_relationships, *operation_args) else - @operations.push JSONAPI::Operation.new(:remove_to_one_relationship, *operation_base_args) + JSONAPI::Operation.new(:remove_to_one_relationship, *operation_base_args) end end diff --git a/lib/jsonapi/resource_serializer.rb b/lib/jsonapi/resource_serializer.rb index 62a23c1d7..e52e8c4c2 100644 --- a/lib/jsonapi/resource_serializer.rb +++ b/lib/jsonapi/resource_serializer.rb @@ -1,7 +1,7 @@ module JSONAPI class ResourceSerializer - attr_reader :link_builder, :key_formatter, :serialization_options, :primary_class_name, + attr_reader :link_builder, :key_formatter, :serialization_options, :fields, :include_directives, :always_include_to_one_linkage_data, :always_include_to_many_linkage_data @@ -19,7 +19,6 @@ class ResourceSerializer def initialize(primary_resource_klass, options = {}) @primary_resource_klass = primary_resource_klass - @primary_class_name = primary_resource_klass._type @fields = options.fetch(:fields, {}) @include = options.fetch(:include, []) @include_directives = options[:include_directives] @@ -84,9 +83,9 @@ def serialize_to_hash(source) end end - primary_hash = { data: is_resource_collection ? primary_objects : primary_objects[0] } + primary_hash = { 'data' => is_resource_collection ? primary_objects : primary_objects[0] } - primary_hash[:included] = included_objects if included_objects.size > 0 + primary_hash['included'] = included_objects if included_objects.size > 0 primary_hash end @@ -98,11 +97,11 @@ def serialize_to_links_hash(source, requested_relationship) end { - links: { - self: self_link(source, requested_relationship), - related: related_link(source, requested_relationship) + 'links' => { + 'self' => self_link(source, requested_relationship), + 'related' => related_link(source, requested_relationship) }, - data: data + 'data' => data } end @@ -251,7 +250,7 @@ def meta_hash(source) def links_hash(source) links = custom_links_hash(source) - links[:self] = link_builder.self_link(source) unless links.key?(:self) + links['self'] = link_builder.self_link(source) unless links.key?('self') links.compact end @@ -330,7 +329,7 @@ def cached_relationships_hash(source, include_directives) ia = include_directives[:include_related][rel_name] if ia if h.has_key?(key) - h[key][:data] = to_many ? [] : nil + h[key]['data'] = to_many ? [] : nil end fragments = source.preloaded_fragments[key] @@ -349,14 +348,14 @@ def cached_relationships_hash(source, include_directives) if h.has_key?(key) # The hash already has everything we need except the :data field data = { - type: format_key(f.is_a?(Resource) ? f.class._type : f.type), - id: @id_formatter.format(id) + 'type' => format_key(f.is_a?(Resource) ? f.class._type : f.type), + 'id' => @id_formatter.format(id) } if to_many - h[key][:data] << data + h[key]['data'] << data else - h[key][:data] = data + h[key]['data'] = data end end end @@ -386,8 +385,8 @@ def to_one_linkage(source, relationship) return unless linkage_id.present? && linkage_type.present? { - type: linkage_type, - id: linkage_id, + 'type' => linkage_type, + 'id' => linkage_id, } end @@ -417,7 +416,7 @@ def to_many_linkage(source, relationship) linkage_types_and_values.each do |type, value| if type && value - linkage.append({type: format_key(type), id: @id_formatter.format(value)}) + linkage.append({'type' => format_key(type), 'id' => @id_formatter.format(value)}) end end linkage @@ -426,20 +425,20 @@ def to_many_linkage(source, relationship) def link_object_to_one(source, relationship, include_linkage) include_linkage = include_linkage | @always_include_to_one_linkage_data | relationship.always_include_linkage_data link_object_hash = {} - link_object_hash[:links] = {} - link_object_hash[:links][:self] = self_link(source, relationship) - link_object_hash[:links][:related] = related_link(source, relationship) - link_object_hash[:data] = to_one_linkage(source, relationship) if include_linkage + link_object_hash['links'] = {} + link_object_hash['links']['self'] = self_link(source, relationship) + link_object_hash['links']['related'] = related_link(source, relationship) + link_object_hash['data'] = to_one_linkage(source, relationship) if include_linkage link_object_hash end def link_object_to_many(source, relationship, include_linkage) include_linkage = include_linkage | relationship.always_include_linkage_data link_object_hash = {} - link_object_hash[:links] = {} - link_object_hash[:links][:self] = self_link(source, relationship) - link_object_hash[:links][:related] = related_link(source, relationship) - link_object_hash[:data] = to_many_linkage(source, relationship) if include_linkage + link_object_hash['links'] = {} + link_object_hash['links']['self'] = self_link(source, relationship) + link_object_hash['links']['related'] = related_link(source, relationship) + link_object_hash['data'] = to_many_linkage(source, relationship) if include_linkage link_object_hash end @@ -467,34 +466,6 @@ def foreign_key_value(source, relationship) @id_formatter.format(related_resource_id) end - def foreign_key_types_and_values(source, relationship) - if relationship.is_a?(JSONAPI::Relationship::ToMany) - if relationship.polymorphic? - assoc = source._model.public_send(relationship.name) - # Avoid hitting the database again for values already pre-loaded - if assoc.respond_to?(:loaded?) and assoc.loaded? - assoc.map do |obj| - [obj.type.underscore.pluralize, @id_formatter.format(obj.id)] - end - else - assoc.pluck(:type, :id).map do |type, id| - [type.underscore.pluralize, @id_formatter.format(id)] - end - end - else - source.public_send(relationship.name).map do |value| - [relationship.type, @id_formatter.format(value.id)] - end - end - end - end - - # Sets that an object should be included in the primary document of the response. - def set_primary(type, id) - type = format_key(type) - @included_objects[type][id][:primary] = true - end - def add_resource(source, include_directives, primary = false) type = source.is_a?(JSONAPI::CachedResourceFragment) ? source.type : source.class._type id = source.id diff --git a/lib/jsonapi/response_document.rb b/lib/jsonapi/response_document.rb index 092645947..3f4833bce 100644 --- a/lib/jsonapi/response_document.rb +++ b/lib/jsonapi/response_document.rb @@ -1,81 +1,132 @@ module JSONAPI class ResponseDocument - def initialize(operation_results, serializer, options = {}) - @operation_results = operation_results - @serializer = serializer + attr_reader :serialized_results + + def initialize(options = {}) + @serialized_results = [] + @result_codes = [] + @error_results = [] + @global_errors = [] + @options = options + @top_level_meta = @options.fetch(:base_meta, {}) + @top_level_links = @options.fetch(:base_links, {}) + @key_formatter = @options.fetch(:key_formatter, JSONAPI.configuration.key_formatter) end - def contents - hash = results_to_hash + def has_errors? + @error_results.length > 0 || @global_errors.length > 0 + end - meta = top_level_meta - hash.merge!(meta: meta) unless meta.empty? + def add_result(result, operation) + if result.is_a?(JSONAPI::ErrorsOperationResult) + # Clear any serialized results + @serialized_results = [] - links = top_level_links - hash.merge!(links: links) unless links.empty? + # In JSONAPI v1 we only have one operation so all errors can be kept together + result.errors.each do |error| + add_global_error(error) + end + else + @serialized_results.push result.to_hash(operation.options[:serializer]) + @result_codes.push result.code.to_i + update_links(operation.options[:serializer], result) + update_meta(result) + end + end - hash + def add_global_error(error) + @global_errors.push error end - def status - if @operation_results.has_errors? - @operation_results.all_errors[0].status + def contents + if has_errors? + return { 'errors' => @global_errors } else - @operation_results.results[0].code + hash = @serialized_results[0] + meta = top_level_meta + hash.merge!('meta' => meta) unless meta.empty? + + links = top_level_links + hash.merge!('links' => links) unless links.empty? + + return hash end end - private + def status + status_codes = if has_errors? + @global_errors.collect do |error| + error.status.to_i + end + else + @result_codes + end + + # Count the unique status codes + counts = status_codes.each_with_object(Hash.new(0)) { |code, counts| counts[code] += 1 } + + # if there is only one status code we can return that + return counts.keys[0].to_i if counts.length == 1 + + # if there are many we should return the highest general code, 200, 400, 500 etc. + max_status = 0 + status_codes.each do |status| + code = status.to_i + max_status = code if max_status < code + end + return (max_status / 100).floor * 100 + end - # Rolls up the top level meta data from the base_meta, the set of operations, - # and the result of each operation. The keys are then formatted. - def top_level_meta - meta = @options.fetch(:base_meta, {}) + # + # def status_sym + # Rack::Utils::HTTP_STATUS_CODES[status].downcase.gsub(/\s|-|'/, '_').to_sym + # end + + private - meta.merge!(@operation_results.meta) + def update_meta(result) + @top_level_meta.merge!(result.meta) - @operation_results.results.each do |result| - meta.merge!(result.meta) + if JSONAPI.configuration.top_level_meta_include_record_count && result.respond_to?(:record_count) + @top_level_meta[JSONAPI.configuration.top_level_meta_record_count_key] = result.record_count + end - if JSONAPI.configuration.top_level_meta_include_record_count && result.respond_to?(:record_count) - meta[JSONAPI.configuration.top_level_meta_record_count_key] = result.record_count - end + if JSONAPI.configuration.top_level_meta_include_page_count && result.respond_to?(:page_count) + @top_level_meta[JSONAPI.configuration.top_level_meta_page_count_key] = result.page_count + end - if JSONAPI.configuration.top_level_meta_include_page_count && result.respond_to?(:page_count) - meta[JSONAPI.configuration.top_level_meta_page_count_key] = result.page_count + if result.warnings.any? + @top_level_meta[:warnings] = result.warnings.collect do |warning| + warning.to_hash end end + end - meta.as_json.deep_transform_keys { |key| @key_formatter.format(key) } + def top_level_meta + @top_level_meta.as_json.deep_transform_keys { |key| @key_formatter.format(key) } end - # Rolls up the top level links from the base_links, the set of operations, - # and the result of each operation. The keys are then formatted. - def top_level_links - links = @options.fetch(:base_links, {}) - - links.merge!(@operation_results.links) - - @operation_results.results.each do |result| - links.merge!(result.links) - - # Build pagination links - if result.is_a?(JSONAPI::ResourcesOperationResult) || result.is_a?(JSONAPI::RelatedResourcesOperationResult) - result.pagination_params.each_pair do |link_name, params| - if result.is_a?(JSONAPI::RelatedResourcesOperationResult) - relationship = result.source_resource.class._relationships[result._type.to_sym] - links[link_name] = @serializer.link_builder.relationships_related_link(result.source_resource, relationship, query_params(params)) - else - links[link_name] = @serializer.query_link(query_params(params)) - end - end + def update_links(serializer, result) + @top_level_links.merge!(result.links) + + # Build pagination links + if result.is_a?(JSONAPI::ResourcesOperationResult) || result.is_a?(JSONAPI::RelatedResourcesOperationResult) + result.pagination_params.each_pair do |link_name, params| + if result.is_a?(JSONAPI::RelatedResourcesOperationResult) + relationship = result.source_resource.class._relationships[result._type.to_sym] + @top_level_links[link_name] = serializer.link_builder.relationships_related_link(result.source_resource, relationship, query_params(params)) + else + @top_level_links[link_name] = serializer.query_link(query_params(params)) + end end end + end - links.deep_transform_keys { |key| @key_formatter.format(key) } + def top_level_links + @top_level_links.deep_transform_keys { |key| @key_formatter.format(key) } end def query_params(params) @@ -96,40 +147,5 @@ def query_params(params) query_params end - - def results_to_hash - if @operation_results.has_errors? - { errors: @operation_results.all_errors } - else - if @operation_results.results.length == 1 - result = @operation_results.results[0] - - case result - when JSONAPI::ResourceOperationResult - @serializer.serialize_to_hash(result.resource) - when JSONAPI::ResourcesOperationResult - @serializer.serialize_to_hash(result.resources) - when JSONAPI::LinksObjectOperationResult - @serializer.serialize_to_links_hash(result.parent_resource, - result.relationship) - when JSONAPI::OperationResult - {} - end - - elsif @operation_results.results.length > 1 - resources = [] - @operation_results.results.each do |result| - case result - when JSONAPI::ResourceOperationResult - resources.push(result.resource) - when JSONAPI::ResourcesOperationResult - resources.concat(result.resources) - end - end - - @serializer.serialize_to_hash(resources) - end - end - end end end diff --git a/lib/jsonapi/routing_ext.rb b/lib/jsonapi/routing_ext.rb index 77af89e7a..facbc1c6f 100644 --- a/lib/jsonapi/routing_ext.rb +++ b/lib/jsonapi/routing_ext.rb @@ -33,8 +33,8 @@ def jsonapi_resource(*resources, &_block) end if res._immutable - options[:except] << :create unless options[:except].include?(:create) || options[:except].include?('create') - options[:except] << :update unless options[:except].include?(:update) || options[:except].include?('update') + options[:except] << :create unless options[:except].include?(:create) || options[:except].include?('create') + options[:except] << :update unless options[:except].include?(:update) || options[:except].include?('update') options[:except] << :destroy unless options[:except].include?(:destroy) || options[:except].include?('destroy') end @@ -102,8 +102,8 @@ def jsonapi_resources(*resources, &_block) end if res._immutable - options[:except] << :create unless options[:except].include?(:create) || options[:except].include?('create') - options[:except] << :update unless options[:except].include?(:update) || options[:except].include?('update') + options[:except] << :create unless options[:except].include?(:create) || options[:except].include?('create') + options[:except] << :update unless options[:except].include?(:update) || options[:except].include?('update') options[:except] << :destroy unless options[:except].include?(:destroy) || options[:except].include?('destroy') end @@ -154,18 +154,18 @@ def jsonapi_link(*links) if methods.include?(:show) match "relationships/#{formatted_relationship_name}", controller: options[:controller], - action: 'show_relationship', relationship: link_type.to_s, via: [:get] + action: 'show_relationship', relationship: link_type.to_s, via: [:get] end if res.mutable? if methods.include?(:update) match "relationships/#{formatted_relationship_name}", controller: options[:controller], - action: 'update_relationship', relationship: link_type.to_s, via: [:put, :patch] + action: 'update_relationship', relationship: link_type.to_s, via: [:put, :patch] end if methods.include?(:destroy) match "relationships/#{formatted_relationship_name}", controller: options[:controller], - action: 'destroy_relationship', relationship: link_type.to_s, via: [:delete] + action: 'destroy_relationship', relationship: link_type.to_s, via: [:delete] end end end @@ -182,23 +182,23 @@ def jsonapi_links(*links) if methods.include?(:show) match "relationships/#{formatted_relationship_name}", controller: options[:controller], - action: 'show_relationship', relationship: link_type.to_s, via: [:get] + action: 'show_relationship', relationship: link_type.to_s, via: [:get] end if res.mutable? if methods.include?(:create) match "relationships/#{formatted_relationship_name}", controller: options[:controller], - action: 'create_relationship', relationship: link_type.to_s, via: [:post] + action: 'create_relationship', relationship: link_type.to_s, via: [:post] end if methods.include?(:update) match "relationships/#{formatted_relationship_name}", controller: options[:controller], - action: 'update_relationship', relationship: link_type.to_s, via: [:put, :patch] + action: 'update_relationship', relationship: link_type.to_s, via: [:put, :patch] end if methods.include?(:destroy) match "relationships/#{formatted_relationship_name}", controller: options[:controller], - action: 'destroy_relationship', relationship: link_type.to_s, via: [:delete] + action: 'destroy_relationship', relationship: link_type.to_s, via: [:delete] end end end @@ -219,9 +219,9 @@ def jsonapi_related_resource(*relationship) options[:controller] ||= related_resource._type.to_s end - match "#{formatted_relationship_name}", controller: options[:controller], - relationship: relationship.name, source: resource_type_with_module_prefix(source._type), - action: 'get_related_resource', via: [:get] + match formatted_relationship_name, controller: options[:controller], + relationship: relationship.name, source: resource_type_with_module_prefix(source._type), + action: 'get_related_resource', via: [:get] end def jsonapi_related_resources(*relationship) @@ -235,9 +235,10 @@ def jsonapi_related_resources(*relationship) related_resource = JSONAPI::Resource.resource_for(resource_type_with_module_prefix(relationship.class_name.underscore)) options[:controller] ||= related_resource._type.to_s - match "#{formatted_relationship_name}", controller: options[:controller], - relationship: relationship.name, source: resource_type_with_module_prefix(source._type), - action: 'get_related_resources', via: [:get] + match formatted_relationship_name, + controller: options[:controller], + relationship: relationship.name, source: resource_type_with_module_prefix(source._type), + action: 'get_related_resources', via: [:get] end protected @@ -249,6 +250,7 @@ def jsonapi_resource_scope(resource, resource_type) #:nodoc: ensure @scope = @scope.parent end + # :nocov: private diff --git a/locales/en.yml b/locales/en.yml index 7a03182a6..d1dfccc01 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -50,12 +50,12 @@ en: title: 'Invalid field' detail: "%{field} is not a valid field for %{type}." invalid_include: - title: 'Invalid field' + title: 'Invalid include' detail: "%{relationship} is not a valid relationship of %{resource}" invalid_sort_criteria: title: 'Invalid sort criteria' detail: "%{sort_criteria} is not a valid sort criteria for %{resource}" - parameters_not_allowed: + parameter_not_allowed: title: 'Param not allowed' detail: "%{param} is not allowed." parameter_missing: diff --git a/test/controllers/controller_test.rb b/test/controllers/controller_test.rb index a55b1956b..82abc5863 100644 --- a/test/controllers/controller_test.rb +++ b/test/controllers/controller_test.rb @@ -1,7 +1,7 @@ require File.expand_path('../../test_helper', __FILE__) def set_content_type_header! - @request.headers['Content-Type'] = 'application/vnd.api+json' + @request.headers['Content-Type'] = JSONAPI::MEDIA_TYPE end class PostsControllerTest < ActionController::TestCase @@ -1656,8 +1656,8 @@ def test_update_missing_param title: 'A great new Post' }, relationships: { - section: {type: 'sections', id: "#{javascript.id}"}, - tags: [{type: 'tags', id: 3}, {type: 'tags', id: 4}] + section: { data: { type: 'sections', id: "#{javascript.id}" } }, + tags: { data: [{ type: 'tags', id: 3 }, { type: 'tags', id: 4 }] } } } } @@ -1698,8 +1698,8 @@ def test_update_missing_type title: 'A great new Post' }, relationships: { - section: {type: 'sections', id: "#{javascript.id}"}, - tags: [{type: 'tags', id: 3}, {type: 'tags', id: 4}] + section: { data: { type: 'sections', id: "#{javascript.id}" } }, + tags: { data: [{ type: 'tags', id: 3 }, { type: 'tags', id: 4 }] } } } } diff --git a/test/fixtures/active_record.rb b/test/fixtures/active_record.rb index e4b4ad5eb..69860232c 100644 --- a/test/fixtures/active_record.rb +++ b/test/fixtures/active_record.rb @@ -25,7 +25,7 @@ end create_table :posts, force: true do |t| - t.string :title + t.string :title, length: 255 t.text :body t.integer :author_id t.integer :parent_post_id @@ -677,15 +677,6 @@ class SerializeError < StandardError; end head :forbidden end - def handle_exceptions(e) - case e - when PostsController::SpecialError - raise e - else - super(e) - end - end - #called by test_on_server_error def self.set_callback_message(error) @callback_message = "Sent from method" diff --git a/test/fixtures/posts.yml b/test/fixtures/posts.yml index fa8947fc1..4cdf94503 100644 --- a/test/fixtures/posts.yml +++ b/test/fixtures/posts.yml @@ -99,4 +99,22 @@ post_17: id: 17 title: No Author!!!!!! body: This post has no Author - author_id: \ No newline at end of file + author_id: + +post_18: + id: 18 + title: Delete This later 18 + body: AAAA + author_id: 3 + +post_19: + id: 19 + title: Update Later - Operations + body: AAAA This should be updated + author_id: 3 + +post_20: + id: 20 + title: Update Later - Ops Multiple + body: AAAA This should also be updated + author_id: 3 diff --git a/test/unit/jsonapi_request/jsonapi_request_test.rb b/test/unit/jsonapi_request/jsonapi_request_test.rb index b96ffb1d1..bc7c4a3ee 100644 --- a/test/unit/jsonapi_request/jsonapi_request_test.rb +++ b/test/unit/jsonapi_request/jsonapi_request_test.rb @@ -32,6 +32,7 @@ def test_parse_includes_underscored } ) + request.parse_include_directives(ExpenseEntryResource, params[:include]) assert request.errors.empty? end @@ -52,6 +53,7 @@ def test_parse_dasherized_with_dasherized_include } ) + request.parse_include_directives(ExpenseEntryResource, params[:include]) assert request.errors.empty? end @@ -72,6 +74,7 @@ def test_parse_dasherized_with_underscored_include } ) + request.parse_include_directives(ExpenseEntryResource, params[:include]) refute request.errors.empty? assert_equal 'iso_currency is not a valid relationship of expense-entries', request.errors[0].detail end @@ -93,6 +96,7 @@ def test_parse_fields_underscored } ) + request.parse_fields(ExpenseEntryResource, params[:fields]) assert request.errors.empty? end @@ -115,6 +119,7 @@ def test_parse_dasherized_with_dasherized_fields } ) + request.parse_fields(ExpenseEntryResource, params[:fields]) assert request.errors.empty? end @@ -137,8 +142,11 @@ def test_parse_dasherized_with_underscored_fields } ) - refute request.errors.empty? - assert_equal 'iso_currency is not a valid field for expense-entries.', request.errors[0].detail + e = assert_raises JSONAPI::Exceptions::Errors do + request.parse_fields(ExpenseEntryResource, params[:fields]) + end + refute e.errors.empty? + assert_equal 'iso_currency is not a valid field for expense-entries.', e.errors[0].detail end def test_parse_dasherized_with_underscored_resource @@ -159,61 +167,60 @@ def test_parse_dasherized_with_underscored_resource key_formatter: JSONAPI::Formatter.formatter_for(:dasherized_key) } ) - - refute request.errors.empty? - assert_equal 'expense_entries is not a valid resource.', request.errors[0].detail + e = assert_raises JSONAPI::Exceptions::Errors do + request.parse_fields(ExpenseEntryResource, params[:fields]) + end + refute e.errors.empty? + assert_equal 'expense_entries is not a valid resource.', e.errors[0].detail end def test_parse_filters_with_valid_filters setup_request - @request.parse_filters({name: 'Whiskers'}) - assert_equal(@request.filters[:name], 'Whiskers') + filters = @request.parse_filters(CatResource, {name: 'Whiskers'}) + assert_equal(filters[:name], 'Whiskers') assert_equal(@request.errors, []) end def test_parse_filters_with_non_valid_filter setup_request - @request.parse_filters({breed: 'Whiskers'}) # breed is not a set filter - assert_equal(@request.filters, {}) + filters = @request.parse_filters(CatResource, {breed: 'Whiskers'}) # breed is not a set filter + assert_equal(filters, {}) assert_equal(@request.errors.count, 1) assert_equal(@request.errors.first.title, "Filter not allowed") end def test_parse_filters_with_no_filters setup_request - @request.parse_filters(nil) - assert_equal(@request.filters, {}) + filters = @request.parse_filters(CatResource, nil) + assert_equal(filters, {}) assert_equal(@request.errors, []) end def test_parse_filters_with_invalid_filters_param setup_request - @request.parse_filters('noeach') # String does not implement #each - assert_equal(@request.filters, {}) + filters = @request.parse_filters(CatResource, 'noeach') # String does not implement #each + assert_equal(filters, {}) assert_equal(@request.errors.count, 1) assert_equal(@request.errors.first.title, "Invalid filters syntax") end def test_parse_sort_with_valid_sorts setup_request - @request.parse_sort_criteria("-name") - assert_equal(@request.filters, {}) + sort_criteria = @request.parse_sort_criteria(CatResource, "-name") assert_equal(@request.errors, []) - assert_equal(@request.sort_criteria, [{:field=>"name", :direction=>:desc}]) + assert_equal(sort_criteria, [{:field=>"name", :direction=>:desc}]) end def test_parse_sort_with_relationships setup_request - @request.parse_sort_criteria("-mother.name") - assert_equal(@request.filters, {}) + sort_criteria = @request.parse_sort_criteria(CatResource, "-mother.name") assert_equal(@request.errors, []) - assert_equal(@request.sort_criteria, [{:field=>"mother.name", :direction=>:desc}]) + assert_equal(sort_criteria, [{:field=>"mother.name", :direction=>:desc}]) end private def setup_request @request = JSONAPI::RequestParser.new - @request.resource_klass = CatResource end end diff --git a/test/unit/operation/operation_dispatcher_test.rb b/test/unit/operation/operation_dispatcher_test.rb deleted file mode 100644 index f7816e583..000000000 --- a/test/unit/operation/operation_dispatcher_test.rb +++ /dev/null @@ -1,434 +0,0 @@ -require File.expand_path('../../../test_helper', __FILE__) - -class OperationDispatcherTest < Minitest::Test - def setup - betax = Planet.find(5) - betay = Planet.find(6) - betaz = Planet.find(7) - unknown = PlanetType.find(5) - end - - def test_create_single_resource - op = JSONAPI::OperationDispatcher.new - - count = Planet.count - - operations = [ - JSONAPI::Operation.new(:create_resource, PlanetResource, data: {attributes: {'name' => 'earth', 'description' => 'The best planet ever.'}}) - ] - - operation_results = op.process(operations) - - assert_kind_of(JSONAPI::OperationResults, operation_results) - assert_equal(:created, operation_results.results[0].code) - assert_equal(operation_results.results.size, 1) - assert_equal(Planet.count, count + 1) - end - - def test_create_multiple_resources - op = JSONAPI::OperationDispatcher.new - - count = Planet.count - - operations = [ - JSONAPI::Operation.new(:create_resource, PlanetResource, data: {attributes: {'name' => 'earth', 'description' => 'The best planet for life.'}}), - JSONAPI::Operation.new(:create_resource, PlanetResource, data: {attributes: {'name' => 'mars', 'description' => 'The red planet.'}}), - JSONAPI::Operation.new(:create_resource, PlanetResource, data: {attributes: {'name' => 'venus', 'description' => 'A very hot planet.'}}) - ] - - operation_results = op.process(operations) - - assert_kind_of(JSONAPI::OperationResults, operation_results) - assert_equal(operation_results.results.size, 3) - assert_equal(Planet.count, count + 3) - end - - def test_replace_to_one_relationship - op = JSONAPI::OperationDispatcher.new - - saturn = Planet.find(1) - gas_giant = PlanetType.find(1) - planetoid = PlanetType.find(2) - assert_equal(saturn.planet_type_id, planetoid.id) - - operations = [ - JSONAPI::Operation.new(:replace_to_one_relationship, - PlanetResource, - { - resource_id: saturn.id, - relationship_type: :planet_type, - key_value: gas_giant.id - } - ) - ] - - operation_results = op.process(operations) - - assert_kind_of(JSONAPI::OperationResults, operation_results) - assert_kind_of(JSONAPI::OperationResult, operation_results.results[0]) - assert_equal(:no_content, operation_results.results[0].code) - - saturn.reload - assert_equal(saturn.planet_type_id, gas_giant.id) - - # Remove link - operations = [ - JSONAPI::Operation.new(:replace_to_one_relationship, - PlanetResource, - { - resource_id: saturn.id, - relationship_type: :planet_type, - key_value: nil - } - ) - ] - - op.process(operations) - saturn.reload - assert_nil(saturn.planet_type_id) - - # Reset - operations = [ - JSONAPI::Operation.new(:replace_to_one_relationship, - PlanetResource, - { - resource_id: saturn.id, - relationship_type: :planet_type, - key_value: 5 - } - ) - ] - - op.process(operations) - saturn.reload - assert_equal(saturn.planet_type_id, 5) - end - - def test_create_to_many_relationships - op = JSONAPI::OperationDispatcher.new - - betax = Planet.find(5) - betay = Planet.find(6) - betaz = Planet.find(7) - gas_giant = PlanetType.find(1) - unknown = PlanetType.find(5) - betax.planet_type_id = unknown.id - betay.planet_type_id = unknown.id - betaz.planet_type_id = unknown.id - betax.save! - betay.save! - betaz.save! - - operations = [ - JSONAPI::Operation.new(:create_to_many_relationships, - PlanetTypeResource, - { - resource_id: gas_giant.id, - relationship_type: :planets, - data: [betax.id, betay.id, betaz.id] - } - ) - ] - - op.process(operations) - - betax.reload - betay.reload - betaz.reload - - assert_equal(betax.planet_type_id, gas_giant.id) - assert_equal(betay.planet_type_id, gas_giant.id) - assert_equal(betaz.planet_type_id, gas_giant.id) - - # Reset - betax.planet_type_id = unknown.id - betay.planet_type_id = unknown.id - betaz.planet_type_id = unknown.id - betax.save! - betay.save! - betaz.save! - end - - def test_replace_to_many_relationships - op = JSONAPI::OperationDispatcher.new - - betax = Planet.find(5) - betay = Planet.find(6) - betaz = Planet.find(7) - gas_giant = PlanetType.find(1) - unknown = PlanetType.find(5) - betax.planet_type_id = unknown.id - betay.planet_type_id = unknown.id - betaz.planet_type_id = unknown.id - betax.save! - betay.save! - betaz.save! - - operations = [ - JSONAPI::Operation.new(:replace_to_many_relationships, - PlanetTypeResource, - { - resource_id: gas_giant.id, - relationship_type: :planets, - data: [betax.id, betay.id, betaz.id] - } - ) - ] - - op.process(operations) - - betax.reload - betay.reload - betaz.reload - - assert_equal(betax.planet_type_id, gas_giant.id) - assert_equal(betay.planet_type_id, gas_giant.id) - assert_equal(betaz.planet_type_id, gas_giant.id) - - # Reset - betax.planet_type_id = unknown.id - betay.planet_type_id = unknown.id - betaz.planet_type_id = unknown.id - betax.save! - betay.save! - betaz.save! - end - - def test_replace_attributes - op = JSONAPI::OperationDispatcher.new - - count = Planet.count - saturn = Planet.find(1) - assert_equal(saturn.name, 'Satern') - - operations = [ - JSONAPI::Operation.new(:replace_fields, - PlanetResource, - { - resource_id: 1, - data: {attributes: {'name' => 'saturn'}} - } - ) - ] - - operation_results = op.process(operations) - - assert_kind_of(JSONAPI::OperationResults, operation_results) - assert_equal(operation_results.results.size, 1) - - assert_kind_of(JSONAPI::ResourceOperationResult, operation_results.results[0]) - assert_equal(:ok, operation_results.results[0].code) - - saturn = Planet.find(1) - - assert_equal(saturn.name, 'saturn') - - assert_equal(Planet.count, count) - end - - def test_remove_resource - op = JSONAPI::OperationDispatcher.new - - count = Planet.count - makemake = Planet.find(2) - assert_equal(makemake.name, 'Makemake') - - operations = [ - JSONAPI::Operation.new(:remove_resource, PlanetResource, resource_id: 2), - ] - - operation_results = op.process(operations) - - assert_kind_of(JSONAPI::OperationResults, operation_results) - assert_equal(operation_results.results.size, 1) - - assert_kind_of(JSONAPI::OperationResult, operation_results.results[0]) - assert_equal(:no_content, operation_results.results[0].code) - assert_equal(Planet.count, count - 1) - end - - def test_rollback_from_error - op = JSONAPI::OperationDispatcher.new(transaction: - lambda { |&block| - ActiveRecord::Base.transaction do - block.yield - end - }, - rollback: - lambda { - fail ActiveRecord::Rollback - } - ) - - count = Planet.count - - operations = [ - JSONAPI::Operation.new(:remove_resource, PlanetResource, resource_id: 3), - JSONAPI::Operation.new(:remove_resource, PlanetResource, resource_id: 4), - JSONAPI::Operation.new(:remove_resource, PlanetResource, resource_id: 4) - ] - - operation_results = op.process(operations) - - assert_kind_of(JSONAPI::OperationResults, operation_results) - - assert_equal(Planet.count, count) - - assert_equal(operation_results.results.size, 3) - - assert_kind_of(JSONAPI::OperationResult, operation_results.results[0]) - assert_equal(:no_content, operation_results.results[0].code) - assert_equal(:no_content, operation_results.results[1].code) - assert_equal('404', operation_results.results[2].code) - end - - def test_show_operation - op = JSONAPI::OperationDispatcher.new - - operations = [ - JSONAPI::Operation.new(:show, PlanetResource, {id: '1'}) - ] - - operation_results = op.process(operations) - - assert_kind_of(JSONAPI::OperationResults, operation_results) - assert_equal(operation_results.results.size, 1) - refute operation_results.has_errors? - end - - def test_show_operation_error - op = JSONAPI::OperationDispatcher.new - - operations = [ - JSONAPI::Operation.new(:show, PlanetResource, {id: '145'}) - ] - - operation_results = op.process(operations) - - assert_kind_of(JSONAPI::OperationResults, operation_results) - assert_equal(operation_results.results.size, 1) - assert operation_results.has_errors? - end - - def test_show_relationship_operation - op = JSONAPI::OperationDispatcher.new - - operations = [ - JSONAPI::Operation.new(:show_relationship, PlanetResource, {parent_key: '1', relationship_type: :planet_type}) - ] - - operation_results = op.process(operations) - - assert_kind_of(JSONAPI::OperationResults, operation_results) - assert_equal(operation_results.results.size, 1) - refute operation_results.has_errors? - end - - def test_show_relationship_operation_error - op = JSONAPI::OperationDispatcher.new - - operations = [ - JSONAPI::Operation.new(:show_relationship, PlanetResource, {parent_key: '145', relationship_type: :planet_type}) - ] - - operation_results = op.process(operations) - - assert_kind_of(JSONAPI::OperationResults, operation_results) - assert_equal(operation_results.results.size, 1) - assert operation_results.has_errors? - end - - def test_show_related_resource_operation - op = JSONAPI::OperationDispatcher.new - - operations = [ - JSONAPI::Operation.new(:show_related_resource, PlanetResource, - { - source_klass: PlanetResource, - source_id: '1', - relationship_type: :planet_type}) - ] - - operation_results = op.process(operations) - - assert_kind_of(JSONAPI::OperationResults, operation_results) - assert_equal(operation_results.results.size, 1) - refute operation_results.has_errors? - end - - def test_show_related_resource_operation_error - op = JSONAPI::OperationDispatcher.new - - operations = [ - JSONAPI::Operation.new(:show_related_resource, PlanetResource, - { - source_klass: PlanetResource, - source_id: '145', - relationship_type: :planet_type}) - ] - - operation_results = op.process(operations) - - assert_kind_of(JSONAPI::OperationResults, operation_results) - assert_equal(operation_results.results.size, 1) - assert operation_results.has_errors? - end - - def test_show_related_resources_operation - op = JSONAPI::OperationDispatcher.new - - operations = [ - JSONAPI::Operation.new(:show_related_resources, PlanetResource, - { - source_klass: PlanetResource, - source_id: '1', - relationship_type: :moons}) - ] - - operation_results = op.process(operations) - - assert_kind_of(JSONAPI::OperationResults, operation_results) - assert_equal(operation_results.results.size, 1) - refute operation_results.has_errors? - end - - def test_show_related_resources_operation_error - op = JSONAPI::OperationDispatcher.new - - operations = [ - JSONAPI::Operation.new(:show_related_resources, PlanetResource, - { - source_klass: PlanetResource, - source_id: '145', - relationship_type: :moons}) - ] - - operation_results = op.process(operations) - - assert_kind_of(JSONAPI::OperationResults, operation_results) - assert_equal(operation_results.results.size, 1) - assert operation_results.has_errors? - end - - def test_safe_run_callback_pass - op = JSONAPI::OperationDispatcher.new - error = StandardError.new - - check = false - callback = ->(error) { check = true } - - op.send(:safe_run_callback, callback, error) - assert check - end - - def test_safe_run_callback_catch_fail - op = JSONAPI::OperationDispatcher.new - error = StandardError.new - - callback = ->(error) { nil.explosions } - result = op.send(:safe_run_callback, callback, error) - - assert_instance_of(JSONAPI::ErrorsOperationResult, result) - assert_equal(result.code, '500') - end -end diff --git a/test/unit/serializer/response_document_test.rb b/test/unit/serializer/response_document_test.rb deleted file mode 100644 index a32727d3d..000000000 --- a/test/unit/serializer/response_document_test.rb +++ /dev/null @@ -1,56 +0,0 @@ -require File.expand_path('../../../test_helper', __FILE__) -require 'jsonapi-resources' -require 'json' - -class ResponseDocumentTest < ActionDispatch::IntegrationTest - def setup - JSONAPI.configuration.json_key_format = :dasherized_key - JSONAPI.configuration.route_format = :dasherized_route - end - - def create_response_document(operation_results, resource_klass) - JSONAPI::ResponseDocument.new( - operation_results, - JSONAPI::ResourceSerializer.new(resource_klass), - { - primary_resource_klass: resource_klass - } - ) - end - - def test_response_document - operations = [ - JSONAPI::Operation.new(:create_resource, PlanetResource, data: {attributes: {'name' => 'Earth 2.0'}}), - JSONAPI::Operation.new(:create_resource, PlanetResource, data: {attributes: {'name' => 'Vulcan'}}) - ] - - op = JSONAPI::OperationDispatcher.new() - operation_results = op.process(operations) - - response_doc = create_response_document(operation_results, PlanetResource) - - assert_equal :created, response_doc.status - contents = response_doc.contents - assert contents.is_a?(Hash) - assert contents[:data].is_a?(Array) - assert_equal 2, contents[:data].size - end - - def test_response_document_multiple_find - operations = [ - JSONAPI::Operation.new(:find, PostResource, filters: {id: '1'}), - JSONAPI::Operation.new(:find, PostResource, filters: {id: '2'}) - ] - - op = JSONAPI::OperationDispatcher.new() - operation_results = op.process(operations) - - response_doc = create_response_document(operation_results, PostResource) - - assert_equal :ok, response_doc.status - contents = response_doc.contents - assert contents.is_a?(Hash) - assert contents[:data].is_a?(Array) - assert_equal 2, contents[:data].size - end -end diff --git a/test/unit/serializer/serializer_test.rb b/test/unit/serializer/serializer_test.rb index 2686424e0..f546c7901 100644 --- a/test/unit/serializer/serializer_test.rb +++ b/test/unit/serializer/serializer_test.rb @@ -537,7 +537,7 @@ def test_serializer_keeps_sorted_order_of_objects_with_self_referential_relation ParentApi::PostResource, include: ['parent_post'], base_url: 'http://example.com').serialize_to_hash(ordered_posts.map {|p| ParentApi::PostResource.new(p, nil)} - )[:data] + )['data'] assert_equal(3, serialized_data.length) assert_equal("1", serialized_data[0]["id"]) @@ -1829,7 +1829,7 @@ def test_serialize_model_attr { "model" => "A model attribute" }, - serialized[:data]["attributes"] + serialized["data"]["attributes"] ) end @@ -1841,11 +1841,11 @@ def test_confusingly_named_attrs assert_hash_equals( { - :data=>{ + "data"=>{ "id"=>"#{@wp.id}", "type"=>"webPages", "links"=>{ - :self=>"/webPages/#{@wp.id}" + "self"=>"/webPages/#{@wp.id}" }, "attributes"=>{ "href"=>"http://example.com", @@ -1879,23 +1879,23 @@ class ::QuestionableResource < JSONAPI::Resource assert err.blank? assert_equal( { - :data=>{ + "data"=>{ "id"=>"1", "type"=>"questionables", "links"=>{ - :self=>"/questionables/1" + "self"=>"/questionables/1" }, "relationships"=>{ "link"=>{ - :links=>{ - :self=>"/questionables/1/relationships/link", - :related=>"/questionables/1/link" + "links"=>{ + "self"=>"/questionables/1/relationships/link", + "related"=>"/questionables/1/link" } }, "href"=>{ - :links=>{ - :self=>"/questionables/1/relationships/href", - :related=>"/questionables/1/href" + "links"=>{ + "self"=>"/questionables/1/relationships/href", + "related"=>"/questionables/1/href" } } } @@ -1928,23 +1928,23 @@ class ::Questionable2Resource < JSONAPI::Resource assert err.blank? assert_equal( { - :data=>{ + "data"=>{ "id"=>"1", "type"=>"questionable2s", "links"=>{ - :self=>"/questionable2s/1" + "self"=>"/questionable2s/1" }, "relationships"=>{ "links"=>{ - :links=>{ - :self=>"/questionable2s/1/relationships/links", - :related=>"/questionable2s/1/links" + "links"=>{ + "self"=>"/questionable2s/1/relationships/links", + "related"=>"/questionable2s/1/links" } }, "hrefs"=>{ - :links=>{ - :self=>"/questionable2s/1/relationships/hrefs", - :related=>"/questionable2s/1/hrefs" + "links"=>{ + "self"=>"/questionable2s/1/relationships/hrefs", + "related"=>"/questionable2s/1/hrefs" } } } From 280ccea0cd06762f443b4ff61edd9278388ce0e1 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Wed, 11 Jan 2017 14:36:24 -0500 Subject: [PATCH 021/237] Rework model_name in derived resources Fixes an issue where the recreated relationships might be using the wrong model_name if the model_name is changed in a resource that is derived from a non abstract resource, such as done in many of the tests. Breaking change: Derived resources now use the model name of their base resource, if it is not abstract. Cleans up the tests to not have the warnings about missing models. Fixes issue in _model_class related to #952, and renames @model (class level) to @model_class to avoid confusion with instance level @model Fixes #952, #654 --- lib/jsonapi/resource.rb | 50 +++++++++++----- test/fixtures/active_record.rb | 1 + .../jsonapi_request/jsonapi_request_test.rb | 2 +- test/unit/resource/resource_test.rb | 57 ++++++++++--------- 4 files changed, 67 insertions(+), 43 deletions(-) diff --git a/lib/jsonapi/resource.rb b/lib/jsonapi/resource.rb index 1cda2a20a..dfd642cb2 100644 --- a/lib/jsonapi/resource.rb +++ b/lib/jsonapi/resource.rb @@ -410,18 +410,15 @@ def inherited(subclass) 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 - if _relationships.is_a?(Hash) - _relationships.each_value do |relationship| - options = relationship.options.dup - options[:parent_resource] = subclass - subclass._add_relationship(relationship.class, relationship.name, options) - end + unless _model_name.empty? + 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 type = subclass.name.demodulize.sub(/Resource$/, '').underscore @@ -432,6 +429,20 @@ def inherited(subclass) check_reserved_resource_name(subclass._type, subclass.name) end + 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 + _add_relationship(relationship.class, relationship.name, options) + end + end + end + def resource_for(type) type = type.underscore type_with_module = type.include?('/') ? type : module_path + type @@ -554,6 +565,8 @@ def model_name(model, options = {}) @_model_name = model.to_sym model_hint(model: @_model_name, resource: self) unless options[:add_model_hint] == false + + rebuild_relationships(_relationships) end def model_hint(model: _model_name, resource: _type) @@ -908,10 +921,11 @@ def _model_name if _abstract return '' else - return @_model_name if defined?(@_model_name) + return @_model_name.to_s if defined?(@_model_name) class_name = self.name return '' if class_name.nil? - return @_model_name = class_name.demodulize.sub(/Resource$/, '') + @_model_name = class_name.demodulize.sub(/Resource$/, '') + return @_model_name.to_s end end @@ -982,11 +996,17 @@ def attribute_caching_context(context) def _model_class return nil if _abstract - 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 + return @model_class if @model_class + + model_name = _model_name + return nil if model_name.to_s.blank? + + @model_class = model_name.to_s.safe_constantize + if @model_class.nil? + warn "[MODEL NOT FOUND] Model could not be found for #{self.name}. If this a base Resource declare it as abstract." + end + + @model_class end def _allowed_filter?(filter) diff --git a/test/fixtures/active_record.rb b/test/fixtures/active_record.rb index 69860232c..18bda5794 100644 --- a/test/fixtures/active_record.rb +++ b/test/fixtures/active_record.rb @@ -1005,6 +1005,7 @@ class CompanyResource < JSONAPI::Resource end class FirmResource < CompanyResource + model_name "Firm" end class TagResource < JSONAPI::Resource diff --git a/test/unit/jsonapi_request/jsonapi_request_test.rb b/test/unit/jsonapi_request/jsonapi_request_test.rb index bc7c4a3ee..002be1927 100644 --- a/test/unit/jsonapi_request/jsonapi_request_test.rb +++ b/test/unit/jsonapi_request/jsonapi_request_test.rb @@ -4,7 +4,7 @@ class CatResource < JSONAPI::Resource attribute :name attribute :breed - belongs_to :mother, class_name: 'Cat' + has_one :mother, class_name: 'Cat' has_one :father, class_name: 'Cat' filters :name diff --git a/test/unit/resource/resource_test.rb b/test/unit/resource/resource_test.rb index a794ef8d5..01e921127 100644 --- a/test/unit/resource/resource_test.rb +++ b/test/unit/resource/resource_test.rb @@ -47,7 +47,9 @@ class NoMatchAbstractResource < JSONAPI::Resource abstract end -class CatResource < JSONAPI::Resource +class FelineResource < JSONAPI::Resource + model_name 'Cat' + attribute :name attribute :breed attribute :kind, :delegate => :breed @@ -99,6 +101,7 @@ class RelatedResource < MyModule::RelatedResource end class PostWithReadonlyAttributesResource < JSONAPI::Resource + model_name 'Post' attribute :title, readonly: true has_one :author, readonly: true end @@ -180,7 +183,7 @@ def test_derived_not_abstract def test_nil_model_class # ToDo:Figure out why this test does not work on Rails 4.0 # :nocov: - if Rails::VERSION::MAJOR >= 4 && Rails::VERSION::MINOR >= 1 + if (Rails::VERSION::MAJOR >= 4 && Rails::VERSION::MINOR >= 1) || (Rails::VERSION::MAJOR >= 5) assert_output nil, "[MODEL NOT FOUND] Model could not be found for NoMatchResource. If this a base Resource declare it as abstract.\n" do assert_nil NoMatchResource._model_class end @@ -199,13 +202,13 @@ def test_model_alternate end def test_class_attributes - attrs = CatResource._attributes + attrs = FelineResource._attributes assert_kind_of(Hash, attrs) assert_equal(attrs.keys.size, 4) end def test_class_relationships - relationships = CatResource._relationships + relationships = FelineResource._relationships assert_kind_of(Hash, relationships) assert_equal(relationships.size, 2) end @@ -219,16 +222,16 @@ def test_replace_polymorphic_to_one_link end def test_duplicate_relationship_name - assert_output nil, "[DUPLICATE RELATIONSHIP] `mother` has already been defined in CatResource.\n" do - CatResource.instance_eval do + assert_output nil, "[DUPLICATE RELATIONSHIP] `mother` has already been defined in FelineResource.\n" do + FelineResource.instance_eval do has_one :mother, class_name: 'Cat' end end end def test_duplicate_attribute_name - assert_output nil, "[DUPLICATE ATTRIBUTE] `name` has already been defined in CatResource.\n" do - CatResource.instance_eval do + assert_output nil, "[DUPLICATE ATTRIBUTE] `name` has already been defined in FelineResource.\n" do + FelineResource.instance_eval do attribute :name end end @@ -299,7 +302,7 @@ def test_find_by_key_with_customized_base_records end def test_updatable_fields_does_not_include_id - assert(!CatResource.updatable_fields.include?(:id)) + assert(!FelineResource.updatable_fields.include?(:id)) end def test_filter_on_to_many_relationship_id @@ -443,60 +446,60 @@ def apply_pagination(records, criteria, order_options) end def test_key_type_integer - CatResource.instance_eval do + FelineResource.instance_eval do key_type :integer end - assert CatResource.verify_key('45') - assert CatResource.verify_key(45) + assert FelineResource.verify_key('45') + assert FelineResource.verify_key(45) assert_raises JSONAPI::Exceptions::InvalidFieldValue do - CatResource.verify_key('45,345') + FelineResource.verify_key('45,345') end ensure - CatResource.instance_eval do + FelineResource.instance_eval do key_type nil end end def test_key_type_string - CatResource.instance_eval do + FelineResource.instance_eval do key_type :string end - assert CatResource.verify_key('45') - assert CatResource.verify_key(45) + assert FelineResource.verify_key('45') + assert FelineResource.verify_key(45) assert_raises JSONAPI::Exceptions::InvalidFieldValue do - CatResource.verify_key('45,345') + FelineResource.verify_key('45,345') end ensure - CatResource.instance_eval do + FelineResource.instance_eval do key_type nil end end def test_key_type_uuid - CatResource.instance_eval do + FelineResource.instance_eval do key_type :uuid end - assert CatResource.verify_key('f1a4d5f2-e77a-4d0a-acbb-ee0b98b3f6b5') + assert FelineResource.verify_key('f1a4d5f2-e77a-4d0a-acbb-ee0b98b3f6b5') assert_raises JSONAPI::Exceptions::InvalidFieldValue do - CatResource.verify_key('f1a-e77a-4d0a-acbb-ee0b98b3f6b5') + FelineResource.verify_key('f1a-e77a-4d0a-acbb-ee0b98b3f6b5') end ensure - CatResource.instance_eval do + FelineResource.instance_eval do key_type nil end end def test_key_type_proc - CatResource.instance_eval do + FelineResource.instance_eval do key_type -> (key, context) { return key if key.nil? if key.to_s.match(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/) @@ -507,14 +510,14 @@ def test_key_type_proc } end - assert CatResource.verify_key('f1a4d5f2-e77a-4d0a-acbb-ee0b98b3f6b5') + assert FelineResource.verify_key('f1a4d5f2-e77a-4d0a-acbb-ee0b98b3f6b5') assert_raises JSONAPI::Exceptions::InvalidFieldValue do - CatResource.verify_key('f1a-e77a-4d0a-acbb-ee0b98b3f6b5') + FelineResource.verify_key('f1a-e77a-4d0a-acbb-ee0b98b3f6b5') end ensure - CatResource.instance_eval do + FelineResource.instance_eval do key_type nil end end From e4462fe1e0e0a925a906e701530e15ddd5e1848d Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Fri, 13 Jan 2017 08:55:03 -0500 Subject: [PATCH 022/237] Updates Travis to use latest stable ruby versions --- .travis.yml | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/.travis.yml b/.travis.yml index ffd2d0481..3a2d03dd8 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,18 +1,19 @@ language: ruby sudo: false env: - - "RAILS_VERSION=4.2.6" + - "RAILS_VERSION=4.2.7" - "RAILS_VERSION=5.0.0" - "RAILS_VERSION=master" rvm: - - 2.1 - - 2.2.4 - - 2.3.0 + - 2.1.10 + - 2.2.6 + - 2.3.3 + - 2.4.0 matrix: exclude: - - rvm: 2.0 - env: "RAILS_VERSION=5.0.0" - - rvm: 2.1 + - rvm: 2.1.10 env: "RAILS_VERSION=5.0.0" + - rvm: 2.4.0 + env: "RAILS_VERSION=4.2.7" allow_failures: - env: "RAILS_VERSION=master" From 4f5133190c9ade0a09eb39c8bd9457a9245b87ce Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Thu, 19 Jan 2017 11:17:56 -0500 Subject: [PATCH 023/237] Default `id` to being a read only attribute Fixes #960 Potentially a breaking change for apps with guids --- lib/jsonapi/resource.rb | 4 +++- test/fixtures/active_record.rb | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/jsonapi/resource.rb b/lib/jsonapi/resource.rb index dfd642cb2..f2a81a6d4 100644 --- a/lib/jsonapi/resource.rb +++ b/lib/jsonapi/resource.rb @@ -424,7 +424,9 @@ def inherited(subclass) type = subclass.name.demodulize.sub(/Resource$/, '').underscore subclass._type = type.pluralize.to_sym - subclass.attribute :id, format: :id + unless subclass._attributes[:id] + subclass.attribute :id, format: :id, readonly: true + end check_reserved_resource_name(subclass._type, subclass.name) end diff --git a/test/fixtures/active_record.rb b/test/fixtures/active_record.rb index 18bda5794..8d0fe7f46 100644 --- a/test/fixtures/active_record.rb +++ b/test/fixtures/active_record.rb @@ -1111,7 +1111,7 @@ def self.updatable_fields(context) end def self.creatable_fields(context) - super(context) - [:subject, :id] + super(context) - [:subject] end def self.sortable_fields(context) @@ -1132,6 +1132,7 @@ class HairCutResource < JSONAPI::Resource class IsoCurrencyResource < JSONAPI::Resource attributes :name, :country_name, :minor_unit + attribute :id, format: :id, readonly: false filter :country_name From b444939c135183613792d89fef87d74839b20d83 Mon Sep 17 00:00:00 2001 From: Manuel Wiedenmann Date: Sun, 22 Jan 2017 11:43:45 +0800 Subject: [PATCH 024/237] Prevent error if using paginator :none The PR fixes a bug if you use `paginator :none` and have `config.top_level_meta_include_page_count` set to `true`. --- lib/jsonapi/processor.rb | 2 +- test/controllers/controller_test.rb | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/lib/jsonapi/processor.rb b/lib/jsonapi/processor.rb index f7ab30b19..dac2a2447 100644 --- a/lib/jsonapi/processor.rb +++ b/lib/jsonapi/processor.rb @@ -92,7 +92,7 @@ def find include_directives: include_directives) end - if (JSONAPI.configuration.top_level_meta_include_page_count && page_options[:record_count]) + if (JSONAPI.configuration.top_level_meta_include_page_count && paginator && page_options[:record_count]) page_options[:page_count] = paginator.calculate_page_count(page_options[:record_count]) end diff --git a/test/controllers/controller_test.rb b/test/controllers/controller_test.rb index 82abc5863..9bd1a2edc 100644 --- a/test/controllers/controller_test.rb +++ b/test/controllers/controller_test.rb @@ -2881,6 +2881,18 @@ def test_books_page_count_in_meta assert_equal 'Book 0', json_response['data'][0]['attributes']['title'] end + def test_books_no_page_count_in_meta_with_none_paginator + Api::V2::BookResource.paginator :none + JSONAPI.configuration.top_level_meta_include_page_count = true + assert_cacheable_get :index, params: {include: 'book-comments'} + JSONAPI.configuration.top_level_meta_include_page_count = false + + assert_response :success + assert_nil json_response['meta']['page-count'] + assert_equal 901, json_response['data'].size + assert_equal 'Book 0', json_response['data'][0]['attributes']['title'] + end + def test_books_record_count_in_meta_custom_name Api::V2::BookResource.paginator :offset JSONAPI.configuration.top_level_meta_include_record_count = true From 48eaa022fbc18d34a22d5da618c243594bc8fd34 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 22 Jan 2017 14:05:01 +0800 Subject: [PATCH 025/237] fix page count when paginator is none --- lib/jsonapi/processor.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/jsonapi/processor.rb b/lib/jsonapi/processor.rb index f7ab30b19..c057989fb 100644 --- a/lib/jsonapi/processor.rb +++ b/lib/jsonapi/processor.rb @@ -93,7 +93,7 @@ def find end if (JSONAPI.configuration.top_level_meta_include_page_count && page_options[:record_count]) - page_options[:page_count] = paginator.calculate_page_count(page_options[:record_count]) + page_options[:page_count] = paginator ? paginator.calculate_page_count(page_options[:record_count]) : 1 end if JSONAPI.configuration.top_level_links_include_pagination && paginator From 57fa0ae878039ae7f85606593d095658ba78c157 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Scott=20Gonz=C3=A1lez?= Date: Mon, 30 Jan 2017 21:29:08 -0500 Subject: [PATCH 026/237] Fix wording for missing model --- lib/jsonapi/resource.rb | 2 +- test/unit/resource/resource_test.rb | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/jsonapi/resource.rb b/lib/jsonapi/resource.rb index f2a81a6d4..0a5e15ff6 100644 --- a/lib/jsonapi/resource.rb +++ b/lib/jsonapi/resource.rb @@ -1005,7 +1005,7 @@ def _model_class @model_class = model_name.to_s.safe_constantize if @model_class.nil? - warn "[MODEL NOT FOUND] Model could not be found for #{self.name}. If this a base Resource declare it as abstract." + warn "[MODEL NOT FOUND] Model could not be found for #{self.name}. If this is a base Resource declare it as abstract." end @model_class diff --git a/test/unit/resource/resource_test.rb b/test/unit/resource/resource_test.rb index 01e921127..bdae5ee4a 100644 --- a/test/unit/resource/resource_test.rb +++ b/test/unit/resource/resource_test.rb @@ -184,7 +184,7 @@ def test_nil_model_class # ToDo:Figure out why this test does not work on Rails 4.0 # :nocov: if (Rails::VERSION::MAJOR >= 4 && Rails::VERSION::MINOR >= 1) || (Rails::VERSION::MAJOR >= 5) - assert_output nil, "[MODEL NOT FOUND] Model could not be found for NoMatchResource. If this a base Resource declare it as abstract.\n" do + assert_output nil, "[MODEL NOT FOUND] Model could not be found for NoMatchResource. If this is a base Resource declare it as abstract.\n" do assert_nil NoMatchResource._model_class end end @@ -593,7 +593,7 @@ class NoModelResource < JSONAPI::Resource NoModelResource._model_class CODE end - assert_match "[MODEL NOT FOUND] Model could not be found for ResourceTest::NoModelResource. If this a base Resource declare it as abstract.\n", err + assert_match "[MODEL NOT FOUND] Model could not be found for ResourceTest::NoModelResource. If this is a base Resource declare it as abstract.\n", err end def test_no_warning_when_abstract From 726b10240c49ca2a26e2867e0d0179dd9f5deb3d Mon Sep 17 00:00:00 2001 From: Benjamin Fleischer Date: Sun, 5 Feb 2017 20:09:26 -0600 Subject: [PATCH 027/237] Fix typos ``` go get -u github.com/client9/misspell/cmd/misspell misspell -w -error -source=text . ``` --- lib/jsonapi/resource.rb | 2 +- test/controllers/controller_test.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/jsonapi/resource.rb b/lib/jsonapi/resource.rb index f2a81a6d4..edb871d1e 100644 --- a/lib/jsonapi/resource.rb +++ b/lib/jsonapi/resource.rb @@ -656,7 +656,7 @@ def apply_sort(records, order_options, _context = {}) associations = _lookup_association_chain([records.model.to_s, *model_names]) joins_query = _build_joins([records.model, *associations]) - # _sorting is appended to avoid name clashes with manual joins eg. overriden filters + # _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 diff --git a/test/controllers/controller_test.rb b/test/controllers/controller_test.rb index 82abc5863..c1b350329 100644 --- a/test/controllers/controller_test.rb +++ b/test/controllers/controller_test.rb @@ -2488,7 +2488,7 @@ def test_destroy_relationship_has_and_belongs_to_many JSONAPI.configuration.use_relationship_reflection = false end - def test_destroy_relationship_has_and_belongs_to_many_refect + def test_destroy_relationship_has_and_belongs_to_many_reflect JSONAPI.configuration.use_relationship_reflection = true assert_equal 2, Book.find(2).authors.count From 4b93707d99c8376c9b383036157b7a07484e4f13 Mon Sep 17 00:00:00 2001 From: Hidde-Jan Jongsma Date: Tue, 7 Feb 2017 12:18:39 +0100 Subject: [PATCH 028/237] Filter nil values from include param --- lib/jsonapi/request_parser.rb | 2 +- test/controllers/controller_test.rb | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/jsonapi/request_parser.rb b/lib/jsonapi/request_parser.rb index 3e7388819..e1b64df5c 100644 --- a/lib/jsonapi/request_parser.rb +++ b/lib/jsonapi/request_parser.rb @@ -352,7 +352,7 @@ def parse_include_directives(resource_klass, raw_include) return if included_resources.nil? - result = included_resources.map do |included_resource| + result = included_resources.compact.map do |included_resource| check_include(resource_klass, included_resource.partition('.')) unformat_key(included_resource).to_s end diff --git a/test/controllers/controller_test.rb b/test/controllers/controller_test.rb index 82abc5863..cbef9f6d0 100644 --- a/test/controllers/controller_test.rb +++ b/test/controllers/controller_test.rb @@ -3629,6 +3629,11 @@ def test_complex_includes_base assert_response :success end + def test_complex_includes_filters_nil_includes + assert_cacheable_get :index, params: {include: ',,'} + assert_response :success + end + def test_complex_includes_two_level assert_cacheable_get :index, params: {include: 'things,things.user'} From 671d28fd6758093a27ed590bc975e5042e403215 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Fri, 10 Feb 2017 15:53:47 -0500 Subject: [PATCH 029/237] Record Accessors (#977) Removing AR specific code from Resource and moving to new RecordAccessor class. --- lib/jsonapi-resources.rb | 2 + lib/jsonapi/active_record_accessor.rb | 501 ++++++++++++++++++++++++ lib/jsonapi/cached_resource_fragment.rb | 2 +- lib/jsonapi/configuration.rb | 13 + lib/jsonapi/operation_result.rb | 10 + lib/jsonapi/processor.rb | 59 +-- lib/jsonapi/record_accessor.rb | 66 ++++ lib/jsonapi/relationship.rb | 2 +- lib/jsonapi/relationship_builder.rb | 167 -------- lib/jsonapi/request_parser.rb | 14 +- lib/jsonapi/resource.rb | 439 +++++---------------- lib/jsonapi/routing_ext.rb | 18 +- test/controllers/controller_test.rb | 4 +- test/fixtures/active_record.rb | 2 +- test/unit/resource/resource_test.rb | 86 +++- 15 files changed, 804 insertions(+), 581 deletions(-) create mode 100644 lib/jsonapi/active_record_accessor.rb create mode 100644 lib/jsonapi/record_accessor.rb delete mode 100644 lib/jsonapi/relationship_builder.rb diff --git a/lib/jsonapi-resources.rb b/lib/jsonapi-resources.rb index 194de869a..e16022ff6 100644 --- a/lib/jsonapi-resources.rb +++ b/lib/jsonapi-resources.rb @@ -24,3 +24,5 @@ require 'jsonapi/operation_result' require 'jsonapi/callbacks' require 'jsonapi/link_builder' +require 'jsonapi/record_accessor' +require 'jsonapi/active_record_accessor' diff --git a/lib/jsonapi/active_record_accessor.rb b/lib/jsonapi/active_record_accessor.rb new file mode 100644 index 000000000..a92b39b53 --- /dev/null +++ b/lib/jsonapi/active_record_accessor.rb @@ -0,0 +1,501 @@ +require 'jsonapi/record_accessor' + +module JSONAPI + class ActiveRecordAccessor < RecordAccessor + + # RecordAccessor methods + + def find_resource(filters, options = {}) + if options[:caching] && options[:caching][:cache_serializer_output] + find_serialized_with_caching(filters, options[:caching][:serializer], options) + else + _resource_klass.resources_for(find_records(filters, options), options[:context]) + end + end + + def find_resource_by_key(key, options = {}) + if options[:caching] && options[:caching][:cache_serializer_output] + find_by_key_serialized_with_caching(key, options[:caching][:serializer], options) + else + records = find_records({ _resource_klass._primary_key => key }, options.except(:paginator, :sort_criteria)) + model = records.first + fail JSONAPI::Exceptions::RecordNotFound.new(key) if model.nil? + _resource_klass.resource_for(model, options[:context]) + end + end + + def find_resources_by_keys(keys, options = {}) + records = records(options) + records = apply_includes(records, options) + records = records.where({ _resource_klass._primary_key => keys }) + + _resource_klass.resources_for(records, options[:context]) + end + + def find_count(filters, options = {}) + count_records(filter_records(filters, options)) + end + + def related_resource(resource, relationship_name, options = {}) + relationship = resource.class._relationships[relationship_name.to_sym] + + if relationship.polymorphic? + associated_model = records_for_relationship(resource, relationship_name, options) + resource_klass = resource.class.resource_klass_for_model(associated_model) if associated_model + return resource_klass.new(associated_model, resource.context) if resource_klass && associated_model + else + resource_klass = relationship.resource_klass + if resource_klass + associated_model = records_for_relationship(resource, relationship_name, options) + return associated_model ? resource_klass.new(associated_model, resource.context) : nil + end + end + end + + def related_resources(resource, relationship_name, options = {}) + relationship = resource.class._relationships[relationship_name.to_sym] + relationship_resource_klass = relationship.resource_klass + + if options[:caching] && options[:caching][:cache_serializer_output] + scope = relationship_resource_klass._record_accessor.records_for_relationship(resource, relationship_name, options) + relationship_resource_klass._record_accessor.find_serialized_with_caching(scope, options[:caching][:serializer], options) + else + records = records_for_relationship(resource, relationship_name, options) + return records.collect do |record| + klass = relationship.polymorphic? ? resource.class.resource_klass_for_model(record) : relationship_resource_klass + klass.new(record, resource.context) + end + end + end + + def count_for_relationship(resource, relationship_name, options = {}) + relationship = resource.class._relationships[relationship_name.to_sym] + + context = resource.context + + relation_name = relationship.relation_name(context: context) + records = records_for(resource, relation_name) + + resource_klass = relationship.resource_klass + + filters = options.fetch(:filters, {}) + unless filters.nil? || filters.empty? + records = resource_klass._record_accessor.apply_filters(records, filters, options) + end + + records.count(:all) + end + + def foreign_key(resource, relationship_name, options = {}) + relationship = resource.class._relationships[relationship_name.to_sym] + + if relationship.belongs_to? + resource._model.method(relationship.foreign_key).call + else + records = records_for_relationship(resource, relationship_name, options) + return nil if records.nil? + records.public_send(relationship.resource_klass._primary_key) + end + end + + def foreign_keys(resource, relationship_name, options = {}) + relationship = resource.class._relationships[relationship_name.to_sym] + + records = records_for_relationship(resource, relationship_name, options) + records.collect do |record| + record.public_send(relationship.resource_klass._primary_key) + end + end + + # protected-ish methods left public for tests and what not + + def find_serialized_with_caching(filters_or_source, serializer, options = {}) + if filters_or_source.is_a?(ActiveRecord::Relation) + return cached_resources_for(filters_or_source, serializer, options) + elsif _resource_klass._model_class.respond_to?(:all) && _resource_klass._model_class.respond_to?(:arel_table) + records = find_records(filters_or_source, options.except(:include_directives)) + return cached_resources_for(records, serializer, options) + else + # :nocov: + warn('Caching enabled on model that does not support ActiveRelation') + # :nocov: + end + end + + def find_by_key_serialized_with_caching(key, serializer, options = {}) + if _resource_klass._model_class.respond_to?(:all) && _resource_klass._model_class.respond_to?(:arel_table) + results = find_serialized_with_caching({ _resource_klass._primary_key => key }, serializer, options) + result = results.first + fail JSONAPI::Exceptions::RecordNotFound.new(key) if result.nil? + return result + else + # :nocov: + warn('Caching enabled on model that does not support ActiveRelation') + # :nocov: + end + end + + def records_for_relationship(resource, relationship_name, options = {}) + relationship = resource.class._relationships[relationship_name.to_sym] + + context = resource.context + + relation_name = relationship.relation_name(context: context) + records = records_for(resource, relation_name) + + resource_klass = relationship.resource_klass + + filters = options.fetch(:filters, {}) + unless filters.nil? || filters.empty? + records = resource_klass._record_accessor.apply_filters(records, filters, options) + end + + sort_criteria = options.fetch(:sort_criteria, {}) + unless sort_criteria.nil? || sort_criteria.empty? + order_options = relationship.resource_klass.construct_order_options(sort_criteria) + records = apply_sort(records, order_options, context) + end + + paginator = options[:paginator] + if paginator + records = apply_pagination(records, paginator, order_options) + end + + records + end + + # Implement self.records on the resource if you want to customize the relation for + # finder methods (find, find_by_key, find_serialized_with_caching) + def records(_options = {}) + if defined?(_resource_klass.records) + _resource_klass.records(_options) + else + _resource_klass._model_class.all + end + end + + # Implement records_for on the resource to customize how the associated records + # are fetched for a model. Particularly helpful for authorization. + def records_for(resource, relation_name) + if resource.respond_to?(:records_for) + return resource.records_for(relation_name) + end + + relationship = resource.class._relationships[relation_name] + + if relationship.is_a?(JSONAPI::Relationship::ToMany) + if resource.respond_to?(:"records_for_#{relation_name}") + return resource.method(:"records_for_#{relation_name}").call + end + else + if resource.respond_to?(:"record_for_#{relation_name}") + return resource.method(:"record_for_#{relation_name}").call + end + end + + resource._model.public_send(relation_name) + end + + def apply_includes(records, options = {}) + include_directives = options[:include_directives] + if include_directives + model_includes = resolve_relationship_names_to_relations(_resource_klass, include_directives.model_includes, options) + records = records.includes(model_includes) + end + + records + end + + def apply_pagination(records, paginator, order_options) + records = paginator.apply(records, order_options) if paginator + records + end + + def apply_sort(records, order_options, context = {}) + if defined?(_resource_klass.apply_sort) + _resource_klass.apply_sort(records, order_options, context) + else + if order_options.any? + order_options.each_pair do |field, direction| + if field.to_s.include?(".") + *model_names, column_name = field.split(".") + + associations = _lookup_association_chain([records.model.to_s, *model_names]) + joins_query = _build_joins([records.model, *associations]) + + # _sorting is appended to avoid name clashes with manual joins eg. overridden filters + order_by_query = "#{associations.last.name}_sorting.#{column_name} #{direction}" + records = records.joins(joins_query).order(order_by_query) + else + records = records.order(field => direction) + end + end + end + + records + end + end + + def _lookup_association_chain(model_names) + associations = [] + model_names.inject do |prev, current| + association = prev.classify.constantize.reflect_on_all_associations.detect do |assoc| + assoc.name.to_s.downcase == current.downcase + end + associations << association + association.class_name + end + + associations + end + + def _build_joins(associations) + joins = [] + + associations.inject do |prev, current| + joins << "LEFT JOIN #{current.table_name} AS #{current.name}_sorting ON #{current.name}_sorting.id = #{prev.table_name}.#{current.foreign_key}" + current + end + joins.join("\n") + end + + def apply_filter(records, filter, value, options = {}) + strategy = _resource_klass._allowed_filters.fetch(filter.to_sym, Hash.new)[:apply] + + if strategy + if strategy.is_a?(Symbol) || strategy.is_a?(String) + _resource_klass.send(strategy, records, value, options) + else + strategy.call(records, value, options) + end + else + records.where(filter => value) + end + end + + # Assumes ActiveRecord's counting. Override if you need a different counting method + def count_records(records) + records.count(:all) + end + + def resolve_relationship_names_to_relations(resource_klass, model_includes, options = {}) + case model_includes + when Array + return model_includes.map do |value| + resolve_relationship_names_to_relations(resource_klass, value, options) + end + when Hash + model_includes.keys.each do |key| + relationship = resource_klass._relationships[key] + value = model_includes[key] + model_includes.delete(key) + model_includes[relationship.relation_name(options)] = resolve_relationship_names_to_relations(relationship.resource_klass, value, options) + end + return model_includes + when Symbol + relationship = resource_klass._relationships[model_includes] + return relationship.relation_name(options) + end + end + + def apply_filters(records, filters, options = {}) + required_includes = [] + + if filters + filters.each do |filter, value| + if _resource_klass._relationships.include?(filter) + if _resource_klass._relationships[filter].belongs_to? + records = apply_filter(records, _resource_klass._relationships[filter].foreign_key, value, options) + else + required_includes.push(filter.to_s) + records = apply_filter(records, "#{_resource_klass._relationships[filter].table_name}.#{_resource_klass._relationships[filter].primary_key}", value, options) + end + else + records = apply_filter(records, filter, value, options) + end + end + end + + if required_includes.any? + records = apply_includes(records, options.merge(include_directives: IncludeDirectives.new(_resource_klass, required_includes, force_eager_load: true))) + end + + records + end + + def filter_records(filters, options, records = records(options)) + records = apply_filters(records, filters, options) + apply_includes(records, options) + end + + def sort_records(records, order_options, context = {}) + apply_sort(records, order_options, context) + end + + def cached_resources_for(records, serializer, options) + if _resource_klass.caching? + t = _resource_klass._model_class.arel_table + cache_ids = pluck_arel_attributes(records, t[_resource_klass._primary_key], t[_resource_klass._cache_field]) + resources = CachedResourceFragment.fetch_fragments(_resource_klass, serializer, options[:context], cache_ids) + else + resources = _resource_klass.resources_for(records, options[:context]).map { |r| [r.id, r] }.to_h + end + + preload_included_fragments(resources, records, serializer, options) + + resources.values + end + + def find_records(filters, options = {}) + if defined?(_resource_klass.find_records) + ActiveSupport::Deprecation.warn "In #{_resource_klass.name} you overrode `find_records`. "\ + "`find_records` has been deprecated in favor of using `apply` "\ + "and `verify` callables on the filter." + + _resource_klass.find_records(filters, options) + else + context = options[:context] + + records = filter_records(filters, options) + + sort_criteria = options.fetch(:sort_criteria) { [] } + order_options = _resource_klass.construct_order_options(sort_criteria) + records = sort_records(records, order_options, context) + + records = apply_pagination(records, options[:paginator], order_options) + + records + end + end + + def preload_included_fragments(resources, records, serializer, options) + return if resources.empty? + res_ids = resources.keys + + include_directives = options[:include_directives] + return unless include_directives + + context = options[:context] + + # For each association, including indirect associations, find the target record ids. + # Even if a target class doesn't have caching enabled, we still have to look up + # and match the target ids here, because we can't use ActiveRecord#includes. + # + # Note that `paths` returns partial paths before complete paths, so e.g. the partial + # fragments for posts.comments will exist before we start working with posts.comments.author + target_resources = {} + include_directives.paths.each do |path| + # If path is [:posts, :comments, :author], then... + pluck_attrs = [] # ...will be [posts.id, comments.id, authors.id, authors.updated_at] + pluck_attrs << _resource_klass._model_class.arel_table[_resource_klass._primary_key] + + relation = records + .except(:limit, :offset, :order) + .where({ _resource_klass._primary_key => res_ids }) + + # These are updated as we iterate through the association path; afterwards they will + # refer to the final resource on the path, i.e. the actual resource to find in the cache. + # So e.g. if path is [:posts, :comments, :author], then after iteration... + parent_klass = nil # Comment + klass = _resource_klass # Person + relationship = nil # JSONAPI::Relationship::ToOne for CommentResource.author + table = nil # people + assocs_path = [] # [ :posts, :approved_comments, :author ] + ar_hash = nil # { :posts => { :approved_comments => :author } } + + # For each step on the path, figure out what the actual table name/alias in the join + # will be, and include the primary key of that table in our list of fields to select + non_polymorphic = true + path.each do |elem| + relationship = klass._relationships[elem] + if relationship.polymorphic + # Can't preload through a polymorphic belongs_to association, ResourceSerializer + # will just have to bypass the cache and load the real Resource. + non_polymorphic = false + break + end + assocs_path << relationship.relation_name(options).to_sym + # Converts [:a, :b, :c] to Rails-style { :a => { :b => :c }} + ar_hash = assocs_path.reverse.reduce { |memo, step| { step => memo } } + # We can't just look up the table name from the resource class, because Arel could + # have used a table alias if the relation includes a self-reference. + join_source = relation.joins(ar_hash).arel.source.right.reverse.find do |arel_node| + arel_node.is_a?(Arel::Nodes::InnerJoin) + end + table = join_source.left + parent_klass = klass + klass = relationship.resource_klass + pluck_attrs << table[klass._primary_key] + end + next unless non_polymorphic + + # Pre-fill empty hashes for each resource up to the end of the path. + # This allows us to later distinguish between a preload that returned nothing + # vs. a preload that never ran. + prefilling_resources = resources.values + path.each do |rel_name| + rel_name = serializer.key_formatter.format(rel_name) + prefilling_resources.map! do |res| + res.preloaded_fragments[rel_name] ||= {} + res.preloaded_fragments[rel_name].values + end + prefilling_resources.flatten!(1) + end + + pluck_attrs << table[klass._cache_field] if klass.caching? + relation = relation.joins(ar_hash) + if relationship.is_a?(JSONAPI::Relationship::ToMany) + # Rails doesn't include order clauses in `joins`, so we have to add that manually here. + # FIXME Should find a better way to reflect on relationship ordering. :-( + relation = relation.order(parent_klass._model_class.new.send(assocs_path.last).arel.orders) + end + + # [[post id, comment id, author id, author updated_at], ...] + id_rows = pluck_arel_attributes(relation.joins(ar_hash), *pluck_attrs) + + target_resources[klass.name] ||= {} + + if klass.caching? + sub_cache_ids = id_rows + .map { |row| row.last(2) } + .reject { |row| target_resources[klass.name].has_key?(row.first) } + .uniq + target_resources[klass.name].merge! CachedResourceFragment.fetch_fragments( + klass, serializer, context, sub_cache_ids + ) + else + sub_res_ids = id_rows + .map(&:last) + .reject { |id| target_resources[klass.name].has_key?(id) } + .uniq + found = klass.find({ klass._primary_key => sub_res_ids }, context: options[:context]) + target_resources[klass.name].merge! found.map { |r| [r.id, r] }.to_h + end + + id_rows.each do |row| + res = resources[row.first] + path.each_with_index do |rel_name, index| + rel_name = serializer.key_formatter.format(rel_name) + rel_id = row[index+1] + assoc_rels = res.preloaded_fragments[rel_name] + if index == path.length - 1 + assoc_rels[rel_id] = target_resources[klass.name].fetch(rel_id) + else + res = assoc_rels[rel_id] + end + end + end + end + end + + def pluck_arel_attributes(relation, *attrs) + conn = relation.connection + quoted_attrs = attrs.map do |attr| + quoted_table = conn.quote_table_name(attr.relation.table_alias || attr.relation.name) + quoted_column = conn.quote_column_name(attr.name) + "#{quoted_table}.#{quoted_column}" + end + relation.pluck(*quoted_attrs) + end + end +end diff --git a/lib/jsonapi/cached_resource_fragment.rb b/lib/jsonapi/cached_resource_fragment.rb index 67df1d9a5..a8ed301b2 100644 --- a/lib/jsonapi/cached_resource_fragment.rb +++ b/lib/jsonapi/cached_resource_fragment.rb @@ -65,7 +65,7 @@ def to_cache_value end def to_real_resource - rs = Resource.resource_for(self.type).find_by_keys([self.id], {context: self.context}) + rs = Resource.resource_klass_for(self.type).find_by_keys([self.id], {context: self.context}) return rs.try(:first) end diff --git a/lib/jsonapi/configuration.rb b/lib/jsonapi/configuration.rb index 4601bb351..52c34ab81 100644 --- a/lib/jsonapi/configuration.rb +++ b/lib/jsonapi/configuration.rb @@ -1,5 +1,7 @@ require 'jsonapi/formatter' require 'jsonapi/processor' +require 'jsonapi/record_accessor' +require 'jsonapi/active_record_accessor' require 'concurrent' module JSONAPI @@ -14,6 +16,7 @@ class Configuration :default_paginator, :default_page_size, :maximum_page_size, + :default_record_accessor_klass, :default_processor_klass, :use_text_errors, :top_level_links_include_pagination, @@ -92,6 +95,12 @@ def initialize self.always_include_to_one_linkage_data = false self.always_include_to_many_linkage_data = false + # Record Accessor + # The default Record Accessor is the ActiveRecordAccessor which provides + # caching access to ActiveRecord backed models. Custom Accessors can be specified + # in order to support other models. + self.default_record_accessor_klass = JSONAPI::ActiveRecordAccessor + # The default Operation Processor to use if one is not defined specifically # for a Resource. self.default_processor_klass = JSONAPI::Processor @@ -201,6 +210,10 @@ def default_processor_klass=(default_processor_klass) @default_processor_klass = default_processor_klass end + def default_record_accessor_klass=(default_record_accessor_klass) + @default_record_accessor_klass = default_record_accessor_klass + end + attr_writer :allow_include, :allow_sort, :allow_filter attr_writer :default_paginator diff --git a/lib/jsonapi/operation_result.rb b/lib/jsonapi/operation_result.rb index eed2916d4..3ea7f892f 100644 --- a/lib/jsonapi/operation_result.rb +++ b/lib/jsonapi/operation_result.rb @@ -30,7 +30,9 @@ def initialize(code, errors, options = {}) def to_hash(serializer = nil) { errors: errors.collect do |error| + # :nocov: error.to_hash + # :nocov: end } end @@ -48,7 +50,9 @@ def to_hash(serializer = nil) if serializer serializer.serialize_to_hash(resource) else + # :nocov: {} + # :nocov: end end end @@ -68,7 +72,9 @@ def to_hash(serializer) if serializer serializer.serialize_to_hash(resources) else + # :nocov: {} + # :nocov: end end end @@ -86,7 +92,9 @@ def to_hash(serializer = nil) if serializer serializer.serialize_to_hash(resources) else + # :nocov: {} + # :nocov: end end end @@ -104,7 +112,9 @@ def to_hash(serializer = nil) if serializer serializer.serialize_to_links_hash(parent_resource, relationship) else + # :nocov: {} + # :nocov: end end end diff --git a/lib/jsonapi/processor.rb b/lib/jsonapi/processor.rb index c057989fb..e4279bfd1 100644 --- a/lib/jsonapi/processor.rb +++ b/lib/jsonapi/processor.rb @@ -73,16 +73,14 @@ def find include_directives: include_directives, sort_criteria: sort_criteria, paginator: paginator, - fields: fields + fields: fields, + caching: { + cache_serializer_output: params[:cache_serializer_output], + serializer: params[:serializer] + } } - resource_records = if params[:cache_serializer_output] - resource_klass.find_serialized_with_caching(verified_filters, - params[:serializer], - find_options) - else - resource_klass.find(verified_filters, find_options) - end + resources = resource_klass.find(verified_filters, find_options) page_options = result_options if (JSONAPI.configuration.top_level_meta_include_record_count || @@ -100,7 +98,7 @@ def find page_options[:pagination_params] = paginator.links_page_params(page_options) end - return JSONAPI::ResourcesOperationResult.new(:ok, resource_records, page_options) + return JSONAPI::ResourcesOperationResult.new(:ok, resources, page_options) end def show @@ -113,18 +111,16 @@ def show find_options = { context: context, include_directives: include_directives, - fields: fields + fields: fields, + caching: { + cache_serializer_output: params[:cache_serializer_output], + serializer: params[:serializer] + } } - resource_record = if params[:cache_serializer_output] - resource_klass.find_by_key_serialized_with_caching(key, - params[:serializer], - find_options) - else - resource_klass.find_by_key(key, find_options) - end + resource = resource_klass.find_by_key(key, find_options) - return JSONAPI::ResourceOperationResult.new(:ok, resource_record, result_options) + return JSONAPI::ResourceOperationResult.new(:ok, resource, result_options) end def show_relationship @@ -171,32 +167,19 @@ def show_related_resources paginator: paginator, fields: fields, context: context, - include_directives: include_directives + include_directives: include_directives, + caching: { + cache_serializer_output: params[:cache_serializer_output], + serializer: params[:serializer] + } } - if params[:cache_serializer_output] - # TODO Could also avoid instantiating source_resource as actual Resource by - # allowing LinkBuilder to accept CachedResourceFragment as source in - # relationships_related_link - scope = source_resource.public_send(:"records_for_#{relationship_type}", rel_opts) - relationship = source_klass._relationship(relationship_type) - related_resources = relationship.resource_klass.find_serialized_with_caching( - scope, - params[:serializer], - rel_opts - ) - else - related_resources = source_resource.public_send(relationship_type, rel_opts) - end + related_resources = source_resource.public_send(relationship_type, rel_opts) if ((JSONAPI.configuration.top_level_meta_include_record_count) || (paginator && paginator.class.requires_record_count) || (JSONAPI.configuration.top_level_meta_include_page_count)) - related_resource_records = source_resource.public_send("records_for_" + relationship_type) - records = resource_klass.filter_records(filters, { context: context }, - related_resource_records) - - record_count = resource_klass.count_records(records) + record_count = source_resource.count_for_relationship(relationship_type, rel_opts) end if (JSONAPI.configuration.top_level_meta_include_page_count && record_count) diff --git a/lib/jsonapi/record_accessor.rb b/lib/jsonapi/record_accessor.rb new file mode 100644 index 000000000..3cf39ee99 --- /dev/null +++ b/lib/jsonapi/record_accessor.rb @@ -0,0 +1,66 @@ +module JSONAPI + class RecordAccessor + attr_reader :_resource_klass + + def initialize(resource_klass) + @_resource_klass = resource_klass + end + + # Resource records + def find_resource(_filters, _options = {}) + # :nocov: + raise 'Abstract method called' + # :nocov: + end + + def find_resource_by_key(_key, options = {}) + # :nocov: + raise 'Abstract method called' + # :nocov: + end + + def find_resources_by_keys(_keys, options = {}) + # :nocov: + raise 'Abstract method called' + # :nocov: + end + + def find_count(_filters, _options = {}) + # :nocov: + raise 'Abstract method called' + # :nocov: + end + + # Relationship records + def related_resource(_resource, _relationship_name, _options = {}) + # :nocov: + raise 'Abstract method called' + # :nocov: + end + + def related_resources(_resource, _relationship_name, _options = {}) + # :nocov: + raise 'Abstract method called' + # :nocov: + end + + def count_for_relationship(_resource, _relationship_name, _options = {}) + # :nocov: + raise 'Abstract method called' + # :nocov: + end + + # Keys + def foreign_key(_resource, _relationship_name, options = {}) + # :nocov: + raise 'Abstract method called' + # :nocov: + end + + def foreign_keys(_resource, _relationship_name, _options = {}) + # :nocov: + raise 'Abstract method called' + # :nocov: + end + end +end \ No newline at end of file diff --git a/lib/jsonapi/relationship.rb b/lib/jsonapi/relationship.rb index 978d1606e..b56bfaa01 100644 --- a/lib/jsonapi/relationship.rb +++ b/lib/jsonapi/relationship.rb @@ -23,7 +23,7 @@ def primary_key end def resource_klass - @resource_klass ||= @parent_resource.resource_for(@class_name) + @resource_klass ||= @parent_resource.resource_klass_for(@class_name) end def table_name diff --git a/lib/jsonapi/relationship_builder.rb b/lib/jsonapi/relationship_builder.rb deleted file mode 100644 index 9c7364d2f..000000000 --- a/lib/jsonapi/relationship_builder.rb +++ /dev/null @@ -1,167 +0,0 @@ -module JSONAPI - class RelationshipBuilder - attr_reader :model_class, :options, :relationship_class - delegate :register_relationship, to: :@resource_class - - def initialize(relationship_class, model_class, options) - @relationship_class = relationship_class - @model_class = model_class - @resource_class = options[:parent_resource] - @options = options - end - - def define_relationship_methods(relationship_name) - # Initialize from an ActiveRecord model's properties - if model_class && model_class.ancestors.collect{|ancestor| ancestor.name}.include?('ActiveRecord::Base') - model_association = model_class.reflect_on_association(relationship_name) - if model_association - options[:class_name] ||= model_association.class_name - end - end - - relationship = register_relationship( - relationship_name, - relationship_class.new(relationship_name, options) - ) - - foreign_key = define_foreign_key_setter(relationship.foreign_key) - - case relationship - when JSONAPI::Relationship::ToOne - associated = define_resource_relationship_accessor(:one, relationship_name) - args = [relationship, foreign_key, associated, relationship_name] - - relationship.belongs_to? ? build_belongs_to(*args) : build_has_one(*args) - when JSONAPI::Relationship::ToMany - associated = define_resource_relationship_accessor(:many, relationship_name) - - build_to_many(relationship, foreign_key, associated, relationship_name) - end - end - - def define_foreign_key_setter(foreign_key) - define_on_resource "#{foreign_key}=" do |value| - @model.method("#{foreign_key}=").call(value) - end - foreign_key - end - - def define_resource_relationship_accessor(type, relationship_name) - associated_records_method_name = { - one: "record_for_#{relationship_name}", - many: "records_for_#{relationship_name}" - } - .fetch(type) - - define_on_resource associated_records_method_name do |options = {}| - relationship = self.class._relationships[relationship_name] - relation_name = relationship.relation_name(context: @context) - records = records_for(relation_name) - - resource_klass = relationship.resource_klass - - filters = options.fetch(:filters, {}) - unless filters.nil? || filters.empty? - records = resource_klass.apply_filters(records, filters, options) - end - - sort_criteria = options.fetch(:sort_criteria, {}) - unless sort_criteria.nil? || sort_criteria.empty? - order_options = relationship.resource_klass.construct_order_options(sort_criteria) - records = resource_klass.apply_sort(records, order_options, @context) - end - - paginator = options[:paginator] - if paginator - records = resource_klass.apply_pagination(records, paginator, order_options) - end - - records - end - - associated_records_method_name - end - - def build_belongs_to(relationship, foreign_key, associated_records_method_name, relationship_name) - # Calls method matching foreign key name on model instance - define_on_resource foreign_key do - @model.method(foreign_key).call - end - - # Returns instantiated related resource object or nil - define_on_resource relationship_name do |options = {}| - relationship = self.class._relationships[relationship_name] - - if relationship.polymorphic? - associated_model = public_send(associated_records_method_name) - resource_klass = self.class.resource_for_model(associated_model) if associated_model - return resource_klass.new(associated_model, @context) if resource_klass - else - resource_klass = relationship.resource_klass - if resource_klass - associated_model = public_send(associated_records_method_name) - return associated_model ? resource_klass.new(associated_model, @context) : nil - end - end - end - end - - def build_has_one(relationship, foreign_key, associated_records_method_name, relationship_name) - # Returns primary key name of related resource class - define_on_resource foreign_key do - relationship = self.class._relationships[relationship_name] - - record = public_send(associated_records_method_name) - return nil if record.nil? - record.public_send(relationship.resource_klass._primary_key) - end - - # Returns instantiated related resource object or nil - define_on_resource relationship_name do |options = {}| - relationship = self.class._relationships[relationship_name] - - if relationship.polymorphic? - associated_model = public_send(associated_records_method_name) - resource_klass = self.class.resource_for_model(associated_model) if associated_model - return resource_klass.new(associated_model, @context) if resource_klass && associated_model - else - resource_klass = relationship.resource_klass - if resource_klass - associated_model = public_send(associated_records_method_name) - return associated_model ? resource_klass.new(associated_model, @context) : nil - end - end - end - end - - def build_to_many(relationship, foreign_key, associated_records_method_name, relationship_name) - # Returns array of primary keys of related resource classes - define_on_resource foreign_key do - records = public_send(associated_records_method_name) - return records.collect do |record| - record.public_send(relationship.resource_klass._primary_key) - end - end - - # Returns array of instantiated related resource objects - define_on_resource relationship_name do |options = {}| - relationship = self.class._relationships[relationship_name] - - resource_klass = relationship.resource_klass - records = public_send(associated_records_method_name, options) - - return records.collect do |record| - if relationship.polymorphic? - resource_klass = self.class.resource_for_model(record) - end - resource_klass.new(record, @context) - end - end - end - - def define_on_resource(method_name, &block) - return if @resource_class.method_defined?(method_name) - @resource_class.inject_method_definition(method_name, block) - end - end -end diff --git a/lib/jsonapi/request_parser.rb b/lib/jsonapi/request_parser.rb index e1b64df5c..14d2ca487 100644 --- a/lib/jsonapi/request_parser.rb +++ b/lib/jsonapi/request_parser.rb @@ -47,7 +47,7 @@ def transactional? def setup_base_op(params) return if params.nil? - resource_klass = Resource.resource_for(params[:controller]) if params[:controller] + resource_klass = Resource.resource_klass_for(params[:controller]) if params[:controller] setup_action_method_name = "setup_#{params[:action]}_action" if respond_to?(setup_action_method_name) @@ -81,7 +81,7 @@ def setup_index_action(params, resource_klass) end def setup_get_related_resource_action(params, resource_klass) - source_klass = Resource.resource_for(params.require(:source)) + source_klass = Resource.resource_klass_for(params.require(:source)) source_id = source_klass.verify_key(params.require(source_klass._as_parent_key), @context) fields = parse_fields(resource_klass, params[:fields]) @@ -102,7 +102,7 @@ def setup_get_related_resource_action(params, resource_klass) end def setup_get_related_resources_action(params, resource_klass) - source_klass = Resource.resource_for(params.require(:source)) + source_klass = Resource.resource_klass_for(params.require(:source)) source_id = source_klass.verify_key(params.require(source_klass._as_parent_key), @context) fields = parse_fields(resource_klass, params[:fields]) @@ -291,7 +291,7 @@ def parse_fields(resource_klass, fields) if type != format_key(type) fail JSONAPI::Exceptions::InvalidResource.new(type, error_object_overrides) end - type_resource = Resource.resource_for(resource_klass.module_path + underscored_type.to_s) + type_resource = Resource.resource_klass_for(resource_klass.module_path + underscored_type.to_s) rescue NameError errors.concat(JSONAPI::Exceptions::InvalidResource.new(type, error_object_overrides).errors) rescue JSONAPI::Exceptions::InvalidResource => e @@ -327,7 +327,7 @@ def check_include(resource_klass, include_parts) relationship = resource_klass._relationship(relationship_name) if relationship && format_key(relationship_name) == include_parts.first unless include_parts.last.empty? - check_include(Resource.resource_for(resource_klass.module_path + relationship.class_name.to_s.underscore), + check_include(Resource.resource_klass_for(resource_klass.module_path + relationship.class_name.to_s.underscore), include_parts.last.partition('.')) end else @@ -530,7 +530,7 @@ def parse_to_one_relationship(resource_klass, link_value, relationship) unless links_object[:id].nil? resource = resource_klass || Resource - relationship_resource = resource.resource_for(unformat_key(links_object[:type]).to_s) + relationship_resource = resource.resource_klass_for(unformat_key(links_object[:type]).to_s) relationship_id = relationship_resource.verify_key(links_object[:id], @context) if relationship.polymorphic? { id: relationship_id, type: unformat_key(links_object[:type].to_s) } @@ -565,7 +565,7 @@ def parse_to_many_relationship(resource_klass, link_value, relationship, &add_re end links_object.each_pair do |type, keys| - relationship_resource = Resource.resource_for(resource_klass.module_path + unformat_key(type).to_s) + relationship_resource = Resource.resource_klass_for(resource_klass.module_path + unformat_key(type).to_s) add_result.call relationship_resource.verify_keys(keys, @context) end end diff --git a/lib/jsonapi/resource.rb b/lib/jsonapi/resource.rb index edb871d1e..68c5a6f9e 100644 --- a/lib/jsonapi/resource.rb +++ b/lib/jsonapi/resource.rb @@ -1,5 +1,4 @@ require 'jsonapi/callbacks' -require 'jsonapi/relationship_builder' module JSONAPI class Resource @@ -118,12 +117,6 @@ def fetchable_fields self.class.fields end - # Override this on a resource to customize how the associated records - # are fetched for a model. Particularly helpful for authorization. - def records_for(relation_name) - _model.public_send relation_name - end - def model_error_messages _model.errors.messages end @@ -174,6 +167,10 @@ def preloaded_fragments @preloaded_fragments ||= Hash.new end + def count_for_relationship(relationship_name, options) + self.class._record_accessor.count_for_relationship(self, relationship_name, options) + end + private def save @@ -429,6 +426,8 @@ def inherited(subclass) end check_reserved_resource_name(subclass._type, subclass.name) + + subclass.record_accessor = @_record_accessor_klass end def rebuild_relationships(relationships) @@ -445,7 +444,7 @@ def rebuild_relationships(relationships) end end - def resource_for(type) + def resource_klass_for(type) type = type.underscore type_with_module = type.include?('/') ? type : module_path + type @@ -457,8 +456,8 @@ def resource_for(type) resource end - def resource_for_model(model) - resource_for(resource_type_for(model)) + def resource_klass_for_model(model) + resource_klass_for(resource_type_for(model)) end def _resource_name_from_type(type) @@ -476,8 +475,8 @@ def resource_type_for(model) def model_name_for_type(key_type) type_class_name = key_type.to_s.classify - resource = resource_for(type_class_name) - resource ? resource._model_name.to_s : type_class_name + resource_klass = resource_klass_for(type_class_name) + resource_klass ? resource_klass._model_name.to_s : type_class_name end attr_accessor :_attributes, :_relationships, :_type, :_model_hints @@ -612,62 +611,6 @@ def fields _relationships.keys | _attributes.keys end - def resolve_relationship_names_to_relations(resource_klass, model_includes, options = {}) - case model_includes - when Array - return model_includes.map do |value| - resolve_relationship_names_to_relations(resource_klass, value, options) - end - when Hash - model_includes.keys.each do |key| - relationship = resource_klass._relationships[key] - value = model_includes[key] - model_includes.delete(key) - model_includes[relationship.relation_name(options)] = resolve_relationship_names_to_relations(relationship.resource_klass, value, options) - end - return model_includes - when Symbol - relationship = resource_klass._relationships[model_includes] - return relationship.relation_name(options) - end - end - - def apply_includes(records, options = {}) - include_directives = options[:include_directives] - if include_directives - model_includes = resolve_relationship_names_to_relations(self, include_directives.model_includes, options) - records = records.includes(model_includes) - end - - records - end - - def apply_pagination(records, paginator, order_options) - records = paginator.apply(records, order_options) if paginator - records - end - - def apply_sort(records, order_options, _context = {}) - if order_options.any? - order_options.each_pair do |field, direction| - if field.to_s.include?(".") - *model_names, column_name = field.split(".") - - associations = _lookup_association_chain([records.model.to_s, *model_names]) - joins_query = _build_joins([records.model, *associations]) - - # _sorting is appended to avoid name clashes with manual joins eg. 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| @@ -681,129 +624,31 @@ def _lookup_association_chain(model_names) associations end - def _build_joins(associations) - joins = [] - - associations.inject do |prev, current| - joins << "LEFT JOIN #{current.table_name} AS #{current.name}_sorting ON #{current.name}_sorting.id = #{prev.table_name}.#{current.foreign_key}" - current - end - joins.join("\n") - end - - def apply_filter(records, filter, value, options = {}) - strategy = _allowed_filters.fetch(filter.to_sym, Hash.new)[:apply] - - if strategy - if strategy.is_a?(Symbol) || strategy.is_a?(String) - send(strategy, records, value, options) - else - strategy.call(records, value, options) - end - else - records.where(filter => value) - end - end - - def apply_filters(records, filters, options = {}) - required_includes = [] - - if filters - filters.each do |filter, value| - if _relationships.include?(filter) - if _relationships[filter].belongs_to? - records = apply_filter(records, _relationships[filter].foreign_key, value, options) - else - required_includes.push(filter.to_s) - records = apply_filter(records, "#{_relationships[filter].table_name}.#{_relationships[filter].primary_key}", value, options) - end - else - records = apply_filter(records, filter, value, options) - end - end - end - - if required_includes.any? - records = apply_includes(records, options.merge(include_directives: IncludeDirectives.new(self, required_includes, force_eager_load: true))) - end - - records - end - - def filter_records(filters, options, records = records(options)) - records = apply_filters(records, filters, options) - apply_includes(records, options) - end - - def sort_records(records, order_options, context = {}) - apply_sort(records, order_options, context) - end - - # Assumes ActiveRecord's counting. Override if you need a different counting method - def count_records(records) - records.count(:all) - end - def find_count(filters, options = {}) - count_records(filter_records(filters, options)) + _record_accessor.find_count(filters, options) end def find(filters, options = {}) - resources_for(find_records(filters, options), options[:context]) + _record_accessor.find_resource(filters, options) end - def resources_for(records, context) - records.collect do |model| - resource_class = self.resource_for_model(model) - resource_class.new(model, context) - end - end - - def find_by_keys(keys, options = {}) - context = options[:context] - records = records(options) - records = apply_includes(records, options) - models = records.where({_primary_key => keys}) + def resources_for(models, context) models.collect do |model| - self.resource_for_model(model).new(model, context) + resource_for(model, context) end end - def find_serialized_with_caching(filters_or_source, serializer, options = {}) - if filters_or_source.is_a?(ActiveRecord::Relation) - records = filters_or_source - elsif _model_class.respond_to?(:all) && _model_class.respond_to?(:arel_table) - records = find_records(filters_or_source, options.except(:include_directives)) - else - records = find(filters_or_source, options) - end - cached_resources_for(records, serializer, options) + def resource_for(model, context) + resource_klass = self.resource_klass_for_model(model) + resource_klass.new(model, context) end - def find_by_key(key, options = {}) - context = options[:context] - records = find_records({_primary_key => key}, options.except(:paginator, :sort_criteria)) - model = records.first - fail JSONAPI::Exceptions::RecordNotFound.new(key) if model.nil? - self.resource_for_model(model).new(model, context) - end - - def find_by_key_serialized_with_caching(key, serializer, options = {}) - if _model_class.respond_to?(:all) && _model_class.respond_to?(:arel_table) - results = find_serialized_with_caching({_primary_key => key}, serializer, options) - result = results.first - fail JSONAPI::Exceptions::RecordNotFound.new(key) if result.nil? - return result - else - resource = find_by_key(key, options) - return cached_resources_for([resource], serializer, options).first - end + def find_by_keys(keys, options = {}) + _record_accessor.find_resources_by_keys(keys, options) end - # Override this method if you want to customize the relation for - # finder methods (find, find_by_key, find_serialized_with_caching) - def records(_options = {}) - _model_class.all + def find_by_key(key, options = {}) + _record_accessor.find_resource_by_key(key, options) end def verify_filters(filters, context = nil) @@ -959,6 +804,18 @@ def paginator(paginator) @_paginator = paginator end + def _record_accessor + @_record_accessor = _record_accessor_klass.new(self) + end + + def record_accessor=(record_accessor_klass) + @record_accessor_klass = record_accessor_klass + end + + def _record_accessor_klass + @_record_accessor_klass ||= JSONAPI.configuration.default_record_accessor_klass + end + def abstract(val = true) @abstract = val end @@ -1046,52 +903,96 @@ def _add_relationship(klass, *attrs) check_reserved_relationship_name(relationship_name) check_duplicate_relationship_name(relationship_name) - JSONAPI::RelationshipBuilder.new(klass, _model_class, options) - .define_relationship_methods(relationship_name.to_sym) + define_relationship_methods(relationship_name.to_sym, klass, options) end end - # Allows JSONAPI::RelationshipBuilder to access metaprogramming hooks - def inject_method_definition(name, body) - define_method(name, body) + # ResourceBuilder methods + def define_relationship_methods(relationship_name, relationship_klass, options) + # Initialize from an ActiveRecord model's properties + if _model_class && _model_class.ancestors.collect { |ancestor| ancestor.name }.include?('ActiveRecord::Base') + model_association = _model_class.reflect_on_association(relationship_name) + if model_association + options[:class_name] ||= model_association.class_name + end + end + + relationship = register_relationship( + relationship_name, + relationship_klass.new(relationship_name, options) + ) + + define_foreign_key_setter(relationship) + + case relationship + when JSONAPI::Relationship::ToOne + if relationship.belongs_to? + build_belongs_to(relationship) + else + build_has_one(relationship) + end + when JSONAPI::Relationship::ToMany + build_to_many(relationship) + end end - def register_relationship(name, relationship_object) - @_relationships[name] = relationship_object + def define_foreign_key_setter(relationship) + define_on_resource "#{relationship.foreign_key}=" do |value| + _model.method("#{relationship.foreign_key}=").call(value) + end end - private + def build_belongs_to(relationship) + foreign_key = relationship.foreign_key + define_on_resource foreign_key do + self.class._record_accessor.foreign_key(self, relationship.name) + end - def cached_resources_for(records, serializer, options) - if records.is_a?(Array) && records.all?{|rec| rec.is_a?(JSONAPI::Resource)} - resources = records.map{|r| [r.id, r] }.to_h - elsif self.caching? - t = _model_class.arel_table - cache_ids = pluck_arel_attributes(records, t[_primary_key], t[_cache_field]) - resources = CachedResourceFragment.fetch_fragments(self, serializer, options[:context], cache_ids) - else - resources = resources_for(records, options[:context]).map{|r| [r.id, r] }.to_h + # Returns instantiated related resource object or nil + define_on_resource relationship.name do |options = {}| + self.class._record_accessor.related_resource(self, relationship.name, options) end + end - preload_included_fragments(resources, records, serializer, options) + def build_has_one(relationship) + foreign_key = relationship.foreign_key - resources.values + # Returns primary key name of related resource class + define_on_resource foreign_key do + self.class._record_accessor.foreign_key(self, relationship.name) + end + + # Returns instantiated related resource object or nil + define_on_resource relationship.name do |options = {}| + self.class._record_accessor.related_resource(self, relationship.name, options) + end end - def find_records(filters, options = {}) - context = options[:context] + def build_to_many(relationship) + foreign_key = relationship.foreign_key - records = filter_records(filters, options) + # Returns array of primary keys of related resource classes + define_on_resource foreign_key do + self.class._record_accessor.foreign_keys(self, relationship.name) + end - sort_criteria = options.fetch(:sort_criteria) { [] } - order_options = construct_order_options(sort_criteria) - records = sort_records(records, order_options, context) + # Returns array of instantiated related resource objects + define_on_resource relationship.name do |options = {}| + self.class._record_accessor.related_resources(self, relationship.name, options) + end + end - records = apply_pagination(records, options[:paginator], order_options) + def define_on_resource(method_name, &block) + return if method_defined?(method_name) + define_method(method_name, block) + end - records + def register_relationship(name, relationship_object) + @_relationships[name] = relationship_object end + private + def check_reserved_resource_name(type, name) if [:ids, :types, :hrefs, :links].include?(type) warn "[NAME COLLISION] `#{name}` is a reserved resource name." @@ -1124,136 +1025,6 @@ def check_duplicate_attribute_name(name) warn "[DUPLICATE ATTRIBUTE] `#{name}` has already been defined in #{_resource_name_from_type(_type)}." end end - - def preload_included_fragments(resources, records, serializer, options) - return if resources.empty? - res_ids = resources.keys - - include_directives = options[:include_directives] - return unless include_directives - - context = options[:context] - - # For each association, including indirect associations, find the target record ids. - # Even if a target class doesn't have caching enabled, we still have to look up - # and match the target ids here, because we can't use ActiveRecord#includes. - # - # Note that `paths` returns partial paths before complete paths, so e.g. the partial - # fragments for posts.comments will exist before we start working with posts.comments.author - target_resources = {} - include_directives.paths.each do |path| - # If path is [:posts, :comments, :author], then... - pluck_attrs = [] # ...will be [posts.id, comments.id, authors.id, authors.updated_at] - pluck_attrs << self._model_class.arel_table[self._primary_key] - - relation = records - .except(:limit, :offset, :order) - .where({_primary_key => res_ids}) - - # These are updated as we iterate through the association path; afterwards they will - # refer to the final resource on the path, i.e. the actual resource to find in the cache. - # So e.g. if path is [:posts, :comments, :author], then after iteration... - parent_klass = nil # Comment - klass = self # Person - relationship = nil # JSONAPI::Relationship::ToOne for CommentResource.author - table = nil # people - assocs_path = [] # [ :posts, :approved_comments, :author ] - ar_hash = nil # { :posts => { :approved_comments => :author } } - - # For each step on the path, figure out what the actual table name/alias in the join - # will be, and include the primary key of that table in our list of fields to select - non_polymorphic = true - path.each do |elem| - relationship = klass._relationships[elem] - if relationship.polymorphic - # Can't preload through a polymorphic belongs_to association, ResourceSerializer - # will just have to bypass the cache and load the real Resource. - non_polymorphic = false - break - end - assocs_path << relationship.relation_name(options).to_sym - # Converts [:a, :b, :c] to Rails-style { :a => { :b => :c }} - ar_hash = assocs_path.reverse.reduce{|memo, step| { step => memo } } - # We can't just look up the table name from the resource class, because Arel could - # have used a table alias if the relation includes a self-reference. - join_source = relation.joins(ar_hash).arel.source.right.reverse.find do |arel_node| - arel_node.is_a?(Arel::Nodes::InnerJoin) - end - table = join_source.left - parent_klass = klass - klass = relationship.resource_klass - pluck_attrs << table[klass._primary_key] - end - next unless non_polymorphic - - # Pre-fill empty hashes for each resource up to the end of the path. - # This allows us to later distinguish between a preload that returned nothing - # vs. a preload that never ran. - prefilling_resources = resources.values - path.each do |rel_name| - rel_name = serializer.key_formatter.format(rel_name) - prefilling_resources.map! do |res| - res.preloaded_fragments[rel_name] ||= {} - res.preloaded_fragments[rel_name].values - end - prefilling_resources.flatten!(1) - end - - pluck_attrs << table[klass._cache_field] if klass.caching? - relation = relation.joins(ar_hash) - if relationship.is_a?(JSONAPI::Relationship::ToMany) - # Rails doesn't include order clauses in `joins`, so we have to add that manually here. - # FIXME Should find a better way to reflect on relationship ordering. :-( - relation = relation.order(parent_klass._model_class.new.send(assocs_path.last).arel.orders) - end - - # [[post id, comment id, author id, author updated_at], ...] - id_rows = pluck_arel_attributes(relation.joins(ar_hash), *pluck_attrs) - - target_resources[klass.name] ||= {} - - if klass.caching? - sub_cache_ids = id_rows - .map{|row| row.last(2) } - .reject{|row| target_resources[klass.name].has_key?(row.first) } - .uniq - target_resources[klass.name].merge! CachedResourceFragment.fetch_fragments( - klass, serializer, context, sub_cache_ids - ) - else - sub_res_ids = id_rows - .map(&:last) - .reject{|id| target_resources[klass.name].has_key?(id) } - .uniq - found = klass.find({klass._primary_key => sub_res_ids}, context: options[:context]) - target_resources[klass.name].merge! found.map{|r| [r.id, r] }.to_h - end - - id_rows.each do |row| - res = resources[row.first] - path.each_with_index do |rel_name, index| - rel_name = serializer.key_formatter.format(rel_name) - rel_id = row[index+1] - assoc_rels = res.preloaded_fragments[rel_name] - if index == path.length - 1 - assoc_rels[rel_id] = target_resources[klass.name].fetch(rel_id) - else - res = assoc_rels[rel_id] - end - end - end - end - end - - def pluck_arel_attributes(relation, *attrs) - conn = relation.connection - quoted_attrs = attrs.map do |attr| - quoted_table = conn.quote_table_name(attr.relation.table_alias || attr.relation.name) - quoted_column = conn.quote_column_name(attr.name) - "#{quoted_table}.#{quoted_column}" - end - relation.pluck(*quoted_attrs) - end end end end diff --git a/lib/jsonapi/routing_ext.rb b/lib/jsonapi/routing_ext.rb index facbc1c6f..045090cc4 100644 --- a/lib/jsonapi/routing_ext.rb +++ b/lib/jsonapi/routing_ext.rb @@ -18,7 +18,7 @@ def format_route(route) def jsonapi_resource(*resources, &_block) @resource_type = resources.first - res = JSONAPI::Resource.resource_for(resource_type_with_module_prefix(@resource_type)) + res = JSONAPI::Resource.resource_klass_for(resource_type_with_module_prefix(@resource_type)) options = resources.extract_options!.dup options[:controller] ||= @resource_type @@ -64,7 +64,7 @@ def jsonapi_resource(*resources, &_block) end def jsonapi_relationships(options = {}) - res = JSONAPI::Resource.resource_for(resource_type_with_module_prefix(@resource_type)) + res = JSONAPI::Resource.resource_klass_for(resource_type_with_module_prefix(@resource_type)) res._relationships.each do |relationship_name, relationship| if relationship.is_a?(JSONAPI::Relationship::ToMany) jsonapi_links(relationship_name, options) @@ -78,7 +78,7 @@ def jsonapi_relationships(options = {}) def jsonapi_resources(*resources, &_block) @resource_type = resources.first - res = JSONAPI::Resource.resource_for(resource_type_with_module_prefix(@resource_type)) + res = JSONAPI::Resource.resource_klass_for(resource_type_with_module_prefix(@resource_type)) options = resources.extract_options!.dup options[:controller] ||= @resource_type @@ -147,7 +147,7 @@ def jsonapi_link(*links) formatted_relationship_name = format_route(link_type) options = links.extract_options!.dup - res = JSONAPI::Resource.resource_for(resource_type_with_module_prefix) + res = JSONAPI::Resource.resource_klass_for(resource_type_with_module_prefix) options[:controller] ||= res._type.to_s methods = links_methods(options) @@ -175,7 +175,7 @@ def jsonapi_links(*links) formatted_relationship_name = format_route(link_type) options = links.extract_options!.dup - res = JSONAPI::Resource.resource_for(resource_type_with_module_prefix) + res = JSONAPI::Resource.resource_klass_for(resource_type_with_module_prefix) options[:controller] ||= res._type.to_s methods = links_methods(options) @@ -204,7 +204,7 @@ def jsonapi_links(*links) end def jsonapi_related_resource(*relationship) - source = JSONAPI::Resource.resource_for(resource_type_with_module_prefix) + source = JSONAPI::Resource.resource_klass_for(resource_type_with_module_prefix) options = relationship.extract_options!.dup relationship_name = relationship.first @@ -215,7 +215,7 @@ def jsonapi_related_resource(*relationship) if relationship.polymorphic? options[:controller] ||= relationship.class_name.underscore.pluralize else - related_resource = JSONAPI::Resource.resource_for(resource_type_with_module_prefix(relationship.class_name.underscore.pluralize)) + related_resource = JSONAPI::Resource.resource_klass_for(resource_type_with_module_prefix(relationship.class_name.underscore.pluralize)) options[:controller] ||= related_resource._type.to_s end @@ -225,14 +225,14 @@ def jsonapi_related_resource(*relationship) end def jsonapi_related_resources(*relationship) - source = JSONAPI::Resource.resource_for(resource_type_with_module_prefix) + source = JSONAPI::Resource.resource_klass_for(resource_type_with_module_prefix) options = relationship.extract_options!.dup relationship_name = relationship.first relationship = source._relationships[relationship_name] formatted_relationship_name = format_route(relationship.name) - related_resource = JSONAPI::Resource.resource_for(resource_type_with_module_prefix(relationship.class_name.underscore)) + related_resource = JSONAPI::Resource.resource_klass_for(resource_type_with_module_prefix(relationship.class_name.underscore)) options[:controller] ||= related_resource._type.to_s match formatted_relationship_name, diff --git a/test/controllers/controller_test.rb b/test/controllers/controller_test.rb index b1d82ac2d..7f67f7672 100644 --- a/test/controllers/controller_test.rb +++ b/test/controllers/controller_test.rb @@ -2609,14 +2609,14 @@ class BreedsControllerTest < ActionController::TestCase # Note: Breed names go through the TitleValueFormatter def test_poro_index - assert_cacheable_get :index + get :index assert_response :success assert_equal '0', json_response['data'][0]['id'] assert_equal 'Persian', json_response['data'][0]['attributes']['name'] end def test_poro_show - assert_cacheable_get :show, params: {id: '0'} + get :show, params: {id: '0'} assert_response :success assert json_response['data'].is_a?(Hash) assert_equal '0', json_response['data']['id'] diff --git a/test/fixtures/active_record.rb b/test/fixtures/active_record.rb index 8d0fe7f46..7a12cb117 100644 --- a/test/fixtures/active_record.rb +++ b/test/fixtures/active_record.rb @@ -1614,7 +1614,7 @@ class CommentResource < CommentResource; end class PostResource < PostResource # Test caching with SQL fragments def self.records(options = {}) - super.joins('INNER JOIN people on people.id = author_id') + _model_class.all.joins('INNER JOIN people on people.id = author_id') end end diff --git a/test/unit/resource/resource_test.rb b/test/unit/resource/resource_test.rb index 01e921127..48a07c87a 100644 --- a/test/unit/resource/resource_test.rb +++ b/test/unit/resource/resource_test.rb @@ -129,30 +129,30 @@ def test_module_path def test_resource_for_root_resource assert_raises NameError do - JSONAPI::Resource.resource_for('related') + JSONAPI::Resource.resource_klass_for('related') end end def test_resource_for_resource_does_not_exist_at_root assert_raises NameError do - ArticleResource.resource_for('related') + ArticleResource.resource_klass_for('related') end end def test_resource_for_with_underscored_namespaced_paths - assert_equal(JSONAPI::Resource.resource_for('my_module/related'), MyModule::RelatedResource) - assert_equal(PostResource.resource_for('my_module/related'), MyModule::RelatedResource) - assert_equal(MyModule::MyNamespacedResource.resource_for('my_module/related'), MyModule::RelatedResource) + assert_equal(JSONAPI::Resource.resource_klass_for('my_module/related'), MyModule::RelatedResource) + assert_equal(PostResource.resource_klass_for('my_module/related'), MyModule::RelatedResource) + assert_equal(MyModule::MyNamespacedResource.resource_klass_for('my_module/related'), MyModule::RelatedResource) end def test_resource_for_with_camelized_namespaced_paths - assert_equal(JSONAPI::Resource.resource_for('MyModule::Related'), MyModule::RelatedResource) - assert_equal(PostResource.resource_for('MyModule::Related'), MyModule::RelatedResource) - assert_equal(MyModule::MyNamespacedResource.resource_for('MyModule::Related'), MyModule::RelatedResource) + assert_equal(JSONAPI::Resource.resource_klass_for('MyModule::Related'), MyModule::RelatedResource) + assert_equal(PostResource.resource_klass_for('MyModule::Related'), MyModule::RelatedResource) + assert_equal(MyModule::MyNamespacedResource.resource_klass_for('MyModule::Related'), MyModule::RelatedResource) end def test_resource_for_namespaced_resource - assert_equal(MyModule::MyNamespacedResource.resource_for('related'), MyModule::RelatedResource) + assert_equal(MyModule::MyNamespacedResource.resource_klass_for('related'), MyModule::RelatedResource) end def test_relationship_parent_point_to_correct_resource @@ -266,28 +266,32 @@ def test_records_for_meta_method_for_to_one author = Person.find(1) author.update! preferences: Preferences.first author_resource = PersonWithCustomRecordsForRelationshipsResource.new(author, nil) - assert_equal(author_resource.record_for_preferences, :record_for_preferences) + assert_equal(author_resource.class._record_accessor.records_for( + author_resource, :preferences), :record_for_preferences) end def test_records_for_meta_method_for_to_one_calling_records_for author = Person.find(1) author.update! preferences: Preferences.first author_resource = PersonWithCustomRecordsForResource.new(author, nil) - assert_equal(author_resource.record_for_preferences, :records_for) + assert_equal(author_resource.class._record_accessor.records_for( + author_resource, :preferences), :records_for) end def test_associated_records_meta_method_for_to_many author = Person.find(1) author.posts << Post.find(1) author_resource = PersonWithCustomRecordsForRelationshipsResource.new(author, nil) - assert_equal(author_resource.records_for_posts, :records_for_posts) + assert_equal(author_resource.class._record_accessor.records_for( + author_resource, :posts), :records_for_posts) end def test_associated_records_meta_method_for_to_many_calling_records_for author = Person.find(1) author.posts << Post.find(1) author_resource = PersonWithCustomRecordsForResource.new(author, nil) - assert_equal(author_resource.records_for_posts, :records_for) + assert_equal(author_resource.class._record_accessor.records_for( + author_resource, :posts), :records_for) end def test_find_by_key_with_customized_base_records @@ -347,7 +351,28 @@ def apply_filters(records, filters, options) PostResource.instance_eval do def apply_filters(records, filters, options) # :nocov: - super + required_includes = [] + + if filters + filters.each do |filter, value| + if _relationships.include?(filter) + if _relationships[filter].belongs_to? + records = apply_filter(records, _relationships[filter].foreign_key, value, options) + else + required_includes.push(filter.to_s) + records = apply_filter(records, "#{_relationships[filter].table_name}.#{_relationships[filter].primary_key}", value, options) + end + else + records = apply_filter(records, filter, value, options) + end + end + end + + if required_includes.any? + records = apply_includes(records, options.merge(include_directives: IncludeDirectives.new(self, required_includes, force_eager_load: true))) + end + + records # :nocov: end end @@ -358,11 +383,12 @@ def test_to_many_relationship_sorts comment_ids = post_resource.comments.map{|c| c._model.id } assert_equal [1,2], comment_ids - # define apply_filters method on post resource to not respect filters + # define apply_filters method on post resource to sort descending PostResource.instance_eval do def apply_sort(records, criteria, context = {}) # :nocov: - records + order_by_query = 'id desc' + records.order(order_by_query) # :nocov: end end @@ -373,9 +399,26 @@ def apply_sort(records, criteria, context = {}) ensure # reset method to original implementation PostResource.instance_eval do - def apply_sort(records, criteria, context = {}) + def apply_sort(records, order_options, _context = {}) # :nocov: - super + if order_options.any? + order_options.each_pair do |field, direction| + if field.to_s.include?(".") + *model_names, column_name = field.split(".") + + associations = _lookup_association_chain([records.model.to_s, *model_names]) + joins_query = _record_accessor._build_joins([records.model, *associations]) + + # _sorting is appended to avoid name clashes with manual joins eg. overriden filters + order_by_query = "#{associations.last.name}_sorting.#{column_name} #{direction}" + records = records.joins(joins_query).order(order_by_query) + else + records = records.order(field => direction) + end + end + end + + records # :nocov: end end @@ -400,7 +443,7 @@ def test_lookup_association_chain def test_build_joins model_names = %w(person posts parent_post author) associations = PostResource._lookup_association_chain(model_names) - result = PostResource._build_joins(associations) + result = PostResource._record_accessor._build_joins(associations) assert_equal "LEFT JOIN posts AS parent_post_sorting ON parent_post_sorting.id = posts.parent_post_id LEFT JOIN people AS author_sorting ON author_sorting.id = posts.author_id", result @@ -439,7 +482,8 @@ def apply(relation, order_options) PostResource.instance_eval do def apply_pagination(records, criteria, order_options) # :nocov: - super + records = paginator.apply(records, order_options) if paginator + records # :nocov: end end @@ -621,7 +665,7 @@ def test_resource_for_model_use_hint special_person = Person.create!(name: 'Special', date_joined: Date.today, special: true) special_resource = SpecialPersonResource.new(special_person, nil) resource_model = SpecialPersonResource.records({}).first # simulate a find - assert_equal(SpecialPersonResource, SpecialPersonResource.resource_for_model(resource_model)) + assert_equal(SpecialPersonResource, SpecialPersonResource.resource_klass_for_model(resource_model)) end def test_resource_performs_validations_in_custom_context From c980031474c189c8f4b036b477608270173b9cbd Mon Sep 17 00:00:00 2001 From: Denis Talakevich Date: Wed, 15 Feb 2017 21:13:57 +0200 Subject: [PATCH 030/237] add bug report templates --- README.md | 14 +++ lib/bug_report_templates/rails_5_latest.rb | 125 ++++++++++++++++++++ lib/bug_report_templates/rails_5_master.rb | 130 +++++++++++++++++++++ test/bug_report_templates_test.rb | 25 ++++ 4 files changed, 294 insertions(+) create mode 100644 lib/bug_report_templates/rails_5_latest.rb create mode 100644 lib/bug_report_templates/rails_5_master.rb create mode 100644 test/bug_report_templates_test.rb diff --git a/README.md b/README.md index 8213112a3..6bafb893b 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,20 @@ Or install it yourself as: 4. Push to the branch (`git push origin my-new-feature`) 5. Create a new Pull Request +## Did you find a bug? + +* **Ensure the bug was not already reported** by searching on GitHub under [Issues](https://github.com/cerebris/jsonapi-resources/issues). + +* If you're unable to find an open issue addressing the problem, [open a new one](https://github.com/cerebris/jsonapi-resources/issues/new). +Be sure to include a **title and clear description**, as much relevant information as possible, +and a **code sample** or an **executable test case** demonstrating the expected behavior that is not occurring. + +* If possible, use the relevant bug report templates to create the issue. +Simply copy the content of the appropriate template into a .rb file, make the necessary changes to demonstrate the issue, +and **paste the content into the issue description**: + * [**Rails 5** issues](https://github.com/cerebris/jsonapi-resources/blob/master/lib/bug_report_templates/rails_5_master.rb) + + ## License Copyright 2014-2016 Cerebris Corporation. MIT License (see LICENSE for details). diff --git a/lib/bug_report_templates/rails_5_latest.rb b/lib/bug_report_templates/rails_5_latest.rb new file mode 100644 index 000000000..688424617 --- /dev/null +++ b/lib/bug_report_templates/rails_5_latest.rb @@ -0,0 +1,125 @@ +begin + require 'bundler/inline' +rescue LoadError => e + STDERR.puts 'Bundler version 1.10 or later is required. Please update your Bundler' + raise e +end + +gemfile(true) do + source 'https://rubygems.org' + + gem 'rails', require: false + gem 'sqlite3', platform: :mri + + gem 'activerecord-jdbcsqlite3-adapter', + git: 'https://github.com/jruby/activerecord-jdbc-adapter', + branch: 'rails-5', + platform: :jruby + + gem 'jsonapi-resources', require: false +end + +# prepare active_record database +require 'active_record' + +ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:') +ActiveRecord::Base.logger = Logger.new(STDOUT) + +ActiveRecord::Schema.define do + # Add your schema here + create_table :your_models, force: true do |t| + t.string :name + end +end + +# create models +class YourModel < ActiveRecord::Base +end + +# prepare rails app +require 'action_controller/railtie' +# require 'action_view/railtie' +require 'jsonapi-resources' + +class ApplicationController < ActionController::Base +end + +# prepare jsonapi resources and controllers +class YourModelsController < ApplicationController + include JSONAPI::ActsAsResourceController +end + +class YourModelResource < JSONAPI::Resource + attribute :name + filter :name +end + +class TestApp < Rails::Application + config.root = File.dirname(__FILE__) + config.logger = Logger.new(STDOUT) + Rails.logger = config.logger + + secrets.secret_token = 'secret_token' + secrets.secret_key_base = 'secret_key_base' + + config.eager_load = false +end + +# initialize app +Rails.application.initialize! + +JSONAPI.configure do |config| + config.json_key_format = :underscored_key + config.route_format = :underscored_key +end + +# draw routes +Rails.application.routes.draw do + jsonapi_resources :your_models, only: [:index, :create] +end + +# prepare tests +require 'minitest/autorun' +require 'rack/test' + +# Replace this with the code necessary to make your test fail. +class BugTest < Minitest::Test + include Rack::Test::Methods + + def json_api_headers + {'Accept' => JSONAPI::MEDIA_TYPE, 'CONTENT_TYPE' => JSONAPI::MEDIA_TYPE} + end + + def test_index_your_models + record = YourModel.create! name: 'John Doe' + get '/your_models', nil, json_api_headers + assert last_response.ok? + json_response = JSON.parse(last_response.body) + refute_nil json_response['data'] + refute_empty json_response['data'] + refute_empty json_response['data'].first + assert record.id.to_s, json_response['data'].first['id'] + assert 'your_models', json_response['data'].first['type'] + assert({'name' => 'John Doe'}, json_response['data'].first['attributes']) + end + + def test_create_your_models + json_request = { + 'data' => { + type: 'your_models', + attributes: { + name: 'Jane Doe' + } + } + } + post '/your_models', json_request.to_json, json_api_headers + assert last_response.created? + refute_nil YourModel.find_by(name: 'Jane Doe') + end + + private + + def app + Rails.application + end +end diff --git a/lib/bug_report_templates/rails_5_master.rb b/lib/bug_report_templates/rails_5_master.rb new file mode 100644 index 000000000..db29978ce --- /dev/null +++ b/lib/bug_report_templates/rails_5_master.rb @@ -0,0 +1,130 @@ +begin + require 'bundler/inline' +rescue LoadError => e + STDERR.puts 'Bundler version 1.10 or later is required. Please update your Bundler' + raise e +end + +gemfile(true) do + source 'https://rubygems.org' + + gem 'rails', require: false + gem 'sqlite3', platform: :mri + + gem 'activerecord-jdbcsqlite3-adapter', + git: 'https://github.com/jruby/activerecord-jdbc-adapter', + branch: 'rails-5', + platform: :jruby + + if ENV['JSONAPI_RESOURCES_PATH'] + gem 'jsonapi-resources', path: ENV['JSONAPI_RESOURCES_PATH'], require: false + else + gem 'jsonapi-resources', git: 'https://github.com/cerebris/jsonapi-resources', require: false + end + +end + +# prepare active_record database +require 'active_record' + +ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:') +ActiveRecord::Base.logger = Logger.new(STDOUT) + +ActiveRecord::Schema.define do + # Add your schema here + create_table :your_models, force: true do |t| + t.string :name + end +end + +# create models +class YourModel < ActiveRecord::Base +end + +# prepare rails app +require 'action_controller/railtie' +# require 'action_view/railtie' +require 'jsonapi-resources' + +class ApplicationController < ActionController::Base +end + +# prepare jsonapi resources and controllers +class YourModelsController < ApplicationController + include JSONAPI::ActsAsResourceController +end + +class YourModelResource < JSONAPI::Resource + attribute :name + filter :name +end + +class TestApp < Rails::Application + config.root = File.dirname(__FILE__) + config.logger = Logger.new(STDOUT) + Rails.logger = config.logger + + secrets.secret_token = 'secret_token' + secrets.secret_key_base = 'secret_key_base' + + config.eager_load = false +end + +# initialize app +Rails.application.initialize! + +JSONAPI.configure do |config| + config.json_key_format = :underscored_key + config.route_format = :underscored_key +end + +# draw routes +Rails.application.routes.draw do + jsonapi_resources :your_models, only: [:index, :create] +end + +# prepare tests +require 'minitest/autorun' +require 'rack/test' + +# Replace this with the code necessary to make your test fail. +class BugTest < Minitest::Test + include Rack::Test::Methods + + def json_api_headers + {'Accept' => JSONAPI::MEDIA_TYPE, 'CONTENT_TYPE' => JSONAPI::MEDIA_TYPE} + end + + def test_index_your_models + record = YourModel.create! name: 'John Doe' + get '/your_models', nil, json_api_headers + assert last_response.ok? + json_response = JSON.parse(last_response.body) + refute_nil json_response['data'] + refute_empty json_response['data'] + refute_empty json_response['data'].first + assert record.id.to_s, json_response['data'].first['id'] + assert 'your_models', json_response['data'].first['type'] + assert({'name' => 'John Doe'}, json_response['data'].first['attributes']) + end + + def test_create_your_models + json_request = { + 'data' => { + type: 'your_models', + attributes: { + name: 'Jane Doe' + } + } + } + post '/your_models', json_request.to_json, json_api_headers + assert last_response.created? + refute_nil YourModel.find_by(name: 'Jane Doe') + end + + private + + def app + Rails.application + end +end diff --git a/test/bug_report_templates_test.rb b/test/bug_report_templates_test.rb new file mode 100644 index 000000000..3e0cca8e0 --- /dev/null +++ b/test/bug_report_templates_test.rb @@ -0,0 +1,25 @@ +require File.expand_path('../test_helper', __FILE__) + +class BugReportTemplatesTest < ActiveSupport::TestCase + + def jsonapi_resources_root + File.expand_path('../..', __FILE__) + end + + def chdir_path + File.join(jsonapi_resources_root, 'lib', 'bug_report_templates') + end + + def assert_bug_report(file_name) + Bundler.with_clean_env do + Dir.chdir(chdir_path) do + assert system({'JSONAPI_RESOURCES_PATH' => jsonapi_resources_root}, Gem.ruby, file_name) + end + end + end + + def test_rails_5 + assert_bug_report 'rails_5_master.rb' + end + +end From 2480458038b93b098bf85aeb030ecf4f34b00bc5 Mon Sep 17 00:00:00 2001 From: Hidde-Jan Jongsma Date: Thu, 23 Feb 2017 15:17:21 +0100 Subject: [PATCH 031/237] Verify filters that are passed to show_related_resources (#971) --- lib/jsonapi/processor.rb | 3 ++- lib/jsonapi/request_parser.rb | 2 +- test/controllers/controller_test.rb | 11 +++++++++++ test/fixtures/active_record.rb | 1 + test/test_helper.rb | 1 + 5 files changed, 16 insertions(+), 2 deletions(-) diff --git a/lib/jsonapi/processor.rb b/lib/jsonapi/processor.rb index e4279bfd1..e53b5c342 100644 --- a/lib/jsonapi/processor.rb +++ b/lib/jsonapi/processor.rb @@ -160,9 +160,10 @@ def show_related_resources include_directives = params[:include_directives] source_resource ||= source_klass.find_by_key(source_id, context: context, fields: fields) + verified_filters = resource_klass.verify_filters(filters, context) rel_opts = { - filters: filters, + filters: verified_filters, sort_criteria: sort_criteria, paginator: paginator, fields: fields, diff --git a/lib/jsonapi/request_parser.rb b/lib/jsonapi/request_parser.rb index 14d2ca487..fbf8d4bcd 100644 --- a/lib/jsonapi/request_parser.rb +++ b/lib/jsonapi/request_parser.rb @@ -119,7 +119,7 @@ def setup_get_related_resources_action(params, resource_klass) relationship_type: relationship_type, source_klass: source_klass, source_id: source_id, - filters: source_klass.verify_filters(filters, @context), + filters: filters, sort_criteria: sort_criteria, paginator: paginator, fields: fields, diff --git a/test/controllers/controller_test.rb b/test/controllers/controller_test.rb index 7f67f7672..60d8d11ec 100644 --- a/test/controllers/controller_test.rb +++ b/test/controllers/controller_test.rb @@ -2390,6 +2390,17 @@ def test_invalid_filter_value assert_response :bad_request end + def test_invalid_filter_value_for_get_related_resources + assert_cacheable_get :get_related_resources, params: { + hair_cut_id: 1, + relationship: 'people', + source: 'hair_cuts', + filter: {name: 'L'} + } + + assert_response :bad_request + end + def test_valid_filter_value assert_cacheable_get :index, params: {filter: {name: 'Joe Author'}} assert_response :success diff --git a/test/fixtures/active_record.rb b/test/fixtures/active_record.rb index 7a12cb117..9ba31320a 100644 --- a/test/fixtures/active_record.rb +++ b/test/fixtures/active_record.rb @@ -394,6 +394,7 @@ class Section < ActiveRecord::Base end class HairCut < ActiveRecord::Base + has_many :people end class Property < ActiveRecord::Base diff --git a/test/test_helper.rb b/test/test_helper.rb index 51ba0459d..cb1d47991 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -229,6 +229,7 @@ class CatResource < JSONAPI::Resource jsonapi_resources :comments jsonapi_resources :firms jsonapi_resources :tags + jsonapi_resources :hair_cuts jsonapi_resources :posts do jsonapi_relationships jsonapi_links :special_tags From 7ebba9b096c4d84d351b9c18af2971a36b97129d Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Fri, 24 Feb 2017 09:43:19 -0500 Subject: [PATCH 032/237] Apply default sort to related_resource requests (cherry picked from commit 600a818) --- lib/jsonapi/active_record_accessor.rb | 6 ++---- test/controllers/controller_test.rb | 18 ++++++++++++++++++ test/fixtures/active_record.rb | 4 ++++ test/integration/requests/request_test.rb | 20 ++++++++++---------- 4 files changed, 34 insertions(+), 14 deletions(-) diff --git a/lib/jsonapi/active_record_accessor.rb b/lib/jsonapi/active_record_accessor.rb index a92b39b53..18ae2abaf 100644 --- a/lib/jsonapi/active_record_accessor.rb +++ b/lib/jsonapi/active_record_accessor.rb @@ -151,10 +151,8 @@ def records_for_relationship(resource, relationship_name, options = {}) end sort_criteria = options.fetch(:sort_criteria, {}) - unless sort_criteria.nil? || sort_criteria.empty? - order_options = relationship.resource_klass.construct_order_options(sort_criteria) - records = apply_sort(records, order_options, context) - end + order_options = relationship.resource_klass.construct_order_options(sort_criteria) + records = apply_sort(records, order_options, context) paginator = options[:paginator] if paginator diff --git a/test/controllers/controller_test.rb b/test/controllers/controller_test.rb index 60d8d11ec..b95dafbbf 100644 --- a/test/controllers/controller_test.rb +++ b/test/controllers/controller_test.rb @@ -1898,6 +1898,24 @@ def test_show_to_one_relationship_nil } } end + + def test_get_related_resources_sorted + assert_cacheable_get :get_related_resources, params: {person_id: '1', relationship: 'posts', source:'people', sort: 'title' } + assert_response :success + assert_equal 'JR How To', json_response['data'][0]['attributes']['title'] + assert_equal 'New post', json_response['data'][2]['attributes']['title'] + assert_cacheable_get :get_related_resources, params: {person_id: '1', relationship: 'posts', source:'people', sort: '-title' } + assert_response :success + assert_equal 'New post', json_response['data'][0]['attributes']['title'] + assert_equal 'JR How To', json_response['data'][2]['attributes']['title'] + end + + def test_get_related_resources_default_sorted + assert_cacheable_get :get_related_resources, params: {person_id: '1', relationship: 'posts', source:'people'} + assert_response :success + assert_equal 'New post', json_response['data'][0]['attributes']['title'] + assert_equal 'JR How To', json_response['data'][2]['attributes']['title'] + end end class TagsControllerTest < ActionController::TestCase diff --git a/test/fixtures/active_record.rb b/test/fixtures/active_record.rb index 9ba31320a..22fb9ed5f 100644 --- a/test/fixtures/active_record.rb +++ b/test/fixtures/active_record.rb @@ -1042,6 +1042,10 @@ class PostResource < JSONAPI::Resource # Not needed - just for testing primary_key :id + def self.default_sort + [{field: 'title', direction: :desc}, {field: 'id', direction: :desc}] + end + before_save do msg = "Before save" end diff --git a/test/integration/requests/request_test.rb b/test/integration/requests/request_test.rb index 31821f8fd..8a27de962 100644 --- a/test/integration/requests/request_test.rb +++ b/test/integration/requests/request_test.rb @@ -606,16 +606,16 @@ def test_pagination_empty_results def test_flow_self - assert_cacheable_jsonapi_get '/posts' - post_1 = json_response['data'][0] + assert_cacheable_jsonapi_get '/posts/1' + post_1 = json_response['data'] assert_cacheable_jsonapi_get post_1['links']['self'] assert_hash_equals post_1, json_response['data'] end def test_flow_link_to_one_self_link - assert_cacheable_jsonapi_get '/posts' - post_1 = json_response['data'][0] + assert_cacheable_jsonapi_get '/posts/1' + post_1 = json_response['data'] assert_cacheable_jsonapi_get post_1['relationships']['author']['links']['self'] assert_hash_equals(json_response, { @@ -628,8 +628,8 @@ def test_flow_link_to_one_self_link end def test_flow_link_to_many_self_link - assert_cacheable_jsonapi_get '/posts' - post_1 = json_response['data'][0] + assert_cacheable_jsonapi_get '/posts/1' + post_1 = json_response['data'] assert_cacheable_jsonapi_get post_1['relationships']['tags']['links']['self'] assert_hash_equals(json_response, @@ -647,10 +647,10 @@ def test_flow_link_to_many_self_link end def test_flow_link_to_many_self_link_put - assert_cacheable_jsonapi_get '/posts' - post_1 = json_response['data'][4] + assert_cacheable_jsonapi_get '/posts/5' + post_5 = json_response['data'] - post post_1['relationships']['tags']['links']['self'], params: + post post_5['relationships']['tags']['links']['self'], params: {'data' => [{'type' => 'tags', 'id' => '10'}]}.to_json, headers: { 'CONTENT_TYPE' => JSONAPI::MEDIA_TYPE, @@ -659,7 +659,7 @@ def test_flow_link_to_many_self_link_put assert_equal 204, status - assert_cacheable_jsonapi_get post_1['relationships']['tags']['links']['self'] + assert_cacheable_jsonapi_get post_5['relationships']['tags']['links']['self'] assert_hash_equals(json_response, { 'links' => { From 81377d222c598513fb5033bf63ad8c6e58b2b600 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Wed, 1 Mar 2017 13:26:40 -0500 Subject: [PATCH 033/237] Fix `record_accessor=` method --- lib/jsonapi/resource.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/jsonapi/resource.rb b/lib/jsonapi/resource.rb index 8e8e73278..5c79b564b 100644 --- a/lib/jsonapi/resource.rb +++ b/lib/jsonapi/resource.rb @@ -809,7 +809,7 @@ def _record_accessor end def record_accessor=(record_accessor_klass) - @record_accessor_klass = record_accessor_klass + @_record_accessor_klass = record_accessor_klass end def _record_accessor_klass From ae71cab83bb8a628a6dd5422e603310a96c43b2e Mon Sep 17 00:00:00 2001 From: Travis Hunter Date: Wed, 1 Mar 2017 12:24:43 -0500 Subject: [PATCH 034/237] Store exceptions in request env --- lib/jsonapi/acts_as_resource_controller.rb | 3 +++ test/controllers/controller_test.rb | 15 +++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/lib/jsonapi/acts_as_resource_controller.rb b/lib/jsonapi/acts_as_resource_controller.rb index bc0229ddc..830109df5 100644 --- a/lib/jsonapi/acts_as_resource_controller.rb +++ b/lib/jsonapi/acts_as_resource_controller.rb @@ -284,6 +284,9 @@ def handle_exceptions(e) } end + # Store exception for other middlewares + request.env['action_dispatch.exception'] ||= e + internal_server_error = JSONAPI::Exceptions::InternalServerError.new(e) Rails.logger.error { "Internal Server Error: #{e.message} #{e.backtrace.join("\n")}" } errors = internal_server_error.errors diff --git a/test/controllers/controller_test.rb b/test/controllers/controller_test.rb index b95dafbbf..20fa914f6 100644 --- a/test/controllers/controller_test.rb +++ b/test/controllers/controller_test.rb @@ -104,6 +104,21 @@ def test_whitelist_all_exceptions JSONAPI.configuration.whitelist_all_exceptions = original_config end + def test_exception_added_to_request_env + original_config = JSONAPI.configuration.whitelist_all_exceptions + $PostProcessorRaisesErrors = true + refute @request.env['action_dispatch.exception'] + assert_cacheable_get :index + assert @request.env['action_dispatch.exception'] + + JSONAPI.configuration.whitelist_all_exceptions = true + assert_cacheable_get :index + assert @request.env['action_dispatch.exception'] + ensure + $PostProcessorRaisesErrors = false + JSONAPI.configuration.whitelist_all_exceptions = original_config + end + def test_exception_includes_backtrace_when_enabled original_config = JSONAPI.configuration.include_backtraces_in_errors $PostProcessorRaisesErrors = true From e6929ea90fcea9569227f08dd58aba9595fa848f Mon Sep 17 00:00:00 2001 From: Thiago Rodrigues de Paula Date: Thu, 9 Mar 2017 17:25:52 +0100 Subject: [PATCH 035/237] Pass fetched records to paginator instance when building links --- lib/jsonapi/processor.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/jsonapi/processor.rb b/lib/jsonapi/processor.rb index e53b5c342..a3ebf0647 100644 --- a/lib/jsonapi/processor.rb +++ b/lib/jsonapi/processor.rb @@ -95,7 +95,7 @@ def find end if JSONAPI.configuration.top_level_links_include_pagination && paginator - page_options[:pagination_params] = paginator.links_page_params(page_options) + page_options[:pagination_params] = paginator.links_page_params(page_options.merge(fetched_resources: resources)) end return JSONAPI::ResourcesOperationResult.new(:ok, resources, page_options) @@ -190,7 +190,7 @@ def show_related_resources pagination_params = if paginator && JSONAPI.configuration.top_level_links_include_pagination page_options = {} page_options[:record_count] = record_count if paginator.class.requires_record_count - paginator.links_page_params(page_options) + paginator.links_page_params(page_options.merge(fetched_resources: related_resources)) else {} end From 510b4210119cfbe894a4ec88317093eafeebf7e7 Mon Sep 17 00:00:00 2001 From: Doug Orleans Date: Tue, 14 Mar 2017 19:17:41 -0400 Subject: [PATCH 036/237] Use foreign_key option when getting foreign key value. --- lib/jsonapi/resource_serializer.rb | 4 ++-- test/unit/serializer/serializer_test.rb | 12 ++++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/lib/jsonapi/resource_serializer.rb b/lib/jsonapi/resource_serializer.rb index e52e8c4c2..50c4b7c86 100644 --- a/lib/jsonapi/resource_serializer.rb +++ b/lib/jsonapi/resource_serializer.rb @@ -454,11 +454,11 @@ def link_object(source, relationship, include_linkage = false) def foreign_key_value(source, relationship) related_resource_id = if source.preloaded_fragments.has_key?(format_key(relationship.name)) source.preloaded_fragments[format_key(relationship.name)].values.first.try(:id) - elsif source.respond_to?("#{relationship.name}_id") + elsif source.respond_to?(relationship.foreign_key) # If you have direct access to the underlying id, you don't have to load the relationship # which can save quite a lot of time when loading a lot of data. # This does not apply to e.g. has_one :through relationships. - source.public_send("#{relationship.name}_id") + source.public_send(relationship.foreign_key) else source.public_send(relationship.name).try(:id) end diff --git a/test/unit/serializer/serializer_test.rb b/test/unit/serializer/serializer_test.rb index f546c7901..163c39cad 100644 --- a/test/unit/serializer/serializer_test.rb +++ b/test/unit/serializer/serializer_test.rb @@ -985,6 +985,18 @@ def test_serializer_array_of_resources_always_include_to_one_linkage_data JSONAPI.configuration.always_include_to_one_linkage_data = false end + def test_serializer_always_include_to_one_linkage_data_does_not_load_association + JSONAPI.configuration.always_include_to_one_linkage_data = true + + post = Post.find(1) + resource = Api::V1::PostResource.new(post, nil) + JSONAPI::ResourceSerializer.new(Api::V1::PostResource).serialize_to_hash(resource) + + refute_predicate post.association(:writer), :loaded? + ensure + JSONAPI.configuration.always_include_to_one_linkage_data = false + end + def test_serializer_array_of_resources posts = [] From 42da41247ed0403da1bf0b9f283f118523ea6424 Mon Sep 17 00:00:00 2001 From: Doug Orleans Date: Wed, 15 Mar 2017 16:04:11 -0400 Subject: [PATCH 037/237] Bug fix: Allow multiple relationships to be defined in one declaration. --- lib/jsonapi/resource.rb | 2 +- test/fixtures/active_record.rb | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/jsonapi/resource.rb b/lib/jsonapi/resource.rb index 8e8e73278..f395a792d 100644 --- a/lib/jsonapi/resource.rb +++ b/lib/jsonapi/resource.rb @@ -913,7 +913,7 @@ def define_relationship_methods(relationship_name, relationship_klass, options) 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 + options = options.reverse_merge(class_name: model_association.class_name) end end diff --git a/test/fixtures/active_record.rb b/test/fixtures/active_record.rb index 22fb9ed5f..62326f732 100644 --- a/test/fixtures/active_record.rb +++ b/test/fixtures/active_record.rb @@ -936,8 +936,7 @@ class PersonResource < BaseResource attributes :name, :email attribute :date_joined, format: :date_with_timezone - has_many :comments - has_many :posts + has_many :comments, :posts has_many :vehicles, polymorphic: true has_one :preferences From 1ea26b338409d75e8157f23ab53f43234ff4c85d Mon Sep 17 00:00:00 2001 From: Ryan Barber Date: Tue, 14 Mar 2017 16:51:41 -0700 Subject: [PATCH 038/237] Give resource more control over sortable fields I'm in a situation where I want clients to have repeatable, random sort order on a collection. My solution is to pass a ?sort=rand-N, with N being the seed used for sorting. This way, if the client was to page through the list or link to the list, the sort order would be preserved. For example, `/providers?sort=rand-42` would apply a random sort order with 42 as the key. To accommodate this, I need JSONAPI::RequestParser to allow sort keys that are not in a predetermined list. I think a good solution to this would be to have the RequestParser defer to the Resource when checking if a sort key is valid. The existing behaviour is maintained by moving the check on sortable_fields to sortable_field?, which can be overloaded by application specific resource classes. --- lib/jsonapi/request_parser.rb | 3 +-- lib/jsonapi/resource.rb | 4 ++++ test/unit/jsonapi_request/jsonapi_request_test.rb | 15 +++++++++++++++ test/unit/resource/resource_test.rb | 6 ++++++ 4 files changed, 26 insertions(+), 2 deletions(-) diff --git a/lib/jsonapi/request_parser.rb b/lib/jsonapi/request_parser.rb index fbf8d4bcd..3da999aee 100644 --- a/lib/jsonapi/request_parser.rb +++ b/lib/jsonapi/request_parser.rb @@ -426,9 +426,8 @@ def parse_sort_criteria(resource_klass, sort_criteria) def check_sort_criteria(resource_klass, sort_criteria) sort_field = sort_criteria[:field] - sortable_fields = resource_klass.sortable_fields(context) - unless sortable_fields.include? sort_field.to_sym + unless resource_klass.sortable_field?(sort_field.to_sym, context) @errors.concat(JSONAPI::Exceptions::InvalidSortCriteria .new(format_key(resource_klass._type), sort_field).errors) end diff --git a/lib/jsonapi/resource.rb b/lib/jsonapi/resource.rb index 8e8e73278..247e7f4c0 100644 --- a/lib/jsonapi/resource.rb +++ b/lib/jsonapi/resource.rb @@ -607,6 +607,10 @@ def sortable_fields(_context = nil) _attributes.keys end + def sortable_field?(key, context = nil) + sortable_fields(context).include? key.to_sym + end + def fields _relationships.keys | _attributes.keys end diff --git a/test/unit/jsonapi_request/jsonapi_request_test.rb b/test/unit/jsonapi_request/jsonapi_request_test.rb index 002be1927..325e58302 100644 --- a/test/unit/jsonapi_request/jsonapi_request_test.rb +++ b/test/unit/jsonapi_request/jsonapi_request_test.rb @@ -14,6 +14,12 @@ def self.sortable_fields(context) end end +class TreeResource < JSONAPI::Resource + def self.sortable_field?(key, context) + key =~ /^sort\d+/ + end +end + class JSONAPIRequestTest < ActiveSupport::TestCase def test_parse_includes_underscored params = ActionController::Parameters.new( @@ -211,6 +217,15 @@ def test_parse_sort_with_valid_sorts assert_equal(sort_criteria, [{:field=>"name", :direction=>:desc}]) end + def test_parse_sort_with_resource_validated_sorts + setup_request + sort_criteria = @request.parse_sort_criteria(TreeResource, "sort66,name") + assert_equal(@request.errors.count, 1) + assert_equal(@request.errors.first.title, "Invalid sort criteria") + assert_equal(@request.errors.first.detail, "name is not a valid sort criteria for trees") + assert_equal(sort_criteria, [{:field=>"sort66", :direction=>:asc}, {:field=>"name", :direction=>:asc}]) + end + def test_parse_sort_with_relationships setup_request sort_criteria = @request.parse_sort_criteria(CatResource, "-mother.name") diff --git a/test/unit/resource/resource_test.rb b/test/unit/resource/resource_test.rb index 80e9a46c5..d1dd28d8a 100644 --- a/test/unit/resource/resource_test.rb +++ b/test/unit/resource/resource_test.rb @@ -689,4 +689,10 @@ def test_readonly_attribute refute_includes(PostWithReadonlyAttributesResource.creatable_fields, :author) refute_includes(PostWithReadonlyAttributesResource.updatable_fields, :author) end + + def test_sortable_field? + assert(PostResource.sortable_field?(:title)) + assert(PostResource.sortable_field?(:body)) + refute(PostResource.sortable_field?(:color)) + end end From 56d94a05772164b5fc217c2fcf2b20372fef871e Mon Sep 17 00:00:00 2001 From: Olle Jonsson Date: Thu, 23 Mar 2017 07:31:10 +0100 Subject: [PATCH 039/237] Travis: use 2.4.1 --- .travis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 3a2d03dd8..80149d962 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,12 +8,12 @@ rvm: - 2.1.10 - 2.2.6 - 2.3.3 - - 2.4.0 + - 2.4.1 matrix: exclude: - rvm: 2.1.10 env: "RAILS_VERSION=5.0.0" - - rvm: 2.4.0 + - rvm: 2.4.1 env: "RAILS_VERSION=4.2.7" allow_failures: - env: "RAILS_VERSION=master" From de0e7450f1948a51dc32b5eabc040b24ff56746f Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Thu, 23 Mar 2017 08:44:51 -0400 Subject: [PATCH 040/237] Spelling fixes --- lib/jsonapi/acts_as_resource_controller.rb | 2 +- lib/jsonapi/request_parser.rb | 2 +- lib/jsonapi/resource.rb | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/jsonapi/acts_as_resource_controller.rb b/lib/jsonapi/acts_as_resource_controller.rb index 830109df5..f95efffca 100644 --- a/lib/jsonapi/acts_as_resource_controller.rb +++ b/lib/jsonapi/acts_as_resource_controller.rb @@ -243,7 +243,7 @@ def render_response_document if response_document.has_errors? render_options[:json] = content else - # Bypasing ActiveSupport allows us to use CompiledJson objects for cached response fragments + # Bypassing ActiveSupport allows us to use CompiledJson objects for cached response fragments render_options[:body] = JSON.generate(content) render_options[:location] = content['data']['links']['self'] if (response_document.status == 201 && content[:data].class != Array) diff --git a/lib/jsonapi/request_parser.rb b/lib/jsonapi/request_parser.rb index 3da999aee..078136cae 100644 --- a/lib/jsonapi/request_parser.rb +++ b/lib/jsonapi/request_parser.rb @@ -201,7 +201,7 @@ def setup_update_action(params, resource_klass) fail JSONAPI::Exceptions::MissingKey.new(error_object_overrides) if data[:id].nil? resource_id = data.require(:id) - # Singlton resources may not have the ID set in the URL + # Singleton resources may not have the ID set in the URL if key fail JSONAPI::Exceptions::KeyNotIncludedInURL.new(resource_id) if key.to_s != resource_id.to_s end diff --git a/lib/jsonapi/resource.rb b/lib/jsonapi/resource.rb index a9667bb1e..02f2d6a1d 100644 --- a/lib/jsonapi/resource.rb +++ b/lib/jsonapi/resource.rb @@ -739,12 +739,12 @@ def verify_keys(keys, context = nil) 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] From 1880d070473117e39a92c4d2ea12b96a22257dc4 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Thu, 23 Mar 2017 08:52:07 -0400 Subject: [PATCH 041/237] Unused argument cleanup --- lib/jsonapi/cached_resource_fragment.rb | 2 +- lib/jsonapi/compiled_json.rb | 2 +- lib/jsonapi/formatter.rb | 6 +++--- lib/jsonapi/include_directives.rb | 2 +- lib/jsonapi/link_builder.rb | 2 +- lib/jsonapi/request_parser.rb | 4 ++-- lib/jsonapi/resource.rb | 8 ++++---- lib/jsonapi/resource_serializer.rb | 4 ++-- 8 files changed, 15 insertions(+), 15 deletions(-) diff --git a/lib/jsonapi/cached_resource_fragment.rb b/lib/jsonapi/cached_resource_fragment.rb index a8ed301b2..4a6d598d4 100644 --- a/lib/jsonapi/cached_resource_fragment.rb +++ b/lib/jsonapi/cached_resource_fragment.rb @@ -8,7 +8,7 @@ def self.fetch_fragments(resource_klass, serializer, context, cache_ids) results = self.lookup(resource_klass, serializer_config_key, context, context_key, cache_ids) - miss_ids = results.select{|k,v| v.nil? }.keys + miss_ids = results.select{|_k,v| v.nil? }.keys unless miss_ids.empty? find_filters = {resource_klass._primary_key => miss_ids.uniq} find_options = {context: context} diff --git a/lib/jsonapi/compiled_json.rb b/lib/jsonapi/compiled_json.rb index 3bdb8998d..a6f7360ad 100644 --- a/lib/jsonapi/compiled_json.rb +++ b/lib/jsonapi/compiled_json.rb @@ -19,7 +19,7 @@ def initialize(json, h = nil) @h = h end - def to_json(*args) + def to_json(*_args) @json end diff --git a/lib/jsonapi/formatter.rb b/lib/jsonapi/formatter.rb index b6f2e4cce..6f2922b57 100644 --- a/lib/jsonapi/formatter.rb +++ b/lib/jsonapi/formatter.rb @@ -108,7 +108,7 @@ def unformat(formatted_key) class DasherizedKeyFormatter < JSONAPI::KeyFormatter class << self - def format(key) + def format(_key) super.underscore.dasherize end @@ -146,7 +146,7 @@ class UnderscoredRouteFormatter < JSONAPI::RouteFormatter class CamelizedRouteFormatter < JSONAPI::RouteFormatter class << self - def format(route) + def format(_route) super.camelize(:lower) end @@ -158,7 +158,7 @@ def unformat(formatted_route) class DasherizedRouteFormatter < JSONAPI::RouteFormatter class << self - def format(route) + def format(_route) super.dasherize end diff --git a/lib/jsonapi/include_directives.rb b/lib/jsonapi/include_directives.rb index 0fd41536c..60f8bd68e 100644 --- a/lib/jsonapi/include_directives.rb +++ b/lib/jsonapi/include_directives.rb @@ -65,7 +65,7 @@ def get_related(current_path) def get_includes(directive, only_joined_includes = true) ir = directive[:include_related] - ir = ir.select { |k,v| v[:include_in_join] } if only_joined_includes + ir = ir.select { |_k,v| v[:include_in_join] } if only_joined_includes ir.map do |name, sub_directive| sub = get_includes(sub_directive, only_joined_includes) diff --git a/lib/jsonapi/link_builder.rb b/lib/jsonapi/link_builder.rb index 61604496c..54d0d3d38 100644 --- a/lib/jsonapi/link_builder.rb +++ b/lib/jsonapi/link_builder.rb @@ -61,7 +61,7 @@ def build_engine_name unless scopes.empty? "#{ scopes.first.to_s.camelize }::Engine".safe_constantize end - rescue LoadError => e + rescue LoadError => _e nil end end diff --git a/lib/jsonapi/request_parser.rb b/lib/jsonapi/request_parser.rb index 078136cae..2da0d9405 100644 --- a/lib/jsonapi/request_parser.rb +++ b/lib/jsonapi/request_parser.rb @@ -24,7 +24,7 @@ def error_object_overrides {} end - def each(response_document) + def each(_response_document) operation = setup_base_op(params) if @errors.any? fail JSONAPI::Exceptions::Errors.new(@errors) @@ -595,7 +595,7 @@ def verify_permitted_params(params, allowed_fields) end end when 'attributes' - value.each do |attr_key, attr_value| + value.each do |attr_key, _attr_value| unless formatted_allowed_fields.include?(attr_key.to_sym) if JSONAPI.configuration.raise_if_parameters_not_allowed param_errors.concat JSONAPI::Exceptions::ParameterNotAllowed.new( diff --git a/lib/jsonapi/resource.rb b/lib/jsonapi/resource.rb index 02f2d6a1d..0562b4ea8 100644 --- a/lib/jsonapi/resource.rb +++ b/lib/jsonapi/resource.rb @@ -308,7 +308,7 @@ def _replace_to_many_links(relationship_type, relationship_key_values, options) :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) @@ -317,7 +317,7 @@ def _replace_to_one_link(relationship_type, relationship_key_value, options) :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) @@ -360,7 +360,7 @@ def _remove_to_many_link(relationship_type, key, options) 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) @@ -852,7 +852,7 @@ def caching? @caching && !JSONAPI.configuration.resource_cache.nil? end - def attribute_caching_context(context) + def attribute_caching_context(_context) nil end diff --git a/lib/jsonapi/resource_serializer.rb b/lib/jsonapi/resource_serializer.rb index 50c4b7c86..a0a30c7a2 100644 --- a/lib/jsonapi/resource_serializer.rb +++ b/lib/jsonapi/resource_serializer.rb @@ -278,7 +278,7 @@ def relationships_hash(source, fetchable_fields, include_directives = {}) include_directives[:include_related] ||= {} - relationships = source.class._relationships.select{|k,v| fetchable_fields.include?(k) } + relationships = source.class._relationships.select{|k,_v| fetchable_fields.include?(k) } field_set = supplying_relationship_fields(source.class) & relationships.keys relationships.each_with_object({}) do |(name, relationship), hash| @@ -317,7 +317,7 @@ def cached_relationships_hash(source, include_directives) h = source.relationships || {} return h unless include_directives.has_key?(:include_related) - relationships = source.resource_klass._relationships.select do |k,v| + relationships = source.resource_klass._relationships.select do |k,_v| source.fetchable_fields.include?(k) end From 23ea59ef240f5566258fec407db755a6eb56f603 Mon Sep 17 00:00:00 2001 From: Doug Orleans Date: Mon, 3 Apr 2017 12:28:43 -0400 Subject: [PATCH 042/237] Handle empty include= parameter. --- lib/jsonapi/request_parser.rb | 2 +- test/unit/jsonapi_request/jsonapi_request_test.rb | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/jsonapi/request_parser.rb b/lib/jsonapi/request_parser.rb index 2da0d9405..a80b971ac 100644 --- a/lib/jsonapi/request_parser.rb +++ b/lib/jsonapi/request_parser.rb @@ -345,7 +345,7 @@ def parse_include_directives(resource_klass, raw_include) included_resources = [] begin - included_resources += raw_include.is_a?(Array) ? raw_include : CSV.parse_line(raw_include) + included_resources += raw_include.is_a?(Array) ? raw_include : CSV.parse_line(raw_include) || [] rescue CSV::MalformedCSVError fail JSONAPI::Exceptions::InvalidInclude.new(format_key(resource_klass._type), raw_include) end diff --git a/test/unit/jsonapi_request/jsonapi_request_test.rb b/test/unit/jsonapi_request/jsonapi_request_test.rb index 325e58302..f2f4d2e59 100644 --- a/test/unit/jsonapi_request/jsonapi_request_test.rb +++ b/test/unit/jsonapi_request/jsonapi_request_test.rb @@ -42,6 +42,11 @@ def test_parse_includes_underscored assert request.errors.empty? end + def test_parse_blank_includes + include_directives = JSONAPI::RequestParser.new.parse_include_directives(nil, '') + assert_empty include_directives.model_includes + end + def test_parse_dasherized_with_dasherized_include params = ActionController::Parameters.new( { From de02b1a435f7f98162b31a5716af5f3c543fca1b Mon Sep 17 00:00:00 2001 From: David Simon Date: Fri, 7 Apr 2017 12:17:40 -0400 Subject: [PATCH 043/237] Fix issue with caching nil 'fragments' excluded by custom records methods --- lib/jsonapi/active_record_accessor.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/jsonapi/active_record_accessor.rb b/lib/jsonapi/active_record_accessor.rb index 18ae2abaf..660b2dda4 100644 --- a/lib/jsonapi/active_record_accessor.rb +++ b/lib/jsonapi/active_record_accessor.rb @@ -477,7 +477,10 @@ def preload_included_fragments(resources, records, serializer, options) 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) + fragment = target_resources[klass.name].fetch(rel_id) + if fragment + assoc_rels[rel_id] = fragment + end else res = assoc_rels[rel_id] end From 75ccea08a41d010559bd65a1a419b350a0580396 Mon Sep 17 00:00:00 2001 From: David Simon Date: Tue, 11 Apr 2017 13:18:47 -0400 Subject: [PATCH 044/237] Add test for cache pollution bug --- test/controllers/controller_test.rb | 17 +++++++++++++++++ test/fixtures/active_record.rb | 23 ++++++++++++++++++++++- test/fixtures/book_authors.yml | 4 ++++ test/test_helper.rb | 1 + 4 files changed, 44 insertions(+), 1 deletion(-) diff --git a/test/controllers/controller_test.rb b/test/controllers/controller_test.rb index 20fa914f6..2f4909f48 100644 --- a/test/controllers/controller_test.rb +++ b/test/controllers/controller_test.rb @@ -3667,6 +3667,23 @@ def test_show_author_recursive end end +class Api::V2::AuthorsControllerTest < ActionController::TestCase + def test_cache_pollution_for_non_admin_indirect_access_to_banned_books + cache = ActiveSupport::Cache::MemoryStore.new + with_resource_caching(cache) do + $test_user = Person.find(5) + get :show, params: {id: '2', include: 'books'} + assert_response :success + assert_equal 2, json_response['included'].length + + $test_user = Person.find(1) + get :show, params: {id: '2', include: 'books'} + assert_response :success + assert_equal 1, json_response['included'].length + end + end +end + class Api::BoxesControllerTest < ActionController::TestCase def test_complex_includes_base assert_cacheable_get :index diff --git a/test/fixtures/active_record.rb b/test/fixtures/active_record.rb index 62326f732..b92beaf92 100644 --- a/test/fixtures/active_record.rb +++ b/test/fixtures/active_record.rb @@ -798,6 +798,9 @@ class LikesController < JSONAPI::ResourceController module V2 class AuthorsController < JSONAPI::ResourceController + def context + {current_user: $test_user} + end end class PeopleController < JSONAPI::ResourceController @@ -1448,6 +1451,24 @@ class PreferencesResource < PreferencesResource; end class PersonResource < PersonResource; end class PostResource < PostResource; end + class AuthorResource < JSONAPI::Resource + model_name 'Person' + attributes :name + + has_many :books, inverse_relationship: :authors + + def records_for(rel_name) + records = _model.public_send(rel_name) + if rel_name == :books + # Hide indirect access to banned books unless current user is a book admin + unless context[:current_user].try(:book_admin) + records = records.where(banned: false) + end + end + return records + end + end + class BookResource < JSONAPI::Resource attribute :title attributes :isbn, :banned @@ -1483,7 +1504,7 @@ def records(options = {}) context = options[:context] current_user = context ? context[:current_user] : nil - records = _model_class + records = _model_class.all # Hide the banned books from people who are not book admins unless current_user && current_user.book_admin records = records.where(not_banned_books) diff --git a/test/fixtures/book_authors.yml b/test/fixtures/book_authors.yml index 12af5bd16..3b7c3787e 100644 --- a/test/fixtures/book_authors.yml +++ b/test/fixtures/book_authors.yml @@ -9,3 +9,7 @@ book_author_2_1: book_author_2_2: book_id: 2 person_id: 2 + +book_author_654_2: + book_id: 654 # Banned book + person_id: 2 diff --git a/test/test_helper.rb b/test/test_helper.rb index cb1d47991..256258a5a 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -289,6 +289,7 @@ class CatResource < JSONAPI::Resource jsonapi_resource :preferences, except: [:create, :destroy] + jsonapi_resources :authors jsonapi_resources :books jsonapi_resources :book_comments end From 544a53653cbbb584965ef787c7b4fbf172e59b85 Mon Sep 17 00:00:00 2001 From: David Simon Date: Tue, 11 Apr 2017 13:19:06 -0400 Subject: [PATCH 045/237] Fixed pollution of cache with related entries from other contexts, simplified ActiveRecordAccessor#preload_included_fragments somewhat --- lib/jsonapi/active_record_accessor.rb | 239 +++++++++++++------------- lib/jsonapi/include_directives.rb | 2 +- lib/jsonapi/resource_serializer.rb | 2 +- 3 files changed, 117 insertions(+), 126 deletions(-) diff --git a/lib/jsonapi/active_record_accessor.rb b/lib/jsonapi/active_record_accessor.rb index 660b2dda4..0f5312bdb 100644 --- a/lib/jsonapi/active_record_accessor.rb +++ b/lib/jsonapi/active_record_accessor.rb @@ -112,25 +112,25 @@ def foreign_keys(resource, relationship_name, options = {}) def find_serialized_with_caching(filters_or_source, serializer, options = {}) if filters_or_source.is_a?(ActiveRecord::Relation) return cached_resources_for(filters_or_source, serializer, options) - elsif _resource_klass._model_class.respond_to?(:all) && _resource_klass._model_class.respond_to?(:arel_table) + elsif resource_class_based_on_active_record?(_resource_klass) records = find_records(filters_or_source, options.except(:include_directives)) return cached_resources_for(records, serializer, options) else # :nocov: - warn('Caching enabled on model that does not support ActiveRelation') + warn('Caching enabled on model not based on ActiveRecord API or similar') # :nocov: end end def find_by_key_serialized_with_caching(key, serializer, options = {}) - if _resource_klass._model_class.respond_to?(:all) && _resource_klass._model_class.respond_to?(:arel_table) + if resource_class_based_on_active_record?(_resource_klass) results = find_serialized_with_caching({ _resource_klass._primary_key => key }, serializer, options) result = results.first fail JSONAPI::Exceptions::RecordNotFound.new(key) if result.nil? return result else # :nocov: - warn('Caching enabled on model that does not support ActiveRelation') + warn('Caching enabled on model not based on ActiveRecord API or similar') # :nocov: end end @@ -339,7 +339,14 @@ def cached_resources_for(records, serializer, options) resources = _resource_klass.resources_for(records, options[:context]).map { |r| [r.id, r] }.to_h end - preload_included_fragments(resources, records, serializer, options) + if options[:include_directives] + resource_pile = { _resource_klass.name => resources } + options[:include_directives].all_paths.each do |path| + # Note that `all_paths` returns shorter paths first, so e.g. the partial fragments for + # posts.comments will exist before we start working with posts.comments.author + preload_included_fragments(_resource_klass, resource_pile, path, serializer, options) + end + end resources.values end @@ -366,137 +373,121 @@ def find_records(filters, options = {}) end end - def preload_included_fragments(resources, records, serializer, options) - return if resources.empty? - res_ids = resources.keys + def preload_included_fragments(src_res_class, resource_pile, path, serializer, options) + src_resources = resource_pile[src_res_class.name] + return if src_resources.nil? || src_resources.empty? + + rel_name = path.first + relationship = src_res_class._relationships[rel_name] + if relationship.polymorphic + # FIXME Preloading through a polymorphic belongs_to association is not implemented. + # For now, in this case, ResourceSerializer will have to do the fetch itself, without + # using either the cache or eager-loading. + return + end - include_directives = options[:include_directives] - return unless include_directives - - context = options[:context] - - # For each association, including indirect associations, find the target record ids. - # Even if a target class doesn't have caching enabled, we still have to look up - # and match the target ids here, because we can't use ActiveRecord#includes. - # - # Note that `paths` returns partial paths before complete paths, so e.g. the partial - # fragments for posts.comments will exist before we start working with posts.comments.author - target_resources = {} - include_directives.paths.each do |path| - # If path is [:posts, :comments, :author], then... - pluck_attrs = [] # ...will be [posts.id, comments.id, authors.id, authors.updated_at] - pluck_attrs << _resource_klass._model_class.arel_table[_resource_klass._primary_key] - - relation = records - .except(:limit, :offset, :order) - .where({ _resource_klass._primary_key => res_ids }) - - # These are updated as we iterate through the association path; afterwards they will - # refer to the final resource on the path, i.e. the actual resource to find in the cache. - # So e.g. if path is [:posts, :comments, :author], then after iteration... - parent_klass = nil # Comment - klass = _resource_klass # Person - relationship = nil # JSONAPI::Relationship::ToOne for CommentResource.author - table = nil # people - assocs_path = [] # [ :posts, :approved_comments, :author ] - ar_hash = nil # { :posts => { :approved_comments => :author } } - - # For each step on the path, figure out what the actual table name/alias in the join - # will be, and include the primary key of that table in our list of fields to select - non_polymorphic = true - path.each do |elem| - relationship = klass._relationships[elem] - if relationship.polymorphic - # Can't preload through a polymorphic belongs_to association, ResourceSerializer - # will just have to bypass the cache and load the real Resource. - non_polymorphic = false - break - end - assocs_path << relationship.relation_name(options).to_sym - # Converts [:a, :b, :c] to Rails-style { :a => { :b => :c }} - ar_hash = assocs_path.reverse.reduce { |memo, step| { step => memo } } - # We can't just look up the table name from the resource class, because Arel could - # have used a table alias if the relation includes a self-reference. - join_source = relation.joins(ar_hash).arel.source.right.reverse.find do |arel_node| - arel_node.is_a?(Arel::Nodes::InnerJoin) - end - table = join_source.left - parent_klass = klass - klass = relationship.resource_klass - pluck_attrs << table[klass._primary_key] - end - next unless non_polymorphic - - # Pre-fill empty hashes for each resource up to the end of the path. - # This allows us to later distinguish between a preload that returned nothing - # vs. a preload that never ran. - prefilling_resources = resources.values - path.each do |rel_name| - rel_name = serializer.key_formatter.format(rel_name) - prefilling_resources.map! do |res| - res.preloaded_fragments[rel_name] ||= {} - res.preloaded_fragments[rel_name].values - end - prefilling_resources.flatten!(1) - end + tgt_res_class = relationship.resource_klass + unless resource_class_based_on_active_record?(tgt_res_class) + # Can't preload relationships from non-AR resources, this association will be filled + # in on-demand later by ResourceSerializer. + return + 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 + # Assume for longer paths that the intermediate fragments have already been preloaded + if path.length > 1 + preload_included_fragments(tgt_res_class, resource_pile, path.drop(1), serializer, options) + return + end - # [[post id, comment id, author id, author updated_at], ...] - id_rows = pluck_arel_attributes(relation.joins(ar_hash), *pluck_attrs) + record_source = src_res_class._model_class + .where({ src_res_class._primary_key => src_resources.keys }) + .joins(relationship.relation_name(options).to_sym) - target_resources[klass.name] ||= {} + 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. :-( + fake_model_instance = src_res_class._model_class.new + record_source = record_source.order(fake_model_instance.send(rel_name).arel.orders) + end - 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 + # Pre-fill empty fragment hashes. + # This allows us to later distinguish between a preload that returned nothing + # vs. a preload that never ran. + serialized_rel_name = serializer.key_formatter.format(rel_name) + src_resources.each do |key, res| + res.preloaded_fragments[serialized_rel_name] ||= {} + 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 - fragment = target_resources[klass.name].fetch(rel_id) - if fragment - assoc_rels[rel_id] = fragment - end - else - res = assoc_rels[rel_id] - end - end - end + # We can't just look up the table name from the target class, because Arel could + # have used a table alias if the relation is a self-reference. + join_node = record_source.arel.source.right.reverse.find do |arel_node| + arel_node.is_a?(Arel::Nodes::InnerJoin) + end + tgt_table = join_node.left + + # Resource class may restrict current user to a subset of available records + if tgt_res_class.respond_to?(:records) + valid_tgts_rel = tgt_res_class.records(options) + valid_tgts_rel = valid_tgts_rel.all if valid_tgts_rel.respond_to?(:all) + conn = valid_tgts_rel.connection + tgt_attr = tgt_table[tgt_res_class._primary_key] + + # Alter a normal AR query to select only the primary key instead of all columns. + # Sadly doing direct string manipulation of query here, cannot use ARel for this due to + # bind values being stripped from AR::Relation#arel in Rails >= 4.2, see + # https://github.com/rails/arel/issues/363 + valid_tgts_query = valid_tgts_rel.to_sql.sub('*', conn.quote_column_name(tgt_attr.name)) + valid_tgts_cond = "#{quote_arel_attribute(conn, tgt_attr)} IN (#{valid_tgts_query})" + + record_source = record_source.where(valid_tgts_cond) + end + + pluck_attrs = [ + src_res_class._model_class.arel_table[src_res_class._primary_key], + tgt_table[tgt_res_class._primary_key] + ] + pluck_attrs << tgt_table[tgt_res_class._cache_field] if tgt_res_class.caching? + + id_rows = pluck_arel_attributes(record_source, *pluck_attrs) + + target_resources = resource_pile[tgt_res_class.name] ||= {} + + if tgt_res_class.caching? + sub_cache_ids = id_rows.map{ |row| row.last(2) }.uniq.reject{|p| target_resources.has_key?(p[0]) } + target_resources.merge! CachedResourceFragment.fetch_fragments( + tgt_res_class, serializer, options[:context], sub_cache_ids + ) + else + sub_res_ids = id_rows.map(&:last).uniq - target_resources.keys + recs = tgt_res_class.find({ tgt_res_class._primary_key => sub_res_ids }, context: options[:context]) + target_resources.merge!(recs.map{ |r| [r.id, r] }.to_h) + end + + id_rows.each do |row| + src_id, tgt_id = row[0], row[1] + src_res = src_resources[src_id] + next unless src_res + fragment = target_resources[tgt_id] + next unless fragment + src_res.preloaded_fragments[serialized_rel_name][tgt_id] = fragment 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 + quoted_attrs = attrs.map{|attr| quote_arel_attribute(conn, attr) } relation.pluck(*quoted_attrs) end + + def quote_arel_attribute(connection, attr) + quoted_table = connection.quote_table_name(attr.relation.table_alias || attr.relation.name) + quoted_column = connection.quote_column_name(attr.name) + "#{quoted_table}.#{quoted_column}" + end + + def resource_class_based_on_active_record?(klass) + model_class = klass._model_class + model_class.respond_to?(:all) && model_class.respond_to?(:arel_table) + end end end diff --git a/lib/jsonapi/include_directives.rb b/lib/jsonapi/include_directives.rb index 60f8bd68e..f5974de2a 100644 --- a/lib/jsonapi/include_directives.rb +++ b/lib/jsonapi/include_directives.rb @@ -36,7 +36,7 @@ def model_includes get_includes(@include_directives_hash) end - def paths + def all_paths delve_paths(get_includes(@include_directives_hash, false)) end diff --git a/lib/jsonapi/resource_serializer.rb b/lib/jsonapi/resource_serializer.rb index a0a30c7a2..bf088aa77 100644 --- a/lib/jsonapi/resource_serializer.rb +++ b/lib/jsonapi/resource_serializer.rb @@ -323,7 +323,7 @@ def cached_relationships_hash(source, include_directives) real_res = nil relationships.each do |rel_name, relationship| - key = @key_formatter.format(rel_name) + key = format_key(rel_name) to_many = relationship.is_a? JSONAPI::Relationship::ToMany ia = include_directives[:include_related][rel_name] From e02c3785d2a7d42d33c7ba1b77cf61bc96cf483d Mon Sep 17 00:00:00 2001 From: David Simon Date: Thu, 13 Apr 2017 13:12:15 -0400 Subject: [PATCH 046/237] Correctly handle errors from process_operations callbacks --- lib/jsonapi/acts_as_resource_controller.rb | 74 ++++++++-------------- 1 file changed, 26 insertions(+), 48 deletions(-) diff --git a/lib/jsonapi/acts_as_resource_controller.rb b/lib/jsonapi/acts_as_resource_controller.rb index f95efffca..75e64e755 100644 --- a/lib/jsonapi/acts_as_resource_controller.rb +++ b/lib/jsonapi/acts_as_resource_controller.rb @@ -10,8 +10,7 @@ def self.included(base) base.include Callbacks base.cattr_reader :server_error_callbacks base.define_jsonapi_resources_callbacks :process_operations, - :transaction, - :rollback + :transaction end attr_reader :response_document @@ -76,37 +75,30 @@ def process_request transactional = request_parser.transactional? - force_rollback = false - run_in_transaction(transactional) do - begin + begin + run_in_transaction(transactional) do run_callbacks :process_operations do - begin - request_parser.each(response_document) do |op| - op.options[:serializer] = resource_serializer_klass.new( - op.resource_klass, - include_directives: op.options[:include_directives], - fields: op.options[:fields], - base_url: base_url, - key_formatter: key_formatter, - route_formatter: route_formatter, - serialization_options: serialization_options - ) - op.options[:cache_serializer_output] = !JSONAPI.configuration.resource_cache.nil? - - process_operation(op) - end - rescue => e - handle_exceptions(e) + request_parser.each(response_document) do |op| + op.options[:serializer] = resource_serializer_klass.new( + op.resource_klass, + include_directives: op.options[:include_directives], + fields: op.options[:fields], + base_url: base_url, + key_formatter: key_formatter, + route_formatter: route_formatter, + serialization_options: serialization_options + ) + op.options[:cache_serializer_output] = !JSONAPI.configuration.resource_cache.nil? + + process_operation(op) end end - rescue => e - force_rollback = true - raise e - ensure - if response_document.has_errors? || force_rollback - rollback_transaction(transactional) + if response_document.has_errors? + raise ActiveRecord::Rollback end end + rescue => e + handle_exceptions(e) end render_response_document end @@ -114,19 +106,15 @@ def process_request def run_in_transaction(transactional) if transactional run_callbacks :transaction do - transaction do + ActiveRecord::Base.transaction do yield end end else - yield - end - end - - def rollback_transaction(transactional) - if transactional - run_callbacks :rollback do - rollback + begin + yield + rescue ActiveRecord::Rollback + # Can't rollback without transaction, so just ignore it end end end @@ -136,16 +124,6 @@ def process_operation(operation) response_document.add_result(result, operation) end - def transaction - ActiveRecord::Base.transaction do - yield - end - end - - def rollback - fail ActiveRecord::Rollback - end - private def resource_klass @@ -276,7 +254,7 @@ def handle_exceptions(e) errors = JSONAPI::Exceptions::ParameterMissing.new(e.param).errors else if JSONAPI.configuration.exception_class_whitelisted?(e) - fail e + raise e else if self.class.server_error_callbacks self.class.server_error_callbacks.each { |callback| From 53edcf9fe5cd0f24c718e48389b32e17723e6c6e Mon Sep 17 00:00:00 2001 From: David Simon Date: Fri, 14 Apr 2017 09:56:43 -0400 Subject: [PATCH 047/237] Set version constant to 0.10.0.pre --- lib/jsonapi/resources/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/jsonapi/resources/version.rb b/lib/jsonapi/resources/version.rb index 631ac0c72..c71b0694d 100644 --- a/lib/jsonapi/resources/version.rb +++ b/lib/jsonapi/resources/version.rb @@ -1,5 +1,5 @@ module JSONAPI module Resources - VERSION = '0.9.0.pre' + VERSION = '0.10.0.pre' end end From bbebf892dcd517cf4e41759342b3f56a7b258b3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vanja=20Radovanovi=C4=87?= Date: Fri, 14 Apr 2017 16:56:44 +0200 Subject: [PATCH 048/237] Fix nested namespaces parsing and resource resolving --- lib/jsonapi/request_parser.rb | 2 +- lib/jsonapi/resource.rb | 2 +- test/unit/resource/resource_test.rb | 13 +++++++++++++ 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/lib/jsonapi/request_parser.rb b/lib/jsonapi/request_parser.rb index a80b971ac..3ef6099ee 100644 --- a/lib/jsonapi/request_parser.rb +++ b/lib/jsonapi/request_parser.rb @@ -529,7 +529,7 @@ def parse_to_one_relationship(resource_klass, link_value, relationship) unless links_object[:id].nil? resource = resource_klass || Resource - relationship_resource = resource.resource_klass_for(unformat_key(links_object[:type]).to_s) + relationship_resource = resource.resource_klass_for(unformat_key(relationship.options[:class_name] || links_object[:type]).to_s) relationship_id = relationship_resource.verify_key(links_object[:id], @context) if relationship.polymorphic? { id: relationship_id, type: unformat_key(links_object[:type].to_s) } diff --git a/lib/jsonapi/resource.rb b/lib/jsonapi/resource.rb index fac409177..88003c1e6 100644 --- a/lib/jsonapi/resource.rb +++ b/lib/jsonapi/resource.rb @@ -446,7 +446,7 @@ def rebuild_relationships(relationships) def resource_klass_for(type) type = type.underscore - type_with_module = type.include?('/') ? type : module_path + type + 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 diff --git a/test/unit/resource/resource_test.rb b/test/unit/resource/resource_test.rb index d1dd28d8a..6ced13d70 100644 --- a/test/unit/resource/resource_test.rb +++ b/test/unit/resource/resource_test.rb @@ -85,11 +85,18 @@ module MyModule class MyNamespacedResource < JSONAPI::Resource model_name "Person" has_many :related + has_one :default_profile, class_name: "Nested::Profile" end class RelatedResource < JSONAPI::Resource model_name "Comment" end + + module Nested + class ProfileResource < JSONAPI::Resource + model_name "Nested::Profile" + end + end end module MyAPI @@ -155,6 +162,12 @@ def test_resource_for_namespaced_resource assert_equal(MyModule::MyNamespacedResource.resource_klass_for('related'), MyModule::RelatedResource) end + def test_resource_for_nested_namespaced_resource + assert_equal(JSONAPI::Resource.resource_klass_for('my_module/nested/profile'), MyModule::Nested::ProfileResource) + assert_equal(MyModule::MyNamespacedResource.resource_klass_for('my_module/nested/profile'), MyModule::Nested::ProfileResource) + assert_equal(MyModule::MyNamespacedResource.resource_klass_for('nested/profile'), MyModule::Nested::ProfileResource) + end + def test_relationship_parent_point_to_correct_resource assert_equal MyModule::MyNamespacedResource, MyModule::MyNamespacedResource._relationships[:related].parent_resource end From 1ff2e5c39ad7a6a20865b7a64246ab6f051595d3 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Tue, 18 Apr 2017 15:15:27 -0400 Subject: [PATCH 049/237] Rework apply_filter(s) to allow callables to be called for relationship filters --- lib/jsonapi/active_record_accessor.rb | 25 ++++++++++++++----------- test/fixtures/active_record.rb | 6 +++++- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/lib/jsonapi/active_record_accessor.rb b/lib/jsonapi/active_record_accessor.rb index 0f5312bdb..37df6fa22 100644 --- a/lib/jsonapi/active_record_accessor.rb +++ b/lib/jsonapi/active_record_accessor.rb @@ -267,7 +267,15 @@ def apply_filter(records, filter, value, options = {}) strategy.call(records, value, options) end else - records.where(filter => value) + if _resource_klass._relationships.include?(filter) + if _resource_klass._relationships[filter].belongs_to? + records.where(_resource_klass._relationships[filter].foreign_key => value) + else + records.where("#{_resource_klass._relationships[filter].table_name}.#{_resource_klass._relationships[filter].primary_key}" => value) + end + else + records.where(filter => value) + end end end @@ -301,21 +309,16 @@ def apply_filters(records, filters, options = {}) if filters filters.each do |filter, value| - if _resource_klass._relationships.include?(filter) - if _resource_klass._relationships[filter].belongs_to? - records = apply_filter(records, _resource_klass._relationships[filter].foreign_key, value, options) - else - required_includes.push(filter.to_s) - records = apply_filter(records, "#{_resource_klass._relationships[filter].table_name}.#{_resource_klass._relationships[filter].primary_key}", value, options) - end - else - records = apply_filter(records, filter, value, options) + if _resource_klass._relationships.include?(filter) && !_resource_klass._relationships[filter].belongs_to? + required_includes.push(filter.to_s) end + + records = apply_filter(records, filter, value, options) end end if required_includes.any? - records = apply_includes(records, options.merge(include_directives: IncludeDirectives.new(_resource_klass, required_includes, force_eager_load: true))) + options.merge!(include_directives: IncludeDirectives.new(_resource_klass, required_includes, force_eager_load: true)) end records diff --git a/test/fixtures/active_record.rb b/test/fixtures/active_record.rb index b92beaf92..4ae8b37f2 100644 --- a/test/fixtures/active_record.rb +++ b/test/fixtures/active_record.rb @@ -1488,7 +1488,11 @@ class BookResource < JSONAPI::Resource has_many :aliased_comments, class_name: 'BookComments', relation_name: :approved_book_comments - filters :book_comments + filter :book_comments, + apply: ->(records, value, options) { + return records.where('book_comments.id' => value) + } + filter :banned, apply: :apply_filter_banned class << self From e6240e027d3d4ea72f5f504d50ee9af562f45f24 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Tue, 18 Apr 2017 16:39:30 -0400 Subject: [PATCH 050/237] Convert attribute and relatioship names to symbols Fixes issues when strings are provided #1019 & #1032 --- lib/jsonapi/resource.rb | 9 ++++++--- test/fixtures/active_record.rb | 8 ++++---- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/lib/jsonapi/resource.rb b/lib/jsonapi/resource.rb index fac409177..ddd9ae7d2 100644 --- a/lib/jsonapi/resource.rb +++ b/lib/jsonapi/resource.rb @@ -506,10 +506,12 @@ def attributes(*attrs) end end - def attribute(attr, options = {}) + def attribute(attribute_name, options = {}) + attr = attribute_name.to_sym + check_reserved_attribute_name(attr) - if (attr.to_sym == :id) && (options[:format].nil?) + if (attr == :id) && (options[:format].nil?) ActiveSupport::Deprecation.warn('Id without format is no longer supported. Please remove ids from attributes, or specify a format.') end @@ -903,7 +905,8 @@ def _add_relationship(klass, *attrs) options = attrs.extract_options! options[:parent_resource] = self - attrs.each do |relationship_name| + attrs.each do |name| + relationship_name = name.to_sym check_reserved_relationship_name(relationship_name) check_duplicate_relationship_name(relationship_name) diff --git a/test/fixtures/active_record.rb b/test/fixtures/active_record.rb index b92beaf92..c1893a836 100644 --- a/test/fixtures/active_record.rb +++ b/test/fixtures/active_record.rb @@ -1470,12 +1470,12 @@ def records_for(rel_name) end class BookResource < JSONAPI::Resource - attribute :title + attribute "title" attributes :isbn, :banned - has_many :authors + has_many "authors" - has_many :book_comments, relation_name: -> (options = {}) { + has_many "book_comments", relation_name: -> (options = {}) { context = options[:context] current_user = context ? context[:current_user] : nil @@ -1486,7 +1486,7 @@ class BookResource < JSONAPI::Resource end }, reflect: true - has_many :aliased_comments, class_name: 'BookComments', relation_name: :approved_book_comments + has_many "aliased_comments", class_name: 'BookComments', relation_name: :approved_book_comments filters :book_comments filter :banned, apply: :apply_filter_banned From 49f0aeeb27438d6d44d331e19b82d8d75165a7eb Mon Sep 17 00:00:00 2001 From: Denis Talakevich Date: Tue, 7 Feb 2017 18:05:07 +0200 Subject: [PATCH 051/237] apply_filter and apply_sort respects :delegate option of attribute --- lib/jsonapi/active_record_accessor.rb | 2 ++ lib/jsonapi/resource.rb | 4 ++++ test/controllers/controller_test.rb | 23 +++++++++++++++++++++++ test/fixtures/active_record.rb | 18 ++++++++++++++++++ test/test_helper.rb | 1 + 5 files changed, 48 insertions(+) diff --git a/lib/jsonapi/active_record_accessor.rb b/lib/jsonapi/active_record_accessor.rb index 37df6fa22..4607d5903 100644 --- a/lib/jsonapi/active_record_accessor.rb +++ b/lib/jsonapi/active_record_accessor.rb @@ -225,6 +225,7 @@ def apply_sort(records, order_options, context = {}) order_by_query = "#{associations.last.name}_sorting.#{column_name} #{direction}" records = records.joins(joins_query).order(order_by_query) else + field = _resource_klass._attribute_delegated_name(field) records = records.order(field => direction) end end @@ -274,6 +275,7 @@ def apply_filter(records, filter, value, options = {}) records.where("#{_resource_klass._relationships[filter].table_name}.#{_resource_klass._relationships[filter].primary_key}" => value) end else + filter = _resource_klass._attribute_delegated_name(filter) records.where(filter => value) end end diff --git a/lib/jsonapi/resource.rb b/lib/jsonapi/resource.rb index fac409177..b8520aa5c 100644 --- a/lib/jsonapi/resource.rb +++ b/lib/jsonapi/resource.rb @@ -755,6 +755,10 @@ 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 _updatable_attributes _attributes.map { |key, options| key unless options[:readonly] }.compact end diff --git a/test/controllers/controller_test.rb b/test/controllers/controller_test.rb index 2f4909f48..9764ed916 100644 --- a/test/controllers/controller_test.rb +++ b/test/controllers/controller_test.rb @@ -3757,3 +3757,26 @@ def test_complex_includes_nested_things_secondary_users assert_equal '2', json_response['included'][1]['relationships']['things']['data'][0]['id'] end end + +class BlogPostsControllerTest < ActionController::TestCase + def test_filter_by_delegated_attribute + assert_cacheable_get :index, params: {filter: {name: 'some title'}} + assert_response :success + end + + def test_sorting_by_delegated_attribute + assert_cacheable_get :index, params: {sort: 'name'} + assert_response :success + end + + def test_fields_with_delegated_attribute + original_config = JSONAPI.configuration.dup + JSONAPI.configuration.json_key_format = :underscored_key + + assert_cacheable_get :index, params: {fields: {blog_posts: 'name'}} + assert_response :success + assert_equal ['name'], json_response['data'].first['attributes'].keys + ensure + JSONAPI.configuration = original_config + end +end diff --git a/test/fixtures/active_record.rb b/test/fixtures/active_record.rb index 4ae8b37f2..04bfe0e7f 100644 --- a/test/fixtures/active_record.rb +++ b/test/fixtures/active_record.rb @@ -1801,6 +1801,24 @@ class FlatPostResource < JSONAPI::Resource class FlatPostsController < JSONAPI::ResourceController end +class BlogPost < ActiveRecord::Base + self.table_name = 'posts' +end + +class BlogPostsController < JSONAPI::ResourceController + +end + +class BlogPostResource < JSONAPI::Resource + model_name 'BlogPost', add_model_hint: false + model_hint model: 'BlogPost', resource: BlogPostResource + + attribute :name, :delegate => :title + attribute :body + + filter :name +end + # CustomProcessors class Api::V4::BookProcessor < JSONAPI::Processor after_find do diff --git a/test/test_helper.rb b/test/test_helper.rb index 256258a5a..5bb96c98a 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -252,6 +252,7 @@ class CatResource < JSONAPI::Resource jsonapi_resources :cars jsonapi_resources :boats jsonapi_resources :flat_posts + jsonapi_resources :blog_posts jsonapi_resources :books jsonapi_resources :authors From a8123c99bb3f5e7a8b8d7934cafc11751fb7c6ef Mon Sep 17 00:00:00 2001 From: Denis Talakevich Date: Mon, 27 Mar 2017 17:58:31 +0300 Subject: [PATCH 052/237] fixes #1013 require bundler in bug_report_templates_test --- test/bug_report_templates_test.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/test/bug_report_templates_test.rb b/test/bug_report_templates_test.rb index 3e0cca8e0..fd98c0544 100644 --- a/test/bug_report_templates_test.rb +++ b/test/bug_report_templates_test.rb @@ -1,4 +1,5 @@ require File.expand_path('../test_helper', __FILE__) +require 'bundler' class BugReportTemplatesTest < ActiveSupport::TestCase From ec3f673d202c50f67997bbb5358e4205ce51e586 Mon Sep 17 00:00:00 2001 From: Denis Talakevich Date: Tue, 4 Apr 2017 11:51:34 +0300 Subject: [PATCH 053/237] #1013 run bug_report_template rails_5_master in rake default task add rake task test:bug_report_template:rails_5 silence bug_report_templates/rails_5_master.rb when running from rake remove bug_report_templates_test from test suite --- Rakefile | 19 +++++++++++++++- lib/bug_report_templates/rails_5_master.rb | 16 ++++++++++--- test/bug_report_templates_test.rb | 26 ---------------------- 3 files changed, 31 insertions(+), 30 deletions(-) delete mode 100644 test/bug_report_templates_test.rb diff --git a/Rakefile b/Rakefile index 7c629c8a6..f47de1277 100644 --- a/Rakefile +++ b/Rakefile @@ -8,7 +8,7 @@ Rake::TestTask.new do |t| t.test_files = FileList['test/**/*_test.rb'] end -task default: :test +task default: [:test, 'test:bug_report_template:rails_5'] desc 'Run benchmarks' namespace :test do @@ -16,3 +16,20 @@ namespace :test do t.pattern = 'test/benchmark/*_benchmark.rb' end end + +desc 'Test bug report template' +namespace :test do + namespace :bug_report_template do + task :rails_5 do + puts 'Test bug report templates' + jsonapi_resources_root = File.expand_path('..', __FILE__) + chdir_path = File.join(jsonapi_resources_root, 'lib', 'bug_report_templates') + report_env = {'SILENT' => 'true', 'JSONAPI_RESOURCES_PATH' => jsonapi_resources_root} + Bundler.with_clean_env do + Dir.chdir(chdir_path) do + abort('bug report template rails_5_master fails') unless system(report_env, Gem.ruby, 'rails_5_master.rb') + end + end + end + end +end diff --git a/lib/bug_report_templates/rails_5_master.rb b/lib/bug_report_templates/rails_5_master.rb index db29978ce..2e39916ba 100644 --- a/lib/bug_report_templates/rails_5_master.rb +++ b/lib/bug_report_templates/rails_5_master.rb @@ -1,11 +1,12 @@ begin require 'bundler/inline' + require 'bundler' rescue LoadError => e STDERR.puts 'Bundler version 1.10 or later is required. Please update your Bundler' raise e end -gemfile(true) do +gemfile(true, ui: ENV['SILENT'] ? Bundler::UI::Silent.new : Bundler::UI::Shell.new) do source 'https://rubygems.org' gem 'rails', require: false @@ -27,8 +28,17 @@ # prepare active_record database require 'active_record' +class NullLogger < Logger + def initialize(*_args) + end + + def add(*_args, &_block) + end +end + ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:') -ActiveRecord::Base.logger = Logger.new(STDOUT) +ActiveRecord::Base.logger = ENV['SILENT'] ? NullLogger.new : Logger.new(STDOUT) +ActiveRecord::Migration.verbose = !ENV['SILENT'] ActiveRecord::Schema.define do # Add your schema here @@ -61,7 +71,7 @@ class YourModelResource < JSONAPI::Resource class TestApp < Rails::Application config.root = File.dirname(__FILE__) - config.logger = Logger.new(STDOUT) + config.logger = ENV['SILENT'] ? NullLogger.new : Logger.new(STDOUT) Rails.logger = config.logger secrets.secret_token = 'secret_token' diff --git a/test/bug_report_templates_test.rb b/test/bug_report_templates_test.rb deleted file mode 100644 index fd98c0544..000000000 --- a/test/bug_report_templates_test.rb +++ /dev/null @@ -1,26 +0,0 @@ -require File.expand_path('../test_helper', __FILE__) -require 'bundler' - -class BugReportTemplatesTest < ActiveSupport::TestCase - - def jsonapi_resources_root - File.expand_path('../..', __FILE__) - end - - def chdir_path - File.join(jsonapi_resources_root, 'lib', 'bug_report_templates') - end - - def assert_bug_report(file_name) - Bundler.with_clean_env do - Dir.chdir(chdir_path) do - assert system({'JSONAPI_RESOURCES_PATH' => jsonapi_resources_root}, Gem.ruby, file_name) - end - end - end - - def test_rails_5 - assert_bug_report 'rails_5_master.rb' - end - -end From ff646b27aa753199b0aab502677ff23fbe9ca1da Mon Sep 17 00:00:00 2001 From: Olle Jonsson Date: Thu, 20 Apr 2017 13:52:09 +0200 Subject: [PATCH 054/237] Travis: Test on Rails 5.0.2 --- .travis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 80149d962..5251d9847 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,7 +2,7 @@ language: ruby sudo: false env: - "RAILS_VERSION=4.2.7" - - "RAILS_VERSION=5.0.0" + - "RAILS_VERSION=5.0.2" - "RAILS_VERSION=master" rvm: - 2.1.10 @@ -12,7 +12,7 @@ rvm: matrix: exclude: - rvm: 2.1.10 - env: "RAILS_VERSION=5.0.0" + env: "RAILS_VERSION=5.0.2" - rvm: 2.4.1 env: "RAILS_VERSION=4.2.7" allow_failures: From 6f5bdd5d43ce7a8155725b043f63182f52bb899d Mon Sep 17 00:00:00 2001 From: Denis Talakevich Date: Mon, 20 Feb 2017 15:02:06 +0200 Subject: [PATCH 055/237] fixes #982 fix relationship linkage when belongs_to relationship has overridden primary_key when polymorphic relationship has overridden primary_key add tests --- lib/jsonapi/relationship.rb | 4 ++ lib/jsonapi/resource.rb | 6 +- lib/jsonapi/resource_serializer.rb | 2 +- test/fixtures/access_cards.yml | 4 ++ test/fixtures/active_record.rb | 81 ++++++++++++++++++++++- test/fixtures/keepers.yml | 5 ++ test/fixtures/storages.yml | 4 ++ test/fixtures/workers.yml | 4 ++ test/integration/requests/request_test.rb | 44 ++++++++++++ test/test_helper.rb | 3 + 10 files changed, 154 insertions(+), 3 deletions(-) create mode 100644 test/fixtures/access_cards.yml create mode 100644 test/fixtures/keepers.yml create mode 100644 test/fixtures/storages.yml create mode 100644 test/fixtures/workers.yml diff --git a/lib/jsonapi/relationship.rb b/lib/jsonapi/relationship.rb index b56bfaa01..10b274e5a 100644 --- a/lib/jsonapi/relationship.rb +++ b/lib/jsonapi/relationship.rb @@ -64,6 +64,10 @@ def readonly? @options[:readonly] end + def redefined_pkey? + belongs_to? && primary_key != resource_klass._default_primary_key + end + class ToOne < Relationship attr_reader :foreign_key_on diff --git a/lib/jsonapi/resource.rb b/lib/jsonapi/resource.rb index ddd9ae7d2..154ac55b7 100644 --- a/lib/jsonapi/resource.rb +++ b/lib/jsonapi/resource.rb @@ -783,7 +783,11 @@ def _model_name 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 diff --git a/lib/jsonapi/resource_serializer.rb b/lib/jsonapi/resource_serializer.rb index bf088aa77..7cef106c5 100644 --- a/lib/jsonapi/resource_serializer.rb +++ b/lib/jsonapi/resource_serializer.rb @@ -454,7 +454,7 @@ def link_object(source, relationship, include_linkage = false) def foreign_key_value(source, relationship) related_resource_id = if source.preloaded_fragments.has_key?(format_key(relationship.name)) source.preloaded_fragments[format_key(relationship.name)].values.first.try(:id) - elsif source.respond_to?(relationship.foreign_key) + elsif !relationship.redefined_pkey? && !relationship.polymorphic? && source.respond_to?(relationship.foreign_key) # If you have direct access to the underlying id, you don't have to load the relationship # which can save quite a lot of time when loading a lot of data. # This does not apply to e.g. has_one :through relationships. diff --git a/test/fixtures/access_cards.yml b/test/fixtures/access_cards.yml new file mode 100644 index 000000000..a5ca5aea5 --- /dev/null +++ b/test/fixtures/access_cards.yml @@ -0,0 +1,4 @@ +john_doe_worker_card: + id: 1 + token: "some-token" + security_level: "admin" diff --git a/test/fixtures/active_record.rb b/test/fixtures/active_record.rb index 97f2b74e0..7d558076a 100644 --- a/test/fixtures/active_record.rb +++ b/test/fixtures/active_record.rb @@ -310,7 +310,30 @@ t.string :name end - # special cases + create_table :storages, force: true do |t| + t.string :token, null: false + t.string :name + t.timestamps null: false + end + + create_table :keepers, force: true do |t| + t.string :name + t.string :keepable_type, null: false + t.integer :keepable_id, null: false + t.timestamps null: false + end + + create_table :access_cards, force: true do |t| + t.string :token, null: false + t.string :security_level + t.timestamps null: false + end + + create_table :workers, force: true do |t| + t.string :name + t.integer :access_card_id, null: false + t.timestamps null: false + end end ### MODELS @@ -655,6 +678,22 @@ class Customer < Customer end end +class Storage < ActiveRecord::Base + has_one :keeper, class_name: 'Keeper', as: :keepable +end + +class Keeper < ActiveRecord::Base + belongs_to :keepable, polymorphic: true +end + +class AccessCard < ActiveRecord::Base + has_one :worker, class_name: 'Worker' +end + +class Worker < ActiveRecord::Base + belongs_to :access_card +end + ### CONTROLLERS class AuthorsController < JSONAPI::ResourceControllerMetal end @@ -930,6 +969,17 @@ class DoctorsController < JSONAPI::ResourceController class RespondentController < JSONAPI::ResourceController end +class StoragesController < BaseController +end + +class KeepersController < BaseController +end + +class AccessCardsController < BaseController +end + +class WorkersController < BaseController +end ### RESOURCES class BaseResource < JSONAPI::Resource abstract @@ -1894,6 +1944,35 @@ class RespondentResource < JSONAPI::Resource abstract end +class StorageResource < JSONAPI::Resource + key_type :string + primary_key :token + + attribute :name +end + +class KeeperResource < JSONAPI::Resource + has_one :keepable, polymorphic: true, foreign_key: :keepable_id + + attribute :name +end + +class KeepableResource < JSONAPI::Resource +end + +class AccessCardResource < JSONAPI::Resource + key_type :string + primary_key :token + + attribute :security_level +end + +class WorkerResource < JSONAPI::Resource + has_one :access_card + + attribute :name +end + ### PORO Data - don't do this in a production app $breed_data = BreedData.new $breed_data.add(Breed.new(0, 'persian')) diff --git a/test/fixtures/keepers.yml b/test/fixtures/keepers.yml new file mode 100644 index 000000000..6612cbd27 --- /dev/null +++ b/test/fixtures/keepers.yml @@ -0,0 +1,5 @@ +john_doe: + id: 1 + name: "John Doe" + keepable_id: 1 # storages.yml warehouse_1 + keepable_type: "Storage" diff --git a/test/fixtures/storages.yml b/test/fixtures/storages.yml new file mode 100644 index 000000000..50f889c80 --- /dev/null +++ b/test/fixtures/storages.yml @@ -0,0 +1,4 @@ +warehouse_1: + id: 1 + name: "Warehouse 1" + token: "some-token" diff --git a/test/fixtures/workers.yml b/test/fixtures/workers.yml new file mode 100644 index 000000000..ce0f82c81 --- /dev/null +++ b/test/fixtures/workers.yml @@ -0,0 +1,4 @@ +john_doe_worker: + id: 1 + name: "John Doe" + access_card_id: 1 # access_cards.yml john_doe_worker_card diff --git a/test/integration/requests/request_test.rb b/test/integration/requests/request_test.rb index 8a27de962..c801aae41 100644 --- a/test/integration/requests/request_test.rb +++ b/test/integration/requests/request_test.rb @@ -1102,4 +1102,48 @@ def test_getting_resource_with_correct_type_when_sti assert_cacheable_jsonapi_get '/vehicles/1' assert_equal 'cars', json_response['data']['type'] end + + def test_get_resource_with_polymorphic_relationship_and_changed_primary_key + keeper = Keeper.find(1) + storage = keeper.keepable + assert_cacheable_jsonapi_get '/keepers/1?include=keepable' + assert_jsonapi_response 200 + + data = json_response['data'] + refute_nil data + assert_equal keeper.id.to_s, data['id'] + + refute_nil data['relationships'] + refute_nil data['relationships']['keepable'] + refute_nil data['relationships']['keepable']['data'] + assert_equal 'storages', data['relationships']['keepable']['data']['type'] + assert_equal storage.token, data['relationships']['keepable']['data']['id'] + + included = json_response['included'] + refute_nil included + assert_equal 'storages', included.first['type'] + assert_equal storage.token, included.first['id'] + end + + def test_get_resource_with_belongs_to_relationship_and_changed_primary_key + worker = Worker.find(1) + access_card = worker.access_card + assert_cacheable_jsonapi_get '/workers/1?include=access_card' + assert_jsonapi_response 200 + + data = json_response['data'] + refute_nil data + assert_equal worker.id.to_s, data['id'] + + refute_nil data['relationships'] + refute_nil data['relationships']['access_card'] + refute_nil data['relationships']['access_card']['data'] + assert_equal 'access_cards', data['relationships']['access_card']['data']['type'] + assert_equal access_card.token, data['relationships']['access_card']['data']['id'] + + included = json_response['included'] + refute_nil included + assert_equal 'access_cards', included.first['type'] + assert_equal access_card.token, included.first['id'] + end end diff --git a/test/test_helper.rb b/test/test_helper.rb index 256258a5a..744acc14a 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -377,6 +377,9 @@ class CatResource < JSONAPI::Resource end end + jsonapi_resources :keepers, only: [:show] + jsonapi_resources :workers, only: [:show] + mount MyEngine::Engine => "/boomshaka", as: :my_engine mount ApiV2Engine::Engine => "/api_v2", as: :api_v2_engine end From 216cb7084c94843d4d71f519390ebfaaa1261295 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Kwa=C5=9Bniak?= Date: Fri, 21 Apr 2017 19:00:49 +0200 Subject: [PATCH 056/237] Fix apply sort --- lib/jsonapi/active_record_accessor.rb | 38 ++++++++++----------- test/unit/resource/resource_test.rb | 49 ++++++++++++++------------- 2 files changed, 44 insertions(+), 43 deletions(-) diff --git a/lib/jsonapi/active_record_accessor.rb b/lib/jsonapi/active_record_accessor.rb index 37df6fa22..b9a9ccfdd 100644 --- a/lib/jsonapi/active_record_accessor.rb +++ b/lib/jsonapi/active_record_accessor.rb @@ -210,28 +210,26 @@ def apply_pagination(records, paginator, order_options) end def apply_sort(records, order_options, context = {}) - if defined?(_resource_klass.apply_sort) - _resource_klass.apply_sort(records, order_options, context) - else - if order_options.any? - order_options.each_pair do |field, direction| - if field.to_s.include?(".") - *model_names, column_name = field.split(".") - - associations = _lookup_association_chain([records.model.to_s, *model_names]) - joins_query = _build_joins([records.model, *associations]) - - # _sorting is appended to avoid name clashes with manual joins eg. overridden filters - order_by_query = "#{associations.last.name}_sorting.#{column_name} #{direction}" - records = records.joins(joins_query).order(order_by_query) - else - records = records.order(field => direction) - end + 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 - - records end + + return records unless defined?(_resource_klass.apply_sort) + custom_sort = _resource_klass.apply_sort(records, order_options, context) + custom_sort.nil? ? records : custom_sort end def _lookup_association_chain(model_names) @@ -472,7 +470,7 @@ def preload_included_fragments(src_res_class, resource_pile, path, serializer, o next unless src_res fragment = target_resources[tgt_id] next unless fragment - src_res.preloaded_fragments[serialized_rel_name][tgt_id] = fragment + src_res.preloaded_fragments[serialized_rel_name][tgt_id] = fragment end end diff --git a/test/unit/resource/resource_test.rb b/test/unit/resource/resource_test.rb index d1dd28d8a..448038263 100644 --- a/test/unit/resource/resource_test.rb +++ b/test/unit/resource/resource_test.rb @@ -378,12 +378,36 @@ def apply_filters(records, filters, options) end end + def test_custom_sorting + post_resource = PostResource.new(Post.find(1), nil) + comment_ids = post_resource.comments.map{|c| c._model.id } + assert_equal [1,2], comment_ids + + # define apply_sort method on post resource that will never sort + PostResource.instance_eval do + def apply_sort(records, criteria, context = {}) + if criteria.key?('name') + # this sort will never occure + records.order('name asc') + end + end + end + + sorted_comment_ids = post_resource.comments(sort_criteria: [{ field: 'id', direction: :desc}]).map{|c| c._model.id } + assert_equal [2,1], sorted_comment_ids + ensure + # reset method to original implementation + PostResource.instance_eval do + undef :apply_sort + end + end + def test_to_many_relationship_sorts post_resource = PostResource.new(Post.find(1), nil) comment_ids = post_resource.comments.map{|c| c._model.id } assert_equal [1,2], comment_ids - # define apply_filters method on post resource to sort descending + # define apply_sort method on post resource to sort descending PostResource.instance_eval do def apply_sort(records, criteria, context = {}) # :nocov: @@ -399,28 +423,7 @@ def apply_sort(records, criteria, context = {}) ensure # reset method to original implementation PostResource.instance_eval do - def apply_sort(records, order_options, _context = {}) - # :nocov: - if order_options.any? - order_options.each_pair do |field, direction| - if field.to_s.include?(".") - *model_names, column_name = field.split(".") - - associations = _lookup_association_chain([records.model.to_s, *model_names]) - joins_query = _record_accessor._build_joins([records.model, *associations]) - - # _sorting is appended to avoid name clashes with manual joins eg. overriden filters - order_by_query = "#{associations.last.name}_sorting.#{column_name} #{direction}" - records = records.joins(joins_query).order(order_by_query) - else - records = records.order(field => direction) - end - end - end - - records - # :nocov: - end + undef :apply_sort end end From bc6ad3c1facb97c1aa01ca08bdf841583a46033b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Kwa=C5=9Bniak?= Date: Tue, 25 Apr 2017 13:17:40 +0200 Subject: [PATCH 057/237] Fix order of applies --- lib/jsonapi/active_record_accessor.rb | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/lib/jsonapi/active_record_accessor.rb b/lib/jsonapi/active_record_accessor.rb index b9a9ccfdd..f397d4ff1 100644 --- a/lib/jsonapi/active_record_accessor.rb +++ b/lib/jsonapi/active_record_accessor.rb @@ -210,6 +210,11 @@ def apply_pagination(records, paginator, order_options) end def apply_sort(records, order_options, context = {}) + if defined?(_resource_klass.apply_sort) + custom_sort = _resource_klass.apply_sort(records, order_options, context) + records = custom_sort unless custom_sort.nil? + end + if order_options.any? order_options.each_pair do |field, direction| if field.to_s.include?(".") @@ -227,9 +232,7 @@ def apply_sort(records, order_options, context = {}) end end - return records unless defined?(_resource_klass.apply_sort) - custom_sort = _resource_klass.apply_sort(records, order_options, context) - custom_sort.nil? ? records : custom_sort + records end def _lookup_association_chain(model_names) From 51570ad56ced1086fd2795ab642b8dc1ef0684f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Kwa=C5=9Bniak?= Date: Tue, 25 Apr 2017 14:42:31 +0200 Subject: [PATCH 058/237] Refactor --- lib/jsonapi/active_record_accessor.rb | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/jsonapi/active_record_accessor.rb b/lib/jsonapi/active_record_accessor.rb index f397d4ff1..29660f912 100644 --- a/lib/jsonapi/active_record_accessor.rb +++ b/lib/jsonapi/active_record_accessor.rb @@ -212,9 +212,13 @@ def apply_pagination(records, paginator, order_options) def apply_sort(records, order_options, context = {}) if defined?(_resource_klass.apply_sort) custom_sort = _resource_klass.apply_sort(records, order_options, context) - records = custom_sort unless custom_sort.nil? + custom_sort.nil? ? default_sort(records, order_options) : custom_sort + else + default_sort(records, order_options) end + end + def default_sort(records, order_options) if order_options.any? order_options.each_pair do |field, direction| if field.to_s.include?(".") From e2fd4bda5f51ac040f40db6a4b625bea1a791c9c Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Wed, 3 May 2017 20:46:16 -0400 Subject: [PATCH 059/237] Tightens the error handling on include parsing. Now fails on the first error encountered. --- lib/jsonapi/include_directives.rb | 2 +- lib/jsonapi/request_parser.rb | 18 +++++++++++------- test/controllers/controller_test.rb | 13 ++++++++++++- .../unit/serializer/include_directives_test.rb | 18 ++++++++++++++++++ 4 files changed, 42 insertions(+), 9 deletions(-) diff --git a/lib/jsonapi/include_directives.rb b/lib/jsonapi/include_directives.rb index f5974de2a..12b24d4fe 100644 --- a/lib/jsonapi/include_directives.rb +++ b/lib/jsonapi/include_directives.rb @@ -52,7 +52,7 @@ def get_related(current_path) current_relationship = current_resource_klass._relationships[fragment] current_resource_klass = current_relationship.try(:resource_klass) else - warn "[RELATIONSHIP NOT FOUND] Relationship could not be found for #{current_path}." + raise JSONAPI::Exceptions::InvalidInclude.new(current_resource_klass, current_path) end include_in_join = @force_eager_load || !current_relationship || current_relationship.eager_load_on_include diff --git a/lib/jsonapi/request_parser.rb b/lib/jsonapi/request_parser.rb index a80b971ac..c7dea78d5 100644 --- a/lib/jsonapi/request_parser.rb +++ b/lib/jsonapi/request_parser.rb @@ -331,8 +331,7 @@ def check_include(resource_klass, include_parts) include_parts.last.partition('.')) end else - @errors.concat(JSONAPI::Exceptions::InvalidInclude.new(format_key(resource_klass._type), - include_parts.first).errors) + fail JSONAPI::Exceptions::InvalidInclude.new(format_key(resource_klass._type), include_parts.first) end end @@ -352,12 +351,17 @@ def parse_include_directives(resource_klass, raw_include) return if included_resources.nil? - result = included_resources.compact.map do |included_resource| - check_include(resource_klass, included_resource.partition('.')) - unformat_key(included_resource).to_s - end + begin + result = included_resources.compact.map do |included_resource| + check_include(resource_klass, included_resource.partition('.')) + unformat_key(included_resource).to_s + end - JSONAPI::IncludeDirectives.new(resource_klass, result) + return JSONAPI::IncludeDirectives.new(resource_klass, result) + rescue JSONAPI::Exceptions::InvalidInclude => e + @errors.concat(e.errors) + return {} + end end def parse_filters(resource_klass, filters) diff --git a/test/controllers/controller_test.rb b/test/controllers/controller_test.rb index 2f4909f48..2c14b9008 100644 --- a/test/controllers/controller_test.rb +++ b/test/controllers/controller_test.rb @@ -2043,7 +2043,6 @@ def test_expense_entries_show_bad_include_missing_relationship assert_cacheable_get :show, params: {id: 1, include: 'isoCurrencies,employees'} assert_response :bad_request assert_match /isoCurrencies is not a valid relationship of expenseEntries/, json_response['errors'][0]['detail'] - assert_match /employees is not a valid relationship of expenseEntries/, json_response['errors'][1]['detail'] end def test_expense_entries_show_bad_include_missing_sub_relationship @@ -2052,6 +2051,18 @@ def test_expense_entries_show_bad_include_missing_sub_relationship assert_match /post is not a valid relationship of people/, json_response['errors'][0]['detail'] end + def test_invalid_include + assert_cacheable_get :index, params: {include: 'invalid../../../../'} + assert_response :bad_request + assert_match /invalid is not a valid relationship of expenseEntries/, json_response['errors'][0]['detail'] + end + + def test_invalid_include_long_garbage_string + assert_cacheable_get :index, params: {include: 'invalid.foo.bar.dfsdfs,dfsdfs.sdfwe.ewrerw.erwrewrew'} + assert_response :bad_request + assert_match /invalid is not a valid relationship of expenseEntries/, json_response['errors'][0]['detail'] + end + def test_expense_entries_show_fields assert_cacheable_get :show, params: {id: 1, include: 'isoCurrency,employee', 'fields' => {'expenseEntries' => 'transactionDate'}} assert_response :success diff --git a/test/unit/serializer/include_directives_test.rb b/test/unit/serializer/include_directives_test.rb index 8738c5044..56306f114 100644 --- a/test/unit/serializer/include_directives_test.rb +++ b/test/unit/serializer/include_directives_test.rb @@ -143,4 +143,22 @@ def test_three_levels_include_full_model_includes directives = JSONAPI::IncludeDirectives.new(PersonResource, ['posts.comments.tags']) assert_array_equals([{:posts=>[{:comments=>[:tags]}]}], directives.model_includes) end + + def test_invalid_includes_1 + assert_raises JSONAPI::Exceptions::InvalidInclude do + JSONAPI::IncludeDirectives.new(PersonResource, ['../../../../']).include_directives + end + end + + def test_invalid_includes_2 + assert_raises JSONAPI::Exceptions::InvalidInclude do + JSONAPI::IncludeDirectives.new(PersonResource, ['posts./sdaa./........']).include_directives + end + end + + def test_invalid_includes_3 + assert_raises JSONAPI::Exceptions::InvalidInclude do + JSONAPI::IncludeDirectives.new(PersonResource, ['invalid../../../../']).include_directives + end + end end From d177c228510e122ccb069c5f3eb3ad36b2261430 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Thu, 4 May 2017 16:30:44 -0400 Subject: [PATCH 060/237] Tightens the error handling on request parsing. Now fails on the first error encountered. --- lib/jsonapi/request_parser.rb | 35 ++++++------------- test/controllers/controller_test.rb | 1 - .../jsonapi_request/jsonapi_request_test.rb | 21 ++++++----- 3 files changed, 21 insertions(+), 36 deletions(-) diff --git a/lib/jsonapi/request_parser.rb b/lib/jsonapi/request_parser.rb index c7dea78d5..00448dbcf 100644 --- a/lib/jsonapi/request_parser.rb +++ b/lib/jsonapi/request_parser.rb @@ -281,7 +281,6 @@ def parse_fields(resource_klass, fields) fail JSONAPI::Exceptions::InvalidFieldFormat.new(error_object_overrides) end - errors = [] # Validate the fields validated_fields = {} extracted_fields.each do |type, values| @@ -293,13 +292,11 @@ def parse_fields(resource_klass, fields) end type_resource = Resource.resource_klass_for(resource_klass.module_path + underscored_type.to_s) rescue NameError - errors.concat(JSONAPI::Exceptions::InvalidResource.new(type, error_object_overrides).errors) - rescue JSONAPI::Exceptions::InvalidResource => e - errors.concat(e.errors) + fail JSONAPI::Exceptions::InvalidResource.new(type, error_object_overrides) end if type_resource.nil? - errors.concat(JSONAPI::Exceptions::InvalidResource.new(type, error_object_overrides).errors) + fail JSONAPI::Exceptions::InvalidResource.new(type, error_object_overrides) else unless values.nil? valid_fields = type_resource.fields.collect { |key| format_key(key) } @@ -307,17 +304,15 @@ def parse_fields(resource_klass, fields) if valid_fields.include?(field) validated_fields[type].push unformat_key(field) else - errors.concat(JSONAPI::Exceptions::InvalidField.new(type, field, error_object_overrides).errors) + fail JSONAPI::Exceptions::InvalidField.new(type, field, error_object_overrides) end end else - errors.concat(JSONAPI::Exceptions::InvalidField.new(type, 'nil', error_object_overrides).errors) + fail JSONAPI::Exceptions::InvalidField.new(type, 'nil', error_object_overrides) end end end - fail JSONAPI::Exceptions::Errors.new(errors) unless errors.empty? - validated_fields.deep_transform_keys { |key| unformat_key(key) } end @@ -389,7 +384,7 @@ def parse_filters(resource_klass, filters) if resource_klass._allowed_filter?(filter) parsed_filters[filter] = value else - @errors.concat(JSONAPI::Exceptions::FilterNotAllowed.new(filter).errors) + fail JSONAPI::Exceptions::FilterNotAllowed.new(filter) end end @@ -432,8 +427,7 @@ def check_sort_criteria(resource_klass, sort_criteria) sort_field = sort_criteria[:field] unless resource_klass.sortable_field?(sort_field.to_sym, context) - @errors.concat(JSONAPI::Exceptions::InvalidSortCriteria - .new(format_key(resource_klass._type), sort_field).errors) + fail JSONAPI::Exceptions::InvalidSortCriteria.new(format_key(resource_klass._type), sort_field) end end @@ -582,7 +576,6 @@ def unformat_value(resource_klass, attribute, value) def verify_permitted_params(params, allowed_fields) formatted_allowed_fields = allowed_fields.collect { |field| format_key(field).to_sym } params_not_allowed = [] - param_errors = [] params.each do |key, value| case key.to_s @@ -590,8 +583,7 @@ def verify_permitted_params(params, allowed_fields) value.keys.each do |links_key| unless formatted_allowed_fields.include?(links_key.to_sym) if JSONAPI.configuration.raise_if_parameters_not_allowed - param_errors.concat JSONAPI::Exceptions::ParameterNotAllowed.new( - links_key, error_object_overrides).errors + fail JSONAPI::Exceptions::ParameterNotAllowed.new(links_key, error_object_overrides) else params_not_allowed.push(links_key) value.delete links_key @@ -602,8 +594,7 @@ def verify_permitted_params(params, allowed_fields) value.each do |attr_key, _attr_value| unless formatted_allowed_fields.include?(attr_key.to_sym) if JSONAPI.configuration.raise_if_parameters_not_allowed - param_errors.concat JSONAPI::Exceptions::ParameterNotAllowed.new( - attr_key, error_object_overrides).errors + fail JSONAPI::Exceptions::ParameterNotAllowed.new(attr_key, error_object_overrides) else params_not_allowed.push(attr_key) value.delete attr_key @@ -614,8 +605,7 @@ def verify_permitted_params(params, allowed_fields) when 'id' unless formatted_allowed_fields.include?(:id) if JSONAPI.configuration.raise_if_parameters_not_allowed - param_errors.concat JSONAPI::Exceptions::ParameterNotAllowed.new( - :id, error_object_overrides).errors + fail JSONAPI::Exceptions::ParameterNotAllowed.new(:id, error_object_overrides) else params_not_allowed.push(:id) params.delete :id @@ -623,8 +613,7 @@ def verify_permitted_params(params, allowed_fields) end else if JSONAPI.configuration.raise_if_parameters_not_allowed - param_errors += JSONAPI::Exceptions::ParameterNotAllowed.new( - key, error_object_overrides).errors + fail JSONAPI::Exceptions::ParameterNotAllowed.new(key, error_object_overrides) else params_not_allowed.push(key) params.delete key @@ -632,9 +621,7 @@ def verify_permitted_params(params, allowed_fields) end end - if param_errors.length > 0 - fail JSONAPI::Exceptions::Errors.new(param_errors) - elsif params_not_allowed.length > 0 + if params_not_allowed.length > 0 params_not_allowed_warnings = params_not_allowed.map do |param| JSONAPI::Warning.new(code: JSONAPI::PARAM_NOT_ALLOWED, title: 'Param not allowed', diff --git a/test/controllers/controller_test.rb b/test/controllers/controller_test.rb index 2c14b9008..bd05abef7 100644 --- a/test/controllers/controller_test.rb +++ b/test/controllers/controller_test.rb @@ -1818,7 +1818,6 @@ def test_update_unpermitted_attributes } assert_response :bad_request - assert_match /author is not allowed./, response.body assert_match /subject is not allowed./, response.body end diff --git a/test/unit/jsonapi_request/jsonapi_request_test.rb b/test/unit/jsonapi_request/jsonapi_request_test.rb index f2f4d2e59..5e05611b4 100644 --- a/test/unit/jsonapi_request/jsonapi_request_test.rb +++ b/test/unit/jsonapi_request/jsonapi_request_test.rb @@ -153,7 +153,7 @@ def test_parse_dasherized_with_underscored_fields } ) - e = assert_raises JSONAPI::Exceptions::Errors do + e = assert_raises JSONAPI::Exceptions::InvalidField do request.parse_fields(ExpenseEntryResource, params[:fields]) end refute e.errors.empty? @@ -178,7 +178,7 @@ def test_parse_dasherized_with_underscored_resource key_formatter: JSONAPI::Formatter.formatter_for(:dasherized_key) } ) - e = assert_raises JSONAPI::Exceptions::Errors do + e = assert_raises JSONAPI::Exceptions::InvalidResource do request.parse_fields(ExpenseEntryResource, params[:fields]) end refute e.errors.empty? @@ -194,10 +194,10 @@ def test_parse_filters_with_valid_filters def test_parse_filters_with_non_valid_filter setup_request - filters = @request.parse_filters(CatResource, {breed: 'Whiskers'}) # breed is not a set filter - assert_equal(filters, {}) - assert_equal(@request.errors.count, 1) - assert_equal(@request.errors.first.title, "Filter not allowed") + e = assert_raises JSONAPI::Exceptions::FilterNotAllowed do + @request.parse_filters(CatResource, {breed: 'Whiskers'}) # breed is not a set filter + end + assert_equal 'breed is not allowed.', e.errors[0].detail end def test_parse_filters_with_no_filters @@ -224,11 +224,10 @@ def test_parse_sort_with_valid_sorts def test_parse_sort_with_resource_validated_sorts setup_request - sort_criteria = @request.parse_sort_criteria(TreeResource, "sort66,name") - assert_equal(@request.errors.count, 1) - assert_equal(@request.errors.first.title, "Invalid sort criteria") - assert_equal(@request.errors.first.detail, "name is not a valid sort criteria for trees") - assert_equal(sort_criteria, [{:field=>"sort66", :direction=>:asc}, {:field=>"name", :direction=>:asc}]) + e = assert_raises JSONAPI::Exceptions::InvalidSortCriteria do + @request.parse_sort_criteria(TreeResource, "sort66,name") + end + assert_equal 'name is not a valid sort criteria for trees', e.errors[0].detail end def test_parse_sort_with_relationships From 2dc8b4fd94ffb06192c44cbd0e6de0e2a389f958 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Fri, 5 May 2017 11:07:31 -0400 Subject: [PATCH 061/237] Skips the `test:bug_report_template:rails_5` in default task --- Rakefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Rakefile b/Rakefile index f47de1277..01619ed8e 100644 --- a/Rakefile +++ b/Rakefile @@ -8,7 +8,7 @@ Rake::TestTask.new do |t| t.test_files = FileList['test/**/*_test.rb'] end -task default: [:test, 'test:bug_report_template:rails_5'] +task default: [:test] desc 'Run benchmarks' namespace :test do From 3cd0be9ca1b1dc59c00a78f31767e3d8eff4ef12 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Fri, 5 May 2017 11:19:10 -0400 Subject: [PATCH 062/237] Update rails 5.1 and Ruby 2.3.4 Rails 4.2.8 now supports ruby 2.4 --- .travis.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.travis.yml b/.travis.yml index 5251d9847..f92f52e9c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,19 +1,20 @@ language: ruby sudo: false env: - - "RAILS_VERSION=4.2.7" + - "RAILS_VERSION=4.2.8" - "RAILS_VERSION=5.0.2" + - "RAILS_VERSION=5.1.0" - "RAILS_VERSION=master" rvm: - 2.1.10 - - 2.2.6 - - 2.3.3 + - 2.2.7 + - 2.3.4 - 2.4.1 matrix: exclude: - rvm: 2.1.10 env: "RAILS_VERSION=5.0.2" - - rvm: 2.4.1 - env: "RAILS_VERSION=4.2.7" + - rvm: 2.1.10 + env: "RAILS_VERSION=5.1.0" allow_failures: - env: "RAILS_VERSION=master" From 68584b83b45c1eae56ee0cd94e1df2c5d5c9b976 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Fri, 5 May 2017 11:19:41 -0400 Subject: [PATCH 063/237] Update versions and dates --- LICENSE.txt | 2 +- README.md | 2 +- test/test_helper.rb | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/LICENSE.txt b/LICENSE.txt index 997411409..3dec1f286 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,4 +1,4 @@ -Copyright (c) 2014 Larry Gebhardt +Copyright (c) 2014-2017 Cerebris Corporation MIT License diff --git a/README.md b/README.md index 6bafb893b..913486b78 100644 --- a/README.md +++ b/README.md @@ -64,4 +64,4 @@ and **paste the content into the issue description**: ## License -Copyright 2014-2016 Cerebris Corporation. MIT License (see LICENSE for details). +Copyright 2014-2017 Cerebris Corporation. MIT License (see LICENSE for details). diff --git a/test/test_helper.rb b/test/test_helper.rb index 782e03833..01ae9b38c 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -6,6 +6,7 @@ # To test on a specific rails version use this: # export RAILS_VERSION=4.2.6; bundle update rails; bundle exec rake test # export RAILS_VERSION=5.0.0; bundle update rails; bundle exec rake test +# export RAILS_VERSION=5.1.0; bundle update rails; bundle exec rake test # We are no longer having Travis test Rails 4.1.x., but you can try it with: # export RAILS_VERSION=4.1.0; bundle update rails; bundle exec rake test From 92e99b5a441da8564cfe5d333e50b1e635cc377e Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Fri, 5 May 2017 11:21:48 -0400 Subject: [PATCH 064/237] Exclude rails master from testing with ruby 2.1 --- .travis.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.travis.yml b/.travis.yml index f92f52e9c..bcc2e9510 100644 --- a/.travis.yml +++ b/.travis.yml @@ -16,5 +16,7 @@ matrix: env: "RAILS_VERSION=5.0.2" - rvm: 2.1.10 env: "RAILS_VERSION=5.1.0" + - rvm: 2.1.10 + env: "RAILS_VERSION=master" allow_failures: - env: "RAILS_VERSION=master" From dd436dbb6a159f9c51e274847aa7991f6e595d49 Mon Sep 17 00:00:00 2001 From: Kevin Traver Date: Thu, 25 May 2017 13:43:32 -0600 Subject: [PATCH 065/237] Use original key format when displaying error for filter not allowed --- lib/jsonapi/request_parser.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/jsonapi/request_parser.rb b/lib/jsonapi/request_parser.rb index c987e68be..7fc38da7c 100644 --- a/lib/jsonapi/request_parser.rb +++ b/lib/jsonapi/request_parser.rb @@ -384,7 +384,7 @@ def parse_filters(resource_klass, filters) if resource_klass._allowed_filter?(filter) parsed_filters[filter] = value else - fail JSONAPI::Exceptions::FilterNotAllowed.new(filter) + fail JSONAPI::Exceptions::FilterNotAllowed.new(key) end end From bdcbf615155b8c6b3f34136d56d77254ed7d9cbf Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Thu, 29 Jun 2017 15:47:26 -0400 Subject: [PATCH 066/237] Refactor Processors and RecordAccessors --- Gemfile | 2 +- lib/jsonapi-resources.rb | 6 +- lib/jsonapi/active_record_accessor.rb | 503 -- .../active_relation_resource_finder.rb | 572 ++ lib/jsonapi/acts_as_resource_controller.rb | 9 +- ...ragment.rb => cached_response_fragment.rb} | 43 +- lib/jsonapi/compiled_json.rb | 10 + lib/jsonapi/configuration.rb | 32 +- lib/jsonapi/error.rb | 4 + lib/jsonapi/include_directives.rb | 5 + lib/jsonapi/link_builder.rb | 6 +- lib/jsonapi/operation.rb | 17 +- lib/jsonapi/operation_result.rb | 40 +- lib/jsonapi/processor.rb | 431 +- lib/jsonapi/record_accessor.rb | 66 - lib/jsonapi/relationship.rb | 41 +- lib/jsonapi/request_parser.rb | 15 +- lib/jsonapi/resource.rb | 240 +- lib/jsonapi/resource_identity.rb | 42 + lib/jsonapi/resource_serializer.rb | 370 +- lib/jsonapi/response_document.rb | 15 +- lib/jsonapi/routing_ext.rb | 2 +- test/config/database.yml | 1 + test/controllers/controller_test.rb | 564 +- test/fixtures/active_record.rb | 201 +- test/fixtures/author_details.yml | 11 +- test/fixtures/book_authors.yml | 16 +- test/fixtures/book_comments.yml | 2 +- test/fixtures/boxes.yml | 4 +- test/fixtures/comments.yml | 13 +- test/fixtures/comments_tags.yml | 10 +- test/fixtures/documents.yml | 12 + test/fixtures/expense_entries.yml | 4 +- test/fixtures/people.yml | 12 +- test/fixtures/pictures.yml | 24 + test/fixtures/posts.yml | 38 +- test/fixtures/posts_tags.yml | 40 +- test/fixtures/products.yml | 4 + test/fixtures/related_things.yml | 16 +- test/fixtures/tags.yml | 33 +- test/fixtures/things.yml | 16 +- test/fixtures/users.yml | 2 +- test/fixtures/vehicles.yml | 4 +- test/helpers/configuration_helpers.rb | 11 +- test/integration/requests/request_test.rb | 84 +- test/integration/routes/routes_test.rb | 46 +- test/test_helper.rb | 24 +- test/unit/processor/default_processor_test.rb | 119 + .../active_relation_resource_finder_test.rb | 222 + test/unit/resource/resource_test.rb | 249 +- .../serializer/polymorphic_serializer_test.rb | 964 ++-- test/unit/serializer/serializer_test.rb | 4830 +++++++++-------- 52 files changed, 5453 insertions(+), 4594 deletions(-) delete mode 100644 lib/jsonapi/active_record_accessor.rb create mode 100644 lib/jsonapi/active_relation_resource_finder.rb rename lib/jsonapi/{cached_resource_fragment.rb => cached_response_fragment.rb} (71%) delete mode 100644 lib/jsonapi/record_accessor.rb create mode 100644 lib/jsonapi/resource_identity.rb create mode 100644 test/unit/processor/default_processor_test.rb create mode 100644 test/unit/resource/active_relation_resource_finder_test.rb diff --git a/Gemfile b/Gemfile index c58d1c896..0c783b266 100644 --- a/Gemfile +++ b/Gemfile @@ -3,7 +3,7 @@ source 'https://rubygems.org' gemspec platforms :ruby do - gem 'sqlite3', '1.3.10' + gem 'sqlite3', '1.3.13' end platforms :jruby do diff --git a/lib/jsonapi-resources.rb b/lib/jsonapi-resources.rb index e16022ff6..33d8af5c8 100644 --- a/lib/jsonapi-resources.rb +++ b/lib/jsonapi-resources.rb @@ -1,7 +1,7 @@ require 'jsonapi/naive_cache' require 'jsonapi/compiled_json' require 'jsonapi/resource' -require 'jsonapi/cached_resource_fragment' +require 'jsonapi/cached_response_fragment' require 'jsonapi/response_document' require 'jsonapi/acts_as_resource_controller' require 'jsonapi/resource_controller' @@ -24,5 +24,5 @@ require 'jsonapi/operation_result' require 'jsonapi/callbacks' require 'jsonapi/link_builder' -require 'jsonapi/record_accessor' -require 'jsonapi/active_record_accessor' +require 'jsonapi/active_relation_resource_finder' +require 'jsonapi/resource_identity' diff --git a/lib/jsonapi/active_record_accessor.rb b/lib/jsonapi/active_record_accessor.rb deleted file mode 100644 index a4f902dc3..000000000 --- a/lib/jsonapi/active_record_accessor.rb +++ /dev/null @@ -1,503 +0,0 @@ -require 'jsonapi/record_accessor' - -module JSONAPI - class ActiveRecordAccessor < RecordAccessor - - # RecordAccessor methods - - def find_resource(filters, options = {}) - if options[:caching] && options[:caching][:cache_serializer_output] - find_serialized_with_caching(filters, options[:caching][:serializer], options) - else - _resource_klass.resources_for(find_records(filters, options), options[:context]) - end - end - - def find_resource_by_key(key, options = {}) - if options[:caching] && options[:caching][:cache_serializer_output] - find_by_key_serialized_with_caching(key, options[:caching][:serializer], options) - else - records = find_records({ _resource_klass._primary_key => key }, options.except(:paginator, :sort_criteria)) - model = records.first - fail JSONAPI::Exceptions::RecordNotFound.new(key) if model.nil? - _resource_klass.resource_for(model, options[:context]) - end - end - - def find_resources_by_keys(keys, options = {}) - records = records(options) - records = apply_includes(records, options) - records = records.where({ _resource_klass._primary_key => keys }) - - _resource_klass.resources_for(records, options[:context]) - end - - def find_count(filters, options = {}) - count_records(filter_records(filters, options)) - end - - def related_resource(resource, relationship_name, options = {}) - relationship = resource.class._relationships[relationship_name.to_sym] - - if relationship.polymorphic? - associated_model = records_for_relationship(resource, relationship_name, options) - resource_klass = resource.class.resource_klass_for_model(associated_model) if associated_model - return resource_klass.new(associated_model, resource.context) if resource_klass && associated_model - else - resource_klass = relationship.resource_klass - if resource_klass - associated_model = records_for_relationship(resource, relationship_name, options) - return associated_model ? resource_klass.new(associated_model, resource.context) : nil - end - end - end - - def related_resources(resource, relationship_name, options = {}) - relationship = resource.class._relationships[relationship_name.to_sym] - relationship_resource_klass = relationship.resource_klass - - if options[:caching] && options[:caching][:cache_serializer_output] - scope = relationship_resource_klass._record_accessor.records_for_relationship(resource, relationship_name, options) - relationship_resource_klass._record_accessor.find_serialized_with_caching(scope, options[:caching][:serializer], options) - else - records = records_for_relationship(resource, relationship_name, options) - return records.collect do |record| - klass = relationship.polymorphic? ? resource.class.resource_klass_for_model(record) : relationship_resource_klass - klass.new(record, resource.context) - end - end - end - - def count_for_relationship(resource, relationship_name, options = {}) - relationship = resource.class._relationships[relationship_name.to_sym] - - context = resource.context - - relation_name = relationship.relation_name(context: context) - records = records_for(resource, relation_name) - - resource_klass = relationship.resource_klass - - filters = options.fetch(:filters, {}) - unless filters.nil? || filters.empty? - records = resource_klass._record_accessor.apply_filters(records, filters, options) - end - - records.count(:all) - end - - def foreign_key(resource, relationship_name, options = {}) - relationship = resource.class._relationships[relationship_name.to_sym] - - if relationship.belongs_to? - resource._model.method(relationship.foreign_key).call - else - records = records_for_relationship(resource, relationship_name, options) - return nil if records.nil? - records.public_send(relationship.resource_klass._primary_key) - end - end - - def foreign_keys(resource, relationship_name, options = {}) - relationship = resource.class._relationships[relationship_name.to_sym] - - records = records_for_relationship(resource, relationship_name, options) - records.collect do |record| - record.public_send(relationship.resource_klass._primary_key) - end - end - - # protected-ish methods left public for tests and what not - - def find_serialized_with_caching(filters_or_source, serializer, options = {}) - if filters_or_source.is_a?(ActiveRecord::Relation) - return cached_resources_for(filters_or_source, serializer, options) - elsif resource_class_based_on_active_record?(_resource_klass) - records = find_records(filters_or_source, options.except(:include_directives)) - return cached_resources_for(records, serializer, options) - else - # :nocov: - warn('Caching enabled on model not based on ActiveRecord API or similar') - # :nocov: - end - end - - def find_by_key_serialized_with_caching(key, serializer, options = {}) - if resource_class_based_on_active_record?(_resource_klass) - results = find_serialized_with_caching({ _resource_klass._primary_key => key }, serializer, options) - result = results.first - fail JSONAPI::Exceptions::RecordNotFound.new(key) if result.nil? - return result - else - # :nocov: - warn('Caching enabled on model not based on ActiveRecord API or similar') - # :nocov: - end - end - - def records_for_relationship(resource, relationship_name, options = {}) - relationship = resource.class._relationships[relationship_name.to_sym] - - context = resource.context - - relation_name = relationship.relation_name(context: context) - records = records_for(resource, relation_name) - - resource_klass = relationship.resource_klass - - filters = options.fetch(:filters, {}) - unless filters.nil? || filters.empty? - records = resource_klass._record_accessor.apply_filters(records, filters, options) - end - - sort_criteria = options.fetch(:sort_criteria, {}) - order_options = relationship.resource_klass.construct_order_options(sort_criteria) - records = apply_sort(records, order_options, context) - - paginator = options[:paginator] - if paginator - records = apply_pagination(records, paginator, order_options) - end - - records - end - - # Implement self.records on the resource if you want to customize the relation for - # finder methods (find, find_by_key, find_serialized_with_caching) - def records(_options = {}) - if defined?(_resource_klass.records) - _resource_klass.records(_options) - else - _resource_klass._model_class.all - end - end - - # Implement records_for on the resource to customize how the associated records - # are fetched for a model. Particularly helpful for authorization. - def records_for(resource, relation_name) - if resource.respond_to?(:records_for) - return resource.records_for(relation_name) - end - - relationship = resource.class._relationships[relation_name] - - if relationship.is_a?(JSONAPI::Relationship::ToMany) - if resource.respond_to?(:"records_for_#{relation_name}") - return resource.method(:"records_for_#{relation_name}").call - end - else - if resource.respond_to?(:"record_for_#{relation_name}") - return resource.method(:"record_for_#{relation_name}").call - end - end - - resource._model.public_send(relation_name) - end - - def apply_includes(records, options = {}) - include_directives = options[:include_directives] - if include_directives - model_includes = resolve_relationship_names_to_relations(_resource_klass, include_directives.model_includes, options) - records = records.includes(model_includes) - end - - records - end - - def apply_pagination(records, paginator, order_options) - records = paginator.apply(records, order_options) if paginator - records - end - - def apply_sort(records, order_options, context = {}) - if defined?(_resource_klass.apply_sort) - custom_sort = _resource_klass.apply_sort(records, order_options, context) - custom_sort.nil? ? default_sort(records, order_options) : custom_sort - else - default_sort(records, order_options) - end - end - - def default_sort(records, order_options) - 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 - field = _resource_klass._attribute_delegated_name(field) - 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 = _resource_klass._allowed_filters.fetch(filter.to_sym, Hash.new)[:apply] - - if strategy - if strategy.is_a?(Symbol) || strategy.is_a?(String) - _resource_klass.send(strategy, records, value, options) - else - strategy.call(records, value, options) - end - else - if _resource_klass._relationships.include?(filter) - if _resource_klass._relationships[filter].belongs_to? - records.where(_resource_klass._relationships[filter].foreign_key => value) - else - records.where("#{_resource_klass._relationships[filter].table_name}.#{_resource_klass._relationships[filter].primary_key}" => value) - end - else - filter = _resource_klass._attribute_delegated_name(filter) - records.where(filter => value) - end - end - end - - # Assumes ActiveRecord's counting. Override if you need a different counting method - def count_records(records) - records.count(:all) - end - - def resolve_relationship_names_to_relations(resource_klass, model_includes, options = {}) - case model_includes - when Array - return model_includes.map do |value| - resolve_relationship_names_to_relations(resource_klass, value, options) - end - when Hash - model_includes.keys.each do |key| - relationship = resource_klass._relationships[key] - value = model_includes[key] - model_includes.delete(key) - model_includes[relationship.relation_name(options)] = resolve_relationship_names_to_relations(relationship.resource_klass, value, options) - end - return model_includes - when Symbol - relationship = resource_klass._relationships[model_includes] - return relationship.relation_name(options) - end - end - - def apply_filters(records, filters, options = {}) - required_includes = [] - - if filters - filters.each do |filter, value| - if _resource_klass._relationships.include?(filter) && !_resource_klass._relationships[filter].belongs_to? - required_includes.push(filter.to_s) - end - - records = apply_filter(records, filter, value, options) - end - end - - if required_includes.any? - options.merge!(include_directives: IncludeDirectives.new(_resource_klass, required_includes, force_eager_load: true)) - end - - records - end - - def filter_records(filters, options, records = records(options)) - records = apply_filters(records, filters, options) - apply_includes(records, options) - end - - def sort_records(records, order_options, context = {}) - apply_sort(records, order_options, context) - end - - def cached_resources_for(records, serializer, options) - if _resource_klass.caching? - t = _resource_klass._model_class.arel_table - cache_ids = pluck_arel_attributes(records, t[_resource_klass._primary_key], t[_resource_klass._cache_field]) - resources = CachedResourceFragment.fetch_fragments(_resource_klass, serializer, options[:context], cache_ids) - else - resources = _resource_klass.resources_for(records, options[:context]).map { |r| [r.id, r] }.to_h - end - - if options[:include_directives] - resource_pile = { _resource_klass.name => resources } - options[:include_directives].all_paths.each do |path| - # Note that `all_paths` returns shorter paths first, so e.g. the partial fragments for - # posts.comments will exist before we start working with posts.comments.author - preload_included_fragments(_resource_klass, resource_pile, path, serializer, options) - end - end - - resources.values - end - - def find_records(filters, options = {}) - if defined?(_resource_klass.find_records) - ActiveSupport::Deprecation.warn "In #{_resource_klass.name} you overrode `find_records`. "\ - "`find_records` has been deprecated in favor of using `apply` "\ - "and `verify` callables on the filter." - - _resource_klass.find_records(filters, options) - else - context = options[:context] - - records = filter_records(filters, options) - - sort_criteria = options.fetch(:sort_criteria) { [] } - order_options = _resource_klass.construct_order_options(sort_criteria) - records = sort_records(records, order_options, context) - - records = apply_pagination(records, options[:paginator], order_options) - - records - end - end - - def preload_included_fragments(src_res_class, resource_pile, path, serializer, options) - src_resources = resource_pile[src_res_class.name] - return if src_resources.nil? || src_resources.empty? - - rel_name = path.first - relationship = src_res_class._relationships[rel_name] - if relationship.polymorphic - # FIXME Preloading through a polymorphic belongs_to association is not implemented. - # For now, in this case, ResourceSerializer will have to do the fetch itself, without - # using either the cache or eager-loading. - return - end - - tgt_res_class = relationship.resource_klass - unless resource_class_based_on_active_record?(tgt_res_class) - # Can't preload relationships from non-AR resources, this association will be filled - # in on-demand later by ResourceSerializer. - return - end - - # Assume for longer paths that the intermediate fragments have already been preloaded - if path.length > 1 - preload_included_fragments(tgt_res_class, resource_pile, path.drop(1), serializer, options) - return - end - - record_source = src_res_class._model_class - .where({ src_res_class._primary_key => src_resources.keys }) - .joins(relationship.relation_name(options).to_sym) - - 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. :-( - fake_model_instance = src_res_class._model_class.new - record_source = record_source.order(fake_model_instance.send(rel_name).arel.orders) - end - - # Pre-fill empty fragment hashes. - # This allows us to later distinguish between a preload that returned nothing - # vs. a preload that never ran. - serialized_rel_name = serializer.key_formatter.format(rel_name) - src_resources.each do |key, res| - res.preloaded_fragments[serialized_rel_name] ||= {} - end - - # We can't just look up the table name from the target class, because Arel could - # have used a table alias if the relation is a self-reference. - join_node = record_source.arel.source.right.reverse.find do |arel_node| - arel_node.is_a?(Arel::Nodes::InnerJoin) - end - tgt_table = join_node.left - - # Resource class may restrict current user to a subset of available records - if tgt_res_class.respond_to?(:records) - valid_tgts_rel = tgt_res_class.records(options) - valid_tgts_rel = valid_tgts_rel.all if valid_tgts_rel.respond_to?(:all) - conn = valid_tgts_rel.connection - tgt_attr = tgt_table[tgt_res_class._primary_key] - - # Alter a normal AR query to select only the primary key instead of all columns. - # Sadly doing direct string manipulation of query here, cannot use ARel for this due to - # bind values being stripped from AR::Relation#arel in Rails >= 4.2, see - # https://github.com/rails/arel/issues/363 - valid_tgts_query = valid_tgts_rel.to_sql.sub('*', conn.quote_column_name(tgt_attr.name)) - valid_tgts_cond = "#{quote_arel_attribute(conn, tgt_attr)} IN (#{valid_tgts_query})" - - record_source = record_source.where(valid_tgts_cond) - end - - pluck_attrs = [ - src_res_class._model_class.arel_table[src_res_class._primary_key], - tgt_table[tgt_res_class._primary_key] - ] - pluck_attrs << tgt_table[tgt_res_class._cache_field] if tgt_res_class.caching? - - id_rows = pluck_arel_attributes(record_source, *pluck_attrs) - - target_resources = resource_pile[tgt_res_class.name] ||= {} - - if tgt_res_class.caching? - sub_cache_ids = id_rows.map{ |row| row.last(2) }.uniq.reject{|p| target_resources.has_key?(p[0]) } - target_resources.merge! CachedResourceFragment.fetch_fragments( - tgt_res_class, serializer, options[:context], sub_cache_ids - ) - else - sub_res_ids = id_rows.map(&:last).uniq - target_resources.keys - recs = tgt_res_class.find({ tgt_res_class._primary_key => sub_res_ids }, context: options[:context]) - target_resources.merge!(recs.map{ |r| [r.id, r] }.to_h) - end - - id_rows.each do |row| - src_id, tgt_id = row[0], row[1] - src_res = src_resources[src_id] - next unless src_res - fragment = target_resources[tgt_id] - next unless fragment - src_res.preloaded_fragments[serialized_rel_name][tgt_id] = fragment - end - end - - def pluck_arel_attributes(relation, *attrs) - conn = relation.connection - quoted_attrs = attrs.map{|attr| quote_arel_attribute(conn, attr) } - relation.pluck(*quoted_attrs) - end - - def quote_arel_attribute(connection, attr) - quoted_table = connection.quote_table_name(attr.relation.table_alias || attr.relation.name) - quoted_column = connection.quote_column_name(attr.name) - "#{quoted_table}.#{quoted_column}" - end - - def resource_class_based_on_active_record?(klass) - model_class = klass._model_class - model_class.respond_to?(:all) && model_class.respond_to?(:arel_table) - end - end -end diff --git a/lib/jsonapi/active_relation_resource_finder.rb b/lib/jsonapi/active_relation_resource_finder.rb new file mode 100644 index 000000000..47e57839c --- /dev/null +++ b/lib/jsonapi/active_relation_resource_finder.rb @@ -0,0 +1,572 @@ +module JSONAPI + module ActiveRelationResourceFinder + def self.included(base) + base.extend ClassMethods + end + + module ClassMethods + + # Finds Resources using the `filters`. Pagination and sort options are used when provided + # + # @param filters [Hash] the filters hash + # @option options [Hash] :context The context of the request, set in the controller + # @option options [Hash] :sort_criteria The `sort criteria` + # @option options [Hash] :include_directives The `include_directives` + # + # @return [Array] the Resource instances matching the filters, sorting and pagination rules. + def find(filters, options = {}) + records = find_records(filters, options) + resources_for(records, options[:context]) + end + + # Counts Resources found using the `filters` + # + # @param filters [Hash] the filters hash + # @option options [Hash] :context The context of the request, set in the controller + # + # @return [Integer] the count + def count(filters, options = {}) + count_records(filter_records(filters, options)) + end + + # Returns the single Resource identified by `key` + # + # @param key the primary key of the resource to find + # @option options [Hash] :context The context of the request, set in the controller + def find_by_key(key, options = {}) + record = find_record_by_key(key, options) + resource_for(record, options[:context]) + end + + # Returns an array of Resources identified by the `keys` array + # + # @param keys [Array] Array of primary keys to find resources for + # @option options [Hash] :context The context of the request, set in the controller + def find_by_keys(keys, options = {}) + records = find_records_by_keys(keys, options) + resources_for(records, options[:context]) + end + + # Finds Resource fragments using the `filters`. Pagination and sort options are used when provided. + # Retrieving the ResourceIdentities and attributes does not instantiate a model instance. + # + # @param filters [Hash] the filters hash + # @option options [Hash] :context The context of the request, set in the controller + # @option options [Hash] :sort_criteria The `sort criteria` + # @option options [Hash] :include_directives The `include_directives` + # @option options [Hash] :attributes Additional fields to be retrieved. + # @option options [Boolean] :cache Return the resources' cache field + # + # @return [Hash{ResourceIdentity => {identity: => ResourceIdentity, cache: cache_field, attributes: => {name => value}}}] + # the ResourceInstances matching the filters, sorting, and pagination rules along with any request + # additional_field values + def find_fragments(filters, options = {}) + records = find_records(filters, options) + + table_name = _model_class.table_name + pluck_fields = [concat_table_field(table_name, _primary_key)] + + cache_field = attribute_to_model_field(:_cache_field) if options[:cache] + if cache_field + pluck_fields << concat_table_field(table_name, cache_field[:name]) + end + + model_fields = {} + attributes = options[:attributes] + attributes.try(:each) do |attribute| + model_field = attribute_to_model_field(attribute) + model_fields[attribute] = model_field + pluck_fields << concat_table_field(table_name, model_field[:name]) + end + + fragments = {} + records.pluck(*pluck_fields).collect do |row| + rid = JSONAPI::ResourceIdentity.new(self, pluck_fields.length == 1 ? row : row[0]) + fragments[rid] = { identity: rid } + attributes_offset = 1 + + if cache_field + fragments[rid][:cache] = cast_to_attribute_type(row[1], cache_field[:type]) + attributes_offset+= 1 + end + + fragments[rid][:attributes]= {} unless model_fields.empty? + model_fields.each_with_index do |k, idx| + fragments[rid][:attributes][k[0]]= cast_to_attribute_type(row[idx + attributes_offset], k[1][:type]) + end + end + + fragments + end + + # Finds Resource Fragments related to the source resources through the specified relationship + # + # @param source_rids [Array] The resources to find related ResourcesIdentities for + # @param relationship_name [String | Symbol] The name of the relationship + # @option options [Hash] :context The context of the request, set in the controller + # @option options [Hash] :attributes Additional fields to be retrieved. + # @option options [Boolean] :cache Return the resources' cache field + # + # @return [Hash{ResourceIdentity => {identity: => ResourceIdentity, cache: cache_field, attributes: => {name => value}, related: {relationship_name: [] }}}] + # the ResourceInstances matching the filters, sorting, and pagination rules along with any request + # additional_field values + def find_related_fragments(source_rids, relationship_name, options = {}) + relationship = _relationship(relationship_name) + + if relationship.polymorphic? && relationship.foreign_key_on == :self + find_related_polymorphic_fragments(source_rids, relationship, options) + else + find_related_monomorphic_fragments(source_rids, relationship, options) + end + end + + # Counts Resources related to the source resource through the specified relationship + # + # @param source_rid [ResourceIdentity] Source resource identifier + # @param relationship_name [String | Symbol] The name of the relationship + # @option options [Hash] :context The context of the request, set in the controller + # + # @return [Integer] the count + def count_related(source_rid, relationship_name, options = {}) + relationship = _relationship(relationship_name) + related_klass = relationship.resource_klass + + context = context + + records = records(context: context) + records, table_alias = apply_join(records, relationship, options) + + filters = options.fetch(:filters, {}) + + primary_key_field = concat_table_field(_table_name, _primary_key) + filters[primary_key_field] = source_rid.id + + filter_options = options.dup + filter_options[:table_alias] = table_alias + records = related_klass.apply_filters(records, filters, filter_options) + records.count(:all) + end + + protected + + def find_record_by_key(key, options = {}) + records = find_records({ _primary_key => key }, options.except(:paginator, :sort_criteria)) + record = records.first + fail JSONAPI::Exceptions::RecordNotFound.new(key) if record.nil? + record + end + + def find_records_by_keys(keys, options = {}) + records = records(options) + records = apply_includes(records, options) + records.where({ _primary_key => keys }) + end + + def find_related_monomorphic_fragments(source_rids, relationship, options = {}) + source_ids = source_rids.collect {|rid| rid.id} + + context = options[:context] + + records = records(context: context) + related_klass = relationship.resource_klass + + records, table_alias = apply_join(records, relationship, options) + + sort_criteria = [] + options[:sort_criteria].try(:each) do |sort| + field = sort[:field].to_s == 'id' ? related_klass._primary_key : sort[:field] + sort_criteria << { field: concat_table_field(table_alias, field), + direction: sort[:direction] } + end + + order_options = related_klass.construct_order_options(sort_criteria) + + paginator = options[:paginator] + + # ToDO: Remove count check. Currently pagination isn't working with multiple source_rids (i.e. it only works + # for show relationships, not related includes). + if paginator && source_rids.count == 1 + records = related_klass.apply_pagination(records, paginator, order_options) + end + + records = related_klass.apply_basic_sort(records, order_options, context: context) + + filters = options.fetch(:filters, {}) + + primary_key_field = concat_table_field(_table_name, _primary_key) + + filters[primary_key_field] = source_ids + + filter_options = options.dup + filter_options[:table_alias] = table_alias + + records = related_klass.apply_filters(records, filters, filter_options) + + pluck_fields = [ + primary_key_field, + concat_table_field(table_alias, related_klass._primary_key) + ] + + cache_field = related_klass.attribute_to_model_field(:_cache_field) if options[:cache] + if cache_field + pluck_fields << concat_table_field(table_alias, cache_field[:name]) + end + + model_fields = {} + attributes = options[:attributes] + attributes.try(:each) do |attribute| + model_field = related_klass.attribute_to_model_field(attribute) + model_fields[attribute] = model_field + pluck_fields << concat_table_field(table_alias, model_field[:name]) + end + + rows = records.pluck(*pluck_fields) + + relation_name = relationship.name.to_sym + + related_fragments = {} + + rows.each do |row| + unless row[1].nil? + rid = JSONAPI::ResourceIdentity.new(related_klass, row[1]) + related_fragments[rid] ||= { identity: rid, related: {relation_name => [] } } + + attributes_offset = 2 + + if cache_field + related_fragments[rid][:cache] = cast_to_attribute_type(row[attributes_offset], cache_field[:type]) + attributes_offset+= 1 + end + + related_fragments[rid][:attributes]= {} unless model_fields.empty? + model_fields.each_with_index do |k, idx| + related_fragments[rid][:attributes][k[0]] = cast_to_attribute_type(row[idx + attributes_offset], k[1][:type]) + end + + related_fragments[rid][:related][relation_name] << JSONAPI::ResourceIdentity.new(self, row[0]) + end + end + + related_fragments + end + + # Gets resource identities where the related resource is polymorphic and the resource type and id + # are stored on the primary resources. Cache fields will always be on the related resources. + def find_related_polymorphic_fragments(source_rids, relationship, options = {}) + source_ids = source_rids.collect {|rid| rid.id} + + context = options[:context] + + records = records(context: context) + + primary_key = concat_table_field(_table_name, _primary_key) + related_key = concat_table_field(_table_name, relationship.foreign_key) + related_type = concat_table_field(_table_name, relationship.polymorphic_type) + + pluck_fields = [primary_key, related_key, related_type] + + relations = relationship.polymorphic_relations + + # Get the additional fields from each relation. There's a limitation that the fields must exist in each relation + + relation_positions = {} + relation_index = 3 + + attributes = options.fetch(:attributes, []) + + if relations.nil? || relations.length == 0 + warn "No relations found for polymorphic relationship." + else + relations.try(:each) do |relation| + related_klass = resource_klass_for(relation.to_s) + + cache_field = related_klass.attribute_to_model_field(:_cache_field) if options[:cache] + + # We only need to join the relations if we are getting additional fields + if cache_field || attributes.length > 0 + records, table_alias = apply_join(records, relationship, options, relation) + + if cache_field + pluck_fields << concat_table_field(table_alias, cache_field[:name]) + end + + model_fields = {} + attributes.try(:each) do |attribute| + model_field = related_klass.attribute_to_model_field(attribute) + model_fields[attribute] = model_field + end + + model_fields.each do |_k, v| + pluck_fields << concat_table_field(table_alias, v[:name]) + end + + end + + related = related_klass._model_class.name + relation_positions[related] = { relation_klass: related_klass, + cache_field: cache_field, + model_fields: model_fields, + field_offset: relation_index} + + relation_index+= 1 if cache_field + relation_index+= attributes.length if attributes.length > 0 + end + end + + primary_resource_filters = options[:filters] + primary_resource_filters ||= {} + + primary_resource_filters[_primary_key] = source_ids + + records = apply_filters(records, primary_resource_filters, options) + + rows = records.pluck(*pluck_fields) + + relation_name = relationship.name.to_sym + + related_fragments = {} + + rows.each do |row| + unless row[1].nil? || row[2].nil? + related_klass = resource_klass_for(row[2]) + + rid = JSONAPI::ResourceIdentity.new(related_klass, row[1]) + related_fragments[rid] ||= { identity: rid, related: { relation_name => [] } } + related_fragments[rid][:related][relation_name] << JSONAPI::ResourceIdentity.new(self, row[0]) + + relation_position = relation_positions[row[2]] + model_fields = relation_position[:model_fields] + cache_field = relation_position[:cache_field] + field_offset = relation_position[:field_offset] + + attributes_offset = 0 + + if cache_field + related_fragments[rid][:cache] = cast_to_attribute_type(row[field_offset], cache_field[:type]) + attributes_offset+= 1 + end + + if attributes.length > 0 + related_fragments[rid][:attributes]= {} + model_fields.each_with_index do |k, idx| + related_fragments[rid][:attributes][k[0]] = cast_to_attribute_type(row[idx + field_offset + attributes_offset], k[1][:type]) + end + end + end + end + + related_fragments + 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 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.joins(model_includes).references(model_includes) + end + + records + end + + def apply_pagination(records, paginator, order_options) + records = paginator.apply(records, order_options) if paginator + records + end + + def apply_sort(records, order_options, context = {}) + if order_options.any? + order_options.each_pair do |field, direction| + if field.to_s.include?(".") + *model_names, column_name = field.split(".") + + associations = _lookup_association_chain([records.model.to_s, *model_names]) + joins_query = _build_joins([records.model, *associations]) + + # _sorting is appended to avoid name clashes with manual joins eg. overridden filters + order_by_query = "#{associations.last.name}_sorting.#{column_name} #{direction}" + records = records.joins(joins_query).order(order_by_query) + else + field = _attribute_delegated_name(field) + records = records.order(field => direction) + end + end + end + + records + end + + def apply_basic_sort(records, order_options, context = {}) + if order_options.any? + order_options.each_pair do |field, direction| + records = records.order("#{field} #{direction}") + end + end + + records + 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 + + # Assumes ActiveRecord's counting. Override if you need a different counting method + def count_records(records) + records.count(:all) + end + + def resolve_relationship_names_to_relations(resource_klass, model_includes, options = {}) + case model_includes + when Array + return model_includes.map do |value| + resolve_relationship_names_to_relations(resource_klass, value, options) + end + when Hash + model_includes.keys.each do |key| + relationship = resource_klass._relationships[key] + value = model_includes[key] + model_includes.delete(key) + model_includes[relationship.relation_name(options)] = resolve_relationship_names_to_relations(relationship.resource_klass, value, options) + end + return model_includes + when Symbol + relationship = resource_klass._relationships[model_includes] + unless relationship + warn "relationship no found." + end + return relationship.relation_name(options) + end + 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 + filter = _attribute_delegated_name(filter) + table_alias = options[:table_alias] + records.where(concat_table_field(table_alias, filter) => value) + end + end + + def apply_filters(records, filters, options = {}) + required_includes = [] + + if filters + filters.each do |filter, value| + strategy = _allowed_filters.fetch(filter.to_sym, Hash.new)[:apply] + + if strategy + records = apply_filter(records, filter, value, options) + elsif _relationships.include?(filter) + if _relationships[filter].belongs_to? + records = apply_filter(records, _relationships[filter].foreign_key, value, options) + else + required_includes.push(filter.to_s) + records = apply_filter(records, "#{_relationships[filter].table_name}.#{_relationships[filter].primary_key}", value, options) + end + else + records = apply_filter(records, filter, value, options) + end + end + end + + if required_includes.any? + records = apply_includes(records, options.merge(include_directives: IncludeDirectives.new(self, required_includes, force_eager_load: true))) + end + + records + end + + def filter_records(filters, options, records = records(options)) + apply_filters(records, filters, options) + end + + def sort_records(records, order_options, context = {}) + apply_sort(records, order_options, context) + end + + def concat_table_field(table, field, quoted = false) + if table.nil? || field.to_s.include?('.') + if quoted + "\"#{field.to_s}\"" + else + field.to_s + end + else + if quoted + "\"#{table.to_s}\".\"#{field.to_s}\"" + else + "#{table.to_s}.#{field.to_s}" + end + end + end + + def apply_join(records, relationship, options, polymorphic_relation_name = nil) + custom_apply_join = relationship.custom_methods[:apply_join] + + if custom_apply_join + # Set a default alias for the join to use, which it may change by updating the option + table_alias = relationship.resource_klass._table_name + + custom_apply_options = { + relationship: relationship, + polymorphic_relation_name: polymorphic_relation_name, + context: options[:context], + records: records, + table_alias: table_alias, + options: options} + + records = custom_apply_join.call(custom_apply_options) + + # Get the table alias in case it was changed + table_alias = custom_apply_options[:table_alias] + else + if relationship.polymorphic? + table_alias = relationship.parent_resource._table_name + + relation_name = polymorphic_relation_name + related_klass = resource_klass_for(relation_name.to_s) + related_table_name = related_klass._table_name + + join_statement = "LEFT OUTER JOIN #{related_table_name} ON #{table_alias}.#{relationship.foreign_key} = #{related_table_name}.#{related_klass._primary_key} AND #{concat_table_field(table_alias, relationship.polymorphic_type, true)} = \"#{relation_name.capitalize}\"" + records = records.joins(join_statement) + else + relation_name = relationship.relation_name(options) + related_klass = relationship.resource_klass + + records = records.joins(relation_name).references(relation_name) + end + + table_alias = related_klass._table_name + end + + return records, table_alias + end + end + end +end diff --git a/lib/jsonapi/acts_as_resource_controller.rb b/lib/jsonapi/acts_as_resource_controller.rb index 75e64e755..dbe4336e6 100644 --- a/lib/jsonapi/acts_as_resource_controller.rb +++ b/lib/jsonapi/acts_as_resource_controller.rb @@ -76,7 +76,7 @@ def process_request transactional = request_parser.transactional? begin - run_in_transaction(transactional) do + process_operations(transactional) do run_callbacks :process_operations do request_parser.each(response_document) do |op| op.options[:serializer] = resource_serializer_klass.new( @@ -103,7 +103,7 @@ def process_request render_response_document end - def run_in_transaction(transactional) + def process_operations(transactional) if transactional run_callbacks :transaction do ActiveRecord::Base.transaction do @@ -224,7 +224,10 @@ def render_response_document # Bypassing ActiveSupport allows us to use CompiledJson objects for cached response fragments render_options[:body] = JSON.generate(content) - render_options[:location] = content['data']['links']['self'] if (response_document.status == 201 && content[:data].class != Array) + if (response_document.status == 201 && content[:data].class != Array) && + content['data'] && content['data']['links'] && content['data']['links']['self'] + render_options[:location] = content['data']['links']['self'] + end end # For whatever reason, `render` ignores :status and :content_type when :body is set. diff --git a/lib/jsonapi/cached_resource_fragment.rb b/lib/jsonapi/cached_response_fragment.rb similarity index 71% rename from lib/jsonapi/cached_resource_fragment.rb rename to lib/jsonapi/cached_response_fragment.rb index 4a6d598d4..7c2e84f5a 100644 --- a/lib/jsonapi/cached_resource_fragment.rb +++ b/lib/jsonapi/cached_response_fragment.rb @@ -1,37 +1,26 @@ module JSONAPI - class CachedResourceFragment - def self.fetch_fragments(resource_klass, serializer, context, cache_ids) - serializer_config_key = serializer.config_key(resource_klass).gsub("/", "_") + class CachedResponseFragment + def self.fetch_cached_fragments(resource_klass, serializer_config_key, cache_ids, context) context_json = resource_klass.attribute_caching_context(context).to_json context_b64 = JSONAPI.configuration.resource_cache_digest_function.call(context_json) context_key = "ATTR-CTX-#{context_b64.gsub("/", "_")}" results = self.lookup(resource_klass, serializer_config_key, context, context_key, cache_ids) - miss_ids = results.select{|_k,v| v.nil? }.keys - unless miss_ids.empty? - find_filters = {resource_klass._primary_key => miss_ids.uniq} - find_options = {context: context} - resource_klass.find(find_filters, find_options).each do |resource| - (id, cr) = write(resource_klass, resource, serializer, serializer_config_key, context, context_key) - results[id] = cr - end - end - if JSONAPI.configuration.resource_cache_usage_report_function + miss_ids = results.select{|_k,v| v.nil? }.keys JSONAPI.configuration.resource_cache_usage_report_function.call( - resource_klass.name, - cache_ids.size - miss_ids.size, - miss_ids.size + resource_klass.name, + cache_ids.size - miss_ids.size, + miss_ids.size ) end - return results + results end attr_reader :resource_klass, :id, :type, :context, :fetchable_fields, :relationships, - :links_json, :attributes_json, :meta_json, - :preloaded_fragments + :links_json, :attributes_json, :meta_json def initialize(resource_klass, id, type, context, fetchable_fields, relationships, links_json, attributes_json, meta_json) @@ -47,9 +36,6 @@ def initialize(resource_klass, id, type, context, fetchable_fields, relationship @links_json = CompiledJson.of(links_json) @attributes_json = CompiledJson.of(attributes_json) @meta_json = CompiledJson.of(meta_json) - - # A hash of hashes - @preloaded_fragments ||= Hash.new end def to_cache_value @@ -64,11 +50,6 @@ def to_cache_value } end - def to_real_resource - rs = Resource.resource_klass_for(self.type).find_by_keys([self.id], {context: self.context}) - return rs.try(:first) - end - private def self.lookup(resource_klass, serializer_config_key, context, context_key, cache_ids) @@ -103,12 +84,14 @@ def self.from_cache_value(resource_klass, context, h) ) end - def self.write(resource_klass, resource, serializer, serializer_config_key, context, context_key) + def self.write(resource_klass, resource, serializer, serializer_config_key, context, context_key, relationship_data ) (id, cache_key) = resource.cache_id - json = serializer.object_hash(resource) # No inclusions passed to object_hash + + json = serializer.object_hash(resource, relationship_data) + cr = self.new( resource_klass, - json['id'], + id, json['type'], context, resource.fetchable_fields, diff --git a/lib/jsonapi/compiled_json.rb b/lib/jsonapi/compiled_json.rb index a6f7360ad..59ce6266b 100644 --- a/lib/jsonapi/compiled_json.rb +++ b/lib/jsonapi/compiled_json.rb @@ -5,6 +5,7 @@ def self.compile(h) end def self.of(obj) + # :nocov: case obj when NilClass then nil when CompiledJson then obj @@ -12,6 +13,7 @@ def self.of(obj) when Hash then CompiledJson.compile(obj) else raise "Can't figure out how to turn #{obj.inspect} into CompiledJson" end + # :nocov: end def initialize(json, h = nil) @@ -27,9 +29,17 @@ def to_s @json end + # :nocov: def to_h @h ||= JSON.parse(@json) end + # :nocov: + + def [](key) + # :nocov: + to_h[key] + # :nocov: + end undef_method :as_json end diff --git a/lib/jsonapi/configuration.rb b/lib/jsonapi/configuration.rb index 52c34ab81..63ad5fab1 100644 --- a/lib/jsonapi/configuration.rb +++ b/lib/jsonapi/configuration.rb @@ -1,7 +1,6 @@ require 'jsonapi/formatter' require 'jsonapi/processor' -require 'jsonapi/record_accessor' -require 'jsonapi/active_record_accessor' +require 'jsonapi/active_relation_resource_finder' require 'concurrent' module JSONAPI @@ -10,13 +9,14 @@ class Configuration :resource_key_type, :route_format, :raise_if_parameters_not_allowed, + :warn_on_route_setup_issues, :allow_include, :allow_sort, :allow_filter, :default_paginator, :default_page_size, :maximum_page_size, - :default_record_accessor_klass, + :resource_finder, :default_processor_klass, :use_text_errors, :top_level_links_include_pagination, @@ -33,6 +33,7 @@ class Configuration :cache_formatters, :use_relationship_reflection, :resource_cache, + :default_caching, :default_resource_cache_field, :resource_cache_digest_function, :resource_cache_usage_report_function @@ -54,6 +55,8 @@ def initialize self.raise_if_parameters_not_allowed = true + self.warn_on_route_setup_issues = true + # :none, :offset, :paged, or a custom paginator name self.default_paginator = :none @@ -95,11 +98,11 @@ def initialize self.always_include_to_one_linkage_data = false self.always_include_to_many_linkage_data = false - # Record Accessor - # The default Record Accessor is the ActiveRecordAccessor which provides - # caching access to ActiveRecord backed models. Custom Accessors can be specified - # in order to support other models. - self.default_record_accessor_klass = JSONAPI::ActiveRecordAccessor + # ResourceFinder Mixin + # The default ResourceFinder is the ActiveRelationResourceFinder which provides + # access to ActiveRelation backed models. Custom ResourceFinders can be specified + # in order to support other ORMs. + self.resource_finder = JSONAPI::ActiveRelationResourceFinder # The default Operation Processor to use if one is not defined specifically # for a Resource. @@ -126,6 +129,11 @@ def initialize # Rails cache store. self.resource_cache = nil + # Cache resources by default + # Cache resources by default. Individual resources can be excluded from caching by calling: + # `caching false` + self.default_caching = false + # Default resource cache field # On Resources with caching enabled, this field will be used to check for out-of-date # cache entries, unless overridden on a specific Resource. Defaults to "updated_at". @@ -210,8 +218,8 @@ def default_processor_klass=(default_processor_klass) @default_processor_klass = default_processor_klass end - def default_record_accessor_klass=(default_record_accessor_klass) - @default_record_accessor_klass = default_record_accessor_klass + def resource_finder=(resource_finder) + @resource_finder = resource_finder end attr_writer :allow_include, :allow_sort, :allow_filter @@ -248,10 +256,14 @@ def default_record_accessor_klass=(default_record_accessor_klass) attr_writer :raise_if_parameters_not_allowed + attr_writer :warn_on_route_setup_issues + attr_writer :use_relationship_reflection attr_writer :resource_cache + attr_writer :default_caching + attr_writer :default_resource_cache_field attr_writer :resource_cache_digest_function diff --git a/lib/jsonapi/error.rb b/lib/jsonapi/error.rb index 354af7adc..a5d878af8 100644 --- a/lib/jsonapi/error.rb +++ b/lib/jsonapi/error.rb @@ -32,18 +32,22 @@ def update_with_overrides(error_object_overrides) @href = error_object_overrides[:href] || href if error_object_overrides[:code] + # :nocov: @code = if JSONAPI.configuration.use_text_errors TEXT_ERRORS[error_object_overrides[:code]] else error_object_overrides[:code] end + # :nocov: end @source = error_object_overrides[:source] || @source @links = error_object_overrides[:links] || @links if error_object_overrides[:status] + # :nocov: @status = Rack::Utils::SYMBOL_TO_STATUS_CODE[error_object_overrides[:status]].to_s + # :nocov: end @meta = error_object_overrides[:meta] || @meta end diff --git a/lib/jsonapi/include_directives.rb b/lib/jsonapi/include_directives.rb index 12b24d4fe..1ba1ff51b 100644 --- a/lib/jsonapi/include_directives.rb +++ b/lib/jsonapi/include_directives.rb @@ -36,9 +36,11 @@ def model_includes get_includes(@include_directives_hash) end + # :nocov: def all_paths delve_paths(get_includes(@include_directives_hash, false)) end + # :nocov: private @@ -84,6 +86,7 @@ def parse_include(include) end end + # :nocov: def delve_paths(obj) case obj when Array @@ -96,5 +99,7 @@ def delve_paths(obj) raise "delve_paths cannot descend into #{obj.class.name}" end end + # :nocov: + end end diff --git a/lib/jsonapi/link_builder.rb b/lib/jsonapi/link_builder.rb index 54d0d3d38..c49633d7f 100644 --- a/lib/jsonapi/link_builder.rb +++ b/lib/jsonapi/link_builder.rb @@ -138,7 +138,11 @@ def regular_primary_resources_url end def regular_resource_path(source) - "#{regular_resources_path(source.class)}/#{source.id}" + if source.is_a?(JSONAPI::CachedResponseFragment) + "#{regular_resources_path(source.resource_klass)}/#{source.id}" + else + "#{regular_resources_path(source.class)}/#{source.id}" + end end def regular_resource_url(source) diff --git a/lib/jsonapi/operation.rb b/lib/jsonapi/operation.rb index 80897fd92..3e6996a41 100644 --- a/lib/jsonapi/operation.rb +++ b/lib/jsonapi/operation.rb @@ -14,7 +14,22 @@ def process private def processor - JSONAPI::Processor.processor_instance_for(resource_klass, operation_type, options) + self.class.processor_instance_for(resource_klass, operation_type, options) + end + + class << self + def processor_instance_for(resource_klass, operation_type, params) + _processor_from_resource_type(resource_klass).new(resource_klass, operation_type, params) + end + + def _processor_from_resource_type(resource_klass) + processor = resource_klass.name.gsub(/Resource$/,'Processor').safe_constantize + if processor.nil? + processor = JSONAPI.configuration.default_processor_klass + end + + return processor + end end end end diff --git a/lib/jsonapi/operation_result.rb b/lib/jsonapi/operation_result.rb index 3ea7f892f..412916b41 100644 --- a/lib/jsonapi/operation_result.rb +++ b/lib/jsonapi/operation_result.rb @@ -38,17 +38,18 @@ def to_hash(serializer = nil) end end - class ResourceOperationResult < OperationResult - attr_accessor :resource + class ResourceSetOperationResult < OperationResult + attr_accessor :resource_set, :pagination_params - def initialize(code, resource, options = {}) - @resource = resource + def initialize(code, resource_set, options = {}) + @resource_set = resource_set + @pagination_params = options.fetch(:pagination_params, {}) super(code, options) end - def to_hash(serializer = nil) + def to_hash(serializer) if serializer - serializer.serialize_to_hash(resource) + serializer.serialize_resource_set_to_hash(resource_set) else # :nocov: {} @@ -57,11 +58,11 @@ def to_hash(serializer = nil) end end - class ResourcesOperationResult < OperationResult - attr_accessor :resources, :pagination_params, :record_count, :page_count + class ResourcesSetOperationResult < OperationResult + attr_accessor :resource_set, :pagination_params, :record_count, :page_count - def initialize(code, resources, options = {}) - @resources = resources + def initialize(code, resource_set, options = {}) + @resource_set = resource_set @pagination_params = options.fetch(:pagination_params, {}) @record_count = options[:record_count] @page_count = options[:page_count] @@ -70,7 +71,7 @@ def initialize(code, resources, options = {}) def to_hash(serializer) if serializer - serializer.serialize_to_hash(resources) + serializer.serialize_resources_set_to_hash(resource_set) else # :nocov: {} @@ -79,18 +80,18 @@ def to_hash(serializer) end end - class RelatedResourcesOperationResult < ResourcesOperationResult - attr_accessor :source_resource, :_type + class RelatedResourcesSetOperationResult < ResourcesSetOperationResult + attr_accessor :resource_set, :source_resource, :_type - def initialize(code, source_resource, type, resources, options = {}) + def initialize(code, source_resource, type, resource_set, options = {}) @source_resource = source_resource @_type = type - super(code, resources, options) + super(code, resource_set, options) end def to_hash(serializer = nil) if serializer - serializer.serialize_to_hash(resources) + serializer.serialize_related_resources_set_to_hash(source_resource, resource_set) else # :nocov: {} @@ -100,17 +101,18 @@ def to_hash(serializer = nil) end class LinksObjectOperationResult < OperationResult - attr_accessor :parent_resource, :relationship + attr_accessor :parent_resource, :relationship, :resource_ids - def initialize(code, parent_resource, relationship, options = {}) + def initialize(code, parent_resource, relationship, resource_ids, options = {}) @parent_resource = parent_resource @relationship = relationship + @resource_ids = resource_ids super(code, options) end def to_hash(serializer = nil) if serializer - serializer.serialize_to_links_hash(parent_resource, relationship) + serializer.serialize_to_links_hash(parent_resource, relationship, resource_ids) else # :nocov: {} diff --git a/lib/jsonapi/processor.rb b/lib/jsonapi/processor.rb index a3ebf0647..20a4265fa 100644 --- a/lib/jsonapi/processor.rb +++ b/lib/jsonapi/processor.rb @@ -17,21 +17,6 @@ class Processor :remove_to_one_relationship, :operation - class << self - def processor_instance_for(resource_klass, operation_type, params) - _processor_from_resource_type(resource_klass).new(resource_klass, operation_type, params) - end - - def _processor_from_resource_type(resource_klass) - processor = resource_klass.name.gsub(/Resource$/,'Processor').safe_constantize - if processor.nil? - processor = JSONAPI.configuration.default_processor_klass - end - - return processor - end - end - attr_reader :resource_klass, :operation_type, :params, :context, :result, :result_options def initialize(resource_klass, operation_type, params) @@ -54,40 +39,34 @@ def process @result = JSONAPI::ErrorsOperationResult.new(e.errors[0].code, e.errors) end - def result_options - options = {} - options[:warnings] = params[:warnings] if params[:warnings] - options - end - def find filters = params[:filters] include_directives = params[:include_directives] sort_criteria = params.fetch(:sort_criteria, []) paginator = params[:paginator] fields = params[:fields] + serializer = params[:serializer] verified_filters = resource_klass.verify_filters(filters, context) + find_options = { context: context, - include_directives: include_directives, sort_criteria: sort_criteria, paginator: paginator, fields: fields, - caching: { - cache_serializer_output: params[:cache_serializer_output], - serializer: params[:serializer] - } + filters: verified_filters } - resources = resource_klass.find(verified_filters, find_options) + resource_set = find_resource_set(resource_klass, + include_directives, + serializer, + find_options) page_options = result_options - if (JSONAPI.configuration.top_level_meta_include_record_count || - (paginator && paginator.class.requires_record_count)) - page_options[:record_count] = resource_klass.find_count(verified_filters, - context: context, - include_directives: include_directives) + if (JSONAPI.configuration.top_level_meta_include_record_count || (paginator && paginator.class.requires_record_count)) + page_options[:record_count] = resource_klass.count(verified_filters, + context: context, + include_directives: include_directives) end if (JSONAPI.configuration.top_level_meta_include_page_count && page_options[:record_count]) @@ -95,58 +74,87 @@ def find end if JSONAPI.configuration.top_level_links_include_pagination && paginator - page_options[:pagination_params] = paginator.links_page_params(page_options.merge(fetched_resources: resources)) + page_options[:pagination_params] = paginator.links_page_params(page_options.merge(fetched_resources: resource_set)) end - return JSONAPI::ResourcesOperationResult.new(:ok, resources, page_options) + return JSONAPI::ResourcesSetOperationResult.new(:ok, resource_set, page_options) end def show include_directives = params[:include_directives] fields = params[:fields] id = params[:id] + serializer = params[:serializer] key = resource_klass.verify_key(id, context) find_options = { context: context, - include_directives: include_directives, fields: fields, - caching: { - cache_serializer_output: params[:cache_serializer_output], - serializer: params[:serializer] - } + filters: { resource_klass._primary_key => key } } - resource = resource_klass.find_by_key(key, find_options) + resource_set = find_resource_set(resource_klass, + include_directives, + serializer, + find_options) - return JSONAPI::ResourceOperationResult.new(:ok, resource, result_options) + return JSONAPI::ResourceSetOperationResult.new(:ok, resource_set, result_options) end def show_relationship parent_key = params[:parent_key] relationship_type = params[:relationship_type].to_sym + paginator = params[:paginator] + sort_criteria = params.fetch(:sort_criteria, []) + include_directives = params[:include_directives] + fields = params[:fields] parent_resource = resource_klass.find_by_key(parent_key, context: context) + find_options = { + context: context, + sort_criteria: sort_criteria, + paginator: paginator, + fields: fields + } + + resource_id_tree = find_related_resource_id_tree(resource_klass, + JSONAPI::ResourceIdentity.new(resource_klass, parent_key), + relationship_type, + find_options, + nil) + return JSONAPI::LinksObjectOperationResult.new(:ok, parent_resource, resource_klass._relationship(relationship_type), + resource_id_tree[:resources].keys, result_options) end def show_related_resource + include_directives = params[:include_directives] source_klass = params[:source_klass] source_id = params[:source_id] - relationship_type = params[:relationship_type].to_sym + relationship_type = params[:relationship_type] + serializer = params[:serializer] fields = params[:fields] - # TODO Should fetch related_resource from cache if caching enabled + find_options = { + context: context, + fields: fields, + filters: {} + } + source_resource = source_klass.find_by_key(source_id, context: context, fields: fields) - related_resource = source_resource.public_send(relationship_type) + resource_set = find_related_resource_set(source_resource, + relationship_type, + include_directives, + serializer, + find_options) - return JSONAPI::ResourceOperationResult.new(:ok, related_resource, result_options) + return JSONAPI::ResourceSetOperationResult.new(:ok, resource_set, result_options) end def show_related_resources @@ -154,33 +162,38 @@ def show_related_resources source_id = params[:source_id] relationship_type = params[:relationship_type] filters = params[:filters] - sort_criteria = params[:sort_criteria] + sort_criteria = params.fetch(:sort_criteria, resource_klass.default_sort) paginator = params[:paginator] fields = params[:fields] include_directives = params[:include_directives] + serializer = params[:serializer] - source_resource ||= source_klass.find_by_key(source_id, context: context, fields: fields) verified_filters = resource_klass.verify_filters(filters, context) - rel_opts = { + find_options = { filters: verified_filters, sort_criteria: sort_criteria, paginator: paginator, fields: fields, - context: context, - include_directives: include_directives, - caching: { - cache_serializer_output: params[:cache_serializer_output], - serializer: params[:serializer] - } + context: context } - related_resources = source_resource.public_send(relationship_type, rel_opts) + source_resource = source_klass.find_by_key(source_id, context: context, fields: fields) + + resource_set = find_related_resource_set(source_resource, + relationship_type, + include_directives, + serializer, + find_options) if ((JSONAPI.configuration.top_level_meta_include_record_count) || (paginator && paginator.class.requires_record_count) || (JSONAPI.configuration.top_level_meta_include_page_count)) - record_count = source_resource.count_for_relationship(relationship_type, rel_opts) + + record_count = source_resource.class.count_related( + source_resource.identity, + relationship_type, + find_options) end if (JSONAPI.configuration.top_level_meta_include_page_count && record_count) @@ -190,7 +203,7 @@ def show_related_resources pagination_params = if paginator && JSONAPI.configuration.top_level_links_include_pagination page_options = {} page_options[:record_count] = record_count if paginator.class.requires_record_count - paginator.links_page_params(page_options.merge(fetched_resources: related_resources)) + paginator.links_page_params(page_options.merge(fetched_resources: resource_set)) else {} end @@ -200,19 +213,35 @@ def show_related_resources opts.merge!(record_count: record_count) if JSONAPI.configuration.top_level_meta_include_record_count opts.merge!(page_count: page_count) if JSONAPI.configuration.top_level_meta_include_page_count - return JSONAPI::RelatedResourcesOperationResult.new(:ok, - source_resource, - relationship_type, - related_resources, - opts) + return JSONAPI::RelatedResourcesSetOperationResult.new(:ok, + source_resource, + relationship_type, + resource_set, + opts) end def create_resource + include_directives = params[:include_directives] + fields = params[:fields] + serializer = params[:serializer] + data = params[:data] resource = resource_klass.create(context) result = resource.replace_fields(data) - return JSONAPI::ResourceOperationResult.new((result == :completed ? :created : :accepted), resource, result_options) + find_options = { + context: context, + fields: fields, + filters: { resource_klass._primary_key => resource.id } + } + + resource_set = find_resource_set(resource_klass, + include_directives, + serializer, + find_options) + + + return JSONAPI::ResourceSetOperationResult.new((result == :completed ? :created : :accepted), resource_set, result_options) end def remove_resource @@ -226,12 +255,28 @@ def remove_resource def replace_fields resource_id = params[:resource_id] + include_directives = params[:include_directives] + fields = params[:fields] + serializer = params[:serializer] + data = params[:data] resource = resource_klass.find_by_key(resource_id, context: context) + result = resource.replace_fields(data) - return JSONAPI::ResourceOperationResult.new(result == :completed ? :ok : :accepted, resource, result_options) + find_options = { + context: context, + fields: fields, + filters: { resource_klass._primary_key => resource.id } + } + + resource_set = find_resource_set(resource_klass, + include_directives, + serializer, + find_options) + + return JSONAPI::ResourceSetOperationResult.new((result == :completed ? :ok : :accepted), resource_set, result_options) end def replace_to_one_relationship @@ -305,5 +350,263 @@ def remove_to_one_relationship return JSONAPI::OperationResult.new(result == :completed ? :no_content : :accepted, result_options) end + + def result_options + options = {} + options[:warnings] = params[:warnings] if params[:warnings] + options + end + + def find_resource_set(resource_klass, include_directives, serializer, options) + include_related = include_directives.include_directives[:include_related] if include_directives + + resource_id_tree = find_resource_id_tree(resource_klass, options, include_related) + + # Generate a set of resources that can be used to turn the resource_id_tree into a result set + resource_set = flatten_resource_id_tree(resource_id_tree) + + populate_resource_set(resource_set, serializer, options) + + resource_set + end + + def find_related_resource_set(resource, relationship_name, include_directives, serializer, options) + include_related = include_directives.include_directives[:include_related] if include_directives + + resource_id_tree = find_resource_id_tree_from_resource_relationship(resource, relationship_name, options, include_related) + + # Generate a set of resources that can be used to turn the resource_id_tree into a result set + resource_set = flatten_resource_id_tree(resource_id_tree) + + populate_resource_set(resource_set, serializer, options) + + resource_set + end + + def find_related_resource_id_tree(resource_klass, source_id, relationship_name, find_options, include_related) + options = find_options.except(:include_directives) + options[:cache] = resource_klass.caching? + + relationship = resource_klass._relationship(relationship_name) + + resources = {} + + identities = resource_klass.find_related_fragments([source_id], relationship_name, options) + + identities.each do |identity, value| + resources[identity] = { id: identity, + resource_klass: relationship.resource_klass, + primary: true, relationships: {} + } + + if resource_klass.caching? + resources[identity][:cache_field] = value[:cache] + end + end + + included_relationships = get_related(relationship.resource_klass, resources, include_related, options) + + { resources: resources, included: included_relationships } + end + + def find_resource_id_tree(resource_klass, find_options, include_related) + options = find_options.except(:include_directives) + options[:cache] = resource_klass.caching? + resources = {} + + identities = resource_klass.find_fragments(find_options[:filters], options) + identities.each do |identity, values| + resources[identity] = { primary: true, relationships: {} } + if resource_klass.caching? + resources[identity][:cache_field] = values[:cache] + end + end + + included_relationships = get_related(resource_klass, resources, include_related, options.except(:filters, :sort_criteria)) + + { resources: resources, included: included_relationships } + end + + def find_resource_id_tree_from_resource_relationship(resource, relationship_name, find_options, include_related) + relationship = resource.class._relationship(relationship_name) + + options = find_options.except(:include_directives) + options[:cache] = relationship.resource_klass.caching? + + identities = resource.class.find_related_fragments([resource.identity], relationship_name, options) + + resources = {} + + identities.each do |identity, values| + resources[identity] = { primary: true, relationships: {} } + if relationship.resource_klass.caching? + resources[identity][:cache_field] = values[:cache] + end + end + + options = options.except(:filters) + + included_relationships = get_related(resource_klass, resources, include_related, options) + + { resources: resources, included: included_relationships } + end + + # Gets the related resource connections for the source resources + # Note: source_resources must all be of the same type. This precludes includes through polymorphic + # relationships. ToDo: Prevent this when parsing the includes + def get_related(resource_klass, source_resources, include_related, options) + source_rids = source_resources.keys + + related = {} + + include_related.try(:keys).try(:each) do |key| + relationship = resource_klass._relationship(key) + relationship_name = relationship.name.to_sym + + cache_related = relationship.resource_klass.caching? + + related[relationship_name] = {} + related[relationship_name][:relationship] = relationship + related[relationship_name][:resources] = {} + + find_related_resource_options = options.dup + find_related_resource_options[:sort_criteria] = relationship.resource_klass.default_sort + find_related_resource_options[:cache] = resource_klass.caching? + + related_identities = resource_klass.find_related_fragments(source_rids, relationship_name, find_related_resource_options) + + related_identities.each_pair do |identity, v| + related[relationship_name][:resources][identity] = + { + source_rids: v[:related][relationship_name], + relationships: { + relationship.parent_resource._type => { rids: v[:related][relationship_name] } + } + } + + if cache_related + related[relationship_name][:resources][identity][:cache_field] = v[:cache] + end + end + + related[relationship_name][:resources].each do |related_rid, related_resource| + # add linkage to source records + related_resource[:source_rids].each do |id| + source_resource = source_resources[id] + source_resource[:relationships][relationship_name] ||= { rids: [] } + source_resource[:relationships][relationship_name][:rids] << related_rid + end + end + + # Now get the related resources for the currently found resources + included_resources = get_related(relationship.resource_klass, + related[relationship_name][:resources], + include_related[relationship_name][:include_related], + options) + + related[relationship_name][:included] = included_resources + end + + related + end + + # flatten the resource id tree into groupings by resource klass + def flatten_resource_id_tree(resource_id_tree, flattened_tree = {}) + resource_id_tree[:resources].each_pair do |resource_rid, resource_details| + + resource_klass = resource_rid.resource_klass + id = resource_rid.id + + flattened_tree[resource_klass] ||= {} + + flattened_tree[resource_klass][id] ||= { primary: resource_details[:primary], relationships: {} } + flattened_tree[resource_klass][id][:cache_id] ||= resource_details[:cache_field] + + resource_details[:relationships].try(:each_pair) do |relationship_name, details| + flattened_tree[resource_klass][id][:relationships][relationship_name] ||= { rids: [] } + + if details[:rids] && details[:rids].is_a?(Array) + details[:rids].each do |related_rid| + flattened_tree[resource_klass][id][:relationships][relationship_name][:rids] << related_rid + end + end + end + end + + included = resource_id_tree[:included] + included.try(:each_value) do |i| + flatten_resource_id_tree(i, flattened_tree) + end + + flattened_tree + end + + def populate_resource_set(resource_set, serializer, find_options) + + resource_set.each_key do |resource_klass| + missed_ids = [] + + serializer_config_key = serializer.config_key(resource_klass).gsub("/", "_") + context_json = resource_klass.attribute_caching_context(context).to_json + context_b64 = JSONAPI.configuration.resource_cache_digest_function.call(context_json) + context_key = "ATTR-CTX-#{context_b64.gsub("/", "_")}" + + if resource_klass.caching? + cache_ids = [] + + resource_set[resource_klass].each_pair do |k, v| + # Store the hashcode of the cache_field to avoid storing objects and to ensure precision isn't lost + # on timestamp types (i.e. string conversions dropping milliseconds) + cache_ids.push([k, resource_klass.hash_cache_field(v[:cache_id])]) + end + + found_resources = CachedResponseFragment.fetch_cached_fragments( + resource_klass, + serializer_config_key, + cache_ids, + context) + + found_resources.each do |found_result| + resource = found_result[1] + if resource.nil? + missed_ids.push(found_result[0]) + else + resource_set[resource_klass][resource.id][:resource] = resource + end + end + else + missed_ids = resource_set[resource_klass].keys + end + + # fill in the missed resources, it there are any + unless missed_ids.empty? + filters = {resource_klass._primary_key => missed_ids} + find_opts = { + context: context, + fields: find_options[:fields] } + + found_resources = resource_klass.find(filters, find_opts) + + found_resources.each do |resource| + relationship_data = resource_set[resource_klass][resource.id][:relationships] + + if resource_klass.caching? + (id, cr) = CachedResponseFragment.write( + resource_klass, + resource, + serializer, + serializer_config_key, + context, + context_key, + relationship_data) + + resource_set[resource_klass][id][:resource] = cr + else + resource_set[resource_klass][resource.id][:resource] = resource + end + end + end + end + end end end diff --git a/lib/jsonapi/record_accessor.rb b/lib/jsonapi/record_accessor.rb deleted file mode 100644 index 3cf39ee99..000000000 --- a/lib/jsonapi/record_accessor.rb +++ /dev/null @@ -1,66 +0,0 @@ -module JSONAPI - class RecordAccessor - attr_reader :_resource_klass - - def initialize(resource_klass) - @_resource_klass = resource_klass - end - - # Resource records - def find_resource(_filters, _options = {}) - # :nocov: - raise 'Abstract method called' - # :nocov: - end - - def find_resource_by_key(_key, options = {}) - # :nocov: - raise 'Abstract method called' - # :nocov: - end - - def find_resources_by_keys(_keys, options = {}) - # :nocov: - raise 'Abstract method called' - # :nocov: - end - - def find_count(_filters, _options = {}) - # :nocov: - raise 'Abstract method called' - # :nocov: - end - - # Relationship records - def related_resource(_resource, _relationship_name, _options = {}) - # :nocov: - raise 'Abstract method called' - # :nocov: - end - - def related_resources(_resource, _relationship_name, _options = {}) - # :nocov: - raise 'Abstract method called' - # :nocov: - end - - def count_for_relationship(_resource, _relationship_name, _options = {}) - # :nocov: - raise 'Abstract method called' - # :nocov: - end - - # Keys - def foreign_key(_resource, _relationship_name, options = {}) - # :nocov: - raise 'Abstract method called' - # :nocov: - end - - def foreign_keys(_resource, _relationship_name, _options = {}) - # :nocov: - raise 'Abstract method called' - # :nocov: - end - end -end \ No newline at end of file diff --git a/lib/jsonapi/relationship.rb b/lib/jsonapi/relationship.rb index 10b274e5a..4449742be 100644 --- a/lib/jsonapi/relationship.rb +++ b/lib/jsonapi/relationship.rb @@ -2,7 +2,8 @@ module JSONAPI class Relationship attr_reader :acts_as_set, :foreign_key, :options, :name, :class_name, :polymorphic, :always_include_linkage_data, - :parent_resource, :eager_load_on_include + :parent_resource, :eager_load_on_include, :custom_methods, + :inverse_relationship def initialize(name, options = {}) @name = name.to_s @@ -11,7 +12,9 @@ def initialize(name, options = {}) @foreign_key = options[:foreign_key] ? options[:foreign_key].to_sym : nil @parent_resource = options[:parent_resource] @relation_name = options.fetch(:relation_name, @name) + @custom_methods = options.fetch(:custom_methods, {}) @polymorphic = options.fetch(:polymorphic, false) == true + @polymorphic_relations = options[:polymorphic_relations] @always_include_linkage_data = options.fetch(:always_include_linkage_data, false) == true @eager_load_on_include = options.fetch(:eager_load_on_include, true) == true end @@ -30,6 +33,24 @@ def table_name @table_name ||= resource_klass._table_name end + def self.polymorphic_types(name) + @poly_hash ||= {}.tap do |hash| + ObjectSpace.each_object do |klass| + next unless Module === klass + if ActiveRecord::Base > klass + klass.reflect_on_all_associations(:has_many).select{|r| r.options[:as] }.each do |reflection| + (hash[reflection.options[:as]] ||= []) << klass.name.downcase + end + end + end + end + @poly_hash[name.to_sym] + end + + def polymorphic_relations + @polymorphic_relations ||= self.class.polymorphic_types(@relation_name) + end + def type @type ||= resource_klass._type.to_sym end @@ -47,15 +68,6 @@ def relation_name(options) end end - def type_for_source(source) - if polymorphic? - resource = source.public_send(name) - resource.class._type if resource - else - type - end - end - def belongs_to? false end @@ -76,6 +88,9 @@ def initialize(name, options = {}) @class_name = options.fetch(:class_name, name.to_s.camelize) @foreign_key ||= "#{name}_id".to_sym @foreign_key_on = options.fetch(:foreign_key_on, :self) + if parent_resource + @inverse_relationship = options.fetch(:inverse_relationship, parent_resource._type) + end end def belongs_to? @@ -88,14 +103,16 @@ def polymorphic_type end class ToMany < Relationship - attr_reader :reflect, :inverse_relationship + attr_reader :reflect def initialize(name, options = {}) super @class_name = options.fetch(:class_name, name.to_s.camelize.singularize) @foreign_key ||= "#{name.to_s.singularize}_ids".to_sym @reflect = options.fetch(:reflect, true) == true - @inverse_relationship = options.fetch(:inverse_relationship, parent_resource._type.to_s.singularize.to_sym) if parent_resource + if parent_resource + @inverse_relationship = options.fetch(:inverse_relationship, parent_resource._type.to_s.singularize.to_sym) + end end end end diff --git a/lib/jsonapi/request_parser.rb b/lib/jsonapi/request_parser.rb index c987e68be..f0019a1e6 100644 --- a/lib/jsonapi/request_parser.rb +++ b/lib/jsonapi/request_parser.rb @@ -146,13 +146,22 @@ def setup_show_action(params, resource_klass) def setup_show_relationship_action(params, resource_klass) relationship_type = params[:relationship] parent_key = params.require(resource_klass._as_parent_key) + include_directives = parse_include_directives(resource_klass, params[:include]) + filters = parse_filters(resource_klass, params[:filter]) + sort_criteria = parse_sort_criteria(resource_klass, params[:sort]) + paginator = parse_pagination(resource_klass, params[:page]) JSONAPI::Operation.new( :show_relationship, resource_klass, context: @context, relationship_type: relationship_type, - parent_key: resource_klass.verify_key(parent_key) + parent_key: resource_klass.verify_key(parent_key), + filters: filters, + sort_criteria: sort_criteria, + paginator: paginator, + fields: fields, + include_directives: include_directives ) end @@ -540,9 +549,7 @@ def parse_to_one_relationship(resource_klass, link_value, relationship) end def parse_to_many_relationship(resource_klass, link_value, relationship, &add_result) - if link_value.is_a?(Array) && link_value.length == 0 - linkage = [] - elsif (link_value.is_a?(Hash) || link_value.is_a?(ActionController::Parameters)) + if (link_value.is_a?(Hash) || link_value.is_a?(ActionController::Parameters)) linkage = link_value[:data] else fail JSONAPI::Exceptions::InvalidLinksObject.new(error_object_overrides) diff --git a/lib/jsonapi/resource.rb b/lib/jsonapi/resource.rb index 3a984ca00..ebc0c79c1 100644 --- a/lib/jsonapi/resource.rb +++ b/lib/jsonapi/resource.rb @@ -1,4 +1,5 @@ require 'jsonapi/callbacks' +require 'jsonapi/configuration' module JSONAPI class Resource @@ -35,8 +36,12 @@ 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? @@ -162,15 +167,6 @@ def custom_links(_options) {} end - def preloaded_fragments - # A hash of hashes - @preloaded_fragments ||= Hash.new - end - - def count_for_relationship(relationship_name, options) - self.class._record_accessor.count_for_relationship(self, relationship_name, options) - end - private def save @@ -290,7 +286,10 @@ def _replace_to_many_links(relationship_type, relationship_key_values, options) 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) @@ -320,9 +319,7 @@ def _replace_to_one_link(relationship_type, relationship_key_value, _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}=", self.class.model_name_for_type(key_type)) - + send("#{relationship.foreign_key}=", {type: key_type, id: key_value}) @save_needed = true :completed @@ -405,12 +402,12 @@ class << self def inherited(subclass) subclass.abstract(false) subclass.immutable(false) - subclass.caching(false) + subclass.caching(_caching) 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 @@ -427,8 +424,66 @@ def inherited(subclass) check_reserved_resource_name(subclass._type, subclass.name) - subclass.record_accessor = @_record_accessor_klass - end + 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: , + # cache: + # attributes: + # related: { + # : + # } + # } + # + # 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_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 @@ -439,6 +494,7 @@ def rebuild_relationships(relationships) 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 @@ -528,6 +584,32 @@ def attribute(attribute_name, options = {}) end unless method_defined?("#{attr}=") 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 { format: :default } end @@ -631,30 +713,23 @@ def _lookup_association_chain(model_names) end def find_count(filters, options = {}) - _record_accessor.find_count(filters, options) + # ToDo: Deprecation warning + count(filters, options) end - def find(filters, options = {}) - _record_accessor.find_resource(filters, options) + def records(options = {}) + _model_class.all end - def resources_for(models, context) - models.collect do |model| - resource_for(model, context) + def resources_for(records, context) + records.collect do |record| + resource_for(record, context) end end - def resource_for(model, context) - resource_klass = self.resource_klass_for_model(model) - resource_klass.new(model, context) - end - - def find_by_keys(keys, options = {}) - _record_accessor.find_resources_by_keys(keys, options) - end - - def find_by_key(key, options = {}) - _record_accessor.find_resource_by_key(key, options) + def resource_for(model_record, context) + resource_klass = self.resource_klass_for_model(model_record) + resource_klass.new(model_record, context) end def verify_filters(filters, context = nil) @@ -818,18 +893,6 @@ def paginator(paginator) @_paginator = paginator end - def _record_accessor - @_record_accessor = _record_accessor_klass.new(self) - end - - def record_accessor=(record_accessor_klass) - @_record_accessor_klass = record_accessor_klass - end - - def _record_accessor_klass - @_record_accessor_klass ||= JSONAPI.configuration.default_record_accessor_klass - end - def abstract(val = true) @abstract = val end @@ -859,13 +922,22 @@ def _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) 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 @@ -924,76 +996,24 @@ def _add_relationship(klass, *attrs) # ResourceBuilder methods def define_relationship_methods(relationship_name, relationship_klass, options) - # Initialize from an ActiveRecord model's properties - if _model_class && _model_class.ancestors.collect { |ancestor| ancestor.name }.include?('ActiveRecord::Base') - model_association = _model_class.reflect_on_association(relationship_name) - if model_association - options = options.reverse_merge(class_name: model_association.class_name) - end - end - relationship = register_relationship( relationship_name, relationship_klass.new(relationship_name, options) ) define_foreign_key_setter(relationship) - - case relationship - when JSONAPI::Relationship::ToOne - if relationship.belongs_to? - build_belongs_to(relationship) - else - build_has_one(relationship) - end - when JSONAPI::Relationship::ToMany - build_to_many(relationship) - end end def define_foreign_key_setter(relationship) - define_on_resource "#{relationship.foreign_key}=" do |value| - _model.method("#{relationship.foreign_key}=").call(value) - end - end - - def build_belongs_to(relationship) - foreign_key = relationship.foreign_key - define_on_resource foreign_key do - self.class._record_accessor.foreign_key(self, relationship.name) - end - - # Returns instantiated related resource object or nil - define_on_resource relationship.name do |options = {}| - self.class._record_accessor.related_resource(self, relationship.name, options) - end - end - - def build_has_one(relationship) - foreign_key = relationship.foreign_key - - # Returns primary key name of related resource class - define_on_resource foreign_key do - self.class._record_accessor.foreign_key(self, relationship.name) - end - - # Returns instantiated related resource object or nil - define_on_resource relationship.name do |options = {}| - self.class._record_accessor.related_resource(self, relationship.name, options) - end - end - - def build_to_many(relationship) - foreign_key = relationship.foreign_key - - # Returns array of primary keys of related resource classes - define_on_resource foreign_key do - self.class._record_accessor.foreign_keys(self, relationship.name) - end - - # Returns array of instantiated related resource objects - define_on_resource relationship.name do |options = {}| - self.class._record_accessor.related_resources(self, relationship.name, options) + 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 + define_on_resource "#{relationship.foreign_key}=" do |value| + _model.method("#{relationship.foreign_key}=").call(value) + end end end @@ -1018,7 +1038,7 @@ def check_reserved_resource_name(type, name) 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 diff --git a/lib/jsonapi/resource_identity.rb b/lib/jsonapi/resource_identity.rb new file mode 100644 index 000000000..72635ecb4 --- /dev/null +++ b/lib/jsonapi/resource_identity.rb @@ -0,0 +1,42 @@ +module JSONAPI + + # ResourceIdentity describes a unique identity of a resource in the system. + # This consists of a Resource class and an identifier that is unique within + # that Resource class. ResourceIdentities are intended to be used as hash + # keys to provide ordered mixing of resource types in result sets. + # + # + # == Creating a ResourceIdentity + # + # rid = ResourceIdentity.new(PostResource, 12) + # + class ResourceIdentity + attr_reader :resource_klass, :id + + def initialize(resource_klass, id) + @resource_klass = resource_klass + @id = id + end + + def ==(other) + # :nocov: + eql?(other) + # :nocov: + end + + def eql?(other) + other.is_a?(ResourceIdentity) && other.resource_klass == @resource_klass && other.id == @id + end + + def hash + [@resource_klass, @id].hash + end + + # Creates a string representation of the identifier. + def to_s + # :nocov: + "#{resource_klass}:#{id}" + # :nocov: + end + end +end diff --git a/lib/jsonapi/resource_serializer.rb b/lib/jsonapi/resource_serializer.rb index 7cef106c5..f805c5d71 100644 --- a/lib/jsonapi/resource_serializer.rb +++ b/lib/jsonapi/resource_serializer.rb @@ -41,67 +41,90 @@ def initialize(primary_resource_klass, options = {}) @_supplying_relationship_fields = {} end - # Converts a single resource, or an array of resources to a hash, conforming to the JSONAPI structure - def serialize_to_hash(source) - @top_level_sources = Set.new([source].flatten(1).compact.map {|s| top_level_source_key(s) }) + # Converts a resource_set to a hash, conforming to the JSONAPI structure + def serialize_resource_set_to_hash(result_set) - is_resource_collection = source.respond_to?(:to_ary) + primary_objects = [] + included_objects = [] + + result_set.each_value do |values| + values.each_value do |value| + serialized_result = object_hash(value[:resource], value[:relationships]) + + if value[:primary] + primary_objects.push(serialized_result) + else + included_objects.push(serialized_result) + end + end + end + + fail "To Many primary objects for show" if (primary_objects.count > 1) + primary_hash = { 'data' => primary_objects[0] } - @included_objects = {} + primary_hash['included'] = included_objects if included_objects.size > 0 + primary_hash + end - process_source_objects(source, @include_directives.include_directives) + def serialize_resources_set_to_hash(result_set) primary_objects = [] + included_objects = [] - # pull the processed objects corresponding to the source objects. Ensures we preserve order. - if is_resource_collection - source.each do |primary| - if primary.id - case primary - when CachedResourceFragment then primary_objects.push(@included_objects[primary.type][primary.id][:object_hash]) - when Resource then primary_objects.push(@included_objects[primary.class._type][primary.id][:object_hash]) - else raise "Unknown source type #{primary.inspect}" - end - end - end - else - if source.try(:id) - case source - when CachedResourceFragment then primary_objects.push(@included_objects[source.type][source.id][:object_hash]) - when Resource then primary_objects.push(@included_objects[source.class._type][source.id][:object_hash]) - else raise "Unknown source type #{source.inspect}" + result_set.each_value do |resources| + resources.each_value do |resource| + serialized_result = object_hash(resource[:resource], resource[:relationships]) + + if resource[:primary] + primary_objects.push(serialized_result) + else + included_objects.push(serialized_result) end end end + primary_hash = { 'data' => primary_objects } + + primary_hash['included'] = included_objects if included_objects.size > 0 + primary_hash + end + + def serialize_related_resources_set_to_hash(source_resource, result_set) + + primary_objects = [] included_objects = [] - @included_objects.each_value do |objects| - objects.each_value do |object| - unless object[:primary] - included_objects.push(object[:object_hash]) + + result_set.each_value do |values| + values.each_value do |value| + serialized_result = object_hash(value[:resource], value[:relationships]) + + if value[:primary] + primary_objects.push(serialized_result) + else + included_objects.push(serialized_result) end end end - primary_hash = { 'data' => is_resource_collection ? primary_objects : primary_objects[0] } + primary_hash = { 'data' => primary_objects } primary_hash['included'] = included_objects if included_objects.size > 0 primary_hash end - def serialize_to_links_hash(source, requested_relationship) + def serialize_to_links_hash(source, requested_relationship, resource_ids) if requested_relationship.is_a?(JSONAPI::Relationship::ToOne) - data = to_one_linkage(source, requested_relationship) + data = to_one_linkage(resource_ids[0]) else - data = to_many_linkage(source, requested_relationship) + data = to_many_linkage(resource_ids) end { - 'links' => { - 'self' => self_link(source, requested_relationship), - 'related' => related_link(source, requested_relationship) - }, - 'data' => data + 'links' => { + 'self' => self_link(source, requested_relationship), + 'related' => related_link(source, requested_relationship) + }, + 'data' => data } end @@ -113,6 +136,10 @@ def format_key(key) @key_formatter.format(key) end + def unformat_key(key) + @key_formatter.unformat(key) + end + def format_value(value, format) @value_formatter_type_cache.get(format).format(value) end @@ -139,24 +166,28 @@ def config_description(resource_klass) } end - # Returns a serialized hash for the source model - def object_hash(source, include_directives = {}) + def object_hash(source, relationship_data) obj_hash = {} - if source.is_a?(JSONAPI::CachedResourceFragment) - obj_hash['id'] = source.id + return obj_hash if source.nil? + + fetchable_fields = Set.new(source.fetchable_fields) + + if source.is_a?(JSONAPI::CachedResponseFragment) + id_format = source.resource_klass._attribute_options(:id)[:format] + + id_format = 'id' if id_format == :default + obj_hash['id'] = format_value(source.id, id_format) obj_hash['type'] = source.type obj_hash['links'] = source.links_json if source.links_json obj_hash['attributes'] = source.attributes_json if source.attributes_json - relationships = cached_relationships_hash(source, include_directives) - obj_hash['relationships'] = relationships unless relationships.empty? + relationships = cached_relationships_hash(source, fetchable_fields, relationship_data) + obj_hash['relationships'] = relationships unless relationships.nil? || relationships.empty? obj_hash['meta'] = source.meta_json if source.meta_json else - fetchable_fields = Set.new(source.fetchable_fields) - # TODO Should this maybe be using @id_formatter instead, for consistency? id_format = source.class._attribute_options(:id)[:format] # protect against ids that were declared as an attribute, but did not have a format set. @@ -171,7 +202,7 @@ def object_hash(source, include_directives = {}) attributes = attributes_hash(source, fetchable_fields) obj_hash['attributes'] = attributes unless attributes.empty? - relationships = relationships_hash(source, fetchable_fields, include_directives) + relationships = relationships_hash(source, fetchable_fields, relationship_data) obj_hash['relationships'] = relationships unless relationships.nil? || relationships.empty? meta = meta_hash(source) @@ -183,19 +214,6 @@ def object_hash(source, include_directives = {}) private - # Process the primary source object(s). This will then serialize associated object recursively based on the - # requested includes. Fields are controlled fields option for each resource type, such - # as fields: { people: [:id, :email, :comments], posts: [:id, :title, :author], comments: [:id, :body, :post]} - # The fields options controls both fields and included links references. - def process_source_objects(source, include_directives) - if source.respond_to?(:to_ary) - source.each { |resource| process_source_objects(resource, include_directives) } - else - return {} if source.nil? - add_resource(source, include_directives, true) - end - end - def supplying_attribute_fields(resource_klass) @_supplying_attribute_fields.fetch resource_klass do attrs = Set.new(resource_klass._attributes.keys.map(&:to_sym)) @@ -259,116 +277,61 @@ def custom_links_hash(source) (custom_links.is_a?(Hash) && custom_links) || {} end - def top_level_source_key(source) - case source - when CachedResourceFragment then "#{source.resource_klass}_#{source.id}" - when Resource then "#{source.class}_#{@id_formatter.format(source.id)}" - else raise "Unknown source type #{source.inspect}" - end - end - - def self_referential_and_already_in_source(resource) - resource && @top_level_sources.include?(top_level_source_key(resource)) - end - - def relationships_hash(source, fetchable_fields, include_directives = {}) - if source.is_a?(CachedResourceFragment) - return cached_relationships_hash(source, include_directives) - end - - include_directives[:include_related] ||= {} - + def relationships_hash(source, fetchable_fields, relationship_data) relationships = source.class._relationships.select{|k,_v| fetchable_fields.include?(k) } field_set = supplying_relationship_fields(source.class) & relationships.keys relationships.each_with_object({}) do |(name, relationship), hash| - ia = include_directives[:include_related][name] - include_linkage = ia && ia[:include] - include_linked_children = ia && !ia[:include_related].empty? - if field_set.include?(name) - hash[format_key(name)] = link_object(source, relationship, include_linkage) - end - - # If the object has been serialized once it will be in the related objects list, - # but it's possible all children won't have been captured. So we must still go - # through the relationships. - if include_linkage || include_linked_children - resources = if source.preloaded_fragments.has_key?(format_key(name)) - source.preloaded_fragments[format_key(name)].values - else - [source.public_send(name)].flatten(1).compact - end - resources.each do |resource| - next if self_referential_and_already_in_source(resource) - id = resource.id - relationships_only = already_serialized?(relationship.type, id) - if include_linkage && !relationships_only - add_resource(resource, ia) - elsif include_linked_children || relationships_only - relationships_hash(resource, fetchable_fields, ia) + if relationship_data[name] + if relationship.is_a?(JSONAPI::Relationship::ToOne) + rids = relationship_data[name][:rids].first + else + rids = relationship_data[name][:rids] end end + + hash[format_key(name)] = link_object(source, relationship, rids) end end end - def cached_relationships_hash(source, include_directives) - h = source.relationships || {} - return h unless include_directives.has_key?(:include_related) + def cached_relationships_hash(source, fetchable_fields, relationship_data) + relationships = {} - relationships = source.resource_klass._relationships.select do |k,_v| - source.fetchable_fields.include?(k) + source.relationships.try(:each_pair) do |k,v| + if fetchable_fields.include?(unformat_key(k).to_sym) + relationships[k.to_sym] = v + end end - real_res = nil - relationships.each do |rel_name, relationship| - key = format_key(rel_name) - to_many = relationship.is_a? JSONAPI::Relationship::ToMany + field_set = supplying_relationship_fields(source.resource_klass).collect {|k| format_key(k).to_sym } & relationships.keys - ia = include_directives[:include_related][rel_name] - if ia - if h.has_key?(key) - h[key]['data'] = to_many ? [] : nil - end + relationships.each_with_object({}) do |(name, relationship), hash| + if field_set.include?(name) - fragments = source.preloaded_fragments[key] - if fragments.nil? - # The resources we want were not preloaded, we'll have to bypass the cache. - # This happens when including through belongs_to polymorphic relationships - if real_res.nil? - real_res = source.to_real_resource + relationship_name = unformat_key(name).to_sym + relationship_klass = source.resource_klass._relationships[relationship_name] + + if relationship_klass.is_a?(JSONAPI::Relationship::ToOne) + # include_linkage = @always_include_to_one_linkage_data | relationship_klass.always_include_linkage_data + if relationship_data[relationship_name] + rids = relationship_data[relationship_name][:rids].first + include_linkage = rids + relationship['data'] = to_one_linkage(rids) if include_linkage end - relation_resources = [real_res.public_send(rel_name)].flatten(1).compact - fragments = relation_resources.map{|r| [r.id, r]}.to_h - end - fragments.each do |id, f| - add_resource(f, ia) - - if h.has_key?(key) - # The hash already has everything we need except the :data field - data = { - 'type' => format_key(f.is_a?(Resource) ? f.class._type : f.type), - 'id' => @id_formatter.format(id) - } - - if to_many - h[key]['data'] << data - else - h[key]['data'] = data - end + else + # include_linkage = relationship_klass.always_include_linkage_data + if relationship_data[relationship_name] + rids = relationship_data[relationship_name][:rids] + include_linkage = !(rids.nil? || rids.empty?) + relationship['data'] = to_many_linkage(rids) if include_linkage end end + + hash[format_key(name)] = relationship end end - - return h - end - - def already_serialized?(type, id) - type = format_key(type) - id = @id_formatter.format(id) - @included_objects.key?(type) && @included_objects[type].key?(id) end def self_link(source, relationship) @@ -379,115 +342,56 @@ def related_link(source, relationship) link_builder.relationships_related_link(source, relationship) end - def to_one_linkage(source, relationship) - linkage_id = foreign_key_value(source, relationship) - linkage_type = format_key(relationship.type_for_source(source)) - return unless linkage_id.present? && linkage_type.present? - - { - 'type' => linkage_type, - 'id' => linkage_id, - } - end - - def to_many_linkage(source, relationship) + def to_many_linkage(rids) linkage = [] - linkage_types_and_values = if source.preloaded_fragments.has_key?(format_key(relationship.name)) - source.preloaded_fragments[format_key(relationship.name)].map do |_, resource| - [relationship.type, resource.id] - end - elsif relationship.polymorphic? - assoc = source._model.public_send(relationship.name) - # Avoid hitting the database again for values already pre-loaded - if assoc.respond_to?(:loaded?) and assoc.loaded? - assoc.map do |obj| - [obj.type.underscore.pluralize, obj.id] - end - else - assoc.pluck(:type, :id).map do |type, id| - [type.underscore.pluralize, id] - end - end - else - source.public_send(relationship.name).map do |value| - [relationship.type, value.id] - end - end - linkage_types_and_values.each do |type, value| - if type && value - linkage.append({'type' => format_key(type), 'id' => @id_formatter.format(value)}) + rids.each do |details| + id = details.id + type = details.resource_klass.try(:_type) + if type && id + linkage.append({'type' => format_key(type), 'id' => @id_formatter.format(id)}) end end + linkage end - def link_object_to_one(source, relationship, include_linkage) - include_linkage = include_linkage | @always_include_to_one_linkage_data | relationship.always_include_linkage_data + def to_one_linkage(rid) + return unless rid + + { + 'type' => format_key(rid.resource_klass._type), + 'id' => @id_formatter.format(rid.id), + } + end + + def link_object_to_one(source, relationship, rid) + # include_linkage = @always_include_to_one_linkage_data | relationship.always_include_linkage_data + include_linkage = rid link_object_hash = {} link_object_hash['links'] = {} link_object_hash['links']['self'] = self_link(source, relationship) link_object_hash['links']['related'] = related_link(source, relationship) - link_object_hash['data'] = to_one_linkage(source, relationship) if include_linkage + link_object_hash['data'] = to_one_linkage(rid) if include_linkage link_object_hash end - def link_object_to_many(source, relationship, include_linkage) - include_linkage = include_linkage | relationship.always_include_linkage_data + def link_object_to_many(source, relationship, rids) + # include_linkage = relationship.always_include_linkage_data + include_linkage = rids && !rids.empty? link_object_hash = {} link_object_hash['links'] = {} link_object_hash['links']['self'] = self_link(source, relationship) link_object_hash['links']['related'] = related_link(source, relationship) - link_object_hash['data'] = to_many_linkage(source, relationship) if include_linkage + link_object_hash['data'] = to_many_linkage(rids) if include_linkage link_object_hash end - def link_object(source, relationship, include_linkage = false) + def link_object(source, relationship, rid) if relationship.is_a?(JSONAPI::Relationship::ToOne) - link_object_to_one(source, relationship, include_linkage) + link_object_to_one(source, relationship, rid) elsif relationship.is_a?(JSONAPI::Relationship::ToMany) - link_object_to_many(source, relationship, include_linkage) - end - end - - # Extracts the foreign key value for a to_one relationship. - def foreign_key_value(source, relationship) - related_resource_id = if source.preloaded_fragments.has_key?(format_key(relationship.name)) - source.preloaded_fragments[format_key(relationship.name)].values.first.try(:id) - elsif !relationship.redefined_pkey? && !relationship.polymorphic? && source.respond_to?(relationship.foreign_key) - # If you have direct access to the underlying id, you don't have to load the relationship - # which can save quite a lot of time when loading a lot of data. - # This does not apply to e.g. has_one :through relationships. - source.public_send(relationship.foreign_key) - else - source.public_send(relationship.name).try(:id) - end - return nil unless related_resource_id - @id_formatter.format(related_resource_id) - end - - def add_resource(source, include_directives, primary = false) - type = source.is_a?(JSONAPI::CachedResourceFragment) ? source.type : source.class._type - id = source.id - - @included_objects[type] ||= {} - existing = @included_objects[type][id] - - if existing.nil? - obj_hash = object_hash(source, include_directives) - @included_objects[type][id] = { - primary: primary, - object_hash: obj_hash, - includes: Set.new(include_directives[:include_related].keys) - } - else - include_related = Set.new(include_directives[:include_related].keys) - unless existing[:includes].superset?(include_related) - obj_hash = object_hash(source, include_directives) - @included_objects[type][id][:object_hash].deep_merge!(obj_hash) - @included_objects[type][id][:includes].add(include_related) - @included_objects[type][id][:primary] = existing[:primary] | primary - end + link_object_to_many(source, relationship, rid) end end diff --git a/lib/jsonapi/response_document.rb b/lib/jsonapi/response_document.rb index 3f4833bce..78728d995 100644 --- a/lib/jsonapi/response_document.rb +++ b/lib/jsonapi/response_document.rb @@ -71,6 +71,8 @@ def status # if there is only one status code we can return that return counts.keys[0].to_i if counts.length == 1 + # :nocov: not currently used + # if there are many we should return the highest general code, 200, 400, 500 etc. max_status = 0 status_codes.each do |status| @@ -78,13 +80,9 @@ def status max_status = code if max_status < code end return (max_status / 100).floor * 100 + # :nocov: end - # - # def status_sym - # Rack::Utils::HTTP_STATUS_CODES[status].downcase.gsub(/\s|-|'/, '_').to_sym - # end - private def update_meta(result) @@ -113,9 +111,12 @@ def update_links(serializer, result) @top_level_links.merge!(result.links) # Build pagination links - if result.is_a?(JSONAPI::ResourcesOperationResult) || result.is_a?(JSONAPI::RelatedResourcesOperationResult) + if result.is_a?(JSONAPI::ResourceSetOperationResult) || + result.is_a?(JSONAPI::ResourcesSetOperationResult) || + result.is_a?(JSONAPI::RelatedResourcesSetOperationResult) + result.pagination_params.each_pair do |link_name, params| - if result.is_a?(JSONAPI::RelatedResourcesOperationResult) + if result.is_a?(JSONAPI::RelatedResourcesSetOperationResult) relationship = result.source_resource.class._relationships[result._type.to_sym] @top_level_links[link_name] = serializer.link_builder.relationships_related_link(result.source_resource, relationship, query_params(params)) else diff --git a/lib/jsonapi/routing_ext.rb b/lib/jsonapi/routing_ext.rb index 045090cc4..34e9d6ff7 100644 --- a/lib/jsonapi/routing_ext.rb +++ b/lib/jsonapi/routing_ext.rb @@ -250,8 +250,8 @@ def jsonapi_resource_scope(resource, resource_type) #:nodoc: ensure @scope = @scope.parent end - # :nocov: + private def resource_type_with_module_prefix(resource = nil) diff --git a/test/config/database.yml b/test/config/database.yml index 0cda30abf..97abfd13b 100644 --- a/test/config/database.yml +++ b/test/config/database.yml @@ -1,5 +1,6 @@ test: adapter: sqlite3 database: test_db +# database: ":memory:" pool: 5 timeout: 5000 diff --git a/test/controllers/controller_test.rb b/test/controllers/controller_test.rb index 262acc1d3..d10ddd275 100644 --- a/test/controllers/controller_test.rb +++ b/test/controllers/controller_test.rb @@ -212,6 +212,13 @@ def test_on_server_error_callback_without_exception $PostProcessorRaisesErrors = false end + def test_posts_index_include + assert_cacheable_get :index, params: {filter: {id: '10,12'}, include: 'author'} + assert_response :success + assert_equal 2, json_response['data'].size + assert_equal 2, json_response['included'].size + end + def test_index_filter_with_empty_result assert_cacheable_get :index, params: {filter: {title: 'post that does not exist'}} assert_response :success @@ -270,14 +277,14 @@ def test_index_filter_not_allowed end def test_index_include_one_level_query_count - assert_query_count(2) do + assert_query_count(4) do assert_cacheable_get :index, params: {include: 'author'} end assert_response :success end def test_index_include_two_levels_query_count - assert_query_count(3) do + assert_query_count(6) do assert_cacheable_get :index, params: {include: 'author,author.comments'} end assert_response :success @@ -328,8 +335,8 @@ def test_index_filter_by_ids_and_fields_2 end def test_filter_relationship_single - assert_query_count(1) do - assert_cacheable_get :index, params: {filter: {tags: '5,1'}} + assert_query_count(2) do + assert_cacheable_get :index, params: {filter: {tags: '505,501'}} end assert_response :success assert_equal 3, json_response['data'].size @@ -339,8 +346,8 @@ def test_filter_relationship_single end def test_filter_relationships_multiple - assert_query_count(1) do - assert_cacheable_get :index, params: {filter: {tags: '5,1', comments: '3'}} + assert_query_count(2) do + assert_cacheable_get :index, params: {filter: {tags: '505,501', comments: '3'}} end assert_response :success assert_equal 1, json_response['data'].size @@ -348,7 +355,7 @@ def test_filter_relationships_multiple end def test_filter_relationships_multiple_not_found - assert_cacheable_get :index, params: {filter: {tags: '1', comments: '3'}} + assert_cacheable_get :index, params: {filter: {tags: '501', comments: '3'}} assert_response :success assert_equal 0, json_response['data'].size end @@ -396,7 +403,7 @@ def test_resource_not_supported end def test_index_filter_on_relationship - assert_cacheable_get :index, params: {filter: {author: '1'}} + assert_cacheable_get :index, params: {filter: {author: '1001'}} assert_response :success assert_equal 3, json_response['data'].size end @@ -485,7 +492,7 @@ def test_excluded_sort_param assert_match /id is not a valid sort criteria for post/, response.body end - def test_show_single + def test_show_single_no_includes assert_cacheable_get :show, params: {id: '1'} assert_response :success assert json_response['data'].is_a?(Hash) @@ -580,7 +587,7 @@ def test_create_simple body: 'JSONAPIResources is the greatest thing since unsliced bread.' }, relationships: { - author: {data: {type: 'people', id: '3'}} + author: {data: {type: 'people', id: '1003'}} } } } @@ -604,7 +611,7 @@ def test_create_simple_id_not_allowed body: 'JSONAPIResources is the greatest thing since unsliced bread.' }, relationships: { - author: {data: {type: 'people', id: '3'}} + author: {data: {type: 'people', id: '1003'}} } } } @@ -636,6 +643,26 @@ def test_create_link_to_missing_object assert_nil response.location end + def test_create_bad_relationship_array + set_content_type_header! + put :create, params: + { + data: { + type: 'posts', + attributes: { + title: 'A poorly formed new Post' + }, + relationships: { + author: {data: {type: 'people', id: '1003'}}, + tags: [] + } + } + } + + assert_response :bad_request + assert_match /Data is not a valid Links Object./, response.body + end + def test_create_extra_param set_content_type_header! post :create, params: @@ -648,7 +675,7 @@ def test_create_extra_param body: 'JSONAPIResources is the greatest thing since unsliced bread.' }, relationships: { - author: {data: {type: 'people', id: '3'}} + author: {data: {type: 'people', id: '1003'}} } } } @@ -673,7 +700,7 @@ def test_create_extra_param_allow_extra_params body: 'JSONAPIResources is the greatest thing since unsliced bread.' }, relationships: { - author: {data: {type: 'people', id: '3'}} + author: {data: {type: 'people', id: '1003'}} } }, include: 'author' @@ -681,7 +708,7 @@ def test_create_extra_param_allow_extra_params assert_response :created assert json_response['data'].is_a?(Hash) - assert_equal '3', json_response['data']['relationships']['author']['data']['id'] + assert_equal '1003', json_response['data']['relationships']['author']['data']['id'] assert_equal 'JR is Great', json_response['data']['attributes']['title'] assert_equal 'JSONAPIResources is the greatest thing since unsliced bread.', json_response['data']['attributes']['body'] @@ -737,7 +764,7 @@ def test_create_multiple body: 'JSONAPIResources is the greatest thing since unsliced bread.' }, relationships: { - author: {data: {type: 'people', id: '3'}} + author: {data: {type: 'people', id: '1003'}} } }, { @@ -747,7 +774,7 @@ def test_create_multiple body: 'Ember is the greatest thing since unsliced bread.' }, relationships: { - author: {data: {type: 'people', id: '3'}} + author: {data: {type: 'people', id: '1003'}} } } ] @@ -768,7 +795,7 @@ def test_create_simple_missing_posts body: 'JSONAPIResources is the greatest thing since unsliced bread.' }, relationships: { - author: {data: {type: 'people', id: '3'}} + author: {data: {type: 'people', id: '1003'}} } } } @@ -789,7 +816,7 @@ def test_create_simple_wrong_type body: 'JSONAPIResources is the greatest thing since unsliced bread.' }, relationships: { - author: {data: {type: 'people', id: '3'}} + author: {data: {type: 'people', id: '1003'}} } } } @@ -809,7 +836,7 @@ def test_create_simple_missing_type body: 'JSONAPIResources is the greatest thing since unsliced bread.' }, relationships: { - author: {data: {type: 'people', id: '3'}} + author: {data: {type: 'people', id: '1003'}} } } } @@ -830,7 +857,7 @@ def test_create_simple_unpermitted_attributes body: 'JSONAPIResources is the greatest thing since unsliced bread.' }, relationships: { - author: {data: {type: 'people', id: '3'}} + author: {data: {type: 'people', id: '1003'}} } } } @@ -854,7 +881,7 @@ def test_create_simple_unpermitted_attributes_allow_extra_params body: 'JSONAPIResources is the greatest thing since unsliced bread.' }, relationships: { - author: {data: {type: 'people', id: '3'}} + author: {data: {type: 'people', id: '1003'}} } }, include: 'author' @@ -862,7 +889,7 @@ def test_create_simple_unpermitted_attributes_allow_extra_params assert_response :created assert json_response['data'].is_a?(Hash) - assert_equal '3', json_response['data']['relationships']['author']['data']['id'] + assert_equal '1003', json_response['data']['relationships']['author']['data']['id'] assert_equal 'JR is Great', json_response['data']['attributes']['title'] assert_equal 'JR is Great', json_response['data']['attributes']['subject'] assert_equal 'JSONAPIResources is the greatest thing since unsliced bread.', json_response['data']['attributes']['body'] @@ -888,8 +915,8 @@ def test_create_with_links_to_many_type_ids body: 'JSONAPIResources is the greatest thing since unsliced bread.' }, relationships: { - author: {data: {type: 'people', id: '3'}}, - tags: {data: [{type: 'tags', id: 3}, {type: 'tags', id: 4}]} + author: {data: {type: 'people', id: '1003'}}, + tags: {data: [{type: 'tags', id: 503}, {type: 'tags', id: 504}]} } }, include: 'author' @@ -897,7 +924,7 @@ def test_create_with_links_to_many_type_ids assert_response :created assert json_response['data'].is_a?(Hash) - assert_equal '3', json_response['data']['relationships']['author']['data']['id'] + assert_equal '1003', json_response['data']['relationships']['author']['data']['id'] assert_equal 'JR is Great', json_response['data']['attributes']['title'] assert_equal 'JSONAPIResources is the greatest thing since unsliced bread.', json_response['data']['attributes']['body'] assert_equal json_response['data']['links']['self'], response.location @@ -914,8 +941,8 @@ def test_create_with_links_to_many_array body: 'JSONAPIResources is the greatest thing since unsliced bread.' }, relationships: { - author: {data: {type: 'people', id: '3'}}, - tags: {data: [{type: 'tags', id: 3}, {type: 'tags', id: 4}]} + author: {data: {type: 'people', id: '1003'}}, + tags: {data: [{type: 'tags', id: 503}, {type: 'tags', id: 504}]} } }, include: 'author' @@ -923,7 +950,7 @@ def test_create_with_links_to_many_array assert_response :created assert json_response['data'].is_a?(Hash) - assert_equal '3', json_response['data']['relationships']['author']['data']['id'] + assert_equal '1003', json_response['data']['relationships']['author']['data']['id'] assert_equal 'JR is Great', json_response['data']['attributes']['title'] assert_equal 'JSONAPIResources is the greatest thing since unsliced bread.', json_response['data']['attributes']['body'] assert_equal json_response['data']['links']['self'], response.location @@ -940,8 +967,8 @@ def test_create_with_links_include_and_fields body: 'JSONAPIResources is the greatest thing since unsliced bread!' }, relationships: { - author: {data: {type: 'people', id: '3'}}, - tags: {data: [{type: 'tags', id: 3}, {type: 'tags', id: 4}]} + author: {data: {type: 'people', id: '1003'}}, + tags: {data: [{type: 'tags', id: 503}, {type: 'tags', id: 504}]} } }, include: 'author,author.posts', @@ -950,7 +977,7 @@ def test_create_with_links_include_and_fields assert_response :created assert json_response['data'].is_a?(Hash) - assert_equal '3', json_response['data']['relationships']['author']['data']['id'] + assert_equal '1003', json_response['data']['relationships']['author']['data']['id'] assert_equal 'JR is Great!', json_response['data']['attributes']['title'] assert_not_nil json_response['included'].size assert_equal json_response['data']['links']['self'], response.location @@ -971,7 +998,7 @@ def test_update_with_links }, relationships: { section: {data: {type: 'sections', id: "#{javascript.id}"}}, - tags: {data: [{type: 'tags', id: 3}, {type: 'tags', id: 4}]} + tags: {data: [{type: 'tags', id: 503}, {type: 'tags', id: 504}]} } }, include: 'tags,author,section' @@ -979,11 +1006,11 @@ def test_update_with_links assert_response :success assert json_response['data'].is_a?(Hash) - assert_equal '3', json_response['data']['relationships']['author']['data']['id'] + assert_equal '1003', json_response['data']['relationships']['author']['data']['id'] assert_equal javascript.id.to_s, json_response['data']['relationships']['section']['data']['id'] assert_equal 'A great new Post', json_response['data']['attributes']['title'] assert_equal 'AAAA', json_response['data']['attributes']['body'] - assert matches_array?([{'type' => 'tags', 'id' => '3'}, {'type' => 'tags', 'id' => '4'}], + assert matches_array?([{'type' => 'tags', 'id' => '503'}, {'type' => 'tags', 'id' => '504'}], json_response['data']['relationships']['tags']['data']) end @@ -1027,7 +1054,7 @@ def test_update_with_links_allow_extra_params }, relationships: { section: {data: {type: 'sections', id: "#{javascript.id}"}}, - tags: {data: [{type: 'tags', id: 3}, {type: 'tags', id: 4}]} + tags: {data: [{type: 'tags', id: 503}, {type: 'tags', id: 504}]} } }, include: 'tags,author,section' @@ -1035,11 +1062,11 @@ def test_update_with_links_allow_extra_params assert_response :success assert json_response['data'].is_a?(Hash) - assert_equal '3', json_response['data']['relationships']['author']['data']['id'] + assert_equal '1003', json_response['data']['relationships']['author']['data']['id'] assert_equal javascript.id.to_s, json_response['data']['relationships']['section']['data']['id'] assert_equal 'A great new Post', json_response['data']['attributes']['title'] assert_equal 'AAAA', json_response['data']['attributes']['body'] - assert matches_array?([{'type' => 'tags', 'id' => '3'}, {'type' => 'tags', 'id' => '4'}], + assert matches_array?([{'type' => 'tags', 'id' => '503'}, {'type' => 'tags', 'id' => '504'}], json_response['data']['relationships']['tags']['data']) @@ -1066,7 +1093,7 @@ def test_update_remove_links }, relationships: { section: {data: {type: 'sections', id: 1}}, - tags: {data: [{type: 'tags', id: 3}, {type: 'tags', id: 4}]} + tags: {data: [{type: 'tags', id: 503}, {type: 'tags', id: 504}]} } }, include: 'tags' @@ -1092,7 +1119,7 @@ def test_update_remove_links }, relationships: { section: nil, - tags: [] + tags: {data: []} } }, include: 'tags,author,section' @@ -1100,12 +1127,13 @@ def test_update_remove_links assert_response :success assert json_response['data'].is_a?(Hash) - assert_equal '3', json_response['data']['relationships']['author']['data']['id'] + assert_equal '1003', json_response['data']['relationships']['author']['data']['id'] assert_nil json_response['data']['relationships']['section']['data'] assert_equal 'A great new Post', json_response['data']['attributes']['title'] assert_equal 'AAAA', json_response['data']['attributes']['body'] - assert matches_array?([], - json_response['data']['relationships']['tags']['data']) + + # Todo: determine if we should preserve the empty array when included data is included + # assert matches_array?([], json_response['data']['relationships']['tags']['data']) end def test_update_relationship_to_one @@ -1152,7 +1180,7 @@ def test_update_relationship_to_one_invalid_links_hash_count def test_update_relationship_to_many_not_array set_content_type_header! - put :update_relationship, params: {post_id: 3, relationship: 'tags', data: {type: 'tags', id: 2}} + put :update_relationship, params: {post_id: 3, relationship: 'tags', data: {type: 'tags', id: 502}} assert_response :bad_request assert_match /Invalid Links Object/, response.body @@ -1300,46 +1328,46 @@ def test_update_relationship_to_many_join_table_single post_object = Post.find(3) assert_equal 0, post_object.tags.length - put :update_relationship, params: {post_id: 3, relationship: 'tags', data: [{type: 'tags', id: 2}]} + put :update_relationship, params: {post_id: 3, relationship: 'tags', data: [{type: 'tags', id: 502}]} assert_response :no_content post_object = Post.find(3) assert_equal 1, post_object.tags.length - put :update_relationship, params: {post_id: 3, relationship: 'tags', data: [{type: 'tags', id: 5}]} + put :update_relationship, params: {post_id: 3, relationship: 'tags', data: [{type: 'tags', id: 505}]} assert_response :no_content post_object = Post.find(3) tags = post_object.tags.collect { |tag| tag.id } assert_equal 1, tags.length - assert matches_array? [5], tags + assert matches_array? [505], tags end def test_update_relationship_to_many set_content_type_header! - put :update_relationship, params: {post_id: 3, relationship: 'tags', data: [{type: 'tags', id: 2}, {type: 'tags', id: 3}]} + put :update_relationship, params: {post_id: 3, relationship: 'tags', data: [{type: 'tags', id: 502}, {type: 'tags', id: 503}]} assert_response :no_content post_object = Post.find(3) assert_equal 2, post_object.tags.collect { |tag| tag.id }.length - assert matches_array? [2, 3], post_object.tags.collect { |tag| tag.id } + assert matches_array? [502, 503], post_object.tags.collect { |tag| tag.id } end def test_create_relationship_to_many_join_table set_content_type_header! - put :update_relationship, params: {post_id: 3, relationship: 'tags', data: [{type: 'tags', id: 2}, {type: 'tags', id: 3}]} + put :update_relationship, params: {post_id: 3, relationship: 'tags', data: [{type: 'tags', id: 502}, {type: 'tags', id: 503}]} assert_response :no_content post_object = Post.find(3) assert_equal 2, post_object.tags.collect { |tag| tag.id }.length - assert matches_array? [2, 3], post_object.tags.collect { |tag| tag.id } + assert matches_array? [502, 503], post_object.tags.collect { |tag| tag.id } - post :create_relationship, params: {post_id: 3, relationship: 'tags', data: [{type: 'tags', id: 5}]} + post :create_relationship, params: {post_id: 3, relationship: 'tags', data: [{type: 'tags', id: 505}]} assert_response :no_content post_object = Post.find(3) assert_equal 3, post_object.tags.collect { |tag| tag.id }.length - assert matches_array? [2, 3, 5], post_object.tags.collect { |tag| tag.id } + assert matches_array? [502, 503, 505], post_object.tags.collect { |tag| tag.id } end def test_create_relationship_to_many_join_table_reflect @@ -1348,12 +1376,12 @@ def test_create_relationship_to_many_join_table_reflect post_object = Post.find(15) assert_equal 5, post_object.tags.collect { |tag| tag.id }.length - put :update_relationship, params: {post_id: 15, relationship: 'tags', data: [{type: 'tags', id: 2}, {type: 'tags', id: 3}, {type: 'tags', id: 4}]} + put :update_relationship, params: {post_id: 15, relationship: 'tags', data: [{type: 'tags', id: 502}, {type: 'tags', id: 503}, {type: 'tags', id: 504}]} assert_response :no_content post_object = Post.find(15) assert_equal 3, post_object.tags.collect { |tag| tag.id }.length - assert matches_array? [2, 3, 4], post_object.tags.collect { |tag| tag.id } + assert matches_array? [502, 503, 504], post_object.tags.collect { |tag| tag.id } ensure JSONAPI.configuration.use_relationship_reflection = false end @@ -1368,7 +1396,7 @@ def test_create_relationship_to_many_mismatched_type def test_create_relationship_to_many_missing_id set_content_type_header! - post :create_relationship, params: {post_id: 3, relationship: 'tags', data: [{type: 'tags', idd: 5}]} + post :create_relationship, params: {post_id: 3, relationship: 'tags', data: [{type: 'tags', idd: 505}]} assert_response :bad_request assert_match /Data is not a valid Links Object./, response.body @@ -1376,7 +1404,7 @@ def test_create_relationship_to_many_missing_id def test_create_relationship_to_many_not_array set_content_type_header! - post :create_relationship, params: {post_id: 3, relationship: 'tags', data: {type: 'tags', id: 5}} + post :create_relationship, params: {post_id: 3, relationship: 'tags', data: {type: 'tags', id: 505}} assert_response :bad_request assert_match /Data is not a valid Links Object./, response.body @@ -1396,11 +1424,11 @@ def test_create_relationship_to_many_join_table_no_reflection p = Post.find(4) assert_equal [], p.tag_ids - post :create_relationship, params: {post_id: 4, relationship: 'tags', data: [{type: 'tags', id: 1}, {type: 'tags', id: 2}, {type: 'tags', id: 3}]} + post :create_relationship, params: {post_id: 4, relationship: 'tags', data: [{type: 'tags', id: 501}, {type: 'tags', id: 502}, {type: 'tags', id: 503}]} assert_response :no_content p.reload - assert_equal [1,2,3], p.tag_ids + assert_equal [501,502,503], p.tag_ids ensure JSONAPI.configuration.use_relationship_reflection = false end @@ -1411,11 +1439,11 @@ def test_create_relationship_to_many_join_table_reflection p = Post.find(4) assert_equal [], p.tag_ids - post :create_relationship, params: {post_id: 4, relationship: 'tags', data: [{type: 'tags', id: 1}, {type: 'tags', id: 2}, {type: 'tags', id: 3}]} + post :create_relationship, params: {post_id: 4, relationship: 'tags', data: [{type: 'tags', id: 501}, {type: 'tags', id: 502}, {type: 'tags', id: 503}]} assert_response :no_content p.reload - assert_equal [1,2,3], p.tag_ids + assert_equal [501,502,503], p.tag_ids ensure JSONAPI.configuration.use_relationship_reflection = false end @@ -1452,17 +1480,17 @@ def test_create_relationship_to_many_reflection def test_create_relationship_to_many_join_table_record_exists set_content_type_header! - put :update_relationship, params: {post_id: 3, relationship: 'tags', data: [{type: 'tags', id: 2}, {type: 'tags', id: 3}]} + put :update_relationship, params: {post_id: 3, relationship: 'tags', data: [{type: 'tags', id: 502}, {type: 'tags', id: 503}]} assert_response :no_content post_object = Post.find(3) assert_equal 2, post_object.tags.collect { |tag| tag.id }.length - assert matches_array? [2, 3], post_object.tags.collect { |tag| tag.id } + assert matches_array? [502, 503], post_object.tags.collect { |tag| tag.id } - post :create_relationship, params: {post_id: 3, relationship: 'tags', data: [{type: 'tags', id: 2}, {type: 'tags', id: 5}]} + post :create_relationship, params: {post_id: 3, relationship: 'tags', data: [{type: 'tags', id: 502}, {type: 'tags', id: 505}]} assert_response :bad_request - assert_match /The relation to 2 already exists./, response.body + assert_match /The relation to 502 already exists./, response.body end def test_update_relationship_to_many_missing_tags @@ -1480,42 +1508,42 @@ def test_delete_relationship_to_many post_id: 14, relationship: 'tags', data: [ - {type: 'tags', id: 2}, - {type: 'tags', id: 3}, - {type: 'tags', id: 4} + {type: 'tags', id: 502}, + {type: 'tags', id: 503}, + {type: 'tags', id: 504} ] } assert_response :no_content p = Post.find(14) - assert_equal [2, 3, 4], p.tag_ids + assert_equal [502, 503, 504], p.tag_ids delete :destroy_relationship, params: { post_id: 14, relationship: 'tags', data: [ - {type: 'tags', id: 3}, - {type: 'tags', id: 4} + {type: 'tags', id: 503}, + {type: 'tags', id: 504} ] } p.reload assert_response :no_content - assert_equal [2], p.tag_ids + assert_equal [502], p.tag_ids end def test_delete_relationship_to_many_with_relationship_url_not_matching_type set_content_type_header! # Reflection turned off since tags doesn't have the inverse relationship PostResource.has_many :special_tags, relation_name: :special_tags, class_name: "Tag", reflect: false - post :create_relationship, params: {post_id: 14, relationship: 'special_tags', data: [{type: 'tags', id: 2}]} + post :create_relationship, params: {post_id: 14, relationship: 'special_tags', data: [{type: 'tags', id: 502}]} #check the relationship was created successfully assert_equal 1, Post.find(14).special_tags.count before_tags = Post.find(14).tags.count - delete :destroy_relationship, params: {post_id: 14, relationship: 'special_tags', data: [{type: 'tags', id: 2}]} + delete :destroy_relationship, params: {post_id: 14, relationship: 'special_tags', data: [{type: 'tags', id: 502}]} assert_equal 0, Post.find(14).special_tags.count, "Relationship that matches URL relationship not destroyed" #check that the tag association is not affected @@ -1526,24 +1554,24 @@ def test_delete_relationship_to_many_with_relationship_url_not_matching_type def test_delete_relationship_to_many_does_not_exist set_content_type_header! - put :update_relationship, params: {post_id: 14, relationship: 'tags', data: [{type: 'tags', id: 2}, {type: 'tags', id: 3}]} + put :update_relationship, params: {post_id: 14, relationship: 'tags', data: [{type: 'tags', id: 502}, {type: 'tags', id: 503}]} assert_response :no_content p = Post.find(14) - assert_equal [2, 3], p.tag_ids + assert_equal [502, 503], p.tag_ids - delete :destroy_relationship, params: {post_id: 14, relationship: 'tags', data: [{type: 'tags', id: 4}]} + delete :destroy_relationship, params: {post_id: 14, relationship: 'tags', data: [{type: 'tags', id: 504}]} p.reload assert_response :not_found - assert_equal [2, 3], p.tag_ids + assert_equal [502, 503], p.tag_ids end def test_delete_relationship_to_many_with_empty_data set_content_type_header! - put :update_relationship, params: {post_id: 14, relationship: 'tags', data: [{type: 'tags', id: 2}, {type: 'tags', id: 3}]} + put :update_relationship, params: {post_id: 14, relationship: 'tags', data: [{type: 'tags', id: 502}, {type: 'tags', id: 503}]} assert_response :no_content p = Post.find(14) - assert_equal [2, 3], p.tag_ids + assert_equal [502, 503], p.tag_ids put :update_relationship, params: {post_id: 14, relationship: 'tags', data: [] } @@ -1567,7 +1595,7 @@ def test_update_mismatch_single_key }, relationships: { section: {type: 'sections', id: "#{javascript.id}"}, - tags: [{type: 'tags', id: 3}, {type: 'tags', id: 4}] + tags: [{type: 'tags', id: 503}, {type: 'tags', id: 504}] } } } @@ -1592,7 +1620,7 @@ def test_update_extra_param }, relationships: { section: {type: 'sections', id: "#{javascript.id}"}, - tags: [{type: 'tags', id: 3}, {type: 'tags', id: 4}] + tags: [{type: 'tags', id: 503}, {type: 'tags', id: 504}] } } } @@ -1617,7 +1645,7 @@ def test_update_extra_param_in_links relationships: { asdfg: 'aaaa', section: {type: 'sections', id: "#{javascript.id}"}, - tags: [{type: 'tags', id: 3}, {type: 'tags', id: 4}] + tags: [{type: 'tags', id: 503}, {type: 'tags', id: 504}] } } } @@ -1672,7 +1700,7 @@ def test_update_missing_param }, relationships: { section: { data: { type: 'sections', id: "#{javascript.id}" } }, - tags: { data: [{ type: 'tags', id: 3 }, { type: 'tags', id: 4 }] } + tags: { data: [{ type: 'tags', id: 503 }, { type: 'tags', id: 504 }] } } } } @@ -1714,7 +1742,7 @@ def test_update_missing_type }, relationships: { section: { data: { type: 'sections', id: "#{javascript.id}" } }, - tags: { data: [{ type: 'tags', id: 3 }, { type: 'tags', id: 4 }] } + tags: { data: [{ type: 'tags', id: 503 }, { type: 'tags', id: 504 }] } } } } @@ -1739,7 +1767,7 @@ def test_update_unknown_key }, relationships: { section: {type: 'sections', id: "#{javascript.id}"}, - tags: [{type: 'tags', id: 3}, {type: 'tags', id: 4}] + tags: [{type: 'tags', id: 503}, {type: 'tags', id: 504}] } } } @@ -1762,7 +1790,7 @@ def test_update_multiple_ids }, relationships: { section: { data: { type: 'sections', id: "#{javascript.id}" } }, - tags: { data: [{ type: 'tags', id: 3 }, { type: 'tags', id: 4 }] } + tags: { data: [{ type: 'tags', id: 503 }, { type: 'tags', id: 504 }] } } }, include: 'tags' @@ -1788,7 +1816,7 @@ def test_update_multiple_array }, relationships: { section: {data: {type: 'sections', id: "#{javascript.id}"}}, - tags: {data: [{type: 'tags', id: 3}, {type: 'tags', id: 4}]} + tags: {data: [{type: 'tags', id: 503}, {type: 'tags', id: 504}]} } } ], @@ -1811,8 +1839,8 @@ def test_update_unpermitted_attributes subject: 'A great new Post' }, relationships: { - author: {type: 'people', id: '1'}, - tags: [{type: 'tags', id: 3}, {type: 'tags', id: 4}] + author: {type: 'people', id: '1001'}, + tags: [{type: 'tags', id: 503}, {type: 'tags', id: 504}] } } } @@ -1832,8 +1860,8 @@ def test_update_bad_attributes subject: 'A great new Post' }, linked_objects: { - author: {type: 'people', id: '1'}, - tags: [{type: 'tags', id: 3}, {type: 'tags', id: 4}] + author: {type: 'people', id: '1001'}, + tags: [{type: 'tags', id: 503}, {type: 'tags', id: 504}] } } } @@ -1865,12 +1893,12 @@ def test_delete_multiple end def test_show_to_one_relationship - assert_cacheable_get :show_relationship, params: {post_id: '1', relationship: 'author'} + get :show_relationship, params: {post_id: '1', relationship: 'author'} assert_response :success assert_hash_equals json_response, {data: { type: 'people', - id: '1' + id: '1001' }, links: { self: 'http://test.host/posts/1/relationships/author', @@ -1885,7 +1913,7 @@ def test_show_to_many_relationship assert_hash_equals json_response, { data: [ - {type: 'tags', id: '5'} + {type: 'tags', id: '505'} ], links: { self: 'http://test.host/posts/2/relationships/tags', @@ -1914,58 +1942,71 @@ def test_show_to_one_relationship_nil end def test_get_related_resources_sorted - assert_cacheable_get :get_related_resources, params: {person_id: '1', relationship: 'posts', source:'people', sort: 'title' } + assert_cacheable_get :get_related_resources, params: {person_id: '1001', relationship: 'posts', source:'people', sort: 'title' } assert_response :success assert_equal 'JR How To', json_response['data'][0]['attributes']['title'] assert_equal 'New post', json_response['data'][2]['attributes']['title'] - assert_cacheable_get :get_related_resources, params: {person_id: '1', relationship: 'posts', source:'people', sort: '-title' } + assert_cacheable_get :get_related_resources, params: {person_id: '1001', relationship: 'posts', source:'people', sort: '-title' } assert_response :success assert_equal 'New post', json_response['data'][0]['attributes']['title'] assert_equal 'JR How To', json_response['data'][2]['attributes']['title'] end def test_get_related_resources_default_sorted - assert_cacheable_get :get_related_resources, params: {person_id: '1', relationship: 'posts', source:'people'} + assert_cacheable_get :get_related_resources, params: {person_id: '1001', relationship: 'posts', source:'people'} assert_response :success assert_equal 'New post', json_response['data'][0]['attributes']['title'] assert_equal 'JR How To', json_response['data'][2]['attributes']['title'] end + + def test_get_related_resources_has_many_filtered + assert_cacheable_get :get_related_resources, params: {person_id: '1001', relationship: 'posts', source:'people', filter: { title: 'JR How To' } } + assert_response :success + assert_equal 'JR How To', json_response['data'][0]['attributes']['title'] + assert_equal 1, json_response['data'].size + end end class TagsControllerTest < ActionController::TestCase def test_tags_index - assert_cacheable_get :index, params: {filter: {id: '6,7,8,9'}, include: 'posts.tags,posts.author.posts'} + assert_cacheable_get :index, params: {filter: {id: '506,507,508,509'}} assert_response :success assert_equal 4, json_response['data'].size - assert_equal 3, json_response['included'].size + end + + def test_tags_index_include_nested_tree + assert_cacheable_get :index, params: {filter: {id: '506,508,509'}, include: 'posts.tags,posts.author.posts'} + assert_response :success + assert_equal 3, json_response['data'].size + assert_equal 4, json_response['included'].size end def test_tags_show_multiple - assert_cacheable_get :show, params: {id: '6,7,8,9'} + assert_cacheable_get :show, params: {id: '506,507,508,509'} assert_response :bad_request - assert_match /6,7,8,9 is not a valid value for id/, response.body + assert_match /506,507,508,509 is not a valid value for id/, response.body end def test_tags_show_multiple_with_include - assert_cacheable_get :show, params: {id: '6,7,8,9', include: 'posts.tags,posts.author.posts'} + assert_cacheable_get :show, params: {id: '506,507,508,509', include: 'posts.tags,posts.author.posts'} assert_response :bad_request - assert_match /6,7,8,9 is not a valid value for id/, response.body + assert_match /506,507,508,509 is not a valid value for id/, response.body end def test_tags_show_multiple_with_nonexistent_ids - assert_cacheable_get :show, params: {id: '6,99,9,100'} + assert_cacheable_get :show, params: {id: '506,5099,509,50100'} assert_response :bad_request - assert_match /6,99,9,100 is not a valid value for id/, response.body + assert_match /506,5099,509,50100 is not a valid value for id/, response.body end def test_tags_show_multiple_with_nonexistent_ids_at_the_beginning - assert_cacheable_get :show, params: {id: '99,9,100'} + assert_cacheable_get :show, params: {id: '5099,509,50100'} assert_response :bad_request - assert_match /99,9,100 is not a valid value for id/, response.body + assert_match /5099,509,50100 is not a valid value for id/, response.body end def test_nested_includes_sort - assert_cacheable_get :index, params: {filter: {id: '6,7,8,9'}, + assert_cacheable_get :index, params: {filter: {id: '506,507,508,509'}, include: 'posts.tags,posts.author.posts', sort: 'name'} assert_response :success @@ -1978,14 +2019,24 @@ class PicturesControllerTest < ActionController::TestCase def test_pictures_index assert_cacheable_get :index assert_response :success - assert_equal 3, json_response['data'].size + assert_equal 7, json_response['data'].size end def test_pictures_index_with_polymorphic_include_one_level assert_cacheable_get :index, params: {include: 'imageable'} assert_response :success - assert_equal 3, json_response['data'].size - assert_equal 2, json_response['included'].size + assert_equal 7, json_response['data'].try(:size) + assert_equal 4, json_response['included'].try(:size) + end + + def test_update_relationship_to_one_polymorphic + set_content_type_header! + + put :update_relationship, params: { picture_id: 48, relationship: 'imageable', data: { type: 'product', id: '2' } } + + assert_response :no_content + picture_object = Picture.find(48) + assert_equal 2, picture_object.imageable_id end end @@ -1993,14 +2044,14 @@ class DocumentsControllerTest < ActionController::TestCase def test_documents_index assert_cacheable_get :index assert_response :success - assert_equal 1, json_response['data'].size + assert_equal 4, json_response['data'].size end def test_documents_index_with_polymorphic_include_one_level assert_cacheable_get :index, params: {include: 'pictures'} assert_response :success - assert_equal 1, json_response['data'].size - assert_equal 1, json_response['included'].size + assert_equal 4, json_response['data'].size + assert_equal 5, json_response['included'].size end end @@ -2047,7 +2098,7 @@ def test_expense_entries_show_bad_include_missing_relationship def test_expense_entries_show_bad_include_missing_sub_relationship assert_cacheable_get :show, params: {id: 1, include: 'isoCurrency,employee.post'} assert_response :bad_request - assert_match /post is not a valid relationship of people/, json_response['errors'][0]['detail'] + assert_match /post is not a valid relationship of employees/, json_response['errors'][0]['detail'] end def test_invalid_include @@ -2093,7 +2144,7 @@ def test_create_expense_entries_underscored cost: 50.58 }, relationships: { - employee: {data: {type: 'people', id: '3'}}, + employee: {data: {type: 'employees', id: '1003'}}, iso_currency: {data: {type: 'iso_currencies', id: 'USD'}} } }, @@ -2103,7 +2154,7 @@ def test_create_expense_entries_underscored assert_response :created assert json_response['data'].is_a?(Hash) - assert_equal '3', json_response['data']['relationships']['employee']['data']['id'] + assert_equal '1003', json_response['data']['relationships']['employee']['data']['id'] assert_equal 'USD', json_response['data']['relationships']['iso_currency']['data']['id'] assert_equal '50.58', json_response['data']['attributes']['cost'] @@ -2127,7 +2178,7 @@ def test_create_expense_entries_camelized_key cost: 50.58 }, relationships: { - employee: {data: {type: 'people', id: '3'}}, + employee: {data: {type: 'employees', id: '1003'}}, isoCurrency: {data: {type: 'iso_currencies', id: 'USD'}} } }, @@ -2137,7 +2188,7 @@ def test_create_expense_entries_camelized_key assert_response :created assert json_response['data'].is_a?(Hash) - assert_equal '3', json_response['data']['relationships']['employee']['data']['id'] + assert_equal '1003', json_response['data']['relationships']['employee']['data']['id'] assert_equal 'USD', json_response['data']['relationships']['isoCurrency']['data']['id'] assert_equal '50.58', json_response['data']['attributes']['cost'] @@ -2161,7 +2212,7 @@ def test_create_expense_entries_dasherized_key cost: 50.58 }, relationships: { - employee: {data: {type: 'people', id: '3'}}, + employee: {data: {type: 'employees', id: '1003'}}, 'iso-currency' => {data: {type: 'iso_currencies', id: 'USD'}} } }, @@ -2171,7 +2222,7 @@ def test_create_expense_entries_dasherized_key assert_response :created assert json_response['data'].is_a?(Hash) - assert_equal '3', json_response['data']['relationships']['employee']['data']['id'] + assert_equal '1003', json_response['data']['relationships']['employee']['data']['id'] assert_equal 'USD', json_response['data']['relationships']['iso-currency']['data']['id'] assert_equal '50.58', json_response['data']['attributes']['cost'] @@ -2362,9 +2413,9 @@ def test_update_link_with_dasherized_type set_content_type_header! put :update, params: { - id: 3, + id: 1003, data: { - id: '3', + id: '1003', type: 'people', relationships: { 'hair-cut' => { @@ -2405,9 +2456,9 @@ def test_update_validations_missing_attribute set_content_type_header! put :update, params: { - id: 3, + id: 1003, data: { - id: '3', + id: '1003', type: 'people', attributes: { name: '' @@ -2423,7 +2474,7 @@ def test_update_validations_missing_attribute def test_delete_locked initial_count = Person.count - delete :destroy, params: {id: '3'} + delete :destroy, params: {id: '1003'} assert_response :locked assert_equal initial_count, Person.count end @@ -2448,8 +2499,8 @@ def test_valid_filter_value assert_cacheable_get :index, params: {filter: {name: 'Joe Author'}} assert_response :success assert_equal json_response['data'].size, 1 - assert_equal json_response['data'][0]['id'], '1' - assert_equal json_response['data'][0]['attributes']['name'], 'Joe Author' + assert_equal '1001', json_response['data'][0]['id'] + assert_equal 'Joe Author', json_response['data'][0]['attributes']['name'] end def test_get_related_resource_no_namespace @@ -2458,49 +2509,56 @@ def test_get_related_resource_no_namespace JSONAPI.configuration.route_format = :underscored_key assert_cacheable_get :get_related_resource, params: {post_id: '2', relationship: 'author', source:'posts'} assert_response :success + assert_hash_equals( { data: { - id: '1', + id: '1001', type: 'people', + links: { + self: 'http://test.host/people/1001' + }, attributes: { name: 'Joe Author', email: 'joe@xyz.fake', "date-joined" => '2013-08-07 16:25:00 -0400' }, - links: { - self: 'http://test.host/people/1' - }, relationships: { comments: { links: { - self: 'http://test.host/people/1/relationships/comments', - related: 'http://test.host/people/1/comments' + self: 'http://test.host/people/1001/relationships/comments', + related: 'http://test.host/people/1001/comments' } }, posts: { links: { - self: 'http://test.host/people/1/relationships/posts', - related: 'http://test.host/people/1/posts' + self: 'http://test.host/people/1001/relationships/posts', + related: 'http://test.host/people/1001/posts' } }, preferences: { links: { - self: 'http://test.host/people/1/relationships/preferences', - related: 'http://test.host/people/1/preferences' - } - }, - "hair-cut" => { - "links" => { - "self" => "http://test.host/people/1/relationships/hair_cut", - "related" => "http://test.host/people/1/hair_cut" + self: 'http://test.host/people/1001/relationships/preferences', + related: 'http://test.host/people/1001/preferences' } }, vehicles: { links: { - self: "http://test.host/people/1/relationships/vehicles", - related: "http://test.host/people/1/vehicles" + self: "http://test.host/people/1001/relationships/vehicles", + related: "http://test.host/people/1001/vehicles" } + }, + "hair-cut" => { + "links" => { + "self" => "http://test.host/people/1001/relationships/hair_cut", + "related" => "http://test.host/people/1001/hair_cut" + } + }, + "expense-entries" => { + "links" => { + "self" => "http://test.host/people/1001/relationships/expense_entries", + "related" => "http://test.host/people/1001/expense_entries" + } } } } @@ -2511,8 +2569,19 @@ def test_get_related_resource_no_namespace JSONAPI.configuration = original_config end + def test_get_related_resource_includes + original_config = JSONAPI.configuration.dup + JSONAPI.configuration.json_key_format = :dasherized_key + JSONAPI.configuration.route_format = :underscored_key + assert_cacheable_get :get_related_resource, params: {post_id: '2', relationship: 'author', source:'posts', include: 'posts'} + assert_response :success + assert_equal 'posts', json_response['included'][0]['type'] + ensure + JSONAPI.configuration = original_config + end + def test_get_related_resource_nil - assert_cacheable_get :get_related_resource, params: {post_id: '17', relationship: 'author', source:'posts'} + get :get_related_resource, params: {post_id: '17', relationship: 'author', source:'posts'} assert_response :success assert_hash_equals json_response, { @@ -2524,7 +2593,7 @@ def test_get_related_resource_nil class BooksControllerTest < ActionController::TestCase def test_books_include_correct_type - $test_user = Person.find(1) + $test_user = Person.find(1001) assert_cacheable_get :index, params: {filter: {id: '1'}, include: 'authors'} assert_response :success assert_equal 'authors', json_response['included'][0]['type'] @@ -2535,7 +2604,7 @@ def test_destroy_relationship_has_and_belongs_to_many assert_equal 2, Book.find(2).authors.count - delete :destroy_relationship, params: {book_id: 2, relationship: 'authors', data: [{type: 'authors', id: 1}]} + delete :destroy_relationship, params: {book_id: 2, relationship: 'authors', data: [{type: 'authors', id: '1001'}]} assert_response :no_content assert_equal 1, Book.find(2).authors.count ensure @@ -2547,7 +2616,7 @@ def test_destroy_relationship_has_and_belongs_to_many_reflect assert_equal 2, Book.find(2).authors.count - delete :destroy_relationship, params: {book_id: 2, relationship: 'authors', data: [{type: 'authors', id: 1}]} + delete :destroy_relationship, params: {book_id: 2, relationship: 'authors', data: [{type: 'authors', id: '1001'}]} assert_response :no_content assert_equal 1, Book.find(2).authors.count @@ -2564,19 +2633,19 @@ def test_index_with_caching_enabled_uses_context class Api::V5::AuthorsControllerTest < ActionController::TestCase def test_get_person_as_author - assert_cacheable_get :index, params: {filter: {id: '1'}} + assert_cacheable_get :index, params: {filter: {id: '1001'}} assert_response :success assert_equal 1, json_response['data'].size - assert_equal '1', json_response['data'][0]['id'] + assert_equal '1001', json_response['data'][0]['id'] assert_equal 'authors', json_response['data'][0]['type'] assert_equal 'Joe Author', json_response['data'][0]['attributes']['name'] assert_nil json_response['data'][0]['attributes']['email'] end def test_show_person_as_author - assert_cacheable_get :show, params: {id: '1'} + assert_cacheable_get :show, params: {id: '1001'} assert_response :success - assert_equal '1', json_response['data']['id'] + assert_equal '1001', json_response['data']['id'] assert_equal 'authors', json_response['data']['type'] assert_equal 'Joe Author', json_response['data']['attributes']['name'] assert_nil json_response['data']['attributes']['email'] @@ -2586,7 +2655,7 @@ def test_get_person_as_author_by_name_filter assert_cacheable_get :index, params: {filter: {name: 'thor'}} assert_response :success assert_equal 3, json_response['data'].size - assert_equal '1', json_response['data'][0]['id'] + assert_equal '1001', json_response['data'][0]['id'] assert_equal 'Joe Author', json_response['data'][0]['attributes']['name'] end @@ -2604,11 +2673,11 @@ def meta(options) end end - assert_cacheable_get :show, params: {id: '1'} + assert_cacheable_get :show, params: {id: '1001'} assert_response :success - assert_equal '1', json_response['data']['id'] + assert_equal '1001', json_response['data']['id'] assert_equal 'Hardcoded value', json_response['data']['meta']['fixed'] - assert_equal 'authors: http://test.host/api/v5/authors/1', json_response['data']['meta']['computed'] + assert_equal 'authors: http://test.host/api/v5/authors/1001', json_response['data']['meta']['computed'] assert_equal 'bar', json_response['data']['meta']['computed_foo'] assert_equal 'test value', json_response['data']['meta']['testKey'] @@ -2639,11 +2708,11 @@ def meta(options) end end - assert_cacheable_get :show, params: {id: '1'} + assert_cacheable_get :show, params: {id: '1001'} assert_response :success - assert_equal '1', json_response['data']['id'] + assert_equal '1001', json_response['data']['id'] assert_equal 'Hardcoded value', json_response['data']['meta']['custom_hash']['fixed'] - assert_equal 'authors: http://test.host/api/v5/authors/1', json_response['data']['meta']['custom_hash']['computed'] + assert_equal 'authors: http://test.host/api/v5/authors/1001', json_response['data']['meta']['custom_hash']['computed'] assert_equal 'bar', json_response['data']['meta']['custom_hash']['computed_foo'] assert_equal 'test value', json_response['data']['meta']['custom_hash']['testKey'] @@ -2789,15 +2858,15 @@ def test_show_post_namespaced def test_show_post_namespaced_include assert_cacheable_get :show, params: {id: '1', include: 'writer'} assert_response :success - assert_equal '1', json_response['data']['relationships']['writer']['data']['id'] + assert_equal '1001', json_response['data']['relationships']['writer']['data']['id'] assert_nil json_response['data']['relationships']['tags'] - assert_equal '1', json_response['included'][0]['id'] + assert_equal '1001', json_response['included'][0]['id'] assert_equal 'writers', json_response['included'][0]['type'] assert_equal 'joe@xyz.fake', json_response['included'][0]['attributes']['email'] end def test_index_filter_on_relationship_namespaced - assert_cacheable_get :index, params: {filter: {writer: '1'}} + assert_cacheable_get :index, params: {filter: {writer: '1001'}} assert_response :success assert_equal 3, json_response['data'].size end @@ -2820,7 +2889,7 @@ def test_create_simple_namespaced body: 'JSONAPIResources is the greatest thing since unsliced bread now that it has namespaced resources.' }, relationships: { - writer: { data: {type: 'writers', id: '3'}} + writer: { data: {type: 'writers', id: '1003'}} } } } @@ -2895,7 +2964,7 @@ def test_create_with_invalid_data class Api::V2::BooksControllerTest < ActionController::TestCase def setup JSONAPI.configuration.json_key_format = :dasherized_key - $test_user = Person.find(1) + $test_user = Person.find(1001) end def after_teardown @@ -2968,7 +3037,7 @@ def test_books_page_count_in_meta_custom_name def test_books_offset_pagination_no_params_includes_query_count_one_level Api::V2::BookResource.paginator :offset - assert_query_count(3) do + assert_query_count(5) do assert_cacheable_get :index, params: {include: 'book-comments'} end assert_response :success @@ -2979,7 +3048,7 @@ def test_books_offset_pagination_no_params_includes_query_count_one_level def test_books_offset_pagination_no_params_includes_query_count_two_levels Api::V2::BookResource.paginator :offset - assert_query_count(4) do + assert_query_count(7) do assert_cacheable_get :index, params: {include: 'book-comments,book-comments.author'} end assert_response :success @@ -3107,7 +3176,7 @@ def test_books_paged_pagination_invalid_page_format_interpret_int def test_books_included_paged Api::V2::BookResource.paginator :offset - assert_query_count(3) do + assert_query_count(5) do assert_cacheable_get :index, params: {filter: {id: '0'}, include: 'book-comments'} end assert_response :success @@ -3116,10 +3185,10 @@ def test_books_included_paged end def test_books_banned_non_book_admin - $test_user = Person.find(1) + $test_user = Person.find(1001) Api::V2::BookResource.paginator :offset JSONAPI.configuration.top_level_meta_include_record_count = true - assert_query_count(2) do + assert_query_count(3) do assert_cacheable_get :index, params: {page: {offset: 50, limit: 12}} end assert_response :success @@ -3131,10 +3200,10 @@ def test_books_banned_non_book_admin end def test_books_banned_non_book_admin_includes_switched - $test_user = Person.find(1) + $test_user = Person.find(1001) Api::V2::BookResource.paginator :offset JSONAPI.configuration.top_level_meta_include_record_count = true - assert_query_count(3) do + assert_query_count(5) do assert_cacheable_get :index, params: {page: {offset: 0, limit: 12}, include: 'book-comments'} end @@ -3150,10 +3219,10 @@ def test_books_banned_non_book_admin_includes_switched end def test_books_banned_non_book_admin_includes_nested_includes - $test_user = Person.find(1) + $test_user = Person.find(1001) JSONAPI.configuration.top_level_meta_include_record_count = true Api::V2::BookResource.paginator :offset - assert_query_count(4) do + assert_query_count(7) do assert_cacheable_get :index, params: {page: {offset: 0, limit: 12}, include: 'book-comments.author'} end assert_response :success @@ -3166,10 +3235,10 @@ def test_books_banned_non_book_admin_includes_nested_includes end def test_books_banned_admin - $test_user = Person.find(5) + $test_user = Person.find(1005) Api::V2::BookResource.paginator :offset JSONAPI.configuration.top_level_meta_include_record_count = true - assert_query_count(2) do + assert_query_count(3) do assert_cacheable_get :index, params: {page: {offset: 50, limit: 12}, filter: {banned: 'true'}} end assert_response :success @@ -3181,10 +3250,10 @@ def test_books_banned_admin end def test_books_not_banned_admin - $test_user = Person.find(5) + $test_user = Person.find(1005) Api::V2::BookResource.paginator :offset JSONAPI.configuration.top_level_meta_include_record_count = true - assert_query_count(2) do + assert_query_count(3) do assert_cacheable_get :index, params: {page: {offset: 50, limit: 12}, filter: {banned: 'false'}, fields: {books: 'id,title'}} end assert_response :success @@ -3196,10 +3265,10 @@ def test_books_not_banned_admin end def test_books_banned_non_book_admin_overlapped - $test_user = Person.find(1) + $test_user = Person.find(1001) Api::V2::BookResource.paginator :offset JSONAPI.configuration.top_level_meta_include_record_count = true - assert_query_count(2) do + assert_query_count(3) do assert_cacheable_get :index, params: {page: {offset: 590, limit: 20}} end assert_response :success @@ -3211,10 +3280,10 @@ def test_books_banned_non_book_admin_overlapped end def test_books_included_exclude_unapproved - $test_user = Person.find(1) + $test_user = Person.find(1001) Api::V2::BookResource.paginator :none - assert_query_count(2) do + assert_query_count(4) do assert_cacheable_get :index, params: {filter: {id: '0,1,2,3,4'}, include: 'book-comments'} end assert_response :success @@ -3225,7 +3294,7 @@ def test_books_included_exclude_unapproved end def test_books_included_all_comments_for_admin - $test_user = Person.find(5) + $test_user = Person.find(1005) Api::V2::BookResource.paginator :none assert_cacheable_get :index, params: {filter: {id: '0,1,2,3,4'}, include: 'book-comments'} @@ -3237,14 +3306,14 @@ def test_books_included_all_comments_for_admin end def test_books_filter_by_book_comment_id_limited_user - $test_user = Person.find(1) + $test_user = Person.find(1001) assert_cacheable_get :index, params: {filter: {book_comments: '0,52' }} assert_response :success assert_equal 1, json_response['data'].size end def test_books_filter_by_book_comment_id_admin_user - $test_user = Person.find(5) + $test_user = Person.find(1005) assert_cacheable_get :index, params: {filter: {book_comments: '0,52' }} assert_response :success assert_equal 2, json_response['data'].size @@ -3252,7 +3321,7 @@ def test_books_filter_by_book_comment_id_admin_user def test_books_create_unapproved_comment_limited_user_using_relation_name set_content_type_header! - $test_user = Person.find(1) + $test_user = Person.find(1001) book_comment = BookComment.create(body: 'Not Approved dummy comment', approved: false) post :create_relationship, params: {book_id: 1, relationship: 'book_comments', data: [{type: 'book_comments', id: book_comment.id}]} @@ -3266,7 +3335,7 @@ def test_books_create_unapproved_comment_limited_user_using_relation_name def test_books_create_approved_comment_limited_user_using_relation_name set_content_type_header! - $test_user = Person.find(1) + $test_user = Person.find(1001) book_comment = BookComment.create(body: 'Approved dummy comment', approved: true) post :create_relationship, params: {book_id: 1, relationship: 'book_comments', data: [{type: 'book_comments', id: book_comment.id}]} @@ -3277,7 +3346,7 @@ def test_books_create_approved_comment_limited_user_using_relation_name end def test_books_delete_unapproved_comment_limited_user_using_relation_name - $test_user = Person.find(1) + $test_user = Person.find(1001) book_comment = BookComment.create(book_id: 1, body: 'Not Approved dummy comment', approved: false) delete :destroy_relationship, params: {book_id: 1, relationship: 'book_comments', data: [{type: 'book_comments', id: book_comment.id}]} @@ -3288,7 +3357,7 @@ def test_books_delete_unapproved_comment_limited_user_using_relation_name end def test_books_delete_approved_comment_limited_user_using_relation_name - $test_user = Person.find(1) + $test_user = Person.find(1001) book_comment = BookComment.create(book_id: 1, body: 'Approved dummy comment', approved: true) delete :destroy_relationship, params: {book_id: 1, relationship: 'book_comments', data: [{type: 'book_comments', id: book_comment.id}]} @@ -3300,7 +3369,7 @@ def test_books_delete_approved_comment_limited_user_using_relation_name def test_books_delete_approved_comment_limited_user_using_relation_name_reflected JSONAPI.configuration.use_relationship_reflection = true - $test_user = Person.find(1) + $test_user = Person.find(1001) book_comment = BookComment.create(book_id: 1, body: 'Approved dummy comment', approved: true) delete :destroy_relationship, params: {book_id: 1, relationship: 'book_comments', data: [{type: 'book_comments', id: book_comment.id}]} @@ -3310,18 +3379,28 @@ def test_books_delete_approved_comment_limited_user_using_relation_name_reflecte JSONAPI.configuration.use_relationship_reflection = false book_comment.delete end + + def test_get_related_resources_pagination + Api::V2::BookResource.paginator :offset + + assert_cacheable_get :get_related_resources, params: {author_id: '1003', relationship: 'books', source:'api/v2/authors'} + assert_response :success + assert_equal 10, json_response['data'].size + assert_equal 3, json_response['links'].size + assert_equal 'http://test.host/api/v2/authors/1003/books?page%5Blimit%5D=10&page%5Boffset%5D=0', json_response['links']['first'] + end end class Api::V2::BookCommentsControllerTest < ActionController::TestCase def setup JSONAPI.configuration.json_key_format = :dasherized_key Api::V2::BookCommentResource.paginator :none - $test_user = Person.find(1) + $test_user = Person.find(1001) end def test_book_comments_all_for_admin - $test_user = Person.find(5) - assert_query_count(1) do + $test_user = Person.find(1005) + assert_query_count(2) do assert_cacheable_get :index end assert_response :success @@ -3329,8 +3408,8 @@ def test_book_comments_all_for_admin end def test_book_comments_unapproved_context_based - $test_user = Person.find(5) - assert_query_count(1) do + $test_user = Person.find(1005) + assert_query_count(2) do assert_cacheable_get :index, params: {filter: {approved: 'false'}} end assert_response :success @@ -3338,8 +3417,8 @@ def test_book_comments_unapproved_context_based end def test_book_comments_exclude_unapproved_context_based - $test_user = Person.find(1) - assert_query_count(1) do + $test_user = Person.find(1001) + assert_query_count(2) do assert_cacheable_get :index end assert_response :success @@ -3449,7 +3528,7 @@ def test_get_related_resource end def test_get_related_resources_with_select_some_db_columns - PlanetResource.paginator :paged + Api::V1::MoonResource.paginator :paged original_config = JSONAPI.configuration.dup JSONAPI.configuration.top_level_meta_include_record_count = true JSONAPI.configuration.json_key_format = :dasherized_key @@ -3495,8 +3574,15 @@ def test_get_related_resources end def test_get_related_resources_filtered - $test_user = Person.find(1) - get :get_related_resources, params: {moon_id: '1', relationship: 'craters', source: "api/v1/moons", filter: {description: 'Small crater'}} + $test_user = Person.find(1001) + assert_cacheable_get :get_related_resources, + params: { + moon_id: '1', + relationship: 'craters', + source: "api/v1/moons", + filter: { description: 'Small crater' } + } + assert_response :success assert_hash_equals({ data: [ @@ -3505,7 +3591,14 @@ def test_get_related_resources_filtered type:"craters", links:{self: "http://test.host/api/v1/craters/A4D3"}, attributes:{code: "A4D3", description: "Small crater"}, - relationships:{moon: {links: {self: "http://test.host/api/v1/craters/A4D3/relationships/moon", related: "http://test.host/api/v1/craters/A4D3/moon"}}} + relationships: { + moon: { + links: { + self: "http://test.host/api/v1/craters/A4D3/relationships/moon", + related: "http://test.host/api/v1/craters/A4D3/moon" + } + } + } } ] }, json_response) @@ -3553,6 +3646,13 @@ def setup JSONAPI.configuration.json_key_format = :camelized_key end + def test_STI_index_returns_all_types + assert_cacheable_get :index + assert_response :success + assert_equal 'cars', json_response['data'][0]['type'] + assert_equal 'boats', json_response['data'][1]['type'] + end + def test_immutable_create_not_supported set_content_type_header! @@ -3618,7 +3718,7 @@ def test_get_namespaced_model_matching_resource class Api::V7::CategoriesControllerTest < ActionController::TestCase def test_uncaught_error_in_controller_translated_to_internal_server_error - assert_cacheable_get :show, params: {id: '1'} + get :show, params: {id: '1'} assert_response 500 assert_match /Internal Server Error/, json_response['errors'][0]['detail'] end @@ -3626,7 +3726,7 @@ def test_uncaught_error_in_controller_translated_to_internal_server_error def test_not_whitelisted_error_in_controller original_config = JSONAPI.configuration.dup JSONAPI.configuration.exception_class_whitelist = [] - assert_cacheable_get :show, params: {id: '1'} + get :show, params: {id: '1'} assert_response 500 assert_match /Internal Server Error/, json_response['errors'][0]['detail'] ensure @@ -3662,15 +3762,15 @@ def test_caching_with_join_to_resource_with_sql_fragment class AuthorsControllerTest < ActionController::TestCase def test_show_author_recursive - get :show, params: {id: '2', include: 'books.authors'} + get :show, params: {id: '1002', include: 'books.authors'} assert_response :success - assert_equal '2', json_response['data']['id'] + assert_equal '1002', json_response['data']['id'] assert_equal 'authors', json_response['data']['type'] assert_equal 'Fred Reader', json_response['data']['attributes']['name'] # The test is hardcoded with the include order. This should be changed at some # point since either thing could come first and still be valid - assert_equal '1', json_response['included'][0]['id'] + assert_equal '1001', json_response['included'][0]['id'] assert_equal 'authors', json_response['included'][0]['type'] assert_equal '2', json_response['included'][1]['id'] assert_equal 'books', json_response['included'][1]['type'] @@ -3681,13 +3781,13 @@ class Api::V2::AuthorsControllerTest < ActionController::TestCase def test_cache_pollution_for_non_admin_indirect_access_to_banned_books cache = ActiveSupport::Cache::MemoryStore.new with_resource_caching(cache) do - $test_user = Person.find(5) - get :show, params: {id: '2', include: 'books'} + $test_user = Person.find(1005) + get :show, params: {id: '1002', include: 'books'} assert_response :success assert_equal 2, json_response['included'].length - $test_user = Person.find(1) - get :show, params: {id: '2', include: 'books'} + $test_user = Person.find(1001) + get :show, params: {id: '1002', include: 'books'} assert_response :success assert_equal 1, json_response['included'].length end @@ -3712,59 +3812,57 @@ def test_complex_includes_two_level # The test is hardcoded with the include order. This should be changed at some # point since either thing could come first and still be valid - assert_equal '1', json_response['included'][0]['id'] + assert_equal '10', json_response['included'][0]['id'] assert_equal 'things', json_response['included'][0]['type'] - assert_equal '1', json_response['included'][0]['relationships']['user']['data']['id'] + assert_equal '10001', json_response['included'][0]['relationships']['user']['data']['id'] assert_nil json_response['included'][0]['relationships']['things']['data'] - assert_equal '2', json_response['included'][1]['id'] + assert_equal '20', json_response['included'][1]['id'] assert_equal 'things', json_response['included'][1]['type'] - assert_equal '1', json_response['included'][1]['relationships']['user']['data']['id'] + assert_equal '10001', json_response['included'][1]['relationships']['user']['data']['id'] assert_nil json_response['included'][1]['relationships']['things']['data'] - assert_equal '1', json_response['included'][2]['id'] + assert_equal '10001', json_response['included'][2]['id'] assert_equal 'users', json_response['included'][2]['type'] - assert_nil json_response['included'][2]['relationships']['things']['data'] end def test_complex_includes_things_nested_things - assert_cacheable_get :index, params: {include: 'things,things.things'} + get :index, params: {include: 'things,things.things'} assert_response :success # The test is hardcoded with the include order. This should be changed at some # point since either thing could come first and still be valid - assert_equal '2', json_response['included'][0]['id'] + assert_equal '10', json_response['included'][0]['id'] assert_equal 'things', json_response['included'][0]['type'] assert_nil json_response['included'][0]['relationships']['user']['data'] - assert_equal '1', json_response['included'][0]['relationships']['things']['data'][0]['id'] + assert_equal '20', json_response['included'][0]['relationships']['things']['data'][0]['id'] - assert_equal '1', json_response['included'][1]['id'] + assert_equal '20', json_response['included'][1]['id'] assert_equal 'things', json_response['included'][1]['type'] assert_nil json_response['included'][1]['relationships']['user']['data'] - assert_equal '2', json_response['included'][1]['relationships']['things']['data'][0]['id'] + assert_equal '10', json_response['included'][1]['relationships']['things']['data'][0]['id'] end def test_complex_includes_nested_things_secondary_users - assert_cacheable_get :index, params: {include: 'things,things.user,things.things'} + get :index, params: {include: 'things,things.user,things.things'} assert_response :success # The test is hardcoded with the include order. This should be changed at some # point since either thing could come first and still be valid - assert_equal '1', json_response['included'][2]['id'] - assert_equal 'users', json_response['included'][2]['type'] - assert_nil json_response['included'][2]['relationships']['things']['data'] - - assert_equal '2', json_response['included'][0]['id'] + assert_equal '10', json_response['included'][0]['id'] assert_equal 'things', json_response['included'][0]['type'] - assert_equal '1', json_response['included'][0]['relationships']['user']['data']['id'] - assert_equal '1', json_response['included'][0]['relationships']['things']['data'][0]['id'] + assert_equal '10001', json_response['included'][0]['relationships']['user']['data']['id'] + assert_equal '20', json_response['included'][0]['relationships']['things']['data'][0]['id'] - assert_equal '1', json_response['included'][1]['id'] + assert_equal '20', json_response['included'][1]['id'] assert_equal 'things', json_response['included'][1]['type'] - assert_equal '1', json_response['included'][1]['relationships']['user']['data']['id'] - assert_equal '2', json_response['included'][1]['relationships']['things']['data'][0]['id'] + assert_equal '10001', json_response['included'][1]['relationships']['user']['data']['id'] + assert_equal '10', json_response['included'][1]['relationships']['things']['data'][0]['id'] + + assert_equal '10001', json_response['included'][2]['id'] + assert_equal 'users', json_response['included'][2]['type'] end end diff --git a/test/fixtures/active_record.rb b/test/fixtures/active_record.rb index 39a8b37fe..fbf47008a 100644 --- a/test/fixtures/active_record.rb +++ b/test/fixtures/active_record.rb @@ -213,8 +213,7 @@ create_table :pictures, force: true do |t| t.string :name - t.integer :imageable_id - t.string :imageable_type + t.references :imageable, polymorphic: true, index: true t.timestamps null: false end @@ -340,6 +339,7 @@ class Person < ActiveRecord::Base has_many :posts, foreign_key: 'author_id' has_many :comments, foreign_key: 'author_id' + has_many :book_comments, foreign_key: 'author_id' has_many :expense_entries, foreign_key: 'employee_id', dependent: :restrict_with_exception has_many :vehicles belongs_to :preferences @@ -410,6 +410,8 @@ class Firm < Company class Tag < ActiveRecord::Base has_and_belongs_to_many :posts, join_table: :posts_tags has_and_belongs_to_many :planets, join_table: :planets_tags + + has_and_belongs_to_many :comments, join_table: :comments_tags end class Section < ActiveRecord::Base @@ -434,7 +436,7 @@ class Cat < ActiveRecord::Base class IsoCurrency < ActiveRecord::Base self.primary_key = :code - # has_many :expense_entries, foreign_key: 'currency_code' + has_many :expense_entries, foreign_key: 'currency_code' end class ExpenseEntry < ActiveRecord::Base @@ -493,6 +495,7 @@ class Like < ActiveRecord::Base end class Breed + include ActiveModel::Model def initialize(id = nil, name = nil) if id.nil? @@ -511,19 +514,7 @@ def destroy $breed_data.remove(@id) end - def valid?(context = nil) - @errors.clear - if name.is_a?(String) && name.length > 0 - return true - else - @errors.add(:name, "can't be blank") - return false - end - end - - def errors - @errors - end + validates :name, presence: true end class Book < ActiveRecord::Base @@ -599,6 +590,9 @@ class Category < ActiveRecord::Base class Picture < ActiveRecord::Base belongs_to :imageable, polymorphic: true + + # belongs_to :document, -> { where( pictures: { imageable_type: 'Document' } ).includes( :pictures ) }, foreign_key: 'imageable_id' + # belongs_to :product, -> { where( pictures: { imageable_type: 'Product' } ).includes( :pictures ) }, foreign_key: 'imageable_id' end class Vehicle < ActiveRecord::Base @@ -615,11 +609,8 @@ class Document < ActiveRecord::Base has_many :pictures, as: :imageable end -class Document::Topic < Document -end - class Product < ActiveRecord::Base - has_one :picture, as: :imageable + has_many :pictures, as: :imageable end class Make < ActiveRecord::Base @@ -645,8 +636,8 @@ class Thing < ActiveRecord::Base end class RelatedThing < ActiveRecord::Base - belongs_to :from, class_name: Thing, foreign_key: :from_id - belongs_to :to, class_name: Thing, foreign_key: :to_id + belongs_to :from, class_name: "Thing", foreign_key: :from_id + belongs_to :to, class_name: "Thing", foreign_key: :to_id end class Question < ActiveRecord::Base @@ -687,7 +678,7 @@ class Keeper < ActiveRecord::Base end class AccessCard < ActiveRecord::Base - has_one :worker, class_name: 'Worker' + has_many :workers end class Worker < ActiveRecord::Base @@ -980,6 +971,7 @@ class AccessCardsController < BaseController class WorkersController < BaseController end + ### RESOURCES class BaseResource < JSONAPI::Resource abstract @@ -989,12 +981,15 @@ class PersonResource < BaseResource attributes :name, :email attribute :date_joined, format: :date_with_timezone - has_many :comments, :posts + has_many :comments, inverse_relationship: :author + has_many :posts, inverse_relationship: :author has_many :vehicles, polymorphic: true has_one :preferences has_one :hair_cut + has_many :expense_entries + filter :name, verify: :verify_name_filter def self.verify_name_filter(values, _context) @@ -1065,12 +1060,15 @@ class TagResource < JSONAPI::Resource attributes :name has_many :posts + has_many :comments # Not including the planets relationship so they don't get output #has_many :planets end class SectionResource < JSONAPI::Resource attributes 'name' + + has_many :posts end module ParentApi @@ -1152,7 +1150,7 @@ def title=(title) return values }, apply: -> (records, value, _options) { - records.where('id IN (?)', value) + records.where('posts.id IN (?)', value) } filter :search, @@ -1191,6 +1189,8 @@ class IsoCurrencyResource < JSONAPI::Resource attributes :name, :country_name, :minor_unit attribute :id, format: :id, readonly: false + has_many :expense_entries + filter :country_name key_type :string @@ -1201,44 +1201,90 @@ class ExpenseEntryResource < JSONAPI::Resource attribute :transaction_date, format: :date has_one :iso_currency, foreign_key: 'currency_code' - has_one :employee, class_name: 'Person' + has_one :employee end class EmployeeResource < JSONAPI::Resource attributes :name, :email model_name 'Person' + has_many :expense_entries end -class BreedResource < JSONAPI::Resource - attribute :name, format: :title +module BreedResourceFinder + def self.included(base) + base.extend ClassMethods + end - # This is unneeded, just here for testing - routing_options param: :id + module ClassMethods + def find(filters, options = {}) + records = find_records(filters, options) + resources_for(records, options[:context]) + end + + # Records + def find_fragments(filters, options = {}) + identities = {} + find_records(filters, options).each do |breed| + identities[JSONAPI::ResourceIdentity.new(BreedResource, breed.id)] = { cache_field: nil } + end + identities + end - def self.find(filters, options = {}) - breeds = [] - $breed_data.breeds.values.each do |breed| - breeds.push(BreedResource.new(breed, options[:context])) + def find_by_key(key, options = {}) + record = find_record_by_key(key, options) + resource_for(record, options[:context]) + end + + def find_by_keys(keys, options = {}) + records = find_records_by_keys(keys, options) + resources_for(records, options[:context]) + end + + # + def find_records(filters, options = {}) + breeds = [] + id_filter = filters[:id] + id_filter = [id_filter] unless id_filter.nil? || id_filter.is_a?(Array) + $breed_data.breeds.values.each do |breed| + breeds.push(breed) unless id_filter && !id_filter.include?(breed.id) + end + breeds end - breeds - end - def self.find_by_key(id, options = {}) - BreedResource.new($breed_data.breeds[id.to_i], options[:context]) + def find_record_by_key(key, options = {}) + $breed_data.breeds[key.to_i] + end + + def find_records_by_keys(keys, options = {}) + breeds = [] + keys.each do |key| + breeds.push($breed_data.breeds[key.to_i]) + end + breeds + end end +end + +JSONAPI.configuration.resource_finder = BreedResourceFinder +class BreedResource < JSONAPI::Resource + attribute :name, format: :title + + # This is unneeded, just here for testing + routing_options param: :id def _save super return :accepted end end +JSONAPI.configuration.resource_finder = JSONAPI::ActiveRelationResourceFinder class PlanetResource < JSONAPI::Resource attribute :name attribute :description has_many :moons - has_one :planet_type + belongs_to :planet_type has_many :tags, acts_as_set: true end @@ -1270,7 +1316,7 @@ class CraterResource < JSONAPI::Resource filter :description, apply: -> (records, value, options) { fail "context not set" unless options[:context][:current_user] != nil && options[:context][:current_user] == $test_user - records.where(:description => value) + records.where(concat_table_field(options[:table_alias], :description) => value) } def self.verify_key(key, context = nil) @@ -1281,7 +1327,7 @@ def self.verify_key(key, context = nil) class PreferencesResource < JSONAPI::Resource attribute :advanced_mode - has_one :author, :foreign_key_on => :related + has_one :author, :foreign_key_on => :related, class_name: "Person" def self.find_records(filters, options = {}) Preferences.limit(1) @@ -1306,7 +1352,8 @@ class CategoryResource < JSONAPI::Resource class PictureResource < JSONAPI::Resource attribute :name - has_one :imageable, polymorphic: true + has_one :imageable, polymorphic: true + # has_one :imageable, polymorphic: true, polymorphic_relations: [:document, :product] end class DocumentResource < JSONAPI::Resource @@ -1314,11 +1361,6 @@ class DocumentResource < JSONAPI::Resource has_many :pictures end -class TopicResource < JSONAPI::Resource - model_name 'Document::Topic' - has_many :pictures -end - class ProductResource < JSONAPI::Resource attribute :name has_one :picture, always_include_linkage_data: true @@ -1328,6 +1370,7 @@ def picture_id end end +# ToDo: Remove the need for the polymorphic fake resource class ImageableResource < JSONAPI::Resource end @@ -1498,14 +1541,33 @@ class BoatResource < BoatResource; end module Api module V2 class PreferencesResource < PreferencesResource; end - class PersonResource < PersonResource; end + + class PersonResource < PersonResource + has_many :book_comments + end + class PostResource < PostResource; end class AuthorResource < JSONAPI::Resource model_name 'Person' attributes :name - has_many :books, inverse_relationship: :authors + has_many :books, inverse_relationship: :authors, + custom_methods: { + apply_join: -> (options) { + relationship = options[:relationship] + relation_name = relationship.relation_name(options[:options]) + + records = options[:records].joins(relation_name).references(relation_name) + + unless options[:context][:current_user].try(:book_admin) + records = records.where("#{relation_name}.banned" => false) + end + records + } + } + + has_many :book_comments def records_for(rel_name) records = _model.public_send(rel_name) @@ -1523,7 +1585,7 @@ class BookResource < JSONAPI::Resource attribute "title" attributes :isbn, :banned - has_many "authors" + has_many "authors", class_name: 'Authors' has_many "book_comments", relation_name: -> (options = {}) { context = options[:context] @@ -1540,7 +1602,17 @@ class BookResource < JSONAPI::Resource filter :book_comments, apply: ->(records, value, options) { - return records.where('book_comments.id' => value) + context = options[:context] + current_user = context ? context[:current_user] : nil + + relation = + unless current_user && current_user.book_admin + :approved_book_comments + else + :book_comments + end + + return records.joins(relation).references(relation).where('book_comments.id' => value) } filter :banned, apply: :apply_filter_banned @@ -1583,7 +1655,7 @@ class BookCommentResource < JSONAPI::Resource attributes :body, :approved has_one :book - has_one :author, class_name: 'Person' + has_one :author filters :book filter :approved, apply: ->(records, value, options) { @@ -1594,6 +1666,9 @@ class BookCommentResource < JSONAPI::Resource records.where(approved_comments(value[0] == 'true')) end } + filter :body, apply: ->(records, value, options) { + records.where(BookComment.arel_table[:body].matches("%#{value[0]}%")) + } class << self def book_comments @@ -1627,6 +1702,8 @@ class PersonResource < PersonResource; end class ExpenseEntryResource < ExpenseEntryResource; end class IsoCurrencyResource < IsoCurrencyResource; end + class AuthorResource < Api::V2::AuthorResource; end + class BookResource < Api::V2::BookResource paginator :paged end @@ -1746,6 +1823,8 @@ class PurchaseOrderResource < JSONAPI::Resource class OrderFlagResource < JSONAPI::Resource attributes :name + caching false + has_many :purchase_orders, reflect: false end @@ -1930,7 +2009,17 @@ class ThingResource < JSONAPI::Resource has_one :box has_one :user - has_many :things + has_many :things, + custom_methods: { + apply_join: -> (options) { + table_alias = "aliased_#{options[:table_alias]}" + options[:table_alias] = table_alias + + join_stmt = "LEFT OUTER JOIN related_things related_things_#{table_alias} ON related_things_#{table_alias}.from_id = things.id LEFT OUTER JOIN things \"#{table_alias}\" ON \"#{table_alias}\".id = related_things_#{table_alias}.to_id" + + return options[:records].joins(join_stmt) + } + } end class UserResource < JSONAPI::Resource @@ -1967,21 +2056,25 @@ class StorageResource < JSONAPI::Resource primary_key :token attribute :name + has_many :keepers end class KeeperResource < JSONAPI::Resource - has_one :keepable, polymorphic: true, foreign_key: :keepable_id + has_one :keepable, polymorphic: true attribute :name end class KeepableResource < JSONAPI::Resource + has_many :keepers end class AccessCardResource < JSONAPI::Resource key_type :string primary_key :token + has_many :workers + attribute :security_level end diff --git a/test/fixtures/author_details.yml b/test/fixtures/author_details.yml index 5711265c8..0d9d56077 100644 --- a/test/fixtures/author_details.yml +++ b/test/fixtures/author_details.yml @@ -1,9 +1,14 @@ a: id: 1 - person_id: 1 + person_id: 1001 author_stuff: blah blah b: id: 2 - person_id: 2 - author_stuff: blah blah blah \ No newline at end of file + person_id: 1002 + author_stuff: blah blah blah + +c: + id: 3 + person_id: 1003 + author_stuff: Prolific writer of schlock \ No newline at end of file diff --git a/test/fixtures/book_authors.yml b/test/fixtures/book_authors.yml index 3b7c3787e..5b3819989 100644 --- a/test/fixtures/book_authors.yml +++ b/test/fixtures/book_authors.yml @@ -1,15 +1,23 @@ book_author_1_1: book_id: 1 - person_id: 1 + person_id: 1001 book_author_2_1: book_id: 2 - person_id: 1 + person_id: 1001 book_author_2_2: book_id: 2 - person_id: 2 + person_id: 1002 book_author_654_2: book_id: 654 # Banned book - person_id: 2 + person_id: 1002 + + +<% for book_num in 300..343 %> +book_author_1003_<%= book_num %>: + id: <%= book_num + 30321 %> + book_id: <%= book_num %> + person_id: 1003 +<% end %> diff --git a/test/fixtures/book_comments.yml b/test/fixtures/book_comments.yml index 0fbf3487b..2dd40bd7e 100644 --- a/test/fixtures/book_comments.yml +++ b/test/fixtures/book_comments.yml @@ -4,7 +4,7 @@ book_<%= book_num %>_comment_<%= comment_num %>: id: <%= comment_id %> body: This is comment <%= comment_num %> on book <%= book_num %>. - author_id: <%= book_num.even? ? comment_id % 2 : (comment_id % 2) + 2 %> + author_id: <%= book_num.even? ? (comment_id % 2) + 1000: (comment_id % 2) + 1002 %> book_id: <%= book_num %> approved: <%= comment_num.even? %> <% comment_id = comment_id + 1 %> diff --git a/test/fixtures/boxes.yml b/test/fixtures/boxes.yml index c2c299d81..9325efae1 100644 --- a/test/fixtures/boxes.yml +++ b/test/fixtures/boxes.yml @@ -1,2 +1,2 @@ -box_1: - id: 1 +box_100: + id: 100 diff --git a/test/fixtures/comments.yml b/test/fixtures/comments.yml index c68f16c05..4d23a67b1 100644 --- a/test/fixtures/comments.yml +++ b/test/fixtures/comments.yml @@ -2,30 +2,31 @@ post_1_dumb_post: id: 1 post_id: 1 body: what a dumb post - author_id: 1 + author_id: 1001 post_1_i_liked_it: id: 2 post_id: 1 body: i liked it - author_id: 2 + author_id: 1002 post_2_thanks_man: id: 3 post_id: 2 body: Thanks man. Great post. But what is JR? - author_id: 2 + author_id: 1002 rogue_comment: + id: 6 body: Rogue Comment Here - author_id: 3 + author_id: 1003 rogue_comment_2: id: 7 body: Rogue Comment 2 Here - author_id: 1 + author_id: 1001 rogue_comment_3: id: 8 body: Rogue Comment 3 Here - author_id: 1 \ No newline at end of file + author_id: 1001 \ No newline at end of file diff --git a/test/fixtures/comments_tags.yml b/test/fixtures/comments_tags.yml index d85aaa257..4491d6d62 100644 --- a/test/fixtures/comments_tags.yml +++ b/test/fixtures/comments_tags.yml @@ -1,20 +1,20 @@ post_1_dumb_post_whiny: comment_id: 1 - tag_id: 2 + tag_id: 502 post_1_dumb_post_short: comment_id: 1 - tag_id: 1 + tag_id: 501 post_1_i_liked_it_happy: comment_id: 2 - tag_id: 4 + tag_id: 504 post_1_i_liked_it_short: comment_id: 2 - tag_id: 1 + tag_id: 501 post_2_thanks_man_jr: comment_id: 3 - tag_id: 5 + tag_id: 505 diff --git a/test/fixtures/documents.yml b/test/fixtures/documents.yml index 12312278d..ffaac63b3 100644 --- a/test/fixtures/documents.yml +++ b/test/fixtures/documents.yml @@ -1,3 +1,15 @@ document_1: id: 1 name: Company Brochure + +document_2: + id: 2 + name: Enagement Letter + +document_200: + id: 200 + name: Management Through the Years + +document_201: + id: 201 + name: Foo diff --git a/test/fixtures/expense_entries.yml b/test/fixtures/expense_entries.yml index 2f640b707..eabeea196 100644 --- a/test/fixtures/expense_entries.yml +++ b/test/fixtures/expense_entries.yml @@ -1,13 +1,13 @@ entry_1: id: 1 currency_code: USD - employee_id: 3 + employee_id: 1003 cost: 12.05 transaction_date: <%= Date.parse('2014-04-15') %> entry_2: id: 2 currency_code: USD - employee_id: 3 + employee_id: 1003 cost: 12.06 transaction_date: <%= Date.parse('2014-04-15') %> \ No newline at end of file diff --git a/test/fixtures/people.yml b/test/fixtures/people.yml index 8e151f64c..47e868b34 100644 --- a/test/fixtures/people.yml +++ b/test/fixtures/people.yml @@ -1,37 +1,37 @@ a: - id: 1 + id: 1001 name: Joe Author email: joe@xyz.fake date_joined: <%= DateTime.parse('2013-08-07 20:25:00 UTC +00:00') %> preferences_id: 1 b: - id: 2 + id: 1002 name: Fred Reader email: fred@xyz.fake date_joined: <%= DateTime.parse('2013-10-31 20:25:00 UTC +00:00') %> c: - id: 3 + id: 1003 name: Lazy Author email: lazy@xyz.fake date_joined: <%= DateTime.parse('2013-10-31 21:25:00 UTC +00:00') %> d: - id: 4 + id: 1004 name: Tag Crazy Author email: taggy@xyz.fake date_joined: <%= DateTime.parse('2013-11-30 4:20:00 UTC +00:00') %> e: - id: 5 + id: 1005 name: Wilma Librarian email: lib@xyz.fake date_joined: <%= DateTime.parse('2013-11-30 4:20:00 UTC +00:00') %> book_admin: true x: - id: 0 + id: 1000 name: The Shadow email: nobody@nowhere.comment_num date_joined: <%= DateTime.parse('1970-01-01 20:25:00 UTC +00:00') %> diff --git a/test/fixtures/pictures.yml b/test/fixtures/pictures.yml index 62584e945..d43eca90e 100644 --- a/test/fixtures/pictures.yml +++ b/test/fixtures/pictures.yml @@ -13,3 +13,27 @@ picture_2: picture_3: id: 3 name: group_photo.jpg + +picture_40: + id: 40 + name: company_management_team_2015.jpg + imageable_id: 200 + imageable_type: Document + +picture_41: + id: 41 + name: company_management_team_2016.jpg + imageable_id: 200 + imageable_type: Document + +picture_47: + id: 47 + name: company_management_team_2017.jpg + imageable_id: 200 + imageable_type: Document + +picture_48: + id: 48 + name: JunkYardDogs.jpg + imageable_id: 201 + imageable_type: Document diff --git a/test/fixtures/posts.yml b/test/fixtures/posts.yml index 4cdf94503..491a627b6 100644 --- a/test/fixtures/posts.yml +++ b/test/fixtures/posts.yml @@ -2,98 +2,98 @@ post_1: id: 1 title: New post body: A body!!! - author_id: 1 + author_id: 1001 post_2: id: 2 title: JR Solves your serialization woes! body: Use JR - author_id: 1 + author_id: 1001 section_id: 2 post_3: id: 3 title: Update This Later body: AAAA - author_id: 3 + author_id: 1003 post_4: id: 4 title: Delete This Later - Single body: AAAA - author_id: 3 + author_id: 1003 post_5: id: 5 title: Delete This Later - Multiple1 body: AAAA - author_id: 3 + author_id: 1003 post_6: id: 6 title: Delete This Later - Multiple2 body: AAAA - author_id: 3 + author_id: 1003 post_7: id: 7 title: Delete This Later - Single2 body: AAAA - author_id: 3 + author_id: 1003 post_8: id: 8 title: Delete This Later - Multiple2-1 body: AAAA - author_id: 3 + author_id: 1003 post_9: id: 9 title: Delete This Later - Multiple2-2 body: AAAA - author_id: 3 + author_id: 1003 post_10: id: 10 title: Update This Later - Multiple body: AAAA - author_id: 3 + author_id: 1003 post_11: id: 11 title: JR How To body: Use JR to write API apps - author_id: 1 + author_id: 1001 post_12: id: 12 title: Tagged up post 1 body: AAAA - author_id: 4 + author_id: 1004 post_13: id: 13 title: Tagged up post 2 body: BBBB - author_id: 4 + author_id: 1004 post_14: id: 14 title: A First Post body: A First Post!!!!!!!!! - author_id: 3 + author_id: 1003 post_15: id: 15 title: AAAA First Post body: First!!!!!!!!! - author_id: 3 + author_id: 1003 post_16: id: 16 title: SDFGH body: Not First!!!! - author_id: 3 + author_id: 1003 post_17: id: 17 @@ -105,16 +105,16 @@ post_18: id: 18 title: Delete This later 18 body: AAAA - author_id: 3 + author_id: 1003 post_19: id: 19 title: Update Later - Operations body: AAAA This should be updated - author_id: 3 + author_id: 1003 post_20: id: 20 title: Update Later - Ops Multiple body: AAAA This should also be updated - author_id: 3 + author_id: 1003 diff --git a/test/fixtures/posts_tags.yml b/test/fixtures/posts_tags.yml index f42495cd8..dbf5b58c1 100644 --- a/test/fixtures/posts_tags.yml +++ b/test/fixtures/posts_tags.yml @@ -1,79 +1,79 @@ post_1_short: post_id: 1 - tag_id: 1 + tag_id: 501 post_1_whiny: post_id: 1 - tag_id: 2 + tag_id: 502 post_1_grumpy: post_id: 1 - tag_id: 3 + tag_id: 503 post_2_jr: post_id: 2 - tag_id: 5 + tag_id: 505 post_11_jr: post_id: 11 - tag_id: 5 + tag_id: 505 post_12_silly: post_id: 12 - tag_id: 6 + tag_id: 506 post_12_sleepy: post_id: 12 - tag_id: 7 + tag_id: 507 post_12_goofy: post_id: 12 - tag_id: 8 + tag_id: 508 post_12_wacky: post_id: 12 - tag_id: 9 + tag_id: 509 post_13_silly: post_id: 13 - tag_id: 6 + tag_id: 506 post_13_sleepy: post_id: 13 - tag_id: 7 + tag_id: 507 post_13_goofy: post_id: 13 - tag_id: 8 + tag_id: 508 post_13_wacky: post_id: 13 - tag_id: 9 + tag_id: 509 post_14_whiny: post_id: 14 - tag_id: 2 + tag_id: 502 post_14_grumpy: post_id: 14 - tag_id: 3 + tag_id: 503 post_15_11: post_id: 15 - tag_id: 11 + tag_id: 511 post_15_2: post_id: 15 - tag_id: 2 + tag_id: 502 post_15_4: post_id: 15 - tag_id: 4 + tag_id: 504 post_15_10: post_id: 15 - tag_id: 10 + tag_id: 510 post_15_16: post_id: 15 - tag_id: 16 + tag_id: 516 diff --git a/test/fixtures/products.yml b/test/fixtures/products.yml index 77eab3ece..c8e9884c4 100644 --- a/test/fixtures/products.yml +++ b/test/fixtures/products.yml @@ -1,3 +1,7 @@ product_1: id: 1 name: Enterprise Gizmo + +product_2: + id: 2 + name: Fighting Hot Sauce diff --git a/test/fixtures/related_things.yml b/test/fixtures/related_things.yml index bfa9b2a44..e20da2a42 100644 --- a/test/fixtures/related_things.yml +++ b/test/fixtures/related_things.yml @@ -1,9 +1,9 @@ -related_thing_1: - id: 1 - from_id: 1 - to_id: 2 +related_thing_10: + id: 101 + from_id: 10 + to_id: 20 -related_thing_2: - id: 2 - from_id: 2 - to_id: 1 \ No newline at end of file +related_thing_20: + id: 201 + from_id: 20 + to_id: 10 \ No newline at end of file diff --git a/test/fixtures/tags.yml b/test/fixtures/tags.yml index 5a9b248c6..7179675ad 100644 --- a/test/fixtures/tags.yml +++ b/test/fixtures/tags.yml @@ -1,64 +1,63 @@ short_tag: - id: 1 + id: 501 name: short whiny_tag: - id: 2 + id: 502 name: whiny grumpy_tag: - id: 3 + id: 503 name: grumpy happy_tag: - id: 4 + id: 504 name: happy jr_tag: - id: 5 + id: 505 name: JR silly_tag: - id: 6 + id: 506 name: silly sleepy_tag: - id: 7 + id: 507 name: sleepy goofy_tag: - id: 8 + id: 508 name: goofy wacky_tag: - id: 9 + id: 509 name: wacky bad_tag: - id: 10 + id: 510 name: bad tag_11: - id: 11 + id: 511 name: Tag11 tag_12: - id: 12 + id: 512 name: Tag12 tag_13: - id: 13 + id: 513 name: Tag13 tag_14: - id: 14 + id: 514 name: Tag14 tag_15: - id: 15 + id: 515 name: Tag15 tag_16: - id: 16 + id: 516 name: Tag16 - diff --git a/test/fixtures/things.yml b/test/fixtures/things.yml index 10667a7ee..2428c8f19 100644 --- a/test/fixtures/things.yml +++ b/test/fixtures/things.yml @@ -1,9 +1,9 @@ -thing_1: - id: 1 - user_id: 1 - box_id: 1 +thing_10: + id: 10 + user_id: 10001 + box_id: 100 -thing_2: - id: 2 - user_id: 1 - box_id: 1 \ No newline at end of file +thing_20: + id: 20 + user_id: 10001 + box_id: 100 \ No newline at end of file diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml index 69aa43201..6680a6271 100644 --- a/test/fixtures/users.yml +++ b/test/fixtures/users.yml @@ -1,2 +1,2 @@ user_1: - id: 1 + id: 10001 diff --git a/test/fixtures/vehicles.yml b/test/fixtures/vehicles.yml index 720257cee..97cbec05f 100644 --- a/test/fixtures/vehicles.yml +++ b/test/fixtures/vehicles.yml @@ -5,7 +5,7 @@ Miata: model: Miata MX5 drive_layout: Front Engine RWD serial_number: 32432adfsfdysua - person_id: 1 + person_id: 1001 Launch20: id: 2 @@ -14,4 +14,4 @@ Launch20: model: Launch 20 length_at_water_line: 15.5ft serial_number: 434253JJJSD - person_id: 1 + person_id: 1001 diff --git a/test/helpers/configuration_helpers.rb b/test/helpers/configuration_helpers.rb index ed7169700..5afe3296d 100644 --- a/test/helpers/configuration_helpers.rb +++ b/test/helpers/configuration_helpers.rb @@ -16,6 +16,7 @@ def with_resource_caching(cache, classes = :all) results = {total: {hits: 0, misses: 0}} new_config_options = { resource_cache: cache, + default_caching: true, resource_cache_usage_report_function: Proc.new do |name, hits, misses| [name.to_sym, :total].each do |key| results[key] ||= {hits: 0, misses: 0} @@ -50,17 +51,7 @@ def with_resource_caching(cache, classes = :all) end begin - classes.each do |klass| - raise "#{klass.name} already caching!" if klass.caching? - klass.caching - raise "Couldn't enable caching for #{klass.name}" unless klass.caching? - end - yield - ensure - classes.each do |klass| - klass.caching(false) - end end end diff --git a/test/integration/requests/request_test.rb b/test/integration/requests/request_test.rb index c801aae41..5db3e26f2 100644 --- a/test/integration/requests/request_test.rb +++ b/test/integration/requests/request_test.rb @@ -5,7 +5,7 @@ def setup JSONAPI.configuration.json_key_format = :underscored_key JSONAPI.configuration.route_format = :underscored_route Api::V2::BookResource.paginator :offset - $test_user = Person.find(1) + $test_user = Person.find(1001) end def after_teardown @@ -122,8 +122,8 @@ def test_put_single_without_content_type 'relationships' => { 'tags' => { 'data' => [ - {'type' => 'tags', 'id' => '3'}, - {'type' => 'tags', 'id' => '4'} + {'type' => 'tags', 'id' => '503'}, + {'type' => 'tags', 'id' => '504'} ] } } @@ -149,8 +149,8 @@ def test_put_single 'relationships' => { 'tags' => { 'data' => [ - {'type' => 'tags', 'id' => '3'}, - {'type' => 'tags', 'id' => '4'} + {'type' => 'tags', 'id' => '503'}, + {'type' => 'tags', 'id' => '504'} ] } } @@ -174,8 +174,8 @@ def test_post_single_with_wrong_content_type 'relationships' => { 'tags' => { 'data' => [ - {'type' => 'tags', 'id' => '3'}, - {'type' => 'tags', 'id' => '4'} + {'type' => 'tags', 'id' => '503'}, + {'type' => 'tags', 'id' => '504'} ] } } @@ -199,7 +199,7 @@ def test_post_single 'body' => 'JSONAPIResources is the greatest thing since unsliced bread.' }, 'relationships' => { - 'author' => {'data' => {'type' => 'people', 'id' => '3'}} + 'author' => {'data' => {'type' => 'people', 'id' => '1003'}} } } }.to_json, @@ -347,8 +347,8 @@ def test_put_content_type 'relationships' => { 'tags' => { 'data' => [ - {'type' => 'tags', 'id' => '3'}, - {'type' => 'tags', 'id' => '4'} + {'type' => 'tags', 'id' => '503'}, + {'type' => 'tags', 'id' => '504'} ] } } @@ -407,8 +407,8 @@ def test_patch_content_type 'relationships' => { 'tags' => { 'data' => [ - {'type' => 'tags', 'id' => '3'}, - {'type' => 'tags', 'id' => '4'} + {'type' => 'tags', 'id' => '503'}, + {'type' => 'tags', 'id' => '504'} ] } } @@ -526,17 +526,28 @@ def test_pagination_related_resources_links_meta JSONAPI.configuration.top_level_meta_include_record_count = false end - def test_filter_related_resources + def test_filter_related_resources_relationship_filter Api::V2::BookCommentResource.paginator :offset JSONAPI.configuration.top_level_meta_include_record_count = true assert_cacheable_jsonapi_get '/api/v2/books/1/book_comments?filter[book]=2' assert_equal 0, json_response['meta']['record_count'] assert_cacheable_jsonapi_get '/api/v2/books/1/book_comments?filter[book]=1&page[limit]=20' + assert_equal 20, json_response['data'].length assert_equal 26, json_response['meta']['record_count'] ensure JSONAPI.configuration.top_level_meta_include_record_count = false end + def test_filter_related_resources + Api::V2::BookCommentResource.paginator :offset + JSONAPI.configuration.top_level_meta_include_record_count = true + assert_cacheable_jsonapi_get '/api/v2/books/1/book_comments?filter[body]=2' + assert_equal 9, json_response['data'].length + assert_equal 9, json_response['meta']['record_count'] + ensure + JSONAPI.configuration.top_level_meta_include_record_count = false + end + def test_page_count_meta Api::V2::BookCommentResource.paginator :paged JSONAPI.configuration.top_level_meta_include_record_count = true @@ -604,6 +615,13 @@ def test_pagination_empty_results # assert_equal 'This is comment 18 on book 1.', json_response['data'][9]['attributes']['body'] # end + def test_polymorpic_related_resources + assert_cacheable_jsonapi_get '/pictures/1/imageable' + assert_equal 'Enterprise Gizmo', json_response['data']['attributes']['name'] + + assert_cacheable_jsonapi_get '/pictures/2/imageable' + assert_equal 'Company Brochure', json_response['data']['attributes']['name'] + end def test_flow_self assert_cacheable_jsonapi_get '/posts/1' @@ -623,7 +641,7 @@ def test_flow_link_to_one_self_link 'self' => 'http://www.example.com/posts/1/relationships/author', 'related' => 'http://www.example.com/posts/1/author' }, - 'data' => {'type' => 'people', 'id' => '1'} + 'data' => {'type' => 'people', 'id' => '1001'} }) end @@ -639,9 +657,9 @@ def test_flow_link_to_many_self_link 'related' => 'http://www.example.com/posts/1/tags' }, 'data' => [ - {'type' => 'tags', 'id' => '1'}, - {'type' => 'tags', 'id' => '2'}, - {'type' => 'tags', 'id' => '3'} + {'type' => 'tags', 'id' => '501'}, + {'type' => 'tags', 'id' => '502'}, + {'type' => 'tags', 'id' => '503'} ] }) end @@ -651,7 +669,7 @@ def test_flow_link_to_many_self_link_put post_5 = json_response['data'] post post_5['relationships']['tags']['links']['self'], params: - {'data' => [{'type' => 'tags', 'id' => '10'}]}.to_json, + {'data' => [{'type' => 'tags', 'id' => '510'}]}.to_json, headers: { 'CONTENT_TYPE' => JSONAPI::MEDIA_TYPE, 'Accept' => JSONAPI::MEDIA_TYPE @@ -667,7 +685,7 @@ def test_flow_link_to_many_self_link_put 'related' => 'http://www.example.com/posts/5/tags' }, 'data' => [ - {'type' => 'tags', 'id' => '10'} + {'type' => 'tags', 'id' => '510'} ] }) end @@ -896,7 +914,7 @@ def test_patch_formatted_dasherized_replace_to_many def test_patch_formatted_dasherized_replace_to_many_computed_relation $original_test_user = $test_user - $test_user = Person.find(5) + $test_user = Person.find(1005) original_config = JSONAPI.configuration.dup JSONAPI.configuration.route_format = :dasherized_route JSONAPI.configuration.json_key_format = :dasherized_key @@ -955,7 +973,7 @@ def test_post_to_many_link def test_post_computed_relation_to_many $original_test_user = $test_user - $test_user = Person.find(5) + $test_user = Person.find(1005) original_config = JSONAPI.configuration.dup JSONAPI.configuration.route_format = :dasherized_route JSONAPI.configuration.json_key_format = :dasherized_key @@ -1000,7 +1018,7 @@ def test_patch_to_many_link def test_patch_to_many_link_computed_relation $original_test_user = $test_user - $test_user = Person.find(5) + $test_user = Person.find(1005) original_config = JSONAPI.configuration.dup JSONAPI.configuration.route_format = :dasherized_route JSONAPI.configuration.json_key_format = :dasherized_key @@ -1103,28 +1121,6 @@ def test_getting_resource_with_correct_type_when_sti assert_equal 'cars', json_response['data']['type'] end - def test_get_resource_with_polymorphic_relationship_and_changed_primary_key - keeper = Keeper.find(1) - storage = keeper.keepable - assert_cacheable_jsonapi_get '/keepers/1?include=keepable' - assert_jsonapi_response 200 - - data = json_response['data'] - refute_nil data - assert_equal keeper.id.to_s, data['id'] - - refute_nil data['relationships'] - refute_nil data['relationships']['keepable'] - refute_nil data['relationships']['keepable']['data'] - assert_equal 'storages', data['relationships']['keepable']['data']['type'] - assert_equal storage.token, data['relationships']['keepable']['data']['id'] - - included = json_response['included'] - refute_nil included - assert_equal 'storages', included.first['type'] - assert_equal storage.token, included.first['id'] - end - def test_get_resource_with_belongs_to_relationship_and_changed_primary_key worker = Worker.find(1) access_card = worker.access_card diff --git a/test/integration/routes/routes_test.rb b/test/integration/routes/routes_test.rb index 7f31ffb00..b2ddd9816 100644 --- a/test/integration/routes/routes_test.rb +++ b/test/integration/routes/routes_test.rb @@ -2,6 +2,18 @@ class RoutesTest < ActionDispatch::IntegrationTest + # def test_dump_routes + # r = {} + # + # Rails.application.routes.routes.each do |route| + # r[route.path.spec.right.left.to_s] ||= {routes: {}} + # r[route.path.spec.right.left.to_s][:routes][route.path.spec.to_s] ||= {} + # r[route.path.spec.right.left.to_s][:routes][route.path.spec.to_s][route.defaults[:action]] = route + # end + # + # r + # end + def test_routing_post assert_routing({path: 'posts', method: :post}, {controller: 'posts', action: 'create'}) @@ -64,21 +76,23 @@ def test_routing_uuid # end # Polymorphic - def test_routing_polymorphic_get_related_resource - assert_routing( - { - path: '/pictures/1/imageable', - method: :get - }, - { - relationship: 'imageable', - source: 'pictures', - controller: 'imageables', - action: 'get_related_resource', - picture_id: '1' - } - ) - end + # ToDo: refute this routing. Polymorphic relationships can't support a shared set of filters or includes so + # this this route is no longer supported + # def test_routing_polymorphic_get_related_resource + # assert_routing( + # { + # path: '/pictures/1/imageable', + # method: :get + # }, + # { + # relationship: 'imageable', + # source: 'pictures', + # controller: 'imageables', + # action: 'get_related_resource', + # picture_id: '1' + # } + # ) + # end def test_routing_polymorphic_patch_related_resource assert_routing( @@ -213,6 +227,6 @@ def test_routing_primary_key_jsonapi_resources # { controller: 'api/v3/posts', action: 'destroy_relationship', post_id: '1', keys: '1,2', relationship: 'tags' }) # end - # Test that non acts as set to_many relationship update route is not created + # Test that non-acts-as-set to_many relationship update route is not created end diff --git a/test/test_helper.rb b/test/test_helper.rb index 01ae9b38c..950da56be 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -39,6 +39,8 @@ config.json_key_format = :camelized_key end +ActiveSupport::Deprecation.silenced = true + puts "Testing With RAILS VERSION #{Rails.version}" class TestApp < Rails::Application @@ -190,7 +192,9 @@ def assign_parameters(routes, controller_path, action, parameters, generated_pat def assert_query_count(expected, msg = nil, &block) @queries = [] - callback = lambda {|_, _, _, _, payload| @queries.push payload[:sql] } + callback = lambda {|_, _, _, _, payload| + @queries.push payload[:sql] + } ActiveSupport::Notifications.subscribed(callback, 'sql.active_record', &block) show_queries unless expected == @queries.size @@ -198,6 +202,17 @@ def assert_query_count(expected, msg = nil, &block) @queries = nil end +def track_queries(&block) + @queries = [] + callback = lambda {|_, _, _, _, payload| + @queries.push payload[:sql] + } + ActiveSupport::Notifications.subscribed(callback, 'sql.active_record', &block) + + show_queries + @queries = nil +end + def show_queries @queries.each_with_index do |query, index| puts "sql[#{index}]: #{query}" @@ -380,6 +395,7 @@ class CatResource < JSONAPI::Resource end jsonapi_resources :keepers, only: [:show] + jsonapi_resources :storages jsonapi_resources :workers, only: [:show] mount MyEngine::Engine => "/boomshaka", as: :my_engine @@ -522,7 +538,9 @@ def assert_cacheable_get(action, *args) [:warmup, :lookup].each do |phase| begin cache_queries = [] - cache_query_callback = lambda {|_, _, _, _, payload| cache_queries.push payload[:sql] } + cache_query_callback = lambda { |_, _, _, _, payload| + cache_queries.push payload[:sql] + } cache_activity[phase] = with_resource_caching(cache, cached_resources) do ActiveSupport::Notifications.subscribed(cache_query_callback, 'sql.active_record') do @controller = nil @@ -552,7 +570,7 @@ def assert_cacheable_get(action, *args) assert_operator( cache_queries.size, :<=, - normal_queries.size*2, # Allow up to double the number of queries as the uncached action + normal_queries.size, "Cache (mode: #{mode}) #{phase} action made too many queries:\n#{cache_queries.pretty_inspect}" ) end diff --git a/test/unit/processor/default_processor_test.rb b/test/unit/processor/default_processor_test.rb new file mode 100644 index 000000000..0f4b221ea --- /dev/null +++ b/test/unit/processor/default_processor_test.rb @@ -0,0 +1,119 @@ +require File.expand_path('../../../test_helper', __FILE__) +require 'jsonapi-resources' +require 'json' + +class DefaultProcessorIdTreeTest < ActionDispatch::IntegrationTest + def setup + JSONAPI.configuration.json_key_format = :camelized_key + JSONAPI.configuration.route_format = :camelized_route + JSONAPI.configuration.always_include_to_one_linkage_data = false + + JSONAPI.configuration.resource_cache = ActiveSupport::Cache::MemoryStore.new + PostResource.caching true + PersonResource.caching true + + $serializer = JSONAPI::ResourceSerializer.new(PostResource, base_url: 'http://example.com') + + # no includes + filters = { id: [10, 12] } + + find_options = { filters: filters } + params = { + filters: filters, + include_directives: {}, + sort_criteria: {}, + paginator: {}, + fields: {}, + serializer: {} + } + p = JSONAPI::Processor.new(PostResource, :find, params) + $id_tree_no_includes = p.find_resource_id_tree(PostResource, find_options, nil) + $resource_set_no_includes = p.flatten_resource_id_tree($id_tree_no_includes) + $populated_resource_set_no_includes = p.populate_resource_set($resource_set_no_includes, + $serializer, + {}) + + + # has_one included + directives = JSONAPI::IncludeDirectives.new(PersonResource, ['author']).include_directives + params = { + filters: filters, + include_directives: directives, + sort_criteria: {}, + paginator: {}, + fields: {}, + serializer: {} + } + p = JSONAPI::Processor.new(PostResource, :find, params) + + $id_tree_has_one_includes = p.find_resource_id_tree(PostResource, find_options, directives[:include_related]) + + $resource_set_has_one_includes = p.flatten_resource_id_tree($id_tree_has_one_includes) + $populated_resource_set_has_one_includes = p.populate_resource_set($resource_set_has_one_includes, + $serializer, + {}) + end + + def after_teardown + JSONAPI.configuration.always_include_to_one_linkage_data = false + JSONAPI.configuration.json_key_format = :camelized_key + JSONAPI.configuration.route_format = :underscored_route + + JSONAPI.configuration.resource_cache = nil + PostResource.caching nil + PersonResource.caching nil + end + + def test_id_tree_without_includes_should_be_a_hash + assert $id_tree_no_includes.is_a?(Hash) + end + + def test_id_tree_without_includes_should_have_resources + assert_equal 2, $id_tree_no_includes[:resources].size + end + + def test_id_tree_without_includes_should_not_have_includes + assert_nil $id_tree_no_includes[:includes] + end + + def test_id_tree_without_includes_resource_relationships_should_be_empty + assert_equal 0, $id_tree_no_includes[:resources][JSONAPI::ResourceIdentity.new(PostResource, 10)][:relationships].length + assert_equal 0, $id_tree_no_includes[:resources][JSONAPI::ResourceIdentity.new(PostResource, 12)][:relationships].length + end + + + def test_id_tree_has_one_includes_should_be_a_hash + assert $id_tree_has_one_includes.is_a?(Hash) + end + + def test_id_tree_has_one_includes_should_have_included_resources + assert $id_tree_has_one_includes[:included].is_a?(Hash) + assert $id_tree_has_one_includes[:included][:author].is_a?(Hash) + assert_equal 2, $id_tree_has_one_includes[:included][:author][:resources].size + end + + def test_id_tree_has_one_includes_should_have_resources + assert_equal 2, $id_tree_has_one_includes[:resources].size + end + + def test_id_tree_has_one_includes_resource_relationships_should_have_rids + assert_equal 1, $id_tree_has_one_includes[:resources][JSONAPI::ResourceIdentity.new(PostResource, 10)][:relationships][:author][:rids].length + assert_equal 1, $id_tree_has_one_includes[:resources][JSONAPI::ResourceIdentity.new(PostResource, 12)][:relationships][:author][:rids].length + end + + def test_populated_resource_set_has_one_includes_have_resources + assert $populated_resource_set_has_one_includes[PostResource][10].is_a?(Hash) + assert $populated_resource_set_has_one_includes[PostResource][12].is_a?(Hash) + assert $populated_resource_set_has_one_includes[PersonResource][1003].is_a?(Hash) + assert $populated_resource_set_has_one_includes[PersonResource][1004].is_a?(Hash) + end + + def test_populated_resource_set_has_one_includes_relationships_are_resolved + assert_equal 1003, $populated_resource_set_has_one_includes[PostResource][10][:relationships][:author][:rids].first.id + assert_equal 1004, $populated_resource_set_has_one_includes[PostResource][12][:relationships][:author][:rids].first.id + + assert_equal 10, $populated_resource_set_has_one_includes[PersonResource][1003][:relationships][:posts][:rids].first.id + assert_equal 12, $populated_resource_set_has_one_includes[PersonResource][1004][:relationships][:posts][:rids].first.id + end + +end \ No newline at end of file diff --git a/test/unit/resource/active_relation_resource_finder_test.rb b/test/unit/resource/active_relation_resource_finder_test.rb new file mode 100644 index 000000000..228103689 --- /dev/null +++ b/test/unit/resource/active_relation_resource_finder_test.rb @@ -0,0 +1,222 @@ +require File.expand_path('../../../test_helper', __FILE__) + +class ARPostResource < JSONAPI::Resource + model_name 'Post' + attribute :headline, delegate: :title + has_one :author + has_many :tags +end + +class ActiveRelationResourceFinderTest < ActiveSupport::TestCase + def setup + end + + def test_find_fragments_no_attributes + filters = {} + posts_identities = ARPostResource.find_fragments(filters) + + assert_equal 20, posts_identities.length + assert_equal JSONAPI::ResourceIdentity.new(ARPostResource, 1), posts_identities.keys[0] + assert_equal JSONAPI::ResourceIdentity.new(ARPostResource, 1), posts_identities.values[0][:identity] + assert posts_identities.values[0].is_a?(Hash) + assert_equal 1, posts_identities.values[0].length + end + + def test_find_fragments_cache_field + filters = {} + options = { cache: true } + posts_identities = ARPostResource.find_fragments(filters, options) + + assert_equal 20, posts_identities.length + assert_equal JSONAPI::ResourceIdentity.new(ARPostResource, 1), posts_identities.keys[0] + assert_equal JSONAPI::ResourceIdentity.new(ARPostResource, 1), posts_identities.values[0][:identity] + assert posts_identities.values[0].is_a?(Hash) + assert_equal 2, posts_identities.values[0].length + assert posts_identities.values[0][:cache].is_a?(ActiveSupport::TimeWithZone) + end + + def test_find_fragments_cache_field_attributes + filters = {} + options = { attributes: [:headline, :author_id], cache: true } + posts_identities = ARPostResource.find_fragments(filters, options) + + assert_equal 20, posts_identities.length + assert_equal JSONAPI::ResourceIdentity.new(ARPostResource, 1), posts_identities.keys[0] + assert_equal JSONAPI::ResourceIdentity.new(ARPostResource, 1), posts_identities.values[0][:identity] + assert posts_identities.values[0].is_a?(Hash) + assert_equal 3, posts_identities.values[0].length + assert_equal 2, posts_identities.values[0][:attributes].length + assert posts_identities.values[0][:cache].is_a?(ActiveSupport::TimeWithZone) + assert_equal 'New post', posts_identities.values[0][:attributes][:headline] + assert_equal 1001, posts_identities.values[0][:attributes][:author_id] + end + + def test_find_related_has_one_fragments_no_attributes + options = {} + source_rids = [JSONAPI::ResourceIdentity.new(ARPostResource, 1), + JSONAPI::ResourceIdentity.new(ARPostResource, 2), + JSONAPI::ResourceIdentity.new(ARPostResource, 20)] + + related_identities = ARPostResource.find_related_fragments(source_rids, 'author', options) + + assert_equal 2, related_identities.length + assert_equal JSONAPI::ResourceIdentity.new(AuthorResource, 1001), related_identities.keys[0] + assert_equal JSONAPI::ResourceIdentity.new(AuthorResource, 1001), related_identities.values[0][:identity] + assert related_identities.values[0].is_a?(Hash) + assert_equal 2, related_identities.values[0].length + assert_equal 2, related_identities.values[0][:related][:author].length + end + + def test_find_related_has_one_fragments_cache_field + options = { cache: true } + source_rids = [JSONAPI::ResourceIdentity.new(ARPostResource, 1), + JSONAPI::ResourceIdentity.new(ARPostResource, 2), + JSONAPI::ResourceIdentity.new(ARPostResource, 20)] + + related_identities = ARPostResource.find_related_fragments(source_rids, 'author', options) + + assert_equal 2, related_identities.length + assert_equal JSONAPI::ResourceIdentity.new(AuthorResource, 1001), related_identities.keys[0] + assert_equal JSONAPI::ResourceIdentity.new(AuthorResource, 1001), related_identities.values[0][:identity] + assert related_identities.values[0].is_a?(Hash) + assert_equal 3, related_identities.values[0].length + assert_equal 2, related_identities.values[0][:related][:author].length + assert related_identities.values[0][:cache].is_a?(ActiveSupport::TimeWithZone) + end + + def test_find_related_has_one_fragments_cache_field_attributes + options = { cache: true, attributes: [:name] } + source_rids = [JSONAPI::ResourceIdentity.new(ARPostResource, 1), + JSONAPI::ResourceIdentity.new(ARPostResource, 2), + JSONAPI::ResourceIdentity.new(ARPostResource, 20)] + + related_identities = ARPostResource.find_related_fragments(source_rids, 'author', options) + + assert_equal 2, related_identities.length + assert_equal JSONAPI::ResourceIdentity.new(AuthorResource, 1001), related_identities.keys[0] + assert_equal JSONAPI::ResourceIdentity.new(AuthorResource, 1001), related_identities.values[0][:identity] + assert related_identities.values[0].is_a?(Hash) + assert_equal 4, related_identities.values[0].length + assert_equal 2, related_identities.values[0][:related][:author].length + assert_equal 1, related_identities.values[0][:attributes].length + assert related_identities.values[0][:cache].is_a?(ActiveSupport::TimeWithZone) + assert_equal 'Joe Author', related_identities.values[0][:attributes][:name] + end + + def test_find_related_has_many_fragments_no_attributes + options = {} + source_rids = [JSONAPI::ResourceIdentity.new(ARPostResource, 1), + JSONAPI::ResourceIdentity.new(ARPostResource, 2), + JSONAPI::ResourceIdentity.new(ARPostResource, 12), + JSONAPI::ResourceIdentity.new(ARPostResource, 14)] + + related_identities = ARPostResource.find_related_fragments(source_rids, 'tags', options) + + assert_equal 8, related_identities.length + assert_equal JSONAPI::ResourceIdentity.new(TagResource, 501), related_identities.keys[0] + assert_equal JSONAPI::ResourceIdentity.new(TagResource, 501), related_identities.values[0][:identity] + assert related_identities.values[0].is_a?(Hash) + assert_equal 2, related_identities.values[0].length + assert_equal 1, related_identities.values[0][:related][:tags].length + assert_equal 2, related_identities[JSONAPI::ResourceIdentity.new(TagResource, 502)][:related][:tags].length + end + + def test_find_related_has_many_fragments_cache_field + options = { cache: true } + source_rids = [JSONAPI::ResourceIdentity.new(ARPostResource, 1), + JSONAPI::ResourceIdentity.new(ARPostResource, 2), + JSONAPI::ResourceIdentity.new(ARPostResource, 12), + JSONAPI::ResourceIdentity.new(ARPostResource, 14)] + + related_identities = ARPostResource.find_related_fragments(source_rids, 'tags', options) + + assert_equal 8, related_identities.length + assert_equal JSONAPI::ResourceIdentity.new(TagResource, 501), related_identities.keys[0] + assert_equal JSONAPI::ResourceIdentity.new(TagResource, 501), related_identities.values[0][:identity] + assert related_identities.values[0].is_a?(Hash) + assert_equal 3, related_identities.values[0].length + assert_equal 1, related_identities.values[0][:related][:tags].length + assert_equal 2, related_identities[JSONAPI::ResourceIdentity.new(TagResource, 502)][:related][:tags].length + assert related_identities.values[0][:cache].is_a?(ActiveSupport::TimeWithZone) + end + + def test_find_related_has_many_fragments_cache_field_attributes + options = { cache: true, attributes: [:name] } + source_rids = [JSONAPI::ResourceIdentity.new(ARPostResource, 1), + JSONAPI::ResourceIdentity.new(ARPostResource, 2), + JSONAPI::ResourceIdentity.new(ARPostResource, 12), + JSONAPI::ResourceIdentity.new(ARPostResource, 14)] + + related_identities = ARPostResource.find_related_fragments(source_rids, 'tags', options) + + assert_equal 8, related_identities.length + assert_equal JSONAPI::ResourceIdentity.new(TagResource, 501), related_identities.keys[0] + assert_equal JSONAPI::ResourceIdentity.new(TagResource, 501), related_identities.values[0][:identity] + assert related_identities.values[0].is_a?(Hash) + assert_equal 4, related_identities.values[0].length + assert_equal 1, related_identities.values[0][:related][:tags].length + assert_equal 2, related_identities[JSONAPI::ResourceIdentity.new(TagResource, 502)][:related][:tags].length + assert_equal 1, related_identities.values[0][:attributes].length + assert related_identities.values[0][:cache].is_a?(ActiveSupport::TimeWithZone) + assert_equal 'short', related_identities.values[0][:attributes][:name] + end + + def test_find_related_polymorphic_fragments_no_attributes + options = {} + source_rids = [JSONAPI::ResourceIdentity.new(PictureResource, 1), + JSONAPI::ResourceIdentity.new(PictureResource, 2), + JSONAPI::ResourceIdentity.new(PictureResource, 20)] + + related_identities = PictureResource.find_related_fragments(source_rids, 'imageable', options) + + assert_equal 2, related_identities.length + assert_equal JSONAPI::ResourceIdentity.new(ProductResource, 1), related_identities.keys[0] + assert_equal JSONAPI::ResourceIdentity.new(ProductResource, 1), related_identities.values[0][:identity] + assert_equal JSONAPI::ResourceIdentity.new(DocumentResource, 1), related_identities.keys[1] + assert_equal JSONAPI::ResourceIdentity.new(DocumentResource, 1), related_identities.values[1][:identity] + assert related_identities.values[0].is_a?(Hash) + assert_equal 2, related_identities.values[0].length + assert_equal 1, related_identities.values[0][:related][:imageable].length + assert_equal JSONAPI::ResourceIdentity.new(ProductResource, 1), related_identities.values[0][:identity] + end + + def test_find_related_polymorphic_fragments_cache_field + options = { cache: true } + source_rids = [JSONAPI::ResourceIdentity.new(PictureResource, 1), + JSONAPI::ResourceIdentity.new(PictureResource, 2), + JSONAPI::ResourceIdentity.new(PictureResource, 20)] + + related_identities = PictureResource.find_related_fragments(source_rids, 'imageable', options) + + assert_equal 2, related_identities.length + assert_equal JSONAPI::ResourceIdentity.new(ProductResource, 1), related_identities.keys[0] + assert_equal JSONAPI::ResourceIdentity.new(ProductResource, 1), related_identities.values[0][:identity] + assert_equal JSONAPI::ResourceIdentity.new(DocumentResource, 1), related_identities.keys[1] + assert_equal JSONAPI::ResourceIdentity.new(DocumentResource, 1), related_identities.values[1][:identity] + assert related_identities.values[0].is_a?(Hash) + assert_equal 3, related_identities.values[0].length + assert_equal 1, related_identities.values[0][:related][:imageable].length + assert related_identities.values[0][:cache].is_a?(ActiveSupport::TimeWithZone) + end + + def test_find_related_polymorphic_fragments_cache_field_attributes + options = { cache: true , attributes: [:name] } + source_rids = [JSONAPI::ResourceIdentity.new(PictureResource, 1), + JSONAPI::ResourceIdentity.new(PictureResource, 2), + JSONAPI::ResourceIdentity.new(PictureResource, 20)] + + related_identities = PictureResource.find_related_fragments(source_rids, 'imageable', options) + + assert_equal 2, related_identities.length + assert_equal JSONAPI::ResourceIdentity.new(ProductResource, 1), related_identities.keys[0] + assert_equal JSONAPI::ResourceIdentity.new(ProductResource, 1), related_identities.values[0][:identity] + assert_equal JSONAPI::ResourceIdentity.new(DocumentResource, 1), related_identities.keys[1] + assert_equal JSONAPI::ResourceIdentity.new(DocumentResource, 1), related_identities.values[1][:identity] + assert related_identities.values[0].is_a?(Hash) + assert_equal 4, related_identities.values[0].length + assert_equal 1, related_identities.values[0][:related][:imageable].length + assert_equal 1, related_identities.values[0][:attributes].length + assert related_identities.values[0][:cache].is_a?(ActiveSupport::TimeWithZone) + assert_equal 'Enterprise Gizmo', related_identities.values[0][:attributes][:name] + end +end diff --git a/test/unit/resource/resource_test.rb b/test/unit/resource/resource_test.rb index 1bece69dc..5e7322603 100644 --- a/test/unit/resource/resource_test.rb +++ b/test/unit/resource/resource_test.rb @@ -226,14 +226,6 @@ def test_class_relationships assert_equal(relationships.size, 2) end - def test_replace_polymorphic_to_one_link - picture_resource = PictureResource.find_by_key(Picture.first) - picture_resource.replace_polymorphic_to_one_link('imageable', '9', 'Topic') - - assert Picture.first.imageable_id == 9 - assert Picture.first.imageable_type == Document::Topic.to_s - end - def test_duplicate_relationship_name assert_output nil, "[DUPLICATE RELATIONSHIP] `mother` has already been defined in FelineResource.\n" do FelineResource.instance_eval do @@ -251,64 +243,15 @@ def test_duplicate_attribute_name end def test_find_with_customized_base_records - author = Person.find(1) + author = Person.find(1001) posts = ArticleResource.find([], context: author).map(&:_model) assert(posts.include?(Post.find(1))) refute(posts.include?(Post.find(3))) end - def test_records_for - author = Person.find(1) - preferences = Preferences.first - refute(preferences == nil) - author.update! preferences: preferences - author_resource = PersonResource.new(author, nil) - assert_equal(author_resource.preferences._model, preferences) - - author_resource = PersonWithCustomRecordsForResource.new(author, nil) - assert_equal(author_resource.preferences._model, :records_for) - - author_resource = PersonWithCustomRecordsForErrorResource.new(author, nil) - assert_raises PersonWithCustomRecordsForErrorResource::AuthorizationError do - author_resource.posts - end - end - - def test_records_for_meta_method_for_to_one - author = Person.find(1) - author.update! preferences: Preferences.first - author_resource = PersonWithCustomRecordsForRelationshipsResource.new(author, nil) - assert_equal(author_resource.class._record_accessor.records_for( - author_resource, :preferences), :record_for_preferences) - end - - def test_records_for_meta_method_for_to_one_calling_records_for - author = Person.find(1) - author.update! preferences: Preferences.first - author_resource = PersonWithCustomRecordsForResource.new(author, nil) - assert_equal(author_resource.class._record_accessor.records_for( - author_resource, :preferences), :records_for) - end - - def test_associated_records_meta_method_for_to_many - author = Person.find(1) - author.posts << Post.find(1) - author_resource = PersonWithCustomRecordsForRelationshipsResource.new(author, nil) - assert_equal(author_resource.class._record_accessor.records_for( - author_resource, :posts), :records_for_posts) - end - - def test_associated_records_meta_method_for_to_many_calling_records_for - author = Person.find(1) - author.posts << Post.find(1) - author_resource = PersonWithCustomRecordsForResource.new(author, nil) - assert_equal(author_resource.class._record_accessor.records_for( - author_resource, :posts), :records_for) - end - def test_find_by_key_with_customized_base_records - author = Person.find(1) + author = Person.find(1001) post = ArticleResource.find_by_key(1, context: author)._model assert_equal(post, Post.find(1)) @@ -344,83 +287,20 @@ def test_filter_on_has_one_relationship_id def test_to_many_relationship_filters post_resource = PostResource.new(Post.find(1), nil) - comments = post_resource.comments - assert_equal(2, comments.size) - # define apply_filters method on post resource to not respect filters - PostResource.instance_eval do - def apply_filters(records, filters, options) - # :nocov: - records - # :nocov: - end - end + comments = PostResource.find_related_fragments([post_resource.identity], :comments) + assert_equal(2, comments.size) - filtered_comments = post_resource.comments({ filters: { body: 'i liked it' } }) + filtered_comments = PostResource.find_related_fragments([post_resource.identity], :comments, { filters: { body: 'i liked it' } }) assert_equal(1, filtered_comments.size) - - ensure - # reset method to original implementation - PostResource.instance_eval do - def apply_filters(records, filters, options) - # :nocov: - required_includes = [] - - if filters - filters.each do |filter, value| - if _relationships.include?(filter) - if _relationships[filter].belongs_to? - records = apply_filter(records, _relationships[filter].foreign_key, value, options) - else - required_includes.push(filter.to_s) - records = apply_filter(records, "#{_relationships[filter].table_name}.#{_relationships[filter].primary_key}", value, options) - end - else - records = apply_filter(records, filter, value, options) - end - end - end - - if required_includes.any? - records = apply_includes(records, options.merge(include_directives: IncludeDirectives.new(self, required_includes, force_eager_load: true))) - end - - records - # :nocov: - end - end - end - - def test_custom_sorting - post_resource = PostResource.new(Post.find(1), nil) - comment_ids = post_resource.comments.map{|c| c._model.id } - assert_equal [1,2], comment_ids - - # define apply_sort method on post resource that will never sort - PostResource.instance_eval do - def apply_sort(records, criteria, context = {}) - if criteria.key?('name') - # this sort will never occure - records.order('name asc') - end - end - end - - sorted_comment_ids = post_resource.comments(sort_criteria: [{ field: 'id', direction: :desc}]).map{|c| c._model.id } - assert_equal [2,1], sorted_comment_ids - ensure - # reset method to original implementation - PostResource.instance_eval do - undef :apply_sort - end end def test_to_many_relationship_sorts post_resource = PostResource.new(Post.find(1), nil) - comment_ids = post_resource.comments.map{|c| c._model.id } + comment_ids = post_resource.class.find_related_fragments([post_resource.identity], :comments).keys.collect {|c| c.id } assert_equal [1,2], comment_ids - # define apply_sort method on post resource to sort descending + # define apply_filters method on post resource to sort descending PostResource.instance_eval do def apply_sort(records, criteria, context = {}) # :nocov: @@ -430,13 +310,36 @@ def apply_sort(records, criteria, context = {}) end end - sorted_comment_ids = post_resource.comments(sort_criteria: [{ field: 'id', direction: :desc}]).map{|c| c._model.id } + sorted_comment_ids = post_resource.class.find_related_fragments( + [post_resource.identity], + :comments, + { sort_criteria: [{ field: 'id', direction: :desc }] }).keys.collect {|c| c.id} + assert_equal [2,1], sorted_comment_ids ensure - # reset method to original implementation PostResource.instance_eval do - undef :apply_sort + 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 + field = _attribute_delegated_name(field) + records = records.order(field => direction) + end + end + end + + records + end end end @@ -459,51 +362,53 @@ def test_lookup_association_chain def test_build_joins model_names = %w(person posts parent_post author) associations = PostResource._lookup_association_chain(model_names) - result = PostResource._record_accessor._build_joins(associations) + result = PostResource.send(:_build_joins, associations) assert_equal "LEFT JOIN posts AS parent_post_sorting ON parent_post_sorting.id = posts.parent_post_id LEFT JOIN people AS author_sorting ON author_sorting.id = posts.author_id", result end - def test_to_many_relationship_pagination - post_resource = PostResource.new(Post.find(1), nil) - comments = post_resource.comments - assert_equal 2, comments.size - - # define apply_filters method on post resource to not respect filters - PostResource.instance_eval do - def apply_pagination(records, criteria, order_options) - # :nocov: - records - # :nocov: - end - end - - paginator_class = Class.new(JSONAPI::Paginator) do - def initialize(params) - # param parsing and validation here - @page = params.to_i - end - - def apply(relation, order_options) - relation.offset(@page).limit(1) - end - end - - paged_comments = post_resource.comments(paginator: paginator_class.new(1)) - assert_equal 1, paged_comments.size - - ensure - # reset method to original implementation - PostResource.instance_eval do - def apply_pagination(records, criteria, order_options) - # :nocov: - records = paginator.apply(records, order_options) if paginator - records - # :nocov: - end - end - end + # ToDo: Implement relationship pagination + # + # def test_to_many_relationship_pagination + # post_resource = PostResource.new(Post.find(1), nil) + # comments = post_resource.comments + # assert_equal 2, comments.size + # + # # define apply_filters method on post resource to not respect filters + # PostResource.instance_eval do + # def apply_pagination(records, criteria, order_options) + # # :nocov: + # records + # # :nocov: + # end + # end + # + # paginator_class = Class.new(JSONAPI::Paginator) do + # def initialize(params) + # # param parsing and validation here + # @page = params.to_i + # end + # + # def apply(relation, order_options) + # relation.offset(@page).limit(1) + # end + # end + # + # paged_comments = post_resource.comments(paginator: paginator_class.new(1)) + # assert_equal 1, paged_comments.size + # + # ensure + # # reset method to original implementation + # PostResource.instance_eval do + # def apply_pagination(records, criteria, order_options) + # # :nocov: + # records = paginator.apply(records, order_options) if paginator + # records + # # :nocov: + # end + # end + # end def test_key_type_integer FelineResource.instance_eval do @@ -583,6 +488,8 @@ def test_key_type_proc end def test_id_attr_deprecation + + ActiveSupport::Deprecation.silenced = false _out, err = capture_io do eval <<-CODE class ProblemResource < JSONAPI::Resource @@ -591,6 +498,8 @@ class ProblemResource < JSONAPI::Resource CODE end assert_match /DEPRECATION WARNING: Id without format is no longer supported. Please remove ids from attributes, or specify a format./, err + ensure + ActiveSupport::Deprecation.silenced = true end def test_id_attr_with_format diff --git a/test/unit/serializer/polymorphic_serializer_test.rb b/test/unit/serializer/polymorphic_serializer_test.rb index bb905fde8..3ba927764 100644 --- a/test/unit/serializer/polymorphic_serializer_test.rb +++ b/test/unit/serializer/polymorphic_serializer_test.rb @@ -1,482 +1,484 @@ -require File.expand_path('../../../test_helper', __FILE__) -require 'jsonapi-resources' -require 'json' +# ToDo: Revisit these tests. -class PolymorphismTest < ActionDispatch::IntegrationTest - def setup - @pictures = Picture.all - @person = Person.find(1) - - @questions = Question.all - - JSONAPI.configuration.json_key_format = :camelized_key - JSONAPI.configuration.route_format = :camelized_route - end - - def after_teardown - JSONAPI.configuration.json_key_format = :underscored_key - end - - def test_polymorphic_relationship - relationships = PictureResource._relationships - imageable = relationships[:imageable] - - assert_equal relationships.size, 1 - assert imageable.polymorphic? - end - - def test_sti_polymorphic_to_many_serialization - serialized_data = JSONAPI::ResourceSerializer.new( - PersonResource, - include: %w(vehicles) - ).serialize_to_hash(PersonResource.new(@person, nil)) - - assert_hash_equals( - { - data: { - id: '1', - type: 'people', - links: { - self: '/people/1' - }, - attributes: { - name: 'Joe Author', - email: 'joe@xyz.fake', - dateJoined: '2013-08-07 16:25:00 -0400' - }, - relationships: { - comments: { - links: { - self: '/people/1/relationships/comments', - related: '/people/1/comments' - } - }, - posts: { - links: { - self: '/people/1/relationships/posts', - related: '/people/1/posts' - } - }, - vehicles: { - links: { - self: '/people/1/relationships/vehicles', - related: '/people/1/vehicles' - }, - :data => [ - { type: 'cars', id: '1' }, - { type: 'boats', id: '2' } - ] - }, - preferences: { - links: { - self: '/people/1/relationships/preferences', - related: '/people/1/preferences' - } - }, - hairCut: { - links: { - self: '/people/1/relationships/hairCut', - related: '/people/1/hairCut' - } - } - } - }, - included: [ - { - id: '1', - type: 'cars', - links: { - self: '/cars/1' - }, - attributes: { - make: 'Mazda', - model: 'Miata MX5', - driveLayout: 'Front Engine RWD', - serialNumber: '32432adfsfdysua' - }, - relationships: { - person: { - links: { - self: '/cars/1/relationships/person', - related: '/cars/1/person' - } - } - } - }, - { - id: '2', - type: 'boats', - links: { - self: '/boats/2' - }, - attributes: { - make: 'Chris-Craft', - model: 'Launch 20', - lengthAtWaterLine: '15.5ft', - serialNumber: '434253JJJSD' - }, - relationships: { - person: { - links: { - self: '/boats/2/relationships/person', - related: '/boats/2/person' - } - } - } - } - ] - }, - serialized_data - ) - end - - def test_polymorphic_belongs_to_serialization - serialized_data = JSONAPI::ResourceSerializer.new( - PictureResource, - include: %w(imageable) - ).serialize_to_hash(@pictures.map { |p| PictureResource.new p, nil }) - - assert_hash_equals( - { - data: [ - { - id: '1', - type: 'pictures', - links: { - self: '/pictures/1' - }, - attributes: { - name: 'enterprise_gizmo.jpg' - }, - relationships: { - imageable: { - links: { - self: '/pictures/1/relationships/imageable', - related: '/pictures/1/imageable' - }, - data: { - type: 'products', - id: '1' - } - } - } - }, - { - id: '2', - type: 'pictures', - links: { - self: '/pictures/2' - }, - attributes: { - name: 'company_brochure.jpg' - }, - relationships: { - imageable: { - links: { - self: '/pictures/2/relationships/imageable', - related: '/pictures/2/imageable' - }, - data: { - type: 'documents', - id: '1' - } - } - } - }, - { - id: '3', - type: 'pictures', - links: { - self: '/pictures/3' - }, - attributes: { - name: 'group_photo.jpg' - }, - relationships: { - imageable: { - links: { - self: '/pictures/3/relationships/imageable', - related: '/pictures/3/imageable' - }, - data: nil - } - } - } - - ], - :included => [ - { - id: '1', - type: 'products', - links: { - self: '/products/1' - }, - attributes: { - name: 'Enterprise Gizmo' - }, - relationships: { - picture: { - links: { - self: '/products/1/relationships/picture', - related: '/products/1/picture', - }, - data: { - type: 'pictures', - id: '1' - } - } - } - }, - { - id: '1', - type: 'documents', - links: { - self: '/documents/1' - }, - attributes: { - name: 'Company Brochure' - }, - relationships: { - pictures: { - links: { - self: '/documents/1/relationships/pictures', - related: '/documents/1/pictures' - } - } - } - } - ] - }, - serialized_data - ) - end - - def test_polymorphic_has_one_serialization - serialized_data = JSONAPI::ResourceSerializer.new( - QuestionResource, - include: %w(respondent) - ).serialize_to_hash(@questions.map { |p| QuestionResource.new p, nil }) - - assert_hash_equals( - { - data: [ - { - id: '1', - type: 'questions', - links: { - self: '/questions/1' - }, - attributes: { - text: 'How are you feeling today?' - }, - relationships: { - answer: { - links: { - self: '/questions/1/relationships/answer', - related: '/questions/1/answer' - } - }, - respondent: { - links: { - self: '/questions/1/relationships/respondent', - related: '/questions/1/respondent' - }, - data: { - type: 'patients', - id: '1' - } - } - } - }, - { - id: '2', - type: 'questions', - links: { - self: '/questions/2' - }, - attributes: { - text: 'How does the patient look today?' - }, - relationships: { - answer: { - links: { - self: '/questions/2/relationships/answer', - related: '/questions/2/answer' - } - }, - respondent: { - links: { - self: '/questions/2/relationships/respondent', - related: '/questions/2/respondent' - }, - data: { - type: 'doctors', - id: '1' - } - } - } - } - ], - :included => [ - { - id: '1', - type: 'patients', - links: { - self: '/patients/1' - }, - attributes: { - name: 'Bob Smith' - }, - }, - { - id: '1', - type: 'doctors', - links: { - self: '/doctors/1' - }, - attributes: { - name: 'Henry Jones Jr' - }, - } - ] - }, - serialized_data - ) - end - - def test_polymorphic_get_related_resource - get '/pictures/1/imageable', headers: { 'Accept' => JSONAPI::MEDIA_TYPE } - serialized_data = JSON.parse(response.body) - assert_hash_equals( - { - data: { - id: '1', - type: 'products', - links: { - self: 'http://www.example.com/products/1' - }, - attributes: { - name: 'Enterprise Gizmo' - }, - relationships: { - picture: { - links: { - self: 'http://www.example.com/products/1/relationships/picture', - related: 'http://www.example.com/products/1/picture' - }, - data: { - type: 'pictures', - id: '1' - } - } - } - } - }, - serialized_data - ) - end - - def test_create_resource_with_polymorphic_relationship - document = Document.find(1) - post "/pictures/", params: - { - data: { - type: "pictures", - attributes: { - name: "hello.jpg" - }, - relationships: { - imageable: { - data: { - type: "documents", - id: document.id.to_s - } - } - } - } - }.to_json, - headers: { - 'Content-Type' => JSONAPI::MEDIA_TYPE, - 'Accept' => JSONAPI::MEDIA_TYPE - } - assert_equal 201, response.status - picture = Picture.find(json_response["data"]["id"]) - assert_not_nil picture.imageable, "imageable should be present" - ensure - picture.destroy if picture - end - - def test_polymorphic_create_relationship - picture = Picture.find(3) - original_imageable = picture.imageable - assert_nil original_imageable - - patch "/pictures/#{picture.id}/relationships/imageable", params: - { - relationship: 'imageable', - data: { - type: 'documents', - id: '1' - } - }.to_json, - headers: { - 'Content-Type' => JSONAPI::MEDIA_TYPE, - 'Accept' => JSONAPI::MEDIA_TYPE - } - assert_response :no_content - picture = Picture.find(3) - assert_equal 'Document', picture.imageable.class.to_s - - # restore data - picture.imageable = original_imageable - picture.save - end - - def test_polymorphic_update_relationship - picture = Picture.find(1) - original_imageable = picture.imageable - assert_not_equal 'Document', picture.imageable.class.to_s - - patch "/pictures/#{picture.id}/relationships/imageable", params: - { - relationship: 'imageable', - data: { - type: 'documents', - id: '1' - } - }.to_json, - headers: { - 'Content-Type' => JSONAPI::MEDIA_TYPE, - 'Accept' => JSONAPI::MEDIA_TYPE - } - assert_response :no_content - picture = Picture.find(1) - assert_equal 'Document', picture.imageable.class.to_s - - # restore data - picture.imageable = original_imageable - picture.save - end - - def test_polymorphic_delete_relationship - picture = Picture.find(1) - original_imageable = picture.imageable - assert original_imageable - - delete "/pictures/#{picture.id}/relationships/imageable", params: - { - relationship: 'imageable' - }.to_json, - headers: { - 'Content-Type' => JSONAPI::MEDIA_TYPE, - 'Accept' => JSONAPI::MEDIA_TYPE - } - assert_response :no_content - picture = Picture.find(1) - assert_nil picture.imageable - - # restore data - picture.imageable = original_imageable - picture.save - end -end +# require File.expand_path('../../../test_helper', __FILE__) +# require 'jsonapi-resources' +# require 'json' +# +# class PolymorphismTest < ActionDispatch::IntegrationTest +# def setup +# @pictures = Picture.all +# @person = Person.find(1) +# +# @questions = Question.all +# +# JSONAPI.configuration.json_key_format = :camelized_key +# JSONAPI.configuration.route_format = :camelized_route +# end +# +# def after_teardown +# JSONAPI.configuration.json_key_format = :underscored_key +# end +# +# def test_polymorphic_relationship +# relationships = PictureResource._relationships +# imageable = relationships[:imageable] +# +# assert_equal relationships.size, 1 +# assert imageable.polymorphic? +# end +# +# def test_sti_polymorphic_to_many_serialization +# serialized_data = JSONAPI::ResourceSerializer.new( +# PersonResource, +# include: %w(vehicles) +# ).serialize_to_hash(PersonResource.new(@person, nil)) +# +# assert_hash_equals( +# { +# data: { +# id: '1', +# type: 'people', +# links: { +# self: '/people/1' +# }, +# attributes: { +# name: 'Joe Author', +# email: 'joe@xyz.fake', +# dateJoined: '2013-08-07 16:25:00 -0400' +# }, +# relationships: { +# comments: { +# links: { +# self: '/people/1/relationships/comments', +# related: '/people/1/comments' +# } +# }, +# posts: { +# links: { +# self: '/people/1/relationships/posts', +# related: '/people/1/posts' +# } +# }, +# vehicles: { +# links: { +# self: '/people/1/relationships/vehicles', +# related: '/people/1/vehicles' +# }, +# :data => [ +# { type: 'cars', id: '1' }, +# { type: 'boats', id: '2' } +# ] +# }, +# preferences: { +# links: { +# self: '/people/1/relationships/preferences', +# related: '/people/1/preferences' +# } +# }, +# hairCut: { +# links: { +# self: '/people/1/relationships/hairCut', +# related: '/people/1/hairCut' +# } +# } +# } +# }, +# included: [ +# { +# id: '1', +# type: 'cars', +# links: { +# self: '/cars/1' +# }, +# attributes: { +# make: 'Mazda', +# model: 'Miata MX5', +# driveLayout: 'Front Engine RWD', +# serialNumber: '32432adfsfdysua' +# }, +# relationships: { +# person: { +# links: { +# self: '/cars/1/relationships/person', +# related: '/cars/1/person' +# } +# } +# } +# }, +# { +# id: '2', +# type: 'boats', +# links: { +# self: '/boats/2' +# }, +# attributes: { +# make: 'Chris-Craft', +# model: 'Launch 20', +# lengthAtWaterLine: '15.5ft', +# serialNumber: '434253JJJSD' +# }, +# relationships: { +# person: { +# links: { +# self: '/boats/2/relationships/person', +# related: '/boats/2/person' +# } +# } +# } +# } +# ] +# }, +# serialized_data +# ) +# end +# +# def test_polymorphic_belongs_to_serialization +# serialized_data = JSONAPI::ResourceSerializer.new( +# PictureResource, +# include: %w(imageable) +# ).serialize_to_hash(@pictures.map { |p| PictureResource.new p, nil }) +# +# assert_hash_equals( +# { +# data: [ +# { +# id: '1', +# type: 'pictures', +# links: { +# self: '/pictures/1' +# }, +# attributes: { +# name: 'enterprise_gizmo.jpg' +# }, +# relationships: { +# imageable: { +# links: { +# self: '/pictures/1/relationships/imageable', +# related: '/pictures/1/imageable' +# }, +# data: { +# type: 'products', +# id: '1' +# } +# } +# } +# }, +# { +# id: '2', +# type: 'pictures', +# links: { +# self: '/pictures/2' +# }, +# attributes: { +# name: 'company_brochure.jpg' +# }, +# relationships: { +# imageable: { +# links: { +# self: '/pictures/2/relationships/imageable', +# related: '/pictures/2/imageable' +# }, +# data: { +# type: 'documents', +# id: '1' +# } +# } +# } +# }, +# { +# id: '3', +# type: 'pictures', +# links: { +# self: '/pictures/3' +# }, +# attributes: { +# name: 'group_photo.jpg' +# }, +# relationships: { +# imageable: { +# links: { +# self: '/pictures/3/relationships/imageable', +# related: '/pictures/3/imageable' +# }, +# data: nil +# } +# } +# } +# +# ], +# :included => [ +# { +# id: '1', +# type: 'products', +# links: { +# self: '/products/1' +# }, +# attributes: { +# name: 'Enterprise Gizmo' +# }, +# relationships: { +# picture: { +# links: { +# self: '/products/1/relationships/picture', +# related: '/products/1/picture', +# }, +# data: { +# type: 'pictures', +# id: '1' +# } +# } +# } +# }, +# { +# id: '1', +# type: 'documents', +# links: { +# self: '/documents/1' +# }, +# attributes: { +# name: 'Company Brochure' +# }, +# relationships: { +# pictures: { +# links: { +# self: '/documents/1/relationships/pictures', +# related: '/documents/1/pictures' +# } +# } +# } +# } +# ] +# }, +# serialized_data +# ) +# end +# +# def test_polymorphic_has_one_serialization +# serialized_data = JSONAPI::ResourceSerializer.new( +# QuestionResource, +# include: %w(respondent) +# ).serialize_to_hash(@questions.map { |p| QuestionResource.new p, nil }) +# +# assert_hash_equals( +# { +# data: [ +# { +# id: '1', +# type: 'questions', +# links: { +# self: '/questions/1' +# }, +# attributes: { +# text: 'How are you feeling today?' +# }, +# relationships: { +# answer: { +# links: { +# self: '/questions/1/relationships/answer', +# related: '/questions/1/answer' +# } +# }, +# respondent: { +# links: { +# self: '/questions/1/relationships/respondent', +# related: '/questions/1/respondent' +# }, +# data: { +# type: 'patients', +# id: '1' +# } +# } +# } +# }, +# { +# id: '2', +# type: 'questions', +# links: { +# self: '/questions/2' +# }, +# attributes: { +# text: 'How does the patient look today?' +# }, +# relationships: { +# answer: { +# links: { +# self: '/questions/2/relationships/answer', +# related: '/questions/2/answer' +# } +# }, +# respondent: { +# links: { +# self: '/questions/2/relationships/respondent', +# related: '/questions/2/respondent' +# }, +# data: { +# type: 'doctors', +# id: '1' +# } +# } +# } +# } +# ], +# :included => [ +# { +# id: '1', +# type: 'patients', +# links: { +# self: '/patients/1' +# }, +# attributes: { +# name: 'Bob Smith' +# }, +# }, +# { +# id: '1', +# type: 'doctors', +# links: { +# self: '/doctors/1' +# }, +# attributes: { +# name: 'Henry Jones Jr' +# }, +# } +# ] +# }, +# serialized_data +# ) +# end +# +# def test_polymorphic_get_related_resource +# get '/pictures/1/imageable', headers: { 'Accept' => JSONAPI::MEDIA_TYPE } +# serialized_data = JSON.parse(response.body) +# assert_hash_equals( +# { +# data: { +# id: '1', +# type: 'products', +# links: { +# self: 'http://www.example.com/products/1' +# }, +# attributes: { +# name: 'Enterprise Gizmo' +# }, +# relationships: { +# picture: { +# links: { +# self: 'http://www.example.com/products/1/relationships/picture', +# related: 'http://www.example.com/products/1/picture' +# }, +# data: { +# type: 'pictures', +# id: '1' +# } +# } +# } +# } +# }, +# serialized_data +# ) +# end +# +# def test_create_resource_with_polymorphic_relationship +# document = Document.find(1) +# post "/pictures/", params: +# { +# data: { +# type: "pictures", +# attributes: { +# name: "hello.jpg" +# }, +# relationships: { +# imageable: { +# data: { +# type: "documents", +# id: document.id.to_s +# } +# } +# } +# } +# }.to_json, +# headers: { +# 'Content-Type' => JSONAPI::MEDIA_TYPE, +# 'Accept' => JSONAPI::MEDIA_TYPE +# } +# assert_equal 201, response.status +# picture = Picture.find(json_response["data"]["id"]) +# assert_not_nil picture.imageable, "imageable should be present" +# ensure +# picture.destroy if picture +# end +# +# def test_polymorphic_create_relationship +# picture = Picture.find(3) +# original_imageable = picture.imageable +# assert_nil original_imageable +# +# patch "/pictures/#{picture.id}/relationships/imageable", params: +# { +# relationship: 'imageable', +# data: { +# type: 'documents', +# id: '1' +# } +# }.to_json, +# headers: { +# 'Content-Type' => JSONAPI::MEDIA_TYPE, +# 'Accept' => JSONAPI::MEDIA_TYPE +# } +# assert_response :no_content +# picture = Picture.find(3) +# assert_equal 'Document', picture.imageable.class.to_s +# +# # restore data +# picture.imageable = original_imageable +# picture.save +# end +# +# def test_polymorphic_update_relationship +# picture = Picture.find(1) +# original_imageable = picture.imageable +# assert_not_equal 'Document', picture.imageable.class.to_s +# +# patch "/pictures/#{picture.id}/relationships/imageable", params: +# { +# relationship: 'imageable', +# data: { +# type: 'documents', +# id: '1' +# } +# }.to_json, +# headers: { +# 'Content-Type' => JSONAPI::MEDIA_TYPE, +# 'Accept' => JSONAPI::MEDIA_TYPE +# } +# assert_response :no_content +# picture = Picture.find(1) +# assert_equal 'Document', picture.imageable.class.to_s +# +# # restore data +# picture.imageable = original_imageable +# picture.save +# end +# +# def test_polymorphic_delete_relationship +# picture = Picture.find(1) +# original_imageable = picture.imageable +# assert original_imageable +# +# delete "/pictures/#{picture.id}/relationships/imageable", params: +# { +# relationship: 'imageable' +# }.to_json, +# headers: { +# 'Content-Type' => JSONAPI::MEDIA_TYPE, +# 'Accept' => JSONAPI::MEDIA_TYPE +# } +# assert_response :no_content +# picture = Picture.find(1) +# assert_nil picture.imageable +# +# # restore data +# picture.imageable = original_imageable +# picture.save +# end +# end diff --git a/test/unit/serializer/serializer_test.rb b/test/unit/serializer/serializer_test.rb index 163c39cad..e775735b6 100644 --- a/test/unit/serializer/serializer_test.rb +++ b/test/unit/serializer/serializer_test.rb @@ -1,2411 +1,2419 @@ -require File.expand_path('../../../test_helper', __FILE__) -require 'jsonapi-resources' -require 'json' - -class SerializerTest < ActionDispatch::IntegrationTest - def setup - @post = Post.find(1) - @fred = Person.find_by(name: 'Fred Reader') - - @expense_entry = ExpenseEntry.find(1) - - JSONAPI.configuration.json_key_format = :camelized_key - JSONAPI.configuration.route_format = :camelized_route - JSONAPI.configuration.always_include_to_one_linkage_data = false - end - - def after_teardown - JSONAPI.configuration.always_include_to_one_linkage_data = false - JSONAPI.configuration.json_key_format = :underscored_key - end - - def test_serializer - - serialized = JSONAPI::ResourceSerializer.new( - PostResource, - base_url: 'http://example.com').serialize_to_hash(PostResource.new(@post, nil) - ) - - assert_hash_equals( - { - data: { - type: 'posts', - id: '1', - links: { - self: 'http://example.com/posts/1', - }, - attributes: { - title: 'New post', - body: 'A body!!!', - subject: 'New post' - }, - relationships: { - section: { - links: { - self: 'http://example.com/posts/1/relationships/section', - related: 'http://example.com/posts/1/section' - } - }, - author: { - links: { - self: 'http://example.com/posts/1/relationships/author', - related: 'http://example.com/posts/1/author' - } - }, - tags: { - links: { - self: 'http://example.com/posts/1/relationships/tags', - related: 'http://example.com/posts/1/tags' - } - }, - comments: { - links: { - self: 'http://example.com/posts/1/relationships/comments', - related: 'http://example.com/posts/1/comments' - } - } - } - } - }, - serialized - ) - end - - def test_serializer_nil_handling - assert_hash_equals( - { - data: nil - }, - JSONAPI::ResourceSerializer.new(PostResource).serialize_to_hash(nil) - ) - end - - def test_serializer_namespaced_resource - assert_hash_equals( - { - data: { - type: 'posts', - id: '1', - links: { - self: 'http://example.com/api/v1/posts/1' - }, - attributes: { - title: 'New post', - body: 'A body!!!', - subject: 'New post' - }, - relationships: { - section: { - links:{ - self: 'http://example.com/api/v1/posts/1/relationships/section', - related: 'http://example.com/api/v1/posts/1/section' - } - }, - writer: { - links:{ - self: 'http://example.com/api/v1/posts/1/relationships/writer', - related: 'http://example.com/api/v1/posts/1/writer' - } - }, - comments: { - links:{ - self: 'http://example.com/api/v1/posts/1/relationships/comments', - related: 'http://example.com/api/v1/posts/1/comments' - } - } - } - } - }, - JSONAPI::ResourceSerializer.new(Api::V1::PostResource, - base_url: 'http://example.com').serialize_to_hash( - Api::V1::PostResource.new(@post, nil)) - ) - end - - def test_serializer_limited_fieldset - - assert_hash_equals( - { - data: { - type: 'posts', - id: '1', - links: { - self: '/posts/1' - }, - attributes: { - title: 'New post' - }, - relationships: { - author: { - links: { - self: '/posts/1/relationships/author', - related: '/posts/1/author' - } - } - } - } - }, - JSONAPI::ResourceSerializer.new(PostResource, - fields: {posts: [:id, :title, :author]}).serialize_to_hash(PostResource.new(@post, nil)) - ) - end - - def test_serializer_include - serialized = JSONAPI::ResourceSerializer.new( - PostResource, - include: ['author'] - ).serialize_to_hash(PostResource.new(@post, nil)) - - assert_hash_equals( - { - data: { - type: 'posts', - id: '1', - links: { - self: '/posts/1' - }, - attributes: { - title: 'New post', - body: 'A body!!!', - subject: 'New post' - }, - relationships: { - section: { - links: { - self: '/posts/1/relationships/section', - related: '/posts/1/section' - } - }, - author: { - links: { - self: '/posts/1/relationships/author', - related: '/posts/1/author' - }, - data: { - type: 'people', - id: '1' - } - }, - tags: { - links: { - self: '/posts/1/relationships/tags', - related: '/posts/1/tags' - } - }, - comments: { - links: { - self: '/posts/1/relationships/comments', - related: '/posts/1/comments' - } - } - } - }, - included: [ - { - type: 'people', - id: '1', - attributes: { - name: 'Joe Author', - email: 'joe@xyz.fake', - dateJoined: '2013-08-07 16:25:00 -0400' - }, - links: { - self: '/people/1' - }, - relationships: { - comments: { - links: { - self: '/people/1/relationships/comments', - related: '/people/1/comments' - } - }, - posts: { - links: { - self: '/people/1/relationships/posts', - related: '/people/1/posts' - } - }, - preferences: { - links: { - self: '/people/1/relationships/preferences', - related: '/people/1/preferences' - } - }, - hairCut: { - links: { - self: "/people/1/relationships/hairCut", - related: "/people/1/hairCut" - } - }, - vehicles: { - links: { - self: "/people/1/relationships/vehicles", - related: "/people/1/vehicles" - } - } - } - } - ] - }, - serialized - ) - end - - def test_serializer_key_format - serialized = JSONAPI::ResourceSerializer.new( - PostResource, - include: ['author'], - key_formatter: UnderscoredKeyFormatter - ).serialize_to_hash(PostResource.new(@post, nil)) - - assert_hash_equals( - { - data: { - type: 'posts', - id: '1', - attributes: { - title: 'New post', - body: 'A body!!!', - subject: 'New post' - }, - links: { - self: '/posts/1' - }, - relationships: { - section: { - links: { - self: '/posts/1/relationships/section', - related: '/posts/1/section' - } - }, - author: { - links: { - self: '/posts/1/relationships/author', - related: '/posts/1/author' - }, - data: { - type: 'people', - id: '1' - } - }, - tags: { - links: { - self: '/posts/1/relationships/tags', - related: '/posts/1/tags' - } - }, - comments: { - links: { - self: '/posts/1/relationships/comments', - related: '/posts/1/comments' - } - } - } - }, - included: [ - { - type: 'people', - id: '1', - attributes: { - name: 'Joe Author', - email: 'joe@xyz.fake', - date_joined: '2013-08-07 16:25:00 -0400' - }, - links: { - self: '/people/1' - }, - relationships: { - comments: { - links: { - self: '/people/1/relationships/comments', - related: '/people/1/comments' - } - }, - posts: { - links: { - self: '/people/1/relationships/posts', - related: '/people/1/posts' - } - }, - preferences: { - links: { - self: '/people/1/relationships/preferences', - related: '/people/1/preferences' - } - }, - hair_cut: { - links: { - self: '/people/1/relationships/hairCut', - related: '/people/1/hairCut' - } - }, - vehicles: { - links: { - self: "/people/1/relationships/vehicles", - related: "/people/1/vehicles" - } - } - } - } - ] - }, - serialized - ) - end - - def test_serializer_include_sub_objects - - assert_hash_equals( - { - data: { - type: 'posts', - id: '1', - attributes: { - title: 'New post', - body: 'A body!!!', - subject: 'New post' - }, - links: { - self: '/posts/1' - }, - relationships: { - section: { - links: { - self: '/posts/1/relationships/section', - related: '/posts/1/section' - } - }, - author: { - links: { - self: '/posts/1/relationships/author', - related: '/posts/1/author' - } - }, - tags: { - links: { - self: '/posts/1/relationships/tags', - related: '/posts/1/tags' - } - }, - comments: { - links: { - self: '/posts/1/relationships/comments', - related: '/posts/1/comments' - }, - data: [ - {type: 'comments', id: '1'}, - {type: 'comments', id: '2'} - ] - } - } - }, - included: [ - { - type: 'tags', - id: '1', - attributes: { - name: 'short' - }, - links: { - self: '/tags/1' - }, - relationships: { - posts: { - links: { - self: '/tags/1/relationships/posts', - related: '/tags/1/posts' - } - } - } - }, - { - type: 'tags', - id: '2', - attributes: { - name: 'whiny' - }, - links: { - self: '/tags/2' - }, - relationships: { - posts: { - links: { - self: '/tags/2/relationships/posts', - related: '/tags/2/posts' - } - } - } - }, - { - type: 'tags', - id: '4', - attributes: { - name: 'happy' - }, - links: { - self: '/tags/4' - }, - relationships: { - posts: { - links: { - self: '/tags/4/relationships/posts', - related: '/tags/4/posts' - }, - } - } - }, - { - type: 'comments', - id: '1', - attributes: { - body: 'what a dumb post' - }, - links: { - self: '/comments/1' - }, - relationships: { - author: { - links: { - self: '/comments/1/relationships/author', - related: '/comments/1/author' - } - }, - post: { - links: { - self: '/comments/1/relationships/post', - related: '/comments/1/post' - } - }, - tags: { - links: { - self: '/comments/1/relationships/tags', - related: '/comments/1/tags' - }, - data: [ - {type: 'tags', id: '1'}, - {type: 'tags', id: '2'} - ] - } - } - }, - { - type: 'comments', - id: '2', - attributes: { - body: 'i liked it' - }, - links: { - self: '/comments/2' - }, - relationships: { - author: { - links: { - self: '/comments/2/relationships/author', - related: '/comments/2/author' - } - }, - post: { - links: { - self: '/comments/2/relationships/post', - related: '/comments/2/post' - } - }, - tags: { - links: { - self: '/comments/2/relationships/tags', - related: '/comments/2/tags' - }, - data: [ - {type: 'tags', id: '1'}, - {type: 'tags', id: '4'} - ] - } - } - } - ] - }, - JSONAPI::ResourceSerializer.new(PostResource, - include: ['comments', 'comments.tags']).serialize_to_hash(PostResource.new(@post, nil)) - ) - end - - def test_serializer_keeps_sorted_order_of_objects_with_self_referential_relationships - post1, post2, post3 = Post.find(1), Post.find(2), Post.find(3) - post1.parent_post = post3 - ordered_posts = [post1, post2, post3] - serialized_data = JSONAPI::ResourceSerializer.new( - ParentApi::PostResource, - include: ['parent_post'], - base_url: 'http://example.com').serialize_to_hash(ordered_posts.map {|p| ParentApi::PostResource.new(p, nil)} - )['data'] - - assert_equal(3, serialized_data.length) - assert_equal("1", serialized_data[0]["id"]) - assert_equal("2", serialized_data[1]["id"]) - assert_equal("3", serialized_data[2]["id"]) - end - - - def test_serializer_different_foreign_key - serialized = JSONAPI::ResourceSerializer.new( - PersonResource, - include: ['comments'] - ).serialize_to_hash(PersonResource.new(@fred, nil)) - - assert_hash_equals( - { - data: { - type: 'people', - id: '2', - attributes: { - name: 'Fred Reader', - email: 'fred@xyz.fake', - dateJoined: '2013-10-31 16:25:00 -0400' - }, - links: { - self: '/people/2' - }, - relationships: { - posts: { - links: { - self: '/people/2/relationships/posts', - related: '/people/2/posts' - } - }, - comments: { - links: { - self: '/people/2/relationships/comments', - related: '/people/2/comments' - }, - data: [ - {type: 'comments', id: '2'}, - {type: 'comments', id: '3'} - ] - }, - preferences: { - links: { - self: "/people/2/relationships/preferences", - related: "/people/2/preferences" - } - }, - hairCut: { - links: { - self: "/people/2/relationships/hairCut", - related: "/people/2/hairCut" - } - }, - vehicles: { - links: { - self: "/people/2/relationships/vehicles", - related: "/people/2/vehicles" - } - }, - } - }, - included: [ - { - type: 'comments', - id: '2', - attributes: { - body: 'i liked it' - }, - links: { - self: '/comments/2' - }, - relationships: { - author: { - links: { - self: '/comments/2/relationships/author', - related: '/comments/2/author' - } - }, - post: { - links: { - self: '/comments/2/relationships/post', - related: '/comments/2/post' - } - }, - tags: { - links: { - self: '/comments/2/relationships/tags', - related: '/comments/2/tags' - } - } - } - }, - { - type: 'comments', - id: '3', - attributes: { - body: 'Thanks man. Great post. But what is JR?' - }, - links: { - self: '/comments/3' - }, - relationships: { - author: { - links: { - self: '/comments/3/relationships/author', - related: '/comments/3/author' - } - }, - post: { - links: { - self: '/comments/3/relationships/post', - related: '/comments/3/post' - } - }, - tags: { - links: { - self: '/comments/3/relationships/tags', - related: '/comments/3/tags' - } - } - } - } - ] - }, - serialized - ) - end - - def test_serializer_array_of_resources_always_include_to_one_linkage_data - - posts = [] - Post.find(1, 2).each do |post| - posts.push PostResource.new(post, nil) - end - - JSONAPI.configuration.always_include_to_one_linkage_data = true - - assert_hash_equals( - { - data: [ - { - type: 'posts', - id: '1', - attributes: { - title: 'New post', - body: 'A body!!!', - subject: 'New post' - }, - links: { - self: '/posts/1' - }, - relationships: { - section: { - links: { - self: '/posts/1/relationships/section', - related: '/posts/1/section' - }, - data: nil - }, - author: { - links: { - self: '/posts/1/relationships/author', - related: '/posts/1/author' - }, - data: { - type: 'people', - id: '1' - } - }, - tags: { - links: { - self: '/posts/1/relationships/tags', - related: '/posts/1/tags' - } - }, - comments: { - links: { - self: '/posts/1/relationships/comments', - related: '/posts/1/comments' - }, - data: [ - {type: 'comments', id: '1'}, - {type: 'comments', id: '2'} - ] - } - } - }, - { - type: 'posts', - id: '2', - attributes: { - title: 'JR Solves your serialization woes!', - body: 'Use JR', - subject: 'JR Solves your serialization woes!' - }, - links: { - self: '/posts/2' - }, - relationships: { - section: { - links: { - self: '/posts/2/relationships/section', - related: '/posts/2/section' - }, - data: { - type: 'sections', - id: '2' - } - }, - author: { - links: { - self: '/posts/2/relationships/author', - related: '/posts/2/author' - }, - data: { - type: 'people', - id: '1' - } - }, - tags: { - links: { - self: '/posts/2/relationships/tags', - related: '/posts/2/tags' - } - }, - comments: { - links: { - self: '/posts/2/relationships/comments', - related: '/posts/2/comments' - }, - data: [ - {type: 'comments', id: '3'} - ] - } - } - } - ], - included: [ - { - type: 'tags', - id: '1', - attributes: { - name: 'short' - }, - links: { - self: '/tags/1' - }, - relationships: { - posts: { - links: { - self: '/tags/1/relationships/posts', - related: '/tags/1/posts' - } - } - } - }, - { - type: 'tags', - id: '2', - attributes: { - name: 'whiny' - }, - links: { - self: '/tags/2' - }, - relationships: { - posts: { - links: { - self: '/tags/2/relationships/posts', - related: '/tags/2/posts' - } - } - } - }, - { - type: 'tags', - id: '4', - attributes: { - name: 'happy' - }, - links: { - self: '/tags/4' - }, - relationships: { - posts: { - links: { - self: '/tags/4/relationships/posts', - related: '/tags/4/posts' - } - } - } - }, - { - type: 'tags', - id: '5', - attributes: { - name: 'JR' - }, - links: { - self: '/tags/5' - }, - relationships: { - posts: { - links: { - self: '/tags/5/relationships/posts', - related: '/tags/5/posts' - } - } - } - }, - { - type: 'comments', - id: '1', - attributes: { - body: 'what a dumb post' - }, - links: { - self: '/comments/1' - }, - relationships: { - author: { - links: { - self: '/comments/1/relationships/author', - related: '/comments/1/author' - }, - data: { - type: 'people', - id: '1' - } - }, - post: { - links: { - self: '/comments/1/relationships/post', - related: '/comments/1/post' - }, - data: { - type: 'posts', - id: '1' - } - }, - tags: { - links: { - self: '/comments/1/relationships/tags', - related: '/comments/1/tags' - }, - data: [ - {type: 'tags', id: '1'}, - {type: 'tags', id: '2'} - ] - } - } - }, - { - type: 'comments', - id: '2', - attributes: { - body: 'i liked it' - }, - links: { - self: '/comments/2' - }, - relationships: { - author: { - links: { - self: '/comments/2/relationships/author', - related: '/comments/2/author' - }, - data: { - type: 'people', - id: '2' - } - }, - post: { - links: { - self: '/comments/2/relationships/post', - related: '/comments/2/post' - }, - data: { - type: 'posts', - id: '1' - } - }, - tags: { - links: { - self: '/comments/2/relationships/tags', - related: '/comments/2/tags' - }, - data: [ - {type: 'tags', id: '4'}, - {type: 'tags', id: '1'} - ] - } - } - }, - { - type: 'comments', - id: '3', - attributes: { - body: 'Thanks man. Great post. But what is JR?' - }, - links: { - self: '/comments/3' - }, - relationships: { - author: { - links: { - self: '/comments/3/relationships/author', - related: '/comments/3/author' - }, - data: { - type: 'people', - id: '2' - } - }, - post: { - links: { - self: '/comments/3/relationships/post', - related: '/comments/3/post' - }, - data: { - type: 'posts', - id: '2' - } - }, - tags: { - links: { - self: '/comments/3/relationships/tags', - related: '/comments/3/tags' - }, - data: [ - {type: 'tags', id: '5'} - ] - } - } - } - ] - }, - JSONAPI::ResourceSerializer.new(PostResource, - include: ['comments', 'comments.tags']).serialize_to_hash(posts) - ) - ensure - JSONAPI.configuration.always_include_to_one_linkage_data = false - end - - def test_serializer_always_include_to_one_linkage_data_does_not_load_association - JSONAPI.configuration.always_include_to_one_linkage_data = true - - post = Post.find(1) - resource = Api::V1::PostResource.new(post, nil) - JSONAPI::ResourceSerializer.new(Api::V1::PostResource).serialize_to_hash(resource) - - refute_predicate post.association(:writer), :loaded? - ensure - JSONAPI.configuration.always_include_to_one_linkage_data = false - end - - def test_serializer_array_of_resources - - posts = [] - Post.find(1, 2).each do |post| - posts.push PostResource.new(post, nil) - end - - assert_hash_equals( - { - data: [ - { - type: 'posts', - id: '1', - attributes: { - title: 'New post', - body: 'A body!!!', - subject: 'New post' - }, - links: { - self: '/posts/1' - }, - relationships: { - section: { - links: { - self: '/posts/1/relationships/section', - related: '/posts/1/section' - } - }, - author: { - links: { - self: '/posts/1/relationships/author', - related: '/posts/1/author' - } - }, - tags: { - links: { - self: '/posts/1/relationships/tags', - related: '/posts/1/tags' - } - }, - comments: { - links: { - self: '/posts/1/relationships/comments', - related: '/posts/1/comments' - }, - data: [ - {type: 'comments', id: '1'}, - {type: 'comments', id: '2'} - ] - } - } - }, - { - type: 'posts', - id: '2', - attributes: { - title: 'JR Solves your serialization woes!', - body: 'Use JR', - subject: 'JR Solves your serialization woes!' - }, - links: { - self: '/posts/2' - }, - relationships: { - section: { - links: { - self: '/posts/2/relationships/section', - related: '/posts/2/section' - } - }, - author: { - links: { - self: '/posts/2/relationships/author', - related: '/posts/2/author' - } - }, - tags: { - links: { - self: '/posts/2/relationships/tags', - related: '/posts/2/tags' - } - }, - comments: { - links: { - self: '/posts/2/relationships/comments', - related: '/posts/2/comments' - }, - data: [ - {type: 'comments', id: '3'} - ] - } - } - } - ], - included: [ - { - type: 'tags', - id: '1', - attributes: { - name: 'short' - }, - links: { - self: '/tags/1' - }, - relationships: { - posts: { - links: { - self: '/tags/1/relationships/posts', - related: '/tags/1/posts' - } - } - } - }, - { - type: 'tags', - id: '2', - attributes: { - name: 'whiny' - }, - links: { - self: '/tags/2' - }, - relationships: { - posts: { - links: { - self: '/tags/2/relationships/posts', - related: '/tags/2/posts' - } - } - } - }, - { - type: 'tags', - id: '4', - attributes: { - name: 'happy' - }, - links: { - self: '/tags/4' - }, - relationships: { - posts: { - links: { - self: '/tags/4/relationships/posts', - related: '/tags/4/posts' - } - } - } - }, - { - type: 'tags', - id: '5', - attributes: { - name: 'JR' - }, - links: { - self: '/tags/5' - }, - relationships: { - posts: { - links: { - self: '/tags/5/relationships/posts', - related: '/tags/5/posts' - } - } - } - }, - { - type: 'comments', - id: '1', - attributes: { - body: 'what a dumb post' - }, - links: { - self: '/comments/1' - }, - relationships: { - author: { - links: { - self: '/comments/1/relationships/author', - related: '/comments/1/author' - } - }, - post: { - links: { - self: '/comments/1/relationships/post', - related: '/comments/1/post' - } - }, - tags: { - links: { - self: '/comments/1/relationships/tags', - related: '/comments/1/tags' - }, - data: [ - {type: 'tags', id: '1'}, - {type: 'tags', id: '2'} - ] - } - } - }, - { - type: 'comments', - id: '2', - attributes: { - body: 'i liked it' - }, - links: { - self: '/comments/2' - }, - relationships: { - author: { - links: { - self: '/comments/2/relationships/author', - related: '/comments/2/author' - } - }, - post: { - links: { - self: '/comments/2/relationships/post', - related: '/comments/2/post' - } - }, - tags: { - links: { - self: '/comments/2/relationships/tags', - related: '/comments/2/tags' - }, - data: [ - {type: 'tags', id: '4'}, - {type: 'tags', id: '1'} - ] - } - } - }, - { - type: 'comments', - id: '3', - attributes: { - body: 'Thanks man. Great post. But what is JR?' - }, - links: { - self: '/comments/3' - }, - relationships: { - author: { - links: { - self: '/comments/3/relationships/author', - related: '/comments/3/author' - } - }, - post: { - links: { - self: '/comments/3/relationships/post', - related: '/comments/3/post' - } - }, - tags: { - links: { - self: '/comments/3/relationships/tags', - related: '/comments/3/tags' - }, - data: [ - {type: 'tags', id: '5'} - ] - } - } - } - ] - }, - JSONAPI::ResourceSerializer.new(PostResource, - include: ['comments', 'comments.tags']).serialize_to_hash(posts) - ) - end - - def test_serializer_array_of_resources_limited_fields - - posts = [] - Post.find(1, 2).each do |post| - posts.push PostResource.new(post, nil) - end - - assert_hash_equals( - { - data: [ - { - type: 'posts', - id: '1', - attributes: { - title: 'New post' - }, - links: { - self: '/posts/1' - } - }, - { - type: 'posts', - id: '2', - attributes: { - title: 'JR Solves your serialization woes!' - }, - links: { - self: '/posts/2' - } - } - ], - included: [ - { - type: 'posts', - id: '11', - attributes: { - title: 'JR How To' - }, - links: { - self: '/posts/11' - } - }, - { - type: 'people', - id: '1', - attributes: { - email: 'joe@xyz.fake' - }, - links: { - self: '/people/1' - }, - relationships: { - comments: { - links: { - self: '/people/1/relationships/comments', - related: '/people/1/comments' - } - } - } - }, - { - id: '1', - type: 'tags', - attributes: { - name: 'short' - }, - links: { - self: '/tags/1' - } - }, - { - id: '2', - type: 'tags', - attributes: { - name: 'whiny' - }, - links: { - self: '/tags/2' - } - }, - { - id: '4', - type: 'tags', - attributes: { - name: 'happy' - }, - links: { - self: '/tags/4' - } - }, - { - id: '5', - type: 'tags', - attributes: { - name: 'JR' - }, - links: { - self: '/tags/5' - } - }, - { - type: 'comments', - id: '1', - attributes: { - body: 'what a dumb post' - }, - links: { - self: '/comments/1' - }, - relationships: { - post: { - links: { - self: '/comments/1/relationships/post', - related: '/comments/1/post' - } - } - } - }, - { - type: 'comments', - id: '2', - attributes: { - body: 'i liked it' - }, - links: { - self: '/comments/2' - }, - relationships: { - post: { - links: { - self: '/comments/2/relationships/post', - related: '/comments/2/post' - } - } - } - }, - { - type: 'comments', - id: '3', - attributes: { - body: 'Thanks man. Great post. But what is JR?' - }, - links: { - self: '/comments/3' - }, - relationships: { - post: { - links: { - self: '/comments/3/relationships/post', - related: '/comments/3/post' - } - } - } - } - ] - }, - JSONAPI::ResourceSerializer.new(PostResource, - include: ['comments', 'author', 'comments.tags', 'author.posts'], - fields: { - people: [:id, :email, :comments], - posts: [:id, :title], - tags: [:name], - comments: [:id, :body, :post] - }).serialize_to_hash(posts) - ) - end - - def test_serializer_camelized_with_value_formatters - assert_hash_equals( - { - data: { - type: 'expenseEntries', - id: '1', - attributes: { - transactionDate: '04/15/2014', - cost: '12.05' - }, - links: { - self: '/expenseEntries/1' - }, - relationships: { - isoCurrency: { - links: { - self: '/expenseEntries/1/relationships/isoCurrency', - related: '/expenseEntries/1/isoCurrency' - }, - data: { - type: 'isoCurrencies', - id: 'USD' - } - }, - employee: { - links: { - self: '/expenseEntries/1/relationships/employee', - related: '/expenseEntries/1/employee' - }, - data: { - type: 'people', - id: '3' - } - } - } - }, - included: [ - { - type: 'isoCurrencies', - id: 'USD', - attributes: { - countryName: 'United States', - name: 'United States Dollar', - minorUnit: 'cent' - }, - links: { - self: '/isoCurrencies/USD' - } - }, - { - type: 'people', - id: '3', - attributes: { - email: 'lazy@xyz.fake', - name: 'Lazy Author', - dateJoined: '2013-10-31 17:25:00 -0400' - }, - links: { - self: '/people/3', - } - } - ] - }, - JSONAPI::ResourceSerializer.new(ExpenseEntryResource, - include: ['iso_currency', 'employee'], - fields: {people: [:id, :name, :email, :date_joined]}).serialize_to_hash( - ExpenseEntryResource.new(@expense_entry, nil)) - ) - end - - def test_serializer_empty_links_null_and_array - planet_hash = JSONAPI::ResourceSerializer.new(PlanetResource).serialize_to_hash( - PlanetResource.new(Planet.find(8), nil)) - - assert_hash_equals( - { - data: { - type: 'planets', - id: '8', - attributes: { - name: 'Beta W', - description: 'Newly discovered Planet W' - }, - links: { - self: '/planets/8' - }, - relationships: { - planetType: { - links: { - self: '/planets/8/relationships/planetType', - related: '/planets/8/planetType' - } - }, - tags: { - links: { - self: '/planets/8/relationships/tags', - related: '/planets/8/tags' - } - }, - moons: { - links: { - self: '/planets/8/relationships/moons', - related: '/planets/8/moons' - } - } - } - } - }, planet_hash) - end - - def test_serializer_include_with_empty_links_null_and_array - planets = [] - Planet.find(7, 8).each do |planet| - planets.push PlanetResource.new(planet, nil) - end - - planet_hash = JSONAPI::ResourceSerializer.new(PlanetResource, - include: ['planet_type'], - fields: { planet_types: [:id, :name] }).serialize_to_hash(planets) - - assert_hash_equals( - { - data: [{ - type: 'planets', - id: '7', - attributes: { - name: 'Beta X', - description: 'Newly discovered Planet Z' - }, - links: { - self: '/planets/7' - }, - relationships: { - planetType: { - links: { - self: '/planets/7/relationships/planetType', - related: '/planets/7/planetType' - }, - data: { - type: 'planetTypes', - id: '5' - } - }, - tags: { - links: { - self: '/planets/7/relationships/tags', - related: '/planets/7/tags' - } - }, - moons: { - links: { - self: '/planets/7/relationships/moons', - related: '/planets/7/moons' - } - } - } - }, - { - type: 'planets', - id: '8', - attributes: { - name: 'Beta W', - description: 'Newly discovered Planet W' - }, - links: { - self: '/planets/8' - }, - relationships: { - planetType: { - links: { - self: '/planets/8/relationships/planetType', - related: '/planets/8/planetType' - }, - data: nil - }, - tags: { - links: { - self: '/planets/8/relationships/tags', - related: '/planets/8/tags' - } - }, - moons: { - links: { - self: '/planets/8/relationships/moons', - related: '/planets/8/moons' - } - } - } - } - ], - included: [ - { - type: 'planetTypes', - id: '5', - attributes: { - name: 'unknown' - }, - links: { - self: '/planetTypes/5' - } - } - ] - }, planet_hash) - end - - def test_serializer_booleans - original_config = JSONAPI.configuration.dup - JSONAPI.configuration.json_key_format = :underscored_key - - preferences = PreferencesResource.new(Preferences.find(1), nil) - - assert_hash_equals( - { - data: { - type: 'preferences', - id: '1', - attributes: { - advanced_mode: false - }, - links: { - self: '/preferences/1' - }, - relationships: { - author: { - links: { - self: '/preferences/1/relationships/author', - related: '/preferences/1/author' - } - } - } - } - }, - JSONAPI::ResourceSerializer.new(PreferencesResource).serialize_to_hash(preferences) - ) - ensure - JSONAPI.configuration = original_config - end - - def test_serializer_data_types - original_config = JSONAPI.configuration.dup - JSONAPI.configuration.json_key_format = :underscored_key - - facts = FactResource.new(Fact.find(1), nil) - - assert_hash_equals( - { - data: { - type: 'facts', - id: '1', - attributes: { - spouse_name: 'Jane Author', - bio: 'First man to run across Antartica.', - quality_rating: 23.89/45.6, - salary: BigDecimal('47000.56', 30).as_json, - date_time_joined: DateTime.parse('2013-08-07 20:25:00 UTC +00:00').in_time_zone('UTC').as_json, - birthday: Date.parse('1965-06-30').as_json, - bedtime: Time.parse('2000-01-01 20:00:00 UTC +00:00').as_json, #DB seems to set the date to 2000-01-01 for time types - photo: "abc", - cool: false - }, - links: { - self: '/facts/1' - } - } - }, - JSONAPI::ResourceSerializer.new(FactResource).serialize_to_hash(facts) - ) - ensure - JSONAPI.configuration = original_config - end - - def test_serializer_to_one - serialized = JSONAPI::ResourceSerializer.new( - Api::V5::AuthorResource, - include: ['author_detail'] - ).serialize_to_hash(Api::V5::AuthorResource.new(Person.find(1), nil)) - - assert_hash_equals( - { - data: { - type: 'authors', - id: '1', - attributes: { - name: 'Joe Author', - }, - links: { - self: '/api/v5/authors/1' - }, - relationships: { - posts: { - links: { - self: '/api/v5/authors/1/relationships/posts', - related: '/api/v5/authors/1/posts' - } - }, - authorDetail: { - links: { - self: '/api/v5/authors/1/relationships/authorDetail', - related: '/api/v5/authors/1/authorDetail' - }, - data: {type: 'authorDetails', id: '1'} - } - } - }, - included: [ - { - type: 'authorDetails', - id: '1', - attributes: { - authorStuff: 'blah blah' - }, - links: { - self: '/api/v5/authorDetails/1' - } - } - ] - }, - serialized - ) - end - - def test_serializer_resource_meta_fixed_value - Api::V5::AuthorResource.class_eval do - def meta(options) - { - fixed: 'Hardcoded value', - computed: "#{self.class._type.to_s}: #{options[:serializer].link_builder.self_link(self)}" - } - end - end - - serialized = JSONAPI::ResourceSerializer.new( - Api::V5::AuthorResource, - include: ['author_detail'] - ).serialize_to_hash(Api::V5::AuthorResource.new(Person.find(1), nil)) - - assert_hash_equals( - { - data: { - type: 'authors', - id: '1', - attributes: { - name: 'Joe Author', - }, - links: { - self: '/api/v5/authors/1' - }, - relationships: { - posts: { - links: { - self: '/api/v5/authors/1/relationships/posts', - related: '/api/v5/authors/1/posts' - } - }, - authorDetail: { - links: { - self: '/api/v5/authors/1/relationships/authorDetail', - related: '/api/v5/authors/1/authorDetail' - }, - data: {type: 'authorDetails', id: '1'} - } - }, - meta: { - fixed: 'Hardcoded value', - computed: 'authors: /api/v5/authors/1' - } - }, - included: [ - { - type: 'authorDetails', - id: '1', - attributes: { - authorStuff: 'blah blah' - }, - links: { - self: '/api/v5/authorDetails/1' - } - } - ] - }, - serialized - ) - ensure - Api::V5::AuthorResource.class_eval do - def meta(options) - # :nocov: - { } - # :nocov: - end - end - end - - def test_serialize_model_attr - @make = Make.first - serialized = JSONAPI::ResourceSerializer.new( - MakeResource, - ).serialize_to_hash(MakeResource.new(@make, nil)) - - assert_hash_equals( - { - "model" => "A model attribute" - }, - serialized["data"]["attributes"] - ) - end - - def test_confusingly_named_attrs - @wp = WebPage.first - serialized = JSONAPI::ResourceSerializer.new( - WebPageResource, - ).serialize_to_hash(WebPageResource.new(@wp, nil)) - - assert_hash_equals( - { - "data"=>{ - "id"=>"#{@wp.id}", - "type"=>"webPages", - "links"=>{ - "self"=>"/webPages/#{@wp.id}" - }, - "attributes"=>{ - "href"=>"http://example.com", - "link"=>"http://link.example.com" - } - } - }, - serialized - ) - end - - def test_questionable_has_one - # has_one - out, err = capture_io do - eval <<-CODE - class ::Questionable < ActiveRecord::Base - has_one :link - has_one :href - end - class ::QuestionableResource < JSONAPI::Resource - model_name '::Questionable' - has_one :link - has_one :href - end - cn = ::Questionable.new id: 1 - puts JSONAPI::ResourceSerializer.new( - ::QuestionableResource, - ).serialize_to_hash(::QuestionableResource.new(cn, nil)) - CODE - end - assert err.blank? - assert_equal( - { - "data"=>{ - "id"=>"1", - "type"=>"questionables", - "links"=>{ - "self"=>"/questionables/1" - }, - "relationships"=>{ - "link"=>{ - "links"=>{ - "self"=>"/questionables/1/relationships/link", - "related"=>"/questionables/1/link" - } - }, - "href"=>{ - "links"=>{ - "self"=>"/questionables/1/relationships/href", - "related"=>"/questionables/1/href" - } - } - } - } - }.to_s, - out.strip - ) - end - - def test_questionable_has_many - # has_one - out, err = capture_io do - eval <<-CODE - class ::Questionable2 < ActiveRecord::Base - self.table_name = 'questionables' - has_many :links - has_many :hrefs - end - class ::Questionable2Resource < JSONAPI::Resource - model_name '::Questionable2' - has_many :links - has_many :hrefs - end - cn = ::Questionable2.new id: 1 - puts JSONAPI::ResourceSerializer.new( - ::Questionable2Resource, - ).serialize_to_hash(::Questionable2Resource.new(cn, nil)) - CODE - end - assert err.blank? - assert_equal( - { - "data"=>{ - "id"=>"1", - "type"=>"questionable2s", - "links"=>{ - "self"=>"/questionable2s/1" - }, - "relationships"=>{ - "links"=>{ - "links"=>{ - "self"=>"/questionable2s/1/relationships/links", - "related"=>"/questionable2s/1/links" - } - }, - "hrefs"=>{ - "links"=>{ - "self"=>"/questionable2s/1/relationships/hrefs", - "related"=>"/questionable2s/1/hrefs" - } - } - } - } - }.to_s, - out.strip - ) - end - - def test_simple_custom_links - serialized_custom_link_resource = JSONAPI::ResourceSerializer.new(SimpleCustomLinkResource, base_url: 'http://example.com').serialize_to_hash(SimpleCustomLinkResource.new(Post.first, {})) - - custom_link_spec = { - data: { - type: 'simpleCustomLinks', - id: '1', - attributes: { - title: "New post", - body: "A body!!!", - subject: "New post" - }, - links: { - self: "http://example.com/simpleCustomLinks/1", - raw: "http://example.com/simpleCustomLinks/1/raw" - }, - relationships: { - writer: { - links: { - self: "http://example.com/simpleCustomLinks/1/relationships/writer", - related: "http://example.com/simpleCustomLinks/1/writer" - } - }, - section: { - links: { - self: "http://example.com/simpleCustomLinks/1/relationships/section", - related: "http://example.com/simpleCustomLinks/1/section" - } - }, - comments: { - links: { - self: "http://example.com/simpleCustomLinks/1/relationships/comments", - related: "http://example.com/simpleCustomLinks/1/comments" - } - } - } - } - } - - assert_hash_equals(custom_link_spec, serialized_custom_link_resource) - end - - def test_custom_links_with_custom_relative_paths - serialized_custom_link_resource = JSONAPI::ResourceSerializer - .new(CustomLinkWithRelativePathOptionResource, base_url: 'http://example.com') - .serialize_to_hash(CustomLinkWithRelativePathOptionResource.new(Post.first, {})) - - custom_link_spec = { - data: { - type: 'customLinkWithRelativePathOptions', - id: '1', - attributes: { - title: "New post", - body: "A body!!!", - subject: "New post" - }, - links: { - self: "http://example.com/customLinkWithRelativePathOptions/1", - raw: "http://example.com/customLinkWithRelativePathOptions/1/super/duper/path.xml" - }, - relationships: { - writer: { - links: { - self: "http://example.com/customLinkWithRelativePathOptions/1/relationships/writer", - related: "http://example.com/customLinkWithRelativePathOptions/1/writer" - } - }, - section: { - links: { - self: "http://example.com/customLinkWithRelativePathOptions/1/relationships/section", - related: "http://example.com/customLinkWithRelativePathOptions/1/section" - } - }, - comments: { - links: { - self: "http://example.com/customLinkWithRelativePathOptions/1/relationships/comments", - related: "http://example.com/customLinkWithRelativePathOptions/1/comments" - } - } - } - } - } - - assert_hash_equals(custom_link_spec, serialized_custom_link_resource) - end - - def test_custom_links_with_if_condition_equals_false - serialized_custom_link_resource = JSONAPI::ResourceSerializer - .new(CustomLinkWithIfCondition, base_url: 'http://example.com') - .serialize_to_hash(CustomLinkWithIfCondition.new(Post.first, {})) - - custom_link_spec = { - data: { - type: 'customLinkWithIfConditions', - id: '1', - attributes: { - title: "New post", - body: "A body!!!", - subject: "New post" - }, - links: { - self: "http://example.com/customLinkWithIfConditions/1", - }, - relationships: { - writer: { - links: { - self: "http://example.com/customLinkWithIfConditions/1/relationships/writer", - related: "http://example.com/customLinkWithIfConditions/1/writer" - } - }, - section: { - links: { - self: "http://example.com/customLinkWithIfConditions/1/relationships/section", - related: "http://example.com/customLinkWithIfConditions/1/section" - } - }, - comments: { - links: { - self: "http://example.com/customLinkWithIfConditions/1/relationships/comments", - related: "http://example.com/customLinkWithIfConditions/1/comments" - } - } - } - } - } - - assert_hash_equals(custom_link_spec, serialized_custom_link_resource) - end - - def test_custom_links_with_if_condition_equals_true - serialized_custom_link_resource = JSONAPI::ResourceSerializer - .new(CustomLinkWithIfCondition, base_url: 'http://example.com') - .serialize_to_hash(CustomLinkWithIfCondition.new(Post.find_by(title: "JR Solves your serialization woes!"), {})) - - custom_link_spec = { - data: { - type: 'customLinkWithIfConditions', - id: '2', - attributes: { - title: "JR Solves your serialization woes!", - body: "Use JR", - subject: "JR Solves your serialization woes!" - }, - links: { - self: "http://example.com/customLinkWithIfConditions/2", - conditional_custom_link: "http://example.com/customLinkWithIfConditions/2/conditional/link.json" - }, - relationships: { - writer: { - links: { - self: "http://example.com/customLinkWithIfConditions/2/relationships/writer", - related: "http://example.com/customLinkWithIfConditions/2/writer" - } - }, - section: { - links: { - self: "http://example.com/customLinkWithIfConditions/2/relationships/section", - related: "http://example.com/customLinkWithIfConditions/2/section" - } - }, - comments: { - links: { - self: "http://example.com/customLinkWithIfConditions/2/relationships/comments", - related: "http://example.com/customLinkWithIfConditions/2/comments" - } - } - } - } - } - - assert_hash_equals(custom_link_spec, serialized_custom_link_resource) - end - - - def test_custom_links_with_lambda - # custom link is based on created_at timestamp of Post - post_created_at = Post.first.created_at - serialized_custom_link_resource = JSONAPI::ResourceSerializer - .new(CustomLinkWithLambda, base_url: 'http://example.com') - .serialize_to_hash(CustomLinkWithLambda.new(Post.first, {})) - - custom_link_spec = { - data: { - type: 'customLinkWithLambdas', - id: '1', - attributes: { - title: "New post", - body: "A body!!!", - subject: "New post", - createdAt: post_created_at.as_json - }, - links: { - self: "http://example.com/customLinkWithLambdas/1", - link_to_external_api: "http://external-api.com/posts/#{post_created_at.year}/#{post_created_at.month}/#{post_created_at.day}-New-post" - }, - relationships: { - writer: { - links: { - self: "http://example.com/customLinkWithLambdas/1/relationships/writer", - related: "http://example.com/customLinkWithLambdas/1/writer" - } - }, - section: { - links: { - self: "http://example.com/customLinkWithLambdas/1/relationships/section", - related: "http://example.com/customLinkWithLambdas/1/section" - } - }, - comments: { - links: { - self: "http://example.com/customLinkWithLambdas/1/relationships/comments", - related: "http://example.com/customLinkWithLambdas/1/comments" - } - } - } - } - } - - assert_hash_equals(custom_link_spec, serialized_custom_link_resource) - end - - def test_includes_two_relationships_with_same_foreign_key - serialized_resource = JSONAPI::ResourceSerializer - .new(PersonWithEvenAndOddPostsResource, include: ['even_posts','odd_posts']) - .serialize_to_hash(PersonWithEvenAndOddPostsResource.new(Person.find(1), nil)) - - assert_hash_equals( - { - data: { - id: "1", - type: "personWithEvenAndOddPosts", - links: { - self: "/personWithEvenAndOddPosts/1" - }, - relationships: { - evenPosts: { - links: { - self: "/personWithEvenAndOddPosts/1/relationships/evenPosts", - related: "/personWithEvenAndOddPosts/1/evenPosts" - }, - data: [ - { - type: "posts", - id: "2" - } - ] - }, - oddPosts: { - links: { - self: "/personWithEvenAndOddPosts/1/relationships/oddPosts", - related: "/personWithEvenAndOddPosts/1/oddPosts" - }, - data:[ - { - type: "posts", - id: "1" - }, - { - type: "posts", - id: "11" - } - ] - } - } - }, - included:[ - { - id: "2", - type: "posts", - links: { - self: "/posts/2" - }, - attributes: { - title: "JR Solves your serialization woes!", - body: "Use JR", - subject: "JR Solves your serialization woes!" - }, - relationships: { - author: { - links: { - self: "/posts/2/relationships/author", - related: "/posts/2/author" - } - }, - section: { - links: { - self: "/posts/2/relationships/section", - related: "/posts/2/section" - } - }, - tags: { - links: { - self: "/posts/2/relationships/tags", - related: "/posts/2/tags" - } - }, - comments: { - links: { - self: "/posts/2/relationships/comments", - related: "/posts/2/comments" - } - } - } - }, - { - id: "1", - type: "posts", - links: { - self: "/posts/1" - }, - attributes: { - title: "New post", - body: "A body!!!", - subject: "New post" - }, - relationships: { - author: { - links: { - self: "/posts/1/relationships/author", - related: "/posts/1/author" - } - }, - section: { - links: { - self: "/posts/1/relationships/section", - related: "/posts/1/section" - } - }, - tags: { - links: { - self: "/posts/1/relationships/tags", - related: "/posts/1/tags" - } - }, - comments: { - links: { - self: "/posts/1/relationships/comments", - related: "/posts/1/comments" - } - } - } - }, - { - id: "11", - type: "posts", - links: { - self: "/posts/11" - }, - attributes: { - title: "JR How To", - body: "Use JR to write API apps", - subject: "JR How To" - }, - relationships: { - author: { - links: { - self: "/posts/11/relationships/author", - related: "/posts/11/author" - } - }, - section: { - links: { - self: "/posts/11/relationships/section", - related: "/posts/11/section" - } - }, - tags: { - links: { - self: "/posts/11/relationships/tags", - related: "/posts/11/tags" - } - }, - comments: { - links: { - self: "/posts/11/relationships/comments", - related: "/posts/11/comments" - } - } - } - } - ] - }, - serialized_resource - ) - end - - def test_config_keys_stable - (serializer_a, serializer_b) = 2.times.map do - JSONAPI::ResourceSerializer.new( - PostResource, - include: ['comments', 'author', 'comments.tags', 'author.posts'], - fields: { - people: [:email, :comments], - posts: [:title], - tags: [:name], - comments: [:body, :post] - } - ) - end - - assert_equal serializer_a.config_key(PostResource), serializer_b.config_key(PostResource) - end - - def test_config_keys_vary_with_relevant_config_changes - serializer_a = JSONAPI::ResourceSerializer.new( - PostResource, - fields: { posts: [:title] } - ) - serializer_b = JSONAPI::ResourceSerializer.new( - PostResource, - fields: { posts: [:title, :body] } - ) - - assert_not_equal serializer_a.config_key(PostResource), serializer_b.config_key(PostResource) - end - - def test_config_keys_stable_with_irrelevant_config_changes - serializer_a = JSONAPI::ResourceSerializer.new( - PostResource, - fields: { posts: [:title, :body], people: [:name, :email] } - ) - serializer_b = JSONAPI::ResourceSerializer.new( - PostResource, - fields: { posts: [:title, :body], people: [:name] } - ) - - assert_equal serializer_a.config_key(PostResource), serializer_b.config_key(PostResource) - end - - def test_config_keys_stable_with_different_primary_resource - serializer_a = JSONAPI::ResourceSerializer.new( - PostResource, - fields: { posts: [:title, :body], people: [:name, :email] } - ) - serializer_b = JSONAPI::ResourceSerializer.new( - PersonResource, - fields: { posts: [:title, :body], people: [:name, :email] } - ) - - assert_equal serializer_a.config_key(PostResource), serializer_b.config_key(PostResource) - end - -end +# ToDo: Rework these tests + +# require File.expand_path('../../../test_helper', __FILE__) +# require 'jsonapi-resources' +# require 'json' +# +# class SerializerTest < ActionDispatch::IntegrationTest +# def setup +# @post = Post.find(1) +# @fred = Person.find_by(name: 'Fred Reader') +# +# @expense_entry = ExpenseEntry.find(1) +# +# JSONAPI.configuration.json_key_format = :camelized_key +# JSONAPI.configuration.route_format = :camelized_route +# JSONAPI.configuration.always_include_to_one_linkage_data = false +# end +# +# def after_teardown +# JSONAPI.configuration.always_include_to_one_linkage_data = false +# JSONAPI.configuration.json_key_format = :underscored_key +# end +# +# def test_serializer +# +# serialized = JSONAPI::ResourceSerializer.new( +# PostResource, +# base_url: 'http://example.com').serialize_to_hash(PostResource.new(@post, nil) +# ) +# +# assert_hash_equals( +# { +# data: { +# type: 'posts', +# id: '1', +# links: { +# self: 'http://example.com/posts/1', +# }, +# attributes: { +# title: 'New post', +# body: 'A body!!!', +# subject: 'New post' +# }, +# relationships: { +# section: { +# links: { +# self: 'http://example.com/posts/1/relationships/section', +# related: 'http://example.com/posts/1/section' +# } +# }, +# author: { +# links: { +# self: 'http://example.com/posts/1/relationships/author', +# related: 'http://example.com/posts/1/author' +# } +# }, +# tags: { +# links: { +# self: 'http://example.com/posts/1/relationships/tags', +# related: 'http://example.com/posts/1/tags' +# } +# }, +# comments: { +# links: { +# self: 'http://example.com/posts/1/relationships/comments', +# related: 'http://example.com/posts/1/comments' +# } +# } +# } +# } +# }, +# serialized +# ) +# end +# +# def test_serializer_nil_handling +# assert_hash_equals( +# { +# data: nil +# }, +# JSONAPI::ResourceSerializer.new(PostResource).serialize_to_hash(nil) +# ) +# end +# +# def test_serializer_namespaced_resource +# assert_hash_equals( +# { +# data: { +# type: 'posts', +# id: '1', +# links: { +# self: 'http://example.com/api/v1/posts/1' +# }, +# attributes: { +# title: 'New post', +# body: 'A body!!!', +# subject: 'New post' +# }, +# relationships: { +# section: { +# links:{ +# self: 'http://example.com/api/v1/posts/1/relationships/section', +# related: 'http://example.com/api/v1/posts/1/section' +# } +# }, +# writer: { +# links:{ +# self: 'http://example.com/api/v1/posts/1/relationships/writer', +# related: 'http://example.com/api/v1/posts/1/writer' +# } +# }, +# comments: { +# links:{ +# self: 'http://example.com/api/v1/posts/1/relationships/comments', +# related: 'http://example.com/api/v1/posts/1/comments' +# } +# } +# } +# } +# }, +# JSONAPI::ResourceSerializer.new(Api::V1::PostResource, +# base_url: 'http://example.com').serialize_to_hash( +# Api::V1::PostResource.new(@post, nil)) +# ) +# end +# +# def test_serializer_limited_fieldset +# +# assert_hash_equals( +# { +# data: { +# type: 'posts', +# id: '1', +# links: { +# self: '/posts/1' +# }, +# attributes: { +# title: 'New post' +# }, +# relationships: { +# author: { +# links: { +# self: '/posts/1/relationships/author', +# related: '/posts/1/author' +# } +# } +# } +# } +# }, +# JSONAPI::ResourceSerializer.new(PostResource, +# fields: {posts: [:id, :title, :author]}).serialize_to_hash(PostResource.new(@post, nil)) +# ) +# end +# +# def test_serializer_include +# serialized = JSONAPI::ResourceSerializer.new( +# PostResource, +# include: ['author'] +# ).serialize_to_hash(PostResource.new(@post, nil)) +# +# assert_hash_equals( +# { +# data: { +# type: 'posts', +# id: '1', +# links: { +# self: '/posts/1' +# }, +# attributes: { +# title: 'New post', +# body: 'A body!!!', +# subject: 'New post' +# }, +# relationships: { +# section: { +# links: { +# self: '/posts/1/relationships/section', +# related: '/posts/1/section' +# } +# }, +# author: { +# links: { +# self: '/posts/1/relationships/author', +# related: '/posts/1/author' +# }, +# data: { +# type: 'people', +# id: '1' +# } +# }, +# tags: { +# links: { +# self: '/posts/1/relationships/tags', +# related: '/posts/1/tags' +# } +# }, +# comments: { +# links: { +# self: '/posts/1/relationships/comments', +# related: '/posts/1/comments' +# } +# } +# } +# }, +# included: [ +# { +# type: 'people', +# id: '1', +# attributes: { +# name: 'Joe Author', +# email: 'joe@xyz.fake', +# dateJoined: '2013-08-07 16:25:00 -0400' +# }, +# links: { +# self: '/people/1' +# }, +# relationships: { +# comments: { +# links: { +# self: '/people/1/relationships/comments', +# related: '/people/1/comments' +# } +# }, +# posts: { +# links: { +# self: '/people/1/relationships/posts', +# related: '/people/1/posts' +# } +# }, +# preferences: { +# links: { +# self: '/people/1/relationships/preferences', +# related: '/people/1/preferences' +# } +# }, +# hairCut: { +# links: { +# self: "/people/1/relationships/hairCut", +# related: "/people/1/hairCut" +# } +# }, +# vehicles: { +# links: { +# self: "/people/1/relationships/vehicles", +# related: "/people/1/vehicles" +# } +# } +# } +# } +# ] +# }, +# serialized +# ) +# end +# +# def test_serializer_key_format +# serialized = JSONAPI::ResourceSerializer.new( +# PostResource, +# include: ['author'], +# key_formatter: UnderscoredKeyFormatter +# ).serialize_to_hash(PostResource.new(@post, nil)) +# +# assert_hash_equals( +# { +# data: { +# type: 'posts', +# id: '1', +# attributes: { +# title: 'New post', +# body: 'A body!!!', +# subject: 'New post' +# }, +# links: { +# self: '/posts/1' +# }, +# relationships: { +# section: { +# links: { +# self: '/posts/1/relationships/section', +# related: '/posts/1/section' +# } +# }, +# author: { +# links: { +# self: '/posts/1/relationships/author', +# related: '/posts/1/author' +# }, +# data: { +# type: 'people', +# id: '1' +# } +# }, +# tags: { +# links: { +# self: '/posts/1/relationships/tags', +# related: '/posts/1/tags' +# } +# }, +# comments: { +# links: { +# self: '/posts/1/relationships/comments', +# related: '/posts/1/comments' +# } +# } +# } +# }, +# included: [ +# { +# type: 'people', +# id: '1', +# attributes: { +# name: 'Joe Author', +# email: 'joe@xyz.fake', +# date_joined: '2013-08-07 16:25:00 -0400' +# }, +# links: { +# self: '/people/1' +# }, +# relationships: { +# comments: { +# links: { +# self: '/people/1/relationships/comments', +# related: '/people/1/comments' +# } +# }, +# posts: { +# links: { +# self: '/people/1/relationships/posts', +# related: '/people/1/posts' +# } +# }, +# preferences: { +# links: { +# self: '/people/1/relationships/preferences', +# related: '/people/1/preferences' +# } +# }, +# hair_cut: { +# links: { +# self: '/people/1/relationships/hairCut', +# related: '/people/1/hairCut' +# } +# }, +# vehicles: { +# links: { +# self: "/people/1/relationships/vehicles", +# related: "/people/1/vehicles" +# } +# }, +# expense_entries: { +# links: { +# self: "/people/1/relationships/expenseEntries", +# related: "/people/1/expenseEntries" +# } +# } +# } +# } +# ] +# }, +# serialized +# ) +# end +# +# def test_serializer_include_sub_objects +# +# assert_hash_equals( +# { +# data: { +# type: 'posts', +# id: '1', +# attributes: { +# title: 'New post', +# body: 'A body!!!', +# subject: 'New post' +# }, +# links: { +# self: '/posts/1' +# }, +# relationships: { +# section: { +# links: { +# self: '/posts/1/relationships/section', +# related: '/posts/1/section' +# } +# }, +# author: { +# links: { +# self: '/posts/1/relationships/author', +# related: '/posts/1/author' +# } +# }, +# tags: { +# links: { +# self: '/posts/1/relationships/tags', +# related: '/posts/1/tags' +# } +# }, +# comments: { +# links: { +# self: '/posts/1/relationships/comments', +# related: '/posts/1/comments' +# }, +# data: [ +# {type: 'comments', id: '1'}, +# {type: 'comments', id: '2'} +# ] +# } +# } +# }, +# included: [ +# { +# type: 'tags', +# id: '1', +# attributes: { +# name: 'short' +# }, +# links: { +# self: '/tags/1' +# }, +# relationships: { +# posts: { +# links: { +# self: '/tags/1/relationships/posts', +# related: '/tags/1/posts' +# } +# } +# } +# }, +# { +# type: 'tags', +# id: '2', +# attributes: { +# name: 'whiny' +# }, +# links: { +# self: '/tags/2' +# }, +# relationships: { +# posts: { +# links: { +# self: '/tags/2/relationships/posts', +# related: '/tags/2/posts' +# } +# } +# } +# }, +# { +# type: 'tags', +# id: '4', +# attributes: { +# name: 'happy' +# }, +# links: { +# self: '/tags/4' +# }, +# relationships: { +# posts: { +# links: { +# self: '/tags/4/relationships/posts', +# related: '/tags/4/posts' +# }, +# } +# } +# }, +# { +# type: 'comments', +# id: '1', +# attributes: { +# body: 'what a dumb post' +# }, +# links: { +# self: '/comments/1' +# }, +# relationships: { +# author: { +# links: { +# self: '/comments/1/relationships/author', +# related: '/comments/1/author' +# } +# }, +# post: { +# links: { +# self: '/comments/1/relationships/post', +# related: '/comments/1/post' +# } +# }, +# tags: { +# links: { +# self: '/comments/1/relationships/tags', +# related: '/comments/1/tags' +# }, +# data: [ +# {type: 'tags', id: '1'}, +# {type: 'tags', id: '2'} +# ] +# } +# } +# }, +# { +# type: 'comments', +# id: '2', +# attributes: { +# body: 'i liked it' +# }, +# links: { +# self: '/comments/2' +# }, +# relationships: { +# author: { +# links: { +# self: '/comments/2/relationships/author', +# related: '/comments/2/author' +# } +# }, +# post: { +# links: { +# self: '/comments/2/relationships/post', +# related: '/comments/2/post' +# } +# }, +# tags: { +# links: { +# self: '/comments/2/relationships/tags', +# related: '/comments/2/tags' +# }, +# data: [ +# {type: 'tags', id: '1'}, +# {type: 'tags', id: '4'} +# ] +# } +# } +# } +# ] +# }, +# JSONAPI::ResourceSerializer.new(PostResource, +# include: ['comments', 'comments.tags']).serialize_to_hash(PostResource.new(@post, nil)) +# ) +# end +# +# def test_serializer_keeps_sorted_order_of_objects_with_self_referential_relationships +# post1, post2, post3 = Post.find(1), Post.find(2), Post.find(3) +# post1.parent_post = post3 +# ordered_posts = [post1, post2, post3] +# serialized_data = JSONAPI::ResourceSerializer.new( +# ParentApi::PostResource, +# include: ['parent_post'], +# base_url: 'http://example.com').serialize_to_hash(ordered_posts.map {|p| ParentApi::PostResource.new(p, nil)} +# )['data'] +# +# assert_equal(3, serialized_data.length) +# assert_equal("1", serialized_data[0]["id"]) +# assert_equal("2", serialized_data[1]["id"]) +# assert_equal("3", serialized_data[2]["id"]) +# end +# +# +# def test_serializer_different_foreign_key +# serialized = JSONAPI::ResourceSerializer.new( +# PersonResource, +# include: ['comments'] +# ).serialize_to_hash(PersonResource.new(@fred, nil)) +# +# assert_hash_equals( +# { +# data: { +# type: 'people', +# id: '2', +# attributes: { +# name: 'Fred Reader', +# email: 'fred@xyz.fake', +# dateJoined: '2013-10-31 16:25:00 -0400' +# }, +# links: { +# self: '/people/2' +# }, +# relationships: { +# posts: { +# links: { +# self: '/people/2/relationships/posts', +# related: '/people/2/posts' +# } +# }, +# comments: { +# links: { +# self: '/people/2/relationships/comments', +# related: '/people/2/comments' +# }, +# data: [ +# {type: 'comments', id: '2'}, +# {type: 'comments', id: '3'} +# ] +# }, +# preferences: { +# links: { +# self: "/people/2/relationships/preferences", +# related: "/people/2/preferences" +# } +# }, +# hairCut: { +# links: { +# self: "/people/2/relationships/hairCut", +# related: "/people/2/hairCut" +# } +# }, +# vehicles: { +# links: { +# self: "/people/2/relationships/vehicles", +# related: "/people/2/vehicles" +# } +# }, +# } +# }, +# included: [ +# { +# type: 'comments', +# id: '2', +# attributes: { +# body: 'i liked it' +# }, +# links: { +# self: '/comments/2' +# }, +# relationships: { +# author: { +# links: { +# self: '/comments/2/relationships/author', +# related: '/comments/2/author' +# } +# }, +# post: { +# links: { +# self: '/comments/2/relationships/post', +# related: '/comments/2/post' +# } +# }, +# tags: { +# links: { +# self: '/comments/2/relationships/tags', +# related: '/comments/2/tags' +# } +# } +# } +# }, +# { +# type: 'comments', +# id: '3', +# attributes: { +# body: 'Thanks man. Great post. But what is JR?' +# }, +# links: { +# self: '/comments/3' +# }, +# relationships: { +# author: { +# links: { +# self: '/comments/3/relationships/author', +# related: '/comments/3/author' +# } +# }, +# post: { +# links: { +# self: '/comments/3/relationships/post', +# related: '/comments/3/post' +# } +# }, +# tags: { +# links: { +# self: '/comments/3/relationships/tags', +# related: '/comments/3/tags' +# } +# } +# } +# } +# ] +# }, +# serialized +# ) +# end +# +# def test_serializer_array_of_resources_always_include_to_one_linkage_data +# +# posts = [] +# Post.find(1, 2).each do |post| +# posts.push PostResource.new(post, nil) +# end +# +# JSONAPI.configuration.always_include_to_one_linkage_data = true +# +# assert_hash_equals( +# { +# data: [ +# { +# type: 'posts', +# id: '1', +# attributes: { +# title: 'New post', +# body: 'A body!!!', +# subject: 'New post' +# }, +# links: { +# self: '/posts/1' +# }, +# relationships: { +# section: { +# links: { +# self: '/posts/1/relationships/section', +# related: '/posts/1/section' +# }, +# data: nil +# }, +# author: { +# links: { +# self: '/posts/1/relationships/author', +# related: '/posts/1/author' +# }, +# data: { +# type: 'people', +# id: '1' +# } +# }, +# tags: { +# links: { +# self: '/posts/1/relationships/tags', +# related: '/posts/1/tags' +# } +# }, +# comments: { +# links: { +# self: '/posts/1/relationships/comments', +# related: '/posts/1/comments' +# }, +# data: [ +# {type: 'comments', id: '1'}, +# {type: 'comments', id: '2'} +# ] +# } +# } +# }, +# { +# type: 'posts', +# id: '2', +# attributes: { +# title: 'JR Solves your serialization woes!', +# body: 'Use JR', +# subject: 'JR Solves your serialization woes!' +# }, +# links: { +# self: '/posts/2' +# }, +# relationships: { +# section: { +# links: { +# self: '/posts/2/relationships/section', +# related: '/posts/2/section' +# }, +# data: { +# type: 'sections', +# id: '2' +# } +# }, +# author: { +# links: { +# self: '/posts/2/relationships/author', +# related: '/posts/2/author' +# }, +# data: { +# type: 'people', +# id: '1' +# } +# }, +# tags: { +# links: { +# self: '/posts/2/relationships/tags', +# related: '/posts/2/tags' +# } +# }, +# comments: { +# links: { +# self: '/posts/2/relationships/comments', +# related: '/posts/2/comments' +# }, +# data: [ +# {type: 'comments', id: '3'} +# ] +# } +# } +# } +# ], +# included: [ +# { +# type: 'tags', +# id: '1', +# attributes: { +# name: 'short' +# }, +# links: { +# self: '/tags/1' +# }, +# relationships: { +# posts: { +# links: { +# self: '/tags/1/relationships/posts', +# related: '/tags/1/posts' +# } +# } +# } +# }, +# { +# type: 'tags', +# id: '2', +# attributes: { +# name: 'whiny' +# }, +# links: { +# self: '/tags/2' +# }, +# relationships: { +# posts: { +# links: { +# self: '/tags/2/relationships/posts', +# related: '/tags/2/posts' +# } +# } +# } +# }, +# { +# type: 'tags', +# id: '4', +# attributes: { +# name: 'happy' +# }, +# links: { +# self: '/tags/4' +# }, +# relationships: { +# posts: { +# links: { +# self: '/tags/4/relationships/posts', +# related: '/tags/4/posts' +# } +# } +# } +# }, +# { +# type: 'tags', +# id: '5', +# attributes: { +# name: 'JR' +# }, +# links: { +# self: '/tags/5' +# }, +# relationships: { +# posts: { +# links: { +# self: '/tags/5/relationships/posts', +# related: '/tags/5/posts' +# } +# } +# } +# }, +# { +# type: 'comments', +# id: '1', +# attributes: { +# body: 'what a dumb post' +# }, +# links: { +# self: '/comments/1' +# }, +# relationships: { +# author: { +# links: { +# self: '/comments/1/relationships/author', +# related: '/comments/1/author' +# }, +# data: { +# type: 'people', +# id: '1' +# } +# }, +# post: { +# links: { +# self: '/comments/1/relationships/post', +# related: '/comments/1/post' +# }, +# data: { +# type: 'posts', +# id: '1' +# } +# }, +# tags: { +# links: { +# self: '/comments/1/relationships/tags', +# related: '/comments/1/tags' +# }, +# data: [ +# {type: 'tags', id: '1'}, +# {type: 'tags', id: '2'} +# ] +# } +# } +# }, +# { +# type: 'comments', +# id: '2', +# attributes: { +# body: 'i liked it' +# }, +# links: { +# self: '/comments/2' +# }, +# relationships: { +# author: { +# links: { +# self: '/comments/2/relationships/author', +# related: '/comments/2/author' +# }, +# data: { +# type: 'people', +# id: '2' +# } +# }, +# post: { +# links: { +# self: '/comments/2/relationships/post', +# related: '/comments/2/post' +# }, +# data: { +# type: 'posts', +# id: '1' +# } +# }, +# tags: { +# links: { +# self: '/comments/2/relationships/tags', +# related: '/comments/2/tags' +# }, +# data: [ +# {type: 'tags', id: '4'}, +# {type: 'tags', id: '1'} +# ] +# } +# } +# }, +# { +# type: 'comments', +# id: '3', +# attributes: { +# body: 'Thanks man. Great post. But what is JR?' +# }, +# links: { +# self: '/comments/3' +# }, +# relationships: { +# author: { +# links: { +# self: '/comments/3/relationships/author', +# related: '/comments/3/author' +# }, +# data: { +# type: 'people', +# id: '2' +# } +# }, +# post: { +# links: { +# self: '/comments/3/relationships/post', +# related: '/comments/3/post' +# }, +# data: { +# type: 'posts', +# id: '2' +# } +# }, +# tags: { +# links: { +# self: '/comments/3/relationships/tags', +# related: '/comments/3/tags' +# }, +# data: [ +# {type: 'tags', id: '5'} +# ] +# } +# } +# } +# ] +# }, +# JSONAPI::ResourceSerializer.new(PostResource, +# include: ['comments', 'comments.tags']).serialize_to_hash(posts) +# ) +# ensure +# JSONAPI.configuration.always_include_to_one_linkage_data = false +# end +# +# def test_serializer_always_include_to_one_linkage_data_does_not_load_association +# JSONAPI.configuration.always_include_to_one_linkage_data = true +# +# post = Post.find(1) +# resource = Api::V1::PostResource.new(post, nil) +# JSONAPI::ResourceSerializer.new(Api::V1::PostResource).serialize_to_hash(resource) +# +# refute_predicate post.association(:writer), :loaded? +# ensure +# JSONAPI.configuration.always_include_to_one_linkage_data = false +# end +# +# def test_serializer_array_of_resources +# +# posts = [] +# Post.find(1, 2).each do |post| +# posts.push PostResource.new(post, nil) +# end +# +# assert_hash_equals( +# { +# data: [ +# { +# type: 'posts', +# id: '1', +# attributes: { +# title: 'New post', +# body: 'A body!!!', +# subject: 'New post' +# }, +# links: { +# self: '/posts/1' +# }, +# relationships: { +# section: { +# links: { +# self: '/posts/1/relationships/section', +# related: '/posts/1/section' +# } +# }, +# author: { +# links: { +# self: '/posts/1/relationships/author', +# related: '/posts/1/author' +# } +# }, +# tags: { +# links: { +# self: '/posts/1/relationships/tags', +# related: '/posts/1/tags' +# } +# }, +# comments: { +# links: { +# self: '/posts/1/relationships/comments', +# related: '/posts/1/comments' +# }, +# data: [ +# {type: 'comments', id: '1'}, +# {type: 'comments', id: '2'} +# ] +# } +# } +# }, +# { +# type: 'posts', +# id: '2', +# attributes: { +# title: 'JR Solves your serialization woes!', +# body: 'Use JR', +# subject: 'JR Solves your serialization woes!' +# }, +# links: { +# self: '/posts/2' +# }, +# relationships: { +# section: { +# links: { +# self: '/posts/2/relationships/section', +# related: '/posts/2/section' +# } +# }, +# author: { +# links: { +# self: '/posts/2/relationships/author', +# related: '/posts/2/author' +# } +# }, +# tags: { +# links: { +# self: '/posts/2/relationships/tags', +# related: '/posts/2/tags' +# } +# }, +# comments: { +# links: { +# self: '/posts/2/relationships/comments', +# related: '/posts/2/comments' +# }, +# data: [ +# {type: 'comments', id: '3'} +# ] +# } +# } +# } +# ], +# included: [ +# { +# type: 'tags', +# id: '1', +# attributes: { +# name: 'short' +# }, +# links: { +# self: '/tags/1' +# }, +# relationships: { +# posts: { +# links: { +# self: '/tags/1/relationships/posts', +# related: '/tags/1/posts' +# } +# } +# } +# }, +# { +# type: 'tags', +# id: '2', +# attributes: { +# name: 'whiny' +# }, +# links: { +# self: '/tags/2' +# }, +# relationships: { +# posts: { +# links: { +# self: '/tags/2/relationships/posts', +# related: '/tags/2/posts' +# } +# } +# } +# }, +# { +# type: 'tags', +# id: '4', +# attributes: { +# name: 'happy' +# }, +# links: { +# self: '/tags/4' +# }, +# relationships: { +# posts: { +# links: { +# self: '/tags/4/relationships/posts', +# related: '/tags/4/posts' +# } +# } +# } +# }, +# { +# type: 'tags', +# id: '5', +# attributes: { +# name: 'JR' +# }, +# links: { +# self: '/tags/5' +# }, +# relationships: { +# posts: { +# links: { +# self: '/tags/5/relationships/posts', +# related: '/tags/5/posts' +# } +# } +# } +# }, +# { +# type: 'comments', +# id: '1', +# attributes: { +# body: 'what a dumb post' +# }, +# links: { +# self: '/comments/1' +# }, +# relationships: { +# author: { +# links: { +# self: '/comments/1/relationships/author', +# related: '/comments/1/author' +# } +# }, +# post: { +# links: { +# self: '/comments/1/relationships/post', +# related: '/comments/1/post' +# } +# }, +# tags: { +# links: { +# self: '/comments/1/relationships/tags', +# related: '/comments/1/tags' +# }, +# data: [ +# {type: 'tags', id: '1'}, +# {type: 'tags', id: '2'} +# ] +# } +# } +# }, +# { +# type: 'comments', +# id: '2', +# attributes: { +# body: 'i liked it' +# }, +# links: { +# self: '/comments/2' +# }, +# relationships: { +# author: { +# links: { +# self: '/comments/2/relationships/author', +# related: '/comments/2/author' +# } +# }, +# post: { +# links: { +# self: '/comments/2/relationships/post', +# related: '/comments/2/post' +# } +# }, +# tags: { +# links: { +# self: '/comments/2/relationships/tags', +# related: '/comments/2/tags' +# }, +# data: [ +# {type: 'tags', id: '4'}, +# {type: 'tags', id: '1'} +# ] +# } +# } +# }, +# { +# type: 'comments', +# id: '3', +# attributes: { +# body: 'Thanks man. Great post. But what is JR?' +# }, +# links: { +# self: '/comments/3' +# }, +# relationships: { +# author: { +# links: { +# self: '/comments/3/relationships/author', +# related: '/comments/3/author' +# } +# }, +# post: { +# links: { +# self: '/comments/3/relationships/post', +# related: '/comments/3/post' +# } +# }, +# tags: { +# links: { +# self: '/comments/3/relationships/tags', +# related: '/comments/3/tags' +# }, +# data: [ +# {type: 'tags', id: '5'} +# ] +# } +# } +# } +# ] +# }, +# JSONAPI::ResourceSerializer.new(PostResource, +# include: ['comments', 'comments.tags']).serialize_to_hash(posts) +# ) +# end +# +# def test_serializer_array_of_resources_limited_fields +# +# posts = [] +# Post.find(1, 2).each do |post| +# posts.push PostResource.new(post, nil) +# end +# +# assert_hash_equals( +# { +# data: [ +# { +# type: 'posts', +# id: '1', +# attributes: { +# title: 'New post' +# }, +# links: { +# self: '/posts/1' +# } +# }, +# { +# type: 'posts', +# id: '2', +# attributes: { +# title: 'JR Solves your serialization woes!' +# }, +# links: { +# self: '/posts/2' +# } +# } +# ], +# included: [ +# { +# type: 'posts', +# id: '11', +# attributes: { +# title: 'JR How To' +# }, +# links: { +# self: '/posts/11' +# } +# }, +# { +# type: 'people', +# id: '1', +# attributes: { +# email: 'joe@xyz.fake' +# }, +# links: { +# self: '/people/1' +# }, +# relationships: { +# comments: { +# links: { +# self: '/people/1/relationships/comments', +# related: '/people/1/comments' +# } +# } +# } +# }, +# { +# id: '1', +# type: 'tags', +# attributes: { +# name: 'short' +# }, +# links: { +# self: '/tags/1' +# } +# }, +# { +# id: '2', +# type: 'tags', +# attributes: { +# name: 'whiny' +# }, +# links: { +# self: '/tags/2' +# } +# }, +# { +# id: '4', +# type: 'tags', +# attributes: { +# name: 'happy' +# }, +# links: { +# self: '/tags/4' +# } +# }, +# { +# id: '5', +# type: 'tags', +# attributes: { +# name: 'JR' +# }, +# links: { +# self: '/tags/5' +# } +# }, +# { +# type: 'comments', +# id: '1', +# attributes: { +# body: 'what a dumb post' +# }, +# links: { +# self: '/comments/1' +# }, +# relationships: { +# post: { +# links: { +# self: '/comments/1/relationships/post', +# related: '/comments/1/post' +# } +# } +# } +# }, +# { +# type: 'comments', +# id: '2', +# attributes: { +# body: 'i liked it' +# }, +# links: { +# self: '/comments/2' +# }, +# relationships: { +# post: { +# links: { +# self: '/comments/2/relationships/post', +# related: '/comments/2/post' +# } +# } +# } +# }, +# { +# type: 'comments', +# id: '3', +# attributes: { +# body: 'Thanks man. Great post. But what is JR?' +# }, +# links: { +# self: '/comments/3' +# }, +# relationships: { +# post: { +# links: { +# self: '/comments/3/relationships/post', +# related: '/comments/3/post' +# } +# } +# } +# } +# ] +# }, +# JSONAPI::ResourceSerializer.new(PostResource, +# include: ['comments', 'author', 'comments.tags', 'author.posts'], +# fields: { +# people: [:id, :email, :comments], +# posts: [:id, :title], +# tags: [:name], +# comments: [:id, :body, :post] +# }).serialize_to_hash(posts) +# ) +# end +# +# def test_serializer_camelized_with_value_formatters +# assert_hash_equals( +# { +# data: { +# type: 'expenseEntries', +# id: '1', +# attributes: { +# transactionDate: '04/15/2014', +# cost: '12.05' +# }, +# links: { +# self: '/expenseEntries/1' +# }, +# relationships: { +# isoCurrency: { +# links: { +# self: '/expenseEntries/1/relationships/isoCurrency', +# related: '/expenseEntries/1/isoCurrency' +# }, +# data: { +# type: 'isoCurrencies', +# id: 'USD' +# } +# }, +# employee: { +# links: { +# self: '/expenseEntries/1/relationships/employee', +# related: '/expenseEntries/1/employee' +# }, +# data: { +# type: 'people', +# id: '3' +# } +# } +# } +# }, +# included: [ +# { +# type: 'isoCurrencies', +# id: 'USD', +# attributes: { +# countryName: 'United States', +# name: 'United States Dollar', +# minorUnit: 'cent' +# }, +# links: { +# self: '/isoCurrencies/USD' +# } +# }, +# { +# type: 'people', +# id: '3', +# attributes: { +# email: 'lazy@xyz.fake', +# name: 'Lazy Author', +# dateJoined: '2013-10-31 17:25:00 -0400' +# }, +# links: { +# self: '/people/3', +# } +# } +# ] +# }, +# JSONAPI::ResourceSerializer.new(ExpenseEntryResource, +# include: ['iso_currency', 'employee'], +# fields: {people: [:id, :name, :email, :date_joined]}).serialize_to_hash( +# ExpenseEntryResource.new(@expense_entry, nil)) +# ) +# end +# +# def test_serializer_empty_links_null_and_array +# planet_hash = JSONAPI::ResourceSerializer.new(PlanetResource).serialize_to_hash( +# PlanetResource.new(Planet.find(8), nil)) +# +# assert_hash_equals( +# { +# data: { +# type: 'planets', +# id: '8', +# attributes: { +# name: 'Beta W', +# description: 'Newly discovered Planet W' +# }, +# links: { +# self: '/planets/8' +# }, +# relationships: { +# planetType: { +# links: { +# self: '/planets/8/relationships/planetType', +# related: '/planets/8/planetType' +# } +# }, +# tags: { +# links: { +# self: '/planets/8/relationships/tags', +# related: '/planets/8/tags' +# } +# }, +# moons: { +# links: { +# self: '/planets/8/relationships/moons', +# related: '/planets/8/moons' +# } +# } +# } +# } +# }, planet_hash) +# end +# +# def test_serializer_include_with_empty_links_null_and_array +# planets = [] +# Planet.find(7, 8).each do |planet| +# planets.push PlanetResource.new(planet, nil) +# end +# +# planet_hash = JSONAPI::ResourceSerializer.new(PlanetResource, +# include: ['planet_type'], +# fields: { planet_types: [:id, :name] }).serialize_to_hash(planets) +# +# assert_hash_equals( +# { +# data: [{ +# type: 'planets', +# id: '7', +# attributes: { +# name: 'Beta X', +# description: 'Newly discovered Planet Z' +# }, +# links: { +# self: '/planets/7' +# }, +# relationships: { +# planetType: { +# links: { +# self: '/planets/7/relationships/planetType', +# related: '/planets/7/planetType' +# }, +# data: { +# type: 'planetTypes', +# id: '5' +# } +# }, +# tags: { +# links: { +# self: '/planets/7/relationships/tags', +# related: '/planets/7/tags' +# } +# }, +# moons: { +# links: { +# self: '/planets/7/relationships/moons', +# related: '/planets/7/moons' +# } +# } +# } +# }, +# { +# type: 'planets', +# id: '8', +# attributes: { +# name: 'Beta W', +# description: 'Newly discovered Planet W' +# }, +# links: { +# self: '/planets/8' +# }, +# relationships: { +# planetType: { +# links: { +# self: '/planets/8/relationships/planetType', +# related: '/planets/8/planetType' +# }, +# data: nil +# }, +# tags: { +# links: { +# self: '/planets/8/relationships/tags', +# related: '/planets/8/tags' +# } +# }, +# moons: { +# links: { +# self: '/planets/8/relationships/moons', +# related: '/planets/8/moons' +# } +# } +# } +# } +# ], +# included: [ +# { +# type: 'planetTypes', +# id: '5', +# attributes: { +# name: 'unknown' +# }, +# links: { +# self: '/planetTypes/5' +# } +# } +# ] +# }, planet_hash) +# end +# +# def test_serializer_booleans +# original_config = JSONAPI.configuration.dup +# JSONAPI.configuration.json_key_format = :underscored_key +# +# preferences = PreferencesResource.new(Preferences.find(1), nil) +# +# assert_hash_equals( +# { +# data: { +# type: 'preferences', +# id: '1', +# attributes: { +# advanced_mode: false +# }, +# links: { +# self: '/preferences/1' +# }, +# relationships: { +# author: { +# links: { +# self: '/preferences/1/relationships/author', +# related: '/preferences/1/author' +# } +# } +# } +# } +# }, +# JSONAPI::ResourceSerializer.new(PreferencesResource).serialize_to_hash(preferences) +# ) +# ensure +# JSONAPI.configuration = original_config +# end +# +# def test_serializer_data_types +# original_config = JSONAPI.configuration.dup +# JSONAPI.configuration.json_key_format = :underscored_key +# +# facts = FactResource.new(Fact.find(1), nil) +# +# assert_hash_equals( +# { +# data: { +# type: 'facts', +# id: '1', +# attributes: { +# spouse_name: 'Jane Author', +# bio: 'First man to run across Antartica.', +# quality_rating: 23.89/45.6, +# salary: BigDecimal('47000.56', 30).as_json, +# date_time_joined: DateTime.parse('2013-08-07 20:25:00 UTC +00:00').in_time_zone('UTC').as_json, +# birthday: Date.parse('1965-06-30').as_json, +# bedtime: Time.parse('2000-01-01 20:00:00 UTC +00:00').as_json, #DB seems to set the date to 2000-01-01 for time types +# photo: "abc", +# cool: false +# }, +# links: { +# self: '/facts/1' +# } +# } +# }, +# JSONAPI::ResourceSerializer.new(FactResource).serialize_to_hash(facts) +# ) +# ensure +# JSONAPI.configuration = original_config +# end +# +# def test_serializer_to_one +# serialized = JSONAPI::ResourceSerializer.new( +# Api::V5::AuthorResource, +# include: ['author_detail'] +# ).serialize_to_hash(Api::V5::AuthorResource.new(Person.find(1), nil)) +# +# assert_hash_equals( +# { +# data: { +# type: 'authors', +# id: '1', +# attributes: { +# name: 'Joe Author', +# }, +# links: { +# self: '/api/v5/authors/1' +# }, +# relationships: { +# posts: { +# links: { +# self: '/api/v5/authors/1/relationships/posts', +# related: '/api/v5/authors/1/posts' +# } +# }, +# authorDetail: { +# links: { +# self: '/api/v5/authors/1/relationships/authorDetail', +# related: '/api/v5/authors/1/authorDetail' +# }, +# data: {type: 'authorDetails', id: '1'} +# } +# } +# }, +# included: [ +# { +# type: 'authorDetails', +# id: '1', +# attributes: { +# authorStuff: 'blah blah' +# }, +# links: { +# self: '/api/v5/authorDetails/1' +# } +# } +# ] +# }, +# serialized +# ) +# end +# +# def test_serializer_resource_meta_fixed_value +# Api::V5::AuthorResource.class_eval do +# def meta(options) +# { +# fixed: 'Hardcoded value', +# computed: "#{self.class._type.to_s}: #{options[:serializer].link_builder.self_link(self)}" +# } +# end +# end +# +# serialized = JSONAPI::ResourceSerializer.new( +# Api::V5::AuthorResource, +# include: ['author_detail'] +# ).serialize_to_hash(Api::V5::AuthorResource.new(Person.find(1), nil)) +# +# assert_hash_equals( +# { +# data: { +# type: 'authors', +# id: '1', +# attributes: { +# name: 'Joe Author', +# }, +# links: { +# self: '/api/v5/authors/1' +# }, +# relationships: { +# posts: { +# links: { +# self: '/api/v5/authors/1/relationships/posts', +# related: '/api/v5/authors/1/posts' +# } +# }, +# authorDetail: { +# links: { +# self: '/api/v5/authors/1/relationships/authorDetail', +# related: '/api/v5/authors/1/authorDetail' +# }, +# data: {type: 'authorDetails', id: '1'} +# } +# }, +# meta: { +# fixed: 'Hardcoded value', +# computed: 'authors: /api/v5/authors/1' +# } +# }, +# included: [ +# { +# type: 'authorDetails', +# id: '1', +# attributes: { +# authorStuff: 'blah blah' +# }, +# links: { +# self: '/api/v5/authorDetails/1' +# } +# } +# ] +# }, +# serialized +# ) +# ensure +# Api::V5::AuthorResource.class_eval do +# def meta(options) +# # :nocov: +# { } +# # :nocov: +# end +# end +# end +# +# def test_serialize_model_attr +# @make = Make.first +# serialized = JSONAPI::ResourceSerializer.new( +# MakeResource, +# ).serialize_to_hash(MakeResource.new(@make, nil)) +# +# assert_hash_equals( +# { +# "model" => "A model attribute" +# }, +# serialized["data"]["attributes"] +# ) +# end +# +# def test_confusingly_named_attrs +# @wp = WebPage.first +# serialized = JSONAPI::ResourceSerializer.new( +# WebPageResource, +# ).serialize_to_hash(WebPageResource.new(@wp, nil)) +# +# assert_hash_equals( +# { +# "data"=>{ +# "id"=>"#{@wp.id}", +# "type"=>"webPages", +# "links"=>{ +# "self"=>"/webPages/#{@wp.id}" +# }, +# "attributes"=>{ +# "href"=>"http://example.com", +# "link"=>"http://link.example.com" +# } +# } +# }, +# serialized +# ) +# end +# +# def test_questionable_has_one +# # has_one +# out, err = capture_io do +# eval <<-CODE +# class ::Questionable < ActiveRecord::Base +# has_one :link +# has_one :href +# end +# class ::QuestionableResource < JSONAPI::Resource +# model_name '::Questionable' +# has_one :link +# has_one :href +# end +# cn = ::Questionable.new id: 1 +# puts JSONAPI::ResourceSerializer.new( +# ::QuestionableResource, +# ).serialize_to_hash(::QuestionableResource.new(cn, nil)) +# CODE +# end +# assert err.blank? +# assert_equal( +# { +# "data"=>{ +# "id"=>"1", +# "type"=>"questionables", +# "links"=>{ +# "self"=>"/questionables/1" +# }, +# "relationships"=>{ +# "link"=>{ +# "links"=>{ +# "self"=>"/questionables/1/relationships/link", +# "related"=>"/questionables/1/link" +# } +# }, +# "href"=>{ +# "links"=>{ +# "self"=>"/questionables/1/relationships/href", +# "related"=>"/questionables/1/href" +# } +# } +# } +# } +# }.to_s, +# out.strip +# ) +# end +# +# def test_questionable_has_many +# # has_one +# out, err = capture_io do +# eval <<-CODE +# class ::Questionable2 < ActiveRecord::Base +# self.table_name = 'questionables' +# has_many :links +# has_many :hrefs +# end +# class ::Questionable2Resource < JSONAPI::Resource +# model_name '::Questionable2' +# has_many :links +# has_many :hrefs +# end +# cn = ::Questionable2.new id: 1 +# puts JSONAPI::ResourceSerializer.new( +# ::Questionable2Resource, +# ).serialize_to_hash(::Questionable2Resource.new(cn, nil)) +# CODE +# end +# assert err.blank? +# assert_equal( +# { +# "data"=>{ +# "id"=>"1", +# "type"=>"questionable2s", +# "links"=>{ +# "self"=>"/questionable2s/1" +# }, +# "relationships"=>{ +# "links"=>{ +# "links"=>{ +# "self"=>"/questionable2s/1/relationships/links", +# "related"=>"/questionable2s/1/links" +# } +# }, +# "hrefs"=>{ +# "links"=>{ +# "self"=>"/questionable2s/1/relationships/hrefs", +# "related"=>"/questionable2s/1/hrefs" +# } +# } +# } +# } +# }.to_s, +# out.strip +# ) +# end +# +# def test_simple_custom_links +# serialized_custom_link_resource = JSONAPI::ResourceSerializer.new(SimpleCustomLinkResource, base_url: 'http://example.com').serialize_to_hash(SimpleCustomLinkResource.new(Post.first, {})) +# +# custom_link_spec = { +# data: { +# type: 'simpleCustomLinks', +# id: '1', +# attributes: { +# title: "New post", +# body: "A body!!!", +# subject: "New post" +# }, +# links: { +# self: "http://example.com/simpleCustomLinks/1", +# raw: "http://example.com/simpleCustomLinks/1/raw" +# }, +# relationships: { +# writer: { +# links: { +# self: "http://example.com/simpleCustomLinks/1/relationships/writer", +# related: "http://example.com/simpleCustomLinks/1/writer" +# } +# }, +# section: { +# links: { +# self: "http://example.com/simpleCustomLinks/1/relationships/section", +# related: "http://example.com/simpleCustomLinks/1/section" +# } +# }, +# comments: { +# links: { +# self: "http://example.com/simpleCustomLinks/1/relationships/comments", +# related: "http://example.com/simpleCustomLinks/1/comments" +# } +# } +# } +# } +# } +# +# assert_hash_equals(custom_link_spec, serialized_custom_link_resource) +# end +# +# def test_custom_links_with_custom_relative_paths +# serialized_custom_link_resource = JSONAPI::ResourceSerializer +# .new(CustomLinkWithRelativePathOptionResource, base_url: 'http://example.com') +# .serialize_to_hash(CustomLinkWithRelativePathOptionResource.new(Post.first, {})) +# +# custom_link_spec = { +# data: { +# type: 'customLinkWithRelativePathOptions', +# id: '1', +# attributes: { +# title: "New post", +# body: "A body!!!", +# subject: "New post" +# }, +# links: { +# self: "http://example.com/customLinkWithRelativePathOptions/1", +# raw: "http://example.com/customLinkWithRelativePathOptions/1/super/duper/path.xml" +# }, +# relationships: { +# writer: { +# links: { +# self: "http://example.com/customLinkWithRelativePathOptions/1/relationships/writer", +# related: "http://example.com/customLinkWithRelativePathOptions/1/writer" +# } +# }, +# section: { +# links: { +# self: "http://example.com/customLinkWithRelativePathOptions/1/relationships/section", +# related: "http://example.com/customLinkWithRelativePathOptions/1/section" +# } +# }, +# comments: { +# links: { +# self: "http://example.com/customLinkWithRelativePathOptions/1/relationships/comments", +# related: "http://example.com/customLinkWithRelativePathOptions/1/comments" +# } +# } +# } +# } +# } +# +# assert_hash_equals(custom_link_spec, serialized_custom_link_resource) +# end +# +# def test_custom_links_with_if_condition_equals_false +# serialized_custom_link_resource = JSONAPI::ResourceSerializer +# .new(CustomLinkWithIfCondition, base_url: 'http://example.com') +# .serialize_to_hash(CustomLinkWithIfCondition.new(Post.first, {})) +# +# custom_link_spec = { +# data: { +# type: 'customLinkWithIfConditions', +# id: '1', +# attributes: { +# title: "New post", +# body: "A body!!!", +# subject: "New post" +# }, +# links: { +# self: "http://example.com/customLinkWithIfConditions/1", +# }, +# relationships: { +# writer: { +# links: { +# self: "http://example.com/customLinkWithIfConditions/1/relationships/writer", +# related: "http://example.com/customLinkWithIfConditions/1/writer" +# } +# }, +# section: { +# links: { +# self: "http://example.com/customLinkWithIfConditions/1/relationships/section", +# related: "http://example.com/customLinkWithIfConditions/1/section" +# } +# }, +# comments: { +# links: { +# self: "http://example.com/customLinkWithIfConditions/1/relationships/comments", +# related: "http://example.com/customLinkWithIfConditions/1/comments" +# } +# } +# } +# } +# } +# +# assert_hash_equals(custom_link_spec, serialized_custom_link_resource) +# end +# +# def test_custom_links_with_if_condition_equals_true +# serialized_custom_link_resource = JSONAPI::ResourceSerializer +# .new(CustomLinkWithIfCondition, base_url: 'http://example.com') +# .serialize_to_hash(CustomLinkWithIfCondition.new(Post.find_by(title: "JR Solves your serialization woes!"), {})) +# +# custom_link_spec = { +# data: { +# type: 'customLinkWithIfConditions', +# id: '2', +# attributes: { +# title: "JR Solves your serialization woes!", +# body: "Use JR", +# subject: "JR Solves your serialization woes!" +# }, +# links: { +# self: "http://example.com/customLinkWithIfConditions/2", +# conditional_custom_link: "http://example.com/customLinkWithIfConditions/2/conditional/link.json" +# }, +# relationships: { +# writer: { +# links: { +# self: "http://example.com/customLinkWithIfConditions/2/relationships/writer", +# related: "http://example.com/customLinkWithIfConditions/2/writer" +# } +# }, +# section: { +# links: { +# self: "http://example.com/customLinkWithIfConditions/2/relationships/section", +# related: "http://example.com/customLinkWithIfConditions/2/section" +# } +# }, +# comments: { +# links: { +# self: "http://example.com/customLinkWithIfConditions/2/relationships/comments", +# related: "http://example.com/customLinkWithIfConditions/2/comments" +# } +# } +# } +# } +# } +# +# assert_hash_equals(custom_link_spec, serialized_custom_link_resource) +# end +# +# +# def test_custom_links_with_lambda +# # custom link is based on created_at timestamp of Post +# post_created_at = Post.first.created_at +# serialized_custom_link_resource = JSONAPI::ResourceSerializer +# .new(CustomLinkWithLambda, base_url: 'http://example.com') +# .serialize_to_hash(CustomLinkWithLambda.new(Post.first, {})) +# +# custom_link_spec = { +# data: { +# type: 'customLinkWithLambdas', +# id: '1', +# attributes: { +# title: "New post", +# body: "A body!!!", +# subject: "New post", +# createdAt: post_created_at.as_json +# }, +# links: { +# self: "http://example.com/customLinkWithLambdas/1", +# link_to_external_api: "http://external-api.com/posts/#{post_created_at.year}/#{post_created_at.month}/#{post_created_at.day}-New-post" +# }, +# relationships: { +# writer: { +# links: { +# self: "http://example.com/customLinkWithLambdas/1/relationships/writer", +# related: "http://example.com/customLinkWithLambdas/1/writer" +# } +# }, +# section: { +# links: { +# self: "http://example.com/customLinkWithLambdas/1/relationships/section", +# related: "http://example.com/customLinkWithLambdas/1/section" +# } +# }, +# comments: { +# links: { +# self: "http://example.com/customLinkWithLambdas/1/relationships/comments", +# related: "http://example.com/customLinkWithLambdas/1/comments" +# } +# } +# } +# } +# } +# +# assert_hash_equals(custom_link_spec, serialized_custom_link_resource) +# end +# +# def test_includes_two_relationships_with_same_foreign_key +# serialized_resource = JSONAPI::ResourceSerializer +# .new(PersonWithEvenAndOddPostsResource, include: ['even_posts','odd_posts']) +# .serialize_to_hash(PersonWithEvenAndOddPostsResource.new(Person.find(1), nil)) +# +# assert_hash_equals( +# { +# data: { +# id: "1", +# type: "personWithEvenAndOddPosts", +# links: { +# self: "/personWithEvenAndOddPosts/1" +# }, +# relationships: { +# evenPosts: { +# links: { +# self: "/personWithEvenAndOddPosts/1/relationships/evenPosts", +# related: "/personWithEvenAndOddPosts/1/evenPosts" +# }, +# data: [ +# { +# type: "posts", +# id: "2" +# } +# ] +# }, +# oddPosts: { +# links: { +# self: "/personWithEvenAndOddPosts/1/relationships/oddPosts", +# related: "/personWithEvenAndOddPosts/1/oddPosts" +# }, +# data:[ +# { +# type: "posts", +# id: "1" +# }, +# { +# type: "posts", +# id: "11" +# } +# ] +# } +# } +# }, +# included:[ +# { +# id: "2", +# type: "posts", +# links: { +# self: "/posts/2" +# }, +# attributes: { +# title: "JR Solves your serialization woes!", +# body: "Use JR", +# subject: "JR Solves your serialization woes!" +# }, +# relationships: { +# author: { +# links: { +# self: "/posts/2/relationships/author", +# related: "/posts/2/author" +# } +# }, +# section: { +# links: { +# self: "/posts/2/relationships/section", +# related: "/posts/2/section" +# } +# }, +# tags: { +# links: { +# self: "/posts/2/relationships/tags", +# related: "/posts/2/tags" +# } +# }, +# comments: { +# links: { +# self: "/posts/2/relationships/comments", +# related: "/posts/2/comments" +# } +# } +# } +# }, +# { +# id: "1", +# type: "posts", +# links: { +# self: "/posts/1" +# }, +# attributes: { +# title: "New post", +# body: "A body!!!", +# subject: "New post" +# }, +# relationships: { +# author: { +# links: { +# self: "/posts/1/relationships/author", +# related: "/posts/1/author" +# } +# }, +# section: { +# links: { +# self: "/posts/1/relationships/section", +# related: "/posts/1/section" +# } +# }, +# tags: { +# links: { +# self: "/posts/1/relationships/tags", +# related: "/posts/1/tags" +# } +# }, +# comments: { +# links: { +# self: "/posts/1/relationships/comments", +# related: "/posts/1/comments" +# } +# } +# } +# }, +# { +# id: "11", +# type: "posts", +# links: { +# self: "/posts/11" +# }, +# attributes: { +# title: "JR How To", +# body: "Use JR to write API apps", +# subject: "JR How To" +# }, +# relationships: { +# author: { +# links: { +# self: "/posts/11/relationships/author", +# related: "/posts/11/author" +# } +# }, +# section: { +# links: { +# self: "/posts/11/relationships/section", +# related: "/posts/11/section" +# } +# }, +# tags: { +# links: { +# self: "/posts/11/relationships/tags", +# related: "/posts/11/tags" +# } +# }, +# comments: { +# links: { +# self: "/posts/11/relationships/comments", +# related: "/posts/11/comments" +# } +# } +# } +# } +# ] +# }, +# serialized_resource +# ) +# end +# +# def test_config_keys_stable +# (serializer_a, serializer_b) = 2.times.map do +# JSONAPI::ResourceSerializer.new( +# PostResource, +# include: ['comments', 'author', 'comments.tags', 'author.posts'], +# fields: { +# people: [:email, :comments], +# posts: [:title], +# tags: [:name], +# comments: [:body, :post] +# } +# ) +# end +# +# assert_equal serializer_a.config_key(PostResource), serializer_b.config_key(PostResource) +# end +# +# def test_config_keys_vary_with_relevant_config_changes +# serializer_a = JSONAPI::ResourceSerializer.new( +# PostResource, +# fields: { posts: [:title] } +# ) +# serializer_b = JSONAPI::ResourceSerializer.new( +# PostResource, +# fields: { posts: [:title, :body] } +# ) +# +# assert_not_equal serializer_a.config_key(PostResource), serializer_b.config_key(PostResource) +# end +# +# def test_config_keys_stable_with_irrelevant_config_changes +# serializer_a = JSONAPI::ResourceSerializer.new( +# PostResource, +# fields: { posts: [:title, :body], people: [:name, :email] } +# ) +# serializer_b = JSONAPI::ResourceSerializer.new( +# PostResource, +# fields: { posts: [:title, :body], people: [:name] } +# ) +# +# assert_equal serializer_a.config_key(PostResource), serializer_b.config_key(PostResource) +# end +# +# def test_config_keys_stable_with_different_primary_resource +# serializer_a = JSONAPI::ResourceSerializer.new( +# PostResource, +# fields: { posts: [:title, :body], people: [:name, :email] } +# ) +# serializer_b = JSONAPI::ResourceSerializer.new( +# PersonResource, +# fields: { posts: [:title, :body], people: [:name, :email] } +# ) +# +# assert_equal serializer_a.config_key(PostResource), serializer_b.config_key(PostResource) +# end +# +# end From 72912e05363cf96eea93a092ff5ce32fac979460 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Kwa=C5=9Bniak?= Date: Thu, 11 May 2017 22:34:10 +0200 Subject: [PATCH 067/237] Fix inherited pagination --- lib/jsonapi/resource.rb | 1 + test/controllers/controller_test.rb | 4 ++++ test/fixtures/active_record.rb | 3 +++ 3 files changed, 8 insertions(+) diff --git a/lib/jsonapi/resource.rb b/lib/jsonapi/resource.rb index ebc0c79c1..2fc43d643 100644 --- a/lib/jsonapi/resource.rb +++ b/lib/jsonapi/resource.rb @@ -403,6 +403,7 @@ def inherited(subclass) subclass.abstract(false) subclass.immutable(false) subclass.caching(_caching) + subclass.paginator(_paginator) subclass._attributes = (_attributes || {}).dup subclass._model_hints = (_model_hints || {}).dup diff --git a/test/controllers/controller_test.rb b/test/controllers/controller_test.rb index d10ddd275..49d9473b1 100644 --- a/test/controllers/controller_test.rb +++ b/test/controllers/controller_test.rb @@ -3443,6 +3443,10 @@ def test_books_offset_pagination_meta JSONAPI.configuration = original_config end + def test_inherited_pagination + assert_equal :paged, Api::V4::BiggerBookResource._paginator + end + def test_books_operation_links original_config = JSONAPI.configuration.dup Api::V4::BookResource.paginator :offset diff --git a/test/fixtures/active_record.rb b/test/fixtures/active_record.rb index fbf47008a..20255f30a 100644 --- a/test/fixtures/active_record.rb +++ b/test/fixtures/active_record.rb @@ -1708,6 +1708,9 @@ class BookResource < Api::V2::BookResource paginator :paged end + class BiggerBookResource < Api::V4::BookResource + end + class BookCommentResource < Api::V2::BookCommentResource paginator :paged end From 0c3140514d3937ddfdda2e89fbaf6729766b2bfa Mon Sep 17 00:00:00 2001 From: Denis Talakevich Date: Fri, 18 Aug 2017 11:34:15 +0300 Subject: [PATCH 068/237] fixes #909 add possibility to set generic validation error --- lib/jsonapi/exceptions.rb | 14 ++++++++++++-- lib/jsonapi/resource.rb | 4 ++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/lib/jsonapi/exceptions.rb b/lib/jsonapi/exceptions.rb index 2bc206177..bb967d4fd 100644 --- a/lib/jsonapi/exceptions.rb +++ b/lib/jsonapi/exceptions.rb @@ -465,11 +465,12 @@ def errors end class ValidationErrors < Error - attr_reader :error_messages, :error_metadata, :resource_relationships + attr_reader :error_messages, :error_metadata, :resource_relationships, :resource_class def initialize(resource, error_object_overrides = {}) @error_messages = resource.model_error_messages @error_metadata = resource.validation_error_metadata + @resource_class = resource.class @resource_relationships = resource.class._relationships.keys @key_formatter = JSONAPI.configuration.key_formatter super(error_object_overrides) @@ -491,7 +492,7 @@ def json_api_error(attr_key, message) create_error_object(code: JSONAPI::VALIDATION_ERROR, status: :unprocessable_entity, title: message, - detail: "#{format_key(attr_key)} - #{message}", + detail: detail(attr_key, message), source: { pointer: pointer(attr_key) }, meta: metadata_for(attr_key, message)) end @@ -501,7 +502,12 @@ def metadata_for(attr_key, message) error_metadata[attr_key] ? error_metadata[attr_key][message] : nil end + def detail(attr_key, message) + general_error?(attr_key) ? message : "#{format_key(attr_key)} - #{message}" + end + def pointer(attr_or_relationship_name) + return '/data' if general_error?(attr_or_relationship_name) formatted_attr_or_relationship_name = format_key(attr_or_relationship_name) if resource_relationships.include?(attr_or_relationship_name) "/data/relationships/#{formatted_attr_or_relationship_name}" @@ -509,6 +515,10 @@ def pointer(attr_or_relationship_name) "/data/attributes/#{formatted_attr_or_relationship_name}" end end + + def general_error?(attr_key) + attr_key.to_sym == :base && !resource_class._has_attribute?(attr_key) + end end class SaveFailed < Error diff --git a/lib/jsonapi/resource.rb b/lib/jsonapi/resource.rb index 2fc43d643..b9f502bf5 100644 --- a/lib/jsonapi/resource.rb +++ b/lib/jsonapi/resource.rb @@ -837,6 +837,10 @@ 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 From 230e244a3caf9712c34ef29f73da3cda8d24acb3 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Wed, 6 Sep 2017 08:39:32 -0400 Subject: [PATCH 069/237] Tests validation behavior on :base --- test/controllers/controller_test.rb | 21 ++++++++++++++++++++- test/fixtures/active_record.rb | 21 +++++++++++++++++++-- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/test/controllers/controller_test.rb b/test/controllers/controller_test.rb index 49d9473b1..4eb26d0f4 100644 --- a/test/controllers/controller_test.rb +++ b/test/controllers/controller_test.rb @@ -1869,11 +1869,21 @@ def test_update_bad_attributes assert_response :bad_request end - def test_delete_with_validation_error + def test_delete_with_validation_error_base post = Post.create!(title: "can't destroy me", author: Person.first) delete :destroy, params: { id: post.id } assert_equal "can't destroy me", json_response['errors'][0]['title'] + assert_equal "/data", json_response['errors'][0]['source']['pointer'] + assert_response :unprocessable_entity + end + + def test_delete_with_validation_error_attr + post = Post.create!(title: "locked title", author: Person.first) + delete :destroy, params: { id: post.id } + + assert_equal "is locked", json_response['errors'][0]['title'] + assert_equal "/data/attributes/title", json_response['errors'][0]['source']['pointer'] assert_response :unprocessable_entity end @@ -3755,6 +3765,15 @@ def test_caching_with_join_from_resource_with_sql_fragment assert_cacheable_get :index, params: {include: 'section'} assert_response :success end + + def test_delete_with_validation_error_base_on_resource + post = Post.create!(title: "can't destroy me either", author: Person.first) + delete :destroy, params: { id: post.id } + + assert_equal "can't destroy me", json_response['errors'][0]['title'] + assert_equal "/data/attributes/base", json_response['errors'][0]['source']['pointer'] + assert_response :unprocessable_entity + end end class Api::V6::SectionsControllerTest < ActionController::TestCase diff --git a/test/fixtures/active_record.rb b/test/fixtures/active_record.rb index 20255f30a..49eebade5 100644 --- a/test/fixtures/active_record.rb +++ b/test/fixtures/active_record.rb @@ -376,8 +376,19 @@ class Post < ActiveRecord::Base before_destroy :destroy_callback def destroy_callback - if title == "can't destroy me" - errors.add(:title, "can't destroy me") + case title + when "can't destroy me", "can't destroy me either" + errors.add(:base, "can't destroy me") + + # :nocov: + if Rails::VERSION::MAJOR >= 5 + throw(:abort) + else + return false + end + # :nocov: + when "locked title" + errors.add(:title, "is locked") # :nocov: if Rails::VERSION::MAJOR >= 5 @@ -1775,6 +1786,12 @@ class PostResource < PostResource def self.records(options = {}) _model_class.all.joins('INNER JOIN people on people.id = author_id') end + + attribute :base + + def base + _model.title + end end class CustomerResource < JSONAPI::Resource From 2a84cdcb9ff2186af700dac90c8c61809df116b8 Mon Sep 17 00:00:00 2001 From: Ross-Hunter Date: Wed, 10 May 2017 16:29:32 -0400 Subject: [PATCH 070/237] Alters build_joins sql for has_one relationships The existing tests are passing because they seem to all use a self- referential `has_one` relationship. If the foreign_key is on a different table, however, the queries will fail. So if it the relationship is a has_one, we need to alter the query to look for the foreign-key on the related table. --- lib/jsonapi/active_relation_resource_finder.rb | 7 ++++++- test/unit/resource/resource_test.rb | 9 ++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/lib/jsonapi/active_relation_resource_finder.rb b/lib/jsonapi/active_relation_resource_finder.rb index 47e57839c..a8b081b53 100644 --- a/lib/jsonapi/active_relation_resource_finder.rb +++ b/lib/jsonapi/active_relation_resource_finder.rb @@ -423,7 +423,12 @@ 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}" + if current.has_one? + joins << "LEFT JOIN #{current.table_name} AS #{current.name}_sorting ON #{current.name}_sorting.#{current.foreign_key} = #{prev.table_name}.id" + else + joins << "LEFT JOIN #{current.table_name} AS #{current.name}_sorting ON #{current.name}_sorting.id = #{prev.table_name}.#{current.foreign_key}" + end + current end joins.join("\n") diff --git a/test/unit/resource/resource_test.rb b/test/unit/resource/resource_test.rb index 5e7322603..8807590e7 100644 --- a/test/unit/resource/resource_test.rb +++ b/test/unit/resource/resource_test.rb @@ -360,12 +360,15 @@ def test_lookup_association_chain end def test_build_joins - model_names = %w(person posts parent_post author) + model_names = %w(person posts parent_post author author_detail) associations = PostResource._lookup_association_chain(model_names) result = PostResource.send(:_build_joins, associations) - assert_equal "LEFT JOIN posts AS parent_post_sorting ON parent_post_sorting.id = posts.parent_post_id -LEFT JOIN people AS author_sorting ON author_sorting.id = posts.author_id", result + sql = "LEFT JOIN posts AS parent_post_sorting ON parent_post_sorting.parent_post_id = posts.id +LEFT JOIN people AS author_sorting ON author_sorting.id = posts.author_id +LEFT JOIN author_details AS author_detail_sorting ON author_detail_sorting.person_id = people.id" + + assert_equal sql, result end # ToDo: Implement relationship pagination From 9cdc890b21c7281227b5962a0d6d2a49ff022e7b Mon Sep 17 00:00:00 2001 From: Ross-Hunter Date: Wed, 10 May 2017 22:42:27 -0400 Subject: [PATCH 071/237] Lock down minitest version. 5.10.2 is broken --- jsonapi-resources.gemspec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jsonapi-resources.gemspec b/jsonapi-resources.gemspec index 3f031d173..8b53da8fe 100644 --- a/jsonapi-resources.gemspec +++ b/jsonapi-resources.gemspec @@ -21,7 +21,7 @@ Gem::Specification.new do |spec| spec.add_development_dependency 'bundler', '~> 1.5' spec.add_development_dependency 'rake' - spec.add_development_dependency 'minitest' + spec.add_development_dependency 'minitest', '~> 5.10', '!= 5.10.2' spec.add_development_dependency 'minitest-spec-rails' spec.add_development_dependency 'simplecov' spec.add_development_dependency 'pry' From ee4ae072bd48614d3c2f79ede0973c63a703ff96 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Wed, 6 Sep 2017 11:36:37 -0400 Subject: [PATCH 072/237] Tests sorting on related attributes of has_one relationships --- test/fixtures/active_record.rb | 24 +++++++++++++++++++++++ test/integration/requests/request_test.rb | 20 +++++++++++++++++++ test/test_helper.rb | 1 + 3 files changed, 45 insertions(+) diff --git a/test/fixtures/active_record.rb b/test/fixtures/active_record.rb index 49eebade5..ce3fa0323 100644 --- a/test/fixtures/active_record.rb +++ b/test/fixtures/active_record.rb @@ -903,6 +903,9 @@ class IsoCurrenciesController < JSONAPI::ResourceController end module V6 + class AuthorsController < JSONAPI::ResourceController + end + class PostsController < JSONAPI::ResourceController end @@ -1753,6 +1756,10 @@ def self.find_records(filters, options = {}) def fetchable_fields super - [:email] end + + def self.sortable_fields(context) + super(context) + [:"author_detail.author_stuff"] + end end class AuthorDetailResource < JSONAPI::Resource @@ -1772,6 +1779,23 @@ class EmployeeResource < EmployeeResource; end module Api module V6 + class AuthorDetailResource < JSONAPI::Resource + attributes :author_stuff + end + + class AuthorResource < JSONAPI::Resource + attributes :name, :email + model_name 'Person' + relationship :posts, to: :many + relationship :author_detail, to: :one, foreign_key_on: :related + + filter :name + + def self.sortable_fields(context) + super(context) + [:"author_detail.author_stuff"] + end + end + class PersonResource < PersonResource; end class TagResource < TagResource; end diff --git a/test/integration/requests/request_test.rb b/test/integration/requests/request_test.rb index 5db3e26f2..4b0e4cc10 100644 --- a/test/integration/requests/request_test.rb +++ b/test/integration/requests/request_test.rb @@ -1100,6 +1100,26 @@ def test_sort_parameter_openquoted assert_jsonapi_response 400 end + def test_sort_primary_attribute + get '/api/v6/authors?sort=name', headers: { 'Accept' => JSONAPI::MEDIA_TYPE } + assert_jsonapi_response 200 + assert_equal '1002', json_response['data'][0]['id'] + + get '/api/v6/authors?sort=-name', headers: { 'Accept' => JSONAPI::MEDIA_TYPE } + assert_jsonapi_response 200 + assert_equal '1005', json_response['data'][0]['id'] + end + + def test_sort_included_attribute + get '/api/v6/authors?sort=author_detail.author_stuff', headers: { 'Accept' => JSONAPI::MEDIA_TYPE } + assert_jsonapi_response 200 + assert_equal '1000', json_response['data'][0]['id'] + + get '/api/v6/authors?sort=-author_detail.author_stuff', headers: { 'Accept' => JSONAPI::MEDIA_TYPE } + assert_jsonapi_response 200 + assert_equal '1002', json_response['data'][0]['id'] + end + def test_include_parameter_quoted get '/api/v2/posts?include=%22author%22', headers: { 'Accept' => JSONAPI::MEDIA_TYPE } assert_jsonapi_response 200 diff --git a/test/test_helper.rb b/test/test_helper.rb index 950da56be..5d79ef2e6 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -354,6 +354,7 @@ class CatResource < JSONAPI::Resource JSONAPI.configuration.route_format = :dasherized_route namespace :v6 do + jsonapi_resources :authors jsonapi_resources :posts jsonapi_resources :sections jsonapi_resources :customers From 357998d6e937ed1e878329042fc23ee3ce6d79f0 Mon Sep 17 00:00:00 2001 From: Clement Berti Date: Thu, 7 Sep 2017 11:11:30 +0200 Subject: [PATCH 073/237] Fix active_relation_resource_finder.rb I think there was a little mistake in here, your probably meant `context = options[:context]` instead of `context = context` This solve an issue I had while fetching relationships --- lib/jsonapi/active_relation_resource_finder.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/jsonapi/active_relation_resource_finder.rb b/lib/jsonapi/active_relation_resource_finder.rb index a8b081b53..d2f204ad6 100644 --- a/lib/jsonapi/active_relation_resource_finder.rb +++ b/lib/jsonapi/active_relation_resource_finder.rb @@ -131,7 +131,7 @@ def count_related(source_rid, relationship_name, options = {}) relationship = _relationship(relationship_name) related_klass = relationship.resource_klass - context = context + context = options[:context] records = records(context: context) records, table_alias = apply_join(records, relationship, options) From 40cf1eb537bb5d25866b3cb5fc93f3dd6b75182e Mon Sep 17 00:00:00 2001 From: Denis Talakevich Date: Sat, 9 Sep 2017 23:09:12 +0300 Subject: [PATCH 074/237] #1094 fix sorting by nested relationships also fix sorting by has_many association add tests fix resource tests for building sort joins --- .../active_relation_resource_finder.rb | 24 +++++--- test/controllers/controller_test.rb | 42 +++++++++++++ test/fixtures/active_record.rb | 60 ++++++++++++++++++- test/test_helper.rb | 2 + test/unit/resource/resource_test.rb | 17 +++--- 5 files changed, 130 insertions(+), 15 deletions(-) diff --git a/lib/jsonapi/active_relation_resource_finder.rb b/lib/jsonapi/active_relation_resource_finder.rb index d2f204ad6..af8f3086c 100644 --- a/lib/jsonapi/active_relation_resource_finder.rb +++ b/lib/jsonapi/active_relation_resource_finder.rb @@ -3,7 +3,7 @@ module ActiveRelationResourceFinder def self.included(base) base.extend ClassMethods end - + module ClassMethods # Finds Resources using the `filters`. Pagination and sort options are used when provided @@ -387,7 +387,7 @@ def apply_pagination(records, paginator, order_options) records end - def apply_sort(records, order_options, context = {}) + def apply_sort(records, order_options, _context = {}) if order_options.any? order_options.each_pair do |field, direction| if field.to_s.include?(".") @@ -396,8 +396,7 @@ def apply_sort(records, order_options, context = {}) 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}" + order_by_query = "#{_join_table_name(associations.last)}.#{column_name} #{direction}" records = records.joins(joins_query).order(order_by_query) else field = _attribute_delegated_name(field) @@ -423,10 +422,12 @@ def _build_joins(associations) joins = [] associations.inject do |prev, current| - if current.has_one? - joins << "LEFT JOIN #{current.table_name} AS #{current.name}_sorting ON #{current.name}_sorting.#{current.foreign_key} = #{prev.table_name}.id" + prev_table_name = _join_table_name(prev) + curr_table_name = _join_table_name(current) + if current.belongs_to? + joins << "LEFT JOIN #{current.table_name} AS #{curr_table_name} ON #{curr_table_name}.id = #{prev_table_name}.#{current.foreign_key}" else - joins << "LEFT JOIN #{current.table_name} AS #{current.name}_sorting ON #{current.name}_sorting.id = #{prev.table_name}.#{current.foreign_key}" + joins << "LEFT JOIN #{current.table_name} AS #{curr_table_name} ON #{curr_table_name}.#{current.foreign_key} = #{prev_table_name}.id" end current @@ -434,6 +435,15 @@ def _build_joins(associations) joins.join("\n") end + # _sorting is appended to avoid name clashes with manual joins eg. overridden filters + def _join_table_name(association) + if association.is_a?(ActiveRecord::Reflection::AssociationReflection) + "#{association.name}_sorting" + else + association.table_name + end + end + # Assumes ActiveRecord's counting. Override if you need a different counting method def count_records(records) records.count(:all) diff --git a/test/controllers/controller_test.rb b/test/controllers/controller_test.rb index 4eb26d0f4..fb06fee4b 100644 --- a/test/controllers/controller_test.rb +++ b/test/controllers/controller_test.rb @@ -3911,3 +3911,45 @@ def test_fields_with_delegated_attribute JSONAPI.configuration = original_config end end + +class WidgetsControllerTest < ActionController::TestCase + def teardown + Widget.delete_all + Indicator.delete_all + Agency.delete_all + end + + def test_fetch_widgets_sort_by_agency_name + agency_1 = Agency.create! name: 'beta' + agency_2 = Agency.create! name: 'alpha' + indicator_1 = Indicator.create! name: 'bar', agency: agency_1 + indicator_2 = Indicator.create! name: 'foo', agency: agency_2 + Widget.create! name: 'bar', indicator: indicator_1 + widget = Widget.create! name: 'foo', indicator: indicator_2 + assert_cacheable_get :index, params: {sort: 'indicator.agency.name'} + assert_response :success + assert_equal widget.id.to_s, json_response['data'].first['id'] + end +end + +class IndicatorsControllerTest < ActionController::TestCase + def teardown + Widget.delete_all + Indicator.delete_all + Agency.delete_all + end + + def test_fetch_indicators_sort_by_widgets_name + agency = Agency.create! name: 'test' + indicator_1 = Indicator.create! name: 'bar', agency: agency + indicator_2 = Indicator.create! name: 'foo', agency: agency + Widget.create! name: 'omega', indicator: indicator_1 + Widget.create! name: 'beta', indicator: indicator_1 + Widget.create! name: 'alpha', indicator: indicator_2 + Widget.create! name: 'zeta', indicator: indicator_2 + assert_cacheable_get :index, params: {sort: 'widgets.name'} + assert_response :success + assert_equal indicator_2.id.to_s, json_response['data'].first['id'] + assert_equal 2, json_response['data'].size + end +end diff --git a/test/fixtures/active_record.rb b/test/fixtures/active_record.rb index ce3fa0323..409fad119 100644 --- a/test/fixtures/active_record.rb +++ b/test/fixtures/active_record.rb @@ -333,6 +333,23 @@ t.integer :access_card_id, null: false t.timestamps null: false end + + create_table :agencies, force: true do |t| + t.string :name + t.timestamps null: false + end + + create_table :indicators, force: true do |t| + t.string :name + t.integer :agency_id, null: false + t.timestamps null: false + end + + create_table :widgets, force: true do |t| + t.string :name + t.integer :indicator_id, null: false + t.timestamps null: false + end end ### MODELS @@ -368,7 +385,7 @@ class Post < ActiveRecord::Base has_many :special_post_tags, source: :tag has_many :special_tags, through: :special_post_tags, source: :tag belongs_to :section - has_one :parent_post, class_name: 'Post', foreign_key: 'parent_post_id' + belongs_to :parent_post, class_name: 'Post', foreign_key: 'parent_post_id' validates :author, presence: true validates :title, length: { maximum: 35 } @@ -696,6 +713,18 @@ class Worker < ActiveRecord::Base belongs_to :access_card end +class Agency < ActiveRecord::Base +end + +class Indicator < ActiveRecord::Base + belongs_to :agency + has_many :widgets +end + +class Widget < ActiveRecord::Base + belongs_to :indicator +end + ### CONTROLLERS class AuthorsController < JSONAPI::ResourceControllerMetal end @@ -986,6 +1015,12 @@ class AccessCardsController < BaseController class WorkersController < BaseController end +class WidgetsController < JSONAPI::ResourceController +end + +class IndicatorsController < JSONAPI::ResourceController +end + ### RESOURCES class BaseResource < JSONAPI::Resource abstract @@ -1992,6 +2027,29 @@ class BlogPostResource < JSONAPI::Resource filter :name end +class AgencyResource < JSONAPI::Resource + attributes :name +end + +class IndicatorResource < JSONAPI::Resource + attributes :name + has_one :agency + has_many :widgets + + def self.sortable_fields(_context = nil) + super + [:'widgets.name'] + end +end + +class WidgetResource < JSONAPI::Resource + attributes :name + has_one :indicator + + def self.sortable_fields(_context = nil) + super + [:'indicator.agency.name'] + end +end + # CustomProcessors class Api::V4::BookProcessor < JSONAPI::Processor after_find do diff --git a/test/test_helper.rb b/test/test_helper.rb index 5d79ef2e6..b846c967b 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -398,6 +398,8 @@ class CatResource < JSONAPI::Resource jsonapi_resources :keepers, only: [:show] jsonapi_resources :storages jsonapi_resources :workers, only: [:show] + jsonapi_resources :widgets, only: [:index] + jsonapi_resources :indicators, only: [:index] mount MyEngine::Engine => "/boomshaka", as: :my_engine mount ApiV2Engine::Engine => "/api_v2", as: :api_v2_engine diff --git a/test/unit/resource/resource_test.rb b/test/unit/resource/resource_test.rb index 8807590e7..252da026a 100644 --- a/test/unit/resource/resource_test.rb +++ b/test/unit/resource/resource_test.rb @@ -345,7 +345,7 @@ def apply_sort(records, order_options, context = {}) def test_lookup_association_chain model_names = %w(person posts parent_post) - result = PostResource._lookup_association_chain(model_names) + result = PersonResource._lookup_association_chain(model_names) assert_equal 2, result.length posts_reflection, parent_post_reflection = result @@ -361,12 +361,15 @@ def test_lookup_association_chain def test_build_joins model_names = %w(person posts parent_post author author_detail) - associations = PostResource._lookup_association_chain(model_names) - result = PostResource.send(:_build_joins, associations) - - sql = "LEFT JOIN posts AS parent_post_sorting ON parent_post_sorting.parent_post_id = posts.id -LEFT JOIN people AS author_sorting ON author_sorting.id = posts.author_id -LEFT JOIN author_details AS author_detail_sorting ON author_detail_sorting.person_id = people.id" + associations = PersonResource._lookup_association_chain(model_names) + result = PersonResource.send(:_build_joins, [Person, *associations]) + + sql = [ + 'LEFT JOIN posts AS posts_sorting ON posts_sorting.author_id = people.id', + 'LEFT JOIN posts AS parent_post_sorting ON parent_post_sorting.id = posts_sorting.parent_post_id', + 'LEFT JOIN people AS author_sorting ON author_sorting.id = parent_post_sorting.author_id', + 'LEFT JOIN author_details AS author_detail_sorting ON author_detail_sorting.person_id = author_sorting.id' + ].join("\n") assert_equal sql, result end From 71ad6304049c47adf25701358cbcd70ac3976e4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Scott=20Gonz=C3=A1lez?= Date: Tue, 14 Nov 2017 08:08:07 -0500 Subject: [PATCH 075/237] Rename controller actions that begin with `get_` The following methods were renamed to comply with Rubocop's `Style/AccessorMethodName` rule: * `get_related_resource` -> `show_related_resource` * `get_related_resources` -> `index_related_resources` Fixes gh-1131 --- lib/jsonapi/acts_as_resource_controller.rb | 16 +++++- lib/jsonapi/request_parser.rb | 6 +-- lib/jsonapi/routing_ext.rb | 4 +- test/controllers/controller_test.rb | 50 +++++++++---------- test/integration/routes/routes_test.rb | 4 +- .../serializer/polymorphic_serializer_test.rb | 2 +- 6 files changed, 47 insertions(+), 35 deletions(-) diff --git a/lib/jsonapi/acts_as_resource_controller.rb b/lib/jsonapi/acts_as_resource_controller.rb index dbe4336e6..73617924d 100644 --- a/lib/jsonapi/acts_as_resource_controller.rb +++ b/lib/jsonapi/acts_as_resource_controller.rb @@ -51,14 +51,26 @@ def destroy_relationship process_request end - def get_related_resource + def show_related_resource process_request end - def get_related_resources + def index_related_resources process_request end + def get_related_resource + ActiveSupport::Deprecation.warn "In #{self.class.name} you exposed a `get_related_resource`"\ + " action. Please use `show_related_resource` instead." + show_related_resource + end + + def get_related_resources + ActiveSupport::Deprecation.warn "In #{self.class.name} you exposed a `get_related_resources`"\ + " action. Please use `index_related_resource` instead." + index_related_resources + end + def process_request @response_document = create_response_document diff --git a/lib/jsonapi/request_parser.rb b/lib/jsonapi/request_parser.rb index 4f54702c4..3335c3d74 100644 --- a/lib/jsonapi/request_parser.rb +++ b/lib/jsonapi/request_parser.rb @@ -37,7 +37,7 @@ def each(_response_document) def transactional? case params[:action] - when 'index', 'get_related_resource', 'get_related_resources', 'show', 'show_relationship' + when 'index', 'show_related_resource', 'index_related_resources', 'show', 'show_relationship' return false else return true @@ -80,7 +80,7 @@ def setup_index_action(params, resource_klass) ) end - def setup_get_related_resource_action(params, resource_klass) + def setup_show_related_resource_action(params, resource_klass) source_klass = Resource.resource_klass_for(params.require(:source)) source_id = source_klass.verify_key(params.require(source_klass._as_parent_key), @context) @@ -101,7 +101,7 @@ def setup_get_related_resource_action(params, resource_klass) ) end - def setup_get_related_resources_action(params, resource_klass) + def setup_index_related_resources_action(params, resource_klass) source_klass = Resource.resource_klass_for(params.require(:source)) source_id = source_klass.verify_key(params.require(source_klass._as_parent_key), @context) diff --git a/lib/jsonapi/routing_ext.rb b/lib/jsonapi/routing_ext.rb index 34e9d6ff7..2014aa374 100644 --- a/lib/jsonapi/routing_ext.rb +++ b/lib/jsonapi/routing_ext.rb @@ -221,7 +221,7 @@ def jsonapi_related_resource(*relationship) match formatted_relationship_name, controller: options[:controller], relationship: relationship.name, source: resource_type_with_module_prefix(source._type), - action: 'get_related_resource', via: [:get] + action: 'show_related_resource', via: [:get] end def jsonapi_related_resources(*relationship) @@ -238,7 +238,7 @@ def jsonapi_related_resources(*relationship) match formatted_relationship_name, controller: options[:controller], relationship: relationship.name, source: resource_type_with_module_prefix(source._type), - action: 'get_related_resources', via: [:get] + action: 'index_related_resources', via: [:get] end protected diff --git a/test/controllers/controller_test.rb b/test/controllers/controller_test.rb index 4eb26d0f4..950430493 100644 --- a/test/controllers/controller_test.rb +++ b/test/controllers/controller_test.rb @@ -1951,26 +1951,26 @@ def test_show_to_one_relationship_nil } end - def test_get_related_resources_sorted - assert_cacheable_get :get_related_resources, params: {person_id: '1001', relationship: 'posts', source:'people', sort: 'title' } + def test_index_related_resources_sorted + assert_cacheable_get :index_related_resources, params: {person_id: '1001', relationship: 'posts', source:'people', sort: 'title' } assert_response :success assert_equal 'JR How To', json_response['data'][0]['attributes']['title'] assert_equal 'New post', json_response['data'][2]['attributes']['title'] - assert_cacheable_get :get_related_resources, params: {person_id: '1001', relationship: 'posts', source:'people', sort: '-title' } + assert_cacheable_get :index_related_resources, params: {person_id: '1001', relationship: 'posts', source:'people', sort: '-title' } assert_response :success assert_equal 'New post', json_response['data'][0]['attributes']['title'] assert_equal 'JR How To', json_response['data'][2]['attributes']['title'] end - def test_get_related_resources_default_sorted - assert_cacheable_get :get_related_resources, params: {person_id: '1001', relationship: 'posts', source:'people'} + def test_index_related_resources_default_sorted + assert_cacheable_get :index_related_resources, params: {person_id: '1001', relationship: 'posts', source:'people'} assert_response :success assert_equal 'New post', json_response['data'][0]['attributes']['title'] assert_equal 'JR How To', json_response['data'][2]['attributes']['title'] end - def test_get_related_resources_has_many_filtered - assert_cacheable_get :get_related_resources, params: {person_id: '1001', relationship: 'posts', source:'people', filter: { title: 'JR How To' } } + def test_index_related_resources_has_many_filtered + assert_cacheable_get :index_related_resources, params: {person_id: '1001', relationship: 'posts', source:'people', filter: { title: 'JR How To' } } assert_response :success assert_equal 'JR How To', json_response['data'][0]['attributes']['title'] assert_equal 1, json_response['data'].size @@ -2494,8 +2494,8 @@ def test_invalid_filter_value assert_response :bad_request end - def test_invalid_filter_value_for_get_related_resources - assert_cacheable_get :get_related_resources, params: { + def test_invalid_filter_value_for_index_related_resources + assert_cacheable_get :index_related_resources, params: { hair_cut_id: 1, relationship: 'people', source: 'hair_cuts', @@ -2513,11 +2513,11 @@ def test_valid_filter_value assert_equal 'Joe Author', json_response['data'][0]['attributes']['name'] end - def test_get_related_resource_no_namespace + def test_show_related_resource_no_namespace original_config = JSONAPI.configuration.dup JSONAPI.configuration.json_key_format = :dasherized_key JSONAPI.configuration.route_format = :underscored_key - assert_cacheable_get :get_related_resource, params: {post_id: '2', relationship: 'author', source:'posts'} + assert_cacheable_get :show_related_resource, params: {post_id: '2', relationship: 'author', source:'posts'} assert_response :success assert_hash_equals( @@ -2579,19 +2579,19 @@ def test_get_related_resource_no_namespace JSONAPI.configuration = original_config end - def test_get_related_resource_includes + def test_show_related_resource_includes original_config = JSONAPI.configuration.dup JSONAPI.configuration.json_key_format = :dasherized_key JSONAPI.configuration.route_format = :underscored_key - assert_cacheable_get :get_related_resource, params: {post_id: '2', relationship: 'author', source:'posts', include: 'posts'} + assert_cacheable_get :show_related_resource, params: {post_id: '2', relationship: 'author', source:'posts', include: 'posts'} assert_response :success assert_equal 'posts', json_response['included'][0]['type'] ensure JSONAPI.configuration = original_config end - def test_get_related_resource_nil - get :get_related_resource, params: {post_id: '17', relationship: 'author', source:'posts'} + def test_show_related_resource_nil + get :show_related_resource, params: {post_id: '17', relationship: 'author', source:'posts'} assert_response :success assert_hash_equals json_response, { @@ -3390,10 +3390,10 @@ def test_books_delete_approved_comment_limited_user_using_relation_name_reflecte book_comment.delete end - def test_get_related_resources_pagination + def test_index_related_resources_pagination Api::V2::BookResource.paginator :offset - assert_cacheable_get :get_related_resources, params: {author_id: '1003', relationship: 'books', source:'api/v2/authors'} + assert_cacheable_get :index_related_resources, params: {author_id: '1003', relationship: 'books', source:'api/v2/authors'} assert_response :success assert_equal 10, json_response['data'].size assert_equal 3, json_response['links'].size @@ -3525,8 +3525,8 @@ def test_save_model_callbacks_fail end class Api::V1::MoonsControllerTest < ActionController::TestCase - def test_get_related_resource - assert_cacheable_get :get_related_resource, params: {crater_id: 'S56D', relationship: 'moon', source: "api/v1/craters"} + def test_show_related_resource + assert_cacheable_get :show_related_resource, params: {crater_id: 'S56D', relationship: 'moon', source: "api/v1/craters"} assert_response :success assert_hash_equals({ data: { @@ -3541,12 +3541,12 @@ def test_get_related_resource }, json_response) end - def test_get_related_resources_with_select_some_db_columns + def test_index_related_resources_with_select_some_db_columns Api::V1::MoonResource.paginator :paged original_config = JSONAPI.configuration.dup JSONAPI.configuration.top_level_meta_include_record_count = true JSONAPI.configuration.json_key_format = :dasherized_key - assert_cacheable_get :get_related_resources, params: {planet_id: '1', relationship: 'moons', source: 'api/v1/planets'} + assert_cacheable_get :index_related_resources, params: {planet_id: '1', relationship: 'moons', source: 'api/v1/planets'} assert_response :success assert_equal 1, json_response['meta']['record-count'] ensure @@ -3564,8 +3564,8 @@ def test_show_single assert_nil json_response['included'] end - def test_get_related_resources - assert_cacheable_get :get_related_resources, params: {moon_id: '1', relationship: 'craters', source: "api/v1/moons"} + def test_index_related_resources + assert_cacheable_get :index_related_resources, params: {moon_id: '1', relationship: 'craters', source: "api/v1/moons"} assert_response :success assert_hash_equals({ data: [ @@ -3587,9 +3587,9 @@ def test_get_related_resources }, json_response) end - def test_get_related_resources_filtered + def test_index_related_resources_filtered $test_user = Person.find(1001) - assert_cacheable_get :get_related_resources, + assert_cacheable_get :index_related_resources, params: { moon_id: '1', relationship: 'craters', diff --git a/test/integration/routes/routes_test.rb b/test/integration/routes/routes_test.rb index b2ddd9816..8d3f1ffa2 100644 --- a/test/integration/routes/routes_test.rb +++ b/test/integration/routes/routes_test.rb @@ -78,7 +78,7 @@ def test_routing_uuid # Polymorphic # ToDo: refute this routing. Polymorphic relationships can't support a shared set of filters or includes so # this this route is no longer supported - # def test_routing_polymorphic_get_related_resource + # def test_routing_polymorphic_show_related_resource # assert_routing( # { # path: '/pictures/1/imageable', @@ -88,7 +88,7 @@ def test_routing_uuid # relationship: 'imageable', # source: 'pictures', # controller: 'imageables', - # action: 'get_related_resource', + # action: 'show_related_resource', # picture_id: '1' # } # ) diff --git a/test/unit/serializer/polymorphic_serializer_test.rb b/test/unit/serializer/polymorphic_serializer_test.rb index 3ba927764..2963e51fd 100644 --- a/test/unit/serializer/polymorphic_serializer_test.rb +++ b/test/unit/serializer/polymorphic_serializer_test.rb @@ -346,7 +346,7 @@ # ) # end # -# def test_polymorphic_get_related_resource +# def test_polymorphic_show_related_resource # get '/pictures/1/imageable', headers: { 'Accept' => JSONAPI::MEDIA_TYPE } # serialized_data = JSON.parse(response.body) # assert_hash_equals( From 21dd5b61e71bb87a662b813e7363dd908e7c4c16 Mon Sep 17 00:00:00 2001 From: Denis Talakevich Date: Fri, 17 Nov 2017 12:30:26 +0200 Subject: [PATCH 076/237] Processor#show_related_resources should pass options to result if they are calculated --- lib/jsonapi/processor.rb | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/lib/jsonapi/processor.rb b/lib/jsonapi/processor.rb index 20a4265fa..0c0c906a0 100644 --- a/lib/jsonapi/processor.rb +++ b/lib/jsonapi/processor.rb @@ -186,32 +186,28 @@ def show_related_resources serializer, find_options) + opts = result_options if ((JSONAPI.configuration.top_level_meta_include_record_count) || (paginator && paginator.class.requires_record_count) || (JSONAPI.configuration.top_level_meta_include_page_count)) - record_count = source_resource.class.count_related( + opts[:record_count] = source_resource.class.count_related( source_resource.identity, relationship_type, find_options) end - if (JSONAPI.configuration.top_level_meta_include_page_count && record_count) - page_count = paginator.calculate_page_count(record_count) + if (JSONAPI.configuration.top_level_meta_include_page_count && opts[:record_count]) + opts[:page_count] = paginator.calculate_page_count(opts[:record_count]) end - pagination_params = if paginator && JSONAPI.configuration.top_level_links_include_pagination - page_options = {} - page_options[:record_count] = record_count if paginator.class.requires_record_count - paginator.links_page_params(page_options.merge(fetched_resources: resource_set)) - else - {} - end - - opts = result_options - opts.merge!(pagination_params: pagination_params) if JSONAPI.configuration.top_level_links_include_pagination - opts.merge!(record_count: record_count) if JSONAPI.configuration.top_level_meta_include_record_count - opts.merge!(page_count: page_count) if JSONAPI.configuration.top_level_meta_include_page_count + opts[:pagination_params] = if paginator && JSONAPI.configuration.top_level_links_include_pagination + page_options = {} + page_options[:record_count] = opts[:record_count] if paginator.class.requires_record_count + paginator.links_page_params(page_options.merge(fetched_resources: resource_set)) + else + {} + end return JSONAPI::RelatedResourcesSetOperationResult.new(:ok, source_resource, From 83328c47d7be313d9133e5510208df2775614c66 Mon Sep 17 00:00:00 2001 From: Ivan Rodriguez Date: Thu, 30 Nov 2017 11:48:15 -0800 Subject: [PATCH 077/237] added application backtrace feature --- lib/jsonapi/configuration.rb | 7 +++++++ lib/jsonapi/exceptions.rb | 6 ++++++ test/controllers/controller_test.rb | 23 +++++++++++++++++++++-- test/test_helper.rb | 7 ++++--- 4 files changed, 38 insertions(+), 5 deletions(-) diff --git a/lib/jsonapi/configuration.rb b/lib/jsonapi/configuration.rb index 63ad5fab1..729bc09f1 100644 --- a/lib/jsonapi/configuration.rb +++ b/lib/jsonapi/configuration.rb @@ -26,6 +26,7 @@ class Configuration :top_level_meta_page_count_key, :allow_transactions, :include_backtraces_in_errors, + :include_application_backtraces_in_errors, :exception_class_whitelist, :whitelist_all_exceptions, :always_include_to_one_linkage_data, @@ -80,6 +81,10 @@ def initialize # responses. Defaults to `false` in production, and `true` otherwise. self.include_backtraces_in_errors = !Rails.env.production? + # Whether or not to include exception application backtraces in JSONAPI error + # responses. Defaults to `false` in production, and `true` otherwise. + self.include_application_backtraces_in_errors = !Rails.env.production? + # List of classes that should not be rescued by the operations processor. # For example, if you use Pundit for authorization, you might # raise a Pundit::NotAuthorizedError at some point during operations @@ -246,6 +251,8 @@ def resource_finder=(resource_finder) attr_writer :include_backtraces_in_errors + attr_writer :include_application_backtraces_in_errors + attr_writer :exception_class_whitelist attr_writer :whitelist_all_exceptions diff --git a/lib/jsonapi/exceptions.rb b/lib/jsonapi/exceptions.rb index bb967d4fd..e589f08e0 100644 --- a/lib/jsonapi/exceptions.rb +++ b/lib/jsonapi/exceptions.rb @@ -49,6 +49,12 @@ def errors meta[:backtrace] = exception.backtrace end + if JSONAPI.configuration.include_application_backtraces_in_errors + meta ||= Hash.new + meta[:exception] ||= exception.message + meta[:application_backtrace] = exception.backtrace.select{|line| line =~ /#{Rails.root}/} + end + [create_error_object(code: JSONAPI::INTERNAL_SERVER_ERROR, status: :internal_server_error, title: I18n.t('jsonapi-resources.exceptions.internal_server_error.title', diff --git a/test/controllers/controller_test.rb b/test/controllers/controller_test.rb index 950430493..2bc5454a6 100644 --- a/test/controllers/controller_test.rb +++ b/test/controllers/controller_test.rb @@ -126,18 +126,37 @@ def test_exception_includes_backtrace_when_enabled JSONAPI.configuration.include_backtraces_in_errors = true assert_cacheable_get :index assert_response 500 - assert_includes @response.body, "backtrace", "expected backtrace in error body" + assert_includes @response.body, '"backtrace"', "expected backtrace in error body" JSONAPI.configuration.include_backtraces_in_errors = false assert_cacheable_get :index assert_response 500 - refute_includes @response.body, "backtrace", "expected backtrace in error body" + refute_includes @response.body, '"backtrace"', "expected backtrace in error body" ensure $PostProcessorRaisesErrors = false JSONAPI.configuration.include_backtraces_in_errors = original_config end + def test_exception_includes_application_backtrace_when_enabled + original_config = JSONAPI.configuration.include_application_backtraces_in_errors + $PostProcessorRaisesErrors = true + + JSONAPI.configuration.include_application_backtraces_in_errors = true + assert_cacheable_get :index + assert_response 500 + assert_includes @response.body, '"application_backtrace"', "expected application backtrace in error body" + + JSONAPI.configuration.include_application_backtraces_in_errors = false + assert_cacheable_get :index + assert_response 500 + refute_includes @response.body, '"application_backtrace"', "expected application backtrace in error body" + + ensure + $PostProcessorRaisesErrors = false + JSONAPI.configuration.include_application_backtraces_in_errors = original_config + end + def test_on_server_error_block_callback_with_exception original_config = JSONAPI.configuration.dup JSONAPI.configuration.exception_class_whitelist = [] diff --git a/test/test_helper.rb b/test/test_helper.rb index 5d79ef2e6..5e536e594 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -513,7 +513,7 @@ def assert_cacheable_get(action, *args) ActiveSupport::Notifications.subscribed(normal_query_callback, 'sql.active_record') do get action, *args end - non_caching_response = json_response_sans_backtraces + non_caching_response = json_response_sans_all_backtraces non_caching_status = response.status # Don't let all the cache-testing requests mess with assert_query_count @@ -565,7 +565,7 @@ def assert_cacheable_get(action, *args) ) assert_equal( non_caching_response.pretty_inspect, - json_response_sans_backtraces.pretty_inspect, + json_response_sans_all_backtraces.pretty_inspect, "Cache (mode: #{mode}) #{phase} response body must match normal response" ) assert_operator( @@ -605,12 +605,13 @@ def assert_cacheable_get(action, *args) private - def json_response_sans_backtraces + def json_response_sans_all_backtraces return nil if response.body.to_s.strip.empty? r = json_response.dup (r["errors"] || []).each do |err| err["meta"].delete("backtrace") if err.has_key?("meta") + err["meta"].delete("application_backtrace") if err.has_key?("meta") end return r end From 1089a4533e74e9f758c957f923968306c8e20e0f Mon Sep 17 00:00:00 2001 From: Ivan Rodriguez Date: Thu, 30 Nov 2017 23:39:37 -0800 Subject: [PATCH 078/237] added optional route formatting feature --- lib/jsonapi/link_builder.rb | 2 +- test/fixtures/active_record.rb | 14 ++++++++++++ test/test_helper.rb | 19 +++++++++++++++ test/unit/serializer/link_builder_test.rb | 28 +++++++++++++++++++++++ 4 files changed, 62 insertions(+), 1 deletion(-) diff --git a/lib/jsonapi/link_builder.rb b/lib/jsonapi/link_builder.rb index c49633d7f..13200cb44 100644 --- a/lib/jsonapi/link_builder.rb +++ b/lib/jsonapi/link_builder.rb @@ -115,7 +115,7 @@ def formatted_module_path_from_class(klass) scopes = module_scopes_from_class(klass) unless scopes.empty? - "/#{ scopes.map{ |scope| format_route(scope.to_s.underscore) }.join('/') }/" + "/#{ scopes.map{ |scope| format_route(scope.to_s.underscore) }.compact.join('/') }/" else "/" end diff --git a/test/fixtures/active_record.rb b/test/fixtures/active_record.rb index ce3fa0323..bb204cd20 100644 --- a/test/fixtures/active_record.rb +++ b/test/fixtures/active_record.rb @@ -1929,6 +1929,13 @@ class PersonResource < JSONAPI::Resource end end +module OptionalNamespace + module V1 + class PersonResource < JSONAPI::Resource + end + end +end + module MyEngine module Api module V1 @@ -1950,6 +1957,13 @@ class PersonResource < JSONAPI::Resource end end end + + module OptionalNamespace + module V1 + class PersonResource < JSONAPI::Resource + end + end + end end module ApiV2Engine diff --git a/test/test_helper.rb b/test/test_helper.rb index 5d79ef2e6..15d4570b7 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -421,6 +421,12 @@ class CatResource < JSONAPI::Resource jsonapi_resources :people end end + + namespace :optional_namespace, path: 'optional_namespace' do + namespace :v1, path: '' do + jsonapi_resources :people + end + end end ApiV2Engine::Engine.routes.draw do @@ -670,3 +676,16 @@ def unformat(value) end end end + +class OptionalRouteFormatter < JSONAPI::RouteFormatter + class << self + def format(route) + return if route == 'v1' + super + end + + def unformat(formatted_route) + super + end + end +end diff --git a/test/unit/serializer/link_builder_test.rb b/test/unit/serializer/link_builder_test.rb index a91238a7b..3dcd5774f 100644 --- a/test/unit/serializer/link_builder_test.rb +++ b/test/unit/serializer/link_builder_test.rb @@ -302,6 +302,20 @@ def test_query_link_for_regular_app_with_dasherized_scope assert_equal expected_link, builder.query_link(query) end + def test_query_link_for_regular_app_with_optional_scope + config = { + base_url: @base_url, + route_formatter: OptionalRouteFormatter, + primary_resource_klass: OptionalNamespace::V1::PersonResource + } + + query = { page: { offset: 0, limit: 12 } } + builder = JSONAPI::LinkBuilder.new(config) + expected_link = "#{ @base_url }/optional_namespace/people?page%5Blimit%5D=12&page%5Boffset%5D=0" + + assert_equal expected_link, builder.query_link(query) + end + def test_query_link_for_engine config = { base_url: @base_url, @@ -344,6 +358,20 @@ def test_query_link_for_engine_with_dasherized_scope assert_equal expected_link, builder.query_link(query) end + def test_query_link_for_engine_with_optional_scope + config = { + base_url: @base_url, + route_formatter: OptionalRouteFormatter, + primary_resource_klass: MyEngine::OptionalNamespace::V1::PersonResource + } + + query = { page: { offset: 0, limit: 12 } } + builder = JSONAPI::LinkBuilder.new(config) + expected_link = "#{ @base_url }/boomshaka/optional_namespace/people?page%5Blimit%5D=12&page%5Boffset%5D=0" + + assert_equal expected_link, builder.query_link(query) + end + def test_query_link_for_engine_with_camel_case_scope config = { base_url: @base_url, From d9ca5cec175e7bdcc950ca97c7f85e274b6f42ce Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Sun, 10 Dec 2017 11:21:15 -0500 Subject: [PATCH 079/237] Add Issue and PR templates --- .github/ISSUE_TEMPLATE.md | 24 ++++++++++++++++++++++++ .github/PULL_REQUEST_TEMPLATE.md | 26 ++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 000000000..148dd4f86 --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,24 @@ +## This issue is a (choose one): + +- [ ] Problem/bug report. +- [ ] Feature request. +- [ ] Request for support. **Note: Please try to avoid submitting issues for support requests. Use [Gitter](https://gitter.im/cerebris/jsonapi-resources) instead.** + +## Checklist before submitting: + +- [ ] I've searched for an existing issue. +- [ ] I've asked my question on [Gitter](https://gitter.im/cerebris/jsonapi-resources) and have not received a satisfactory answer. +- [ ] I've included a complete [bug report template](https://github.com/cerebris/jsonapi-resources/blob/master/lib/bug_report_templates/rails_5_master.rb). This step helps us and allows us to see the bug without trying to reproduce the problem from your description. It helps you because you will frequently detect if it's a problem specific to your project. +- [ ] The feature I'm asking for is compliant with the [JSON:API](http://jsonapi.org/) spec. + +## Description + +Choose one section below and delete the other: + +### Bug reports: + +Please review [Did you find a bug?](https://github.com/cerebris/jsonapi-resources/blob/master/README.md#did-you-find-a-bug) and replace this content with a brief summary of your issue. If you can't submit a [bug report template](https://github.com/cerebris/jsonapi-resources/blob/master/lib/bug_report_templates/rails_5_master.rb) please be as thorough as possible when describing your your description. It's helpful to indicate which version of ruby and the JR gem you are using. + +### Features: + +Please replace this line with a clear writeup of your feature request. Features that break compliance with the [JSON:API](http://jsonapi.org/) spec will probably be closed. \ No newline at end of file diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..c7c3f312a --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,26 @@ + + +### All Submissions: + +- [ ] I've checked to ensure there aren't other open [Pull Requests](https://github.com/cerebris/jsonapi-resources/pulls) for the same update/change. +- [ ] I've submitted a [ticket](https://github.com/cerebris/jsonapi-resources/issues) for my issue if one did not already exist. +- [ ] My submission passes all tests. (Please run the full test suite locally to cut down on noise from travis failures.) +- [ ] I've used Github [auto-closing keywords](https://help.github.com/articles/closing-issues-via-commit-messages/) in the commit message or the description. +- [ ] I've added/updated tests for this change. + +### New Feature Submissions: + +- [ ] I've submitted an issue that describes this feature, and received the go ahead from the maintainers. +- [ ] My submission includes new tests. +- [ ] My submission maintains compliance with [JSON:API](http://jsonapi.org/). + +### Bug fixes and Changes to Core Features: + +- [ ] I've included an explanation of what the changes do and why I'd like you to include them. +- [ ] I've provided test(s) that fails without the change. + +### Test Plan: + +### Reviewer Checklist: +- [ ] Maintains compliance with JSON:API +- [ ] Adequate test coverage exists to prevent regressions \ No newline at end of file From 6e35f3e3595279e353d227fb9a944351172c4adc Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Sun, 10 Dec 2017 11:21:26 -0500 Subject: [PATCH 080/237] Update readme --- README.md | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 913486b78..b1d42609e 100644 --- a/README.md +++ b/README.md @@ -28,25 +28,34 @@ which *should* be compatible with JSON:API compliant server implementations such Add JR to your application's `Gemfile`: - gem 'jsonapi-resources' +``` +gem 'jsonapi-resources' +``` And then execute: - $ bundle +```bash +bundle +``` Or install it yourself as: - $ gem install jsonapi-resources +```bash +gem install jsonapi-resources +``` **For further usage see the [v0.10 alpha Guide](http://jsonapi-resources.com/v0.10/guide/)** ## Contributing +1. Submit an issue describing any new features you wish it add or the bug you intend to fix 1. Fork it ( http://github.com/cerebris/jsonapi-resources/fork ) -2. Create your feature branch (`git checkout -b my-new-feature`) -3. Commit your changes (`git commit -am 'Add some feature'`) -4. Push to the branch (`git push origin my-new-feature`) -5. Create a new Pull Request +1. Create your feature branch (`git checkout -b my-new-feature`) +1. Run the full test suite (`rake test`) +1. Fix any failing tests +1. Commit your changes (`git commit -am 'Add some feature'`) +1. Push to the branch (`git push origin my-new-feature`) +1. Create a new Pull Request ## Did you find a bug? @@ -58,7 +67,7 @@ and a **code sample** or an **executable test case** demonstrating the expected * If possible, use the relevant bug report templates to create the issue. Simply copy the content of the appropriate template into a .rb file, make the necessary changes to demonstrate the issue, -and **paste the content into the issue description**: +and **paste the content into the issue description or attach as a file**: * [**Rails 5** issues](https://github.com/cerebris/jsonapi-resources/blob/master/lib/bug_report_templates/rails_5_master.rb) From 7a05c2b85dc909902ffd8a6684f12c38e9d3063a Mon Sep 17 00:00:00 2001 From: Denis Talakevich Date: Fri, 8 Sep 2017 19:02:31 +0300 Subject: [PATCH 081/237] #936 add ability to define custom sorting add tests --- .../active_relation_resource_finder.rb | 44 +++++++++++-------- lib/jsonapi/resource.rb | 41 ++++++++++++++--- test/controllers/controller_test.rb | 33 ++++++++++++++ test/fixtures/active_record.rb | 21 +++++++++ test/test_helper.rb | 1 + 5 files changed, 114 insertions(+), 26 deletions(-) diff --git a/lib/jsonapi/active_relation_resource_finder.rb b/lib/jsonapi/active_relation_resource_finder.rb index af8f3086c..af6595a01 100644 --- a/lib/jsonapi/active_relation_resource_finder.rb +++ b/lib/jsonapi/active_relation_resource_finder.rb @@ -387,28 +387,38 @@ def apply_pagination(records, paginator, order_options) records end - def apply_sort(records, order_options, _context = {}) + 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]) - - order_by_query = "#{_join_table_name(associations.last)}.#{column_name} #{direction}" - records = records.joins(joins_query).order(order_by_query) - else - field = _attribute_delegated_name(field) - records = records.order(field => direction) - end + records = apply_single_sort(records, field, direction, context) end end records end - def apply_basic_sort(records, order_options, context = {}) + def apply_single_sort(records, field, direction, context = {}) + strategy = _allowed_sort.fetch(field.to_sym, {})[:apply] + + if strategy + call_method_or_proc(strategy, records, direction, context) + else + 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]) + + order_by_query = "#{_join_table_name(associations.last)}.#{column_name} #{direction}" + records.joins(joins_query).order(order_by_query) + else + field = _attribute_delegated_name(field) + records.order(field => direction) + end + end + end + + def apply_basic_sort(records, order_options, _context = {}) if order_options.any? order_options.each_pair do |field, direction| records = records.order("#{field} #{direction}") @@ -476,11 +486,7 @@ 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 + call_method_or_proc(strategy, records, value, options) else filter = _attribute_delegated_name(filter) table_alias = options[:table_alias] diff --git a/lib/jsonapi/resource.rb b/lib/jsonapi/resource.rb index b9f502bf5..bd1c6e9f4 100644 --- a/lib/jsonapi/resource.rb +++ b/lib/jsonapi/resource.rb @@ -416,6 +416,8 @@ def inherited(subclass) 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 @@ -537,7 +539,7 @@ def model_name_for_type(key_type) end attr_accessor :_attributes, :_relationships, :_type, :_model_hints - attr_writer :_allowed_filters, :_paginator + attr_writer :_allowed_filters, :_paginator, :_allowed_sort def create(context) new(create_model, context) @@ -583,6 +585,10 @@ def attribute(attribute_name, options = {}) 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) @@ -669,6 +675,15 @@ 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 @@ -689,7 +704,7 @@ def creatable_fields(_context = nil) # 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) @@ -759,11 +774,7 @@ def verify_filter(filter, raw, context = nil) 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) @@ -774,6 +785,14 @@ def verify_filter(filter, raw, context = nil) 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 @@ -890,6 +909,10 @@ def _allowed_filters defined?(@_allowed_filters) ? @_allowed_filters : { id: {} } end + def _allowed_sort + @_allowed_sort ||= {} + end + def _paginator @_paginator ||= JSONAPI.configuration.default_paginator end @@ -963,6 +986,10 @@ 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' '' diff --git a/test/controllers/controller_test.rb b/test/controllers/controller_test.rb index 63e571af7..f89a23ca7 100644 --- a/test/controllers/controller_test.rb +++ b/test/controllers/controller_test.rb @@ -3952,4 +3952,37 @@ def test_fetch_indicators_sort_by_widgets_name assert_equal indicator_2.id.to_s, json_response['data'].first['id'] assert_equal 2, json_response['data'].size end + +end + +class RobotsControllerTest < ActionController::TestCase + + def teardown + Robot.delete_all + end + + def test_fetch_robots_with_sort_by_name + Robot.create! name: 'John', version: 1 + Robot.create! name: 'jane', version: 1 + assert_cacheable_get :index, params: {sort: 'name'} + assert_response :success + assert_equal 'John', json_response['data'].first['attributes']['name'] + end + + def test_fetch_robots_with_sort_by_lower_name + Robot.create! name: 'John', version: 1 + Robot.create! name: 'jane', version: 1 + assert_cacheable_get :index, params: {sort: 'lower_name'} + assert_response :success + assert_equal 'jane', json_response['data'].first['attributes']['name'] + end + + def test_fetch_robots_with_sort_by_version + Robot.create! name: 'John', version: 1 + Robot.create! name: 'jane', version: 2 + assert_cacheable_get :index, params: {sort: 'version'} + assert_response 400 + assert_equal 'version is not a valid sort criteria for robots', json_response['errors'].first['detail'] + end + end diff --git a/test/fixtures/active_record.rb b/test/fixtures/active_record.rb index 409fad119..b5e3dc40b 100644 --- a/test/fixtures/active_record.rb +++ b/test/fixtures/active_record.rb @@ -350,6 +350,12 @@ t.integer :indicator_id, null: false t.timestamps null: false end + + create_table :robots, force: true do |t| + t.string :name + t.integer :version + t.timestamps null: false + end end ### MODELS @@ -725,6 +731,9 @@ class Widget < ActiveRecord::Base belongs_to :indicator end +class Robot < ActiveRecord::Base +end + ### CONTROLLERS class AuthorsController < JSONAPI::ResourceControllerMetal end @@ -1021,6 +1030,9 @@ class WidgetsController < JSONAPI::ResourceController class IndicatorsController < JSONAPI::ResourceController end +class RobotsController < JSONAPI::ResourceController +end + ### RESOURCES class BaseResource < JSONAPI::Resource abstract @@ -2186,6 +2198,15 @@ class WorkerResource < JSONAPI::Resource attribute :name end +class RobotResource < ::JSONAPI::Resource + attribute :name + attribute :version, sortable: false + + sort :lower_name, apply: ->(records, direction, _context) do + records.order("LOWER(robots.name) #{direction}") + end +end + ### PORO Data - don't do this in a production app $breed_data = BreedData.new $breed_data.add(Breed.new(0, 'persian')) diff --git a/test/test_helper.rb b/test/test_helper.rb index b846c967b..57195ba98 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -400,6 +400,7 @@ class CatResource < JSONAPI::Resource jsonapi_resources :workers, only: [:show] jsonapi_resources :widgets, only: [:index] jsonapi_resources :indicators, only: [:index] + jsonapi_resources :robots, only: [:index] mount MyEngine::Engine => "/boomshaka", as: :my_engine mount ApiV2Engine::Engine => "/api_v2", as: :api_v2_engine From 4c98648fd646e104caa0d4bee3ca0906276fad76 Mon Sep 17 00:00:00 2001 From: Butch Marshall Date: Thu, 10 May 2018 23:52:59 -0400 Subject: [PATCH 082/237] Works around primary key being cast due to pluck bug --- .../active_relation_resource_finder.rb | 20 +-- test/fixtures/active_record.rb | 125 ++++++++++++++++++ test/integration/requests/request_test.rb | 61 +++++++++ test/test_helper.rb | 1 + 4 files changed, 199 insertions(+), 8 deletions(-) diff --git a/lib/jsonapi/active_relation_resource_finder.rb b/lib/jsonapi/active_relation_resource_finder.rb index af6595a01..b81624dcf 100644 --- a/lib/jsonapi/active_relation_resource_finder.rb +++ b/lib/jsonapi/active_relation_resource_finder.rb @@ -64,11 +64,11 @@ def find_fragments(filters, options = {}) records = find_records(filters, options) table_name = _model_class.table_name - pluck_fields = [concat_table_field(table_name, _primary_key)] + pluck_fields = ["#{concat_table_field(table_name, _primary_key)} AS #{table_name}_#{_primary_key}"] cache_field = attribute_to_model_field(:_cache_field) if options[:cache] if cache_field - pluck_fields << concat_table_field(table_name, cache_field[:name]) + pluck_fields << "#{concat_table_field(table_name, cache_field[:name])} AS #{table_name}_#{cache_field[:name]}" end model_fields = {} @@ -76,7 +76,7 @@ def find_fragments(filters, options = {}) attributes.try(:each) do |attribute| model_field = attribute_to_model_field(attribute) model_fields[attribute] = model_field - pluck_fields << concat_table_field(table_name, model_field[:name]) + pluck_fields << "#{concat_table_field(table_name, model_field[:name])} AS #{table_name}_#{model_field[:name]}" end fragments = {} @@ -203,13 +203,13 @@ def find_related_monomorphic_fragments(source_rids, relationship, options = {}) records = related_klass.apply_filters(records, filters, filter_options) pluck_fields = [ - primary_key_field, - concat_table_field(table_alias, related_klass._primary_key) + "#{primary_key_field} AS #{_table_name}_#{_primary_key}", + "#{concat_table_field(table_alias, related_klass._primary_key)} AS #{table_alias}_#{related_klass._primary_key}" ] cache_field = related_klass.attribute_to_model_field(:_cache_field) if options[:cache] if cache_field - pluck_fields << concat_table_field(table_alias, cache_field[:name]) + pluck_fields << "#{concat_table_field(table_alias, cache_field[:name])} AS #{table_alias}_#{cache_field[:name]}" end model_fields = {} @@ -217,7 +217,7 @@ def find_related_monomorphic_fragments(source_rids, relationship, options = {}) attributes.try(:each) do |attribute| model_field = related_klass.attribute_to_model_field(attribute) model_fields[attribute] = model_field - pluck_fields << concat_table_field(table_alias, model_field[:name]) + pluck_fields << "#{concat_table_field(table_alias, model_field[:name])} AS #{table_alias}_#{model_field[:name]}" end rows = records.pluck(*pluck_fields) @@ -263,7 +263,11 @@ def find_related_polymorphic_fragments(source_rids, relationship, options = {}) related_key = concat_table_field(_table_name, relationship.foreign_key) related_type = concat_table_field(_table_name, relationship.polymorphic_type) - pluck_fields = [primary_key, related_key, related_type] + pluck_fields = [ + "#{primary_key} AS #{_table_name}_#{_primary_key}", + "#{related_key} AS #{_table_name}_#{relationship.foreign_key}", + "#{related_type} AS #{_table_name}_#{relationship.polymorphic_type}" + ] relations = relationship.polymorphic_relations diff --git a/test/fixtures/active_record.rb b/test/fixtures/active_record.rb index b5e3dc40b..fc44e0315 100644 --- a/test/fixtures/active_record.rb +++ b/test/fixtures/active_record.rb @@ -8,6 +8,31 @@ ### DATABASE ActiveRecord::Schema.define do + create_table :sessions, id: false, force: true do |t| + t.string :id, :limit => 36, :primary_key => true, null: false + t.string :survey_id, :limit => 36, null: false + + t.timestamps + end + + create_table :responses, force: true do |t| + #t.string :id, :limit => 36, :primary_key => true, null: false + + t.string :session_id, limit: 36, null: false + + t.string :type + t.string :question_id, limit: 36 + + t.timestamps + end + + create_table :response_texts, force: true do |t| + t.text :text + t.integer :response_id + + t.timestamps + end + create_table :people, force: true do |t| t.string :name t.string :email @@ -359,6 +384,42 @@ end ### MODELS +class Session < ActiveRecord::Base + has_many :responses +end + +class Response < ActiveRecord::Base + belongs_to :session + has_one :paragraph, :class_name => "ResponseText::Paragraph" + + def response_type + case self.type + when "Response::SingleTextbox" + "single_textbox" + else + "question" + end + end + def response_type=type + self.type = case type + when "single_textbox" + "Response::SingleTextbox" + else + "Response" + end + end +end + +class Response::SingleTextbox < Response + has_one :paragraph, :class_name => "ResponseText::Paragraph", :foreign_key => :response_id +end + +class ResponseText < ActiveRecord::Base +end + +class ResponseText::Paragraph < ResponseText +end + class Person < ActiveRecord::Base has_many :posts, foreign_key: 'author_id' has_many :comments, foreign_key: 'author_id' @@ -735,6 +796,19 @@ class Robot < ActiveRecord::Base end ### CONTROLLERS +class SessionsController < ActionController::Base + include JSONAPI::ActsAsResourceController + before_action :create_responses_relationships, :only => [:create,:update] + + private + def create_responses_relationships + if !params[:data][:relationships].nil? && !params[:data][:relationships][:responses].nil? + responses_params = params[:data][:relationships].delete(:responses) + params[:data][:attributes][:responses] = responses_params + end + end +end + class AuthorsController < JSONAPI::ResourceControllerMetal end @@ -1038,6 +1112,57 @@ class BaseResource < JSONAPI::Resource abstract end +class SessionResource < JSONAPI::Resource + key_type :uuid + + attributes :survey_id, :responses + + has_many :responses + + def responses=params + params[:data].each { |datum| + response = @model.responses.build(datum[:attributes].permit(:response_type, :question_id)) + + (datum[:relationships] || {}).each_pair { |k,v| + case k + when "paragraph" + response.paragraph = ResponseText::Paragraph.create(v[:data][:attributes].permit(:text)) + end + } + } + end + def responses + end + + def self.creatable_fields(context) + super + [ + :id, + ] + end + + def fetchable_fields + super - [:responses] + end +end + +class ResponseResource < JSONAPI::Resource + model_hint model: Response::SingleTextbox, resource: :response + + has_one :session + + attributes :question_id, :response_type + + has_one :paragraph +end + +class ParagraphResource < JSONAPI::Resource + model_name 'ResponseText::Paragraph' + + attributes :text + + has_one :response +end + class PersonResource < BaseResource attributes :name, :email attribute :date_joined, format: :date_with_timezone diff --git a/test/integration/requests/request_test.rb b/test/integration/requests/request_test.rb index 4b0e4cc10..99ce0e597 100644 --- a/test/integration/requests/request_test.rb +++ b/test/integration/requests/request_test.rb @@ -20,6 +20,67 @@ def test_large_get assert_cacheable_jsonapi_get '/api/v2/books?include=book_comments,book_comments.author' end + def test_post_sessions + session_id = SecureRandom.uuid + + post '/sessions', params: { + data: { + id: session_id, + type: "sessions", + attributes: { + "survey_id": SecureRandom.uuid, + }, + relationships: { + responses: { + data: [ + { + "type": "responses", + "attributes": { + "response_type": "single_textbox", + "question_id": SecureRandom.uuid, + }, + "relationships": { + "paragraph": { + "data": { + "type": "responses", + "response_type": "paragraph", + "attributes": { + "text": "This is my single textbox response" + } + } + } + } + }, + ], + }, + }, + } + }.to_json, + headers: { + 'CONTENT_TYPE' => JSONAPI::MEDIA_TYPE, + 'Accept' => JSONAPI::MEDIA_TYPE + } + assert_jsonapi_response 201 + json_body = JSON.parse(response.body) + session_id = json_body["data"]["id"] + + # Get what we just created + get "/sessions/#{session_id}?include=responses" + assert_jsonapi_response 200 + json_body = JSON.parse(response.body) + + assert(json_body.is_a?(Object)); + assert(json_body["included"].is_a?(Array)); + assert_equal("single_textbox", json_body["included"][0]["attributes"]["response_type"]["single_textbox"]); + + get "/sessions/#{session_id}?include=responses,responses.paragraph" + assert_jsonapi_response 200 + json_body = JSON.parse(response.body) + + assert_equal("single_textbox", json_body["included"][0]["attributes"]["response_type"]["single_textbox"]); + assert_equal("paragraphs", json_body["included"][1]["type"]); + end + def test_get_inflected_resource assert_cacheable_jsonapi_get '/api/v8/numeros_telefone' end diff --git a/test/test_helper.rb b/test/test_helper.rb index 249638f94..af286c605 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -240,6 +240,7 @@ class CatResource < JSONAPI::Resource JSONAPI.configuration.route_format = :underscored_route TestApp.routes.draw do + jsonapi_resources :sessions jsonapi_resources :people jsonapi_resources :special_people jsonapi_resources :comments From d6d6cb5b077073b95df0412a9c1879f3e9912fc7 Mon Sep 17 00:00:00 2001 From: Arne Zeising Date: Wed, 20 Jun 2018 12:08:05 +0200 Subject: [PATCH 083/237] Allow pagination for nested resources but not for included resources --- lib/jsonapi/active_relation_resource_finder.rb | 9 +++++---- lib/jsonapi/processor.rb | 4 +++- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/lib/jsonapi/active_relation_resource_finder.rb b/lib/jsonapi/active_relation_resource_finder.rb index af6595a01..2701ad88c 100644 --- a/lib/jsonapi/active_relation_resource_finder.rb +++ b/lib/jsonapi/active_relation_resource_finder.rb @@ -110,13 +110,13 @@ def find_fragments(filters, options = {}) # @return [Hash{ResourceIdentity => {identity: => ResourceIdentity, cache: cache_field, attributes: => {name => value}, related: {relationship_name: [] }}}] # the ResourceInstances matching the filters, sorting, and pagination rules along with any request # additional_field values - def find_related_fragments(source_rids, relationship_name, options = {}) + def find_related_fragments(source_rids, relationship_name, options = {}, included_key = nil) relationship = _relationship(relationship_name) if relationship.polymorphic? && relationship.foreign_key_on == :self find_related_polymorphic_fragments(source_rids, relationship, options) else - find_related_monomorphic_fragments(source_rids, relationship, options) + find_related_monomorphic_fragments(source_rids, relationship, included_key, options) end end @@ -162,7 +162,7 @@ def find_records_by_keys(keys, options = {}) records.where({ _primary_key => keys }) end - def find_related_monomorphic_fragments(source_rids, relationship, options = {}) + def find_related_monomorphic_fragments(source_rids, relationship, included_key, options = {}) source_ids = source_rids.collect {|rid| rid.id} context = options[:context] @@ -185,7 +185,8 @@ def find_related_monomorphic_fragments(source_rids, relationship, options = {}) # ToDO: Remove count check. Currently pagination isn't working with multiple source_rids (i.e. it only works # for show relationships, not related includes). - if paginator && source_rids.count == 1 + # Check included_key to not paginate included resources but ensure that nested resources can be paginated + if paginator && source_rids.count == 1 && !included_key records = related_klass.apply_pagination(records, paginator, order_options) end diff --git a/lib/jsonapi/processor.rb b/lib/jsonapi/processor.rb index 13c5cbde7..68a1e0d5c 100644 --- a/lib/jsonapi/processor.rb +++ b/lib/jsonapi/processor.rb @@ -469,7 +469,9 @@ def get_related(resource_klass, source_resources, include_related, options) find_related_resource_options[:sort_criteria] = relationship.resource_klass.default_sort find_related_resource_options[:cache] = resource_klass.caching? - related_identities = resource_klass.find_related_fragments(source_rids, relationship_name, find_related_resource_options) + related_identities = resource_klass.find_related_fragments( + source_rids, relationship_name, find_related_resource_options, key + ) related_identities.each_pair do |identity, v| related[relationship_name][:resources][identity] = From abe63e57e2dedaef1b20e100f3551814dd0843b9 Mon Sep 17 00:00:00 2001 From: Arne Zeising Date: Wed, 20 Jun 2018 14:45:44 +0200 Subject: [PATCH 084/237] Add tests for included key Fixes #1162 --- .../active_relation_resource_finder_test.rb | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/test/unit/resource/active_relation_resource_finder_test.rb b/test/unit/resource/active_relation_resource_finder_test.rb index 228103689..a329a9eba 100644 --- a/test/unit/resource/active_relation_resource_finder_test.rb +++ b/test/unit/resource/active_relation_resource_finder_test.rb @@ -121,6 +121,36 @@ def test_find_related_has_many_fragments_no_attributes assert_equal 2, related_identities[JSONAPI::ResourceIdentity.new(TagResource, 502)][:related][:tags].length end + def test_find_related_has_many_fragments_pagination + params = ActionController::Parameters.new(number: 2, size: 4) + options = { paginator: PagedPaginator.new(params) } + source_rids = [JSONAPI::ResourceIdentity.new(ARPostResource, 15)] + + related_identities = ARPostResource.find_related_fragments(source_rids, 'tags', options) + + assert_equal 1, related_identities.length + assert_equal JSONAPI::ResourceIdentity.new(TagResource, 516), related_identities.keys[0] + assert_equal JSONAPI::ResourceIdentity.new(TagResource, 516), related_identities.values[0][:identity] + assert related_identities.values[0].is_a?(Hash) + assert_equal 2, related_identities.values[0].length + assert_equal 1, related_identities.values[0][:related][:tags].length + end + + def test_find_related_has_many_fragments_pagination_included_key + params = ActionController::Parameters.new(number: 2, size: 4) + options = { paginator: PagedPaginator.new(params) } + source_rids = [JSONAPI::ResourceIdentity.new(ARPostResource, 15)] + + related_identities = ARPostResource.find_related_fragments(source_rids, 'tags', options, :tags) + + assert_equal 5, related_identities.length + assert_equal JSONAPI::ResourceIdentity.new(TagResource, 502), related_identities.keys[0] + assert_equal JSONAPI::ResourceIdentity.new(TagResource, 502), related_identities.values[0][:identity] + assert related_identities.values[0].is_a?(Hash) + assert_equal 2, related_identities.values[0].length + assert_equal 1, related_identities.values[0][:related][:tags].length + end + def test_find_related_has_many_fragments_cache_field options = { cache: true } source_rids = [JSONAPI::ResourceIdentity.new(ARPostResource, 1), @@ -200,7 +230,7 @@ def test_find_related_polymorphic_fragments_cache_field end def test_find_related_polymorphic_fragments_cache_field_attributes - options = { cache: true , attributes: [:name] } + options = { cache: true, attributes: [:name] } source_rids = [JSONAPI::ResourceIdentity.new(PictureResource, 1), JSONAPI::ResourceIdentity.new(PictureResource, 2), JSONAPI::ResourceIdentity.new(PictureResource, 20)] From 6946014232464f79b67ba40f1df23cf15bdeb578 Mon Sep 17 00:00:00 2001 From: Martin Schneider Date: Mon, 2 Jul 2018 14:32:58 +0200 Subject: [PATCH 085/237] Added support for :primary_key in sorting on relationships --- .../active_relation_resource_finder.rb | 5 +- test/controllers/widget_controller_test.rb | 47 +++++++++++++++++++ test/fixtures/active_record.rb | 11 +++-- .../active_relation_resource_finder_test.rb | 2 +- 4 files changed, 57 insertions(+), 8 deletions(-) create mode 100644 test/controllers/widget_controller_test.rb diff --git a/lib/jsonapi/active_relation_resource_finder.rb b/lib/jsonapi/active_relation_resource_finder.rb index af6595a01..dab43eddc 100644 --- a/lib/jsonapi/active_relation_resource_finder.rb +++ b/lib/jsonapi/active_relation_resource_finder.rb @@ -434,10 +434,11 @@ def _build_joins(associations) associations.inject do |prev, current| prev_table_name = _join_table_name(prev) curr_table_name = _join_table_name(current) + relationship_primary_key = current.options.fetch(:primary_key, "id") if current.belongs_to? - joins << "LEFT JOIN #{current.table_name} AS #{curr_table_name} ON #{curr_table_name}.id = #{prev_table_name}.#{current.foreign_key}" + joins << "LEFT JOIN #{current.table_name} AS #{curr_table_name} ON #{curr_table_name}.#{relationship_primary_key} = #{prev_table_name}.#{current.foreign_key}" else - joins << "LEFT JOIN #{current.table_name} AS #{curr_table_name} ON #{curr_table_name}.#{current.foreign_key} = #{prev_table_name}.id" + joins << "LEFT JOIN #{current.table_name} AS #{curr_table_name} ON #{curr_table_name}.#{current.foreign_key} = #{prev_table_name}.#{relationship_primary_key}" end current diff --git a/test/controllers/widget_controller_test.rb b/test/controllers/widget_controller_test.rb new file mode 100644 index 000000000..6edfaee01 --- /dev/null +++ b/test/controllers/widget_controller_test.rb @@ -0,0 +1,47 @@ +require File.expand_path('../../test_helper', __FILE__) + +def set_content_type_header! + @request.headers['Content-Type'] = JSONAPI::MEDIA_TYPE +end + +class WidgetsControllerTest < ActionController::TestCase + def teardown + Widget.delete_all + Indicator.delete_all + Agency.delete_all + end + + def test_fetch_widgets_sort_by_agency_name + agency_1 = Agency.create! name: 'beta' + agency_2 = Agency.create! name: 'alpha' + indicator_1 = Indicator.create! import_id: 'foobar', name: 'bar', agency: agency_1 + indicator_2 = Indicator.create! import_id: 'foobar2', name: 'foo', agency: agency_2 + Widget.create! name: 'bar', indicator: indicator_1 + widget = Widget.create! name: 'foo', indicator: indicator_2 + assert_cacheable_get :index, params: {sort: 'indicator.agency.name'} + assert_response :success + assert_equal widget.id.to_s, json_response['data'].first['id'] + end +end + +class IndicatorsControllerTest < ActionController::TestCase + def teardown + Widget.delete_all + Indicator.delete_all + Agency.delete_all + end + + def test_fetch_indicators_sort_by_widgets_name + agency = Agency.create! name: 'test' + indicator_1 = Indicator.create! import_id: 'bar', name: 'bar', agency: agency + indicator_2 = Indicator.create! import_id: 'foo', name: 'foo', agency: agency + Widget.create! name: 'omega', indicator: indicator_1 + Widget.create! name: 'beta', indicator: indicator_1 + Widget.create! name: 'alpha', indicator: indicator_2 + Widget.create! name: 'zeta', indicator: indicator_2 + assert_cacheable_get :index, params: {sort: 'widgets.name'} + assert_response :success + assert_equal indicator_2.id.to_s, json_response['data'].first['id'] + assert_equal 2, json_response['data'].size + end +end diff --git a/test/fixtures/active_record.rb b/test/fixtures/active_record.rb index b5e3dc40b..f7f48b089 100644 --- a/test/fixtures/active_record.rb +++ b/test/fixtures/active_record.rb @@ -341,13 +341,14 @@ create_table :indicators, force: true do |t| t.string :name + t.string :import_id t.integer :agency_id, null: false t.timestamps null: false end create_table :widgets, force: true do |t| t.string :name - t.integer :indicator_id, null: false + t.string :indicator_import_id, null: false t.timestamps null: false end @@ -724,11 +725,11 @@ class Agency < ActiveRecord::Base class Indicator < ActiveRecord::Base belongs_to :agency - has_many :widgets + has_many :widgets, primary_key: :import_id, foreign_key: :indicator_import_id end class Widget < ActiveRecord::Base - belongs_to :indicator + belongs_to :indicator, primary_key: :import_id, foreign_key: :indicator_import_id end class Robot < ActiveRecord::Base @@ -2046,7 +2047,7 @@ class AgencyResource < JSONAPI::Resource class IndicatorResource < JSONAPI::Resource attributes :name has_one :agency - has_many :widgets + has_many :widgets, foreign_key: :indicator_import_id, primary_key: :import_id def self.sortable_fields(_context = nil) super + [:'widgets.name'] @@ -2055,7 +2056,7 @@ def self.sortable_fields(_context = nil) class WidgetResource < JSONAPI::Resource attributes :name - has_one :indicator + has_one :indicator, foreign_key: :indicator_import_id, primary_key: :import_id def self.sortable_fields(_context = nil) super + [:'indicator.agency.name'] diff --git a/test/unit/resource/active_relation_resource_finder_test.rb b/test/unit/resource/active_relation_resource_finder_test.rb index 228103689..b0d8afdaf 100644 --- a/test/unit/resource/active_relation_resource_finder_test.rb +++ b/test/unit/resource/active_relation_resource_finder_test.rb @@ -4,7 +4,7 @@ class ARPostResource < JSONAPI::Resource model_name 'Post' attribute :headline, delegate: :title has_one :author - has_many :tags + has_many :tags, primary_key: :tags_import_id end class ActiveRelationResourceFinderTest < ActiveSupport::TestCase From fca75eccb232e224cdfb12b4af5e919d8232abb6 Mon Sep 17 00:00:00 2001 From: Arne Zeising Date: Thu, 5 Jul 2018 14:44:49 +0200 Subject: [PATCH 086/237] Remove checking for existing relationships --- lib/jsonapi/resource.rb | 7 ------- 1 file changed, 7 deletions(-) diff --git a/lib/jsonapi/resource.rb b/lib/jsonapi/resource.rb index bd1c6e9f4..e69712c74 100644 --- a/lib/jsonapi/resource.rb +++ b/lib/jsonapi/resource.rb @@ -238,14 +238,7 @@ def reflect_relationship?(relationship, options) 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 From 24c724979dd64771401b8ffd0aba9a8d9f337808 Mon Sep 17 00:00:00 2001 From: Arne Zeising Date: Thu, 5 Jul 2018 15:41:26 +0200 Subject: [PATCH 087/237] Skip adding relationship if it already exists --- lib/jsonapi/resource.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/jsonapi/resource.rb b/lib/jsonapi/resource.rb index e69712c74..1cc081d25 100644 --- a/lib/jsonapi/resource.rb +++ b/lib/jsonapi/resource.rb @@ -266,7 +266,9 @@ def _create_to_many_links(relationship_type, relationship_key_values, options) 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 From 5f4c1a22891a2d165bad532331265f4ef929b231 Mon Sep 17 00:00:00 2001 From: Arne Zeising Date: Thu, 5 Jul 2018 16:10:08 +0200 Subject: [PATCH 088/237] Adapt test for adding existing relationships --- test/controllers/controller_test.rb | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/controllers/controller_test.rb b/test/controllers/controller_test.rb index 1b19e7efb..a2bddffe2 100644 --- a/test/controllers/controller_test.rb +++ b/test/controllers/controller_test.rb @@ -1508,8 +1508,9 @@ def test_create_relationship_to_many_join_table_record_exists post :create_relationship, params: {post_id: 3, relationship: 'tags', data: [{type: 'tags', id: 502}, {type: 'tags', id: 505}]} - assert_response :bad_request - assert_match /The relation to 502 already exists./, response.body + assert_response :no_content + post_object.reload + assert_equal [502,503,505], post_object.tag_ids end def test_update_relationship_to_many_missing_tags From 60bdd2eca97d692718a921f78d243e481cc0d67d Mon Sep 17 00:00:00 2001 From: Arne Zeising Date: Thu, 5 Jul 2018 16:28:11 +0200 Subject: [PATCH 089/237] Remove now superfluous error --- lib/jsonapi/exceptions.rb | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/lib/jsonapi/exceptions.rb b/lib/jsonapi/exceptions.rb index e589f08e0..58dbab5b8 100644 --- a/lib/jsonapi/exceptions.rb +++ b/lib/jsonapi/exceptions.rb @@ -141,26 +141,6 @@ def errors end end - - class HasManyRelationExists < Error - attr_accessor :id - - def initialize(id, error_object_overrides = {}) - @id = id - super(error_object_overrides) - end - - def errors - [create_error_object(code: JSONAPI::RELATION_EXISTS, - status: :bad_request, - title: I18n.translate('jsonapi-resources.exceptions.has_many_relation.title', - default: 'Relation exists'), - detail: I18n.translate('jsonapi-resources.exceptions.has_many_relation.detail', - default: "The relation to #{id} already exists.", - id: id))] - end - end - class BadRequest < Error def initialize(exception, error_object_overrides = {}) @exception = exception From a51ea6214cc6b8b81442dbc2517193cfca5ef295 Mon Sep 17 00:00:00 2001 From: Arne Zeising Date: Thu, 5 Jul 2018 16:29:26 +0200 Subject: [PATCH 090/237] Remove now superfluous translations --- locales/en.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/locales/en.yml b/locales/en.yml index d1dfccc01..7425f89ef 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -16,9 +16,6 @@ en: unsupported_media_type: title: 'Unsupported media type' detail: "All requests that create or update must use the '%{needed_media_type}' Content-Type. This request specified '%{media_type}.'" - has_many_relation: - title: 'Relation exists' - detail: "The relation to %{id} already exists." to_many_set_replacement_forbidden: title: 'Complete replacement forbidden' detail: 'Complete replacement forbidden for this relationship' From 1ee9cc81d5e51f1e768683159f13f6ccdd369d8c Mon Sep 17 00:00:00 2001 From: Butch Marshall Date: Mon, 9 Jul 2018 22:43:39 -0400 Subject: [PATCH 091/237] Fixed Rails 4.x branch --- test/fixtures/active_record.rb | 5 ++-- test/integration/requests/request_test.rb | 28 ++++++++++++----------- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/test/fixtures/active_record.rb b/test/fixtures/active_record.rb index fc44e0315..7ea3e774c 100644 --- a/test/fixtures/active_record.rb +++ b/test/fixtures/active_record.rb @@ -385,6 +385,7 @@ ### MODELS class Session < ActiveRecord::Base + self.primary_key = "id" has_many :responses end @@ -1121,12 +1122,12 @@ class SessionResource < JSONAPI::Resource def responses=params params[:data].each { |datum| - response = @model.responses.build(datum[:attributes].permit(:response_type, :question_id)) + response = @model.responses.build(((datum[:attributes].respond_to?(:permit))? datum[:attributes].permit(:response_type, :question_id) : datum[:attributes])) (datum[:relationships] || {}).each_pair { |k,v| case k when "paragraph" - response.paragraph = ResponseText::Paragraph.create(v[:data][:attributes].permit(:text)) + response.paragraph = ResponseText::Paragraph.create(((v[:data][:attributes].respond_to?(:permit))? v[:data][:attributes].permit(:text) : v[:data][:attributes])) end } } diff --git a/test/integration/requests/request_test.rb b/test/integration/requests/request_test.rb index 99ce0e597..69a65abc2 100644 --- a/test/integration/requests/request_test.rb +++ b/test/integration/requests/request_test.rb @@ -28,24 +28,24 @@ def test_post_sessions id: session_id, type: "sessions", attributes: { - "survey_id": SecureRandom.uuid, + survey_id: SecureRandom.uuid, }, relationships: { responses: { data: [ { - "type": "responses", - "attributes": { - "response_type": "single_textbox", - "question_id": SecureRandom.uuid, + type: "responses", + attributes: { + response_type: "single_textbox", + question_id: SecureRandom.uuid, }, - "relationships": { - "paragraph": { - "data": { - "type": "responses", - "response_type": "paragraph", - "attributes": { - "text": "This is my single textbox response" + relationships: { + paragraph: { + data: { + type: "responses", + response_type: "paragraph", + attributes: { + text: "This is my single textbox response" } } } @@ -78,7 +78,9 @@ def test_post_sessions json_body = JSON.parse(response.body) assert_equal("single_textbox", json_body["included"][0]["attributes"]["response_type"]["single_textbox"]); - assert_equal("paragraphs", json_body["included"][1]["type"]); + + # Rails 4.2.x branch will not retrieve the responses.paragraph, 5.x branch will - this looks to be a deeper, but unrelated bug + #assert_equal("paragraphs", json_body["included"][1]["type"]); end def test_get_inflected_resource From 565d4b6edbc75eee5f8d608f76306129dff3fd65 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Thu, 30 Aug 2018 15:18:00 -0400 Subject: [PATCH 092/237] Use `destroy` instead of `delete` to ensure callbacks are called --- lib/jsonapi/resource.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/jsonapi/resource.rb b/lib/jsonapi/resource.rb index 1cc081d25..5d960a0bd 100644 --- a/lib/jsonapi/resource.rb +++ b/lib/jsonapi/resource.rb @@ -341,7 +341,7 @@ def _remove_to_many_link(relationship_type, key, options) @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 From a57a82cb09027f9f7187eafe8d8d2f1450c66a99 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Mon, 1 Oct 2018 08:32:02 -0400 Subject: [PATCH 093/237] Update ruby and rails versions for travis tests --- .travis.yml | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/.travis.yml b/.travis.yml index bcc2e9510..5d80a0a33 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,22 +1,15 @@ language: ruby sudo: false env: - - "RAILS_VERSION=4.2.8" - - "RAILS_VERSION=5.0.2" - - "RAILS_VERSION=5.1.0" + - "RAILS_VERSION=4.2.10" + - "RAILS_VERSION=5.0.7" + - "RAILS_VERSION=5.1.6" + - "RAILS_VERSION=5.2.1" - "RAILS_VERSION=master" rvm: - - 2.1.10 - - 2.2.7 - - 2.3.4 - - 2.4.1 + - 2.3.7 + - 2.4.4 + - 2.5.1 matrix: - exclude: - - rvm: 2.1.10 - env: "RAILS_VERSION=5.0.2" - - rvm: 2.1.10 - env: "RAILS_VERSION=5.1.0" - - rvm: 2.1.10 - env: "RAILS_VERSION=master" allow_failures: - env: "RAILS_VERSION=master" From cffb8c1eff1654ba5ffa4a2d8891bde4e4373631 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Tue, 8 Aug 2017 08:42:13 -0400 Subject: [PATCH 094/237] Deprecates and replaces the `allow_include` config option Replaces `allow_includes` with `default_allow_include_to_one` and `default_allow_include_to_many` configuration options. Adds support for `allow_include` option on Relationships to override the config settings. --- lib/jsonapi/configuration.rb | 14 ++++- lib/jsonapi/exceptions.rb | 2 +- lib/jsonapi/relationship.rb | 19 +++++- lib/jsonapi/request_parser.rb | 8 +-- locales/en.yml | 2 +- test/controllers/controller_test.rb | 11 ++-- test/integration/requests/request_test.rb | 43 ++++++++++++- .../jsonapi_request/jsonapi_request_test.rb | 2 +- test/unit/resource/relationship_test.rb | 63 +++++++++++++++++++ 9 files changed, 146 insertions(+), 18 deletions(-) diff --git a/lib/jsonapi/configuration.rb b/lib/jsonapi/configuration.rb index 729bc09f1..0a5b7ac6b 100644 --- a/lib/jsonapi/configuration.rb +++ b/lib/jsonapi/configuration.rb @@ -10,7 +10,8 @@ class Configuration :route_format, :raise_if_parameters_not_allowed, :warn_on_route_setup_issues, - :allow_include, + :default_allow_include_to_one, + :default_allow_include_to_many, :allow_sort, :allow_filter, :default_paginator, @@ -50,7 +51,8 @@ def initialize self.resource_key_type = :integer # optional request features - self.allow_include = true + self.default_allow_include_to_one = true + self.default_allow_include_to_many = true self.allow_sort = true self.allow_filter = true @@ -227,7 +229,13 @@ def resource_finder=(resource_finder) @resource_finder = resource_finder end - attr_writer :allow_include, :allow_sort, :allow_filter + def allow_include=(allow_include) + ActiveSupport::Deprecation.warn('`allow_include` has been replaced by `default_allow_include_to_one` and `default_allow_include_to_many` options.') + @default_allow_include_to_one = allow_include + @default_allow_include_to_many = allow_include + end + + attr_writer :allow_sort, :allow_filter, :default_allow_include_to_one, :default_allow_include_to_many attr_writer :default_paginator diff --git a/lib/jsonapi/exceptions.rb b/lib/jsonapi/exceptions.rb index 58dbab5b8..323915525 100644 --- a/lib/jsonapi/exceptions.rb +++ b/lib/jsonapi/exceptions.rb @@ -342,7 +342,7 @@ def errors title: I18n.translate('jsonapi-resources.exceptions.invalid_include.title', default: 'Invalid field'), detail: I18n.translate('jsonapi-resources.exceptions.invalid_include.detail', - default: "#{relationship} is not a valid relationship of #{resource}", + default: "#{relationship} is not a valid includable relationship of #{resource}", relationship: relationship, resource: resource))] end end diff --git a/lib/jsonapi/relationship.rb b/lib/jsonapi/relationship.rb index 4449742be..561367220 100644 --- a/lib/jsonapi/relationship.rb +++ b/lib/jsonapi/relationship.rb @@ -3,7 +3,7 @@ class Relationship attr_reader :acts_as_set, :foreign_key, :options, :name, :class_name, :polymorphic, :always_include_linkage_data, :parent_resource, :eager_load_on_include, :custom_methods, - :inverse_relationship + :inverse_relationship, :allow_include def initialize(name, options = {}) @name = name.to_s @@ -17,6 +17,7 @@ def initialize(name, options = {}) @polymorphic_relations = options[:polymorphic_relations] @always_include_linkage_data = options.fetch(:always_include_linkage_data, false) == true @eager_load_on_include = options.fetch(:eager_load_on_include, true) == true + @allow_include = options[:allow_include] end alias_method :polymorphic?, :polymorphic @@ -100,6 +101,14 @@ def belongs_to? def polymorphic_type "#{name}_type" if polymorphic? end + + def allow_include? + if @allow_include.nil? + JSONAPI.configuration.default_allow_include_to_one + else + @allow_include + end + end end class ToMany < Relationship @@ -114,6 +123,14 @@ def initialize(name, options = {}) @inverse_relationship = options.fetch(:inverse_relationship, parent_resource._type.to_s.singularize.to_sym) end end + + def allow_include? + if @allow_include.nil? + JSONAPI.configuration.default_allow_include_to_one + else + @allow_include + end + end end end end diff --git a/lib/jsonapi/request_parser.rb b/lib/jsonapi/request_parser.rb index 3335c3d74..d10c424db 100644 --- a/lib/jsonapi/request_parser.rb +++ b/lib/jsonapi/request_parser.rb @@ -330,6 +330,10 @@ def check_include(resource_klass, include_parts) relationship = resource_klass._relationship(relationship_name) if relationship && format_key(relationship_name) == include_parts.first + unless relationship.allow_include? + fail JSONAPI::Exceptions::InvalidInclude.new(format_key(resource_klass._type), include_parts.first) + end + unless include_parts.last.empty? check_include(Resource.resource_klass_for(resource_klass.module_path + relationship.class_name.to_s.underscore), include_parts.last.partition('.')) @@ -342,10 +346,6 @@ def check_include(resource_klass, include_parts) def parse_include_directives(resource_klass, raw_include) return unless raw_include - unless JSONAPI.configuration.allow_include - fail JSONAPI::Exceptions::ParameterNotAllowed.new(:include) - end - included_resources = [] begin included_resources += raw_include.is_a?(Array) ? raw_include : CSV.parse_line(raw_include) || [] diff --git a/locales/en.yml b/locales/en.yml index 7425f89ef..ee210f9dd 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -48,7 +48,7 @@ en: detail: "%{field} is not a valid field for %{type}." invalid_include: title: 'Invalid include' - detail: "%{relationship} is not a valid relationship of %{resource}" + detail: "%{relationship} is not a valid includable relationship of %{resource}" invalid_sort_criteria: title: 'Invalid sort criteria' detail: "%{sort_criteria} is not a valid sort criteria for %{resource}" diff --git a/test/controllers/controller_test.rb b/test/controllers/controller_test.rb index a2bddffe2..f4823a62a 100644 --- a/test/controllers/controller_test.rb +++ b/test/controllers/controller_test.rb @@ -551,11 +551,12 @@ def test_show_single_with_includes end def test_show_single_with_include_disallowed + original_config = JSONAPI.configuration.dup JSONAPI.configuration.allow_include = false assert_cacheable_get :show, params: {id: '1', include: 'comments'} assert_response :bad_request ensure - JSONAPI.configuration.allow_include = true + JSONAPI.configuration = original_config end def test_show_single_with_fields @@ -2122,25 +2123,25 @@ def test_expense_entries_show_include def test_expense_entries_show_bad_include_missing_relationship assert_cacheable_get :show, params: {id: 1, include: 'isoCurrencies,employees'} assert_response :bad_request - assert_match /isoCurrencies is not a valid relationship of expenseEntries/, json_response['errors'][0]['detail'] + assert_match /isoCurrencies is not a valid includable relationship of expenseEntries/, json_response['errors'][0]['detail'] end def test_expense_entries_show_bad_include_missing_sub_relationship assert_cacheable_get :show, params: {id: 1, include: 'isoCurrency,employee.post'} assert_response :bad_request - assert_match /post is not a valid relationship of employees/, json_response['errors'][0]['detail'] + assert_match /post is not a valid includable relationship of employees/, json_response['errors'][0]['detail'] end def test_invalid_include assert_cacheable_get :index, params: {include: 'invalid../../../../'} assert_response :bad_request - assert_match /invalid is not a valid relationship of expenseEntries/, json_response['errors'][0]['detail'] + assert_match /invalid is not a valid includable relationship of expenseEntries/, json_response['errors'][0]['detail'] end def test_invalid_include_long_garbage_string assert_cacheable_get :index, params: {include: 'invalid.foo.bar.dfsdfs,dfsdfs.sdfwe.ewrerw.erwrewrew'} assert_response :bad_request - assert_match /invalid is not a valid relationship of expenseEntries/, json_response['errors'][0]['detail'] + assert_match /invalid is not a valid includable relationship of expenseEntries/, json_response['errors'][0]['detail'] end def test_expense_entries_show_fields diff --git a/test/integration/requests/request_test.rb b/test/integration/requests/request_test.rb index 69a65abc2..437ff1a13 100644 --- a/test/integration/requests/request_test.rb +++ b/test/integration/requests/request_test.rb @@ -1125,14 +1125,53 @@ def test_include_parameter_allowed assert_cacheable_jsonapi_get '/api/v2/books/1/book_comments?include=author' end - def test_include_parameter_not_allowed + def test_deprecated_include_parameter_not_allowed + original_config = JSONAPI.configuration.dup JSONAPI.configuration.allow_include = false get '/api/v2/books/1/book_comments?include=author', headers: { 'Accept' => JSONAPI::MEDIA_TYPE } assert_jsonapi_response 400 ensure - JSONAPI.configuration.allow_include = true + JSONAPI.configuration = original_config + end + + def test_deprecated_include_message + ActiveSupport::Deprecation.silenced = false + original_config = JSONAPI.configuration.dup + _out, err = capture_io do + eval <<-CODE + JSONAPI.configuration.allow_include = false + CODE + end + assert_match /DEPRECATION WARNING: `allow_include` has been replaced by `default_allow_include_to_one` and `default_allow_include_to_many` options./, err + ensure + JSONAPI.configuration = original_config + ActiveSupport::Deprecation.silenced = true + end + + + def test_to_one_include_parameter_not_allowed + original_config = JSONAPI.configuration.dup + JSONAPI.configuration.default_allow_include_to_one = false + get '/api/v2/books/1/book_comments?include=author', headers: { + 'Accept' => JSONAPI::MEDIA_TYPE + } + assert_jsonapi_response 400 + ensure + JSONAPI.configuration = original_config + end + + def test_to_one_include_parameter_allowed + original_config = JSONAPI.configuration.dup + JSONAPI.configuration.default_allow_include_to_one = true + get '/api/v2/books/1/book_comments?include=author', headers: { + 'Accept' => JSONAPI::MEDIA_TYPE + } + assert_jsonapi_response 200 + assert_equal 1, json_response['included'].size + ensure + JSONAPI.configuration = original_config end def test_filter_parameter_not_allowed diff --git a/test/unit/jsonapi_request/jsonapi_request_test.rb b/test/unit/jsonapi_request/jsonapi_request_test.rb index 5e05611b4..598040beb 100644 --- a/test/unit/jsonapi_request/jsonapi_request_test.rb +++ b/test/unit/jsonapi_request/jsonapi_request_test.rb @@ -87,7 +87,7 @@ def test_parse_dasherized_with_underscored_include request.parse_include_directives(ExpenseEntryResource, params[:include]) refute request.errors.empty? - assert_equal 'iso_currency is not a valid relationship of expense-entries', request.errors[0].detail + assert_equal 'iso_currency is not a valid includable relationship of expense-entries', request.errors[0].detail end def test_parse_fields_underscored diff --git a/test/unit/resource/relationship_test.rb b/test/unit/resource/relationship_test.rb index 8208933e4..04d7cc17d 100644 --- a/test/unit/resource/relationship_test.rb +++ b/test/unit/resource/relationship_test.rb @@ -9,4 +9,67 @@ def test_polymorphic_type assert_equal(relationship.polymorphic_type, "imageable_type") end + def test_allow_include_not_set_defaults_to_config_to_one + original_config = JSONAPI.configuration.dup + + JSONAPI.configuration.default_allow_include_to_one = true + relationship = JSONAPI::Relationship::ToOne.new("foo") + assert(relationship.allow_include?) + + JSONAPI.configuration.default_allow_include_to_one = false + relationship = JSONAPI::Relationship::ToOne.new("foo") + refute(relationship.allow_include?) + + ensure + JSONAPI.configuration = original_config + end + + def test_allow_include_not_set_defaults_to_config_to_many + original_config = JSONAPI.configuration.dup + + JSONAPI.configuration.default_allow_include_to_many = true + relationship = JSONAPI::Relationship::ToMany.new("foobar") + assert(relationship.allow_include?) + + JSONAPI.configuration.default_allow_include_to_one = false + relationship = JSONAPI::Relationship::ToOne.new("foobar") + refute(relationship.allow_include?) + + ensure + JSONAPI.configuration = original_config + end + + def test_allow_include_set_overrides_to_config_to_one + original_config = JSONAPI.configuration.dup + + JSONAPI.configuration.default_allow_include_to_one = true + relationship1 = JSONAPI::Relationship::ToOne.new("foo1", allow_include: false) + relationship2 = JSONAPI::Relationship::ToOne.new("foo2", allow_include: true) + refute(relationship1.allow_include?) + assert(relationship2.allow_include?) + + JSONAPI.configuration.default_allow_include_to_one = false + refute(relationship1.allow_include?) + assert(relationship2.allow_include?) + + ensure + JSONAPI.configuration = original_config + end + + def test_allow_include_set_overrides_to_config_to_many + original_config = JSONAPI.configuration.dup + + JSONAPI.configuration.default_allow_include_to_one = true + relationship1 = JSONAPI::Relationship::ToMany.new("foobar1", allow_include: false) + relationship2 = JSONAPI::Relationship::ToMany.new("foobar2", allow_include: true) + refute(relationship1.allow_include?) + assert(relationship2.allow_include?) + + JSONAPI.configuration.default_allow_include_to_one = false + refute(relationship1.allow_include?) + assert(relationship2.allow_include?) + + ensure + JSONAPI.configuration = original_config + end end From b6a05fb874b85cbb92ab5cf12f2520cddab94297 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Mon, 1 Oct 2018 13:58:52 -0400 Subject: [PATCH 095/237] Add support for lambda and callables for allow_include? --- lib/jsonapi/relationship.rb | 33 +++++++++++++++++------ lib/jsonapi/request_parser.rb | 2 +- test/unit/resource/relationship_test.rb | 35 +++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 9 deletions(-) diff --git a/lib/jsonapi/relationship.rb b/lib/jsonapi/relationship.rb index 561367220..3928c9f3f 100644 --- a/lib/jsonapi/relationship.rb +++ b/lib/jsonapi/relationship.rb @@ -102,11 +102,19 @@ def polymorphic_type "#{name}_type" if polymorphic? end - def allow_include? - if @allow_include.nil? - JSONAPI.configuration.default_allow_include_to_one + def allow_include?(context = nil) + strategy = if @allow_include.nil? + JSONAPI.configuration.default_allow_include_to_one + else + @allow_include + end + + if !!strategy == strategy #check for boolean + return strategy + elsif strategy.is_a?(Symbol) || strategy.is_a?(String) + parent_resource.send(strategy, context) else - @allow_include + strategy.call(context) end end end @@ -124,12 +132,21 @@ def initialize(name, options = {}) end end - def allow_include? - if @allow_include.nil? - JSONAPI.configuration.default_allow_include_to_one + def allow_include?(context = nil) + strategy = if @allow_include.nil? + JSONAPI.configuration.default_allow_include_to_one + else + @allow_include + end + + if !!strategy == strategy #check for boolean + return strategy + elsif strategy.is_a?(Symbol) || strategy.is_a?(String) + parent_resource.send(strategy, context) else - @allow_include + strategy.call(context) end + end end end diff --git a/lib/jsonapi/request_parser.rb b/lib/jsonapi/request_parser.rb index d10c424db..4bb29c639 100644 --- a/lib/jsonapi/request_parser.rb +++ b/lib/jsonapi/request_parser.rb @@ -330,7 +330,7 @@ def check_include(resource_klass, include_parts) relationship = resource_klass._relationship(relationship_name) if relationship && format_key(relationship_name) == include_parts.first - unless relationship.allow_include? + unless relationship.allow_include?(context) fail JSONAPI::Exceptions::InvalidInclude.new(format_key(resource_klass._type), include_parts.first) end diff --git a/test/unit/resource/relationship_test.rb b/test/unit/resource/relationship_test.rb index 04d7cc17d..3a793e706 100644 --- a/test/unit/resource/relationship_test.rb +++ b/test/unit/resource/relationship_test.rb @@ -1,5 +1,23 @@ require File.expand_path('../../../test_helper', __FILE__) +class LambdaBlogPostsResource < JSONAPI::Resource + model_name 'Post' + + has_one :author, allow_include: -> (context) { context[:admin] } + has_many :comments, allow_include: -> (context) { context[:admin] } +end + +class CallableBlogPostsResource < JSONAPI::Resource + model_name 'Post' + + has_one :author, allow_include: :is_admin + has_many :comments, allow_include: :is_admin + + def self.is_admin(context) + context[:admin] + end +end + class HasOneRelationshipTest < ActiveSupport::TestCase def test_polymorphic_type @@ -72,4 +90,21 @@ def test_allow_include_set_overrides_to_config_to_many ensure JSONAPI.configuration = original_config end + + def test_allow_include_set_by_lambda + assert LambdaBlogPostsResource._relationship(:author).allow_include?(admin: true) + refute LambdaBlogPostsResource._relationship(:author).allow_include?(admin: false) + + assert LambdaBlogPostsResource._relationship(:comments).allow_include?(admin: true) + refute LambdaBlogPostsResource._relationship(:comments).allow_include?(admin: false) + end + + def test_allow_include_set_by_callable + assert CallableBlogPostsResource._relationship(:author).allow_include?(admin: true) + refute CallableBlogPostsResource._relationship(:author).allow_include?(admin: false) + + assert CallableBlogPostsResource._relationship(:comments).allow_include?(admin: true) + refute CallableBlogPostsResource._relationship(:comments).allow_include?(admin: false) + end + end From 1b12a00e3ba38338ea2190f282b93dce64e31a1e Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Mon, 29 Oct 2018 18:31:25 -0400 Subject: [PATCH 096/237] Introduce `Resource.retrieve_records` Allows the processor to retrieve records without further filtering, which will have taken place earlier in the `find_fragments` and `find_related_fragments`. This fixes issues where `records` has been overridden and interferes with getting included records (and already filtered by the find fragment step). --- lib/jsonapi/processor.rb | 10 +++------- lib/jsonapi/resource.rb | 4 ++++ test/fixtures/active_record.rb | 4 ++++ 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/lib/jsonapi/processor.rb b/lib/jsonapi/processor.rb index 68a1e0d5c..b270c0219 100644 --- a/lib/jsonapi/processor.rb +++ b/lib/jsonapi/processor.rb @@ -578,14 +578,10 @@ def populate_resource_set(resource_set, serializer, find_options) # fill in the missed resources, it there are any unless missed_ids.empty? - filters = {resource_klass._primary_key => missed_ids} - find_opts = { - context: context, - fields: find_options[:fields] } + missed_records = resource_klass.retrieve_records(missed_ids, find_options) + missed_resources = resource_klass.resources_for(missed_records, context) - found_resources = resource_klass.find(filters, find_opts) - - found_resources.each do |resource| + missed_resources.each do |resource| relationship_data = resource_set[resource_klass][resource.id][:relationships] if resource_klass.caching? diff --git a/lib/jsonapi/resource.rb b/lib/jsonapi/resource.rb index 5d960a0bd..b794bb344 100644 --- a/lib/jsonapi/resource.rb +++ b/lib/jsonapi/resource.rb @@ -732,6 +732,10 @@ def records(options = {}) _model_class.all end + def retrieve_records(ids, options = {}) + _model_class.where(_primary_key => ids) + end + def resources_for(records, context) records.collect do |record| resource_for(record, context) diff --git a/test/fixtures/active_record.rb b/test/fixtures/active_record.rb index b54d486de..60d9d637a 100644 --- a/test/fixtures/active_record.rb +++ b/test/fixtures/active_record.rb @@ -1450,6 +1450,10 @@ def find_records_by_keys(keys, options = {}) end breeds end + + def retrieve_records(ids, options = {}) + find_records_by_keys(ids, options) + end end end From 3330e77f0872979048c01593f9daf82653665792 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Tue, 30 Oct 2018 18:10:17 -0400 Subject: [PATCH 097/237] Fix to_many allow_include? --- lib/jsonapi/relationship.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/jsonapi/relationship.rb b/lib/jsonapi/relationship.rb index 3928c9f3f..2fa58cb4b 100644 --- a/lib/jsonapi/relationship.rb +++ b/lib/jsonapi/relationship.rb @@ -5,6 +5,8 @@ class Relationship :parent_resource, :eager_load_on_include, :custom_methods, :inverse_relationship, :allow_include + attr_writer :allow_include + def initialize(name, options = {}) @name = name.to_s @options = options @@ -134,7 +136,7 @@ def initialize(name, options = {}) def allow_include?(context = nil) strategy = if @allow_include.nil? - JSONAPI.configuration.default_allow_include_to_one + JSONAPI.configuration.default_allow_include_to_many else @allow_include end From be2912fb3426f55b8c82aef512912c842114f9b4 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Tue, 30 Oct 2018 18:10:49 -0400 Subject: [PATCH 098/237] Add check_include tests --- lib/jsonapi/request_parser.rb | 1 + .../jsonapi_request/jsonapi_request_test.rb | 66 +++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/lib/jsonapi/request_parser.rb b/lib/jsonapi/request_parser.rb index 4bb29c639..8f30174c5 100644 --- a/lib/jsonapi/request_parser.rb +++ b/lib/jsonapi/request_parser.rb @@ -341,6 +341,7 @@ def check_include(resource_klass, include_parts) else fail JSONAPI::Exceptions::InvalidInclude.new(format_key(resource_klass._type), include_parts.first) end + true end def parse_include_directives(resource_klass, raw_include) diff --git a/test/unit/jsonapi_request/jsonapi_request_test.rb b/test/unit/jsonapi_request/jsonapi_request_test.rb index 598040beb..38fb3ce54 100644 --- a/test/unit/jsonapi_request/jsonapi_request_test.rb +++ b/test/unit/jsonapi_request/jsonapi_request_test.rb @@ -47,6 +47,72 @@ def test_parse_blank_includes assert_empty include_directives.model_includes end + def test_check_include_allowed + assert JSONAPI::RequestParser.new.check_include(ExpenseEntryResource, "isoCurrency".partition('.')) + end + + def test_check_nested_include_allowed + assert JSONAPI::RequestParser.new.check_include(ExpenseEntryResource, "employee.expenseEntries".partition('.')) + end + + def test_check_include_relationship_does_not_exist + assert_raises JSONAPI::Exceptions::InvalidInclude do + assert JSONAPI::RequestParser.new.check_include(ExpenseEntryResource, "foo".partition('.')) + end + end + + def test_check_nested_include_relationship_does_not_exist_wrong_format + assert_raises JSONAPI::Exceptions::InvalidInclude do + assert JSONAPI::RequestParser.new.check_include(ExpenseEntryResource, "employee.expense-entries".partition('.')) + end + end + + def test_check_include_has_one_not_allowed_default + assert JSONAPI::RequestParser.new.check_include(ExpenseEntryResource, "isoCurrency".partition('.')) + JSONAPI.configuration.default_allow_include_to_one = false + + assert_raises JSONAPI::Exceptions::InvalidInclude do + JSONAPI::RequestParser.new.check_include(ExpenseEntryResource, "isoCurrency".partition('.')) + end + ensure + JSONAPI.configuration.default_allow_include_to_one = true + end + + def test_check_include_has_one_not_allowed_resource + assert JSONAPI::RequestParser.new.check_include(ExpenseEntryResource, "isoCurrency".partition('.')) + ExpenseEntryResource._relationship(:iso_currency).allow_include = false + + assert_raises JSONAPI::Exceptions::InvalidInclude do + JSONAPI::RequestParser.new.check_include(ExpenseEntryResource, "isoCurrency".partition('.')) + end + ensure + ExpenseEntryResource._relationship(:iso_currency).allow_include = nil + end + + def test_check_include_has_many_not_allowed_default + JSONAPI.configuration.default_allow_include_to_many = true + + assert JSONAPI::RequestParser.new.check_include(EmployeeResource, "expenseEntries".partition('.')) + JSONAPI.configuration.default_allow_include_to_many = false + + assert_raises JSONAPI::Exceptions::InvalidInclude do + JSONAPI::RequestParser.new.check_include(EmployeeResource, "expenseEntries".partition('.')) + end + ensure + JSONAPI.configuration.default_allow_include_to_many = true + end + + def test_check_include_has_many_not_allowed_relationship + assert JSONAPI::RequestParser.new.check_include(EmployeeResource, "expenseEntries".partition('.')) + EmployeeResource._relationship(:expense_entries).allow_include = false + + assert_raises JSONAPI::Exceptions::InvalidInclude do + JSONAPI::RequestParser.new.check_include(EmployeeResource, "expenseEntries".partition('.')) + end + ensure + EmployeeResource._relationship(:expense_entries).allow_include = nil + end + def test_parse_dasherized_with_dasherized_include params = ActionController::Parameters.new( { From 617aedb906327d1cb4fab845bd4b5693749f1bf4 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Wed, 31 Oct 2018 09:32:12 -0400 Subject: [PATCH 099/237] Fix flappy check_include tests --- .../jsonapi_request/jsonapi_request_test.rb | 42 +++++++++++++++---- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/test/unit/jsonapi_request/jsonapi_request_test.rb b/test/unit/jsonapi_request/jsonapi_request_test.rb index 38fb3ce54..038ea6476 100644 --- a/test/unit/jsonapi_request/jsonapi_request_test.rb +++ b/test/unit/jsonapi_request/jsonapi_request_test.rb @@ -48,37 +48,55 @@ def test_parse_blank_includes end def test_check_include_allowed + reset_includes assert JSONAPI::RequestParser.new.check_include(ExpenseEntryResource, "isoCurrency".partition('.')) + ensure + reset_includes end def test_check_nested_include_allowed + reset_includes assert JSONAPI::RequestParser.new.check_include(ExpenseEntryResource, "employee.expenseEntries".partition('.')) + ensure + reset_includes end def test_check_include_relationship_does_not_exist + reset_includes + assert_raises JSONAPI::Exceptions::InvalidInclude do assert JSONAPI::RequestParser.new.check_include(ExpenseEntryResource, "foo".partition('.')) end + ensure + reset_includes end def test_check_nested_include_relationship_does_not_exist_wrong_format + reset_includes + assert_raises JSONAPI::Exceptions::InvalidInclude do assert JSONAPI::RequestParser.new.check_include(ExpenseEntryResource, "employee.expense-entries".partition('.')) end + ensure + reset_includes end def test_check_include_has_one_not_allowed_default + reset_includes + assert JSONAPI::RequestParser.new.check_include(ExpenseEntryResource, "isoCurrency".partition('.')) JSONAPI.configuration.default_allow_include_to_one = false assert_raises JSONAPI::Exceptions::InvalidInclude do JSONAPI::RequestParser.new.check_include(ExpenseEntryResource, "isoCurrency".partition('.')) end - ensure - JSONAPI.configuration.default_allow_include_to_one = true + ensure + reset_includes end def test_check_include_has_one_not_allowed_resource + reset_includes + assert JSONAPI::RequestParser.new.check_include(ExpenseEntryResource, "isoCurrency".partition('.')) ExpenseEntryResource._relationship(:iso_currency).allow_include = false @@ -86,11 +104,11 @@ def test_check_include_has_one_not_allowed_resource JSONAPI::RequestParser.new.check_include(ExpenseEntryResource, "isoCurrency".partition('.')) end ensure - ExpenseEntryResource._relationship(:iso_currency).allow_include = nil + reset_includes end def test_check_include_has_many_not_allowed_default - JSONAPI.configuration.default_allow_include_to_many = true + reset_includes assert JSONAPI::RequestParser.new.check_include(EmployeeResource, "expenseEntries".partition('.')) JSONAPI.configuration.default_allow_include_to_many = false @@ -99,10 +117,12 @@ def test_check_include_has_many_not_allowed_default JSONAPI::RequestParser.new.check_include(EmployeeResource, "expenseEntries".partition('.')) end ensure - JSONAPI.configuration.default_allow_include_to_many = true + reset_includes end - def test_check_include_has_many_not_allowed_relationship + def test_check_include_has_many_not_allowed_resource + reset_includes + assert JSONAPI::RequestParser.new.check_include(EmployeeResource, "expenseEntries".partition('.')) EmployeeResource._relationship(:expense_entries).allow_include = false @@ -110,7 +130,7 @@ def test_check_include_has_many_not_allowed_relationship JSONAPI::RequestParser.new.check_include(EmployeeResource, "expenseEntries".partition('.')) end ensure - EmployeeResource._relationship(:expense_entries).allow_include = nil + reset_includes end def test_parse_dasherized_with_dasherized_include @@ -308,4 +328,12 @@ def test_parse_sort_with_relationships def setup_request @request = JSONAPI::RequestParser.new end + + def reset_includes + JSONAPI.configuration.json_key_format = :camelized_key + JSONAPI.configuration.default_allow_include_to_one = true + JSONAPI.configuration.default_allow_include_to_many = true + ExpenseEntryResource._relationship(:iso_currency).allow_include = nil + EmployeeResource._relationship(:expense_entries).allow_include = nil + end end From e8cad463f3a3509b307ab0eaf2c67187a782b12f Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Tue, 6 Nov 2018 20:19:02 -0500 Subject: [PATCH 100/237] Add Arel.sql to pluck fields Needed to avoid "DEPRECATION WARNING: Dangerous query method..." See https://github.com/rails/rails/issues/32995 --- .../active_relation_resource_finder.rb | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/lib/jsonapi/active_relation_resource_finder.rb b/lib/jsonapi/active_relation_resource_finder.rb index 3095e6079..a3617f73d 100644 --- a/lib/jsonapi/active_relation_resource_finder.rb +++ b/lib/jsonapi/active_relation_resource_finder.rb @@ -64,11 +64,11 @@ def find_fragments(filters, options = {}) records = find_records(filters, options) table_name = _model_class.table_name - pluck_fields = ["#{concat_table_field(table_name, _primary_key)} AS #{table_name}_#{_primary_key}"] + pluck_fields = [Arel.sql("#{concat_table_field(table_name, _primary_key)} AS #{table_name}_#{_primary_key}")] cache_field = attribute_to_model_field(:_cache_field) if options[:cache] if cache_field - pluck_fields << "#{concat_table_field(table_name, cache_field[:name])} AS #{table_name}_#{cache_field[:name]}" + pluck_fields << Arel.sql("#{concat_table_field(table_name, cache_field[:name])} AS #{table_name}_#{cache_field[:name]}") end model_fields = {} @@ -76,7 +76,7 @@ def find_fragments(filters, options = {}) attributes.try(:each) do |attribute| model_field = attribute_to_model_field(attribute) model_fields[attribute] = model_field - pluck_fields << "#{concat_table_field(table_name, model_field[:name])} AS #{table_name}_#{model_field[:name]}" + pluck_fields << Arel.sql("#{concat_table_field(table_name, model_field[:name])} AS #{table_name}_#{model_field[:name]}") end fragments = {} @@ -204,13 +204,13 @@ def find_related_monomorphic_fragments(source_rids, relationship, included_key, records = related_klass.apply_filters(records, filters, filter_options) pluck_fields = [ - "#{primary_key_field} AS #{_table_name}_#{_primary_key}", - "#{concat_table_field(table_alias, related_klass._primary_key)} AS #{table_alias}_#{related_klass._primary_key}" + Arel.sql("#{primary_key_field} AS #{_table_name}_#{_primary_key}"), + Arel.sql("#{concat_table_field(table_alias, related_klass._primary_key)} AS #{table_alias}_#{related_klass._primary_key}") ] cache_field = related_klass.attribute_to_model_field(:_cache_field) if options[:cache] if cache_field - pluck_fields << "#{concat_table_field(table_alias, cache_field[:name])} AS #{table_alias}_#{cache_field[:name]}" + pluck_fields << Arel.sql("#{concat_table_field(table_alias, cache_field[:name])} AS #{table_alias}_#{cache_field[:name]}") end model_fields = {} @@ -218,7 +218,7 @@ def find_related_monomorphic_fragments(source_rids, relationship, included_key, attributes.try(:each) do |attribute| model_field = related_klass.attribute_to_model_field(attribute) model_fields[attribute] = model_field - pluck_fields << "#{concat_table_field(table_alias, model_field[:name])} AS #{table_alias}_#{model_field[:name]}" + pluck_fields << Arel.sql("#{concat_table_field(table_alias, model_field[:name])} AS #{table_alias}_#{model_field[:name]}") end rows = records.pluck(*pluck_fields) @@ -265,9 +265,9 @@ def find_related_polymorphic_fragments(source_rids, relationship, options = {}) related_type = concat_table_field(_table_name, relationship.polymorphic_type) pluck_fields = [ - "#{primary_key} AS #{_table_name}_#{_primary_key}", - "#{related_key} AS #{_table_name}_#{relationship.foreign_key}", - "#{related_type} AS #{_table_name}_#{relationship.polymorphic_type}" + Arel.sql("#{primary_key} AS #{_table_name}_#{_primary_key}"), + Arel.sql("#{related_key} AS #{_table_name}_#{relationship.foreign_key}"), + Arel.sql("#{related_type} AS #{_table_name}_#{relationship.polymorphic_type}") ] relations = relationship.polymorphic_relations From 306e96e5f3dcacd8a29a8817179b9481d503ca73 Mon Sep 17 00:00:00 2001 From: Carl Thuringer Date: Wed, 19 Sep 2018 12:32:49 -0400 Subject: [PATCH 101/237] Fix deprecation warning re: sqlite representing boolean as integer in Rails >= 5.2 (cherry picked from commit b4c0bdd) --- test/test_helper.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/test_helper.rb b/test/test_helper.rb index 5a226609b..36ca6fab5 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -60,6 +60,9 @@ class TestApp < Rails::Application config.active_support.halt_callback_chains_on_return_false = false config.active_record.time_zone_aware_types = [:time, :datetime] config.active_record.belongs_to_required_by_default = false + if Rails::VERSION::MINOR >= 2 + config.active_record.sqlite3.represent_boolean_as_integer = true + end end end From f37dd10bb6199c8b8244e73e748c5835403a5e9f Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Thu, 6 Dec 2018 12:49:00 -0500 Subject: [PATCH 102/237] Update Travis testing to latest versions --- .travis.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.travis.yml b/.travis.yml index 5d80a0a33..ea9bbe781 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,15 +1,15 @@ language: ruby sudo: false env: - - "RAILS_VERSION=4.2.10" - - "RAILS_VERSION=5.0.7" - - "RAILS_VERSION=5.1.6" - - "RAILS_VERSION=5.2.1" + - "RAILS_VERSION=4.2.11" + - "RAILS_VERSION=5.0.7.1" + - "RAILS_VERSION=5.1.6.1" + - "RAILS_VERSION=5.2.2" - "RAILS_VERSION=master" rvm: - - 2.3.7 - - 2.4.4 - - 2.5.1 + - 2.3.8 + - 2.4.5 + - 2.5.3 matrix: allow_failures: - env: "RAILS_VERSION=master" From 9759c6e3cf634ec601e743477a47bb1d172d0db1 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Thu, 6 Dec 2018 13:33:39 -0500 Subject: [PATCH 103/237] Update tests and test data --- test/controllers/controller_test.rb | 68 ++------ test/fixtures/active_record.rb | 147 +++++++++++++----- test/fixtures/boxes.yml | 3 + test/fixtures/collectors.yml | 9 ++ test/fixtures/documents.yml | 5 + test/fixtures/painters.yml | 7 + test/fixtures/paintings.yml | 35 +++++ test/fixtures/pictures.yml | 6 + test/fixtures/related_things.yml | 17 +- test/fixtures/things.yml | 34 +++- test/fixtures/users.yml | 3 + test/integration/requests/request_test.rb | 2 +- test/test_helper.rb | 6 +- .../jsonapi_request/jsonapi_request_test.rb | 5 - test/unit/resource/resource_test.rb | 74 +-------- .../serializer/include_directives_test.rb | 65 ++------ 16 files changed, 259 insertions(+), 227 deletions(-) create mode 100644 test/fixtures/collectors.yml create mode 100644 test/fixtures/painters.yml create mode 100644 test/fixtures/paintings.yml diff --git a/test/controllers/controller_test.rb b/test/controllers/controller_test.rb index f4823a62a..c6ac8685d 100644 --- a/test/controllers/controller_test.rb +++ b/test/controllers/controller_test.rb @@ -245,7 +245,7 @@ def test_index_filter_with_empty_result assert_equal 0, json_response['data'].size end - def test_index_filter_by_id + def test_index_filter_by_single_id assert_cacheable_get :index, params: {filter: {id: '1'}} assert_response :success assert json_response['data'].is_a?(Array) @@ -266,7 +266,7 @@ def test_index_filter_with_hash_values assert_equal 1, json_response['data'].size end - def test_index_filter_by_ids + def test_index_filter_by_array_of_ids assert_cacheable_get :index, params: {filter: {ids: '1,2'}} assert_response :success assert json_response['data'].is_a?(Array) @@ -2050,14 +2050,14 @@ class PicturesControllerTest < ActionController::TestCase def test_pictures_index assert_cacheable_get :index assert_response :success - assert_equal 7, json_response['data'].size + assert_equal 8, json_response['data'].size end def test_pictures_index_with_polymorphic_include_one_level - assert_cacheable_get :index, params: {include: 'imageable'} + get :index, params: {include: 'imageable'} assert_response :success - assert_equal 7, json_response['data'].try(:size) - assert_equal 4, json_response['included'].try(:size) + assert_equal 8, json_response['data'].try(:size) + assert_equal 5, json_response['included'].try(:size) end def test_update_relationship_to_one_polymorphic @@ -2075,14 +2075,14 @@ class DocumentsControllerTest < ActionController::TestCase def test_documents_index assert_cacheable_get :index assert_response :success - assert_equal 4, json_response['data'].size + assert_equal 5, json_response['data'].size end def test_documents_index_with_polymorphic_include_one_level assert_cacheable_get :index, params: {include: 'pictures'} assert_response :success - assert_equal 4, json_response['data'].size - assert_equal 5, json_response['included'].size + assert_equal 5, json_response['data'].size + assert_equal 6, json_response['included'].size end end @@ -3877,9 +3877,6 @@ def test_complex_includes_two_level assert_equal 'things', json_response['included'][1]['type'] assert_equal '10001', json_response['included'][1]['relationships']['user']['data']['id'] assert_nil json_response['included'][1]['relationships']['things']['data'] - - assert_equal '10001', json_response['included'][2]['id'] - assert_equal 'users', json_response['included'][2]['type'] end def test_complex_includes_things_nested_things @@ -3916,9 +3913,6 @@ def test_complex_includes_nested_things_secondary_users assert_equal 'things', json_response['included'][1]['type'] assert_equal '10001', json_response['included'][1]['relationships']['user']['data']['id'] assert_equal '10', json_response['included'][1]['relationships']['things']['data'][0]['id'] - - assert_equal '10001', json_response['included'][2]['id'] - assert_equal 'users', json_response['included'][2]['type'] end end @@ -3945,49 +3939,6 @@ def test_fields_with_delegated_attribute end end -class WidgetsControllerTest < ActionController::TestCase - def teardown - Widget.delete_all - Indicator.delete_all - Agency.delete_all - end - - def test_fetch_widgets_sort_by_agency_name - agency_1 = Agency.create! name: 'beta' - agency_2 = Agency.create! name: 'alpha' - indicator_1 = Indicator.create! name: 'bar', agency: agency_1 - indicator_2 = Indicator.create! name: 'foo', agency: agency_2 - Widget.create! name: 'bar', indicator: indicator_1 - widget = Widget.create! name: 'foo', indicator: indicator_2 - assert_cacheable_get :index, params: {sort: 'indicator.agency.name'} - assert_response :success - assert_equal widget.id.to_s, json_response['data'].first['id'] - end -end - -class IndicatorsControllerTest < ActionController::TestCase - def teardown - Widget.delete_all - Indicator.delete_all - Agency.delete_all - end - - def test_fetch_indicators_sort_by_widgets_name - agency = Agency.create! name: 'test' - indicator_1 = Indicator.create! name: 'bar', agency: agency - indicator_2 = Indicator.create! name: 'foo', agency: agency - Widget.create! name: 'omega', indicator: indicator_1 - Widget.create! name: 'beta', indicator: indicator_1 - Widget.create! name: 'alpha', indicator: indicator_2 - Widget.create! name: 'zeta', indicator: indicator_2 - assert_cacheable_get :index, params: {sort: 'widgets.name'} - assert_response :success - assert_equal indicator_2.id.to_s, json_response['data'].first['id'] - assert_equal 2, json_response['data'].size - end - -end - class RobotsControllerTest < ActionController::TestCase def teardown @@ -4017,5 +3968,4 @@ def test_fetch_robots_with_sort_by_version assert_response 400 assert_equal 'version is not a valid sort criteria for robots', json_response['errors'].first['detail'] end - end diff --git a/test/fixtures/active_record.rb b/test/fixtures/active_record.rb index 60d9d637a..394545450 100644 --- a/test/fixtures/active_record.rb +++ b/test/fixtures/active_record.rb @@ -334,6 +334,26 @@ t.string :name end + create_table :painters, force: true do |t| + t.string :name + + t.timestamps null: false + end + + create_table :paintings, force: true do |t| + t.string :title + t.string :category + t.belongs_to :painter + + t.timestamps null: false + end + + create_table :collectors, force: true do |t| + t.string :name + t.belongs_to :painting + end + + # special cases create_table :storages, force: true do |t| t.string :token, null: false t.string :name @@ -433,6 +453,11 @@ class Person < ActiveRecord::Base has_one :author_detail has_and_belongs_to_many :books, join_table: :book_authors + has_and_belongs_to_many :not_banned_books, -> { + merge(Book.not_banned) + }, + class_name: 'Book', + join_table: :book_authors has_many :even_posts, -> { where('posts.id % 2 = 0') }, class_name: 'Post', foreign_key: 'author_id' has_many :odd_posts, -> { where('posts.id % 2 = 1') }, class_name: 'Post', foreign_key: 'author_id' @@ -619,6 +644,10 @@ class Book < ActiveRecord::Base has_many :approved_book_comments, -> { where(approved: true) }, class_name: "BookComment" has_and_belongs_to_many :authors, join_table: :book_authors, class_name: "Person" + + scope :not_banned, -> { + where(banned: false) + } end class BookComment < ActiveRecord::Base @@ -626,7 +655,7 @@ class BookComment < ActiveRecord::Base belongs_to :book def self.for_user(current_user) - records = self + records = self.all # Hide the unapproved comments from people who are not book admins unless current_user && current_user.book_admin records = records.where(approved: true) @@ -688,8 +717,8 @@ class Category < ActiveRecord::Base class Picture < ActiveRecord::Base belongs_to :imageable, polymorphic: true - # belongs_to :document, -> { where( pictures: { imageable_type: 'Document' } ).includes( :pictures ) }, foreign_key: 'imageable_id' - # belongs_to :product, -> { where( pictures: { imageable_type: 'Product' } ).includes( :pictures ) }, foreign_key: 'imageable_id' + belongs_to :document, -> { where( pictures: { imageable_type: 'Document' } ).eager_load( :pictures ) }, foreign_key: 'imageable_id' + belongs_to :product, -> { where( pictures: { imageable_type: 'Product' } ).eager_load( :pictures ) }, foreign_key: 'imageable_id' end class Vehicle < ActiveRecord::Base @@ -797,6 +826,19 @@ class Widget < ActiveRecord::Base class Robot < ActiveRecord::Base end +class Painter < ActiveRecord::Base + has_many :paintings +end + +class Painting < ActiveRecord::Base + belongs_to :painter + has_many :collectors +end + +class Collector < ActiveRecord::Base + belongs_to :painting +end + ### CONTROLLERS class SessionsController < ActionController::Base include JSONAPI::ActsAsResourceController @@ -1014,6 +1056,9 @@ class ExpenseEntriesController < JSONAPI::ResourceController class IsoCurrenciesController < JSONAPI::ResourceController end + + class PaintersController < JSONAPI::ResourceController + end end module V6 @@ -1349,6 +1394,11 @@ def title=(title) records.where(title: values.first['title']) } + filter 'tags.name' + + filter 'comments.author.name' + filter 'comments.tags.name' + def self.updatable_fields(context) super(context) - [:author, :subject] end @@ -1508,7 +1558,7 @@ class CraterResource < JSONAPI::Resource filter :description, apply: -> (records, value, options) { fail "context not set" unless options[:context][:current_user] != nil && options[:context][:current_user] == $test_user - records.where(concat_table_field(options[:table_alias], :description) => value) + records.where(concat_table_field(options[:related_alias], :description) => value) } def self.verify_key(key, context = nil) @@ -1545,7 +1595,16 @@ class CategoryResource < JSONAPI::Resource class PictureResource < JSONAPI::Resource attribute :name has_one :imageable, polymorphic: true - # has_one :imageable, polymorphic: true, polymorphic_relations: [:document, :product] + + filter 'imageable.name', perform_joins: true, apply: -> (records, value, options) { + joins = options[:joins] + relationship = _relationship(:imageable) + or_parts = relationship.polymorphic_relations.collect do |relation| + table_alias = joins["imageable[#{relation}]"][:alias] + "#{concat_table_field(table_alias, "name")} = '#{value.first}'" + end + records.where(or_parts.join(' OR ')) + } end class DocumentResource < JSONAPI::Resource @@ -1744,33 +1803,15 @@ class AuthorResource < JSONAPI::Resource model_name 'Person' attributes :name - has_many :books, inverse_relationship: :authors, - custom_methods: { - apply_join: -> (options) { - relationship = options[:relationship] - relation_name = relationship.relation_name(options[:options]) - - records = options[:records].joins(relation_name).references(relation_name) - - unless options[:context][:current_user].try(:book_admin) - records = records.where("#{relation_name}.banned" => false) - end - records - } - } + has_many :books, inverse_relationship: :authors, relation_name: -> (options) { + if options[:context][:current_user].try(:book_admin) + :books + else + :not_banned_books + end + } has_many :book_comments - - def records_for(rel_name) - records = _model.public_send(rel_name) - if rel_name == :books - # Hide indirect access to banned books unless current user is a book admin - unless context[:current_user].try(:book_admin) - records = records.where(banned: false) - end - end - return records - end end class BookResource < JSONAPI::Resource @@ -1804,6 +1845,7 @@ class BookResource < JSONAPI::Resource :book_comments end + # Using an inner join here, which is different than the new default left_join return records.joins(relation).references(relation).where('book_comments.id' => value) } @@ -1944,6 +1986,36 @@ class AuthorDetailResource < JSONAPI::Resource attributes :author_stuff end + class PaintingResource < JSONAPI::Resource + model_name 'Painting' + attributes :title, :category #, :collector_roster + has_one :painter + has_many :collectors + + filter :title + filter :category + filter :collectors + + def collector_roster + collectors.map(&:name) + end + end + + class CollectorResource < JSONAPI::Resource + attributes :name + has_one :painting + end + + class PainterResource < JSONAPI::Resource + model_name 'Painter' + attributes :name + has_many :paintings + + filter :name, apply: lambda { |records, value, options| + records.where('name LIKE ?', value) + } + end + class PersonResource < PersonResource; end class PostResource < PostResource; end class TagResource < TagResource; end @@ -2262,23 +2334,18 @@ def show module Api class BoxResource < JSONAPI::Resource has_many :things + + filter 'things.things.name' + filter 'things.name' end class ThingResource < JSONAPI::Resource has_one :box has_one :user - has_many :things, - custom_methods: { - apply_join: -> (options) { - table_alias = "aliased_#{options[:table_alias]}" - options[:table_alias] = table_alias - - join_stmt = "LEFT OUTER JOIN related_things related_things_#{table_alias} ON related_things_#{table_alias}.from_id = things.id LEFT OUTER JOIN things \"#{table_alias}\" ON \"#{table_alias}\".id = related_things_#{table_alias}.to_id" + has_many :things - return options[:records].joins(join_stmt) - } - } + filter 'things.things.name' end class UserResource < JSONAPI::Resource diff --git a/test/fixtures/boxes.yml b/test/fixtures/boxes.yml index 9325efae1..7ed5966a0 100644 --- a/test/fixtures/boxes.yml +++ b/test/fixtures/boxes.yml @@ -1,2 +1,5 @@ box_100: id: 100 + +box_102: + id: 102 diff --git a/test/fixtures/collectors.yml b/test/fixtures/collectors.yml new file mode 100644 index 000000000..3b7755626 --- /dev/null +++ b/test/fixtures/collectors.yml @@ -0,0 +1,9 @@ +collector_1: + id: 1 + name: "Alice" + painting_id: 4 + +collector_2: + id: 2 + name: "Bob" + painting_id: 4 \ No newline at end of file diff --git a/test/fixtures/documents.yml b/test/fixtures/documents.yml index ffaac63b3..2002137f7 100644 --- a/test/fixtures/documents.yml +++ b/test/fixtures/documents.yml @@ -13,3 +13,8 @@ document_200: document_201: id: 201 name: Foo + +#ToDo: rename this once we have different filter types by default. See test_polymorpic_relation_filter +document_300: + id: 300 + name: Enterprise Gizmo \ No newline at end of file diff --git a/test/fixtures/painters.yml b/test/fixtures/painters.yml new file mode 100644 index 000000000..6c5e0caaa --- /dev/null +++ b/test/fixtures/painters.yml @@ -0,0 +1,7 @@ +painter_1: + id: 1 + name: "Wyspianski" + +painter_2: + id: 2 + name: "Matejko" \ No newline at end of file diff --git a/test/fixtures/paintings.yml b/test/fixtures/paintings.yml new file mode 100644 index 000000000..85c1c688d --- /dev/null +++ b/test/fixtures/paintings.yml @@ -0,0 +1,35 @@ +painting_1: + id: 1 + title: "Rejtan" + category: "historic" + painter_id: 2 + +painting_2: + id: 2 + title: "Stanczyk" + category: "fantasy" + painter_id: 2 + +painting_3: + id: 3 + title: "Macierzynstwo" + category: "pastel" + painter_id: 1 + +painting_4: + id: 4 + title: "Helenka" + category: "oil" + painter_id: 1 + +painting_5: + id: 5 + title: "Motherhood" + category: "oil" + painter_id: 1 + +painting_6: + id: 6 + title: "Motherhood" + category: "fake" + painter_id: 1 \ No newline at end of file diff --git a/test/fixtures/pictures.yml b/test/fixtures/pictures.yml index d43eca90e..c3cdee65b 100644 --- a/test/fixtures/pictures.yml +++ b/test/fixtures/pictures.yml @@ -37,3 +37,9 @@ picture_48: name: JunkYardDogs.jpg imageable_id: 201 imageable_type: Document + +picture_50: + id: 50 + name: Gizmo_logo.png + imageable_id: 300 + imageable_type: Document \ No newline at end of file diff --git a/test/fixtures/related_things.yml b/test/fixtures/related_things.yml index e20da2a42..d40d37161 100644 --- a/test/fixtures/related_things.yml +++ b/test/fixtures/related_things.yml @@ -6,4 +6,19 @@ related_thing_10: related_thing_20: id: 201 from_id: 20 - to_id: 10 \ No newline at end of file + to_id: 10 + +related_thing_3040: + id: 301 + from_id: 30 + to_id: 40 + +related_thing_3050: + id: 302 + from_id: 30 + to_id: 50 + +related_thing_5060: + id: 303 + from_id: 50 + to_id: 60 diff --git a/test/fixtures/things.yml b/test/fixtures/things.yml index 2428c8f19..83783449e 100644 --- a/test/fixtures/things.yml +++ b/test/fixtures/things.yml @@ -2,8 +2,40 @@ thing_10: id: 10 user_id: 10001 box_id: 100 + name: Thing10 thing_20: id: 20 user_id: 10001 - box_id: 100 \ No newline at end of file + box_id: 100 + name: Thing20 + +thing_30: + id: 30 + user_id: 10002 + box_id: 102 + name: Thing30 + +thing_40: + id: 40 + user_id: 10002 + box_id: 102 + name: Thing40 + +thing_50: + id: 50 + user_id: 10002 + box_id: 102 + name: Thing50 + +thing_60: + id: 60 + user_id: 10002 + box_id: 102 + name: Thing60 + +#thing_70: +# id: 70 +# user_id: 10001 +# box_id: 100 +# name: Thing70 diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml index 6680a6271..0fcda58fa 100644 --- a/test/fixtures/users.yml +++ b/test/fixtures/users.yml @@ -1,2 +1,5 @@ user_1: id: 10001 + +user_2: + id: 10002 diff --git a/test/integration/requests/request_test.rb b/test/integration/requests/request_test.rb index 437ff1a13..2f18adc49 100644 --- a/test/integration/requests/request_test.rb +++ b/test/integration/requests/request_test.rb @@ -678,7 +678,7 @@ def test_pagination_empty_results # assert_equal 'This is comment 18 on book 1.', json_response['data'][9]['attributes']['body'] # end - def test_polymorpic_related_resources + def test_polymorphic_related_resources assert_cacheable_jsonapi_get '/pictures/1/imageable' assert_equal 'Enterprise Gizmo', json_response['data']['attributes']['name'] diff --git a/test/test_helper.rb b/test/test_helper.rb index 36ca6fab5..01520a3b2 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -92,6 +92,10 @@ class ActionController::TestCase end end +if Rails::VERSION::MAJOR < 5 + require 'left_join' +end + # Tests are now using the rails 5 format for the http methods. So for rails 4 we will simply convert them back # in a standard way. if Rails::VERSION::MAJOR < 5 @@ -346,7 +350,7 @@ class CatResource < JSONAPI::Resource namespace :v5 do jsonapi_resources :posts do end - + jsonapi_resources :painters jsonapi_resources :authors jsonapi_resources :expense_entries jsonapi_resources :iso_currencies diff --git a/test/unit/jsonapi_request/jsonapi_request_test.rb b/test/unit/jsonapi_request/jsonapi_request_test.rb index 038ea6476..0fde94224 100644 --- a/test/unit/jsonapi_request/jsonapi_request_test.rb +++ b/test/unit/jsonapi_request/jsonapi_request_test.rb @@ -42,11 +42,6 @@ def test_parse_includes_underscored assert request.errors.empty? end - def test_parse_blank_includes - include_directives = JSONAPI::RequestParser.new.parse_include_directives(nil, '') - assert_empty include_directives.model_includes - end - def test_check_include_allowed reset_includes assert JSONAPI::RequestParser.new.check_include(ExpenseEntryResource, "isoCurrency".partition('.')) diff --git a/test/unit/resource/resource_test.rb b/test/unit/resource/resource_test.rb index 252da026a..71ac6ceb4 100644 --- a/test/unit/resource/resource_test.rb +++ b/test/unit/resource/resource_test.rb @@ -58,29 +58,6 @@ class FelineResource < JSONAPI::Resource has_one :father, class_name: 'Cat' end -class PersonWithCustomRecordsForResource < PersonResource - def records_for(relationship_name) - :records_for - end -end - -class PersonWithCustomRecordsForRelationshipsResource < PersonResource - def records_for_posts - :records_for_posts - end - - def record_for_preferences - :record_for_preferences - end -end - -class PersonWithCustomRecordsForErrorResource < PersonResource - class AuthorizationError < StandardError; end - def records_for(relationship_name) - raise AuthorizationError - end -end - module MyModule class MyNamespacedResource < JSONAPI::Resource model_name "Person" @@ -244,7 +221,7 @@ def test_duplicate_attribute_name def test_find_with_customized_base_records author = Person.find(1001) - posts = ArticleResource.find([], context: author).map(&:_model) + posts = ArticleResource.find({}, context: author).map(&:_model) assert(posts.include?(Post.find(1))) refute(posts.include?(Post.find(3))) @@ -302,9 +279,9 @@ def test_to_many_relationship_sorts # define apply_filters method on post resource to sort descending PostResource.instance_eval do - def apply_sort(records, criteria, context = {}) + def apply_sort(records, _order_options, options) # :nocov: - order_by_query = 'id desc' + order_by_query = "#{options[:related_alias]}.id desc" records.order(order_by_query) # :nocov: end @@ -322,19 +299,7 @@ def apply_sort(records, criteria, context = {}) 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 - field = _attribute_delegated_name(field) - records = records.order(field => direction) - end + records = apply_single_sort(records, field, direction, context) end end @@ -343,37 +308,6 @@ def apply_sort(records, order_options, context = {}) end end - def test_lookup_association_chain - model_names = %w(person posts parent_post) - result = PersonResource._lookup_association_chain(model_names) - assert_equal 2, result.length - - posts_reflection, parent_post_reflection = result - assert_equal :posts, posts_reflection.name - assert_equal :parent_post, parent_post_reflection.name - - assert_equal "posts", posts_reflection.table_name - assert_equal "posts", parent_post_reflection.table_name - - assert_equal "author_id", posts_reflection.foreign_key - assert_equal "parent_post_id", parent_post_reflection.foreign_key - end - - def test_build_joins - model_names = %w(person posts parent_post author author_detail) - associations = PersonResource._lookup_association_chain(model_names) - result = PersonResource.send(:_build_joins, [Person, *associations]) - - sql = [ - 'LEFT JOIN posts AS posts_sorting ON posts_sorting.author_id = people.id', - 'LEFT JOIN posts AS parent_post_sorting ON parent_post_sorting.id = posts_sorting.parent_post_id', - 'LEFT JOIN people AS author_sorting ON author_sorting.id = parent_post_sorting.author_id', - 'LEFT JOIN author_details AS author_detail_sorting ON author_detail_sorting.person_id = author_sorting.id' - ].join("\n") - - assert_equal sql, result - end - # ToDo: Implement relationship pagination # # def test_to_many_relationship_pagination diff --git a/test/unit/serializer/include_directives_test.rb b/test/unit/serializer/include_directives_test.rb index 56306f114..279ee76d1 100644 --- a/test/unit/serializer/include_directives_test.rb +++ b/test/unit/serializer/include_directives_test.rb @@ -11,8 +11,7 @@ def test_one_level_one_include include_related: { posts: { include: true, - include_related:{}, - include_in_join: true + include_related:{} } } }, @@ -27,18 +26,15 @@ def test_one_level_multiple_includes include_related: { posts: { include: true, - include_related:{}, - include_in_join: true + include_related:{} }, comments: { include: true, - include_related:{}, - include_in_join: true + include_related:{} }, tags: { include: true, - include_related:{}, - include_in_join: true + include_related:{} } } }, @@ -56,33 +52,9 @@ def test_two_levels_include_full_path include_related:{ comments: { include: true, - include_related:{}, - include_in_join: true + include_related:{} } - }, - include_in_join: true - } - } - }, - directives) - end - - def test_no_eager_join - directives = JSONAPI::IncludeDirectives.new(PersonResource, ['posts.tags']).include_directives - - assert_hash_equals( - { - include_related: { - posts: { - include: true, - include_related:{ - tags: { - include: true, - include_related:{}, - include_in_join: false - } - }, - include_in_join: true + } } } }, @@ -100,11 +72,9 @@ def test_two_levels_include_full_path_redundant include_related:{ comments: { include: true, - include_related:{}, - include_in_join: true + include_related:{} } - }, - include_in_join: true + } } } }, @@ -125,25 +95,22 @@ def test_three_levels_include_full include_related:{ tags: { include: true, - include_related:{}, - include_in_join: true + include_related:{} } - }, - include_in_join: true + } } - }, - include_in_join: true + } } } }, directives) end - def test_three_levels_include_full_model_includes - directives = JSONAPI::IncludeDirectives.new(PersonResource, ['posts.comments.tags']) - assert_array_equals([{:posts=>[{:comments=>[:tags]}]}], directives.model_includes) - end - + # def test_three_levels_include_full_model_includes + # directives = JSONAPI::IncludeDirectives.new(PersonResource, ['posts.comments.tags']) + # assert_array_equals([{:posts=>[{:comments=>[:tags]}]}], directives.model_includes) + # end + # def test_invalid_includes_1 assert_raises JSONAPI::Exceptions::InvalidInclude do JSONAPI::IncludeDirectives.new(PersonResource, ['../../../../']).include_directives From 16ff808609770e40c5a5d3ab8bd7af47b9fbebc0 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Thu, 6 Dec 2018 13:36:58 -0500 Subject: [PATCH 104/237] Update coverage directives for uncovered lines --- lib/jsonapi/acts_as_resource_controller.rb | 6 +++++- lib/jsonapi/link_builder.rb | 4 ++++ lib/jsonapi/relationship.rb | 8 ++++++++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/lib/jsonapi/acts_as_resource_controller.rb b/lib/jsonapi/acts_as_resource_controller.rb index 73617924d..3a00e1268 100644 --- a/lib/jsonapi/acts_as_resource_controller.rb +++ b/lib/jsonapi/acts_as_resource_controller.rb @@ -60,15 +60,19 @@ def index_related_resources end def get_related_resource - ActiveSupport::Deprecation.warn "In #{self.class.name} you exposed a `get_related_resource`"\ + # :nocov: + ActiveSupport::Deprecation.warn "In #{self.class.name} you exposed a `get_related_resource`"\ " action. Please use `show_related_resource` instead." show_related_resource + # :nocov: end def get_related_resources + # :nocov: ActiveSupport::Deprecation.warn "In #{self.class.name} you exposed a `get_related_resources`"\ " action. Please use `index_related_resource` instead." index_related_resources + # :nocov: end def process_request diff --git a/lib/jsonapi/link_builder.rb b/lib/jsonapi/link_builder.rb index 13200cb44..6d4f84bc8 100644 --- a/lib/jsonapi/link_builder.rb +++ b/lib/jsonapi/link_builder.rb @@ -61,8 +61,10 @@ def build_engine_name unless scopes.empty? "#{ scopes.first.to_s.camelize }::Engine".safe_constantize end + # :nocov: rescue LoadError => _e nil + # :nocov: end end @@ -139,7 +141,9 @@ def regular_primary_resources_url def regular_resource_path(source) if source.is_a?(JSONAPI::CachedResponseFragment) + # :nocov: "#{regular_resources_path(source.resource_klass)}/#{source.id}" + # :nocov: else "#{regular_resources_path(source.class)}/#{source.id}" end diff --git a/lib/jsonapi/relationship.rb b/lib/jsonapi/relationship.rb index 2fa58cb4b..97b35bd11 100644 --- a/lib/jsonapi/relationship.rb +++ b/lib/jsonapi/relationship.rb @@ -25,7 +25,9 @@ def initialize(name, options = {}) alias_method :polymorphic?, :polymorphic def primary_key + # :nocov: @primary_key ||= resource_klass._primary_key + # :nocov: end def resource_klass @@ -33,7 +35,9 @@ def resource_klass end def table_name + # :nocov: @table_name ||= resource_klass._table_name + # :nocov: end def self.polymorphic_types(name) @@ -72,7 +76,9 @@ def relation_name(options) end def belongs_to? + # :nocov: false + # :nocov: end def readonly? @@ -97,7 +103,9 @@ def initialize(name, options = {}) end def belongs_to? + # :nocov: foreign_key_on == :self + # :nocov: end def polymorphic_type From 012286b0f35377fad144c02ddd550ecc5bb7c4ca Mon Sep 17 00:00:00 2001 From: Adam Robertson Date: Wed, 26 Dec 2018 11:35:39 -0800 Subject: [PATCH 105/237] Default to more restrictive backtrace configuration Needed to avoid exposing stack traces in non-production but non-development environments (e.g. "staging") --- lib/jsonapi/configuration.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/jsonapi/configuration.rb b/lib/jsonapi/configuration.rb index 0a5b7ac6b..acc30fef0 100644 --- a/lib/jsonapi/configuration.rb +++ b/lib/jsonapi/configuration.rb @@ -80,12 +80,12 @@ def initialize self.use_text_errors = false # Whether or not to include exception backtraces in JSONAPI error - # responses. Defaults to `false` in production, and `true` otherwise. - self.include_backtraces_in_errors = !Rails.env.production? + # responses. Defaults to `false` in anything other than development or test. + self.include_backtraces_in_errors = (Rails.env.development? || Rails.env.test?) # Whether or not to include exception application backtraces in JSONAPI error - # responses. Defaults to `false` in production, and `true` otherwise. - self.include_application_backtraces_in_errors = !Rails.env.production? + # responses. Defaults to `false` in anything other than development or test. + self.include_application_backtraces_in_errors = (Rails.env.development? || Rails.env.test?) # List of classes that should not be rescued by the operations processor. # For example, if you use Pundit for authorization, you might From 4c3c771a29ad5e06681332b5984f563e9cd45eea Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Thu, 6 Dec 2018 13:47:43 -0500 Subject: [PATCH 106/237] Add automatic alias tracking to ActiveRelationResourceFinder * enables related resource filtering * introduces JoinTree to track the required joins for a request to avoid join collisions * simplifies the resource finder, but there's still some room for improvement * makes use of the rails 5 `left_join` method. For rails 4 will require an additional gem to add the functionality. This is tested with the `left_join` gem, though others could exist and work. --- Gemfile | 1 + lib/jsonapi-resources.rb | 1 + .../active_relation_resource_finder.rb | 419 +++++++++--------- .../join_tree.rb | 126 ++++++ lib/jsonapi/include_directives.rb | 42 +- lib/jsonapi/processor.rb | 8 +- lib/jsonapi/relationship.rb | 12 +- lib/jsonapi/resource.rb | 24 - test/controllers/controller_test.rb | 8 +- test/fixtures/active_record.rb | 4 +- test/integration/requests/request_test.rb | 39 ++ .../join_tree_test.rb | 148 +++++++ .../active_relation_resource_finder_test.rb | 44 ++ 13 files changed, 596 insertions(+), 280 deletions(-) create mode 100644 lib/jsonapi/active_relation_resource_finder/join_tree.rb create mode 100644 test/unit/active_relation_resource_finder/join_tree_test.rb diff --git a/Gemfile b/Gemfile index 0c783b266..efc046d97 100644 --- a/Gemfile +++ b/Gemfile @@ -19,5 +19,6 @@ when 'master' when 'default' gem 'railties', '>= 5.0' else + gem 'left_join' if version.start_with?('4.2') gem 'railties', "~> #{version}" end diff --git a/lib/jsonapi-resources.rb b/lib/jsonapi-resources.rb index 33d8af5c8..bbdcc7000 100644 --- a/lib/jsonapi-resources.rb +++ b/lib/jsonapi-resources.rb @@ -25,4 +25,5 @@ require 'jsonapi/callbacks' require 'jsonapi/link_builder' require 'jsonapi/active_relation_resource_finder' +require 'jsonapi/active_relation_resource_finder/join_tree' require 'jsonapi/resource_identity' diff --git a/lib/jsonapi/active_relation_resource_finder.rb b/lib/jsonapi/active_relation_resource_finder.rb index a3617f73d..a5f227ad8 100644 --- a/lib/jsonapi/active_relation_resource_finder.rb +++ b/lib/jsonapi/active_relation_resource_finder.rb @@ -26,7 +26,7 @@ def find(filters, options = {}) # # @return [Integer] the count def count(filters, options = {}) - count_records(filter_records(filters, options)) + count_records(filter_records(records(options), filters, options)) end # Returns the single Resource identified by `key` @@ -128,25 +128,80 @@ def find_related_fragments(source_rids, relationship_name, options = {}, include # # @return [Integer] the count def count_related(source_rid, relationship_name, options = {}) + opts = options.dup + relationship = _relationship(relationship_name) related_klass = relationship.resource_klass - context = options[:context] + context = opts[:context] - records = records(context: context) - records, table_alias = apply_join(records, relationship, options) + primary_key_field = "#{_table_name}.#{_primary_key}" + + records = records(context: context).where(primary_key_field => source_rid.id) + + # join in related to the source records + records, related_alias = get_join_alias(records) { |records| records.joins(relationship.relation_name(opts)) } - filters = options.fetch(:filters, {}) + join_tree = JoinTree.new(resource_klass: related_klass, + source_relationship: relationship, + filters: filters, + options: opts) - primary_key_field = concat_table_field(_table_name, _primary_key) - filters[primary_key_field] = source_rid.id + records, joins = apply_joins(records, join_tree, opts) + + # Options for filtering + opts[:joins] = joins + opts[:related_alias] = related_alias + + filters = opts.fetch(:filters, {}) + records = related_klass.filter_records(records, filters, opts) - filter_options = options.dup - filter_options[:table_alias] = table_alias - records = related_klass.apply_filters(records, filters, filter_options) records.count(:all) end + def parse_relationship_path(path) + relationships = [] + relationship_names = [] + field = nil + + current_path = path + current_resource_klass = self + loop do + parts = current_path.to_s.partition('.') + relationship = current_resource_klass._relationship(parts[0]) + if relationship + relationships << relationship + relationship_names << relationship.name + else + if parts[2].blank? + field = parts[0] + break + else + # :nocov: + warn "Unknown relationship #{parts[0]}" + # :nocov: + end + end + + current_resource_klass = relationship.resource_klass + + if parts[2].include?('.') + current_path = parts[2] + else + relationship = current_resource_klass._relationship(parts[2]) + if relationship + relationships << relationship + relationship_names << relationship.name + else + field = parts[2] + end + break + end + end + + return relationships, relationship_names.join('.'), field + end + protected def find_record_by_key(key, options = {}) @@ -157,31 +212,51 @@ def find_record_by_key(key, options = {}) end def find_records_by_keys(keys, options = {}) - records = records(options) - records = apply_includes(records, options) - records.where({ _primary_key => keys }) + records(options).where({ _primary_key => keys }) end def find_related_monomorphic_fragments(source_rids, relationship, included_key, options = {}) + opts = options.dup + source_ids = source_rids.collect {|rid| rid.id} - context = options[:context] + context = opts[:context] - records = records(context: context) related_klass = relationship.resource_klass - records, table_alias = apply_join(records, relationship, options) + primary_key_field = "#{_table_name}.#{_primary_key}" + + records = records(context: context).where(primary_key_field => source_ids) + + # join in related to the source records + records, related_alias = get_join_alias(records) { |records| records.joins(relationship.relation_name(opts)) } sort_criteria = [] - options[:sort_criteria].try(:each) do |sort| + opts[:sort_criteria].try(:each) do |sort| field = sort[:field].to_s == 'id' ? related_klass._primary_key : sort[:field] - sort_criteria << { field: concat_table_field(table_alias, field), - direction: sort[:direction] } + sort_criteria << { field: field, direction: sort[:direction] } end - order_options = related_klass.construct_order_options(sort_criteria) + paginator = opts[:paginator] + + filters = opts.fetch(:filters, {}) + + # Joins in this case are related to the related_klass + join_tree = JoinTree.new(resource_klass: related_klass, + source_relationship: relationship, + filters: filters, + sort_criteria: sort_criteria, + options: opts) - paginator = options[:paginator] + records, joins = apply_joins(records, join_tree, opts) + + # Options for filtering + opts[:joins] = joins + opts[:related_alias] = related_alias + + records = related_klass.filter_records(records, filters, opts) + + order_options = related_klass.construct_order_options(sort_criteria) # ToDO: Remove count check. Currently pagination isn't working with multiple source_rids (i.e. it only works # for show relationships, not related includes). @@ -190,35 +265,24 @@ def find_related_monomorphic_fragments(source_rids, relationship, included_key, records = related_klass.apply_pagination(records, paginator, order_options) end - records = related_klass.apply_basic_sort(records, order_options, context: context) - - filters = options.fetch(:filters, {}) - - primary_key_field = concat_table_field(_table_name, _primary_key) - - filters[primary_key_field] = source_ids - - filter_options = options.dup - filter_options[:table_alias] = table_alias - - records = related_klass.apply_filters(records, filters, filter_options) + records = sort_records(records, order_options, opts) pluck_fields = [ - Arel.sql("#{primary_key_field} AS #{_table_name}_#{_primary_key}"), - Arel.sql("#{concat_table_field(table_alias, related_klass._primary_key)} AS #{table_alias}_#{related_klass._primary_key}") + Arel.sql(primary_key_field), + Arel.sql("#{concat_table_field(related_alias, related_klass._primary_key)} AS #{related_alias}_#{related_klass._primary_key}") ] - cache_field = related_klass.attribute_to_model_field(:_cache_field) if options[:cache] + cache_field = related_klass.attribute_to_model_field(:_cache_field) if opts[:cache] if cache_field - pluck_fields << Arel.sql("#{concat_table_field(table_alias, cache_field[:name])} AS #{table_alias}_#{cache_field[:name]}") + pluck_fields << Arel.sql("#{concat_table_field(related_alias, cache_field[:name])} AS #{related_alias}_#{cache_field[:name]}") end model_fields = {} - attributes = options[:attributes] + attributes = opts[:attributes] attributes.try(:each) do |attribute| model_field = related_klass.attribute_to_model_field(attribute) model_fields[attribute] = model_field - pluck_fields << Arel.sql("#{concat_table_field(table_alias, model_field[:name])} AS #{table_alias}_#{model_field[:name]}") + pluck_fields << Arel.sql("#{concat_table_field(related_alias, model_field[:name])} AS #{related_alias}_#{model_field[:name]}") end rows = records.pluck(*pluck_fields) @@ -280,7 +344,9 @@ def find_related_polymorphic_fragments(source_rids, relationship, options = {}) attributes = options.fetch(:attributes, []) if relations.nil? || relations.length == 0 + # :nocov: warn "No relations found for polymorphic relationship." + # :nocov: else relations.try(:each) do |relation| related_klass = resource_klass_for(relation.to_s) @@ -289,7 +355,7 @@ def find_related_polymorphic_fragments(source_rids, relationship, options = {}) # We only need to join the relations if we are getting additional fields if cache_field || attributes.length > 0 - records, table_alias = apply_join(records, relationship, options, relation) + records, table_alias = get_join_alias(records) { |records| records.left_joins(relation.to_sym) } if cache_field pluck_fields << concat_table_field(table_alias, cache_field[:name]) @@ -364,99 +430,103 @@ def find_related_polymorphic_fragments(source_rids, relationship, options = {}) end def find_records(filters, options = {}) - context = options[:context] + opts = options.dup + + sort_criteria = opts.fetch(:sort_criteria) { [] } - records = filter_records(filters, options) + join_tree = JoinTree.new(resource_klass: self, + filters: filters, + sort_criteria: sort_criteria, + options: opts) + + records, joins = apply_joins(records(opts), join_tree, opts) + + opts[:joins] = joins + + records = filter_records(records, filters, opts) - sort_criteria = options.fetch(:sort_criteria) { [] } order_options = construct_order_options(sort_criteria) - records = sort_records(records, order_options, context) + records = sort_records(records, order_options, opts) - records = apply_pagination(records, options[:paginator], order_options) + records = apply_pagination(records, opts[:paginator], order_options) - records + records.distinct 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.joins(model_includes).references(model_includes) + def get_join_alias(records, &block) + init_join_sources = records.arel.join_sources + init_join_sources_length = init_join_sources.length + + records = yield(records) + + join_sources = records.arel.join_sources + if join_sources.length > init_join_sources_length + last_join = (join_sources - init_join_sources).last + join_alias = + case last_join.left + when Arel::Table + last_join.left.name + when Arel::Nodes::TableAlias + last_join.left.right + when Arel::Nodes::StringJoin + # :nocov: + warn "get_join_alias: Unsupported join type - use custom filtering and sorting" + nil + # :nocov: + end + else + # :nocov: + warn "get_join_alias: No join added" + join_alias = nil + # :nocov: end - records + return records, join_alias end - def apply_pagination(records, paginator, order_options) - records = paginator.apply(records, order_options) if paginator - records - end + def apply_joins(records, join_tree, _options) + joins = join_tree.get_joins - def apply_sort(records, order_options, context = {}) - if order_options.any? - order_options.each_pair do |field, direction| - records = apply_single_sort(records, field, direction, context) + joins.each do |key, join_details| + case join_details[:join_type] + when :inner + records, join_alias = get_join_alias(records) { |records| records.joins(join_details[:relation_join_hash]) } + when :left + records, join_alias = get_join_alias(records) { |records| records.left_joins(join_details[:relation_join_hash]) } end + + joins[key][:alias] = join_alias end - records + return records, joins end - def apply_single_sort(records, field, direction, context = {}) - strategy = _allowed_sort.fetch(field.to_sym, {})[:apply] - - if strategy - call_method_or_proc(strategy, records, direction, context) - else - 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]) - - order_by_query = "#{_join_table_name(associations.last)}.#{column_name} #{direction}" - records.joins(joins_query).order(order_by_query) - else - field = _attribute_delegated_name(field) - records.order(field => direction) - end - end + def apply_pagination(records, paginator, order_options) + records = paginator.apply(records, order_options) if paginator + records end - def apply_basic_sort(records, order_options, _context = {}) + def apply_sort(records, order_options, options) if order_options.any? order_options.each_pair do |field, direction| - records = records.order("#{field} #{direction}") + records = apply_single_sort(records, field, direction, options) end end records end - def _build_joins(associations) - joins = [] - - associations.inject do |prev, current| - prev_table_name = _join_table_name(prev) - curr_table_name = _join_table_name(current) - relationship_primary_key = current.options.fetch(:primary_key, "id") - if current.belongs_to? - joins << "LEFT JOIN #{current.table_name} AS #{curr_table_name} ON #{curr_table_name}.#{relationship_primary_key} = #{prev_table_name}.#{current.foreign_key}" - else - joins << "LEFT JOIN #{current.table_name} AS #{curr_table_name} ON #{curr_table_name}.#{current.foreign_key} = #{prev_table_name}.#{relationship_primary_key}" - end + def apply_single_sort(records, field, direction, options) + context = options[:context] - current - end - joins.join("\n") - end + strategy = _allowed_sort.fetch(field.to_sym, {})[:apply] - # _sorting is appended to avoid name clashes with manual joins eg. overridden filters - def _join_table_name(association) - if association.is_a?(ActiveRecord::Reflection::AssociationReflection) - "#{association.name}_sorting" + if strategy + call_method_or_proc(strategy, records, direction, context) else - association.table_name + joins = options[:joins] || {} + + records.order("#{get_aliased_field(field, joins, options[:related_alias])} #{direction}") end end @@ -465,134 +535,83 @@ def count_records(records) records.count(:all) end - def resolve_relationship_names_to_relations(resource_klass, model_includes, options = {}) - case model_includes - when Array - return model_includes.map do |value| - resolve_relationship_names_to_relations(resource_klass, value, options) - end - when Hash - model_includes.keys.each do |key| - relationship = resource_klass._relationships[key] - value = model_includes[key] - model_includes.delete(key) - model_includes[relationship.relation_name(options)] = resolve_relationship_names_to_relations(relationship.resource_klass, value, options) - end - return model_includes - when Symbol - relationship = resource_klass._relationships[model_includes] - unless relationship - warn "relationship no found." - end - return relationship.relation_name(options) - end - end - - def apply_filter(records, filter, value, options = {}) - strategy = _allowed_filters.fetch(filter.to_sym, Hash.new)[:apply] - - if strategy - call_method_or_proc(strategy, records, value, options) - else - filter = _attribute_delegated_name(filter) - table_alias = options[:table_alias] - records.where(concat_table_field(table_alias, filter) => value) - end - end - - def apply_filters(records, filters, options = {}) - required_includes = [] - - if filters - filters.each do |filter, value| - strategy = _allowed_filters.fetch(filter.to_sym, Hash.new)[:apply] - - if strategy - records = apply_filter(records, filter, value, options) - elsif _relationships.include?(filter) - if _relationships[filter].belongs_to? - records = apply_filter(records, _relationships[filter].foreign_key, value, options) - else - required_includes.push(filter.to_s) - records = apply_filter(records, "#{_relationships[filter].table_name}.#{_relationships[filter].primary_key}", value, options) - end - else - records = apply_filter(records, filter, value, options) - end - end - end - - if required_includes.any? - records = apply_includes(records, options.merge(include_directives: IncludeDirectives.new(self, required_includes, force_eager_load: true))) - end - - records - end - - def filter_records(filters, options, records = records(options)) + def filter_records(records, filters, options) apply_filters(records, filters, options) end - def sort_records(records, order_options, context = {}) - apply_sort(records, order_options, context) + def sort_records(records, order_options, options) + apply_sort(records, order_options, options) end def concat_table_field(table, field, quoted = false) - if table.nil? || field.to_s.include?('.') + if table.blank? || field.to_s.include?('.') + # :nocov: if quoted "\"#{field.to_s}\"" else field.to_s end + # :nocov: else if quoted + # :nocov: "\"#{table.to_s}\".\"#{field.to_s}\"" + # :nocov: else "#{table.to_s}.#{field.to_s}" end end end - def apply_join(records, relationship, options, polymorphic_relation_name = nil) - custom_apply_join = relationship.custom_methods[:apply_join] + def apply_filters(records, filters, options = {}) + if filters + filters.each do |filter, value| + records = apply_filter(records, filter, value, options) + end + end + + records + end + + def get_aliased_field(path_with_field, joins, related_alias) + relationships, relationship_path, field = parse_relationship_path(path_with_field) + relationship = relationships.last - if custom_apply_join - # Set a default alias for the join to use, which it may change by updating the option - table_alias = relationship.resource_klass._table_name + resource_klass = relationship ? relationship.resource_klass : self - custom_apply_options = { - relationship: relationship, - polymorphic_relation_name: polymorphic_relation_name, - context: options[:context], - records: records, - table_alias: table_alias, - options: options} + if field.empty? + field_name = resource_klass._primary_key + else + field_name = resource_klass._attribute_delegated_name(field) + end - records = custom_apply_join.call(custom_apply_options) + if relationship + join_name = relationship_path - # Get the table alias in case it was changed - table_alias = custom_apply_options[:table_alias] + join = joins.try(:[], join_name) + + table_alias = join.try(:[], :alias) else - if relationship.polymorphic? - table_alias = relationship.parent_resource._table_name + table_alias = related_alias + end - relation_name = polymorphic_relation_name - related_klass = resource_klass_for(relation_name.to_s) - related_table_name = related_klass._table_name + table_alias ||= resource_klass._table_name - join_statement = "LEFT OUTER JOIN #{related_table_name} ON #{table_alias}.#{relationship.foreign_key} = #{related_table_name}.#{related_klass._primary_key} AND #{concat_table_field(table_alias, relationship.polymorphic_type, true)} = \"#{relation_name.capitalize}\"" - records = records.joins(join_statement) - else - relation_name = relationship.relation_name(options) - related_klass = relationship.resource_klass + concat_table_field(table_alias, field_name) + end - records = records.joins(relation_name).references(relation_name) - end + def apply_filter(records, filter, value, options = {}) + strategy = _allowed_filters.fetch(filter.to_sym, Hash.new)[:apply] - table_alias = related_klass._table_name + if strategy + records = call_method_or_proc(strategy, records, value, options) + else + joins = options[:joins] || {} + related_alias = options[:related_alias] + records = records.where(get_aliased_field(filter, joins, related_alias) => value) end - return records, table_alias + records end end end diff --git a/lib/jsonapi/active_relation_resource_finder/join_tree.rb b/lib/jsonapi/active_relation_resource_finder/join_tree.rb new file mode 100644 index 000000000..61ea1e047 --- /dev/null +++ b/lib/jsonapi/active_relation_resource_finder/join_tree.rb @@ -0,0 +1,126 @@ +module JSONAPI + module ActiveRelationResourceFinder + class JoinTree + # Stores relationship paths starting from the resource_klass. This allows consolidation of duplicate paths from + # relationships, filters and sorts. This enables the determination of table aliases as they are joined. + + attr_reader :resource_klass, :options, :source_relationship + + def initialize(resource_klass:, options: {}, source_relationship: nil, filters: nil, sort_criteria: nil) + @resource_klass = resource_klass + @options = options + @source_relationship = source_relationship + + @join_relationships = {} + + add_sort_criteria(sort_criteria) + add_filters(filters) + end + + # A hash of joins that can be used to create the required joins + def get_joins + walk_relation_node(@join_relationships) + end + + def add_filters(filters) + return if filters.blank? + filters.each_key do |filter| + # Do not add joins for filters with an apply callable. This can be overridden by setting perform_joins to true + next if resource_klass._allowed_filters[filter].try(:[], :apply) && + !resource_klass._allowed_filters[filter].try(:[], :perform_joins) + + add_join(filter) + end + end + + def add_sort_criteria(sort_criteria) + return if sort_criteria.blank? + + sort_criteria.each do |sort| + add_join(sort[:field], :left) + end + end + + private + + def add_join_relationship(parent_joins, join_name, relation_name, type) + parent_joins[join_name] ||= {relation_name: relation_name, relationship: {}, type: type} + if parent_joins[join_name][:type] == :left && type == :inner + parent_joins[join_name][:type] = :inner + end + parent_joins[join_name][:relationship] + end + + def add_join(path, default_type = :inner) + relationships, _field = resource_klass.parse_relationship_path(path) + + current_joins = @join_relationships + + terminated = false + + relationships.each do |relationship| + if terminated + # ToDo: Relax this, if possible + # :nocov: + warn "Can not nest joins under polymorphic join" + # :nocov: + end + + if relationship.polymorphic? + relation_names = relationship.polymorphic_relations + relation_names.each do |relation_name| + join_name = "#{relationship.name}[#{relation_name}]" + add_join_relationship(current_joins, join_name, relation_name, :left) + end + terminated = true + else + join_name = relationship.name + current_joins = add_join_relationship(current_joins, join_name, relationship.relation_name(options), default_type) + end + end + end + + # Create a nested set of hashes from an array of path components. This will be used by the `join` methods. + # [post, comments] => { post: { comments: {} } + def relation_join_hash(path, path_hash = {}) + relation = path.shift + if relation + path_hash[relation] = {} + relation_join_hash(path, path_hash[relation]) + end + path_hash + end + + # Returns the paths from shortest to longest, allowing the capture of the table alias for earlier paths. For + # example posts, posts.comments and then posts.comments.author joined in that order will alow each + # alias to be determined whereas just joining posts.comments.author will only record the author alias. + # ToDo: Dependence on this specialized logic should be removed in the future, if possible. + def walk_relation_node(node, paths = {}, current_relation_path = [], current_relationship_path = []) + node.each do |key, value| + if current_relation_path.empty? && source_relationship + current_relation_path << source_relationship.relation_name(options) + end + + current_relation_path << value[:relation_name].to_s + current_relationship_path << key.to_s + + rel_path = current_relationship_path.join('.') + paths[rel_path] ||= { + alias: nil, + join_type: value[:type], + relation_join_hash: relation_join_hash(current_relation_path.dup) + } + + walk_relation_node(value[:relationship], + paths, + current_relation_path, + current_relationship_path) + + current_relation_path.pop + current_relationship_path.pop + end + paths + end + end + end +end diff --git a/lib/jsonapi/include_directives.rb b/lib/jsonapi/include_directives.rb index 1ba1ff51b..0457a9df9 100644 --- a/lib/jsonapi/include_directives.rb +++ b/lib/jsonapi/include_directives.rb @@ -19,9 +19,8 @@ class IncludeDirectives # } # } - def initialize(resource_klass, includes_array, force_eager_load: false) + def initialize(resource_klass, includes_array) @resource_klass = resource_klass - @force_eager_load = force_eager_load @include_directives_hash = { include_related: {} } includes_array.each do |include| parse_include(include) @@ -32,16 +31,6 @@ def include_directives @include_directives_hash end - def model_includes - get_includes(@include_directives_hash) - end - - # :nocov: - def all_paths - delve_paths(get_includes(@include_directives_hash, false)) - end - # :nocov: - private def get_related(current_path) @@ -57,24 +46,13 @@ def get_related(current_path) raise JSONAPI::Exceptions::InvalidInclude.new(current_resource_klass, current_path) end - include_in_join = @force_eager_load || !current_relationship || current_relationship.eager_load_on_include - current[:include_related][fragment] ||= { include: false, include_related: {}, include_in_join: include_in_join } + current[:include_related][fragment] ||= { include: false, include_related: {} } current = current[:include_related][fragment] end current end - def get_includes(directive, only_joined_includes = true) - ir = directive[:include_related] - ir = ir.select { |_k,v| v[:include_in_join] } if only_joined_includes - - ir.map do |name, sub_directive| - sub = get_includes(sub_directive, only_joined_includes) - sub.any? ? { name => sub } : name - end - end - def parse_include(include) parts = include.split('.') local_path = '' @@ -85,21 +63,5 @@ def parse_include(include) related[:include] = true end end - - # :nocov: - def delve_paths(obj) - case obj - when Array - obj.map{|elem| delve_paths(elem)}.flatten(1) - when Hash - obj.map{|k,v| [[k]] + delve_paths(v).map{|path| [k] + path } }.flatten(1) - when Symbol, String - [[obj]] - else - raise "delve_paths cannot descend into #{obj.class.name}" - end - end - # :nocov: - end end diff --git a/lib/jsonapi/processor.rb b/lib/jsonapi/processor.rb index b270c0219..3dfa90944 100644 --- a/lib/jsonapi/processor.rb +++ b/lib/jsonapi/processor.rb @@ -54,7 +54,8 @@ def find sort_criteria: sort_criteria, paginator: paginator, fields: fields, - filters: verified_filters + filters: verified_filters, + include_directives: include_directives } resource_set = find_resource_set(resource_klass, @@ -406,7 +407,7 @@ def find_related_resource_id_tree(resource_klass, source_id, relationship_name, end def find_resource_id_tree(resource_klass, find_options, include_related) - options = find_options.except(:include_directives) + options = find_options options[:cache] = resource_klass.caching? resources = {} @@ -455,7 +456,8 @@ def get_related(resource_klass, source_resources, include_related, options) related = {} - include_related.try(:keys).try(:each) do |key| + include_related.try(:each_pair) do |key, value| + next unless value[:include] relationship = resource_klass._relationship(key) relationship_name = relationship.name.to_sym diff --git a/lib/jsonapi/relationship.rb b/lib/jsonapi/relationship.rb index 97b35bd11..ac7f6a04f 100644 --- a/lib/jsonapi/relationship.rb +++ b/lib/jsonapi/relationship.rb @@ -14,12 +14,16 @@ def initialize(name, options = {}) @foreign_key = options[:foreign_key] ? options[:foreign_key].to_sym : nil @parent_resource = options[:parent_resource] @relation_name = options.fetch(:relation_name, @name) - @custom_methods = options.fetch(:custom_methods, {}) @polymorphic = options.fetch(:polymorphic, false) == true @polymorphic_relations = options[:polymorphic_relations] @always_include_linkage_data = options.fetch(:always_include_linkage_data, false) == true - @eager_load_on_include = options.fetch(:eager_load_on_include, true) == true + @eager_load_on_include = options.fetch(:eager_load_on_include, false) == true @allow_include = options[:allow_include] + @class_name = nil + @inverse_relationship = nil + + # Custom methods are reserved for use in resource finders. Not used in the default ActiveRelationResourceFinder + @custom_methods = options.fetch(:custom_methods, {}) end alias_method :polymorphic?, :polymorphic @@ -85,10 +89,6 @@ def readonly? @options[:readonly] end - def redefined_pkey? - belongs_to? && primary_key != resource_klass._default_primary_key - end - class ToOne < Relationship attr_reader :foreign_key_on diff --git a/lib/jsonapi/resource.rb b/lib/jsonapi/resource.rb index b794bb344..e6a90dda3 100644 --- a/lib/jsonapi/resource.rb +++ b/lib/jsonapi/resource.rb @@ -527,12 +527,6 @@ def resource_type_for(model) end end - def model_name_for_type(key_type) - type_class_name = key_type.to_s.classify - resource_klass = resource_klass_for(type_class_name) - resource_klass ? resource_klass._model_name.to_s : type_class_name - end - attr_accessor :_attributes, :_relationships, :_type, :_model_hints attr_writer :_allowed_filters, :_paginator, :_allowed_sort @@ -710,24 +704,6 @@ def fields _relationships.keys | _attributes.keys 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 find_count(filters, options = {}) - # ToDo: Deprecation warning - count(filters, options) - end - def records(options = {}) _model_class.all end diff --git a/test/controllers/controller_test.rb b/test/controllers/controller_test.rb index c6ac8685d..4363e89b2 100644 --- a/test/controllers/controller_test.rb +++ b/test/controllers/controller_test.rb @@ -1924,7 +1924,7 @@ def test_delete_multiple end def test_show_to_one_relationship - get :show_relationship, params: {post_id: '1', relationship: 'author'} + assert_cacheable_get :show_relationship, params: {post_id: '1', relationship: 'author'} assert_response :success assert_hash_equals json_response, {data: { @@ -2054,7 +2054,7 @@ def test_pictures_index end def test_pictures_index_with_polymorphic_include_one_level - get :index, params: {include: 'imageable'} + assert_cacheable_get :index, params: {include: 'imageable'} assert_response :success assert_equal 8, json_response['data'].try(:size) assert_equal 5, json_response['included'].try(:size) @@ -2612,7 +2612,7 @@ def test_show_related_resource_includes end def test_show_related_resource_nil - get :show_related_resource, params: {post_id: '17', relationship: 'author', source:'posts'} + assert_cacheable_get :show_related_resource, params: {post_id: '17', relationship: 'author', source:'posts'} assert_response :success assert_hash_equals json_response, { @@ -3818,7 +3818,7 @@ def test_caching_with_join_to_resource_with_sql_fragment class AuthorsControllerTest < ActionController::TestCase def test_show_author_recursive - get :show, params: {id: '1002', include: 'books.authors'} + assert_cacheable_get :show, params: {id: '1002', include: 'books.authors'} assert_response :success assert_equal '1002', json_response['data']['id'] assert_equal 'authors', json_response['data']['type'] diff --git a/test/fixtures/active_record.rb b/test/fixtures/active_record.rb index 394545450..8f797bf8e 100644 --- a/test/fixtures/active_record.rb +++ b/test/fixtures/active_record.rb @@ -453,9 +453,7 @@ class Person < ActiveRecord::Base has_one :author_detail has_and_belongs_to_many :books, join_table: :book_authors - has_and_belongs_to_many :not_banned_books, -> { - merge(Book.not_banned) - }, + has_and_belongs_to_many :not_banned_books, -> { merge(Book.not_banned) }, class_name: 'Book', join_table: :book_authors diff --git a/test/integration/requests/request_test.rb b/test/integration/requests/request_test.rb index 2f18adc49..46d353ac5 100644 --- a/test/integration/requests/request_test.rb +++ b/test/integration/requests/request_test.rb @@ -99,6 +99,39 @@ def test_get_nested_to_many_bad_param assert_cacheable_jsonapi_get '/posts/1/comments?relationship=books' end + def test_nested_filters + assert_cacheable_jsonapi_get '/posts?filter[search][title]=New post' + assert_jsonapi_response 200 + assert_equal 1, json_response['data'].size + end + + def test_relationship_filters + assert_cacheable_jsonapi_get '/posts?filter[tags.name]=whiny&sort=-author.name' + assert_jsonapi_response 200 + assert_equal 3, json_response['data'].size + end + + # ToDo: change filter to return results + def test_relationship_filters_nested + assert_cacheable_jsonapi_get '/posts?filter[comments.author.name]=Lazy Author&filter[comments.tags.name]=whiny' + assert_jsonapi_response 200 + assert_equal 0, json_response['data'].size + end + + def test_filters_one_level + assert_cacheable_jsonapi_get '/api/boxes?filter[things.name]=Thing10' + assert_jsonapi_response 200 + assert_equal 1, json_response['data'].size + assert_equal '100', json_response['data'][0]['id'] + end + + def test_filters_two_level + assert_cacheable_jsonapi_get '/api/boxes?filter[things.things.name]=Thing40' + assert_jsonapi_response 200 + assert_equal 1, json_response['data'].size + assert_equal '102', json_response['data'][0]['id'] + end + def test_get_underscored_key original_config = JSONAPI.configuration.dup JSONAPI.configuration.json_key_format = :underscored_key @@ -686,6 +719,12 @@ def test_polymorphic_related_resources assert_equal 'Company Brochure', json_response['data']['attributes']['name'] end + def test_polymorphic_relation_filter + assert_cacheable_jsonapi_get '/pictures?include=imageable&filter[imageable.name]=Enterprise Gizmo' + assert_equal '1', json_response['data'][0]['id'] + assert_equal '50', json_response['data'][1]['id'] + end + def test_flow_self assert_cacheable_jsonapi_get '/posts/1' post_1 = json_response['data'] diff --git a/test/unit/active_relation_resource_finder/join_tree_test.rb b/test/unit/active_relation_resource_finder/join_tree_test.rb new file mode 100644 index 000000000..231f90b66 --- /dev/null +++ b/test/unit/active_relation_resource_finder/join_tree_test.rb @@ -0,0 +1,148 @@ +require File.expand_path('../../../test_helper', __FILE__) +require 'jsonapi-resources' + +class JoinTreeTest < ActiveSupport::TestCase + + def test_no_added_joins + join_tree = JSONAPI::ActiveRelationResourceFinder::JoinTree.new(resource_klass: PostResource) + + assert_hash_equals({}, join_tree.get_joins) + end + + def test_add_single_join + filters = {"tags": ["1"]} + join_tree = JSONAPI::ActiveRelationResourceFinder::JoinTree.new(resource_klass: PostResource, filters: filters) + assert_hash_equals( + { + tags: {alias: nil, join_type: :inner, relation_join_hash: {tags: {}}} + }, + join_tree.get_joins) + end + + def test_add_single_sort_join + sort_criteria = [ {field: "tags.name", direction: :desc}] + join_tree = JSONAPI::ActiveRelationResourceFinder::JoinTree.new(resource_klass: PostResource, sort_criteria: sort_criteria) + assert_hash_equals( + { + tags: {alias: nil, join_type: :left, relation_join_hash: {tags: {}}} + }, + join_tree.get_joins) + end + + def test_add_single_sort_and_filter_join + filters = {"tags": ["1"]} + sort_criteria = [ {field: "tags.name", direction: :desc}] + join_tree = JSONAPI::ActiveRelationResourceFinder::JoinTree.new(resource_klass: PostResource, sort_criteria: sort_criteria, filters: filters) + assert_hash_equals( + { + tags: {alias: nil, join_type: :inner, relation_join_hash: {tags: {}}} + }, + join_tree.get_joins) + end + + def test_add_sibling_joins + filters = { + "tags": ["1"], + "author": ["1"] + } + + join_tree = JSONAPI::ActiveRelationResourceFinder::JoinTree.new(resource_klass: PostResource, filters: filters) + + assert_hash_equals( + { + tags: {alias: nil, join_type: :inner, relation_join_hash: {tags: {}}}, + author: {alias: nil, join_type: :inner, relation_join_hash: {author: {}}} + }, + join_tree.get_joins) + end + + def test_add_nested_joins + filters = { + "comments.author": ["1"], + "comments.tags": ["1"], + "author": ["1"] + } + + join_tree = JSONAPI::ActiveRelationResourceFinder::JoinTree.new(resource_klass: PostResource, filters: filters) + joins = join_tree.get_joins + assert_hash_equals( + { + "comments": {alias: nil, join_type: :inner, relation_join_hash: {comments: {}}}, + "comments.author": {alias: nil, join_type: :inner, relation_join_hash: {comments: { author: {}}}}, + "comments.tags": {alias: nil, join_type: :inner, relation_join_hash: {comments: { tags: {}}}}, + "author": {alias: nil, join_type: :inner, relation_join_hash: {author: {}}} + }, + joins) + end + + def test_add_nested_joins_with_fields + filters = { + "comments.author.name": ["1"], + "comments.tags.id": ["1"], + "author.foo": ["1"] + } + + join_tree = JSONAPI::ActiveRelationResourceFinder::JoinTree.new(resource_klass: PostResource, filters: filters) + + assert_hash_equals( + { + "comments": {alias: nil, join_type: :inner, relation_join_hash: {comments: {}}}, + "comments.author": {alias: nil, join_type: :inner, relation_join_hash: {comments: { author: {}}}}, + "comments.tags": {alias: nil, join_type: :inner, relation_join_hash: {comments: { tags: {}}}}, + "author": {alias: nil, join_type: :inner, relation_join_hash: {author: {}}} + }, + join_tree.get_joins) + end + + def test_add_joins_with_fields_not_from_relationship + filters = { + "author.name": ["1"], + "author.comments.name": ["Foo"], + "tags.id": ["1"] + } + + join_tree = JSONAPI::ActiveRelationResourceFinder::JoinTree.new(resource_klass: PostResource, + filters: filters) + + joins = join_tree.get_joins + assert_hash_equals( + { + "author": {alias: nil, join_type: :inner, relation_join_hash: { author: {}}}, + "author.comments": {alias: nil, join_type: :inner, relation_join_hash: { author: { comments: {}}}}, + "tags": {alias: nil, join_type: :inner, relation_join_hash: { tags: {}}} + }, + joins) + end + + def test_add_joins_with_fields_from_relationship + filters = { + "author.name": ["1"], + "author.comments.name": ["Foo"], + "tags.id": ["1"] + } + + join_tree = JSONAPI::ActiveRelationResourceFinder::JoinTree.new(resource_klass: PostResource, + filters: filters, + source_relationship: PostResource._relationship(:comments)) + + assert_hash_equals( + { + "author": {alias: nil, join_type: :inner, relation_join_hash: {comments: { author: {}}}}, + "author.comments": {alias: nil, join_type: :inner, relation_join_hash: {comments: { author: { comments: {}}}}}, + "tags": {alias: nil, join_type: :inner, relation_join_hash: {comments: { tags: {}}}} + }, + join_tree.get_joins) + end + + def test_polymorphic_join + filters = {"imageable": ["Foo"]} + join_tree = JSONAPI::ActiveRelationResourceFinder::JoinTree.new(resource_klass: PictureResource, filters: filters) + assert_hash_equals( + { + "imageable[product]": {alias: nil, join_type: :left, relation_join_hash: {product: {}}}, + "imageable[document]": {alias: nil, join_type: :left, relation_join_hash: {document: {}}} + + }, + join_tree.get_joins) + end +end diff --git a/test/unit/resource/active_relation_resource_finder_test.rb b/test/unit/resource/active_relation_resource_finder_test.rb index 3b5996534..31711e60a 100644 --- a/test/unit/resource/active_relation_resource_finder_test.rb +++ b/test/unit/resource/active_relation_resource_finder_test.rb @@ -249,4 +249,48 @@ def test_find_related_polymorphic_fragments_cache_field_attributes assert related_identities.values[0][:cache].is_a?(ActiveSupport::TimeWithZone) assert_equal 'Enterprise Gizmo', related_identities.values[0][:attributes][:name] end + + def test_gets_relationship_chain_with_only_field + relationships, path, field = PictureResource.parse_relationship_path('name') + assert_equal [], relationships + assert_equal '', path + assert_equal 'name', field + end + + def test_gets_relationship_chain_with_field_polymorphic_one_level + relationships, path, field = PictureResource.parse_relationship_path('imageable.name') + assert_equal [PictureResource._relationship(:imageable)], relationships + assert_equal 'imageable', path + assert_equal 'name', field + end + + def test_gets_relationship_chain_with_field_one_level + relationships, path, field = PostResource.parse_relationship_path('author.name') + assert_equal [PostResource._relationship(:author)], relationships + assert_equal 'author', path + assert_equal 'name', field + end + + def test_gets_relationship_chain_with_two_relationship_levels + relationships, path, field = PostResource.parse_relationship_path('author.comments') + assert_equal [PostResource._relationship(:author), PersonResource._relationship(:comments)], relationships + assert_equal 'author.comments', path + assert_nil field + end + + def test_gets_relationship_chain_with_two_relationship_levels_and_field + relationships, path, field = PostResource.parse_relationship_path('author.comments.body') + assert_equal [PostResource._relationship(:author), PersonResource._relationship(:comments)], relationships + assert_equal 'author.comments', path + assert_equal 'body', field + end + + def test_gets_relationship_chain_with_three_relationship_levels_and_field + relationships, path, field = PostResource.parse_relationship_path('author.comments.tags.name') + assert_equal [PostResource._relationship(:author), + PersonResource._relationship(:comments), + CommentResource._relationship(:tags)], relationships + assert_equal 'author.comments.tags', path + assert_equal 'name', field + end end From 3f55ee64b5d3464ffdcdbcdaf30e1ff735b8eab0 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Thu, 23 Aug 2018 13:50:31 -0400 Subject: [PATCH 107/237] Introduce ResourceSet, ResourceIdTree, and ResourceFragment helper classes --- lib/jsonapi-resources.rb | 3 + .../active_relation_resource_finder.rb | 74 +- lib/jsonapi/exceptions.rb | 2 +- lib/jsonapi/operation_result.rb | 6 +- lib/jsonapi/processor.rb | 246 ++----- lib/jsonapi/resource.rb | 10 +- lib/jsonapi/resource_fragment.rb | 47 ++ lib/jsonapi/resource_id_tree.rb | 112 +++ lib/jsonapi/resource_serializer.rb | 87 +-- lib/jsonapi/resource_set.rb | 108 +++ test/controllers/controller_test.rb | 646 ++++++++++++++++-- test/controllers/widget_controller_test.rb | 47 -- test/fixtures/active_record.rb | 8 +- test/fixtures/things.yml | 6 +- test/fixtures/users.yml | 2 + test/unit/processor/default_processor_test.rb | 67 +- .../active_relation_resource_finder_test.rb | 224 +++--- test/unit/resource/resource_test.rb | 8 +- 18 files changed, 1141 insertions(+), 562 deletions(-) create mode 100644 lib/jsonapi/resource_fragment.rb create mode 100644 lib/jsonapi/resource_id_tree.rb create mode 100644 lib/jsonapi/resource_set.rb delete mode 100644 test/controllers/widget_controller_test.rb diff --git a/lib/jsonapi-resources.rb b/lib/jsonapi-resources.rb index bbdcc7000..6aca96e44 100644 --- a/lib/jsonapi-resources.rb +++ b/lib/jsonapi-resources.rb @@ -27,3 +27,6 @@ require 'jsonapi/active_relation_resource_finder' require 'jsonapi/active_relation_resource_finder/join_tree' require 'jsonapi/resource_identity' +require 'jsonapi/resource_fragment' +require 'jsonapi/resource_id_tree' +require 'jsonapi/resource_set' diff --git a/lib/jsonapi/active_relation_resource_finder.rb b/lib/jsonapi/active_relation_resource_finder.rb index a5f227ad8..4828be800 100644 --- a/lib/jsonapi/active_relation_resource_finder.rb +++ b/lib/jsonapi/active_relation_resource_finder.rb @@ -82,17 +82,17 @@ def find_fragments(filters, options = {}) fragments = {} records.pluck(*pluck_fields).collect do |row| rid = JSONAPI::ResourceIdentity.new(self, pluck_fields.length == 1 ? row : row[0]) - fragments[rid] = { identity: rid } + + fragments[rid] ||= JSONAPI::ResourceFragment.new(rid) attributes_offset = 1 if cache_field - fragments[rid][:cache] = cast_to_attribute_type(row[1], cache_field[:type]) + fragments[rid].cache = cast_to_attribute_type(row[1], cache_field[:type]) attributes_offset+= 1 end - fragments[rid][:attributes]= {} unless model_fields.empty? model_fields.each_with_index do |k, idx| - fragments[rid][:attributes][k[0]]= cast_to_attribute_type(row[idx + attributes_offset], k[1][:type]) + fragments[rid].attributes[k[0]]= cast_to_attribute_type(row[idx + attributes_offset], k[1][:type]) end end @@ -110,13 +110,23 @@ def find_fragments(filters, options = {}) # @return [Hash{ResourceIdentity => {identity: => ResourceIdentity, cache: cache_field, attributes: => {name => value}, related: {relationship_name: [] }}}] # the ResourceInstances matching the filters, sorting, and pagination rules along with any request # additional_field values - def find_related_fragments(source_rids, relationship_name, options = {}, included_key = nil) + def find_related_fragments(source_rids, relationship_name, options = {}) + relationship = _relationship(relationship_name) + + if relationship.polymorphic? && relationship.foreign_key_on == :self + find_related_polymorphic_fragments(source_rids, relationship, options, false) + else + find_related_monomorphic_fragments(source_rids, relationship, options, false) + end + end + + def find_included_fragments(source_rids, relationship_name, options = {}) relationship = _relationship(relationship_name) if relationship.polymorphic? && relationship.foreign_key_on == :self - find_related_polymorphic_fragments(source_rids, relationship, options) + find_related_polymorphic_fragments(source_rids, relationship, options, true) else - find_related_monomorphic_fragments(source_rids, relationship, included_key, options) + find_related_monomorphic_fragments(source_rids, relationship, options, true) end end @@ -215,7 +225,7 @@ def find_records_by_keys(keys, options = {}) records(options).where({ _primary_key => keys }) end - def find_related_monomorphic_fragments(source_rids, relationship, included_key, options = {}) + def find_related_monomorphic_fragments(source_rids, relationship, options, connect_source_identity) opts = options.dup source_ids = source_rids.collect {|rid| rid.id} @@ -260,8 +270,7 @@ def find_related_monomorphic_fragments(source_rids, relationship, included_key, # ToDO: Remove count check. Currently pagination isn't working with multiple source_rids (i.e. it only works # for show relationships, not related includes). - # Check included_key to not paginate included resources but ensure that nested resources can be paginated - if paginator && source_rids.count == 1 && !included_key + if paginator && source_rids.count == 1 records = related_klass.apply_pagination(records, paginator, order_options) end @@ -287,28 +296,35 @@ def find_related_monomorphic_fragments(source_rids, relationship, included_key, rows = records.pluck(*pluck_fields) - relation_name = relationship.name.to_sym - related_fragments = {} rows.each do |row| unless row[1].nil? rid = JSONAPI::ResourceIdentity.new(related_klass, row[1]) - related_fragments[rid] ||= { identity: rid, related: {relation_name => [] } } + + related_fragments[rid] ||= JSONAPI::ResourceFragment.new(rid) attributes_offset = 2 if cache_field - related_fragments[rid][:cache] = cast_to_attribute_type(row[attributes_offset], cache_field[:type]) + related_fragments[rid].cache = cast_to_attribute_type(row[attributes_offset], cache_field[:type]) attributes_offset+= 1 end - related_fragments[rid][:attributes]= {} unless model_fields.empty? model_fields.each_with_index do |k, idx| - related_fragments[rid][:attributes][k[0]] = cast_to_attribute_type(row[idx + attributes_offset], k[1][:type]) + related_fragments[rid].attributes[k[0]] = cast_to_attribute_type(row[idx + attributes_offset], k[1][:type]) end - related_fragments[rid][:related][relation_name] << JSONAPI::ResourceIdentity.new(self, row[0]) + source_rid = JSONAPI::ResourceIdentity.new(self, row[0]) + + related_fragments[rid].add_related_from(source_rid) + + if connect_source_identity + related_relationship = related_klass._relationships[relationship.inverse_relationship] + if related_relationship + related_fragments[rid].add_related_identity(related_relationship.name, source_rid) + end + end end end @@ -317,7 +333,7 @@ def find_related_monomorphic_fragments(source_rids, relationship, included_key, # Gets resource identities where the related resource is polymorphic and the resource type and id # are stored on the primary resources. Cache fields will always be on the related resources. - def find_related_polymorphic_fragments(source_rids, relationship, options = {}) + def find_related_polymorphic_fragments(source_rids, relationship, options, connect_source_identity) source_ids = source_rids.collect {|rid| rid.id} context = options[:context] @@ -393,8 +409,6 @@ def find_related_polymorphic_fragments(source_rids, relationship, options = {}) rows = records.pluck(*pluck_fields) - relation_name = relationship.name.to_sym - related_fragments = {} rows.each do |row| @@ -402,8 +416,17 @@ def find_related_polymorphic_fragments(source_rids, relationship, options = {}) related_klass = resource_klass_for(row[2]) rid = JSONAPI::ResourceIdentity.new(related_klass, row[1]) - related_fragments[rid] ||= { identity: rid, related: { relation_name => [] } } - related_fragments[rid][:related][relation_name] << JSONAPI::ResourceIdentity.new(self, row[0]) + related_fragments[rid] ||= JSONAPI::ResourceFragment.new(rid) + + source_rid = JSONAPI::ResourceIdentity.new(self, row[0]) + related_fragments[rid].add_related_from(source_rid) + + if connect_source_identity + related_relationship = related_klass._relationships[relationship.inverse_relationship] + if related_relationship + related_fragments[rid].add_related_identity(related_relationship.name, source_rid) + end + end relation_position = relation_positions[row[2]] model_fields = relation_position[:model_fields] @@ -413,14 +436,13 @@ def find_related_polymorphic_fragments(source_rids, relationship, options = {}) attributes_offset = 0 if cache_field - related_fragments[rid][:cache] = cast_to_attribute_type(row[field_offset], cache_field[:type]) + related_fragments[rid].cache = cast_to_attribute_type(row[field_offset], cache_field[:type]) attributes_offset+= 1 end if attributes.length > 0 - related_fragments[rid][:attributes]= {} model_fields.each_with_index do |k, idx| - related_fragments[rid][:attributes][k[0]] = cast_to_attribute_type(row[idx + field_offset + attributes_offset], k[1][:type]) + related_fragments[rid].add_attribute(k[0], cast_to_attribute_type(row[idx + field_offset + attributes_offset], k[1][:type])) end end end @@ -615,4 +637,4 @@ def apply_filter(records, filter, value, options = {}) end end end -end +end \ No newline at end of file diff --git a/lib/jsonapi/exceptions.rb b/lib/jsonapi/exceptions.rb index 323915525..12ec17783 100644 --- a/lib/jsonapi/exceptions.rb +++ b/lib/jsonapi/exceptions.rb @@ -1,7 +1,7 @@ module JSONAPI module Exceptions class Error < RuntimeError - attr :error_object_overrides + attr_reader :error_object_overrides def initialize(error_object_overrides = {}) @error_object_overrides = error_object_overrides diff --git a/lib/jsonapi/operation_result.rb b/lib/jsonapi/operation_result.rb index 412916b41..369c77204 100644 --- a/lib/jsonapi/operation_result.rb +++ b/lib/jsonapi/operation_result.rb @@ -49,7 +49,7 @@ def initialize(code, resource_set, options = {}) def to_hash(serializer) if serializer - serializer.serialize_resource_set_to_hash(resource_set) + serializer.serialize_resource_set_to_hash_single(resource_set) else # :nocov: {} @@ -71,7 +71,7 @@ def initialize(code, resource_set, options = {}) def to_hash(serializer) if serializer - serializer.serialize_resources_set_to_hash(resource_set) + serializer.serialize_resource_set_to_hash_plural(resource_set) else # :nocov: {} @@ -91,7 +91,7 @@ def initialize(code, source_resource, type, resource_set, options = {}) def to_hash(serializer = nil) if serializer - serializer.serialize_related_resources_set_to_hash(source_resource, resource_set) + serializer.serialize_related_resource_set_to_hash_plural(resource_set, source_resource) else # :nocov: {} diff --git a/lib/jsonapi/processor.rb b/lib/jsonapi/processor.rb index 3dfa90944..b15dcecf9 100644 --- a/lib/jsonapi/processor.rb +++ b/lib/jsonapi/processor.rb @@ -60,9 +60,10 @@ def find resource_set = find_resource_set(resource_klass, include_directives, - serializer, find_options) + resource_set.populate!(serializer, context, find_options) + page_options = result_options if (JSONAPI.configuration.top_level_meta_include_record_count || (paginator && paginator.class.requires_record_count)) page_options[:record_count] = resource_klass.count(verified_filters, @@ -97,9 +98,10 @@ def show resource_set = find_resource_set(resource_klass, include_directives, - serializer, find_options) + resource_set.populate!(serializer, context, find_options) + return JSONAPI::ResourceSetOperationResult.new(:ok, resource_set, result_options) end @@ -129,7 +131,7 @@ def show_relationship return JSONAPI::LinksObjectOperationResult.new(:ok, parent_resource, resource_klass._relationship(relationship_type), - resource_id_tree[:resources].keys, + resource_id_tree.fragments.keys, result_options) end @@ -152,9 +154,10 @@ def show_related_resource resource_set = find_related_resource_set(source_resource, relationship_type, include_directives, - serializer, find_options) + resource_set.populate!(serializer, context, find_options) + return JSONAPI::ResourceSetOperationResult.new(:ok, resource_set, result_options) end @@ -184,9 +187,10 @@ def show_related_resources resource_set = find_related_resource_set(source_resource, relationship_type, include_directives, - serializer, find_options) + resource_set.populate!(serializer, context, find_options) + opts = result_options if ((JSONAPI.configuration.top_level_meta_include_record_count) || (paginator && paginator.class.requires_record_count) || @@ -234,9 +238,9 @@ def create_resource resource_set = find_resource_set(resource_klass, include_directives, - serializer, find_options) + resource_set.populate!(serializer, context, find_options) return JSONAPI::ResourceSetOperationResult.new((result == :completed ? :created : :accepted), resource_set, result_options) end @@ -270,9 +274,10 @@ def replace_fields resource_set = find_resource_set(resource_klass, include_directives, - serializer, find_options) + resource_set.populate!(serializer, context, find_options) + return JSONAPI::ResourceSetOperationResult.new((result == :completed ? :ok : :accepted), resource_set, result_options) end @@ -354,74 +359,49 @@ def result_options options end - def find_resource_set(resource_klass, include_directives, serializer, options) + def find_resource_set(resource_klass, include_directives, options) include_related = include_directives.include_directives[:include_related] if include_directives resource_id_tree = find_resource_id_tree(resource_klass, options, include_related) - # Generate a set of resources that can be used to turn the resource_id_tree into a result set - resource_set = flatten_resource_id_tree(resource_id_tree) - - populate_resource_set(resource_set, serializer, options) - - resource_set + JSONAPI::ResourceSet.new(resource_id_tree) end - def find_related_resource_set(resource, relationship_name, include_directives, serializer, options) + def find_related_resource_set(resource, relationship_name, include_directives, options) include_related = include_directives.include_directives[:include_related] if include_directives resource_id_tree = find_resource_id_tree_from_resource_relationship(resource, relationship_name, options, include_related) - # Generate a set of resources that can be used to turn the resource_id_tree into a result set - resource_set = flatten_resource_id_tree(resource_id_tree) - - populate_resource_set(resource_set, serializer, options) - - resource_set + JSONAPI::ResourceSet.new(resource_id_tree) end + private def find_related_resource_id_tree(resource_klass, source_id, relationship_name, find_options, include_related) options = find_options.except(:include_directives) options[:cache] = resource_klass.caching? - relationship = resource_klass._relationship(relationship_name) - - resources = {} - - identities = resource_klass.find_related_fragments([source_id], relationship_name, options) - - identities.each do |identity, value| - resources[identity] = { id: identity, - resource_klass: relationship.resource_klass, - primary: true, relationships: {} - } + fragments = resource_klass.find_included_fragments([source_id], relationship_name, options) - if resource_klass.caching? - resources[identity][:cache_field] = value[:cache] - end - end + primary_resource_id_tree = PrimaryResourceIdTree.new + primary_resource_id_tree.add_resource_fragments(fragments, include_related) - included_relationships = get_related(relationship.resource_klass, resources, include_related, options) + load_included(resource_klass, primary_resource_id_tree, include_related, options.except(:filters, :sort_criteria)) - { resources: resources, included: included_relationships } + primary_resource_id_tree end def find_resource_id_tree(resource_klass, find_options, include_related) options = find_options options[:cache] = resource_klass.caching? - resources = {} - identities = resource_klass.find_fragments(find_options[:filters], options) - identities.each do |identity, values| - resources[identity] = { primary: true, relationships: {} } - if resource_klass.caching? - resources[identity][:cache_field] = values[:cache] - end - end + fragments = resource_klass.find_fragments(find_options[:filters], options) + + primary_resource_id_tree = PrimaryResourceIdTree.new + primary_resource_id_tree.add_resource_fragments(fragments, include_related) - included_relationships = get_related(resource_klass, resources, include_related, options.except(:filters, :sort_criteria)) + load_included(resource_klass, primary_resource_id_tree, include_related, options.except(:filters, :sort_criteria)) - { resources: resources, included: included_relationships } + primary_resource_id_tree end def find_resource_id_tree_from_resource_relationship(resource, relationship_name, find_options, include_related) @@ -430,178 +410,40 @@ def find_resource_id_tree_from_resource_relationship(resource, relationship_name options = find_options.except(:include_directives) options[:cache] = relationship.resource_klass.caching? - identities = resource.class.find_related_fragments([resource.identity], relationship_name, options) - - resources = {} + fragments = resource.class.find_related_fragments([resource.identity], relationship_name, options) - identities.each do |identity, values| - resources[identity] = { primary: true, relationships: {} } - if relationship.resource_klass.caching? - resources[identity][:cache_field] = values[:cache] - end - end - - options = options.except(:filters) + primary_resource_id_tree = PrimaryResourceIdTree.new + primary_resource_id_tree.add_resource_fragments(fragments, include_related) - included_relationships = get_related(resource_klass, resources, include_related, options) + load_included(resource_klass, primary_resource_id_tree, include_related, options.except(:filters, :sort_criteria)) - { resources: resources, included: included_relationships } + primary_resource_id_tree end - # Gets the related resource connections for the source resources - # Note: source_resources must all be of the same type. This precludes includes through polymorphic - # relationships. ToDo: Prevent this when parsing the includes - def get_related(resource_klass, source_resources, include_related, options) - source_rids = source_resources.keys - - related = {} + def load_included(resource_klass, source_resource_id_tree, include_related, options) + source_rids = source_resource_id_tree.fragments.keys include_related.try(:each_pair) do |key, value| next unless value[:include] relationship = resource_klass._relationship(key) relationship_name = relationship.name.to_sym - cache_related = relationship.resource_klass.caching? - - related[relationship_name] = {} - related[relationship_name][:relationship] = relationship - related[relationship_name][:resources] = {} - find_related_resource_options = options.dup find_related_resource_options[:sort_criteria] = relationship.resource_klass.default_sort find_related_resource_options[:cache] = resource_klass.caching? - related_identities = resource_klass.find_related_fragments( - source_rids, relationship_name, find_related_resource_options, key + related_fragments = resource_klass.find_included_fragments( + source_rids, relationship_name, find_related_resource_options ) - related_identities.each_pair do |identity, v| - related[relationship_name][:resources][identity] = - { - source_rids: v[:related][relationship_name], - relationships: { - relationship.parent_resource._type => { rids: v[:related][relationship_name] } - } - } - - if cache_related - related[relationship_name][:resources][identity][:cache_field] = v[:cache] - end - end - - related[relationship_name][:resources].each do |related_rid, related_resource| - # add linkage to source records - related_resource[:source_rids].each do |id| - source_resource = source_resources[id] - source_resource[:relationships][relationship_name] ||= { rids: [] } - source_resource[:relationships][relationship_name][:rids] << related_rid - end - end - - # Now get the related resources for the currently found resources - included_resources = get_related(relationship.resource_klass, - related[relationship_name][:resources], - include_related[relationship_name][:include_related], - options) - - related[relationship_name][:included] = included_resources - end - - related - end - - # flatten the resource id tree into groupings by resource klass - def flatten_resource_id_tree(resource_id_tree, flattened_tree = {}) - resource_id_tree[:resources].each_pair do |resource_rid, resource_details| - - resource_klass = resource_rid.resource_klass - id = resource_rid.id - - flattened_tree[resource_klass] ||= {} - - flattened_tree[resource_klass][id] ||= { primary: resource_details[:primary], relationships: {} } - flattened_tree[resource_klass][id][:cache_id] ||= resource_details[:cache_field] + related_resource_id_tree = source_resource_id_tree.fetch_related_resource_id_tree(relationship) + related_resource_id_tree.add_resource_fragments(related_fragments, include_related[key][include_related]) - resource_details[:relationships].try(:each_pair) do |relationship_name, details| - flattened_tree[resource_klass][id][:relationships][relationship_name] ||= { rids: [] } - - if details[:rids] && details[:rids].is_a?(Array) - details[:rids].each do |related_rid| - flattened_tree[resource_klass][id][:relationships][relationship_name][:rids] << related_rid - end - end - end - end - - included = resource_id_tree[:included] - included.try(:each_value) do |i| - flatten_resource_id_tree(i, flattened_tree) - end - - flattened_tree - end - - def populate_resource_set(resource_set, serializer, find_options) - - resource_set.each_key do |resource_klass| - missed_ids = [] - - serializer_config_key = serializer.config_key(resource_klass).gsub("/", "_") - context_json = resource_klass.attribute_caching_context(context).to_json - context_b64 = JSONAPI.configuration.resource_cache_digest_function.call(context_json) - context_key = "ATTR-CTX-#{context_b64.gsub("/", "_")}" - - if resource_klass.caching? - cache_ids = [] - - resource_set[resource_klass].each_pair do |k, v| - # Store the hashcode of the cache_field to avoid storing objects and to ensure precision isn't lost - # on timestamp types (i.e. string conversions dropping milliseconds) - cache_ids.push([k, resource_klass.hash_cache_field(v[:cache_id])]) - end - - found_resources = CachedResponseFragment.fetch_cached_fragments( - resource_klass, - serializer_config_key, - cache_ids, - context) - - found_resources.each do |found_result| - resource = found_result[1] - if resource.nil? - missed_ids.push(found_result[0]) - else - resource_set[resource_klass][resource.id][:resource] = resource - end - end - else - missed_ids = resource_set[resource_klass].keys - end - - # fill in the missed resources, it there are any - unless missed_ids.empty? - missed_records = resource_klass.retrieve_records(missed_ids, find_options) - missed_resources = resource_klass.resources_for(missed_records, context) - - missed_resources.each do |resource| - relationship_data = resource_set[resource_klass][resource.id][:relationships] - - if resource_klass.caching? - (id, cr) = CachedResponseFragment.write( - resource_klass, - resource, - serializer, - serializer_config_key, - context, - context_key, - relationship_data) - - resource_set[resource_klass][id][:resource] = cr - else - resource_set[resource_klass][resource.id][:resource] = resource - end - end - end + # Now recursively get the related resources for the currently found resources + load_included(relationship.resource_klass, + related_resource_id_tree, + include_related[relationship_name][:include_related], + options) end end end diff --git a/lib/jsonapi/resource.rb b/lib/jsonapi/resource.rb index e6a90dda3..675726f88 100644 --- a/lib/jsonapi/resource.rb +++ b/lib/jsonapi/resource.rb @@ -276,7 +276,7 @@ def _create_to_many_links(relationship_type, relationship_key_values, options) 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) @@ -434,7 +434,7 @@ def inherited(subclass) # cache: # attributes: # related: { - # : + # : # } # } # @@ -469,6 +469,12 @@ def find_fragments(_filters, _options = {}) # :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.' diff --git a/lib/jsonapi/resource_fragment.rb b/lib/jsonapi/resource_fragment.rb new file mode 100644 index 000000000..933ad6b8e --- /dev/null +++ b/lib/jsonapi/resource_fragment.rb @@ -0,0 +1,47 @@ +module JSONAPI + + # A ResourceFragment holds a ResourceIdentity and associated partial resource data. + # + # The following partial resource data may be stored + # cache - the value of the cache field for the resource instance + # related - a hash of arrays of related resource identities, grouped by relationship name + # related_from - a set of related resource identities that loaded the fragment + # + # Todo: optionally use these for faster responses by bypassing model instantiation) + # attributes - resource attributes + + class ResourceFragment + attr_reader :identity, :attributes, :related_from, :related + + attr_accessor :primary, :cache + + alias :cache_field :cache #ToDo: Rename one or the other + + def initialize(identity) + @identity = identity + @cache = nil + @attributes = {} + @related = {} + @primary = false + @related_from = Set.new + end + + def initialize_related(relationship_name) + @related ||= {} + @related[relationship_name.to_sym] ||= Set.new + end + + def add_related_identity(relationship_name, identity) + initialize_related(relationship_name) + @related[relationship_name.to_sym] << identity + end + + def add_related_from(identity) + @related_from << identity + end + + def add_attribute(name, value) + @attributes[name] = value + end + end +end \ No newline at end of file diff --git a/lib/jsonapi/resource_id_tree.rb b/lib/jsonapi/resource_id_tree.rb new file mode 100644 index 000000000..2bb2f456f --- /dev/null +++ b/lib/jsonapi/resource_id_tree.rb @@ -0,0 +1,112 @@ +module JSONAPI + + # A tree structure representing the resource structure of the requested resource(s). This is an intermediate structure + # used to keep track of the resources, by identity, found at different included relationships. It will be flattened and + # the resource instances will be fetched from the cache or the record store. + class ResourceIdTree + + attr_reader :fragments, :related_resource_id_trees + + # Gets the related Resource Id Tree for a relationship, and creates it first if it does not exist + # + # @param relationship [JSONAPI::Relationship] + # + # @return [JSONAPI::RelatedResourceIdTree] the new or existing resource id tree for the requested relationship + def fetch_related_resource_id_tree(relationship) + relationship_name = relationship.name.to_sym + @related_resource_id_trees[relationship_name] ||= RelatedResourceIdTree.new(relationship, self) + end + + private + + def init_included_relationships(fragment, include_related) + include_related && include_related.each_key do |relationship_name| + fragment.initialize_related(relationship_name) + end + end + end + + class PrimaryResourceIdTree < ResourceIdTree + + # Creates a PrimaryResourceIdTree with no resources and no related ResourceIdTrees + def initialize + @fragments ||= {} + @related_resource_id_trees ||= {} + end + + # Adds each Resource Fragment to the Resources hash + # + # @param fragments [Hash] + # @param include_related [Hash] + # + # @return [null] + def add_resource_fragments(fragments, include_related) + fragments.each_value do |fragment| + add_resource_fragment(fragment, include_related) + end + end + + # Adds a Resource Fragment to the Resources hash + # + # @param fragment [JSONAPI::ResourceFragment] + # @param include_related [Hash] + # + # @return [null] + def add_resource_fragment(fragment, include_related) + fragment.primary = true + + init_included_relationships(fragment, include_related) + + @fragments[fragment.identity] = fragment + end + end + + class RelatedResourceIdTree < ResourceIdTree + + attr_reader :parent_relationship, :source_resource_id_tree + + # Creates a RelatedResourceIdTree with no resources and no related ResourceIdTrees. A connection to the parent + # ResourceIdTree is maintained. + # + # @param parent_relationship [JSONAPI::Relationship] + # @param source_resource_id_tree [JSONAPI::ResourceIdTree] + # + # @return [JSONAPI::RelatedResourceIdTree] the new or existing resource id tree for the requested relationship + def initialize(parent_relationship, source_resource_id_tree) + @fragments ||= {} + @related_resource_id_trees ||= {} + + @parent_relationship = parent_relationship + @parent_relationship_name = parent_relationship.name.to_sym + @source_resource_id_tree = source_resource_id_tree + end + + # Adds each Resource Fragment to the Resources hash + # + # @param fragments [Hash] + # @param include_related [Hash] + # + # @return [null] + def add_resource_fragments(fragments, include_related) + fragments.each_value do |fragment| + add_resource_fragment(fragment, include_related) + end + end + + # Adds a Resource Fragment to the fragments hash + # + # @param fragment [JSONAPI::ResourceFragment] + # @param include_related [Hash] + # + # @return [null] + def add_resource_fragment(fragment, include_related) + init_included_relationships(fragment, include_related) + + fragment.related_from.each do |rid| + @source_resource_id_tree.fragments[rid].add_related_identity(parent_relationship.name, fragment.identity) + end + + @fragments[fragment.identity] = fragment + end + end +end \ No newline at end of file diff --git a/lib/jsonapi/resource_serializer.rb b/lib/jsonapi/resource_serializer.rb index f805c5d71..9d685361a 100644 --- a/lib/jsonapi/resource_serializer.rb +++ b/lib/jsonapi/resource_serializer.rb @@ -42,19 +42,19 @@ def initialize(primary_resource_klass, options = {}) end # Converts a resource_set to a hash, conforming to the JSONAPI structure - def serialize_resource_set_to_hash(result_set) + def serialize_resource_set_to_hash_single(resource_set) primary_objects = [] included_objects = [] - result_set.each_value do |values| - values.each_value do |value| - serialized_result = object_hash(value[:resource], value[:relationships]) + resource_set.resource_klasses.each_value do |resource_klass| + resource_klass.each_value do |resource| + serialized_resource = object_hash(resource[:resource], resource[:relationships]) - if value[:primary] - primary_objects.push(serialized_result) + if resource[:primary] + primary_objects.push(serialized_resource) else - included_objects.push(serialized_result) + included_objects.push(serialized_resource) end end end @@ -66,19 +66,19 @@ def serialize_resource_set_to_hash(result_set) primary_hash end - def serialize_resources_set_to_hash(result_set) + def serialize_resource_set_to_hash_plural(resource_set) primary_objects = [] included_objects = [] - result_set.each_value do |resources| - resources.each_value do |resource| - serialized_result = object_hash(resource[:resource], resource[:relationships]) + resource_set.resource_klasses.each_value do |resource_klass| + resource_klass.each_value do |resource| + serialized_resource = object_hash(resource[:resource], resource[:relationships]) if resource[:primary] - primary_objects.push(serialized_result) + primary_objects.push(serialized_resource) else - included_objects.push(serialized_result) + included_objects.push(serialized_resource) end end end @@ -89,27 +89,8 @@ def serialize_resources_set_to_hash(result_set) primary_hash end - def serialize_related_resources_set_to_hash(source_resource, result_set) - - primary_objects = [] - included_objects = [] - - result_set.each_value do |values| - values.each_value do |value| - serialized_result = object_hash(value[:resource], value[:relationships]) - - if value[:primary] - primary_objects.push(serialized_result) - else - included_objects.push(serialized_result) - end - end - end - - primary_hash = { 'data' => primary_objects } - - primary_hash['included'] = included_objects if included_objects.size > 0 - primary_hash + def serialize_related_resource_set_to_hash_plural(resource_set, _source_resource) + return serialize_resource_set_to_hash_plural(resource_set) end def serialize_to_links_hash(source, requested_relationship, resource_ids) @@ -282,16 +263,18 @@ def relationships_hash(source, fetchable_fields, relationship_data) field_set = supplying_relationship_fields(source.class) & relationships.keys relationships.each_with_object({}) do |(name, relationship), hash| + include_data = false if field_set.include?(name) if relationship_data[name] + include_data = true if relationship.is_a?(JSONAPI::Relationship::ToOne) - rids = relationship_data[name][:rids].first + rids = relationship_data[name].first else - rids = relationship_data[name][:rids] + rids = relationship_data[name] end end - hash[format_key(name)] = link_object(source, relationship, rids) + hash[format_key(name)] = link_object(source, relationship, rids, include_data) end end end @@ -316,16 +299,14 @@ def cached_relationships_hash(source, fetchable_fields, relationship_data) if relationship_klass.is_a?(JSONAPI::Relationship::ToOne) # include_linkage = @always_include_to_one_linkage_data | relationship_klass.always_include_linkage_data if relationship_data[relationship_name] - rids = relationship_data[relationship_name][:rids].first - include_linkage = rids - relationship['data'] = to_one_linkage(rids) if include_linkage + rids = relationship_data[relationship_name].first + relationship['data'] = to_one_linkage(rids) end else # include_linkage = relationship_klass.always_include_linkage_data if relationship_data[relationship_name] - rids = relationship_data[relationship_name][:rids] - include_linkage = !(rids.nil? || rids.empty?) - relationship['data'] = to_many_linkage(rids) if include_linkage + rids = relationship_data[relationship_name] + relationship['data'] = to_many_linkage(rids) end end @@ -345,7 +326,7 @@ def related_link(source, relationship) def to_many_linkage(rids) linkage = [] - rids.each do |details| + rids && rids.each do |details| id = details.id type = details.resource_klass.try(:_type) if type && id @@ -365,33 +346,29 @@ def to_one_linkage(rid) } end - def link_object_to_one(source, relationship, rid) - # include_linkage = @always_include_to_one_linkage_data | relationship.always_include_linkage_data - include_linkage = rid + def link_object_to_one(source, relationship, rid, include_data) link_object_hash = {} link_object_hash['links'] = {} link_object_hash['links']['self'] = self_link(source, relationship) link_object_hash['links']['related'] = related_link(source, relationship) - link_object_hash['data'] = to_one_linkage(rid) if include_linkage + link_object_hash['data'] = to_one_linkage(rid) if include_data link_object_hash end - def link_object_to_many(source, relationship, rids) - # include_linkage = relationship.always_include_linkage_data - include_linkage = rids && !rids.empty? + def link_object_to_many(source, relationship, rids, include_data) link_object_hash = {} link_object_hash['links'] = {} link_object_hash['links']['self'] = self_link(source, relationship) link_object_hash['links']['related'] = related_link(source, relationship) - link_object_hash['data'] = to_many_linkage(rids) if include_linkage + link_object_hash['data'] = to_many_linkage(rids) if include_data link_object_hash end - def link_object(source, relationship, rid) + def link_object(source, relationship, rid, include_data) if relationship.is_a?(JSONAPI::Relationship::ToOne) - link_object_to_one(source, relationship, rid) + link_object_to_one(source, relationship, rid, include_data) elsif relationship.is_a?(JSONAPI::Relationship::ToMany) - link_object_to_many(source, relationship, rid) + link_object_to_many(source, relationship, rid, include_data) end end diff --git a/lib/jsonapi/resource_set.rb b/lib/jsonapi/resource_set.rb new file mode 100644 index 000000000..391de4452 --- /dev/null +++ b/lib/jsonapi/resource_set.rb @@ -0,0 +1,108 @@ +module JSONAPI + # Contains a hash of resource types which contain a hash of resources, relationships and primary status keyed by + # resource id. + class ResourceSet + + attr_reader :resource_klasses, :populated + + def initialize(resource_id_tree) + @populated = false + @resource_klasses = flatten_resource_id_tree(resource_id_tree) + end + + def populate!(serializer, context, find_options) + @resource_klasses.each_key do |resource_klass| + missed_ids = [] + + serializer_config_key = serializer.config_key(resource_klass).gsub("/", "_") + context_json = resource_klass.attribute_caching_context(context).to_json + context_b64 = JSONAPI.configuration.resource_cache_digest_function.call(context_json) + context_key = "ATTR-CTX-#{context_b64.gsub("/", "_")}" + + if resource_klass.caching? + cache_ids = [] + + @resource_klasses[resource_klass].each_pair do |k, v| + # Store the hashcode of the cache_field to avoid storing objects and to ensure precision isn't lost + # on timestamp types (i.e. string conversions dropping milliseconds) + cache_ids.push([k, resource_klass.hash_cache_field(v[:cache_id])]) + end + + found_resources = CachedResponseFragment.fetch_cached_fragments( + resource_klass, + serializer_config_key, + cache_ids, + context) + + found_resources.each do |found_result| + resource = found_result[1] + if resource.nil? + missed_ids.push(found_result[0]) + else + @resource_klasses[resource_klass][resource.id][:resource] = resource + end + end + else + missed_ids = @resource_klasses[resource_klass].keys + end + + # fill in any missed resources + unless missed_ids.empty? + filters = {resource_klass._primary_key => missed_ids} + find_opts = { + context: context, + fields: find_options[:fields] } + + found_resources = resource_klass.find(filters, find_opts) + + found_resources.each do |resource| + relationship_data = @resource_klasses[resource_klass][resource.id][:relationships] + + if resource_klass.caching? + (id, cr) = CachedResponseFragment.write( + resource_klass, + resource, + serializer, + serializer_config_key, + context, + context_key, + relationship_data) + + @resource_klasses[resource_klass][id][:resource] = cr + else + @resource_klasses[resource_klass][resource.id][:resource] = resource + end + end + end + end + @populated = true + self + end + + private + def flatten_resource_id_tree(resource_id_tree, flattened_tree = {}) + resource_id_tree.fragments.each_pair do |resource_rid, fragment| + + resource_klass = resource_rid.resource_klass + id = resource_rid.id + + flattened_tree[resource_klass] ||= {} + + flattened_tree[resource_klass][id] ||= { primary: fragment.primary, relationships: {} } + flattened_tree[resource_klass][id][:cache_id] ||= fragment.cache + + fragment.related.try(:each_pair) do |relationship_name, related_rids| + flattened_tree[resource_klass][id][:relationships][relationship_name] ||= Set.new + flattened_tree[resource_klass][id][:relationships][relationship_name].merge(related_rids) + end + end + + related_resource_id_trees = resource_id_tree.related_resource_id_trees + related_resource_id_trees.try(:each_value) do |related_resource_id_tree| + flatten_resource_id_tree(related_resource_id_tree, flattened_tree) + end + + flattened_tree + end + end +end \ No newline at end of file diff --git a/test/controllers/controller_test.rb b/test/controllers/controller_test.rb index 4363e89b2..93f3e9021 100644 --- a/test/controllers/controller_test.rb +++ b/test/controllers/controller_test.rb @@ -538,7 +538,25 @@ def test_show_does_not_include_pages_count_in_meta JSONAPI.configuration.top_level_meta_include_page_count = false end - def test_show_single_with_includes + def test_show_single_with_has_one_include_included_exists + assert_cacheable_get :show, params: {id: '1', include: 'author'} + assert_response :success + assert_equal 1, json_response['included'].size + assert json_response['data']['relationships']['author'].has_key?('data'), 'Missing required data key' + refute_nil json_response['data']['relationships']['author']['data'], 'Data should not be nil' + refute json_response['data']['relationships']['tags'].has_key?('data'), 'Not included relationships should not have data' + end + + def test_show_single_with_has_one_include_included_does_not_exist + assert_cacheable_get :show, params: {id: '1', include: 'section'} + assert_response :success + assert_nil json_response['included'] + assert json_response['data']['relationships']['section'].has_key?('data'), 'Missing required data key' + assert_nil json_response['data']['relationships']['section']['data'], 'Data should be nil' + refute json_response['data']['relationships']['tags'].has_key?('data'), 'Not included relationships should not have data' + end + + def test_show_single_with_has_many_include assert_cacheable_get :show, params: {id: '1', include: 'comments'} assert_response :success assert json_response['data'].is_a?(Hash) @@ -2543,40 +2561,40 @@ def test_show_related_resource_no_namespace assert_hash_equals( { - data: { - id: '1001', - type: 'people', - links: { - self: 'http://test.host/people/1001' + "data" => { + "id" => "1001", + "type" => "people", + "links" => { + "self" => "http://test.host/people/1001" }, - attributes: { - name: 'Joe Author', - email: 'joe@xyz.fake', - "date-joined" => '2013-08-07 16:25:00 -0400' + "attributes" => { + "name" => "Joe Author", + "email" => "joe@xyz.fake", + "date-joined" => "2013-08-07 16:25:00 -0400" }, - relationships: { - comments: { - links: { - self: 'http://test.host/people/1001/relationships/comments', - related: 'http://test.host/people/1001/comments' + "relationships" => { + "comments" => { + "links" => { + "self" => "http://test.host/people/1001/relationships/comments", + "related" => "http://test.host/people/1001/comments" } }, - posts: { - links: { - self: 'http://test.host/people/1001/relationships/posts', - related: 'http://test.host/people/1001/posts' + "posts" => { + "links" => { + "self" => "http://test.host/people/1001/relationships/posts", + "related" => "http://test.host/people/1001/posts" } }, - preferences: { - links: { - self: 'http://test.host/people/1001/relationships/preferences', - related: 'http://test.host/people/1001/preferences' + "preferences" => { + "links" => { + "self" => "http://test.host/people/1001/relationships/preferences", + "related" => "http://test.host/people/1001/preferences" } }, - vehicles: { - links: { - self: "http://test.host/people/1001/relationships/vehicles", - related: "http://test.host/people/1001/vehicles" + "vehicles" => { + "links" => { + "self" => "http://test.host/people/1001/relationships/vehicles", + "related" => "http://test.host/people/1001/vehicles" } }, "hair-cut" => { @@ -3880,39 +3898,561 @@ def test_complex_includes_two_level end def test_complex_includes_things_nested_things - get :index, params: {include: 'things,things.things'} + assert_cacheable_get :index, params: {include: 'things,things.things,things.things.things'} assert_response :success - - # The test is hardcoded with the include order. This should be changed at some - # point since either thing could come first and still be valid - assert_equal '10', json_response['included'][0]['id'] - assert_equal 'things', json_response['included'][0]['type'] - assert_nil json_response['included'][0]['relationships']['user']['data'] - assert_equal '20', json_response['included'][0]['relationships']['things']['data'][0]['id'] - - assert_equal '20', json_response['included'][1]['id'] - assert_equal 'things', json_response['included'][1]['type'] - assert_nil json_response['included'][1]['relationships']['user']['data'] - assert_equal '10', json_response['included'][1]['relationships']['things']['data'][0]['id'] + assert_hash_equals( + { + "data" => [ + { + "id" => "100", + "type" => "boxes", + "links" => { + "self" => "http://test.host/api/boxes/100" + }, + "relationships" => { + "things" => { + "links" => { + "self" => "http://test.host/api/boxes/100/relationships/things", + "related" => "http://test.host/api/boxes/100/things" + }, + "data" => [ + { + "type" => "things", + "id" => "10" + }, + { + "type" => "things", + "id" => "20" + } + ] + } + } + }, + { + "id" => "102", + "type" => "boxes", + "links" => { + "self" => "http://test.host/api/boxes/102" + }, + "relationships" => { + "things" => { + "links" => { + "self" => "http://test.host/api/boxes/102/relationships/things", + "related" => "http://test.host/api/boxes/102/things" + }, + "data" => [ + { + "type" => "things", + "id" => "30" + } + ] + } + } + } + ], + "included" => [ + { + "id" => "10", + "type" => "things", + "links" => { + "self" => "http://test.host/api/things/10" + }, + "relationships" => { + "box" => { + "links" => { + "self" => "http://test.host/api/things/10/relationships/box", + "related" => "http://test.host/api/things/10/box" + }, + "data" => { + "type" => "boxes", + "id" => "100" + } + }, + "user" => { + "links" => { + "self" => "http://test.host/api/things/10/relationships/user", + "related" => "http://test.host/api/things/10/user" + } + }, + "things" => { + "links" => { + "self" => "http://test.host/api/things/10/relationships/things", + "related" => "http://test.host/api/things/10/things" + }, + "data" => [ + { + "type" => "things", + "id" => "20" + } + ] + } + } + }, + { + "id" => "20", + "type" => "things", + "links" => { + "self" => "http://test.host/api/things/20" + }, + "relationships" => { + "box" => { + "links" => { + "self" => "http://test.host/api/things/20/relationships/box", + "related" => "http://test.host/api/things/20/box" + }, + "data" => { + "type" => "boxes", + "id" => "100" + } + }, + "user" => { + "links" => { + "self" => "http://test.host/api/things/20/relationships/user", + "related" => "http://test.host/api/things/20/user" + } + }, + "things" => { + "links" => { + "self" => "http://test.host/api/things/20/relationships/things", + "related" => "http://test.host/api/things/20/things" + }, + "data" => [ + { + "type" => "things", + "id" => "10" + } + ] + } + } + }, + { + "id" => "30", + "type" => "things", + "links" => { + "self" => "http://test.host/api/things/30" + }, + "relationships" => { + "box" => { + "links" => { + "self" => "http://test.host/api/things/30/relationships/box", + "related" => "http://test.host/api/things/30/box" + }, + "data" => { + "type" => "boxes", + "id" => "102" + } + }, + "user" => { + "links" => { + "self" => "http://test.host/api/things/30/relationships/user", + "related" => "http://test.host/api/things/30/user" + } + }, + "things" => { + "links" => { + "self" => "http://test.host/api/things/30/relationships/things", + "related" => "http://test.host/api/things/30/things" + }, + "data" => [ + { + "type" => "things", + "id" => "40" + }, + { + "type" => "things", + "id" => "50" + } + + ] + } + } + }, + { + "id" => "40", + "type" => "things", + "links" => { + "self" => "http://test.host/api/things/40" + }, + "relationships" => { + "box" => { + "links" => { + "self" => "http://test.host/api/things/40/relationships/box", + "related" => "http://test.host/api/things/40/box" + } + }, + "user" => { + "links" => { + "self" => "http://test.host/api/things/40/relationships/user", + "related" => "http://test.host/api/things/40/user" + } + }, + "things" => { + "links" => { + "self" => "http://test.host/api/things/40/relationships/things", + "related" => "http://test.host/api/things/40/things" + } + } + } + }, + { + "id" => "50", + "type" => "things", + "links" => { + "self" => "http://test.host/api/things/50" + }, + "relationships" => { + "box" => { + "links" => { + "self" => "http://test.host/api/things/50/relationships/box", + "related" => "http://test.host/api/things/50/box" + } + }, + "user" => { + "links" => { + "self" => "http://test.host/api/things/50/relationships/user", + "related" => "http://test.host/api/things/50/user" + } + }, + "things" => { + "links" => { + "self" => "http://test.host/api/things/50/relationships/things", + "related" => "http://test.host/api/things/50/things" + }, + "data" => [ + { + "type" => "things", + "id" => "60" + } + ] + } + } + }, + { + "id" => "60", + "type" => "things", + "links" => { + "self" => "http://test.host/api/things/60" + }, + "relationships" => { + "box" => { + "links" => { + "self" => "http://test.host/api/things/60/relationships/box", + "related" => "http://test.host/api/things/60/box" + } + }, + "user" => { + "links" => { + "self" => "http://test.host/api/things/60/relationships/user", + "related" => "http://test.host/api/things/60/user" + } + }, + "things" => { + "links" => { + "self" => "http://test.host/api/things/60/relationships/things", + "related" => "http://test.host/api/things/60/things" + } + } + } + } + ] + }, + json_response) end def test_complex_includes_nested_things_secondary_users - get :index, params: {include: 'things,things.user,things.things'} + assert_cacheable_get :index, params: {include: 'things,things.user,things.things'} assert_response :success - - # The test is hardcoded with the include order. This should be changed at some - # point since either thing could come first and still be valid - assert_equal '10', json_response['included'][0]['id'] - assert_equal 'things', json_response['included'][0]['type'] - assert_equal '10001', json_response['included'][0]['relationships']['user']['data']['id'] - assert_equal '20', json_response['included'][0]['relationships']['things']['data'][0]['id'] - - assert_equal '20', json_response['included'][1]['id'] - assert_equal 'things', json_response['included'][1]['type'] - assert_equal '10001', json_response['included'][1]['relationships']['user']['data']['id'] - assert_equal '10', json_response['included'][1]['relationships']['things']['data'][0]['id'] + assert_hash_equals( + { + "data" => [ + { + "id" => "100", + "type" => "boxes", + "links" => { + "self" => "http://test.host/api/boxes/100" + }, + "relationships" => { + "things" => { + "links" => { + "self" => "http://test.host/api/boxes/100/relationships/things", + "related" => "http://test.host/api/boxes/100/things" + }, + "data" => [ + { + "type" => "things", + "id" => "10" + }, + { + "type" => "things", + "id" => "20" + } + ] + } + } + }, + { + "id" => "102", + "type" => "boxes", + "links" => { + "self" => "http://test.host/api/boxes/102" + }, + "relationships" => { + "things" => { + "links" => { + "self" => "http://test.host/api/boxes/102/relationships/things", + "related" => "http://test.host/api/boxes/102/things" + }, + "data" => [ + { + "type" => "things", + "id" => "30" + } + ] + } + } + } + ], + "included" => [ + { + "id" => "10", + "type" => "things", + "links" => { + "self" => "http://test.host/api/things/10" + }, + "relationships" => { + "box" => { + "links" => { + "self" => "http://test.host/api/things/10/relationships/box", + "related" => "http://test.host/api/things/10/box" + }, + "data" => { + "type" => "boxes", + "id" => "100" + } + }, + "user" => { + "links" => { + "self" => "http://test.host/api/things/10/relationships/user", + "related" => "http://test.host/api/things/10/user" + }, + "data" => { + "type" => "users", + "id" => "10001" + } + }, + "things" => { + "links" => { + "self" => "http://test.host/api/things/10/relationships/things", + "related" => "http://test.host/api/things/10/things" + }, + "data" => [ + { + "type" => "things", + "id" => "20" + } + ] + } + } + }, + { + "id" => "20", + "type" => "things", + "links" => { + "self" => "http://test.host/api/things/20" + }, + "relationships" => { + "box" => { + "links" => { + "self" => "http://test.host/api/things/20/relationships/box", + "related" => "http://test.host/api/things/20/box" + }, + "data" => { + "type" => "boxes", + "id" => "100" + } + }, + "user" => { + "links" => { + "self" => "http://test.host/api/things/20/relationships/user", + "related" => "http://test.host/api/things/20/user" + }, + "data" => { + "type" => "users", + "id" => "10001" + } + }, + "things" => { + "links" => { + "self" => "http://test.host/api/things/20/relationships/things", + "related" => "http://test.host/api/things/20/things" + }, + "data" => [ + { + "type" => "things", + "id" => "10" + } + ] + } + } + }, + { + "id" => "30", + "type" => "things", + "links" => { + "self" => "http://test.host/api/things/30" + }, + "relationships" => { + "box" => { + "links" => { + "self" => "http://test.host/api/things/30/relationships/box", + "related" => "http://test.host/api/things/30/box" + }, + "data" => { + "type" => "boxes", + "id" => "102" + } + }, + "user" => { + "links" => { + "self" => "http://test.host/api/things/30/relationships/user", + "related" => "http://test.host/api/things/30/user" + }, + "data" => { + "type" => "users", + "id" => "10002" + } + + }, + "things" => { + "links" => { + "self" => "http://test.host/api/things/30/relationships/things", + "related" => "http://test.host/api/things/30/things" + }, + "data" => [ + { + "type" => "things", + "id" => "40" + }, + { + "type" => "things", + "id" => "50" + } + + ] + } + } + }, + { + "id" => "40", + "type" => "things", + "links" => { + "self" => "http://test.host/api/things/40" + }, + "relationships" => { + "box" => { + "links" => { + "self" => "http://test.host/api/things/40/relationships/box", + "related" => "http://test.host/api/things/40/box" + } + }, + "user" => { + "links" => { + "self" => "http://test.host/api/things/40/relationships/user", + "related" => "http://test.host/api/things/40/user" + } + }, + "things" => { + "links" => { + "self" => "http://test.host/api/things/40/relationships/things", + "related" => "http://test.host/api/things/40/things" + } + } + } + }, + { + "id" => "50", + "type" => "things", + "links" => { + "self" => "http://test.host/api/things/50" + }, + "relationships" => { + "box" => { + "links" => { + "self" => "http://test.host/api/things/50/relationships/box", + "related" => "http://test.host/api/things/50/box" + } + }, + "user" => { + "links" => { + "self" => "http://test.host/api/things/50/relationships/user", + "related" => "http://test.host/api/things/50/user" + } + }, + "things" => { + "links" => { + "self" => "http://test.host/api/things/50/relationships/things", + "related" => "http://test.host/api/things/50/things" + } + } + } + }, + { + "id" => "10001", + "type" => "users", + "links" => { + "self" => "http://test.host/api/users/10001" + }, + "attributes" => { + "name" => "user 1" + }, + "relationships" => { + "things" => { + "links" => { + "self" => "http://test.host/api/users/10001/relationships/things", + "related" => "http://test.host/api/users/10001/things" + }, + "data" => [ + { + "type" => "things", + "id" => "10" + }, + { + "type" => "things", + "id" => "20" + } + ] + } + } + }, + { + "id" => "10002", + "type" => "users", + "links" => { + "self" => "http://test.host/api/users/10002" + }, + "attributes" => { + "name" => "user 2" + }, + "relationships" => { + "things" => { + "links" => { + "self" => "http://test.host/api/users/10002/relationships/things", + "related" => "http://test.host/api/users/10002/things" + }, + "data" => [ + { + "type" => "things", + "id" => "30" + } + ] + } + } + } + ] + }, + json_response) end end diff --git a/test/controllers/widget_controller_test.rb b/test/controllers/widget_controller_test.rb deleted file mode 100644 index 6edfaee01..000000000 --- a/test/controllers/widget_controller_test.rb +++ /dev/null @@ -1,47 +0,0 @@ -require File.expand_path('../../test_helper', __FILE__) - -def set_content_type_header! - @request.headers['Content-Type'] = JSONAPI::MEDIA_TYPE -end - -class WidgetsControllerTest < ActionController::TestCase - def teardown - Widget.delete_all - Indicator.delete_all - Agency.delete_all - end - - def test_fetch_widgets_sort_by_agency_name - agency_1 = Agency.create! name: 'beta' - agency_2 = Agency.create! name: 'alpha' - indicator_1 = Indicator.create! import_id: 'foobar', name: 'bar', agency: agency_1 - indicator_2 = Indicator.create! import_id: 'foobar2', name: 'foo', agency: agency_2 - Widget.create! name: 'bar', indicator: indicator_1 - widget = Widget.create! name: 'foo', indicator: indicator_2 - assert_cacheable_get :index, params: {sort: 'indicator.agency.name'} - assert_response :success - assert_equal widget.id.to_s, json_response['data'].first['id'] - end -end - -class IndicatorsControllerTest < ActionController::TestCase - def teardown - Widget.delete_all - Indicator.delete_all - Agency.delete_all - end - - def test_fetch_indicators_sort_by_widgets_name - agency = Agency.create! name: 'test' - indicator_1 = Indicator.create! import_id: 'bar', name: 'bar', agency: agency - indicator_2 = Indicator.create! import_id: 'foo', name: 'foo', agency: agency - Widget.create! name: 'omega', indicator: indicator_1 - Widget.create! name: 'beta', indicator: indicator_1 - Widget.create! name: 'alpha', indicator: indicator_2 - Widget.create! name: 'zeta', indicator: indicator_2 - assert_cacheable_get :index, params: {sort: 'widgets.name'} - assert_response :success - assert_equal indicator_2.id.to_s, json_response['data'].first['id'] - assert_equal 2, json_response['data'].size - end -end diff --git a/test/fixtures/active_record.rb b/test/fixtures/active_record.rb index 8f797bf8e..94c74a881 100644 --- a/test/fixtures/active_record.rb +++ b/test/fixtures/active_record.rb @@ -1459,11 +1459,12 @@ def find(filters, options = {}) # Records def find_fragments(filters, options = {}) - identities = {} + fragments = {} find_records(filters, options).each do |breed| - identities[JSONAPI::ResourceIdentity.new(BreedResource, breed.id)] = { cache_field: nil } + rid = JSONAPI::ResourceIdentity.new(BreedResource, breed.id) + fragments[rid] = JSONAPI::ResourceFragment.new(rid) end - identities + fragments end def find_by_key(key, options = {}) @@ -2348,6 +2349,7 @@ class ThingResource < JSONAPI::Resource class UserResource < JSONAPI::Resource has_many :things + attribute :name end end diff --git a/test/fixtures/things.yml b/test/fixtures/things.yml index 83783449e..29970936f 100644 --- a/test/fixtures/things.yml +++ b/test/fixtures/things.yml @@ -19,19 +19,19 @@ thing_30: thing_40: id: 40 user_id: 10002 - box_id: 102 + box_id: name: Thing40 thing_50: id: 50 user_id: 10002 - box_id: 102 + box_id: name: Thing50 thing_60: id: 60 user_id: 10002 - box_id: 102 + box_id: name: Thing60 #thing_70: diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml index 0fcda58fa..6d9ccdd32 100644 --- a/test/fixtures/users.yml +++ b/test/fixtures/users.yml @@ -1,5 +1,7 @@ user_1: id: 10001 + name: user 1 user_2: id: 10002 + name: user 2 diff --git a/test/unit/processor/default_processor_test.rb b/test/unit/processor/default_processor_test.rb index 0f4b221ea..0000e159b 100644 --- a/test/unit/processor/default_processor_test.rb +++ b/test/unit/processor/default_processor_test.rb @@ -2,7 +2,7 @@ require 'jsonapi-resources' require 'json' -class DefaultProcessorIdTreeTest < ActionDispatch::IntegrationTest +class DefaultProcessorTest < ActionDispatch::IntegrationTest def setup JSONAPI.configuration.json_key_format = :camelized_key JSONAPI.configuration.route_format = :camelized_route @@ -27,12 +27,9 @@ def setup serializer: {} } p = JSONAPI::Processor.new(PostResource, :find, params) - $id_tree_no_includes = p.find_resource_id_tree(PostResource, find_options, nil) - $resource_set_no_includes = p.flatten_resource_id_tree($id_tree_no_includes) - $populated_resource_set_no_includes = p.populate_resource_set($resource_set_no_includes, - $serializer, - {}) - + $id_tree_no_includes = p.send(:find_resource_id_tree, PostResource, find_options, nil) + $resource_set_no_includes = JSONAPI::ResourceSet.new($id_tree_no_includes) + $populated_resource_set_no_includes = JSONAPI::ResourceSet.new($id_tree_no_includes).populate!($serializer, nil,{}) # has_one included directives = JSONAPI::IncludeDirectives.new(PersonResource, ['author']).include_directives @@ -46,12 +43,9 @@ def setup } p = JSONAPI::Processor.new(PostResource, :find, params) - $id_tree_has_one_includes = p.find_resource_id_tree(PostResource, find_options, directives[:include_related]) - - $resource_set_has_one_includes = p.flatten_resource_id_tree($id_tree_has_one_includes) - $populated_resource_set_has_one_includes = p.populate_resource_set($resource_set_has_one_includes, - $serializer, - {}) + $id_tree_has_one_includes = p.send(:find_resource_id_tree, PostResource, find_options, directives[:include_related]) + $resource_set_has_one_includes = JSONAPI::ResourceSet.new($id_tree_has_one_includes) + $populated_resource_set_has_one_includes = JSONAPI::ResourceSet.new($id_tree_has_one_includes).populate!($serializer, nil,{}) end def after_teardown @@ -64,56 +58,55 @@ def after_teardown PersonResource.caching nil end - def test_id_tree_without_includes_should_be_a_hash - assert $id_tree_no_includes.is_a?(Hash) + def test_id_tree_without_includes_should_be_a_resource_id_tree + assert $id_tree_no_includes.is_a?(JSONAPI::PrimaryResourceIdTree) end def test_id_tree_without_includes_should_have_resources - assert_equal 2, $id_tree_no_includes[:resources].size + assert_equal 2, $id_tree_no_includes.fragments.size end - def test_id_tree_without_includes_should_not_have_includes - assert_nil $id_tree_no_includes[:includes] + def test_id_tree_without_includes_should_not_have_related_resources + assert_empty $id_tree_no_includes.related_resource_id_trees end def test_id_tree_without_includes_resource_relationships_should_be_empty - assert_equal 0, $id_tree_no_includes[:resources][JSONAPI::ResourceIdentity.new(PostResource, 10)][:relationships].length - assert_equal 0, $id_tree_no_includes[:resources][JSONAPI::ResourceIdentity.new(PostResource, 12)][:relationships].length + assert_equal 0, $id_tree_no_includes.fragments[JSONAPI::ResourceIdentity.new(PostResource, 10)].related.length + assert_equal 0, $id_tree_no_includes.fragments[JSONAPI::ResourceIdentity.new(PostResource, 12)].related.length end - - def test_id_tree_has_one_includes_should_be_a_hash - assert $id_tree_has_one_includes.is_a?(Hash) + def test_id_tree_has_one_includes_should_be_a_resource_id_tree + assert $id_tree_has_one_includes.is_a?(JSONAPI::PrimaryResourceIdTree) end def test_id_tree_has_one_includes_should_have_included_resources - assert $id_tree_has_one_includes[:included].is_a?(Hash) - assert $id_tree_has_one_includes[:included][:author].is_a?(Hash) - assert_equal 2, $id_tree_has_one_includes[:included][:author][:resources].size + assert $id_tree_has_one_includes.related_resource_id_trees.is_a?(Hash) + assert $id_tree_has_one_includes.related_resource_id_trees[:author].is_a?(JSONAPI::RelatedResourceIdTree) + assert_equal 2, $id_tree_has_one_includes.related_resource_id_trees[:author].fragments.size end def test_id_tree_has_one_includes_should_have_resources - assert_equal 2, $id_tree_has_one_includes[:resources].size + assert_equal 2, $id_tree_has_one_includes.fragments.size end def test_id_tree_has_one_includes_resource_relationships_should_have_rids - assert_equal 1, $id_tree_has_one_includes[:resources][JSONAPI::ResourceIdentity.new(PostResource, 10)][:relationships][:author][:rids].length - assert_equal 1, $id_tree_has_one_includes[:resources][JSONAPI::ResourceIdentity.new(PostResource, 12)][:relationships][:author][:rids].length + assert_equal 1, $id_tree_has_one_includes.fragments[JSONAPI::ResourceIdentity.new(PostResource, 10)].related[:author].length + assert_equal 1, $id_tree_has_one_includes.fragments[JSONAPI::ResourceIdentity.new(PostResource, 12)].related[:author].length end def test_populated_resource_set_has_one_includes_have_resources - assert $populated_resource_set_has_one_includes[PostResource][10].is_a?(Hash) - assert $populated_resource_set_has_one_includes[PostResource][12].is_a?(Hash) - assert $populated_resource_set_has_one_includes[PersonResource][1003].is_a?(Hash) - assert $populated_resource_set_has_one_includes[PersonResource][1004].is_a?(Hash) + assert $populated_resource_set_has_one_includes.resource_klasses[PostResource][10].is_a?(Hash) + assert $populated_resource_set_has_one_includes.resource_klasses[PostResource][12].is_a?(Hash) + assert $populated_resource_set_has_one_includes.resource_klasses[PersonResource][1003].is_a?(Hash) + assert $populated_resource_set_has_one_includes.resource_klasses[PersonResource][1004].is_a?(Hash) end def test_populated_resource_set_has_one_includes_relationships_are_resolved - assert_equal 1003, $populated_resource_set_has_one_includes[PostResource][10][:relationships][:author][:rids].first.id - assert_equal 1004, $populated_resource_set_has_one_includes[PostResource][12][:relationships][:author][:rids].first.id + assert_equal 1003, $populated_resource_set_has_one_includes.resource_klasses[PostResource][10][:relationships][:author].first.id + assert_equal 1004, $populated_resource_set_has_one_includes.resource_klasses[PostResource][12][:relationships][:author].first.id - assert_equal 10, $populated_resource_set_has_one_includes[PersonResource][1003][:relationships][:posts][:rids].first.id - assert_equal 12, $populated_resource_set_has_one_includes[PersonResource][1004][:relationships][:posts][:rids].first.id + assert_equal 10, $populated_resource_set_has_one_includes.resource_klasses[PersonResource][1003][:relationships][:posts].first.id + assert_equal 12, $populated_resource_set_has_one_includes.resource_klasses[PersonResource][1004][:relationships][:posts].first.id end end \ No newline at end of file diff --git a/test/unit/resource/active_relation_resource_finder_test.rb b/test/unit/resource/active_relation_resource_finder_test.rb index 31711e60a..36b5a54d8 100644 --- a/test/unit/resource/active_relation_resource_finder_test.rb +++ b/test/unit/resource/active_relation_resource_finder_test.rb @@ -17,9 +17,8 @@ def test_find_fragments_no_attributes assert_equal 20, posts_identities.length assert_equal JSONAPI::ResourceIdentity.new(ARPostResource, 1), posts_identities.keys[0] - assert_equal JSONAPI::ResourceIdentity.new(ARPostResource, 1), posts_identities.values[0][:identity] - assert posts_identities.values[0].is_a?(Hash) - assert_equal 1, posts_identities.values[0].length + assert_equal JSONAPI::ResourceIdentity.new(ARPostResource, 1), posts_identities.values[0].identity + assert posts_identities.values[0].is_a?(JSONAPI::ResourceFragment) end def test_find_fragments_cache_field @@ -29,10 +28,9 @@ def test_find_fragments_cache_field assert_equal 20, posts_identities.length assert_equal JSONAPI::ResourceIdentity.new(ARPostResource, 1), posts_identities.keys[0] - assert_equal JSONAPI::ResourceIdentity.new(ARPostResource, 1), posts_identities.values[0][:identity] - assert posts_identities.values[0].is_a?(Hash) - assert_equal 2, posts_identities.values[0].length - assert posts_identities.values[0][:cache].is_a?(ActiveSupport::TimeWithZone) + assert_equal JSONAPI::ResourceIdentity.new(ARPostResource, 1), posts_identities.values[0].identity + assert posts_identities.values[0].is_a?(JSONAPI::ResourceFragment) + assert posts_identities.values[0].cache.is_a?(ActiveSupport::TimeWithZone) end def test_find_fragments_cache_field_attributes @@ -42,13 +40,12 @@ def test_find_fragments_cache_field_attributes assert_equal 20, posts_identities.length assert_equal JSONAPI::ResourceIdentity.new(ARPostResource, 1), posts_identities.keys[0] - assert_equal JSONAPI::ResourceIdentity.new(ARPostResource, 1), posts_identities.values[0][:identity] - assert posts_identities.values[0].is_a?(Hash) - assert_equal 3, posts_identities.values[0].length - assert_equal 2, posts_identities.values[0][:attributes].length - assert posts_identities.values[0][:cache].is_a?(ActiveSupport::TimeWithZone) - assert_equal 'New post', posts_identities.values[0][:attributes][:headline] - assert_equal 1001, posts_identities.values[0][:attributes][:author_id] + assert_equal JSONAPI::ResourceIdentity.new(ARPostResource, 1), posts_identities.values[0].identity + assert posts_identities.values[0].is_a?(JSONAPI::ResourceFragment) + assert_equal 2, posts_identities.values[0].attributes.length + assert posts_identities.values[0].cache.is_a?(ActiveSupport::TimeWithZone) + assert_equal 'New post', posts_identities.values[0].attributes[:headline] + assert_equal 1001, posts_identities.values[0].attributes[:author_id] end def test_find_related_has_one_fragments_no_attributes @@ -57,14 +54,13 @@ def test_find_related_has_one_fragments_no_attributes JSONAPI::ResourceIdentity.new(ARPostResource, 2), JSONAPI::ResourceIdentity.new(ARPostResource, 20)] - related_identities = ARPostResource.find_related_fragments(source_rids, 'author', options) + related_fragments = ARPostResource.find_included_fragments(source_rids, 'author', options) - assert_equal 2, related_identities.length - assert_equal JSONAPI::ResourceIdentity.new(AuthorResource, 1001), related_identities.keys[0] - assert_equal JSONAPI::ResourceIdentity.new(AuthorResource, 1001), related_identities.values[0][:identity] - assert related_identities.values[0].is_a?(Hash) - assert_equal 2, related_identities.values[0].length - assert_equal 2, related_identities.values[0][:related][:author].length + assert_equal 2, related_fragments.length + assert_equal JSONAPI::ResourceIdentity.new(AuthorResource, 1001), related_fragments.keys[0] + assert_equal JSONAPI::ResourceIdentity.new(AuthorResource, 1001), related_fragments.values[0].identity + assert related_fragments.values[0].is_a?(JSONAPI::ResourceFragment) + assert_equal 2, related_fragments.values[0].related_from.length end def test_find_related_has_one_fragments_cache_field @@ -73,15 +69,14 @@ def test_find_related_has_one_fragments_cache_field JSONAPI::ResourceIdentity.new(ARPostResource, 2), JSONAPI::ResourceIdentity.new(ARPostResource, 20)] - related_identities = ARPostResource.find_related_fragments(source_rids, 'author', options) + related_fragments = ARPostResource.find_included_fragments(source_rids, 'author', options) - assert_equal 2, related_identities.length - assert_equal JSONAPI::ResourceIdentity.new(AuthorResource, 1001), related_identities.keys[0] - assert_equal JSONAPI::ResourceIdentity.new(AuthorResource, 1001), related_identities.values[0][:identity] - assert related_identities.values[0].is_a?(Hash) - assert_equal 3, related_identities.values[0].length - assert_equal 2, related_identities.values[0][:related][:author].length - assert related_identities.values[0][:cache].is_a?(ActiveSupport::TimeWithZone) + assert_equal 2, related_fragments.length + assert_equal JSONAPI::ResourceIdentity.new(AuthorResource, 1001), related_fragments.keys[0] + assert_equal JSONAPI::ResourceIdentity.new(AuthorResource, 1001), related_fragments.values[0].identity + assert related_fragments.values[0].is_a?(JSONAPI::ResourceFragment) + assert_equal 2, related_fragments.values[0].related_from.length + assert related_fragments.values[0].cache.is_a?(ActiveSupport::TimeWithZone) end def test_find_related_has_one_fragments_cache_field_attributes @@ -90,17 +85,16 @@ def test_find_related_has_one_fragments_cache_field_attributes JSONAPI::ResourceIdentity.new(ARPostResource, 2), JSONAPI::ResourceIdentity.new(ARPostResource, 20)] - related_identities = ARPostResource.find_related_fragments(source_rids, 'author', options) - - assert_equal 2, related_identities.length - assert_equal JSONAPI::ResourceIdentity.new(AuthorResource, 1001), related_identities.keys[0] - assert_equal JSONAPI::ResourceIdentity.new(AuthorResource, 1001), related_identities.values[0][:identity] - assert related_identities.values[0].is_a?(Hash) - assert_equal 4, related_identities.values[0].length - assert_equal 2, related_identities.values[0][:related][:author].length - assert_equal 1, related_identities.values[0][:attributes].length - assert related_identities.values[0][:cache].is_a?(ActiveSupport::TimeWithZone) - assert_equal 'Joe Author', related_identities.values[0][:attributes][:name] + related_fragments = ARPostResource.find_included_fragments(source_rids, 'author', options) + + assert_equal 2, related_fragments.length + assert_equal JSONAPI::ResourceIdentity.new(AuthorResource, 1001), related_fragments.keys[0] + assert_equal JSONAPI::ResourceIdentity.new(AuthorResource, 1001), related_fragments.values[0].identity + assert related_fragments.values[0].is_a?(JSONAPI::ResourceFragment) + assert_equal 2, related_fragments.values[0].related_from.length + assert_equal 1, related_fragments.values[0].attributes.length + assert related_fragments.values[0].cache.is_a?(ActiveSupport::TimeWithZone) + assert_equal 'Joe Author', related_fragments.values[0].attributes[:name] end def test_find_related_has_many_fragments_no_attributes @@ -110,15 +104,14 @@ def test_find_related_has_many_fragments_no_attributes JSONAPI::ResourceIdentity.new(ARPostResource, 12), JSONAPI::ResourceIdentity.new(ARPostResource, 14)] - related_identities = ARPostResource.find_related_fragments(source_rids, 'tags', options) + related_fragments = ARPostResource.find_included_fragments(source_rids, 'tags', options) - assert_equal 8, related_identities.length - assert_equal JSONAPI::ResourceIdentity.new(TagResource, 501), related_identities.keys[0] - assert_equal JSONAPI::ResourceIdentity.new(TagResource, 501), related_identities.values[0][:identity] - assert related_identities.values[0].is_a?(Hash) - assert_equal 2, related_identities.values[0].length - assert_equal 1, related_identities.values[0][:related][:tags].length - assert_equal 2, related_identities[JSONAPI::ResourceIdentity.new(TagResource, 502)][:related][:tags].length + assert_equal 8, related_fragments.length + assert_equal JSONAPI::ResourceIdentity.new(TagResource, 501), related_fragments.keys[0] + assert_equal JSONAPI::ResourceIdentity.new(TagResource, 501), related_fragments.values[0].identity + assert related_fragments.values[0].is_a?(JSONAPI::ResourceFragment) + assert_equal 1, related_fragments.values[0].related_from.length + assert_equal 2, related_fragments[JSONAPI::ResourceIdentity.new(TagResource, 502)].related_from.length end def test_find_related_has_many_fragments_pagination @@ -126,29 +119,13 @@ def test_find_related_has_many_fragments_pagination options = { paginator: PagedPaginator.new(params) } source_rids = [JSONAPI::ResourceIdentity.new(ARPostResource, 15)] - related_identities = ARPostResource.find_related_fragments(source_rids, 'tags', options) - - assert_equal 1, related_identities.length - assert_equal JSONAPI::ResourceIdentity.new(TagResource, 516), related_identities.keys[0] - assert_equal JSONAPI::ResourceIdentity.new(TagResource, 516), related_identities.values[0][:identity] - assert related_identities.values[0].is_a?(Hash) - assert_equal 2, related_identities.values[0].length - assert_equal 1, related_identities.values[0][:related][:tags].length - end - - def test_find_related_has_many_fragments_pagination_included_key - params = ActionController::Parameters.new(number: 2, size: 4) - options = { paginator: PagedPaginator.new(params) } - source_rids = [JSONAPI::ResourceIdentity.new(ARPostResource, 15)] - - related_identities = ARPostResource.find_related_fragments(source_rids, 'tags', options, :tags) + related_fragments = ARPostResource.find_included_fragments(source_rids, 'tags', options) - assert_equal 5, related_identities.length - assert_equal JSONAPI::ResourceIdentity.new(TagResource, 502), related_identities.keys[0] - assert_equal JSONAPI::ResourceIdentity.new(TagResource, 502), related_identities.values[0][:identity] - assert related_identities.values[0].is_a?(Hash) - assert_equal 2, related_identities.values[0].length - assert_equal 1, related_identities.values[0][:related][:tags].length + assert_equal 1, related_fragments.length + assert_equal JSONAPI::ResourceIdentity.new(TagResource, 516), related_fragments.keys[0] + assert_equal JSONAPI::ResourceIdentity.new(TagResource, 516), related_fragments.values[0].identity + assert related_fragments.values[0].is_a?(JSONAPI::ResourceFragment) + assert_equal 1, related_fragments.values[0].related_from.length end def test_find_related_has_many_fragments_cache_field @@ -158,16 +135,15 @@ def test_find_related_has_many_fragments_cache_field JSONAPI::ResourceIdentity.new(ARPostResource, 12), JSONAPI::ResourceIdentity.new(ARPostResource, 14)] - related_identities = ARPostResource.find_related_fragments(source_rids, 'tags', options) + related_fragments = ARPostResource.find_included_fragments(source_rids, 'tags', options) - assert_equal 8, related_identities.length - assert_equal JSONAPI::ResourceIdentity.new(TagResource, 501), related_identities.keys[0] - assert_equal JSONAPI::ResourceIdentity.new(TagResource, 501), related_identities.values[0][:identity] - assert related_identities.values[0].is_a?(Hash) - assert_equal 3, related_identities.values[0].length - assert_equal 1, related_identities.values[0][:related][:tags].length - assert_equal 2, related_identities[JSONAPI::ResourceIdentity.new(TagResource, 502)][:related][:tags].length - assert related_identities.values[0][:cache].is_a?(ActiveSupport::TimeWithZone) + assert_equal 8, related_fragments.length + assert_equal JSONAPI::ResourceIdentity.new(TagResource, 501), related_fragments.keys[0] + assert_equal JSONAPI::ResourceIdentity.new(TagResource, 501), related_fragments.values[0].identity + assert related_fragments.values[0].is_a?(JSONAPI::ResourceFragment) + assert_equal 1, related_fragments.values[0].related_from.length + assert_equal 2, related_fragments[JSONAPI::ResourceIdentity.new(TagResource, 502)].related_from.length + assert related_fragments.values[0].cache.is_a?(ActiveSupport::TimeWithZone) end def test_find_related_has_many_fragments_cache_field_attributes @@ -177,18 +153,17 @@ def test_find_related_has_many_fragments_cache_field_attributes JSONAPI::ResourceIdentity.new(ARPostResource, 12), JSONAPI::ResourceIdentity.new(ARPostResource, 14)] - related_identities = ARPostResource.find_related_fragments(source_rids, 'tags', options) - - assert_equal 8, related_identities.length - assert_equal JSONAPI::ResourceIdentity.new(TagResource, 501), related_identities.keys[0] - assert_equal JSONAPI::ResourceIdentity.new(TagResource, 501), related_identities.values[0][:identity] - assert related_identities.values[0].is_a?(Hash) - assert_equal 4, related_identities.values[0].length - assert_equal 1, related_identities.values[0][:related][:tags].length - assert_equal 2, related_identities[JSONAPI::ResourceIdentity.new(TagResource, 502)][:related][:tags].length - assert_equal 1, related_identities.values[0][:attributes].length - assert related_identities.values[0][:cache].is_a?(ActiveSupport::TimeWithZone) - assert_equal 'short', related_identities.values[0][:attributes][:name] + related_fragments = ARPostResource.find_included_fragments(source_rids, 'tags', options) + + assert_equal 8, related_fragments.length + assert_equal JSONAPI::ResourceIdentity.new(TagResource, 501), related_fragments.keys[0] + assert_equal JSONAPI::ResourceIdentity.new(TagResource, 501), related_fragments.values[0].identity + assert related_fragments.values[0].is_a?(JSONAPI::ResourceFragment) + assert_equal 1, related_fragments.values[0].related_from.length + assert_equal 2, related_fragments[JSONAPI::ResourceIdentity.new(TagResource, 502)].related_from.length + assert_equal 1, related_fragments.values[0].attributes.length + assert related_fragments.values[0].cache.is_a?(ActiveSupport::TimeWithZone) + assert_equal 'short', related_fragments.values[0].attributes[:name] end def test_find_related_polymorphic_fragments_no_attributes @@ -197,17 +172,16 @@ def test_find_related_polymorphic_fragments_no_attributes JSONAPI::ResourceIdentity.new(PictureResource, 2), JSONAPI::ResourceIdentity.new(PictureResource, 20)] - related_identities = PictureResource.find_related_fragments(source_rids, 'imageable', options) - - assert_equal 2, related_identities.length - assert_equal JSONAPI::ResourceIdentity.new(ProductResource, 1), related_identities.keys[0] - assert_equal JSONAPI::ResourceIdentity.new(ProductResource, 1), related_identities.values[0][:identity] - assert_equal JSONAPI::ResourceIdentity.new(DocumentResource, 1), related_identities.keys[1] - assert_equal JSONAPI::ResourceIdentity.new(DocumentResource, 1), related_identities.values[1][:identity] - assert related_identities.values[0].is_a?(Hash) - assert_equal 2, related_identities.values[0].length - assert_equal 1, related_identities.values[0][:related][:imageable].length - assert_equal JSONAPI::ResourceIdentity.new(ProductResource, 1), related_identities.values[0][:identity] + related_fragments = PictureResource.find_included_fragments(source_rids, 'imageable', options) + + assert_equal 2, related_fragments.length + assert_equal JSONAPI::ResourceIdentity.new(ProductResource, 1), related_fragments.keys[0] + assert_equal JSONAPI::ResourceIdentity.new(ProductResource, 1), related_fragments.values[0].identity + assert_equal JSONAPI::ResourceIdentity.new(DocumentResource, 1), related_fragments.keys[1] + assert_equal JSONAPI::ResourceIdentity.new(DocumentResource, 1), related_fragments.values[1].identity + assert related_fragments.values[0].is_a?(JSONAPI::ResourceFragment) + assert_equal 1, related_fragments.values[0].related_from.length + assert_equal JSONAPI::ResourceIdentity.new(ProductResource, 1), related_fragments.values[0].identity end def test_find_related_polymorphic_fragments_cache_field @@ -216,17 +190,16 @@ def test_find_related_polymorphic_fragments_cache_field JSONAPI::ResourceIdentity.new(PictureResource, 2), JSONAPI::ResourceIdentity.new(PictureResource, 20)] - related_identities = PictureResource.find_related_fragments(source_rids, 'imageable', options) - - assert_equal 2, related_identities.length - assert_equal JSONAPI::ResourceIdentity.new(ProductResource, 1), related_identities.keys[0] - assert_equal JSONAPI::ResourceIdentity.new(ProductResource, 1), related_identities.values[0][:identity] - assert_equal JSONAPI::ResourceIdentity.new(DocumentResource, 1), related_identities.keys[1] - assert_equal JSONAPI::ResourceIdentity.new(DocumentResource, 1), related_identities.values[1][:identity] - assert related_identities.values[0].is_a?(Hash) - assert_equal 3, related_identities.values[0].length - assert_equal 1, related_identities.values[0][:related][:imageable].length - assert related_identities.values[0][:cache].is_a?(ActiveSupport::TimeWithZone) + related_fragments = PictureResource.find_included_fragments(source_rids, 'imageable', options) + + assert_equal 2, related_fragments.length + assert_equal JSONAPI::ResourceIdentity.new(ProductResource, 1), related_fragments.keys[0] + assert_equal JSONAPI::ResourceIdentity.new(ProductResource, 1), related_fragments.values[0].identity + assert_equal JSONAPI::ResourceIdentity.new(DocumentResource, 1), related_fragments.keys[1] + assert_equal JSONAPI::ResourceIdentity.new(DocumentResource, 1), related_fragments.values[1].identity + assert related_fragments.values[0].is_a?(JSONAPI::ResourceFragment) + assert_equal 1, related_fragments.values[0].related_from.length + assert related_fragments.values[0].cache.is_a?(ActiveSupport::TimeWithZone) end def test_find_related_polymorphic_fragments_cache_field_attributes @@ -235,19 +208,18 @@ def test_find_related_polymorphic_fragments_cache_field_attributes JSONAPI::ResourceIdentity.new(PictureResource, 2), JSONAPI::ResourceIdentity.new(PictureResource, 20)] - related_identities = PictureResource.find_related_fragments(source_rids, 'imageable', options) - - assert_equal 2, related_identities.length - assert_equal JSONAPI::ResourceIdentity.new(ProductResource, 1), related_identities.keys[0] - assert_equal JSONAPI::ResourceIdentity.new(ProductResource, 1), related_identities.values[0][:identity] - assert_equal JSONAPI::ResourceIdentity.new(DocumentResource, 1), related_identities.keys[1] - assert_equal JSONAPI::ResourceIdentity.new(DocumentResource, 1), related_identities.values[1][:identity] - assert related_identities.values[0].is_a?(Hash) - assert_equal 4, related_identities.values[0].length - assert_equal 1, related_identities.values[0][:related][:imageable].length - assert_equal 1, related_identities.values[0][:attributes].length - assert related_identities.values[0][:cache].is_a?(ActiveSupport::TimeWithZone) - assert_equal 'Enterprise Gizmo', related_identities.values[0][:attributes][:name] + related_fragments = PictureResource.find_included_fragments(source_rids, 'imageable', options) + + assert_equal 2, related_fragments.length + assert_equal JSONAPI::ResourceIdentity.new(ProductResource, 1), related_fragments.keys[0] + assert_equal JSONAPI::ResourceIdentity.new(ProductResource, 1), related_fragments.values[0].identity + assert_equal JSONAPI::ResourceIdentity.new(DocumentResource, 1), related_fragments.keys[1] + assert_equal JSONAPI::ResourceIdentity.new(DocumentResource, 1), related_fragments.values[1].identity + assert related_fragments.values[0].is_a?(JSONAPI::ResourceFragment) + assert_equal 1, related_fragments.values[0].related_from.length + assert_equal 1, related_fragments.values[0].attributes.length + assert related_fragments.values[0].cache.is_a?(ActiveSupport::TimeWithZone) + assert_equal 'Enterprise Gizmo', related_fragments.values[0].attributes[:name] end def test_gets_relationship_chain_with_only_field diff --git a/test/unit/resource/resource_test.rb b/test/unit/resource/resource_test.rb index 71ac6ceb4..1d56da915 100644 --- a/test/unit/resource/resource_test.rb +++ b/test/unit/resource/resource_test.rb @@ -265,16 +265,16 @@ def test_filter_on_has_one_relationship_id def test_to_many_relationship_filters post_resource = PostResource.new(Post.find(1), nil) - comments = PostResource.find_related_fragments([post_resource.identity], :comments) + comments = PostResource.find_included_fragments([post_resource.identity], :comments, {}) assert_equal(2, comments.size) - filtered_comments = PostResource.find_related_fragments([post_resource.identity], :comments, { filters: { body: 'i liked it' } }) + filtered_comments = PostResource.find_included_fragments([post_resource.identity], :comments, { filters: { body: 'i liked it' } }) assert_equal(1, filtered_comments.size) end def test_to_many_relationship_sorts post_resource = PostResource.new(Post.find(1), nil) - comment_ids = post_resource.class.find_related_fragments([post_resource.identity], :comments).keys.collect {|c| c.id } + comment_ids = post_resource.class.find_included_fragments([post_resource.identity], :comments, {}).keys.collect {|c| c.id } assert_equal [1,2], comment_ids # define apply_filters method on post resource to sort descending @@ -287,7 +287,7 @@ def apply_sort(records, _order_options, options) end end - sorted_comment_ids = post_resource.class.find_related_fragments( + sorted_comment_ids = post_resource.class.find_included_fragments( [post_resource.identity], :comments, { sort_criteria: [{ field: 'id', direction: :desc }] }).keys.collect {|c| c.id} From 69b1d7396eedf8799cc406d5fe5f4a79463f6d21 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Thu, 3 Jan 2019 18:40:20 -0500 Subject: [PATCH 108/237] Bump jsonapi-resources to 0.10.0.beta1 --- lib/jsonapi/resources/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/jsonapi/resources/version.rb b/lib/jsonapi/resources/version.rb index c71b0694d..635b788c3 100644 --- a/lib/jsonapi/resources/version.rb +++ b/lib/jsonapi/resources/version.rb @@ -1,5 +1,5 @@ module JSONAPI module Resources - VERSION = '0.10.0.pre' + VERSION = '0.10.0.beta1' end end From 895c5dad03498f1c3375a6cf31faaa326b2aa03a Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Wed, 9 Jan 2019 10:05:55 -0500 Subject: [PATCH 109/237] Add test for empty included relationships containing data elements --- test/controllers/controller_test.rb | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test/controllers/controller_test.rb b/test/controllers/controller_test.rb index 93f3e9021..48af4eb58 100644 --- a/test/controllers/controller_test.rb +++ b/test/controllers/controller_test.rb @@ -568,6 +568,17 @@ def test_show_single_with_has_many_include assert_equal 2, json_response['included'].size end + def test_includes_for_empty_relationships_shows_but_are_empty + assert_cacheable_get :show, params: {id: '17', include: 'author,tags'} + + assert_response :success + assert json_response['data']['relationships']['author'].has_key?('data'), 'data key should exist for empty has_one relaionship' + assert_nil json_response['data']['relationships']['author']['data'], 'Data should be null' + assert json_response['data']['relationships']['tags'].has_key?('data'), 'data key should exist for empty has_many relationship' + assert json_response['data']['relationships']['tags']['data'].is_a?(Array), 'Data should be array' + assert json_response['data']['relationships']['tags']['data'].empty?, 'Data array should be empty' + end + def test_show_single_with_include_disallowed original_config = JSONAPI.configuration.dup JSONAPI.configuration.allow_include = false From 86f0c0d79d951d889006c936a9616b3ff8ddb70c Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Mon, 14 Jan 2019 10:52:10 -0500 Subject: [PATCH 110/237] Remove find_records overrides from tests Clean up the tests so as to not encourage overriding `find_records` instead of `records`. --- test/fixtures/active_record.rb | 20 ++++++++------------ test/test_helper.rb | 2 +- test/unit/resource/resource_test.rb | 4 ++-- 3 files changed, 11 insertions(+), 15 deletions(-) diff --git a/test/fixtures/active_record.rb b/test/fixtures/active_record.rb index 94c74a881..b942fb029 100644 --- a/test/fixtures/active_record.rb +++ b/test/fixtures/active_record.rb @@ -1453,14 +1453,14 @@ def self.included(base) module ClassMethods def find(filters, options = {}) - records = find_records(filters, options) + records = find_breeds(filters, options) resources_for(records, options[:context]) end # Records def find_fragments(filters, options = {}) fragments = {} - find_records(filters, options).each do |breed| + find_breeds(filters, options).each do |breed| rid = JSONAPI::ResourceIdentity.new(BreedResource, breed.id) fragments[rid] = JSONAPI::ResourceFragment.new(rid) end @@ -1468,17 +1468,17 @@ def find_fragments(filters, options = {}) end def find_by_key(key, options = {}) - record = find_record_by_key(key, options) + record = find_breed_by_key(key, options) resource_for(record, options[:context]) end def find_by_keys(keys, options = {}) - records = find_records_by_keys(keys, options) + records = find_breeds_by_keys(keys, options) resources_for(records, options[:context]) end # - def find_records(filters, options = {}) + def find_breeds(filters, options = {}) breeds = [] id_filter = filters[:id] id_filter = [id_filter] unless id_filter.nil? || id_filter.is_a?(Array) @@ -1488,11 +1488,11 @@ def find_records(filters, options = {}) breeds end - def find_record_by_key(key, options = {}) + def find_breed_by_key(key, options = {}) $breed_data.breeds[key.to_i] end - def find_records_by_keys(keys, options = {}) + def find_breeds_by_keys(keys, options = {}) breeds = [] keys.each do |key| breeds.push($breed_data.breeds[key.to_i]) @@ -1501,7 +1501,7 @@ def find_records_by_keys(keys, options = {}) end def retrieve_records(ids, options = {}) - find_records_by_keys(ids, options) + find_breeds_by_keys(ids, options) end end end @@ -1569,10 +1569,6 @@ class PreferencesResource < JSONAPI::Resource attribute :advanced_mode has_one :author, :foreign_key_on => :related, class_name: "Person" - - def self.find_records(filters, options = {}) - Preferences.limit(1) - end end class FactResource < JSONAPI::Resource diff --git a/test/test_helper.rb b/test/test_helper.rb index 01520a3b2..eabe2f9a1 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -596,7 +596,7 @@ def assert_cacheable_get(action, *args) if mode == :all # TODO Should also be caching :show_related_resource (non-plural) action if [:index, :show, :show_related_resources].include?(action) - if ar_resource_klass && response.status == 200 && json_response["data"].try(:size) > 0 + if ar_resource_klass && response.status == 200 && json_response["data"].try(:size).try(:>, 0) assert_operator( cache_activity[:warmup][:total][:misses], :>, diff --git a/test/unit/resource/resource_test.rb b/test/unit/resource/resource_test.rb index 1d56da915..c7df61721 100644 --- a/test/unit/resource/resource_test.rb +++ b/test/unit/resource/resource_test.rb @@ -258,8 +258,8 @@ def test_filter_on_aliased_to_many_relationship_id end def test_filter_on_has_one_relationship_id - people = PreferencesResource.find(:author => 1) - assert_equal([1], people.map(&:id)) + prefs = PreferencesResource.find(:author => 1001) + assert_equal([1], prefs.map(&:id)) end def test_to_many_relationship_filters From 087568e845dacbcb5889f7da610e45e701380abd Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Mon, 14 Jan 2019 11:07:57 -0500 Subject: [PATCH 111/237] Remove unused `retrieve_records` method --- lib/jsonapi/resource.rb | 4 ---- test/fixtures/active_record.rb | 4 ---- 2 files changed, 8 deletions(-) diff --git a/lib/jsonapi/resource.rb b/lib/jsonapi/resource.rb index 675726f88..cce8a8630 100644 --- a/lib/jsonapi/resource.rb +++ b/lib/jsonapi/resource.rb @@ -714,10 +714,6 @@ def records(options = {}) _model_class.all end - def retrieve_records(ids, options = {}) - _model_class.where(_primary_key => ids) - end - def resources_for(records, context) records.collect do |record| resource_for(record, context) diff --git a/test/fixtures/active_record.rb b/test/fixtures/active_record.rb index b942fb029..e4fab8602 100644 --- a/test/fixtures/active_record.rb +++ b/test/fixtures/active_record.rb @@ -1499,10 +1499,6 @@ def find_breeds_by_keys(keys, options = {}) end breeds end - - def retrieve_records(ids, options = {}) - find_breeds_by_keys(ids, options) - end end end From fe5c5f19727738e74b33e4b726c727f0c5d744bd Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Mon, 14 Jan 2019 11:26:08 -0500 Subject: [PATCH 112/237] Move `records` method to ActiveRelationResourceFinder --- lib/jsonapi/active_relation_resource_finder.rb | 4 ++++ lib/jsonapi/resource.rb | 4 ---- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/jsonapi/active_relation_resource_finder.rb b/lib/jsonapi/active_relation_resource_finder.rb index 4828be800..0c0a2621c 100644 --- a/lib/jsonapi/active_relation_resource_finder.rb +++ b/lib/jsonapi/active_relation_resource_finder.rb @@ -169,6 +169,10 @@ def count_related(source_rid, relationship_name, options = {}) records.count(:all) end + def records(_options = {}) + _model_class.all + end + def parse_relationship_path(path) relationships = [] relationship_names = [] diff --git a/lib/jsonapi/resource.rb b/lib/jsonapi/resource.rb index cce8a8630..52a9bceb8 100644 --- a/lib/jsonapi/resource.rb +++ b/lib/jsonapi/resource.rb @@ -710,10 +710,6 @@ def fields _relationships.keys | _attributes.keys end - def records(options = {}) - _model_class.all - end - def resources_for(records, context) records.collect do |record| resource_for(record, context) From 3213d57b8f38e6eca29336eaa6f21c8117cb9e23 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Tue, 5 Feb 2019 20:34:13 -0500 Subject: [PATCH 113/237] Introduce Path class for parsing include, filter and sort paths --- lib/jsonapi-resources.rb | 2 + lib/jsonapi/error_codes.rb | 2 + lib/jsonapi/exceptions.rb | 20 +++++ lib/jsonapi/include_directives.rb | 36 +++----- lib/jsonapi/path.rb | 41 +++++++++ lib/jsonapi/path_part.rb | 62 ++++++++++++++ locales/en.yml | 3 + test/unit/paths/path_test.rb | 85 +++++++++++++++++++ .../serializer/include_directives_test.rb | 4 +- 9 files changed, 229 insertions(+), 26 deletions(-) create mode 100644 lib/jsonapi/path.rb create mode 100644 lib/jsonapi/path_part.rb create mode 100644 test/unit/paths/path_test.rb diff --git a/lib/jsonapi-resources.rb b/lib/jsonapi-resources.rb index 6aca96e44..663a39324 100644 --- a/lib/jsonapi-resources.rb +++ b/lib/jsonapi-resources.rb @@ -30,3 +30,5 @@ require 'jsonapi/resource_fragment' require 'jsonapi/resource_id_tree' require 'jsonapi/resource_set' +require 'jsonapi/path' +require 'jsonapi/path_part' diff --git a/lib/jsonapi/error_codes.rb b/lib/jsonapi/error_codes.rb index 35f309bc7..d23f757c2 100644 --- a/lib/jsonapi/error_codes.rb +++ b/lib/jsonapi/error_codes.rb @@ -20,6 +20,7 @@ module JSONAPI INVALID_FILTERS_SYNTAX = '120' SAVE_FAILED = '121' INVALID_DATA_FORMAT = '122' + INVALID_RELATIONSHIP = '123' BAD_REQUEST = '400' FORBIDDEN = '403' RECORD_NOT_FOUND = '404' @@ -50,6 +51,7 @@ module JSONAPI INVALID_FILTERS_SYNTAX => 'INVALID_FILTERS_SYNTAX', SAVE_FAILED => 'SAVE_FAILED', INVALID_DATA_FORMAT => 'INVALID_DATA_FORMAT', + INVALID_RELATIONSHIP => 'INVALID_RELATIONSHIP', FORBIDDEN => 'FORBIDDEN', RECORD_NOT_FOUND => 'RECORD_NOT_FOUND', NOT_ACCEPTABLE => 'NOT_ACCEPTABLE', diff --git a/lib/jsonapi/exceptions.rb b/lib/jsonapi/exceptions.rb index 12ec17783..0ed65e5a2 100644 --- a/lib/jsonapi/exceptions.rb +++ b/lib/jsonapi/exceptions.rb @@ -327,6 +327,26 @@ def errors end end + class InvalidRelationship < Error + attr_accessor :relationship_name, :type + + def initialize(type, relationship_name, error_object_overrides = {}) + @relationship_name = relationship_name + @type = type + super(error_object_overrides) + end + + def errors + [create_error_object(code: JSONAPI::INVALID_RELATIONSHIP, + status: :bad_request, + title: I18n.translate('jsonapi-resources.exceptions.invalid_relationship.title', + default: 'Invalid relationship'), + detail: I18n.translate('jsonapi-resources.exceptions.invalid_relationship.detail', + default: "#{relationship_name} is not a valid field for #{type}.", + relationship_name: relationship_name, type: type))] + end + end + class InvalidInclude < Error attr_accessor :relationship, :resource diff --git a/lib/jsonapi/include_directives.rb b/lib/jsonapi/include_directives.rb index 0457a9df9..4f254cbe4 100644 --- a/lib/jsonapi/include_directives.rb +++ b/lib/jsonapi/include_directives.rb @@ -33,35 +33,23 @@ def include_directives private - def get_related(current_path) - current = @include_directives_hash - current_resource_klass = @resource_klass - current_path.split('.').each do |fragment| - fragment = fragment.to_sym + def parse_include(include) + path = JSONAPI::Path.new(resource_klass: @resource_klass, + path_string: include, + ensure_default_field: false, + parse_fields: false) - if current_resource_klass - current_relationship = current_resource_klass._relationships[fragment] - current_resource_klass = current_relationship.try(:resource_klass) - else - raise JSONAPI::Exceptions::InvalidInclude.new(current_resource_klass, current_path) - end + current = @include_directives_hash + path.parts.each do |part| + relationship_name = part.relationship.name.to_sym - current[:include_related][fragment] ||= { include: false, include_related: {} } - current = current[:include_related][fragment] + current[:include_related][relationship_name] ||= { include: true, include_related: {} } + current = current[:include_related][relationship_name] end - current - end - def parse_include(include) - parts = include.split('.') - local_path = '' - - parts.each do |name| - local_path += local_path.length > 0 ? ".#{name}" : name - related = get_related(local_path) - related[:include] = true - end + rescue JSONAPI::Exceptions::InvalidRelationship => _e + raise JSONAPI::Exceptions::InvalidInclude.new(@resource_klass, include) end end end diff --git a/lib/jsonapi/path.rb b/lib/jsonapi/path.rb new file mode 100644 index 000000000..f79b4277f --- /dev/null +++ b/lib/jsonapi/path.rb @@ -0,0 +1,41 @@ +module JSONAPI + class Path + attr_reader :parts, :resource_klass + def initialize(resource_klass:, + path_string:, + ensure_default_field: true, + parse_fields: true) + @resource_klass = resource_klass + + current_resource_klass = resource_klass + @parts = path_string.to_s.split('.').collect do |part_string| + part = PathPart.parse(source_resource_klass: current_resource_klass, + part_string: part_string, + parse_fields: parse_fields) + + current_resource_klass = part.resource_klass + part + end + + if ensure_default_field && parse_fields && @parts.last.is_a?(PathPart::Relationship) + last = @parts.last + @parts << PathPart::Field.new(resource_klass: last.resource_klass, + field_name: last.resource_klass._primary_key) + end + end + + def relationship_parts + relationships = [] + @parts.each do |part| + relationships << part if part.is_a?(PathPart::Relationship) + end + relationships + end + + def relationship_path_string + relationship_parts.collect do |part| + part.to_s + end.join('.') + end + end +end \ No newline at end of file diff --git a/lib/jsonapi/path_part.rb b/lib/jsonapi/path_part.rb new file mode 100644 index 000000000..937578824 --- /dev/null +++ b/lib/jsonapi/path_part.rb @@ -0,0 +1,62 @@ +module JSONAPI + class PathPart + def self.parse(source_resource_klass:, part_string:, parse_fields: true) + first_part, last_part = part_string.split('#', 2) + relationship = source_resource_klass._relationship(first_part) + + if relationship + if last_part + resource_klass = source_resource_klass.resource_klass_for(last_part) + # ToDo: compare to relationship and raise error if not a match? + end + return PathPart::Relationship.new(relationship: relationship, resource_klass: resource_klass) + else + if last_part.blank? && parse_fields + return PathPart::Field.new(resource_klass: source_resource_klass, field_name: first_part) + else + raise JSONAPI::Exceptions::InvalidRelationship.new(source_resource_klass._type, part_string) + end + end + end + + class Relationship + attr_reader :relationship + + def initialize(relationship:, resource_klass:) + @relationship = relationship + @resource_klass = resource_klass + end + + def to_s + @resource_klass ? "#{relationship.name}##{resource_klass._type}" : "#{relationship.name}" + end + + def resource_klass + @resource_klass || @relationship.resource_klass + end + end + + class Field + attr_reader :resource_klass, :field_name + + def initialize(resource_klass:, field_name:) + # ToDo: Should we enforce the resource has the field? + # unless resource_klass._has_attribute?(field_name) + # raise JSONAPI::Exceptions::InvalidField.new(resource_klass._type, field_name) + # end + @resource_klass = resource_klass + @field_name = field_name + end + + def delegated_field_name + resource_klass._attribute_delegated_name(field_name) + end + + def to_s + # :nocov: + field_name.to_s + # :nocov: + end + end + end +end \ No newline at end of file diff --git a/locales/en.yml b/locales/en.yml index ee210f9dd..02915fcc7 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -46,6 +46,9 @@ en: invalid_field: title: 'Invalid field' detail: "%{field} is not a valid field for %{type}." + invalid_relationship: + title: 'Invalid relationship' + detail: "%{relationship_name} is not a valid relationship for %{type}." invalid_include: title: 'Invalid include' detail: "%{relationship} is not a valid includable relationship of %{resource}" diff --git a/test/unit/paths/path_test.rb b/test/unit/paths/path_test.rb new file mode 100644 index 000000000..171f0fe30 --- /dev/null +++ b/test/unit/paths/path_test.rb @@ -0,0 +1,85 @@ +require File.expand_path('../../../test_helper', __FILE__) +require 'jsonapi-resources' + +class PathTest < ActiveSupport::TestCase + + def test_one_relationship + path = JSONAPI::Path.new(resource_klass: Api::V1::PostResource, path_string: 'comments') + + assert path.parts.is_a?(Array) + assert path.parts[0].is_a?(JSONAPI::PathPart::Relationship), "should be a PathPart::Relationship" + assert_equal Api::V1::PostResource._relationship(:comments), path.parts[0].relationship + end + + def test_one_field + path = JSONAPI::Path.new(resource_klass: Api::V1::PostResource, path_string: 'title') + + assert path.parts.is_a?(Array) + assert path.parts[0].is_a?(JSONAPI::PathPart::Field), "should be a PathPart::Field" + assert_equal 'title', path.parts[0].field_name + end + + def test_two_relationships + path = JSONAPI::Path.new(resource_klass: Api::V1::PostResource, path_string: 'comments.author') + + assert path.parts.is_a?(Array) + assert path.parts[0].is_a?(JSONAPI::PathPart::Relationship), "should be a PathPart::Relationship" + assert path.parts[1].is_a?(JSONAPI::PathPart::Relationship), "should be a PathPart::Relationship" + assert_equal Api::V1::PostResource._relationship(:comments), path.parts[0].relationship + assert_equal Api::V1::CommentResource._relationship(:author), path.parts[1].relationship + end + + def test_two_relationships_and_field + path = JSONAPI::Path.new(resource_klass: Api::V1::PostResource, path_string: 'comments.author.name') + + assert path.parts.is_a?(Array) + assert path.parts[0].is_a?(JSONAPI::PathPart::Relationship), "should be a PathPart::Relationship" + assert path.parts[1].is_a?(JSONAPI::PathPart::Relationship), "should be a PathPart::Relationship" + assert path.parts[2].is_a?(JSONAPI::PathPart::Field), "should be a PathPart::Field" + + assert_equal Api::V1::PostResource._relationship(:comments), path.parts[0].relationship + assert_equal Api::V1::CommentResource._relationship(:author), path.parts[1].relationship + assert_equal 'name', path.parts[2].field_name + end + + def test_two_relationships_and_parse_fields_false_raises_with_field + + assert_raises JSONAPI::Exceptions::InvalidRelationship do + path = JSONAPI::Path.new(resource_klass: Api::V1::PostResource, + path_string: 'comments.author.name', + parse_fields: false) + end + end + + def test_ensure_default_field_false + path = JSONAPI::Path.new(resource_klass: Api::V1::PostResource, path_string: 'comments.author', ensure_default_field: false) + + assert path.parts.is_a?(Array) + assert_equal 2, path.parts.length + assert path.parts[0].is_a?(JSONAPI::PathPart::Relationship), "should be a PathPart::Relationship" + assert path.parts[1].is_a?(JSONAPI::PathPart::Relationship), "should be a PathPart::Relationship" + + assert_equal Api::V1::PostResource._relationship(:comments), path.parts[0].relationship + assert_equal Api::V1::CommentResource._relationship(:author), path.parts[1].relationship + end + + def test_ensure_default_field_true + path = JSONAPI::Path.new(resource_klass: Api::V1::PostResource, path_string: 'comments.author', ensure_default_field: true) + + assert path.parts.is_a?(Array) + assert_equal 3, path.parts.length + assert path.parts[0].is_a?(JSONAPI::PathPart::Relationship), "should be a PathPart::Relationship" + assert path.parts[1].is_a?(JSONAPI::PathPart::Relationship), "should be a PathPart::Relationship" + + assert_equal Api::V1::PostResource._relationship(:comments), path.parts[0].relationship + assert_equal Api::V1::CommentResource._relationship(:author), path.parts[1].relationship + end + + def test_polymorphic_path + path = JSONAPI::Path.new(resource_klass: PictureResource, path_string: :imageable) + + assert path.parts.is_a?(Array) + assert path.parts[0].is_a?(JSONAPI::PathPart::Relationship), "should be a PathPart::Relationship" + assert_equal PictureResource._relationship(:imageable), path.parts[0].relationship + end +end diff --git a/test/unit/serializer/include_directives_test.rb b/test/unit/serializer/include_directives_test.rb index 279ee76d1..e4a336646 100644 --- a/test/unit/serializer/include_directives_test.rb +++ b/test/unit/serializer/include_directives_test.rb @@ -19,7 +19,7 @@ def test_one_level_one_include end def test_one_level_multiple_includes - directives = JSONAPI::IncludeDirectives.new(PersonResource, ['posts', 'comments', 'tags']).include_directives + directives = JSONAPI::IncludeDirectives.new(PersonResource, ['posts', 'comments', 'expense_entries']).include_directives assert_hash_equals( { @@ -32,7 +32,7 @@ def test_one_level_multiple_includes include: true, include_related:{} }, - tags: { + expense_entries: { include: true, include_related:{} } From 825c9647267123ecf7a8ee82647f7419cbf05eee Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Wed, 6 Feb 2019 09:48:24 -0500 Subject: [PATCH 114/237] Update test fixtures and data --- test/fixtures/active_record.rb | 111 ++++++++++++++++++++++-------- test/fixtures/documents.yml | 4 ++ test/fixtures/file_properties.yml | 41 +++++++++++ test/fixtures/people.yml | 1 + test/fixtures/pictures.yml | 4 ++ test/fixtures/products.yml | 1 + test/test_helper.rb | 2 + 7 files changed, 136 insertions(+), 28 deletions(-) create mode 100644 test/fixtures/file_properties.yml diff --git a/test/fixtures/active_record.rb b/test/fixtures/active_record.rb index e4fab8602..2dc6c5c96 100644 --- a/test/fixtures/active_record.rb +++ b/test/fixtures/active_record.rb @@ -3,6 +3,7 @@ ActiveSupport::Inflector.inflections(:en) do |inflect| inflect.uncountable 'preferences' + inflect.uncountable 'file_properties' inflect.irregular 'numero_telefone', 'numeros_telefone' end @@ -47,6 +48,7 @@ create_table :author_details, force: true do |t| t.integer :person_id t.string :author_stuff + t.timestamps null: false end create_table :posts, force: true do |t| @@ -238,20 +240,32 @@ create_table :pictures, force: true do |t| t.string :name + t.integer :author_id t.references :imageable, polymorphic: true, index: true t.timestamps null: false end create_table :documents, force: true do |t| t.string :name + t.integer :author_id t.timestamps null: false end create_table :products, force: true do |t| t.string :name + t.integer :designer_id t.timestamps null: false end + create_table :file_properties, force: true do |t| + t.string :name + t.timestamps null: false + t.references :fileable, polymorphic: true, index: true + t.belongs_to :tag, index: true + + t.integer :size + end + create_table :vehicles, force: true do |t| t.string :type t.string :make @@ -317,6 +331,7 @@ create_table :questions, force: true do |t| t.string :text + t.timestamps null: false end create_table :answers, force: true do |t| @@ -324,14 +339,17 @@ t.integer :respondent_id t.string :respondent_type t.string :text + t.timestamps null: false end create_table :patients, force: true do |t| t.string :name + t.timestamps null: false end create_table :doctors, force: true do |t| t.string :name + t.timestamps null: false end create_table :painters, force: true do |t| @@ -460,6 +478,8 @@ class Person < ActiveRecord::Base has_many :even_posts, -> { where('posts.id % 2 = 0') }, class_name: 'Post', foreign_key: 'author_id' has_many :odd_posts, -> { where('posts.id % 2 = 1') }, class_name: 'Post', foreign_key: 'author_id' + has_many :pictures, foreign_key: 'author_id' + ### Validations validates :name, presence: true validates :date_joined, presence: true @@ -713,10 +733,13 @@ class Category < ActiveRecord::Base end class Picture < ActiveRecord::Base - belongs_to :imageable, polymorphic: true + belongs_to :author, class_name: 'Person', foreign_key: 'author_id' + belongs_to :imageable, polymorphic: true belongs_to :document, -> { where( pictures: { imageable_type: 'Document' } ).eager_load( :pictures ) }, foreign_key: 'imageable_id' belongs_to :product, -> { where( pictures: { imageable_type: 'Product' } ).eager_load( :pictures ) }, foreign_key: 'imageable_id' + + has_one :file_properties, as: 'fileable' end class Vehicle < ActiveRecord::Base @@ -731,10 +754,19 @@ class Boat < Vehicle class Document < ActiveRecord::Base has_many :pictures, as: :imageable + belongs_to :author, class_name: 'Person', foreign_key: 'author_id' + has_one :file_properties, as: 'fileable' end class Product < ActiveRecord::Base has_many :pictures, as: :imageable + belongs_to :designer, class_name: 'Person', foreign_key: 'designer_id' + has_one :file_properties, as: 'fileable' +end + +class FileProperties < ActiveRecord::Base + belongs_to :fileable, polymorphic: true + belongs_to :tag end class Make < ActiveRecord::Base @@ -932,6 +964,9 @@ class ProductsController < JSONAPI::ResourceController class ImageablesController < JSONAPI::ResourceController end +class FilePropertiesController < JSONAPI::ResourceController +end + class VehiclesController < JSONAPI::ResourceController end @@ -1063,6 +1098,9 @@ module V6 class AuthorsController < JSONAPI::ResourceController end + class AuthorDetailsController < JSONAPI::ResourceController + end + class PostsController < JSONAPI::ResourceController end @@ -1553,7 +1591,7 @@ class CraterResource < JSONAPI::Resource filter :description, apply: -> (records, value, options) { fail "context not set" unless options[:context][:current_user] != nil && options[:context][:current_user] == $test_user - records.where(concat_table_field(options[:related_alias], :description) => value) + records.where(concat_table_field(options[:joins][''][:alias], :description) => value) } def self.verify_key(key, context = nil) @@ -1585,35 +1623,58 @@ class CategoryResource < JSONAPI::Resource class PictureResource < JSONAPI::Resource attribute :name - has_one :imageable, polymorphic: true + has_one :author + + has_one :imageable, polymorphic: true + has_one :file_properties, inverse_relationship: :fileable, :foreign_key_on => :related, polymorphic: true filter 'imageable.name', perform_joins: true, apply: -> (records, value, options) { joins = options[:joins] relationship = _relationship(:imageable) - or_parts = relationship.polymorphic_relations.collect do |relation| - table_alias = joins["imageable[#{relation}]"][:alias] + or_parts = relationship.resource_types.collect do |type| + table_alias = joins["imageable##{type}"][:alias] "#{concat_table_field(table_alias, "name")} = '#{value.first}'" end records.where(or_parts.join(' OR ')) } + + filter 'imageable#documents.name' +end + +class ImageableResource < JSONAPI::Resource + polymorphic +end + +class FileableResource < JSONAPI::Resource + polymorphic end class DocumentResource < JSONAPI::Resource attribute :name - has_many :pictures + has_many :pictures, inverse_relationship: :imageable + has_one :author, class_name: 'Person' + + has_one :file_properties, inverse_relationship: :fileable, :foreign_key_on => :related end class ProductResource < JSONAPI::Resource attribute :name - has_one :picture, always_include_linkage_data: true + has_many :pictures, inverse_relationship: :imageable + has_one :designer, class_name: 'Person' + + has_one :file_properties, inverse_relationship: :fileable, :foreign_key_on => :related def picture_id _model.picture.id end end -# ToDo: Remove the need for the polymorphic fake resource -class ImageableResource < JSONAPI::Resource +class FilePropertiesResource < JSONAPI::Resource + attribute :name + attribute :size + + has_one :fileable, polymorphic: true + has_one :tag end class MakeResource < JSONAPI::Resource @@ -1630,6 +1691,7 @@ class AuthorResource < JSONAPI::Resource attributes :name has_many :books, inverse_relationship: :authors + has_many :pictures end class BookResource < JSONAPI::Resource @@ -1950,19 +2012,10 @@ class AuthorResource < JSONAPI::Resource relationship :posts, to: :many relationship :author_detail, to: :one, foreign_key_on: :related - filter :name - - def self.find_records(filters, options = {}) - rel = _model_class - filters.each do |attr, filter| - if attr.to_s == "id" - rel = rel.where(id: filter) - else - rel = rel.where("\"#{attr}\" LIKE \"%#{filter[0]}%\"") - end - end - rel - end + filter :name, apply: lambda { |records, value, options| + table_alias = options[:joins][''][:alias] + records.where("#{concat_table_field(table_alias, "name")} LIKE \"%#{value[0]}%\"") + } def fetchable_fields super - [:email] @@ -2020,15 +2073,19 @@ class EmployeeResource < EmployeeResource; end module Api module V6 + class HairCutResource < HairCutResource; end + class AuthorDetailResource < JSONAPI::Resource attributes :author_stuff + has_one :author, foreign_key: :person_id, inverse_relationship: :author_detail end class AuthorResource < JSONAPI::Resource attributes :name, :email model_name 'Person' relationship :posts, to: :many - relationship :author_detail, to: :one, foreign_key_on: :related + relationship :author_detail, to: :one, foreign_key_on: :related, foreign_key: :person_id + has_one :hair_cut filter :name @@ -2037,6 +2094,7 @@ def self.sortable_fields(context) end end + class PreferencesResource < PreferencesResource; end class PersonResource < PersonResource; end class TagResource < TagResource; end @@ -2047,13 +2105,10 @@ class SectionResource < SectionResource class CommentResource < CommentResource; end class PostResource < PostResource - # Test caching with SQL fragments - def self.records(options = {}) - _model_class.all.joins('INNER JOIN people on people.id = author_id') - end - attribute :base + has_one :author + def base _model.title end diff --git a/test/fixtures/documents.yml b/test/fixtures/documents.yml index 2002137f7..635ee8881 100644 --- a/test/fixtures/documents.yml +++ b/test/fixtures/documents.yml @@ -1,18 +1,22 @@ document_1: id: 1 name: Company Brochure + author_id: 1002 document_2: id: 2 name: Enagement Letter + author_id: 1001 document_200: id: 200 name: Management Through the Years + author_id: document_201: id: 201 name: Foo + author_id: 1001 #ToDo: rename this once we have different filter types by default. See test_polymorpic_relation_filter document_300: diff --git a/test/fixtures/file_properties.yml b/test/fixtures/file_properties.yml new file mode 100644 index 000000000..0a1f2e207 --- /dev/null +++ b/test/fixtures/file_properties.yml @@ -0,0 +1,41 @@ +picture_2: + id: 20002 + name: company_brochure.jpg + fileable_id: 2 + fileable_type: Picture + +picture_3: + id: 20003 + name: group_photo.jpg + fileable_id: 3 + fileable_type: Picture + +picture_40: + id: 20040 + name: company_management_team_2015.jpg + fileable_id: 40 + fileable_type: Picture + +document_2: + id: 30002 + name: Enagement Letter.doc + fileable_id: 2 + fileable_type: Document + +document_200: + id: 30200 + name: Management Through the Years.doc + fileable_id: 200 + fileable_type: Document + +product_1: + id: 40001 + name: Enterprise Gizmo.spec + fileable_id: 1 + fileable_type: Product + +product_2: + id: 40002 + name: Fighting Hot Sauce.spec + fileable_id: 2 + fileable_type: Product diff --git a/test/fixtures/people.yml b/test/fixtures/people.yml index 47e868b34..4af190a7c 100644 --- a/test/fixtures/people.yml +++ b/test/fixtures/people.yml @@ -10,6 +10,7 @@ b: name: Fred Reader email: fred@xyz.fake date_joined: <%= DateTime.parse('2013-10-31 20:25:00 UTC +00:00') %> + hair_cut_id: 1 c: id: 1003 diff --git a/test/fixtures/pictures.yml b/test/fixtures/pictures.yml index c3cdee65b..a43f1df62 100644 --- a/test/fixtures/pictures.yml +++ b/test/fixtures/pictures.yml @@ -3,22 +3,26 @@ picture_1: name: enterprise_gizmo.jpg imageable_id: 1 imageable_type: Product + author_id: 1002 picture_2: id: 2 name: company_brochure.jpg imageable_id: 1 imageable_type: Document + author_id: 1002 picture_3: id: 3 name: group_photo.jpg + author_id: 1001 picture_40: id: 40 name: company_management_team_2015.jpg imageable_id: 200 imageable_type: Document + author_id: 1001 picture_41: id: 41 diff --git a/test/fixtures/products.yml b/test/fixtures/products.yml index c8e9884c4..325d48938 100644 --- a/test/fixtures/products.yml +++ b/test/fixtures/products.yml @@ -1,6 +1,7 @@ product_1: id: 1 name: Enterprise Gizmo + designer_id: 1001 product_2: id: 2 diff --git a/test/test_helper.rb b/test/test_helper.rb index eabe2f9a1..db9f0679f 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -272,6 +272,7 @@ class CatResource < JSONAPI::Resource jsonapi_resources :pictures jsonapi_resources :documents jsonapi_resources :products + jsonapi_resources :file_properties jsonapi_resources :vehicles jsonapi_resources :cars jsonapi_resources :boats @@ -363,6 +364,7 @@ class CatResource < JSONAPI::Resource JSONAPI.configuration.route_format = :dasherized_route namespace :v6 do jsonapi_resources :authors + jsonapi_resources :author_details jsonapi_resources :posts jsonapi_resources :sections jsonapi_resources :customers From 789b3b7e3c62b0ce717150b01cacc687c7c4d41b Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Wed, 6 Feb 2019 11:20:50 -0500 Subject: [PATCH 115/237] Rework joins and support belongs_to linkages without includes --- .../active_relation_resource_finder.rb | 603 ++-- .../join_tree.rb | 224 +- lib/jsonapi/path_part.rb | 4 + lib/jsonapi/processor.rb | 24 +- lib/jsonapi/relationship.rb | 43 +- lib/jsonapi/request_parser.rb | 4 +- lib/jsonapi/resource.rb | 122 +- lib/jsonapi/resource_set.rb | 3 +- test/controllers/controller_test.rb | 150 +- .../join_tree_test.rb | 243 +- test/unit/processor/default_processor_test.rb | 2 +- .../active_relation_resource_finder_test.rb | 53 +- .../serializer/polymorphic_serializer_test.rb | 484 --- test/unit/serializer/serializer_test.rb | 2951 +++-------------- 14 files changed, 1534 insertions(+), 3376 deletions(-) delete mode 100644 test/unit/serializer/polymorphic_serializer_test.rb diff --git a/lib/jsonapi/active_relation_resource_finder.rb b/lib/jsonapi/active_relation_resource_finder.rb index 0c0a2621c..0f09f916e 100644 --- a/lib/jsonapi/active_relation_resource_finder.rb +++ b/lib/jsonapi/active_relation_resource_finder.rb @@ -15,7 +15,21 @@ module ClassMethods # # @return [Array] the Resource instances matching the filters, sorting and pagination rules. def find(filters, options = {}) - records = find_records(filters, options) + sort_criteria = options.fetch(:sort_criteria) { [] } + + join_tree = JoinTree.new(resource_klass: self, + options: options, + filters: filters, + sort_criteria: sort_criteria) + + paginator = options[:paginator] + + records = find_records(records: records(options), + filters: filters, + join_tree: join_tree, + paginator: paginator, + options: options) + resources_for(records, options[:context]) end @@ -26,7 +40,22 @@ def find(filters, options = {}) # # @return [Integer] the count def count(filters, options = {}) - count_records(filter_records(records(options), filters, options)) + sort_criteria = options.fetch(:sort_criteria) { [] } + + join_tree = JoinTree.new(resource_klass: self, + options: options, + filters: filters, + sort_criteria: sort_criteria) + + paginator = options[:paginator] + + records = find_records(records: records(options), + filters: filters, + join_tree: join_tree, + paginator: paginator, + options: options) + + count_records(records) end # Returns the single Resource identified by `key` @@ -49,6 +78,7 @@ def find_by_keys(keys, options = {}) # Finds Resource fragments using the `filters`. Pagination and sort options are used when provided. # Retrieving the ResourceIdentities and attributes does not instantiate a model instance. + # Note: This is incompatible with Polymorphic resources (which are going to come from two separate tables) # # @param filters [Hash] the filters hash # @option options [Hash] :context The context of the request, set in the controller @@ -61,27 +91,76 @@ def find_by_keys(keys, options = {}) # the ResourceInstances matching the filters, sorting, and pagination rules along with any request # additional_field values def find_fragments(filters, options = {}) - records = find_records(filters, options) + include_directives = options[:include_directives] ? options[:include_directives].include_directives : {} + resource_klass = self + linkage_relationships = to_one_relationships_for_linkage(include_directives[:include_related]) + + sort_criteria = options.fetch(:sort_criteria) { [] } + + join_tree = JoinTree.new(resource_klass: resource_klass, + source_relationship: nil, + relationships: linkage_relationships, + sort_criteria: sort_criteria, + filters: filters, + options: options) + + paginator = options[:paginator] - table_name = _model_class.table_name - pluck_fields = [Arel.sql("#{concat_table_field(table_name, _primary_key)} AS #{table_name}_#{_primary_key}")] + records = find_records(records: records(options), + filters: filters, + sort_criteria: sort_criteria, + paginator: paginator, + join_tree: join_tree, + options: options) + + joins = join_tree.joins + + # This alias is going to be resolve down to the model's table name and will not actually be an alias + resource_table_alias = joins[''][:alias] + + pluck_fields = [Arel.sql("#{concat_table_field(resource_table_alias, resource_klass._primary_key)} AS #{resource_table_alias}_#{resource_klass._primary_key}")] cache_field = attribute_to_model_field(:_cache_field) if options[:cache] if cache_field - pluck_fields << Arel.sql("#{concat_table_field(table_name, cache_field[:name])} AS #{table_name}_#{cache_field[:name]}") + pluck_fields << Arel.sql("#{concat_table_field(resource_table_alias, cache_field[:name])} AS #{resource_table_alias}_#{cache_field[:name]}") + end + + linkage_fields = [] + + linkage_relationships.each do |name| + linkage_relationship = resource_klass._relationship(name) + + if linkage_relationship.polymorphic? && linkage_relationship.belongs_to? + linkage_relationship.resource_types.each do |resource_type| + klass = resource_klass_for(resource_type) + linkage_fields << {relationship_name: name, resource_klass: klass} + + linkage_table_alias = joins["#{linkage_relationship.name.to_s}##{resource_type}"][:alias] + primary_key = klass._primary_key + pluck_fields << Arel.sql("#{concat_table_field(linkage_table_alias, primary_key)} AS #{linkage_table_alias}_#{primary_key}") + end + else + klass = linkage_relationship.resource_klass + linkage_fields << {relationship_name: name, resource_klass: klass} + + linkage_table_alias = joins[name.to_s][:alias] + primary_key = klass._primary_key + pluck_fields << Arel.sql("#{concat_table_field(linkage_table_alias, primary_key)} AS #{linkage_table_alias}_#{primary_key}") + end end model_fields = {} attributes = options[:attributes] attributes.try(:each) do |attribute| - model_field = attribute_to_model_field(attribute) + model_field = resource_klass.attribute_to_model_field(attribute) model_fields[attribute] = model_field - pluck_fields << Arel.sql("#{concat_table_field(table_name, model_field[:name])} AS #{table_name}_#{model_field[:name]}") + pluck_fields << Arel.sql("#{concat_table_field(resource_table_alias, model_field[:name])} AS #{resource_table_alias}_#{model_field[:name]}") end fragments = {} - records.pluck(*pluck_fields).collect do |row| - rid = JSONAPI::ResourceIdentity.new(self, pluck_fields.length == 1 ? row : row[0]) + rows = records.pluck(*pluck_fields) + rows.collect do |row| + rid = JSONAPI::ResourceIdentity.new(resource_klass, pluck_fields.length == 1 ? row : row[0]) fragments[rid] ||= JSONAPI::ResourceFragment.new(rid) attributes_offset = 1 @@ -91,6 +170,16 @@ def find_fragments(filters, options = {}) attributes_offset+= 1 end + linkage_fields.each do |linkage_field_details| + fragments[rid].initialize_related(linkage_field_details[:relationship_name]) + related_id = row[attributes_offset] + if related_id + related_rid = JSONAPI::ResourceIdentity.new(linkage_field_details[:resource_klass], related_id) + fragments[rid].add_related_identity(linkage_field_details[:relationship_name], related_rid) + end + attributes_offset+= 1 + end + model_fields.each_with_index do |k, idx| fragments[rid].attributes[k[0]]= cast_to_attribute_type(row[idx + attributes_offset], k[1][:type]) end @@ -113,17 +202,17 @@ def find_fragments(filters, options = {}) def find_related_fragments(source_rids, relationship_name, options = {}) relationship = _relationship(relationship_name) - if relationship.polymorphic? && relationship.foreign_key_on == :self + if relationship.polymorphic? # && relationship.foreign_key_on == :self find_related_polymorphic_fragments(source_rids, relationship, options, false) else find_related_monomorphic_fragments(source_rids, relationship, options, false) end end - def find_included_fragments(source_rids, relationship_name, options = {}) + def find_included_fragments(source_rids, relationship_name, options) relationship = _relationship(relationship_name) - if relationship.polymorphic? && relationship.foreign_key_on == :self + if relationship.polymorphic? # && relationship.foreign_key_on == :self find_related_polymorphic_fragments(source_rids, relationship, options, true) else find_related_monomorphic_fragments(source_rids, relationship, options, true) @@ -138,211 +227,219 @@ def find_included_fragments(source_rids, relationship_name, options = {}) # # @return [Integer] the count def count_related(source_rid, relationship_name, options = {}) - opts = options.dup - relationship = _relationship(relationship_name) related_klass = relationship.resource_klass - context = opts[:context] - - primary_key_field = "#{_table_name}.#{_primary_key}" - - records = records(context: context).where(primary_key_field => source_rid.id) + filters = options.fetch(:filters, {}) - # join in related to the source records - records, related_alias = get_join_alias(records) { |records| records.joins(relationship.relation_name(opts)) } - - join_tree = JoinTree.new(resource_klass: related_klass, + # Joins in this case are related to the related_klass + join_tree = JoinTree.new(resource_klass: self, source_relationship: relationship, filters: filters, - options: opts) + options: options) - records, joins = apply_joins(records, join_tree, opts) + records = find_records(records: records(options), + resource_klass: related_klass, + primary_keys: source_rid.id, + join_tree: join_tree, + filters: filters, + options: options) - # Options for filtering - opts[:joins] = joins - opts[:related_alias] = related_alias + joins = join_tree.joins + related_alias = joins[''][:alias] - filters = opts.fetch(:filters, {}) - records = related_klass.filter_records(records, filters, opts) - - records.count(:all) + records.select(Arel.sql("#{concat_table_field(related_alias, related_klass._primary_key)}")).count(:all) end def records(_options = {}) - _model_class.all + _model_class.distinct.all end - def parse_relationship_path(path) - relationships = [] - relationship_names = [] - field = nil - - current_path = path - current_resource_klass = self - loop do - parts = current_path.to_s.partition('.') - relationship = current_resource_klass._relationship(parts[0]) - if relationship - relationships << relationship - relationship_names << relationship.name - else - if parts[2].blank? - field = parts[0] - break - else - # :nocov: - warn "Unknown relationship #{parts[0]}" - # :nocov: - end - end - - current_resource_klass = relationship.resource_klass + protected - if parts[2].include?('.') - current_path = parts[2] - else - relationship = current_resource_klass._relationship(parts[2]) - if relationship - relationships << relationship - relationship_names << relationship.name - else - field = parts[2] - end - break + def to_one_relationships_for_linkage(include_related) + include_related ||= {} + relationships = [] + _relationships.each do |name, relationship| + if relationship.is_a?(JSONAPI::Relationship::ToOne) && !include_related.has_key?(name) && relationship.include_optional_linkage_data? + relationships << name end end - - return relationships, relationship_names.join('.'), field + relationships end - protected - def find_record_by_key(key, options = {}) - records = find_records({ _primary_key => key }, options.except(:paginator, :sort_criteria)) - record = records.first + record = find_records(records: records(options), primary_keys: key, options: options).first fail JSONAPI::Exceptions::RecordNotFound.new(key) if record.nil? record end def find_records_by_keys(keys, options = {}) - records(options).where({ _primary_key => keys }) + find_records(records: records(options), primary_keys: keys, options: options) end def find_related_monomorphic_fragments(source_rids, relationship, options, connect_source_identity) - opts = options.dup - + filters = options.fetch(:filters, {}) source_ids = source_rids.collect {|rid| rid.id} - context = opts[:context] - - related_klass = relationship.resource_klass - - primary_key_field = "#{_table_name}.#{_primary_key}" - - records = records(context: context).where(primary_key_field => source_ids) - - # join in related to the source records - records, related_alias = get_join_alias(records) { |records| records.joins(relationship.relation_name(opts)) } + include_directives = options[:include_directives] ? options[:include_directives].include_directives : {} + resource_klass = relationship.resource_klass + linkage_relationships = resource_klass.to_one_relationships_for_linkage(include_directives[:include_related]) sort_criteria = [] - opts[:sort_criteria].try(:each) do |sort| - field = sort[:field].to_s == 'id' ? related_klass._primary_key : sort[:field] + options[:sort_criteria].try(:each) do |sort| + field = sort[:field].to_s == 'id' ? resource_klass._primary_key : sort[:field] sort_criteria << { field: field, direction: sort[:direction] } end - paginator = opts[:paginator] - - filters = opts.fetch(:filters, {}) - - # Joins in this case are related to the related_klass - join_tree = JoinTree.new(resource_klass: related_klass, + join_tree = JoinTree.new(resource_klass: self, source_relationship: relationship, - filters: filters, + relationships: linkage_relationships, sort_criteria: sort_criteria, - options: opts) + filters: filters, + options: options) - records, joins = apply_joins(records, join_tree, opts) + paginator = options[:paginator] if source_rids.count == 1 - # Options for filtering - opts[:joins] = joins - opts[:related_alias] = related_alias + records = find_records(records: records(options), + resource_klass: resource_klass, + sort_criteria: sort_criteria, + primary_keys: source_ids, + paginator: paginator, + filters: filters, + join_tree: join_tree, + options: options) - records = related_klass.filter_records(records, filters, opts) + joins = join_tree.joins + resource_table_alias = joins[''][:alias] - order_options = related_klass.construct_order_options(sort_criteria) + pluck_fields = [ + Arel.sql("#{_table_name}.#{_primary_key} AS source_id"), + Arel.sql("#{concat_table_field(resource_table_alias, resource_klass._primary_key)} AS #{resource_table_alias}_#{resource_klass._primary_key}") + ] - # ToDO: Remove count check. Currently pagination isn't working with multiple source_rids (i.e. it only works - # for show relationships, not related includes). - if paginator && source_rids.count == 1 - records = related_klass.apply_pagination(records, paginator, order_options) + cache_field = resource_klass.attribute_to_model_field(:_cache_field) if options[:cache] + if cache_field + pluck_fields << Arel.sql("#{concat_table_field(resource_table_alias, cache_field[:name])} AS #{resource_table_alias}_#{cache_field[:name]}") end - records = sort_records(records, order_options, opts) + linkage_fields = [] - pluck_fields = [ - Arel.sql(primary_key_field), - Arel.sql("#{concat_table_field(related_alias, related_klass._primary_key)} AS #{related_alias}_#{related_klass._primary_key}") - ] + linkage_relationships.each do |name| + linkage_relationship = resource_klass._relationship(name) - cache_field = related_klass.attribute_to_model_field(:_cache_field) if opts[:cache] - if cache_field - pluck_fields << Arel.sql("#{concat_table_field(related_alias, cache_field[:name])} AS #{related_alias}_#{cache_field[:name]}") + if linkage_relationship.polymorphic? && linkage_relationship.belongs_to? + linkage_relationship.resource_types.each do |resource_type| + klass = resource_klass_for(resource_type) + linkage_fields << {relationship_name: name, resource_klass: klass} + + linkage_table_alias = joins["#{linkage_relationship.name.to_s}##{resource_type}"][:alias] + primary_key = klass._primary_key + pluck_fields << Arel.sql("#{concat_table_field(linkage_table_alias, primary_key)} AS #{linkage_table_alias}_#{primary_key}") + end + else + klass = linkage_relationship.resource_klass + linkage_fields << {relationship_name: name, resource_klass: klass} + + linkage_table_alias = joins[name.to_s][:alias] + primary_key = klass._primary_key + pluck_fields << Arel.sql("#{concat_table_field(linkage_table_alias, primary_key)} AS #{linkage_table_alias}_#{primary_key}") + end end model_fields = {} - attributes = opts[:attributes] + attributes = options[:attributes] attributes.try(:each) do |attribute| - model_field = related_klass.attribute_to_model_field(attribute) + model_field = resource_klass.attribute_to_model_field(attribute) model_fields[attribute] = model_field - pluck_fields << Arel.sql("#{concat_table_field(related_alias, model_field[:name])} AS #{related_alias}_#{model_field[:name]}") + pluck_fields << Arel.sql("#{concat_table_field(resource_table_alias, model_field[:name])} AS #{resource_table_alias}_#{model_field[:name]}") end + fragments = {} rows = records.pluck(*pluck_fields) - - related_fragments = {} - rows.each do |row| - unless row[1].nil? - rid = JSONAPI::ResourceIdentity.new(related_klass, row[1]) + rid = JSONAPI::ResourceIdentity.new(resource_klass, row[1]) - related_fragments[rid] ||= JSONAPI::ResourceFragment.new(rid) + fragments[rid] ||= JSONAPI::ResourceFragment.new(rid) - attributes_offset = 2 + attributes_offset = 2 - if cache_field - related_fragments[rid].cache = cast_to_attribute_type(row[attributes_offset], cache_field[:type]) - attributes_offset+= 1 - end + if cache_field + fragments[rid].cache = cast_to_attribute_type(row[attributes_offset], cache_field[:type]) + attributes_offset+= 1 + end - model_fields.each_with_index do |k, idx| - related_fragments[rid].attributes[k[0]] = cast_to_attribute_type(row[idx + attributes_offset], k[1][:type]) - end + model_fields.each_with_index do |k, idx| + fragments[rid].add_attribute(k[0], cast_to_attribute_type(row[idx + attributes_offset], k[1][:type])) + attributes_offset+= 1 + end - source_rid = JSONAPI::ResourceIdentity.new(self, row[0]) + source_rid = JSONAPI::ResourceIdentity.new(self, row[0]) - related_fragments[rid].add_related_from(source_rid) + fragments[rid].add_related_from(source_rid) - if connect_source_identity - related_relationship = related_klass._relationships[relationship.inverse_relationship] - if related_relationship - related_fragments[rid].add_related_identity(related_relationship.name, source_rid) - end + linkage_fields.each do |linkage_field| + fragments[rid].initialize_related(linkage_field[:relationship_name]) + related_id = row[attributes_offset] + if related_id + related_rid = JSONAPI::ResourceIdentity.new(linkage_field[:resource_klass], related_id) + fragments[rid].add_related_identity(linkage_field[:relationship_name], related_rid) + end + attributes_offset+= 1 + end + + if connect_source_identity + related_relationship = resource_klass._relationships[relationship.inverse_relationship] + if related_relationship + fragments[rid].add_related_identity(related_relationship.name, source_rid) end end end - related_fragments + fragments end # Gets resource identities where the related resource is polymorphic and the resource type and id # are stored on the primary resources. Cache fields will always be on the related resources. def find_related_polymorphic_fragments(source_rids, relationship, options, connect_source_identity) + filters = options.fetch(:filters, {}) source_ids = source_rids.collect {|rid| rid.id} - context = options[:context] + resource_klass = relationship.resource_klass + include_directives = options[:include_directives] ? options[:include_directives].include_directives : {} - records = records(context: context) + linkage_relationships = [] + + resource_types = relationship.resource_types + + resource_types.each do |resource_type| + related_resource_klass = resource_klass_for(resource_type) + relationships = related_resource_klass.to_one_relationships_for_linkage(include_directives[:include_related]) + relationships.each do |r| + linkage_relationships << "##{resource_type}.#{r}" + end + end + + join_tree = JoinTree.new(resource_klass: self, + source_relationship: relationship, + relationships: linkage_relationships, + filters: filters, + options: options) + + paginator = options[:paginator] if source_rids.count == 1 + + # Note: We will sort by the source table. Without using unions we can't sort on a polymorphic relationship + # in any manner that makes sense + records = find_records(records: records(options), + resource_klass: resource_klass, + sort_primary: true, + primary_keys: source_ids, + paginator: paginator, + filters: filters, + join_tree: join_tree, + options: options) + + joins = join_tree.joins primary_key = concat_table_field(_table_name, _primary_key) related_key = concat_table_field(_table_name, relationship.foreign_key) @@ -354,62 +451,85 @@ def find_related_polymorphic_fragments(source_rids, relationship, options, conne Arel.sql("#{related_type} AS #{_table_name}_#{relationship.polymorphic_type}") ] - relations = relationship.polymorphic_relations - # Get the additional fields from each relation. There's a limitation that the fields must exist in each relation relation_positions = {} - relation_index = 3 + relation_index = pluck_fields.length attributes = options.fetch(:attributes, []) - if relations.nil? || relations.length == 0 + # Add resource specific fields + if resource_types.nil? || resource_types.length == 0 # :nocov: - warn "No relations found for polymorphic relationship." + warn "No resource types found for polymorphic relationship." # :nocov: else - relations.try(:each) do |relation| - related_klass = resource_klass_for(relation.to_s) + resource_types.try(:each) do |type| + related_klass = resource_klass_for(type.to_s) cache_field = related_klass.attribute_to_model_field(:_cache_field) if options[:cache] - # We only need to join the relations if we are getting additional fields - if cache_field || attributes.length > 0 - records, table_alias = get_join_alias(records) { |records| records.left_joins(relation.to_sym) } - - if cache_field - pluck_fields << concat_table_field(table_alias, cache_field[:name]) - end - - model_fields = {} - attributes.try(:each) do |attribute| - model_field = related_klass.attribute_to_model_field(attribute) - model_fields[attribute] = model_field - end + table_alias = joins["##{type}"][:alias] - model_fields.each do |_k, v| - pluck_fields << concat_table_field(table_alias, v[:name]) - end + cache_offset = relation_index + if cache_field + pluck_fields << Arel.sql("#{concat_table_field(table_alias, cache_field[:name])} AS cache_#{type}_#{cache_field[:name]}") + relation_index+= 1 + end + model_fields = {} + field_offset = relation_index + attributes.try(:each) do |attribute| + model_field = related_klass.attribute_to_model_field(attribute) + model_fields[attribute] = model_field + pluck_fields << Arel.sql("#{concat_table_field(table_alias, model_field[:name])} AS #{table_alias}_#{model_field[:name]}") + relation_index+= 1 end - related = related_klass._model_class.name - relation_positions[related] = { relation_klass: related_klass, - cache_field: cache_field, - model_fields: model_fields, - field_offset: relation_index} + model_offset = relation_index + model_fields.each do |_k, v| + pluck_fields << Arel.sql("#{concat_table_field(table_alias, v[:name])}") + relation_index+= 1 + end - relation_index+= 1 if cache_field - relation_index+= attributes.length if attributes.length > 0 + relation_positions[type] = {relation_klass: related_klass, + cache_field: cache_field, + cache_offset: cache_offset, + model_fields: model_fields, + model_offset: model_offset, + field_offset: field_offset} end end - primary_resource_filters = options[:filters] - primary_resource_filters ||= {} + # Add to_one linkage fields + linkage_fields = [] + linkage_offset = relation_index + + linkage_relationships.each do |linkage_relationship_path| + path = JSONAPI::Path.new(resource_klass: self, + path_string: "#{relationship.name}#{linkage_relationship_path}", + ensure_default_field: false) - primary_resource_filters[_primary_key] = source_ids + linkage_relationship = path.parts[-1].relationship - records = apply_filters(records, primary_resource_filters, options) + if linkage_relationship.polymorphic? && linkage_relationship.belongs_to? + linkage_relationship.resource_types.each do |resource_type| + klass = resource_klass_for(resource_type) + linkage_fields << {relationship: linkage_relationship, resource_klass: klass} + + linkage_table_alias = joins[linkage_relationship_path][:alias] + primary_key = klass._primary_key + pluck_fields << Arel.sql("#{concat_table_field(linkage_table_alias, primary_key)} AS #{linkage_table_alias}_#{primary_key}") + end + else + klass = linkage_relationship.resource_klass + linkage_fields << {relationship: linkage_relationship, resource_klass: klass} + + linkage_table_alias = joins[linkage_relationship_path.to_s][:alias] + primary_key = klass._primary_key + pluck_fields << Arel.sql("#{concat_table_field(linkage_table_alias, primary_key)} AS #{linkage_table_alias}_#{primary_key}") + end + end rows = records.pluck(*pluck_fields) @@ -432,21 +552,29 @@ def find_related_polymorphic_fragments(source_rids, relationship, options, conne end end - relation_position = relation_positions[row[2]] + relation_position = relation_positions[row[2].downcase.pluralize] model_fields = relation_position[:model_fields] cache_field = relation_position[:cache_field] + cache_offset = relation_position[:cache_offset] field_offset = relation_position[:field_offset] - attributes_offset = 0 - if cache_field - related_fragments[rid].cache = cast_to_attribute_type(row[field_offset], cache_field[:type]) - attributes_offset+= 1 + related_fragments[rid].cache = cast_to_attribute_type(row[cache_offset], cache_field[:type]) end if attributes.length > 0 model_fields.each_with_index do |k, idx| - related_fragments[rid].add_attribute(k[0], cast_to_attribute_type(row[idx + field_offset + attributes_offset], k[1][:type])) + related_fragments[rid].add_attribute(k[0], cast_to_attribute_type(row[idx + field_offset], k[1][:type])) + end + end + + linkage_fields.each_with_index do |linkage_field_details, idx| + relationship = linkage_field_details[:relationship] + related_fragments[rid].initialize_related(relationship.name) + related_id = row[linkage_offset + idx] + if related_id + related_rid = JSONAPI::ResourceIdentity.new(linkage_field_details[:resource_klass], related_id) + related_fragments[rid].add_related_identity(relationship.name, related_rid) end end end @@ -455,28 +583,41 @@ def find_related_polymorphic_fragments(source_rids, relationship, options, conne related_fragments end - def find_records(filters, options = {}) - opts = options.dup + def find_records(records:, + join_tree: JoinTree.new(resource_klass: self), + resource_klass: self, + filters: nil, + primary_keys: nil, + sort_criteria: nil, + sort_primary: nil, + paginator: nil, + options: {}) - sort_criteria = opts.fetch(:sort_criteria) { [] } - - join_tree = JoinTree.new(resource_klass: self, - filters: filters, - sort_criteria: sort_criteria, - options: opts) + opts = options.dup + records = resource_klass.apply_joins(records, join_tree, opts) - records, joins = apply_joins(records(opts), join_tree, opts) + if primary_keys + records = records.where(_primary_key => primary_keys) + end - opts[:joins] = joins + opts[:joins] = join_tree.joins - records = filter_records(records, filters, opts) + if filters + records = resource_klass.filter_records(records, filters, opts) + end - order_options = construct_order_options(sort_criteria) - records = sort_records(records, order_options, opts) + if sort_primary + records = records.order(_primary_key => :asc) + else + order_options = resource_klass.construct_order_options(sort_criteria) + records = resource_klass.sort_records(records, order_options, opts) + end - records = apply_pagination(records, opts[:paginator], order_options) + if paginator + records = resource_klass.apply_pagination(records, paginator, order_options) + end - records.distinct + records end def get_join_alias(records, &block) @@ -511,20 +652,20 @@ def get_join_alias(records, &block) end def apply_joins(records, join_tree, _options) - joins = join_tree.get_joins + joins = join_tree.joins - joins.each do |key, join_details| - case join_details[:join_type] + joins.each_value do |join| + case join[:join_type] when :inner - records, join_alias = get_join_alias(records) { |records| records.joins(join_details[:relation_join_hash]) } + records, join_alias = get_join_alias(records) { |records| records.joins(join[:relation_join_hash]) } + join[:alias] = join_alias when :left - records, join_alias = get_join_alias(records) { |records| records.left_joins(join_details[:relation_join_hash]) } + records, join_alias = get_join_alias(records) { |records| records.left_joins(join[:relation_join_hash]) } + join[:alias] = join_alias end - - joins[key][:alias] = join_alias end - return records, joins + return records end def apply_pagination(records, paginator, order_options) @@ -548,12 +689,13 @@ def apply_single_sort(records, field, direction, options) strategy = _allowed_sort.fetch(field.to_sym, {})[:apply] if strategy - call_method_or_proc(strategy, records, direction, context) + records = call_method_or_proc(strategy, records, direction, context) else joins = options[:joins] || {} - records.order("#{get_aliased_field(field, joins, options[:related_alias])} #{direction}") + records = records.order("#{get_aliased_field(field, joins)} #{direction}") end + records end # Assumes ActiveRecord's counting. Override if you need a different counting method @@ -562,7 +704,22 @@ def count_records(records) end def filter_records(records, filters, options) - apply_filters(records, filters, options) + if _polymorphic + _polymorphic_resource_klasses.each do |klass| + records = klass.apply_filters(records, filters, options) + end + else + records = apply_filters(records, filters, options) + end + records + end + + def construct_order_options(sort_params) + if _polymorphic + warn "Sorting is not supported on polymorphic relationships" + else + super(sort_params) + end end def sort_records(records, order_options, options) @@ -599,31 +756,22 @@ def apply_filters(records, filters, options = {}) records end - def get_aliased_field(path_with_field, joins, related_alias) - relationships, relationship_path, field = parse_relationship_path(path_with_field) - relationship = relationships.last - - resource_klass = relationship ? relationship.resource_klass : self + def get_aliased_field(path_with_field, joins) + path = JSONAPI::Path.new(resource_klass: self, path_string: path_with_field) - if field.empty? - field_name = resource_klass._primary_key - else - field_name = resource_klass._attribute_delegated_name(field) - end + relationship = path.parts[-2] + field = path.parts[-1] + relationship_path = path.relationship_path_string if relationship join_name = relationship_path - join = joins.try(:[], join_name) - table_alias = join.try(:[], :alias) - else - table_alias = related_alias end - table_alias ||= resource_klass._table_name + table_alias ||= joins[''][:alias] - concat_table_field(table_alias, field_name) + concat_table_field(table_alias, field.delegated_field_name) end def apply_filter(records, filter, value, options = {}) @@ -633,8 +781,7 @@ def apply_filter(records, filter, value, options = {}) records = call_method_or_proc(strategy, records, value, options) else joins = options[:joins] || {} - related_alias = options[:related_alias] - records = records.where(get_aliased_field(filter, joins, related_alias) => value) + records = records.where(get_aliased_field(filter, joins) => value) end records diff --git a/lib/jsonapi/active_relation_resource_finder/join_tree.rb b/lib/jsonapi/active_relation_resource_finder/join_tree.rb index 61ea1e047..5867772b1 100644 --- a/lib/jsonapi/active_relation_resource_finder/join_tree.rb +++ b/lib/jsonapi/active_relation_resource_finder/join_tree.rb @@ -4,22 +4,116 @@ class JoinTree # Stores relationship paths starting from the resource_klass. This allows consolidation of duplicate paths from # relationships, filters and sorts. This enables the determination of table aliases as they are joined. - attr_reader :resource_klass, :options, :source_relationship + attr_reader :resource_klass, :options, :source_relationship, :resource_joins, :joins + + def initialize(resource_klass:, + options: {}, + source_relationship: nil, + relationships: nil, + filters: nil, + sort_criteria: nil) - def initialize(resource_klass:, options: {}, source_relationship: nil, filters: nil, sort_criteria: nil) @resource_klass = resource_klass @options = options - @source_relationship = source_relationship - - @join_relationships = {} + @resource_joins = { + root: { + join_type: :root, + resource_klasses: { + resource_klass => { + relationships: {} + } + } + } + } + add_source_relationship(source_relationship) add_sort_criteria(sort_criteria) add_filters(filters) + add_relationships(relationships) + + @joins = {} + construct_joins(@resource_joins) + end + + private + + def add_join(path, default_type = :inner, default_polymorphic_join_type = :left) + if source_relationship + if source_relationship.polymorphic? + # Polymorphic paths will come it with the resource_type as the first part (for example `#documents.comments`) + # We just need to prepend the relationship portion the + sourced_path = "#{source_relationship.name}#{path}" + else + sourced_path = "#{source_relationship.name}.#{path}" + end + else + sourced_path = path + end + + join_tree, _field = parse_path_to_tree(sourced_path, resource_klass, default_type, default_polymorphic_join_type) + + @resource_joins[:root].deep_merge!(join_tree) { |key, val, other_val| + if key == :join_type + if val == other_val + val + else + :inner + end + end + } end - # A hash of joins that can be used to create the required joins - def get_joins - walk_relation_node(@join_relationships) + def process_path_to_tree(path_parts, resource_klass, default_join_type, default_polymorphic_join_type) + node = { + resource_klasses: { + resource_klass => { + relationships: {} + } + } + } + + part = path_parts.shift + + if part.is_a?(PathPart::Relationship) + node[:resource_klasses][resource_klass][:relationships][part.relationship] ||= {} + + # join polymorphic as left joins + node[:resource_klasses][resource_klass][:relationships][part.relationship][:join_type] ||= + part.relationship.polymorphic? ? default_polymorphic_join_type : default_join_type + + part.relationship.resource_types.each do |related_resource_type| + related_resource_klass = resource_klass.resource_klass_for(related_resource_type) + if !part.path_specified_resource_klass? || related_resource_klass == part.resource_klass + related_resource_tree = process_path_to_tree(path_parts.dup, related_resource_klass, default_join_type, default_polymorphic_join_type) + node[:resource_klasses][resource_klass][:relationships][part.relationship].deep_merge!(related_resource_tree) + end + end + end + node + end + + def parse_path_to_tree(path_string, resource_klass, default_join_type = :inner, default_polymorphic_join_type = :left) + path = JSONAPI::Path.new(resource_klass: resource_klass, path_string: path_string) + field = path.parts[-1] + return process_path_to_tree(path.parts, resource_klass, default_join_type, default_polymorphic_join_type), field + end + + def add_source_relationship(source_relationship) + @source_relationship = source_relationship + + if @source_relationship + resource_klasses = {} + source_relationship.resource_types.each do |related_resource_type| + related_resource_klass = resource_klass.resource_klass_for(related_resource_type) + resource_klasses[related_resource_klass] = {relationships: {}} + end + + join_type = source_relationship.polymorphic? ? :left : :inner + + @resource_joins[:root][:resource_klasses][resource_klass][:relationships][@source_relationship] = { + source: true, resource_klasses: resource_klasses, join_type: join_type + } + end end def add_filters(filters) @@ -41,42 +135,10 @@ def add_sort_criteria(sort_criteria) end end - private - - def add_join_relationship(parent_joins, join_name, relation_name, type) - parent_joins[join_name] ||= {relation_name: relation_name, relationship: {}, type: type} - if parent_joins[join_name][:type] == :left && type == :inner - parent_joins[join_name][:type] = :inner - end - parent_joins[join_name][:relationship] - end - - def add_join(path, default_type = :inner) - relationships, _field = resource_klass.parse_relationship_path(path) - - current_joins = @join_relationships - - terminated = false - + def add_relationships(relationships) + return if relationships.blank? relationships.each do |relationship| - if terminated - # ToDo: Relax this, if possible - # :nocov: - warn "Can not nest joins under polymorphic join" - # :nocov: - end - - if relationship.polymorphic? - relation_names = relationship.polymorphic_relations - relation_names.each do |relation_name| - join_name = "#{relationship.name}[#{relation_name}]" - add_join_relationship(current_joins, join_name, relation_name, :left) - end - terminated = true - else - join_name = relationship.name - current_joins = add_join_relationship(current_joins, join_name, relationship.relation_name(options), default_type) - end + add_join(relationship, :left) end end @@ -92,35 +154,69 @@ def relation_join_hash(path, path_hash = {}) end # Returns the paths from shortest to longest, allowing the capture of the table alias for earlier paths. For - # example posts, posts.comments and then posts.comments.author joined in that order will alow each + # example posts, posts.comments and then posts.comments.author joined in that order will allow each # alias to be determined whereas just joining posts.comments.author will only record the author alias. # ToDo: Dependence on this specialized logic should be removed in the future, if possible. - def walk_relation_node(node, paths = {}, current_relation_path = [], current_relationship_path = []) - node.each do |key, value| - if current_relation_path.empty? && source_relationship - current_relation_path << source_relationship.relation_name(options) + def construct_joins(node, current_relation_path = [], current_relationship_path = []) + node.each do |relationship, relationship_details| + join_type = relationship_details[:join_type] + if relationship == :root + @joins[:root] = {alias: resource_klass._table_name, join_type: :root} + + # alias to the default table unless a source_relationship is specified + unless source_relationship + @joins[''] = {alias: resource_klass._table_name, join_type: :root} + end + + return construct_joins(relationship_details[:resource_klasses].values[0][:relationships], + current_relation_path, + current_relationship_path) end - current_relation_path << value[:relation_name].to_s - current_relationship_path << key.to_s + relationship_details[:resource_klasses].each do |resource_klass, resource_details| + if relationship.polymorphic? && relationship.belongs_to? + current_relationship_path << "#{relationship.name.to_s}##{resource_klass._type.to_s}" + relation_name = resource_klass._type.to_s.singularize + else + current_relationship_path << relationship.name.to_s + relation_name = relationship.relation_name(options).to_s + end - rel_path = current_relationship_path.join('.') - paths[rel_path] ||= { - alias: nil, - join_type: value[:type], - relation_join_hash: relation_join_hash(current_relation_path.dup) - } + current_relation_path << relation_name - walk_relation_node(value[:relationship], - paths, - current_relation_path, - current_relationship_path) + rel_path = calc_path_string(current_relationship_path) - current_relation_path.pop - current_relationship_path.pop + @joins[rel_path] = { + alias: nil, + join_type: join_type, + relation_join_hash: relation_join_hash(current_relation_path.dup) + } + + construct_joins(resource_details[:relationships], + current_relation_path.dup, + current_relationship_path.dup) + + current_relation_path.pop + current_relationship_path.pop + end end - paths + end + + def calc_path_string(path_array) + if source_relationship + if source_relationship.polymorphic? + _relationship_name, resource_name = path_array[0].split('#', 2) + path = path_array.dup + path[0] = "##{resource_name}" + else + path = path_array.dup.drop(1) + end + else + path = path_array.dup + end + + path.join('.') end end end -end +end \ No newline at end of file diff --git a/lib/jsonapi/path_part.rb b/lib/jsonapi/path_part.rb index 937578824..88f10c47d 100644 --- a/lib/jsonapi/path_part.rb +++ b/lib/jsonapi/path_part.rb @@ -34,6 +34,10 @@ def to_s def resource_klass @resource_klass || @relationship.resource_klass end + + def path_specified_resource_klass? + !@resource_klass.nil? + end end class Field diff --git a/lib/jsonapi/processor.rb b/lib/jsonapi/processor.rb index b15dcecf9..691a36205 100644 --- a/lib/jsonapi/processor.rb +++ b/lib/jsonapi/processor.rb @@ -42,7 +42,7 @@ def process def find filters = params[:filters] include_directives = params[:include_directives] - sort_criteria = params.fetch(:sort_criteria, []) + sort_criteria = params[:sort_criteria] paginator = params[:paginator] fields = params[:fields] serializer = params[:serializer] @@ -93,7 +93,8 @@ def show find_options = { context: context, fields: fields, - filters: { resource_klass._primary_key => key } + filters: { resource_klass._primary_key => key }, + include_directives: include_directives } resource_set = find_resource_set(resource_klass, @@ -109,7 +110,7 @@ def show_relationship parent_key = params[:parent_key] relationship_type = params[:relationship_type].to_sym paginator = params[:paginator] - sort_criteria = params.fetch(:sort_criteria, []) + sort_criteria = params[:sort_criteria] include_directives = params[:include_directives] fields = params[:fields] @@ -119,7 +120,8 @@ def show_relationship context: context, sort_criteria: sort_criteria, paginator: paginator, - fields: fields + fields: fields, + include_directives: include_directives } resource_id_tree = find_related_resource_id_tree(resource_klass, @@ -146,7 +148,8 @@ def show_related_resource find_options = { context: context, fields: fields, - filters: {} + filters: {}, + include_directives: include_directives } source_resource = source_klass.find_by_key(source_id, context: context, fields: fields) @@ -166,7 +169,7 @@ def show_related_resources source_id = params[:source_id] relationship_type = params[:relationship_type] filters = params[:filters] - sort_criteria = params.fetch(:sort_criteria, resource_klass.default_sort) + sort_criteria = params[:sort_criteria] paginator = params[:paginator] fields = params[:fields] include_directives = params[:include_directives] @@ -179,7 +182,8 @@ def show_related_resources sort_criteria: sort_criteria, paginator: paginator, fields: fields, - context: context + context: context, + include_directives: include_directives } source_resource = source_klass.find_by_key(source_id, context: context, fields: fields) @@ -233,7 +237,8 @@ def create_resource find_options = { context: context, fields: fields, - filters: { resource_klass._primary_key => resource.id } + filters: { resource_klass._primary_key => resource.id }, + include_directives: include_directives } resource_set = find_resource_set(resource_klass, @@ -269,7 +274,8 @@ def replace_fields find_options = { context: context, fields: fields, - filters: { resource_klass._primary_key => resource.id } + filters: { resource_klass._primary_key => resource.id }, + include_directives: include_directives } resource_set = find_resource_set(resource_klass, diff --git a/lib/jsonapi/relationship.rb b/lib/jsonapi/relationship.rb index ac7f6a04f..dc691c176 100644 --- a/lib/jsonapi/relationship.rb +++ b/lib/jsonapi/relationship.rb @@ -1,7 +1,7 @@ module JSONAPI class Relationship attr_reader :acts_as_set, :foreign_key, :options, :name, - :class_name, :polymorphic, :always_include_linkage_data, + :class_name, :polymorphic, :always_include_optional_linkage_data, :parent_resource, :eager_load_on_include, :custom_methods, :inverse_relationship, :allow_include @@ -15,8 +15,13 @@ def initialize(name, options = {}) @parent_resource = options[:parent_resource] @relation_name = options.fetch(:relation_name, @name) @polymorphic = options.fetch(:polymorphic, false) == true - @polymorphic_relations = options[:polymorphic_relations] - @always_include_linkage_data = options.fetch(:always_include_linkage_data, false) == true + @polymorphic_types = options[:polymorphic_types] + if options[:polymorphic_relations] + ActiveSupport::Deprecation.warn('Use polymorphic_types instead of polymorphic_relations') + @polymorphic_types ||= options[:polymorphic_relations] + end + + @always_include_optional_linkage_data = options.fetch(:always_include_optional_linkage_data, false) == true @eager_load_on_include = options.fetch(:eager_load_on_include, false) == true @allow_include = options[:allow_include] @class_name = nil @@ -58,8 +63,12 @@ def self.polymorphic_types(name) @poly_hash[name.to_sym] end - def polymorphic_relations - @polymorphic_relations ||= self.class.polymorphic_types(@relation_name) + def resource_types + if polymorphic? && belongs_to? + @polymorphic_types ||= self.class.polymorphic_types(@relation_name).collect {|t| t.pluralize} + else + [resource_klass._type.to_s.pluralize] + end end def type @@ -102,6 +111,12 @@ def initialize(name, options = {}) end end + def to_s + # :nocov: useful for debugging + "#{parent_resource._type}.#{name} => (#{belongs_to? ? 'ToOne' : 'BelongsToOne'}) #{resource_klass._type}" + # :nocov: + end + def belongs_to? # :nocov: foreign_key_on == :self @@ -112,6 +127,10 @@ def polymorphic_type "#{name}_type" if polymorphic? end + def include_optional_linkage_data? + @always_include_optional_linkage_data || JSONAPI::configuration.always_include_to_one_linkage_data + end + def allow_include?(context = nil) strategy = if @allow_include.nil? JSONAPI.configuration.default_allow_include_to_one @@ -142,6 +161,18 @@ def initialize(name, options = {}) end end + def to_s + # :nocov: useful for debugging + "#{parent_resource._type}.#{name} => (ToMany) #{resource_klass._type}" + # :nocov: + end + + def include_optional_linkage_data? + # :nocov: + @always_include_optional_linkage_data || JSONAPI::configuration.always_include_to_many_linkage_data + # :nocov: + end + def allow_include?(context = nil) strategy = if @allow_include.nil? JSONAPI.configuration.default_allow_include_to_many @@ -156,8 +187,8 @@ def allow_include?(context = nil) else strategy.call(context) end - end + end end end diff --git a/lib/jsonapi/request_parser.rb b/lib/jsonapi/request_parser.rb index 8f30174c5..7d7e90ca5 100644 --- a/lib/jsonapi/request_parser.rb +++ b/lib/jsonapi/request_parser.rb @@ -345,7 +345,7 @@ def check_include(resource_klass, include_parts) end def parse_include_directives(resource_klass, raw_include) - return unless raw_include + raw_include ||= '' included_resources = [] begin @@ -354,8 +354,6 @@ def parse_include_directives(resource_klass, raw_include) fail JSONAPI::Exceptions::InvalidInclude.new(format_key(resource_klass._type), raw_include) end - return if included_resources.nil? - begin result = included_resources.compact.map do |included_resource| check_include(resource_klass, included_resource.partition('.')) diff --git a/lib/jsonapi/resource.rb b/lib/jsonapi/resource.rb index 52a9bceb8..6906db0c3 100644 --- a/lib/jsonapi/resource.rb +++ b/lib/jsonapi/resource.rb @@ -400,6 +400,7 @@ def inherited(subclass) subclass.caching(_caching) subclass.paginator(_paginator) subclass._attributes = (_attributes || {}).dup + subclass.polymorphic(false) subclass._model_hints = (_model_hints || {}).dup @@ -439,53 +440,53 @@ def inherited(subclass) # } # # begin ResourceFinder Abstract methods - def find(_filters, _options = {}) - # :nocov: - raise 'Abstract ResourceFinder method called. Ensure that a ResourceFinder has been set.' - # :nocov: - end + 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 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_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_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_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_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 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 + 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 @@ -842,19 +843,28 @@ def _updatable_relationships end def _relationship(type) + return nil unless type type = type.to_sym @_relationships[type] end def _model_name if _abstract - return '' + '' else return @_model_name.to_s if defined?(@_model_name) class_name = self.name return '' if class_name.nil? @_model_name = class_name.demodulize.sub(/Resource$/, '') - return @_model_name.to_s + @_model_name.to_s + end + end + + def _polymorphic_name + if !_polymorphic + '' + else + @_polymorphic_name ||= _model_name.to_s.downcase end end @@ -894,6 +904,34 @@ def paginator(paginator) @_paginator = paginator end + def _polymorphic + @_polymorphic + end + + def polymorphic(polymorphic = true) + @_polymorphic = polymorphic + end + + def _polymorphic_types + @poly_hash ||= {}.tap do |hash| + ObjectSpace.each_object do |klass| + next unless Module === klass + if ActiveRecord::Base > klass + klass.reflect_on_all_associations(:has_many).select{|r| r.options[:as] }.each do |reflection| + (hash[reflection.options[:as]] ||= []) << klass.name.downcase + end + end + end + end + @poly_hash[_polymorphic_name.to_sym] + end + + def _polymorphic_resource_klasses + @_polymorphic_resource_klasses ||= _polymorphic_types.collect do |type| + resource_klass_for(type) + end + end + def abstract(val = true) @abstract = val end diff --git a/lib/jsonapi/resource_set.rb b/lib/jsonapi/resource_set.rb index 391de4452..56972d73d 100644 --- a/lib/jsonapi/resource_set.rb +++ b/lib/jsonapi/resource_set.rb @@ -48,12 +48,11 @@ def populate!(serializer, context, find_options) # fill in any missed resources unless missed_ids.empty? - filters = {resource_klass._primary_key => missed_ids} find_opts = { context: context, fields: find_options[:fields] } - found_resources = resource_klass.find(filters, find_opts) + found_resources = resource_klass.find_by_keys(missed_ids, find_opts) found_resources.each do |resource| relationship_data = @resource_klasses[resource_klass][resource.id][:relationships] diff --git a/test/controllers/controller_test.rb b/test/controllers/controller_test.rb index 48af4eb58..49b385708 100644 --- a/test/controllers/controller_test.rb +++ b/test/controllers/controller_test.rb @@ -7,6 +7,7 @@ def set_content_type_header! class PostsControllerTest < ActionController::TestCase def setup JSONAPI.configuration.raise_if_parameters_not_allowed = true + JSONAPI.configuration.always_include_to_one_linkage_data = false end def test_index @@ -572,7 +573,7 @@ def test_includes_for_empty_relationships_shows_but_are_empty assert_cacheable_get :show, params: {id: '17', include: 'author,tags'} assert_response :success - assert json_response['data']['relationships']['author'].has_key?('data'), 'data key should exist for empty has_one relaionship' + assert json_response['data']['relationships']['author'].has_key?('data'), 'data key should exist for empty has_one relationship' assert_nil json_response['data']['relationships']['author']['data'], 'Data should be null' assert json_response['data']['relationships']['tags'].has_key?('data'), 'data key should exist for empty has_many relationship' assert json_response['data']['relationships']['tags']['data'].is_a?(Array), 'Data should be array' @@ -588,6 +589,32 @@ def test_show_single_with_include_disallowed JSONAPI.configuration = original_config end + def test_show_single_include_linkage + JSONAPI.configuration.always_include_to_one_linkage_data = true + + assert_cacheable_get :show, params: {id: '17'} + assert_response :success + assert json_response['data']['relationships']['author'].has_key?('data'), 'data key should exist for empty has_one relationship' + assert_nil json_response['data']['relationships']['author']['data'], 'Data should be null' + refute json_response['data']['relationships']['tags'].has_key?('data'), 'data key should not exist for empty has_many relationship if not included' + + ensure + JSONAPI.configuration.always_include_to_one_linkage_data = false + end + + def test_index_single_include_linkage + JSONAPI.configuration.always_include_to_one_linkage_data = true + + assert_cacheable_get :index, params: { filter: { id: '17'} } + assert_response :success + assert json_response['data'][0]['relationships']['author'].has_key?('data'), 'data key should exist for empty has_one relationship' + assert_nil json_response['data'][0]['relationships']['author']['data'], 'Data should be null' + refute json_response['data'][0]['relationships']['tags'].has_key?('data'), 'data key should not exist for empty has_many relationship if not included' + + ensure + JSONAPI.configuration.always_include_to_one_linkage_data = false + end + def test_show_single_with_fields assert_cacheable_get :show, params: {id: '1', fields: {posts: 'author'}} assert_response :success @@ -2089,6 +2116,32 @@ def test_pictures_index_with_polymorphic_include_one_level assert_equal 5, json_response['included'].try(:size) end + def test_pictures_index_with_polymorphic_to_one_linkage + JSONAPI.configuration.always_include_to_one_linkage_data = true + assert_cacheable_get :index + assert_response :success + assert_equal 8, json_response['data'].try(:size) + assert_equal '3', json_response['data'][2]['id'] + assert_nil json_response['data'][2]['relationships']['imageable']['data'] + assert_equal 'products', json_response['data'][0]['relationships']['imageable']['data']['type'] + assert_equal '1', json_response['data'][0]['relationships']['imageable']['data']['id'] + ensure + JSONAPI.configuration.always_include_to_one_linkage_data = false + end + + def test_pictures_index_with_polymorphic_include_one_level_to_one_linkages + JSONAPI.configuration.always_include_to_one_linkage_data = true + assert_cacheable_get :index, params: {include: 'imageable'} + assert_response :success + assert_equal 8, json_response['data'].try(:size) + assert_equal 5, json_response['included'].try(:size) + assert_nil json_response['data'][2]['relationships']['imageable']['data'] + assert_equal 'products', json_response['data'][0]['relationships']['imageable']['data']['type'] + assert_equal '1', json_response['data'][0]['relationships']['imageable']['data']['id'] + ensure + JSONAPI.configuration.always_include_to_one_linkage_data = false + end + def test_update_relationship_to_one_polymorphic set_content_type_header! @@ -2098,6 +2151,13 @@ def test_update_relationship_to_one_polymorphic picture_object = Picture.find(48) assert_equal 2, picture_object.imageable_id end + + def test_pictures_index_with_filter_documents + assert_cacheable_get :index, params: {include: 'imageable', filter: {'imageable#documents.name': 'Management Through the Years'}} + assert_response :success + assert_equal 3, json_response['data'].try(:size) + assert_equal 1, json_response['included'].try(:size) + end end class DocumentsControllerTest < ActionController::TestCase @@ -3250,10 +3310,10 @@ def test_books_included_paged assert_query_count(5) do assert_cacheable_get :index, params: {filter: {id: '0'}, include: 'book-comments'} + assert_response :success + assert_equal 1, json_response['data'].size + assert_equal 'Book 0', json_response['data'][0]['attributes']['title'] end - assert_response :success - assert_equal 1, json_response['data'].size - assert_equal 'Book 0', json_response['data'][0]['attributes']['title'] end def test_books_banned_non_book_admin @@ -3262,11 +3322,11 @@ def test_books_banned_non_book_admin JSONAPI.configuration.top_level_meta_include_record_count = true assert_query_count(3) do assert_cacheable_get :index, params: {page: {offset: 50, limit: 12}} + assert_response :success + assert_equal 12, json_response['data'].size + assert_equal 'Book 50', json_response['data'][0]['attributes']['title'] + assert_equal 901, json_response['meta']['record-count'] end - assert_response :success - assert_equal 12, json_response['data'].size - assert_equal 'Book 50', json_response['data'][0]['attributes']['title'] - assert_equal 901, json_response['meta']['record-count'] ensure JSONAPI.configuration.top_level_meta_include_record_count = false end @@ -3277,15 +3337,14 @@ def test_books_banned_non_book_admin_includes_switched JSONAPI.configuration.top_level_meta_include_record_count = true assert_query_count(5) do assert_cacheable_get :index, params: {page: {offset: 0, limit: 12}, include: 'book-comments'} + assert_response :success + assert_equal 12, json_response['data'].size + assert_equal 130, json_response['included'].size + assert_equal 'Book 0', json_response['data'][0]['attributes']['title'] + assert_equal 26, json_response['data'][0]['relationships']['book-comments']['data'].size + assert_equal 'book-comments', json_response['included'][0]['type'] + assert_equal 901, json_response['meta']['record-count'] end - - assert_response :success - assert_equal 12, json_response['data'].size - assert_equal 130, json_response['included'].size - assert_equal 'Book 0', json_response['data'][0]['attributes']['title'] - assert_equal 26, json_response['data'][0]['relationships']['book-comments']['data'].size - assert_equal 'book-comments', json_response['included'][0]['type'] - assert_equal 901, json_response['meta']['record-count'] ensure JSONAPI.configuration.top_level_meta_include_record_count = false end @@ -3296,12 +3355,12 @@ def test_books_banned_non_book_admin_includes_nested_includes Api::V2::BookResource.paginator :offset assert_query_count(7) do assert_cacheable_get :index, params: {page: {offset: 0, limit: 12}, include: 'book-comments.author'} + assert_response :success + assert_equal 12, json_response['data'].size + assert_equal 132, json_response['included'].size + assert_equal 'Book 0', json_response['data'][0]['attributes']['title'] + assert_equal 901, json_response['meta']['record-count'] end - assert_response :success - assert_equal 12, json_response['data'].size - assert_equal 132, json_response['included'].size - assert_equal 'Book 0', json_response['data'][0]['attributes']['title'] - assert_equal 901, json_response['meta']['record-count'] ensure JSONAPI.configuration.top_level_meta_include_record_count = false end @@ -3603,6 +3662,29 @@ def test_show_related_resource }, json_response) end + def test_show_related_resource_to_one_linkage_data + JSONAPI.configuration.always_include_to_one_linkage_data = true + + assert_cacheable_get :show_related_resource, params: {crater_id: 'S56D', relationship: 'moon', source: "api/v1/craters"} + assert_response :success + assert_hash_equals({ + data: { + id: "1", + type: "moons", + links: {self: "http://test.host/api/v1/moons/1"}, + attributes: {name: "Titan", description: "Best known of the Saturn moons."}, + relationships: { + planet: {links: {self: "http://test.host/api/v1/moons/1/relationships/planet", + related: "http://test.host/api/v1/moons/1/planet"}, + data: {type: "planets", id: "1"} + }, + craters: {links: {self: "http://test.host/api/v1/moons/1/relationships/craters", related: "http://test.host/api/v1/moons/1/craters"}}} + } + }, json_response) + ensure + JSONAPI.configuration.always_include_to_one_linkage_data = false + end + def test_index_related_resources_with_select_some_db_columns Api::V1::MoonResource.paginator :paged original_config = JSONAPI.configuration.dup @@ -3860,6 +3942,32 @@ def test_show_author_recursive assert_equal '2', json_response['included'][1]['id'] assert_equal 'books', json_response['included'][1]['type'] end + + def test_show_author_do_not_include_polymorphic_linkage + assert_cacheable_get :show, params: {id: '1002', include: 'pictures'} + assert_response :success + assert_equal '1002', json_response['data']['id'] + assert_equal 'authors', json_response['data']['type'] + assert_equal 'Fred Reader', json_response['data']['attributes']['name'] + assert json_response['included'][0]['relationships']['imageable']['links'] + refute json_response['included'][0]['relationships']['imageable']['data'] + end + + def test_show_author_include_polymorphic_linkage + JSONAPI.configuration.always_include_to_one_linkage_data = true + + assert_cacheable_get :show, params: {id: '1002', include: 'pictures'} + assert_response :success + assert_equal '1002', json_response['data']['id'] + assert_equal 'authors', json_response['data']['type'] + assert_equal 'Fred Reader', json_response['data']['attributes']['name'] + assert json_response['included'][0]['relationships']['imageable']['links'] + assert json_response['included'][0]['relationships']['imageable']['data'] + assert_equal 'products', json_response['included'][0]['relationships']['imageable']['data']['type'] + assert_equal '1', json_response['included'][0]['relationships']['imageable']['data']['id'] + ensure + JSONAPI.configuration.always_include_to_one_linkage_data = false + end end class Api::V2::AuthorsControllerTest < ActionController::TestCase diff --git a/test/unit/active_relation_resource_finder/join_tree_test.rb b/test/unit/active_relation_resource_finder/join_tree_test.rb index 231f90b66..acf8c07f0 100644 --- a/test/unit/active_relation_resource_finder/join_tree_test.rb +++ b/test/unit/active_relation_resource_finder/join_tree_test.rb @@ -6,119 +6,146 @@ class JoinTreeTest < ActiveSupport::TestCase def test_no_added_joins join_tree = JSONAPI::ActiveRelationResourceFinder::JoinTree.new(resource_klass: PostResource) - assert_hash_equals({}, join_tree.get_joins) + assert_hash_equals({root: {alias: 'posts', join_type: :root }, '' => {alias: 'posts', join_type: :root}}, join_tree.joins) end def test_add_single_join - filters = {"tags": ["1"]} + filters = {'tags' => ['1']} join_tree = JSONAPI::ActiveRelationResourceFinder::JoinTree.new(resource_klass: PostResource, filters: filters) assert_hash_equals( { - tags: {alias: nil, join_type: :inner, relation_join_hash: {tags: {}}} + root: {alias: 'posts', join_type: :root}, + '' => {alias: 'posts', join_type: :root}, + 'tags' => {alias: nil, join_type: :inner, relation_join_hash: {'tags' => {}}} }, - join_tree.get_joins) + join_tree.joins) end def test_add_single_sort_join - sort_criteria = [ {field: "tags.name", direction: :desc}] + sort_criteria = [ {field: 'tags.name', direction: :desc}] join_tree = JSONAPI::ActiveRelationResourceFinder::JoinTree.new(resource_klass: PostResource, sort_criteria: sort_criteria) assert_hash_equals( { - tags: {alias: nil, join_type: :left, relation_join_hash: {tags: {}}} + root: {alias: 'posts', join_type: :root}, + '' => {alias: 'posts', join_type: :root}, + 'tags' => {alias: nil, join_type: :left, relation_join_hash: {'tags' => {}}} }, - join_tree.get_joins) + join_tree.joins) end def test_add_single_sort_and_filter_join - filters = {"tags": ["1"]} - sort_criteria = [ {field: "tags.name", direction: :desc}] + filters = {'tags' => ['1']} + sort_criteria = [ {field: 'tags.name', direction: :desc}] join_tree = JSONAPI::ActiveRelationResourceFinder::JoinTree.new(resource_klass: PostResource, sort_criteria: sort_criteria, filters: filters) assert_hash_equals( { - tags: {alias: nil, join_type: :inner, relation_join_hash: {tags: {}}} + root: {alias: 'posts', join_type: :root}, + '' => {alias: 'posts', join_type: :root}, + 'tags' => {alias: nil, join_type: :inner, relation_join_hash: {'tags' => {}}} }, - join_tree.get_joins) + join_tree.joins) end def test_add_sibling_joins filters = { - "tags": ["1"], - "author": ["1"] + 'tags' => ['1'], + 'author' => ['1'] } join_tree = JSONAPI::ActiveRelationResourceFinder::JoinTree.new(resource_klass: PostResource, filters: filters) assert_hash_equals( { - tags: {alias: nil, join_type: :inner, relation_join_hash: {tags: {}}}, - author: {alias: nil, join_type: :inner, relation_join_hash: {author: {}}} + root: {alias: 'posts', join_type: :root}, + '' => {alias: 'posts', join_type: :root}, + 'tags' => {alias: nil, join_type: :inner, relation_join_hash: {'tags' => {}}}, + 'author' => {alias: nil, join_type: :inner, relation_join_hash: {'author' => {}}} }, - join_tree.get_joins) + join_tree.joins) + end + + + def test_add_joins_source_relationship + join_tree = JSONAPI::ActiveRelationResourceFinder::JoinTree.new(resource_klass: PostResource, + source_relationship: PostResource._relationship(:comments)) + joins = join_tree.joins + assert_hash_equals( + { + root: {alias: 'posts', join_type: :root}, + '' => {alias: nil, join_type: :inner, relation_join_hash: {'comments' => {}}}, + }, + joins) end def test_add_nested_joins filters = { - "comments.author": ["1"], - "comments.tags": ["1"], - "author": ["1"] + 'comments.author' => ['1'], + 'comments.tags' => ['1'], + 'author' => ['1'] } join_tree = JSONAPI::ActiveRelationResourceFinder::JoinTree.new(resource_klass: PostResource, filters: filters) - joins = join_tree.get_joins + joins = join_tree.joins assert_hash_equals( { - "comments": {alias: nil, join_type: :inner, relation_join_hash: {comments: {}}}, - "comments.author": {alias: nil, join_type: :inner, relation_join_hash: {comments: { author: {}}}}, - "comments.tags": {alias: nil, join_type: :inner, relation_join_hash: {comments: { tags: {}}}}, - "author": {alias: nil, join_type: :inner, relation_join_hash: {author: {}}} + root: {alias: 'posts', join_type: :root}, + '' => {alias: 'posts', join_type: :root}, + 'comments' => {alias: nil, join_type: :inner, relation_join_hash: { 'comments' => {}}}, + 'comments.author' => {alias: nil, join_type: :inner, relation_join_hash: { 'comments' => { 'author' => {}}}}, + 'comments.tags' => {alias: nil, join_type: :inner, relation_join_hash: { 'comments' => { 'tags' => {}}}}, + 'author' => {alias: nil, join_type: :inner, relation_join_hash: {'author' => {}}} }, joins) end def test_add_nested_joins_with_fields filters = { - "comments.author.name": ["1"], - "comments.tags.id": ["1"], - "author.foo": ["1"] + 'comments.author.name' => ['1'], + 'comments.tags.id' => ['1'], + 'author.foo' => ['1'] } join_tree = JSONAPI::ActiveRelationResourceFinder::JoinTree.new(resource_klass: PostResource, filters: filters) assert_hash_equals( { - "comments": {alias: nil, join_type: :inner, relation_join_hash: {comments: {}}}, - "comments.author": {alias: nil, join_type: :inner, relation_join_hash: {comments: { author: {}}}}, - "comments.tags": {alias: nil, join_type: :inner, relation_join_hash: {comments: { tags: {}}}}, - "author": {alias: nil, join_type: :inner, relation_join_hash: {author: {}}} + root: {alias: 'posts', join_type: :root}, + '' => {alias: 'posts', join_type: :root}, + 'comments' => {alias: nil, join_type: :inner, relation_join_hash: { 'comments' => {}}}, + 'comments.author' => {alias: nil, join_type: :inner, relation_join_hash: { 'comments' => { 'author' => {}}}}, + 'comments.tags' => {alias: nil, join_type: :inner, relation_join_hash: { 'comments' => { 'tags' => {}}}}, + 'author' => {alias: nil, join_type: :inner, relation_join_hash: {'author' => {}}} }, - join_tree.get_joins) + join_tree.joins) end def test_add_joins_with_fields_not_from_relationship filters = { - "author.name": ["1"], - "author.comments.name": ["Foo"], - "tags.id": ["1"] + 'author.name' => ['1'], + 'author.comments.name' => ['Foo'], + 'tags.id' => ['1'] } join_tree = JSONAPI::ActiveRelationResourceFinder::JoinTree.new(resource_klass: PostResource, filters: filters) - joins = join_tree.get_joins + joins = join_tree.joins assert_hash_equals( { - "author": {alias: nil, join_type: :inner, relation_join_hash: { author: {}}}, - "author.comments": {alias: nil, join_type: :inner, relation_join_hash: { author: { comments: {}}}}, - "tags": {alias: nil, join_type: :inner, relation_join_hash: { tags: {}}} + root: {alias: 'posts', join_type: :root}, + '' => {alias: 'posts', join_type: :root}, + 'author' => {alias: nil, join_type: :inner, relation_join_hash: {'author' => {}}}, + 'author.comments' => {alias: nil, join_type: :inner, relation_join_hash: { 'author' => { 'comments' => {}}}}, + 'tags' => {alias: nil, join_type: :inner, relation_join_hash: {'tags' => {}}}, }, joins) end def test_add_joins_with_fields_from_relationship filters = { - "author.name": ["1"], - "author.comments.name": ["Foo"], - "tags.id": ["1"] + 'author.name' => ['1'], + 'author.comments.name' => ['Foo'], + 'tags.id' => ['1'] } join_tree = JSONAPI::ActiveRelationResourceFinder::JoinTree.new(resource_klass: PostResource, @@ -127,22 +154,136 @@ def test_add_joins_with_fields_from_relationship assert_hash_equals( { - "author": {alias: nil, join_type: :inner, relation_join_hash: {comments: { author: {}}}}, - "author.comments": {alias: nil, join_type: :inner, relation_join_hash: {comments: { author: { comments: {}}}}}, - "tags": {alias: nil, join_type: :inner, relation_join_hash: {comments: { tags: {}}}} + root: {alias: 'posts', join_type: :root}, + '' => {alias: nil, join_type: :inner, relation_join_hash: {'comments' => {}}}, + 'author' => {alias: nil, join_type: :inner, relation_join_hash: {'comments' => { 'author' => {}}}}, + 'author.comments' => {alias: nil, join_type: :inner, relation_join_hash: { 'comments' => { 'author' => { 'comments' => {}}}}}, + 'tags' => {alias: nil, join_type: :inner, relation_join_hash: {'comments' => { 'tags' => {}}}} + }, + join_tree.joins) + + assert join_tree.joins.keys.include?(:root), 'Root must be a symbol' + refute join_tree.joins.keys.include?('root'), 'Root must be a symbol' + refute join_tree.joins.keys.include?(:tags), 'Relationship names must be a string' + assert join_tree.joins.keys.include?('tags'), 'Relationship names must be a string' + end + + def test_add_joins_with_sub_relationship + relationships = %w(author author.comments tags) + + join_tree = JSONAPI::ActiveRelationResourceFinder::JoinTree.new(resource_klass: PostResource, + relationships: relationships, + source_relationship: PostResource._relationship(:comments)) + + assert_hash_equals( + { + root: {alias: 'posts', join_type: :root}, + '' => {alias: nil, join_type: :inner, relation_join_hash: {'comments' => {}}}, + 'author' => {alias: nil, join_type: :left, relation_join_hash: {'comments' => { 'author' => {}}}}, + 'author.comments' => {alias: nil, join_type: :left, relation_join_hash: { 'comments' => { 'author' => { 'comments' => {}}}}}, + 'tags' => {alias: nil, join_type: :left, relation_join_hash: {'comments' => { 'tags' => {}}}} + }, + join_tree.joins) + end + + def test_add_joins_with_sub_relationship_and_filters + filters = { + 'author.name' => ['1'], + 'author.comments.name' => ['Foo'] + } + + relationships = %w(author author.comments tags) + + join_tree = JSONAPI::ActiveRelationResourceFinder::JoinTree.new(resource_klass: PostResource, + filters:filters, + relationships: relationships, + source_relationship: PostResource._relationship(:comments)) + + assert_hash_equals( + { + root: {alias: 'posts', join_type: :root}, + '' => {alias: nil, join_type: :inner, relation_join_hash: {'comments' => {}}}, + 'author' => {alias: nil, join_type: :inner, relation_join_hash: {'comments' => { 'author' => {}}}}, + 'author.comments' => {alias: nil, join_type: :inner, relation_join_hash: { 'comments' => { 'author' => { 'comments' => {}}}}}, + 'tags' => {alias: nil, join_type: :left, relation_join_hash: {'comments' => { 'tags' => {}}}} }, - join_tree.get_joins) + join_tree.joins) end - def test_polymorphic_join - filters = {"imageable": ["Foo"]} + def test_polymorphic_join_belongs_to_just_source + join_tree = JSONAPI::ActiveRelationResourceFinder::JoinTree.new(resource_klass: PictureResource, + source_relationship: PictureResource._relationship(:imageable)) + + joins = join_tree.joins + assert_hash_equals( + { + root: { alias: 'pictures', join_type: :root}, + '#products' => {alias: nil, join_type: :left, relation_join_hash: {'product' => {}}}, + '#documents' => {alias: nil, join_type: :left, relation_join_hash: {'document' => {}}} + }, + joins) + end + + def test_polymorphic_join_belongs_to_filter + filters = {'imageable' => ['Foo']} join_tree = JSONAPI::ActiveRelationResourceFinder::JoinTree.new(resource_klass: PictureResource, filters: filters) + + joins = join_tree.joins assert_hash_equals( { - "imageable[product]": {alias: nil, join_type: :left, relation_join_hash: {product: {}}}, - "imageable[document]": {alias: nil, join_type: :left, relation_join_hash: {document: {}}} + root: { alias: 'pictures', join_type: :root}, + '' => {alias: 'pictures', join_type: :root}, + 'imageable#products' => {alias: nil, join_type: :left, relation_join_hash: {'product' => {}}}, + 'imageable#documents' => {alias: nil, join_type: :left, relation_join_hash: {'document' => {}}} + }, + joins) + end + def test_polymorphic_join_belongs_to_filter_on_resource + filters = { + 'imageable#documents.name' => ['foo'] + } + + relationships = %w(imageable file_properties) + join_tree = JSONAPI::ActiveRelationResourceFinder::JoinTree.new(resource_klass: PictureResource, + filters: filters, + relationships: relationships) + assert_hash_equals( + { + root: { alias: 'pictures', join_type: :root}, + '' => {alias: 'pictures', join_type: :root}, + 'imageable#documents' => {alias: nil, join_type: :left, relation_join_hash: {'document' => {}}}, + 'imageable#products' => {alias: nil, join_type: :left, relation_join_hash: {'product' => {}}}, + 'file_properties' => {alias: nil, join_type: :left, relation_join_hash: {'file_properties' => {}}} + }, + join_tree.joins) + end + + def test_polymorphic_join_to_one + relationships = %w(file_properties) + join_tree = JSONAPI::ActiveRelationResourceFinder::JoinTree.new(resource_klass: PictureResource, + relationships: relationships) + assert_hash_equals( + { + root: { alias: 'pictures', join_type: :root}, + '' => {alias: 'pictures', join_type: :root}, + 'file_properties' => {alias: nil, join_type: :left, relation_join_hash: {'file_properties' => {}}} + }, + join_tree.joins) + end + + def test_polymorphic_relationship + relationships = %w(imageable file_properties) + join_tree = JSONAPI::ActiveRelationResourceFinder::JoinTree.new(resource_klass: PictureResource, + relationships: relationships) + assert_hash_equals( + { + root: { alias: 'pictures', join_type: :root}, + '' => {alias: 'pictures', join_type: :root}, + 'imageable#products' => {alias: nil, join_type: :left, relation_join_hash: {'product' => {}}}, + 'imageable#documents' => {alias: nil, join_type: :left, relation_join_hash: {'document' => {}}}, + 'file_properties' => {alias: nil, join_type: :left, relation_join_hash: {'file_properties' => {}}} }, - join_tree.get_joins) + join_tree.joins) end end diff --git a/test/unit/processor/default_processor_test.rb b/test/unit/processor/default_processor_test.rb index 0000e159b..7a3cfa08d 100644 --- a/test/unit/processor/default_processor_test.rb +++ b/test/unit/processor/default_processor_test.rb @@ -32,7 +32,7 @@ def setup $populated_resource_set_no_includes = JSONAPI::ResourceSet.new($id_tree_no_includes).populate!($serializer, nil,{}) # has_one included - directives = JSONAPI::IncludeDirectives.new(PersonResource, ['author']).include_directives + directives = JSONAPI::IncludeDirectives.new(PostResource, ['author']).include_directives params = { filters: filters, include_directives: directives, diff --git a/test/unit/resource/active_relation_resource_finder_test.rb b/test/unit/resource/active_relation_resource_finder_test.rb index 36b5a54d8..2a5833280 100644 --- a/test/unit/resource/active_relation_resource_finder_test.rb +++ b/test/unit/resource/active_relation_resource_finder_test.rb @@ -170,7 +170,7 @@ def test_find_related_polymorphic_fragments_no_attributes options = {} source_rids = [JSONAPI::ResourceIdentity.new(PictureResource, 1), JSONAPI::ResourceIdentity.new(PictureResource, 2), - JSONAPI::ResourceIdentity.new(PictureResource, 20)] + JSONAPI::ResourceIdentity.new(PictureResource, 3)] related_fragments = PictureResource.find_included_fragments(source_rids, 'imageable', options) @@ -188,7 +188,7 @@ def test_find_related_polymorphic_fragments_cache_field options = { cache: true } source_rids = [JSONAPI::ResourceIdentity.new(PictureResource, 1), JSONAPI::ResourceIdentity.new(PictureResource, 2), - JSONAPI::ResourceIdentity.new(PictureResource, 20)] + JSONAPI::ResourceIdentity.new(PictureResource, 3)] related_fragments = PictureResource.find_included_fragments(source_rids, 'imageable', options) @@ -200,13 +200,14 @@ def test_find_related_polymorphic_fragments_cache_field assert related_fragments.values[0].is_a?(JSONAPI::ResourceFragment) assert_equal 1, related_fragments.values[0].related_from.length assert related_fragments.values[0].cache.is_a?(ActiveSupport::TimeWithZone) + assert related_fragments.values[1].cache.is_a?(ActiveSupport::TimeWithZone) end def test_find_related_polymorphic_fragments_cache_field_attributes options = { cache: true, attributes: [:name] } source_rids = [JSONAPI::ResourceIdentity.new(PictureResource, 1), JSONAPI::ResourceIdentity.new(PictureResource, 2), - JSONAPI::ResourceIdentity.new(PictureResource, 20)] + JSONAPI::ResourceIdentity.new(PictureResource, 3)] related_fragments = PictureResource.find_included_fragments(source_rids, 'imageable', options) @@ -219,50 +220,8 @@ def test_find_related_polymorphic_fragments_cache_field_attributes assert_equal 1, related_fragments.values[0].related_from.length assert_equal 1, related_fragments.values[0].attributes.length assert related_fragments.values[0].cache.is_a?(ActiveSupport::TimeWithZone) + assert related_fragments.values[1].cache.is_a?(ActiveSupport::TimeWithZone) assert_equal 'Enterprise Gizmo', related_fragments.values[0].attributes[:name] - end - - def test_gets_relationship_chain_with_only_field - relationships, path, field = PictureResource.parse_relationship_path('name') - assert_equal [], relationships - assert_equal '', path - assert_equal 'name', field - end - - def test_gets_relationship_chain_with_field_polymorphic_one_level - relationships, path, field = PictureResource.parse_relationship_path('imageable.name') - assert_equal [PictureResource._relationship(:imageable)], relationships - assert_equal 'imageable', path - assert_equal 'name', field - end - - def test_gets_relationship_chain_with_field_one_level - relationships, path, field = PostResource.parse_relationship_path('author.name') - assert_equal [PostResource._relationship(:author)], relationships - assert_equal 'author', path - assert_equal 'name', field - end - - def test_gets_relationship_chain_with_two_relationship_levels - relationships, path, field = PostResource.parse_relationship_path('author.comments') - assert_equal [PostResource._relationship(:author), PersonResource._relationship(:comments)], relationships - assert_equal 'author.comments', path - assert_nil field - end - - def test_gets_relationship_chain_with_two_relationship_levels_and_field - relationships, path, field = PostResource.parse_relationship_path('author.comments.body') - assert_equal [PostResource._relationship(:author), PersonResource._relationship(:comments)], relationships - assert_equal 'author.comments', path - assert_equal 'body', field - end - - def test_gets_relationship_chain_with_three_relationship_levels_and_field - relationships, path, field = PostResource.parse_relationship_path('author.comments.tags.name') - assert_equal [PostResource._relationship(:author), - PersonResource._relationship(:comments), - CommentResource._relationship(:tags)], relationships - assert_equal 'author.comments.tags', path - assert_equal 'name', field + assert_equal 'Company Brochure', related_fragments.values[1].attributes[:name] end end diff --git a/test/unit/serializer/polymorphic_serializer_test.rb b/test/unit/serializer/polymorphic_serializer_test.rb deleted file mode 100644 index 2963e51fd..000000000 --- a/test/unit/serializer/polymorphic_serializer_test.rb +++ /dev/null @@ -1,484 +0,0 @@ -# ToDo: Revisit these tests. - -# require File.expand_path('../../../test_helper', __FILE__) -# require 'jsonapi-resources' -# require 'json' -# -# class PolymorphismTest < ActionDispatch::IntegrationTest -# def setup -# @pictures = Picture.all -# @person = Person.find(1) -# -# @questions = Question.all -# -# JSONAPI.configuration.json_key_format = :camelized_key -# JSONAPI.configuration.route_format = :camelized_route -# end -# -# def after_teardown -# JSONAPI.configuration.json_key_format = :underscored_key -# end -# -# def test_polymorphic_relationship -# relationships = PictureResource._relationships -# imageable = relationships[:imageable] -# -# assert_equal relationships.size, 1 -# assert imageable.polymorphic? -# end -# -# def test_sti_polymorphic_to_many_serialization -# serialized_data = JSONAPI::ResourceSerializer.new( -# PersonResource, -# include: %w(vehicles) -# ).serialize_to_hash(PersonResource.new(@person, nil)) -# -# assert_hash_equals( -# { -# data: { -# id: '1', -# type: 'people', -# links: { -# self: '/people/1' -# }, -# attributes: { -# name: 'Joe Author', -# email: 'joe@xyz.fake', -# dateJoined: '2013-08-07 16:25:00 -0400' -# }, -# relationships: { -# comments: { -# links: { -# self: '/people/1/relationships/comments', -# related: '/people/1/comments' -# } -# }, -# posts: { -# links: { -# self: '/people/1/relationships/posts', -# related: '/people/1/posts' -# } -# }, -# vehicles: { -# links: { -# self: '/people/1/relationships/vehicles', -# related: '/people/1/vehicles' -# }, -# :data => [ -# { type: 'cars', id: '1' }, -# { type: 'boats', id: '2' } -# ] -# }, -# preferences: { -# links: { -# self: '/people/1/relationships/preferences', -# related: '/people/1/preferences' -# } -# }, -# hairCut: { -# links: { -# self: '/people/1/relationships/hairCut', -# related: '/people/1/hairCut' -# } -# } -# } -# }, -# included: [ -# { -# id: '1', -# type: 'cars', -# links: { -# self: '/cars/1' -# }, -# attributes: { -# make: 'Mazda', -# model: 'Miata MX5', -# driveLayout: 'Front Engine RWD', -# serialNumber: '32432adfsfdysua' -# }, -# relationships: { -# person: { -# links: { -# self: '/cars/1/relationships/person', -# related: '/cars/1/person' -# } -# } -# } -# }, -# { -# id: '2', -# type: 'boats', -# links: { -# self: '/boats/2' -# }, -# attributes: { -# make: 'Chris-Craft', -# model: 'Launch 20', -# lengthAtWaterLine: '15.5ft', -# serialNumber: '434253JJJSD' -# }, -# relationships: { -# person: { -# links: { -# self: '/boats/2/relationships/person', -# related: '/boats/2/person' -# } -# } -# } -# } -# ] -# }, -# serialized_data -# ) -# end -# -# def test_polymorphic_belongs_to_serialization -# serialized_data = JSONAPI::ResourceSerializer.new( -# PictureResource, -# include: %w(imageable) -# ).serialize_to_hash(@pictures.map { |p| PictureResource.new p, nil }) -# -# assert_hash_equals( -# { -# data: [ -# { -# id: '1', -# type: 'pictures', -# links: { -# self: '/pictures/1' -# }, -# attributes: { -# name: 'enterprise_gizmo.jpg' -# }, -# relationships: { -# imageable: { -# links: { -# self: '/pictures/1/relationships/imageable', -# related: '/pictures/1/imageable' -# }, -# data: { -# type: 'products', -# id: '1' -# } -# } -# } -# }, -# { -# id: '2', -# type: 'pictures', -# links: { -# self: '/pictures/2' -# }, -# attributes: { -# name: 'company_brochure.jpg' -# }, -# relationships: { -# imageable: { -# links: { -# self: '/pictures/2/relationships/imageable', -# related: '/pictures/2/imageable' -# }, -# data: { -# type: 'documents', -# id: '1' -# } -# } -# } -# }, -# { -# id: '3', -# type: 'pictures', -# links: { -# self: '/pictures/3' -# }, -# attributes: { -# name: 'group_photo.jpg' -# }, -# relationships: { -# imageable: { -# links: { -# self: '/pictures/3/relationships/imageable', -# related: '/pictures/3/imageable' -# }, -# data: nil -# } -# } -# } -# -# ], -# :included => [ -# { -# id: '1', -# type: 'products', -# links: { -# self: '/products/1' -# }, -# attributes: { -# name: 'Enterprise Gizmo' -# }, -# relationships: { -# picture: { -# links: { -# self: '/products/1/relationships/picture', -# related: '/products/1/picture', -# }, -# data: { -# type: 'pictures', -# id: '1' -# } -# } -# } -# }, -# { -# id: '1', -# type: 'documents', -# links: { -# self: '/documents/1' -# }, -# attributes: { -# name: 'Company Brochure' -# }, -# relationships: { -# pictures: { -# links: { -# self: '/documents/1/relationships/pictures', -# related: '/documents/1/pictures' -# } -# } -# } -# } -# ] -# }, -# serialized_data -# ) -# end -# -# def test_polymorphic_has_one_serialization -# serialized_data = JSONAPI::ResourceSerializer.new( -# QuestionResource, -# include: %w(respondent) -# ).serialize_to_hash(@questions.map { |p| QuestionResource.new p, nil }) -# -# assert_hash_equals( -# { -# data: [ -# { -# id: '1', -# type: 'questions', -# links: { -# self: '/questions/1' -# }, -# attributes: { -# text: 'How are you feeling today?' -# }, -# relationships: { -# answer: { -# links: { -# self: '/questions/1/relationships/answer', -# related: '/questions/1/answer' -# } -# }, -# respondent: { -# links: { -# self: '/questions/1/relationships/respondent', -# related: '/questions/1/respondent' -# }, -# data: { -# type: 'patients', -# id: '1' -# } -# } -# } -# }, -# { -# id: '2', -# type: 'questions', -# links: { -# self: '/questions/2' -# }, -# attributes: { -# text: 'How does the patient look today?' -# }, -# relationships: { -# answer: { -# links: { -# self: '/questions/2/relationships/answer', -# related: '/questions/2/answer' -# } -# }, -# respondent: { -# links: { -# self: '/questions/2/relationships/respondent', -# related: '/questions/2/respondent' -# }, -# data: { -# type: 'doctors', -# id: '1' -# } -# } -# } -# } -# ], -# :included => [ -# { -# id: '1', -# type: 'patients', -# links: { -# self: '/patients/1' -# }, -# attributes: { -# name: 'Bob Smith' -# }, -# }, -# { -# id: '1', -# type: 'doctors', -# links: { -# self: '/doctors/1' -# }, -# attributes: { -# name: 'Henry Jones Jr' -# }, -# } -# ] -# }, -# serialized_data -# ) -# end -# -# def test_polymorphic_show_related_resource -# get '/pictures/1/imageable', headers: { 'Accept' => JSONAPI::MEDIA_TYPE } -# serialized_data = JSON.parse(response.body) -# assert_hash_equals( -# { -# data: { -# id: '1', -# type: 'products', -# links: { -# self: 'http://www.example.com/products/1' -# }, -# attributes: { -# name: 'Enterprise Gizmo' -# }, -# relationships: { -# picture: { -# links: { -# self: 'http://www.example.com/products/1/relationships/picture', -# related: 'http://www.example.com/products/1/picture' -# }, -# data: { -# type: 'pictures', -# id: '1' -# } -# } -# } -# } -# }, -# serialized_data -# ) -# end -# -# def test_create_resource_with_polymorphic_relationship -# document = Document.find(1) -# post "/pictures/", params: -# { -# data: { -# type: "pictures", -# attributes: { -# name: "hello.jpg" -# }, -# relationships: { -# imageable: { -# data: { -# type: "documents", -# id: document.id.to_s -# } -# } -# } -# } -# }.to_json, -# headers: { -# 'Content-Type' => JSONAPI::MEDIA_TYPE, -# 'Accept' => JSONAPI::MEDIA_TYPE -# } -# assert_equal 201, response.status -# picture = Picture.find(json_response["data"]["id"]) -# assert_not_nil picture.imageable, "imageable should be present" -# ensure -# picture.destroy if picture -# end -# -# def test_polymorphic_create_relationship -# picture = Picture.find(3) -# original_imageable = picture.imageable -# assert_nil original_imageable -# -# patch "/pictures/#{picture.id}/relationships/imageable", params: -# { -# relationship: 'imageable', -# data: { -# type: 'documents', -# id: '1' -# } -# }.to_json, -# headers: { -# 'Content-Type' => JSONAPI::MEDIA_TYPE, -# 'Accept' => JSONAPI::MEDIA_TYPE -# } -# assert_response :no_content -# picture = Picture.find(3) -# assert_equal 'Document', picture.imageable.class.to_s -# -# # restore data -# picture.imageable = original_imageable -# picture.save -# end -# -# def test_polymorphic_update_relationship -# picture = Picture.find(1) -# original_imageable = picture.imageable -# assert_not_equal 'Document', picture.imageable.class.to_s -# -# patch "/pictures/#{picture.id}/relationships/imageable", params: -# { -# relationship: 'imageable', -# data: { -# type: 'documents', -# id: '1' -# } -# }.to_json, -# headers: { -# 'Content-Type' => JSONAPI::MEDIA_TYPE, -# 'Accept' => JSONAPI::MEDIA_TYPE -# } -# assert_response :no_content -# picture = Picture.find(1) -# assert_equal 'Document', picture.imageable.class.to_s -# -# # restore data -# picture.imageable = original_imageable -# picture.save -# end -# -# def test_polymorphic_delete_relationship -# picture = Picture.find(1) -# original_imageable = picture.imageable -# assert original_imageable -# -# delete "/pictures/#{picture.id}/relationships/imageable", params: -# { -# relationship: 'imageable' -# }.to_json, -# headers: { -# 'Content-Type' => JSONAPI::MEDIA_TYPE, -# 'Accept' => JSONAPI::MEDIA_TYPE -# } -# assert_response :no_content -# picture = Picture.find(1) -# assert_nil picture.imageable -# -# # restore data -# picture.imageable = original_imageable -# picture.save -# end -# end diff --git a/test/unit/serializer/serializer_test.rb b/test/unit/serializer/serializer_test.rb index e775735b6..94e4b2af8 100644 --- a/test/unit/serializer/serializer_test.rb +++ b/test/unit/serializer/serializer_test.rb @@ -1,2419 +1,534 @@ -# ToDo: Rework these tests +require File.expand_path('../../../test_helper', __FILE__) +require 'jsonapi-resources' +require 'json' -# require File.expand_path('../../../test_helper', __FILE__) -# require 'jsonapi-resources' -# require 'json' -# -# class SerializerTest < ActionDispatch::IntegrationTest -# def setup -# @post = Post.find(1) -# @fred = Person.find_by(name: 'Fred Reader') -# -# @expense_entry = ExpenseEntry.find(1) -# -# JSONAPI.configuration.json_key_format = :camelized_key -# JSONAPI.configuration.route_format = :camelized_route -# JSONAPI.configuration.always_include_to_one_linkage_data = false -# end -# -# def after_teardown -# JSONAPI.configuration.always_include_to_one_linkage_data = false -# JSONAPI.configuration.json_key_format = :underscored_key -# end -# -# def test_serializer -# -# serialized = JSONAPI::ResourceSerializer.new( -# PostResource, -# base_url: 'http://example.com').serialize_to_hash(PostResource.new(@post, nil) -# ) -# -# assert_hash_equals( -# { -# data: { -# type: 'posts', -# id: '1', -# links: { -# self: 'http://example.com/posts/1', -# }, -# attributes: { -# title: 'New post', -# body: 'A body!!!', -# subject: 'New post' -# }, -# relationships: { -# section: { -# links: { -# self: 'http://example.com/posts/1/relationships/section', -# related: 'http://example.com/posts/1/section' -# } -# }, -# author: { -# links: { -# self: 'http://example.com/posts/1/relationships/author', -# related: 'http://example.com/posts/1/author' -# } -# }, -# tags: { -# links: { -# self: 'http://example.com/posts/1/relationships/tags', -# related: 'http://example.com/posts/1/tags' -# } -# }, -# comments: { -# links: { -# self: 'http://example.com/posts/1/relationships/comments', -# related: 'http://example.com/posts/1/comments' -# } -# } -# } -# } -# }, -# serialized -# ) -# end -# -# def test_serializer_nil_handling -# assert_hash_equals( -# { -# data: nil -# }, -# JSONAPI::ResourceSerializer.new(PostResource).serialize_to_hash(nil) -# ) -# end -# -# def test_serializer_namespaced_resource -# assert_hash_equals( -# { -# data: { -# type: 'posts', -# id: '1', -# links: { -# self: 'http://example.com/api/v1/posts/1' -# }, -# attributes: { -# title: 'New post', -# body: 'A body!!!', -# subject: 'New post' -# }, -# relationships: { -# section: { -# links:{ -# self: 'http://example.com/api/v1/posts/1/relationships/section', -# related: 'http://example.com/api/v1/posts/1/section' -# } -# }, -# writer: { -# links:{ -# self: 'http://example.com/api/v1/posts/1/relationships/writer', -# related: 'http://example.com/api/v1/posts/1/writer' -# } -# }, -# comments: { -# links:{ -# self: 'http://example.com/api/v1/posts/1/relationships/comments', -# related: 'http://example.com/api/v1/posts/1/comments' -# } -# } -# } -# } -# }, -# JSONAPI::ResourceSerializer.new(Api::V1::PostResource, -# base_url: 'http://example.com').serialize_to_hash( -# Api::V1::PostResource.new(@post, nil)) -# ) -# end -# -# def test_serializer_limited_fieldset -# -# assert_hash_equals( -# { -# data: { -# type: 'posts', -# id: '1', -# links: { -# self: '/posts/1' -# }, -# attributes: { -# title: 'New post' -# }, -# relationships: { -# author: { -# links: { -# self: '/posts/1/relationships/author', -# related: '/posts/1/author' -# } -# } -# } -# } -# }, -# JSONAPI::ResourceSerializer.new(PostResource, -# fields: {posts: [:id, :title, :author]}).serialize_to_hash(PostResource.new(@post, nil)) -# ) -# end -# -# def test_serializer_include -# serialized = JSONAPI::ResourceSerializer.new( -# PostResource, -# include: ['author'] -# ).serialize_to_hash(PostResource.new(@post, nil)) -# -# assert_hash_equals( -# { -# data: { -# type: 'posts', -# id: '1', -# links: { -# self: '/posts/1' -# }, -# attributes: { -# title: 'New post', -# body: 'A body!!!', -# subject: 'New post' -# }, -# relationships: { -# section: { -# links: { -# self: '/posts/1/relationships/section', -# related: '/posts/1/section' -# } -# }, -# author: { -# links: { -# self: '/posts/1/relationships/author', -# related: '/posts/1/author' -# }, -# data: { -# type: 'people', -# id: '1' -# } -# }, -# tags: { -# links: { -# self: '/posts/1/relationships/tags', -# related: '/posts/1/tags' -# } -# }, -# comments: { -# links: { -# self: '/posts/1/relationships/comments', -# related: '/posts/1/comments' -# } -# } -# } -# }, -# included: [ -# { -# type: 'people', -# id: '1', -# attributes: { -# name: 'Joe Author', -# email: 'joe@xyz.fake', -# dateJoined: '2013-08-07 16:25:00 -0400' -# }, -# links: { -# self: '/people/1' -# }, -# relationships: { -# comments: { -# links: { -# self: '/people/1/relationships/comments', -# related: '/people/1/comments' -# } -# }, -# posts: { -# links: { -# self: '/people/1/relationships/posts', -# related: '/people/1/posts' -# } -# }, -# preferences: { -# links: { -# self: '/people/1/relationships/preferences', -# related: '/people/1/preferences' -# } -# }, -# hairCut: { -# links: { -# self: "/people/1/relationships/hairCut", -# related: "/people/1/hairCut" -# } -# }, -# vehicles: { -# links: { -# self: "/people/1/relationships/vehicles", -# related: "/people/1/vehicles" -# } -# } -# } -# } -# ] -# }, -# serialized -# ) -# end -# -# def test_serializer_key_format -# serialized = JSONAPI::ResourceSerializer.new( -# PostResource, -# include: ['author'], -# key_formatter: UnderscoredKeyFormatter -# ).serialize_to_hash(PostResource.new(@post, nil)) -# -# assert_hash_equals( -# { -# data: { -# type: 'posts', -# id: '1', -# attributes: { -# title: 'New post', -# body: 'A body!!!', -# subject: 'New post' -# }, -# links: { -# self: '/posts/1' -# }, -# relationships: { -# section: { -# links: { -# self: '/posts/1/relationships/section', -# related: '/posts/1/section' -# } -# }, -# author: { -# links: { -# self: '/posts/1/relationships/author', -# related: '/posts/1/author' -# }, -# data: { -# type: 'people', -# id: '1' -# } -# }, -# tags: { -# links: { -# self: '/posts/1/relationships/tags', -# related: '/posts/1/tags' -# } -# }, -# comments: { -# links: { -# self: '/posts/1/relationships/comments', -# related: '/posts/1/comments' -# } -# } -# } -# }, -# included: [ -# { -# type: 'people', -# id: '1', -# attributes: { -# name: 'Joe Author', -# email: 'joe@xyz.fake', -# date_joined: '2013-08-07 16:25:00 -0400' -# }, -# links: { -# self: '/people/1' -# }, -# relationships: { -# comments: { -# links: { -# self: '/people/1/relationships/comments', -# related: '/people/1/comments' -# } -# }, -# posts: { -# links: { -# self: '/people/1/relationships/posts', -# related: '/people/1/posts' -# } -# }, -# preferences: { -# links: { -# self: '/people/1/relationships/preferences', -# related: '/people/1/preferences' -# } -# }, -# hair_cut: { -# links: { -# self: '/people/1/relationships/hairCut', -# related: '/people/1/hairCut' -# } -# }, -# vehicles: { -# links: { -# self: "/people/1/relationships/vehicles", -# related: "/people/1/vehicles" -# } -# }, -# expense_entries: { -# links: { -# self: "/people/1/relationships/expenseEntries", -# related: "/people/1/expenseEntries" -# } -# } -# } -# } -# ] -# }, -# serialized -# ) -# end -# -# def test_serializer_include_sub_objects -# -# assert_hash_equals( -# { -# data: { -# type: 'posts', -# id: '1', -# attributes: { -# title: 'New post', -# body: 'A body!!!', -# subject: 'New post' -# }, -# links: { -# self: '/posts/1' -# }, -# relationships: { -# section: { -# links: { -# self: '/posts/1/relationships/section', -# related: '/posts/1/section' -# } -# }, -# author: { -# links: { -# self: '/posts/1/relationships/author', -# related: '/posts/1/author' -# } -# }, -# tags: { -# links: { -# self: '/posts/1/relationships/tags', -# related: '/posts/1/tags' -# } -# }, -# comments: { -# links: { -# self: '/posts/1/relationships/comments', -# related: '/posts/1/comments' -# }, -# data: [ -# {type: 'comments', id: '1'}, -# {type: 'comments', id: '2'} -# ] -# } -# } -# }, -# included: [ -# { -# type: 'tags', -# id: '1', -# attributes: { -# name: 'short' -# }, -# links: { -# self: '/tags/1' -# }, -# relationships: { -# posts: { -# links: { -# self: '/tags/1/relationships/posts', -# related: '/tags/1/posts' -# } -# } -# } -# }, -# { -# type: 'tags', -# id: '2', -# attributes: { -# name: 'whiny' -# }, -# links: { -# self: '/tags/2' -# }, -# relationships: { -# posts: { -# links: { -# self: '/tags/2/relationships/posts', -# related: '/tags/2/posts' -# } -# } -# } -# }, -# { -# type: 'tags', -# id: '4', -# attributes: { -# name: 'happy' -# }, -# links: { -# self: '/tags/4' -# }, -# relationships: { -# posts: { -# links: { -# self: '/tags/4/relationships/posts', -# related: '/tags/4/posts' -# }, -# } -# } -# }, -# { -# type: 'comments', -# id: '1', -# attributes: { -# body: 'what a dumb post' -# }, -# links: { -# self: '/comments/1' -# }, -# relationships: { -# author: { -# links: { -# self: '/comments/1/relationships/author', -# related: '/comments/1/author' -# } -# }, -# post: { -# links: { -# self: '/comments/1/relationships/post', -# related: '/comments/1/post' -# } -# }, -# tags: { -# links: { -# self: '/comments/1/relationships/tags', -# related: '/comments/1/tags' -# }, -# data: [ -# {type: 'tags', id: '1'}, -# {type: 'tags', id: '2'} -# ] -# } -# } -# }, -# { -# type: 'comments', -# id: '2', -# attributes: { -# body: 'i liked it' -# }, -# links: { -# self: '/comments/2' -# }, -# relationships: { -# author: { -# links: { -# self: '/comments/2/relationships/author', -# related: '/comments/2/author' -# } -# }, -# post: { -# links: { -# self: '/comments/2/relationships/post', -# related: '/comments/2/post' -# } -# }, -# tags: { -# links: { -# self: '/comments/2/relationships/tags', -# related: '/comments/2/tags' -# }, -# data: [ -# {type: 'tags', id: '1'}, -# {type: 'tags', id: '4'} -# ] -# } -# } -# } -# ] -# }, -# JSONAPI::ResourceSerializer.new(PostResource, -# include: ['comments', 'comments.tags']).serialize_to_hash(PostResource.new(@post, nil)) -# ) -# end -# -# def test_serializer_keeps_sorted_order_of_objects_with_self_referential_relationships -# post1, post2, post3 = Post.find(1), Post.find(2), Post.find(3) -# post1.parent_post = post3 -# ordered_posts = [post1, post2, post3] -# serialized_data = JSONAPI::ResourceSerializer.new( -# ParentApi::PostResource, -# include: ['parent_post'], -# base_url: 'http://example.com').serialize_to_hash(ordered_posts.map {|p| ParentApi::PostResource.new(p, nil)} -# )['data'] -# -# assert_equal(3, serialized_data.length) -# assert_equal("1", serialized_data[0]["id"]) -# assert_equal("2", serialized_data[1]["id"]) -# assert_equal("3", serialized_data[2]["id"]) -# end -# -# -# def test_serializer_different_foreign_key -# serialized = JSONAPI::ResourceSerializer.new( -# PersonResource, -# include: ['comments'] -# ).serialize_to_hash(PersonResource.new(@fred, nil)) -# -# assert_hash_equals( -# { -# data: { -# type: 'people', -# id: '2', -# attributes: { -# name: 'Fred Reader', -# email: 'fred@xyz.fake', -# dateJoined: '2013-10-31 16:25:00 -0400' -# }, -# links: { -# self: '/people/2' -# }, -# relationships: { -# posts: { -# links: { -# self: '/people/2/relationships/posts', -# related: '/people/2/posts' -# } -# }, -# comments: { -# links: { -# self: '/people/2/relationships/comments', -# related: '/people/2/comments' -# }, -# data: [ -# {type: 'comments', id: '2'}, -# {type: 'comments', id: '3'} -# ] -# }, -# preferences: { -# links: { -# self: "/people/2/relationships/preferences", -# related: "/people/2/preferences" -# } -# }, -# hairCut: { -# links: { -# self: "/people/2/relationships/hairCut", -# related: "/people/2/hairCut" -# } -# }, -# vehicles: { -# links: { -# self: "/people/2/relationships/vehicles", -# related: "/people/2/vehicles" -# } -# }, -# } -# }, -# included: [ -# { -# type: 'comments', -# id: '2', -# attributes: { -# body: 'i liked it' -# }, -# links: { -# self: '/comments/2' -# }, -# relationships: { -# author: { -# links: { -# self: '/comments/2/relationships/author', -# related: '/comments/2/author' -# } -# }, -# post: { -# links: { -# self: '/comments/2/relationships/post', -# related: '/comments/2/post' -# } -# }, -# tags: { -# links: { -# self: '/comments/2/relationships/tags', -# related: '/comments/2/tags' -# } -# } -# } -# }, -# { -# type: 'comments', -# id: '3', -# attributes: { -# body: 'Thanks man. Great post. But what is JR?' -# }, -# links: { -# self: '/comments/3' -# }, -# relationships: { -# author: { -# links: { -# self: '/comments/3/relationships/author', -# related: '/comments/3/author' -# } -# }, -# post: { -# links: { -# self: '/comments/3/relationships/post', -# related: '/comments/3/post' -# } -# }, -# tags: { -# links: { -# self: '/comments/3/relationships/tags', -# related: '/comments/3/tags' -# } -# } -# } -# } -# ] -# }, -# serialized -# ) -# end -# -# def test_serializer_array_of_resources_always_include_to_one_linkage_data -# -# posts = [] -# Post.find(1, 2).each do |post| -# posts.push PostResource.new(post, nil) -# end -# -# JSONAPI.configuration.always_include_to_one_linkage_data = true -# -# assert_hash_equals( -# { -# data: [ -# { -# type: 'posts', -# id: '1', -# attributes: { -# title: 'New post', -# body: 'A body!!!', -# subject: 'New post' -# }, -# links: { -# self: '/posts/1' -# }, -# relationships: { -# section: { -# links: { -# self: '/posts/1/relationships/section', -# related: '/posts/1/section' -# }, -# data: nil -# }, -# author: { -# links: { -# self: '/posts/1/relationships/author', -# related: '/posts/1/author' -# }, -# data: { -# type: 'people', -# id: '1' -# } -# }, -# tags: { -# links: { -# self: '/posts/1/relationships/tags', -# related: '/posts/1/tags' -# } -# }, -# comments: { -# links: { -# self: '/posts/1/relationships/comments', -# related: '/posts/1/comments' -# }, -# data: [ -# {type: 'comments', id: '1'}, -# {type: 'comments', id: '2'} -# ] -# } -# } -# }, -# { -# type: 'posts', -# id: '2', -# attributes: { -# title: 'JR Solves your serialization woes!', -# body: 'Use JR', -# subject: 'JR Solves your serialization woes!' -# }, -# links: { -# self: '/posts/2' -# }, -# relationships: { -# section: { -# links: { -# self: '/posts/2/relationships/section', -# related: '/posts/2/section' -# }, -# data: { -# type: 'sections', -# id: '2' -# } -# }, -# author: { -# links: { -# self: '/posts/2/relationships/author', -# related: '/posts/2/author' -# }, -# data: { -# type: 'people', -# id: '1' -# } -# }, -# tags: { -# links: { -# self: '/posts/2/relationships/tags', -# related: '/posts/2/tags' -# } -# }, -# comments: { -# links: { -# self: '/posts/2/relationships/comments', -# related: '/posts/2/comments' -# }, -# data: [ -# {type: 'comments', id: '3'} -# ] -# } -# } -# } -# ], -# included: [ -# { -# type: 'tags', -# id: '1', -# attributes: { -# name: 'short' -# }, -# links: { -# self: '/tags/1' -# }, -# relationships: { -# posts: { -# links: { -# self: '/tags/1/relationships/posts', -# related: '/tags/1/posts' -# } -# } -# } -# }, -# { -# type: 'tags', -# id: '2', -# attributes: { -# name: 'whiny' -# }, -# links: { -# self: '/tags/2' -# }, -# relationships: { -# posts: { -# links: { -# self: '/tags/2/relationships/posts', -# related: '/tags/2/posts' -# } -# } -# } -# }, -# { -# type: 'tags', -# id: '4', -# attributes: { -# name: 'happy' -# }, -# links: { -# self: '/tags/4' -# }, -# relationships: { -# posts: { -# links: { -# self: '/tags/4/relationships/posts', -# related: '/tags/4/posts' -# } -# } -# } -# }, -# { -# type: 'tags', -# id: '5', -# attributes: { -# name: 'JR' -# }, -# links: { -# self: '/tags/5' -# }, -# relationships: { -# posts: { -# links: { -# self: '/tags/5/relationships/posts', -# related: '/tags/5/posts' -# } -# } -# } -# }, -# { -# type: 'comments', -# id: '1', -# attributes: { -# body: 'what a dumb post' -# }, -# links: { -# self: '/comments/1' -# }, -# relationships: { -# author: { -# links: { -# self: '/comments/1/relationships/author', -# related: '/comments/1/author' -# }, -# data: { -# type: 'people', -# id: '1' -# } -# }, -# post: { -# links: { -# self: '/comments/1/relationships/post', -# related: '/comments/1/post' -# }, -# data: { -# type: 'posts', -# id: '1' -# } -# }, -# tags: { -# links: { -# self: '/comments/1/relationships/tags', -# related: '/comments/1/tags' -# }, -# data: [ -# {type: 'tags', id: '1'}, -# {type: 'tags', id: '2'} -# ] -# } -# } -# }, -# { -# type: 'comments', -# id: '2', -# attributes: { -# body: 'i liked it' -# }, -# links: { -# self: '/comments/2' -# }, -# relationships: { -# author: { -# links: { -# self: '/comments/2/relationships/author', -# related: '/comments/2/author' -# }, -# data: { -# type: 'people', -# id: '2' -# } -# }, -# post: { -# links: { -# self: '/comments/2/relationships/post', -# related: '/comments/2/post' -# }, -# data: { -# type: 'posts', -# id: '1' -# } -# }, -# tags: { -# links: { -# self: '/comments/2/relationships/tags', -# related: '/comments/2/tags' -# }, -# data: [ -# {type: 'tags', id: '4'}, -# {type: 'tags', id: '1'} -# ] -# } -# } -# }, -# { -# type: 'comments', -# id: '3', -# attributes: { -# body: 'Thanks man. Great post. But what is JR?' -# }, -# links: { -# self: '/comments/3' -# }, -# relationships: { -# author: { -# links: { -# self: '/comments/3/relationships/author', -# related: '/comments/3/author' -# }, -# data: { -# type: 'people', -# id: '2' -# } -# }, -# post: { -# links: { -# self: '/comments/3/relationships/post', -# related: '/comments/3/post' -# }, -# data: { -# type: 'posts', -# id: '2' -# } -# }, -# tags: { -# links: { -# self: '/comments/3/relationships/tags', -# related: '/comments/3/tags' -# }, -# data: [ -# {type: 'tags', id: '5'} -# ] -# } -# } -# } -# ] -# }, -# JSONAPI::ResourceSerializer.new(PostResource, -# include: ['comments', 'comments.tags']).serialize_to_hash(posts) -# ) -# ensure -# JSONAPI.configuration.always_include_to_one_linkage_data = false -# end -# -# def test_serializer_always_include_to_one_linkage_data_does_not_load_association -# JSONAPI.configuration.always_include_to_one_linkage_data = true -# -# post = Post.find(1) -# resource = Api::V1::PostResource.new(post, nil) -# JSONAPI::ResourceSerializer.new(Api::V1::PostResource).serialize_to_hash(resource) -# -# refute_predicate post.association(:writer), :loaded? -# ensure -# JSONAPI.configuration.always_include_to_one_linkage_data = false -# end -# -# def test_serializer_array_of_resources -# -# posts = [] -# Post.find(1, 2).each do |post| -# posts.push PostResource.new(post, nil) -# end -# -# assert_hash_equals( -# { -# data: [ -# { -# type: 'posts', -# id: '1', -# attributes: { -# title: 'New post', -# body: 'A body!!!', -# subject: 'New post' -# }, -# links: { -# self: '/posts/1' -# }, -# relationships: { -# section: { -# links: { -# self: '/posts/1/relationships/section', -# related: '/posts/1/section' -# } -# }, -# author: { -# links: { -# self: '/posts/1/relationships/author', -# related: '/posts/1/author' -# } -# }, -# tags: { -# links: { -# self: '/posts/1/relationships/tags', -# related: '/posts/1/tags' -# } -# }, -# comments: { -# links: { -# self: '/posts/1/relationships/comments', -# related: '/posts/1/comments' -# }, -# data: [ -# {type: 'comments', id: '1'}, -# {type: 'comments', id: '2'} -# ] -# } -# } -# }, -# { -# type: 'posts', -# id: '2', -# attributes: { -# title: 'JR Solves your serialization woes!', -# body: 'Use JR', -# subject: 'JR Solves your serialization woes!' -# }, -# links: { -# self: '/posts/2' -# }, -# relationships: { -# section: { -# links: { -# self: '/posts/2/relationships/section', -# related: '/posts/2/section' -# } -# }, -# author: { -# links: { -# self: '/posts/2/relationships/author', -# related: '/posts/2/author' -# } -# }, -# tags: { -# links: { -# self: '/posts/2/relationships/tags', -# related: '/posts/2/tags' -# } -# }, -# comments: { -# links: { -# self: '/posts/2/relationships/comments', -# related: '/posts/2/comments' -# }, -# data: [ -# {type: 'comments', id: '3'} -# ] -# } -# } -# } -# ], -# included: [ -# { -# type: 'tags', -# id: '1', -# attributes: { -# name: 'short' -# }, -# links: { -# self: '/tags/1' -# }, -# relationships: { -# posts: { -# links: { -# self: '/tags/1/relationships/posts', -# related: '/tags/1/posts' -# } -# } -# } -# }, -# { -# type: 'tags', -# id: '2', -# attributes: { -# name: 'whiny' -# }, -# links: { -# self: '/tags/2' -# }, -# relationships: { -# posts: { -# links: { -# self: '/tags/2/relationships/posts', -# related: '/tags/2/posts' -# } -# } -# } -# }, -# { -# type: 'tags', -# id: '4', -# attributes: { -# name: 'happy' -# }, -# links: { -# self: '/tags/4' -# }, -# relationships: { -# posts: { -# links: { -# self: '/tags/4/relationships/posts', -# related: '/tags/4/posts' -# } -# } -# } -# }, -# { -# type: 'tags', -# id: '5', -# attributes: { -# name: 'JR' -# }, -# links: { -# self: '/tags/5' -# }, -# relationships: { -# posts: { -# links: { -# self: '/tags/5/relationships/posts', -# related: '/tags/5/posts' -# } -# } -# } -# }, -# { -# type: 'comments', -# id: '1', -# attributes: { -# body: 'what a dumb post' -# }, -# links: { -# self: '/comments/1' -# }, -# relationships: { -# author: { -# links: { -# self: '/comments/1/relationships/author', -# related: '/comments/1/author' -# } -# }, -# post: { -# links: { -# self: '/comments/1/relationships/post', -# related: '/comments/1/post' -# } -# }, -# tags: { -# links: { -# self: '/comments/1/relationships/tags', -# related: '/comments/1/tags' -# }, -# data: [ -# {type: 'tags', id: '1'}, -# {type: 'tags', id: '2'} -# ] -# } -# } -# }, -# { -# type: 'comments', -# id: '2', -# attributes: { -# body: 'i liked it' -# }, -# links: { -# self: '/comments/2' -# }, -# relationships: { -# author: { -# links: { -# self: '/comments/2/relationships/author', -# related: '/comments/2/author' -# } -# }, -# post: { -# links: { -# self: '/comments/2/relationships/post', -# related: '/comments/2/post' -# } -# }, -# tags: { -# links: { -# self: '/comments/2/relationships/tags', -# related: '/comments/2/tags' -# }, -# data: [ -# {type: 'tags', id: '4'}, -# {type: 'tags', id: '1'} -# ] -# } -# } -# }, -# { -# type: 'comments', -# id: '3', -# attributes: { -# body: 'Thanks man. Great post. But what is JR?' -# }, -# links: { -# self: '/comments/3' -# }, -# relationships: { -# author: { -# links: { -# self: '/comments/3/relationships/author', -# related: '/comments/3/author' -# } -# }, -# post: { -# links: { -# self: '/comments/3/relationships/post', -# related: '/comments/3/post' -# } -# }, -# tags: { -# links: { -# self: '/comments/3/relationships/tags', -# related: '/comments/3/tags' -# }, -# data: [ -# {type: 'tags', id: '5'} -# ] -# } -# } -# } -# ] -# }, -# JSONAPI::ResourceSerializer.new(PostResource, -# include: ['comments', 'comments.tags']).serialize_to_hash(posts) -# ) -# end -# -# def test_serializer_array_of_resources_limited_fields -# -# posts = [] -# Post.find(1, 2).each do |post| -# posts.push PostResource.new(post, nil) -# end -# -# assert_hash_equals( -# { -# data: [ -# { -# type: 'posts', -# id: '1', -# attributes: { -# title: 'New post' -# }, -# links: { -# self: '/posts/1' -# } -# }, -# { -# type: 'posts', -# id: '2', -# attributes: { -# title: 'JR Solves your serialization woes!' -# }, -# links: { -# self: '/posts/2' -# } -# } -# ], -# included: [ -# { -# type: 'posts', -# id: '11', -# attributes: { -# title: 'JR How To' -# }, -# links: { -# self: '/posts/11' -# } -# }, -# { -# type: 'people', -# id: '1', -# attributes: { -# email: 'joe@xyz.fake' -# }, -# links: { -# self: '/people/1' -# }, -# relationships: { -# comments: { -# links: { -# self: '/people/1/relationships/comments', -# related: '/people/1/comments' -# } -# } -# } -# }, -# { -# id: '1', -# type: 'tags', -# attributes: { -# name: 'short' -# }, -# links: { -# self: '/tags/1' -# } -# }, -# { -# id: '2', -# type: 'tags', -# attributes: { -# name: 'whiny' -# }, -# links: { -# self: '/tags/2' -# } -# }, -# { -# id: '4', -# type: 'tags', -# attributes: { -# name: 'happy' -# }, -# links: { -# self: '/tags/4' -# } -# }, -# { -# id: '5', -# type: 'tags', -# attributes: { -# name: 'JR' -# }, -# links: { -# self: '/tags/5' -# } -# }, -# { -# type: 'comments', -# id: '1', -# attributes: { -# body: 'what a dumb post' -# }, -# links: { -# self: '/comments/1' -# }, -# relationships: { -# post: { -# links: { -# self: '/comments/1/relationships/post', -# related: '/comments/1/post' -# } -# } -# } -# }, -# { -# type: 'comments', -# id: '2', -# attributes: { -# body: 'i liked it' -# }, -# links: { -# self: '/comments/2' -# }, -# relationships: { -# post: { -# links: { -# self: '/comments/2/relationships/post', -# related: '/comments/2/post' -# } -# } -# } -# }, -# { -# type: 'comments', -# id: '3', -# attributes: { -# body: 'Thanks man. Great post. But what is JR?' -# }, -# links: { -# self: '/comments/3' -# }, -# relationships: { -# post: { -# links: { -# self: '/comments/3/relationships/post', -# related: '/comments/3/post' -# } -# } -# } -# } -# ] -# }, -# JSONAPI::ResourceSerializer.new(PostResource, -# include: ['comments', 'author', 'comments.tags', 'author.posts'], -# fields: { -# people: [:id, :email, :comments], -# posts: [:id, :title], -# tags: [:name], -# comments: [:id, :body, :post] -# }).serialize_to_hash(posts) -# ) -# end -# -# def test_serializer_camelized_with_value_formatters -# assert_hash_equals( -# { -# data: { -# type: 'expenseEntries', -# id: '1', -# attributes: { -# transactionDate: '04/15/2014', -# cost: '12.05' -# }, -# links: { -# self: '/expenseEntries/1' -# }, -# relationships: { -# isoCurrency: { -# links: { -# self: '/expenseEntries/1/relationships/isoCurrency', -# related: '/expenseEntries/1/isoCurrency' -# }, -# data: { -# type: 'isoCurrencies', -# id: 'USD' -# } -# }, -# employee: { -# links: { -# self: '/expenseEntries/1/relationships/employee', -# related: '/expenseEntries/1/employee' -# }, -# data: { -# type: 'people', -# id: '3' -# } -# } -# } -# }, -# included: [ -# { -# type: 'isoCurrencies', -# id: 'USD', -# attributes: { -# countryName: 'United States', -# name: 'United States Dollar', -# minorUnit: 'cent' -# }, -# links: { -# self: '/isoCurrencies/USD' -# } -# }, -# { -# type: 'people', -# id: '3', -# attributes: { -# email: 'lazy@xyz.fake', -# name: 'Lazy Author', -# dateJoined: '2013-10-31 17:25:00 -0400' -# }, -# links: { -# self: '/people/3', -# } -# } -# ] -# }, -# JSONAPI::ResourceSerializer.new(ExpenseEntryResource, -# include: ['iso_currency', 'employee'], -# fields: {people: [:id, :name, :email, :date_joined]}).serialize_to_hash( -# ExpenseEntryResource.new(@expense_entry, nil)) -# ) -# end -# -# def test_serializer_empty_links_null_and_array -# planet_hash = JSONAPI::ResourceSerializer.new(PlanetResource).serialize_to_hash( -# PlanetResource.new(Planet.find(8), nil)) -# -# assert_hash_equals( -# { -# data: { -# type: 'planets', -# id: '8', -# attributes: { -# name: 'Beta W', -# description: 'Newly discovered Planet W' -# }, -# links: { -# self: '/planets/8' -# }, -# relationships: { -# planetType: { -# links: { -# self: '/planets/8/relationships/planetType', -# related: '/planets/8/planetType' -# } -# }, -# tags: { -# links: { -# self: '/planets/8/relationships/tags', -# related: '/planets/8/tags' -# } -# }, -# moons: { -# links: { -# self: '/planets/8/relationships/moons', -# related: '/planets/8/moons' -# } -# } -# } -# } -# }, planet_hash) -# end -# -# def test_serializer_include_with_empty_links_null_and_array -# planets = [] -# Planet.find(7, 8).each do |planet| -# planets.push PlanetResource.new(planet, nil) -# end -# -# planet_hash = JSONAPI::ResourceSerializer.new(PlanetResource, -# include: ['planet_type'], -# fields: { planet_types: [:id, :name] }).serialize_to_hash(planets) -# -# assert_hash_equals( -# { -# data: [{ -# type: 'planets', -# id: '7', -# attributes: { -# name: 'Beta X', -# description: 'Newly discovered Planet Z' -# }, -# links: { -# self: '/planets/7' -# }, -# relationships: { -# planetType: { -# links: { -# self: '/planets/7/relationships/planetType', -# related: '/planets/7/planetType' -# }, -# data: { -# type: 'planetTypes', -# id: '5' -# } -# }, -# tags: { -# links: { -# self: '/planets/7/relationships/tags', -# related: '/planets/7/tags' -# } -# }, -# moons: { -# links: { -# self: '/planets/7/relationships/moons', -# related: '/planets/7/moons' -# } -# } -# } -# }, -# { -# type: 'planets', -# id: '8', -# attributes: { -# name: 'Beta W', -# description: 'Newly discovered Planet W' -# }, -# links: { -# self: '/planets/8' -# }, -# relationships: { -# planetType: { -# links: { -# self: '/planets/8/relationships/planetType', -# related: '/planets/8/planetType' -# }, -# data: nil -# }, -# tags: { -# links: { -# self: '/planets/8/relationships/tags', -# related: '/planets/8/tags' -# } -# }, -# moons: { -# links: { -# self: '/planets/8/relationships/moons', -# related: '/planets/8/moons' -# } -# } -# } -# } -# ], -# included: [ -# { -# type: 'planetTypes', -# id: '5', -# attributes: { -# name: 'unknown' -# }, -# links: { -# self: '/planetTypes/5' -# } -# } -# ] -# }, planet_hash) -# end -# -# def test_serializer_booleans -# original_config = JSONAPI.configuration.dup -# JSONAPI.configuration.json_key_format = :underscored_key -# -# preferences = PreferencesResource.new(Preferences.find(1), nil) -# -# assert_hash_equals( -# { -# data: { -# type: 'preferences', -# id: '1', -# attributes: { -# advanced_mode: false -# }, -# links: { -# self: '/preferences/1' -# }, -# relationships: { -# author: { -# links: { -# self: '/preferences/1/relationships/author', -# related: '/preferences/1/author' -# } -# } -# } -# } -# }, -# JSONAPI::ResourceSerializer.new(PreferencesResource).serialize_to_hash(preferences) -# ) -# ensure -# JSONAPI.configuration = original_config -# end -# -# def test_serializer_data_types -# original_config = JSONAPI.configuration.dup -# JSONAPI.configuration.json_key_format = :underscored_key -# -# facts = FactResource.new(Fact.find(1), nil) -# -# assert_hash_equals( -# { -# data: { -# type: 'facts', -# id: '1', -# attributes: { -# spouse_name: 'Jane Author', -# bio: 'First man to run across Antartica.', -# quality_rating: 23.89/45.6, -# salary: BigDecimal('47000.56', 30).as_json, -# date_time_joined: DateTime.parse('2013-08-07 20:25:00 UTC +00:00').in_time_zone('UTC').as_json, -# birthday: Date.parse('1965-06-30').as_json, -# bedtime: Time.parse('2000-01-01 20:00:00 UTC +00:00').as_json, #DB seems to set the date to 2000-01-01 for time types -# photo: "abc", -# cool: false -# }, -# links: { -# self: '/facts/1' -# } -# } -# }, -# JSONAPI::ResourceSerializer.new(FactResource).serialize_to_hash(facts) -# ) -# ensure -# JSONAPI.configuration = original_config -# end -# -# def test_serializer_to_one -# serialized = JSONAPI::ResourceSerializer.new( -# Api::V5::AuthorResource, -# include: ['author_detail'] -# ).serialize_to_hash(Api::V5::AuthorResource.new(Person.find(1), nil)) -# -# assert_hash_equals( -# { -# data: { -# type: 'authors', -# id: '1', -# attributes: { -# name: 'Joe Author', -# }, -# links: { -# self: '/api/v5/authors/1' -# }, -# relationships: { -# posts: { -# links: { -# self: '/api/v5/authors/1/relationships/posts', -# related: '/api/v5/authors/1/posts' -# } -# }, -# authorDetail: { -# links: { -# self: '/api/v5/authors/1/relationships/authorDetail', -# related: '/api/v5/authors/1/authorDetail' -# }, -# data: {type: 'authorDetails', id: '1'} -# } -# } -# }, -# included: [ -# { -# type: 'authorDetails', -# id: '1', -# attributes: { -# authorStuff: 'blah blah' -# }, -# links: { -# self: '/api/v5/authorDetails/1' -# } -# } -# ] -# }, -# serialized -# ) -# end -# -# def test_serializer_resource_meta_fixed_value -# Api::V5::AuthorResource.class_eval do -# def meta(options) -# { -# fixed: 'Hardcoded value', -# computed: "#{self.class._type.to_s}: #{options[:serializer].link_builder.self_link(self)}" -# } -# end -# end -# -# serialized = JSONAPI::ResourceSerializer.new( -# Api::V5::AuthorResource, -# include: ['author_detail'] -# ).serialize_to_hash(Api::V5::AuthorResource.new(Person.find(1), nil)) -# -# assert_hash_equals( -# { -# data: { -# type: 'authors', -# id: '1', -# attributes: { -# name: 'Joe Author', -# }, -# links: { -# self: '/api/v5/authors/1' -# }, -# relationships: { -# posts: { -# links: { -# self: '/api/v5/authors/1/relationships/posts', -# related: '/api/v5/authors/1/posts' -# } -# }, -# authorDetail: { -# links: { -# self: '/api/v5/authors/1/relationships/authorDetail', -# related: '/api/v5/authors/1/authorDetail' -# }, -# data: {type: 'authorDetails', id: '1'} -# } -# }, -# meta: { -# fixed: 'Hardcoded value', -# computed: 'authors: /api/v5/authors/1' -# } -# }, -# included: [ -# { -# type: 'authorDetails', -# id: '1', -# attributes: { -# authorStuff: 'blah blah' -# }, -# links: { -# self: '/api/v5/authorDetails/1' -# } -# } -# ] -# }, -# serialized -# ) -# ensure -# Api::V5::AuthorResource.class_eval do -# def meta(options) -# # :nocov: -# { } -# # :nocov: -# end -# end -# end -# -# def test_serialize_model_attr -# @make = Make.first -# serialized = JSONAPI::ResourceSerializer.new( -# MakeResource, -# ).serialize_to_hash(MakeResource.new(@make, nil)) -# -# assert_hash_equals( -# { -# "model" => "A model attribute" -# }, -# serialized["data"]["attributes"] -# ) -# end -# -# def test_confusingly_named_attrs -# @wp = WebPage.first -# serialized = JSONAPI::ResourceSerializer.new( -# WebPageResource, -# ).serialize_to_hash(WebPageResource.new(@wp, nil)) -# -# assert_hash_equals( -# { -# "data"=>{ -# "id"=>"#{@wp.id}", -# "type"=>"webPages", -# "links"=>{ -# "self"=>"/webPages/#{@wp.id}" -# }, -# "attributes"=>{ -# "href"=>"http://example.com", -# "link"=>"http://link.example.com" -# } -# } -# }, -# serialized -# ) -# end -# -# def test_questionable_has_one -# # has_one -# out, err = capture_io do -# eval <<-CODE -# class ::Questionable < ActiveRecord::Base -# has_one :link -# has_one :href -# end -# class ::QuestionableResource < JSONAPI::Resource -# model_name '::Questionable' -# has_one :link -# has_one :href -# end -# cn = ::Questionable.new id: 1 -# puts JSONAPI::ResourceSerializer.new( -# ::QuestionableResource, -# ).serialize_to_hash(::QuestionableResource.new(cn, nil)) -# CODE -# end -# assert err.blank? -# assert_equal( -# { -# "data"=>{ -# "id"=>"1", -# "type"=>"questionables", -# "links"=>{ -# "self"=>"/questionables/1" -# }, -# "relationships"=>{ -# "link"=>{ -# "links"=>{ -# "self"=>"/questionables/1/relationships/link", -# "related"=>"/questionables/1/link" -# } -# }, -# "href"=>{ -# "links"=>{ -# "self"=>"/questionables/1/relationships/href", -# "related"=>"/questionables/1/href" -# } -# } -# } -# } -# }.to_s, -# out.strip -# ) -# end -# -# def test_questionable_has_many -# # has_one -# out, err = capture_io do -# eval <<-CODE -# class ::Questionable2 < ActiveRecord::Base -# self.table_name = 'questionables' -# has_many :links -# has_many :hrefs -# end -# class ::Questionable2Resource < JSONAPI::Resource -# model_name '::Questionable2' -# has_many :links -# has_many :hrefs -# end -# cn = ::Questionable2.new id: 1 -# puts JSONAPI::ResourceSerializer.new( -# ::Questionable2Resource, -# ).serialize_to_hash(::Questionable2Resource.new(cn, nil)) -# CODE -# end -# assert err.blank? -# assert_equal( -# { -# "data"=>{ -# "id"=>"1", -# "type"=>"questionable2s", -# "links"=>{ -# "self"=>"/questionable2s/1" -# }, -# "relationships"=>{ -# "links"=>{ -# "links"=>{ -# "self"=>"/questionable2s/1/relationships/links", -# "related"=>"/questionable2s/1/links" -# } -# }, -# "hrefs"=>{ -# "links"=>{ -# "self"=>"/questionable2s/1/relationships/hrefs", -# "related"=>"/questionable2s/1/hrefs" -# } -# } -# } -# } -# }.to_s, -# out.strip -# ) -# end -# -# def test_simple_custom_links -# serialized_custom_link_resource = JSONAPI::ResourceSerializer.new(SimpleCustomLinkResource, base_url: 'http://example.com').serialize_to_hash(SimpleCustomLinkResource.new(Post.first, {})) -# -# custom_link_spec = { -# data: { -# type: 'simpleCustomLinks', -# id: '1', -# attributes: { -# title: "New post", -# body: "A body!!!", -# subject: "New post" -# }, -# links: { -# self: "http://example.com/simpleCustomLinks/1", -# raw: "http://example.com/simpleCustomLinks/1/raw" -# }, -# relationships: { -# writer: { -# links: { -# self: "http://example.com/simpleCustomLinks/1/relationships/writer", -# related: "http://example.com/simpleCustomLinks/1/writer" -# } -# }, -# section: { -# links: { -# self: "http://example.com/simpleCustomLinks/1/relationships/section", -# related: "http://example.com/simpleCustomLinks/1/section" -# } -# }, -# comments: { -# links: { -# self: "http://example.com/simpleCustomLinks/1/relationships/comments", -# related: "http://example.com/simpleCustomLinks/1/comments" -# } -# } -# } -# } -# } -# -# assert_hash_equals(custom_link_spec, serialized_custom_link_resource) -# end -# -# def test_custom_links_with_custom_relative_paths -# serialized_custom_link_resource = JSONAPI::ResourceSerializer -# .new(CustomLinkWithRelativePathOptionResource, base_url: 'http://example.com') -# .serialize_to_hash(CustomLinkWithRelativePathOptionResource.new(Post.first, {})) -# -# custom_link_spec = { -# data: { -# type: 'customLinkWithRelativePathOptions', -# id: '1', -# attributes: { -# title: "New post", -# body: "A body!!!", -# subject: "New post" -# }, -# links: { -# self: "http://example.com/customLinkWithRelativePathOptions/1", -# raw: "http://example.com/customLinkWithRelativePathOptions/1/super/duper/path.xml" -# }, -# relationships: { -# writer: { -# links: { -# self: "http://example.com/customLinkWithRelativePathOptions/1/relationships/writer", -# related: "http://example.com/customLinkWithRelativePathOptions/1/writer" -# } -# }, -# section: { -# links: { -# self: "http://example.com/customLinkWithRelativePathOptions/1/relationships/section", -# related: "http://example.com/customLinkWithRelativePathOptions/1/section" -# } -# }, -# comments: { -# links: { -# self: "http://example.com/customLinkWithRelativePathOptions/1/relationships/comments", -# related: "http://example.com/customLinkWithRelativePathOptions/1/comments" -# } -# } -# } -# } -# } -# -# assert_hash_equals(custom_link_spec, serialized_custom_link_resource) -# end -# -# def test_custom_links_with_if_condition_equals_false -# serialized_custom_link_resource = JSONAPI::ResourceSerializer -# .new(CustomLinkWithIfCondition, base_url: 'http://example.com') -# .serialize_to_hash(CustomLinkWithIfCondition.new(Post.first, {})) -# -# custom_link_spec = { -# data: { -# type: 'customLinkWithIfConditions', -# id: '1', -# attributes: { -# title: "New post", -# body: "A body!!!", -# subject: "New post" -# }, -# links: { -# self: "http://example.com/customLinkWithIfConditions/1", -# }, -# relationships: { -# writer: { -# links: { -# self: "http://example.com/customLinkWithIfConditions/1/relationships/writer", -# related: "http://example.com/customLinkWithIfConditions/1/writer" -# } -# }, -# section: { -# links: { -# self: "http://example.com/customLinkWithIfConditions/1/relationships/section", -# related: "http://example.com/customLinkWithIfConditions/1/section" -# } -# }, -# comments: { -# links: { -# self: "http://example.com/customLinkWithIfConditions/1/relationships/comments", -# related: "http://example.com/customLinkWithIfConditions/1/comments" -# } -# } -# } -# } -# } -# -# assert_hash_equals(custom_link_spec, serialized_custom_link_resource) -# end -# -# def test_custom_links_with_if_condition_equals_true -# serialized_custom_link_resource = JSONAPI::ResourceSerializer -# .new(CustomLinkWithIfCondition, base_url: 'http://example.com') -# .serialize_to_hash(CustomLinkWithIfCondition.new(Post.find_by(title: "JR Solves your serialization woes!"), {})) -# -# custom_link_spec = { -# data: { -# type: 'customLinkWithIfConditions', -# id: '2', -# attributes: { -# title: "JR Solves your serialization woes!", -# body: "Use JR", -# subject: "JR Solves your serialization woes!" -# }, -# links: { -# self: "http://example.com/customLinkWithIfConditions/2", -# conditional_custom_link: "http://example.com/customLinkWithIfConditions/2/conditional/link.json" -# }, -# relationships: { -# writer: { -# links: { -# self: "http://example.com/customLinkWithIfConditions/2/relationships/writer", -# related: "http://example.com/customLinkWithIfConditions/2/writer" -# } -# }, -# section: { -# links: { -# self: "http://example.com/customLinkWithIfConditions/2/relationships/section", -# related: "http://example.com/customLinkWithIfConditions/2/section" -# } -# }, -# comments: { -# links: { -# self: "http://example.com/customLinkWithIfConditions/2/relationships/comments", -# related: "http://example.com/customLinkWithIfConditions/2/comments" -# } -# } -# } -# } -# } -# -# assert_hash_equals(custom_link_spec, serialized_custom_link_resource) -# end -# -# -# def test_custom_links_with_lambda -# # custom link is based on created_at timestamp of Post -# post_created_at = Post.first.created_at -# serialized_custom_link_resource = JSONAPI::ResourceSerializer -# .new(CustomLinkWithLambda, base_url: 'http://example.com') -# .serialize_to_hash(CustomLinkWithLambda.new(Post.first, {})) -# -# custom_link_spec = { -# data: { -# type: 'customLinkWithLambdas', -# id: '1', -# attributes: { -# title: "New post", -# body: "A body!!!", -# subject: "New post", -# createdAt: post_created_at.as_json -# }, -# links: { -# self: "http://example.com/customLinkWithLambdas/1", -# link_to_external_api: "http://external-api.com/posts/#{post_created_at.year}/#{post_created_at.month}/#{post_created_at.day}-New-post" -# }, -# relationships: { -# writer: { -# links: { -# self: "http://example.com/customLinkWithLambdas/1/relationships/writer", -# related: "http://example.com/customLinkWithLambdas/1/writer" -# } -# }, -# section: { -# links: { -# self: "http://example.com/customLinkWithLambdas/1/relationships/section", -# related: "http://example.com/customLinkWithLambdas/1/section" -# } -# }, -# comments: { -# links: { -# self: "http://example.com/customLinkWithLambdas/1/relationships/comments", -# related: "http://example.com/customLinkWithLambdas/1/comments" -# } -# } -# } -# } -# } -# -# assert_hash_equals(custom_link_spec, serialized_custom_link_resource) -# end -# -# def test_includes_two_relationships_with_same_foreign_key -# serialized_resource = JSONAPI::ResourceSerializer -# .new(PersonWithEvenAndOddPostsResource, include: ['even_posts','odd_posts']) -# .serialize_to_hash(PersonWithEvenAndOddPostsResource.new(Person.find(1), nil)) -# -# assert_hash_equals( -# { -# data: { -# id: "1", -# type: "personWithEvenAndOddPosts", -# links: { -# self: "/personWithEvenAndOddPosts/1" -# }, -# relationships: { -# evenPosts: { -# links: { -# self: "/personWithEvenAndOddPosts/1/relationships/evenPosts", -# related: "/personWithEvenAndOddPosts/1/evenPosts" -# }, -# data: [ -# { -# type: "posts", -# id: "2" -# } -# ] -# }, -# oddPosts: { -# links: { -# self: "/personWithEvenAndOddPosts/1/relationships/oddPosts", -# related: "/personWithEvenAndOddPosts/1/oddPosts" -# }, -# data:[ -# { -# type: "posts", -# id: "1" -# }, -# { -# type: "posts", -# id: "11" -# } -# ] -# } -# } -# }, -# included:[ -# { -# id: "2", -# type: "posts", -# links: { -# self: "/posts/2" -# }, -# attributes: { -# title: "JR Solves your serialization woes!", -# body: "Use JR", -# subject: "JR Solves your serialization woes!" -# }, -# relationships: { -# author: { -# links: { -# self: "/posts/2/relationships/author", -# related: "/posts/2/author" -# } -# }, -# section: { -# links: { -# self: "/posts/2/relationships/section", -# related: "/posts/2/section" -# } -# }, -# tags: { -# links: { -# self: "/posts/2/relationships/tags", -# related: "/posts/2/tags" -# } -# }, -# comments: { -# links: { -# self: "/posts/2/relationships/comments", -# related: "/posts/2/comments" -# } -# } -# } -# }, -# { -# id: "1", -# type: "posts", -# links: { -# self: "/posts/1" -# }, -# attributes: { -# title: "New post", -# body: "A body!!!", -# subject: "New post" -# }, -# relationships: { -# author: { -# links: { -# self: "/posts/1/relationships/author", -# related: "/posts/1/author" -# } -# }, -# section: { -# links: { -# self: "/posts/1/relationships/section", -# related: "/posts/1/section" -# } -# }, -# tags: { -# links: { -# self: "/posts/1/relationships/tags", -# related: "/posts/1/tags" -# } -# }, -# comments: { -# links: { -# self: "/posts/1/relationships/comments", -# related: "/posts/1/comments" -# } -# } -# } -# }, -# { -# id: "11", -# type: "posts", -# links: { -# self: "/posts/11" -# }, -# attributes: { -# title: "JR How To", -# body: "Use JR to write API apps", -# subject: "JR How To" -# }, -# relationships: { -# author: { -# links: { -# self: "/posts/11/relationships/author", -# related: "/posts/11/author" -# } -# }, -# section: { -# links: { -# self: "/posts/11/relationships/section", -# related: "/posts/11/section" -# } -# }, -# tags: { -# links: { -# self: "/posts/11/relationships/tags", -# related: "/posts/11/tags" -# } -# }, -# comments: { -# links: { -# self: "/posts/11/relationships/comments", -# related: "/posts/11/comments" -# } -# } -# } -# } -# ] -# }, -# serialized_resource -# ) -# end -# -# def test_config_keys_stable -# (serializer_a, serializer_b) = 2.times.map do -# JSONAPI::ResourceSerializer.new( -# PostResource, -# include: ['comments', 'author', 'comments.tags', 'author.posts'], -# fields: { -# people: [:email, :comments], -# posts: [:title], -# tags: [:name], -# comments: [:body, :post] -# } -# ) -# end -# -# assert_equal serializer_a.config_key(PostResource), serializer_b.config_key(PostResource) -# end -# -# def test_config_keys_vary_with_relevant_config_changes -# serializer_a = JSONAPI::ResourceSerializer.new( -# PostResource, -# fields: { posts: [:title] } -# ) -# serializer_b = JSONAPI::ResourceSerializer.new( -# PostResource, -# fields: { posts: [:title, :body] } -# ) -# -# assert_not_equal serializer_a.config_key(PostResource), serializer_b.config_key(PostResource) -# end -# -# def test_config_keys_stable_with_irrelevant_config_changes -# serializer_a = JSONAPI::ResourceSerializer.new( -# PostResource, -# fields: { posts: [:title, :body], people: [:name, :email] } -# ) -# serializer_b = JSONAPI::ResourceSerializer.new( -# PostResource, -# fields: { posts: [:title, :body], people: [:name] } -# ) -# -# assert_equal serializer_a.config_key(PostResource), serializer_b.config_key(PostResource) -# end -# -# def test_config_keys_stable_with_different_primary_resource -# serializer_a = JSONAPI::ResourceSerializer.new( -# PostResource, -# fields: { posts: [:title, :body], people: [:name, :email] } -# ) -# serializer_b = JSONAPI::ResourceSerializer.new( -# PersonResource, -# fields: { posts: [:title, :body], people: [:name, :email] } -# ) -# -# assert_equal serializer_a.config_key(PostResource), serializer_b.config_key(PostResource) -# end -# -# end +class SerializerTest < ActionDispatch::IntegrationTest + def setup + @post = Post.find(1) + @fred = Person.find_by(name: 'Fred Reader') + + @expense_entry = ExpenseEntry.find(1) + + JSONAPI.configuration.json_key_format = :camelized_key + JSONAPI.configuration.route_format = :camelized_route + JSONAPI.configuration.always_include_to_one_linkage_data = false + end + + def after_teardown + JSONAPI.configuration.always_include_to_one_linkage_data = false + JSONAPI.configuration.json_key_format = :underscored_key + end + + def test_serializer + post_1_identity = JSONAPI::ResourceIdentity.new(PostResource, 1) + id_tree = JSONAPI::PrimaryResourceIdTree.new + + directives = JSONAPI::IncludeDirectives.new(PersonResource, ['']).include_directives + + id_tree.add_resource_fragment(JSONAPI::ResourceFragment.new(post_1_identity), directives[:include_related]) + resource_set = JSONAPI::ResourceSet.new(id_tree) + + serializer = JSONAPI::ResourceSerializer.new( + PostResource, + base_url: 'http://example.com') + + resource_set.populate!(serializer, {}, {}) + serialized = serializer.serialize_resource_set_to_hash_single(resource_set) + + assert_hash_equals( + { + data: { + type: 'posts', + id: '1', + links: { + self: 'http://example.com/posts/1', + }, + attributes: { + title: 'New post', + body: 'A body!!!', + subject: 'New post' + }, + relationships: { + section: { + links: { + self: 'http://example.com/posts/1/relationships/section', + related: 'http://example.com/posts/1/section' + } + }, + author: { + links: { + self: 'http://example.com/posts/1/relationships/author', + related: 'http://example.com/posts/1/author' + } + }, + tags: { + links: { + self: 'http://example.com/posts/1/relationships/tags', + related: 'http://example.com/posts/1/tags' + } + }, + comments: { + links: { + self: 'http://example.com/posts/1/relationships/comments', + related: 'http://example.com/posts/1/comments' + } + } + } + } + }, + serialized + ) + end + + def test_serializer_nil_handling + id_tree = JSONAPI::PrimaryResourceIdTree.new + + resource_set = JSONAPI::ResourceSet.new(id_tree) + + serializer = JSONAPI::ResourceSerializer.new( + Api::V1::PostResource, + base_url: 'http://example.com') + + resource_set.populate!(serializer, {}, {}) + serialized = serializer.serialize_resource_set_to_hash_single(resource_set) + + assert_hash_equals( + { + data: nil + }, + serialized + ) + end + + def test_serializer_namespaced_resource + post_1_identity = JSONAPI::ResourceIdentity.new(Api::V1::PostResource, 1) + id_tree = JSONAPI::PrimaryResourceIdTree.new + + directives = JSONAPI::IncludeDirectives.new(PersonResource, ['']).include_directives + + id_tree.add_resource_fragment(JSONAPI::ResourceFragment.new(post_1_identity), directives[:include_related]) + resource_set = JSONAPI::ResourceSet.new(id_tree) + + serializer = JSONAPI::ResourceSerializer.new( + Api::V1::PostResource, + base_url: 'http://example.com') + + resource_set.populate!(serializer, {}, {}) + serialized = serializer.serialize_resource_set_to_hash_single(resource_set) + + assert_hash_equals( + { + data: { + type: 'posts', + id: '1', + links: { + self: 'http://example.com/api/v1/posts/1' + }, + attributes: { + title: 'New post', + body: 'A body!!!', + subject: 'New post' + }, + relationships: { + section: { + links:{ + self: 'http://example.com/api/v1/posts/1/relationships/section', + related: 'http://example.com/api/v1/posts/1/section' + } + }, + writer: { + links:{ + self: 'http://example.com/api/v1/posts/1/relationships/writer', + related: 'http://example.com/api/v1/posts/1/writer' + } + }, + comments: { + links:{ + self: 'http://example.com/api/v1/posts/1/relationships/comments', + related: 'http://example.com/api/v1/posts/1/comments' + } + } + } + } + }, + serialized + ) + end + + def test_serializer_limited_fieldset + post_1_identity = JSONAPI::ResourceIdentity.new(PostResource, 1) + id_tree = JSONAPI::PrimaryResourceIdTree.new + + directives = JSONAPI::IncludeDirectives.new(PersonResource, []).include_directives + + id_tree.add_resource_fragment(JSONAPI::ResourceFragment.new(post_1_identity), directives[:include_related]) + resource_set = JSONAPI::ResourceSet.new(id_tree) + + serializer = JSONAPI::ResourceSerializer.new( + PostResource, + fields: {posts: [:id, :title, :author]}) + + resource_set.populate!(serializer, {}, {}) + serialized = serializer.serialize_resource_set_to_hash_single(resource_set) + + assert_hash_equals( + { + data: { + type: 'posts', + id: '1', + links: { + self: '/posts/1' + }, + attributes: { + title: 'New post' + }, + relationships: { + author: { + links: { + self: '/posts/1/relationships/author', + related: '/posts/1/author' + } + } + } + } + }, + serialized + ) + end + + def test_serializer_include + post_1_identity = JSONAPI::ResourceIdentity.new(PostResource, 1) + person_1001_identity = JSONAPI::ResourceIdentity.new(PersonResource, 1001) + id_tree = JSONAPI::PrimaryResourceIdTree.new + + directives = JSONAPI::IncludeDirectives.new(PostResource, ['author']).include_directives + + id_tree.add_resource_fragment(JSONAPI::ResourceFragment.new(post_1_identity), directives[:include_related]) + + rel_id_tree = id_tree.fetch_related_resource_id_tree(PostResource._relationships[:author]) + + author_fragment = JSONAPI::ResourceFragment.new(person_1001_identity) + author_fragment.add_related_from(post_1_identity) + author_fragment.add_related_identity(:posts, post_1_identity) + rel_id_tree.add_resource_fragment(author_fragment, directives[:include_related][:author][:include_related]) + + resource_set = JSONAPI::ResourceSet.new(id_tree) + + serializer = JSONAPI::ResourceSerializer.new(PostResource) + + resource_set.populate!(serializer, {}, {}) + serialized = serializer.serialize_resource_set_to_hash_single(resource_set) + + assert_hash_equals( + { + data: { + type: 'posts', + id: '1', + links: { + self: '/posts/1' + }, + attributes: { + title: 'New post', + body: 'A body!!!', + subject: 'New post' + }, + relationships: { + section: { + links: { + self: '/posts/1/relationships/section', + related: '/posts/1/section' + } + }, + author: { + links: { + self: '/posts/1/relationships/author', + related: '/posts/1/author' + }, + data: { + type: 'people', + id: '1001' + } + }, + tags: { + links: { + self: '/posts/1/relationships/tags', + related: '/posts/1/tags' + } + }, + comments: { + links: { + self: '/posts/1/relationships/comments', + related: '/posts/1/comments' + } + } + } + }, + included: [ + { + type: 'people', + id: '1001', + attributes: { + name: 'Joe Author', + email: 'joe@xyz.fake', + dateJoined: '2013-08-07 16:25:00 -0400' + }, + links: { + self: '/people/1001' + }, + relationships: { + comments: { + links: { + self: '/people/1001/relationships/comments', + related: '/people/1001/comments' + } + }, + posts: { + links: { + self: '/people/1001/relationships/posts', + related: '/people/1001/posts' + }, + data: [ + { + type: 'posts', + id: '1' + } + ] + }, + preferences: { + links: { + self: '/people/1001/relationships/preferences', + related: '/people/1001/preferences' + } + }, + hairCut: { + links: { + self: '/people/1001/relationships/hairCut', + related: '/people/1001/hairCut' + } + }, + vehicles: { + links: { + self: '/people/1001/relationships/vehicles', + related: '/people/1001/vehicles' + } + }, + expenseEntries: { + links: { + self: '/people/1001/relationships/expenseEntries', + related: '/people/1001/expenseEntries' + } + } + } + } + ] + }, + serialized + ) + end + + def test_serializer_key_format + post_1_identity = JSONAPI::ResourceIdentity.new(PostResource, 1) + person_1001_identity = JSONAPI::ResourceIdentity.new(PersonResource, 1001) + id_tree = JSONAPI::PrimaryResourceIdTree.new + + directives = JSONAPI::IncludeDirectives.new(PostResource, ['author']).include_directives + + id_tree.add_resource_fragment(JSONAPI::ResourceFragment.new(post_1_identity), directives[:include_related]) + + rel_id_tree = id_tree.fetch_related_resource_id_tree(PostResource._relationships[:author]) + + author_fragment = JSONAPI::ResourceFragment.new(person_1001_identity) + author_fragment.add_related_from(post_1_identity) + author_fragment.add_related_identity(:posts, post_1_identity) + rel_id_tree.add_resource_fragment(author_fragment, directives[:include_related][:author][:include_related]) + + resource_set = JSONAPI::ResourceSet.new(id_tree) + + serializer = JSONAPI::ResourceSerializer.new(PostResource, + key_formatter: UnderscoredKeyFormatter,) + + resource_set.populate!(serializer, {}, {}) + serialized = serializer.serialize_resource_set_to_hash_single(resource_set) + + assert_hash_equals( + { + data: { + type: 'posts', + id: '1', + links: { + self: '/posts/1' + }, + attributes: { + title: 'New post', + body: 'A body!!!', + subject: 'New post' + }, + relationships: { + section: { + links: { + self: '/posts/1/relationships/section', + related: '/posts/1/section' + } + }, + author: { + links: { + self: '/posts/1/relationships/author', + related: '/posts/1/author' + }, + data: { + type: 'people', + id: '1001' + } + }, + tags: { + links: { + self: '/posts/1/relationships/tags', + related: '/posts/1/tags' + } + }, + comments: { + links: { + self: '/posts/1/relationships/comments', + related: '/posts/1/comments' + } + } + } + }, + included: [ + { + type: 'people', + id: '1001', + attributes: { + name: 'Joe Author', + email: 'joe@xyz.fake', + date_joined: '2013-08-07 16:25:00 -0400' + }, + links: { + self: '/people/1001' + }, + relationships: { + comments: { + links: { + self: '/people/1001/relationships/comments', + related: '/people/1001/comments' + } + }, + posts: { + links: { + self: '/people/1001/relationships/posts', + related: '/people/1001/posts' + }, + data: [ + { + type: 'posts', + id: '1' + } + ] + }, + preferences: { + links: { + self: '/people/1001/relationships/preferences', + related: '/people/1001/preferences' + } + }, + hair_cut: { + links: { + self: '/people/1001/relationships/hairCut', + related: '/people/1001/hairCut' + } + }, + vehicles: { + links: { + self: '/people/1001/relationships/vehicles', + related: '/people/1001/vehicles' + } + }, + expense_entries: { + links: { + self: '/people/1001/relationships/expenseEntries', + related: '/people/1001/expenseEntries' + } + } + } + } + ] + }, + serialized + ) + end + + def test_serializers_linkage_even_without_included_resource + + post_1_identity = JSONAPI::ResourceIdentity.new(PostResource, 1) + person_1001_identity = JSONAPI::ResourceIdentity.new(PersonResource, 1001) + + id_tree = JSONAPI::PrimaryResourceIdTree.new + + directives = JSONAPI::IncludeDirectives.new(PersonResource, []).include_directives + + fragment = JSONAPI::ResourceFragment.new(post_1_identity) + + fragment.add_related_identity(:author, person_1001_identity) + fragment.initialize_related(:section) + fragment.initialize_related(:tags) + + id_tree.add_resource_fragment(fragment, directives[:include_related]) + resource_set = JSONAPI::ResourceSet.new(id_tree) + + serializer = JSONAPI::ResourceSerializer.new(PostResource) + + resource_set.populate!(serializer, {}, {}) + serialized = serializer.serialize_resource_set_to_hash_single(resource_set) + + assert_hash_equals( + { + data: + { + id: '1', + type: 'posts', + links: { + self: '/posts/1' + }, + attributes: { + title: 'New post', + body: 'A body!!!', + subject: 'New post' + }, + relationships: { + author: { + links: { + self: '/posts/1/relationships/author', + related: '/posts/1/author' + }, + data: { + type: 'people', + id: '1001' + } + }, + section: { + links: { + self: '/posts/1/relationships/section', + related: '/posts/1/section' + }, + data: nil + }, + tags: { + links: { + self: '/posts/1/relationships/tags', + related: '/posts/1/tags' + }, + data: [] + }, + comments: { + links: { + self: '/posts/1/relationships/comments', + related: '/posts/1/comments' + } + } + } + } + }, + serialized + ) + end +end From 9c00a9ad0b52e42103650736daf0d904adb6417c Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Wed, 6 Feb 2019 16:28:14 -0500 Subject: [PATCH 116/237] Fix count on `records` that select a column on rails 4 --- lib/jsonapi/active_relation_resource_finder.rb | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/jsonapi/active_relation_resource_finder.rb b/lib/jsonapi/active_relation_resource_finder.rb index 0f09f916e..51e99e9b4 100644 --- a/lib/jsonapi/active_relation_resource_finder.rb +++ b/lib/jsonapi/active_relation_resource_finder.rb @@ -248,7 +248,9 @@ def count_related(source_rid, relationship_name, options = {}) joins = join_tree.joins related_alias = joins[''][:alias] - records.select(Arel.sql("#{concat_table_field(related_alias, related_klass._primary_key)}")).count(:all) + records = records.select(Arel.sql("#{concat_table_field(related_alias, related_klass._primary_key)}")) + + count_records(records) end def records(_options = {}) @@ -700,7 +702,11 @@ def apply_single_sort(records, field, direction, options) # Assumes ActiveRecord's counting. Override if you need a different counting method def count_records(records) - records.count(:all) + if Rails::VERSION::MAJOR >= 5 && ActiveRecord::VERSION::MINOR >= 1 + records.count(:all) + else + records.count + end end def filter_records(records, filters, options) From 48f1c0a74e3e84ba47e0917549999efa9d292fb3 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Thu, 7 Feb 2019 11:51:28 -0500 Subject: [PATCH 117/237] Add join_left ActiveRecordAdapter to ponyfill Rails 4 is missing the left join functionality added to rails 5. In addition rails 5.0.x and 5.1.x produced erroneous SQL when joining the same table as both inner and left (see https://github.com/rails/rails/issues/30504). JR is now generating joins like this for including linkages on related record queries and includes. This patch add the logic from the left_join gem as a new ActiveRecord Adapter for rails 4.2 through 5.1. --- Gemfile | 1 - .../active_relation_resource_finder.rb | 4 ++- .../join_left_active_record_adapter.rb | 27 +++++++++++++++++ test/test_helper.rb | 4 --- .../active_record_adapter_test.rb | 29 +++++++++++++++++++ 5 files changed, 59 insertions(+), 6 deletions(-) create mode 100644 lib/jsonapi/active_relation_resource_finder/adapters/join_left_active_record_adapter.rb create mode 100644 test/unit/active_relation_resource_finder/active_record_adapter_test.rb diff --git a/Gemfile b/Gemfile index efc046d97..0c783b266 100644 --- a/Gemfile +++ b/Gemfile @@ -19,6 +19,5 @@ when 'master' when 'default' gem 'railties', '>= 5.0' else - gem 'left_join' if version.start_with?('4.2') gem 'railties', "~> #{version}" end diff --git a/lib/jsonapi/active_relation_resource_finder.rb b/lib/jsonapi/active_relation_resource_finder.rb index 51e99e9b4..ee443e254 100644 --- a/lib/jsonapi/active_relation_resource_finder.rb +++ b/lib/jsonapi/active_relation_resource_finder.rb @@ -1,3 +1,5 @@ +require 'jsonapi/active_relation_resource_finder/adapters/join_left_active_record_adapter' + module JSONAPI module ActiveRelationResourceFinder def self.included(base) @@ -662,7 +664,7 @@ def apply_joins(records, join_tree, _options) records, join_alias = get_join_alias(records) { |records| records.joins(join[:relation_join_hash]) } join[:alias] = join_alias when :left - records, join_alias = get_join_alias(records) { |records| records.left_joins(join[:relation_join_hash]) } + records, join_alias = get_join_alias(records) { |records| records.joins_left(join[:relation_join_hash]) } join[:alias] = join_alias end end diff --git a/lib/jsonapi/active_relation_resource_finder/adapters/join_left_active_record_adapter.rb b/lib/jsonapi/active_relation_resource_finder/adapters/join_left_active_record_adapter.rb new file mode 100644 index 000000000..500dffd1e --- /dev/null +++ b/lib/jsonapi/active_relation_resource_finder/adapters/join_left_active_record_adapter.rb @@ -0,0 +1,27 @@ +module JSONAPI + module ActiveRelationResourceFinder + module Adapters + module JoinLeftActiveRecordAdapter + + # Extends left_joins functionality to rails 4, and uses the same logic for rails 5.0.x and 5.1.x + # The default left_joins logic of rails 5.2.x is used. This results in and extra join in some cases. For + # example Post.joins(:comments).joins_left(comments: :author) will join the comments table twice, + # once inner and once left in 5.2, but only as inner in earlier versions. + def joins_left(*columns) + if Rails::VERSION::MAJOR >= 5 && ActiveRecord::VERSION::MINOR >= 2 + left_joins(columns) + else + join_dependency = ActiveRecord::Associations::JoinDependency.new(self, columns, []) + joins(join_dependency) + end + end + + alias_method :join_left, :joins_left + end + + if defined?(ActiveRecord) + ActiveRecord::Base.extend JoinLeftActiveRecordAdapter + end + end + end +end \ No newline at end of file diff --git a/test/test_helper.rb b/test/test_helper.rb index db9f0679f..684316aab 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -92,10 +92,6 @@ class ActionController::TestCase end end -if Rails::VERSION::MAJOR < 5 - require 'left_join' -end - # Tests are now using the rails 5 format for the http methods. So for rails 4 we will simply convert them back # in a standard way. if Rails::VERSION::MAJOR < 5 diff --git a/test/unit/active_relation_resource_finder/active_record_adapter_test.rb b/test/unit/active_relation_resource_finder/active_record_adapter_test.rb new file mode 100644 index 000000000..0456b9dfb --- /dev/null +++ b/test/unit/active_relation_resource_finder/active_record_adapter_test.rb @@ -0,0 +1,29 @@ +require File.expand_path('../../../test_helper', __FILE__) +require 'jsonapi-resources' + +class ActiveRecordAdapterTest < ActiveSupport::TestCase + + def test_joins_left + sql = Post.joins_left(:comments).to_sql + assert_equal 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "comments" ON "comments"."post_id" = "posts"."id"', + sql + end + + def test_joins_left_through_inner + sql = Post.joins(:comments).joins_left(comments: :author).to_sql + + # Note this joins_left reverts to left_joins on rails 5.2 and later + # This behaves slightly differently in that the base join table is joined twice using left the second time (in this test). + # This should produce the same result set, but will be slightly less efficient on the database + if Rails::VERSION::MAJOR >= 5 && ActiveRecord::VERSION::MINOR >= 2 + assert_equal 'SELECT "posts".* FROM "posts" INNER JOIN "comments" ON "comments"."post_id" = "posts"."id" '\ + 'LEFT OUTER JOIN "comments" "comments_posts" ON "comments_posts"."post_id" = "posts"."id" '\ + 'LEFT OUTER JOIN "people" ON "people"."id" = "comments_posts"."author_id"', + sql + else + assert_equal 'SELECT "posts".* FROM "posts" INNER JOIN "comments" ON "comments"."post_id" = "posts"."id" ' \ + 'LEFT OUTER JOIN "people" ON "people"."id" = "comments"."author_id"', + sql + end + end +end From dd9ab5e83b08bc6ca78c344909e50bfad4778b28 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Thu, 7 Feb 2019 13:18:47 -0500 Subject: [PATCH 118/237] Do not use pagination or sorting in `count` --- lib/jsonapi/active_relation_resource_finder.rb | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/lib/jsonapi/active_relation_resource_finder.rb b/lib/jsonapi/active_relation_resource_finder.rb index ee443e254..7fbd2bea3 100644 --- a/lib/jsonapi/active_relation_resource_finder.rb +++ b/lib/jsonapi/active_relation_resource_finder.rb @@ -42,19 +42,13 @@ def find(filters, options = {}) # # @return [Integer] the count def count(filters, options = {}) - sort_criteria = options.fetch(:sort_criteria) { [] } - join_tree = JoinTree.new(resource_klass: self, options: options, - filters: filters, - sort_criteria: sort_criteria) - - paginator = options[:paginator] + filters: filters) records = find_records(records: records(options), filters: filters, join_tree: join_tree, - paginator: paginator, options: options) count_records(records) From 6243176d67bb387ad5aed45a5bbeaab674692baa Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Fri, 8 Feb 2019 08:37:04 -0500 Subject: [PATCH 119/237] Rename PathPart to PathSegment raises an error if segment specifies a type that the relationship does not support the resource type --- lib/jsonapi-resources.rb | 2 +- .../active_relation_resource_finder.rb | 6 +- .../join_tree.rb | 26 +++--- lib/jsonapi/include_directives.rb | 4 +- lib/jsonapi/path.rb | 34 ++++--- lib/jsonapi/{path_part.rb => path_segment.rb} | 20 ++-- test/unit/paths/path_test.rb | 91 ++++++++++++------- 7 files changed, 102 insertions(+), 81 deletions(-) rename lib/jsonapi/{path_part.rb => path_segment.rb} (65%) diff --git a/lib/jsonapi-resources.rb b/lib/jsonapi-resources.rb index 663a39324..e8bdc068d 100644 --- a/lib/jsonapi-resources.rb +++ b/lib/jsonapi-resources.rb @@ -31,4 +31,4 @@ require 'jsonapi/resource_id_tree' require 'jsonapi/resource_set' require 'jsonapi/path' -require 'jsonapi/path_part' +require 'jsonapi/path_segment' diff --git a/lib/jsonapi/active_relation_resource_finder.rb b/lib/jsonapi/active_relation_resource_finder.rb index 7fbd2bea3..5e60eaf08 100644 --- a/lib/jsonapi/active_relation_resource_finder.rb +++ b/lib/jsonapi/active_relation_resource_finder.rb @@ -508,7 +508,7 @@ def find_related_polymorphic_fragments(source_rids, relationship, options, conne path_string: "#{relationship.name}#{linkage_relationship_path}", ensure_default_field: false) - linkage_relationship = path.parts[-1].relationship + linkage_relationship = path.segments[-1].relationship if linkage_relationship.polymorphic? && linkage_relationship.belongs_to? linkage_relationship.resource_types.each do |resource_type| @@ -761,8 +761,8 @@ def apply_filters(records, filters, options = {}) def get_aliased_field(path_with_field, joins) path = JSONAPI::Path.new(resource_klass: self, path_string: path_with_field) - relationship = path.parts[-2] - field = path.parts[-1] + relationship = path.segments[-2] + field = path.segments[-1] relationship_path = path.relationship_path_string if relationship diff --git a/lib/jsonapi/active_relation_resource_finder/join_tree.rb b/lib/jsonapi/active_relation_resource_finder/join_tree.rb index 5867772b1..271059035 100644 --- a/lib/jsonapi/active_relation_resource_finder/join_tree.rb +++ b/lib/jsonapi/active_relation_resource_finder/join_tree.rb @@ -40,7 +40,7 @@ def initialize(resource_klass:, def add_join(path, default_type = :inner, default_polymorphic_join_type = :left) if source_relationship if source_relationship.polymorphic? - # Polymorphic paths will come it with the resource_type as the first part (for example `#documents.comments`) + # Polymorphic paths will come it with the resource_type as the first segment (for example `#documents.comments`) # We just need to prepend the relationship portion the sourced_path = "#{source_relationship.name}#{path}" else @@ -63,7 +63,7 @@ def add_join(path, default_type = :inner, default_polymorphic_join_type = :left) } end - def process_path_to_tree(path_parts, resource_klass, default_join_type, default_polymorphic_join_type) + def process_path_to_tree(path_segments, resource_klass, default_join_type, default_polymorphic_join_type) node = { resource_klasses: { resource_klass => { @@ -72,20 +72,20 @@ def process_path_to_tree(path_parts, resource_klass, default_join_type, default_ } } - part = path_parts.shift + segment = path_segments.shift - if part.is_a?(PathPart::Relationship) - node[:resource_klasses][resource_klass][:relationships][part.relationship] ||= {} + if segment.is_a?(PathSegment::Relationship) + node[:resource_klasses][resource_klass][:relationships][segment.relationship] ||= {} # join polymorphic as left joins - node[:resource_klasses][resource_klass][:relationships][part.relationship][:join_type] ||= - part.relationship.polymorphic? ? default_polymorphic_join_type : default_join_type + node[:resource_klasses][resource_klass][:relationships][segment.relationship][:join_type] ||= + segment.relationship.polymorphic? ? default_polymorphic_join_type : default_join_type - part.relationship.resource_types.each do |related_resource_type| + segment.relationship.resource_types.each do |related_resource_type| related_resource_klass = resource_klass.resource_klass_for(related_resource_type) - if !part.path_specified_resource_klass? || related_resource_klass == part.resource_klass - related_resource_tree = process_path_to_tree(path_parts.dup, related_resource_klass, default_join_type, default_polymorphic_join_type) - node[:resource_klasses][resource_klass][:relationships][part.relationship].deep_merge!(related_resource_tree) + if !segment.path_specified_resource_klass? || related_resource_klass == segment.resource_klass + related_resource_tree = process_path_to_tree(path_segments.dup, related_resource_klass, default_join_type, default_polymorphic_join_type) + node[:resource_klasses][resource_klass][:relationships][segment.relationship].deep_merge!(related_resource_tree) end end end @@ -94,8 +94,8 @@ def process_path_to_tree(path_parts, resource_klass, default_join_type, default_ def parse_path_to_tree(path_string, resource_klass, default_join_type = :inner, default_polymorphic_join_type = :left) path = JSONAPI::Path.new(resource_klass: resource_klass, path_string: path_string) - field = path.parts[-1] - return process_path_to_tree(path.parts, resource_klass, default_join_type, default_polymorphic_join_type), field + field = path.segments[-1] + return process_path_to_tree(path.segments, resource_klass, default_join_type, default_polymorphic_join_type), field end def add_source_relationship(source_relationship) diff --git a/lib/jsonapi/include_directives.rb b/lib/jsonapi/include_directives.rb index 4f254cbe4..d811c1143 100644 --- a/lib/jsonapi/include_directives.rb +++ b/lib/jsonapi/include_directives.rb @@ -41,8 +41,8 @@ def parse_include(include) current = @include_directives_hash - path.parts.each do |part| - relationship_name = part.relationship.name.to_sym + path.segments.each do |segment| + relationship_name = segment.relationship.name.to_sym current[:include_related][relationship_name] ||= { include: true, include_related: {} } current = current[:include_related][relationship_name] diff --git a/lib/jsonapi/path.rb b/lib/jsonapi/path.rb index f79b4277f..31142ea48 100644 --- a/lib/jsonapi/path.rb +++ b/lib/jsonapi/path.rb @@ -1,6 +1,6 @@ module JSONAPI class Path - attr_reader :parts, :resource_klass + attr_reader :segments, :resource_klass def initialize(resource_klass:, path_string:, ensure_default_field: true, @@ -8,34 +8,32 @@ def initialize(resource_klass:, @resource_klass = resource_klass current_resource_klass = resource_klass - @parts = path_string.to_s.split('.').collect do |part_string| - part = PathPart.parse(source_resource_klass: current_resource_klass, - part_string: part_string, - parse_fields: parse_fields) + @segments = path_string.to_s.split('.').collect do |segment_string| + segment = PathSegment.parse(source_resource_klass: current_resource_klass, + segment_string: segment_string, + parse_fields: parse_fields) - current_resource_klass = part.resource_klass - part + current_resource_klass = segment.resource_klass + segment end - if ensure_default_field && parse_fields && @parts.last.is_a?(PathPart::Relationship) - last = @parts.last - @parts << PathPart::Field.new(resource_klass: last.resource_klass, - field_name: last.resource_klass._primary_key) + if ensure_default_field && parse_fields && @segments.last.is_a?(PathSegment::Relationship) + last = @segments.last + @segments << PathSegment::Field.new(resource_klass: last.resource_klass, + field_name: last.resource_klass._primary_key) end end - def relationship_parts + def relationship_segments relationships = [] - @parts.each do |part| - relationships << part if part.is_a?(PathPart::Relationship) + @segments.each do |segment| + relationships << segment if segment.is_a?(PathSegment::Relationship) end relationships end def relationship_path_string - relationship_parts.collect do |part| - part.to_s - end.join('.') + relationship_segments.collect(&:to_s).join('.') end end -end \ No newline at end of file +end diff --git a/lib/jsonapi/path_part.rb b/lib/jsonapi/path_segment.rb similarity index 65% rename from lib/jsonapi/path_part.rb rename to lib/jsonapi/path_segment.rb index 88f10c47d..bd34ff13d 100644 --- a/lib/jsonapi/path_part.rb +++ b/lib/jsonapi/path_segment.rb @@ -1,20 +1,22 @@ module JSONAPI - class PathPart - def self.parse(source_resource_klass:, part_string:, parse_fields: true) - first_part, last_part = part_string.split('#', 2) + class PathSegment + def self.parse(source_resource_klass:, segment_string:, parse_fields: true) + first_part, last_part = segment_string.split('#', 2) relationship = source_resource_klass._relationship(first_part) if relationship if last_part + unless relationship.resource_types.include?(last_part) + raise JSONAPI::Exceptions::InvalidRelationship.new(source_resource_klass._type, segment_string) + end resource_klass = source_resource_klass.resource_klass_for(last_part) - # ToDo: compare to relationship and raise error if not a match? end - return PathPart::Relationship.new(relationship: relationship, resource_klass: resource_klass) + return PathSegment::Relationship.new(relationship: relationship, resource_klass: resource_klass) else if last_part.blank? && parse_fields - return PathPart::Field.new(resource_klass: source_resource_klass, field_name: first_part) + return PathSegment::Field.new(resource_klass: source_resource_klass, field_name: first_part) else - raise JSONAPI::Exceptions::InvalidRelationship.new(source_resource_klass._type, part_string) + raise JSONAPI::Exceptions::InvalidRelationship.new(source_resource_klass._type, segment_string) end end end @@ -44,10 +46,6 @@ class Field attr_reader :resource_klass, :field_name def initialize(resource_klass:, field_name:) - # ToDo: Should we enforce the resource has the field? - # unless resource_klass._has_attribute?(field_name) - # raise JSONAPI::Exceptions::InvalidField.new(resource_klass._type, field_name) - # end @resource_klass = resource_klass @field_name = field_name end diff --git a/test/unit/paths/path_test.rb b/test/unit/paths/path_test.rb index 171f0fe30..3e59a8fd8 100644 --- a/test/unit/paths/path_test.rb +++ b/test/unit/paths/path_test.rb @@ -6,40 +6,40 @@ class PathTest < ActiveSupport::TestCase def test_one_relationship path = JSONAPI::Path.new(resource_klass: Api::V1::PostResource, path_string: 'comments') - assert path.parts.is_a?(Array) - assert path.parts[0].is_a?(JSONAPI::PathPart::Relationship), "should be a PathPart::Relationship" - assert_equal Api::V1::PostResource._relationship(:comments), path.parts[0].relationship + assert path.segments.is_a?(Array) + assert path.segments[0].is_a?(JSONAPI::PathSegment::Relationship), "should be a PathSegment::Relationship" + assert_equal Api::V1::PostResource._relationship(:comments), path.segments[0].relationship end def test_one_field path = JSONAPI::Path.new(resource_klass: Api::V1::PostResource, path_string: 'title') - assert path.parts.is_a?(Array) - assert path.parts[0].is_a?(JSONAPI::PathPart::Field), "should be a PathPart::Field" - assert_equal 'title', path.parts[0].field_name + assert path.segments.is_a?(Array) + assert path.segments[0].is_a?(JSONAPI::PathSegment::Field), "should be a PathSegment::Field" + assert_equal 'title', path.segments[0].field_name end def test_two_relationships path = JSONAPI::Path.new(resource_klass: Api::V1::PostResource, path_string: 'comments.author') - assert path.parts.is_a?(Array) - assert path.parts[0].is_a?(JSONAPI::PathPart::Relationship), "should be a PathPart::Relationship" - assert path.parts[1].is_a?(JSONAPI::PathPart::Relationship), "should be a PathPart::Relationship" - assert_equal Api::V1::PostResource._relationship(:comments), path.parts[0].relationship - assert_equal Api::V1::CommentResource._relationship(:author), path.parts[1].relationship + assert path.segments.is_a?(Array) + assert path.segments[0].is_a?(JSONAPI::PathSegment::Relationship), "should be a PathSegment::Relationship" + assert path.segments[1].is_a?(JSONAPI::PathSegment::Relationship), "should be a PathSegment::Relationship" + assert_equal Api::V1::PostResource._relationship(:comments), path.segments[0].relationship + assert_equal Api::V1::CommentResource._relationship(:author), path.segments[1].relationship end def test_two_relationships_and_field path = JSONAPI::Path.new(resource_klass: Api::V1::PostResource, path_string: 'comments.author.name') - assert path.parts.is_a?(Array) - assert path.parts[0].is_a?(JSONAPI::PathPart::Relationship), "should be a PathPart::Relationship" - assert path.parts[1].is_a?(JSONAPI::PathPart::Relationship), "should be a PathPart::Relationship" - assert path.parts[2].is_a?(JSONAPI::PathPart::Field), "should be a PathPart::Field" + assert path.segments.is_a?(Array) + assert path.segments[0].is_a?(JSONAPI::PathSegment::Relationship), "should be a PathSegment::Relationship" + assert path.segments[1].is_a?(JSONAPI::PathSegment::Relationship), "should be a PathSegment::Relationship" + assert path.segments[2].is_a?(JSONAPI::PathSegment::Field), "should be a PathSegment::Field" - assert_equal Api::V1::PostResource._relationship(:comments), path.parts[0].relationship - assert_equal Api::V1::CommentResource._relationship(:author), path.parts[1].relationship - assert_equal 'name', path.parts[2].field_name + assert_equal Api::V1::PostResource._relationship(:comments), path.segments[0].relationship + assert_equal Api::V1::CommentResource._relationship(:author), path.segments[1].relationship + assert_equal 'name', path.segments[2].field_name end def test_two_relationships_and_parse_fields_false_raises_with_field @@ -54,32 +54,57 @@ def test_two_relationships_and_parse_fields_false_raises_with_field def test_ensure_default_field_false path = JSONAPI::Path.new(resource_klass: Api::V1::PostResource, path_string: 'comments.author', ensure_default_field: false) - assert path.parts.is_a?(Array) - assert_equal 2, path.parts.length - assert path.parts[0].is_a?(JSONAPI::PathPart::Relationship), "should be a PathPart::Relationship" - assert path.parts[1].is_a?(JSONAPI::PathPart::Relationship), "should be a PathPart::Relationship" + assert path.segments.is_a?(Array) + assert_equal 2, path.segments.length + assert path.segments[0].is_a?(JSONAPI::PathSegment::Relationship), "should be a PathSegment::Relationship" + assert path.segments[1].is_a?(JSONAPI::PathSegment::Relationship), "should be a PathSegment::Relationship" - assert_equal Api::V1::PostResource._relationship(:comments), path.parts[0].relationship - assert_equal Api::V1::CommentResource._relationship(:author), path.parts[1].relationship + assert_equal Api::V1::PostResource._relationship(:comments), path.segments[0].relationship + assert_equal Api::V1::CommentResource._relationship(:author), path.segments[1].relationship end def test_ensure_default_field_true path = JSONAPI::Path.new(resource_klass: Api::V1::PostResource, path_string: 'comments.author', ensure_default_field: true) - assert path.parts.is_a?(Array) - assert_equal 3, path.parts.length - assert path.parts[0].is_a?(JSONAPI::PathPart::Relationship), "should be a PathPart::Relationship" - assert path.parts[1].is_a?(JSONAPI::PathPart::Relationship), "should be a PathPart::Relationship" + assert path.segments.is_a?(Array) + assert_equal 3, path.segments.length + assert path.segments[0].is_a?(JSONAPI::PathSegment::Relationship), "should be a PathSegment::Relationship" + assert path.segments[1].is_a?(JSONAPI::PathSegment::Relationship), "should be a PathSegment::Relationship" - assert_equal Api::V1::PostResource._relationship(:comments), path.parts[0].relationship - assert_equal Api::V1::CommentResource._relationship(:author), path.parts[1].relationship + assert_equal Api::V1::PostResource._relationship(:comments), path.segments[0].relationship + assert_equal Api::V1::CommentResource._relationship(:author), path.segments[1].relationship end def test_polymorphic_path path = JSONAPI::Path.new(resource_klass: PictureResource, path_string: :imageable) - assert path.parts.is_a?(Array) - assert path.parts[0].is_a?(JSONAPI::PathPart::Relationship), "should be a PathPart::Relationship" - assert_equal PictureResource._relationship(:imageable), path.parts[0].relationship + assert path.segments.is_a?(Array) + assert path.segments[0].is_a?(JSONAPI::PathSegment::Relationship), "should be a PathSegment::Relationship" + assert_equal PictureResource._relationship(:imageable), path.segments[0].relationship + refute path.segments[0].path_specified_resource_klass?, "should note that the resource klass was not specified" + end + + def test_polymorphic_path_with_resource_type + path = JSONAPI::Path.new(resource_klass: PictureResource, path_string: 'imageable#documents') + + assert path.segments.is_a?(Array) + assert path.segments[0].is_a?(JSONAPI::PathSegment::Relationship), "should be a PathSegment::Relationship" + assert_equal PictureResource._relationship(:imageable), path.segments[0].relationship + assert_equal DocumentResource, path.segments[0].resource_klass, "should return the specified resource klass" + assert path.segments[0].path_specified_resource_klass?, "should note that the resource klass was specified" + end + + def test_polymorphic_path_with_wrong_resource_type + assert_raises JSONAPI::Exceptions::InvalidRelationship do + JSONAPI::Path.new(resource_klass: PictureResource, path_string: 'imageable#docs') + end + end + + def test_raises_when_field_is_specified_if_not_expected + assert JSONAPI::Path.new(resource_klass: PictureResource, path_string: 'comments.author.name', parse_fields: true) + + assert_raises JSONAPI::Exceptions::InvalidRelationship do + JSONAPI::Path.new(resource_klass: PictureResource, path_string: 'comments.author.name', parse_fields: false) + end end end From 9c0391d5e9486bf57d4ee0383fb6459571a9605b Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Fri, 8 Feb 2019 09:35:02 -0500 Subject: [PATCH 120/237] Clarify logic for processing polymorphic path segments with resource type --- lib/jsonapi/active_relation_resource_finder/join_tree.rb | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/jsonapi/active_relation_resource_finder/join_tree.rb b/lib/jsonapi/active_relation_resource_finder/join_tree.rb index 271059035..eaf3b5025 100644 --- a/lib/jsonapi/active_relation_resource_finder/join_tree.rb +++ b/lib/jsonapi/active_relation_resource_finder/join_tree.rb @@ -83,7 +83,12 @@ def process_path_to_tree(path_segments, resource_klass, default_join_type, defau segment.relationship.resource_types.each do |related_resource_type| related_resource_klass = resource_klass.resource_klass_for(related_resource_type) - if !segment.path_specified_resource_klass? || related_resource_klass == segment.resource_klass + + # If the resource type was specified in the path segment we want to only process the next segments for + # that resource type, otherwise process for all + process_all_types = !segment.path_specified_resource_klass? + + if process_all_types || related_resource_klass == segment.resource_klass related_resource_tree = process_path_to_tree(path_segments.dup, related_resource_klass, default_join_type, default_polymorphic_join_type) node[:resource_klasses][resource_klass][:relationships][segment.relationship].deep_merge!(related_resource_tree) end From 14b1a1ea380be5a070f503888eb878d56f5e1e15 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Fri, 8 Feb 2019 09:48:19 -0500 Subject: [PATCH 121/237] Clarify model check --- lib/jsonapi/resource.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/jsonapi/resource.rb b/lib/jsonapi/resource.rb index 6906db0c3..dccc51241 100644 --- a/lib/jsonapi/resource.rb +++ b/lib/jsonapi/resource.rb @@ -916,7 +916,7 @@ def _polymorphic_types @poly_hash ||= {}.tap do |hash| ObjectSpace.each_object do |klass| next unless Module === klass - if ActiveRecord::Base > klass + if klass < ActiveRecord::Base klass.reflect_on_all_associations(:has_many).select{|r| r.options[:as] }.each do |reflection| (hash[reflection.options[:as]] ||= []) << klass.name.downcase end From 991855623571592fddd48338e9196c02f36e5270 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Fri, 8 Feb 2019 15:20:57 -0500 Subject: [PATCH 122/237] Test caching for `show_related_resource` requests --- test/test_helper.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/test_helper.rb b/test/test_helper.rb index 684316aab..dc5b5fdf3 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -592,8 +592,7 @@ def assert_cacheable_get(action, *args) end if mode == :all - # TODO Should also be caching :show_related_resource (non-plural) action - if [:index, :show, :show_related_resources].include?(action) + if [:index, :show, :show_related_resource, :show_related_resources].include?(action) if ar_resource_klass && response.status == 200 && json_response["data"].try(:size).try(:>, 0) assert_operator( cache_activity[:warmup][:total][:misses], From d82073c9d74ffc7a673741b499649f4ff0acd409 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Fri, 8 Feb 2019 16:20:51 -0500 Subject: [PATCH 123/237] Simplify and test `relationship_segments` --- lib/jsonapi/path.rb | 6 +----- test/unit/paths/path_test.rb | 2 ++ 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/lib/jsonapi/path.rb b/lib/jsonapi/path.rb index 31142ea48..2b11326f1 100644 --- a/lib/jsonapi/path.rb +++ b/lib/jsonapi/path.rb @@ -25,11 +25,7 @@ def initialize(resource_klass:, end def relationship_segments - relationships = [] - @segments.each do |segment| - relationships << segment if segment.is_a?(PathSegment::Relationship) - end - relationships + @segments.select {|p| p.is_a?(PathSegment::Relationship)} end def relationship_path_string diff --git a/test/unit/paths/path_test.rb b/test/unit/paths/path_test.rb index 3e59a8fd8..9ef438d40 100644 --- a/test/unit/paths/path_test.rb +++ b/test/unit/paths/path_test.rb @@ -40,6 +40,8 @@ def test_two_relationships_and_field assert_equal Api::V1::PostResource._relationship(:comments), path.segments[0].relationship assert_equal Api::V1::CommentResource._relationship(:author), path.segments[1].relationship assert_equal 'name', path.segments[2].field_name + + assert_equal 2, path.relationship_segments.length end def test_two_relationships_and_parse_fields_false_raises_with_field From d2db72b370b9150e3363e3cd406294e9cacfcc2f Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Mon, 11 Feb 2019 15:30:15 -0500 Subject: [PATCH 124/237] Bump jsonapi-resources to 0.10.0.beta2 --- lib/jsonapi/resources/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/jsonapi/resources/version.rb b/lib/jsonapi/resources/version.rb index 635b788c3..b9fe18940 100644 --- a/lib/jsonapi/resources/version.rb +++ b/lib/jsonapi/resources/version.rb @@ -1,5 +1,5 @@ module JSONAPI module Resources - VERSION = '0.10.0.beta1' + VERSION = '0.10.0.beta2' end end From fbb6d360accfb0f2df70239b6df6688e798a1788 Mon Sep 17 00:00:00 2001 From: Joe Gaudet Date: Tue, 12 Feb 2019 17:57:11 -0800 Subject: [PATCH 125/237] Improve caching performance This commit improves the performance of the fragment cache by batching all reads and writes to single caching operations. --- .gitignore | 2 + lib/jsonapi/cached_response_fragment.rb | 115 +++++++++------- lib/jsonapi/resource_set.rb | 166 +++++++++++++++++------- 3 files changed, 186 insertions(+), 97 deletions(-) diff --git a/.gitignore b/.gitignore index 6cc125d63..800c71c6a 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,5 @@ coverage test/log test_db test_db-journal +.idea +*.iml diff --git a/lib/jsonapi/cached_response_fragment.rb b/lib/jsonapi/cached_response_fragment.rb index 7c2e84f5a..4f2abccdb 100644 --- a/lib/jsonapi/cached_response_fragment.rb +++ b/lib/jsonapi/cached_response_fragment.rb @@ -1,22 +1,42 @@ module JSONAPI class CachedResponseFragment - def self.fetch_cached_fragments(resource_klass, serializer_config_key, cache_ids, context) - context_json = resource_klass.attribute_caching_context(context).to_json - context_b64 = JSONAPI.configuration.resource_cache_digest_function.call(context_json) - context_key = "ATTR-CTX-#{context_b64.gsub("/", "_")}" - - results = self.lookup(resource_klass, serializer_config_key, context, context_key, cache_ids) - - if JSONAPI.configuration.resource_cache_usage_report_function - miss_ids = results.select{|_k,v| v.nil? }.keys - JSONAPI.configuration.resource_cache_usage_report_function.call( - resource_klass.name, - cache_ids.size - miss_ids.size, - miss_ids.size - ) + + Lookup = Struct.new(:resource_klass, :serializer_config_key, :context, :context_key, :cache_ids) do + + def type + resource_klass._type end - results + def keys + cache_ids.map do |(id, cache_key)| + [type, id, cache_key, serializer_config_key, context_key] + end + end + end + + Write = Struct.new(:resource_klass, :resource, :serializer, :serializer_config_key, :context, :context_key, :relationship_data) do + def to_key_value + + (id, cache_key) = resource.cache_id + + json = serializer.object_hash(resource, relationship_data) + + cr = CachedResponseFragment.new( + resource_klass, + id, + json['type'], + context, + resource.fetchable_fields, + json['relationships'], + json['links'], + json['attributes'], + json['meta'] + ) + + key = [resource_klass._type, id, cache_key, serializer_config_key, context_key] + + [key, cr] + end end attr_reader :resource_klass, :id, :type, :context, :fetchable_fields, :relationships, @@ -50,26 +70,46 @@ def to_cache_value } end - private + # @param [Lookup[]] lookups + # @return [Hash, Hash>] + def self.lookup(lookups, context) + type_to_klass = lookups.map {|l| [l.type, l.resource_klass]}.to_h - def self.lookup(resource_klass, serializer_config_key, context, context_key, cache_ids) - type = resource_klass._type + keys = lookups.map(&:keys).flatten(1) - keys = cache_ids.map do |(id, cache_key)| - [type, id, cache_key, serializer_config_key, context_key] - end + hits = JSONAPI.configuration.resource_cache.read_multi(*keys).reject {|_, v| v.nil?} + + return keys.inject({}) do |hash, key| + (type, id, _, _) = key + resource_klass = type_to_klass[type] + hash[resource_klass] ||= {} - hits = JSONAPI.configuration.resource_cache.read_multi(*keys).reject{|_,v| v.nil? } - return keys.each_with_object({}) do |key, hash| - (_, id, _, _) = key if hits.has_key?(key) - hash[id] = self.from_cache_value(resource_klass, context, hits[key]) + hash[resource_klass][id] = self.from_cache_value(resource_klass, context, hits[key]) else - hash[id] = nil + hash[resource_klass][id] = nil end + + hash end end + # @param [Write[]] lookups + def self.write(writes) + key_values = writes.map(&:to_key_value) + + to_write = key_values.map {|(k, v)| [k, v.to_cache_value]}.to_h + + if JSONAPI.configuration.resource_cache.respond_to? :write_multi + JSONAPI.configuration.resource_cache.write_multi(to_write) + else + to_write.each do |key, value| + JSONAPI.configuration.resource_cache.write(key, value) + end + end + + end + def self.from_cache_value(resource_klass, context, h) new( resource_klass, @@ -83,28 +123,5 @@ def self.from_cache_value(resource_klass, context, h) h.fetch(:meta, nil) ) end - - def self.write(resource_klass, resource, serializer, serializer_config_key, context, context_key, relationship_data ) - (id, cache_key) = resource.cache_id - - json = serializer.object_hash(resource, relationship_data) - - cr = self.new( - resource_klass, - id, - json['type'], - context, - resource.fetchable_fields, - json['relationships'], - json['links'], - json['attributes'], - json['meta'] - ) - - key = [resource_klass._type, id, cache_key, serializer_config_key, context_key] - JSONAPI.configuration.resource_cache.write(key, cr.to_cache_value) - return [id, cr] - end - end end diff --git a/lib/jsonapi/resource_set.rb b/lib/jsonapi/resource_set.rb index 56972d73d..83ba5c526 100644 --- a/lib/jsonapi/resource_set.rb +++ b/lib/jsonapi/resource_set.rb @@ -5,14 +5,25 @@ class ResourceSet attr_reader :resource_klasses, :populated - def initialize(resource_id_tree) + def initialize(resource_id_tree = nil) @populated = false - @resource_klasses = flatten_resource_id_tree(resource_id_tree) + @resource_klasses = resource_id_tree.nil? ? {} : flatten_resource_id_tree(resource_id_tree) end def populate!(serializer, context, find_options) + # For each resource klass we want to generate the caching key + + # Hash for collecting types and ids + # @type [Hash, Id[]]] + missed_resource_ids = {} + + # Array for collecting CachedResponseFragment::Lookups + # @type [Lookup[]] + lookups = [] + + + # Step One collect all of the lookups for the cache, or keys that don't require cache access @resource_klasses.each_key do |resource_klass| - missed_ids = [] serializer_config_key = serializer.config_key(resource_klass).gsub("/", "_") context_json = resource_klass.attribute_caching_context(context).to_json @@ -20,65 +31,124 @@ def populate!(serializer, context, find_options) context_key = "ATTR-CTX-#{context_b64.gsub("/", "_")}" if resource_klass.caching? - cache_ids = [] - - @resource_klasses[resource_klass].each_pair do |k, v| + cache_ids = @resource_klasses[resource_klass].map do |(k, v)| # Store the hashcode of the cache_field to avoid storing objects and to ensure precision isn't lost # on timestamp types (i.e. string conversions dropping milliseconds) - cache_ids.push([k, resource_klass.hash_cache_field(v[:cache_id])]) + [k, resource_klass.hash_cache_field(v[:cache_id])] end - found_resources = CachedResponseFragment.fetch_cached_fragments( + lookups.push( + CachedResponseFragment::Lookup.new( resource_klass, serializer_config_key, - cache_ids, - context) - - found_resources.each do |found_result| - resource = found_result[1] - if resource.nil? - missed_ids.push(found_result[0]) - else - @resource_klasses[resource_klass][resource.id][:resource] = resource - end - end + context, + context_key, + cache_ids + ) + ) else - missed_ids = @resource_klasses[resource_klass].keys + missed_resource_ids[resource_klass] ||= {} + missed_resource_ids[resource_klass] = @resource_klasses[resource_klass].keys end + end + + if lookups.any? + raise "You've declared some Resources as caching without providing a caching store" if JSONAPI.configuration.resource_cache.nil? + + # Step Two execute the cache lookup + found_resources = CachedResponseFragment.lookup(lookups, context) + else + found_resources = {} + end - # fill in any missed resources - unless missed_ids.empty? - find_opts = { - context: context, - fields: find_options[:fields] } - - found_resources = resource_klass.find_by_keys(missed_ids, find_opts) - - found_resources.each do |resource| - relationship_data = @resource_klasses[resource_klass][resource.id][:relationships] - - if resource_klass.caching? - (id, cr) = CachedResponseFragment.write( - resource_klass, - resource, - serializer, - serializer_config_key, - context, - context_key, - relationship_data) - - @resource_klasses[resource_klass][id][:resource] = cr - else - @resource_klasses[resource_klass][resource.id][:resource] = resource - end + + # Step Three collect the results and collect hit/miss stats + stats = {} + found_resources.each do |resource_klass, resources| + resources.each do |id, cached_resource| + stats[resource_klass] ||= {} + + if cached_resource.nil? + stats[resource_klass][:misses] ||= 0 + stats[resource_klass][:misses] += 1 + + # Collect misses + missed_resource_ids[resource_klass] ||= [] + missed_resource_ids[resource_klass].push(id) + else + stats[resource_klass][:hits] ||= 0 + stats[resource_klass][:hits] += 1 + + register_resource(resource_klass, cached_resource) end end end - @populated = true + + report_stats(stats) + + writes = [] + + # Step Four find any of the missing resources and join them into the result + missed_resource_ids.each_pair do |resource_klass, ids| + find_opts = {context: context, fields: find_options[:fields]} + found_resources = resource_klass.find_by_keys(ids, find_opts) + + found_resources.each do |resource| + relationship_data = @resource_klasses[resource_klass][resource.id][:relationships] + + if resource_klass.caching? + + serializer_config_key = serializer.config_key(resource_klass).gsub("/", "_") + context_json = resource_klass.attribute_caching_context(context).to_json + context_b64 = JSONAPI.configuration.resource_cache_digest_function.call(context_json) + context_key = "ATTR-CTX-#{context_b64.gsub("/", "_")}" + + writes.push(CachedResponseFragment::Write.new( + resource_klass, + resource, + serializer, + serializer_config_key, + context, + context_key, + relationship_data + )) + end + + register_resource(resource_klass, resource) + end + end + + # Step Five conditionally write to the cache + CachedResponseFragment.write(writes) unless JSONAPI.configuration.resource_cache.nil? + + mark_populated! self end + def mark_populated! + @populated = true + end + + def register_resource(resource_klass, resource, primary = false) + @resource_klasses[resource_klass] ||= {} + @resource_klasses[resource_klass][resource.id] ||= {primary: resource.try(:primary) || primary, relationships: {}} + @resource_klasses[resource_klass][resource.id][:resource] = resource + end + private + + def report_stats(stats) + return unless JSONAPI.configuration.resource_cache_usage_report_function || JSONAPI.configuration.resource_cache.nil? + + stats.each_pair do |resource_klass, stat| + JSONAPI.configuration.resource_cache_usage_report_function.call( + resource_klass.name, + stat[:hits] || 0, + stat[:misses] || 0 + ) + end + end + def flatten_resource_id_tree(resource_id_tree, flattened_tree = {}) resource_id_tree.fragments.each_pair do |resource_rid, fragment| @@ -87,7 +157,7 @@ def flatten_resource_id_tree(resource_id_tree, flattened_tree = {}) flattened_tree[resource_klass] ||= {} - flattened_tree[resource_klass][id] ||= { primary: fragment.primary, relationships: {} } + flattened_tree[resource_klass][id] ||= {primary: fragment.primary, relationships: {}} flattened_tree[resource_klass][id][:cache_id] ||= fragment.cache fragment.related.try(:each_pair) do |relationship_name, related_rids| @@ -104,4 +174,4 @@ def flatten_resource_id_tree(resource_id_tree, flattened_tree = {}) flattened_tree end end -end \ No newline at end of file +end From 0bb3067b84423ab8411fbfd65c90225e86ad5b66 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Wed, 20 Feb 2019 06:54:14 -0500 Subject: [PATCH 126/237] Add relationship apply callable for custom joins Reworks the JoinTree and renamed to JoinManager --- lib/jsonapi-resources.rb | 2 +- .../active_relation_resource_finder.rb | 228 +++++++------- .../join_manager.rb | 288 +++++++++++++++++ .../join_tree.rb | 227 -------------- lib/jsonapi/path.rb | 8 + lib/jsonapi/path_segment.rb | 22 +- lib/jsonapi/relationship.rb | 1 + test/fixtures/active_record.rb | 72 ++++- .../join_manager_test.rb | 268 ++++++++++++++++ .../join_tree_test.rb | 289 ------------------ 10 files changed, 756 insertions(+), 649 deletions(-) create mode 100644 lib/jsonapi/active_relation_resource_finder/join_manager.rb delete mode 100644 lib/jsonapi/active_relation_resource_finder/join_tree.rb create mode 100644 test/unit/active_relation_resource_finder/join_manager_test.rb delete mode 100644 test/unit/active_relation_resource_finder/join_tree_test.rb diff --git a/lib/jsonapi-resources.rb b/lib/jsonapi-resources.rb index e8bdc068d..bafa53788 100644 --- a/lib/jsonapi-resources.rb +++ b/lib/jsonapi-resources.rb @@ -25,7 +25,7 @@ require 'jsonapi/callbacks' require 'jsonapi/link_builder' require 'jsonapi/active_relation_resource_finder' -require 'jsonapi/active_relation_resource_finder/join_tree' +require 'jsonapi/active_relation_resource_finder/join_manager' require 'jsonapi/resource_identity' require 'jsonapi/resource_fragment' require 'jsonapi/resource_id_tree' diff --git a/lib/jsonapi/active_relation_resource_finder.rb b/lib/jsonapi/active_relation_resource_finder.rb index 5e60eaf08..8a0406e65 100644 --- a/lib/jsonapi/active_relation_resource_finder.rb +++ b/lib/jsonapi/active_relation_resource_finder.rb @@ -19,16 +19,16 @@ module ClassMethods def find(filters, options = {}) sort_criteria = options.fetch(:sort_criteria) { [] } - join_tree = JoinTree.new(resource_klass: self, - options: options, - filters: filters, - sort_criteria: sort_criteria) + join_manager = JoinManager.new(resource_klass: self, + filters: filters, + sort_criteria: sort_criteria) paginator = options[:paginator] records = find_records(records: records(options), + sort_criteria: sort_criteria, filters: filters, - join_tree: join_tree, + join_manager: join_manager, paginator: paginator, options: options) @@ -42,13 +42,12 @@ def find(filters, options = {}) # # @return [Integer] the count def count(filters, options = {}) - join_tree = JoinTree.new(resource_klass: self, - options: options, - filters: filters) + join_manager = JoinManager.new(resource_klass: self, + filters: filters) records = find_records(records: records(options), filters: filters, - join_tree: join_tree, + join_manager: join_manager, options: options) count_records(records) @@ -93,12 +92,11 @@ def find_fragments(filters, options = {}) sort_criteria = options.fetch(:sort_criteria) { [] } - join_tree = JoinTree.new(resource_klass: resource_klass, - source_relationship: nil, - relationships: linkage_relationships, - sort_criteria: sort_criteria, - filters: filters, - options: options) + join_manager = JoinManager.new(resource_klass: resource_klass, + source_relationship: nil, + relationships: linkage_relationships, + sort_criteria: sort_criteria, + filters: filters) paginator = options[:paginator] @@ -106,13 +104,11 @@ def find_fragments(filters, options = {}) filters: filters, sort_criteria: sort_criteria, paginator: paginator, - join_tree: join_tree, + join_manager: join_manager, options: options) - joins = join_tree.joins - # This alias is going to be resolve down to the model's table name and will not actually be an alias - resource_table_alias = joins[''][:alias] + resource_table_alias = resource_klass._table_name pluck_fields = [Arel.sql("#{concat_table_field(resource_table_alias, resource_klass._primary_key)} AS #{resource_table_alias}_#{resource_klass._primary_key}")] @@ -131,7 +127,7 @@ def find_fragments(filters, options = {}) klass = resource_klass_for(resource_type) linkage_fields << {relationship_name: name, resource_klass: klass} - linkage_table_alias = joins["#{linkage_relationship.name.to_s}##{resource_type}"][:alias] + linkage_table_alias = join_manager.join_details_by_polymorphic_relationship(linkage_relationship, resource_type)[:alias] primary_key = klass._primary_key pluck_fields << Arel.sql("#{concat_table_field(linkage_table_alias, primary_key)} AS #{linkage_table_alias}_#{primary_key}") end @@ -139,7 +135,7 @@ def find_fragments(filters, options = {}) klass = linkage_relationship.resource_klass linkage_fields << {relationship_name: name, resource_klass: klass} - linkage_table_alias = joins[name.to_s][:alias] + linkage_table_alias = join_manager.join_details_by_relationship(linkage_relationship)[:alias] primary_key = klass._primary_key pluck_fields << Arel.sql("#{concat_table_field(linkage_table_alias, primary_key)} AS #{linkage_table_alias}_#{primary_key}") end @@ -154,7 +150,7 @@ def find_fragments(filters, options = {}) end fragments = {} - rows = records.pluck(*pluck_fields) + rows = records.distinct.pluck(*pluck_fields) rows.collect do |row| rid = JSONAPI::ResourceIdentity.new(resource_klass, pluck_fields.length == 1 ? row : row[0]) @@ -229,20 +225,18 @@ def count_related(source_rid, relationship_name, options = {}) filters = options.fetch(:filters, {}) # Joins in this case are related to the related_klass - join_tree = JoinTree.new(resource_klass: self, - source_relationship: relationship, - filters: filters, - options: options) + join_manager = JoinManager.new(resource_klass: self, + source_relationship: relationship, + filters: filters) records = find_records(records: records(options), resource_klass: related_klass, primary_keys: source_rid.id, - join_tree: join_tree, + join_manager: join_manager, filters: filters, options: options) - joins = join_tree.joins - related_alias = joins[''][:alias] + related_alias = join_manager.join_details_by_relationship(relationship)[:alias] records = records.select(Arel.sql("#{concat_table_field(related_alias, related_klass._primary_key)}")) @@ -250,7 +244,52 @@ def count_related(source_rid, relationship_name, options = {}) end def records(_options = {}) - _model_class.distinct.all + _model_class.all + end + + def apply_join(records:, relationship:, resource_type:, join_type:, options:) + if relationship.polymorphic? && relationship.belongs_to? + case join_type + when :inner + records = records.joins(resource_type.to_s.singularize.to_sym) + when :left + records = records.joins_left(resource_type.to_s.singularize.to_sym) + end + else + relation_name = relationship.relation_name(options) + case join_type + when :inner + records = records.joins(relation_name) + when :left + records = records.joins_left(relation_name) + end + end + records + end + + def relationship_records(relationship:, join_type: :inner, resource_type: nil, options: {}) + records = relationship.parent_resource.records(options) + strategy = relationship.options[:apply_join] + + if strategy + records = call_method_or_proc(strategy, records, relationship, resource_type, join_type, options) + else + records = apply_join(records: records, + relationship: relationship, + resource_type: resource_type, + join_type: join_type, + options: options) + end + + records + end + + def join_relationship(records:, relationship:, resource_type: nil, join_type: :inner, options: {}) + relationship_records = relationship_records(relationship: relationship, + join_type: join_type, + resource_type: resource_type, + options: options) + records.merge(relationship_records) end protected @@ -290,12 +329,11 @@ def find_related_monomorphic_fragments(source_rids, relationship, options, conne sort_criteria << { field: field, direction: sort[:direction] } end - join_tree = JoinTree.new(resource_klass: self, - source_relationship: relationship, - relationships: linkage_relationships, - sort_criteria: sort_criteria, - filters: filters, - options: options) + join_manager = JoinManager.new(resource_klass: self, + source_relationship: relationship, + relationships: linkage_relationships, + sort_criteria: sort_criteria, + filters: filters) paginator = options[:paginator] if source_rids.count == 1 @@ -305,11 +343,10 @@ def find_related_monomorphic_fragments(source_rids, relationship, options, conne primary_keys: source_ids, paginator: paginator, filters: filters, - join_tree: join_tree, + join_manager: join_manager, options: options) - joins = join_tree.joins - resource_table_alias = joins[''][:alias] + resource_table_alias = join_manager.join_details_by_relationship(relationship)[:alias] pluck_fields = [ Arel.sql("#{_table_name}.#{_primary_key} AS source_id"), @@ -331,7 +368,7 @@ def find_related_monomorphic_fragments(source_rids, relationship, options, conne klass = resource_klass_for(resource_type) linkage_fields << {relationship_name: name, resource_klass: klass} - linkage_table_alias = joins["#{linkage_relationship.name.to_s}##{resource_type}"][:alias] + linkage_table_alias = join_manager.join_details_by_polymorphic_relationship(linkage_relationship, resource_type)[:alias] primary_key = klass._primary_key pluck_fields << Arel.sql("#{concat_table_field(linkage_table_alias, primary_key)} AS #{linkage_table_alias}_#{primary_key}") end @@ -339,7 +376,7 @@ def find_related_monomorphic_fragments(source_rids, relationship, options, conne klass = linkage_relationship.resource_klass linkage_fields << {relationship_name: name, resource_klass: klass} - linkage_table_alias = joins[name.to_s][:alias] + linkage_table_alias = join_manager.join_details_by_relationship(linkage_relationship)[:alias] primary_key = klass._primary_key pluck_fields << Arel.sql("#{concat_table_field(linkage_table_alias, primary_key)} AS #{linkage_table_alias}_#{primary_key}") end @@ -354,7 +391,7 @@ def find_related_monomorphic_fragments(source_rids, relationship, options, conne end fragments = {} - rows = records.pluck(*pluck_fields) + rows = records.distinct.pluck(*pluck_fields) rows.each do |row| rid = JSONAPI::ResourceIdentity.new(resource_klass, row[1]) @@ -418,11 +455,10 @@ def find_related_polymorphic_fragments(source_rids, relationship, options, conne end end - join_tree = JoinTree.new(resource_klass: self, - source_relationship: relationship, - relationships: linkage_relationships, - filters: filters, - options: options) + join_manager = JoinManager.new(resource_klass: self, + source_relationship: relationship, + relationships: linkage_relationships, + filters: filters) paginator = options[:paginator] if source_rids.count == 1 @@ -434,11 +470,9 @@ def find_related_polymorphic_fragments(source_rids, relationship, options, conne primary_keys: source_ids, paginator: paginator, filters: filters, - join_tree: join_tree, + join_manager: join_manager, options: options) - joins = join_tree.joins - primary_key = concat_table_field(_table_name, _primary_key) related_key = concat_table_field(_table_name, relationship.foreign_key) related_type = concat_table_field(_table_name, relationship.polymorphic_type) @@ -467,7 +501,7 @@ def find_related_polymorphic_fragments(source_rids, relationship, options, conne cache_field = related_klass.attribute_to_model_field(:_cache_field) if options[:cache] - table_alias = joins["##{type}"][:alias] + table_alias = join_manager.source_join_details(type)[:alias] cache_offset = relation_index if cache_field @@ -515,7 +549,7 @@ def find_related_polymorphic_fragments(source_rids, relationship, options, conne klass = resource_klass_for(resource_type) linkage_fields << {relationship: linkage_relationship, resource_klass: klass} - linkage_table_alias = joins[linkage_relationship_path][:alias] + linkage_table_alias = join_manager.join_details_by_polymorphic_relationship(linkage_relationship, resource_type)[:alias] primary_key = klass._primary_key pluck_fields << Arel.sql("#{concat_table_field(linkage_table_alias, primary_key)} AS #{linkage_table_alias}_#{primary_key}") end @@ -523,13 +557,13 @@ def find_related_polymorphic_fragments(source_rids, relationship, options, conne klass = linkage_relationship.resource_klass linkage_fields << {relationship: linkage_relationship, resource_klass: klass} - linkage_table_alias = joins[linkage_relationship_path.to_s][:alias] + linkage_table_alias = join_manager.join_details_by_relationship(linkage_relationship)[:alias] primary_key = klass._primary_key pluck_fields << Arel.sql("#{concat_table_field(linkage_table_alias, primary_key)} AS #{linkage_table_alias}_#{primary_key}") end end - rows = records.pluck(*pluck_fields) + rows = records.distinct.pluck(*pluck_fields) related_fragments = {} @@ -582,9 +616,9 @@ def find_related_polymorphic_fragments(source_rids, relationship, options, conne end def find_records(records:, - join_tree: JoinTree.new(resource_klass: self), + join_manager: JoinManager.new(resource_klass: self), resource_klass: self, - filters: nil, + filters: {}, primary_keys: nil, sort_criteria: nil, sort_primary: nil, @@ -592,15 +626,15 @@ def find_records(records:, options: {}) opts = options.dup - records = resource_klass.apply_joins(records, join_tree, opts) + records = resource_klass.apply_joins(records, join_manager, opts) if primary_keys records = records.where(_primary_key => primary_keys) end - opts[:joins] = join_tree.joins + opts[:join_manager] = join_manager - if filters + unless filters.empty? records = resource_klass.filter_records(records, filters, opts) end @@ -618,52 +652,8 @@ def find_records(records:, records end - def get_join_alias(records, &block) - init_join_sources = records.arel.join_sources - init_join_sources_length = init_join_sources.length - - records = yield(records) - - join_sources = records.arel.join_sources - if join_sources.length > init_join_sources_length - last_join = (join_sources - init_join_sources).last - join_alias = - case last_join.left - when Arel::Table - last_join.left.name - when Arel::Nodes::TableAlias - last_join.left.right - when Arel::Nodes::StringJoin - # :nocov: - warn "get_join_alias: Unsupported join type - use custom filtering and sorting" - nil - # :nocov: - end - else - # :nocov: - warn "get_join_alias: No join added" - join_alias = nil - # :nocov: - end - - return records, join_alias - end - - def apply_joins(records, join_tree, _options) - joins = join_tree.joins - - joins.each_value do |join| - case join[:join_type] - when :inner - records, join_alias = get_join_alias(records) { |records| records.joins(join[:relation_join_hash]) } - join[:alias] = join_alias - when :left - records, join_alias = get_join_alias(records) { |records| records.joins_left(join[:relation_join_hash]) } - join[:alias] = join_alias - end - end - - return records + def apply_joins(records, join_manager, options) + join_manager.join(records, options) end def apply_pagination(records, paginator, order_options) @@ -689,9 +679,9 @@ def apply_single_sort(records, field, direction, options) if strategy records = call_method_or_proc(strategy, records, direction, context) else - joins = options[:joins] || {} + join_manager = options[:join_manager] - records = records.order("#{get_aliased_field(field, joins)} #{direction}") + records = records.order(Arel.sql("#{get_aliased_field(field, join_manager)} #{direction}")) end records end @@ -758,22 +748,20 @@ def apply_filters(records, filters, options = {}) records end - def get_aliased_field(path_with_field, joins) + def get_aliased_field(path_with_field, join_manager) path = JSONAPI::Path.new(resource_klass: self, path_string: path_with_field) - relationship = path.segments[-2] - field = path.segments[-1] - relationship_path = path.relationship_path_string + relationship_segment = path.segments[-2] + field_segment = path.segments[-1] - if relationship - join_name = relationship_path - join = joins.try(:[], join_name) - table_alias = join.try(:[], :alias) + if relationship_segment + join_details = join_manager.join_details[path.last_relationship] + table_alias = join_details[:alias] + else + table_alias = self._table_name end - table_alias ||= joins[''][:alias] - - concat_table_field(table_alias, field.delegated_field_name) + concat_table_field(table_alias, field_segment.delegated_field_name) end def apply_filter(records, filter, value, options = {}) @@ -782,8 +770,8 @@ def apply_filter(records, filter, value, options = {}) if strategy records = call_method_or_proc(strategy, records, value, options) else - joins = options[:joins] || {} - records = records.where(get_aliased_field(filter, joins) => value) + join_manager = options[:join_manager] + records = records.where(Arel.sql(get_aliased_field(filter, join_manager)) => value) end records diff --git a/lib/jsonapi/active_relation_resource_finder/join_manager.rb b/lib/jsonapi/active_relation_resource_finder/join_manager.rb new file mode 100644 index 000000000..0ab087962 --- /dev/null +++ b/lib/jsonapi/active_relation_resource_finder/join_manager.rb @@ -0,0 +1,288 @@ +module JSONAPI + module ActiveRelationResourceFinder + + # Stores relationship paths starting from the resource_klass, consolidating duplicate paths from + # relationships, filters and sorts. When joins are made the table aliases are tracked in join_details + class JoinManager + attr_reader :resource_klass, + :source_relationship, + :resource_join_tree, + :join_details + + def initialize(resource_klass:, + source_relationship: nil, + relationships: nil, + filters: nil, + sort_criteria: nil) + + @resource_klass = resource_klass + @join_details = nil + @collected_aliases = Set.new + + @resource_join_tree = { + root: { + join_type: :root, + resource_klasses: { + resource_klass => { + relationships: {} + } + } + } + } + add_source_relationship(source_relationship) + add_sort_criteria(sort_criteria) + add_filters(filters) + add_relationships(relationships) + end + + def join(records, options) + fail "can't be joined again" if @join_details + @join_details = {} + perform_joins(records, options) + end + + # source details will only be on a relationship if the source_relationship is set + # this method gets the join details whether they are on a relationship or are just pseudo details for the base + # resource. Specify the resource type for polymorphic relationships + # + def source_join_details(type=nil) + if source_relationship + related_resource_klass = type ? resource_klass.resource_klass_for(type) : source_relationship.resource_klass + segment = PathSegment::Relationship.new(relationship: source_relationship, resource_klass: related_resource_klass) + details = @join_details[segment] + else + if type + details = @join_details["##{type}"] + else + details = @join_details[''] + end + end + details + end + + def join_details_by_polymorphic_relationship(relationship, type) + segment = PathSegment::Relationship.new(relationship: relationship, resource_klass: resource_klass.resource_klass_for(type)) + @join_details[segment] + end + + def join_details_by_relationship(relationship) + segment = PathSegment::Relationship.new(relationship: relationship, resource_klass: relationship.resource_klass) + @join_details[segment] + end + + def self.get_join_arel_node(records, options = {}) + init_join_sources = records.arel.join_sources + init_join_sources_length = init_join_sources.length + + records = yield(records, options) + + join_sources = records.arel.join_sources + if join_sources.length > init_join_sources_length + last_join = (join_sources - init_join_sources).last + else + # :nocov: + warn "get_join_arel_node: No join added" + last_join = nil + # :nocov: + end + + return records, last_join + end + + def self.alias_from_arel_node(node) + case node.left + when Arel::Table + node.left.name + when Arel::Nodes::TableAlias + node.left.right + when Arel::Nodes::StringJoin + # :nocov: + warn "alias_from_arel_node: Unsupported join type - use custom filtering and sorting" + nil + # :nocov: + end + end + + private + + def flatten_join_tree_by_depth(join_array = [], node = @resource_join_tree, level = 0) + join_array[level] = [] unless join_array[level] + + node.each do |relationship, relationship_details| + relationship_details[:resource_klasses].each do |related_resource_klass, resource_details| + join_array[level] << { relationship: relationship, + relationship_details: relationship_details, + related_resource_klass: related_resource_klass} + flatten_join_tree_by_depth(join_array, resource_details[:relationships], level+1) + end + end + join_array + end + + def add_join_details(join_key, details, check_for_duplicate_alias = true) + fail "details already set" if @join_details.has_key?(join_key) + @join_details[join_key] = details + + if check_for_duplicate_alias && @collected_aliases.include?(details[:alias]) + fail "alias '#{details[:alias]}' has already been added. Possible relation reordering" + end + + @collected_aliases << details[:alias] + end + + def perform_joins(records, options) + join_array = flatten_join_tree_by_depth + + join_array.each do |level_joins| + level_joins.each do |join_details| + relationship = join_details[:relationship] + relationship_details = join_details[:relationship_details] + related_resource_klass = join_details[:related_resource_klass] + join_type = relationship_details[:join_type] + + if relationship == :root + unless source_relationship + add_join_details('', {alias: resource_klass._table_name, join_type: :root}) + end + next + end + + records, join_node = self.class.get_join_arel_node(records, options) {|records, options| + records = related_resource_klass.join_relationship( + records: records, + resource_type: related_resource_klass._type, + join_type: join_type, + relationship: relationship, + options: options) + } + + details = {alias: self.class.alias_from_arel_node(join_node), join_type: join_type} + + if relationship == source_relationship + if relationship.polymorphic? && relationship.belongs_to? + add_join_details("##{related_resource_klass._type}", details) + else + add_join_details('', details) + end + end + + check_for_duplicate_alias = !(relationship == source_relationship) + add_join_details(PathSegment::Relationship.new(relationship: relationship, resource_klass: related_resource_klass), details, check_for_duplicate_alias) + end + end + records + end + + def add_join(path, default_type = :inner, default_polymorphic_join_type = :left) + if source_relationship + if source_relationship.polymorphic? + # Polymorphic paths will come it with the resource_type as the first segment (for example `#documents.comments`) + # We just need to prepend the relationship portion the + sourced_path = "#{source_relationship.name}#{path}" + else + sourced_path = "#{source_relationship.name}.#{path}" + end + else + sourced_path = path + end + + join_manager, _field = parse_path_to_tree(sourced_path, resource_klass, default_type, default_polymorphic_join_type) + + @resource_join_tree[:root].deep_merge!(join_manager) { |key, val, other_val| + if key == :join_type + if val == other_val + val + else + :inner + end + end + } + end + + def process_path_to_tree(path_segments, resource_klass, default_join_type, default_polymorphic_join_type) + node = { + resource_klasses: { + resource_klass => { + relationships: {} + } + } + } + + segment = path_segments.shift + + if segment.is_a?(PathSegment::Relationship) + node[:resource_klasses][resource_klass][:relationships][segment.relationship] ||= {} + + # join polymorphic as left joins + node[:resource_klasses][resource_klass][:relationships][segment.relationship][:join_type] ||= + segment.relationship.polymorphic? ? default_polymorphic_join_type : default_join_type + + segment.relationship.resource_types.each do |related_resource_type| + related_resource_klass = resource_klass.resource_klass_for(related_resource_type) + + # If the resource type was specified in the path segment we want to only process the next segments for + # that resource type, otherwise process for all + process_all_types = !segment.path_specified_resource_klass? + + if process_all_types || related_resource_klass == segment.resource_klass + related_resource_tree = process_path_to_tree(path_segments.dup, related_resource_klass, default_join_type, default_polymorphic_join_type) + node[:resource_klasses][resource_klass][:relationships][segment.relationship].deep_merge!(related_resource_tree) + end + end + end + node + end + + def parse_path_to_tree(path_string, resource_klass, default_join_type = :inner, default_polymorphic_join_type = :left) + path = JSONAPI::Path.new(resource_klass: resource_klass, path_string: path_string) + + field = path.segments[-1] + return process_path_to_tree(path.segments, resource_klass, default_join_type, default_polymorphic_join_type), field + end + + def add_source_relationship(source_relationship) + @source_relationship = source_relationship + + if @source_relationship + resource_klasses = {} + source_relationship.resource_types.each do |related_resource_type| + related_resource_klass = resource_klass.resource_klass_for(related_resource_type) + resource_klasses[related_resource_klass] = {relationships: {}} + end + + join_type = source_relationship.polymorphic? ? :left : :inner + + @resource_join_tree[:root][:resource_klasses][resource_klass][:relationships][@source_relationship] = { + source: true, resource_klasses: resource_klasses, join_type: join_type + } + end + end + + def add_filters(filters) + return if filters.blank? + filters.each_key do |filter| + # Do not add joins for filters with an apply callable. This can be overridden by setting perform_joins to true + next if resource_klass._allowed_filters[filter].try(:[], :apply) && + !resource_klass._allowed_filters[filter].try(:[], :perform_joins) + + add_join(filter, :left) + end + end + + def add_sort_criteria(sort_criteria) + return if sort_criteria.blank? + + sort_criteria.each do |sort| + add_join(sort[:field], :left) + end + end + + def add_relationships(relationships) + return if relationships.blank? + relationships.each do |relationship| + add_join(relationship, :left) + end + end + end + end +end \ No newline at end of file diff --git a/lib/jsonapi/active_relation_resource_finder/join_tree.rb b/lib/jsonapi/active_relation_resource_finder/join_tree.rb deleted file mode 100644 index eaf3b5025..000000000 --- a/lib/jsonapi/active_relation_resource_finder/join_tree.rb +++ /dev/null @@ -1,227 +0,0 @@ -module JSONAPI - module ActiveRelationResourceFinder - class JoinTree - # Stores relationship paths starting from the resource_klass. This allows consolidation of duplicate paths from - # relationships, filters and sorts. This enables the determination of table aliases as they are joined. - - attr_reader :resource_klass, :options, :source_relationship, :resource_joins, :joins - - def initialize(resource_klass:, - options: {}, - source_relationship: nil, - relationships: nil, - filters: nil, - sort_criteria: nil) - - @resource_klass = resource_klass - @options = options - - @resource_joins = { - root: { - join_type: :root, - resource_klasses: { - resource_klass => { - relationships: {} - } - } - } - } - add_source_relationship(source_relationship) - add_sort_criteria(sort_criteria) - add_filters(filters) - add_relationships(relationships) - - @joins = {} - construct_joins(@resource_joins) - end - - private - - def add_join(path, default_type = :inner, default_polymorphic_join_type = :left) - if source_relationship - if source_relationship.polymorphic? - # Polymorphic paths will come it with the resource_type as the first segment (for example `#documents.comments`) - # We just need to prepend the relationship portion the - sourced_path = "#{source_relationship.name}#{path}" - else - sourced_path = "#{source_relationship.name}.#{path}" - end - else - sourced_path = path - end - - join_tree, _field = parse_path_to_tree(sourced_path, resource_klass, default_type, default_polymorphic_join_type) - - @resource_joins[:root].deep_merge!(join_tree) { |key, val, other_val| - if key == :join_type - if val == other_val - val - else - :inner - end - end - } - end - - def process_path_to_tree(path_segments, resource_klass, default_join_type, default_polymorphic_join_type) - node = { - resource_klasses: { - resource_klass => { - relationships: {} - } - } - } - - segment = path_segments.shift - - if segment.is_a?(PathSegment::Relationship) - node[:resource_klasses][resource_klass][:relationships][segment.relationship] ||= {} - - # join polymorphic as left joins - node[:resource_klasses][resource_klass][:relationships][segment.relationship][:join_type] ||= - segment.relationship.polymorphic? ? default_polymorphic_join_type : default_join_type - - segment.relationship.resource_types.each do |related_resource_type| - related_resource_klass = resource_klass.resource_klass_for(related_resource_type) - - # If the resource type was specified in the path segment we want to only process the next segments for - # that resource type, otherwise process for all - process_all_types = !segment.path_specified_resource_klass? - - if process_all_types || related_resource_klass == segment.resource_klass - related_resource_tree = process_path_to_tree(path_segments.dup, related_resource_klass, default_join_type, default_polymorphic_join_type) - node[:resource_klasses][resource_klass][:relationships][segment.relationship].deep_merge!(related_resource_tree) - end - end - end - node - end - - def parse_path_to_tree(path_string, resource_klass, default_join_type = :inner, default_polymorphic_join_type = :left) - path = JSONAPI::Path.new(resource_klass: resource_klass, path_string: path_string) - field = path.segments[-1] - return process_path_to_tree(path.segments, resource_klass, default_join_type, default_polymorphic_join_type), field - end - - def add_source_relationship(source_relationship) - @source_relationship = source_relationship - - if @source_relationship - resource_klasses = {} - source_relationship.resource_types.each do |related_resource_type| - related_resource_klass = resource_klass.resource_klass_for(related_resource_type) - resource_klasses[related_resource_klass] = {relationships: {}} - end - - join_type = source_relationship.polymorphic? ? :left : :inner - - @resource_joins[:root][:resource_klasses][resource_klass][:relationships][@source_relationship] = { - source: true, resource_klasses: resource_klasses, join_type: join_type - } - end - end - - def add_filters(filters) - return if filters.blank? - filters.each_key do |filter| - # Do not add joins for filters with an apply callable. This can be overridden by setting perform_joins to true - next if resource_klass._allowed_filters[filter].try(:[], :apply) && - !resource_klass._allowed_filters[filter].try(:[], :perform_joins) - - add_join(filter) - end - end - - def add_sort_criteria(sort_criteria) - return if sort_criteria.blank? - - sort_criteria.each do |sort| - add_join(sort[:field], :left) - end - end - - def add_relationships(relationships) - return if relationships.blank? - relationships.each do |relationship| - add_join(relationship, :left) - end - end - - # Create a nested set of hashes from an array of path components. This will be used by the `join` methods. - # [post, comments] => { post: { comments: {} } - def relation_join_hash(path, path_hash = {}) - relation = path.shift - if relation - path_hash[relation] = {} - relation_join_hash(path, path_hash[relation]) - end - path_hash - end - - # Returns the paths from shortest to longest, allowing the capture of the table alias for earlier paths. For - # example posts, posts.comments and then posts.comments.author joined in that order will allow each - # alias to be determined whereas just joining posts.comments.author will only record the author alias. - # ToDo: Dependence on this specialized logic should be removed in the future, if possible. - def construct_joins(node, current_relation_path = [], current_relationship_path = []) - node.each do |relationship, relationship_details| - join_type = relationship_details[:join_type] - if relationship == :root - @joins[:root] = {alias: resource_klass._table_name, join_type: :root} - - # alias to the default table unless a source_relationship is specified - unless source_relationship - @joins[''] = {alias: resource_klass._table_name, join_type: :root} - end - - return construct_joins(relationship_details[:resource_klasses].values[0][:relationships], - current_relation_path, - current_relationship_path) - end - - relationship_details[:resource_klasses].each do |resource_klass, resource_details| - if relationship.polymorphic? && relationship.belongs_to? - current_relationship_path << "#{relationship.name.to_s}##{resource_klass._type.to_s}" - relation_name = resource_klass._type.to_s.singularize - else - current_relationship_path << relationship.name.to_s - relation_name = relationship.relation_name(options).to_s - end - - current_relation_path << relation_name - - rel_path = calc_path_string(current_relationship_path) - - @joins[rel_path] = { - alias: nil, - join_type: join_type, - relation_join_hash: relation_join_hash(current_relation_path.dup) - } - - construct_joins(resource_details[:relationships], - current_relation_path.dup, - current_relationship_path.dup) - - current_relation_path.pop - current_relationship_path.pop - end - end - end - - def calc_path_string(path_array) - if source_relationship - if source_relationship.polymorphic? - _relationship_name, resource_name = path_array[0].split('#', 2) - path = path_array.dup - path[0] = "##{resource_name}" - else - path = path_array.dup.drop(1) - end - else - path = path_array.dup - end - - path.join('.') - end - end - end -end \ No newline at end of file diff --git a/lib/jsonapi/path.rb b/lib/jsonapi/path.rb index 2b11326f1..ae111b49e 100644 --- a/lib/jsonapi/path.rb +++ b/lib/jsonapi/path.rb @@ -31,5 +31,13 @@ def relationship_segments def relationship_path_string relationship_segments.collect(&:to_s).join('.') end + + def last_relationship + if @segments.last.is_a?(PathSegment::Relationship) + @segments.last + else + @segments[-2] + end + end end end diff --git a/lib/jsonapi/path_segment.rb b/lib/jsonapi/path_segment.rb index bd34ff13d..aa2d78050 100644 --- a/lib/jsonapi/path_segment.rb +++ b/lib/jsonapi/path_segment.rb @@ -22,19 +22,27 @@ def self.parse(source_resource_klass:, segment_string:, parse_fields: true) end class Relationship - attr_reader :relationship + attr_reader :relationship, :resource_klass - def initialize(relationship:, resource_klass:) + def initialize(relationship:, resource_klass: nil) @relationship = relationship @resource_klass = resource_klass end + def eql?(other) + relationship == other.relationship && resource_klass == other.resource_klass + end + + def hash + [relationship, resource_klass].hash + end + def to_s - @resource_klass ? "#{relationship.name}##{resource_klass._type}" : "#{relationship.name}" + @resource_klass ? "#{relationship.parent_resource_klass._type}.#{relationship.name}##{resource_klass._type}" : "#{resource_klass._type}.#{relationship.name}" end def resource_klass - @resource_klass || @relationship.resource_klass + @resource_klass || relationship.resource_klass end def path_specified_resource_klass? @@ -50,13 +58,17 @@ def initialize(resource_klass:, field_name:) @field_name = field_name end + def eql?(other) + field_name == other.field_name && resource_klass == other.resource_klass + end + def delegated_field_name resource_klass._attribute_delegated_name(field_name) end def to_s # :nocov: - field_name.to_s + "#{resource_klass._type}.#{field_name.to_s}" # :nocov: end end diff --git a/lib/jsonapi/relationship.rb b/lib/jsonapi/relationship.rb index dc691c176..75f94d311 100644 --- a/lib/jsonapi/relationship.rb +++ b/lib/jsonapi/relationship.rb @@ -32,6 +32,7 @@ def initialize(name, options = {}) end alias_method :polymorphic?, :polymorphic + alias_method :parent_resource_klass, :parent_resource def primary_key # :nocov: diff --git a/test/fixtures/active_record.rb b/test/fixtures/active_record.rb index 2dc6c5c96..cbcd1a167 100644 --- a/test/fixtures/active_record.rb +++ b/test/fixtures/active_record.rb @@ -1147,6 +1147,23 @@ module V8 class NumerosTelefoneController < JSONAPI::ResourceController end end + + module V9 + class AuthorsController < JSONAPI::ResourceController + end + + class AuthorDetailsController < JSONAPI::ResourceController + end + + class PostsController < JSONAPI::ResourceController + end + + class CommentsController < JSONAPI::ResourceController + end + + class SectionsController < JSONAPI::ResourceController + end + end end module Api @@ -1591,7 +1608,7 @@ class CraterResource < JSONAPI::Resource filter :description, apply: -> (records, value, options) { fail "context not set" unless options[:context][:current_user] != nil && options[:context][:current_user] == $test_user - records.where(concat_table_field(options[:joins][''][:alias], :description) => value) + records.where(concat_table_field(options[:join_manager].source_join_details[:alias], :description) => value) } def self.verify_key(key, context = nil) @@ -1629,13 +1646,13 @@ class PictureResource < JSONAPI::Resource has_one :file_properties, inverse_relationship: :fileable, :foreign_key_on => :related, polymorphic: true filter 'imageable.name', perform_joins: true, apply: -> (records, value, options) { - joins = options[:joins] + join_manager = options[:join_manager] relationship = _relationship(:imageable) or_parts = relationship.resource_types.collect do |type| - table_alias = joins["imageable##{type}"][:alias] + table_alias = join_manager.join_details_by_polymorphic_relationship(relationship, type)[:alias] "#{concat_table_field(table_alias, "name")} = '#{value.first}'" end - records.where(or_parts.join(' OR ')) + records.where(Arel.sql(or_parts.join(' OR '))) } filter 'imageable#documents.name' @@ -1857,7 +1874,9 @@ class AuthorResource < JSONAPI::Resource attributes :name has_many :books, inverse_relationship: :authors, relation_name: -> (options) { - if options[:context][:current_user].try(:book_admin) + book_admin = options[:context][:book_admin] || options[:context][:current_user].try(:book_admin) + + if book_admin :books else :not_banned_books @@ -2013,8 +2032,9 @@ class AuthorResource < JSONAPI::Resource relationship :author_detail, to: :one, foreign_key_on: :related filter :name, apply: lambda { |records, value, options| - table_alias = options[:joins][''][:alias] - records.where("#{concat_table_field(table_alias, "name")} LIKE \"%#{value[0]}%\"") + table_alias = options[:join_manager].source_join_details[:alias] + t = Arel::Table.new(:people, as: table_alias) + records.where(t[:name].matches("%#{value[0]}%")) } def fetchable_fields @@ -2209,6 +2229,44 @@ class NumeroTelefoneResource < JSONAPI::Resource attribute :numero_telefone end end + + module V9 + class PersonResource < PersonResource; end + class PostResource < PostResource + has_many :comments, apply_join: -> (records, relationship, resource_type, join_type, options) { + case join_type + when :inner + records = records.joins(relationship.relation_name(options)) + when :left + records = records.joins_left(relationship.relation_name(options)) + end + records.where(comments: {approved: true}) + } + end + + class TagResource < TagResource; end + class SectionResource < SectionResource; end + class CommentResource < CommentResource + has_one :author, class_name: 'Person', apply_join: -> (records, relationship, resource_type, join_type, options) { + records = apply_join(records: records, + relationship: relationship, + resource_type: resource_type, + join_type: join_type, + options: options) + + records.where(author: {special: true}) + } + end + + class AuthorResource < Api::V2::AuthorResource + end + + class BookResource < Api::V2::BookResource + end + + class BookCommentResource < Api::V2::BookCommentResource + end + end end module AdminApi diff --git a/test/unit/active_relation_resource_finder/join_manager_test.rb b/test/unit/active_relation_resource_finder/join_manager_test.rb new file mode 100644 index 000000000..013cbb699 --- /dev/null +++ b/test/unit/active_relation_resource_finder/join_manager_test.rb @@ -0,0 +1,268 @@ +require File.expand_path('../../../test_helper', __FILE__) +require 'jsonapi-resources' + +class JoinTreeTest < ActiveSupport::TestCase + + def test_no_added_joins + join_manager = JSONAPI::ActiveRelationResourceFinder::JoinManager.new(resource_klass: PostResource) + + records = PostResource.records({}) + records = join_manager.join(records, {}) + assert_equal 'SELECT "posts".* FROM "posts"', records.to_sql + + assert_hash_equals({alias: 'posts', join_type: :root}, join_manager.source_join_details) + end + + def test_add_single_join + filters = {'tags' => ['1']} + join_manager = JSONAPI::ActiveRelationResourceFinder::JoinManager.new(resource_klass: PostResource, filters: filters) + records = PostResource.records({}) + records = join_manager.join(records, {}) + assert_equal 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "posts_tags" ON "posts_tags"."post_id" = "posts"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "posts_tags"."tag_id"', records.to_sql + assert_hash_equals({alias: 'posts', join_type: :root}, join_manager.source_join_details) + assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(PostResource._relationship(:tags))) + end + + def test_add_single_sort_join + sort_criteria = [{field: 'tags.name', direction: :desc}] + join_manager = JSONAPI::ActiveRelationResourceFinder::JoinManager.new(resource_klass: PostResource, sort_criteria: sort_criteria) + records = PostResource.records({}) + records = join_manager.join(records, {}) + + assert_equal 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "posts_tags" ON "posts_tags"."post_id" = "posts"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "posts_tags"."tag_id"', records.to_sql + assert_hash_equals({alias: 'posts', join_type: :root}, join_manager.source_join_details) + assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(PostResource._relationship(:tags))) + end + + def test_add_single_sort_and_filter_join + filters = {'tags' => ['1']} + sort_criteria = [{field: 'tags.name', direction: :desc}] + join_manager = JSONAPI::ActiveRelationResourceFinder::JoinManager.new(resource_klass: PostResource, sort_criteria: sort_criteria, filters: filters) + records = PostResource.records({}) + records = join_manager.join(records, {}) + assert_equal 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "posts_tags" ON "posts_tags"."post_id" = "posts"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "posts_tags"."tag_id"', records.to_sql + assert_hash_equals({alias: 'posts', join_type: :root}, join_manager.source_join_details) + assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(PostResource._relationship(:tags))) + end + + def test_add_sibling_joins + filters = { + 'tags' => ['1'], + 'author' => ['1'] + } + + join_manager = JSONAPI::ActiveRelationResourceFinder::JoinManager.new(resource_klass: PostResource, filters: filters) + records = PostResource.records({}) + records = join_manager.join(records, {}) + + assert_equal 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "posts_tags" ON "posts_tags"."post_id" = "posts"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "posts_tags"."tag_id" LEFT OUTER JOIN "people" ON "people"."id" = "posts"."author_id"', records.to_sql + assert_hash_equals({alias: 'posts', join_type: :root}, join_manager.source_join_details) + assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(PostResource._relationship(:tags))) + assert_hash_equals({alias: 'people', join_type: :left}, join_manager.join_details_by_relationship(PostResource._relationship(:author))) + end + + + def test_add_joins_source_relationship + join_manager = JSONAPI::ActiveRelationResourceFinder::JoinManager.new(resource_klass: PostResource, + source_relationship: PostResource._relationship(:comments)) + records = PostResource.records({}) + records = join_manager.join(records, {}) + + assert_equal 'SELECT "posts".* FROM "posts" INNER JOIN "comments" ON "comments"."post_id" = "posts"."id"', records.to_sql + assert_hash_equals({alias: 'comments', join_type: :inner}, join_manager.source_join_details) + end + + + def test_add_joins_source_relationship_with_custom_apply + join_manager = JSONAPI::ActiveRelationResourceFinder::JoinManager.new(resource_klass: Api::V9::PostResource, + source_relationship: Api::V9::PostResource._relationship(:comments)) + records = Api::V9::PostResource.records({}) + records = join_manager.join(records, {}) + + if Rails::VERSION::MAJOR >= 5 && Rails::VERSION::MINOR >= 2 + assert_equal 'SELECT "posts".* FROM "posts" INNER JOIN "comments" ON "comments"."post_id" = "posts"."id" WHERE "comments"."approved" = 1', records.to_sql + else + assert_equal 'SELECT "posts".* FROM "posts" INNER JOIN "comments" ON "comments"."post_id" = "posts"."id" WHERE "comments"."approved" = \'t\'', records.to_sql + end + + assert_hash_equals({alias: 'comments', join_type: :inner}, join_manager.source_join_details) + end + + def test_add_nested_scoped_joins + filters = { + 'comments.author' => ['1'], + 'comments.tags' => ['1'], + 'author' => ['1'] + } + + join_manager = JSONAPI::ActiveRelationResourceFinder::JoinManager.new(resource_klass: Api::V9::PostResource, filters: filters) + records = Api::V9::PostResource.records({}) + records = join_manager.join(records, {}) + + if Rails::VERSION::MAJOR >= 5 && Rails::VERSION::MINOR >= 2 + assert_equal 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "comments" ON "comments"."post_id" = "posts"."id" LEFT OUTER JOIN "people" ON "people"."id" = "posts"."author_id" LEFT OUTER JOIN "people" "authors_comments" ON "authors_comments"."id" = "comments"."author_id" LEFT OUTER JOIN "comments_tags" ON "comments_tags"."comment_id" = "comments"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "comments_tags"."tag_id" WHERE "comments"."approved" = 1 AND "author"."special" = 1', records.to_sql + else + assert_equal 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "comments" ON "comments"."post_id" = "posts"."id" LEFT OUTER JOIN "people" ON "people"."id" = "posts"."author_id" LEFT OUTER JOIN "people" "authors_comments" ON "authors_comments"."id" = "comments"."author_id" LEFT OUTER JOIN "comments_tags" ON "comments_tags"."comment_id" = "comments"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "comments_tags"."tag_id" WHERE "comments"."approved" = \'t\' AND "author"."special" = \'t\'', records.to_sql + end + + assert_hash_equals({alias: 'posts', join_type: :root}, join_manager.source_join_details) + assert_hash_equals({alias: 'comments', join_type: :left}, join_manager.join_details_by_relationship(Api::V9::PostResource._relationship(:comments))) + assert_hash_equals({alias: 'authors_comments', join_type: :left}, join_manager.join_details_by_relationship(Api::V9::CommentResource._relationship(:author))) + assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(Api::V9::CommentResource._relationship(:tags))) + assert_hash_equals({alias: 'people', join_type: :left}, join_manager.join_details_by_relationship(Api::V9::PostResource._relationship(:author))) + + # Now test with different order for the filters + filters = { + 'author' => ['1'], + 'comments.author' => ['1'], + 'comments.tags' => ['1'] + } + + join_manager = JSONAPI::ActiveRelationResourceFinder::JoinManager.new(resource_klass: Api::V9::PostResource, filters: filters) + records = Api::V9::PostResource.records({}) + records = join_manager.join(records, {}) + + # Note sql is in different order, but aliases should still be right + if Rails::VERSION::MAJOR >= 5 && Rails::VERSION::MINOR >= 2 + assert_equal 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "people" ON "people"."id" = "posts"."author_id" LEFT OUTER JOIN "comments" ON "comments"."post_id" = "posts"."id" LEFT OUTER JOIN "people" "authors_comments" ON "authors_comments"."id" = "comments"."author_id" LEFT OUTER JOIN "comments_tags" ON "comments_tags"."comment_id" = "comments"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "comments_tags"."tag_id" WHERE "comments"."approved" = 1 AND "author"."special" = 1', records.to_sql + else + assert_equal 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "people" ON "people"."id" = "posts"."author_id" LEFT OUTER JOIN "comments" ON "comments"."post_id" = "posts"."id" LEFT OUTER JOIN "people" "authors_comments" ON "authors_comments"."id" = "comments"."author_id" LEFT OUTER JOIN "comments_tags" ON "comments_tags"."comment_id" = "comments"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "comments_tags"."tag_id" WHERE "comments"."approved" = \'t\' AND "author"."special" = \'t\'', records.to_sql + end + assert_hash_equals({alias: 'posts', join_type: :root}, join_manager.source_join_details) + assert_hash_equals({alias: 'comments', join_type: :left}, join_manager.join_details_by_relationship(Api::V9::PostResource._relationship(:comments))) + assert_hash_equals({alias: 'authors_comments', join_type: :left}, join_manager.join_details_by_relationship(Api::V9::CommentResource._relationship(:author))) + assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(Api::V9::CommentResource._relationship(:tags))) + assert_hash_equals({alias: 'people', join_type: :left}, join_manager.join_details_by_relationship(Api::V9::PostResource._relationship(:author))) + + # Easier to read SQL to show joins are the same, but in different order + # Pass 1 + # SELECT "posts".* FROM "posts" + # LEFT OUTER JOIN "comments" ON "comments"."post_id" = "posts"."id" + # LEFT OUTER JOIN "people" ON "people"."id" = "posts"."author_id" + # LEFT OUTER JOIN "people" "authors_comments" ON "authors_comments"."id" = "comments"."author_id" + # LEFT OUTER JOIN "comments_tags" ON "comments_tags"."comment_id" = "comments"."id" + # LEFT OUTER JOIN "tags" ON "tags"."id" = "comments_tags"."tag_id" WHERE "comments"."approved" = 1 AND "author"."special" = 1 + # + # Pass 2 + # SELECT "posts".* FROM "posts" + # LEFT OUTER JOIN "people" ON "people"."id" = "posts"."author_id" + # LEFT OUTER JOIN "comments" ON "comments"."post_id" = "posts"."id" + # LEFT OUTER JOIN "people" "authors_comments" ON "authors_comments"."id" = "comments"."author_id" + # LEFT OUTER JOIN "comments_tags" ON "comments_tags"."comment_id" = "comments"."id" + # LEFT OUTER JOIN "tags" ON "tags"."id" = "comments_tags"."tag_id" WHERE "comments"."approved" = 1 AND "author"."special" = 1 + end + + def test_add_nested_joins_with_fields + filters = { + 'comments.author.name' => ['1'], + 'comments.tags.id' => ['1'], + 'author.foo' => ['1'] + } + + join_manager = JSONAPI::ActiveRelationResourceFinder::JoinManager.new(resource_klass: Api::V9::PostResource, filters: filters) + records = Api::V9::PostResource.records({}) + records = join_manager.join(records, {}) + + if Rails::VERSION::MAJOR >= 5 && Rails::VERSION::MINOR >= 2 + assert_equal 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "comments" ON "comments"."post_id" = "posts"."id" LEFT OUTER JOIN "people" ON "people"."id" = "posts"."author_id" LEFT OUTER JOIN "people" "authors_comments" ON "authors_comments"."id" = "comments"."author_id" LEFT OUTER JOIN "comments_tags" ON "comments_tags"."comment_id" = "comments"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "comments_tags"."tag_id" WHERE "comments"."approved" = 1 AND "author"."special" = 1', records.to_sql + else + assert_equal 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "comments" ON "comments"."post_id" = "posts"."id" LEFT OUTER JOIN "people" ON "people"."id" = "posts"."author_id" LEFT OUTER JOIN "people" "authors_comments" ON "authors_comments"."id" = "comments"."author_id" LEFT OUTER JOIN "comments_tags" ON "comments_tags"."comment_id" = "comments"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "comments_tags"."tag_id" WHERE "comments"."approved" = \'t\' AND "author"."special" = \'t\'', records.to_sql + end + + assert_hash_equals({alias: 'posts', join_type: :root}, join_manager.source_join_details) + assert_hash_equals({alias: 'comments', join_type: :left}, join_manager.join_details_by_relationship(Api::V9::PostResource._relationship(:comments))) + assert_hash_equals({alias: 'authors_comments', join_type: :left}, join_manager.join_details_by_relationship(Api::V9::CommentResource._relationship(:author))) + assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(Api::V9::CommentResource._relationship(:tags))) + assert_hash_equals({alias: 'people', join_type: :left}, join_manager.join_details_by_relationship(Api::V9::PostResource._relationship(:author))) + end + + def test_add_joins_with_sub_relationship + relationships = %w(author author.comments tags) + + join_manager = JSONAPI::ActiveRelationResourceFinder::JoinManager.new(resource_klass: Api::V9::PostResource, relationships: relationships, + source_relationship: Api::V9::PostResource._relationship(:comments)) + records = Api::V9::PostResource.records({}) + records = join_manager.join(records, {}) + + if Rails::VERSION::MAJOR >= 5 && Rails::VERSION::MINOR >= 2 + assert_equal 'SELECT "posts".* FROM "posts" INNER JOIN "comments" ON "comments"."post_id" = "posts"."id" LEFT OUTER JOIN "people" ON "people"."id" = "comments"."author_id" LEFT OUTER JOIN "comments_tags" ON "comments_tags"."comment_id" = "comments"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "comments_tags"."tag_id" LEFT OUTER JOIN "comments" "comments_people" ON "comments_people"."author_id" = "people"."id" WHERE "comments"."approved" = 1 AND "author"."special" = 1', records.to_sql + else + assert_equal 'SELECT "posts".* FROM "posts" INNER JOIN "comments" ON "comments"."post_id" = "posts"."id" LEFT OUTER JOIN "people" ON "people"."id" = "comments"."author_id" LEFT OUTER JOIN "comments_tags" ON "comments_tags"."comment_id" = "comments"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "comments_tags"."tag_id" LEFT OUTER JOIN "comments" "comments_people" ON "comments_people"."author_id" = "people"."id" WHERE "comments"."approved" = \'t\' AND "author"."special" = \'t\'', records.to_sql + end + + assert_hash_equals({alias: 'comments', join_type: :inner}, join_manager.source_join_details) + assert_hash_equals({alias: 'comments', join_type: :inner}, join_manager.join_details_by_relationship(Api::V9::PostResource._relationship(:comments))) + assert_hash_equals({alias: 'people', join_type: :left}, join_manager.join_details_by_relationship(Api::V9::CommentResource._relationship(:author))) + assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(Api::V9::CommentResource._relationship(:tags))) + assert_hash_equals({alias: 'comments_people', join_type: :left}, join_manager.join_details_by_relationship(Api::V9::PersonResource._relationship(:comments))) + end + + def test_add_joins_with_sub_relationship_and_filters + filters = { + 'author.name' => ['1'], + 'author.comments.name' => ['Foo'] + } + + relationships = %w(author author.comments tags) + + join_manager = JSONAPI::ActiveRelationResourceFinder::JoinManager.new(resource_klass: PostResource, + filters: filters, + relationships: relationships, + source_relationship: PostResource._relationship(:comments)) + records = PostResource.records({}) + records = join_manager.join(records, {}) + + assert_hash_equals({alias: 'comments', join_type: :inner}, join_manager.source_join_details) + assert_hash_equals({alias: 'comments', join_type: :inner}, join_manager.join_details_by_relationship(PostResource._relationship(:comments))) + assert_hash_equals({alias: 'people', join_type: :left}, join_manager.join_details_by_relationship(CommentResource._relationship(:author))) + assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(CommentResource._relationship(:tags))) + assert_hash_equals({alias: 'comments_people', join_type: :left}, join_manager.join_details_by_relationship(PersonResource._relationship(:comments))) + end + + def test_polymorphic_join_belongs_to_just_source + join_manager = JSONAPI::ActiveRelationResourceFinder::JoinManager.new(resource_klass: PictureResource, + source_relationship: PictureResource._relationship(:imageable)) + + records = PictureResource.records({}) + records = join_manager.join(records, {}) + + # assert_equal 'SELECT "pictures".* FROM "pictures" LEFT OUTER JOIN "products" ON "products"."id" = "pictures"."imageable_id" AND "pictures"."imageable_type" = \'Product\' LEFT OUTER JOIN "documents" ON "documents"."id" = "pictures"."imageable_id" AND "pictures"."imageable_type" = \'Document\'', records.to_sql + assert_hash_equals({alias: 'products', join_type: :left}, join_manager.source_join_details('products')) + assert_hash_equals({alias: 'documents', join_type: :left}, join_manager.source_join_details('documents')) + assert_hash_equals({alias: 'products', join_type: :left}, join_manager.join_details_by_polymorphic_relationship(PictureResource._relationship(:imageable), 'products')) + assert_hash_equals({alias: 'documents', join_type: :left}, join_manager.join_details_by_polymorphic_relationship(PictureResource._relationship(:imageable), 'documents')) + end + + def test_polymorphic_join_belongs_to_filter + filters = {'imageable' => ['Foo']} + join_manager = JSONAPI::ActiveRelationResourceFinder::JoinManager.new(resource_klass: PictureResource, filters: filters) + + records = PictureResource.records({}) + records = join_manager.join(records, {}) + + # assert_equal 'SELECT "pictures".* FROM "pictures" LEFT OUTER JOIN "products" ON "products"."id" = "pictures"."imageable_id" AND "pictures"."imageable_type" = \'Product\' LEFT OUTER JOIN "documents" ON "documents"."id" = "pictures"."imageable_id" AND "pictures"."imageable_type" = \'Document\'', records.to_sql + assert_hash_equals({alias: 'pictures', join_type: :root}, join_manager.source_join_details) + assert_hash_equals({alias: 'products', join_type: :left}, join_manager.join_details_by_polymorphic_relationship(PictureResource._relationship(:imageable), 'products')) + assert_hash_equals({alias: 'documents', join_type: :left}, join_manager.join_details_by_polymorphic_relationship(PictureResource._relationship(:imageable), 'documents')) + end + + def test_polymorphic_join_belongs_to_filter_on_resource + filters = { + 'imageable#documents.name' => ['foo'] + } + + relationships = %w(imageable file_properties) + join_manager = JSONAPI::ActiveRelationResourceFinder::JoinManager.new(resource_klass: PictureResource, + filters: filters, + relationships: relationships) + + records = PictureResource.records({}) + records = join_manager.join(records, {}) + + assert_equal 'SELECT "pictures".* FROM "pictures" LEFT OUTER JOIN "documents" ON "documents"."id" = "pictures"."imageable_id" AND "pictures"."imageable_type" = \'Document\' LEFT OUTER JOIN "products" ON "products"."id" = "pictures"."imageable_id" AND "pictures"."imageable_type" = \'Product\' LEFT OUTER JOIN "file_properties" ON "file_properties"."fileable_id" = "pictures"."id" AND "file_properties"."fileable_type" = \'Picture\'', records.to_sql + assert_hash_equals({alias: 'pictures', join_type: :root}, join_manager.source_join_details) + assert_hash_equals({alias: 'products', join_type: :left}, join_manager.join_details_by_polymorphic_relationship(PictureResource._relationship(:imageable), 'products')) + assert_hash_equals({alias: 'documents', join_type: :left}, join_manager.join_details_by_polymorphic_relationship(PictureResource._relationship(:imageable), 'documents')) + assert_hash_equals({alias: 'file_properties', join_type: :left}, join_manager.join_details_by_relationship(PictureResource._relationship(:file_properties))) + end +end diff --git a/test/unit/active_relation_resource_finder/join_tree_test.rb b/test/unit/active_relation_resource_finder/join_tree_test.rb deleted file mode 100644 index acf8c07f0..000000000 --- a/test/unit/active_relation_resource_finder/join_tree_test.rb +++ /dev/null @@ -1,289 +0,0 @@ -require File.expand_path('../../../test_helper', __FILE__) -require 'jsonapi-resources' - -class JoinTreeTest < ActiveSupport::TestCase - - def test_no_added_joins - join_tree = JSONAPI::ActiveRelationResourceFinder::JoinTree.new(resource_klass: PostResource) - - assert_hash_equals({root: {alias: 'posts', join_type: :root }, '' => {alias: 'posts', join_type: :root}}, join_tree.joins) - end - - def test_add_single_join - filters = {'tags' => ['1']} - join_tree = JSONAPI::ActiveRelationResourceFinder::JoinTree.new(resource_klass: PostResource, filters: filters) - assert_hash_equals( - { - root: {alias: 'posts', join_type: :root}, - '' => {alias: 'posts', join_type: :root}, - 'tags' => {alias: nil, join_type: :inner, relation_join_hash: {'tags' => {}}} - }, - join_tree.joins) - end - - def test_add_single_sort_join - sort_criteria = [ {field: 'tags.name', direction: :desc}] - join_tree = JSONAPI::ActiveRelationResourceFinder::JoinTree.new(resource_klass: PostResource, sort_criteria: sort_criteria) - assert_hash_equals( - { - root: {alias: 'posts', join_type: :root}, - '' => {alias: 'posts', join_type: :root}, - 'tags' => {alias: nil, join_type: :left, relation_join_hash: {'tags' => {}}} - }, - join_tree.joins) - end - - def test_add_single_sort_and_filter_join - filters = {'tags' => ['1']} - sort_criteria = [ {field: 'tags.name', direction: :desc}] - join_tree = JSONAPI::ActiveRelationResourceFinder::JoinTree.new(resource_klass: PostResource, sort_criteria: sort_criteria, filters: filters) - assert_hash_equals( - { - root: {alias: 'posts', join_type: :root}, - '' => {alias: 'posts', join_type: :root}, - 'tags' => {alias: nil, join_type: :inner, relation_join_hash: {'tags' => {}}} - }, - join_tree.joins) - end - - def test_add_sibling_joins - filters = { - 'tags' => ['1'], - 'author' => ['1'] - } - - join_tree = JSONAPI::ActiveRelationResourceFinder::JoinTree.new(resource_klass: PostResource, filters: filters) - - assert_hash_equals( - { - root: {alias: 'posts', join_type: :root}, - '' => {alias: 'posts', join_type: :root}, - 'tags' => {alias: nil, join_type: :inner, relation_join_hash: {'tags' => {}}}, - 'author' => {alias: nil, join_type: :inner, relation_join_hash: {'author' => {}}} - }, - join_tree.joins) - end - - - def test_add_joins_source_relationship - join_tree = JSONAPI::ActiveRelationResourceFinder::JoinTree.new(resource_klass: PostResource, - source_relationship: PostResource._relationship(:comments)) - joins = join_tree.joins - assert_hash_equals( - { - root: {alias: 'posts', join_type: :root}, - '' => {alias: nil, join_type: :inner, relation_join_hash: {'comments' => {}}}, - }, - joins) - end - - def test_add_nested_joins - filters = { - 'comments.author' => ['1'], - 'comments.tags' => ['1'], - 'author' => ['1'] - } - - join_tree = JSONAPI::ActiveRelationResourceFinder::JoinTree.new(resource_klass: PostResource, filters: filters) - joins = join_tree.joins - assert_hash_equals( - { - root: {alias: 'posts', join_type: :root}, - '' => {alias: 'posts', join_type: :root}, - 'comments' => {alias: nil, join_type: :inner, relation_join_hash: { 'comments' => {}}}, - 'comments.author' => {alias: nil, join_type: :inner, relation_join_hash: { 'comments' => { 'author' => {}}}}, - 'comments.tags' => {alias: nil, join_type: :inner, relation_join_hash: { 'comments' => { 'tags' => {}}}}, - 'author' => {alias: nil, join_type: :inner, relation_join_hash: {'author' => {}}} - }, - joins) - end - - def test_add_nested_joins_with_fields - filters = { - 'comments.author.name' => ['1'], - 'comments.tags.id' => ['1'], - 'author.foo' => ['1'] - } - - join_tree = JSONAPI::ActiveRelationResourceFinder::JoinTree.new(resource_klass: PostResource, filters: filters) - - assert_hash_equals( - { - root: {alias: 'posts', join_type: :root}, - '' => {alias: 'posts', join_type: :root}, - 'comments' => {alias: nil, join_type: :inner, relation_join_hash: { 'comments' => {}}}, - 'comments.author' => {alias: nil, join_type: :inner, relation_join_hash: { 'comments' => { 'author' => {}}}}, - 'comments.tags' => {alias: nil, join_type: :inner, relation_join_hash: { 'comments' => { 'tags' => {}}}}, - 'author' => {alias: nil, join_type: :inner, relation_join_hash: {'author' => {}}} - }, - join_tree.joins) - end - - def test_add_joins_with_fields_not_from_relationship - filters = { - 'author.name' => ['1'], - 'author.comments.name' => ['Foo'], - 'tags.id' => ['1'] - } - - join_tree = JSONAPI::ActiveRelationResourceFinder::JoinTree.new(resource_klass: PostResource, - filters: filters) - - joins = join_tree.joins - assert_hash_equals( - { - root: {alias: 'posts', join_type: :root}, - '' => {alias: 'posts', join_type: :root}, - 'author' => {alias: nil, join_type: :inner, relation_join_hash: {'author' => {}}}, - 'author.comments' => {alias: nil, join_type: :inner, relation_join_hash: { 'author' => { 'comments' => {}}}}, - 'tags' => {alias: nil, join_type: :inner, relation_join_hash: {'tags' => {}}}, - }, - joins) - end - - def test_add_joins_with_fields_from_relationship - filters = { - 'author.name' => ['1'], - 'author.comments.name' => ['Foo'], - 'tags.id' => ['1'] - } - - join_tree = JSONAPI::ActiveRelationResourceFinder::JoinTree.new(resource_klass: PostResource, - filters: filters, - source_relationship: PostResource._relationship(:comments)) - - assert_hash_equals( - { - root: {alias: 'posts', join_type: :root}, - '' => {alias: nil, join_type: :inner, relation_join_hash: {'comments' => {}}}, - 'author' => {alias: nil, join_type: :inner, relation_join_hash: {'comments' => { 'author' => {}}}}, - 'author.comments' => {alias: nil, join_type: :inner, relation_join_hash: { 'comments' => { 'author' => { 'comments' => {}}}}}, - 'tags' => {alias: nil, join_type: :inner, relation_join_hash: {'comments' => { 'tags' => {}}}} - }, - join_tree.joins) - - assert join_tree.joins.keys.include?(:root), 'Root must be a symbol' - refute join_tree.joins.keys.include?('root'), 'Root must be a symbol' - refute join_tree.joins.keys.include?(:tags), 'Relationship names must be a string' - assert join_tree.joins.keys.include?('tags'), 'Relationship names must be a string' - end - - def test_add_joins_with_sub_relationship - relationships = %w(author author.comments tags) - - join_tree = JSONAPI::ActiveRelationResourceFinder::JoinTree.new(resource_klass: PostResource, - relationships: relationships, - source_relationship: PostResource._relationship(:comments)) - - assert_hash_equals( - { - root: {alias: 'posts', join_type: :root}, - '' => {alias: nil, join_type: :inner, relation_join_hash: {'comments' => {}}}, - 'author' => {alias: nil, join_type: :left, relation_join_hash: {'comments' => { 'author' => {}}}}, - 'author.comments' => {alias: nil, join_type: :left, relation_join_hash: { 'comments' => { 'author' => { 'comments' => {}}}}}, - 'tags' => {alias: nil, join_type: :left, relation_join_hash: {'comments' => { 'tags' => {}}}} - }, - join_tree.joins) - end - - def test_add_joins_with_sub_relationship_and_filters - filters = { - 'author.name' => ['1'], - 'author.comments.name' => ['Foo'] - } - - relationships = %w(author author.comments tags) - - join_tree = JSONAPI::ActiveRelationResourceFinder::JoinTree.new(resource_klass: PostResource, - filters:filters, - relationships: relationships, - source_relationship: PostResource._relationship(:comments)) - - assert_hash_equals( - { - root: {alias: 'posts', join_type: :root}, - '' => {alias: nil, join_type: :inner, relation_join_hash: {'comments' => {}}}, - 'author' => {alias: nil, join_type: :inner, relation_join_hash: {'comments' => { 'author' => {}}}}, - 'author.comments' => {alias: nil, join_type: :inner, relation_join_hash: { 'comments' => { 'author' => { 'comments' => {}}}}}, - 'tags' => {alias: nil, join_type: :left, relation_join_hash: {'comments' => { 'tags' => {}}}} - }, - join_tree.joins) - end - - def test_polymorphic_join_belongs_to_just_source - join_tree = JSONAPI::ActiveRelationResourceFinder::JoinTree.new(resource_klass: PictureResource, - source_relationship: PictureResource._relationship(:imageable)) - - joins = join_tree.joins - assert_hash_equals( - { - root: { alias: 'pictures', join_type: :root}, - '#products' => {alias: nil, join_type: :left, relation_join_hash: {'product' => {}}}, - '#documents' => {alias: nil, join_type: :left, relation_join_hash: {'document' => {}}} - }, - joins) - end - - def test_polymorphic_join_belongs_to_filter - filters = {'imageable' => ['Foo']} - join_tree = JSONAPI::ActiveRelationResourceFinder::JoinTree.new(resource_klass: PictureResource, filters: filters) - - joins = join_tree.joins - assert_hash_equals( - { - root: { alias: 'pictures', join_type: :root}, - '' => {alias: 'pictures', join_type: :root}, - 'imageable#products' => {alias: nil, join_type: :left, relation_join_hash: {'product' => {}}}, - 'imageable#documents' => {alias: nil, join_type: :left, relation_join_hash: {'document' => {}}} - }, - joins) - end - - def test_polymorphic_join_belongs_to_filter_on_resource - filters = { - 'imageable#documents.name' => ['foo'] - } - - relationships = %w(imageable file_properties) - join_tree = JSONAPI::ActiveRelationResourceFinder::JoinTree.new(resource_klass: PictureResource, - filters: filters, - relationships: relationships) - assert_hash_equals( - { - root: { alias: 'pictures', join_type: :root}, - '' => {alias: 'pictures', join_type: :root}, - 'imageable#documents' => {alias: nil, join_type: :left, relation_join_hash: {'document' => {}}}, - 'imageable#products' => {alias: nil, join_type: :left, relation_join_hash: {'product' => {}}}, - 'file_properties' => {alias: nil, join_type: :left, relation_join_hash: {'file_properties' => {}}} - }, - join_tree.joins) - end - - def test_polymorphic_join_to_one - relationships = %w(file_properties) - join_tree = JSONAPI::ActiveRelationResourceFinder::JoinTree.new(resource_klass: PictureResource, - relationships: relationships) - assert_hash_equals( - { - root: { alias: 'pictures', join_type: :root}, - '' => {alias: 'pictures', join_type: :root}, - 'file_properties' => {alias: nil, join_type: :left, relation_join_hash: {'file_properties' => {}}} - }, - join_tree.joins) - end - - def test_polymorphic_relationship - relationships = %w(imageable file_properties) - join_tree = JSONAPI::ActiveRelationResourceFinder::JoinTree.new(resource_klass: PictureResource, - relationships: relationships) - assert_hash_equals( - { - root: { alias: 'pictures', join_type: :root}, - '' => {alias: 'pictures', join_type: :root}, - 'imageable#products' => {alias: nil, join_type: :left, relation_join_hash: {'product' => {}}}, - 'imageable#documents' => {alias: nil, join_type: :left, relation_join_hash: {'document' => {}}}, - 'file_properties' => {alias: nil, join_type: :left, relation_join_hash: {'file_properties' => {}}} - }, - join_tree.joins) - end -end From 47b45e62914f4d15e11abd00c46c17a8ae07d237 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Sat, 23 Feb 2019 07:07:03 -0500 Subject: [PATCH 127/237] Add ruby 2.6.1 testing and temporarily disable rails 6/master There's no sense making the travis build take longer when rails 6 will surely fail. --- .travis.yml | 8 +++++++- test/test_helper.rb | 1 + 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index ea9bbe781..837e2faec 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,11 +5,17 @@ env: - "RAILS_VERSION=5.0.7.1" - "RAILS_VERSION=5.1.6.1" - "RAILS_VERSION=5.2.2" - - "RAILS_VERSION=master" +# - "RAILS_VERSION=6.0.0.beta1" +# - "RAILS_VERSION=master" rvm: - 2.3.8 - 2.4.5 - 2.5.3 + - 2.6.1 matrix: allow_failures: - env: "RAILS_VERSION=master" + - env: "RAILS_VERSION=6.0.0.beta1" + exclude: + - rvm: 2.6.1 + env: "RAILS_VERSION=4.2.11" diff --git a/test/test_helper.rb b/test/test_helper.rb index dc5b5fdf3..09ed740b2 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -7,6 +7,7 @@ # export RAILS_VERSION=4.2.6; bundle update rails; bundle exec rake test # export RAILS_VERSION=5.0.0; bundle update rails; bundle exec rake test # export RAILS_VERSION=5.1.0; bundle update rails; bundle exec rake test +# export RAILS_VERSION=6.0.0.beta1; bundle update rails; bundle exec rake test # We are no longer having Travis test Rails 4.1.x., but you can try it with: # export RAILS_VERSION=4.1.0; bundle update rails; bundle exec rake test From 80285b97b7cc38795e5fcd442d776441738268cd Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Mon, 25 Feb 2019 08:34:29 -0500 Subject: [PATCH 128/237] Add note about duplicate alias check --- .../active_relation_resource_finder/join_manager.rb | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lib/jsonapi/active_relation_resource_finder/join_manager.rb b/lib/jsonapi/active_relation_resource_finder/join_manager.rb index 0ab087962..06981a0a5 100644 --- a/lib/jsonapi/active_relation_resource_finder/join_manager.rb +++ b/lib/jsonapi/active_relation_resource_finder/join_manager.rb @@ -123,6 +123,13 @@ def add_join_details(join_key, details, check_for_duplicate_alias = true) fail "details already set" if @join_details.has_key?(join_key) @join_details[join_key] = details + # Joins are being tracked as they are added to the built up relation. If the same table is added to a + # relation more than once subsequent versions will be assigned an alias. Depending on the order the joins + # are made the computed aliases may change. The order this library performs the joins was chosen + # to prevent this. However if the relation is reordered it should result in reusing on of the earlier + # aliases (in this case a plain table name). The following check will catch this an raise an exception. + # An exception is appropriate because not using the correct alias could leak data due to filters and + # applied permissions being performed on the wrong data. if check_for_duplicate_alias && @collected_aliases.include?(details[:alias]) fail "alias '#{details[:alias]}' has already been added. Possible relation reordering" end @@ -166,6 +173,8 @@ def perform_joins(records, options) end end + # We're adding the source alias with two keys. We only want the check for duplicate aliases once. + # See the note in `add_join_details`. check_for_duplicate_alias = !(relationship == source_relationship) add_join_details(PathSegment::Relationship.new(relationship: relationship, resource_klass: related_resource_klass), details, check_for_duplicate_alias) end From 924f5c535ba93c669cdaf172d7c2f8ff1c918b0e Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Fri, 22 Feb 2019 17:43:42 -0500 Subject: [PATCH 129/237] Rename `find_records` to allow detection of overrides that will break during upgrade --- .../active_relation_resource_finder.rb | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/lib/jsonapi/active_relation_resource_finder.rb b/lib/jsonapi/active_relation_resource_finder.rb index 8a0406e65..f37774132 100644 --- a/lib/jsonapi/active_relation_resource_finder.rb +++ b/lib/jsonapi/active_relation_resource_finder.rb @@ -25,9 +25,8 @@ def find(filters, options = {}) paginator = options[:paginator] - records = find_records(records: records(options), - sort_criteria: sort_criteria, - filters: filters, + records = apply_request_settings_to_records(records: records(options), + sort_criteria: sort_criteria,filters: filters, join_manager: join_manager, paginator: paginator, options: options) @@ -45,7 +44,7 @@ def count(filters, options = {}) join_manager = JoinManager.new(resource_klass: self, filters: filters) - records = find_records(records: records(options), + records = apply_request_settings_to_records(records: records(options), filters: filters, join_manager: join_manager, options: options) @@ -100,7 +99,7 @@ def find_fragments(filters, options = {}) paginator = options[:paginator] - records = find_records(records: records(options), + records = apply_request_settings_to_records(records: records(options), filters: filters, sort_criteria: sort_criteria, paginator: paginator, @@ -229,7 +228,7 @@ def count_related(source_rid, relationship_name, options = {}) source_relationship: relationship, filters: filters) - records = find_records(records: records(options), + records = apply_request_settings_to_records(records: records(options), resource_klass: related_klass, primary_keys: source_rid.id, join_manager: join_manager, @@ -306,13 +305,13 @@ def to_one_relationships_for_linkage(include_related) end def find_record_by_key(key, options = {}) - record = find_records(records: records(options), primary_keys: key, options: options).first + record = apply_request_settings_to_records(records: records(options), primary_keys: key, options: options).first fail JSONAPI::Exceptions::RecordNotFound.new(key) if record.nil? record end def find_records_by_keys(keys, options = {}) - find_records(records: records(options), primary_keys: keys, options: options) + apply_request_settings_to_records(records: records(options), primary_keys: keys, options: options) end def find_related_monomorphic_fragments(source_rids, relationship, options, connect_source_identity) @@ -337,7 +336,7 @@ def find_related_monomorphic_fragments(source_rids, relationship, options, conne paginator = options[:paginator] if source_rids.count == 1 - records = find_records(records: records(options), + records = apply_request_settings_to_records(records: records(options), resource_klass: resource_klass, sort_criteria: sort_criteria, primary_keys: source_ids, @@ -464,7 +463,7 @@ def find_related_polymorphic_fragments(source_rids, relationship, options, conne # Note: We will sort by the source table. Without using unions we can't sort on a polymorphic relationship # in any manner that makes sense - records = find_records(records: records(options), + records = apply_request_settings_to_records(records: records(options), resource_klass: resource_klass, sort_primary: true, primary_keys: source_ids, @@ -615,7 +614,7 @@ def find_related_polymorphic_fragments(source_rids, relationship, options, conne related_fragments end - def find_records(records:, + def apply_request_settings_to_records(records:, join_manager: JoinManager.new(resource_klass: self), resource_klass: self, filters: {}, From dd31121ca464994337245fc2894dbae35babd03d Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Fri, 22 Feb 2019 17:51:14 -0500 Subject: [PATCH 130/237] Add rake task to check the upgrade for orphaned overrides --- lib/jsonapi-resources.rb | 1 + lib/jsonapi/resources/railtie.rb | 9 ++++++ lib/tasks/check_upgrade.rake | 52 ++++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+) create mode 100644 lib/jsonapi/resources/railtie.rb create mode 100644 lib/tasks/check_upgrade.rake diff --git a/lib/jsonapi-resources.rb b/lib/jsonapi-resources.rb index bafa53788..76580e2a0 100644 --- a/lib/jsonapi-resources.rb +++ b/lib/jsonapi-resources.rb @@ -1,3 +1,4 @@ +require 'jsonapi/resources/railtie' require 'jsonapi/naive_cache' require 'jsonapi/compiled_json' require 'jsonapi/resource' diff --git a/lib/jsonapi/resources/railtie.rb b/lib/jsonapi/resources/railtie.rb new file mode 100644 index 000000000..a2d92c1c5 --- /dev/null +++ b/lib/jsonapi/resources/railtie.rb @@ -0,0 +1,9 @@ +module JSONAPI + module Resources + class Railtie < Rails::Railtie + rake_tasks do + load 'tasks/check_upgrade.rake' + end + end + end +end \ No newline at end of file diff --git a/lib/tasks/check_upgrade.rake b/lib/tasks/check_upgrade.rake new file mode 100644 index 000000000..34ddef4a6 --- /dev/null +++ b/lib/tasks/check_upgrade.rake @@ -0,0 +1,52 @@ +require 'rake' +require 'jsonapi-resources' + +namespace :jsonapi do + namespace :resources do + desc 'Checks application for orphaned overrides' + task :check_upgrade => :environment do + Rails.application.eager_load! + + resource_klasses = ObjectSpace.each_object(Class).select { |klass| klass < JSONAPI::Resource} + + puts "Checking #{resource_klasses.count} resources" + + issues_found = 0 + + klasses_with_deprecated = resource_klasses.select { |klass| klass.methods.include?(:find_records) } + unless klasses_with_deprecated.empty? + puts " Found the following resources the still implement `find_records`:" + klasses_with_deprecated.each { |klass| puts " #{klass}"} + puts " The `find_records` method is no longer called by JR. Please review and ensure your functionality is ported over." + + issues_found = issues_found + klasses_with_deprecated.length + end + + klasses_with_deprecated = resource_klasses.select { |klass| klass.methods.include?(:records_for) } + unless klasses_with_deprecated.empty? + puts " Found the following resources the still implement `records_for`:" + klasses_with_deprecated.each { |klass| puts " #{klass}"} + puts " The `records_for` method is no longer called by JR. Please review and ensure your functionality is ported over." + + issues_found = issues_found + klasses_with_deprecated.length + end + + klasses_with_deprecated = resource_klasses.select { |klass| klass.methods.include?(:apply_includes) } + unless klasses_with_deprecated.empty? + puts " Found the following resources the still implement `apply_includes`:" + klasses_with_deprecated.each { |klass| puts " #{klass}"} + puts " The `apply_includes` method is no longer called by JR. Please review and ensure your functionality is ported over." + + issues_found = issues_found + klasses_with_deprecated.length + end + + if issues_found > 0 + puts "Finished inspection. #{issues_found} issues found that may impact upgrading. Please address these issues. " + else + puts "Finished inspection with no issues found. Note this is only a cursory check for method overrides that will no \n" \ + "longer be called by JSONAPI::Resources. This check in no way assures your code will continue to function as \n" \ + "it did before the upgrade. Please do adequate testing before using in production." + end + end + end +end From 1d328ea4e0f3da28bc369a1b2a915cb9120eed39 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Mon, 25 Feb 2019 11:50:10 -0500 Subject: [PATCH 131/237] Bump jsonapi-resources to 0.10.0.beta3 --- lib/jsonapi/resources/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/jsonapi/resources/version.rb b/lib/jsonapi/resources/version.rb index b9fe18940..dbb0801ab 100644 --- a/lib/jsonapi/resources/version.rb +++ b/lib/jsonapi/resources/version.rb @@ -1,5 +1,5 @@ module JSONAPI module Resources - VERSION = '0.10.0.beta2' + VERSION = '0.10.0.beta3' end end From 77610a31f3a71dc22226616c021b2660ae680eda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Scott=20Gonz=C3=A1lez?= Date: Wed, 27 Feb 2019 15:41:41 -0500 Subject: [PATCH 132/237] Fix typo in deprecation warning --- lib/jsonapi/acts_as_resource_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/jsonapi/acts_as_resource_controller.rb b/lib/jsonapi/acts_as_resource_controller.rb index 3a00e1268..9295831b2 100644 --- a/lib/jsonapi/acts_as_resource_controller.rb +++ b/lib/jsonapi/acts_as_resource_controller.rb @@ -70,7 +70,7 @@ def get_related_resource def get_related_resources # :nocov: ActiveSupport::Deprecation.warn "In #{self.class.name} you exposed a `get_related_resources`"\ - " action. Please use `index_related_resource` instead." + " action. Please use `index_related_resources` instead." index_related_resources # :nocov: end From 0fed6d8a1edb5818a73557788d87db63e80671ad Mon Sep 17 00:00:00 2001 From: Tommy Russoniello Date: Wed, 27 Feb 2019 15:59:54 -0500 Subject: [PATCH 133/237] Remove unnecessary `distinct` from query in `Resource::find_fragments` --- lib/jsonapi/active_relation_resource_finder.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/jsonapi/active_relation_resource_finder.rb b/lib/jsonapi/active_relation_resource_finder.rb index f37774132..b72e0a427 100644 --- a/lib/jsonapi/active_relation_resource_finder.rb +++ b/lib/jsonapi/active_relation_resource_finder.rb @@ -149,7 +149,7 @@ def find_fragments(filters, options = {}) end fragments = {} - rows = records.distinct.pluck(*pluck_fields) + rows = records.pluck(*pluck_fields) rows.collect do |row| rid = JSONAPI::ResourceIdentity.new(resource_klass, pluck_fields.length == 1 ? row : row[0]) @@ -777,4 +777,4 @@ def apply_filter(records, filter, value, options = {}) end end end -end \ No newline at end of file +end From c50b1c5f81e5ac8307c6b839d355106986ba656e Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Thu, 7 Mar 2019 09:50:47 -0500 Subject: [PATCH 134/237] Rename resource_finder config option to default_resource_finder --- lib/jsonapi/configuration.rb | 8 ++++---- lib/jsonapi/resource.rb | 2 +- test/fixtures/active_record.rb | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/jsonapi/configuration.rb b/lib/jsonapi/configuration.rb index acc30fef0..482b6e043 100644 --- a/lib/jsonapi/configuration.rb +++ b/lib/jsonapi/configuration.rb @@ -17,7 +17,7 @@ class Configuration :default_paginator, :default_page_size, :maximum_page_size, - :resource_finder, + :default_resource_finder, :default_processor_klass, :use_text_errors, :top_level_links_include_pagination, @@ -109,7 +109,7 @@ def initialize # The default ResourceFinder is the ActiveRelationResourceFinder which provides # access to ActiveRelation backed models. Custom ResourceFinders can be specified # in order to support other ORMs. - self.resource_finder = JSONAPI::ActiveRelationResourceFinder + self.default_resource_finder = JSONAPI::ActiveRelationResourceFinder # The default Operation Processor to use if one is not defined specifically # for a Resource. @@ -225,8 +225,8 @@ def default_processor_klass=(default_processor_klass) @default_processor_klass = default_processor_klass end - def resource_finder=(resource_finder) - @resource_finder = resource_finder + def default_resource_finder=(default_resource_finder) + @default_resource_finder = default_resource_finder end def allow_include=(allow_include) diff --git a/lib/jsonapi/resource.rb b/lib/jsonapi/resource.rb index dccc51241..1ad2760a9 100644 --- a/lib/jsonapi/resource.rb +++ b/lib/jsonapi/resource.rb @@ -423,7 +423,7 @@ def inherited(subclass) check_reserved_resource_name(subclass._type, subclass.name) - subclass.include JSONAPI.configuration.resource_finder if JSONAPI.configuration.resource_finder + subclass.include JSONAPI.configuration.default_resource_finder if JSONAPI.configuration.default_resource_finder end # A ResourceFinder is a mixin that adds functionality to find Resources and Resource Fragments diff --git a/test/fixtures/active_record.rb b/test/fixtures/active_record.rb index cbcd1a167..84fbcc702 100644 --- a/test/fixtures/active_record.rb +++ b/test/fixtures/active_record.rb @@ -1557,7 +1557,7 @@ def find_breeds_by_keys(keys, options = {}) end end -JSONAPI.configuration.resource_finder = BreedResourceFinder +JSONAPI.configuration.default_resource_finder = BreedResourceFinder class BreedResource < JSONAPI::Resource attribute :name, format: :title @@ -1569,7 +1569,7 @@ def _save return :accepted end end -JSONAPI.configuration.resource_finder = JSONAPI::ActiveRelationResourceFinder +JSONAPI.configuration.default_resource_finder = JSONAPI::ActiveRelationResourceFinder class PlanetResource < JSONAPI::Resource attribute :name From 013d0908b6688399e97185fe13df12896cced4dd Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Thu, 14 Mar 2019 11:19:58 -0400 Subject: [PATCH 135/237] Allow Resource to set Resource Finder Loads resource finder when found to be missing. This delayed loading enables the resource to set a resource finder that is used in place of the default. Removes the abstract methods from resource. These may vary based on the processor. Closes gh-1230 --- lib/jsonapi/resource.rb | 98 ++++++++++++---------------------- test/fixtures/active_record.rb | 5 +- 2 files changed, 36 insertions(+), 67 deletions(-) diff --git a/lib/jsonapi/resource.rb b/lib/jsonapi/resource.rb index 1ad2760a9..528cf25d9 100644 --- a/lib/jsonapi/resource.rb +++ b/lib/jsonapi/resource.rb @@ -421,75 +421,21 @@ def inherited(subclass) subclass.attribute :id, format: :id, readonly: true end - check_reserved_resource_name(subclass._type, subclass.name) - - subclass.include JSONAPI.configuration.default_resource_finder if JSONAPI.configuration.default_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: , - # cache: - # attributes: - # related: { - # : - # } - # } - # - # 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 + # Ensure that the parent's resource finder is included before inheriting from the parent is completed + if !_resource_finder_included && self != JSONAPI::Resource + include_resource_finder + end + subclass._resource_finder_included = _resource_finder_included - def find_related_fragments(_source_rids, _relationship_name, _options = {}) - # :nocov: - raise 'Abstract ResourceFinder method called. Ensure that a ResourceFinder has been set.' - # :nocov: + check_reserved_resource_name(subclass._type, subclass.name) end - def count_related(_source_rid, _relationship_name, _options = {}) - # :nocov: - raise 'Abstract ResourceFinder method called. Ensure that a ResourceFinder has been set.' - # :nocov: + # Set the resource finder for a resource, which will override the default_resource_finder + def resource_finder(resource_finder) + @resource_finder = resource_finder + include_resource_finder end - #end ResourceFinder Abstract methods - def rebuild_relationships(relationships) original_relationships = relationships.deep_dup @@ -534,7 +480,7 @@ def resource_type_for(model) end end - attr_accessor :_attributes, :_relationships, :_type, :_model_hints + attr_accessor :_attributes, :_relationships, :_type, :_model_hints, :_resource_finder_included attr_writer :_allowed_filters, :_paginator, :_allowed_sort def create(context) @@ -1070,6 +1016,28 @@ def register_relationship(name, relationship_object) end private + def _resource_finder + @resource_finder ||= JSONAPI.configuration.default_resource_finder + end + + def include_resource_finder + return if self == JSONAPI::Resource + if self._resource_finder_included + warn "#{self.name} is including a Resource Finder when one has already been included" + end + include _resource_finder + self._resource_finder_included = true + end + + def method_missing(m, *args, &block) + if _resource_finder_included + super + else + # Handle the case where a resource finder has not been included yet. This should only happen once per class. + include_resource_finder + send(m, *args, &block) + end + end def check_reserved_resource_name(type, name) if [:ids, :types, :hrefs, :links].include?(type) diff --git a/test/fixtures/active_record.rb b/test/fixtures/active_record.rb index 84fbcc702..392d3f9dd 100644 --- a/test/fixtures/active_record.rb +++ b/test/fixtures/active_record.rb @@ -1557,8 +1557,10 @@ def find_breeds_by_keys(keys, options = {}) end end -JSONAPI.configuration.default_resource_finder = BreedResourceFinder class BreedResource < JSONAPI::Resource + + resource_finder BreedResourceFinder + attribute :name, format: :title # This is unneeded, just here for testing @@ -1569,7 +1571,6 @@ def _save return :accepted end end -JSONAPI.configuration.default_resource_finder = JSONAPI::ActiveRelationResourceFinder class PlanetResource < JSONAPI::Resource attribute :name From d87cd2d2e80ab7660081c32db2303f196860b060 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Fri, 1 Mar 2019 10:06:26 -0500 Subject: [PATCH 136/237] Add specialized `records` methods, `find_to_populate_by_keys` Specialized records methods allow the system to avoid duplicating expensive permission checks for records that have already been found. This is needed because of the new multiphase approach building up the result set. Closes gh-1228 --- .../active_relation_resource_finder.rb | 65 +++++++++++++++++-- lib/jsonapi/resource_set.rb | 2 +- test/fixtures/active_record.rb | 4 ++ 3 files changed, 66 insertions(+), 5 deletions(-) diff --git a/lib/jsonapi/active_relation_resource_finder.rb b/lib/jsonapi/active_relation_resource_finder.rb index b72e0a427..b97e3355c 100644 --- a/lib/jsonapi/active_relation_resource_finder.rb +++ b/lib/jsonapi/active_relation_resource_finder.rb @@ -70,6 +70,16 @@ def find_by_keys(keys, options = {}) resources_for(records, options[:context]) end + # Returns an array of Resources identified by the `keys` array. The resources are not filtered as this + # will have been done in a prior step + # + # @param keys [Array] Array of primary keys to find resources for + # @option options [Hash] :context The context of the request, set in the controller + def find_to_populate_by_keys(keys, options = {}) + records = records_for_populate(options).where(_primary_key => keys) + resources_for(records, options[:context]) + end + # Finds Resource fragments using the `filters`. Pagination and sort options are used when provided. # Retrieving the ResourceIdentities and attributes does not instantiate a model instance. # Note: This is incompatible with Polymorphic resources (which are going to come from two separate tables) @@ -242,10 +252,57 @@ def count_related(source_rid, relationship_name, options = {}) count_records(records) end - def records(_options = {}) + # This resource finder (ActiveRecordResourceFinder) uses an `ActiveRecord::Relation` as the starting point for + # retrieving models. From this relation filters, sorts and joins are applied as needed. + # Depending on which phase of the request processing different `records` methods will be called, giving the user + # the opportunity to override them differently for performance and security reasons. + + # begin `records`methods + + # Base for the `records` methods that follow and is not directly used for accessing model data by this class. + # Overriding this method gives a single place to affect the `ActiveRecord::Relation` used for the resource. + # + # @option options [Hash] :context The context of the request, set in the controller + # + # @return [ActiveRecord::Relation] + def records_base(_options = {}) _model_class.all end + # The `ActiveRecord::Relation` used for finding user requested models. This may be overridden to enforce + # permissions checks on the request. + # + # @option options [Hash] :context The context of the request, set in the controller + # + # @return [ActiveRecord::Relation] + def records(options = {}) + records_base(options) + end + + # The `ActiveRecord::Relation` used for populating the ResourceSet. Only resources that have been previously + # identified through the `records` method will be accessed. Thus it should not be necessary to reapply permissions + # checks. However if the model needs to include other models adding `includes` is appropriate + # + # @option options [Hash] :context The context of the request, set in the controller + # + # @return [ActiveRecord::Relation] + def records_for_populate(options = {}) + records_base(options) + end + + # The `ActiveRecord::Relation` used for the finding related resources. Only resources that have been previously + # identified through the `records` method will be accessed and used as the basis to find related resources. Thus + # it should not be necessary to reapply permissions checks. + # + # @option options [Hash] :context The context of the request, set in the controller + # + # @return [ActiveRecord::Relation] + def records_for_source_to_related(options = {}) + records_base(options) + end + + # end `records` methods + def apply_join(records:, relationship:, resource_type:, join_type:, options:) if relationship.polymorphic? && relationship.belongs_to? case join_type @@ -267,7 +324,7 @@ def apply_join(records:, relationship:, resource_type:, join_type:, options:) end def relationship_records(relationship:, join_type: :inner, resource_type: nil, options: {}) - records = relationship.parent_resource.records(options) + records = relationship.parent_resource.records_for_source_to_related(options) strategy = relationship.options[:apply_join] if strategy @@ -336,7 +393,7 @@ def find_related_monomorphic_fragments(source_rids, relationship, options, conne paginator = options[:paginator] if source_rids.count == 1 - records = apply_request_settings_to_records(records: records(options), + records = apply_request_settings_to_records(records: records_for_source_to_related(options), resource_klass: resource_klass, sort_criteria: sort_criteria, primary_keys: source_ids, @@ -463,7 +520,7 @@ def find_related_polymorphic_fragments(source_rids, relationship, options, conne # Note: We will sort by the source table. Without using unions we can't sort on a polymorphic relationship # in any manner that makes sense - records = apply_request_settings_to_records(records: records(options), + records = apply_request_settings_to_records(records: records_for_source_to_related(options), resource_klass: resource_klass, sort_primary: true, primary_keys: source_ids, diff --git a/lib/jsonapi/resource_set.rb b/lib/jsonapi/resource_set.rb index 83ba5c526..ef3c285ee 100644 --- a/lib/jsonapi/resource_set.rb +++ b/lib/jsonapi/resource_set.rb @@ -91,7 +91,7 @@ def populate!(serializer, context, find_options) # Step Four find any of the missing resources and join them into the result missed_resource_ids.each_pair do |resource_klass, ids| find_opts = {context: context, fields: find_options[:fields]} - found_resources = resource_klass.find_by_keys(ids, find_opts) + found_resources = resource_klass.find_to_populate_by_keys(ids, find_opts) found_resources.each do |resource| relationship_data = @resource_klasses[resource_klass][resource.id][:relationships] diff --git a/test/fixtures/active_record.rb b/test/fixtures/active_record.rb index 392d3f9dd..e269dfc2e 100644 --- a/test/fixtures/active_record.rb +++ b/test/fixtures/active_record.rb @@ -1527,6 +1527,10 @@ def find_by_key(key, options = {}) resource_for(record, options[:context]) end + def find_to_populate_by_keys(keys, options = {}) + find_by_keys(keys, options) + end + def find_by_keys(keys, options = {}) records = find_breeds_by_keys(keys, options) resources_for(records, options[:context]) From 5d1884985d967d5935d2d1082c4d71889a075f30 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Wed, 27 Feb 2019 15:51:47 -0500 Subject: [PATCH 137/237] Remove unused `:include` IncludeDirectives key Closes gh-1226 --- lib/jsonapi/include_directives.rb | 14 ++--- lib/jsonapi/processor.rb | 3 +- .../serializer/include_directives_test.rb | 60 ++++++++++++------- 3 files changed, 44 insertions(+), 33 deletions(-) diff --git a/lib/jsonapi/include_directives.rb b/lib/jsonapi/include_directives.rb index d811c1143..c1a1d7b3b 100644 --- a/lib/jsonapi/include_directives.rb +++ b/lib/jsonapi/include_directives.rb @@ -4,14 +4,12 @@ class IncludeDirectives # For example ['posts.comments.tags'] # will transform into => # { - # posts:{ - # include:true, - # include_related:{ + # posts: { + # include_related: { # comments:{ - # include:true, - # include_related:{ - # tags:{ - # include:true + # include_related: { + # tags: { + # include_related: {} # } # } # } @@ -44,7 +42,7 @@ def parse_include(include) path.segments.each do |segment| relationship_name = segment.relationship.name.to_sym - current[:include_related][relationship_name] ||= { include: true, include_related: {} } + current[:include_related][relationship_name] ||= { include_related: {} } current = current[:include_related][relationship_name] end diff --git a/lib/jsonapi/processor.rb b/lib/jsonapi/processor.rb index 691a36205..6beb36019 100644 --- a/lib/jsonapi/processor.rb +++ b/lib/jsonapi/processor.rb @@ -429,8 +429,7 @@ def find_resource_id_tree_from_resource_relationship(resource, relationship_name def load_included(resource_klass, source_resource_id_tree, include_related, options) source_rids = source_resource_id_tree.fragments.keys - include_related.try(:each_pair) do |key, value| - next unless value[:include] + include_related.try(:each_key) do |key| relationship = resource_klass._relationship(key) relationship_name = relationship.name.to_sym diff --git a/test/unit/serializer/include_directives_test.rb b/test/unit/serializer/include_directives_test.rb index e4a336646..ad6e6710d 100644 --- a/test/unit/serializer/include_directives_test.rb +++ b/test/unit/serializer/include_directives_test.rb @@ -10,8 +10,7 @@ def test_one_level_one_include { include_related: { posts: { - include: true, - include_related:{} + include_related: {} } } }, @@ -25,22 +24,44 @@ def test_one_level_multiple_includes { include_related: { posts: { - include: true, - include_related:{} + include_related: {} }, comments: { - include: true, - include_related:{} + include_related: {} }, expense_entries: { - include: true, - include_related:{} + include_related: {} } } }, directives) end + def test_multiple_level_multiple_includes + directives = JSONAPI::IncludeDirectives.new(PersonResource, ['posts', 'posts.comments', 'comments', 'expense_entries']).include_directives + + assert_hash_equals( + { + include_related: { + posts: { + include_related: { + comments: { + include_related: {} + } + } + }, + comments: { + include_related: {} + }, + expense_entries: { + include_related: {} + } + } + }, + directives) + end + + def test_two_levels_include_full_path directives = JSONAPI::IncludeDirectives.new(PersonResource, ['posts.comments']).include_directives @@ -48,11 +69,9 @@ def test_two_levels_include_full_path { include_related: { posts: { - include: true, - include_related:{ + include_related: { comments: { - include: true, - include_related:{} + include_related: {} } } } @@ -62,17 +81,15 @@ def test_two_levels_include_full_path end def test_two_levels_include_full_path_redundant - directives = JSONAPI::IncludeDirectives.new(PersonResource, ['posts','posts.comments']).include_directives + directives = JSONAPI::IncludeDirectives.new(PersonResource, ['posts', 'posts.comments']).include_directives assert_hash_equals( { include_related: { posts: { - include: true, - include_related:{ + include_related: { comments: { - include: true, - include_related:{} + include_related: {} } } } @@ -88,14 +105,11 @@ def test_three_levels_include_full { include_related: { posts: { - include: true, - include_related:{ + include_related: { comments: { - include: true, - include_related:{ + include_related: { tags: { - include: true, - include_related:{} + include_related: {} } } } From 50144c4dd6be258fe40b46d5c6cd284518d6074c Mon Sep 17 00:00:00 2001 From: st0012 Date: Sat, 16 Mar 2019 00:00:24 +0800 Subject: [PATCH 138/237] Add database_cleaner to keep integration tests isolated. If we create/modify/delete records (via requests) in integration tests, it might affect other tests' test result. The ideal way is to keep integration test data isolated. And by adding database_cleaner, we can make sure database is reset between each test cases. --- jsonapi-resources.gemspec | 1 + test/integration/requests/request_test.rb | 5 +++++ test/test_helper.rb | 3 +++ 3 files changed, 9 insertions(+) diff --git a/jsonapi-resources.gemspec b/jsonapi-resources.gemspec index 8b53da8fe..1bb48eea2 100644 --- a/jsonapi-resources.gemspec +++ b/jsonapi-resources.gemspec @@ -26,6 +26,7 @@ Gem::Specification.new do |spec| spec.add_development_dependency 'simplecov' spec.add_development_dependency 'pry' spec.add_development_dependency 'concurrent-ruby-ext' + spec.add_development_dependency 'database_cleaner' spec.add_dependency 'activerecord', '>= 4.1' spec.add_dependency 'railties', '>= 4.1' spec.add_dependency 'concurrent-ruby' diff --git a/test/integration/requests/request_test.rb b/test/integration/requests/request_test.rb index 46d353ac5..f04fdc171 100644 --- a/test/integration/requests/request_test.rb +++ b/test/integration/requests/request_test.rb @@ -2,12 +2,17 @@ class RequestTest < ActionDispatch::IntegrationTest def setup + DatabaseCleaner.start JSONAPI.configuration.json_key_format = :underscored_key JSONAPI.configuration.route_format = :underscored_route Api::V2::BookResource.paginator :offset $test_user = Person.find(1001) end + def teardown + DatabaseCleaner.clean + end + def after_teardown JSONAPI.configuration.route_format = :underscored_route end diff --git a/test/test_helper.rb b/test/test_helper.rb index 09ed740b2..61f42f493 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,4 +1,5 @@ require 'simplecov' +require 'database_cleaner' # To run tests with coverage: # COVERAGE=true bundle exec rake test @@ -443,6 +444,8 @@ class CatResource < JSONAPI::Resource jsonapi_resources :people end +DatabaseCleaner.strategy = :transaction + # Ensure backward compatibility with Minitest 4 Minitest::Test = MiniTest::Unit::TestCase unless defined?(Minitest::Test) From e6922edd06dba768bd960159154f6155917b7527 Mon Sep 17 00:00:00 2001 From: st0012 Date: Wed, 20 Mar 2019 11:52:38 +0800 Subject: [PATCH 139/237] Port polymorphic to many linkage parsing from 0.9 Closes gh-1233 --- lib/jsonapi/request_parser.rb | 39 +++-- lib/jsonapi/resource.rb | 27 ++++ test/fixtures/active_record.rb | 2 + test/fixtures/vehicles.yml | 18 +++ test/integration/requests/request_test.rb | 185 +++++++++++++++++++++- 5 files changed, 259 insertions(+), 12 deletions(-) diff --git a/lib/jsonapi/request_parser.rb b/lib/jsonapi/request_parser.rb index 7d7e90ca5..8fca08e64 100644 --- a/lib/jsonapi/request_parser.rb +++ b/lib/jsonapi/request_parser.rb @@ -556,20 +556,39 @@ def parse_to_many_relationship(resource_klass, link_value, relationship, &add_re links_object = parse_to_many_links_object(linkage) - # Since we do not yet support polymorphic to_many relationships we will raise an error if the type does not match the - # relationship's type. - # ToDo: Support Polymorphic relationships - if links_object.length == 0 add_result.call([]) else - if links_object.length > 1 || !links_object.has_key?(unformat_key(relationship.type).to_s) - fail JSONAPI::Exceptions::TypeMismatch.new(links_object[:type], error_object_overrides) - end + if relationship.polymorphic? + polymorphic_results = [] + + links_object.each_pair do |type, keys| + type_name = unformat_key(type).to_s + + relationship_resource_klass = resource_klass.resource_klass_for(relationship.class_name) + relationship_klass = relationship_resource_klass._model_class + + linkage_object_resource_klass = resource_klass.resource_klass_for(type_name) + linkage_object_klass = linkage_object_resource_klass._model_class + + unless linkage_object_klass == relationship_klass || linkage_object_klass.in?(relationship_klass.subclasses) + fail JSONAPI::Exceptions::TypeMismatch.new(type_name) + end + + relationship_ids = relationship_resource_klass.verify_keys(keys, @context) + polymorphic_results << { type: type, ids: relationship_ids } + end + + add_result.call polymorphic_results + else + relationship_type = unformat_key(relationship.type).to_s + + if links_object.length > 1 || !links_object.has_key?(relationship_type) + fail JSONAPI::Exceptions::TypeMismatch.new(links_object[:type]) + end - links_object.each_pair do |type, keys| - relationship_resource = Resource.resource_klass_for(resource_klass.module_path + unformat_key(type).to_s) - add_result.call relationship_resource.verify_keys(keys, @context) + relationship_resource_klass = Resource.resource_klass_for(resource_klass.module_path + relationship_type) + add_result.call relationship_resource_klass.verify_keys(links_object[relationship_type], @context) end end end diff --git a/lib/jsonapi/resource.rb b/lib/jsonapi/resource.rb index 528cf25d9..b1fe7dfb6 100644 --- a/lib/jsonapi/resource.rb +++ b/lib/jsonapi/resource.rb @@ -293,6 +293,26 @@ def _replace_to_many_links(relationship_type, relationship_key_values, options) 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_klass_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) @@ -595,7 +615,14 @@ 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 diff --git a/test/fixtures/active_record.rb b/test/fixtures/active_record.rb index e269dfc2e..558e7468f 100644 --- a/test/fixtures/active_record.rb +++ b/test/fixtures/active_record.rb @@ -1318,10 +1318,12 @@ class VehicleResource < JSONAPI::Resource end class CarResource < VehicleResource + model_name "Car" attributes :drive_layout end class BoatResource < VehicleResource + model_name "Boat" attributes :length_at_water_line end diff --git a/test/fixtures/vehicles.yml b/test/fixtures/vehicles.yml index 97cbec05f..c046bca03 100644 --- a/test/fixtures/vehicles.yml +++ b/test/fixtures/vehicles.yml @@ -15,3 +15,21 @@ Launch20: length_at_water_line: 15.5ft serial_number: 434253JJJSD person_id: 1001 + +M5: + id: 3 + type: Car + make: BMW + model: M5 + drive_layout: Front Engine RWD + serial_number: 56256 + person_id: 2 + +M3: + id: 4 + type: Car + make: BMW + model: M3 + drive_layout: Front Engine RWD + serial_number: 894345 + person_id: 2 diff --git a/test/integration/requests/request_test.rb b/test/integration/requests/request_test.rb index f04fdc171..eedf3c18e 100644 --- a/test/integration/requests/request_test.rb +++ b/test/integration/requests/request_test.rb @@ -312,6 +312,97 @@ def test_post_single assert_jsonapi_response 201 end + def test_post_polymorphic_with_has_many_relationship + post '/people', params: + { + 'data' => { + 'type' => 'people', + 'attributes' => { + 'name' => 'Reo', + 'email' => 'reo@xyz.fake', + 'date_joined' => 'Thu, 01 Jan 2019 00:00:00 UTC +00:00', + }, + 'relationships' => { + 'vehicles' => { + 'data' => [ + {'type' => 'car', 'id' => '1'}, + {'type' => 'boat', 'id' => '2'}, + {'type' => 'car', 'id' => '3'}, + {'type' => 'car', 'id' => '4'} + ] + } + } + } + }.to_json, + headers: { + 'CONTENT_TYPE' => JSONAPI::MEDIA_TYPE, + 'Accept' => JSONAPI::MEDIA_TYPE + } + + assert_jsonapi_response 201 + + body = JSON.parse(response.body) + person = Person.find(body.dig("data", "id")) + + assert_equal "Reo", person.name + assert_equal 4, person.vehicles.count + assert_equal Car, person.vehicles.first.class + assert_equal Boat, person.vehicles.second.class + assert_equal Car, person.vehicles.third.class + assert_equal Car, person.vehicles.fourth.class + end + + def test_post_polymorphic_invalid_with_wrong_type + post '/people', params: + { + 'data' => { + 'type' => 'people', + 'attributes' => { + 'name' => 'Reo', + 'email' => 'reo@xyz.fake', + 'date_joined' => 'Thu, 01 Jan 2019 00:00:00 UTC +00:00', + }, + 'relationships' => { + 'vehicles' => {'data' => [{'type' => 'author', 'id' => '1'}]}, + } + } + }.to_json, + headers: { + 'CONTENT_TYPE' => JSONAPI::MEDIA_TYPE, + 'Accept' => JSONAPI::MEDIA_TYPE + } + + assert_jsonapi_response 400, msg: "Submitting a thing as a vehicle should raise a type mismatch error" + end + + def test_post_polymorphic_invalid_with_not_matched_type_and_id + post '/people', params: + { + 'data' => { + 'type' => 'people', + 'attributes' => { + 'name' => 'Reo', + 'email' => 'reo@xyz.fake', + 'date_joined' => 'Thu, 01 Jan 2019 00:00:00 UTC +00:00', + }, + 'relationships' => { + 'vehicles' => { + 'data' => [ + {'type' => 'car', 'id' => '1'}, + {'type' => 'car', 'id' => '2'} #vehicle 2 is actually a boat + ] + } + } + } + }.to_json, + headers: { + 'CONTENT_TYPE' => JSONAPI::MEDIA_TYPE, + 'Accept' => JSONAPI::MEDIA_TYPE + } + + assert_jsonapi_response 404, msg: "Submitting a thing as a vehicle should raise a record not found" + end + def test_post_single_missing_data_contents post '/posts', params: { @@ -523,6 +614,96 @@ def test_patch_content_type assert_match JSONAPI::MEDIA_TYPE, headers['Content-Type'] end + def test_patch_polymorphic_with_has_many_relationship + patch '/people/1000', params: + { + 'data' => { + 'id' => 1000, + 'type' => 'people', + 'attributes' => { + 'name' => 'Reo', + 'email' => 'reo@xyz.fake', + 'date_joined' => 'Thu, 01 Jan 2019 00:00:00 UTC +00:00', + }, + 'relationships' => { + 'vehicles' => { + 'data' => [ + {'type' => 'car', 'id' => '1'}, + {'type' => 'boat', 'id' => '2'} + ] + } + } + } + }.to_json, + headers: { + 'CONTENT_TYPE' => JSONAPI::MEDIA_TYPE, + 'Accept' => JSONAPI::MEDIA_TYPE + } + + assert_jsonapi_response 200 + + body = JSON.parse(response.body) + person = Person.find(body.dig("data", "id")) + + assert_equal "Reo", person.name + assert_equal 2, person.vehicles.count + assert_equal Car, person.vehicles.first.class + assert_equal Boat, person.vehicles.second.class + end + + def test_patch_polymorphic_invalid_with_wrong_type + patch '/people/1000', params: + { + 'data' => { + 'id' => 1000, + 'type' => 'people', + 'attributes' => { + 'name' => 'Reo', + 'email' => 'reo@xyz.fake', + 'date_joined' => 'Thu, 01 Jan 2019 00:00:00 UTC +00:00', + }, + 'relationships' => { + 'vehicles' => {'data' => [{'type' => 'author', 'id' => '1'}]}, + } + } + }.to_json, + headers: { + 'CONTENT_TYPE' => JSONAPI::MEDIA_TYPE, + 'Accept' => JSONAPI::MEDIA_TYPE + } + + assert_jsonapi_response 400, msg: "Submitting a thing as a vehicle should raise a type mismatch error" + end + + def test_patch_polymorphic_invalid_with_not_matched_type_and_id + patch '/people/1000', params: + { + 'data' => { + 'id' => 1000, + 'type' => 'people', + 'attributes' => { + 'name' => 'Reo', + 'email' => 'reo@xyz.fake', + 'date_joined' => 'Thu, 01 Jan 2019 00:00:00 UTC +00:00', + }, + 'relationships' => { + 'vehicles' => { + 'data' => [ + {'type' => 'car', 'id' => '1'}, + {'type' => 'car', 'id' => '2'} #vehicle 2 is actually a boat + ] + } + } + } + }.to_json, + headers: { + 'CONTENT_TYPE' => JSONAPI::MEDIA_TYPE, + 'Accept' => JSONAPI::MEDIA_TYPE + } + + assert_jsonapi_response 404, msg: "Submitting a thing as a vehicle should raise a record not found" + end + def test_post_correct_content_type post '/posts', params: { @@ -1278,8 +1459,8 @@ def test_include_parameter_openquoted def test_getting_different_resources_when_sti assert_cacheable_jsonapi_get '/vehicles' - types = json_response['data'].map{|r| r['type']}.sort - assert_array_equals ['boats', 'cars'], types + types = json_response['data'].map{|r| r['type']}.to_set + assert types == Set['cars', 'boats'] end def test_getting_resource_with_correct_type_when_sti From b8ba9d2217447f969d170d4791a695ff089a7f0b Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Thu, 11 Apr 2019 11:50:47 -0400 Subject: [PATCH 140/237] Remove ResourceFinder support from Resource --- lib/jsonapi/resource.rb | 36 +----------------------------------- 1 file changed, 1 insertion(+), 35 deletions(-) diff --git a/lib/jsonapi/resource.rb b/lib/jsonapi/resource.rb index b1fe7dfb6..24f8f06f8 100644 --- a/lib/jsonapi/resource.rb +++ b/lib/jsonapi/resource.rb @@ -441,21 +441,9 @@ def inherited(subclass) subclass.attribute :id, format: :id, readonly: true end - # Ensure that the parent's resource finder is included before inheriting from the parent is completed - if !_resource_finder_included && self != JSONAPI::Resource - include_resource_finder - end - subclass._resource_finder_included = _resource_finder_included - check_reserved_resource_name(subclass._type, subclass.name) end - # Set the resource finder for a resource, which will override the default_resource_finder - def resource_finder(resource_finder) - @resource_finder = resource_finder - include_resource_finder - end - def rebuild_relationships(relationships) original_relationships = relationships.deep_dup @@ -500,7 +488,7 @@ def resource_type_for(model) end end - attr_accessor :_attributes, :_relationships, :_type, :_model_hints, :_resource_finder_included + attr_accessor :_attributes, :_relationships, :_type, :_model_hints attr_writer :_allowed_filters, :_paginator, :_allowed_sort def create(context) @@ -1043,28 +1031,6 @@ def register_relationship(name, relationship_object) end private - def _resource_finder - @resource_finder ||= JSONAPI.configuration.default_resource_finder - end - - def include_resource_finder - return if self == JSONAPI::Resource - if self._resource_finder_included - warn "#{self.name} is including a Resource Finder when one has already been included" - end - include _resource_finder - self._resource_finder_included = true - end - - def method_missing(m, *args, &block) - if _resource_finder_included - super - else - # Handle the case where a resource finder has not been included yet. This should only happen once per class. - include_resource_finder - send(m, *args, &block) - end - end def check_reserved_resource_name(type, name) if [:ids, :types, :hrefs, :links].include?(type) From 9f7243e1a76b8b044cc6380db8efb7e8dfb52a96 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Thu, 11 Apr 2019 11:53:27 -0400 Subject: [PATCH 141/237] Rename Resource to BasicResource --- lib/jsonapi/{resource.rb => basic_resource.rb} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename lib/jsonapi/{resource.rb => basic_resource.rb} (99%) diff --git a/lib/jsonapi/resource.rb b/lib/jsonapi/basic_resource.rb similarity index 99% rename from lib/jsonapi/resource.rb rename to lib/jsonapi/basic_resource.rb index 24f8f06f8..45bb75a3f 100644 --- a/lib/jsonapi/resource.rb +++ b/lib/jsonapi/basic_resource.rb @@ -2,7 +2,7 @@ require 'jsonapi/configuration' module JSONAPI - class Resource + class BasicResource include Callbacks attr_reader :context From e17fcc29e0161e46e98b1851ae1f5f3f4f3390a9 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Thu, 11 Apr 2019 16:43:47 -0400 Subject: [PATCH 142/237] Refactor ActiveRelationResourceFinder into ActiveRelationResource Make Resource derive from ActiveRelationResource Support concept of `root_resource` for when we need to walk back up the resource ancestor chain since there can now be more than one root resource class (not just `JSONAPI::Resource`). --- lib/jsonapi-resources.rb | 6 +- .../join_left_active_record_adapter.rb | 2 +- .../join_manager.rb | 2 +- ..._finder.rb => active_relation_resource.rb} | 73 +++++++++---------- lib/jsonapi/basic_resource.rb | 14 ++++ lib/jsonapi/configuration.rb | 12 --- lib/jsonapi/relationship.rb | 2 +- lib/jsonapi/resource.rb | 5 ++ lib/jsonapi/resource_serializer.rb | 4 +- test/fixtures/active_record.rb | 12 +-- .../join_manager_test.rb | 30 ++++---- ...st.rb => active_relation_resource_test.rb} | 2 +- 12 files changed, 82 insertions(+), 82 deletions(-) rename lib/jsonapi/{active_relation_resource_finder => active_relation}/adapters/join_left_active_record_adapter.rb (96%) rename lib/jsonapi/{active_relation_resource_finder => active_relation}/join_manager.rb (99%) rename lib/jsonapi/{active_relation_resource_finder.rb => active_relation_resource.rb} (93%) create mode 100644 lib/jsonapi/resource.rb rename test/unit/resource/{active_relation_resource_finder_test.rb => active_relation_resource_test.rb} (99%) diff --git a/lib/jsonapi-resources.rb b/lib/jsonapi-resources.rb index 76580e2a0..e08bebcdd 100644 --- a/lib/jsonapi-resources.rb +++ b/lib/jsonapi-resources.rb @@ -1,6 +1,8 @@ require 'jsonapi/resources/railtie' require 'jsonapi/naive_cache' require 'jsonapi/compiled_json' +require 'jsonapi/basic_resource' +require 'jsonapi/active_relation_resource' require 'jsonapi/resource' require 'jsonapi/cached_response_fragment' require 'jsonapi/response_document' @@ -25,8 +27,8 @@ require 'jsonapi/operation_result' require 'jsonapi/callbacks' require 'jsonapi/link_builder' -require 'jsonapi/active_relation_resource_finder' -require 'jsonapi/active_relation_resource_finder/join_manager' +require 'jsonapi/active_relation/adapters/join_left_active_record_adapter' +require 'jsonapi/active_relation/join_manager' require 'jsonapi/resource_identity' require 'jsonapi/resource_fragment' require 'jsonapi/resource_id_tree' diff --git a/lib/jsonapi/active_relation_resource_finder/adapters/join_left_active_record_adapter.rb b/lib/jsonapi/active_relation/adapters/join_left_active_record_adapter.rb similarity index 96% rename from lib/jsonapi/active_relation_resource_finder/adapters/join_left_active_record_adapter.rb rename to lib/jsonapi/active_relation/adapters/join_left_active_record_adapter.rb index 500dffd1e..cc4355548 100644 --- a/lib/jsonapi/active_relation_resource_finder/adapters/join_left_active_record_adapter.rb +++ b/lib/jsonapi/active_relation/adapters/join_left_active_record_adapter.rb @@ -1,5 +1,5 @@ module JSONAPI - module ActiveRelationResourceFinder + module ActiveRelation module Adapters module JoinLeftActiveRecordAdapter diff --git a/lib/jsonapi/active_relation_resource_finder/join_manager.rb b/lib/jsonapi/active_relation/join_manager.rb similarity index 99% rename from lib/jsonapi/active_relation_resource_finder/join_manager.rb rename to lib/jsonapi/active_relation/join_manager.rb index 06981a0a5..80dda35bd 100644 --- a/lib/jsonapi/active_relation_resource_finder/join_manager.rb +++ b/lib/jsonapi/active_relation/join_manager.rb @@ -1,5 +1,5 @@ module JSONAPI - module ActiveRelationResourceFinder + module ActiveRelation # Stores relationship paths starting from the resource_klass, consolidating duplicate paths from # relationships, filters and sorts. When joins are made the table aliases are tracked in join_details diff --git a/lib/jsonapi/active_relation_resource_finder.rb b/lib/jsonapi/active_relation_resource.rb similarity index 93% rename from lib/jsonapi/active_relation_resource_finder.rb rename to lib/jsonapi/active_relation_resource.rb index b97e3355c..b8e9c948f 100644 --- a/lib/jsonapi/active_relation_resource_finder.rb +++ b/lib/jsonapi/active_relation_resource.rb @@ -1,13 +1,8 @@ -require 'jsonapi/active_relation_resource_finder/adapters/join_left_active_record_adapter' - module JSONAPI - module ActiveRelationResourceFinder - def self.included(base) - base.extend ClassMethods - end - - module ClassMethods + class ActiveRelationResource < BasicResource + root_resource + class << self # Finds Resources using the `filters`. Pagination and sort options are used when provided # # @param filters [Hash] the filters hash @@ -19,9 +14,9 @@ module ClassMethods def find(filters, options = {}) sort_criteria = options.fetch(:sort_criteria) { [] } - join_manager = JoinManager.new(resource_klass: self, - filters: filters, - sort_criteria: sort_criteria) + join_manager = ActiveRelation::JoinManager.new(resource_klass: self, + filters: filters, + sort_criteria: sort_criteria) paginator = options[:paginator] @@ -41,8 +36,8 @@ def find(filters, options = {}) # # @return [Integer] the count def count(filters, options = {}) - join_manager = JoinManager.new(resource_klass: self, - filters: filters) + join_manager = ActiveRelation::JoinManager.new(resource_klass: self, + filters: filters) records = apply_request_settings_to_records(records: records(options), filters: filters, @@ -101,11 +96,11 @@ def find_fragments(filters, options = {}) sort_criteria = options.fetch(:sort_criteria) { [] } - join_manager = JoinManager.new(resource_klass: resource_klass, - source_relationship: nil, - relationships: linkage_relationships, - sort_criteria: sort_criteria, - filters: filters) + join_manager = ActiveRelation::JoinManager.new(resource_klass: resource_klass, + source_relationship: nil, + relationships: linkage_relationships, + sort_criteria: sort_criteria, + filters: filters) paginator = options[:paginator] @@ -234,9 +229,9 @@ def count_related(source_rid, relationship_name, options = {}) filters = options.fetch(:filters, {}) # Joins in this case are related to the related_klass - join_manager = JoinManager.new(resource_klass: self, - source_relationship: relationship, - filters: filters) + join_manager = ActiveRelation::JoinManager.new(resource_klass: self, + source_relationship: relationship, + filters: filters) records = apply_request_settings_to_records(records: records(options), resource_klass: related_klass, @@ -252,7 +247,7 @@ def count_related(source_rid, relationship_name, options = {}) count_records(records) end - # This resource finder (ActiveRecordResourceFinder) uses an `ActiveRecord::Relation` as the starting point for + # This resource class (ActiveRelationResource) uses an `ActiveRecord::Relation` as the starting point for # retrieving models. From this relation filters, sorts and joins are applied as needed. # Depending on which phase of the request processing different `records` methods will be called, giving the user # the opportunity to override them differently for performance and security reasons. @@ -385,11 +380,11 @@ def find_related_monomorphic_fragments(source_rids, relationship, options, conne sort_criteria << { field: field, direction: sort[:direction] } end - join_manager = JoinManager.new(resource_klass: self, - source_relationship: relationship, - relationships: linkage_relationships, - sort_criteria: sort_criteria, - filters: filters) + join_manager = ActiveRelation::JoinManager.new(resource_klass: self, + source_relationship: relationship, + relationships: linkage_relationships, + sort_criteria: sort_criteria, + filters: filters) paginator = options[:paginator] if source_rids.count == 1 @@ -511,10 +506,10 @@ def find_related_polymorphic_fragments(source_rids, relationship, options, conne end end - join_manager = JoinManager.new(resource_klass: self, - source_relationship: relationship, - relationships: linkage_relationships, - filters: filters) + join_manager = ActiveRelation::JoinManager.new(resource_klass: self, + source_relationship: relationship, + relationships: linkage_relationships, + filters: filters) paginator = options[:paginator] if source_rids.count == 1 @@ -672,14 +667,14 @@ def find_related_polymorphic_fragments(source_rids, relationship, options, conne end def apply_request_settings_to_records(records:, - join_manager: JoinManager.new(resource_klass: self), - resource_klass: self, - filters: {}, - primary_keys: nil, - sort_criteria: nil, - sort_primary: nil, - paginator: nil, - options: {}) + join_manager: ActiveRelation::JoinManager.new(resource_klass: self), + resource_klass: self, + filters: {}, + primary_keys: nil, + sort_criteria: nil, + sort_primary: nil, + paginator: nil, + options: {}) opts = options.dup records = resource_klass.apply_joins(records, join_manager, opts) diff --git a/lib/jsonapi/basic_resource.rb b/lib/jsonapi/basic_resource.rb index 45bb75a3f..2a1dbd440 100644 --- a/lib/jsonapi/basic_resource.rb +++ b/lib/jsonapi/basic_resource.rb @@ -5,6 +5,10 @@ module JSONAPI class BasicResource include Callbacks + @abstract = true + @immutable = true + @root = true + attr_reader :context define_jsonapi_resources_callbacks :create, @@ -893,6 +897,16 @@ def _polymorphic_resource_klasses end end + def root_resource + @abstract = true + @immutable = true + @root = true + end + + def root? + @root + end + def abstract(val = true) @abstract = val end diff --git a/lib/jsonapi/configuration.rb b/lib/jsonapi/configuration.rb index 482b6e043..f255ef6d5 100644 --- a/lib/jsonapi/configuration.rb +++ b/lib/jsonapi/configuration.rb @@ -1,6 +1,5 @@ require 'jsonapi/formatter' require 'jsonapi/processor' -require 'jsonapi/active_relation_resource_finder' require 'concurrent' module JSONAPI @@ -17,7 +16,6 @@ class Configuration :default_paginator, :default_page_size, :maximum_page_size, - :default_resource_finder, :default_processor_klass, :use_text_errors, :top_level_links_include_pagination, @@ -105,12 +103,6 @@ def initialize self.always_include_to_one_linkage_data = false self.always_include_to_many_linkage_data = false - # ResourceFinder Mixin - # The default ResourceFinder is the ActiveRelationResourceFinder which provides - # access to ActiveRelation backed models. Custom ResourceFinders can be specified - # in order to support other ORMs. - self.default_resource_finder = JSONAPI::ActiveRelationResourceFinder - # The default Operation Processor to use if one is not defined specifically # for a Resource. self.default_processor_klass = JSONAPI::Processor @@ -225,10 +217,6 @@ def default_processor_klass=(default_processor_klass) @default_processor_klass = default_processor_klass end - def default_resource_finder=(default_resource_finder) - @default_resource_finder = default_resource_finder - end - def allow_include=(allow_include) ActiveSupport::Deprecation.warn('`allow_include` has been replaced by `default_allow_include_to_one` and `default_allow_include_to_many` options.') @default_allow_include_to_one = allow_include diff --git a/lib/jsonapi/relationship.rb b/lib/jsonapi/relationship.rb index 75f94d311..1358472da 100644 --- a/lib/jsonapi/relationship.rb +++ b/lib/jsonapi/relationship.rb @@ -27,7 +27,7 @@ def initialize(name, options = {}) @class_name = nil @inverse_relationship = nil - # Custom methods are reserved for use in resource finders. Not used in the default ActiveRelationResourceFinder + # Custom methods are reserved for future use @custom_methods = options.fetch(:custom_methods, {}) end diff --git a/lib/jsonapi/resource.rb b/lib/jsonapi/resource.rb new file mode 100644 index 000000000..0c09fb7e8 --- /dev/null +++ b/lib/jsonapi/resource.rb @@ -0,0 +1,5 @@ +module JSONAPI + class Resource < ActiveRelationResource + root_resource + end +end \ No newline at end of file diff --git a/lib/jsonapi/resource_serializer.rb b/lib/jsonapi/resource_serializer.rb index 9d685361a..dc2657157 100644 --- a/lib/jsonapi/resource_serializer.rb +++ b/lib/jsonapi/resource_serializer.rb @@ -199,7 +199,7 @@ def supplying_attribute_fields(resource_klass) @_supplying_attribute_fields.fetch resource_klass do attrs = Set.new(resource_klass._attributes.keys.map(&:to_sym)) cur = resource_klass - while cur != JSONAPI::Resource + while !cur.root? # do not traverse beyond the first root resource if @fields.has_key?(cur._type) attrs &= @fields[cur._type] break @@ -214,7 +214,7 @@ def supplying_relationship_fields(resource_klass) @_supplying_relationship_fields.fetch resource_klass do relationships = Set.new(resource_klass._relationships.keys.map(&:to_sym)) cur = resource_klass - while cur != JSONAPI::Resource + while !cur.root? # do not traverse beyond the first root resource if @fields.has_key?(cur._type) relationships &= @fields[cur._type] break diff --git a/test/fixtures/active_record.rb b/test/fixtures/active_record.rb index 558e7468f..0d06031cd 100644 --- a/test/fixtures/active_record.rb +++ b/test/fixtures/active_record.rb @@ -1503,12 +1503,10 @@ class EmployeeResource < JSONAPI::Resource has_many :expense_entries end -module BreedResourceFinder - def self.included(base) - base.extend ClassMethods - end +class PoroResource < JSONAPI::BasicResource + root_resource - module ClassMethods + class << self def find(filters, options = {}) records = find_breeds(filters, options) resources_for(records, options[:context]) @@ -1563,9 +1561,7 @@ def find_breeds_by_keys(keys, options = {}) end end -class BreedResource < JSONAPI::Resource - - resource_finder BreedResourceFinder +class BreedResource < PoroResource attribute :name, format: :title diff --git a/test/unit/active_relation_resource_finder/join_manager_test.rb b/test/unit/active_relation_resource_finder/join_manager_test.rb index 013cbb699..a87bb5e0d 100644 --- a/test/unit/active_relation_resource_finder/join_manager_test.rb +++ b/test/unit/active_relation_resource_finder/join_manager_test.rb @@ -4,7 +4,7 @@ class JoinTreeTest < ActiveSupport::TestCase def test_no_added_joins - join_manager = JSONAPI::ActiveRelationResourceFinder::JoinManager.new(resource_klass: PostResource) + join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: PostResource) records = PostResource.records({}) records = join_manager.join(records, {}) @@ -15,7 +15,7 @@ def test_no_added_joins def test_add_single_join filters = {'tags' => ['1']} - join_manager = JSONAPI::ActiveRelationResourceFinder::JoinManager.new(resource_klass: PostResource, filters: filters) + join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: PostResource, filters: filters) records = PostResource.records({}) records = join_manager.join(records, {}) assert_equal 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "posts_tags" ON "posts_tags"."post_id" = "posts"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "posts_tags"."tag_id"', records.to_sql @@ -25,7 +25,7 @@ def test_add_single_join def test_add_single_sort_join sort_criteria = [{field: 'tags.name', direction: :desc}] - join_manager = JSONAPI::ActiveRelationResourceFinder::JoinManager.new(resource_klass: PostResource, sort_criteria: sort_criteria) + join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: PostResource, sort_criteria: sort_criteria) records = PostResource.records({}) records = join_manager.join(records, {}) @@ -37,7 +37,7 @@ def test_add_single_sort_join def test_add_single_sort_and_filter_join filters = {'tags' => ['1']} sort_criteria = [{field: 'tags.name', direction: :desc}] - join_manager = JSONAPI::ActiveRelationResourceFinder::JoinManager.new(resource_klass: PostResource, sort_criteria: sort_criteria, filters: filters) + join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: PostResource, sort_criteria: sort_criteria, filters: filters) records = PostResource.records({}) records = join_manager.join(records, {}) assert_equal 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "posts_tags" ON "posts_tags"."post_id" = "posts"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "posts_tags"."tag_id"', records.to_sql @@ -51,7 +51,7 @@ def test_add_sibling_joins 'author' => ['1'] } - join_manager = JSONAPI::ActiveRelationResourceFinder::JoinManager.new(resource_klass: PostResource, filters: filters) + join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: PostResource, filters: filters) records = PostResource.records({}) records = join_manager.join(records, {}) @@ -63,7 +63,7 @@ def test_add_sibling_joins def test_add_joins_source_relationship - join_manager = JSONAPI::ActiveRelationResourceFinder::JoinManager.new(resource_klass: PostResource, + join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: PostResource, source_relationship: PostResource._relationship(:comments)) records = PostResource.records({}) records = join_manager.join(records, {}) @@ -74,7 +74,7 @@ def test_add_joins_source_relationship def test_add_joins_source_relationship_with_custom_apply - join_manager = JSONAPI::ActiveRelationResourceFinder::JoinManager.new(resource_klass: Api::V9::PostResource, + join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: Api::V9::PostResource, source_relationship: Api::V9::PostResource._relationship(:comments)) records = Api::V9::PostResource.records({}) records = join_manager.join(records, {}) @@ -95,7 +95,7 @@ def test_add_nested_scoped_joins 'author' => ['1'] } - join_manager = JSONAPI::ActiveRelationResourceFinder::JoinManager.new(resource_klass: Api::V9::PostResource, filters: filters) + join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: Api::V9::PostResource, filters: filters) records = Api::V9::PostResource.records({}) records = join_manager.join(records, {}) @@ -118,7 +118,7 @@ def test_add_nested_scoped_joins 'comments.tags' => ['1'] } - join_manager = JSONAPI::ActiveRelationResourceFinder::JoinManager.new(resource_klass: Api::V9::PostResource, filters: filters) + join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: Api::V9::PostResource, filters: filters) records = Api::V9::PostResource.records({}) records = join_manager.join(records, {}) @@ -159,7 +159,7 @@ def test_add_nested_joins_with_fields 'author.foo' => ['1'] } - join_manager = JSONAPI::ActiveRelationResourceFinder::JoinManager.new(resource_klass: Api::V9::PostResource, filters: filters) + join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: Api::V9::PostResource, filters: filters) records = Api::V9::PostResource.records({}) records = join_manager.join(records, {}) @@ -179,7 +179,7 @@ def test_add_nested_joins_with_fields def test_add_joins_with_sub_relationship relationships = %w(author author.comments tags) - join_manager = JSONAPI::ActiveRelationResourceFinder::JoinManager.new(resource_klass: Api::V9::PostResource, relationships: relationships, + join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: Api::V9::PostResource, relationships: relationships, source_relationship: Api::V9::PostResource._relationship(:comments)) records = Api::V9::PostResource.records({}) records = join_manager.join(records, {}) @@ -205,7 +205,7 @@ def test_add_joins_with_sub_relationship_and_filters relationships = %w(author author.comments tags) - join_manager = JSONAPI::ActiveRelationResourceFinder::JoinManager.new(resource_klass: PostResource, + join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: PostResource, filters: filters, relationships: relationships, source_relationship: PostResource._relationship(:comments)) @@ -220,7 +220,7 @@ def test_add_joins_with_sub_relationship_and_filters end def test_polymorphic_join_belongs_to_just_source - join_manager = JSONAPI::ActiveRelationResourceFinder::JoinManager.new(resource_klass: PictureResource, + join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: PictureResource, source_relationship: PictureResource._relationship(:imageable)) records = PictureResource.records({}) @@ -235,7 +235,7 @@ def test_polymorphic_join_belongs_to_just_source def test_polymorphic_join_belongs_to_filter filters = {'imageable' => ['Foo']} - join_manager = JSONAPI::ActiveRelationResourceFinder::JoinManager.new(resource_klass: PictureResource, filters: filters) + join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: PictureResource, filters: filters) records = PictureResource.records({}) records = join_manager.join(records, {}) @@ -252,7 +252,7 @@ def test_polymorphic_join_belongs_to_filter_on_resource } relationships = %w(imageable file_properties) - join_manager = JSONAPI::ActiveRelationResourceFinder::JoinManager.new(resource_klass: PictureResource, + join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: PictureResource, filters: filters, relationships: relationships) diff --git a/test/unit/resource/active_relation_resource_finder_test.rb b/test/unit/resource/active_relation_resource_test.rb similarity index 99% rename from test/unit/resource/active_relation_resource_finder_test.rb rename to test/unit/resource/active_relation_resource_test.rb index 2a5833280..59f57fcda 100644 --- a/test/unit/resource/active_relation_resource_finder_test.rb +++ b/test/unit/resource/active_relation_resource_test.rb @@ -7,7 +7,7 @@ class ARPostResource < JSONAPI::Resource has_many :tags, primary_key: :tags_import_id end -class ActiveRelationResourceFinderTest < ActiveSupport::TestCase +class ActiveRelationResourceTest < ActiveSupport::TestCase def setup end From 25eabb25389adaef4537fb0939ed52af1d05d26f Mon Sep 17 00:00:00 2001 From: Emil Shakirov Date: Tue, 14 May 2019 17:21:50 +0200 Subject: [PATCH 143/237] Fix typo in detail text for unsupported_media_type error --- locales/en.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/locales/en.yml b/locales/en.yml index 02915fcc7..065b37314 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -15,7 +15,7 @@ en: detail: "All requests must use the '%{needed_media_type}' Accept without media type parameters. This request specified '%{media_type}'." unsupported_media_type: title: 'Unsupported media type' - detail: "All requests that create or update must use the '%{needed_media_type}' Content-Type. This request specified '%{media_type}.'" + detail: "All requests that create or update must use the '%{needed_media_type}' Content-Type. This request specified '%{media_type}'." to_many_set_replacement_forbidden: title: 'Complete replacement forbidden' detail: 'Complete replacement forbidden for this relationship' From 35d34ce1f9529d4e0e4cdf2b7b6f9176b61578b6 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Sat, 18 May 2019 06:41:55 -0400 Subject: [PATCH 144/237] Generate links using route helpers, formalize singletons * warn when links can not be built * add option to exclude building resource and relationship links * add to_s for relationships for prettier warning messages and debugging * add additional support for singleton resources with id resolution and routing * fix naming `LinksObjectOperationResult` => `RelationshipOperationResult` and associated methods --- lib/jsonapi/basic_resource.rb | 54 +++ lib/jsonapi/configuration.rb | 4 + lib/jsonapi/link_builder.rb | 177 +++++---- lib/jsonapi/operation_result.rb | 4 +- lib/jsonapi/processor.rb | 10 +- lib/jsonapi/relationship.rb | 27 +- lib/jsonapi/request_parser.rb | 15 + lib/jsonapi/resource_serializer.rb | 62 +-- lib/jsonapi/response_document.rb | 9 +- lib/jsonapi/routing_ext.rb | 20 +- test/controllers/controller_test.rb | 49 +++ test/fixtures/active_record.rb | 208 ++++++---- test/fixtures/people.yml | 1 + test/fixtures/preferences.yml | 6 + test/integration/requests/request_test.rb | 361 +++++++++++++++++- test/test_helper.rb | 40 +- .../join_manager_test.rb | 56 +-- test/unit/resource/relationship_test.rb | 52 +++ test/unit/resource/resource_test.rb | 82 ++++ test/unit/serializer/link_builder_test.rb | 48 +-- test/unit/serializer/serializer_test.rb | 21 +- 21 files changed, 1022 insertions(+), 284 deletions(-) diff --git a/lib/jsonapi/basic_resource.rb b/lib/jsonapi/basic_resource.rb index 2a1dbd440..61e2d5916 100644 --- a/lib/jsonapi/basic_resource.rb +++ b/lib/jsonapi/basic_resource.rb @@ -422,6 +422,8 @@ def inherited(subclass) subclass.abstract(false) subclass.immutable(false) subclass.caching(_caching) + subclass.singleton(singleton?, (_singleton_options.dup || {})) + subclass.exclude_links(_exclude_links) subclass.paginator(_paginator) subclass._attributes = (_attributes || {}).dup subclass.polymorphic(false) @@ -628,6 +630,19 @@ def model_hint(model: _model_name, resource: _type) _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 @@ -740,6 +755,24 @@ 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 @@ -927,6 +960,27 @@ def mutable? !@immutable end + def 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 _exclude_links + @_exclude_links ||= [] + end + + def exclude_link?(link) + _exclude_links.include?(link.to_sym) + end + def caching(val = true) @caching = val end diff --git a/lib/jsonapi/configuration.rb b/lib/jsonapi/configuration.rb index f255ef6d5..6dca6891a 100644 --- a/lib/jsonapi/configuration.rb +++ b/lib/jsonapi/configuration.rb @@ -9,6 +9,7 @@ class Configuration :route_format, :raise_if_parameters_not_allowed, :warn_on_route_setup_issues, + :warn_on_missing_routes, :default_allow_include_to_one, :default_allow_include_to_many, :allow_sort, @@ -57,6 +58,7 @@ def initialize self.raise_if_parameters_not_allowed = true self.warn_on_route_setup_issues = true + self.warn_on_missing_routes = true # :none, :offset, :paged, or a custom paginator name self.default_paginator = :none @@ -261,6 +263,8 @@ def allow_include=(allow_include) attr_writer :warn_on_route_setup_issues + attr_writer :warn_on_missing_routes + attr_writer :use_relationship_reflection attr_writer :resource_cache diff --git a/lib/jsonapi/link_builder.rb b/lib/jsonapi/link_builder.rb index 6d4f84bc8..a0b013ebc 100644 --- a/lib/jsonapi/link_builder.rb +++ b/lib/jsonapi/link_builder.rb @@ -2,32 +2,33 @@ module JSONAPI class LinkBuilder attr_reader :base_url, :primary_resource_klass, - :route_formatter, - :engine_name + :engine, + :routes def initialize(config = {}) @base_url = config[:base_url] @primary_resource_klass = config[:primary_resource_klass] - @route_formatter = config[:route_formatter] - @engine_name = build_engine_name + @engine = build_engine - # Warning: These make LinkBuilder non-thread-safe. That's not a problem with the - # request-specific way it's currently used, though. - @resources_path_cache = JSONAPI::NaiveCache.new do |source_klass| - formatted_module_path_from_class(source_klass) + format_route(source_klass._type.to_s) + if engine? + @routes = @engine.routes + else + @routes = Rails.application.routes end + + # ToDo: Use NaiveCache for values. For this we need to not return nils and create composite keys which work + # as efficient cache lookups. This could be an array of the [source.identifier, relationship] since the + # ResourceIdentity will compare equality correctly end def engine? - !!@engine_name + !!@engine end def primary_resources_url - if engine? - engine_primary_resources_url - else - regular_primary_resources_url - end + @primary_resources_url_cached ||= "#{ base_url }#{ primary_resources_path }" + rescue NoMethodError + warn "primary_resources_url for #{@primary_resource_klass} could not be generated" if JSONAPI.configuration.warn_on_missing_routes end def query_link(query_params) @@ -35,26 +36,45 @@ def query_link(query_params) end def relationships_related_link(source, relationship, query_params = {}) - url = "#{ self_link(source) }/#{ route_for_relationship(relationship) }" + if relationship.parent_resource.singleton? + url_helper_name = singleton_related_url_helper_name(relationship) + url = call_url_helper(url_helper_name) + else + url_helper_name = related_url_helper_name(relationship) + url = call_url_helper(url_helper_name, source.id) + end + + url = "#{ base_url }#{ url }" url = "#{ url }?#{ query_params.to_query }" if query_params.present? url + rescue NoMethodError + warn "related_link for #{relationship} could not be generated" if JSONAPI.configuration.warn_on_missing_routes end def relationships_self_link(source, relationship) - "#{ self_link(source) }/relationships/#{ route_for_relationship(relationship) }" + if relationship.parent_resource.singleton? + url_helper_name = singleton_relationship_self_url_helper_name(relationship) + url = call_url_helper(url_helper_name) + else + url_helper_name = relationship_self_url_helper_name(relationship) + url = call_url_helper(url_helper_name, source.id) + end + + url = "#{ base_url }#{ url }" + url + rescue NoMethodError + warn "self_link for #{relationship} could not be generated" if JSONAPI.configuration.warn_on_missing_routes end def self_link(source) - if engine? - engine_resource_url(source) - else - regular_resource_url(source) - end + "#{ base_url }#{ resource_path(source) }" + rescue NoMethodError + warn "self_link for #{source.class} could not be generated" if JSONAPI.configuration.warn_on_missing_routes end private - def build_engine_name + def build_engine scopes = module_scopes_from_class(primary_resource_klass) begin @@ -68,93 +88,96 @@ def build_engine_name end end - def engine_path_from_resource_class(klass) - path_name = engine_resources_path_name_from_class(klass) - engine_name.routes.url_helpers.public_send(path_name) + def call_url_helper(method, *args) + routes.url_helpers.public_send(method, args) + rescue NoMethodError => e + raise e end - def engine_primary_resources_path - engine_path_from_resource_class(primary_resource_klass) + def path_from_resource_class(klass) + url_helper_name = resources_url_helper_name_from_class(klass) + call_url_helper(url_helper_name) end - def engine_primary_resources_url - "#{ base_url }#{ engine_primary_resources_path }" + def resource_path(source) + url_helper_name = resource_url_helper_name_from_source(source) + if source.class.singleton? + call_url_helper(url_helper_name) + else + call_url_helper(url_helper_name, source.id) + end end - def engine_resource_path(source) - resource_path_name = engine_resource_path_name_from_source(source) - engine_name.routes.url_helpers.public_send(resource_path_name, source.id) + def primary_resources_path + path_from_resource_class(primary_resource_klass) end - def engine_resource_path_name_from_source(source) - scopes = module_scopes_from_class(source.class)[1..-1] - base_path_name = scopes.map { |scope| scope.underscore }.join("_") - end_path_name = source.class._type.to_s.singularize - [base_path_name, end_path_name, "path"].reject(&:blank?).join("_") + def url_helper_name_from_parts(parts) + (parts << "path").reject(&:blank?).join("_") end - def engine_resource_url(source) - "#{ base_url }#{ engine_resource_path(source) }" - end + def resources_path_parts_from_class(klass) + if engine? + scopes = module_scopes_from_class(klass)[1..-1] + else + scopes = module_scopes_from_class(klass) + end - def engine_resources_path_name_from_class(klass) - scopes = module_scopes_from_class(klass)[1..-1] base_path_name = scopes.map { |scope| scope.underscore }.join("_") end_path_name = klass._type.to_s - - if base_path_name.blank? - "#{ end_path_name }_path" - else - "#{ base_path_name }_#{ end_path_name }_path" - end + [base_path_name, end_path_name] end - def format_route(route) - route_formatter.format(route) + def resources_url_helper_name_from_class(klass) + url_helper_name_from_parts(resources_path_parts_from_class(klass)) end - def formatted_module_path_from_class(klass) - scopes = module_scopes_from_class(klass) - - unless scopes.empty? - "/#{ scopes.map{ |scope| format_route(scope.to_s.underscore) }.compact.join('/') }/" + def resource_path_parts_from_class(klass) + if engine? + scopes = module_scopes_from_class(klass)[1..-1] else - "/" + scopes = module_scopes_from_class(klass) end - end - def module_scopes_from_class(klass) - klass.name.to_s.split("::")[0...-1] + base_path_name = scopes.map { |scope| scope.underscore }.join("_") + end_path_name = klass._type.to_s.singularize + [base_path_name, end_path_name] end - def regular_resources_path(source_klass) - @resources_path_cache.get(source_klass) + def resource_url_helper_name_from_source(source) + url_helper_name_from_parts(resource_path_parts_from_class(source.class)) end - def regular_primary_resources_path - regular_resources_path(primary_resource_klass) + def related_url_helper_name(relationship) + relationship_parts = resource_path_parts_from_class(relationship.parent_resource) + relationship_parts << relationship.name + url_helper_name_from_parts(relationship_parts) end - def regular_primary_resources_url - "#{ base_url }#{ regular_primary_resources_path }" + def singleton_related_url_helper_name(relationship) + relationship_parts = [] + relationship_parts << relationship.name + relationship_parts += resource_path_parts_from_class(relationship.parent_resource) + url_helper_name_from_parts(relationship_parts) end - def regular_resource_path(source) - if source.is_a?(JSONAPI::CachedResponseFragment) - # :nocov: - "#{regular_resources_path(source.resource_klass)}/#{source.id}" - # :nocov: - else - "#{regular_resources_path(source.class)}/#{source.id}" - end + def relationship_self_url_helper_name(relationship) + relationship_parts = resource_path_parts_from_class(relationship.parent_resource) + relationship_parts << "relationships" + relationship_parts << relationship.name + url_helper_name_from_parts(relationship_parts) end - def regular_resource_url(source) - "#{ base_url }#{ regular_resource_path(source) }" + def singleton_relationship_self_url_helper_name(relationship) + relationship_parts = [] + relationship_parts << "relationships" + relationship_parts << relationship.name + relationship_parts += resource_path_parts_from_class(relationship.parent_resource) + url_helper_name_from_parts(relationship_parts) end - def route_for_relationship(relationship) - format_route(relationship.name) + def module_scopes_from_class(klass) + klass.name.to_s.split("::")[0...-1] end end end diff --git a/lib/jsonapi/operation_result.rb b/lib/jsonapi/operation_result.rb index 369c77204..1c9384273 100644 --- a/lib/jsonapi/operation_result.rb +++ b/lib/jsonapi/operation_result.rb @@ -100,7 +100,7 @@ def to_hash(serializer = nil) end end - class LinksObjectOperationResult < OperationResult + class RelationshipOperationResult < OperationResult attr_accessor :parent_resource, :relationship, :resource_ids def initialize(code, parent_resource, relationship, resource_ids, options = {}) @@ -112,7 +112,7 @@ def initialize(code, parent_resource, relationship, resource_ids, options = {}) def to_hash(serializer = nil) if serializer - serializer.serialize_to_links_hash(parent_resource, relationship, resource_ids) + serializer.serialize_to_relationship_hash(parent_resource, relationship, resource_ids) else # :nocov: {} diff --git a/lib/jsonapi/processor.rb b/lib/jsonapi/processor.rb index 6beb36019..6e58a6799 100644 --- a/lib/jsonapi/processor.rb +++ b/lib/jsonapi/processor.rb @@ -130,11 +130,11 @@ def show_relationship find_options, nil) - return JSONAPI::LinksObjectOperationResult.new(:ok, - parent_resource, - resource_klass._relationship(relationship_type), - resource_id_tree.fragments.keys, - result_options) + return JSONAPI::RelationshipOperationResult.new(:ok, + parent_resource, + resource_klass._relationship(relationship_type), + resource_id_tree.fragments.keys, + result_options) end def show_related_resource diff --git a/lib/jsonapi/relationship.rb b/lib/jsonapi/relationship.rb index 1358472da..6a58398ca 100644 --- a/lib/jsonapi/relationship.rb +++ b/lib/jsonapi/relationship.rb @@ -27,6 +27,8 @@ def initialize(name, options = {}) @class_name = nil @inverse_relationship = nil + exclude_links(options.fetch(:exclude_links, :none)) + # Custom methods are reserved for future use @custom_methods = options.fetch(:custom_methods, {}) end @@ -99,6 +101,27 @@ def readonly? @options[:readonly] end + def exclude_links(exclude) + case exclude + when :default, "default" + @_exclude_links = [:self, :related] + when :none, "none" + @_exclude_links = [] + when Array + @_exclude_links = exclude.collect {|link| link.to_sym} + else + fail "Invalid exclude_links" + end + end + + def _exclude_links + @_exclude_links ||= [] + end + + def exclude_link?(link) + _exclude_links.include?(link.to_sym) + end + class ToOne < Relationship attr_reader :foreign_key_on @@ -114,7 +137,7 @@ def initialize(name, options = {}) def to_s # :nocov: useful for debugging - "#{parent_resource._type}.#{name} => (#{belongs_to? ? 'ToOne' : 'BelongsToOne'}) #{resource_klass._type}" + "#{parent_resource}.#{name}(#{belongs_to? ? 'BelongsToOne' : 'ToOne'})" # :nocov: end @@ -164,7 +187,7 @@ def initialize(name, options = {}) def to_s # :nocov: useful for debugging - "#{parent_resource._type}.#{name} => (ToMany) #{resource_klass._type}" + "#{parent_resource}.#{name}(ToMany)" # :nocov: end diff --git a/lib/jsonapi/request_parser.rb b/lib/jsonapi/request_parser.rb index 8fca08e64..b8a8a8de9 100644 --- a/lib/jsonapi/request_parser.rb +++ b/lib/jsonapi/request_parser.rb @@ -81,6 +81,7 @@ def setup_index_action(params, resource_klass) end def setup_show_related_resource_action(params, resource_klass) + resolve_singleton_id(params, resource_klass) source_klass = Resource.resource_klass_for(params.require(:source)) source_id = source_klass.verify_key(params.require(source_klass._as_parent_key), @context) @@ -102,6 +103,7 @@ def setup_show_related_resource_action(params, resource_klass) end def setup_index_related_resources_action(params, resource_klass) + resolve_singleton_id(params, resource_klass) source_klass = Resource.resource_klass_for(params.require(:source)) source_id = source_klass.verify_key(params.require(source_klass._as_parent_key), @context) @@ -128,6 +130,7 @@ def setup_index_related_resources_action(params, resource_klass) end def setup_show_action(params, resource_klass) + resolve_singleton_id(params, resource_klass) fields = parse_fields(resource_klass, params[:fields]) include_directives = parse_include_directives(resource_klass, params[:include]) id = params[:id] @@ -144,6 +147,7 @@ def setup_show_action(params, resource_klass) end def setup_show_relationship_action(params, resource_klass) + resolve_singleton_id(params, resource_klass) relationship_type = params[:relationship] parent_key = params.require(resource_klass._as_parent_key) include_directives = parse_include_directives(resource_klass, params[:include]) @@ -191,6 +195,7 @@ def setup_create_action(params, resource_klass) end def setup_create_relationship_action(params, resource_klass) + resolve_singleton_id(params, resource_klass) parse_modify_relationship_action(:add, params, resource_klass) end @@ -199,6 +204,7 @@ def setup_update_relationship_action(params, resource_klass) end def setup_update_action(params, resource_klass) + resolve_singleton_id(params, resource_klass) fields = parse_fields(resource_klass, params[:fields]) include_directives = parse_include_directives(resource_klass, params[:include]) @@ -232,6 +238,7 @@ def setup_update_action(params, resource_klass) end def setup_destroy_action(params, resource_klass) + resolve_singleton_id(params, resource_klass) JSONAPI::Operation.new( :remove_resource, resource_klass, @@ -240,6 +247,7 @@ def setup_destroy_action(params, resource_klass) end def setup_destroy_relationship_action(params, resource_klass) + resolve_singleton_id(params, resource_klass) parse_modify_relationship_action(:remove, params, resource_klass) end @@ -714,6 +722,13 @@ def parse_remove_relationship_operation(resource_klass, params, relationship, pa end end + def resolve_singleton_id(params, resource_klass) + if resource_klass.singleton? && params[:id].nil? + key = resource_klass.singleton_key(context) + params[:id] = key + end + end + def format_key(key) @key_formatter.format(key) end diff --git a/lib/jsonapi/resource_serializer.rb b/lib/jsonapi/resource_serializer.rb index dc2657157..dfb381ff3 100644 --- a/lib/jsonapi/resource_serializer.rb +++ b/lib/jsonapi/resource_serializer.rb @@ -59,7 +59,7 @@ def serialize_resource_set_to_hash_single(resource_set) end end - fail "To Many primary objects for show" if (primary_objects.count > 1) + fail "Too many primary objects for show" if (primary_objects.count > 1) primary_hash = { 'data' => primary_objects[0] } primary_hash['included'] = included_objects if included_objects.size > 0 @@ -93,24 +93,19 @@ def serialize_related_resource_set_to_hash_plural(resource_set, _source_resource return serialize_resource_set_to_hash_plural(resource_set) end - def serialize_to_links_hash(source, requested_relationship, resource_ids) + def serialize_to_relationship_hash(source, requested_relationship, resource_ids) if requested_relationship.is_a?(JSONAPI::Relationship::ToOne) data = to_one_linkage(resource_ids[0]) else data = to_many_linkage(resource_ids) end - { - 'links' => { - 'self' => self_link(source, requested_relationship), - 'related' => related_link(source, requested_relationship) - }, - 'data' => data - } - end + rel_hash = { 'data': data } - def query_link(query_params) - link_builder.query_link(query_params) + links = default_relationship_links(source, requested_relationship) + rel_hash['links'] = links unless links.blank? + + rel_hash end def format_key(key) @@ -140,7 +135,6 @@ def config_description(resource_klass) supplying_attribute_fields: supplying_attribute_fields(resource_klass).sort, supplying_relationship_fields: supplying_relationship_fields(resource_klass).sort, link_builder_base_url: link_builder.base_url, - route_formatter_class: link_builder.route_formatter.uncached.class.name, key_formatter_class: key_formatter.uncached.class.name, always_include_to_one_linkage_data: always_include_to_one_linkage_data, always_include_to_many_linkage_data: always_include_to_many_linkage_data @@ -165,7 +159,7 @@ def object_hash(source, relationship_data) obj_hash['attributes'] = source.attributes_json if source.attributes_json relationships = cached_relationships_hash(source, fetchable_fields, relationship_data) - obj_hash['relationships'] = relationships unless relationships.nil? || relationships.empty? + obj_hash['relationships'] = relationships unless relationships.blank? obj_hash['meta'] = source.meta_json if source.meta_json else @@ -184,7 +178,7 @@ def object_hash(source, relationship_data) obj_hash['attributes'] = attributes unless attributes.empty? relationships = relationships_hash(source, fetchable_fields, relationship_data) - obj_hash['relationships'] = relationships unless relationships.nil? || relationships.empty? + obj_hash['relationships'] = relationships unless relationships.blank? meta = meta_hash(source) obj_hash['meta'] = meta unless meta.empty? @@ -249,7 +243,9 @@ def meta_hash(source) def links_hash(source) links = custom_links_hash(source) - links['self'] = link_builder.self_link(source) unless links.key?('self') + if !links.key?('self') && !source.class.exclude_link?(:self) + links['self'] = link_builder.self_link(source) + end links.compact end @@ -274,7 +270,8 @@ def relationships_hash(source, fetchable_fields, relationship_data) end end - hash[format_key(name)] = link_object(source, relationship, rids, include_data) + ro = relationship_object(source, relationship, rids, include_data) + hash[format_key(name)] = ro unless ro.blank? end end end @@ -323,6 +320,13 @@ def related_link(source, relationship) link_builder.relationships_related_link(source, relationship) end + def default_relationship_links(source, relationship) + links = {} + links['self'] = self_link(source, relationship) unless relationship.exclude_link?(:self) + links['related'] = related_link(source, relationship) unless relationship.exclude_link?(:related) + links.compact + end + def to_many_linkage(rids) linkage = [] @@ -346,36 +350,36 @@ def to_one_linkage(rid) } end - def link_object_to_one(source, relationship, rid, include_data) + def relationship_object_to_one(source, relationship, rid, include_data) link_object_hash = {} - link_object_hash['links'] = {} - link_object_hash['links']['self'] = self_link(source, relationship) - link_object_hash['links']['related'] = related_link(source, relationship) + + links = default_relationship_links(source, relationship) + + link_object_hash['links'] = links unless links.blank? link_object_hash['data'] = to_one_linkage(rid) if include_data link_object_hash end - def link_object_to_many(source, relationship, rids, include_data) + def relationship_object_to_many(source, relationship, rids, include_data) link_object_hash = {} - link_object_hash['links'] = {} - link_object_hash['links']['self'] = self_link(source, relationship) - link_object_hash['links']['related'] = related_link(source, relationship) + + links = default_relationship_links(source, relationship) + link_object_hash['links'] = links unless links.blank? link_object_hash['data'] = to_many_linkage(rids) if include_data link_object_hash end - def link_object(source, relationship, rid, include_data) + def relationship_object(source, relationship, rid, include_data) if relationship.is_a?(JSONAPI::Relationship::ToOne) - link_object_to_one(source, relationship, rid, include_data) + relationship_object_to_one(source, relationship, rid, include_data) elsif relationship.is_a?(JSONAPI::Relationship::ToMany) - link_object_to_many(source, relationship, rid, include_data) + relationship_object_to_many(source, relationship, rid, include_data) end end def generate_link_builder(primary_resource_klass, options) LinkBuilder.new( base_url: options.fetch(:base_url, ''), - route_formatter: options.fetch(:route_formatter, JSONAPI.configuration.route_formatter), primary_resource_klass: primary_resource_klass, ) end diff --git a/lib/jsonapi/response_document.rb b/lib/jsonapi/response_document.rb index 78728d995..f9912bbe7 100644 --- a/lib/jsonapi/response_document.rb +++ b/lib/jsonapi/response_document.rb @@ -118,10 +118,15 @@ def update_links(serializer, result) result.pagination_params.each_pair do |link_name, params| if result.is_a?(JSONAPI::RelatedResourcesSetOperationResult) relationship = result.source_resource.class._relationships[result._type.to_sym] - @top_level_links[link_name] = serializer.link_builder.relationships_related_link(result.source_resource, relationship, query_params(params)) + unless relationship.exclude_link?(link_name) + link = serializer.link_builder.relationships_related_link(result.source_resource, relationship, query_params(params)) + end else - @top_level_links[link_name] = serializer.query_link(query_params(params)) + unless serializer.link_builder.primary_resource_klass.exclude_link?(link_name) + link = serializer.link_builder.query_link(query_params(params)) + end end + @top_level_links[link_name] = link unless link.blank? end end end diff --git a/lib/jsonapi/routing_ext.rb b/lib/jsonapi/routing_ext.rb index 2014aa374..f96e96143 100644 --- a/lib/jsonapi/routing_ext.rb +++ b/lib/jsonapi/routing_ext.rb @@ -20,6 +20,10 @@ def jsonapi_resource(*resources, &_block) @resource_type = resources.first res = JSONAPI::Resource.resource_klass_for(resource_type_with_module_prefix(@resource_type)) + unless res.singleton? + warn "Singleton routes created for non singleton resource #{res}. Links may not be generated correctly." + end + options = resources.extract_options!.dup options[:controller] ||= @resource_type options.merge!(res.routing_resource_options) @@ -80,6 +84,10 @@ def jsonapi_resources(*resources, &_block) @resource_type = resources.first res = JSONAPI::Resource.resource_klass_for(resource_type_with_module_prefix(@resource_type)) + if res.singleton? + warn "Singleton resource #{res} should use `jsonapi_resource` instead." + end + options = resources.extract_options!.dup options[:controller] ||= @resource_type options.merge!(res.routing_resource_options) @@ -154,7 +162,8 @@ def jsonapi_link(*links) if methods.include?(:show) match "relationships/#{formatted_relationship_name}", controller: options[:controller], - action: 'show_relationship', relationship: link_type.to_s, via: [:get] + action: 'show_relationship', relationship: link_type.to_s, via: [:get], + as: "relationships/#{link_type}" end if res.mutable? @@ -182,7 +191,8 @@ def jsonapi_links(*links) if methods.include?(:show) match "relationships/#{formatted_relationship_name}", controller: options[:controller], - action: 'show_relationship', relationship: link_type.to_s, via: [:get] + action: 'show_relationship', relationship: link_type.to_s, via: [:get], + as: "relationships/#{link_type}" end if res.mutable? @@ -221,7 +231,8 @@ def jsonapi_related_resource(*relationship) match formatted_relationship_name, controller: options[:controller], relationship: relationship.name, source: resource_type_with_module_prefix(source._type), - action: 'show_related_resource', via: [:get] + action: 'show_related_resource', via: [:get], + as: relationship_name end def jsonapi_related_resources(*relationship) @@ -238,7 +249,8 @@ def jsonapi_related_resources(*relationship) match formatted_relationship_name, controller: options[:controller], relationship: relationship.name, source: resource_type_with_module_prefix(source._type), - action: 'index_related_resources', via: [:get] + action: 'index_related_resources', via: [:get], + as: relationship_name end protected diff --git a/test/controllers/controller_test.rb b/test/controllers/controller_test.rb index 49b385708..74743de2f 100644 --- a/test/controllers/controller_test.rb +++ b/test/controllers/controller_test.rb @@ -2751,6 +2751,51 @@ def test_index_with_caching_enabled_uses_context end end +class Api::V5::PostsControllerTest < ActionController::TestCase + def test_show_post_no_relationship_routes_exludes_relationships + assert_cacheable_get :show, params: {id: '1'} + assert_response :success + assert_nil json_response['data']['relationships'] + end + + def test_exclude_resource_links + assert_cacheable_get :show, params: {id: '1'} + assert_response :success + assert_nil json_response['data']['relationships'] + assert_equal 1, json_response['data']['links'].length + + Api::V5::PostResource.exclude_links :default + assert_cacheable_get :show, params: {id: '1'} + assert_response :success + assert_nil json_response['data']['relationships'] + assert_nil json_response['data']['links'] + + Api::V5::PostResource.exclude_links [:self] + assert_cacheable_get :show, params: {id: '1'} + assert_response :success + assert_nil json_response['data']['relationships'] + assert_nil json_response['data']['links'] + + Api::V5::PostResource.exclude_links :none + assert_cacheable_get :show, params: {id: '1'} + assert_response :success + assert_nil json_response['data']['relationships'] + assert_equal 1, json_response['data']['links'].length + ensure + Api::V5::PostResource.exclude_links :none + end + + def test_show_post_no_relationship_route_include + get :show, params: {id: '1', include: 'author'} + assert_response :success + assert_equal '1001', json_response['data']['relationships']['author']['data']['id'] + assert_nil json_response['data']['relationships']['tags'] + assert_equal '1001', json_response['included'][0]['id'] + assert_equal 'people', json_response['included'][0]['type'] + assert_equal 'joe@xyz.fake', json_response['included'][0]['attributes']['email'] + end +end + class Api::V5::AuthorsControllerTest < ActionController::TestCase def test_get_person_as_author assert_cacheable_get :index, params: {filter: {id: '1001'}} @@ -2950,12 +2995,16 @@ def test_poro_delete class Api::V2::PreferencesControllerTest < ActionController::TestCase def test_show_singleton_resource_without_id + $test_user = Person.find(1001) + assert_cacheable_get :show assert_response :success end def test_update_singleton_resource_without_id set_content_type_header! + $test_user = Person.find(1001) + patch :update, params: { data: { id: "1", diff --git a/test/fixtures/active_record.rb b/test/fixtures/active_record.rb index 0d06031cd..840e68210 100644 --- a/test/fixtures/active_record.rb +++ b/test/fixtures/active_record.rb @@ -147,6 +147,7 @@ create_table :preferences, force: true do |t| t.integer :person_id t.boolean :advanced_mode, default: false + t.string :nickname t.timestamps null: false end @@ -1040,6 +1041,9 @@ class PostsController < JSONAPI::ResourceController end class PreferencesController < JSONAPI::ResourceController + def context + {current_user: $test_user} + end end class BooksController < JSONAPI::ResourceController @@ -1163,6 +1167,18 @@ class CommentsController < JSONAPI::ResourceController class SectionsController < JSONAPI::ResourceController end + + class PeopleController < JSONAPI::ResourceController + def context + {current_user: $test_user} + end + end + + class PreferencesController < JSONAPI::ResourceController + def context + {current_user: $test_user} + end + end end end @@ -1622,6 +1638,12 @@ def self.verify_key(key, context = nil) class PreferencesResource < JSONAPI::Resource attribute :advanced_mode + singleton singleton_key: -> (context) { + key = context[:current_user].try(:preferences).try(:id) + raise JSONAPI::Exceptions::RecordNotFound.new(nil) if key.nil? + key + } + has_one :author, :foreign_key_on => :related, class_name: "Person" end @@ -1712,6 +1734,7 @@ class AuthorResource < JSONAPI::Resource has_many :books, inverse_relationship: :authors has_many :pictures + # has_one :preferences end class BookResource < JSONAPI::Resource @@ -1728,86 +1751,6 @@ class AuthorDetailResource < JSONAPI::Resource attributes :author_stuff end -class SimpleCustomLinkResource < JSONAPI::Resource - model_name 'Post' - attributes :title, :body, :subject - - def subject - @model.title - end - - has_one :writer, foreign_key: 'author_id', class_name: 'Writer' - has_one :section - has_many :comments, acts_as_set: false - - filters :writer - - def custom_links(options) - { raw: options[:serializer].link_builder.self_link(self) + "/raw" } - end -end - -class CustomLinkWithRelativePathOptionResource < JSONAPI::Resource - model_name 'Post' - attributes :title, :body, :subject - - def subject - @model.title - end - - has_one :writer, foreign_key: 'author_id', class_name: 'Writer' - has_one :section - has_many :comments, acts_as_set: false - - filters :writer - - def custom_links(options) - { raw: options[:serializer].link_builder.self_link(self) + "/super/duper/path.xml" } - end -end - -class CustomLinkWithIfCondition < JSONAPI::Resource - model_name 'Post' - attributes :title, :body, :subject - - def subject - @model.title - end - - has_one :writer, foreign_key: 'author_id', class_name: 'Writer' - has_one :section - has_many :comments, acts_as_set: false - - filters :writer - - def custom_links(options) - if title == "JR Solves your serialization woes!" - {conditional_custom_link: options[:serializer].link_builder.self_link(self) + "/conditional/link.json"} - end - end -end - -class CustomLinkWithLambda < JSONAPI::Resource - model_name 'Post' - attributes :title, :body, :subject, :created_at - - def subject - @model.title - end - - has_one :writer, foreign_key: 'author_id', class_name: 'Writer' - has_one :section - has_many :comments, acts_as_set: false - - filters :writer - - def custom_links(options) - { - link_to_external_api: "http://external-api.com/posts/#{ created_at.year }/#{ created_at.month }/#{ created_at.day }-#{ subject.gsub(' ', '-') }" - } - end -end - module Api module V1 class WriterResource < JSONAPI::Resource @@ -1840,6 +1783,15 @@ def subject end filters :writer + + def custom_links(options) + self_link = options[:serializer].link_builder.self_link(self) + self_link ||= '' + { + 'self' => self_link + '?secret=true', + 'raw' => self_link + "/raw" + } + end end class PersonResource < PersonResource; end @@ -1865,6 +1817,14 @@ class BoatResource < BoatResource; end module Api module V2 class PreferencesResource < PreferencesResource; end + class SectionResource < SectionResource; end + class TagResource < TagResource; end + class CommentResource < CommentResource; end + class VehicleResource < VehicleResource; end + class CarResource < CarResource; end + class BoatResource < BoatResource; end + class HairCutResource < HairCutResource; end + class ExpenseEntryResource < ExpenseEntryResource; end class PersonResource < PersonResource has_many :book_comments @@ -2028,6 +1988,16 @@ class BookCommentResource < Api::V2::BookCommentResource module Api module V5 + class PostResource < JSONAPI::Resource + attribute :title + attribute :body + + has_one :author, class_name: 'Person', exclude_links: [:self, "related"] + has_one :section, exclude_links: [:self, :related] + has_many :tags, acts_as_set: true, inverse_relationship: :posts, eager_load_on_include: false, exclude_links: :default + has_many :comments, acts_as_set: false, inverse_relationship: :post, exclude_links: ["self", :related] + end + class AuthorResource < JSONAPI::Resource attributes :name, :email model_name 'Person' @@ -2084,13 +2054,15 @@ class PainterResource < JSONAPI::Resource end class PersonResource < PersonResource; end - class PostResource < PostResource; end + class PreferencesResource < PreferencesResource; end class TagResource < TagResource; end class SectionResource < SectionResource; end class CommentResource < CommentResource; end class ExpenseEntryResource < ExpenseEntryResource; end class IsoCurrencyResource < IsoCurrencyResource; end class EmployeeResource < EmployeeResource; end + class VehicleResource < PersonResource; end + class HairCutResource < HairCutResource; end end end @@ -2234,6 +2206,60 @@ class NumeroTelefoneResource < JSONAPI::Resource end module V9 + class PersonResource < JSONAPI::Resource + has_one :preferences + singleton false + end + + class PostResource < PostResource + has_many :comments, apply_join: -> (records, relationship, resource_type, join_type, options) { + case join_type + when :inner + records = records.joins(relationship.relation_name(options)) + when :left + records = records.joins_left(relationship.relation_name(options)) + end + records.where(comments: {approved: true}) + } + end + + class TagResource < TagResource; end + class SectionResource < SectionResource; end + class CommentResource < CommentResource + has_one :author, class_name: 'Person', apply_join: -> (records, relationship, resource_type, join_type, options) { + records = apply_join(records: records, + relationship: relationship, + resource_type: resource_type, + join_type: join_type, + options: options) + + records.where(author: {special: true}) + } + end + + class AuthorResource < Api::V2::AuthorResource + end + + class BookResource < Api::V2::BookResource + end + + class BookCommentResource < Api::V2::BookCommentResource + end + + class PreferencesResource < JSONAPI::Resource + singleton singleton_key: -> (context) { + key = context[:current_user].try(:preferences).try(:id) + raise JSONAPI::Exceptions::RecordNotFound.new(nil) if key.nil? + key + } + + has_one :person, :foreign_key_on => :related + + attribute :nickname + end + end + + module V10 class PersonResource < PersonResource; end class PostResource < PostResource has_many :comments, apply_join: -> (records, relationship, resource_type, join_type, options) { @@ -2296,35 +2322,55 @@ class PersonResource < JSONAPI::Resource module MyEngine module Api module V1 + class PostResource < PostResource + end + class PersonResource < JSONAPI::Resource + has_many :posts end end end module AdminApi module V1 + class PostResource < PostResource + end + class PersonResource < JSONAPI::Resource + has_many :posts end end end module DasherizedNamespace module V1 + class PostResource < PostResource + end + class PersonResource < JSONAPI::Resource + has_many :posts end end end module OptionalNamespace module V1 + class PostResource < PostResource + end + class PersonResource < JSONAPI::Resource + has_many :posts end end end end module ApiV2Engine + class PostResource < PostResource + end + class PersonResource < JSONAPI::Resource + has_many :posts end end diff --git a/test/fixtures/people.yml b/test/fixtures/people.yml index 4af190a7c..8bb10b780 100644 --- a/test/fixtures/people.yml +++ b/test/fixtures/people.yml @@ -30,6 +30,7 @@ e: email: lib@xyz.fake date_joined: <%= DateTime.parse('2013-11-30 4:20:00 UTC +00:00') %> book_admin: true + preferences_id: 55 x: id: 1000 diff --git a/test/fixtures/preferences.yml b/test/fixtures/preferences.yml index 2084de513..48c472d76 100644 --- a/test/fixtures/preferences.yml +++ b/test/fixtures/preferences.yml @@ -1,6 +1,7 @@ a: id: 1 advanced_mode: false + nickname: Joe Schmoe b: id: 2 @@ -12,3 +13,8 @@ c: d: id: 4 advanced_mode: false + +wilma: + id: 55 + advanced_mode: true + nickname: Wilma \ No newline at end of file diff --git a/test/integration/requests/request_test.rb b/test/integration/requests/request_test.rb index eedf3c18e..fd49ad039 100644 --- a/test/integration/requests/request_test.rb +++ b/test/integration/requests/request_test.rb @@ -5,6 +5,7 @@ def setup DatabaseCleaner.start JSONAPI.configuration.json_key_format = :underscored_key JSONAPI.configuration.route_format = :underscored_route + JSONAPI.configuration.warn_on_missing_routes = false Api::V2::BookResource.paginator :offset $test_user = Person.find(1001) end @@ -15,6 +16,7 @@ def teardown def after_teardown JSONAPI.configuration.route_format = :underscored_route + JSONAPI.configuration.warn_on_missing_routes = true end def test_get @@ -188,7 +190,6 @@ def test_get_camelized_route_and_key_filtered def test_get_camelized_route_and_links original_config = JSONAPI.configuration.dup JSONAPI.configuration.json_key_format = :camelized_key - JSONAPI.configuration.route_format = :camelized_route assert_cacheable_jsonapi_get '/api/v4/expenseEntries/1/relationships/isoCurrency' assert_hash_equals({'links' => { 'self' => 'http://www.example.com/api/v4/expenseEntries/1/relationships/isoCurrency', @@ -1489,4 +1490,362 @@ def test_get_resource_with_belongs_to_relationship_and_changed_primary_key assert_equal 'access_cards', included.first['type'] assert_equal access_card.token, included.first['id'] end + + + def test_get_resource_include_singleton_relationship + $original_test_user = $test_user + $test_user = Person.find(1005) + + assert_cacheable_jsonapi_get '/api/v9/people/1005?include=preferences' + assert_jsonapi_response 200 + assert_hash_equals json_response, + { + "data" => { + "id" => "1005", + "type" => "people", + "links" => { + "self" => "http://www.example.com/api/v9/people/1005" + }, + "relationships" => { + "preferences" => { + "links" => { + "self" => "http://www.example.com/api/v9/people/1005/relationships/preferences", + "related" => "http://www.example.com/api/v9/people/1005/preferences" + }, + "data" => { + "type" => "preferences", + "id" => "55" + } + } + } + }, + "included" => [ + { + "id" => "55", + "type" => "preferences", + "attributes" => { + "nickname" => "Wilma" + }, + 'relationships' => { + 'person' => { + "links" => { + "self" => "http://www.example.com/api/v9/preferences/relationships/person", + "related" => "http://www.example.com/api/v9/preferences/person" + } + } + }, + "links" => { + "self" => "http://www.example.com/api/v9/preferences" + } + } + ] + } + ensure + $test_user = $original_test_user + end + + def test_caching_included_singleton + original_config = JSONAPI.configuration.dup + + Api::V9::PreferencesResource.caching(true) + Api::V9::PersonResource.caching(true) + + JSONAPI.configuration.resource_cache = ActiveSupport::Cache::MemoryStore.new + + $original_test_user = $test_user + $test_user = Person.find(1005) + + get "/api/v9/people/#{$test_user.id}?include=preferences" + assert_jsonapi_response 200 + assert_hash_equals json_response, + { + "data" => { + "id" => "1005", + "type" => "people", + "links" => { + "self" => "http://www.example.com/api/v9/people/1005" + }, + "relationships" => { + "preferences" => { + "links" => { + "self" => "http://www.example.com/api/v9/people/1005/relationships/preferences", + "related" => "http://www.example.com/api/v9/people/1005/preferences" + }, + "data" => { + "type" => "preferences", + "id" => "55" + } + } + } + }, + "included" => [ + { + "id" => "55", + "type" => "preferences", + "attributes" => { + "nickname" => "Wilma" + }, + 'relationships' => { + 'person' => { + "links" => { + "self" => "http://www.example.com/api/v9/preferences/relationships/person", + "related" => "http://www.example.com/api/v9/preferences/person" + } + } + }, + "links" => { + "self" => "http://www.example.com/api/v9/preferences" + } + } + ] + } + + $test_user = Person.find(1001) + assert_equal 2, JSONAPI.configuration.resource_cache.instance_variable_get(:@key_access).length + + get "/api/v9/people/#{$test_user.id}?include=preferences" + assert_jsonapi_response 200 + assert_hash_equals json_response, + { + "data" => { + "id" => "1001", + "type" => "people", + "links" => { + "self" => "http://www.example.com/api/v9/people/1001" + }, + "relationships" => { + "preferences" => { + "links" => { + "self" => "http://www.example.com/api/v9/people/1001/relationships/preferences", + "related" => "http://www.example.com/api/v9/people/1001/preferences" + }, + "data" => { + "type" => "preferences", + "id" => "1" + } + } + } + }, + "included" => [ + { + "id" => "1", + "type" => "preferences", + "attributes" => { + "nickname" => "Joe Schmoe" + }, + 'relationships' => { + 'person' => { + "links" => { + "self" => "http://www.example.com/api/v9/preferences/relationships/person", + "related" => "http://www.example.com/api/v9/preferences/person" + } + } + }, + "links" => { + "self" => "http://www.example.com/api/v9/preferences" + } + } + ] + } + + assert_equal 4, JSONAPI.configuration.resource_cache.instance_variable_get(:@key_access).length + + ensure + JSONAPI.configuration = original_config + $test_user = $original_test_user + + Api::V9::PreferencesResource.caching(false) + Api::V9::PersonResource.caching(false) + end + + def test_caching_singleton_primary + original_config = JSONAPI.configuration.dup + + Api::V9::PreferencesResource.caching(true) + Api::V9::PersonResource.caching(true) + + JSONAPI.configuration.resource_cache = ActiveSupport::Cache::MemoryStore.new + + $original_test_user = $test_user + $test_user = Person.find(1005) + + get "/api/v9/preferences" + assert_jsonapi_response 200 + assert_hash_equals json_response, + { + "data" => { + "id" => "55", + "type" => "preferences", + "attributes" => { + "nickname" => "Wilma" + }, + 'relationships' => { + 'person' => { + "links" => { + "self" => "http://www.example.com/api/v9/preferences/relationships/person", + "related" => "http://www.example.com/api/v9/preferences/person" + } + } + }, + "links" => { + "self" => "http://www.example.com/api/v9/preferences" + } + } + } + + assert_equal 1, JSONAPI.configuration.resource_cache.instance_variable_get(:@key_access).length + + $test_user = Person.find(1001) + + get "/api/v9/preferences" + assert_jsonapi_response 200 + assert_hash_equals json_response, + { + "data" => { + "id" => "1", + "type" => "preferences", + "attributes" => { + "nickname" => "Joe Schmoe" + }, + 'relationships' => { + 'person' => { + "links" => { + "self" => "http://www.example.com/api/v9/preferences/relationships/person", + "related" => "http://www.example.com/api/v9/preferences/person" + } + } + }, + "links" => { + "self" => "http://www.example.com/api/v9/preferences" + } + } + } + + assert_equal 2, JSONAPI.configuration.resource_cache.instance_variable_get(:@key_access).length + + ensure + JSONAPI.configuration = original_config + $test_user = $original_test_user + + Api::V9::PreferencesResource.caching(false) + Api::V9::PersonResource.caching(false) + end + + def test_patch_singleton + original_config = JSONAPI.configuration.dup + + Api::V9::PreferencesResource.caching(true) + Api::V9::PersonResource.caching(true) + + JSONAPI.configuration.resource_cache = ActiveSupport::Cache::MemoryStore.new + + $original_test_user = $test_user + $test_user = Person.find(1001) + + patch '/api/v9/preferences', params: + { + 'data' => { + 'type' => 'preferences', + 'id' => '1', + 'attributes' => { + 'nickname' => 'Joey' + }, + 'relationships' => { + 'person' => { + "links" => { + "self" => "http://www.example.com/api/v9/preferences/relationships/person", + "related" => "http://www.example.com/api/v9/preferences/person" + } + } + } + } + }.to_json, + headers: { + 'CONTENT_TYPE' => JSONAPI::MEDIA_TYPE, + 'Accept' => JSONAPI::MEDIA_TYPE + } + + assert_equal 200, status + prefs = Preferences.find(1) + assert_equal 'Joey', prefs.nickname + + ensure + JSONAPI.configuration = original_config + $test_user = $original_test_user + + Api::V9::PreferencesResource.caching(false) + Api::V9::PersonResource.caching(false) + end + + def test_create_singleton + original_config = JSONAPI.configuration.dup + + Api::V9::PreferencesResource.caching(true) + Api::V9::PersonResource.caching(true) + + JSONAPI.configuration.resource_cache = ActiveSupport::Cache::MemoryStore.new + + $original_test_user = $test_user + $test_user = Person.find(1004) + + assert_nil $test_user.preferences + + post '/api/v9/preferences', params: + { + 'data' => { + 'type' => 'preferences', + 'attributes' => { + 'nickname' => 'Frank' + }, + 'relationships' => { + 'person' => {'data' => {'type' => 'people', 'id' => '1004'}} + } + } + }.to_json, + headers: { + 'CONTENT_TYPE' => JSONAPI::MEDIA_TYPE, + 'Accept' => JSONAPI::MEDIA_TYPE + } + + assert_equal 201, status + assert_equal 'Frank', json_response['data']['attributes']['nickname'] + + ensure + JSONAPI.configuration = original_config + $test_user = $original_test_user + + Api::V9::PreferencesResource.caching(false) + Api::V9::PersonResource.caching(false) + end + + def test_destroy_singleton + original_config = JSONAPI.configuration.dup + + $original_test_user = $test_user + $test_user = Person.find(1005) + + init_pref_count = Preferences.count + delete '/api/v9/preferences', headers: { 'Accept' => JSONAPI::MEDIA_TYPE } + assert_equal 204, status + assert_equal init_pref_count - 1, Preferences.count + assert_nil headers['Content-Type'] + ensure + JSONAPI.configuration = original_config + $test_user = $original_test_user + end + + def test_destroy_singleton_not_found + original_config = JSONAPI.configuration.dup + + $original_test_user = $test_user + $test_user = Person.find(1003) + + init_pref_count = Preferences.count + delete '/api/v9/preferences', headers: { 'Accept' => JSONAPI::MEDIA_TYPE } + assert_equal 404, status + assert_equal init_pref_count, Preferences.count + ensure + JSONAPI.configuration = original_config + $test_user = $original_test_user + end end diff --git a/test/test_helper.rb b/test/test_helper.rb index 61f42f493..76db18c87 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -264,7 +264,7 @@ class CatResource < JSONAPI::Resource jsonapi_resources :planet_types jsonapi_resources :moons jsonapi_resources :craters - jsonapi_resources :preferences + jsonapi_resource :preferences jsonapi_resources :facts jsonapi_resources :categories jsonapi_resources :pictures @@ -285,8 +285,17 @@ class CatResource < JSONAPI::Resource jsonapi_resources :doctors jsonapi_resources :patients + jsonapi_resources :access_cards + jsonapi_resources :response + jsonapi_resources :paragraph + + jsonapi_resources :employees + jsonapi_resources :robots + namespace :api do jsonapi_resources :boxes + jsonapi_resources :things + jsonapi_resources :users namespace :v1 do jsonapi_resources :people @@ -301,21 +310,28 @@ class CatResource < JSONAPI::Resource jsonapi_resources :planet_types jsonapi_resources :moons jsonapi_resources :craters - jsonapi_resources :preferences + jsonapi_resource :preferences jsonapi_resources :likes + jsonapi_resources :writers end JSONAPI.configuration.route_format = :underscored_route namespace :v2 do - jsonapi_resources :posts do - jsonapi_link :author, except: :destroy - end + jsonapi_resources :posts jsonapi_resource :preferences, except: [:create, :destroy] jsonapi_resources :authors jsonapi_resources :books jsonapi_resources :book_comments + # + jsonapi_resources :sections + jsonapi_resources :comments + jsonapi_resources :vehicles + jsonapi_resources :cars + jsonapi_resources :boats + jsonapi_resources :hair_cuts + jsonapi_resources :people end namespace :v3 do @@ -347,12 +363,20 @@ class CatResource < JSONAPI::Resource JSONAPI.configuration.route_format = :dasherized_route namespace :v5 do + jsonapi_resources :people + jsonapi_resources :posts do end jsonapi_resources :painters + jsonapi_resources :paintings + jsonapi_resources :collectors jsonapi_resources :authors + jsonapi_resources :author_details jsonapi_resources :expense_entries jsonapi_resources :iso_currencies + jsonapi_resources :tags + jsonapi_resources :comments + jsonapi_resources :employees @@ -368,6 +392,7 @@ class CatResource < JSONAPI::Resource jsonapi_resources :customers jsonapi_resources :purchase_orders jsonapi_resources :line_items + jsonapi_resources :order_flags end JSONAPI.configuration.route_format = :underscored_route @@ -383,6 +408,11 @@ class CatResource < JSONAPI::Resource namespace :v8 do jsonapi_resources :numeros_telefone end + + namespace :v9 do + jsonapi_resources :people + jsonapi_resource :preferences + end end namespace :admin_api do diff --git a/test/unit/active_relation_resource_finder/join_manager_test.rb b/test/unit/active_relation_resource_finder/join_manager_test.rb index a87bb5e0d..7075287a9 100644 --- a/test/unit/active_relation_resource_finder/join_manager_test.rb +++ b/test/unit/active_relation_resource_finder/join_manager_test.rb @@ -74,9 +74,9 @@ def test_add_joins_source_relationship def test_add_joins_source_relationship_with_custom_apply - join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: Api::V9::PostResource, - source_relationship: Api::V9::PostResource._relationship(:comments)) - records = Api::V9::PostResource.records({}) + join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: Api::V10::PostResource, + source_relationship: Api::V10::PostResource._relationship(:comments)) + records = Api::V10::PostResource.records({}) records = join_manager.join(records, {}) if Rails::VERSION::MAJOR >= 5 && Rails::VERSION::MINOR >= 2 @@ -95,8 +95,8 @@ def test_add_nested_scoped_joins 'author' => ['1'] } - join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: Api::V9::PostResource, filters: filters) - records = Api::V9::PostResource.records({}) + join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: Api::V10::PostResource, filters: filters) + records = Api::V10::PostResource.records({}) records = join_manager.join(records, {}) if Rails::VERSION::MAJOR >= 5 && Rails::VERSION::MINOR >= 2 @@ -106,10 +106,10 @@ def test_add_nested_scoped_joins end assert_hash_equals({alias: 'posts', join_type: :root}, join_manager.source_join_details) - assert_hash_equals({alias: 'comments', join_type: :left}, join_manager.join_details_by_relationship(Api::V9::PostResource._relationship(:comments))) - assert_hash_equals({alias: 'authors_comments', join_type: :left}, join_manager.join_details_by_relationship(Api::V9::CommentResource._relationship(:author))) - assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(Api::V9::CommentResource._relationship(:tags))) - assert_hash_equals({alias: 'people', join_type: :left}, join_manager.join_details_by_relationship(Api::V9::PostResource._relationship(:author))) + assert_hash_equals({alias: 'comments', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::PostResource._relationship(:comments))) + assert_hash_equals({alias: 'authors_comments', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::CommentResource._relationship(:author))) + assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::CommentResource._relationship(:tags))) + assert_hash_equals({alias: 'people', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::PostResource._relationship(:author))) # Now test with different order for the filters filters = { @@ -118,8 +118,8 @@ def test_add_nested_scoped_joins 'comments.tags' => ['1'] } - join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: Api::V9::PostResource, filters: filters) - records = Api::V9::PostResource.records({}) + join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: Api::V10::PostResource, filters: filters) + records = Api::V10::PostResource.records({}) records = join_manager.join(records, {}) # Note sql is in different order, but aliases should still be right @@ -129,10 +129,10 @@ def test_add_nested_scoped_joins assert_equal 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "people" ON "people"."id" = "posts"."author_id" LEFT OUTER JOIN "comments" ON "comments"."post_id" = "posts"."id" LEFT OUTER JOIN "people" "authors_comments" ON "authors_comments"."id" = "comments"."author_id" LEFT OUTER JOIN "comments_tags" ON "comments_tags"."comment_id" = "comments"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "comments_tags"."tag_id" WHERE "comments"."approved" = \'t\' AND "author"."special" = \'t\'', records.to_sql end assert_hash_equals({alias: 'posts', join_type: :root}, join_manager.source_join_details) - assert_hash_equals({alias: 'comments', join_type: :left}, join_manager.join_details_by_relationship(Api::V9::PostResource._relationship(:comments))) - assert_hash_equals({alias: 'authors_comments', join_type: :left}, join_manager.join_details_by_relationship(Api::V9::CommentResource._relationship(:author))) - assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(Api::V9::CommentResource._relationship(:tags))) - assert_hash_equals({alias: 'people', join_type: :left}, join_manager.join_details_by_relationship(Api::V9::PostResource._relationship(:author))) + assert_hash_equals({alias: 'comments', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::PostResource._relationship(:comments))) + assert_hash_equals({alias: 'authors_comments', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::CommentResource._relationship(:author))) + assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::CommentResource._relationship(:tags))) + assert_hash_equals({alias: 'people', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::PostResource._relationship(:author))) # Easier to read SQL to show joins are the same, but in different order # Pass 1 @@ -159,8 +159,8 @@ def test_add_nested_joins_with_fields 'author.foo' => ['1'] } - join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: Api::V9::PostResource, filters: filters) - records = Api::V9::PostResource.records({}) + join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: Api::V10::PostResource, filters: filters) + records = Api::V10::PostResource.records({}) records = join_manager.join(records, {}) if Rails::VERSION::MAJOR >= 5 && Rails::VERSION::MINOR >= 2 @@ -170,18 +170,18 @@ def test_add_nested_joins_with_fields end assert_hash_equals({alias: 'posts', join_type: :root}, join_manager.source_join_details) - assert_hash_equals({alias: 'comments', join_type: :left}, join_manager.join_details_by_relationship(Api::V9::PostResource._relationship(:comments))) - assert_hash_equals({alias: 'authors_comments', join_type: :left}, join_manager.join_details_by_relationship(Api::V9::CommentResource._relationship(:author))) - assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(Api::V9::CommentResource._relationship(:tags))) - assert_hash_equals({alias: 'people', join_type: :left}, join_manager.join_details_by_relationship(Api::V9::PostResource._relationship(:author))) + assert_hash_equals({alias: 'comments', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::PostResource._relationship(:comments))) + assert_hash_equals({alias: 'authors_comments', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::CommentResource._relationship(:author))) + assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::CommentResource._relationship(:tags))) + assert_hash_equals({alias: 'people', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::PostResource._relationship(:author))) end def test_add_joins_with_sub_relationship relationships = %w(author author.comments tags) - join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: Api::V9::PostResource, relationships: relationships, - source_relationship: Api::V9::PostResource._relationship(:comments)) - records = Api::V9::PostResource.records({}) + join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: Api::V10::PostResource, relationships: relationships, + source_relationship: Api::V10::PostResource._relationship(:comments)) + records = Api::V10::PostResource.records({}) records = join_manager.join(records, {}) if Rails::VERSION::MAJOR >= 5 && Rails::VERSION::MINOR >= 2 @@ -191,10 +191,10 @@ def test_add_joins_with_sub_relationship end assert_hash_equals({alias: 'comments', join_type: :inner}, join_manager.source_join_details) - assert_hash_equals({alias: 'comments', join_type: :inner}, join_manager.join_details_by_relationship(Api::V9::PostResource._relationship(:comments))) - assert_hash_equals({alias: 'people', join_type: :left}, join_manager.join_details_by_relationship(Api::V9::CommentResource._relationship(:author))) - assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(Api::V9::CommentResource._relationship(:tags))) - assert_hash_equals({alias: 'comments_people', join_type: :left}, join_manager.join_details_by_relationship(Api::V9::PersonResource._relationship(:comments))) + assert_hash_equals({alias: 'comments', join_type: :inner}, join_manager.join_details_by_relationship(Api::V10::PostResource._relationship(:comments))) + assert_hash_equals({alias: 'people', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::CommentResource._relationship(:author))) + assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::CommentResource._relationship(:tags))) + assert_hash_equals({alias: 'comments_people', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::PersonResource._relationship(:comments))) end def test_add_joins_with_sub_relationship_and_filters diff --git a/test/unit/resource/relationship_test.rb b/test/unit/resource/relationship_test.rb index 3a793e706..2494724be 100644 --- a/test/unit/resource/relationship_test.rb +++ b/test/unit/resource/relationship_test.rb @@ -107,4 +107,56 @@ def test_allow_include_set_by_callable refute CallableBlogPostsResource._relationship(:comments).allow_include?(admin: false) end + def test_exclude_links_on_relationship + relationship = JSONAPI::Relationship::ToOne.new "foo", exclude_links: :none + assert_equal [], relationship._exclude_links + refute relationship.exclude_link?(:self) + refute relationship.exclude_link?("self") + + relationship = JSONAPI::Relationship::ToOne.new "foo", exclude_links: :default + assert_equal [:self, :related], relationship._exclude_links + assert relationship.exclude_link?(:self) + assert relationship.exclude_link?("self") + assert relationship.exclude_link?(:related) + assert relationship.exclude_link?("related") + + relationship = JSONAPI::Relationship::ToOne.new "foo", exclude_links: "none" + assert_equal [], relationship._exclude_links + refute relationship.exclude_link?(:self) + refute relationship.exclude_link?("self") + + relationship = JSONAPI::Relationship::ToOne.new "foo", exclude_links: "default" + assert_equal [:self, :related], relationship._exclude_links + assert relationship.exclude_link?(:self) + assert relationship.exclude_link?("self") + + relationship = JSONAPI::Relationship::ToOne.new "foo", exclude_links: :none + assert_equal [], relationship._exclude_links + refute relationship.exclude_link?(:self) + refute relationship.exclude_link?("self") + + relationship = JSONAPI::Relationship::ToOne.new "foo", exclude_links: [:self] + assert_equal [:self], relationship._exclude_links + assert relationship.exclude_link?(:self) + assert relationship.exclude_link?("self") + + relationship = JSONAPI::Relationship::ToOne.new "foo", exclude_links: :none + assert_equal [], relationship._exclude_links + refute relationship.exclude_link?(:self) + refute relationship.exclude_link?("self") + + relationship = JSONAPI::Relationship::ToOne.new "foo", exclude_links: ["self", :related] + assert_equal [:self, :related], relationship._exclude_links + assert relationship.exclude_link?(:self) + assert relationship.exclude_link?("self") + + relationship = JSONAPI::Relationship::ToOne.new "foo", exclude_links: [] + assert_equal [], relationship._exclude_links + refute relationship.exclude_link?(:self) + refute relationship.exclude_link?("self") + + assert_raises do + JSONAPI::Relationship::ToOne.new "foo", :self + end + end end diff --git a/test/unit/resource/resource_test.rb b/test/unit/resource/resource_test.rb index c7df61721..0bb8d1aa7 100644 --- a/test/unit/resource/resource_test.rb +++ b/test/unit/resource/resource_test.rb @@ -58,6 +58,9 @@ class FelineResource < JSONAPI::Resource has_one :father, class_name: 'Cat' end +class TestSingletonResource < JSONAPI::Resource +end + module MyModule class MyNamespacedResource < JSONAPI::Resource model_name "Person" @@ -560,4 +563,83 @@ def test_sortable_field? assert(PostResource.sortable_field?(:body)) refute(PostResource.sortable_field?(:color)) end + + def test_exclude_links_on_resource + Api::V5::PostResource.exclude_links :none + assert_equal [], Api::V5::PostResource._exclude_links + refute Api::V5::PostResource.exclude_link?(:self) + refute Api::V5::PostResource.exclude_link?("self") + + Api::V5::PostResource.exclude_links :default + assert_equal [:self], Api::V5::PostResource._exclude_links + assert Api::V5::PostResource.exclude_link?(:self) + assert Api::V5::PostResource.exclude_link?("self") + + Api::V5::PostResource.exclude_links "none" + assert_equal [], Api::V5::PostResource._exclude_links + refute Api::V5::PostResource.exclude_link?(:self) + refute Api::V5::PostResource.exclude_link?("self") + + Api::V5::PostResource.exclude_links "default" + assert_equal [:self], Api::V5::PostResource._exclude_links + assert Api::V5::PostResource.exclude_link?(:self) + assert Api::V5::PostResource.exclude_link?("self") + + Api::V5::PostResource.exclude_links :none + assert_equal [], Api::V5::PostResource._exclude_links + refute Api::V5::PostResource.exclude_link?(:self) + refute Api::V5::PostResource.exclude_link?("self") + + Api::V5::PostResource.exclude_links [:self] + assert_equal [:self], Api::V5::PostResource._exclude_links + assert Api::V5::PostResource.exclude_link?(:self) + assert Api::V5::PostResource.exclude_link?("self") + + Api::V5::PostResource.exclude_links :none + assert_equal [], Api::V5::PostResource._exclude_links + refute Api::V5::PostResource.exclude_link?(:self) + refute Api::V5::PostResource.exclude_link?("self") + + Api::V5::PostResource.exclude_links ["self"] + assert_equal [:self], Api::V5::PostResource._exclude_links + assert Api::V5::PostResource.exclude_link?(:self) + assert Api::V5::PostResource.exclude_link?("self") + + Api::V5::PostResource.exclude_links [] + assert_equal [], Api::V5::PostResource._exclude_links + refute Api::V5::PostResource.exclude_link?(:self) + refute Api::V5::PostResource.exclude_link?("self") + + assert_raises do + Api::V5::PostResource.exclude_links :self + end + + ensure + Api::V5::PostResource.exclude_links :none + end + + def test_singleton_options + TestSingletonResource.singleton true + assert TestSingletonResource.singleton? + assert TestSingletonResource._singleton_options.blank? + + TestSingletonResource.singleton false + refute TestSingletonResource.singleton? + assert TestSingletonResource._singleton_options.blank? + + TestSingletonResource.singleton true, a: :b + assert TestSingletonResource.singleton? + refute TestSingletonResource._singleton_options.blank? + assert_equal :b, TestSingletonResource._singleton_options[:a] + + TestSingletonResource.singleton false, c: :d + refute TestSingletonResource.singleton? + refute TestSingletonResource._singleton_options.blank? + assert_equal :d, TestSingletonResource._singleton_options[:c] + + TestSingletonResource.singleton e: :f + assert TestSingletonResource.singleton? + refute TestSingletonResource._singleton_options.blank? + assert_equal :f, TestSingletonResource._singleton_options[:e] + end end diff --git a/test/unit/serializer/link_builder_test.rb b/test/unit/serializer/link_builder_test.rb index 3dcd5774f..fddda0a39 100644 --- a/test/unit/serializer/link_builder_test.rb +++ b/test/unit/serializer/link_builder_test.rb @@ -32,16 +32,16 @@ def test_engine_name assert_equal MyEngine::Engine, JSONAPI::LinkBuilder.new( primary_resource_klass: MyEngine::Api::V1::PersonResource - ).engine_name + ).engine assert_equal ApiV2Engine::Engine, JSONAPI::LinkBuilder.new( primary_resource_klass: ApiV2Engine::PersonResource - ).engine_name + ).engine assert_nil JSONAPI::LinkBuilder.new( primary_resource_klass: Api::V1::PersonResource - ).engine_name + ).engine end def test_self_link_regular_app @@ -156,7 +156,7 @@ def test_relationships_self_link_for_regular_app builder = JSONAPI::LinkBuilder.new(config) source = Api::V1::PersonResource.new(@steve, nil) - relationship = JSONAPI::Relationship::ToMany.new("posts", {}) + relationship = JSONAPI::Relationship::ToMany.new("posts", {parent_resource: Api::V1::PersonResource}) expected_link = "#{ @base_url }/api/v1/people/#{ @steve.id }/relationships/posts" assert_equal expected_link, @@ -172,7 +172,7 @@ def test_relationships_self_link_for_engine builder = JSONAPI::LinkBuilder.new(config) source = ApiV2Engine::PersonResource.new(@steve, nil) - relationship = JSONAPI::Relationship::ToMany.new("posts", {}) + relationship = JSONAPI::Relationship::ToMany.new("posts", {parent_resource: ApiV2Engine::PersonResource}) expected_link = "#{ @base_url }/api_v2/people/#{ @steve.id }/relationships/posts" assert_equal expected_link, @@ -188,7 +188,7 @@ def test_relationships_self_link_for_namespaced_engine builder = JSONAPI::LinkBuilder.new(config) source = MyEngine::Api::V1::PersonResource.new(@steve, nil) - relationship = JSONAPI::Relationship::ToMany.new("posts", {}) + relationship = JSONAPI::Relationship::ToMany.new("posts", {parent_resource: MyEngine::Api::V1::PersonResource}) expected_link = "#{ @base_url }/boomshaka/api/v1/people/#{ @steve.id }/relationships/posts" assert_equal expected_link, @@ -204,7 +204,7 @@ def test_relationships_related_link_for_regular_app builder = JSONAPI::LinkBuilder.new(config) source = Api::V1::PersonResource.new(@steve, nil) - relationship = JSONAPI::Relationship::ToMany.new("posts", {}) + relationship = JSONAPI::Relationship::ToMany.new("posts", {parent_resource: Api::V1::PersonResource}) expected_link = "#{ @base_url }/api/v1/people/#{ @steve.id }/posts" assert_equal expected_link, @@ -220,7 +220,7 @@ def test_relationships_related_link_for_engine builder = JSONAPI::LinkBuilder.new(config) source = ApiV2Engine::PersonResource.new(@steve, nil) - relationship = JSONAPI::Relationship::ToMany.new("posts", {}) + relationship = JSONAPI::Relationship::ToMany.new("posts", {parent_resource: ApiV2Engine::PersonResource}) expected_link = "#{ @base_url }/api_v2/people/#{ @steve.id }/posts" assert_equal expected_link, @@ -236,7 +236,7 @@ def test_relationships_related_link_for_namespaced_engine builder = JSONAPI::LinkBuilder.new(config) source = MyEngine::Api::V1::PersonResource.new(@steve, nil) - relationship = JSONAPI::Relationship::ToMany.new("posts", {}) + relationship = JSONAPI::Relationship::ToMany.new("posts", {parent_resource: MyEngine::Api::V1::PersonResource}) expected_link = "#{ @base_url }/boomshaka/api/v1/people/#{ @steve.id }/posts" assert_equal expected_link, @@ -252,7 +252,7 @@ def test_relationships_related_link_with_query_params builder = JSONAPI::LinkBuilder.new(config) source = Api::V1::PersonResource.new(@steve, nil) - relationship = JSONAPI::Relationship::ToMany.new("posts", {}) + relationship = JSONAPI::Relationship::ToMany.new("posts", {parent_resource: Api::V1::PersonResource}) expected_link = "#{ @base_url }/api/v1/people/#{ @steve.id }/posts?page%5Blimit%5D=12&page%5Boffset%5D=0" query = { page: { offset: 0, limit: 12 } } @@ -302,20 +302,6 @@ def test_query_link_for_regular_app_with_dasherized_scope assert_equal expected_link, builder.query_link(query) end - def test_query_link_for_regular_app_with_optional_scope - config = { - base_url: @base_url, - route_formatter: OptionalRouteFormatter, - primary_resource_klass: OptionalNamespace::V1::PersonResource - } - - query = { page: { offset: 0, limit: 12 } } - builder = JSONAPI::LinkBuilder.new(config) - expected_link = "#{ @base_url }/optional_namespace/people?page%5Blimit%5D=12&page%5Boffset%5D=0" - - assert_equal expected_link, builder.query_link(query) - end - def test_query_link_for_engine config = { base_url: @base_url, @@ -358,20 +344,6 @@ def test_query_link_for_engine_with_dasherized_scope assert_equal expected_link, builder.query_link(query) end - def test_query_link_for_engine_with_optional_scope - config = { - base_url: @base_url, - route_formatter: OptionalRouteFormatter, - primary_resource_klass: MyEngine::OptionalNamespace::V1::PersonResource - } - - query = { page: { offset: 0, limit: 12 } } - builder = JSONAPI::LinkBuilder.new(config) - expected_link = "#{ @base_url }/boomshaka/optional_namespace/people?page%5Blimit%5D=12&page%5Boffset%5D=0" - - assert_equal expected_link, builder.query_link(query) - end - def test_query_link_for_engine_with_camel_case_scope config = { base_url: @base_url, diff --git a/test/unit/serializer/serializer_test.rb b/test/unit/serializer/serializer_test.rb index 94e4b2af8..6d6201961 100644 --- a/test/unit/serializer/serializer_test.rb +++ b/test/unit/serializer/serializer_test.rb @@ -100,7 +100,7 @@ def test_serializer_nil_handling ) end - def test_serializer_namespaced_resource + def test_serializer_namespaced_resource_with_custom_resource_links post_1_identity = JSONAPI::ResourceIdentity.new(Api::V1::PostResource, 1) id_tree = JSONAPI::PrimaryResourceIdTree.new @@ -122,7 +122,8 @@ def test_serializer_namespaced_resource type: 'posts', id: '1', links: { - self: 'http://example.com/api/v1/posts/1' + self: 'http://example.com/api/v1/posts/1?secret=true', + raw: 'http://example.com/api/v1/posts/1/raw' }, attributes: { title: 'New post', @@ -302,8 +303,8 @@ def test_serializer_include }, hairCut: { links: { - self: '/people/1001/relationships/hairCut', - related: '/people/1001/hairCut' + self: '/people/1001/relationships/hair_cut', + related: '/people/1001/hair_cut' } }, vehicles: { @@ -314,8 +315,8 @@ def test_serializer_include }, expenseEntries: { links: { - self: '/people/1001/relationships/expenseEntries', - related: '/people/1001/expenseEntries' + self: '/people/1001/relationships/expense_entries', + related: '/people/1001/expense_entries' } } } @@ -433,8 +434,8 @@ def test_serializer_key_format }, hair_cut: { links: { - self: '/people/1001/relationships/hairCut', - related: '/people/1001/hairCut' + self: '/people/1001/relationships/hair_cut', + related: '/people/1001/hair_cut' } }, vehicles: { @@ -445,8 +446,8 @@ def test_serializer_key_format }, expense_entries: { links: { - self: '/people/1001/relationships/expenseEntries', - related: '/people/1001/expenseEntries' + self: '/people/1001/relationships/expense_entries', + related: '/people/1001/expense_entries' } } } From f4c6b937d78b6652c039c0cf15c746d57f7d6b87 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Sat, 18 May 2019 09:06:15 -0400 Subject: [PATCH 145/237] Bump jsonapi-resources to 0.10.0.beta4 --- lib/jsonapi/resources/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/jsonapi/resources/version.rb b/lib/jsonapi/resources/version.rb index dbb0801ab..ab0055f80 100644 --- a/lib/jsonapi/resources/version.rb +++ b/lib/jsonapi/resources/version.rb @@ -1,5 +1,5 @@ module JSONAPI module Resources - VERSION = '0.10.0.beta3' + VERSION = '0.10.0.beta4' end end From acaa4748911a288a0f47e2a95db577f9dbd7ca1f Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Wed, 29 May 2019 14:37:18 -0400 Subject: [PATCH 146/237] Scope related route names in a similar manner to relationship routes This dodges an issue where the related route names might conflict with index routes (e.g. a List that `has_many :items, class_name: 'ListItem'` would have previously used `list_items` for the related resources and conflict with the `list_items` index route for the ListItem class`) cherry picked from 886cbe5 in release-09 --- lib/jsonapi/link_builder.rb | 2 ++ lib/jsonapi/routing_ext.rb | 4 ++-- test/fixtures/active_record.rb | 30 ++++++++++++++++++++++++++ test/integration/routes/routes_test.rb | 14 ++++++++++++ test/test_helper.rb | 3 +++ 5 files changed, 51 insertions(+), 2 deletions(-) diff --git a/lib/jsonapi/link_builder.rb b/lib/jsonapi/link_builder.rb index a0b013ebc..c02fed02b 100644 --- a/lib/jsonapi/link_builder.rb +++ b/lib/jsonapi/link_builder.rb @@ -150,12 +150,14 @@ def resource_url_helper_name_from_source(source) def related_url_helper_name(relationship) relationship_parts = resource_path_parts_from_class(relationship.parent_resource) + relationship_parts << "related" relationship_parts << relationship.name url_helper_name_from_parts(relationship_parts) end def singleton_related_url_helper_name(relationship) relationship_parts = [] + relationship_parts << "related" relationship_parts << relationship.name relationship_parts += resource_path_parts_from_class(relationship.parent_resource) url_helper_name_from_parts(relationship_parts) diff --git a/lib/jsonapi/routing_ext.rb b/lib/jsonapi/routing_ext.rb index f96e96143..52d3089c2 100644 --- a/lib/jsonapi/routing_ext.rb +++ b/lib/jsonapi/routing_ext.rb @@ -232,7 +232,7 @@ def jsonapi_related_resource(*relationship) match formatted_relationship_name, controller: options[:controller], relationship: relationship.name, source: resource_type_with_module_prefix(source._type), action: 'show_related_resource', via: [:get], - as: relationship_name + as: "related/#{relationship_name}" end def jsonapi_related_resources(*relationship) @@ -250,7 +250,7 @@ def jsonapi_related_resources(*relationship) controller: options[:controller], relationship: relationship.name, source: resource_type_with_module_prefix(source._type), action: 'index_related_resources', via: [:get], - as: relationship_name + as: "related/#{relationship_name}" end protected diff --git a/test/fixtures/active_record.rb b/test/fixtures/active_record.rb index 840e68210..c27175a4c 100644 --- a/test/fixtures/active_record.rb +++ b/test/fixtures/active_record.rb @@ -372,6 +372,14 @@ t.belongs_to :painting end + create_table :lists, force: true do |t| + t.string :name + end + + create_table :list_items, force: true do |t| + t.belongs_to :list + end + # special cases create_table :storages, force: true do |t| t.string :token, null: false @@ -870,6 +878,14 @@ class Collector < ActiveRecord::Base belongs_to :painting end +class List < ActiveRecord::Base + has_many :items, class_name: 'ListItem', inverse_of: :list +end + +class ListItem < ActiveRecord::Base + belongs_to :list, inverse_of: :items +end + ### CONTROLLERS class SessionsController < ActionController::Base include JSONAPI::ActsAsResourceController @@ -1202,6 +1218,12 @@ class DoctorsController < JSONAPI::ResourceController class RespondentController < JSONAPI::ResourceController end +class ListsController < JSONAPI::ResourceController +end + +class ListItemsController < JSONAPI::ResourceController +end + class StoragesController < BaseController end @@ -2531,6 +2553,14 @@ class RespondentResource < JSONAPI::Resource abstract end +class ListResource < JSONAPI::Resource + has_many :items, class_name: 'ListItem' +end + +class ListItemResource < JSONAPI::Resource + has_one :list +end + class StorageResource < JSONAPI::Resource key_type :string primary_key :token diff --git a/test/integration/routes/routes_test.rb b/test/integration/routes/routes_test.rb index 8d3f1ffa2..fd10e0b46 100644 --- a/test/integration/routes/routes_test.rb +++ b/test/integration/routes/routes_test.rb @@ -205,6 +205,20 @@ def test_routing_author_links_posts_create_not_acts_as_set {controller: 'api/v5/authors', action: 'create_relationship', author_id: '1', relationship: 'posts'}) end + def test_routing_list_items_index + assert_routing({path: '/list_items', method: :get}, + {controller: 'list_items', action: 'index'}) + end + + def test_routing_list_related_items + assert_routing({path: '/lists/1/items', method: :get}, + {controller: 'list_items', action: 'index_related_resources', relationship: 'items', list_id: '1', source: 'lists'}) + end + + def test_list_items_route_helper_name + assert_equal(list_items_path, '/list_items') + end + #primary_key def test_routing_primary_key_jsonapi_resources assert_routing({path: '/iso_currencies/USD', method: :get}, diff --git a/test/test_helper.rb b/test/test_helper.rb index 76db18c87..b8e6acc40 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -292,6 +292,9 @@ class CatResource < JSONAPI::Resource jsonapi_resources :employees jsonapi_resources :robots + jsonapi_resources :lists + jsonapi_resources :list_items + namespace :api do jsonapi_resources :boxes jsonapi_resources :things From 68a553f0b179304f9dbfcada1baeaf4ba6ce247f Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Tue, 4 Jun 2019 09:20:28 -0400 Subject: [PATCH 147/237] Bump jsonapi-resources to 0.10.0.beta5 --- lib/jsonapi/resources/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/jsonapi/resources/version.rb b/lib/jsonapi/resources/version.rb index ab0055f80..9d9929a36 100644 --- a/lib/jsonapi/resources/version.rb +++ b/lib/jsonapi/resources/version.rb @@ -1,5 +1,5 @@ module JSONAPI module Resources - VERSION = '0.10.0.beta4' + VERSION = '0.10.0.beta5' end end From 3a758333ba25690c926b2487556e56ccc5afafb6 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Thu, 6 Jun 2019 08:51:52 -0400 Subject: [PATCH 148/237] Build Links with strings, track route setup on resource and relationships Revert building links with 'url_helpers' for performance reasons --- lib/jsonapi/acts_as_resource_controller.rb | 3 +- lib/jsonapi/basic_resource.rb | 5 +- lib/jsonapi/link_builder.rb | 195 ++++------ lib/jsonapi/relationship.rb | 5 + lib/jsonapi/resource_controller_metal.rb | 3 + lib/jsonapi/resource_serializer.rb | 2 + lib/jsonapi/routing_ext.rb | 8 + test/fixtures/active_record.rb | 6 +- test/integration/requests/request_test.rb | 3 +- test/unit/processor/default_processor_test.rb | 4 +- test/unit/serializer/link_builder_test.rb | 339 +++++++++++++++--- test/unit/serializer/serializer_test.rb | 37 +- 12 files changed, 431 insertions(+), 179 deletions(-) diff --git a/lib/jsonapi/acts_as_resource_controller.rb b/lib/jsonapi/acts_as_resource_controller.rb index 9295831b2..b8d75ae74 100644 --- a/lib/jsonapi/acts_as_resource_controller.rb +++ b/lib/jsonapi/acts_as_resource_controller.rb @@ -102,7 +102,8 @@ def process_request base_url: base_url, key_formatter: key_formatter, route_formatter: route_formatter, - serialization_options: serialization_options + serialization_options: serialization_options, + controller: self ) op.options[:cache_serializer_output] = !JSONAPI.configuration.resource_cache.nil? diff --git a/lib/jsonapi/basic_resource.rb b/lib/jsonapi/basic_resource.rb index 61e2d5916..adf40cc65 100644 --- a/lib/jsonapi/basic_resource.rb +++ b/lib/jsonapi/basic_resource.rb @@ -448,6 +448,9 @@ def inherited(subclass) end check_reserved_resource_name(subclass._type, subclass.name) + + subclass._routed = false + subclass._warned_missing_route = false end def rebuild_relationships(relationships) @@ -494,7 +497,7 @@ def resource_type_for(model) end end - attr_accessor :_attributes, :_relationships, :_type, :_model_hints + attr_accessor :_attributes, :_relationships, :_type, :_model_hints, :_routed, :_warned_missing_route attr_writer :_allowed_filters, :_paginator, :_allowed_sort def create(context) diff --git a/lib/jsonapi/link_builder.rb b/lib/jsonapi/link_builder.rb index c02fed02b..99cd6223c 100644 --- a/lib/jsonapi/link_builder.rb +++ b/lib/jsonapi/link_builder.rb @@ -2,23 +2,24 @@ module JSONAPI class LinkBuilder attr_reader :base_url, :primary_resource_klass, + :route_formatter, :engine, - :routes + :engine_mount_point, + :url_helpers + + @@url_helper_methods = {} def initialize(config = {}) - @base_url = config[:base_url] + @base_url = config[:base_url] @primary_resource_klass = config[:primary_resource_klass] - @engine = build_engine - - if engine? - @routes = @engine.routes - else - @routes = Rails.application.routes - end + @route_formatter = config[:route_formatter] + @engine = build_engine + @engine_mount_point = @engine ? @engine.routes.find_script_name({}) : "" - # ToDo: Use NaiveCache for values. For this we need to not return nils and create composite keys which work - # as efficient cache lookups. This could be an array of the [source.identifier, relationship] since the - # ResourceIdentity will compare equality correctly + # url_helpers may be either a controller which has the route helper methods, or the application router's + # url helpers module, `Rails.application.routes.url_helpers`. Because the method no longer behaves as a + # singleton, and it's expensive to generate the module, the controller is preferred. + @url_helpers = config[:url_helpers] end def engine? @@ -26,50 +27,60 @@ def engine? end def primary_resources_url - @primary_resources_url_cached ||= "#{ base_url }#{ primary_resources_path }" - rescue NoMethodError - warn "primary_resources_url for #{@primary_resource_klass} could not be generated" if JSONAPI.configuration.warn_on_missing_routes + if @primary_resource_klass._routed + primary_resources_path = resources_path(primary_resource_klass) + @primary_resources_url_cached ||= "#{ base_url }#{ engine_mount_point }#{ primary_resources_path }" + else + if JSONAPI.configuration.warn_on_missing_routes && !@primary_resource_klass._warned_missing_route + warn "primary_resources_url for #{@primary_resource_klass} could not be generated" + @primary_resource_klass._warned_missing_route = true + end + nil + end end def query_link(query_params) - "#{ primary_resources_url }?#{ query_params.to_query }" + url = primary_resources_url + return url if url.nil? + "#{ url }?#{ query_params.to_query }" end def relationships_related_link(source, relationship, query_params = {}) - if relationship.parent_resource.singleton? - url_helper_name = singleton_related_url_helper_name(relationship) - url = call_url_helper(url_helper_name) + if relationship._routed + url = "#{ self_link(source) }/#{ route_for_relationship(relationship) }" + url = "#{ url }?#{ query_params.to_query }" if query_params.present? + url else - url_helper_name = related_url_helper_name(relationship) - url = call_url_helper(url_helper_name, source.id) + if JSONAPI.configuration.warn_on_missing_routes && !relationship._warned_missing_route + warn "related_link for #{relationship} could not be generated" + relationship._warned_missing_route = true + end + nil end - - url = "#{ base_url }#{ url }" - url = "#{ url }?#{ query_params.to_query }" if query_params.present? - url - rescue NoMethodError - warn "related_link for #{relationship} could not be generated" if JSONAPI.configuration.warn_on_missing_routes end def relationships_self_link(source, relationship) - if relationship.parent_resource.singleton? - url_helper_name = singleton_relationship_self_url_helper_name(relationship) - url = call_url_helper(url_helper_name) + if relationship._routed + "#{ self_link(source) }/relationships/#{ route_for_relationship(relationship) }" else - url_helper_name = relationship_self_url_helper_name(relationship) - url = call_url_helper(url_helper_name, source.id) + if JSONAPI.configuration.warn_on_missing_routes && !relationship._warned_missing_route + warn "self_link for #{relationship} could not be generated" + relationship._warned_missing_route = true + end + nil end - - url = "#{ base_url }#{ url }" - url - rescue NoMethodError - warn "self_link for #{relationship} could not be generated" if JSONAPI.configuration.warn_on_missing_routes end def self_link(source) - "#{ base_url }#{ resource_path(source) }" - rescue NoMethodError - warn "self_link for #{source.class} could not be generated" if JSONAPI.configuration.warn_on_missing_routes + if source.class._routed + resource_url(source) + else + if JSONAPI.configuration.warn_on_missing_routes && !source.class._warned_missing_route + warn "self_link for #{source.class} could not be generated" + source.class._warned_missing_route = true + end + nil + end end private @@ -81,105 +92,55 @@ def build_engine unless scopes.empty? "#{ scopes.first.to_s.camelize }::Engine".safe_constantize end - # :nocov: + + # :nocov: rescue LoadError => _e nil - # :nocov: + # :nocov: end end - def call_url_helper(method, *args) - routes.url_helpers.public_send(method, args) - rescue NoMethodError => e - raise e + def format_route(route) + route_formatter.format(route) end - def path_from_resource_class(klass) - url_helper_name = resources_url_helper_name_from_class(klass) - call_url_helper(url_helper_name) - end + def formatted_module_path_from_class(klass) + scopes = if @engine + module_scopes_from_class(klass)[1..-1] + else + module_scopes_from_class(klass) + end - def resource_path(source) - url_helper_name = resource_url_helper_name_from_source(source) - if source.class.singleton? - call_url_helper(url_helper_name) + unless scopes.empty? + "/#{ scopes.map {|scope| format_route(scope.to_s.underscore)}.compact.join('/') }/" else - call_url_helper(url_helper_name, source.id) + "/" end end - def primary_resources_path - path_from_resource_class(primary_resource_klass) + def module_scopes_from_class(klass) + klass.name.to_s.split("::")[0...-1] end - def url_helper_name_from_parts(parts) - (parts << "path").reject(&:blank?).join("_") + def resources_path(source_klass) + formatted_module_path_from_class(source_klass) + format_route(source_klass._type.to_s) end - def resources_path_parts_from_class(klass) - if engine? - scopes = module_scopes_from_class(klass)[1..-1] - else - scopes = module_scopes_from_class(klass) - end - - base_path_name = scopes.map { |scope| scope.underscore }.join("_") - end_path_name = klass._type.to_s - [base_path_name, end_path_name] - end - - def resources_url_helper_name_from_class(klass) - url_helper_name_from_parts(resources_path_parts_from_class(klass)) - end + def resource_path(source) + url = "#{resources_path(source.class)}" - def resource_path_parts_from_class(klass) - if engine? - scopes = module_scopes_from_class(klass)[1..-1] - else - scopes = module_scopes_from_class(klass) + unless source.class.singleton? + url = "#{url}/#{source.id}" end - - base_path_name = scopes.map { |scope| scope.underscore }.join("_") - end_path_name = klass._type.to_s.singularize - [base_path_name, end_path_name] - end - - def resource_url_helper_name_from_source(source) - url_helper_name_from_parts(resource_path_parts_from_class(source.class)) - end - - def related_url_helper_name(relationship) - relationship_parts = resource_path_parts_from_class(relationship.parent_resource) - relationship_parts << "related" - relationship_parts << relationship.name - url_helper_name_from_parts(relationship_parts) - end - - def singleton_related_url_helper_name(relationship) - relationship_parts = [] - relationship_parts << "related" - relationship_parts << relationship.name - relationship_parts += resource_path_parts_from_class(relationship.parent_resource) - url_helper_name_from_parts(relationship_parts) - end - - def relationship_self_url_helper_name(relationship) - relationship_parts = resource_path_parts_from_class(relationship.parent_resource) - relationship_parts << "relationships" - relationship_parts << relationship.name - url_helper_name_from_parts(relationship_parts) + url end - def singleton_relationship_self_url_helper_name(relationship) - relationship_parts = [] - relationship_parts << "relationships" - relationship_parts << relationship.name - relationship_parts += resource_path_parts_from_class(relationship.parent_resource) - url_helper_name_from_parts(relationship_parts) + def resource_url(source) + "#{ base_url }#{ engine_mount_point }#{ resource_path(source) }" end - def module_scopes_from_class(klass) - klass.name.to_s.split("::")[0...-1] + def route_for_relationship(relationship) + format_route(relationship.name) end end end diff --git a/lib/jsonapi/relationship.rb b/lib/jsonapi/relationship.rb index 6a58398ca..74b925853 100644 --- a/lib/jsonapi/relationship.rb +++ b/lib/jsonapi/relationship.rb @@ -7,6 +7,8 @@ class Relationship attr_writer :allow_include + attr_accessor :_routed, :_warned_missing_route + def initialize(name, options = {}) @name = name.to_s @options = options @@ -27,6 +29,9 @@ def initialize(name, options = {}) @class_name = nil @inverse_relationship = nil + @_routed = false + @_warned_missing_route = false + exclude_links(options.fetch(:exclude_links, :none)) # Custom methods are reserved for future use diff --git a/lib/jsonapi/resource_controller_metal.rb b/lib/jsonapi/resource_controller_metal.rb index bf5bc9410..c950e4659 100644 --- a/lib/jsonapi/resource_controller_metal.rb +++ b/lib/jsonapi/resource_controller_metal.rb @@ -10,6 +10,9 @@ class ResourceControllerMetal < ActionController::Metal JSONAPI::ActsAsResourceController ].freeze + # Note, the url_helpers are not loaded. This will prevent links from being generated for resources, and warnings + # will be emitted. Link support can be added by including `Rails.application.routes.url_helpers`, and links + # can be disabled, and warning suppressed, for a resource with `exclude_links :default` MODULES.each do |mod| include mod end diff --git a/lib/jsonapi/resource_serializer.rb b/lib/jsonapi/resource_serializer.rb index dfb381ff3..e5749a98e 100644 --- a/lib/jsonapi/resource_serializer.rb +++ b/lib/jsonapi/resource_serializer.rb @@ -381,6 +381,8 @@ def generate_link_builder(primary_resource_klass, options) LinkBuilder.new( base_url: options.fetch(:base_url, ''), primary_resource_klass: primary_resource_klass, + route_formatter: options.fetch(:route_formatter, JSONAPI.configuration.route_formatter), + url_helpers: options.fetch(:url_helpers, options[:controller]), ) end end diff --git a/lib/jsonapi/routing_ext.rb b/lib/jsonapi/routing_ext.rb index 52d3089c2..de6668a4b 100644 --- a/lib/jsonapi/routing_ext.rb +++ b/lib/jsonapi/routing_ext.rb @@ -20,6 +20,8 @@ def jsonapi_resource(*resources, &_block) @resource_type = resources.first res = JSONAPI::Resource.resource_klass_for(resource_type_with_module_prefix(@resource_type)) + res._routed = true + unless res.singleton? warn "Singleton routes created for non singleton resource #{res}. Links may not be generated correctly." end @@ -84,6 +86,8 @@ def jsonapi_resources(*resources, &_block) @resource_type = resources.first res = JSONAPI::Resource.resource_klass_for(resource_type_with_module_prefix(@resource_type)) + res._routed = true + if res.singleton? warn "Singleton resource #{res} should use `jsonapi_resource` instead." end @@ -220,6 +224,8 @@ def jsonapi_related_resource(*relationship) relationship_name = relationship.first relationship = source._relationships[relationship_name] + relationship._routed = true + formatted_relationship_name = format_route(relationship.name) if relationship.polymorphic? @@ -242,6 +248,8 @@ def jsonapi_related_resources(*relationship) relationship_name = relationship.first relationship = source._relationships[relationship_name] + relationship._routed = true + formatted_relationship_name = format_route(relationship.name) related_resource = JSONAPI::Resource.resource_klass_for(resource_type_with_module_prefix(relationship.class_name.underscore)) options[:controller] ||= related_resource._type.to_s diff --git a/test/fixtures/active_record.rb b/test/fixtures/active_record.rb index c27175a4c..602d2825b 100644 --- a/test/fixtures/active_record.rb +++ b/test/fixtures/active_record.rb @@ -901,6 +901,7 @@ def create_responses_relationships end class AuthorsController < JSONAPI::ResourceControllerMetal + include Rails.application.routes.url_helpers end class PeopleController < JSONAPI::ResourceController @@ -1991,7 +1992,9 @@ module V4 class PostResource < PostResource; end class PersonResource < PersonResource; end class ExpenseEntryResource < ExpenseEntryResource; end - class IsoCurrencyResource < IsoCurrencyResource; end + class IsoCurrencyResource < IsoCurrencyResource + has_many :expense_entries, exclude_links: :default + end class AuthorResource < Api::V2::AuthorResource; end @@ -2389,6 +2392,7 @@ class PersonResource < JSONAPI::Resource module ApiV2Engine class PostResource < PostResource + has_one :person end class PersonResource < JSONAPI::Resource diff --git a/test/integration/requests/request_test.rb b/test/integration/requests/request_test.rb index fd49ad039..64f988767 100644 --- a/test/integration/requests/request_test.rb +++ b/test/integration/requests/request_test.rb @@ -5,7 +5,6 @@ def setup DatabaseCleaner.start JSONAPI.configuration.json_key_format = :underscored_key JSONAPI.configuration.route_format = :underscored_route - JSONAPI.configuration.warn_on_missing_routes = false Api::V2::BookResource.paginator :offset $test_user = Person.find(1001) end @@ -16,7 +15,6 @@ def teardown def after_teardown JSONAPI.configuration.route_format = :underscored_route - JSONAPI.configuration.warn_on_missing_routes = true end def test_get @@ -190,6 +188,7 @@ def test_get_camelized_route_and_key_filtered def test_get_camelized_route_and_links original_config = JSONAPI.configuration.dup JSONAPI.configuration.json_key_format = :camelized_key + JSONAPI.configuration.route_format = :camelized_route assert_cacheable_jsonapi_get '/api/v4/expenseEntries/1/relationships/isoCurrency' assert_hash_equals({'links' => { 'self' => 'http://www.example.com/api/v4/expenseEntries/1/relationships/isoCurrency', diff --git a/test/unit/processor/default_processor_test.rb b/test/unit/processor/default_processor_test.rb index 7a3cfa08d..1158f23d0 100644 --- a/test/unit/processor/default_processor_test.rb +++ b/test/unit/processor/default_processor_test.rb @@ -12,7 +12,9 @@ def setup PostResource.caching true PersonResource.caching true - $serializer = JSONAPI::ResourceSerializer.new(PostResource, base_url: 'http://example.com') + $serializer = JSONAPI::ResourceSerializer.new(PostResource, + base_url: 'http://example.com', + url_helpers: TestApp.routes.url_helpers) # no includes filters = { id: [10, 12] } diff --git a/test/unit/serializer/link_builder_test.rb b/test/unit/serializer/link_builder_test.rb index fddda0a39..d7c277ad2 100644 --- a/test/unit/serializer/link_builder_test.rb +++ b/test/unit/serializer/link_builder_test.rb @@ -2,6 +2,20 @@ require 'jsonapi-resources' require 'json' +module Api + module Secret + class PostResource < JSONAPI::Resource + attribute :title + attribute :body + + has_one :author, class_name: 'Person' + end + + class PersonResource < JSONAPI::Resource + end + end +end + class LinkBuilderTest < ActionDispatch::IntegrationTest def setup # the route format is being set directly in test_helper and is being set differently depending on @@ -11,7 +25,9 @@ def setup @base_url = "http://example.com" @route_formatter = JSONAPI.configuration.route_formatter - @steve = Person.create(name: "Steve Rogers", date_joined: "1941-03-01") + @steve = Person.create(name: "Steve Rogers", date_joined: "1941-03-01", id: 777) + @steves_prefs = Preferences.create(advanced_mode: true, id: 444, person_id: 777) + @great_post = Post.create(title: "Greatest Post", id: 555) end def test_engine_boolean @@ -30,14 +46,14 @@ def test_engine_boolean def test_engine_name assert_equal MyEngine::Engine, - JSONAPI::LinkBuilder.new( - primary_resource_klass: MyEngine::Api::V1::PersonResource - ).engine + JSONAPI::LinkBuilder.new( + primary_resource_klass: MyEngine::Api::V1::PersonResource + ).engine assert_equal ApiV2Engine::Engine, - JSONAPI::LinkBuilder.new( - primary_resource_klass: ApiV2Engine::PersonResource - ).engine + JSONAPI::LinkBuilder.new( + primary_resource_klass: ApiV2Engine::PersonResource + ).engine assert_nil JSONAPI::LinkBuilder.new( primary_resource_klass: Api::V1::PersonResource @@ -51,6 +67,7 @@ def test_self_link_regular_app base_url: @base_url, route_formatter: @route_formatter, primary_resource_klass: primary_resource_klass, + url_helpers: TestApp.routes.url_helpers, } builder = JSONAPI::LinkBuilder.new(config) @@ -60,13 +77,200 @@ def test_self_link_regular_app assert_equal expected_link, builder.self_link(source) end + def test_self_link_regular_app_not_routed + primary_resource_klass = Api::Secret::PostResource + + config = { + base_url: @base_url, + route_formatter: @route_formatter, + primary_resource_klass: primary_resource_klass, + url_helpers: TestApp.routes.url_helpers, + } + + builder = JSONAPI::LinkBuilder.new(config) + source = primary_resource_klass.new(@great_post, nil) + + + # Should not warn if warn_on_missing_routes is false + JSONAPI.configuration.warn_on_missing_routes = false + primary_resource_klass._warned_missing_route = false + + _out, err = capture_subprocess_io do + link = builder.self_link(source) + assert_nil link + end + assert_empty(err) + + # Test warn_on_missing_routes + JSONAPI.configuration.warn_on_missing_routes = true + primary_resource_klass._warned_missing_route = false + + _out, err = capture_subprocess_io do + link = builder.self_link(source) + assert_nil link + end + assert_equal(err, "self_link for Api::Secret::PostResource could not be generated\n") + + # should only warn once + builder = JSONAPI::LinkBuilder.new(config) + _out, err = capture_subprocess_io do + link = builder.self_link(source) + assert_nil link + end + assert_empty(err) + + ensure + JSONAPI.configuration.warn_on_missing_routes = true + end + + def test_primary_resources_url_not_routed + primary_resource_klass = Api::Secret::PostResource + + config = { + base_url: @base_url, + route_formatter: @route_formatter, + primary_resource_klass: primary_resource_klass, + url_helpers: TestApp.routes.url_helpers, + } + + builder = JSONAPI::LinkBuilder.new(config) + + # Should not warn if warn_on_missing_routes is false + JSONAPI.configuration.warn_on_missing_routes = false + primary_resource_klass._warned_missing_route = false + + _out, err = capture_subprocess_io do + link = builder.primary_resources_url + assert_nil link + end + assert_empty(err) + + # Test warn_on_missing_routes + JSONAPI.configuration.warn_on_missing_routes = true + primary_resource_klass._warned_missing_route = false + _out, err = capture_subprocess_io do + link = builder.primary_resources_url + assert_nil link + end + assert_equal(err, "primary_resources_url for Api::Secret::PostResource could not be generated\n") + + # should only warn once + builder = JSONAPI::LinkBuilder.new(config) + _out, err = capture_subprocess_io do + link = builder.primary_resources_url + assert_nil link + end + assert_empty(err) + + ensure + JSONAPI.configuration.warn_on_missing_routes = true + end + + def test_relationships_self_link_not_routed + primary_resource_klass = Api::Secret::PostResource + + config = { + base_url: @base_url, + route_formatter: @route_formatter, + primary_resource_klass: primary_resource_klass, + url_helpers: TestApp.routes.url_helpers, + } + + builder = JSONAPI::LinkBuilder.new(config) + + source = primary_resource_klass.new(@great_post, nil) + + relationship = Api::Secret::PostResource._relationships[:author] + + # Should not warn if warn_on_missing_routes is false + JSONAPI.configuration.warn_on_missing_routes = false + relationship._warned_missing_route = false + + _out, err = capture_subprocess_io do + link = builder.relationships_self_link(source, relationship) + assert_nil link + end + assert_empty(err) + + # Test warn_on_missing_routes + JSONAPI.configuration.warn_on_missing_routes = true + relationship._warned_missing_route = false + + _out, err = capture_subprocess_io do + link = builder.relationships_self_link(source, relationship) + assert_nil link + end + assert_equal(err, "self_link for Api::Secret::PostResource.author(BelongsToOne) could not be generated\n") + + # should only warn once + builder = JSONAPI::LinkBuilder.new(config) + _out, err = capture_subprocess_io do + link = builder.relationships_self_link(source, relationship) + assert_nil link + end + assert_empty(err) + + ensure + JSONAPI.configuration.warn_on_missing_routes = true + end + + def test_relationships_related_link_not_routed + primary_resource_klass = Api::Secret::PostResource + + config = { + base_url: @base_url, + route_formatter: @route_formatter, + primary_resource_klass: primary_resource_klass, + url_helpers: TestApp.routes.url_helpers, + } + + builder = JSONAPI::LinkBuilder.new(config) + + source = primary_resource_klass.new(@great_post, nil) + + relationship = Api::Secret::PostResource._relationships[:author] + + # Should not warn if warn_on_missing_routes is false + JSONAPI.configuration.warn_on_missing_routes = false + relationship._warned_missing_route = false + + _out, err = capture_subprocess_io do + link = builder.relationships_related_link(source, relationship) + assert_nil link + end + assert_empty(err) + + # Test warn_on_missing_routes + JSONAPI.configuration.warn_on_missing_routes = true + relationship._warned_missing_route = false + + _out, err = capture_subprocess_io do + link = builder.relationships_related_link(source, relationship) + assert_nil link + end + assert_equal(err, "related_link for Api::Secret::PostResource.author(BelongsToOne) could not be generated\n") + + # should only warn once + builder = JSONAPI::LinkBuilder.new(config) + _out, err = capture_subprocess_io do + link = builder.relationships_related_link(source, relationship) + assert_nil link + end + assert_empty(err) + + ensure + JSONAPI.configuration.warn_on_missing_routes = true + end + def test_self_link_with_engine_app primary_resource_klass = ApiV2Engine::PersonResource + primary_resource_klass._warned_missing_route = false config = { - base_url: @base_url, + base_url: "#{ @base_url }", route_formatter: @route_formatter, primary_resource_klass: primary_resource_klass, + url_helpers: ApiV2Engine::Engine.routes.url_helpers, } builder = JSONAPI::LinkBuilder.new(config) @@ -83,6 +287,7 @@ def test_self_link_with_engine_namespaced_app base_url: @base_url, route_formatter: @route_formatter, primary_resource_klass: primary_resource_klass, + url_helpers: MyEngine::Engine.routes.url_helpers, } builder = JSONAPI::LinkBuilder.new(config) @@ -99,6 +304,7 @@ def test_self_link_with_engine_app_and_camel_case_scope base_url: @base_url, route_formatter: @route_formatter, primary_resource_klass: primary_resource_klass, + url_helpers: MyEngine::Engine.routes.url_helpers, } builder = JSONAPI::LinkBuilder.new(config) @@ -113,6 +319,7 @@ def test_primary_resources_url_for_regular_app base_url: @base_url, route_formatter: @route_formatter, primary_resource_klass: Api::V1::PersonResource, + url_helpers: TestApp.routes.url_helpers, } builder = JSONAPI::LinkBuilder.new(config) @@ -125,7 +332,8 @@ def test_primary_resources_url_for_engine config = { base_url: @base_url, route_formatter: @route_formatter, - primary_resource_klass: ApiV2Engine::PersonResource + primary_resource_klass: ApiV2Engine::PersonResource, + url_helpers: ApiV2Engine::Engine.routes.url_helpers, } builder = JSONAPI::LinkBuilder.new(config) @@ -138,7 +346,8 @@ def test_primary_resources_url_for_namespaced_engine config = { base_url: @base_url, route_formatter: @route_formatter, - primary_resource_klass: MyEngine::Api::V1::PersonResource + primary_resource_klass: MyEngine::Api::V1::PersonResource, + url_helpers: MyEngine::Engine.routes.url_helpers, } builder = JSONAPI::LinkBuilder.new(config) @@ -151,108 +360,149 @@ def test_relationships_self_link_for_regular_app config = { base_url: @base_url, route_formatter: @route_formatter, - primary_resource_klass: Api::V1::PersonResource + primary_resource_klass: Api::V1::PersonResource, + url_helpers: TestApp.routes.url_helpers, } builder = JSONAPI::LinkBuilder.new(config) source = Api::V1::PersonResource.new(@steve, nil) - relationship = JSONAPI::Relationship::ToMany.new("posts", {parent_resource: Api::V1::PersonResource}) + relationship = Api::V1::PersonResource._relationships[:posts] expected_link = "#{ @base_url }/api/v1/people/#{ @steve.id }/relationships/posts" assert_equal expected_link, - builder.relationships_self_link(source, relationship) + builder.relationships_self_link(source, relationship) + end + + def test_relationships_self_link_for_regular_app_singleton + config = { + base_url: @base_url, + route_formatter: @route_formatter, + primary_resource_klass: Api::V1::PersonResource, + url_helpers: TestApp.routes.url_helpers, + } + + builder = JSONAPI::LinkBuilder.new(config) + source = Api::V1::PreferencesResource.new(@steves_prefs, nil) + relationship = Api::V1::PreferencesResource._relationships[:author] + expected_link = "#{ @base_url }/api/v1/preferences/relationships/author" + + assert_equal expected_link, + builder.relationships_self_link(source, relationship) + end + + def test_relationships_related_link_for_regular_app_singleton + config = { + base_url: @base_url, + route_formatter: @route_formatter, + primary_resource_klass: Api::V1::PersonResource, + url_helpers: TestApp.routes.url_helpers, + } + + builder = JSONAPI::LinkBuilder.new(config) + source = Api::V1::PreferencesResource.new(@steves_prefs, nil) + relationship = Api::V1::PreferencesResource._relationships[:author] + expected_link = "#{ @base_url }/api/v1/preferences/author" + + assert_equal expected_link, + builder.relationships_related_link(source, relationship) end def test_relationships_self_link_for_engine config = { base_url: @base_url, route_formatter: @route_formatter, - primary_resource_klass: ApiV2Engine::PersonResource + primary_resource_klass: ApiV2Engine::PersonResource, + url_helpers: ApiV2Engine::Engine.routes.url_helpers, } builder = JSONAPI::LinkBuilder.new(config) source = ApiV2Engine::PersonResource.new(@steve, nil) - relationship = JSONAPI::Relationship::ToMany.new("posts", {parent_resource: ApiV2Engine::PersonResource}) + relationship = ApiV2Engine::PersonResource._relationships[:posts] expected_link = "#{ @base_url }/api_v2/people/#{ @steve.id }/relationships/posts" assert_equal expected_link, - builder.relationships_self_link(source, relationship) + builder.relationships_self_link(source, relationship) end def test_relationships_self_link_for_namespaced_engine config = { base_url: @base_url, route_formatter: @route_formatter, - primary_resource_klass: MyEngine::Api::V1::PersonResource + primary_resource_klass: MyEngine::Api::V1::PersonResource, + url_helpers: MyEngine::Engine.routes.url_helpers, } builder = JSONAPI::LinkBuilder.new(config) source = MyEngine::Api::V1::PersonResource.new(@steve, nil) - relationship = JSONAPI::Relationship::ToMany.new("posts", {parent_resource: MyEngine::Api::V1::PersonResource}) + relationship = MyEngine::Api::V1::PersonResource._relationships[:posts] expected_link = "#{ @base_url }/boomshaka/api/v1/people/#{ @steve.id }/relationships/posts" assert_equal expected_link, - builder.relationships_self_link(source, relationship) + builder.relationships_self_link(source, relationship) end def test_relationships_related_link_for_regular_app config = { base_url: @base_url, route_formatter: @route_formatter, - primary_resource_klass: Api::V1::PersonResource + primary_resource_klass: Api::V1::PersonResource, + url_helpers: TestApp.routes.url_helpers, } builder = JSONAPI::LinkBuilder.new(config) source = Api::V1::PersonResource.new(@steve, nil) - relationship = JSONAPI::Relationship::ToMany.new("posts", {parent_resource: Api::V1::PersonResource}) + relationship = Api::V1::PersonResource._relationships[:posts] expected_link = "#{ @base_url }/api/v1/people/#{ @steve.id }/posts" assert_equal expected_link, - builder.relationships_related_link(source, relationship) + builder.relationships_related_link(source, relationship) end def test_relationships_related_link_for_engine config = { base_url: @base_url, route_formatter: @route_formatter, - primary_resource_klass: ApiV2Engine::PersonResource + primary_resource_klass: ApiV2Engine::PersonResource, + url_helpers: ApiV2Engine::Engine.routes.url_helpers, } builder = JSONAPI::LinkBuilder.new(config) source = ApiV2Engine::PersonResource.new(@steve, nil) - relationship = JSONAPI::Relationship::ToMany.new("posts", {parent_resource: ApiV2Engine::PersonResource}) + relationship = ApiV2Engine::PersonResource._relationships[:posts] expected_link = "#{ @base_url }/api_v2/people/#{ @steve.id }/posts" assert_equal expected_link, - builder.relationships_related_link(source, relationship) + builder.relationships_related_link(source, relationship) end def test_relationships_related_link_for_namespaced_engine config = { base_url: @base_url, route_formatter: @route_formatter, - primary_resource_klass: MyEngine::Api::V1::PersonResource + primary_resource_klass: MyEngine::Api::V1::PersonResource, + url_helpers: MyEngine::Engine.routes.url_helpers, } builder = JSONAPI::LinkBuilder.new(config) source = MyEngine::Api::V1::PersonResource.new(@steve, nil) - relationship = JSONAPI::Relationship::ToMany.new("posts", {parent_resource: MyEngine::Api::V1::PersonResource}) + relationship = MyEngine::Api::V1::PersonResource._relationships[:posts] expected_link = "#{ @base_url }/boomshaka/api/v1/people/#{ @steve.id }/posts" assert_equal expected_link, - builder.relationships_related_link(source, relationship) + builder.relationships_related_link(source, relationship) end def test_relationships_related_link_with_query_params config = { base_url: @base_url, route_formatter: @route_formatter, - primary_resource_klass: Api::V1::PersonResource + primary_resource_klass: Api::V1::PersonResource, + url_helpers: TestApp.routes.url_helpers, } builder = JSONAPI::LinkBuilder.new(config) source = Api::V1::PersonResource.new(@steve, nil) - relationship = JSONAPI::Relationship::ToMany.new("posts", {parent_resource: Api::V1::PersonResource}) + relationship = Api::V1::PersonResource._relationships[:posts] expected_link = "#{ @base_url }/api/v1/people/#{ @steve.id }/posts?page%5Blimit%5D=12&page%5Boffset%5D=0" query = { page: { offset: 0, limit: 12 } } @@ -264,7 +514,8 @@ def test_query_link_for_regular_app config = { base_url: @base_url, route_formatter: @route_formatter, - primary_resource_klass: Api::V1::PersonResource + primary_resource_klass: Api::V1::PersonResource, + url_helpers: TestApp.routes.url_helpers, } query = { page: { offset: 0, limit: 12 } } @@ -278,7 +529,8 @@ def test_query_link_for_regular_app_with_camel_case_scope config = { base_url: @base_url, route_formatter: @route_formatter, - primary_resource_klass: AdminApi::V1::PersonResource + primary_resource_klass: AdminApi::V1::PersonResource, + url_helpers: TestApp.routes.url_helpers, } query = { page: { offset: 0, limit: 12 } } @@ -290,9 +542,10 @@ def test_query_link_for_regular_app_with_camel_case_scope def test_query_link_for_regular_app_with_dasherized_scope config = { - base_url: @base_url, - route_formatter: DasherizedRouteFormatter, - primary_resource_klass: DasherizedNamespace::V1::PersonResource + base_url: @base_url, + route_formatter: DasherizedRouteFormatter, + primary_resource_klass: DasherizedNamespace::V1::PersonResource, + url_helpers: TestApp.routes.url_helpers, } query = { page: { offset: 0, limit: 12 } } @@ -306,7 +559,8 @@ def test_query_link_for_engine config = { base_url: @base_url, route_formatter: @route_formatter, - primary_resource_klass: ApiV2Engine::PersonResource + primary_resource_klass: ApiV2Engine::PersonResource, + url_helpers: ApiV2Engine::Engine.routes.url_helpers, } query = { page: { offset: 0, limit: 12 } } @@ -320,7 +574,8 @@ def test_query_link_for_namespaced_engine config = { base_url: @base_url, route_formatter: @route_formatter, - primary_resource_klass: MyEngine::Api::V1::PersonResource + primary_resource_klass: MyEngine::Api::V1::PersonResource, + url_helpers: MyEngine::Engine.routes.url_helpers, } query = { page: { offset: 0, limit: 12 } } @@ -332,9 +587,10 @@ def test_query_link_for_namespaced_engine def test_query_link_for_engine_with_dasherized_scope config = { - base_url: @base_url, - route_formatter: DasherizedRouteFormatter, - primary_resource_klass: MyEngine::DasherizedNamespace::V1::PersonResource + base_url: @base_url, + route_formatter: DasherizedRouteFormatter, + primary_resource_klass: MyEngine::DasherizedNamespace::V1::PersonResource, + url_helpers: MyEngine::Engine.routes.url_helpers, } query = { page: { offset: 0, limit: 12 } } @@ -348,7 +604,8 @@ def test_query_link_for_engine_with_camel_case_scope config = { base_url: @base_url, route_formatter: @route_formatter, - primary_resource_klass: MyEngine::AdminApi::V1::PersonResource + primary_resource_klass: MyEngine::AdminApi::V1::PersonResource, + url_helpers: MyEngine::Engine.routes.url_helpers, } query = { page: { offset: 0, limit: 12 } } diff --git a/test/unit/serializer/serializer_test.rb b/test/unit/serializer/serializer_test.rb index 6d6201961..b3cda28e8 100644 --- a/test/unit/serializer/serializer_test.rb +++ b/test/unit/serializer/serializer_test.rb @@ -30,7 +30,8 @@ def test_serializer serializer = JSONAPI::ResourceSerializer.new( PostResource, - base_url: 'http://example.com') + base_url: 'http://example.com', + url_helpers: TestApp.routes.url_helpers) resource_set.populate!(serializer, {}, {}) serialized = serializer.serialize_resource_set_to_hash_single(resource_set) @@ -87,7 +88,8 @@ def test_serializer_nil_handling serializer = JSONAPI::ResourceSerializer.new( Api::V1::PostResource, - base_url: 'http://example.com') + base_url: 'http://example.com', + url_helpers: TestApp.routes.url_helpers) resource_set.populate!(serializer, {}, {}) serialized = serializer.serialize_resource_set_to_hash_single(resource_set) @@ -111,7 +113,8 @@ def test_serializer_namespaced_resource_with_custom_resource_links serializer = JSONAPI::ResourceSerializer.new( Api::V1::PostResource, - base_url: 'http://example.com') + base_url: 'http://example.com', + url_helpers: TestApp.routes.url_helpers) resource_set.populate!(serializer, {}, {}) serialized = serializer.serialize_resource_set_to_hash_single(resource_set) @@ -167,7 +170,8 @@ def test_serializer_limited_fieldset serializer = JSONAPI::ResourceSerializer.new( PostResource, - fields: {posts: [:id, :title, :author]}) + fields: {posts: [:id, :title, :author]}, + url_helpers: TestApp.routes.url_helpers) resource_set.populate!(serializer, {}, {}) serialized = serializer.serialize_resource_set_to_hash_single(resource_set) @@ -215,7 +219,8 @@ def test_serializer_include resource_set = JSONAPI::ResourceSet.new(id_tree) - serializer = JSONAPI::ResourceSerializer.new(PostResource) + serializer = JSONAPI::ResourceSerializer.new(PostResource, + url_helpers: TestApp.routes.url_helpers) resource_set.populate!(serializer, {}, {}) serialized = serializer.serialize_resource_set_to_hash_single(resource_set) @@ -303,8 +308,8 @@ def test_serializer_include }, hairCut: { links: { - self: '/people/1001/relationships/hair_cut', - related: '/people/1001/hair_cut' + self: '/people/1001/relationships/hairCut', + related: '/people/1001/hairCut' } }, vehicles: { @@ -315,8 +320,8 @@ def test_serializer_include }, expenseEntries: { links: { - self: '/people/1001/relationships/expense_entries', - related: '/people/1001/expense_entries' + self: '/people/1001/relationships/expenseEntries', + related: '/people/1001/expenseEntries' } } } @@ -346,7 +351,8 @@ def test_serializer_key_format resource_set = JSONAPI::ResourceSet.new(id_tree) serializer = JSONAPI::ResourceSerializer.new(PostResource, - key_formatter: UnderscoredKeyFormatter,) + key_formatter: UnderscoredKeyFormatter, + url_helpers: TestApp.routes.url_helpers) resource_set.populate!(serializer, {}, {}) serialized = serializer.serialize_resource_set_to_hash_single(resource_set) @@ -434,8 +440,8 @@ def test_serializer_key_format }, hair_cut: { links: { - self: '/people/1001/relationships/hair_cut', - related: '/people/1001/hair_cut' + self: '/people/1001/relationships/hairCut', + related: '/people/1001/hairCut' } }, vehicles: { @@ -446,8 +452,8 @@ def test_serializer_key_format }, expense_entries: { links: { - self: '/people/1001/relationships/expense_entries', - related: '/people/1001/expense_entries' + self: '/people/1001/relationships/expenseEntries', + related: '/people/1001/expenseEntries' } } } @@ -476,7 +482,8 @@ def test_serializers_linkage_even_without_included_resource id_tree.add_resource_fragment(fragment, directives[:include_related]) resource_set = JSONAPI::ResourceSet.new(id_tree) - serializer = JSONAPI::ResourceSerializer.new(PostResource) + serializer = JSONAPI::ResourceSerializer.new(PostResource, + url_helpers: TestApp.routes.url_helpers) resource_set.populate!(serializer, {}, {}) serialized = serializer.serialize_resource_set_to_hash_single(resource_set) From 8f326cde4b681969f9794811310b184e399d2a1f Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Wed, 26 Jun 2019 10:43:51 -0400 Subject: [PATCH 149/237] Support global exclude links configuration Port of gh-1256 to master branch --- lib/jsonapi/basic_resource.rb | 14 +++-- lib/jsonapi/configuration.rb | 11 +++- lib/jsonapi/relationship.rb | 2 +- test/unit/resource/relationship_test.rb | 84 +++++++++++++++++++++++++ 4 files changed, 104 insertions(+), 7 deletions(-) diff --git a/lib/jsonapi/basic_resource.rb b/lib/jsonapi/basic_resource.rb index adf40cc65..bc18c96bf 100644 --- a/lib/jsonapi/basic_resource.rb +++ b/lib/jsonapi/basic_resource.rb @@ -963,21 +963,25 @@ def mutable? !@immutable end - def exclude_links(exclude) + def parse_exclude_links(exclude) case exclude when :default, "default" - @_exclude_links = [:self] + [:self] when :none, "none" - @_exclude_links = [] + [] when Array - @_exclude_links = exclude.collect {|link| link.to_sym} + exclude.collect {|link| link.to_sym} else fail "Invalid exclude_links" end end + def exclude_links(exclude) + @_exclude_links = parse_exclude_links(exclude) + end + def _exclude_links - @_exclude_links ||= [] + @_exclude_links ||= parse_exclude_links(JSONAPI.configuration.default_exclude_links) end def exclude_link?(link) diff --git a/lib/jsonapi/configuration.rb b/lib/jsonapi/configuration.rb index 6dca6891a..5cf33e845 100644 --- a/lib/jsonapi/configuration.rb +++ b/lib/jsonapi/configuration.rb @@ -37,7 +37,8 @@ class Configuration :default_caching, :default_resource_cache_field, :resource_cache_digest_function, - :resource_cache_usage_report_function + :resource_cache_usage_report_function, + :default_exclude_links def initialize #:underscored_key, :camelized_key, :dasherized_key, or custom @@ -149,6 +150,12 @@ def initialize # Optionally provide a callable which JSONAPI will call with information about cache # performance. Should accept three arguments: resource name, hits count, misses count. self.resource_cache_usage_report_function = nil + + # Global configuration for links exclusion + # Controls whether to generate links like `self`, `related` with all the resources + # and relationships. Accepts either `:default`, `:none`, or array containing the + # specific default links to exclude, which may be `:self` and `:related`. + self.default_exclude_links = :none end def cache_formatters=(bool) @@ -276,6 +283,8 @@ def allow_include=(allow_include) attr_writer :resource_cache_digest_function attr_writer :resource_cache_usage_report_function + + attr_writer :default_exclude_links end class << self diff --git a/lib/jsonapi/relationship.rb b/lib/jsonapi/relationship.rb index 74b925853..77e700b78 100644 --- a/lib/jsonapi/relationship.rb +++ b/lib/jsonapi/relationship.rb @@ -32,7 +32,7 @@ def initialize(name, options = {}) @_routed = false @_warned_missing_route = false - exclude_links(options.fetch(:exclude_links, :none)) + exclude_links(options.fetch(:exclude_links, JSONAPI.configuration.default_exclude_links)) # Custom methods are reserved for future use @custom_methods = options.fetch(:custom_methods, {}) diff --git a/test/unit/resource/relationship_test.rb b/test/unit/resource/relationship_test.rb index 2494724be..a98d26601 100644 --- a/test/unit/resource/relationship_test.rb +++ b/test/unit/resource/relationship_test.rb @@ -159,4 +159,88 @@ def test_exclude_links_on_relationship JSONAPI::Relationship::ToOne.new "foo", :self end end + + def test_global_exclude_links_configuration_on_relationship + JSONAPI.configuration.default_exclude_links = :none + relationship = JSONAPI::Relationship::ToOne.new "foo" + assert_equal [], relationship._exclude_links + refute relationship.exclude_link?(:self) + refute relationship.exclude_link?("self") + + JSONAPI.configuration.default_exclude_links = :default + relationship = JSONAPI::Relationship::ToOne.new "foo" + assert_equal [:self, :related], relationship._exclude_links + assert relationship.exclude_link?(:self) + assert relationship.exclude_link?("self") + assert relationship.exclude_link?(:related) + assert relationship.exclude_link?("related") + + JSONAPI.configuration.default_exclude_links = "none" + relationship = JSONAPI::Relationship::ToOne.new "foo" + assert_equal [], relationship._exclude_links + refute relationship.exclude_link?(:self) + refute relationship.exclude_link?("self") + + JSONAPI.configuration.default_exclude_links = "default" + relationship = JSONAPI::Relationship::ToOne.new "foo" + assert_equal [:self, :related], relationship._exclude_links + assert relationship.exclude_link?(:self) + assert relationship.exclude_link?("self") + + JSONAPI.configuration.default_exclude_links = :none + relationship = JSONAPI::Relationship::ToOne.new "foo" + assert_equal [], relationship._exclude_links + refute relationship.exclude_link?(:self) + refute relationship.exclude_link?("self") + + JSONAPI.configuration.default_exclude_links = [:self] + relationship = JSONAPI::Relationship::ToOne.new "foo" + assert_equal [:self], relationship._exclude_links + assert relationship.exclude_link?(:self) + assert relationship.exclude_link?("self") + + JSONAPI.configuration.default_exclude_links = :none + relationship = JSONAPI::Relationship::ToOne.new "foo" + assert_equal [], relationship._exclude_links + refute relationship.exclude_link?(:self) + refute relationship.exclude_link?("self") + + JSONAPI.configuration.default_exclude_links = ["self", :related] + relationship = JSONAPI::Relationship::ToOne.new "foo" + assert_equal [:self, :related], relationship._exclude_links + assert relationship.exclude_link?(:self) + assert relationship.exclude_link?("self") + + JSONAPI.configuration.default_exclude_links = [] + relationship = JSONAPI::Relationship::ToOne.new "foo" + assert_equal [], relationship._exclude_links + refute relationship.exclude_link?(:self) + refute relationship.exclude_link?("self") + + assert_raises do + JSONAPI.configuration.default_exclude_links = :self + JSONAPI::Relationship::ToOne.new "foo" + end + + # Test if the relationships will override the the global configuration + JSONAPI.configuration.default_exclude_links = :default + relationship = JSONAPI::Relationship::ToOne.new "foo", exclude_links: :none + assert_equal [], relationship._exclude_links + refute relationship.exclude_link?(:self) + refute relationship.exclude_link?("self") + refute relationship.exclude_link?(:related) + refute relationship.exclude_link?("related") + + JSONAPI.configuration.default_exclude_links = :default + relationship = JSONAPI::Relationship::ToOne.new "foo", exclude_links: [:self] + assert_equal [:self], relationship._exclude_links + refute relationship.exclude_link?(:related) + refute relationship.exclude_link?("related") + assert relationship.exclude_link?(:self) + assert relationship.exclude_link?("self") + ensure + JSONAPI.configuration.default_exclude_links = :none + end + + end From 7445e898e822f1bae3a9bd2d3fc1388e311e3ce0 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Tue, 9 Jul 2019 15:19:26 -0400 Subject: [PATCH 150/237] Test remove relationship does not remove related resource --- test/controllers/controller_test.rb | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/test/controllers/controller_test.rb b/test/controllers/controller_test.rb index 74743de2f..fcc9a9819 100644 --- a/test/controllers/controller_test.rb +++ b/test/controllers/controller_test.rb @@ -1396,6 +1396,25 @@ def test_update_relationship_to_one_singular_param assert_equal ruby.id, post_object.section_id end + def test_remove_relationship_to_many_belongs_to + set_content_type_header! + c = Comment.find(3) + p = Post.find(2) + total_comment_count = Comment.count + post_comment_count = p.comments.count + + put :destroy_relationship, params: {post_id: "#{p.id}", relationship: 'comments', data: [{type: 'comments', id: "#{c.id}"}]} + + assert_response :no_content + p = Post.find(2) + c = Comment.find(3) + + assert_equal post_comment_count - 1, p.comments.length + assert_equal total_comment_count, Comment.count + + assert_nil c.post_id + end + def test_update_relationship_to_many_join_table_single set_content_type_header! put :update_relationship, params: {post_id: 3, relationship: 'tags', data: []} From 436f062e1fe0fb680f852bc2dd3817d7e9c1fb09 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Tue, 9 Jul 2019 16:07:48 -0400 Subject: [PATCH 151/237] Revert "Use `destroy` instead of `delete` to ensure callbacks are called" reverts commit 565d4b6edbc75eee5f8d608f76306129dff3fd65 --- lib/jsonapi/basic_resource.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/jsonapi/basic_resource.rb b/lib/jsonapi/basic_resource.rb index bc18c96bf..edffb4ae1 100644 --- a/lib/jsonapi/basic_resource.rb +++ b/lib/jsonapi/basic_resource.rb @@ -365,7 +365,7 @@ def _remove_to_many_link(relationship_type, key, options) @reload_needed = true else - @model.public_send(relationship.relation_name(context: @context)).destroy(key) + @model.public_send(relationship.relation_name(context: @context)).delete(key) end :completed From 0072fef67ebf451bd66a7212e1820621af0a5d5e Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Tue, 9 Jul 2019 17:41:25 -0400 Subject: [PATCH 152/237] Bump jsonapi-resources to 0.10.0.beta6 --- lib/jsonapi/resources/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/jsonapi/resources/version.rb b/lib/jsonapi/resources/version.rb index 9d9929a36..902e46849 100644 --- a/lib/jsonapi/resources/version.rb +++ b/lib/jsonapi/resources/version.rb @@ -1,5 +1,5 @@ module JSONAPI module Resources - VERSION = '0.10.0.beta5' + VERSION = '0.10.0.beta6' end end From 2efa6bd5ca7758f31aefb366de5f8859954bbc9a Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Wed, 10 Jul 2019 14:34:16 -0400 Subject: [PATCH 153/237] Fix issues with resource definition order and initializers. Resources initialized before configuration initializer inherit the default configuration options, not the intended configuration. This was affecting the paginator. In addition the _cache_field and key_type are updated for consistency when resources are defined from a base resource. --- lib/jsonapi/basic_resource.rb | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/jsonapi/basic_resource.rb b/lib/jsonapi/basic_resource.rb index edffb4ae1..da9042658 100644 --- a/lib/jsonapi/basic_resource.rb +++ b/lib/jsonapi/basic_resource.rb @@ -422,11 +422,13 @@ def inherited(subclass) subclass.abstract(false) subclass.immutable(false) subclass.caching(_caching) + subclass.cache_field(_cache_field) if @_cache_field subclass.singleton(singleton?, (_singleton_options.dup || {})) subclass.exclude_links(_exclude_links) - subclass.paginator(_paginator) + subclass.paginator(@_paginator) subclass._attributes = (_attributes || {}).dup subclass.polymorphic(false) + subclass.key_type(@_resource_key_type) subclass._model_hints = (_model_hints || {}).dup @@ -755,7 +757,7 @@ def key_type(key_type) end def resource_key_type - @_resource_key_type ||= JSONAPI.configuration.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 @@ -878,7 +880,7 @@ def _default_primary_key end def _cache_field - @_cache_field ||= JSONAPI.configuration.default_resource_cache_field + @_cache_field || JSONAPI.configuration.default_resource_cache_field end def _table_name @@ -898,7 +900,7 @@ def _allowed_sort end def _paginator - @_paginator ||= JSONAPI.configuration.default_paginator + @_paginator || JSONAPI.configuration.default_paginator end def paginator(paginator) From ef6fb96c80b4d5da2a055e453f3a968aa6adf760 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Thu, 11 Jul 2019 10:24:17 -0400 Subject: [PATCH 154/237] Bump jsonapi-resources to 0.10.0.beta7 --- lib/jsonapi/resources/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/jsonapi/resources/version.rb b/lib/jsonapi/resources/version.rb index 902e46849..26dad92f4 100644 --- a/lib/jsonapi/resources/version.rb +++ b/lib/jsonapi/resources/version.rb @@ -1,5 +1,5 @@ module JSONAPI module Resources - VERSION = '0.10.0.beta6' + VERSION = '0.10.0.beta7' end end From 3b8cc74c59fad6efad3334c457daff3c929d1f24 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Fri, 12 Jul 2019 10:10:33 -0400 Subject: [PATCH 155/237] Cache computed attribute options Merging the default and the attribute specific options was resulting in a lot of memory allocations --- lib/jsonapi/basic_resource.rb | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/jsonapi/basic_resource.rb b/lib/jsonapi/basic_resource.rb index da9042658..0a933efef 100644 --- a/lib/jsonapi/basic_resource.rb +++ b/lib/jsonapi/basic_resource.rb @@ -453,6 +453,8 @@ def inherited(subclass) subclass._routed = false subclass._warned_missing_route = false + + subclass._clear_cached_attribute_options end def rebuild_relationships(relationships) @@ -527,6 +529,8 @@ def attributes(*attrs) end def attribute(attribute_name, options = {}) + _clear_cached_attribute_options + attr = attribute_name.to_sym check_reserved_attribute_name(attr) @@ -826,7 +830,7 @@ def verify_relationship_filter(filter, raw, _context = nil) # quasi private class methods def _attribute_options(attr) - default_attribute_options.merge(@_attributes[attr]) + @_cached_attribute_options[attr] ||= default_attribute_options.merge(@_attributes[attr]) end def _attribute_delegated_name(attr) @@ -1107,6 +1111,10 @@ def register_relationship(name, relationship_object) @_relationships[name] = relationship_object end + def _clear_cached_attribute_options + @_cached_attribute_options = {} + end + private def check_reserved_resource_name(type, name) From ca1baa00e9bee825a6918256fad158cb23dad993 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Fri, 12 Jul 2019 12:01:24 -0400 Subject: [PATCH 156/237] Cache custom_generation_options options This hash only needs to be computed once, and repeatedly building it results in a lot of memory allocations. --- lib/jsonapi/resource_serializer.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/jsonapi/resource_serializer.rb b/lib/jsonapi/resource_serializer.rb index e5749a98e..5751adb67 100644 --- a/lib/jsonapi/resource_serializer.rb +++ b/lib/jsonapi/resource_serializer.rb @@ -230,7 +230,7 @@ def attributes_hash(source, fetchable_fields) end def custom_generation_options - { + @_custom_generation_options ||= { serializer: self, serialization_options: @serialization_options } From 7d753ec60cf1438c7ad6c421606c8e4f8907af5f Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Fri, 12 Jul 2019 12:03:34 -0400 Subject: [PATCH 157/237] Cache fields Reset the cache if the relationships or the attributes change --- lib/jsonapi/basic_resource.rb | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/jsonapi/basic_resource.rb b/lib/jsonapi/basic_resource.rb index 0a933efef..ea8b19ea7 100644 --- a/lib/jsonapi/basic_resource.rb +++ b/lib/jsonapi/basic_resource.rb @@ -455,6 +455,7 @@ def inherited(subclass) subclass._warned_missing_route = false subclass._clear_cached_attribute_options + subclass._clear_fields_cache end def rebuild_relationships(relationships) @@ -530,6 +531,7 @@ def attributes(*attrs) def attribute(attribute_name, options = {}) _clear_cached_attribute_options + _clear_fields_cache attr = attribute_name.to_sym @@ -697,7 +699,7 @@ def sortable_field?(key, context = nil) end def fields - _relationships.keys | _attributes.keys + @_fields_cache ||= _relationships.keys | _attributes.keys end def resources_for(records, context) @@ -1067,6 +1069,8 @@ def construct_order_options(sort_params) end def _add_relationship(klass, *attrs) + _clear_fields_cache + options = attrs.extract_options! options[:parent_resource] = self @@ -1115,6 +1119,10 @@ def _clear_cached_attribute_options @_cached_attribute_options = {} end + def _clear_fields_cache + @_fields_cache = nil + end + private def check_reserved_resource_name(type, name) From c6e75ac3e5099a32e5ff0bbc087c1458246ff8b4 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Fri, 12 Jul 2019 12:04:41 -0400 Subject: [PATCH 158/237] Optimize resource_path generation to reduce string allocations --- lib/jsonapi/link_builder.rb | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/lib/jsonapi/link_builder.rb b/lib/jsonapi/link_builder.rb index 99cd6223c..c591efc3d 100644 --- a/lib/jsonapi/link_builder.rb +++ b/lib/jsonapi/link_builder.rb @@ -127,12 +127,11 @@ def resources_path(source_klass) end def resource_path(source) - url = "#{resources_path(source.class)}" - - unless source.class.singleton? - url = "#{url}/#{source.id}" + if source.class.singleton? + resources_path(source.class) + else + "#{resources_path(source.class)}/#{source.id}" end - url end def resource_url(source) From 388824b1cf3b15d2ad27463fc864b047690a4258 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Fri, 12 Jul 2019 13:30:38 -0400 Subject: [PATCH 159/237] Cache resources_paths per class Optimization for memory allocation --- lib/jsonapi/link_builder.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/jsonapi/link_builder.rb b/lib/jsonapi/link_builder.rb index c591efc3d..6ede8a022 100644 --- a/lib/jsonapi/link_builder.rb +++ b/lib/jsonapi/link_builder.rb @@ -123,7 +123,8 @@ def module_scopes_from_class(klass) end def resources_path(source_klass) - formatted_module_path_from_class(source_klass) + format_route(source_klass._type.to_s) + @_resources_path ||= {} + @_resources_path[source_klass] ||= formatted_module_path_from_class(source_klass) + format_route(source_klass._type.to_s) end def resource_path(source) From 45fc9bc9e26c3aff5a93096d7fb96628fb67a3c0 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Thu, 18 Jul 2019 12:42:52 -0400 Subject: [PATCH 160/237] Remove unneeded assignment --- lib/jsonapi/active_relation/join_manager.rb | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/jsonapi/active_relation/join_manager.rb b/lib/jsonapi/active_relation/join_manager.rb index 80dda35bd..3d1ec34b5 100644 --- a/lib/jsonapi/active_relation/join_manager.rb +++ b/lib/jsonapi/active_relation/join_manager.rb @@ -154,13 +154,13 @@ def perform_joins(records, options) next end - records, join_node = self.class.get_join_arel_node(records, options) {|records, options| - records = related_resource_klass.join_relationship( - records: records, - resource_type: related_resource_klass._type, - join_type: join_type, - relationship: relationship, - options: options) + records, join_node = self.class.get_join_arel_node(records, options) {|records, options| + related_resource_klass.join_relationship( + records: records, + resource_type: related_resource_klass._type, + join_type: join_type, + relationship: relationship, + options: options) } details = {alias: self.class.alias_from_arel_node(join_node), join_type: join_type} From f4d45b1e66754fbd6d1a961b582bf2be2feceed2 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Thu, 18 Jul 2019 12:43:37 -0400 Subject: [PATCH 161/237] Use each instead of collect --- lib/jsonapi/active_relation_resource.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/jsonapi/active_relation_resource.rb b/lib/jsonapi/active_relation_resource.rb index b8e9c948f..39846b9f6 100644 --- a/lib/jsonapi/active_relation_resource.rb +++ b/lib/jsonapi/active_relation_resource.rb @@ -155,7 +155,7 @@ def find_fragments(filters, options = {}) fragments = {} rows = records.pluck(*pluck_fields) - rows.collect do |row| + rows.each do |row| rid = JSONAPI::ResourceIdentity.new(resource_klass, pluck_fields.length == 1 ? row : row[0]) fragments[rid] ||= JSONAPI::ResourceFragment.new(rid) From 84242714b11acc2011392cb53539546a0f22d482 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Fri, 19 Jul 2019 10:51:40 -0400 Subject: [PATCH 162/237] Warn on performance issues --- lib/jsonapi/active_relation_resource.rb | 4 ++++ lib/jsonapi/configuration.rb | 4 ++++ test/controllers/controller_test.rb | 23 +++++++++++++++++++++++ test/fixtures/active_record.rb | 10 +++++++++- 4 files changed, 40 insertions(+), 1 deletion(-) diff --git a/lib/jsonapi/active_relation_resource.rb b/lib/jsonapi/active_relation_resource.rb index 39846b9f6..5f800de50 100644 --- a/lib/jsonapi/active_relation_resource.rb +++ b/lib/jsonapi/active_relation_resource.rb @@ -181,6 +181,10 @@ def find_fragments(filters, options = {}) end end + if JSONAPI.configuration.warn_on_performance_issues && (rows.length > fragments.length) + warn "Performance issue detected: `#{self.name.to_s}.records` returned non-normalized results in `#{self.name.to_s}.find_fragments`." + end + fragments end diff --git a/lib/jsonapi/configuration.rb b/lib/jsonapi/configuration.rb index 5cf33e845..a91c453da 100644 --- a/lib/jsonapi/configuration.rb +++ b/lib/jsonapi/configuration.rb @@ -10,6 +10,7 @@ class Configuration :raise_if_parameters_not_allowed, :warn_on_route_setup_issues, :warn_on_missing_routes, + :warn_on_performance_issues, :default_allow_include_to_one, :default_allow_include_to_many, :allow_sort, @@ -60,6 +61,7 @@ def initialize self.warn_on_route_setup_issues = true self.warn_on_missing_routes = true + self.warn_on_performance_issues = true # :none, :offset, :paged, or a custom paginator name self.default_paginator = :none @@ -272,6 +274,8 @@ def allow_include=(allow_include) attr_writer :warn_on_missing_routes + attr_writer :warn_on_performance_issues + attr_writer :use_relationship_reflection attr_writer :resource_cache diff --git a/test/controllers/controller_test.rb b/test/controllers/controller_test.rb index fcc9a9819..7d266792f 100644 --- a/test/controllers/controller_test.rb +++ b/test/controllers/controller_test.rb @@ -3625,6 +3625,29 @@ def test_book_comments_exclude_unapproved_context_based end end +class Api::V4::PostsControllerTest < ActionController::TestCase + def test_warn_on_joined_to_many + original_config = JSONAPI.configuration.dup + + JSONAPI.configuration.warn_on_performance_issues = true + _out, err = capture_subprocess_io do + get :index, params: {fields: {posts: 'id,title'}} + assert_response :success + end + assert_equal(err, "Performance issue detected: `Api::V4::PostResource.records` returned non-normalized results in `Api::V4::PostResource.find_fragments`.\n") + + JSONAPI.configuration.warn_on_performance_issues = false + _out, err = capture_subprocess_io do + get :index, params: {fields: {posts: 'id,title'}} + assert_response :success + end + assert_empty err + + ensure + JSONAPI.configuration = original_config + end +end + class Api::V4::BooksControllerTest < ActionController::TestCase def setup JSONAPI.configuration.json_key_format = :camelized_key diff --git a/test/fixtures/active_record.rb b/test/fixtures/active_record.rb index 602d2825b..68bb69fc3 100644 --- a/test/fixtures/active_record.rb +++ b/test/fixtures/active_record.rb @@ -1989,7 +1989,15 @@ class PreferencesResource < PreferencesResource; end module Api module V4 - class PostResource < PostResource; end + class PostResource < PostResource + class << self + def records(options = {}) + # Sets up a performance issue for testing + super(options).joins(:comments) + end + end + end + class PersonResource < PersonResource; end class ExpenseEntryResource < ExpenseEntryResource; end class IsoCurrencyResource < IsoCurrencyResource From 12c94711ff36c7db0680a8b6c8ebe8f37f6f69fd Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Fri, 19 Jul 2019 11:32:15 -0400 Subject: [PATCH 163/237] Bump jsonapi-resources to 0.10.0.beta8 --- lib/jsonapi/resources/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/jsonapi/resources/version.rb b/lib/jsonapi/resources/version.rb index 26dad92f4..ef1703833 100644 --- a/lib/jsonapi/resources/version.rb +++ b/lib/jsonapi/resources/version.rb @@ -1,5 +1,5 @@ module JSONAPI module Resources - VERSION = '0.10.0.beta7' + VERSION = '0.10.0.beta8' end end From a5ab1af74b37cec6358e9a6b0980e593a4e7ba71 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Mon, 16 Sep 2019 10:29:31 -0400 Subject: [PATCH 164/237] Update bundler to 1.17 --- .travis.yml | 16 +++++++++------- jsonapi-resources.gemspec | 2 +- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/.travis.yml b/.travis.yml index 837e2faec..2f0c67127 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,20 +2,22 @@ language: ruby sudo: false env: - "RAILS_VERSION=4.2.11" - - "RAILS_VERSION=5.0.7.1" - - "RAILS_VERSION=5.1.6.1" - - "RAILS_VERSION=5.2.2" + - "RAILS_VERSION=5.0.7.2" + - "RAILS_VERSION=5.1.7" + - "RAILS_VERSION=5.2.3" # - "RAILS_VERSION=6.0.0.beta1" # - "RAILS_VERSION=master" rvm: - 2.3.8 - - 2.4.5 - - 2.5.3 - - 2.6.1 + - 2.4.7 + - 2.5.6 + - 2.6.4 matrix: allow_failures: - env: "RAILS_VERSION=master" - env: "RAILS_VERSION=6.0.0.beta1" exclude: - - rvm: 2.6.1 + - rvm: 2.6.4 env: "RAILS_VERSION=4.2.11" +before_install: + - gem install bundler --version 1.17.3 \ No newline at end of file diff --git a/jsonapi-resources.gemspec b/jsonapi-resources.gemspec index 1bb48eea2..624447ca2 100644 --- a/jsonapi-resources.gemspec +++ b/jsonapi-resources.gemspec @@ -19,7 +19,7 @@ Gem::Specification.new do |spec| spec.require_paths = ['lib'] spec.required_ruby_version = '>= 2.1' - spec.add_development_dependency 'bundler', '~> 1.5' + spec.add_development_dependency 'bundler', '~> 1.17.3' spec.add_development_dependency 'rake' spec.add_development_dependency 'minitest', '~> 5.10', '!= 5.10.2' spec.add_development_dependency 'minitest-spec-rails' From a51d07f345ca1e56e3bcb7ff0ae4d5c708de9f27 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Mon, 16 Sep 2019 10:29:31 -0400 Subject: [PATCH 165/237] Update bundler to 1.17 --- .travis.yml | 16 +++++++++------- jsonapi-resources.gemspec | 2 +- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/.travis.yml b/.travis.yml index 837e2faec..2f0c67127 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,20 +2,22 @@ language: ruby sudo: false env: - "RAILS_VERSION=4.2.11" - - "RAILS_VERSION=5.0.7.1" - - "RAILS_VERSION=5.1.6.1" - - "RAILS_VERSION=5.2.2" + - "RAILS_VERSION=5.0.7.2" + - "RAILS_VERSION=5.1.7" + - "RAILS_VERSION=5.2.3" # - "RAILS_VERSION=6.0.0.beta1" # - "RAILS_VERSION=master" rvm: - 2.3.8 - - 2.4.5 - - 2.5.3 - - 2.6.1 + - 2.4.7 + - 2.5.6 + - 2.6.4 matrix: allow_failures: - env: "RAILS_VERSION=master" - env: "RAILS_VERSION=6.0.0.beta1" exclude: - - rvm: 2.6.1 + - rvm: 2.6.4 env: "RAILS_VERSION=4.2.11" +before_install: + - gem install bundler --version 1.17.3 \ No newline at end of file diff --git a/jsonapi-resources.gemspec b/jsonapi-resources.gemspec index 1bb48eea2..624447ca2 100644 --- a/jsonapi-resources.gemspec +++ b/jsonapi-resources.gemspec @@ -19,7 +19,7 @@ Gem::Specification.new do |spec| spec.require_paths = ['lib'] spec.required_ruby_version = '>= 2.1' - spec.add_development_dependency 'bundler', '~> 1.5' + spec.add_development_dependency 'bundler', '~> 1.17.3' spec.add_development_dependency 'rake' spec.add_development_dependency 'minitest', '~> 5.10', '!= 5.10.2' spec.add_development_dependency 'minitest-spec-rails' From bc157977ca5f39178c44d7be4607305f7c4d811d Mon Sep 17 00:00:00 2001 From: Tobias Grasse <834914+tobias-grasse@users.noreply.github.com> Date: Thu, 8 Aug 2019 11:51:54 +0200 Subject: [PATCH 166/237] Fix typo in config_description hash key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typo “seriserialization_options” → “serialization_options”. I couldn't find any references to this misspelt key in the current codebase, neither any GitHub search result besides forks of jsonapi-resources 😄 So I presume it's safe to correct it. The only usage of the affected `config_description` method I found ends up using the keys as [part of a cache key](https://github.com/cerebris/jsonapi-resources/blob/d87cd2d2e80ab7660081c32db2303f196860b060/lib/jsonapi/resource_set.rb#L28), which won't be affected either. --- lib/jsonapi/resource_serializer.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/jsonapi/resource_serializer.rb b/lib/jsonapi/resource_serializer.rb index 5751adb67..c7adb6c18 100644 --- a/lib/jsonapi/resource_serializer.rb +++ b/lib/jsonapi/resource_serializer.rb @@ -131,7 +131,7 @@ def config_key(resource_klass) def config_description(resource_klass) { class_name: self.class.name, - seriserialization_options: serialization_options.sort.map(&:as_json), + serialization_options: serialization_options.sort.map(&:as_json), supplying_attribute_fields: supplying_attribute_fields(resource_klass).sort, supplying_relationship_fields: supplying_relationship_fields(resource_klass).sort, link_builder_base_url: link_builder.base_url, From a1fdcb3ddb5055311ce7e3179098ad77b52a73f5 Mon Sep 17 00:00:00 2001 From: Tobias Grasse <834914+tobias-grasse@users.noreply.github.com> Date: Wed, 4 Sep 2019 08:39:35 +0200 Subject: [PATCH 167/237] Consider relative URL root configuration When Rails is deployed to a subdirectory per [the official guide instructions](https://guides.rubyonrails.org/configuring.html#deploy-to-a-subdirectory-relative-url-root), JSON:API should take this into account. Implementation detail: Switched from string concatenation (`+`) to string interpolation because `Rails.application.config.relative_url_root` might be `nil`, which would cause an error as @hlogmans [already stated](https://github.com/cerebris/jsonapi-resources/issues/473#issuecomment-153719383). Tested with: - Setting `RAILS_RELATIVE_URL_ROOT=/subdirectory` - Adding `config.relative_url_root = '/subdirectory'` in application.rb or .rb Fixes #473 --- lib/jsonapi/acts_as_resource_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/jsonapi/acts_as_resource_controller.rb b/lib/jsonapi/acts_as_resource_controller.rb index b8d75ae74..032841608 100644 --- a/lib/jsonapi/acts_as_resource_controller.rb +++ b/lib/jsonapi/acts_as_resource_controller.rb @@ -152,7 +152,7 @@ def resource_serializer_klass end def base_url - @base_url ||= request.protocol + request.host_with_port + @base_url ||= "#{request.protocol}#{request.host_with_port}#{Rails.application.config.relative_url_root}" end def resource_klass_name From 4cc1e5ebbcd1be96720ca1dbec78ff1961bf0cb4 Mon Sep 17 00:00:00 2001 From: Tobias Grasse Date: Thu, 5 Sep 2019 01:04:08 +0200 Subject: [PATCH 168/237] Add test for relative root in links Signed-off-by: Tobias Grasse --- test/controllers/controller_test.rb | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/controllers/controller_test.rb b/test/controllers/controller_test.rb index 7d266792f..b865c410b 100644 --- a/test/controllers/controller_test.rb +++ b/test/controllers/controller_test.rb @@ -10,6 +10,12 @@ def setup JSONAPI.configuration.always_include_to_one_linkage_data = false end + def test_links_include_relative_root + Rails.application.config.relative_url_root = '/subdir' + assert_cacheable_get :index + assert json_response['data'][0]['links']['self'].include?('/subdir') + end + def test_index assert_cacheable_get :index assert_response :success From 37fa80d8b1c741446c589e3f72ee940685e45358 Mon Sep 17 00:00:00 2001 From: Tobias Grasse Date: Thu, 5 Sep 2019 01:22:09 +0200 Subject: [PATCH 169/237] Clean up after test Test suite runs fine without this locally (ruby 2.6.3, Rails 5.2.3), but Travis CI builds all fail because subsequent tests expect a clean base URL. Signed-off-by: Tobias Grasse --- test/controllers/controller_test.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/test/controllers/controller_test.rb b/test/controllers/controller_test.rb index b865c410b..f71370322 100644 --- a/test/controllers/controller_test.rb +++ b/test/controllers/controller_test.rb @@ -14,6 +14,7 @@ def test_links_include_relative_root Rails.application.config.relative_url_root = '/subdir' assert_cacheable_get :index assert json_response['data'][0]['links']['self'].include?('/subdir') + Rails.application.config.relative_url_root = nil end def test_index From 3040156c88e7dc0c9874d9043cfa62da03262ac9 Mon Sep 17 00:00:00 2001 From: Lachlan Sylvester Date: Mon, 19 Aug 2019 23:49:28 -0400 Subject: [PATCH 170/237] require ruby 2.3 as minimum version --- jsonapi-resources.gemspec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jsonapi-resources.gemspec b/jsonapi-resources.gemspec index 624447ca2..030d600e0 100644 --- a/jsonapi-resources.gemspec +++ b/jsonapi-resources.gemspec @@ -17,7 +17,7 @@ Gem::Specification.new do |spec| spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) spec.require_paths = ['lib'] - spec.required_ruby_version = '>= 2.1' + spec.required_ruby_version = '>= 2.3' spec.add_development_dependency 'bundler', '~> 1.17.3' spec.add_development_dependency 'rake' From 896563c0c8cbc600149a93fe59202521adfec7f7 Mon Sep 17 00:00:00 2001 From: Sebastian Menhofer Date: Fri, 23 Aug 2019 11:22:35 +0200 Subject: [PATCH 171/237] Add support for Rails 6 fixes #1280 --- .travis.yml | 7 +++++-- Gemfile | 18 ++++++++++++------ lib/jsonapi-resources.rb | 8 +++++++- .../join_left_active_record_adapter.rb | 2 +- .../join_manager_test.rb | 10 +++++----- 5 files changed, 30 insertions(+), 15 deletions(-) diff --git a/.travis.yml b/.travis.yml index 2f0c67127..8ec07511e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,7 +5,7 @@ env: - "RAILS_VERSION=5.0.7.2" - "RAILS_VERSION=5.1.7" - "RAILS_VERSION=5.2.3" -# - "RAILS_VERSION=6.0.0.beta1" + - "RAILS_VERSION=6.0.0" # - "RAILS_VERSION=master" rvm: - 2.3.8 @@ -15,9 +15,12 @@ rvm: matrix: allow_failures: - env: "RAILS_VERSION=master" - - env: "RAILS_VERSION=6.0.0.beta1" exclude: - rvm: 2.6.4 env: "RAILS_VERSION=4.2.11" + - rvm: 2.3.8 + env: "RAILS_VERSION=6.0.0" + - rvm: 2.4.7 + env: "RAILS_VERSION=6.0.0" before_install: - gem install bundler --version 1.17.3 \ No newline at end of file diff --git a/Gemfile b/Gemfile index 0c783b266..c2acdf548 100644 --- a/Gemfile +++ b/Gemfile @@ -2,10 +2,6 @@ source 'https://rubygems.org' gemspec -platforms :ruby do - gem 'sqlite3', '1.3.13' -end - platforms :jruby do gem 'activerecord-jdbcsqlite3-adapter' end @@ -17,7 +13,17 @@ when 'master' gem 'railties', { git: 'https://github.com/rails/rails.git' } gem 'arel', { git: 'https://github.com/rails/arel.git' } when 'default' - gem 'railties', '>= 5.0' + gem 'railties', '>= 6.0' +when '6.0.0' + platforms :ruby do + gem 'sqlite3', '~> 1.4' + end + + gem 'railties', "~> #{version}" else + platforms :ruby do + gem 'sqlite3', '1.3.13' + end + gem 'railties', "~> #{version}" -end +end \ No newline at end of file diff --git a/lib/jsonapi-resources.rb b/lib/jsonapi-resources.rb index e08bebcdd..3a903e98c 100644 --- a/lib/jsonapi-resources.rb +++ b/lib/jsonapi-resources.rb @@ -7,7 +7,13 @@ require 'jsonapi/cached_response_fragment' require 'jsonapi/response_document' require 'jsonapi/acts_as_resource_controller' -require 'jsonapi/resource_controller' +if ActiveSupport.respond_to?(:on_load) + ActiveSupport.on_load(:action_controller_base) do + require 'jsonapi/resource_controller' + end +else + require 'jsonapi/resource_controller' +end require 'jsonapi/resource_controller_metal' require 'jsonapi/resources/version' require 'jsonapi/configuration' diff --git a/lib/jsonapi/active_relation/adapters/join_left_active_record_adapter.rb b/lib/jsonapi/active_relation/adapters/join_left_active_record_adapter.rb index cc4355548..42eb47b6a 100644 --- a/lib/jsonapi/active_relation/adapters/join_left_active_record_adapter.rb +++ b/lib/jsonapi/active_relation/adapters/join_left_active_record_adapter.rb @@ -8,7 +8,7 @@ module JoinLeftActiveRecordAdapter # example Post.joins(:comments).joins_left(comments: :author) will join the comments table twice, # once inner and once left in 5.2, but only as inner in earlier versions. def joins_left(*columns) - if Rails::VERSION::MAJOR >= 5 && ActiveRecord::VERSION::MINOR >= 2 + if Rails::VERSION::MAJOR >= 6 || (Rails::VERSION::MAJOR >= 5 && ActiveRecord::VERSION::MINOR >= 2) left_joins(columns) else join_dependency = ActiveRecord::Associations::JoinDependency.new(self, columns, []) diff --git a/test/unit/active_relation_resource_finder/join_manager_test.rb b/test/unit/active_relation_resource_finder/join_manager_test.rb index 7075287a9..f8526ff30 100644 --- a/test/unit/active_relation_resource_finder/join_manager_test.rb +++ b/test/unit/active_relation_resource_finder/join_manager_test.rb @@ -79,7 +79,7 @@ def test_add_joins_source_relationship_with_custom_apply records = Api::V10::PostResource.records({}) records = join_manager.join(records, {}) - if Rails::VERSION::MAJOR >= 5 && Rails::VERSION::MINOR >= 2 + if Rails::VERSION::MAJOR >= 6 || (Rails::VERSION::MAJOR >= 5 && ActiveRecord::VERSION::MINOR >= 2) assert_equal 'SELECT "posts".* FROM "posts" INNER JOIN "comments" ON "comments"."post_id" = "posts"."id" WHERE "comments"."approved" = 1', records.to_sql else assert_equal 'SELECT "posts".* FROM "posts" INNER JOIN "comments" ON "comments"."post_id" = "posts"."id" WHERE "comments"."approved" = \'t\'', records.to_sql @@ -99,7 +99,7 @@ def test_add_nested_scoped_joins records = Api::V10::PostResource.records({}) records = join_manager.join(records, {}) - if Rails::VERSION::MAJOR >= 5 && Rails::VERSION::MINOR >= 2 + if Rails::VERSION::MAJOR >= 6 || (Rails::VERSION::MAJOR >= 5 && ActiveRecord::VERSION::MINOR >= 2) assert_equal 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "comments" ON "comments"."post_id" = "posts"."id" LEFT OUTER JOIN "people" ON "people"."id" = "posts"."author_id" LEFT OUTER JOIN "people" "authors_comments" ON "authors_comments"."id" = "comments"."author_id" LEFT OUTER JOIN "comments_tags" ON "comments_tags"."comment_id" = "comments"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "comments_tags"."tag_id" WHERE "comments"."approved" = 1 AND "author"."special" = 1', records.to_sql else assert_equal 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "comments" ON "comments"."post_id" = "posts"."id" LEFT OUTER JOIN "people" ON "people"."id" = "posts"."author_id" LEFT OUTER JOIN "people" "authors_comments" ON "authors_comments"."id" = "comments"."author_id" LEFT OUTER JOIN "comments_tags" ON "comments_tags"."comment_id" = "comments"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "comments_tags"."tag_id" WHERE "comments"."approved" = \'t\' AND "author"."special" = \'t\'', records.to_sql @@ -123,7 +123,7 @@ def test_add_nested_scoped_joins records = join_manager.join(records, {}) # Note sql is in different order, but aliases should still be right - if Rails::VERSION::MAJOR >= 5 && Rails::VERSION::MINOR >= 2 + if Rails::VERSION::MAJOR >= 6 || (Rails::VERSION::MAJOR >= 5 && ActiveRecord::VERSION::MINOR >= 2) assert_equal 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "people" ON "people"."id" = "posts"."author_id" LEFT OUTER JOIN "comments" ON "comments"."post_id" = "posts"."id" LEFT OUTER JOIN "people" "authors_comments" ON "authors_comments"."id" = "comments"."author_id" LEFT OUTER JOIN "comments_tags" ON "comments_tags"."comment_id" = "comments"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "comments_tags"."tag_id" WHERE "comments"."approved" = 1 AND "author"."special" = 1', records.to_sql else assert_equal 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "people" ON "people"."id" = "posts"."author_id" LEFT OUTER JOIN "comments" ON "comments"."post_id" = "posts"."id" LEFT OUTER JOIN "people" "authors_comments" ON "authors_comments"."id" = "comments"."author_id" LEFT OUTER JOIN "comments_tags" ON "comments_tags"."comment_id" = "comments"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "comments_tags"."tag_id" WHERE "comments"."approved" = \'t\' AND "author"."special" = \'t\'', records.to_sql @@ -163,7 +163,7 @@ def test_add_nested_joins_with_fields records = Api::V10::PostResource.records({}) records = join_manager.join(records, {}) - if Rails::VERSION::MAJOR >= 5 && Rails::VERSION::MINOR >= 2 + if Rails::VERSION::MAJOR >= 6 || (Rails::VERSION::MAJOR >= 5 && ActiveRecord::VERSION::MINOR >= 2) assert_equal 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "comments" ON "comments"."post_id" = "posts"."id" LEFT OUTER JOIN "people" ON "people"."id" = "posts"."author_id" LEFT OUTER JOIN "people" "authors_comments" ON "authors_comments"."id" = "comments"."author_id" LEFT OUTER JOIN "comments_tags" ON "comments_tags"."comment_id" = "comments"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "comments_tags"."tag_id" WHERE "comments"."approved" = 1 AND "author"."special" = 1', records.to_sql else assert_equal 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "comments" ON "comments"."post_id" = "posts"."id" LEFT OUTER JOIN "people" ON "people"."id" = "posts"."author_id" LEFT OUTER JOIN "people" "authors_comments" ON "authors_comments"."id" = "comments"."author_id" LEFT OUTER JOIN "comments_tags" ON "comments_tags"."comment_id" = "comments"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "comments_tags"."tag_id" WHERE "comments"."approved" = \'t\' AND "author"."special" = \'t\'', records.to_sql @@ -184,7 +184,7 @@ def test_add_joins_with_sub_relationship records = Api::V10::PostResource.records({}) records = join_manager.join(records, {}) - if Rails::VERSION::MAJOR >= 5 && Rails::VERSION::MINOR >= 2 + if Rails::VERSION::MAJOR >= 6 || (Rails::VERSION::MAJOR >= 5 && ActiveRecord::VERSION::MINOR >= 2) assert_equal 'SELECT "posts".* FROM "posts" INNER JOIN "comments" ON "comments"."post_id" = "posts"."id" LEFT OUTER JOIN "people" ON "people"."id" = "comments"."author_id" LEFT OUTER JOIN "comments_tags" ON "comments_tags"."comment_id" = "comments"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "comments_tags"."tag_id" LEFT OUTER JOIN "comments" "comments_people" ON "comments_people"."author_id" = "people"."id" WHERE "comments"."approved" = 1 AND "author"."special" = 1', records.to_sql else assert_equal 'SELECT "posts".* FROM "posts" INNER JOIN "comments" ON "comments"."post_id" = "posts"."id" LEFT OUTER JOIN "people" ON "people"."id" = "comments"."author_id" LEFT OUTER JOIN "comments_tags" ON "comments_tags"."comment_id" = "comments"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "comments_tags"."tag_id" LEFT OUTER JOIN "comments" "comments_people" ON "comments_people"."author_id" = "people"."id" WHERE "comments"."approved" = \'t\' AND "author"."special" = \'t\'', records.to_sql From bf3a943236d0cb8285b7069e6533c5ff9a1a7250 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Mon, 16 Sep 2019 16:24:55 -0400 Subject: [PATCH 172/237] Only use ActiveSupport.on_load for rails 6 and above closes #1281 --- lib/jsonapi-resources.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/jsonapi-resources.rb b/lib/jsonapi-resources.rb index 3a903e98c..e73e9723a 100644 --- a/lib/jsonapi-resources.rb +++ b/lib/jsonapi-resources.rb @@ -7,7 +7,7 @@ require 'jsonapi/cached_response_fragment' require 'jsonapi/response_document' require 'jsonapi/acts_as_resource_controller' -if ActiveSupport.respond_to?(:on_load) +if Rails::VERSION::MAJOR >= 6 ActiveSupport.on_load(:action_controller_base) do require 'jsonapi/resource_controller' end From d9222d314133ebdaae58942b3d253f8fd5ac6cb0 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Tue, 17 Sep 2019 11:50:57 -0400 Subject: [PATCH 173/237] Support rails versions greater than 6.0.0 --- Gemfile | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/Gemfile b/Gemfile index c2acdf548..754aad900 100644 --- a/Gemfile +++ b/Gemfile @@ -8,22 +8,20 @@ end version = ENV['RAILS_VERSION'] || 'default' +platforms :ruby do + if version.start_with?('4.2', '5.0') + gem 'sqlite3', '~> 1.3.13' + else + gem 'sqlite3', '~> 1.4' + end +end + case version when 'master' gem 'railties', { git: 'https://github.com/rails/rails.git' } gem 'arel', { git: 'https://github.com/rails/arel.git' } when 'default' gem 'railties', '>= 6.0' -when '6.0.0' - platforms :ruby do - gem 'sqlite3', '~> 1.4' - end - - gem 'railties', "~> #{version}" else - platforms :ruby do - gem 'sqlite3', '1.3.13' - end - gem 'railties', "~> #{version}" end \ No newline at end of file From 48953a2db49d250b431b20747ab48bf67ac51d6d Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Tue, 17 Sep 2019 13:25:52 -0400 Subject: [PATCH 174/237] Bump jsonapi-resources to 0.10.0.beta9 --- lib/jsonapi/resources/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/jsonapi/resources/version.rb b/lib/jsonapi/resources/version.rb index ef1703833..7bc30107d 100644 --- a/lib/jsonapi/resources/version.rb +++ b/lib/jsonapi/resources/version.rb @@ -1,5 +1,5 @@ module JSONAPI module Resources - VERSION = '0.10.0.beta8' + VERSION = '0.10.0.beta9' end end From 3145b8a4f3dd432c61170323c2d0e1a5f2023df2 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Mon, 30 Sep 2019 19:28:24 -0400 Subject: [PATCH 175/237] Bump jsonapi-resources to 0.10.0 --- lib/jsonapi/resources/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/jsonapi/resources/version.rb b/lib/jsonapi/resources/version.rb index 7bc30107d..e7555288e 100644 --- a/lib/jsonapi/resources/version.rb +++ b/lib/jsonapi/resources/version.rb @@ -1,5 +1,5 @@ module JSONAPI module Resources - VERSION = '0.10.0.beta9' + VERSION = '0.10.0' end end From 864e810cb96e09f40f4c57db27445d1ef74c6554 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Tue, 8 Oct 2019 15:28:34 -0400 Subject: [PATCH 176/237] Test against Postgres --- .travis.yml | 39 ++++++++------ Gemfile | 2 + test/config/database.yml | 6 --- test/controllers/controller_test.rb | 46 ++++++++++++---- test/fixtures/posts.yml | 2 +- test/integration/requests/request_test.rb | 7 ++- test/test_helper.rb | 7 ++- .../join_manager_test.rb | 54 ++++++++++--------- 8 files changed, 100 insertions(+), 63 deletions(-) delete mode 100644 test/config/database.yml diff --git a/.travis.yml b/.travis.yml index 8ec07511e..679787e7d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,26 +1,31 @@ language: ruby sudo: false +services: + - postgresql env: - - "RAILS_VERSION=4.2.11" - - "RAILS_VERSION=5.0.7.2" - - "RAILS_VERSION=5.1.7" - - "RAILS_VERSION=5.2.3" - - "RAILS_VERSION=6.0.0" -# - "RAILS_VERSION=master" + - RAILS_VERSION=6.0.0 DATABASE_URL=postgres://postgres@localhost/jr_test + - RAILS_VERSION=6.0.0 + - RAILS_VERSION=5.2.3 DATABASE_URL=postgres://postgres@localhost/jr_test + - RAILS_VERSION=5.2.3 + - RAILS_VERSION=5.1.7 + - RAILS_VERSION=5.0.7.2 + - RAILS_VERSION=4.2.11 rvm: - - 2.3.8 - - 2.4.7 - - 2.5.6 - - 2.6.4 + - 2.4.9 + - 2.5.7 + - 2.6.5 matrix: - allow_failures: - - env: "RAILS_VERSION=master" exclude: - - rvm: 2.6.4 + - rvm: 2.6.5 env: "RAILS_VERSION=4.2.11" - - rvm: 2.3.8 - env: "RAILS_VERSION=6.0.0" - - rvm: 2.4.7 + - rvm: 2.4.9 env: "RAILS_VERSION=6.0.0" + - rvm: 2.4.9 + env: "RAILS_VERSION=6.0.0 DATABASE_URL=postgres://postgres@localhost/jr_test" + - rvm: 2.4.9 + env: "RAILS_VERSION=5.2.3 DATABASE_URL=postgres://postgres@localhost/jr_test" before_install: - - gem install bundler --version 1.17.3 \ No newline at end of file + - gem install bundler --version 1.17.3 +before_script: + - sh -c "if [ '$DATABASE_URL' = 'postgres://postgres@localhost/jr_test' ]; then psql -c 'DROP DATABASE IF EXISTS jr_test;' -U postgres; fi" + - sh -c "if [ '$DATABASE_URL' = 'postgres://postgres@localhost/jr_test' ]; then psql -c 'CREATE DATABASE jr_test;' -U postgres; fi" \ No newline at end of file diff --git a/Gemfile b/Gemfile index 754aad900..2535d0200 100644 --- a/Gemfile +++ b/Gemfile @@ -9,6 +9,8 @@ end version = ENV['RAILS_VERSION'] || 'default' platforms :ruby do + gem 'pg' + if version.start_with?('4.2', '5.0') gem 'sqlite3', '~> 1.3.13' else diff --git a/test/config/database.yml b/test/config/database.yml deleted file mode 100644 index 97abfd13b..000000000 --- a/test/config/database.yml +++ /dev/null @@ -1,6 +0,0 @@ -test: - adapter: sqlite3 - database: test_db -# database: ":memory:" - pool: 5 - timeout: 5000 diff --git a/test/controllers/controller_test.rb b/test/controllers/controller_test.rb index f71370322..adcac5a94 100644 --- a/test/controllers/controller_test.rb +++ b/test/controllers/controller_test.rb @@ -6,6 +6,7 @@ def set_content_type_header! class PostsControllerTest < ActionController::TestCase def setup + super JSONAPI.configuration.raise_if_parameters_not_allowed = true JSONAPI.configuration.always_include_to_one_linkage_data = false end @@ -445,7 +446,7 @@ def test_sorting_asc assert_cacheable_get :index, params: {sort: 'title'} assert_response :success - assert_equal "A First Post", json_response['data'][0]['attributes']['title'] + assert_equal "A 1ST Post", json_response['data'][0]['attributes']['title'] end def test_sorting_desc @@ -459,7 +460,7 @@ def test_sorting_by_multiple_fields assert_cacheable_get :index, params: {sort: 'title,body'} assert_response :success - assert_equal '14', json_response['data'][0]['id'] + assert_equal '15', json_response['data'][0]['id'] end def create_alphabetically_first_user_and_post @@ -473,8 +474,15 @@ def test_sorting_by_relationship_field assert_response :success assert json_response['data'].length > 10, 'there are enough records to show sort' - assert_equal '17', json_response['data'][0]['id'], 'nil is at the top' - assert_equal post.id.to_s, json_response['data'][1]['id'], 'alphabetically first user is second' + + # Postgres sorts nulls last, whereas sqlite and mysql sort nulls first + if ENV['DATABASE_URL'].starts_with?('postgres') + assert_equal '17', json_response['data'][-1]['id'], 'nil is at the start' + assert_equal post.id.to_s, json_response['data'][0]['id'], 'alphabetically first user is not first' + else + assert_equal '17', json_response['data'][0]['id'], 'nil is at the end' + assert_equal post.id.to_s, json_response['data'][1]['id'], 'alphabetically first user is second' + end end def test_desc_sorting_by_relationship_field @@ -483,8 +491,15 @@ def test_desc_sorting_by_relationship_field assert_response :success assert json_response['data'].length > 10, 'there are enough records to show sort' - assert_equal '17', json_response['data'][-1]['id'], 'nil is at the bottom' - assert_equal post.id.to_s, json_response['data'][-2]['id'], 'alphabetically first user is second last' + + # Postgres sorts nulls last, whereas sqlite and mysql sort nulls first + if ENV['DATABASE_URL'].starts_with?('postgres') + assert_equal '17', json_response['data'][0]['id'], 'nil is at the start' + assert_equal post.id.to_s, json_response['data'][-1]['id'] + else + assert_equal '17', json_response['data'][-1]['id'], 'nil is at the end' + assert_equal post.id.to_s, json_response['data'][-2]['id'], 'alphabetically first user is second last' + end end def test_sorting_by_relationship_field_include @@ -493,8 +508,14 @@ def test_sorting_by_relationship_field_include assert_response :success assert json_response['data'].length > 10, 'there are enough records to show sort' - assert_equal '17', json_response['data'][0]['id'], 'nil is at the top' - assert_equal post.id.to_s, json_response['data'][1]['id'], 'alphabetically first user is second' + + if ENV['DATABASE_URL'].starts_with?('postgres') + assert_equal '17', json_response['data'][-1]['id'], 'nil is at the top' + assert_equal post.id.to_s, json_response['data'][0]['id'] + else + assert_equal '17', json_response['data'][0]['id'], 'nil is at the top' + assert_equal post.id.to_s, json_response['data'][1]['id'], 'alphabetically first user is second' + end end def test_invalid_sort_param @@ -3107,7 +3128,7 @@ def test_type_formatting assert json_response['data'].is_a?(Hash) assert_equal 'Jane Author', json_response['data']['attributes']['spouseName'] assert_equal 'First man to run across Antartica.', json_response['data']['attributes']['bio'] - assert_equal 23.89/45.6, json_response['data']['attributes']['qualityRating'] + assert_equal (23.89/45.6).round(5), json_response['data']['attributes']['qualityRating'].round(5) assert_equal '47000.56', json_response['data']['attributes']['salary'] assert_equal '2013-08-07T20:25:00.000Z', json_response['data']['attributes']['dateTimeJoined'] assert_equal '1965-06-30', json_response['data']['attributes']['birthday'] @@ -4707,7 +4728,12 @@ def test_fetch_robots_with_sort_by_name Robot.create! name: 'jane', version: 1 assert_cacheable_get :index, params: {sort: 'name'} assert_response :success - assert_equal 'John', json_response['data'].first['attributes']['name'] + + if ENV['DATABASE_URL'].starts_with?('postgres') + assert_equal 'jane', json_response['data'].first['attributes']['name'] + else + assert_equal 'John', json_response['data'].first['attributes']['name'] + end end def test_fetch_robots_with_sort_by_lower_name diff --git a/test/fixtures/posts.yml b/test/fixtures/posts.yml index 491a627b6..02d94ef3f 100644 --- a/test/fixtures/posts.yml +++ b/test/fixtures/posts.yml @@ -85,7 +85,7 @@ post_14: post_15: id: 15 - title: AAAA First Post + title: A 1ST Post body: First!!!!!!!!! author_id: 1003 diff --git a/test/integration/requests/request_test.rb b/test/integration/requests/request_test.rb index 64f988767..2cd84a0c9 100644 --- a/test/integration/requests/request_test.rb +++ b/test/integration/requests/request_test.rb @@ -1438,13 +1438,16 @@ def test_sort_primary_attribute end def test_sort_included_attribute + # Postgres sorts nulls last, whereas sqlite and mysql sort nulls first + pg = ENV['DATABASE_URL'].starts_with?('postgres') + get '/api/v6/authors?sort=author_detail.author_stuff', headers: { 'Accept' => JSONAPI::MEDIA_TYPE } assert_jsonapi_response 200 - assert_equal '1000', json_response['data'][0]['id'] + assert_equal pg ? '1001' : '1000', json_response['data'][0]['id'] get '/api/v6/authors?sort=-author_detail.author_stuff', headers: { 'Accept' => JSONAPI::MEDIA_TYPE } assert_jsonapi_response 200 - assert_equal '1002', json_response['data'][0]['id'] + assert_equal pg ? '1000' : '1002', json_response['data'][0]['id'] end def test_include_parameter_quoted diff --git a/test/test_helper.rb b/test/test_helper.rb index b8e6acc40..97e51fe7d 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -21,6 +21,8 @@ end end +ENV['DATABASE_URL'] ||= "sqlite3:test_db" + require 'active_record/railtie' require 'rails/test_help' require 'minitest/mock' @@ -68,6 +70,9 @@ class TestApp < Rails::Application end end +DatabaseCleaner.allow_remote_database_url = true +DatabaseCleaner.strategy = :transaction + module MyEngine class Engine < ::Rails::Engine isolate_namespace MyEngine @@ -477,8 +482,6 @@ class CatResource < JSONAPI::Resource jsonapi_resources :people end -DatabaseCleaner.strategy = :transaction - # Ensure backward compatibility with Minitest 4 Minitest::Test = MiniTest::Unit::TestCase unless defined?(Minitest::Test) diff --git a/test/unit/active_relation_resource_finder/join_manager_test.rb b/test/unit/active_relation_resource_finder/join_manager_test.rb index f8526ff30..9fb13a82b 100644 --- a/test/unit/active_relation_resource_finder/join_manager_test.rb +++ b/test/unit/active_relation_resource_finder/join_manager_test.rb @@ -3,6 +3,19 @@ class JoinTreeTest < ActiveSupport::TestCase + def db_true + case ActiveRecord::Base.connection.adapter_name + when 'SQLite' + if Rails::VERSION::MAJOR >= 6 || (Rails::VERSION::MAJOR >= 5 && ActiveRecord::VERSION::MINOR >= 2) + "1" + else + "'t'" + end + when 'PostgreSQL' + 'TRUE' + end + end + def test_no_added_joins join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: PostResource) @@ -79,11 +92,9 @@ def test_add_joins_source_relationship_with_custom_apply records = Api::V10::PostResource.records({}) records = join_manager.join(records, {}) - if Rails::VERSION::MAJOR >= 6 || (Rails::VERSION::MAJOR >= 5 && ActiveRecord::VERSION::MINOR >= 2) - assert_equal 'SELECT "posts".* FROM "posts" INNER JOIN "comments" ON "comments"."post_id" = "posts"."id" WHERE "comments"."approved" = 1', records.to_sql - else - assert_equal 'SELECT "posts".* FROM "posts" INNER JOIN "comments" ON "comments"."post_id" = "posts"."id" WHERE "comments"."approved" = \'t\'', records.to_sql - end + sql = 'SELECT "posts".* FROM "posts" INNER JOIN "comments" ON "comments"."post_id" = "posts"."id" WHERE "comments"."approved" = ' + db_true + + assert_equal sql, records.to_sql assert_hash_equals({alias: 'comments', join_type: :inner}, join_manager.source_join_details) end @@ -99,11 +110,9 @@ def test_add_nested_scoped_joins records = Api::V10::PostResource.records({}) records = join_manager.join(records, {}) - if Rails::VERSION::MAJOR >= 6 || (Rails::VERSION::MAJOR >= 5 && ActiveRecord::VERSION::MINOR >= 2) - assert_equal 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "comments" ON "comments"."post_id" = "posts"."id" LEFT OUTER JOIN "people" ON "people"."id" = "posts"."author_id" LEFT OUTER JOIN "people" "authors_comments" ON "authors_comments"."id" = "comments"."author_id" LEFT OUTER JOIN "comments_tags" ON "comments_tags"."comment_id" = "comments"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "comments_tags"."tag_id" WHERE "comments"."approved" = 1 AND "author"."special" = 1', records.to_sql - else - assert_equal 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "comments" ON "comments"."post_id" = "posts"."id" LEFT OUTER JOIN "people" ON "people"."id" = "posts"."author_id" LEFT OUTER JOIN "people" "authors_comments" ON "authors_comments"."id" = "comments"."author_id" LEFT OUTER JOIN "comments_tags" ON "comments_tags"."comment_id" = "comments"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "comments_tags"."tag_id" WHERE "comments"."approved" = \'t\' AND "author"."special" = \'t\'', records.to_sql - end + sql = 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "comments" ON "comments"."post_id" = "posts"."id" LEFT OUTER JOIN "people" ON "people"."id" = "posts"."author_id" LEFT OUTER JOIN "people" "authors_comments" ON "authors_comments"."id" = "comments"."author_id" LEFT OUTER JOIN "comments_tags" ON "comments_tags"."comment_id" = "comments"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "comments_tags"."tag_id" WHERE "comments"."approved" = ' + db_true + ' AND "author"."special" = ' + db_true + + assert_equal sql, records.to_sql assert_hash_equals({alias: 'posts', join_type: :root}, join_manager.source_join_details) assert_hash_equals({alias: 'comments', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::PostResource._relationship(:comments))) @@ -123,11 +132,10 @@ def test_add_nested_scoped_joins records = join_manager.join(records, {}) # Note sql is in different order, but aliases should still be right - if Rails::VERSION::MAJOR >= 6 || (Rails::VERSION::MAJOR >= 5 && ActiveRecord::VERSION::MINOR >= 2) - assert_equal 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "people" ON "people"."id" = "posts"."author_id" LEFT OUTER JOIN "comments" ON "comments"."post_id" = "posts"."id" LEFT OUTER JOIN "people" "authors_comments" ON "authors_comments"."id" = "comments"."author_id" LEFT OUTER JOIN "comments_tags" ON "comments_tags"."comment_id" = "comments"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "comments_tags"."tag_id" WHERE "comments"."approved" = 1 AND "author"."special" = 1', records.to_sql - else - assert_equal 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "people" ON "people"."id" = "posts"."author_id" LEFT OUTER JOIN "comments" ON "comments"."post_id" = "posts"."id" LEFT OUTER JOIN "people" "authors_comments" ON "authors_comments"."id" = "comments"."author_id" LEFT OUTER JOIN "comments_tags" ON "comments_tags"."comment_id" = "comments"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "comments_tags"."tag_id" WHERE "comments"."approved" = \'t\' AND "author"."special" = \'t\'', records.to_sql - end + sql = 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "people" ON "people"."id" = "posts"."author_id" LEFT OUTER JOIN "comments" ON "comments"."post_id" = "posts"."id" LEFT OUTER JOIN "people" "authors_comments" ON "authors_comments"."id" = "comments"."author_id" LEFT OUTER JOIN "comments_tags" ON "comments_tags"."comment_id" = "comments"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "comments_tags"."tag_id" WHERE "comments"."approved" = ' + db_true + ' AND "author"."special" = ' + db_true + + assert_equal sql, records.to_sql + assert_hash_equals({alias: 'posts', join_type: :root}, join_manager.source_join_details) assert_hash_equals({alias: 'comments', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::PostResource._relationship(:comments))) assert_hash_equals({alias: 'authors_comments', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::CommentResource._relationship(:author))) @@ -163,11 +171,9 @@ def test_add_nested_joins_with_fields records = Api::V10::PostResource.records({}) records = join_manager.join(records, {}) - if Rails::VERSION::MAJOR >= 6 || (Rails::VERSION::MAJOR >= 5 && ActiveRecord::VERSION::MINOR >= 2) - assert_equal 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "comments" ON "comments"."post_id" = "posts"."id" LEFT OUTER JOIN "people" ON "people"."id" = "posts"."author_id" LEFT OUTER JOIN "people" "authors_comments" ON "authors_comments"."id" = "comments"."author_id" LEFT OUTER JOIN "comments_tags" ON "comments_tags"."comment_id" = "comments"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "comments_tags"."tag_id" WHERE "comments"."approved" = 1 AND "author"."special" = 1', records.to_sql - else - assert_equal 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "comments" ON "comments"."post_id" = "posts"."id" LEFT OUTER JOIN "people" ON "people"."id" = "posts"."author_id" LEFT OUTER JOIN "people" "authors_comments" ON "authors_comments"."id" = "comments"."author_id" LEFT OUTER JOIN "comments_tags" ON "comments_tags"."comment_id" = "comments"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "comments_tags"."tag_id" WHERE "comments"."approved" = \'t\' AND "author"."special" = \'t\'', records.to_sql - end + sql = 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "comments" ON "comments"."post_id" = "posts"."id" LEFT OUTER JOIN "people" ON "people"."id" = "posts"."author_id" LEFT OUTER JOIN "people" "authors_comments" ON "authors_comments"."id" = "comments"."author_id" LEFT OUTER JOIN "comments_tags" ON "comments_tags"."comment_id" = "comments"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "comments_tags"."tag_id" WHERE "comments"."approved" = ' + db_true + ' AND "author"."special" = ' + db_true + + assert_equal sql, records.to_sql assert_hash_equals({alias: 'posts', join_type: :root}, join_manager.source_join_details) assert_hash_equals({alias: 'comments', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::PostResource._relationship(:comments))) @@ -184,11 +190,9 @@ def test_add_joins_with_sub_relationship records = Api::V10::PostResource.records({}) records = join_manager.join(records, {}) - if Rails::VERSION::MAJOR >= 6 || (Rails::VERSION::MAJOR >= 5 && ActiveRecord::VERSION::MINOR >= 2) - assert_equal 'SELECT "posts".* FROM "posts" INNER JOIN "comments" ON "comments"."post_id" = "posts"."id" LEFT OUTER JOIN "people" ON "people"."id" = "comments"."author_id" LEFT OUTER JOIN "comments_tags" ON "comments_tags"."comment_id" = "comments"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "comments_tags"."tag_id" LEFT OUTER JOIN "comments" "comments_people" ON "comments_people"."author_id" = "people"."id" WHERE "comments"."approved" = 1 AND "author"."special" = 1', records.to_sql - else - assert_equal 'SELECT "posts".* FROM "posts" INNER JOIN "comments" ON "comments"."post_id" = "posts"."id" LEFT OUTER JOIN "people" ON "people"."id" = "comments"."author_id" LEFT OUTER JOIN "comments_tags" ON "comments_tags"."comment_id" = "comments"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "comments_tags"."tag_id" LEFT OUTER JOIN "comments" "comments_people" ON "comments_people"."author_id" = "people"."id" WHERE "comments"."approved" = \'t\' AND "author"."special" = \'t\'', records.to_sql - end + sql = 'SELECT "posts".* FROM "posts" INNER JOIN "comments" ON "comments"."post_id" = "posts"."id" LEFT OUTER JOIN "people" ON "people"."id" = "comments"."author_id" LEFT OUTER JOIN "comments_tags" ON "comments_tags"."comment_id" = "comments"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "comments_tags"."tag_id" LEFT OUTER JOIN "comments" "comments_people" ON "comments_people"."author_id" = "people"."id" WHERE "comments"."approved" = ' + db_true + ' AND "author"."special" = ' + db_true + + assert_equal sql, records.to_sql assert_hash_equals({alias: 'comments', join_type: :inner}, join_manager.source_join_details) assert_hash_equals({alias: 'comments', join_type: :inner}, join_manager.join_details_by_relationship(Api::V10::PostResource._relationship(:comments))) From 99268a83d27b049a8196b1aed77b06c661859741 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Tue, 8 Oct 2019 15:29:45 -0400 Subject: [PATCH 177/237] Track and select sort fields to fix postgres sql generation error --- lib/jsonapi/active_relation_resource.rb | 36 +++++++++++++++++-------- test/fixtures/active_record.rb | 6 ++--- 2 files changed, 28 insertions(+), 14 deletions(-) diff --git a/lib/jsonapi/active_relation_resource.rb b/lib/jsonapi/active_relation_resource.rb index 5f800de50..d9272339d 100644 --- a/lib/jsonapi/active_relation_resource.rb +++ b/lib/jsonapi/active_relation_resource.rb @@ -153,6 +153,11 @@ def find_fragments(filters, options = {}) pluck_fields << Arel.sql("#{concat_table_field(resource_table_alias, model_field[:name])} AS #{resource_table_alias}_#{model_field[:name]}") end + sort_fields = options.dig(:_relation_helper_options, :sort_fields) + sort_fields.try(:each) do |field| + pluck_fields << Arel.sql(field) + end + fragments = {} rows = records.pluck(*pluck_fields) rows.each do |row| @@ -445,6 +450,11 @@ def find_related_monomorphic_fragments(source_rids, relationship, options, conne pluck_fields << Arel.sql("#{concat_table_field(resource_table_alias, model_field[:name])} AS #{resource_table_alias}_#{model_field[:name]}") end + sort_fields = options.dig(:_relation_helper_options, :sort_fields) + sort_fields.try(:each) do |field| + pluck_fields << Arel.sql(field) + end + fragments = {} rows = records.distinct.pluck(*pluck_fields) rows.each do |row| @@ -680,24 +690,23 @@ def apply_request_settings_to_records(records:, paginator: nil, options: {}) - opts = options.dup - records = resource_klass.apply_joins(records, join_manager, opts) + options[:_relation_helper_options] = { join_manager: join_manager, sort_fields: [] } + + records = resource_klass.apply_joins(records, join_manager, options) if primary_keys records = records.where(_primary_key => primary_keys) end - opts[:join_manager] = join_manager - unless filters.empty? - records = resource_klass.filter_records(records, filters, opts) + records = resource_klass.filter_records(records, filters, options) end if sort_primary records = records.order(_primary_key => :asc) else order_options = resource_klass.construct_order_options(sort_criteria) - records = resource_klass.sort_records(records, order_options, opts) + records = resource_klass.sort_records(records, order_options, options) end if paginator @@ -731,12 +740,16 @@ def apply_single_sort(records, field, direction, options) strategy = _allowed_sort.fetch(field.to_sym, {})[:apply] + options[:_relation_helper_options] ||= {} + options[:_relation_helper_options][:sort_fields] ||= [] + if strategy records = call_method_or_proc(strategy, records, direction, context) else - join_manager = options[:join_manager] - - records = records.order(Arel.sql("#{get_aliased_field(field, join_manager)} #{direction}")) + join_manager = options.dig(:_relation_helper_options, :join_manager) + sort_field = join_manager ? get_aliased_field(field, join_manager) : field + options[:_relation_helper_options][:sort_fields].push("#{sort_field}") + records = records.order(Arel.sql("#{sort_field} #{direction}")) end records end @@ -825,8 +838,9 @@ def apply_filter(records, filter, value, options = {}) if strategy records = call_method_or_proc(strategy, records, value, options) else - join_manager = options[:join_manager] - records = records.where(Arel.sql(get_aliased_field(filter, join_manager)) => value) + join_manager = options.dig(:_relation_helper_options, :join_manager) + field = join_manager ? get_aliased_field(filter, join_manager) : filter + records = records.where(Arel.sql(field) => value) end records diff --git a/test/fixtures/active_record.rb b/test/fixtures/active_record.rb index 68bb69fc3..d785e8af1 100644 --- a/test/fixtures/active_record.rb +++ b/test/fixtures/active_record.rb @@ -1650,7 +1650,7 @@ class CraterResource < JSONAPI::Resource filter :description, apply: -> (records, value, options) { fail "context not set" unless options[:context][:current_user] != nil && options[:context][:current_user] == $test_user - records.where(concat_table_field(options[:join_manager].source_join_details[:alias], :description) => value) + records.where(concat_table_field(options.dig(:_relation_helper_options, :join_manager).source_join_details[:alias], :description) => value) } def self.verify_key(key, context = nil) @@ -1694,7 +1694,7 @@ class PictureResource < JSONAPI::Resource has_one :file_properties, inverse_relationship: :fileable, :foreign_key_on => :related, polymorphic: true filter 'imageable.name', perform_joins: true, apply: -> (records, value, options) { - join_manager = options[:join_manager] + join_manager = options.dig(:_relation_helper_options, :join_manager) relationship = _relationship(:imageable) or_parts = relationship.resource_types.collect do |type| table_alias = join_manager.join_details_by_polymorphic_relationship(relationship, type)[:alias] @@ -2038,7 +2038,7 @@ class AuthorResource < JSONAPI::Resource relationship :author_detail, to: :one, foreign_key_on: :related filter :name, apply: lambda { |records, value, options| - table_alias = options[:join_manager].source_join_details[:alias] + table_alias = options.dig(:_relation_helper_options, :join_manager).source_join_details[:alias] t = Arel::Table.new(:people, as: table_alias) records.where(t[:name].matches("%#{value[0]}%")) } From a7447ac7d51dcc5c4d5ccc8a59bef5e4d3b744ea Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Wed, 30 Oct 2019 19:50:03 -0400 Subject: [PATCH 178/237] Bump jsonapi-resources to 0.10.1 --- lib/jsonapi/resources/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/jsonapi/resources/version.rb b/lib/jsonapi/resources/version.rb index e7555288e..24e9af367 100644 --- a/lib/jsonapi/resources/version.rb +++ b/lib/jsonapi/resources/version.rb @@ -1,5 +1,5 @@ module JSONAPI module Resources - VERSION = '0.10.0' + VERSION = '0.10.1' end end From c0cdf65f2297ca6cc5a188417eb56b925380ecf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Scott=20Gonz=C3=A1lez?= Date: Thu, 7 Nov 2019 08:12:23 -0500 Subject: [PATCH 179/237] Fix indentation --- lib/jsonapi/acts_as_resource_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/jsonapi/acts_as_resource_controller.rb b/lib/jsonapi/acts_as_resource_controller.rb index 032841608..a882a564a 100644 --- a/lib/jsonapi/acts_as_resource_controller.rb +++ b/lib/jsonapi/acts_as_resource_controller.rb @@ -189,7 +189,7 @@ def valid_accept_media_type? end end - def media_types_for(header) + def media_types_for(header) (request.headers[header] || '') .scan(MEDIA_TYPE_MATCHER) .to_a From 9a06090454fe885790694aaba7c9b2be2ef917c4 Mon Sep 17 00:00:00 2001 From: Marcel Eeken Date: Wed, 13 Nov 2019 16:47:11 +0100 Subject: [PATCH 180/237] Return a 404 response when no record is found The JSON API specification says: > A server MUST respond with `404 Not Found` when processing a request to fetch a single resource that does not exist At the moment JSON API Resources returns a 200, with an empty data object. --- lib/jsonapi/processor.rb | 1 + test/integration/requests/request_test.rb | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/lib/jsonapi/processor.rb b/lib/jsonapi/processor.rb index 6e58a6799..cad9f9b8d 100644 --- a/lib/jsonapi/processor.rb +++ b/lib/jsonapi/processor.rb @@ -101,6 +101,7 @@ def show include_directives, find_options) + fail JSONAPI::Exceptions::RecordNotFound.new(id) if resource_set.resource_klasses.empty? resource_set.populate!(serializer, context, find_options) return JSONAPI::ResourceSetOperationResult.new(:ok, resource_set, result_options) diff --git a/test/integration/requests/request_test.rb b/test/integration/requests/request_test.rb index 2cd84a0c9..e59cdd580 100644 --- a/test/integration/requests/request_test.rb +++ b/test/integration/requests/request_test.rb @@ -25,6 +25,11 @@ def test_large_get assert_cacheable_jsonapi_get '/api/v2/books?include=book_comments,book_comments.author' end + def test_get_not_found + get "/people/2000" + assert_jsonapi_response 404 + end + def test_post_sessions session_id = SecureRandom.uuid From a2d4b4dcd68af77bae7889e9f578fd4e47ab061b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Scott=20Gonz=C3=A1lez?= Date: Thu, 14 Nov 2019 17:59:34 -0500 Subject: [PATCH 181/237] Remove unnecessary setting of `missed_resource_ids` --- lib/jsonapi/resource_set.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/jsonapi/resource_set.rb b/lib/jsonapi/resource_set.rb index ef3c285ee..b1fb136bf 100644 --- a/lib/jsonapi/resource_set.rb +++ b/lib/jsonapi/resource_set.rb @@ -47,7 +47,6 @@ def populate!(serializer, context, find_options) ) ) else - missed_resource_ids[resource_klass] ||= {} missed_resource_ids[resource_klass] = @resource_klasses[resource_klass].keys end end From 95393cde30a0d2755427d5bb7286fa3805fd20db Mon Sep 17 00:00:00 2001 From: Benjamin Fleischer Date: Sun, 1 Dec 2019 19:02:42 -0600 Subject: [PATCH 182/237] Better Poro Resource Test --- test/fixtures/active_record.rb | 107 +++++++++++++++++++++++++++------ 1 file changed, 90 insertions(+), 17 deletions(-) diff --git a/test/fixtures/active_record.rb b/test/fixtures/active_record.rb index d785e8af1..bdb718bbf 100644 --- a/test/fixtures/active_record.rb +++ b/test/fixtures/active_record.rb @@ -1546,23 +1546,91 @@ class PoroResource < JSONAPI::BasicResource root_resource class << self + def find_records(filters, options) + fail NotImplementedError, <<~EOF + Should be something like + def find_records(filters, options) + breeds = [] + id_filter = filters[:id] + id_filter = [id_filter] unless id_filter.nil? || id_filter.is_a?(Array) + $breed_data.breeds.values.each do |breed| + breeds.push(breed) unless id_filter && !id_filter.include?(breed.id) + end + breeds + end + EOF + end + + def find_record_by_key(key, options = {}) + fail NotImplementedError, <<~EOF + Should be something like + def find_record_by_key(key, options = {}) + $breed_data.breeds[key.to_i] + end + EOF + end + + def find_records_by_keys(keys, options = {}) + fail NotImplementedError, <<~EOF + Should be something like + def find_records_by_keys(keys, options = {}) + breeds = [] + keys.each do |key| + breeds.push($breed_data.breeds[key.to_i]) + end + breeds + end + EOF + end + + # Finds Resources using the `filters`. Pagination and sort options are used when provided + # + # @param filters [Hash] the filters hash + # @option options [Hash] :context The context of the request, set in the controller + # @option options [Hash] :sort_criteria The `sort criteria` + # @option options [Hash] :include_directives The `include_directives` + # + # @return [Array] the Resource instances matching the filters, sorting and pagination rules. def find(filters, options = {}) - records = find_breeds(filters, options) + records = find_records(filters, options) resources_for(records, options[:context]) end # Records def find_fragments(filters, options = {}) fragments = {} - find_breeds(filters, options).each do |breed| - rid = JSONAPI::ResourceIdentity.new(BreedResource, breed.id) + find_records(filters, options).each do |record| + rid = JSONAPI::ResourceIdentity.new(resource_klass, record.id) fragments[rid] = JSONAPI::ResourceFragment.new(rid) end fragments end + def resource_klass + self + end + + # Counts Resources found using the `filters` + # + # @param filters [Hash] the filters hash + # @option options [Hash] :context The context of the request, set in the controller + # + # @return [Integer] the count + def count(filters, options = {}) + fail NotImplementedError, <<~EOF + Should be something like + def count(filters, options) + 0 + end + EOF + end + + # Returns the single Resource identified by `key` + # + # @param key the primary key of the resource to find + # @option options [Hash] :context The context of the request, set in the controller def find_by_key(key, options = {}) - record = find_breed_by_key(key, options) + record = find_record_by_key(key, options) resource_for(record, options[:context]) end @@ -1570,13 +1638,26 @@ def find_to_populate_by_keys(keys, options = {}) find_by_keys(keys, options) end + # Returns an array of Resources identified by the `keys` array + # + # @param keys [Array] Array of primary keys to find resources for + # @option options [Hash] :context The context of the request, set in the controller def find_by_keys(keys, options = {}) - records = find_breeds_by_keys(keys, options) + records = find_records_by_keys(keys, options) resources_for(records, options[:context]) end + end +end - # - def find_breeds(filters, options = {}) +class BreedResource < PoroResource + + attribute :name, format: :title + + # This is unneeded, just here for testing + routing_options param: :id + + class << self + def find_records(filters, options = {}) breeds = [] id_filter = filters[:id] id_filter = [id_filter] unless id_filter.nil? || id_filter.is_a?(Array) @@ -1586,11 +1667,11 @@ def find_breeds(filters, options = {}) breeds end - def find_breed_by_key(key, options = {}) + def find_record_by_key(key, options = {}) $breed_data.breeds[key.to_i] end - def find_breeds_by_keys(keys, options = {}) + def find_records_by_keys(keys, options = {}) breeds = [] keys.each do |key| breeds.push($breed_data.breeds[key.to_i]) @@ -1598,14 +1679,6 @@ def find_breeds_by_keys(keys, options = {}) breeds end end -end - -class BreedResource < PoroResource - - attribute :name, format: :title - - # This is unneeded, just here for testing - routing_options param: :id def _save super From 978f590f85fe9e65d5806f206b661fb35332c1bd Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Wed, 11 Dec 2019 07:57:54 -0500 Subject: [PATCH 183/237] Bump jsonapi-resources to 0.10.2 --- lib/jsonapi/resources/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/jsonapi/resources/version.rb b/lib/jsonapi/resources/version.rb index 24e9af367..49297ca88 100644 --- a/lib/jsonapi/resources/version.rb +++ b/lib/jsonapi/resources/version.rb @@ -1,5 +1,5 @@ module JSONAPI module Resources - VERSION = '0.10.1' + VERSION = '0.10.2' end end From 25911bc1987130fc507e6ee11daa586ff5f215b0 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Sun, 15 Mar 2020 12:52:21 -0400 Subject: [PATCH 184/237] Fix issue with primary paginator being used for included resources Fixes #1312 --- lib/jsonapi/active_relation_resource.rb | 4 ++-- lib/jsonapi/processor.rb | 8 +++---- test/controllers/controller_test.rb | 31 +++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 6 deletions(-) diff --git a/lib/jsonapi/active_relation_resource.rb b/lib/jsonapi/active_relation_resource.rb index d9272339d..e2611613f 100644 --- a/lib/jsonapi/active_relation_resource.rb +++ b/lib/jsonapi/active_relation_resource.rb @@ -395,7 +395,7 @@ def find_related_monomorphic_fragments(source_rids, relationship, options, conne sort_criteria: sort_criteria, filters: filters) - paginator = options[:paginator] if source_rids.count == 1 + paginator = options[:paginator] records = apply_request_settings_to_records(records: records_for_source_to_related(options), resource_klass: resource_klass, @@ -525,7 +525,7 @@ def find_related_polymorphic_fragments(source_rids, relationship, options, conne relationships: linkage_relationships, filters: filters) - paginator = options[:paginator] if source_rids.count == 1 + paginator = options[:paginator] # Note: We will sort by the source table. Without using unions we can't sort on a polymorphic relationship # in any manner that makes sense diff --git a/lib/jsonapi/processor.rb b/lib/jsonapi/processor.rb index cad9f9b8d..de3459c82 100644 --- a/lib/jsonapi/processor.rb +++ b/lib/jsonapi/processor.rb @@ -392,7 +392,7 @@ def find_related_resource_id_tree(resource_klass, source_id, relationship_name, primary_resource_id_tree = PrimaryResourceIdTree.new primary_resource_id_tree.add_resource_fragments(fragments, include_related) - load_included(resource_klass, primary_resource_id_tree, include_related, options.except(:filters, :sort_criteria)) + load_included(resource_klass, primary_resource_id_tree, include_related, options) primary_resource_id_tree end @@ -406,7 +406,7 @@ def find_resource_id_tree(resource_klass, find_options, include_related) primary_resource_id_tree = PrimaryResourceIdTree.new primary_resource_id_tree.add_resource_fragments(fragments, include_related) - load_included(resource_klass, primary_resource_id_tree, include_related, options.except(:filters, :sort_criteria)) + load_included(resource_klass, primary_resource_id_tree, include_related, options) primary_resource_id_tree end @@ -422,7 +422,7 @@ def find_resource_id_tree_from_resource_relationship(resource, relationship_name primary_resource_id_tree = PrimaryResourceIdTree.new primary_resource_id_tree.add_resource_fragments(fragments, include_related) - load_included(resource_klass, primary_resource_id_tree, include_related, options.except(:filters, :sort_criteria)) + load_included(resource_klass, primary_resource_id_tree, include_related, options) primary_resource_id_tree end @@ -434,7 +434,7 @@ def load_included(resource_klass, source_resource_id_tree, include_related, opti relationship = resource_klass._relationship(key) relationship_name = relationship.name.to_sym - find_related_resource_options = options.dup + find_related_resource_options = options.except(:filters, :sort_criteria, :paginator) find_related_resource_options[:sort_criteria] = relationship.resource_klass.default_sort find_related_resource_options[:cache] = resource_klass.caching? diff --git a/test/controllers/controller_test.rb b/test/controllers/controller_test.rb index adcac5a94..e815a0e24 100644 --- a/test/controllers/controller_test.rb +++ b/test/controllers/controller_test.rb @@ -4752,3 +4752,34 @@ def test_fetch_robots_with_sort_by_version assert_equal 'version is not a valid sort criteria for robots', json_response['errors'].first['detail'] end end + +class Api::V6::AuthorDetailsControllerTest < ActionController::TestCase + def after_teardown + Api::V6::AuthorDetailResource.paginator :none # TODO: ??? + end + + def test_that_the_last_two_author_details_belong_to_an_author + Api::V6::AuthorDetailResource.paginator :offset + + total_count = AuthorDetail.count + assert_operator total_count, :>=, 2 + + assert_cacheable_get :index, params: {sort: :id, include: :author, page: {limit: 10, offset: total_count - 2}} + assert_response :success + assert_equal 2, json_response['data'].size + assert_not_nil json_response['data'][0]['relationships']['author']['data'] + assert_not_nil json_response['data'][1]['relationships']['author']['data'] + end + + def test_that_the_last_author_detail_includes_its_author_even_if_returned_as_the_single_entry_on_a_page_with_nonzero_offset + Api::V6::AuthorDetailResource.paginator :offset + + total_count = AuthorDetail.count + assert_operator total_count, :>=, 2 + + assert_cacheable_get :index, params: {sort: :id, include: :author, page: {limit: 10, offset: total_count - 1}} + assert_response :success + assert_equal 1, json_response['data'].size + assert_not_nil json_response['data'][0]['relationships']['author']['data'] + end +end From c73ab0b8f59ee68801d8827d67d4c71b5cf98e0f Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Sun, 5 Apr 2020 16:36:40 -0400 Subject: [PATCH 185/237] Set default processor klass by name, deprecate default_processor_klass --- lib/jsonapi/configuration.rb | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/lib/jsonapi/configuration.rb b/lib/jsonapi/configuration.rb index a91c453da..f7e899cfa 100644 --- a/lib/jsonapi/configuration.rb +++ b/lib/jsonapi/configuration.rb @@ -18,7 +18,7 @@ class Configuration :default_paginator, :default_page_size, :maximum_page_size, - :default_processor_klass, + :default_processor_klass_name, :use_text_errors, :top_level_links_include_pagination, :top_level_meta_include_record_count, @@ -110,7 +110,7 @@ def initialize # The default Operation Processor to use if one is not defined specifically # for a Resource. - self.default_processor_klass = JSONAPI::Processor + self.default_processor_klass_name = 'JSONAPI::Processor' # Allows transactions for creating and updating records # Set this to false if your backend does not support transactions (e.g. Mongodb) @@ -225,9 +225,19 @@ def exception_class_whitelisted?(e) end def default_processor_klass=(default_processor_klass) + ActiveSupport::Deprecation.warn('`default_processor_klass` has been replaced by `default_processor_klass_name`.') @default_processor_klass = default_processor_klass end + def default_processor_klass + @default_processor_klass ||= default_processor_klass_name.safe_constantize + end + + def default_processor_klass_name=(default_processor_klass_name) + @default_processor_klass = nil + @default_processor_klass_name = default_processor_klass_name + end + def allow_include=(allow_include) ActiveSupport::Deprecation.warn('`allow_include` has been replaced by `default_allow_include_to_one` and `default_allow_include_to_many` options.') @default_allow_include_to_one = allow_include From 4314ee1544c2296bd16dbd1343acde1bf02ef069 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Sun, 5 Apr 2020 17:15:27 -0400 Subject: [PATCH 186/237] Relax bundler version requirement to support later than 1.17 --- jsonapi-resources.gemspec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jsonapi-resources.gemspec b/jsonapi-resources.gemspec index 030d600e0..8796b9637 100644 --- a/jsonapi-resources.gemspec +++ b/jsonapi-resources.gemspec @@ -19,7 +19,7 @@ Gem::Specification.new do |spec| spec.require_paths = ['lib'] spec.required_ruby_version = '>= 2.3' - spec.add_development_dependency 'bundler', '~> 1.17.3' + spec.add_development_dependency 'bundler', '>= 1.17' spec.add_development_dependency 'rake' spec.add_development_dependency 'minitest', '~> 5.10', '!= 5.10.2' spec.add_development_dependency 'minitest-spec-rails' From ef16a2afdf305752cca8a2fe65f61d3ad4bc783c Mon Sep 17 00:00:00 2001 From: Tommy Russoniello Date: Sat, 19 Sep 2020 16:40:40 -0400 Subject: [PATCH 187/237] Update license copyright years --- LICENSE.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE.txt b/LICENSE.txt index 3dec1f286..536788697 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,4 +1,4 @@ -Copyright (c) 2014-2017 Cerebris Corporation +Copyright (c) 2014-2020 Cerebris Corporation MIT License From ea8a3f9b226cef0be809274e422268fe10303480 Mon Sep 17 00:00:00 2001 From: Victor Antoniazzi Date: Mon, 21 Sep 2020 23:20:38 -0300 Subject: [PATCH 188/237] Use inclusive terminology # Summarize changes in around 50 characters or less --- lib/jsonapi/acts_as_resource_controller.rb | 4 +- lib/jsonapi/configuration.rb | 34 +++++++----- test/controllers/controller_test.rb | 62 +++++++++++++++++----- test/fixtures/active_record.rb | 2 +- 4 files changed, 74 insertions(+), 28 deletions(-) diff --git a/lib/jsonapi/acts_as_resource_controller.rb b/lib/jsonapi/acts_as_resource_controller.rb index a882a564a..310c63d13 100644 --- a/lib/jsonapi/acts_as_resource_controller.rb +++ b/lib/jsonapi/acts_as_resource_controller.rb @@ -273,7 +273,7 @@ def handle_exceptions(e) when ActionController::ParameterMissing errors = JSONAPI::Exceptions::ParameterMissing.new(e.param).errors else - if JSONAPI.configuration.exception_class_whitelisted?(e) + if JSONAPI.configuration.exception_class_allowed?(e) raise e else if self.class.server_error_callbacks @@ -308,7 +308,7 @@ def safe_run_callback(callback, error) # caught that is not a JSONAPI::Exceptions::Error # Useful for additional logging or notification configuration that # would normally depend on rails catching and rendering an exception. - # Ignores whitelist exceptions from config + # Ignores allowlist exceptions from config module ClassMethods diff --git a/lib/jsonapi/configuration.rb b/lib/jsonapi/configuration.rb index f7e899cfa..3ab273d24 100644 --- a/lib/jsonapi/configuration.rb +++ b/lib/jsonapi/configuration.rb @@ -28,8 +28,8 @@ class Configuration :allow_transactions, :include_backtraces_in_errors, :include_application_backtraces_in_errors, - :exception_class_whitelist, - :whitelist_all_exceptions, + :exception_class_allowlist, + :allow_all_exceptions, :always_include_to_one_linkage_data, :always_include_to_many_linkage_data, :cache_formatters, @@ -95,12 +95,12 @@ def initialize # raise a Pundit::NotAuthorizedError at some point during operations # processing. If you want to use Rails' `rescue_from` macro to # catch this error and render a 403 status code, you should add - # the `Pundit::NotAuthorizedError` to the `exception_class_whitelist`. - self.exception_class_whitelist = [] + # the `Pundit::NotAuthorizedError` to the `exception_class_allowlist`. + self.exception_class_allowlist = [] - # If enabled, will override configuration option `exception_class_whitelist` - # and whitelist all exceptions. - self.whitelist_all_exceptions = false + # If enabled, will override configuration option `exception_class_allowlist` + # and allow all exceptions. + self.allow_all_exceptions = false # Resource Linkage # Controls the serialization of resource linkage for non compound documents @@ -219,9 +219,9 @@ def route_formatter return formatter end - def exception_class_whitelisted?(e) - @whitelist_all_exceptions || - @exception_class_whitelist.flatten.any? { |k| e.class.ancestors.map(&:to_s).include?(k.to_s) } + def exception_class_allowed?(e) + @allow_all_exceptions || + @exception_class_allowlist.flatten.any? { |k| e.class.ancestors.map(&:to_s).include?(k.to_s) } end def default_processor_klass=(default_processor_klass) @@ -244,6 +244,16 @@ def allow_include=(allow_include) @default_allow_include_to_many = allow_include end + def whitelist_all_exceptions=(allow_all_exceptions) + ActiveSupport::Deprecation.warn('`whitelist_all_exceptions` has been replaced by `allow_all_exceptions`') + @allow_all_exceptions = allow_all_exceptions + end + + def exception_class_whitelist=(exception_class_allowlist) + ActiveSupport::Deprecation.warn('`exception_class_whitelist` has been replaced by `exception_class_allowlist`') + @exception_class_allowlist = exception_class_allowlist + end + attr_writer :allow_sort, :allow_filter, :default_allow_include_to_one, :default_allow_include_to_many attr_writer :default_paginator @@ -270,9 +280,9 @@ def allow_include=(allow_include) attr_writer :include_application_backtraces_in_errors - attr_writer :exception_class_whitelist + attr_writer :exception_class_allowlist - attr_writer :whitelist_all_exceptions + attr_writer :allow_all_exceptions attr_writer :always_include_to_one_linkage_data diff --git a/test/controllers/controller_test.rb b/test/controllers/controller_test.rb index e815a0e24..3ecab8e83 100644 --- a/test/controllers/controller_test.rb +++ b/test/controllers/controller_test.rb @@ -81,26 +81,40 @@ def test_accept_header_not_jsonapi assert_equal "All requests must use the '#{JSONAPI::MEDIA_TYPE}' Accept without media type parameters. This request specified '#{@request.headers['Accept']}'.", json_response['errors'][0]['detail'] end - def test_exception_class_whitelist - original_whitelist = JSONAPI.configuration.exception_class_whitelist.dup + def test_exception_class_allowlist + original_allowlist = JSONAPI.configuration.exception_class_allowlist.dup $PostProcessorRaisesErrors = true # test that the operations dispatcher rescues the error when it - # has not been added to the exception_class_whitelist + # has not been added to the exception_class_allowlist assert_cacheable_get :index assert_response 500 # test that the operations dispatcher does not rescue the error when it - # has been added to the exception_class_whitelist - JSONAPI.configuration.exception_class_whitelist << PostsController::SpecialError + # has been added to the exception_class_allowlist + JSONAPI.configuration.exception_class_allowlist << PostsController::SpecialError assert_cacheable_get :index assert_response 403 ensure $PostProcessorRaisesErrors = false - JSONAPI.configuration.exception_class_whitelist = original_whitelist + JSONAPI.configuration.exception_class_allowlist = original_allowlist + end + + def test_allow_all_exceptions + original_config = JSONAPI.configuration.allow_all_exceptions + $PostProcessorRaisesErrors = true + assert_cacheable_get :index + assert_response 500 + + JSONAPI.configuration.allow_all_exceptions = true + assert_cacheable_get :index + assert_response 403 + ensure + $PostProcessorRaisesErrors = false + JSONAPI.configuration.allow_all_exceptions = original_config end def test_whitelist_all_exceptions - original_config = JSONAPI.configuration.whitelist_all_exceptions + original_config = JSONAPI.configuration.allow_all_exceptions $PostProcessorRaisesErrors = true assert_cacheable_get :index assert_response 500 @@ -114,18 +128,18 @@ def test_whitelist_all_exceptions end def test_exception_added_to_request_env - original_config = JSONAPI.configuration.whitelist_all_exceptions + original_config = JSONAPI.configuration.allow_all_exceptions $PostProcessorRaisesErrors = true refute @request.env['action_dispatch.exception'] assert_cacheable_get :index assert @request.env['action_dispatch.exception'] - JSONAPI.configuration.whitelist_all_exceptions = true + JSONAPI.configuration.allow_all_exceptions = true assert_cacheable_get :index assert @request.env['action_dispatch.exception'] ensure $PostProcessorRaisesErrors = false - JSONAPI.configuration.whitelist_all_exceptions = original_config + JSONAPI.configuration.allow_all_exceptions = original_config end def test_exception_includes_backtrace_when_enabled @@ -168,7 +182,7 @@ def test_exception_includes_application_backtrace_when_enabled def test_on_server_error_block_callback_with_exception original_config = JSONAPI.configuration.dup - JSONAPI.configuration.exception_class_whitelist = [] + JSONAPI.configuration.exception_class_allowlist = [] $PostProcessorRaisesErrors = true @controller.class.instance_variable_set(:@callback_message, "none") @@ -189,7 +203,7 @@ def test_on_server_error_block_callback_with_exception def test_on_server_error_method_callback_with_exception original_config = JSONAPI.configuration.dup - JSONAPI.configuration.exception_class_whitelist = [] + JSONAPI.configuration.exception_class_allowlist = [] $PostProcessorRaisesErrors = true #ignores methods that don't exist @@ -208,7 +222,7 @@ def test_on_server_error_method_callback_with_exception def test_on_server_error_method_callback_with_exception_on_serialize original_config = JSONAPI.configuration.dup - JSONAPI.configuration.exception_class_whitelist = [] + JSONAPI.configuration.exception_class_allowlist = [] $PostSerializerRaisesErrors = true #ignores methods that don't exist @@ -4000,6 +4014,16 @@ def test_uncaught_error_in_controller_translated_to_internal_server_error assert_match /Internal Server Error/, json_response['errors'][0]['detail'] end + def test_not_allowed_error_in_controller + original_config = JSONAPI.configuration.dup + JSONAPI.configuration.exception_class_allowlist = [] + get :show, params: {id: '1'} + assert_response 500 + assert_match /Internal Server Error/, json_response['errors'][0]['detail'] + ensure + JSONAPI.configuration = original_config + end + def test_not_whitelisted_error_in_controller original_config = JSONAPI.configuration.dup JSONAPI.configuration.exception_class_whitelist = [] @@ -4010,6 +4034,18 @@ def test_not_whitelisted_error_in_controller JSONAPI.configuration = original_config end + def test_allowed_error_in_controller + original_config = JSONAPI.configuration.dup + $PostProcessorRaisesErrors = true + JSONAPI.configuration.exception_class_allowlist = [PostsController::SubSpecialError] + assert_raises PostsController::SubSpecialError do + assert_cacheable_get :show, params: {id: '1'} + end + ensure + JSONAPI.configuration = original_config + $PostProcessorRaisesErrors = false + end + def test_whitelisted_error_in_controller original_config = JSONAPI.configuration.dup $PostProcessorRaisesErrors = true diff --git a/test/fixtures/active_record.rb b/test/fixtures/active_record.rb index bdb718bbf..227fff664 100644 --- a/test/fixtures/active_record.rb +++ b/test/fixtures/active_record.rb @@ -917,7 +917,7 @@ class SpecialError < StandardError; end class SubSpecialError < PostsController::SpecialError; end class SerializeError < StandardError; end - # This is used to test that classes that are whitelisted are reraised by + # This is used to test that classes that are allowed are reraised by # the operations dispatcher. rescue_from PostsController::SpecialError do head :forbidden From b8a1f40901a83e77a3568addd847785de76bf0e7 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Wed, 23 Sep 2020 08:58:06 -0400 Subject: [PATCH 189/237] Add short term workaround to JoinManager test to account for different sql statement order --- .../active_relation_resource_finder/join_manager_test.rb | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/unit/active_relation_resource_finder/join_manager_test.rb b/test/unit/active_relation_resource_finder/join_manager_test.rb index 9fb13a82b..e91e35dbb 100644 --- a/test/unit/active_relation_resource_finder/join_manager_test.rb +++ b/test/unit/active_relation_resource_finder/join_manager_test.rb @@ -263,7 +263,11 @@ def test_polymorphic_join_belongs_to_filter_on_resource records = PictureResource.records({}) records = join_manager.join(records, {}) - assert_equal 'SELECT "pictures".* FROM "pictures" LEFT OUTER JOIN "documents" ON "documents"."id" = "pictures"."imageable_id" AND "pictures"."imageable_type" = \'Document\' LEFT OUTER JOIN "products" ON "products"."id" = "pictures"."imageable_id" AND "pictures"."imageable_type" = \'Product\' LEFT OUTER JOIN "file_properties" ON "file_properties"."fileable_id" = "pictures"."id" AND "file_properties"."fileable_type" = \'Picture\'', records.to_sql + #TODO: Fix this with a better test + sql_v1 = 'SELECT "pictures".* FROM "pictures" LEFT OUTER JOIN "documents" ON "documents"."id" = "pictures"."imageable_id" AND "pictures"."imageable_type" = \'Document\' LEFT OUTER JOIN "products" ON "products"."id" = "pictures"."imageable_id" AND "pictures"."imageable_type" = \'Product\' LEFT OUTER JOIN "file_properties" ON "file_properties"."fileable_id" = "pictures"."id" AND "file_properties"."fileable_type" = \'Picture\'' + sql_v2 = 'SELECT "pictures".* FROM "pictures" LEFT OUTER JOIN "documents" ON "documents"."id" = "pictures"."imageable_id" AND "pictures"."imageable_type" = \'Document\' LEFT OUTER JOIN "products" ON "products"."id" = "pictures"."imageable_id" AND "pictures"."imageable_type" = \'Product\' LEFT OUTER JOIN "file_properties" ON "file_properties"."fileable_type" = \'Picture\' AND "file_properties"."fileable_id" = "pictures"."id"' + assert records.to_sql == sql_v1 || records.to_sql == sql_v2, 'did not generate an expected sql statement' + assert_hash_equals({alias: 'pictures', join_type: :root}, join_manager.source_join_details) assert_hash_equals({alias: 'products', join_type: :left}, join_manager.join_details_by_polymorphic_relationship(PictureResource._relationship(:imageable), 'products')) assert_hash_equals({alias: 'documents', join_type: :left}, join_manager.join_details_by_polymorphic_relationship(PictureResource._relationship(:imageable), 'documents')) From 344fcb0df369a71619405bcbe88e582b4f7a678b Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Wed, 5 Aug 2020 11:19:45 -0400 Subject: [PATCH 190/237] Rework RequestParser to Request, provide access as jsonapi_request for operations callbacks --- lib/jsonapi-resources.rb | 2 +- lib/jsonapi/acts_as_resource_controller.rb | 88 +++-- lib/jsonapi/{request_parser.rb => request.rb} | 319 +++++++++--------- .../jsonapi_request/jsonapi_request_test.rb | 40 +-- 4 files changed, 219 insertions(+), 230 deletions(-) rename lib/jsonapi/{request_parser.rb => request.rb} (76%) diff --git a/lib/jsonapi-resources.rb b/lib/jsonapi-resources.rb index e73e9723a..4ac0532bc 100644 --- a/lib/jsonapi-resources.rb +++ b/lib/jsonapi-resources.rb @@ -25,7 +25,7 @@ require 'jsonapi/exceptions' require 'jsonapi/error' require 'jsonapi/error_codes' -require 'jsonapi/request_parser' +require 'jsonapi/request' require 'jsonapi/processor' require 'jsonapi/relationship' require 'jsonapi/include_directives' diff --git a/lib/jsonapi/acts_as_resource_controller.rb b/lib/jsonapi/acts_as_resource_controller.rb index 310c63d13..90fd296b8 100644 --- a/lib/jsonapi/acts_as_resource_controller.rb +++ b/lib/jsonapi/acts_as_resource_controller.rb @@ -13,7 +13,7 @@ def self.included(base) :transaction end - attr_reader :response_document + attr_reader :response_document, :jsonapi_request def index process_request @@ -76,48 +76,54 @@ def get_related_resources end def process_request - @response_document = create_response_document - - unless verify_content_type_header && verify_accept_header - render_response_document - return + begin + setup_response_document + verify_content_type_header + verify_accept_header + parse_request + execute_request + rescue => e + handle_exceptions(e) end + render_response_document + end - request_parser = JSONAPI::RequestParser.new( - params, - context: context, - key_formatter: key_formatter, - server_error_callbacks: (self.class.server_error_callbacks || [])) - - transactional = request_parser.transactional? + def setup_response_document + @response_document = create_response_document + end - begin - process_operations(transactional) do - run_callbacks :process_operations do - request_parser.each(response_document) do |op| - op.options[:serializer] = resource_serializer_klass.new( - op.resource_klass, - include_directives: op.options[:include_directives], - fields: op.options[:fields], - base_url: base_url, - key_formatter: key_formatter, - route_formatter: route_formatter, - serialization_options: serialization_options, - controller: self - ) - op.options[:cache_serializer_output] = !JSONAPI.configuration.resource_cache.nil? - - process_operation(op) - end - end - if response_document.has_errors? - raise ActiveRecord::Rollback + def parse_request + @jsonapi_request = JSONAPI::Request.new( + params, + context: context, + key_formatter: key_formatter, + server_error_callbacks: (self.class.server_error_callbacks || [])) + fail JSONAPI::Exceptions::Errors.new(@jsonapi_request.errors) if @jsonapi_request.errors.any? + end + + def execute_request + process_operations(jsonapi_request.transactional?) do + run_callbacks :process_operations do + jsonapi_request.operations.each do |op| + op.options[:serializer] = resource_serializer_klass.new( + op.resource_klass, + include_directives: op.options[:include_directives], + fields: op.options[:fields], + base_url: base_url, + key_formatter: key_formatter, + route_formatter: route_formatter, + serialization_options: serialization_options, + controller: self + ) + op.options[:cache_serializer_output] = !JSONAPI.configuration.resource_cache.nil? + + process_operation(op) end end - rescue => e - handle_exceptions(e) + if response_document.has_errors? + raise ActiveRecord::Rollback + end end - render_response_document end def process_operations(transactional) @@ -165,20 +171,12 @@ def verify_content_type_header fail JSONAPI::Exceptions::UnsupportedMediaTypeError.new(request.content_type) end end - true - rescue => e - handle_exceptions(e) - false end def verify_accept_header unless valid_accept_media_type? fail JSONAPI::Exceptions::NotAcceptableError.new(request.accept) end - true - rescue => e - handle_exceptions(e) - false end def valid_accept_media_type? diff --git a/lib/jsonapi/request_parser.rb b/lib/jsonapi/request.rb similarity index 76% rename from lib/jsonapi/request_parser.rb rename to lib/jsonapi/request.rb index b8a8a8de9..62b3c985e 100644 --- a/lib/jsonapi/request_parser.rb +++ b/lib/jsonapi/request.rb @@ -1,8 +1,8 @@ module JSONAPI - class RequestParser + class Request attr_accessor :fields, :include, :filters, :sort_criteria, :errors, :controller_module_path, :context, :paginator, :source_klass, :source_id, - :include_directives, :params, :warnings, :server_error_callbacks + :include_directives, :params, :warnings, :server_error_callbacks, :operations def initialize(params = nil, options = {}) @params = params @@ -18,33 +18,25 @@ def initialize(params = nil, options = {}) @errors = [] @warnings = [] @server_error_callbacks = options.fetch(:server_error_callbacks, []) + @operations = [] + + setup_operations(params) end def error_object_overrides {} end - def each(_response_document) - operation = setup_base_op(params) - if @errors.any? - fail JSONAPI::Exceptions::Errors.new(@errors) - else - yield operation - end - rescue ActionController::ParameterMissing => e - fail JSONAPI::Exceptions::ParameterMissing.new(e.param, error_object_overrides) - end - def transactional? case params[:action] - when 'index', 'show_related_resource', 'index_related_resources', 'show', 'show_relationship' - return false - else - return true + when 'index', 'show_related_resource', 'index_related_resources', 'show', 'show_relationship' + false + else + true end end - def setup_base_op(params) + def setup_operations(params) return if params.nil? resource_klass = Resource.resource_klass_for(params[:controller]) if params[:controller] @@ -68,15 +60,15 @@ def setup_index_action(params, resource_klass) sort_criteria = parse_sort_criteria(resource_klass, params[:sort]) paginator = parse_pagination(resource_klass, params[:page]) - JSONAPI::Operation.new( - :find, - resource_klass, - context: context, - filters: filters, - include_directives: include_directives, - sort_criteria: sort_criteria, - paginator: paginator, - fields: fields + @operations << JSONAPI::Operation.new( + :find, + resource_klass, + context: context, + filters: filters, + include_directives: include_directives, + sort_criteria: sort_criteria, + paginator: paginator, + fields: fields ) end @@ -90,15 +82,15 @@ def setup_show_related_resource_action(params, resource_klass) relationship_type = params[:relationship].to_sym - JSONAPI::Operation.new( - :show_related_resource, - resource_klass, - context: @context, - relationship_type: relationship_type, - source_klass: source_klass, - source_id: source_id, - fields: fields, - include_directives: include_directives + @operations << JSONAPI::Operation.new( + :show_related_resource, + resource_klass, + context: @context, + relationship_type: relationship_type, + source_klass: source_klass, + source_id: source_id, + fields: fields, + include_directives: include_directives ) end @@ -114,18 +106,18 @@ def setup_index_related_resources_action(params, resource_klass) paginator = parse_pagination(resource_klass, params[:page]) relationship_type = params[:relationship] - JSONAPI::Operation.new( - :show_related_resources, - resource_klass, - context: @context, - relationship_type: relationship_type, - source_klass: source_klass, - source_id: source_id, - filters: filters, - sort_criteria: sort_criteria, - paginator: paginator, - fields: fields, - include_directives: include_directives + @operations << JSONAPI::Operation.new( + :show_related_resources, + resource_klass, + context: @context, + relationship_type: relationship_type, + source_klass: source_klass, + source_id: source_id, + filters: filters, + sort_criteria: sort_criteria, + paginator: paginator, + fields: fields, + include_directives: include_directives ) end @@ -135,14 +127,14 @@ def setup_show_action(params, resource_klass) include_directives = parse_include_directives(resource_klass, params[:include]) id = params[:id] - JSONAPI::Operation.new( - :show, - resource_klass, - context: @context, - id: id, - include_directives: include_directives, - fields: fields, - allowed_resources: params[:allowed_resources] + @operations << JSONAPI::Operation.new( + :show, + resource_klass, + context: @context, + id: id, + include_directives: include_directives, + fields: fields, + allowed_resources: params[:allowed_resources] ) end @@ -155,17 +147,17 @@ def setup_show_relationship_action(params, resource_klass) sort_criteria = parse_sort_criteria(resource_klass, params[:sort]) paginator = parse_pagination(resource_klass, params[:page]) - JSONAPI::Operation.new( - :show_relationship, - resource_klass, - context: @context, - relationship_type: relationship_type, - parent_key: resource_klass.verify_key(parent_key), - filters: filters, - sort_criteria: sort_criteria, - paginator: paginator, - fields: fields, - include_directives: include_directives + @operations << JSONAPI::Operation.new( + :show_relationship, + resource_klass, + context: @context, + relationship_type: relationship_type, + parent_key: resource_klass.verify_key(parent_key), + filters: filters, + sort_criteria: sort_criteria, + paginator: paginator, + fields: fields, + include_directives: include_directives ) end @@ -183,14 +175,14 @@ def setup_create_action(params, resource_klass) data = parse_params(resource_klass, data, resource_klass.creatable_fields(@context)) - JSONAPI::Operation.new( - :create_resource, - resource_klass, - context: @context, - data: data, - fields: fields, - include_directives: include_directives, - warnings: @warnings + @operations << JSONAPI::Operation.new( + :create_resource, + resource_klass, + context: @context, + data: data, + fields: fields, + include_directives: include_directives, + warnings: @warnings ) end @@ -225,25 +217,25 @@ def setup_update_action(params, resource_klass) verify_type(data[:type], resource_klass) - JSONAPI::Operation.new( - :replace_fields, - resource_klass, - context: @context, - resource_id: resource_id, - data: parse_params(resource_klass, data, resource_klass.updatable_fields(@context)), - fields: fields, - include_directives: include_directives, - warnings: @warnings + @operations << JSONAPI::Operation.new( + :replace_fields, + resource_klass, + context: @context, + resource_id: resource_id, + data: parse_params(resource_klass, data, resource_klass.updatable_fields(@context)), + fields: fields, + include_directives: include_directives, + warnings: @warnings ) end def setup_destroy_action(params, resource_klass) resolve_singleton_id(params, resource_klass) - JSONAPI::Operation.new( - :remove_resource, - resource_klass, - context: @context, - resource_id: resource_klass.verify_key(params.require(:id), @context)) + @operations << JSONAPI::Operation.new( + :remove_resource, + resource_klass, + context: @context, + resource_id: resource_klass.verify_key(params.require(:id), @context)) end def setup_destroy_relationship_action(params, resource_klass) @@ -349,7 +341,6 @@ def check_include(resource_klass, include_parts) else fail JSONAPI::Exceptions::InvalidInclude.new(format_key(resource_klass._type), include_parts.first) end - true end def parse_include_directives(resource_klass, raw_include) @@ -458,19 +449,19 @@ def verify_type(type, resource_klass) def parse_to_one_links_object(raw) if raw.nil? return { - type: nil, - id: nil + type: nil, + id: nil } end if !(raw.is_a?(Hash) || raw.is_a?(ActionController::Parameters)) || - raw.keys.length != 2 || !(raw.key?('type') && raw.key?('id')) + raw.keys.length != 2 || !(raw.key?('type') && raw.key?('id')) fail JSONAPI::Exceptions::InvalidLinksObject.new(error_object_overrides) end { - type: unformat_key(raw['type']).to_s, - id: raw['id'] + type: unformat_key(raw['type']).to_s, + id: raw['id'] } end @@ -499,33 +490,33 @@ def parse_params(resource_klass, params, allowed_fields) params.each do |key, value| case key.to_s - when 'relationships' - value.each do |link_key, link_value| - param = unformat_key(link_key) - relationship = resource_klass._relationship(param) - - if relationship.is_a?(JSONAPI::Relationship::ToOne) - checked_to_one_relationships[param] = parse_to_one_relationship(resource_klass, link_value, relationship) - elsif relationship.is_a?(JSONAPI::Relationship::ToMany) - parse_to_many_relationship(resource_klass, link_value, relationship) do |result_val| - checked_to_many_relationships[param] = result_val - end + when 'relationships' + value.each do |link_key, link_value| + param = unformat_key(link_key) + relationship = resource_klass._relationship(param) + + if relationship.is_a?(JSONAPI::Relationship::ToOne) + checked_to_one_relationships[param] = parse_to_one_relationship(resource_klass, link_value, relationship) + elsif relationship.is_a?(JSONAPI::Relationship::ToMany) + parse_to_many_relationship(resource_klass, link_value, relationship) do |result_val| + checked_to_many_relationships[param] = result_val end end - when 'id' - checked_attributes['id'] = unformat_value(resource_klass, :id, value) - when 'attributes' - value.each do |key, value| - param = unformat_key(key) - checked_attributes[param] = unformat_value(resource_klass, param, value) - end + end + when 'id' + checked_attributes['id'] = unformat_value(resource_klass, :id, value) + when 'attributes' + value.each do |key, value| + param = unformat_key(key) + checked_attributes[param] = unformat_value(resource_klass, param, value) + end end end - return { - 'attributes' => checked_attributes, - 'to_one' => checked_to_one_relationships, - 'to_many' => checked_to_many_relationships + { + 'attributes' => checked_attributes, + 'to_one' => checked_to_one_relationships, + 'to_many' => checked_to_many_relationships }.deep_transform_keys { |key| unformat_key(key) } end @@ -584,7 +575,7 @@ def parse_to_many_relationship(resource_klass, link_value, relationship, &add_re end relationship_ids = relationship_resource_klass.verify_keys(keys, @context) - polymorphic_results << { type: type, ids: relationship_ids } + polymorphic_results << { type: type, ids: relationship_ids } end add_result.call polymorphic_results @@ -612,45 +603,45 @@ def verify_permitted_params(params, allowed_fields) params.each do |key, value| case key.to_s - when 'relationships' - value.keys.each do |links_key| - unless formatted_allowed_fields.include?(links_key.to_sym) - if JSONAPI.configuration.raise_if_parameters_not_allowed - fail JSONAPI::Exceptions::ParameterNotAllowed.new(links_key, error_object_overrides) - else - params_not_allowed.push(links_key) - value.delete links_key - end - end - end - when 'attributes' - value.each do |attr_key, _attr_value| - unless formatted_allowed_fields.include?(attr_key.to_sym) - if JSONAPI.configuration.raise_if_parameters_not_allowed - fail JSONAPI::Exceptions::ParameterNotAllowed.new(attr_key, error_object_overrides) - else - params_not_allowed.push(attr_key) - value.delete attr_key - end + when 'relationships' + value.keys.each do |links_key| + unless formatted_allowed_fields.include?(links_key.to_sym) + if JSONAPI.configuration.raise_if_parameters_not_allowed + fail JSONAPI::Exceptions::ParameterNotAllowed.new(links_key, error_object_overrides) + else + params_not_allowed.push(links_key) + value.delete links_key end end - when 'type' - when 'id' - unless formatted_allowed_fields.include?(:id) + end + when 'attributes' + value.each do |attr_key, _attr_value| + unless formatted_allowed_fields.include?(attr_key.to_sym) if JSONAPI.configuration.raise_if_parameters_not_allowed - fail JSONAPI::Exceptions::ParameterNotAllowed.new(:id, error_object_overrides) + fail JSONAPI::Exceptions::ParameterNotAllowed.new(attr_key, error_object_overrides) else - params_not_allowed.push(:id) - params.delete :id + params_not_allowed.push(attr_key) + value.delete attr_key end end - else + end + when 'type' + when 'id' + unless formatted_allowed_fields.include?(:id) if JSONAPI.configuration.raise_if_parameters_not_allowed - fail JSONAPI::Exceptions::ParameterNotAllowed.new(key, error_object_overrides) + fail JSONAPI::Exceptions::ParameterNotAllowed.new(:id, error_object_overrides) else - params_not_allowed.push(key) - params.delete key + params_not_allowed.push(:id) + params.delete :id end + end + else + if JSONAPI.configuration.raise_if_parameters_not_allowed + fail JSONAPI::Exceptions::ParameterNotAllowed.new(key, error_object_overrides) + else + params_not_allowed.push(key) + params.delete key + end end end @@ -666,22 +657,22 @@ def verify_permitted_params(params, allowed_fields) def parse_add_relationship_operation(resource_klass, verified_params, relationship, parent_key) if relationship.is_a?(JSONAPI::Relationship::ToMany) - return JSONAPI::Operation.new( - :create_to_many_relationships, - resource_klass, - context: @context, - resource_id: parent_key, - relationship_type: relationship.name, - data: verified_params[:to_many].values[0] + @operations << JSONAPI::Operation.new( + :create_to_many_relationships, + resource_klass, + context: @context, + resource_id: parent_key, + relationship_type: relationship.name, + data: verified_params[:to_many].values[0] ) end end def parse_update_relationship_operation(resource_klass, verified_params, relationship, parent_key) options = { - context: @context, - resource_id: parent_key, - relationship_type: relationship.name + context: @context, + resource_id: parent_key, + relationship_type: relationship.name } if relationship.is_a?(JSONAPI::Relationship::ToOne) @@ -702,23 +693,23 @@ def parse_update_relationship_operation(resource_klass, verified_params, relatio operation_type = :replace_to_many_relationships end - JSONAPI::Operation.new(operation_type, resource_klass, options) + @operations << JSONAPI::Operation.new(operation_type, resource_klass, options) end def parse_remove_relationship_operation(resource_klass, params, relationship, parent_key) operation_base_args = [resource_klass].push( - context: @context, - resource_id: parent_key, - relationship_type: relationship.name + context: @context, + resource_id: parent_key, + relationship_type: relationship.name ) if relationship.is_a?(JSONAPI::Relationship::ToMany) operation_args = operation_base_args.dup keys = params[:to_many].values[0] operation_args[1] = operation_args[1].merge(associated_keys: keys) - JSONAPI::Operation.new(:remove_to_many_relationships, *operation_args) + @operations << JSONAPI::Operation.new(:remove_to_many_relationships, *operation_args) else - JSONAPI::Operation.new(:remove_to_one_relationship, *operation_base_args) + @operations << JSONAPI::Operation.new(:remove_to_one_relationship, *operation_base_args) end end diff --git a/test/unit/jsonapi_request/jsonapi_request_test.rb b/test/unit/jsonapi_request/jsonapi_request_test.rb index 0fde94224..dc467a728 100644 --- a/test/unit/jsonapi_request/jsonapi_request_test.rb +++ b/test/unit/jsonapi_request/jsonapi_request_test.rb @@ -30,7 +30,7 @@ def test_parse_includes_underscored } ) - request = JSONAPI::RequestParser.new( + request = JSONAPI::Request.new( params, { context: nil, @@ -44,14 +44,14 @@ def test_parse_includes_underscored def test_check_include_allowed reset_includes - assert JSONAPI::RequestParser.new.check_include(ExpenseEntryResource, "isoCurrency".partition('.')) + JSONAPI::Request.new.check_include(ExpenseEntryResource, "isoCurrency".partition('.')) ensure reset_includes end def test_check_nested_include_allowed reset_includes - assert JSONAPI::RequestParser.new.check_include(ExpenseEntryResource, "employee.expenseEntries".partition('.')) + JSONAPI::Request.new.check_include(ExpenseEntryResource, "employee.expenseEntries".partition('.')) ensure reset_includes end @@ -60,7 +60,7 @@ def test_check_include_relationship_does_not_exist reset_includes assert_raises JSONAPI::Exceptions::InvalidInclude do - assert JSONAPI::RequestParser.new.check_include(ExpenseEntryResource, "foo".partition('.')) + assert JSONAPI::Request.new.check_include(ExpenseEntryResource, "foo".partition('.')) end ensure reset_includes @@ -70,7 +70,7 @@ def test_check_nested_include_relationship_does_not_exist_wrong_format reset_includes assert_raises JSONAPI::Exceptions::InvalidInclude do - assert JSONAPI::RequestParser.new.check_include(ExpenseEntryResource, "employee.expense-entries".partition('.')) + JSONAPI::Request.new.check_include(ExpenseEntryResource, "employee.expense-entries".partition('.')) end ensure reset_includes @@ -79,11 +79,11 @@ def test_check_nested_include_relationship_does_not_exist_wrong_format def test_check_include_has_one_not_allowed_default reset_includes - assert JSONAPI::RequestParser.new.check_include(ExpenseEntryResource, "isoCurrency".partition('.')) + JSONAPI::Request.new.check_include(ExpenseEntryResource, "isoCurrency".partition('.')) JSONAPI.configuration.default_allow_include_to_one = false assert_raises JSONAPI::Exceptions::InvalidInclude do - JSONAPI::RequestParser.new.check_include(ExpenseEntryResource, "isoCurrency".partition('.')) + JSONAPI::Request.new.check_include(ExpenseEntryResource, "isoCurrency".partition('.')) end ensure reset_includes @@ -92,11 +92,11 @@ def test_check_include_has_one_not_allowed_default def test_check_include_has_one_not_allowed_resource reset_includes - assert JSONAPI::RequestParser.new.check_include(ExpenseEntryResource, "isoCurrency".partition('.')) + JSONAPI::Request.new.check_include(ExpenseEntryResource, "isoCurrency".partition('.')) ExpenseEntryResource._relationship(:iso_currency).allow_include = false assert_raises JSONAPI::Exceptions::InvalidInclude do - JSONAPI::RequestParser.new.check_include(ExpenseEntryResource, "isoCurrency".partition('.')) + JSONAPI::Request.new.check_include(ExpenseEntryResource, "isoCurrency".partition('.')) end ensure reset_includes @@ -105,11 +105,11 @@ def test_check_include_has_one_not_allowed_resource def test_check_include_has_many_not_allowed_default reset_includes - assert JSONAPI::RequestParser.new.check_include(EmployeeResource, "expenseEntries".partition('.')) + JSONAPI::Request.new.check_include(EmployeeResource, "expenseEntries".partition('.')) JSONAPI.configuration.default_allow_include_to_many = false assert_raises JSONAPI::Exceptions::InvalidInclude do - JSONAPI::RequestParser.new.check_include(EmployeeResource, "expenseEntries".partition('.')) + JSONAPI::Request.new.check_include(EmployeeResource, "expenseEntries".partition('.')) end ensure reset_includes @@ -118,11 +118,11 @@ def test_check_include_has_many_not_allowed_default def test_check_include_has_many_not_allowed_resource reset_includes - assert JSONAPI::RequestParser.new.check_include(EmployeeResource, "expenseEntries".partition('.')) + JSONAPI::Request.new.check_include(EmployeeResource, "expenseEntries".partition('.')) EmployeeResource._relationship(:expense_entries).allow_include = false assert_raises JSONAPI::Exceptions::InvalidInclude do - JSONAPI::RequestParser.new.check_include(EmployeeResource, "expenseEntries".partition('.')) + JSONAPI::Request.new.check_include(EmployeeResource, "expenseEntries".partition('.')) end ensure reset_includes @@ -137,7 +137,7 @@ def test_parse_dasherized_with_dasherized_include } ) - request = JSONAPI::RequestParser.new( + request = JSONAPI::Request.new( params, { context: nil, @@ -158,7 +158,7 @@ def test_parse_dasherized_with_underscored_include } ) - request = JSONAPI::RequestParser.new( + request = JSONAPI::Request.new( params, { context: nil, @@ -180,7 +180,7 @@ def test_parse_fields_underscored } ) - request = JSONAPI::RequestParser.new( + request = JSONAPI::Request.new( params, { context: nil, @@ -203,7 +203,7 @@ def test_parse_dasherized_with_dasherized_fields } ) - request = JSONAPI::RequestParser.new( + request = JSONAPI::Request.new( params, { context: nil, @@ -226,7 +226,7 @@ def test_parse_dasherized_with_underscored_fields } ) - request = JSONAPI::RequestParser.new( + request = JSONAPI::Request.new( params, { context: nil, @@ -252,7 +252,7 @@ def test_parse_dasherized_with_underscored_resource } ) - request = JSONAPI::RequestParser.new( + request = JSONAPI::Request.new( params, { context: nil, @@ -321,7 +321,7 @@ def test_parse_sort_with_relationships private def setup_request - @request = JSONAPI::RequestParser.new + @request = JSONAPI::Request.new end def reset_includes From 2708f137bea16d99e8ae2264ae85ed476f2b3b59 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Wed, 23 Sep 2020 09:38:39 -0400 Subject: [PATCH 191/237] Cleanup style --- lib/jsonapi/response_document.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/jsonapi/response_document.rb b/lib/jsonapi/response_document.rb index f9912bbe7..cc2ebba54 100644 --- a/lib/jsonapi/response_document.rb +++ b/lib/jsonapi/response_document.rb @@ -17,7 +17,7 @@ def initialize(options = {}) end def has_errors? - @error_results.length > 0 || @global_errors.length > 0 + @error_results.length.positive? || @global_errors.length.positive? end def add_result(result, operation) From 3e9f9f3c6c574c2f17d7c361e00fdf3cc609ea52 Mon Sep 17 00:00:00 2001 From: Lars Kanis Date: Wed, 11 Nov 2020 11:51:08 +0100 Subject: [PATCH 192/237] Fix JSONAPI::PathSegment::Relationship#eql? Relationship objects are used as key in @join_details in JoinManager. This hash contains two types of objects: String and Relationship In case of hash collisions in @join_details the method Relationship#eql? is called. It currently fails when comparing with a string key. Fixes #1333 --- lib/jsonapi/path_segment.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/jsonapi/path_segment.rb b/lib/jsonapi/path_segment.rb index aa2d78050..e5cebd832 100644 --- a/lib/jsonapi/path_segment.rb +++ b/lib/jsonapi/path_segment.rb @@ -30,7 +30,7 @@ def initialize(relationship:, resource_klass: nil) end def eql?(other) - relationship == other.relationship && resource_klass == other.resource_klass + other.is_a?(self.class) && relationship == other.relationship && resource_klass == other.resource_klass end def hash @@ -59,7 +59,7 @@ def initialize(resource_klass:, field_name:) end def eql?(other) - field_name == other.field_name && resource_klass == other.resource_klass + other.is_a?(self.class) && field_name == other.field_name && resource_klass == other.resource_klass end def delegated_field_name From 64bcfd683914b12d13364a67efb52408a2a44174 Mon Sep 17 00:00:00 2001 From: Mat Trudel Date: Tue, 24 Nov 2020 13:09:37 -0500 Subject: [PATCH 193/237] Have inherit call through to superclass --- lib/jsonapi/basic_resource.rb | 1 + test/unit/resource/resource_test.rb | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/lib/jsonapi/basic_resource.rb b/lib/jsonapi/basic_resource.rb index ea8b19ea7..e7cdd93c4 100644 --- a/lib/jsonapi/basic_resource.rb +++ b/lib/jsonapi/basic_resource.rb @@ -419,6 +419,7 @@ def _replace_fields(field_data) class << self def inherited(subclass) + super subclass.abstract(false) subclass.immutable(false) subclass.caching(_caching) diff --git a/test/unit/resource/resource_test.rb b/test/unit/resource/resource_test.rb index 0bb8d1aa7..10f8b1e13 100644 --- a/test/unit/resource/resource_test.rb +++ b/test/unit/resource/resource_test.rb @@ -173,6 +173,10 @@ def test_derived_not_abstract refute PersonResource._abstract end + def test_inherited_calls_superclass + assert_equal(BaseResource.subclasses, [PersonResource, SpecialBaseResource]) + end + def test_nil_model_class # ToDo:Figure out why this test does not work on Rails 4.0 # :nocov: From d15372e773db4f66498056e9eedae419b8e361a0 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Thu, 7 Jan 2021 14:53:00 -0500 Subject: [PATCH 194/237] Update version to 0.11.0.beta1 --- lib/jsonapi/resources/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/jsonapi/resources/version.rb b/lib/jsonapi/resources/version.rb index 49297ca88..fb4178797 100644 --- a/lib/jsonapi/resources/version.rb +++ b/lib/jsonapi/resources/version.rb @@ -1,5 +1,5 @@ module JSONAPI module Resources - VERSION = '0.10.2' + VERSION = '0.11.0.beta1' end end From 23d274a438459772244f2e9d79c61dfcc819bcf1 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Thu, 7 Jan 2021 14:53:19 -0500 Subject: [PATCH 195/237] Update copyright dates --- LICENSE.txt | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/LICENSE.txt b/LICENSE.txt index 536788697..fd20f1555 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,4 +1,4 @@ -Copyright (c) 2014-2020 Cerebris Corporation +Copyright (c) 2014-2021 Cerebris Corporation MIT License diff --git a/README.md b/README.md index b1d42609e..a180df5e5 100644 --- a/README.md +++ b/README.md @@ -73,4 +73,4 @@ and **paste the content into the issue description or attach as a file**: ## License -Copyright 2014-2017 Cerebris Corporation. MIT License (see LICENSE for details). +Copyright 2014-2021 Cerebris Corporation. MIT License (see LICENSE for details). From ca1a41bb0e159828fb6d0b018c9779b97302f0a1 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Mon, 18 Jan 2021 18:03:59 -0500 Subject: [PATCH 196/237] Update testing matrix (#1350) --- .travis.yml | 32 ++++++++++++++------------------ 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/.travis.yml b/.travis.yml index 679787e7d..7131b30b2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,29 +3,25 @@ sudo: false services: - postgresql env: - - RAILS_VERSION=6.0.0 DATABASE_URL=postgres://postgres@localhost/jr_test - - RAILS_VERSION=6.0.0 - - RAILS_VERSION=5.2.3 DATABASE_URL=postgres://postgres@localhost/jr_test - - RAILS_VERSION=5.2.3 + - RAILS_VERSION=6.1.1 DATABASE_URL=postgres://postgres@localhost/jr_test + - RAILS_VERSION=6.1.1 + - RAILS_VERSION=6.0.3.4 DATABASE_URL=postgres://postgres@localhost/jr_test + - RAILS_VERSION=6.0.3.4 + - RAILS_VERSION=5.2.4.4 DATABASE_URL=postgres://postgres@localhost/jr_test + - RAILS_VERSION=5.2.4.4 - RAILS_VERSION=5.1.7 - RAILS_VERSION=5.0.7.2 - - RAILS_VERSION=4.2.11 rvm: - - 2.4.9 - - 2.5.7 - - 2.6.5 + - 2.6.6 + - 2.7.2 + - 3.0.0 matrix: - exclude: - - rvm: 2.6.5 - env: "RAILS_VERSION=4.2.11" - - rvm: 2.4.9 - env: "RAILS_VERSION=6.0.0" - - rvm: 2.4.9 - env: "RAILS_VERSION=6.0.0 DATABASE_URL=postgres://postgres@localhost/jr_test" - - rvm: 2.4.9 - env: "RAILS_VERSION=5.2.3 DATABASE_URL=postgres://postgres@localhost/jr_test" + allow_failures: + - env: "RAILS_VERSION=6.1.1" + - env: "RAILS_VERSION=6.1.1 DATABASE_URL=postgres://postgres@localhost/jr_test" + - rvm: 3.0.0 before_install: - - gem install bundler --version 1.17.3 + - gem install bundler --version 2.2.5 before_script: - sh -c "if [ '$DATABASE_URL' = 'postgres://postgres@localhost/jr_test' ]; then psql -c 'DROP DATABASE IF EXISTS jr_test;' -U postgres; fi" - sh -c "if [ '$DATABASE_URL' = 'postgres://postgres@localhost/jr_test' ]; then psql -c 'CREATE DATABASE jr_test;' -U postgres; fi" \ No newline at end of file From 1322b5939767687cbc8a0942584aa36afb2bca83 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Wed, 20 Jan 2021 13:30:16 -0500 Subject: [PATCH 197/237] Rails 6.1 (#1352) * Rails v6.1 compatibility (#1346) * Fixes for rails 6.1.1 * Fixes for ruby 3.0.0 * Remove rails 5.0 support Co-authored-by: Igor Gonchar --- .travis.yml | 14 +++++++--- lib/jsonapi/active_relation_resource.rb | 2 +- lib/jsonapi/request.rb | 2 +- lib/jsonapi/resource_controller_metal.rb | 4 +-- test/fixtures/active_record.rb | 6 ++--- test/integration/requests/request_test.rb | 8 +++--- test/test_helper.rb | 10 +++---- .../join_manager_test.rb | 27 +++++++++++++++---- test/unit/resource/resource_test.rb | 4 +-- 9 files changed, 50 insertions(+), 27 deletions(-) diff --git a/.travis.yml b/.travis.yml index 7131b30b2..a7dc8a3b8 100644 --- a/.travis.yml +++ b/.travis.yml @@ -10,16 +10,22 @@ env: - RAILS_VERSION=5.2.4.4 DATABASE_URL=postgres://postgres@localhost/jr_test - RAILS_VERSION=5.2.4.4 - RAILS_VERSION=5.1.7 - - RAILS_VERSION=5.0.7.2 rvm: - 2.6.6 - 2.7.2 - 3.0.0 matrix: - allow_failures: - - env: "RAILS_VERSION=6.1.1" - - env: "RAILS_VERSION=6.1.1 DATABASE_URL=postgres://postgres@localhost/jr_test" + exclude: - rvm: 3.0.0 + env: RAILS_VERSION=6.0.3.4 DATABASE_URL=postgres://postgres@localhost/jr_test + - rvm: 3.0.0 + env: RAILS_VERSION=6.0.3.4 + - rvm: 3.0.0 + env: RAILS_VERSION=5.2.4.4 DATABASE_URL=postgres://postgres@localhost/jr_test + - rvm: 3.0.0 + env: RAILS_VERSION=5.2.4.4 + - rvm: 3.0.0 + env: RAILS_VERSION=5.1.7 before_install: - gem install bundler --version 2.2.5 before_script: diff --git a/lib/jsonapi/active_relation_resource.rb b/lib/jsonapi/active_relation_resource.rb index e2611613f..cb60faabd 100644 --- a/lib/jsonapi/active_relation_resource.rb +++ b/lib/jsonapi/active_relation_resource.rb @@ -756,7 +756,7 @@ def apply_single_sort(records, field, direction, options) # Assumes ActiveRecord's counting. Override if you need a different counting method def count_records(records) - if Rails::VERSION::MAJOR >= 5 && ActiveRecord::VERSION::MINOR >= 1 + if (Rails::VERSION::MAJOR == 5 && ActiveRecord::VERSION::MINOR >= 1) || Rails::VERSION::MAJOR >= 6 records.count(:all) else records.count diff --git a/lib/jsonapi/request.rb b/lib/jsonapi/request.rb index 62b3c985e..0c377d35e 100644 --- a/lib/jsonapi/request.rb +++ b/lib/jsonapi/request.rb @@ -409,7 +409,7 @@ def parse_sort_criteria(resource_klass, sort_criteria) sorts = sort_criteria elsif sort_criteria.is_a?(String) begin - raw = URI.unescape(sort_criteria) + raw = URI.decode_www_form_component(sort_criteria) sorts = CSV.parse_line(raw) rescue CSV::MalformedCSVError fail JSONAPI::Exceptions::InvalidSortCriteria.new(format_key(resource_klass._type), raw) diff --git a/lib/jsonapi/resource_controller_metal.rb b/lib/jsonapi/resource_controller_metal.rb index c950e4659..f6f82e246 100644 --- a/lib/jsonapi/resource_controller_metal.rb +++ b/lib/jsonapi/resource_controller_metal.rb @@ -5,10 +5,10 @@ class ResourceControllerMetal < ActionController::Metal ActionController::Rendering, ActionController::Renderers::All, ActionController::StrongParameters, - ActionController::ForceSSL, + Gem::Requirement.new('< 6.1').satisfied_by?(ActionPack.gem_version) ? ActionController::ForceSSL : nil, ActionController::Instrumentation, JSONAPI::ActsAsResourceController - ].freeze + ].compact.freeze # Note, the url_helpers are not loaded. This will prevent links from being generated for resources, and warnings # will be emitted. Link support can be added by including `Rails.application.routes.url_helpers`, and links diff --git a/test/fixtures/active_record.rb b/test/fixtures/active_record.rb index 227fff664..0f25c5b10 100644 --- a/test/fixtures/active_record.rb +++ b/test/fixtures/active_record.rb @@ -503,7 +503,7 @@ class Post < ActiveRecord::Base belongs_to :writer, class_name: 'Person', foreign_key: 'author_id' has_many :comments has_and_belongs_to_many :tags, join_table: :posts_tags - has_many :special_post_tags, source: :tag + has_many :special_post_tags has_many :special_tags, through: :special_post_tags, source: :tag belongs_to :section belongs_to :parent_post, class_name: 'Post', foreign_key: 'parent_post_id' @@ -745,8 +745,8 @@ class Picture < ActiveRecord::Base belongs_to :author, class_name: 'Person', foreign_key: 'author_id' belongs_to :imageable, polymorphic: true - belongs_to :document, -> { where( pictures: { imageable_type: 'Document' } ).eager_load( :pictures ) }, foreign_key: 'imageable_id' - belongs_to :product, -> { where( pictures: { imageable_type: 'Product' } ).eager_load( :pictures ) }, foreign_key: 'imageable_id' + belongs_to :document, -> { where( pictures: { imageable_type: 'Document' } ) }, foreign_key: 'imageable_id' + belongs_to :product, -> { where( pictures: { imageable_type: 'Product' } ) }, foreign_key: 'imageable_id' has_one :file_properties, as: 'fileable' end diff --git a/test/integration/requests/request_test.rb b/test/integration/requests/request_test.rb index e59cdd580..1863b5c7d 100644 --- a/test/integration/requests/request_test.rb +++ b/test/integration/requests/request_test.rb @@ -1608,7 +1608,7 @@ def test_caching_included_singleton } $test_user = Person.find(1001) - assert_equal 2, JSONAPI.configuration.resource_cache.instance_variable_get(:@key_access).length + assert_equal 2, JSONAPI.configuration.resource_cache.instance_variable_get(:@data).length get "/api/v9/people/#{$test_user.id}?include=preferences" assert_jsonapi_response 200 @@ -1655,7 +1655,7 @@ def test_caching_included_singleton ] } - assert_equal 4, JSONAPI.configuration.resource_cache.instance_variable_get(:@key_access).length + assert_equal 4, JSONAPI.configuration.resource_cache.instance_variable_get(:@data).length ensure JSONAPI.configuration = original_config @@ -1700,7 +1700,7 @@ def test_caching_singleton_primary } } - assert_equal 1, JSONAPI.configuration.resource_cache.instance_variable_get(:@key_access).length + assert_equal 1, JSONAPI.configuration.resource_cache.instance_variable_get(:@data).length $test_user = Person.find(1001) @@ -1728,7 +1728,7 @@ def test_caching_singleton_primary } } - assert_equal 2, JSONAPI.configuration.resource_cache.instance_variable_get(:@key_access).length + assert_equal 2, JSONAPI.configuration.resource_cache.instance_variable_get(:@data).length ensure JSONAPI.configuration = original_config diff --git a/test/test_helper.rb b/test/test_helper.rb index 97e51fe7d..34506e99a 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -88,9 +88,9 @@ class Engine < ::Rails::Engine # Monkeypatch ActionController::TestCase to delete the RAW_POST_DATA on subsequent calls in the same test. if Rails::VERSION::MAJOR >= 5 module ClearRawPostHeader - def process(action, *args) + def process(action, **args) @request.delete_header 'RAW_POST_DATA' - super + super action, **args end end @@ -560,13 +560,13 @@ def assert_cacheable_jsonapi_get(url, cached_classes = :all) end class ActionController::TestCase - def assert_cacheable_get(action, *args) + def assert_cacheable_get(action, **args) assert_nil JSONAPI.configuration.resource_cache normal_queries = [] normal_query_callback = lambda {|_, _, _, _, payload| normal_queries.push payload[:sql] } ActiveSupport::Notifications.subscribed(normal_query_callback, 'sql.active_record') do - get action, *args + get action, **args end non_caching_response = json_response_sans_all_backtraces non_caching_status = response.status @@ -602,7 +602,7 @@ def assert_cacheable_get(action, *args) @controller = nil setup_controller_request_and_response @request.headers.merge!(orig_request_headers.dup) - get action, *args + get action, **args end end rescue Exception diff --git a/test/unit/active_relation_resource_finder/join_manager_test.rb b/test/unit/active_relation_resource_finder/join_manager_test.rb index e91e35dbb..a1198bf28 100644 --- a/test/unit/active_relation_resource_finder/join_manager_test.rb +++ b/test/unit/active_relation_resource_finder/join_manager_test.rb @@ -110,7 +110,11 @@ def test_add_nested_scoped_joins records = Api::V10::PostResource.records({}) records = join_manager.join(records, {}) - sql = 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "comments" ON "comments"."post_id" = "posts"."id" LEFT OUTER JOIN "people" ON "people"."id" = "posts"."author_id" LEFT OUTER JOIN "people" "authors_comments" ON "authors_comments"."id" = "comments"."author_id" LEFT OUTER JOIN "comments_tags" ON "comments_tags"."comment_id" = "comments"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "comments_tags"."tag_id" WHERE "comments"."approved" = ' + db_true + ' AND "author"."special" = ' + db_true + if (Rails::VERSION::MAJOR == 6 && Rails::VERSION::MINOR >= 1) || Rails::VERSION::MAJOR > 6 + sql = 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "comments" ON "comments"."post_id" = "posts"."id" LEFT OUTER JOIN "people" author ON author."id" = "posts"."author_id" LEFT OUTER JOIN "people" "authors_comments" ON "authors_comments"."id" = "comments"."author_id" LEFT OUTER JOIN "comments_tags" ON "comments_tags"."comment_id" = "comments"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "comments_tags"."tag_id" WHERE "comments"."approved" = ' + db_true + ' AND "author"."special" = ' + db_true + else + sql = 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "comments" ON "comments"."post_id" = "posts"."id" LEFT OUTER JOIN "people" ON "people"."id" = "posts"."author_id" LEFT OUTER JOIN "people" "authors_comments" ON "authors_comments"."id" = "comments"."author_id" LEFT OUTER JOIN "comments_tags" ON "comments_tags"."comment_id" = "comments"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "comments_tags"."tag_id" WHERE "comments"."approved" = ' + db_true + ' AND "author"."special" = ' + db_true + end assert_equal sql, records.to_sql @@ -132,7 +136,11 @@ def test_add_nested_scoped_joins records = join_manager.join(records, {}) # Note sql is in different order, but aliases should still be right - sql = 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "people" ON "people"."id" = "posts"."author_id" LEFT OUTER JOIN "comments" ON "comments"."post_id" = "posts"."id" LEFT OUTER JOIN "people" "authors_comments" ON "authors_comments"."id" = "comments"."author_id" LEFT OUTER JOIN "comments_tags" ON "comments_tags"."comment_id" = "comments"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "comments_tags"."tag_id" WHERE "comments"."approved" = ' + db_true + ' AND "author"."special" = ' + db_true + if (Rails::VERSION::MAJOR == 6 && Rails::VERSION::MINOR >= 1) || Rails::VERSION::MAJOR > 6 + sql = 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "people" author ON author."id" = "posts"."author_id" LEFT OUTER JOIN "comments" ON "comments"."post_id" = "posts"."id" LEFT OUTER JOIN "people" "authors_comments" ON "authors_comments"."id" = "comments"."author_id" LEFT OUTER JOIN "comments_tags" ON "comments_tags"."comment_id" = "comments"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "comments_tags"."tag_id" WHERE "comments"."approved" = ' + db_true + ' AND "author"."special" = ' + db_true + else + sql = 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "people" ON "people"."id" = "posts"."author_id" LEFT OUTER JOIN "comments" ON "comments"."post_id" = "posts"."id" LEFT OUTER JOIN "people" "authors_comments" ON "authors_comments"."id" = "comments"."author_id" LEFT OUTER JOIN "comments_tags" ON "comments_tags"."comment_id" = "comments"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "comments_tags"."tag_id" WHERE "comments"."approved" = ' + db_true + ' AND "author"."special" = ' + db_true + end assert_equal sql, records.to_sql @@ -171,7 +179,11 @@ def test_add_nested_joins_with_fields records = Api::V10::PostResource.records({}) records = join_manager.join(records, {}) - sql = 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "comments" ON "comments"."post_id" = "posts"."id" LEFT OUTER JOIN "people" ON "people"."id" = "posts"."author_id" LEFT OUTER JOIN "people" "authors_comments" ON "authors_comments"."id" = "comments"."author_id" LEFT OUTER JOIN "comments_tags" ON "comments_tags"."comment_id" = "comments"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "comments_tags"."tag_id" WHERE "comments"."approved" = ' + db_true + ' AND "author"."special" = ' + db_true + if (Rails::VERSION::MAJOR == 6 && Rails::VERSION::MINOR >= 1) || Rails::VERSION::MAJOR > 6 + sql = 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "comments" ON "comments"."post_id" = "posts"."id" LEFT OUTER JOIN "people" author ON author."id" = "posts"."author_id" LEFT OUTER JOIN "people" "authors_comments" ON "authors_comments"."id" = "comments"."author_id" LEFT OUTER JOIN "comments_tags" ON "comments_tags"."comment_id" = "comments"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "comments_tags"."tag_id" WHERE "comments"."approved" = ' + db_true + ' AND "author"."special" = ' + db_true + else + sql = 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "comments" ON "comments"."post_id" = "posts"."id" LEFT OUTER JOIN "people" ON "people"."id" = "posts"."author_id" LEFT OUTER JOIN "people" "authors_comments" ON "authors_comments"."id" = "comments"."author_id" LEFT OUTER JOIN "comments_tags" ON "comments_tags"."comment_id" = "comments"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "comments_tags"."tag_id" WHERE "comments"."approved" = ' + db_true + ' AND "author"."special" = ' + db_true + end assert_equal sql, records.to_sql @@ -190,13 +202,18 @@ def test_add_joins_with_sub_relationship records = Api::V10::PostResource.records({}) records = join_manager.join(records, {}) - sql = 'SELECT "posts".* FROM "posts" INNER JOIN "comments" ON "comments"."post_id" = "posts"."id" LEFT OUTER JOIN "people" ON "people"."id" = "comments"."author_id" LEFT OUTER JOIN "comments_tags" ON "comments_tags"."comment_id" = "comments"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "comments_tags"."tag_id" LEFT OUTER JOIN "comments" "comments_people" ON "comments_people"."author_id" = "people"."id" WHERE "comments"."approved" = ' + db_true + ' AND "author"."special" = ' + db_true + if (Rails::VERSION::MAJOR == 6 && Rails::VERSION::MINOR >= 1) || Rails::VERSION::MAJOR > 6 + sql = 'SELECT "posts".* FROM "posts" INNER JOIN "comments" ON "comments"."post_id" = "posts"."id" LEFT OUTER JOIN "people" author ON author."id" = "comments"."author_id" LEFT OUTER JOIN "comments_tags" ON "comments_tags"."comment_id" = "comments"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "comments_tags"."tag_id" LEFT OUTER JOIN "comments" "comments_people" ON "comments_people"."author_id" = "people"."id" WHERE "comments"."approved" = ' + db_true + ' AND "author"."special" = ' + db_true + assert_hash_equals({alias: 'author', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::CommentResource._relationship(:author))) + else + sql = 'SELECT "posts".* FROM "posts" INNER JOIN "comments" ON "comments"."post_id" = "posts"."id" LEFT OUTER JOIN "people" ON "people"."id" = "comments"."author_id" LEFT OUTER JOIN "comments_tags" ON "comments_tags"."comment_id" = "comments"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "comments_tags"."tag_id" LEFT OUTER JOIN "comments" "comments_people" ON "comments_people"."author_id" = "people"."id" WHERE "comments"."approved" = ' + db_true + ' AND "author"."special" = ' + db_true + assert_hash_equals({alias: 'people', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::CommentResource._relationship(:author))) + end assert_equal sql, records.to_sql assert_hash_equals({alias: 'comments', join_type: :inner}, join_manager.source_join_details) assert_hash_equals({alias: 'comments', join_type: :inner}, join_manager.join_details_by_relationship(Api::V10::PostResource._relationship(:comments))) - assert_hash_equals({alias: 'people', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::CommentResource._relationship(:author))) assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::CommentResource._relationship(:tags))) assert_hash_equals({alias: 'comments_people', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::PersonResource._relationship(:comments))) end diff --git a/test/unit/resource/resource_test.rb b/test/unit/resource/resource_test.rb index 10f8b1e13..f765875d6 100644 --- a/test/unit/resource/resource_test.rb +++ b/test/unit/resource/resource_test.rb @@ -13,7 +13,7 @@ class PostWithBadAfterSave < ActiveRecord::Base after_save :do_some_after_save_stuff def do_some_after_save_stuff - errors[:base] << 'Boom! Error added in after_save callback.' + errors.add(:base, 'Boom! Error added in after_save callback.') raise ActiveRecord::RecordInvalid.new(self) end end @@ -23,7 +23,7 @@ class PostWithCustomValidationContext < ActiveRecord::Base validate :api_specific_check, on: :json_api_create def api_specific_check - errors[:base] << 'Record is invalid' + errors.add(:base, 'Record is invalid') end end From eb432722b915e76914be9132e010a1244a32e91c Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Thu, 21 Jan 2021 10:55:49 -0500 Subject: [PATCH 198/237] Refactor to allow resources to be serialized without the developer needing to understand the internal components (#1348) * Refactor to allow resources to be serialized without the developer needing to understand the internal components * Simplify and clarify naming for internal components * Refine include_directives --- lib/jsonapi-resources.rb | 2 +- lib/jsonapi/active_relation_resource.rb | 54 +- lib/jsonapi/basic_resource.rb | 20 +- lib/jsonapi/include_directives.rb | 4 +- lib/jsonapi/processor.rb | 230 ++++---- lib/jsonapi/relationship.rb | 2 +- lib/jsonapi/resource_fragment.rb | 19 +- lib/jsonapi/resource_id_tree.rb | 112 ---- lib/jsonapi/resource_serializer.rb | 20 +- lib/jsonapi/resource_set.rb | 48 +- lib/jsonapi/resource_tree.rb | 234 ++++++++ test/controllers/controller_test.rb | 6 + test/fixtures/active_record.rb | 9 +- test/helpers/configuration_helpers.rb | 2 +- test/helpers/value_matchers.rb | 32 +- test/unit/processor/default_processor_test.rb | 25 +- .../resource/active_relation_resource_test.rb | 94 ++-- test/unit/resource/resource_test.rb | 8 +- .../serializer/include_directives_test.rb | 18 +- test/unit/serializer/serializer_test.rb | 500 +++++++++++++++++- 20 files changed, 1024 insertions(+), 415 deletions(-) delete mode 100644 lib/jsonapi/resource_id_tree.rb create mode 100644 lib/jsonapi/resource_tree.rb diff --git a/lib/jsonapi-resources.rb b/lib/jsonapi-resources.rb index 4ac0532bc..04fae654f 100644 --- a/lib/jsonapi-resources.rb +++ b/lib/jsonapi-resources.rb @@ -37,7 +37,7 @@ require 'jsonapi/active_relation/join_manager' require 'jsonapi/resource_identity' require 'jsonapi/resource_fragment' -require 'jsonapi/resource_id_tree' +require 'jsonapi/resource_tree' require 'jsonapi/resource_set' require 'jsonapi/path' require 'jsonapi/path_segment' diff --git a/lib/jsonapi/active_relation_resource.rb b/lib/jsonapi/active_relation_resource.rb index cb60faabd..80b2261a8 100644 --- a/lib/jsonapi/active_relation_resource.rb +++ b/lib/jsonapi/active_relation_resource.rb @@ -2,6 +2,10 @@ module JSONAPI class ActiveRelationResource < BasicResource root_resource + def find_related_ids(relationship, options = {}) + self.class.find_related_fragments([self], relationship.name, options).keys.collect { |rid| rid.id } + end + class << self # Finds Resources using the `filters`. Pagination and sort options are used when provided # @@ -90,8 +94,11 @@ def find_to_populate_by_keys(keys, options = {}) # the ResourceInstances matching the filters, sorting, and pagination rules along with any request # additional_field values def find_fragments(filters, options = {}) - include_directives = options[:include_directives] ? options[:include_directives].include_directives : {} + include_directives = options.fetch(:include_directives, {}) resource_klass = self + + fragments = {} + linkage_relationships = to_one_relationships_for_linkage(include_directives[:include_related]) sort_criteria = options.fetch(:sort_criteria) { [] } @@ -129,18 +136,26 @@ def find_fragments(filters, options = {}) if linkage_relationship.polymorphic? && linkage_relationship.belongs_to? linkage_relationship.resource_types.each do |resource_type| klass = resource_klass_for(resource_type) - linkage_fields << {relationship_name: name, resource_klass: klass} - linkage_table_alias = join_manager.join_details_by_polymorphic_relationship(linkage_relationship, resource_type)[:alias] primary_key = klass._primary_key + + linkage_fields << {relationship_name: name, + resource_klass: klass, + field: "#{concat_table_field(linkage_table_alias, primary_key)} AS #{linkage_table_alias}_#{primary_key}", + alias: "#{linkage_table_alias}_#{primary_key}"} + pluck_fields << Arel.sql("#{concat_table_field(linkage_table_alias, primary_key)} AS #{linkage_table_alias}_#{primary_key}") end else klass = linkage_relationship.resource_klass - linkage_fields << {relationship_name: name, resource_klass: klass} - linkage_table_alias = join_manager.join_details_by_relationship(linkage_relationship)[:alias] primary_key = klass._primary_key + + linkage_fields << {relationship_name: name, + resource_klass: klass, + field: "#{concat_table_field(linkage_table_alias, primary_key)} AS #{linkage_table_alias}_#{primary_key}", + alias: "#{linkage_table_alias}_#{primary_key}"} + pluck_fields << Arel.sql("#{concat_table_field(linkage_table_alias, primary_key)} AS #{linkage_table_alias}_#{primary_key}") end end @@ -158,7 +173,6 @@ def find_fragments(filters, options = {}) pluck_fields << Arel.sql(field) end - fragments = {} rows = records.pluck(*pluck_fields) rows.each do |row| rid = JSONAPI::ResourceIdentity.new(resource_klass, pluck_fields.length == 1 ? row : row[0]) @@ -204,23 +218,23 @@ def find_fragments(filters, options = {}) # @return [Hash{ResourceIdentity => {identity: => ResourceIdentity, cache: cache_field, attributes: => {name => value}, related: {relationship_name: [] }}}] # the ResourceInstances matching the filters, sorting, and pagination rules along with any request # additional_field values - def find_related_fragments(source_rids, relationship_name, options = {}) + def find_related_fragments(source, relationship_name, options = {}) relationship = _relationship(relationship_name) if relationship.polymorphic? # && relationship.foreign_key_on == :self - find_related_polymorphic_fragments(source_rids, relationship, options, false) + find_related_polymorphic_fragments(source, relationship, options, false) else - find_related_monomorphic_fragments(source_rids, relationship, options, false) + find_related_monomorphic_fragments(source, relationship, options, false) end end - def find_included_fragments(source_rids, relationship_name, options) + def find_included_fragments(source, relationship_name, options) relationship = _relationship(relationship_name) if relationship.polymorphic? # && relationship.foreign_key_on == :self - find_related_polymorphic_fragments(source_rids, relationship, options, true) + find_related_polymorphic_fragments(source, relationship, options, true) else - find_related_monomorphic_fragments(source_rids, relationship, options, true) + find_related_monomorphic_fragments(source, relationship, options, true) end end @@ -231,7 +245,7 @@ def find_included_fragments(source_rids, relationship_name, options) # @option options [Hash] :context The context of the request, set in the controller # # @return [Integer] the count - def count_related(source_rid, relationship_name, options = {}) + def count_related(source_resource, relationship_name, options = {}) relationship = _relationship(relationship_name) related_klass = relationship.resource_klass @@ -244,7 +258,7 @@ def count_related(source_rid, relationship_name, options = {}) records = apply_request_settings_to_records(records: records(options), resource_klass: related_klass, - primary_keys: source_rid.id, + primary_keys: source_resource.id, join_manager: join_manager, filters: filters, options: options) @@ -375,11 +389,11 @@ def find_records_by_keys(keys, options = {}) apply_request_settings_to_records(records: records(options), primary_keys: keys, options: options) end - def find_related_monomorphic_fragments(source_rids, relationship, options, connect_source_identity) + def find_related_monomorphic_fragments(source_fragments, relationship, options, connect_source_identity) filters = options.fetch(:filters, {}) - source_ids = source_rids.collect {|rid| rid.id} + source_ids = source_fragments.collect {|item| item.identity.id} - include_directives = options[:include_directives] ? options[:include_directives].include_directives : {} + include_directives = options.fetch(:include_directives, {}) resource_klass = relationship.resource_klass linkage_relationships = resource_klass.to_one_relationships_for_linkage(include_directives[:include_related]) @@ -501,12 +515,12 @@ def find_related_monomorphic_fragments(source_rids, relationship, options, conne # Gets resource identities where the related resource is polymorphic and the resource type and id # are stored on the primary resources. Cache fields will always be on the related resources. - def find_related_polymorphic_fragments(source_rids, relationship, options, connect_source_identity) + def find_related_polymorphic_fragments(source_fragments, relationship, options, connect_source_identity) filters = options.fetch(:filters, {}) - source_ids = source_rids.collect {|rid| rid.id} + source_ids = source_fragments.collect {|item| item.identity.id} resource_klass = relationship.resource_klass - include_directives = options[:include_directives] ? options[:include_directives].include_directives : {} + include_directives = options.fetch(:include_directives, {}) linkage_relationships = [] diff --git a/lib/jsonapi/basic_resource.rb b/lib/jsonapi/basic_resource.rb index e7cdd93c4..5331b4f9d 100644 --- a/lib/jsonapi/basic_resource.rb +++ b/lib/jsonapi/basic_resource.rb @@ -44,8 +44,12 @@ def identity JSONAPI::ResourceIdentity.new(self.class, id) end + def cache_field_value + _model.public_send(self.class._cache_field) + end + def cache_id - [id, self.class.hash_cache_field(_model.public_send(self.class._cache_field))] + [id, self.class.hash_cache_field(cache_field_value)] end def is_new? @@ -285,9 +289,7 @@ def _replace_to_many_links(relationship_type, relationship_key_values, options) reflect = reflect_relationship?(relationship, options) if reflect - existing_rids = self.class.find_related_fragments([identity], relationship_type, options) - - existing = existing_rids.keys.collect { |rid| rid.id } + existing = find_related_ids(relationship, options) to_delete = existing - (relationship_key_values & existing) to_delete.each do |key| @@ -417,6 +419,10 @@ def _replace_fields(field_data) :completed end + def find_related_ids(relationship, options = {}) + send(relationship.foreign_key) + end + class << self def inherited(subclass) super @@ -637,7 +643,7 @@ def model_name(model, options = {}) end def model_hint(model: _model_name, resource: _type) - resource_type = ((resource.is_a?(Class)) && (resource < JSONAPI::Resource)) ? resource._type : resource.to_s + resource_type = ((resource.is_a?(Class)) && (resource < JSONAPI::BasicResource)) ? resource._type : resource.to_s _model_hints[model.to_s.gsub('::', '/').underscore] = resource_type.to_s end @@ -710,7 +716,7 @@ def resources_for(records, context) end def resource_for(model_record, context) - resource_klass = self.resource_klass_for_model(model_record) + resource_klass = resource_klass_for_model(model_record) resource_klass.new(model_record, context) end @@ -1084,7 +1090,7 @@ def _add_relationship(klass, *attrs) end end - # ResourceBuilder methods + # ResourceBuilder methods def define_relationship_methods(relationship_name, relationship_klass, options) relationship = register_relationship( relationship_name, diff --git a/lib/jsonapi/include_directives.rb b/lib/jsonapi/include_directives.rb index c1a1d7b3b..a75b5adcd 100644 --- a/lib/jsonapi/include_directives.rb +++ b/lib/jsonapi/include_directives.rb @@ -25,8 +25,8 @@ def initialize(resource_klass, includes_array) end end - def include_directives - @include_directives_hash + def [](name) + @include_directives_hash[name] end private diff --git a/lib/jsonapi/processor.rb b/lib/jsonapi/processor.rb index de3459c82..88c455590 100644 --- a/lib/jsonapi/processor.rb +++ b/lib/jsonapi/processor.rb @@ -49,7 +49,7 @@ def find verified_filters = resource_klass.verify_filters(filters, context) - find_options = { + options = { context: context, sort_criteria: sort_criteria, paginator: paginator, @@ -58,11 +58,9 @@ def find include_directives: include_directives } - resource_set = find_resource_set(resource_klass, - include_directives, - find_options) + resource_set = find_resource_set(include_directives, options) - resource_set.populate!(serializer, context, find_options) + resource_set.populate!(serializer, context, options) page_options = result_options if (JSONAPI.configuration.top_level_meta_include_record_count || (paginator && paginator.class.requires_record_count)) @@ -79,7 +77,7 @@ def find page_options[:pagination_params] = paginator.links_page_params(page_options.merge(fetched_resources: resource_set)) end - return JSONAPI::ResourcesSetOperationResult.new(:ok, resource_set, page_options) + JSONAPI::ResourcesSetOperationResult.new(:ok, resource_set, page_options) end def show @@ -90,21 +88,19 @@ def show key = resource_klass.verify_key(id, context) - find_options = { + options = { context: context, fields: fields, filters: { resource_klass._primary_key => key }, include_directives: include_directives } - resource_set = find_resource_set(resource_klass, - include_directives, - find_options) + resource_set = find_resource_set(include_directives, options) fail JSONAPI::Exceptions::RecordNotFound.new(id) if resource_set.resource_klasses.empty? - resource_set.populate!(serializer, context, find_options) + resource_set.populate!(serializer, context, options) - return JSONAPI::ResourceSetOperationResult.new(:ok, resource_set, result_options) + JSONAPI::ResourceSetOperationResult.new(:ok, resource_set, result_options) end def show_relationship @@ -117,25 +113,26 @@ def show_relationship parent_resource = resource_klass.find_by_key(parent_key, context: context) - find_options = { - context: context, - sort_criteria: sort_criteria, - paginator: paginator, - fields: fields, - include_directives: include_directives + options = { + context: context, + sort_criteria: sort_criteria, + paginator: paginator, + fields: fields, + include_directives: include_directives } - resource_id_tree = find_related_resource_id_tree(resource_klass, - JSONAPI::ResourceIdentity.new(resource_klass, parent_key), - relationship_type, - find_options, - nil) - - return JSONAPI::RelationshipOperationResult.new(:ok, - parent_resource, - resource_klass._relationship(relationship_type), - resource_id_tree.fragments.keys, - result_options) + resource_tree = find_related_resource_tree( + parent_resource, + relationship_type, + options, + nil + ) + + JSONAPI::RelationshipOperationResult.new(:ok, + parent_resource, + resource_klass._relationship(relationship_type), + resource_tree.fragments.keys, + result_options) end def show_related_resource @@ -146,11 +143,11 @@ def show_related_resource serializer = params[:serializer] fields = params[:fields] - find_options = { - context: context, - fields: fields, - filters: {}, - include_directives: include_directives + options = { + context: context, + fields: fields, + filters: {}, + include_directives: include_directives } source_resource = source_klass.find_by_key(source_id, context: context, fields: fields) @@ -158,11 +155,11 @@ def show_related_resource resource_set = find_related_resource_set(source_resource, relationship_type, include_directives, - find_options) + options) - resource_set.populate!(serializer, context, find_options) + resource_set.populate!(serializer, context, options) - return JSONAPI::ResourceSetOperationResult.new(:ok, resource_set, result_options) + JSONAPI::ResourceSetOperationResult.new(:ok, resource_set, result_options) end def show_related_resources @@ -178,8 +175,8 @@ def show_related_resources verified_filters = resource_klass.verify_filters(filters, context) - find_options = { - filters: verified_filters, + options = { + filters: verified_filters, sort_criteria: sort_criteria, paginator: paginator, fields: fields, @@ -192,19 +189,19 @@ def show_related_resources resource_set = find_related_resource_set(source_resource, relationship_type, include_directives, - find_options) + options) - resource_set.populate!(serializer, context, find_options) + resource_set.populate!(serializer, context, options) opts = result_options if ((JSONAPI.configuration.top_level_meta_include_record_count) || - (paginator && paginator.class.requires_record_count) || - (JSONAPI.configuration.top_level_meta_include_page_count)) + (paginator && paginator.class.requires_record_count) || + (JSONAPI.configuration.top_level_meta_include_page_count)) opts[:record_count] = source_resource.class.count_related( - source_resource.identity, - relationship_type, - find_options) + source_resource, + relationship_type, + options) end if (JSONAPI.configuration.top_level_meta_include_page_count && opts[:record_count]) @@ -219,11 +216,11 @@ def show_related_resources {} end - return JSONAPI::RelatedResourcesSetOperationResult.new(:ok, - source_resource, - relationship_type, - resource_set, - opts) + JSONAPI::RelatedResourcesSetOperationResult.new(:ok, + source_resource, + relationship_type, + resource_set, + opts) end def create_resource @@ -235,20 +232,18 @@ def create_resource resource = resource_klass.create(context) result = resource.replace_fields(data) - find_options = { - context: context, - fields: fields, - filters: { resource_klass._primary_key => resource.id }, - include_directives: include_directives + options = { + context: context, + fields: fields, + filters: { resource_klass._primary_key => resource.id }, + include_directives: include_directives } - resource_set = find_resource_set(resource_klass, - include_directives, - find_options) + resource_set = find_resource_set(include_directives, options) - resource_set.populate!(serializer, context, find_options) + resource_set.populate!(serializer, context, options) - return JSONAPI::ResourceSetOperationResult.new((result == :completed ? :created : :accepted), resource_set, result_options) + JSONAPI::ResourceSetOperationResult.new((result == :completed ? :created : :accepted), resource_set, result_options) end def remove_resource @@ -257,7 +252,7 @@ def remove_resource resource = resource_klass.find_by_key(resource_id, context: context) result = resource.remove - return JSONAPI::OperationResult.new(result == :completed ? :no_content : :accepted, result_options) + JSONAPI::OperationResult.new(result == :completed ? :no_content : :accepted, result_options) end def replace_fields @@ -272,20 +267,18 @@ def replace_fields result = resource.replace_fields(data) - find_options = { - context: context, - fields: fields, - filters: { resource_klass._primary_key => resource.id }, - include_directives: include_directives + options = { + context: context, + fields: fields, + filters: { resource_klass._primary_key => resource.id }, + include_directives: include_directives } - resource_set = find_resource_set(resource_klass, - include_directives, - find_options) + resource_set = find_resource_set(include_directives, options) - resource_set.populate!(serializer, context, find_options) + resource_set.populate!(serializer, context, options) - return JSONAPI::ResourceSetOperationResult.new((result == :completed ? :ok : :accepted), resource_set, result_options) + JSONAPI::ResourceSetOperationResult.new((result == :completed ? :ok : :accepted), resource_set, result_options) end def replace_to_one_relationship @@ -296,7 +289,7 @@ def replace_to_one_relationship resource = resource_klass.find_by_key(resource_id, context: context) result = resource.replace_to_one_link(relationship_type, key_value) - return JSONAPI::OperationResult.new(result == :completed ? :no_content : :accepted, result_options) + JSONAPI::OperationResult.new(result == :completed ? :no_content : :accepted, result_options) end def replace_polymorphic_to_one_relationship @@ -308,7 +301,7 @@ def replace_polymorphic_to_one_relationship resource = resource_klass.find_by_key(resource_id, context: context) result = resource.replace_polymorphic_to_one_link(relationship_type, key_value, key_type) - return JSONAPI::OperationResult.new(result == :completed ? :no_content : :accepted, result_options) + JSONAPI::OperationResult.new(result == :completed ? :no_content : :accepted, result_options) end def create_to_many_relationships @@ -319,7 +312,7 @@ def create_to_many_relationships resource = resource_klass.find_by_key(resource_id, context: context) result = resource.create_to_many_links(relationship_type, data) - return JSONAPI::OperationResult.new(result == :completed ? :no_content : :accepted, result_options) + JSONAPI::OperationResult.new(result == :completed ? :no_content : :accepted, result_options) end def replace_to_many_relationships @@ -330,7 +323,7 @@ def replace_to_many_relationships resource = resource_klass.find_by_key(resource_id, context: context) result = resource.replace_to_many_links(relationship_type, data) - return JSONAPI::OperationResult.new(result == :completed ? :no_content : :accepted, result_options) + JSONAPI::OperationResult.new(result == :completed ? :no_content : :accepted, result_options) end def remove_to_many_relationships @@ -347,7 +340,7 @@ def remove_to_many_relationships complete = false end end - return JSONAPI::OperationResult.new(complete ? :no_content : :accepted, result_options) + JSONAPI::OperationResult.new(complete ? :no_content : :accepted, result_options) end def remove_to_one_relationship @@ -357,7 +350,7 @@ def remove_to_one_relationship resource = resource_klass.find_by_key(resource_id, context: context) result = resource.remove_to_one_link(relationship_type) - return JSONAPI::OperationResult.new(result == :completed ? :no_content : :accepted, result_options) + JSONAPI::OperationResult.new(result == :completed ? :no_content : :accepted, result_options) end def result_options @@ -366,91 +359,46 @@ def result_options options end - def find_resource_set(resource_klass, include_directives, options) - include_related = include_directives.include_directives[:include_related] if include_directives + def find_resource_set(include_directives, options) + include_related = include_directives[:include_related] if include_directives - resource_id_tree = find_resource_id_tree(resource_klass, options, include_related) + resource_tree = find_resource_tree(options, include_related) - JSONAPI::ResourceSet.new(resource_id_tree) + JSONAPI::ResourceSet.new(resource_tree) end def find_related_resource_set(resource, relationship_name, include_directives, options) - include_related = include_directives.include_directives[:include_related] if include_directives + include_related = include_directives[:include_related] if include_directives - resource_id_tree = find_resource_id_tree_from_resource_relationship(resource, relationship_name, options, include_related) + resource_tree = find_resource_tree_from_relationship(resource, relationship_name, options, include_related) - JSONAPI::ResourceSet.new(resource_id_tree) + JSONAPI::ResourceSet.new(resource_tree) end - private - def find_related_resource_id_tree(resource_klass, source_id, relationship_name, find_options, include_related) - options = find_options.except(:include_directives) + def find_resource_tree(options, include_related) options[:cache] = resource_klass.caching? - fragments = resource_klass.find_included_fragments([source_id], relationship_name, options) - - primary_resource_id_tree = PrimaryResourceIdTree.new - primary_resource_id_tree.add_resource_fragments(fragments, include_related) - - load_included(resource_klass, primary_resource_id_tree, include_related, options) - - primary_resource_id_tree + fragments = resource_klass.find_fragments(options[:filters], options) + PrimaryResourceTree.new(fragments: fragments, include_related: include_related, options: options) end - def find_resource_id_tree(resource_klass, find_options, include_related) - options = find_options + def find_related_resource_tree(parent_resource, relationship_name, options, include_related) + options = options.except(:include_directives) options[:cache] = resource_klass.caching? - fragments = resource_klass.find_fragments(find_options[:filters], options) - - primary_resource_id_tree = PrimaryResourceIdTree.new - primary_resource_id_tree.add_resource_fragments(fragments, include_related) - - load_included(resource_klass, primary_resource_id_tree, include_related, options) - - primary_resource_id_tree + fragments = resource_klass.find_included_fragments([parent_resource], relationship_name, options) + PrimaryResourceTree.new(fragments: fragments, include_related: include_related, options: options) end - def find_resource_id_tree_from_resource_relationship(resource, relationship_name, find_options, include_related) + def find_resource_tree_from_relationship(resource, relationship_name, options, include_related) relationship = resource.class._relationship(relationship_name) - options = find_options.except(:include_directives) + options = options.except(:include_directives) options[:cache] = relationship.resource_klass.caching? - fragments = resource.class.find_related_fragments([resource.identity], relationship_name, options) - - primary_resource_id_tree = PrimaryResourceIdTree.new - primary_resource_id_tree.add_resource_fragments(fragments, include_related) + fragments = resource.class.find_related_fragments([resource], relationship_name, options) - load_included(resource_klass, primary_resource_id_tree, include_related, options) - - primary_resource_id_tree - end - - def load_included(resource_klass, source_resource_id_tree, include_related, options) - source_rids = source_resource_id_tree.fragments.keys - - include_related.try(:each_key) do |key| - relationship = resource_klass._relationship(key) - relationship_name = relationship.name.to_sym - - find_related_resource_options = options.except(:filters, :sort_criteria, :paginator) - find_related_resource_options[:sort_criteria] = relationship.resource_klass.default_sort - find_related_resource_options[:cache] = resource_klass.caching? - - related_fragments = resource_klass.find_included_fragments( - source_rids, relationship_name, find_related_resource_options - ) - - related_resource_id_tree = source_resource_id_tree.fetch_related_resource_id_tree(relationship) - related_resource_id_tree.add_resource_fragments(related_fragments, include_related[key][include_related]) - - # Now recursively get the related resources for the currently found resources - load_included(relationship.resource_klass, - related_resource_id_tree, - include_related[relationship_name][:include_related], - options) - end + PrimaryResourceTree.new(fragments: fragments, include_related: include_related, options: options) end end end diff --git a/lib/jsonapi/relationship.rb b/lib/jsonapi/relationship.rb index 77e700b78..6ed3c54b8 100644 --- a/lib/jsonapi/relationship.rb +++ b/lib/jsonapi/relationship.rb @@ -24,7 +24,7 @@ def initialize(name, options = {}) end @always_include_optional_linkage_data = options.fetch(:always_include_optional_linkage_data, false) == true - @eager_load_on_include = options.fetch(:eager_load_on_include, false) == true + @eager_load_on_include = options.fetch(:eager_load_on_include, true) == true @allow_include = options[:allow_include] @class_name = nil @inverse_relationship = nil diff --git a/lib/jsonapi/resource_fragment.rb b/lib/jsonapi/resource_fragment.rb index 933ad6b8e..188e4caef 100644 --- a/lib/jsonapi/resource_fragment.rb +++ b/lib/jsonapi/resource_fragment.rb @@ -6,34 +6,41 @@ module JSONAPI # cache - the value of the cache field for the resource instance # related - a hash of arrays of related resource identities, grouped by relationship name # related_from - a set of related resource identities that loaded the fragment + # resource - a resource instance # # Todo: optionally use these for faster responses by bypassing model instantiation) # attributes - resource attributes class ResourceFragment - attr_reader :identity, :attributes, :related_from, :related + attr_reader :identity, :attributes, :related_from, :related, :resource attr_accessor :primary, :cache alias :cache_field :cache #ToDo: Rename one or the other - def initialize(identity) + def initialize(identity, resource: nil, cache: nil, primary: false) @identity = identity - @cache = nil + @cache = cache + @resource = resource + @primary = primary + @attributes = {} @related = {} - @primary = false @related_from = Set.new end def initialize_related(relationship_name) - @related ||= {} @related[relationship_name.to_sym] ||= Set.new end def add_related_identity(relationship_name, identity) initialize_related(relationship_name) - @related[relationship_name.to_sym] << identity + @related[relationship_name.to_sym] << identity if identity + end + + def merge_related_identities(relationship_name, identities) + initialize_related(relationship_name) + @related[relationship_name.to_sym].merge(identities) if identities end def add_related_from(identity) diff --git a/lib/jsonapi/resource_id_tree.rb b/lib/jsonapi/resource_id_tree.rb deleted file mode 100644 index 2bb2f456f..000000000 --- a/lib/jsonapi/resource_id_tree.rb +++ /dev/null @@ -1,112 +0,0 @@ -module JSONAPI - - # A tree structure representing the resource structure of the requested resource(s). This is an intermediate structure - # used to keep track of the resources, by identity, found at different included relationships. It will be flattened and - # the resource instances will be fetched from the cache or the record store. - class ResourceIdTree - - attr_reader :fragments, :related_resource_id_trees - - # Gets the related Resource Id Tree for a relationship, and creates it first if it does not exist - # - # @param relationship [JSONAPI::Relationship] - # - # @return [JSONAPI::RelatedResourceIdTree] the new or existing resource id tree for the requested relationship - def fetch_related_resource_id_tree(relationship) - relationship_name = relationship.name.to_sym - @related_resource_id_trees[relationship_name] ||= RelatedResourceIdTree.new(relationship, self) - end - - private - - def init_included_relationships(fragment, include_related) - include_related && include_related.each_key do |relationship_name| - fragment.initialize_related(relationship_name) - end - end - end - - class PrimaryResourceIdTree < ResourceIdTree - - # Creates a PrimaryResourceIdTree with no resources and no related ResourceIdTrees - def initialize - @fragments ||= {} - @related_resource_id_trees ||= {} - end - - # Adds each Resource Fragment to the Resources hash - # - # @param fragments [Hash] - # @param include_related [Hash] - # - # @return [null] - def add_resource_fragments(fragments, include_related) - fragments.each_value do |fragment| - add_resource_fragment(fragment, include_related) - end - end - - # Adds a Resource Fragment to the Resources hash - # - # @param fragment [JSONAPI::ResourceFragment] - # @param include_related [Hash] - # - # @return [null] - def add_resource_fragment(fragment, include_related) - fragment.primary = true - - init_included_relationships(fragment, include_related) - - @fragments[fragment.identity] = fragment - end - end - - class RelatedResourceIdTree < ResourceIdTree - - attr_reader :parent_relationship, :source_resource_id_tree - - # Creates a RelatedResourceIdTree with no resources and no related ResourceIdTrees. A connection to the parent - # ResourceIdTree is maintained. - # - # @param parent_relationship [JSONAPI::Relationship] - # @param source_resource_id_tree [JSONAPI::ResourceIdTree] - # - # @return [JSONAPI::RelatedResourceIdTree] the new or existing resource id tree for the requested relationship - def initialize(parent_relationship, source_resource_id_tree) - @fragments ||= {} - @related_resource_id_trees ||= {} - - @parent_relationship = parent_relationship - @parent_relationship_name = parent_relationship.name.to_sym - @source_resource_id_tree = source_resource_id_tree - end - - # Adds each Resource Fragment to the Resources hash - # - # @param fragments [Hash] - # @param include_related [Hash] - # - # @return [null] - def add_resource_fragments(fragments, include_related) - fragments.each_value do |fragment| - add_resource_fragment(fragment, include_related) - end - end - - # Adds a Resource Fragment to the fragments hash - # - # @param fragment [JSONAPI::ResourceFragment] - # @param include_related [Hash] - # - # @return [null] - def add_resource_fragment(fragment, include_related) - init_included_relationships(fragment, include_related) - - fragment.related_from.each do |rid| - @source_resource_id_tree.fragments[rid].add_related_identity(parent_relationship.name, fragment.identity) - end - - @fragments[fragment.identity] = fragment - end - end -end \ No newline at end of file diff --git a/lib/jsonapi/resource_serializer.rb b/lib/jsonapi/resource_serializer.rb index c7adb6c18..d3a03a631 100644 --- a/lib/jsonapi/resource_serializer.rb +++ b/lib/jsonapi/resource_serializer.rb @@ -3,7 +3,7 @@ class ResourceSerializer attr_reader :link_builder, :key_formatter, :serialization_options, :fields, :include_directives, :always_include_to_one_linkage_data, - :always_include_to_many_linkage_data + :always_include_to_many_linkage_data, :options # initialize # Options can include @@ -18,11 +18,12 @@ class ResourceSerializer # serialization_options: additional options that will be passed to resource meta and links lambdas def initialize(primary_resource_klass, options = {}) + @options = options @primary_resource_klass = primary_resource_klass @fields = options.fetch(:fields, {}) @include = options.fetch(:include, []) - @include_directives = options[:include_directives] - @include_directives ||= JSONAPI::IncludeDirectives.new(@primary_resource_klass, @include) + @include_directives = options.fetch(:include_directives, + JSONAPI::IncludeDirectives.new(@primary_resource_klass, @include)) @key_formatter = options.fetch(:key_formatter, JSONAPI.configuration.key_formatter) @id_formatter = ValueFormatter.value_formatter_for(:id) @link_builder = generate_link_builder(primary_resource_klass, options) @@ -41,6 +42,19 @@ def initialize(primary_resource_klass, options = {}) @_supplying_relationship_fields = {} end + # Converts a single resource, or an array of resources to a hash, conforming to the JSONAPI structure + def serialize_to_hash(source) + include_related = include_directives[:include_related] + resource_set = JSONAPI::ResourceSet.new(source, include_related, options) + resource_set.populate!(self, options[:context], options) + + if source.is_a?(Array) + serialize_resource_set_to_hash_plural(resource_set) + else + serialize_resource_set_to_hash_single(resource_set) + end + end + # Converts a resource_set to a hash, conforming to the JSONAPI structure def serialize_resource_set_to_hash_single(resource_set) diff --git a/lib/jsonapi/resource_set.rb b/lib/jsonapi/resource_set.rb index b1fb136bf..5cf6c6bbc 100644 --- a/lib/jsonapi/resource_set.rb +++ b/lib/jsonapi/resource_set.rb @@ -5,12 +5,24 @@ class ResourceSet attr_reader :resource_klasses, :populated - def initialize(resource_id_tree = nil) + def initialize(source, include_related = nil, options = nil) @populated = false - @resource_klasses = resource_id_tree.nil? ? {} : flatten_resource_id_tree(resource_id_tree) + tree = if source.is_a?(JSONAPI::ResourceTree) + source + elsif source.class < JSONAPI::BasicResource + JSONAPI::PrimaryResourceTree.new(resource: source, include_related: include_related, options: options) + elsif source.is_a?(Array) + JSONAPI::PrimaryResourceTree.new(resources: source, include_related: include_related, options: options) + end + + if tree + @resource_klasses = flatten_resource_tree(tree) + end end - def populate!(serializer, context, find_options) + def populate!(serializer, context, options) + return if @populated + # For each resource klass we want to generate the caching key # Hash for collecting types and ids @@ -21,9 +33,9 @@ def populate!(serializer, context, find_options) # @type [Lookup[]] lookups = [] - # Step One collect all of the lookups for the cache, or keys that don't require cache access @resource_klasses.each_key do |resource_klass| + missed_resource_ids[resource_klass] ||= [] serializer_config_key = serializer.config_key(resource_klass).gsub("/", "_") context_json = resource_klass.attribute_caching_context(context).to_json @@ -47,7 +59,13 @@ def populate!(serializer, context, find_options) ) ) else - missed_resource_ids[resource_klass] = @resource_klasses[resource_klass].keys + @resource_klasses[resource_klass].keys.each do |k| + if @resource_klasses[resource_klass][k][:resource].nil? + missed_resource_ids[resource_klass] << k + else + register_resource(resource_klass, @resource_klasses[resource_klass][k][:resource]) + end + end end end @@ -60,7 +78,6 @@ def populate!(serializer, context, find_options) found_resources = {} end - # Step Three collect the results and collect hit/miss stats stats = {} found_resources.each do |resource_klass, resources| @@ -72,7 +89,6 @@ def populate!(serializer, context, find_options) stats[resource_klass][:misses] += 1 # Collect misses - missed_resource_ids[resource_klass] ||= [] missed_resource_ids[resource_klass].push(id) else stats[resource_klass][:hits] ||= 0 @@ -89,14 +105,15 @@ def populate!(serializer, context, find_options) # Step Four find any of the missing resources and join them into the result missed_resource_ids.each_pair do |resource_klass, ids| - find_opts = {context: context, fields: find_options[:fields]} + next if ids.empty? + + find_opts = {context: context, fields: options[:fields]} found_resources = resource_klass.find_to_populate_by_keys(ids, find_opts) found_resources.each do |resource| relationship_data = @resource_klasses[resource_klass][resource.id][:relationships] if resource_klass.caching? - serializer_config_key = serializer.config_key(resource_klass).gsub("/", "_") context_json = resource_klass.attribute_caching_context(context).to_json context_b64 = JSONAPI.configuration.resource_cache_digest_function.call(context_json) @@ -148,8 +165,8 @@ def report_stats(stats) end end - def flatten_resource_id_tree(resource_id_tree, flattened_tree = {}) - resource_id_tree.fragments.each_pair do |resource_rid, fragment| + def flatten_resource_tree(resource_tree, flattened_tree = {}) + resource_tree.fragments.each_pair do |resource_rid, fragment| resource_klass = resource_rid.resource_klass id = resource_rid.id @@ -157,7 +174,8 @@ def flatten_resource_id_tree(resource_id_tree, flattened_tree = {}) flattened_tree[resource_klass] ||= {} flattened_tree[resource_klass][id] ||= {primary: fragment.primary, relationships: {}} - flattened_tree[resource_klass][id][:cache_id] ||= fragment.cache + flattened_tree[resource_klass][id][:cache_id] ||= fragment.cache if fragment.cache + flattened_tree[resource_klass][id][:resource] ||= fragment.resource if fragment.resource fragment.related.try(:each_pair) do |relationship_name, related_rids| flattened_tree[resource_klass][id][:relationships][relationship_name] ||= Set.new @@ -165,9 +183,9 @@ def flatten_resource_id_tree(resource_id_tree, flattened_tree = {}) end end - related_resource_id_trees = resource_id_tree.related_resource_id_trees - related_resource_id_trees.try(:each_value) do |related_resource_id_tree| - flatten_resource_id_tree(related_resource_id_tree, flattened_tree) + related_resource_trees = resource_tree.related_resource_trees + related_resource_trees.try(:each_value) do |related_resource_tree| + flatten_resource_tree(related_resource_tree, flattened_tree) end flattened_tree diff --git a/lib/jsonapi/resource_tree.rb b/lib/jsonapi/resource_tree.rb new file mode 100644 index 000000000..7f873c324 --- /dev/null +++ b/lib/jsonapi/resource_tree.rb @@ -0,0 +1,234 @@ +module JSONAPI + + # A tree structure representing the resource structure of the requested resource(s). This is an intermediate structure + # used to keep track of the resources, by identity, found at different included relationships. It will be flattened and + # the resource instances will be fetched from the cache or the record store. + class ResourceTree + + attr_reader :fragments, :related_resource_trees + + # Gets the related Resource Id Tree for a relationship, and creates it first if it does not exist + # + # @param relationship [JSONAPI::Relationship] + # + # @return [JSONAPI::RelatedResourceTree] the new or existing resource id tree for the requested relationship + def get_related_resource_tree(relationship) + relationship_name = relationship.name.to_sym + @related_resource_trees[relationship_name] ||= RelatedResourceTree.new(relationship, self) + end + + # Adds each Resource Fragment to the Resources hash + # + # @param fragments [Hash] + # @param include_related [Hash] + # + # @return [null] + def add_resource_fragments(fragments, include_related) + fragments.each_value do |fragment| + add_resource_fragment(fragment, include_related) + end + end + + # Adds a Resource Fragment to the fragments hash + # + # @param fragment [JSONAPI::ResourceFragment] + # @param include_related [Hash] + # + # @return [null] + def add_resource_fragment(fragment, include_related) + init_included_relationships(fragment, include_related) + + @fragments[fragment.identity] = fragment + end + + # Adds each Resource to the fragments hash + # + # @param resource [Hash] + # @param include_related [Hash] + # + # @return [null] + def add_resources(resources, include_related) + resources.each do |resource| + add_resource_fragment(JSONAPI::ResourceFragment.new(resource.identity, resource: resource), include_related) + end + end + + # Adds a Resource to the fragments hash + # + # @param fragment [JSONAPI::ResourceFragment] + # @param include_related [Hash] + # + # @return [null] + def add_resource(resource, include_related) + add_resource_fragment(JSONAPI::ResourceFragment.new(resource.identity, resource: resource), include_related) + end + + private + + def init_included_relationships(fragment, include_related) + include_related && include_related.each_key do |relationship_name| + fragment.initialize_related(relationship_name) + end + end + + def load_included(resource_klass, source_resource_tree, include_related, options) + include_related.try(:each_key) do |key| + relationship = resource_klass._relationship(key) + relationship_name = relationship.name.to_sym + + find_related_resource_options = options.except(:filters, :sort_criteria, :paginator) + find_related_resource_options[:sort_criteria] = relationship.resource_klass.default_sort + find_related_resource_options[:cache] = resource_klass.caching? + + related_fragments = resource_klass.find_included_fragments(source_resource_tree.fragments.values, + relationship_name, + find_related_resource_options) + + related_resource_tree = source_resource_tree.get_related_resource_tree(relationship) + related_resource_tree.add_resource_fragments(related_fragments, include_related[key][include_related]) + + # Now recursively get the related resources for the currently found resources + load_included(relationship.resource_klass, + related_resource_tree, + include_related[relationship_name][:include_related], + options) + end + end + + def add_resources_to_tree(resource_klass, + tree, + resources, + include_related, + source_rid: nil, + source_relationship_name: nil, + connect_source_identity: true) + fragments = {} + + resources.each do |resource| + next unless resource + + # fragments[resource.identity] ||= ResourceFragment.new(resource.identity, resource: resource) + # resource_fragment = fragments[resource.identity] + # ToDo: revert when not needed for testing + resource_fragment = if fragments[resource.identity] + fragments[resource.identity] + else + fragments[resource.identity] = ResourceFragment.new(resource.identity, resource: resource) + fragments[resource.identity] + end + + if resource.class.caching? + resource_fragment.cache = resource.cache_field_value + end + + linkage_relationships = resource_klass.to_one_relationships_for_linkage(resource.class, include_related) + linkage_relationships.each do |relationship_name| + related_resource = resource.send(relationship_name) + resource_fragment.add_related_identity(relationship_name, related_resource&.identity) + end + + if source_rid && connect_source_identity + resource_fragment.add_related_from(source_rid) + source_klass = source_rid.resource_klass + related_relationship_name = source_klass._relationships[source_relationship_name].inverse_relationship + if related_relationship_name + resource_fragment.add_related_identity(related_relationship_name, source_rid) + end + end + end + + tree.add_resource_fragments(fragments, include_related) + end + end + + class PrimaryResourceTree < ResourceTree + + # Creates a PrimaryResourceTree with no resources and no related ResourceTrees + def initialize(fragments: nil, resources: nil, resource: nil, include_related: nil, options: nil) + @fragments ||= {} + @related_resource_trees ||= {} + if fragments || resources || resource + if fragments + add_resource_fragments(fragments, include_related) + end + + if resources + add_resources(resources, include_related) + end + + if resource + add_resource(resource, include_related) + end + + complete_includes!(include_related, options) + end + end + + # Adds a Resource Fragment to the fragments hash + # + # @param fragment [JSONAPI::ResourceFragment] + # @param include_related [Hash] + # + # @return [null] + def add_resource_fragment(fragment, include_related) + fragment.primary = true + super(fragment, include_related) + end + + def complete_includes!(include_related, options) + # ToDo: can we skip if more than one resource_klass found? + resource_klasses = Set.new + @fragments.each_key { |identity| resource_klasses << identity.resource_klass } + + resource_klasses.each { |resource_klass| load_included(resource_klass, self, include_related, options)} + + self + end + end + + class RelatedResourceTree < ResourceTree + + attr_reader :parent_relationship, :source_resource_tree + + # Creates a RelatedResourceTree with no resources and no related ResourceTrees. A connection to the parent + # ResourceTree is maintained. + # + # @param parent_relationship [JSONAPI::Relationship] + # @param source_resource_tree [JSONAPI::ResourceTree] + # + # @return [JSONAPI::RelatedResourceTree] the new or existing resource id tree for the requested relationship + def initialize(parent_relationship, source_resource_tree) + @fragments ||= {} + @related_resource_trees ||= {} + + @parent_relationship = parent_relationship + @parent_relationship_name = parent_relationship.name.to_sym + @source_resource_tree = source_resource_tree + end + + # Adds a Resource Fragment to the fragments hash + # + # @param fragment [JSONAPI::ResourceFragment] + # @param include_related [Hash] + # + # @return [null] + def add_resource_fragment(fragment, include_related) + init_included_relationships(fragment, include_related) + + fragment.related_from.each do |rid| + @source_resource_tree.fragments[rid].add_related_identity(parent_relationship.name, fragment.identity) + end + + if @fragments[fragment.identity] + @fragments[fragment.identity].related_from.merge(fragment.related_from) + fragment.related.each_pair do |relationship_name, rids| + if rids + @fragments[fragment.identity].merge_related_identities(relationship_name, rids) + end + end + else + @fragments[fragment.identity] = fragment + end + end + end +end \ No newline at end of file diff --git a/test/controllers/controller_test.rb b/test/controllers/controller_test.rb index 3ecab8e83..af72da60e 100644 --- a/test/controllers/controller_test.rb +++ b/test/controllers/controller_test.rb @@ -24,6 +24,12 @@ def test_index assert json_response['data'].is_a?(Array) end + def test_index_includes + assert_cacheable_get :index, params: { include: 'author,comments' } + assert_response :success + assert json_response['data'].is_a?(Array) + end + def test_accept_header_missing @request.headers['Accept'] = nil diff --git a/test/fixtures/active_record.rb b/test/fixtures/active_record.rb index 0f25c5b10..e86edc6cb 100644 --- a/test/fixtures/active_record.rb +++ b/test/fixtures/active_record.rb @@ -1597,11 +1597,16 @@ def find(filters, options = {}) end # Records - def find_fragments(filters, options = {}) + def find_fragments(filters, options) fragments = {} find_records(filters, options).each do |record| rid = JSONAPI::ResourceIdentity.new(resource_klass, record.id) - fragments[rid] = JSONAPI::ResourceFragment.new(rid) + # We can use either the id or the full resource. + # fragments[rid] = JSONAPI::ResourceFragment.new(rid) + # OR + # fragments[rid] = JSONAPI::ResourceFragment.new(rid, resource: resource_klass.new(record, options[:context])) + # In this case we will use the resource since we already looked up the model instance + fragments[rid] = JSONAPI::ResourceFragment.new(rid, resource: resource_klass.new(record, options[:context])) end fragments end diff --git a/test/helpers/configuration_helpers.rb b/test/helpers/configuration_helpers.rb index 5afe3296d..b3f14f443 100644 --- a/test/helpers/configuration_helpers.rb +++ b/test/helpers/configuration_helpers.rb @@ -29,7 +29,7 @@ def with_resource_caching(cache, classes = :all) with_jsonapi_config(new_config_options) do if classes == :all or (classes.is_a?(Hash) && classes.keys == [:except]) resource_classes = ObjectSpace.each_object(Class).select do |klass| - if klass < JSONAPI::Resource + if klass < JSONAPI::BasicResource # Not using Resource#_model_class to avoid tripping the warning early, which could # cause ResourceTest#test_nil_model_class to fail. model_class = klass._model_name.to_s.safe_constantize diff --git a/test/helpers/value_matchers.rb b/test/helpers/value_matchers.rb index c5bb2ab01..2908f8d4f 100644 --- a/test/helpers/value_matchers.rb +++ b/test/helpers/value_matchers.rb @@ -5,13 +5,21 @@ def matches_value?(v1, v2, options = {}) if v1 == :any # any value is acceptable elsif v1 == :not_nil - return false if v2 == nil + if v2 == nil + return false + end elsif v1.kind_of?(Hash) - return false unless matches_hash?(v1, v2, options) + unless matches_hash?(v1, v2, options) + return false + end elsif v1.kind_of?(Array) - return false unless matches_array?(v1, v2, options) + unless matches_array?(v1, v2, options) + return false + end else - return false unless v2 == v1 + unless v2 == v1 + return false + end end true end @@ -19,7 +27,9 @@ def matches_value?(v1, v2, options = {}) def matches_array?(array1, array2, options = {}) return false unless array1.kind_of?(Array) && array2.kind_of?(Array) if options[:exact] - return false unless array1.size == array2.size + unless array1.size == array2.size + return false + end end # order of items shouldn't matter: @@ -36,7 +46,9 @@ def matches_array?(array1, array2, options = {}) break end end - return false unless matched.has_key?(i.to_s) + unless matched.has_key?(i.to_s) + return false + end end true end @@ -45,14 +57,18 @@ def matches_array?(array1, array2, options = {}) def matches_hash?(hash1, hash2, options = {}) return false unless hash1.kind_of?(Hash) && hash2.kind_of?(Hash) if options[:exact] - return false unless hash1.size == hash2.size + unless hash1.size == hash2.size + return false + end end hash1 = hash1.deep_symbolize_keys hash2 = hash2.deep_symbolize_keys hash1.each do |k1, v1| - return false unless hash2.has_key?(k1) && matches_value?(v1, hash2[k1], options) + unless hash2.has_key?(k1) && matches_value?(v1, hash2[k1], options) + return false + end end true end diff --git a/test/unit/processor/default_processor_test.rb b/test/unit/processor/default_processor_test.rb index 1158f23d0..9ee55ba78 100644 --- a/test/unit/processor/default_processor_test.rb +++ b/test/unit/processor/default_processor_test.rb @@ -19,7 +19,7 @@ def setup # no includes filters = { id: [10, 12] } - find_options = { filters: filters } + options = { filters: filters } params = { filters: filters, include_directives: {}, @@ -29,12 +29,12 @@ def setup serializer: {} } p = JSONAPI::Processor.new(PostResource, :find, params) - $id_tree_no_includes = p.send(:find_resource_id_tree, PostResource, find_options, nil) + $id_tree_no_includes = p.send(:find_resource_tree, options, nil) $resource_set_no_includes = JSONAPI::ResourceSet.new($id_tree_no_includes) $populated_resource_set_no_includes = JSONAPI::ResourceSet.new($id_tree_no_includes).populate!($serializer, nil,{}) # has_one included - directives = JSONAPI::IncludeDirectives.new(PostResource, ['author']).include_directives + directives = JSONAPI::IncludeDirectives.new(PostResource, ['author']) params = { filters: filters, include_directives: directives, @@ -45,7 +45,7 @@ def setup } p = JSONAPI::Processor.new(PostResource, :find, params) - $id_tree_has_one_includes = p.send(:find_resource_id_tree, PostResource, find_options, directives[:include_related]) + $id_tree_has_one_includes = p.send(:find_resource_tree, options, directives[:include_related]) $resource_set_has_one_includes = JSONAPI::ResourceSet.new($id_tree_has_one_includes) $populated_resource_set_has_one_includes = JSONAPI::ResourceSet.new($id_tree_has_one_includes).populate!($serializer, nil,{}) end @@ -60,8 +60,8 @@ def after_teardown PersonResource.caching nil end - def test_id_tree_without_includes_should_be_a_resource_id_tree - assert $id_tree_no_includes.is_a?(JSONAPI::PrimaryResourceIdTree) + def test_id_tree_without_includes_should_be_a_resource_tree + assert $id_tree_no_includes.is_a?(JSONAPI::PrimaryResourceTree) end def test_id_tree_without_includes_should_have_resources @@ -69,7 +69,7 @@ def test_id_tree_without_includes_should_have_resources end def test_id_tree_without_includes_should_not_have_related_resources - assert_empty $id_tree_no_includes.related_resource_id_trees + assert_empty $id_tree_no_includes.related_resource_trees end def test_id_tree_without_includes_resource_relationships_should_be_empty @@ -77,14 +77,14 @@ def test_id_tree_without_includes_resource_relationships_should_be_empty assert_equal 0, $id_tree_no_includes.fragments[JSONAPI::ResourceIdentity.new(PostResource, 12)].related.length end - def test_id_tree_has_one_includes_should_be_a_resource_id_tree - assert $id_tree_has_one_includes.is_a?(JSONAPI::PrimaryResourceIdTree) + def test_id_tree_has_one_includes_should_be_a_resource_tree + assert $id_tree_has_one_includes.is_a?(JSONAPI::PrimaryResourceTree) end def test_id_tree_has_one_includes_should_have_included_resources - assert $id_tree_has_one_includes.related_resource_id_trees.is_a?(Hash) - assert $id_tree_has_one_includes.related_resource_id_trees[:author].is_a?(JSONAPI::RelatedResourceIdTree) - assert_equal 2, $id_tree_has_one_includes.related_resource_id_trees[:author].fragments.size + assert $id_tree_has_one_includes.related_resource_trees.is_a?(Hash) + assert $id_tree_has_one_includes.related_resource_trees[:author].is_a?(JSONAPI::RelatedResourceTree) + assert_equal 2, $id_tree_has_one_includes.related_resource_trees[:author].fragments.size end def test_id_tree_has_one_includes_should_have_resources @@ -110,5 +110,4 @@ def test_populated_resource_set_has_one_includes_relationships_are_resolved assert_equal 10, $populated_resource_set_has_one_includes.resource_klasses[PersonResource][1003][:relationships][:posts].first.id assert_equal 12, $populated_resource_set_has_one_includes.resource_klasses[PersonResource][1004][:relationships][:posts].first.id end - end \ No newline at end of file diff --git a/test/unit/resource/active_relation_resource_test.rb b/test/unit/resource/active_relation_resource_test.rb index 59f57fcda..858009c9b 100644 --- a/test/unit/resource/active_relation_resource_test.rb +++ b/test/unit/resource/active_relation_resource_test.rb @@ -1,6 +1,6 @@ require File.expand_path('../../../test_helper', __FILE__) -class ARPostResource < JSONAPI::Resource +class ArPostResource < JSONAPI::Resource model_name 'Post' attribute :headline, delegate: :title has_one :author @@ -13,22 +13,22 @@ def setup def test_find_fragments_no_attributes filters = {} - posts_identities = ARPostResource.find_fragments(filters) + posts_identities = ArPostResource.find_fragments(filters) assert_equal 20, posts_identities.length - assert_equal JSONAPI::ResourceIdentity.new(ARPostResource, 1), posts_identities.keys[0] - assert_equal JSONAPI::ResourceIdentity.new(ARPostResource, 1), posts_identities.values[0].identity + assert_equal JSONAPI::ResourceIdentity.new(ArPostResource, 1), posts_identities.keys[0] + assert_equal JSONAPI::ResourceIdentity.new(ArPostResource, 1), posts_identities.values[0].identity assert posts_identities.values[0].is_a?(JSONAPI::ResourceFragment) end def test_find_fragments_cache_field filters = {} options = { cache: true } - posts_identities = ARPostResource.find_fragments(filters, options) + posts_identities = ArPostResource.find_fragments(filters, options) assert_equal 20, posts_identities.length - assert_equal JSONAPI::ResourceIdentity.new(ARPostResource, 1), posts_identities.keys[0] - assert_equal JSONAPI::ResourceIdentity.new(ARPostResource, 1), posts_identities.values[0].identity + assert_equal JSONAPI::ResourceIdentity.new(ArPostResource, 1), posts_identities.keys[0] + assert_equal JSONAPI::ResourceIdentity.new(ArPostResource, 1), posts_identities.values[0].identity assert posts_identities.values[0].is_a?(JSONAPI::ResourceFragment) assert posts_identities.values[0].cache.is_a?(ActiveSupport::TimeWithZone) end @@ -36,11 +36,11 @@ def test_find_fragments_cache_field def test_find_fragments_cache_field_attributes filters = {} options = { attributes: [:headline, :author_id], cache: true } - posts_identities = ARPostResource.find_fragments(filters, options) + posts_identities = ArPostResource.find_fragments(filters, options) assert_equal 20, posts_identities.length - assert_equal JSONAPI::ResourceIdentity.new(ARPostResource, 1), posts_identities.keys[0] - assert_equal JSONAPI::ResourceIdentity.new(ARPostResource, 1), posts_identities.values[0].identity + assert_equal JSONAPI::ResourceIdentity.new(ArPostResource, 1), posts_identities.keys[0] + assert_equal JSONAPI::ResourceIdentity.new(ArPostResource, 1), posts_identities.values[0].identity assert posts_identities.values[0].is_a?(JSONAPI::ResourceFragment) assert_equal 2, posts_identities.values[0].attributes.length assert posts_identities.values[0].cache.is_a?(ActiveSupport::TimeWithZone) @@ -50,11 +50,12 @@ def test_find_fragments_cache_field_attributes def test_find_related_has_one_fragments_no_attributes options = {} - source_rids = [JSONAPI::ResourceIdentity.new(ARPostResource, 1), - JSONAPI::ResourceIdentity.new(ARPostResource, 2), - JSONAPI::ResourceIdentity.new(ARPostResource, 20)] + source_rids = [JSONAPI::ResourceIdentity.new(ArPostResource, 1), + JSONAPI::ResourceIdentity.new(ArPostResource, 2), + JSONAPI::ResourceIdentity.new(ArPostResource, 20)] + source_fragments = source_rids.collect {|rid| JSONAPI::ResourceFragment.new(rid) } - related_fragments = ARPostResource.find_included_fragments(source_rids, 'author', options) + related_fragments = ArPostResource.find_included_fragments(source_fragments, 'author', options) assert_equal 2, related_fragments.length assert_equal JSONAPI::ResourceIdentity.new(AuthorResource, 1001), related_fragments.keys[0] @@ -65,11 +66,12 @@ def test_find_related_has_one_fragments_no_attributes def test_find_related_has_one_fragments_cache_field options = { cache: true } - source_rids = [JSONAPI::ResourceIdentity.new(ARPostResource, 1), - JSONAPI::ResourceIdentity.new(ARPostResource, 2), - JSONAPI::ResourceIdentity.new(ARPostResource, 20)] + source_rids = [JSONAPI::ResourceIdentity.new(ArPostResource, 1), + JSONAPI::ResourceIdentity.new(ArPostResource, 2), + JSONAPI::ResourceIdentity.new(ArPostResource, 20)] + source_fragments = source_rids.collect {|rid| JSONAPI::ResourceFragment.new(rid) } - related_fragments = ARPostResource.find_included_fragments(source_rids, 'author', options) + related_fragments = ArPostResource.find_included_fragments(source_fragments, 'author', options) assert_equal 2, related_fragments.length assert_equal JSONAPI::ResourceIdentity.new(AuthorResource, 1001), related_fragments.keys[0] @@ -81,11 +83,12 @@ def test_find_related_has_one_fragments_cache_field def test_find_related_has_one_fragments_cache_field_attributes options = { cache: true, attributes: [:name] } - source_rids = [JSONAPI::ResourceIdentity.new(ARPostResource, 1), - JSONAPI::ResourceIdentity.new(ARPostResource, 2), - JSONAPI::ResourceIdentity.new(ARPostResource, 20)] + source_rids = [JSONAPI::ResourceIdentity.new(ArPostResource, 1), + JSONAPI::ResourceIdentity.new(ArPostResource, 2), + JSONAPI::ResourceIdentity.new(ArPostResource, 20)] + source_fragments = source_rids.collect {|rid| JSONAPI::ResourceFragment.new(rid) } - related_fragments = ARPostResource.find_included_fragments(source_rids, 'author', options) + related_fragments = ArPostResource.find_included_fragments(source_fragments, 'author', options) assert_equal 2, related_fragments.length assert_equal JSONAPI::ResourceIdentity.new(AuthorResource, 1001), related_fragments.keys[0] @@ -99,12 +102,13 @@ def test_find_related_has_one_fragments_cache_field_attributes def test_find_related_has_many_fragments_no_attributes options = {} - source_rids = [JSONAPI::ResourceIdentity.new(ARPostResource, 1), - JSONAPI::ResourceIdentity.new(ARPostResource, 2), - JSONAPI::ResourceIdentity.new(ARPostResource, 12), - JSONAPI::ResourceIdentity.new(ARPostResource, 14)] + source_rids = [JSONAPI::ResourceIdentity.new(ArPostResource, 1), + JSONAPI::ResourceIdentity.new(ArPostResource, 2), + JSONAPI::ResourceIdentity.new(ArPostResource, 12), + JSONAPI::ResourceIdentity.new(ArPostResource, 14)] + source_fragments = source_rids.collect {|rid| JSONAPI::ResourceFragment.new(rid) } - related_fragments = ARPostResource.find_included_fragments(source_rids, 'tags', options) + related_fragments = ArPostResource.find_included_fragments(source_fragments, 'tags', options) assert_equal 8, related_fragments.length assert_equal JSONAPI::ResourceIdentity.new(TagResource, 501), related_fragments.keys[0] @@ -117,9 +121,10 @@ def test_find_related_has_many_fragments_no_attributes def test_find_related_has_many_fragments_pagination params = ActionController::Parameters.new(number: 2, size: 4) options = { paginator: PagedPaginator.new(params) } - source_rids = [JSONAPI::ResourceIdentity.new(ARPostResource, 15)] + source_rids = [JSONAPI::ResourceIdentity.new(ArPostResource, 15)] + source_fragments = source_rids.collect {|rid| JSONAPI::ResourceFragment.new(rid) } - related_fragments = ARPostResource.find_included_fragments(source_rids, 'tags', options) + related_fragments = ArPostResource.find_included_fragments(source_fragments, 'tags', options) assert_equal 1, related_fragments.length assert_equal JSONAPI::ResourceIdentity.new(TagResource, 516), related_fragments.keys[0] @@ -130,12 +135,13 @@ def test_find_related_has_many_fragments_pagination def test_find_related_has_many_fragments_cache_field options = { cache: true } - source_rids = [JSONAPI::ResourceIdentity.new(ARPostResource, 1), - JSONAPI::ResourceIdentity.new(ARPostResource, 2), - JSONAPI::ResourceIdentity.new(ARPostResource, 12), - JSONAPI::ResourceIdentity.new(ARPostResource, 14)] + source_rids = [JSONAPI::ResourceIdentity.new(ArPostResource, 1), + JSONAPI::ResourceIdentity.new(ArPostResource, 2), + JSONAPI::ResourceIdentity.new(ArPostResource, 12), + JSONAPI::ResourceIdentity.new(ArPostResource, 14)] + source_fragments = source_rids.collect {|rid| JSONAPI::ResourceFragment.new(rid) } - related_fragments = ARPostResource.find_included_fragments(source_rids, 'tags', options) + related_fragments = ArPostResource.find_included_fragments(source_fragments, 'tags', options) assert_equal 8, related_fragments.length assert_equal JSONAPI::ResourceIdentity.new(TagResource, 501), related_fragments.keys[0] @@ -148,12 +154,13 @@ def test_find_related_has_many_fragments_cache_field def test_find_related_has_many_fragments_cache_field_attributes options = { cache: true, attributes: [:name] } - source_rids = [JSONAPI::ResourceIdentity.new(ARPostResource, 1), - JSONAPI::ResourceIdentity.new(ARPostResource, 2), - JSONAPI::ResourceIdentity.new(ARPostResource, 12), - JSONAPI::ResourceIdentity.new(ARPostResource, 14)] + source_rids = [JSONAPI::ResourceIdentity.new(ArPostResource, 1), + JSONAPI::ResourceIdentity.new(ArPostResource, 2), + JSONAPI::ResourceIdentity.new(ArPostResource, 12), + JSONAPI::ResourceIdentity.new(ArPostResource, 14)] - related_fragments = ARPostResource.find_included_fragments(source_rids, 'tags', options) + source_fragments = source_rids.collect {|rid| JSONAPI::ResourceFragment.new(rid) } + related_fragments = ArPostResource.find_included_fragments(source_fragments, 'tags', options) assert_equal 8, related_fragments.length assert_equal JSONAPI::ResourceIdentity.new(TagResource, 501), related_fragments.keys[0] @@ -171,8 +178,9 @@ def test_find_related_polymorphic_fragments_no_attributes source_rids = [JSONAPI::ResourceIdentity.new(PictureResource, 1), JSONAPI::ResourceIdentity.new(PictureResource, 2), JSONAPI::ResourceIdentity.new(PictureResource, 3)] + source_fragments = source_rids.collect {|rid| JSONAPI::ResourceFragment.new(rid) } - related_fragments = PictureResource.find_included_fragments(source_rids, 'imageable', options) + related_fragments = PictureResource.find_included_fragments(source_fragments, 'imageable', options) assert_equal 2, related_fragments.length assert_equal JSONAPI::ResourceIdentity.new(ProductResource, 1), related_fragments.keys[0] @@ -189,8 +197,9 @@ def test_find_related_polymorphic_fragments_cache_field source_rids = [JSONAPI::ResourceIdentity.new(PictureResource, 1), JSONAPI::ResourceIdentity.new(PictureResource, 2), JSONAPI::ResourceIdentity.new(PictureResource, 3)] + source_fragments = source_rids.collect {|rid| JSONAPI::ResourceFragment.new(rid) } - related_fragments = PictureResource.find_included_fragments(source_rids, 'imageable', options) + related_fragments = PictureResource.find_included_fragments(source_fragments, 'imageable', options) assert_equal 2, related_fragments.length assert_equal JSONAPI::ResourceIdentity.new(ProductResource, 1), related_fragments.keys[0] @@ -208,8 +217,9 @@ def test_find_related_polymorphic_fragments_cache_field_attributes source_rids = [JSONAPI::ResourceIdentity.new(PictureResource, 1), JSONAPI::ResourceIdentity.new(PictureResource, 2), JSONAPI::ResourceIdentity.new(PictureResource, 3)] + source_fragments = source_rids.collect {|rid| JSONAPI::ResourceFragment.new(rid) } - related_fragments = PictureResource.find_included_fragments(source_rids, 'imageable', options) + related_fragments = PictureResource.find_included_fragments(source_fragments, 'imageable', options) assert_equal 2, related_fragments.length assert_equal JSONAPI::ResourceIdentity.new(ProductResource, 1), related_fragments.keys[0] diff --git a/test/unit/resource/resource_test.rb b/test/unit/resource/resource_test.rb index f765875d6..c92dd24b9 100644 --- a/test/unit/resource/resource_test.rb +++ b/test/unit/resource/resource_test.rb @@ -272,16 +272,16 @@ def test_filter_on_has_one_relationship_id def test_to_many_relationship_filters post_resource = PostResource.new(Post.find(1), nil) - comments = PostResource.find_included_fragments([post_resource.identity], :comments, {}) + comments = PostResource.find_included_fragments([post_resource], :comments, {}) assert_equal(2, comments.size) - filtered_comments = PostResource.find_included_fragments([post_resource.identity], :comments, { filters: { body: 'i liked it' } }) + filtered_comments = PostResource.find_included_fragments([post_resource], :comments, { filters: { body: 'i liked it' } }) assert_equal(1, filtered_comments.size) end def test_to_many_relationship_sorts post_resource = PostResource.new(Post.find(1), nil) - comment_ids = post_resource.class.find_included_fragments([post_resource.identity], :comments, {}).keys.collect {|c| c.id } + comment_ids = post_resource.class.find_included_fragments([post_resource], :comments, {}).keys.collect {|c| c.id } assert_equal [1,2], comment_ids # define apply_filters method on post resource to sort descending @@ -295,7 +295,7 @@ def apply_sort(records, _order_options, options) end sorted_comment_ids = post_resource.class.find_included_fragments( - [post_resource.identity], + [post_resource], :comments, { sort_criteria: [{ field: 'id', direction: :desc }] }).keys.collect {|c| c.id} diff --git a/test/unit/serializer/include_directives_test.rb b/test/unit/serializer/include_directives_test.rb index ad6e6710d..552d13b1b 100644 --- a/test/unit/serializer/include_directives_test.rb +++ b/test/unit/serializer/include_directives_test.rb @@ -4,7 +4,7 @@ class IncludeDirectivesTest < ActiveSupport::TestCase def test_one_level_one_include - directives = JSONAPI::IncludeDirectives.new(PersonResource, ['posts']).include_directives + directives = JSONAPI::IncludeDirectives.new(PersonResource, ['posts']).instance_variable_get(:@include_directives_hash) assert_hash_equals( { @@ -18,7 +18,7 @@ def test_one_level_one_include end def test_one_level_multiple_includes - directives = JSONAPI::IncludeDirectives.new(PersonResource, ['posts', 'comments', 'expense_entries']).include_directives + directives = JSONAPI::IncludeDirectives.new(PersonResource, ['posts', 'comments', 'expense_entries']).instance_variable_get(:@include_directives_hash) assert_hash_equals( { @@ -38,7 +38,7 @@ def test_one_level_multiple_includes end def test_multiple_level_multiple_includes - directives = JSONAPI::IncludeDirectives.new(PersonResource, ['posts', 'posts.comments', 'comments', 'expense_entries']).include_directives + directives = JSONAPI::IncludeDirectives.new(PersonResource, ['posts', 'posts.comments', 'comments', 'expense_entries']).instance_variable_get(:@include_directives_hash) assert_hash_equals( { @@ -63,7 +63,7 @@ def test_multiple_level_multiple_includes def test_two_levels_include_full_path - directives = JSONAPI::IncludeDirectives.new(PersonResource, ['posts.comments']).include_directives + directives = JSONAPI::IncludeDirectives.new(PersonResource, ['posts.comments']).instance_variable_get(:@include_directives_hash) assert_hash_equals( { @@ -81,7 +81,7 @@ def test_two_levels_include_full_path end def test_two_levels_include_full_path_redundant - directives = JSONAPI::IncludeDirectives.new(PersonResource, ['posts', 'posts.comments']).include_directives + directives = JSONAPI::IncludeDirectives.new(PersonResource, ['posts', 'posts.comments']).instance_variable_get(:@include_directives_hash) assert_hash_equals( { @@ -99,7 +99,7 @@ def test_two_levels_include_full_path_redundant end def test_three_levels_include_full - directives = JSONAPI::IncludeDirectives.new(PersonResource, ['posts.comments.tags']).include_directives + directives = JSONAPI::IncludeDirectives.new(PersonResource, ['posts.comments.tags']).instance_variable_get(:@include_directives_hash) assert_hash_equals( { @@ -127,19 +127,19 @@ def test_three_levels_include_full # def test_invalid_includes_1 assert_raises JSONAPI::Exceptions::InvalidInclude do - JSONAPI::IncludeDirectives.new(PersonResource, ['../../../../']).include_directives + JSONAPI::IncludeDirectives.new(PersonResource, ['../../../../']).instance_variable_get(:@include_directives_hash) end end def test_invalid_includes_2 assert_raises JSONAPI::Exceptions::InvalidInclude do - JSONAPI::IncludeDirectives.new(PersonResource, ['posts./sdaa./........']).include_directives + JSONAPI::IncludeDirectives.new(PersonResource, ['posts./sdaa./........']).instance_variable_get(:@include_directives_hash) end end def test_invalid_includes_3 assert_raises JSONAPI::Exceptions::InvalidInclude do - JSONAPI::IncludeDirectives.new(PersonResource, ['invalid../../../../']).include_directives + JSONAPI::IncludeDirectives.new(PersonResource, ['invalid../../../../']).instance_variable_get(:@include_directives_hash) end end end diff --git a/test/unit/serializer/serializer_test.rb b/test/unit/serializer/serializer_test.rb index b3cda28e8..33455b0f1 100644 --- a/test/unit/serializer/serializer_test.rb +++ b/test/unit/serializer/serializer_test.rb @@ -21,9 +21,9 @@ def after_teardown def test_serializer post_1_identity = JSONAPI::ResourceIdentity.new(PostResource, 1) - id_tree = JSONAPI::PrimaryResourceIdTree.new + id_tree = JSONAPI::PrimaryResourceTree.new - directives = JSONAPI::IncludeDirectives.new(PersonResource, ['']).include_directives + directives = JSONAPI::IncludeDirectives.new(PersonResource, ['']) id_tree.add_resource_fragment(JSONAPI::ResourceFragment.new(post_1_identity), directives[:include_related]) resource_set = JSONAPI::ResourceSet.new(id_tree) @@ -81,8 +81,64 @@ def test_serializer ) end + def test_serialize_source_to_hash + post = posts(:post_1) + post_resource = PostResource.new(post, {}) + + serializer = JSONAPI::ResourceSerializer.new( + PostResource, + base_url: 'http://example.com', + url_helpers: TestApp.routes.url_helpers) + + serialized = serializer.serialize_to_hash(post_resource) + + assert_hash_equals( + { + data: { + type: 'posts', + id: '1', + links: { + self: 'http://example.com/posts/1', + }, + attributes: { + title: 'New post', + body: 'A body!!!', + subject: 'New post' + }, + relationships: { + section: { + links: { + self: 'http://example.com/posts/1/relationships/section', + related: 'http://example.com/posts/1/section' + } + }, + author: { + links: { + self: 'http://example.com/posts/1/relationships/author', + related: 'http://example.com/posts/1/author' + } + }, + tags: { + links: { + self: 'http://example.com/posts/1/relationships/tags', + related: 'http://example.com/posts/1/tags' + } + }, + comments: { + links: { + self: 'http://example.com/posts/1/relationships/comments', + related: 'http://example.com/posts/1/comments' + } + } + } + } + }, + serialized + ) + end + def test_serializer_nil_handling - id_tree = JSONAPI::PrimaryResourceIdTree.new + id_tree = JSONAPI::PrimaryResourceTree.new resource_set = JSONAPI::ResourceSet.new(id_tree) @@ -104,9 +160,9 @@ def test_serializer_nil_handling def test_serializer_namespaced_resource_with_custom_resource_links post_1_identity = JSONAPI::ResourceIdentity.new(Api::V1::PostResource, 1) - id_tree = JSONAPI::PrimaryResourceIdTree.new + id_tree = JSONAPI::PrimaryResourceTree.new - directives = JSONAPI::IncludeDirectives.new(PersonResource, ['']).include_directives + directives = JSONAPI::IncludeDirectives.new(PersonResource, ['']) id_tree.add_resource_fragment(JSONAPI::ResourceFragment.new(post_1_identity), directives[:include_related]) resource_set = JSONAPI::ResourceSet.new(id_tree) @@ -161,9 +217,9 @@ def test_serializer_namespaced_resource_with_custom_resource_links def test_serializer_limited_fieldset post_1_identity = JSONAPI::ResourceIdentity.new(PostResource, 1) - id_tree = JSONAPI::PrimaryResourceIdTree.new + id_tree = JSONAPI::PrimaryResourceTree.new - directives = JSONAPI::IncludeDirectives.new(PersonResource, []).include_directives + directives = JSONAPI::IncludeDirectives.new(PersonResource, []) id_tree.add_resource_fragment(JSONAPI::ResourceFragment.new(post_1_identity), directives[:include_related]) resource_set = JSONAPI::ResourceSet.new(id_tree) @@ -204,18 +260,12 @@ def test_serializer_limited_fieldset def test_serializer_include post_1_identity = JSONAPI::ResourceIdentity.new(PostResource, 1) person_1001_identity = JSONAPI::ResourceIdentity.new(PersonResource, 1001) - id_tree = JSONAPI::PrimaryResourceIdTree.new + id_tree = JSONAPI::PrimaryResourceTree.new - directives = JSONAPI::IncludeDirectives.new(PostResource, ['author']).include_directives + directives = JSONAPI::IncludeDirectives.new(PostResource, ['author']) id_tree.add_resource_fragment(JSONAPI::ResourceFragment.new(post_1_identity), directives[:include_related]) - - rel_id_tree = id_tree.fetch_related_resource_id_tree(PostResource._relationships[:author]) - - author_fragment = JSONAPI::ResourceFragment.new(person_1001_identity) - author_fragment.add_related_from(post_1_identity) - author_fragment.add_related_identity(:posts, post_1_identity) - rel_id_tree.add_resource_fragment(author_fragment, directives[:include_related][:author][:include_related]) + id_tree.complete_includes!(directives[:include_related], {}) resource_set = JSONAPI::ResourceSet.new(id_tree) @@ -332,21 +382,297 @@ def test_serializer_include ) end + def test_serializer_source_to_hash_include + post = posts(:post_1) + post_resource = PostResource.new(post, {}) + + serializer = JSONAPI::ResourceSerializer.new( + PostResource, + url_helpers: TestApp.routes.url_helpers, + include_directives: JSONAPI::IncludeDirectives.new(PostResource, ['author'])) + + serialized = serializer.serialize_to_hash(post_resource) + + assert_hash_equals( + { + data: { + type: 'posts', + id: '1', + links: { + self: '/posts/1' + }, + attributes: { + title: 'New post', + body: 'A body!!!', + subject: 'New post' + }, + relationships: { + section: { + links: { + self: '/posts/1/relationships/section', + related: '/posts/1/section' + } + }, + author: { + links: { + self: '/posts/1/relationships/author', + related: '/posts/1/author' + }, + data: { + type: 'people', + id: '1001' + } + }, + tags: { + links: { + self: '/posts/1/relationships/tags', + related: '/posts/1/tags' + } + }, + comments: { + links: { + self: '/posts/1/relationships/comments', + related: '/posts/1/comments' + } + } + } + }, + included: [ + { + type: 'people', + id: '1001', + attributes: { + name: 'Joe Author', + email: 'joe@xyz.fake', + dateJoined: '2013-08-07 16:25:00 -0400' + }, + links: { + self: '/people/1001' + }, + relationships: { + comments: { + links: { + self: '/people/1001/relationships/comments', + related: '/people/1001/comments' + } + }, + posts: { + links: { + self: '/people/1001/relationships/posts', + related: '/people/1001/posts' + }, + data: [ + { + type: 'posts', + id: '1' + } + ] + }, + preferences: { + links: { + self: '/people/1001/relationships/preferences', + related: '/people/1001/preferences' + } + }, + hairCut: { + links: { + self: '/people/1001/relationships/hairCut', + related: '/people/1001/hairCut' + } + }, + vehicles: { + links: { + self: '/people/1001/relationships/vehicles', + related: '/people/1001/vehicles' + } + }, + expenseEntries: { + links: { + self: '/people/1001/relationships/expenseEntries', + related: '/people/1001/expenseEntries' + } + } + } + } + ] + }, + serialized + ) + end + + def test_serializer_source_array_to_hash_include + post_resources = [PostResource.new(posts(:post_1), {}), PostResource.new(posts(:post_2), {})] + + serializer = JSONAPI::ResourceSerializer.new( + PostResource, + url_helpers: TestApp.routes.url_helpers, + include_directives: JSONAPI::IncludeDirectives.new(PostResource, ['author'])) + + serialized = serializer.serialize_to_hash(post_resources) + + assert_hash_equals( + { + data: [ + { + type: 'posts', + id: '1', + links: { + self: '/posts/1' + }, + attributes: { + title: 'New post', + body: 'A body!!!', + subject: 'New post' + }, + relationships: { + section: { + links: { + self: '/posts/1/relationships/section', + related: '/posts/1/section' + } + }, + author: { + links: { + self: '/posts/1/relationships/author', + related: '/posts/1/author' + }, + data: { + type: 'people', + id: '1001' + } + }, + tags: { + links: { + self: '/posts/1/relationships/tags', + related: '/posts/1/tags' + } + }, + comments: { + links: { + self: '/posts/1/relationships/comments', + related: '/posts/1/comments' + } + } + } + }, + { + type: 'posts', + id: '2', + links: { + self: '/posts/2' + }, + attributes: { + title: 'JR Solves your serialization woes!', + body: 'Use JR', + subject: 'JR Solves your serialization woes!' + }, + relationships: { + section: { + links: { + self: '/posts/2/relationships/section', + related: '/posts/2/section' + } + }, + author: { + links: { + self: '/posts/2/relationships/author', + related: '/posts/2/author' + }, + data: { + type: 'people', + id: '1001' + } + }, + tags: { + links: { + self: '/posts/2/relationships/tags', + related: '/posts/2/tags' + } + }, + comments: { + links: { + self: '/posts/2/relationships/comments', + related: '/posts/2/comments' + } + } + } + } + ], + included: [ + { + type: 'people', + id: '1001', + attributes: { + name: 'Joe Author', + email: 'joe@xyz.fake', + dateJoined: '2013-08-07 16:25:00 -0400' + }, + links: { + self: '/people/1001' + }, + relationships: { + comments: { + links: { + self: '/people/1001/relationships/comments', + related: '/people/1001/comments' + } + }, + posts: { + links: { + self: '/people/1001/relationships/posts', + related: '/people/1001/posts' + }, + data: [ + { + type: 'posts', + id: '1' + }, + { + type: 'posts', + id: '2' + } + ] + }, + preferences: { + links: { + self: '/people/1001/relationships/preferences', + related: '/people/1001/preferences' + } + }, + hairCut: { + links: { + self: '/people/1001/relationships/hairCut', + related: '/people/1001/hairCut' + } + }, + vehicles: { + links: { + self: '/people/1001/relationships/vehicles', + related: '/people/1001/vehicles' + } + }, + expenseEntries: { + links: { + self: '/people/1001/relationships/expenseEntries', + related: '/people/1001/expenseEntries' + } + } + } + } + ] + }, + serialized + ) + end + def test_serializer_key_format post_1_identity = JSONAPI::ResourceIdentity.new(PostResource, 1) - person_1001_identity = JSONAPI::ResourceIdentity.new(PersonResource, 1001) - id_tree = JSONAPI::PrimaryResourceIdTree.new + id_tree = JSONAPI::PrimaryResourceTree.new - directives = JSONAPI::IncludeDirectives.new(PostResource, ['author']).include_directives + directives = JSONAPI::IncludeDirectives.new(PostResource, ['author']) id_tree.add_resource_fragment(JSONAPI::ResourceFragment.new(post_1_identity), directives[:include_related]) - - rel_id_tree = id_tree.fetch_related_resource_id_tree(PostResource._relationships[:author]) - - author_fragment = JSONAPI::ResourceFragment.new(person_1001_identity) - author_fragment.add_related_from(post_1_identity) - author_fragment.add_related_identity(:posts, post_1_identity) - rel_id_tree.add_resource_fragment(author_fragment, directives[:include_related][:author][:include_related]) + id_tree.complete_includes!(directives[:include_related], {}) resource_set = JSONAPI::ResourceSet.new(id_tree) @@ -469,9 +795,9 @@ def test_serializers_linkage_even_without_included_resource post_1_identity = JSONAPI::ResourceIdentity.new(PostResource, 1) person_1001_identity = JSONAPI::ResourceIdentity.new(PersonResource, 1001) - id_tree = JSONAPI::PrimaryResourceIdTree.new + id_tree = JSONAPI::PrimaryResourceTree.new - directives = JSONAPI::IncludeDirectives.new(PersonResource, []).include_directives + directives = JSONAPI::IncludeDirectives.new(PersonResource, []) fragment = JSONAPI::ResourceFragment.new(post_1_identity) @@ -539,4 +865,122 @@ def test_serializers_linkage_even_without_included_resource serialized ) end + + def test_serializer_include_from_resource + serializer = JSONAPI::ResourceSerializer.new(PostResource, url_helpers: TestApp.routes.url_helpers) + + directives = JSONAPI::IncludeDirectives.new(PostResource, ['author']) + + resource_set = JSONAPI::ResourceSet.new(PostResource.find_by_key(1), directives[:include_related], {}) + resource_set.populate!(serializer, {}, {}) + + serialized = serializer.serialize_resource_set_to_hash_single(resource_set) + + assert_hash_equals( + { + data: { + type: 'posts', + id: '1', + links: { + self: '/posts/1' + }, + attributes: { + title: 'New post', + body: 'A body!!!', + subject: 'New post' + }, + relationships: { + section: { + links: { + self: '/posts/1/relationships/section', + related: '/posts/1/section' + } + }, + author: { + links: { + self: '/posts/1/relationships/author', + related: '/posts/1/author' + }, + data: { + type: 'people', + id: '1001' + } + }, + tags: { + links: { + self: '/posts/1/relationships/tags', + related: '/posts/1/tags' + } + }, + comments: { + links: { + self: '/posts/1/relationships/comments', + related: '/posts/1/comments' + } + } + } + }, + included: [ + { + type: 'people', + id: '1001', + attributes: { + name: 'Joe Author', + email: 'joe@xyz.fake', + dateJoined: '2013-08-07 16:25:00 -0400' + }, + links: { + self: '/people/1001' + }, + relationships: { + comments: { + links: { + self: '/people/1001/relationships/comments', + related: '/people/1001/comments' + } + }, + posts: { + links: { + self: '/people/1001/relationships/posts', + related: '/people/1001/posts' + }, + data: [ + { + type: 'posts', + id: '1' + } + ] + }, + preferences: { + links: { + self: '/people/1001/relationships/preferences', + related: '/people/1001/preferences' + } + }, + hairCut: { + links: { + self: '/people/1001/relationships/hairCut', + related: '/people/1001/hairCut' + } + }, + vehicles: { + links: { + self: '/people/1001/relationships/vehicles', + related: '/people/1001/vehicles' + } + }, + expenseEntries: { + links: { + self: '/people/1001/relationships/expenseEntries', + related: '/people/1001/expenseEntries' + } + } + } + } + ] + }, + serialized + ) + end + end From e89482775c278c7af6b40bb2d3cc8d1f478e7170 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Thu, 4 Feb 2021 10:27:22 -0500 Subject: [PATCH 199/237] Setup github actions for CI (#1354) --- .github/workflows/ruby.yml | 62 ++++++++++++++++++++++++++++++++++++++ .travis.yml | 33 -------------------- 2 files changed, 62 insertions(+), 33 deletions(-) create mode 100644 .github/workflows/ruby.yml delete mode 100644 .travis.yml diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml new file mode 100644 index 000000000..1c2087bc8 --- /dev/null +++ b/.github/workflows/ruby.yml @@ -0,0 +1,62 @@ +name: CI + +on: + push: + branches: [ 'master', 'release-0-8', 'release-0-9', 'release-0-10' ] + pull_request: + branches: ['**'] + +jobs: + tests: + runs-on: ubuntu-latest + services: + postgres: + image: postgres + env: + POSTGRES_PASSWORD: password + POSTGRES_DB: test + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + strategy: + fail-fast: false + matrix: + ruby: + - 2.6.6 + - 2.7.2 + - 3.0.0 + rails: + - 6.1.1 + - 6.0.3.4 + - 5.2.4.4 + - 5.1.7 + database_url: + - postgresql://postgres:password@localhost:5432/test + - sqlite3:test_db + exclude: + - ruby: 3.0.0 + rails: 6.0.3.4 + - ruby: 3.0.0 + rails: 5.2.4.4 + - ruby: 3.0.0 + rails: 5.1.7 + - database_url: postgresql://postgres:password@localhost:5432/test + rails: 5.1.7 + env: + RAILS_VERSION: ${{ matrix.rails }} + DATABASE_URL: ${{ matrix.database_url }} + name: Ruby ${{ matrix.ruby }} Rails ${{ matrix.rails }} DB ${{ matrix.database_url }} + steps: + - uses: actions/checkout@v2 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + - name: Install dependencies + run: bundle install --jobs 4 --retry 3 + - name: Run tests + run: bundle exec rake test \ No newline at end of file diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index a7dc8a3b8..000000000 --- a/.travis.yml +++ /dev/null @@ -1,33 +0,0 @@ -language: ruby -sudo: false -services: - - postgresql -env: - - RAILS_VERSION=6.1.1 DATABASE_URL=postgres://postgres@localhost/jr_test - - RAILS_VERSION=6.1.1 - - RAILS_VERSION=6.0.3.4 DATABASE_URL=postgres://postgres@localhost/jr_test - - RAILS_VERSION=6.0.3.4 - - RAILS_VERSION=5.2.4.4 DATABASE_URL=postgres://postgres@localhost/jr_test - - RAILS_VERSION=5.2.4.4 - - RAILS_VERSION=5.1.7 -rvm: - - 2.6.6 - - 2.7.2 - - 3.0.0 -matrix: - exclude: - - rvm: 3.0.0 - env: RAILS_VERSION=6.0.3.4 DATABASE_URL=postgres://postgres@localhost/jr_test - - rvm: 3.0.0 - env: RAILS_VERSION=6.0.3.4 - - rvm: 3.0.0 - env: RAILS_VERSION=5.2.4.4 DATABASE_URL=postgres://postgres@localhost/jr_test - - rvm: 3.0.0 - env: RAILS_VERSION=5.2.4.4 - - rvm: 3.0.0 - env: RAILS_VERSION=5.1.7 -before_install: - - gem install bundler --version 2.2.5 -before_script: - - sh -c "if [ '$DATABASE_URL' = 'postgres://postgres@localhost/jr_test' ]; then psql -c 'DROP DATABASE IF EXISTS jr_test;' -U postgres; fi" - - sh -c "if [ '$DATABASE_URL' = 'postgres://postgres@localhost/jr_test' ]; then psql -c 'CREATE DATABASE jr_test;' -U postgres; fi" \ No newline at end of file From 856f139a95abb0a5efc2949d1706aa1f8f8048b0 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Thu, 4 Feb 2021 15:02:38 -0500 Subject: [PATCH 200/237] Remove code specifically for pre 5.1 rails versions (#1353) * Remove code specifically for pre 5.1 rails versions * Update rails version supported --- README.md | 2 +- jsonapi-resources.gemspec | 4 +- .../join_left_active_record_adapter.rb | 1 - lib/jsonapi/basic_resource.rb | 14 +- lib/jsonapi/mime_types.rb | 14 +- test/fixtures/active_record.rb | 26 +-- test/test_helper.rb | 173 +++++++----------- .../active_record_adapter_test.rb | 17 +- test/unit/resource/resource_test.rb | 9 +- 9 files changed, 85 insertions(+), 175 deletions(-) diff --git a/README.md b/README.md index a180df5e5..377e49304 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Like JSON:API itself, JR's design is focused on the resources served by an API. JR needs little more than a definition of your resources, including their attributes and relationships, to make your server compliant with JSON API. -JR is designed to work with Rails 4.2+, and provides custom routes, controllers, and serializers. JR's resources may be +JR is designed to work with Rails 5.1+, and provides custom routes, controllers, and serializers. JR's resources may be backed by ActiveRecord models or by custom objects. ## Documentation diff --git a/jsonapi-resources.gemspec b/jsonapi-resources.gemspec index 8796b9637..eb3c67fa5 100644 --- a/jsonapi-resources.gemspec +++ b/jsonapi-resources.gemspec @@ -27,7 +27,7 @@ Gem::Specification.new do |spec| spec.add_development_dependency 'pry' spec.add_development_dependency 'concurrent-ruby-ext' spec.add_development_dependency 'database_cleaner' - spec.add_dependency 'activerecord', '>= 4.1' - spec.add_dependency 'railties', '>= 4.1' + spec.add_dependency 'activerecord', '>= 5.1' + spec.add_dependency 'railties', '>= 5.1' spec.add_dependency 'concurrent-ruby' end diff --git a/lib/jsonapi/active_relation/adapters/join_left_active_record_adapter.rb b/lib/jsonapi/active_relation/adapters/join_left_active_record_adapter.rb index 42eb47b6a..a9a0bb8a0 100644 --- a/lib/jsonapi/active_relation/adapters/join_left_active_record_adapter.rb +++ b/lib/jsonapi/active_relation/adapters/join_left_active_record_adapter.rb @@ -2,7 +2,6 @@ module JSONAPI module ActiveRelation module Adapters module JoinLeftActiveRecordAdapter - # Extends left_joins functionality to rails 4, and uses the same logic for rails 5.0.x and 5.1.x # The default left_joins logic of rails 5.2.x is used. This results in and extra join in some cases. For # example Post.joins(:comments).joins_left(comments: :author) will join the comments table twice, diff --git a/lib/jsonapi/basic_resource.rb b/lib/jsonapi/basic_resource.rb index 5331b4f9d..ec92a636c 100644 --- a/lib/jsonapi/basic_resource.rb +++ b/lib/jsonapi/basic_resource.rb @@ -575,20 +575,12 @@ def attribute_to_model_field(attribute) 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} + + { name: field_name, type: _model_class.attribute_types[field_name.to_s]} 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 + type.cast(value) end def default_attribute_options diff --git a/lib/jsonapi/mime_types.rb b/lib/jsonapi/mime_types.rb index 78e8f1d4f..981998048 100644 --- a/lib/jsonapi/mime_types.rb +++ b/lib/jsonapi/mime_types.rb @@ -7,16 +7,10 @@ module MimeTypes def self.install Mime::Type.register JSONAPI::MEDIA_TYPE, :api_json - # :nocov: - if Rails::VERSION::MAJOR >= 5 - parsers = ActionDispatch::Request.parameter_parsers.merge( - Mime::Type.lookup(JSONAPI::MEDIA_TYPE).symbol => parser - ) - ActionDispatch::Request.parameter_parsers = parsers - else - ActionDispatch::ParamsParser::DEFAULT_PARSERS[Mime::Type.lookup(JSONAPI::MEDIA_TYPE)] = parser - end - # :nocov: + parsers = ActionDispatch::Request.parameter_parsers.merge( + Mime::Type.lookup(JSONAPI::MEDIA_TYPE).symbol => parser + ) + ActionDispatch::Request.parameter_parsers = parsers end def self.parser diff --git a/test/fixtures/active_record.rb b/test/fixtures/active_record.rb index e86edc6cb..1209302fd 100644 --- a/test/fixtures/active_record.rb +++ b/test/fixtures/active_record.rb @@ -517,24 +517,10 @@ def destroy_callback case title when "can't destroy me", "can't destroy me either" errors.add(:base, "can't destroy me") - - # :nocov: - if Rails::VERSION::MAJOR >= 5 - throw(:abort) - else - return false - end - # :nocov: + throw(:abort) when "locked title" errors.add(:title, "is locked") - - # :nocov: - if Rails::VERSION::MAJOR >= 5 - throw(:abort) - else - return false - end - # :nocov: + throw(:abort) end end end @@ -605,13 +591,7 @@ class Planet < ActiveRecord::Base def check_not_pluto # Pluto can't be a planet, so cancel the save if name.downcase == 'pluto' - # :nocov: - if Rails::VERSION::MAJOR >= 5 - throw(:abort) - else - return false - end - # :nocov: + throw(:abort) end end end diff --git a/test/test_helper.rb b/test/test_helper.rb index 34506e99a..2335cb2a8 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -5,16 +5,15 @@ # COVERAGE=true bundle exec rake test # To test on a specific rails version use this: -# export RAILS_VERSION=4.2.6; bundle update rails; bundle exec rake test -# export RAILS_VERSION=5.0.0; bundle update rails; bundle exec rake test -# export RAILS_VERSION=5.1.0; bundle update rails; bundle exec rake test -# export RAILS_VERSION=6.0.0.beta1; bundle update rails; bundle exec rake test +# export RAILS_VERSION=5.2.4.4; bundle update; bundle exec rake test +# export RAILS_VERSION=6.0.3.4; bundle update; bundle exec rake test +# export RAILS_VERSION=6.1.1; bundle update; bundle exec rake test -# We are no longer having Travis test Rails 4.1.x., but you can try it with: -# export RAILS_VERSION=4.1.0; bundle update rails; bundle exec rake test +# We are no longer having Travis test Rails 4.2.11., but you can try it with: +# export RAILS_VERSION=4.2.11; bundle update rails; bundle exec rake test # To Switch rails versions and run a particular test order: -# export RAILS_VERSION=4.2.6; bundle update rails; bundle exec rake TESTOPTS="--seed=39333" test +# export RAILS_VERSION=6.1.1; bundle update; bundle exec rake TESTOPTS="--seed=39333" test if ENV['COVERAGE'] SimpleCov.start do @@ -60,13 +59,11 @@ class TestApp < Rails::Application config.active_record.schema_format = :none config.active_support.test_order = :random - if Rails::VERSION::MAJOR >= 5 - config.active_support.halt_callback_chains_on_return_false = false - config.active_record.time_zone_aware_types = [:time, :datetime] - config.active_record.belongs_to_required_by_default = false - if Rails::VERSION::MINOR >= 2 - config.active_record.sqlite3.represent_boolean_as_integer = true - end + config.active_support.halt_callback_chains_on_return_false = false + config.active_record.time_zone_aware_types = [:time, :datetime] + config.active_record.belongs_to_required_by_default = false + unless Rails::VERSION::MAJOR == 5 && Rails::VERSION::MINOR < 2 + config.active_record.sqlite3.represent_boolean_as_integer = true end end @@ -86,116 +83,80 @@ class Engine < ::Rails::Engine end # Monkeypatch ActionController::TestCase to delete the RAW_POST_DATA on subsequent calls in the same test. -if Rails::VERSION::MAJOR >= 5 - module ClearRawPostHeader - def process(action, **args) - @request.delete_header 'RAW_POST_DATA' - super action, **args - end - end - - class ActionController::TestCase - prepend ClearRawPostHeader +module ClearRawPostHeader + def process(action, **args) + @request.delete_header 'RAW_POST_DATA' + super action, **args end end -# Tests are now using the rails 5 format for the http methods. So for rails 4 we will simply convert them back -# in a standard way. -if Rails::VERSION::MAJOR < 5 - module Rails4ActionControllerProcess - def process(*args) - if args[2] && args[2][:params] - args[2] = args[2][:params] - end - super - end - end - class ActionController::TestCase - prepend Rails4ActionControllerProcess - end - - module ActionDispatch - module Integration #:nodoc: - module Rails4IntegrationProcess - def process(method, path, parameters = nil, headers_or_env = nil) - params = parameters.nil? ? nil : parameters[:params] - headers = parameters.nil? ? nil : parameters[:headers] - super method, path, params, headers - end - end - - class Session - prepend Rails4IntegrationProcess - end - end - end +class ActionController::TestCase + prepend ClearRawPostHeader end # Patch to allow :api_json mime type to be treated as JSON # Otherwise it is run through `to_query` and empty arrays are dropped. -if Rails::VERSION::MAJOR >= 5 - module ActionController - class TestRequest < ActionDispatch::TestRequest - def assign_parameters(routes, controller_path, action, parameters, generated_path, query_string_keys) - non_path_parameters = {} - path_parameters = {} - - parameters.each do |key, value| - if query_string_keys.include?(key) - non_path_parameters[key] = value +module ActionController + class TestRequest < ActionDispatch::TestRequest + def assign_parameters(routes, controller_path, action, parameters, generated_path, query_string_keys) + non_path_parameters = {} + path_parameters = {} + + parameters.each do |key, value| + if query_string_keys.include?(key) + non_path_parameters[key] = value + else + if value.is_a?(Array) + value = value.map(&:to_param) else - if value.is_a?(Array) - value = value.map(&:to_param) - else - value = value.to_param - end - - path_parameters[key] = value + value = value.to_param end + + path_parameters[key] = value end + end - if get? - if self.query_string.blank? - self.query_string = non_path_parameters.to_query - end + if get? + if self.query_string.blank? + self.query_string = non_path_parameters.to_query + end + else + if ENCODER.should_multipart?(non_path_parameters) + self.content_type = ENCODER.content_type + data = ENCODER.build_multipart non_path_parameters else - if ENCODER.should_multipart?(non_path_parameters) - self.content_type = ENCODER.content_type - data = ENCODER.build_multipart non_path_parameters - else - fetch_header('CONTENT_TYPE') do |k| - set_header k, 'application/x-www-form-urlencoded' - end - - # parser = ActionDispatch::Http::Parameters::DEFAULT_PARSERS[Mime::Type.lookup(fetch_header('CONTENT_TYPE'))] - - case content_mime_type.to_sym - when nil - raise "Unknown Content-Type: #{content_type}" - when :json, :api_json - data = ActiveSupport::JSON.encode(non_path_parameters) - when :xml - data = non_path_parameters.to_xml - when :url_encoded_form - data = non_path_parameters.to_query - else - @custom_param_parsers[content_mime_type] = ->(_) { non_path_parameters } - data = non_path_parameters.to_query - end + fetch_header('CONTENT_TYPE') do |k| + set_header k, 'application/x-www-form-urlencoded' end - set_header 'CONTENT_LENGTH', data.length.to_s - set_header 'rack.input', StringIO.new(data) + # parser = ActionDispatch::Http::Parameters::DEFAULT_PARSERS[Mime::Type.lookup(fetch_header('CONTENT_TYPE'))] + + case content_mime_type.to_sym + when nil + raise "Unknown Content-Type: #{content_type}" + when :json, :api_json + data = ActiveSupport::JSON.encode(non_path_parameters) + when :xml + data = non_path_parameters.to_xml + when :url_encoded_form + data = non_path_parameters.to_query + else + @custom_param_parsers[content_mime_type] = ->(_) { non_path_parameters } + data = non_path_parameters.to_query + end end - fetch_header("PATH_INFO") do |k| - set_header k, generated_path - end - path_parameters[:controller] = controller_path - path_parameters[:action] = action + set_header 'CONTENT_LENGTH', data.length.to_s + set_header 'rack.input', StringIO.new(data) + end - self.path_parameters = path_parameters + fetch_header("PATH_INFO") do |k| + set_header k, generated_path end + path_parameters[:controller] = controller_path + path_parameters[:action] = action + + self.path_parameters = path_parameters end end end diff --git a/test/unit/active_relation_resource_finder/active_record_adapter_test.rb b/test/unit/active_relation_resource_finder/active_record_adapter_test.rb index 0456b9dfb..125893f81 100644 --- a/test/unit/active_relation_resource_finder/active_record_adapter_test.rb +++ b/test/unit/active_relation_resource_finder/active_record_adapter_test.rb @@ -11,19 +11,8 @@ def test_joins_left def test_joins_left_through_inner sql = Post.joins(:comments).joins_left(comments: :author).to_sql - - # Note this joins_left reverts to left_joins on rails 5.2 and later - # This behaves slightly differently in that the base join table is joined twice using left the second time (in this test). - # This should produce the same result set, but will be slightly less efficient on the database - if Rails::VERSION::MAJOR >= 5 && ActiveRecord::VERSION::MINOR >= 2 - assert_equal 'SELECT "posts".* FROM "posts" INNER JOIN "comments" ON "comments"."post_id" = "posts"."id" '\ - 'LEFT OUTER JOIN "comments" "comments_posts" ON "comments_posts"."post_id" = "posts"."id" '\ - 'LEFT OUTER JOIN "people" ON "people"."id" = "comments_posts"."author_id"', - sql - else - assert_equal 'SELECT "posts".* FROM "posts" INNER JOIN "comments" ON "comments"."post_id" = "posts"."id" ' \ - 'LEFT OUTER JOIN "people" ON "people"."id" = "comments"."author_id"', - sql - end + assert_equal 'SELECT "posts".* FROM "posts" INNER JOIN "comments" ON "comments"."post_id" = "posts"."id" ' \ + 'LEFT OUTER JOIN "people" ON "people"."id" = "comments"."author_id"', + sql end end diff --git a/test/unit/resource/resource_test.rb b/test/unit/resource/resource_test.rb index c92dd24b9..21e8aec82 100644 --- a/test/unit/resource/resource_test.rb +++ b/test/unit/resource/resource_test.rb @@ -178,14 +178,9 @@ def test_inherited_calls_superclass end def test_nil_model_class - # ToDo:Figure out why this test does not work on Rails 4.0 - # :nocov: - if (Rails::VERSION::MAJOR >= 4 && Rails::VERSION::MINOR >= 1) || (Rails::VERSION::MAJOR >= 5) - assert_output nil, "[MODEL NOT FOUND] Model could not be found for NoMatchResource. If this is a base Resource declare it as abstract.\n" do - assert_nil NoMatchResource._model_class - end + assert_output nil, "[MODEL NOT FOUND] Model could not be found for NoMatchResource. If this is a base Resource declare it as abstract.\n" do + assert_nil NoMatchResource._model_class end - # :nocov: end def test_nil_abstract_model_class From 695bb7cb51cec2fb54dfb8032d0e07ea1d577c51 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Mon, 12 Apr 2021 15:00:40 -0400 Subject: [PATCH 201/237] Fix some testing issues including flappy tests, simplify quoting generated SQL strings (#1363) * Refine when config.active_record.sqlite3.represent_boolean_as_integer is set Tests with rails > 6.1.1 breaks with this option * Update rails 6.1 tested version * Add quoting for fields in built up sql * Remove tests for generated sql statements These are changing with new rails versions and it's creating a lot of false failures * Remove tests for generated sql statements using joins_left * Fix default sort for when empty array is provided Fixes issue with relationship requests not getting a default sort * Add helper methods for generating sql fields with aliases and quotes --- .github/workflows/ruby.yml | 2 +- lib/jsonapi/active_relation_resource.rb | 78 +++++++++++++------ lib/jsonapi/basic_resource.rb | 2 +- test/test_helper.rb | 2 +- .../active_record_adapter_test.rb | 18 ----- .../join_manager_test.rb | 57 -------------- 6 files changed, 56 insertions(+), 103 deletions(-) delete mode 100644 test/unit/active_relation_resource_finder/active_record_adapter_test.rb diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml index 1c2087bc8..b0fd4e1b4 100644 --- a/.github/workflows/ruby.yml +++ b/.github/workflows/ruby.yml @@ -30,7 +30,7 @@ jobs: - 2.7.2 - 3.0.0 rails: - - 6.1.1 + - 6.1.3.1 - 6.0.3.4 - 5.2.4.4 - 5.1.7 diff --git a/lib/jsonapi/active_relation_resource.rb b/lib/jsonapi/active_relation_resource.rb index 80b2261a8..c86311725 100644 --- a/lib/jsonapi/active_relation_resource.rb +++ b/lib/jsonapi/active_relation_resource.rb @@ -121,11 +121,11 @@ def find_fragments(filters, options = {}) # This alias is going to be resolve down to the model's table name and will not actually be an alias resource_table_alias = resource_klass._table_name - pluck_fields = [Arel.sql("#{concat_table_field(resource_table_alias, resource_klass._primary_key)} AS #{resource_table_alias}_#{resource_klass._primary_key}")] + pluck_fields = [sql_field_with_alias(resource_table_alias, resource_klass._primary_key)] cache_field = attribute_to_model_field(:_cache_field) if options[:cache] if cache_field - pluck_fields << Arel.sql("#{concat_table_field(resource_table_alias, cache_field[:name])} AS #{resource_table_alias}_#{cache_field[:name]}") + pluck_fields << sql_field_with_alias(resource_table_alias, cache_field[:name]) end linkage_fields = [] @@ -141,10 +141,10 @@ def find_fragments(filters, options = {}) linkage_fields << {relationship_name: name, resource_klass: klass, - field: "#{concat_table_field(linkage_table_alias, primary_key)} AS #{linkage_table_alias}_#{primary_key}", - alias: "#{linkage_table_alias}_#{primary_key}"} + field: sql_field_with_alias(linkage_table_alias, primary_key), + alias: alias_table_field(linkage_table_alias, primary_key)} - pluck_fields << Arel.sql("#{concat_table_field(linkage_table_alias, primary_key)} AS #{linkage_table_alias}_#{primary_key}") + pluck_fields << sql_field_with_alias(linkage_table_alias, primary_key) end else klass = linkage_relationship.resource_klass @@ -153,10 +153,10 @@ def find_fragments(filters, options = {}) linkage_fields << {relationship_name: name, resource_klass: klass, - field: "#{concat_table_field(linkage_table_alias, primary_key)} AS #{linkage_table_alias}_#{primary_key}", - alias: "#{linkage_table_alias}_#{primary_key}"} + field: sql_field_with_alias(linkage_table_alias, primary_key), + alias: alias_table_field(linkage_table_alias, primary_key)} - pluck_fields << Arel.sql("#{concat_table_field(linkage_table_alias, primary_key)} AS #{linkage_table_alias}_#{primary_key}") + pluck_fields << sql_field_with_alias(linkage_table_alias, primary_key) end end @@ -165,7 +165,7 @@ def find_fragments(filters, options = {}) attributes.try(:each) do |attribute| model_field = resource_klass.attribute_to_model_field(attribute) model_fields[attribute] = model_field - pluck_fields << Arel.sql("#{concat_table_field(resource_table_alias, model_field[:name])} AS #{resource_table_alias}_#{model_field[:name]}") + pluck_fields << sql_field_with_alias(resource_table_alias, model_field[:name]) end sort_fields = options.dig(:_relation_helper_options, :sort_fields) @@ -423,13 +423,13 @@ def find_related_monomorphic_fragments(source_fragments, relationship, options, resource_table_alias = join_manager.join_details_by_relationship(relationship)[:alias] pluck_fields = [ - Arel.sql("#{_table_name}.#{_primary_key} AS source_id"), - Arel.sql("#{concat_table_field(resource_table_alias, resource_klass._primary_key)} AS #{resource_table_alias}_#{resource_klass._primary_key}") + Arel.sql("#{_table_name}.#{_primary_key} AS \"source_id\""), + sql_field_with_alias(resource_table_alias, resource_klass._primary_key) ] cache_field = resource_klass.attribute_to_model_field(:_cache_field) if options[:cache] if cache_field - pluck_fields << Arel.sql("#{concat_table_field(resource_table_alias, cache_field[:name])} AS #{resource_table_alias}_#{cache_field[:name]}") + pluck_fields << sql_field_with_alias(resource_table_alias, cache_field[:name]) end linkage_fields = [] @@ -444,7 +444,7 @@ def find_related_monomorphic_fragments(source_fragments, relationship, options, linkage_table_alias = join_manager.join_details_by_polymorphic_relationship(linkage_relationship, resource_type)[:alias] primary_key = klass._primary_key - pluck_fields << Arel.sql("#{concat_table_field(linkage_table_alias, primary_key)} AS #{linkage_table_alias}_#{primary_key}") + pluck_fields << sql_field_with_alias(linkage_table_alias, primary_key) end else klass = linkage_relationship.resource_klass @@ -452,7 +452,7 @@ def find_related_monomorphic_fragments(source_fragments, relationship, options, linkage_table_alias = join_manager.join_details_by_relationship(linkage_relationship)[:alias] primary_key = klass._primary_key - pluck_fields << Arel.sql("#{concat_table_field(linkage_table_alias, primary_key)} AS #{linkage_table_alias}_#{primary_key}") + pluck_fields << sql_field_with_alias(linkage_table_alias, primary_key) end end @@ -461,7 +461,7 @@ def find_related_monomorphic_fragments(source_fragments, relationship, options, attributes.try(:each) do |attribute| model_field = resource_klass.attribute_to_model_field(attribute) model_fields[attribute] = model_field - pluck_fields << Arel.sql("#{concat_table_field(resource_table_alias, model_field[:name])} AS #{resource_table_alias}_#{model_field[:name]}") + pluck_fields << sql_field_with_alias(resource_table_alias, model_field[:name]) end sort_fields = options.dig(:_relation_helper_options, :sort_fields) @@ -557,9 +557,9 @@ def find_related_polymorphic_fragments(source_fragments, relationship, options, related_type = concat_table_field(_table_name, relationship.polymorphic_type) pluck_fields = [ - Arel.sql("#{primary_key} AS #{_table_name}_#{_primary_key}"), - Arel.sql("#{related_key} AS #{_table_name}_#{relationship.foreign_key}"), - Arel.sql("#{related_type} AS #{_table_name}_#{relationship.polymorphic_type}") + Arel.sql("#{primary_key} AS #{alias_table_field(_table_name, _primary_key)}"), + Arel.sql("#{related_key} AS #{alias_table_field(_table_name, relationship.foreign_key)}"), + Arel.sql("#{related_type} AS #{alias_table_field(_table_name, relationship.polymorphic_type)}") ] # Get the additional fields from each relation. There's a limitation that the fields must exist in each relation @@ -584,7 +584,7 @@ def find_related_polymorphic_fragments(source_fragments, relationship, options, cache_offset = relation_index if cache_field - pluck_fields << Arel.sql("#{concat_table_field(table_alias, cache_field[:name])} AS cache_#{type}_#{cache_field[:name]}") + pluck_fields << sql_field_with_alias(table_alias, cache_field[:name]) relation_index+= 1 end @@ -593,7 +593,7 @@ def find_related_polymorphic_fragments(source_fragments, relationship, options, attributes.try(:each) do |attribute| model_field = related_klass.attribute_to_model_field(attribute) model_fields[attribute] = model_field - pluck_fields << Arel.sql("#{concat_table_field(table_alias, model_field[:name])} AS #{table_alias}_#{model_field[:name]}") + pluck_fields << sql_field_with_alias(table_alias, model_field[:name]) relation_index+= 1 end @@ -630,7 +630,7 @@ def find_related_polymorphic_fragments(source_fragments, relationship, options, linkage_table_alias = join_manager.join_details_by_polymorphic_relationship(linkage_relationship, resource_type)[:alias] primary_key = klass._primary_key - pluck_fields << Arel.sql("#{concat_table_field(linkage_table_alias, primary_key)} AS #{linkage_table_alias}_#{primary_key}") + pluck_fields << sql_field_with_alias(linkage_table_alias, primary_key) end else klass = linkage_relationship.resource_klass @@ -638,7 +638,7 @@ def find_related_polymorphic_fragments(source_fragments, relationship, options, linkage_table_alias = join_manager.join_details_by_relationship(linkage_relationship)[:alias] primary_key = klass._primary_key - pluck_fields << Arel.sql("#{concat_table_field(linkage_table_alias, primary_key)} AS #{linkage_table_alias}_#{primary_key}") + pluck_fields << sql_field_with_alias(linkage_table_alias, primary_key) end end @@ -804,7 +804,31 @@ def concat_table_field(table, field, quoted = false) if table.blank? || field.to_s.include?('.') # :nocov: if quoted - "\"#{field.to_s}\"" + quote(field) + else + field.to_s + end + # :nocov: + else + if quoted + "#{quote(table)}.#{quote(field)}" + else + # :nocov: + "#{table.to_s}.#{field.to_s}" + # :nocov: + end + end + end + + def sql_field_with_alias(table, field, quoted = true) + Arel.sql("#{concat_table_field(table, field, quoted)} AS #{alias_table_field(table, field, quoted)}") + end + + def alias_table_field(table, field, quoted = false) + if table.blank? || field.to_s.include?('.') + # :nocov: + if quoted + quote(field) else field.to_s end @@ -812,14 +836,18 @@ def concat_table_field(table, field, quoted = false) else if quoted # :nocov: - "\"#{table.to_s}\".\"#{field.to_s}\"" + quote("#{table.to_s}_#{field.to_s}") # :nocov: else - "#{table.to_s}.#{field.to_s}" + "#{table.to_s}_#{field.to_s}" end end end + def quote(field) + "\"#{field.to_s}\"" + end + def apply_filters(records, filters, options = {}) if filters filters.each do |filter, value| diff --git a/lib/jsonapi/basic_resource.rb b/lib/jsonapi/basic_resource.rb index ec92a636c..65e8f57d2 100644 --- a/lib/jsonapi/basic_resource.rb +++ b/lib/jsonapi/basic_resource.rb @@ -1057,7 +1057,7 @@ def default_sort end def construct_order_options(sort_params) - sort_params ||= default_sort + sort_params = default_sort if sort_params.blank? return {} unless sort_params diff --git a/test/test_helper.rb b/test/test_helper.rb index 2335cb2a8..42a86c9a6 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -62,7 +62,7 @@ class TestApp < Rails::Application config.active_support.halt_callback_chains_on_return_false = false config.active_record.time_zone_aware_types = [:time, :datetime] config.active_record.belongs_to_required_by_default = false - unless Rails::VERSION::MAJOR == 5 && Rails::VERSION::MINOR < 2 + unless Rails::VERSION::MAJOR == 5 && Rails::VERSION::MINOR < 2 || Rails::VERSION::MAJOR == 6 && Rails::VERSION::MINOR >= 1 config.active_record.sqlite3.represent_boolean_as_integer = true end end diff --git a/test/unit/active_relation_resource_finder/active_record_adapter_test.rb b/test/unit/active_relation_resource_finder/active_record_adapter_test.rb deleted file mode 100644 index 125893f81..000000000 --- a/test/unit/active_relation_resource_finder/active_record_adapter_test.rb +++ /dev/null @@ -1,18 +0,0 @@ -require File.expand_path('../../../test_helper', __FILE__) -require 'jsonapi-resources' - -class ActiveRecordAdapterTest < ActiveSupport::TestCase - - def test_joins_left - sql = Post.joins_left(:comments).to_sql - assert_equal 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "comments" ON "comments"."post_id" = "posts"."id"', - sql - end - - def test_joins_left_through_inner - sql = Post.joins(:comments).joins_left(comments: :author).to_sql - assert_equal 'SELECT "posts".* FROM "posts" INNER JOIN "comments" ON "comments"."post_id" = "posts"."id" ' \ - 'LEFT OUTER JOIN "people" ON "people"."id" = "comments"."author_id"', - sql - end -end diff --git a/test/unit/active_relation_resource_finder/join_manager_test.rb b/test/unit/active_relation_resource_finder/join_manager_test.rb index a1198bf28..840c90ee2 100644 --- a/test/unit/active_relation_resource_finder/join_manager_test.rb +++ b/test/unit/active_relation_resource_finder/join_manager_test.rb @@ -110,14 +110,6 @@ def test_add_nested_scoped_joins records = Api::V10::PostResource.records({}) records = join_manager.join(records, {}) - if (Rails::VERSION::MAJOR == 6 && Rails::VERSION::MINOR >= 1) || Rails::VERSION::MAJOR > 6 - sql = 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "comments" ON "comments"."post_id" = "posts"."id" LEFT OUTER JOIN "people" author ON author."id" = "posts"."author_id" LEFT OUTER JOIN "people" "authors_comments" ON "authors_comments"."id" = "comments"."author_id" LEFT OUTER JOIN "comments_tags" ON "comments_tags"."comment_id" = "comments"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "comments_tags"."tag_id" WHERE "comments"."approved" = ' + db_true + ' AND "author"."special" = ' + db_true - else - sql = 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "comments" ON "comments"."post_id" = "posts"."id" LEFT OUTER JOIN "people" ON "people"."id" = "posts"."author_id" LEFT OUTER JOIN "people" "authors_comments" ON "authors_comments"."id" = "comments"."author_id" LEFT OUTER JOIN "comments_tags" ON "comments_tags"."comment_id" = "comments"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "comments_tags"."tag_id" WHERE "comments"."approved" = ' + db_true + ' AND "author"."special" = ' + db_true - end - - assert_equal sql, records.to_sql - assert_hash_equals({alias: 'posts', join_type: :root}, join_manager.source_join_details) assert_hash_equals({alias: 'comments', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::PostResource._relationship(:comments))) assert_hash_equals({alias: 'authors_comments', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::CommentResource._relationship(:author))) @@ -135,37 +127,11 @@ def test_add_nested_scoped_joins records = Api::V10::PostResource.records({}) records = join_manager.join(records, {}) - # Note sql is in different order, but aliases should still be right - if (Rails::VERSION::MAJOR == 6 && Rails::VERSION::MINOR >= 1) || Rails::VERSION::MAJOR > 6 - sql = 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "people" author ON author."id" = "posts"."author_id" LEFT OUTER JOIN "comments" ON "comments"."post_id" = "posts"."id" LEFT OUTER JOIN "people" "authors_comments" ON "authors_comments"."id" = "comments"."author_id" LEFT OUTER JOIN "comments_tags" ON "comments_tags"."comment_id" = "comments"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "comments_tags"."tag_id" WHERE "comments"."approved" = ' + db_true + ' AND "author"."special" = ' + db_true - else - sql = 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "people" ON "people"."id" = "posts"."author_id" LEFT OUTER JOIN "comments" ON "comments"."post_id" = "posts"."id" LEFT OUTER JOIN "people" "authors_comments" ON "authors_comments"."id" = "comments"."author_id" LEFT OUTER JOIN "comments_tags" ON "comments_tags"."comment_id" = "comments"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "comments_tags"."tag_id" WHERE "comments"."approved" = ' + db_true + ' AND "author"."special" = ' + db_true - end - - assert_equal sql, records.to_sql - assert_hash_equals({alias: 'posts', join_type: :root}, join_manager.source_join_details) assert_hash_equals({alias: 'comments', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::PostResource._relationship(:comments))) assert_hash_equals({alias: 'authors_comments', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::CommentResource._relationship(:author))) assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::CommentResource._relationship(:tags))) assert_hash_equals({alias: 'people', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::PostResource._relationship(:author))) - - # Easier to read SQL to show joins are the same, but in different order - # Pass 1 - # SELECT "posts".* FROM "posts" - # LEFT OUTER JOIN "comments" ON "comments"."post_id" = "posts"."id" - # LEFT OUTER JOIN "people" ON "people"."id" = "posts"."author_id" - # LEFT OUTER JOIN "people" "authors_comments" ON "authors_comments"."id" = "comments"."author_id" - # LEFT OUTER JOIN "comments_tags" ON "comments_tags"."comment_id" = "comments"."id" - # LEFT OUTER JOIN "tags" ON "tags"."id" = "comments_tags"."tag_id" WHERE "comments"."approved" = 1 AND "author"."special" = 1 - # - # Pass 2 - # SELECT "posts".* FROM "posts" - # LEFT OUTER JOIN "people" ON "people"."id" = "posts"."author_id" - # LEFT OUTER JOIN "comments" ON "comments"."post_id" = "posts"."id" - # LEFT OUTER JOIN "people" "authors_comments" ON "authors_comments"."id" = "comments"."author_id" - # LEFT OUTER JOIN "comments_tags" ON "comments_tags"."comment_id" = "comments"."id" - # LEFT OUTER JOIN "tags" ON "tags"."id" = "comments_tags"."tag_id" WHERE "comments"."approved" = 1 AND "author"."special" = 1 end def test_add_nested_joins_with_fields @@ -179,14 +145,6 @@ def test_add_nested_joins_with_fields records = Api::V10::PostResource.records({}) records = join_manager.join(records, {}) - if (Rails::VERSION::MAJOR == 6 && Rails::VERSION::MINOR >= 1) || Rails::VERSION::MAJOR > 6 - sql = 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "comments" ON "comments"."post_id" = "posts"."id" LEFT OUTER JOIN "people" author ON author."id" = "posts"."author_id" LEFT OUTER JOIN "people" "authors_comments" ON "authors_comments"."id" = "comments"."author_id" LEFT OUTER JOIN "comments_tags" ON "comments_tags"."comment_id" = "comments"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "comments_tags"."tag_id" WHERE "comments"."approved" = ' + db_true + ' AND "author"."special" = ' + db_true - else - sql = 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "comments" ON "comments"."post_id" = "posts"."id" LEFT OUTER JOIN "people" ON "people"."id" = "posts"."author_id" LEFT OUTER JOIN "people" "authors_comments" ON "authors_comments"."id" = "comments"."author_id" LEFT OUTER JOIN "comments_tags" ON "comments_tags"."comment_id" = "comments"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "comments_tags"."tag_id" WHERE "comments"."approved" = ' + db_true + ' AND "author"."special" = ' + db_true - end - - assert_equal sql, records.to_sql - assert_hash_equals({alias: 'posts', join_type: :root}, join_manager.source_join_details) assert_hash_equals({alias: 'comments', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::PostResource._relationship(:comments))) assert_hash_equals({alias: 'authors_comments', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::CommentResource._relationship(:author))) @@ -202,16 +160,6 @@ def test_add_joins_with_sub_relationship records = Api::V10::PostResource.records({}) records = join_manager.join(records, {}) - if (Rails::VERSION::MAJOR == 6 && Rails::VERSION::MINOR >= 1) || Rails::VERSION::MAJOR > 6 - sql = 'SELECT "posts".* FROM "posts" INNER JOIN "comments" ON "comments"."post_id" = "posts"."id" LEFT OUTER JOIN "people" author ON author."id" = "comments"."author_id" LEFT OUTER JOIN "comments_tags" ON "comments_tags"."comment_id" = "comments"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "comments_tags"."tag_id" LEFT OUTER JOIN "comments" "comments_people" ON "comments_people"."author_id" = "people"."id" WHERE "comments"."approved" = ' + db_true + ' AND "author"."special" = ' + db_true - assert_hash_equals({alias: 'author', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::CommentResource._relationship(:author))) - else - sql = 'SELECT "posts".* FROM "posts" INNER JOIN "comments" ON "comments"."post_id" = "posts"."id" LEFT OUTER JOIN "people" ON "people"."id" = "comments"."author_id" LEFT OUTER JOIN "comments_tags" ON "comments_tags"."comment_id" = "comments"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "comments_tags"."tag_id" LEFT OUTER JOIN "comments" "comments_people" ON "comments_people"."author_id" = "people"."id" WHERE "comments"."approved" = ' + db_true + ' AND "author"."special" = ' + db_true - assert_hash_equals({alias: 'people', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::CommentResource._relationship(:author))) - end - - assert_equal sql, records.to_sql - assert_hash_equals({alias: 'comments', join_type: :inner}, join_manager.source_join_details) assert_hash_equals({alias: 'comments', join_type: :inner}, join_manager.join_details_by_relationship(Api::V10::PostResource._relationship(:comments))) assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::CommentResource._relationship(:tags))) @@ -280,11 +228,6 @@ def test_polymorphic_join_belongs_to_filter_on_resource records = PictureResource.records({}) records = join_manager.join(records, {}) - #TODO: Fix this with a better test - sql_v1 = 'SELECT "pictures".* FROM "pictures" LEFT OUTER JOIN "documents" ON "documents"."id" = "pictures"."imageable_id" AND "pictures"."imageable_type" = \'Document\' LEFT OUTER JOIN "products" ON "products"."id" = "pictures"."imageable_id" AND "pictures"."imageable_type" = \'Product\' LEFT OUTER JOIN "file_properties" ON "file_properties"."fileable_id" = "pictures"."id" AND "file_properties"."fileable_type" = \'Picture\'' - sql_v2 = 'SELECT "pictures".* FROM "pictures" LEFT OUTER JOIN "documents" ON "documents"."id" = "pictures"."imageable_id" AND "pictures"."imageable_type" = \'Document\' LEFT OUTER JOIN "products" ON "products"."id" = "pictures"."imageable_id" AND "pictures"."imageable_type" = \'Product\' LEFT OUTER JOIN "file_properties" ON "file_properties"."fileable_type" = \'Picture\' AND "file_properties"."fileable_id" = "pictures"."id"' - assert records.to_sql == sql_v1 || records.to_sql == sql_v2, 'did not generate an expected sql statement' - assert_hash_equals({alias: 'pictures', join_type: :root}, join_manager.source_join_details) assert_hash_equals({alias: 'products', join_type: :left}, join_manager.join_details_by_polymorphic_relationship(PictureResource._relationship(:imageable), 'products')) assert_hash_equals({alias: 'documents', join_type: :left}, join_manager.join_details_by_polymorphic_relationship(PictureResource._relationship(:imageable), 'documents')) From bf4b4cd7d79da453372880068e167066f0d05f47 Mon Sep 17 00:00:00 2001 From: James Glover Date: Mon, 9 Aug 2021 14:59:08 +0100 Subject: [PATCH 202/237] Fix empty relationships on included resources (#1372) JSONAPI::ResourceTree#load_included was failing to correctly populate the resource fragments of included resources, such that the `include_related` parameter was null. This was resulting in the relationships object lacking a data attribute for nil or empty resources. The JSON-API specification states that a null or empty array should be returned in these circumstances: https://jsonapi.org/format/#document-resource-object-linkage The underlying issue appears to be the use of `include_related` rather than the symbol `:include_related` when initializing nested resource fragments. --- lib/jsonapi/resource_tree.rb | 2 +- test/controllers/controller_test.rb | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/jsonapi/resource_tree.rb b/lib/jsonapi/resource_tree.rb index 7f873c324..0d8437f1c 100644 --- a/lib/jsonapi/resource_tree.rb +++ b/lib/jsonapi/resource_tree.rb @@ -85,7 +85,7 @@ def load_included(resource_klass, source_resource_tree, include_related, options find_related_resource_options) related_resource_tree = source_resource_tree.get_related_resource_tree(relationship) - related_resource_tree.add_resource_fragments(related_fragments, include_related[key][include_related]) + related_resource_tree.add_resource_fragments(related_fragments, include_related[key][:include_related]) # Now recursively get the related resources for the currently found resources load_included(relationship.resource_klass, diff --git a/test/controllers/controller_test.rb b/test/controllers/controller_test.rb index af72da60e..e2568f979 100644 --- a/test/controllers/controller_test.rb +++ b/test/controllers/controller_test.rb @@ -4371,7 +4371,8 @@ def test_complex_includes_things_nested_things "links" => { "self" => "http://test.host/api/things/40/relationships/things", "related" => "http://test.host/api/things/40/things" - } + }, + "data"=>[] } } }, From d19ab438392aef053d975f6d71cc245a0814f355 Mon Sep 17 00:00:00 2001 From: Benjamin Fleischer Date: Wed, 11 Jan 2023 10:31:45 -0600 Subject: [PATCH 203/237] Rails 7.0 deprecates content_type in favor of media_type (#1390) Deprecates content_type in favor of media_type --- lib/jsonapi/acts_as_resource_controller.rb | 4 ++-- test/helpers/functional_helpers.rb | 4 ++-- test/test_helper.rb | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/jsonapi/acts_as_resource_controller.rb b/lib/jsonapi/acts_as_resource_controller.rb index 90fd296b8..5d8b1cd6a 100644 --- a/lib/jsonapi/acts_as_resource_controller.rb +++ b/lib/jsonapi/acts_as_resource_controller.rb @@ -167,8 +167,8 @@ def resource_klass_name def verify_content_type_header if ['create', 'create_relationship', 'update_relationship', 'update'].include?(params[:action]) - unless request.content_type == JSONAPI::MEDIA_TYPE - fail JSONAPI::Exceptions::UnsupportedMediaTypeError.new(request.content_type) + unless request.media_type == JSONAPI::MEDIA_TYPE + fail JSONAPI::Exceptions::UnsupportedMediaTypeError.new(request.media_type) end end end diff --git a/test/helpers/functional_helpers.rb b/test/helpers/functional_helpers.rb index e0f504df2..3d6dc9d34 100644 --- a/test/helpers/functional_helpers.rb +++ b/test/helpers/functional_helpers.rb @@ -32,8 +32,8 @@ module FunctionalHelpers # end # end # - # if @response.content_type - # ct = @response.content_type + # if @response.media_type + # ct = @response.media_type # elsif methods.include?('assert_response_response') # ct = assert_response_response # else diff --git a/test/test_helper.rb b/test/test_helper.rb index 42a86c9a6..16731a447 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -474,7 +474,7 @@ class ActionDispatch::IntegrationTest fixtures :all def assert_jsonapi_response(expected_status, msg = nil) - assert_equal JSONAPI::MEDIA_TYPE, response.content_type + assert_equal JSONAPI::MEDIA_TYPE, response.media_type if status != expected_status && status >= 400 pp json_response rescue nil end From 0ba80a1ef6a7b4dbea3097b207380979336e9376 Mon Sep 17 00:00:00 2001 From: Peter Goldstein Date: Thu, 12 Jan 2023 16:27:50 -0500 Subject: [PATCH 204/237] Fixes 1378 - Add Ruby 3.1 and Rails 7.0 to the CI matrix (#1379) * Add Ruby 3.1 and Rails 7 to the CI matrix * Adding Ruby 3.2 * Bump up patch versions --- .github/workflows/ruby.yml | 43 ++++++++++++++++++++--------- test/test_helper.rb | 2 +- test/unit/resource/resource_test.rb | 5 +++- 3 files changed, 35 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml index b0fd4e1b4..aeb9b1ae9 100644 --- a/.github/workflows/ruby.yml +++ b/.github/workflows/ruby.yml @@ -26,24 +26,41 @@ jobs: fail-fast: false matrix: ruby: - - 2.6.6 - - 2.7.2 - - 3.0.0 + - 2.6 + - 2.7 + - '3.0' + - 3.1 + - 3.2 rails: - - 6.1.3.1 - - 6.0.3.4 - - 5.2.4.4 + - 7.0.4 + - 6.1.7 + - 6.0.6 + - 5.2.8.1 - 5.1.7 database_url: - postgresql://postgres:password@localhost:5432/test - sqlite3:test_db exclude: - - ruby: 3.0.0 - rails: 6.0.3.4 - - ruby: 3.0.0 - rails: 5.2.4.4 - - ruby: 3.0.0 + - ruby: 3.2 + rails: 6.0.6 + - ruby: 3.2 + rails: 5.2.8.1 + - ruby: 3.2 rails: 5.1.7 + - ruby: 3.1 + rails: 6.0.6 + - ruby: 3.1 + rails: 5.2.8.1 + - ruby: 3.1 + rails: 5.1.7 + - ruby: '3.0' + rails: 6.0.6 + - ruby: '3.0' + rails: 5.2.8.1 + - ruby: '3.0' + rails: 5.1.7 + - ruby: 2.6 + rails: 7.0.4 - database_url: postgresql://postgres:password@localhost:5432/test rails: 5.1.7 env: @@ -51,7 +68,7 @@ jobs: DATABASE_URL: ${{ matrix.database_url }} name: Ruby ${{ matrix.ruby }} Rails ${{ matrix.rails }} DB ${{ matrix.database_url }} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Ruby uses: ruby/setup-ruby@v1 with: @@ -59,4 +76,4 @@ jobs: - name: Install dependencies run: bundle install --jobs 4 --retry 3 - name: Run tests - run: bundle exec rake test \ No newline at end of file + run: bundle exec rake test diff --git a/test/test_helper.rb b/test/test_helper.rb index 16731a447..9850a49c6 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -62,7 +62,7 @@ class TestApp < Rails::Application config.active_support.halt_callback_chains_on_return_false = false config.active_record.time_zone_aware_types = [:time, :datetime] config.active_record.belongs_to_required_by_default = false - unless Rails::VERSION::MAJOR == 5 && Rails::VERSION::MINOR < 2 || Rails::VERSION::MAJOR == 6 && Rails::VERSION::MINOR >= 1 + if Rails::VERSION::MAJOR == 5 && Rails::VERSION::MINOR == 2 config.active_record.sqlite3.represent_boolean_as_integer = true end end diff --git a/test/unit/resource/resource_test.rb b/test/unit/resource/resource_test.rb index 21e8aec82..df2df1730 100644 --- a/test/unit/resource/resource_test.rb +++ b/test/unit/resource/resource_test.rb @@ -174,7 +174,10 @@ def test_derived_not_abstract end def test_inherited_calls_superclass - assert_equal(BaseResource.subclasses, [PersonResource, SpecialBaseResource]) + subclasses = BaseResource.subclasses + assert_includes(subclasses, PersonResource) + assert_includes(subclasses, SpecialBaseResource) + assert_equal(2, subclasses.size) end def test_nil_model_class From a2e8ad556337936e86321ea44b629e9ee26357e0 Mon Sep 17 00:00:00 2001 From: yaw Date: Mon, 18 Sep 2023 15:31:44 +0100 Subject: [PATCH 205/237] add frozen_string_literal magic comment (#1408) for efficient string storage since we don't mutate any of the string literals after they have been declared. in essence, they're already being used as frozen values. --- lib/jsonapi-resources.rb | 2 ++ lib/jsonapi/active_relation_resource.rb | 2 ++ lib/jsonapi/acts_as_resource_controller.rb | 2 ++ lib/jsonapi/basic_resource.rb | 2 ++ lib/jsonapi/cached_response_fragment.rb | 2 ++ lib/jsonapi/callbacks.rb | 2 ++ lib/jsonapi/compiled_json.rb | 2 ++ lib/jsonapi/configuration.rb | 2 ++ lib/jsonapi/error.rb | 2 ++ lib/jsonapi/error_codes.rb | 2 ++ lib/jsonapi/exceptions.rb | 2 ++ lib/jsonapi/formatter.rb | 2 ++ lib/jsonapi/include_directives.rb | 2 ++ lib/jsonapi/link_builder.rb | 2 ++ lib/jsonapi/mime_types.rb | 2 ++ lib/jsonapi/naive_cache.rb | 2 ++ lib/jsonapi/operation.rb | 2 ++ lib/jsonapi/operation_result.rb | 2 ++ lib/jsonapi/paginator.rb | 2 ++ lib/jsonapi/path.rb | 2 ++ lib/jsonapi/path_segment.rb | 2 ++ lib/jsonapi/processor.rb | 2 ++ lib/jsonapi/relationship.rb | 2 ++ lib/jsonapi/request.rb | 2 ++ lib/jsonapi/resource.rb | 2 ++ lib/jsonapi/resource_controller.rb | 2 ++ lib/jsonapi/resource_controller_metal.rb | 2 ++ lib/jsonapi/resource_fragment.rb | 2 ++ lib/jsonapi/resource_identity.rb | 2 ++ lib/jsonapi/resource_serializer.rb | 2 ++ lib/jsonapi/resource_set.rb | 2 ++ lib/jsonapi/resource_tree.rb | 2 ++ lib/jsonapi/response_document.rb | 2 ++ lib/jsonapi/routing_ext.rb | 2 ++ 34 files changed, 68 insertions(+) diff --git a/lib/jsonapi-resources.rb b/lib/jsonapi-resources.rb index 04fae654f..401d9bbc7 100644 --- a/lib/jsonapi-resources.rb +++ b/lib/jsonapi-resources.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'jsonapi/resources/railtie' require 'jsonapi/naive_cache' require 'jsonapi/compiled_json' diff --git a/lib/jsonapi/active_relation_resource.rb b/lib/jsonapi/active_relation_resource.rb index c86311725..581ed1e02 100644 --- a/lib/jsonapi/active_relation_resource.rb +++ b/lib/jsonapi/active_relation_resource.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module JSONAPI class ActiveRelationResource < BasicResource root_resource diff --git a/lib/jsonapi/acts_as_resource_controller.rb b/lib/jsonapi/acts_as_resource_controller.rb index 5d8b1cd6a..e448fa0ea 100644 --- a/lib/jsonapi/acts_as_resource_controller.rb +++ b/lib/jsonapi/acts_as_resource_controller.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'csv' module JSONAPI diff --git a/lib/jsonapi/basic_resource.rb b/lib/jsonapi/basic_resource.rb index 65e8f57d2..2eeba5c5d 100644 --- a/lib/jsonapi/basic_resource.rb +++ b/lib/jsonapi/basic_resource.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'jsonapi/callbacks' require 'jsonapi/configuration' diff --git a/lib/jsonapi/cached_response_fragment.rb b/lib/jsonapi/cached_response_fragment.rb index 4f2abccdb..5e7d3336a 100644 --- a/lib/jsonapi/cached_response_fragment.rb +++ b/lib/jsonapi/cached_response_fragment.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module JSONAPI class CachedResponseFragment diff --git a/lib/jsonapi/callbacks.rb b/lib/jsonapi/callbacks.rb index 474f50f48..78de6fca9 100644 --- a/lib/jsonapi/callbacks.rb +++ b/lib/jsonapi/callbacks.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'active_support/callbacks' module JSONAPI diff --git a/lib/jsonapi/compiled_json.rb b/lib/jsonapi/compiled_json.rb index 59ce6266b..cd0cc83bb 100644 --- a/lib/jsonapi/compiled_json.rb +++ b/lib/jsonapi/compiled_json.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module JSONAPI class CompiledJson def self.compile(h) diff --git a/lib/jsonapi/configuration.rb b/lib/jsonapi/configuration.rb index 3ab273d24..6cd5d8e1b 100644 --- a/lib/jsonapi/configuration.rb +++ b/lib/jsonapi/configuration.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'jsonapi/formatter' require 'jsonapi/processor' require 'concurrent' diff --git a/lib/jsonapi/error.rb b/lib/jsonapi/error.rb index a5d878af8..12d65f585 100644 --- a/lib/jsonapi/error.rb +++ b/lib/jsonapi/error.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module JSONAPI class Error attr_accessor :title, :detail, :id, :href, :code, :source, :links, :status, :meta diff --git a/lib/jsonapi/error_codes.rb b/lib/jsonapi/error_codes.rb index d23f757c2..f25608413 100644 --- a/lib/jsonapi/error_codes.rb +++ b/lib/jsonapi/error_codes.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module JSONAPI VALIDATION_ERROR = '100' INVALID_RESOURCE = '101' diff --git a/lib/jsonapi/exceptions.rb b/lib/jsonapi/exceptions.rb index 0ed65e5a2..e917118cf 100644 --- a/lib/jsonapi/exceptions.rb +++ b/lib/jsonapi/exceptions.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module JSONAPI module Exceptions class Error < RuntimeError diff --git a/lib/jsonapi/formatter.rb b/lib/jsonapi/formatter.rb index 6f2922b57..7b79f931d 100644 --- a/lib/jsonapi/formatter.rb +++ b/lib/jsonapi/formatter.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module JSONAPI class Formatter class << self diff --git a/lib/jsonapi/include_directives.rb b/lib/jsonapi/include_directives.rb index a75b5adcd..2ad300133 100644 --- a/lib/jsonapi/include_directives.rb +++ b/lib/jsonapi/include_directives.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module JSONAPI class IncludeDirectives # Construct an IncludeDirectives Hash from an array of dot separated include strings. diff --git a/lib/jsonapi/link_builder.rb b/lib/jsonapi/link_builder.rb index 6ede8a022..d78f414e1 100644 --- a/lib/jsonapi/link_builder.rb +++ b/lib/jsonapi/link_builder.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module JSONAPI class LinkBuilder attr_reader :base_url, diff --git a/lib/jsonapi/mime_types.rb b/lib/jsonapi/mime_types.rb index 981998048..d888a1bd8 100644 --- a/lib/jsonapi/mime_types.rb +++ b/lib/jsonapi/mime_types.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'json' module JSONAPI diff --git a/lib/jsonapi/naive_cache.rb b/lib/jsonapi/naive_cache.rb index 53bf6ccb0..098300ba5 100644 --- a/lib/jsonapi/naive_cache.rb +++ b/lib/jsonapi/naive_cache.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module JSONAPI # Cache which memoizes the given block. diff --git a/lib/jsonapi/operation.rb b/lib/jsonapi/operation.rb index 3e6996a41..f87f6570f 100644 --- a/lib/jsonapi/operation.rb +++ b/lib/jsonapi/operation.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module JSONAPI class Operation attr_reader :resource_klass, :operation_type, :options diff --git a/lib/jsonapi/operation_result.rb b/lib/jsonapi/operation_result.rb index 1c9384273..63a3d9dc1 100644 --- a/lib/jsonapi/operation_result.rb +++ b/lib/jsonapi/operation_result.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module JSONAPI class OperationResult attr_accessor :code diff --git a/lib/jsonapi/paginator.rb b/lib/jsonapi/paginator.rb index 53f8fbbe4..1054354e4 100644 --- a/lib/jsonapi/paginator.rb +++ b/lib/jsonapi/paginator.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module JSONAPI class Paginator def initialize(_params) diff --git a/lib/jsonapi/path.rb b/lib/jsonapi/path.rb index ae111b49e..0e5d7e844 100644 --- a/lib/jsonapi/path.rb +++ b/lib/jsonapi/path.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module JSONAPI class Path attr_reader :segments, :resource_klass diff --git a/lib/jsonapi/path_segment.rb b/lib/jsonapi/path_segment.rb index e5cebd832..4b9879819 100644 --- a/lib/jsonapi/path_segment.rb +++ b/lib/jsonapi/path_segment.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module JSONAPI class PathSegment def self.parse(source_resource_klass:, segment_string:, parse_fields: true) diff --git a/lib/jsonapi/processor.rb b/lib/jsonapi/processor.rb index 88c455590..814642a45 100644 --- a/lib/jsonapi/processor.rb +++ b/lib/jsonapi/processor.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module JSONAPI class Processor include Callbacks diff --git a/lib/jsonapi/relationship.rb b/lib/jsonapi/relationship.rb index 6ed3c54b8..8824fc65d 100644 --- a/lib/jsonapi/relationship.rb +++ b/lib/jsonapi/relationship.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module JSONAPI class Relationship attr_reader :acts_as_set, :foreign_key, :options, :name, diff --git a/lib/jsonapi/request.rb b/lib/jsonapi/request.rb index 0c377d35e..5f250993f 100644 --- a/lib/jsonapi/request.rb +++ b/lib/jsonapi/request.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module JSONAPI class Request attr_accessor :fields, :include, :filters, :sort_criteria, :errors, :controller_module_path, diff --git a/lib/jsonapi/resource.rb b/lib/jsonapi/resource.rb index 0c09fb7e8..4d34dd290 100644 --- a/lib/jsonapi/resource.rb +++ b/lib/jsonapi/resource.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module JSONAPI class Resource < ActiveRelationResource root_resource diff --git a/lib/jsonapi/resource_controller.rb b/lib/jsonapi/resource_controller.rb index 0c3f7f345..70450d9d7 100644 --- a/lib/jsonapi/resource_controller.rb +++ b/lib/jsonapi/resource_controller.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module JSONAPI class ResourceController < ActionController::Base include JSONAPI::ActsAsResourceController diff --git a/lib/jsonapi/resource_controller_metal.rb b/lib/jsonapi/resource_controller_metal.rb index f6f82e246..e8dfb3f55 100644 --- a/lib/jsonapi/resource_controller_metal.rb +++ b/lib/jsonapi/resource_controller_metal.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module JSONAPI class ResourceControllerMetal < ActionController::Metal MODULES = [ diff --git a/lib/jsonapi/resource_fragment.rb b/lib/jsonapi/resource_fragment.rb index 188e4caef..c42cf573d 100644 --- a/lib/jsonapi/resource_fragment.rb +++ b/lib/jsonapi/resource_fragment.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module JSONAPI # A ResourceFragment holds a ResourceIdentity and associated partial resource data. diff --git a/lib/jsonapi/resource_identity.rb b/lib/jsonapi/resource_identity.rb index 72635ecb4..baea3fcf8 100644 --- a/lib/jsonapi/resource_identity.rb +++ b/lib/jsonapi/resource_identity.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module JSONAPI # ResourceIdentity describes a unique identity of a resource in the system. diff --git a/lib/jsonapi/resource_serializer.rb b/lib/jsonapi/resource_serializer.rb index d3a03a631..731404f1e 100644 --- a/lib/jsonapi/resource_serializer.rb +++ b/lib/jsonapi/resource_serializer.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module JSONAPI class ResourceSerializer diff --git a/lib/jsonapi/resource_set.rb b/lib/jsonapi/resource_set.rb index 5cf6c6bbc..01fcdb77e 100644 --- a/lib/jsonapi/resource_set.rb +++ b/lib/jsonapi/resource_set.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module JSONAPI # Contains a hash of resource types which contain a hash of resources, relationships and primary status keyed by # resource id. diff --git a/lib/jsonapi/resource_tree.rb b/lib/jsonapi/resource_tree.rb index 0d8437f1c..a7a9a0b63 100644 --- a/lib/jsonapi/resource_tree.rb +++ b/lib/jsonapi/resource_tree.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module JSONAPI # A tree structure representing the resource structure of the requested resource(s). This is an intermediate structure diff --git a/lib/jsonapi/response_document.rb b/lib/jsonapi/response_document.rb index cc2ebba54..3558e5e0a 100644 --- a/lib/jsonapi/response_document.rb +++ b/lib/jsonapi/response_document.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module JSONAPI class ResponseDocument attr_reader :serialized_results diff --git a/lib/jsonapi/routing_ext.rb b/lib/jsonapi/routing_ext.rb index de6668a4b..b0b940138 100644 --- a/lib/jsonapi/routing_ext.rb +++ b/lib/jsonapi/routing_ext.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActionDispatch module Routing class Mapper From a3a2a7a3ce62df3c537d51e7f677a11cecca8ef9 Mon Sep 17 00:00:00 2001 From: Ivan Goncharov Date: Tue, 19 Sep 2023 22:55:19 +0300 Subject: [PATCH 206/237] Implement allow_transactions feature (#1410) You have a configuration option`allow_transactions`, but looks like implementation is missing. --- lib/jsonapi/request.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/jsonapi/request.rb b/lib/jsonapi/request.rb index 5f250993f..5e80ff96a 100644 --- a/lib/jsonapi/request.rb +++ b/lib/jsonapi/request.rb @@ -34,7 +34,7 @@ def transactional? when 'index', 'show_related_resource', 'index_related_resources', 'show', 'show_relationship' false else - true + JSONAPI.configuration.allow_transactions end end From 4a9e54db95d455f2c48cf4e2c8e630bf7ba53a2b Mon Sep 17 00:00:00 2001 From: Seena Nair <55585488+seenanair@users.noreply.github.com> Date: Tue, 17 Jun 2025 16:32:27 +0100 Subject: [PATCH 207/237] Refactor database schema definitions for clarity and consistency --- test/fixtures/active_record.rb | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/test/fixtures/active_record.rb b/test/fixtures/active_record.rb index 1209302fd..e6866785c 100644 --- a/test/fixtures/active_record.rb +++ b/test/fixtures/active_record.rb @@ -52,7 +52,7 @@ end create_table :posts, force: true do |t| - t.string :title, length: 255 + t.string :title, limit: 255 t.text :body t.integer :author_id t.integer :parent_post_id @@ -85,17 +85,20 @@ end create_table :posts_tags, force: true do |t| - t.references :post, :tag, index: true + t.references :post, index:true + t.references :tag, index:true end add_index :posts_tags, [:post_id, :tag_id], unique: true create_table :special_post_tags, force: true do |t| - t.references :post, :tag, index: true + t.references :post, index: true + t.references :tag, index: true end add_index :special_post_tags, [:post_id, :tag_id], unique: true create_table :comments_tags, force: true do |t| - t.references :comment, :tag, index: true + t.references :comment, index: true + t.references :tag, index: true end create_table :iso_currencies, id: false, force: true do |t| @@ -324,8 +327,8 @@ create_table :related_things, force: true do |t| t.string :name - t.references :from, references: :thing - t.references :to, references: :thing + t.references :from, foreign_key: { to_table: :things } + t.references :to, foreign_key: { to_table: :things } t.timestamps null: false end From dea548e82f4ff5cb7104a5757d1f07e10df0f9fc Mon Sep 17 00:00:00 2001 From: Seena Nair <55585488+seenanair@users.noreply.github.com> Date: Wed, 18 Jun 2025 10:09:04 +0100 Subject: [PATCH 208/237] Update Gemfile to simplify SQLite3 version handling --- Gemfile | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/Gemfile b/Gemfile index 2535d0200..f022a438b 100644 --- a/Gemfile +++ b/Gemfile @@ -10,12 +10,9 @@ version = ENV['RAILS_VERSION'] || 'default' platforms :ruby do gem 'pg' - - if version.start_with?('4.2', '5.0') - gem 'sqlite3', '~> 1.3.13' - else - gem 'sqlite3', '~> 1.4' - end + gem 'mysql2' + gem 'sqlite3' + gem 'csv' end case version @@ -26,4 +23,4 @@ when 'default' gem 'railties', '>= 6.0' else gem 'railties', "~> #{version}" -end \ No newline at end of file +end From ff8bc723a13eb68ce669479ff6d750a3241aa4c7 Mon Sep 17 00:00:00 2001 From: Seena Nair <55585488+seenanair@users.noreply.github.com> Date: Wed, 18 Jun 2025 10:09:32 +0100 Subject: [PATCH 209/237] Add missing require statement for Rails generators in controller and resource generators --- lib/generators/jsonapi/controller_generator.rb | 1 + lib/generators/jsonapi/resource_generator.rb | 1 + 2 files changed, 2 insertions(+) diff --git a/lib/generators/jsonapi/controller_generator.rb b/lib/generators/jsonapi/controller_generator.rb index 41ee4eb1e..d6aba8bf9 100644 --- a/lib/generators/jsonapi/controller_generator.rb +++ b/lib/generators/jsonapi/controller_generator.rb @@ -1,3 +1,4 @@ +require 'rails/generators' module Jsonapi class ControllerGenerator < Rails::Generators::NamedBase source_root File.expand_path('../templates', __FILE__) diff --git a/lib/generators/jsonapi/resource_generator.rb b/lib/generators/jsonapi/resource_generator.rb index 80aa24b4d..25feb14a1 100644 --- a/lib/generators/jsonapi/resource_generator.rb +++ b/lib/generators/jsonapi/resource_generator.rb @@ -1,3 +1,4 @@ +require 'rails/generators' module Jsonapi class ResourceGenerator < Rails::Generators::NamedBase source_root File.expand_path('../templates', __FILE__) From e83a16044f0308cbb3be1864e3f48ca2f04ec5c7 Mon Sep 17 00:00:00 2001 From: Seena Nair <55585488+seenanair@users.noreply.github.com> Date: Wed, 18 Jun 2025 10:13:58 +0100 Subject: [PATCH 210/237] Add JSONAPI::CompatibilityHelper module for version-safe deprecation warnings --- lib/jsonapi/compatibility_helper.rb | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 lib/jsonapi/compatibility_helper.rb diff --git a/lib/jsonapi/compatibility_helper.rb b/lib/jsonapi/compatibility_helper.rb new file mode 100644 index 000000000..516609349 --- /dev/null +++ b/lib/jsonapi/compatibility_helper.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +# JSONAPI::CompatibilityHelper +# +# This module provides a version-safe method for issuing deprecation warnings +# that works across multiple versions of Rails (7.x, 8.x, etc). +# +# Usage: +# JSONAPI::CompatibilityHelper.deprecation_warn("Your deprecation message") +# +# The method will use the public `warn` method if available, otherwise it will +# use `send(:warn, ...)` to maintain compatibility with Rails 8+ where `warn` +# is private. +# +# Example: +# JSONAPI::CompatibilityHelper.deprecation_warn("This feature is deprecated.") + +module JSONAPI + module CompatibilityHelper + def deprecation_warn(message) + if ActiveSupport::Deprecation.respond_to?(:warn) && ActiveSupport::Deprecation.public_method_defined?(:warn) + ActiveSupport::Deprecation.warn(message) + else + ActiveSupport::Deprecation.send(:warn, message) + end + end + module_function :deprecation_warn + end +end From 0eb11bc3ea54650778750a9e603e0ffecd99a6ea Mon Sep 17 00:00:00 2001 From: Seena Nair <55585488+seenanair@users.noreply.github.com> Date: Wed, 18 Jun 2025 10:14:45 +0100 Subject: [PATCH 211/237] Refactor deprecation warnings to use CompatibilityHelper and ensure consistent require statements --- lib/jsonapi/acts_as_resource_controller.rb | 10 +++++----- lib/jsonapi/basic_resource.rb | 9 +++++---- lib/jsonapi/configuration.rb | 10 +++++----- lib/jsonapi/relationship.rb | 4 ++-- lib/jsonapi/resource.rb | 2 +- 5 files changed, 18 insertions(+), 17 deletions(-) diff --git a/lib/jsonapi/acts_as_resource_controller.rb b/lib/jsonapi/acts_as_resource_controller.rb index e448fa0ea..8b29043d1 100644 --- a/lib/jsonapi/acts_as_resource_controller.rb +++ b/lib/jsonapi/acts_as_resource_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require 'csv' - +require_relative 'compatibility_helper' module JSONAPI module ActsAsResourceController MEDIA_TYPE_MATCHER = /.+".+"[^,]*|[^,]+/ @@ -63,16 +63,16 @@ def index_related_resources def get_related_resource # :nocov: - ActiveSupport::Deprecation.warn "In #{self.class.name} you exposed a `get_related_resource`"\ - " action. Please use `show_related_resource` instead." + JSONAPI::CompatibilityHelper.deprecation_warn("In #{self.class.name} you exposed a `get_related_resource`"\ + " action. Please use `show_related_resource` instead.") show_related_resource # :nocov: end def get_related_resources # :nocov: - ActiveSupport::Deprecation.warn "In #{self.class.name} you exposed a `get_related_resources`"\ - " action. Please use `index_related_resources` instead." + JSONAPI::CompatibilityHelper.deprecation_warn("In #{self.class.name} you exposed a `get_related_resources`"\ + " action. Please use `index_related_resources` instead.") index_related_resources # :nocov: end diff --git a/lib/jsonapi/basic_resource.rb b/lib/jsonapi/basic_resource.rb index 2eeba5c5d..d35ad796d 100644 --- a/lib/jsonapi/basic_resource.rb +++ b/lib/jsonapi/basic_resource.rb @@ -2,7 +2,7 @@ require 'jsonapi/callbacks' require 'jsonapi/configuration' - +require_relative 'compatibility_helper' module JSONAPI class BasicResource include Callbacks @@ -547,7 +547,7 @@ def attribute(attribute_name, options = {}) check_reserved_attribute_name(attr) if (attr == :id) && (options[:format].nil?) - ActiveSupport::Deprecation.warn('Id without format is no longer supported. Please remove ids from attributes, or specify a format.') + JSONAPI::CompatibilityHelper.deprecation_warn('Id without format is deprecated. Please specify a format for the id attribute.') end check_duplicate_attribute_name(attr) if options[:format].nil? @@ -609,11 +609,12 @@ def has_one(*attrs) end def belongs_to(*attrs) - ActiveSupport::Deprecation.warn "In #{name} you exposed a `has_one` relationship "\ + + JSONAPI::CompatibilityHelper.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." + " `belongs_to` class method on your resource.") _add_relationship(Relationship::ToOne, *attrs) end diff --git a/lib/jsonapi/configuration.rb b/lib/jsonapi/configuration.rb index 6cd5d8e1b..e1a7e1c61 100644 --- a/lib/jsonapi/configuration.rb +++ b/lib/jsonapi/configuration.rb @@ -3,7 +3,7 @@ require 'jsonapi/formatter' require 'jsonapi/processor' require 'concurrent' - +require_relative 'compatibility_helper' module JSONAPI class Configuration attr_reader :json_key_format, @@ -227,7 +227,7 @@ def exception_class_allowed?(e) end def default_processor_klass=(default_processor_klass) - ActiveSupport::Deprecation.warn('`default_processor_klass` has been replaced by `default_processor_klass_name`.') + JSONAPI::CompatibilityHelper.deprecation_warn('`default_processor_klass` has been replaced by `default_processor_klass_name`.') @default_processor_klass = default_processor_klass end @@ -241,18 +241,18 @@ def default_processor_klass_name=(default_processor_klass_name) end def allow_include=(allow_include) - ActiveSupport::Deprecation.warn('`allow_include` has been replaced by `default_allow_include_to_one` and `default_allow_include_to_many` options.') + JSONAPI::CompatibilityHelper.deprecation_warn('`allow_include` has been replaced by `default_allow_include_to_one` and `default_allow_include_to_many` options.') @default_allow_include_to_one = allow_include @default_allow_include_to_many = allow_include end def whitelist_all_exceptions=(allow_all_exceptions) - ActiveSupport::Deprecation.warn('`whitelist_all_exceptions` has been replaced by `allow_all_exceptions`') + JSONAPI::CompatibilityHelper.deprecation_warn('`whitelist_all_exceptions` has been replaced by `allow_all_exceptions`') @allow_all_exceptions = allow_all_exceptions end def exception_class_whitelist=(exception_class_allowlist) - ActiveSupport::Deprecation.warn('`exception_class_whitelist` has been replaced by `exception_class_allowlist`') + JSONAPI::CompatibilityHelper.deprecation_warn('`exception_class_whitelist` has been replaced by `exception_class_allowlist`') @exception_class_allowlist = exception_class_allowlist end diff --git a/lib/jsonapi/relationship.rb b/lib/jsonapi/relationship.rb index 8824fc65d..62c17d9c8 100644 --- a/lib/jsonapi/relationship.rb +++ b/lib/jsonapi/relationship.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true - +require_relative 'compatibility_helper' module JSONAPI class Relationship attr_reader :acts_as_set, :foreign_key, :options, :name, @@ -21,7 +21,7 @@ def initialize(name, options = {}) @polymorphic = options.fetch(:polymorphic, false) == true @polymorphic_types = options[:polymorphic_types] if options[:polymorphic_relations] - ActiveSupport::Deprecation.warn('Use polymorphic_types instead of polymorphic_relations') + JSONAPI::CompatibilityHelper.deprecation_warn('Use polymorphic_types instead of polymorphic_relations') @polymorphic_types ||= options[:polymorphic_relations] end diff --git a/lib/jsonapi/resource.rb b/lib/jsonapi/resource.rb index 4d34dd290..421b46ea2 100644 --- a/lib/jsonapi/resource.rb +++ b/lib/jsonapi/resource.rb @@ -4,4 +4,4 @@ module JSONAPI class Resource < ActiveRelationResource root_resource end -end \ No newline at end of file +end From 652fdb5858b4a099b772700ecfbb09a758ec3e34 Mon Sep 17 00:00:00 2001 From: Seena Nair <55585488+seenanair@users.noreply.github.com> Date: Wed, 18 Jun 2025 10:15:04 +0100 Subject: [PATCH 212/237] Add nil check for relation_position to prevent processing errors --- lib/jsonapi/active_relation_resource.rb | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/lib/jsonapi/active_relation_resource.rb b/lib/jsonapi/active_relation_resource.rb index 581ed1e02..bfe88af26 100644 --- a/lib/jsonapi/active_relation_resource.rb +++ b/lib/jsonapi/active_relation_resource.rb @@ -666,10 +666,14 @@ def find_related_polymorphic_fragments(source_fragments, relationship, options, end relation_position = relation_positions[row[2].downcase.pluralize] - model_fields = relation_position[:model_fields] - cache_field = relation_position[:cache_field] - cache_offset = relation_position[:cache_offset] - field_offset = relation_position[:field_offset] + if relation_position + model_fields = relation_position[:model_fields] + cache_field = relation_position[:cache_field] + cache_offset = relation_position[:cache_offset] + field_offset = relation_position[:field_offset] + else + next # Skip processing if relation_position is nil + end if cache_field related_fragments[rid].cache = cast_to_attribute_type(row[cache_offset], cache_field[:type]) From 45328e382f616e33d02777d3221cfa0cd0acd121 Mon Sep 17 00:00:00 2001 From: Seena Nair <55585488+seenanair@users.noreply.github.com> Date: Wed, 18 Jun 2025 10:15:19 +0100 Subject: [PATCH 213/237] Update test_helper.rb to improve deprecation handling and unify fixture paths --- test/test_helper.rb | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/test/test_helper.rb b/test/test_helper.rb index 9850a49c6..f6f9c1b70 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -23,7 +23,6 @@ ENV['DATABASE_URL'] ||= "sqlite3:test_db" require 'active_record/railtie' -require 'rails/test_help' require 'minitest/mock' require 'jsonapi-resources' require 'pry' @@ -42,7 +41,11 @@ config.json_key_format = :camelized_key end -ActiveSupport::Deprecation.silenced = true +if ActiveSupport::Deprecation.respond_to?(:behavior=) + ActiveSupport::Deprecation.behavior = :silence +elsif ActiveSupport::Deprecation.respond_to?(:silenced=) + ActiveSupport::Deprecation.silenced = true +end puts "Testing With RAILS VERSION #{Rails.version}" @@ -457,12 +460,12 @@ def run_in_transaction? true end - self.fixture_path = "#{Rails.root}/fixtures" + self.fixture_paths = ["#{Rails.root}/fixtures"] fixtures :all end class ActiveSupport::TestCase - self.fixture_path = "#{Rails.root}/fixtures" + self.fixture_paths = ["#{Rails.root}/fixtures"] fixtures :all setup do @routes = TestApp.routes @@ -470,7 +473,7 @@ class ActiveSupport::TestCase end class ActionDispatch::IntegrationTest - self.fixture_path = "#{Rails.root}/fixtures" + self.fixture_paths = ["#{Rails.root}/fixtures"] fixtures :all def assert_jsonapi_response(expected_status, msg = nil) From 35aef58d435583dd0b474814dffd469e93405668 Mon Sep 17 00:00:00 2001 From: Seena Nair <55585488+seenanair@users.noreply.github.com> Date: Wed, 18 Jun 2025 10:48:44 +0100 Subject: [PATCH 214/237] Refactor CI configuration to simplify Ruby and Rails versions --- .github/workflows/ruby.yml | 57 +++++--------------------------------- 1 file changed, 7 insertions(+), 50 deletions(-) diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml index aeb9b1ae9..0fe41e0c2 100644 --- a/.github/workflows/ruby.yml +++ b/.github/workflows/ruby.yml @@ -2,67 +2,25 @@ name: CI on: push: - branches: [ 'master', 'release-0-8', 'release-0-9', 'release-0-10' ] + branches: [ 'master' ] pull_request: branches: ['**'] jobs: tests: runs-on: ubuntu-latest - services: - postgres: - image: postgres - env: - POSTGRES_PASSWORD: password - POSTGRES_DB: test - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - ports: - - 5432:5432 strategy: fail-fast: false matrix: ruby: - - 2.6 - - 2.7 - - '3.0' - - 3.1 - - 3.2 + - '3.3' + - '3.2' rails: - - 7.0.4 - - 6.1.7 - - 6.0.6 - - 5.2.8.1 - - 5.1.7 + - '7.1' + - '7.0' + - '8.0.2' database_url: - - postgresql://postgres:password@localhost:5432/test - sqlite3:test_db - exclude: - - ruby: 3.2 - rails: 6.0.6 - - ruby: 3.2 - rails: 5.2.8.1 - - ruby: 3.2 - rails: 5.1.7 - - ruby: 3.1 - rails: 6.0.6 - - ruby: 3.1 - rails: 5.2.8.1 - - ruby: 3.1 - rails: 5.1.7 - - ruby: '3.0' - rails: 6.0.6 - - ruby: '3.0' - rails: 5.2.8.1 - - ruby: '3.0' - rails: 5.1.7 - - ruby: 2.6 - rails: 7.0.4 - - database_url: postgresql://postgres:password@localhost:5432/test - rails: 5.1.7 env: RAILS_VERSION: ${{ matrix.rails }} DATABASE_URL: ${{ matrix.database_url }} @@ -73,7 +31,6 @@ jobs: uses: ruby/setup-ruby@v1 with: ruby-version: ${{ matrix.ruby }} - - name: Install dependencies - run: bundle install --jobs 4 --retry 3 + bundler-cache: true - name: Run tests run: bundle exec rake test From a7f21c4d6f3a9b675a63abdb97b10cc4bd773ae2 Mon Sep 17 00:00:00 2001 From: Seena Nair <55585488+seenanair@users.noreply.github.com> Date: Wed, 18 Jun 2025 11:02:50 +0100 Subject: [PATCH 215/237] Update gemspec and version to reflect Sanger Institute ownership and version change to 0.2.0 --- jsonapi-resources.gemspec | 10 +++++----- lib/jsonapi/resources/version.rb | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/jsonapi-resources.gemspec b/jsonapi-resources.gemspec index eb3c67fa5..22745c81d 100644 --- a/jsonapi-resources.gemspec +++ b/jsonapi-resources.gemspec @@ -4,13 +4,13 @@ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) require 'jsonapi/resources/version' Gem::Specification.new do |spec| - spec.name = 'jsonapi-resources' + spec.name = 'sanger-jsonapi-resources' spec.version = JSONAPI::Resources::VERSION - spec.authors = ['Dan Gebhardt', 'Larry Gebhardt'] - spec.email = ['dan@cerebris.com', 'larry@cerebris.com'] + spec.authors = ['PSD Team - Wellcome Trust Sanger Institute'] + spec.email = ['psd@sanger.ac.uk'] spec.summary = 'Easily support JSON API in Rails.' - spec.description = 'A resource-centric approach to implementing the controllers, routes, and serializers needed to support the JSON API spec.' - spec.homepage = 'https://github.com/cerebris/jsonapi-resources' + spec.description = 'Forked from jsonapi-resources. A resource-centric approach to implementing the controllers, routes, and serializers needed to support the JSON API spec.' + spec.homepage = 'https://github.com/sanger/jsonapi-resources' spec.license = 'MIT' spec.files = Dir.glob("{bin,lib}/**/*") + %w(LICENSE.txt README.md) diff --git a/lib/jsonapi/resources/version.rb b/lib/jsonapi/resources/version.rb index fb4178797..26eda26ad 100644 --- a/lib/jsonapi/resources/version.rb +++ b/lib/jsonapi/resources/version.rb @@ -1,5 +1,5 @@ module JSONAPI module Resources - VERSION = '0.11.0.beta1' + VERSION = '0.2.0' end end From d999a78b11faa61e0ed2f8b6032119f3fccfaf5b Mon Sep 17 00:00:00 2001 From: Seena Nair <55585488+seenanair@users.noreply.github.com> Date: Wed, 18 Jun 2025 11:03:00 +0100 Subject: [PATCH 216/237] Add wrapper file for sanger-jsonapi-resources to ensure compatibility with RubyGems --- lib/sanger-jsonapi-resources.rb | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 lib/sanger-jsonapi-resources.rb diff --git a/lib/sanger-jsonapi-resources.rb b/lib/sanger-jsonapi-resources.rb new file mode 100644 index 000000000..2b4763cf6 --- /dev/null +++ b/lib/sanger-jsonapi-resources.rb @@ -0,0 +1,7 @@ +# As we are packaging 'sanger-jsonapi-resources' as a separate gem, RubyGems expects +# the main file to be 'lib/sanger-jsonapi-resources.rb' to match the gem name. +# Without this file, requiring the gem or Rails autoloading would fail, even if the internal code is unchanged. +# This file exists to ensure compatibility with RubyGems and Bundler. +# The easiest solution is to use this wrapper file, which simply requires the original 'jsonapi-resources' code, +# so all internal references and modules remain unchanged and compatible. +require_relative 'jsonapi-resources' From 3100a4b4c4b211c78c56bcf30d89cae4fa7870fb Mon Sep 17 00:00:00 2001 From: Seena Nair <55585488+seenanair@users.noreply.github.com> Date: Wed, 18 Jun 2025 15:01:48 +0100 Subject: [PATCH 217/237] Add Ruby 3.4 to CI matrix for testing compatibility --- .github/workflows/ruby.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml index 0fe41e0c2..8fd0da3c2 100644 --- a/.github/workflows/ruby.yml +++ b/.github/workflows/ruby.yml @@ -13,6 +13,7 @@ jobs: fail-fast: false matrix: ruby: + - '3.4' - '3.3' - '3.2' rails: From 54e8f6938bac6a5e6f8a10230b6f619ac38673f1 Mon Sep 17 00:00:00 2001 From: yoldas Date: Tue, 16 Sep 2025 23:59:12 +0100 Subject: [PATCH 218/237] Add compatibility for obsolete Rack status symbols --- lib/jsonapi/error.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/jsonapi/error.rb b/lib/jsonapi/error.rb index 12d65f585..8eeb87cff 100644 --- a/lib/jsonapi/error.rb +++ b/lib/jsonapi/error.rb @@ -17,7 +17,7 @@ def initialize(options = {}) @source = options[:source] @links = options[:links] - @status = Rack::Utils::SYMBOL_TO_STATUS_CODE[options[:status]].to_s + @status = Rack::Utils.status_code(options[:status]).to_s @meta = options[:meta] end From a098867b0d7254c6e1d03a06b2fe3223262f1400 Mon Sep 17 00:00:00 2001 From: yoldas Date: Wed, 17 Sep 2025 00:48:28 +0100 Subject: [PATCH 219/237] Change version to 0.2.1 --- lib/jsonapi/resources/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/jsonapi/resources/version.rb b/lib/jsonapi/resources/version.rb index 26eda26ad..ae4b3a9da 100644 --- a/lib/jsonapi/resources/version.rb +++ b/lib/jsonapi/resources/version.rb @@ -1,5 +1,5 @@ module JSONAPI module Resources - VERSION = '0.2.0' + VERSION = '0.2.1' end end From fe5e1f1d06fc03dcabfd3bccdf3f1fea728a4719 Mon Sep 17 00:00:00 2001 From: Dasun Pubudumal Date: Mon, 26 Jan 2026 11:26:55 +0000 Subject: [PATCH 220/237] Rails 8.1 fix --- lib/jsonapi/routing_ext.rb | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/lib/jsonapi/routing_ext.rb b/lib/jsonapi/routing_ext.rb index b0b940138..8302fc63f 100644 --- a/lib/jsonapi/routing_ext.rb +++ b/lib/jsonapi/routing_ext.rb @@ -47,27 +47,14 @@ def jsonapi_resource(*resources, &_block) end resource @resource_type, options do - # :nocov: - if @scope.respond_to? :[]= - # Rails 4 - @scope[:jsonapi_resource] = @resource_type - + # Rails 6+ and 8.1: always use the modern block style + jsonapi_resource_scope(SingletonResource.new(@resource_type, api_only?, @scope[:shallow], options), @resource_type) do if block_given? yield else jsonapi_relationships end - else - # Rails 5 - jsonapi_resource_scope(SingletonResource.new(@resource_type, api_only?, @scope[:shallow], options), @resource_type) do - if block_given? - yield - else - jsonapi_relationships - end - end end - # :nocov: end end From c2f0662561d9f8454623c2be3e42f90ec2f1ee88 Mon Sep 17 00:00:00 2001 From: Dasun Pubudumal Date: Mon, 26 Jan 2026 11:32:40 +0000 Subject: [PATCH 221/237] Rails 8.1 fixes --- lib/jsonapi/routing_ext.rb | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/lib/jsonapi/routing_ext.rb b/lib/jsonapi/routing_ext.rb index 8302fc63f..6e1753844 100644 --- a/lib/jsonapi/routing_ext.rb +++ b/lib/jsonapi/routing_ext.rb @@ -86,7 +86,6 @@ def jsonapi_resources(*resources, &_block) options.merge!(res.routing_resource_options) options[:param] = :id - options[:path] = format_route(@resource_type) if res.resource_key_type == :uuid @@ -109,26 +108,14 @@ def jsonapi_resources(*resources, &_block) end resources @resource_type, options do - # :nocov: - if @scope.respond_to? :[]= - # Rails 4 - @scope[:jsonapi_resource] = @resource_type + # Rails 6+ and 8.1: always use the modern block style + jsonapi_resource_scope(Resource.new(@resource_type, api_only?, @scope[:shallow], options), @resource_type) do if block_given? yield else jsonapi_relationships end - else - # Rails 5 - jsonapi_resource_scope(Resource.new(@resource_type, api_only?, @scope[:shallow], options), @resource_type) do - if block_given? - yield - else - jsonapi_relationships - end - end end - # :nocov: end end From b80866e06267aef41a1a7e04b4bb5e3aa9f7e45b Mon Sep 17 00:00:00 2001 From: Dasun Pubudumal Date: Mon, 26 Jan 2026 11:36:03 +0000 Subject: [PATCH 222/237] Debugs --- lib/jsonapi/routing_ext.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/jsonapi/routing_ext.rb b/lib/jsonapi/routing_ext.rb index 6e1753844..f3e91e777 100644 --- a/lib/jsonapi/routing_ext.rb +++ b/lib/jsonapi/routing_ext.rb @@ -107,6 +107,8 @@ def jsonapi_resources(*resources, &_block) options[:except] << :destroy unless options[:except].include?(:destroy) || options[:except].include?('destroy') end + p "Options: #{options}" + resources @resource_type, options do # Rails 6+ and 8.1: always use the modern block style jsonapi_resource_scope(Resource.new(@resource_type, api_only?, @scope[:shallow], options), @resource_type) do From 7086a630b9aa1952e572b4bbeeae14f8bc281414 Mon Sep 17 00:00:00 2001 From: Dasun Pubudumal Date: Mon, 26 Jan 2026 11:37:01 +0000 Subject: [PATCH 223/237] Debugs --- lib/jsonapi/routing_ext.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/jsonapi/routing_ext.rb b/lib/jsonapi/routing_ext.rb index f3e91e777..dbb18d7a0 100644 --- a/lib/jsonapi/routing_ext.rb +++ b/lib/jsonapi/routing_ext.rb @@ -109,7 +109,7 @@ def jsonapi_resources(*resources, &_block) p "Options: #{options}" - resources @resource_type, options do + resources @resource_type, **options do # Rails 6+ and 8.1: always use the modern block style jsonapi_resource_scope(Resource.new(@resource_type, api_only?, @scope[:shallow], options), @resource_type) do if block_given? From 5b08066e0d3acb3c44ec8eaa514b7e58f8548051 Mon Sep 17 00:00:00 2001 From: Dasun Pubudumal Date: Mon, 26 Jan 2026 11:39:22 +0000 Subject: [PATCH 224/237] Debugs --- lib/jsonapi/routing_ext.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/jsonapi/routing_ext.rb b/lib/jsonapi/routing_ext.rb index dbb18d7a0..6adbb0c1a 100644 --- a/lib/jsonapi/routing_ext.rb +++ b/lib/jsonapi/routing_ext.rb @@ -46,7 +46,7 @@ def jsonapi_resource(*resources, &_block) options[:except] << :destroy unless options[:except].include?(:destroy) || options[:except].include?('destroy') end - resource @resource_type, options do + resource @resource_type, **options do # Rails 6+ and 8.1: always use the modern block style jsonapi_resource_scope(SingletonResource.new(@resource_type, api_only?, @scope[:shallow], options), @resource_type) do if block_given? From 2d152709cfc8b8b565b70b1bd1454dab85103d2e Mon Sep 17 00:00:00 2001 From: Dasun Pubudumal Date: Mon, 26 Jan 2026 11:41:27 +0000 Subject: [PATCH 225/237] Debugs --- lib/jsonapi/routing_ext.rb | 50 +++++++++++++++++++------------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/lib/jsonapi/routing_ext.rb b/lib/jsonapi/routing_ext.rb index 6adbb0c1a..937c2f835 100644 --- a/lib/jsonapi/routing_ext.rb +++ b/lib/jsonapi/routing_ext.rb @@ -33,18 +33,18 @@ def jsonapi_resource(*resources, &_block) options.merge!(res.routing_resource_options) options[:path] = format_route(@resource_type) - if options[:except] - options[:except] << :new unless options[:except].include?(:new) || options[:except].include?('new') - options[:except] << :edit unless options[:except].include?(:edit) || options[:except].include?('edit') - else - options[:except] = [:new, :edit] - end - - if res._immutable - options[:except] << :create unless options[:except].include?(:create) || options[:except].include?('create') - options[:except] << :update unless options[:except].include?(:update) || options[:except].include?('update') - options[:except] << :destroy unless options[:except].include?(:destroy) || options[:except].include?('destroy') - end + # if options[:except] + # options[:except] << :new unless options[:except].include?(:new) || options[:except].include?('new') + # options[:except] << :edit unless options[:except].include?(:edit) || options[:except].include?('edit') + # else + # options[:except] = [:new, :edit] + # end + + # if res._immutable + # options[:except] << :create unless options[:except].include?(:create) || options[:except].include?('create') + # options[:except] << :update unless options[:except].include?(:update) || options[:except].include?('update') + # options[:except] << :destroy unless options[:except].include?(:destroy) || options[:except].include?('destroy') + # end resource @resource_type, **options do # Rails 6+ and 8.1: always use the modern block style @@ -93,19 +93,19 @@ def jsonapi_resources(*resources, &_block) options[:constraints][:id] ||= /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/ end - if options[:except] - options[:except] = Array(options[:except]) - options[:except] << :new unless options[:except].include?(:new) || options[:except].include?('new') - options[:except] << :edit unless options[:except].include?(:edit) || options[:except].include?('edit') - else - options[:except] = [:new, :edit] - end - - if res._immutable - options[:except] << :create unless options[:except].include?(:create) || options[:except].include?('create') - options[:except] << :update unless options[:except].include?(:update) || options[:except].include?('update') - options[:except] << :destroy unless options[:except].include?(:destroy) || options[:except].include?('destroy') - end + # if options[:except] + # options[:except] = Array(options[:except]) + # options[:except] << :new unless options[:except].include?(:new) || options[:except].include?('new') + # options[:except] << :edit unless options[:except].include?(:edit) || options[:except].include?('edit') + # else + # options[:except] = [:new, :edit] + # end + + # if res._immutable + # options[:except] << :create unless options[:except].include?(:create) || options[:except].include?('create') + # options[:except] << :update unless options[:except].include?(:update) || options[:except].include?('update') + # options[:except] << :destroy unless options[:except].include?(:destroy) || options[:except].include?('destroy') + # end p "Options: #{options}" From 143cc1689ca677a23fc93762b3c6aff52c291fbe Mon Sep 17 00:00:00 2001 From: Dasun Pubudumal Date: Mon, 26 Jan 2026 11:46:48 +0000 Subject: [PATCH 226/237] Debugs --- lib/jsonapi/routing_ext.rb | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/jsonapi/routing_ext.rb b/lib/jsonapi/routing_ext.rb index 937c2f835..51070bf1e 100644 --- a/lib/jsonapi/routing_ext.rb +++ b/lib/jsonapi/routing_ext.rb @@ -48,13 +48,13 @@ def jsonapi_resource(*resources, &_block) resource @resource_type, **options do # Rails 6+ and 8.1: always use the modern block style - jsonapi_resource_scope(SingletonResource.new(@resource_type, api_only?, @scope[:shallow], options), @resource_type) do - if block_given? - yield - else - jsonapi_relationships - end - end + # jsonapi_resource_scope(SingletonResource.new(@resource_type, api_only?, @scope[:shallow], options), @resource_type) do + # if block_given? + # yield + # else + # jsonapi_relationships + # end + # end end end From 4a7d6668b11cc35cfef73c8b172371145ea7dc4f Mon Sep 17 00:00:00 2001 From: Dasun Pubudumal Date: Mon, 26 Jan 2026 11:48:13 +0000 Subject: [PATCH 227/237] Debugs --- lib/jsonapi/routing_ext.rb | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/lib/jsonapi/routing_ext.rb b/lib/jsonapi/routing_ext.rb index 51070bf1e..49deafd7d 100644 --- a/lib/jsonapi/routing_ext.rb +++ b/lib/jsonapi/routing_ext.rb @@ -46,7 +46,7 @@ def jsonapi_resource(*resources, &_block) # options[:except] << :destroy unless options[:except].include?(:destroy) || options[:except].include?('destroy') # end - resource @resource_type, **options do + resource @resource_type, options do # Rails 6+ and 8.1: always use the modern block style # jsonapi_resource_scope(SingletonResource.new(@resource_type, api_only?, @scope[:shallow], options), @resource_type) do # if block_given? @@ -109,15 +109,15 @@ def jsonapi_resources(*resources, &_block) p "Options: #{options}" - resources @resource_type, **options do + resources @resource_type, options do # Rails 6+ and 8.1: always use the modern block style - jsonapi_resource_scope(Resource.new(@resource_type, api_only?, @scope[:shallow], options), @resource_type) do - if block_given? - yield - else - jsonapi_relationships - end - end + # jsonapi_resource_scope(Resource.new(@resource_type, api_only?, @scope[:shallow], options), @resource_type) do + # if block_given? + # yield + # else + # jsonapi_relationships + # end + # end end end From 6f2d402d192e43b00f7c3f4631ad00f6f6349a86 Mon Sep 17 00:00:00 2001 From: Dasun Pubudumal Date: Mon, 26 Jan 2026 11:49:15 +0000 Subject: [PATCH 228/237] Debugs --- lib/jsonapi/routing_ext.rb | 50 +++++++++++++++++++------------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/lib/jsonapi/routing_ext.rb b/lib/jsonapi/routing_ext.rb index 49deafd7d..4c22ada03 100644 --- a/lib/jsonapi/routing_ext.rb +++ b/lib/jsonapi/routing_ext.rb @@ -33,18 +33,18 @@ def jsonapi_resource(*resources, &_block) options.merge!(res.routing_resource_options) options[:path] = format_route(@resource_type) - # if options[:except] - # options[:except] << :new unless options[:except].include?(:new) || options[:except].include?('new') - # options[:except] << :edit unless options[:except].include?(:edit) || options[:except].include?('edit') - # else - # options[:except] = [:new, :edit] - # end - - # if res._immutable - # options[:except] << :create unless options[:except].include?(:create) || options[:except].include?('create') - # options[:except] << :update unless options[:except].include?(:update) || options[:except].include?('update') - # options[:except] << :destroy unless options[:except].include?(:destroy) || options[:except].include?('destroy') - # end + if options[:except] + options[:except] << :new unless options[:except].include?(:new) || options[:except].include?('new') + options[:except] << :edit unless options[:except].include?(:edit) || options[:except].include?('edit') + else + options[:except] = [:new, :edit] + end + + if res._immutable + options[:except] << :create unless options[:except].include?(:create) || options[:except].include?('create') + options[:except] << :update unless options[:except].include?(:update) || options[:except].include?('update') + options[:except] << :destroy unless options[:except].include?(:destroy) || options[:except].include?('destroy') + end resource @resource_type, options do # Rails 6+ and 8.1: always use the modern block style @@ -93,19 +93,19 @@ def jsonapi_resources(*resources, &_block) options[:constraints][:id] ||= /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/ end - # if options[:except] - # options[:except] = Array(options[:except]) - # options[:except] << :new unless options[:except].include?(:new) || options[:except].include?('new') - # options[:except] << :edit unless options[:except].include?(:edit) || options[:except].include?('edit') - # else - # options[:except] = [:new, :edit] - # end - - # if res._immutable - # options[:except] << :create unless options[:except].include?(:create) || options[:except].include?('create') - # options[:except] << :update unless options[:except].include?(:update) || options[:except].include?('update') - # options[:except] << :destroy unless options[:except].include?(:destroy) || options[:except].include?('destroy') - # end + if options[:except] + options[:except] = Array(options[:except]) + options[:except] << :new unless options[:except].include?(:new) || options[:except].include?('new') + options[:except] << :edit unless options[:except].include?(:edit) || options[:except].include?('edit') + else + options[:except] = [:new, :edit] + end + + if res._immutable + options[:except] << :create unless options[:except].include?(:create) || options[:except].include?('create') + options[:except] << :update unless options[:except].include?(:update) || options[:except].include?('update') + options[:except] << :destroy unless options[:except].include?(:destroy) || options[:except].include?('destroy') + end p "Options: #{options}" From 5465f91856f4bc40c97246ec2ed06073e6becdbe Mon Sep 17 00:00:00 2001 From: Dasun Pubudumal Date: Mon, 26 Jan 2026 12:28:49 +0000 Subject: [PATCH 229/237] Debugs --- lib/jsonapi/routing_ext.rb | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/lib/jsonapi/routing_ext.rb b/lib/jsonapi/routing_ext.rb index 4c22ada03..7e6366c17 100644 --- a/lib/jsonapi/routing_ext.rb +++ b/lib/jsonapi/routing_ext.rb @@ -107,17 +107,14 @@ def jsonapi_resources(*resources, &_block) options[:except] << :destroy unless options[:except].include?(:destroy) || options[:except].include?('destroy') end - p "Options: #{options}" - resources @resource_type, options do # Rails 6+ and 8.1: always use the modern block style - # jsonapi_resource_scope(Resource.new(@resource_type, api_only?, @scope[:shallow], options), @resource_type) do - # if block_given? - # yield - # else - # jsonapi_relationships - # end - # end + @jsonapi_resource_type = @resource_type + if block_given? + yield + else + jsonapi_relationships + end end end From 71b84c2e56cf1de9b07dadad01de4a6e49b5040a Mon Sep 17 00:00:00 2001 From: Dasun Pubudumal Date: Mon, 26 Jan 2026 12:30:30 +0000 Subject: [PATCH 230/237] Debugs --- lib/jsonapi/routing_ext.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/jsonapi/routing_ext.rb b/lib/jsonapi/routing_ext.rb index 7e6366c17..a7d5048fb 100644 --- a/lib/jsonapi/routing_ext.rb +++ b/lib/jsonapi/routing_ext.rb @@ -109,7 +109,6 @@ def jsonapi_resources(*resources, &_block) resources @resource_type, options do # Rails 6+ and 8.1: always use the modern block style - @jsonapi_resource_type = @resource_type if block_given? yield else From bde2c2a085f22523685c9de4bd852a730de7b846 Mon Sep 17 00:00:00 2001 From: Dasun Pubudumal Date: Mon, 26 Jan 2026 13:04:20 +0000 Subject: [PATCH 231/237] Rails 8.1 compatibility fix --- lib/jsonapi/routing_ext.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/jsonapi/routing_ext.rb b/lib/jsonapi/routing_ext.rb index a7d5048fb..c46aef329 100644 --- a/lib/jsonapi/routing_ext.rb +++ b/lib/jsonapi/routing_ext.rb @@ -109,11 +109,13 @@ def jsonapi_resources(*resources, &_block) resources @resource_type, options do # Rails 6+ and 8.1: always use the modern block style + jsonapi_resource_scope(Resource.new(@resource_type, api_only?, @scope[:shallow], **options), @resource_type) do if block_given? yield else jsonapi_relationships end + end end end From fe5f506922fe42ef279fd67d12b0d4064fcb2f69 Mon Sep 17 00:00:00 2001 From: Dasun Pubudumal Date: Mon, 26 Jan 2026 13:13:48 +0000 Subject: [PATCH 232/237] Refactor routing_ext.rb to use modern block style for resource handling --- lib/jsonapi/routing_ext.rb | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/lib/jsonapi/routing_ext.rb b/lib/jsonapi/routing_ext.rb index c46aef329..2f2de0d33 100644 --- a/lib/jsonapi/routing_ext.rb +++ b/lib/jsonapi/routing_ext.rb @@ -47,14 +47,13 @@ def jsonapi_resource(*resources, &_block) end resource @resource_type, options do - # Rails 6+ and 8.1: always use the modern block style - # jsonapi_resource_scope(SingletonResource.new(@resource_type, api_only?, @scope[:shallow], options), @resource_type) do - # if block_given? - # yield - # else - # jsonapi_relationships - # end - # end + jsonapi_resource_scope(SingletonResource.new(@resource_type, api_only?, @scope[:shallow], options), @resource_type) do + if block_given? + yield + else + jsonapi_relationships + end + end end end From 928771a62476c88f0af4fa25b7238016a037f269 Mon Sep 17 00:00:00 2001 From: Dasun Pubudumal Date: Mon, 26 Jan 2026 13:14:16 +0000 Subject: [PATCH 233/237] Refactor routing_ext.rb to use modern block style for resource handling --- lib/jsonapi/routing_ext.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/jsonapi/routing_ext.rb b/lib/jsonapi/routing_ext.rb index 2f2de0d33..52995a336 100644 --- a/lib/jsonapi/routing_ext.rb +++ b/lib/jsonapi/routing_ext.rb @@ -47,7 +47,7 @@ def jsonapi_resource(*resources, &_block) end resource @resource_type, options do - jsonapi_resource_scope(SingletonResource.new(@resource_type, api_only?, @scope[:shallow], options), @resource_type) do + jsonapi_resource_scope(SingletonResource.new(@resource_type, api_only?, @scope[:shallow], **options), @resource_type) do if block_given? yield else From 4c72d7f0dd7942a42d2fcc902c1854c8d2065f8a Mon Sep 17 00:00:00 2001 From: Dasun Pubudumal Date: Mon, 26 Jan 2026 13:23:54 +0000 Subject: [PATCH 234/237] Refactor routing_ext.rb to handle Rails version compatibility in resource handling --- lib/jsonapi/routing_ext.rb | 33 ++++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/lib/jsonapi/routing_ext.rb b/lib/jsonapi/routing_ext.rb index 52995a336..2bc480feb 100644 --- a/lib/jsonapi/routing_ext.rb +++ b/lib/jsonapi/routing_ext.rb @@ -47,13 +47,27 @@ def jsonapi_resource(*resources, &_block) end resource @resource_type, options do - jsonapi_resource_scope(SingletonResource.new(@resource_type, api_only?, @scope[:shallow], **options), @resource_type) do + # :nocov: + if @scope.respond_to? :[]= + # Rails 4 + @scope[:jsonapi_resource] = @resource_type + if block_given? yield else jsonapi_relationships end + else + # Rails 5 + jsonapi_resource_scope(SingletonResource.new(@resource_type, api_only?, @scope[:shallow], **options), @resource_type) do + if block_given? + yield + else + jsonapi_relationships + end + end end + # :nocov: end end @@ -85,6 +99,7 @@ def jsonapi_resources(*resources, &_block) options.merge!(res.routing_resource_options) options[:param] = :id + options[:path] = format_route(@resource_type) if res.resource_key_type == :uuid @@ -107,14 +122,26 @@ def jsonapi_resources(*resources, &_block) end resources @resource_type, options do - # Rails 6+ and 8.1: always use the modern block style - jsonapi_resource_scope(Resource.new(@resource_type, api_only?, @scope[:shallow], **options), @resource_type) do + # :nocov: + if @scope.respond_to? :[]= + # Rails 4 + @scope[:jsonapi_resource] = @resource_type if block_given? yield else jsonapi_relationships end + else + # Rails 5 + jsonapi_resource_scope(Resource.new(@resource_type, api_only?, @scope[:shallow], **options), @resource_type) do + if block_given? + yield + else + jsonapi_relationships + end + end end + # :nocov: end end From cc52b3bb599c9361ca76cba3133b42aebdb2d3af Mon Sep 17 00:00:00 2001 From: Dasun Pubudumal Date: Mon, 26 Jan 2026 13:27:02 +0000 Subject: [PATCH 235/237] Bump version to 0.3.0 --- lib/jsonapi/resources/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/jsonapi/resources/version.rb b/lib/jsonapi/resources/version.rb index ae4b3a9da..b25e5b708 100644 --- a/lib/jsonapi/resources/version.rb +++ b/lib/jsonapi/resources/version.rb @@ -1,5 +1,5 @@ module JSONAPI module Resources - VERSION = '0.2.1' + VERSION = '0.3.0' end end From 33321fad6e0d3dc4b3291f46639344fd072f3257 Mon Sep 17 00:00:00 2001 From: Tom Whiteley Date: Mon, 16 Mar 2026 16:00:28 +0000 Subject: [PATCH 236/237] Adding CONTRIBUTING.md --- CONTRIBUTING.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..03ce14d91 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,30 @@ +# Contributing + +All contributions to this project are subject to the [MIT License](https://foss-haas.mit-license.org/). By submitting a contribution, you agree to license your work under these terms. + +## Contribution Process + +### 1. Issue First + +All contributions from outside the core team require an **Issue First** approach. Before submitting a pull request (PR), you must: + +- Open an issue in the repository. +- Ensure the issue includes: + - **Clear problem statement:** Describe the issue or feature request. + - **Reproduction steps:** If reporting a bug, provide steps to reproduce it. + - **Proposed approach:** Outline your suggested solution or implementation. + - **Why this change matters:** Explain the impact or necessity of the change. +- Tag `@sanger/psd-developers` in the issue to bring it to the attention of a maintainer. +- Wait for the issue to be assigned or approved by a maintainer. + +### 2. Pull Request + +Once your issue is approved: + +- Fork the repository and create a branch for your changes. +- Submit a PR referencing the approved issue. +- Ensure your code adheres to the project's coding standards and passes all tests. + +### 3. Review + +Maintainers will review your PR. Address any feedback before merging. \ No newline at end of file From 4a9b17537f8a867ce95d4e2a732452eb6730d836 Mon Sep 17 00:00:00 2001 From: Tom Whiteley Date: Wed, 1 Apr 2026 10:52:14 +0100 Subject: [PATCH 237/237] Updating contributing section in README.md to reference CONTRIBUTING.md --- README.md | 24 +----------------------- 1 file changed, 1 insertion(+), 23 deletions(-) diff --git a/README.md b/README.md index 377e49304..b7f2f18f2 100644 --- a/README.md +++ b/README.md @@ -47,29 +47,7 @@ gem install jsonapi-resources **For further usage see the [v0.10 alpha Guide](http://jsonapi-resources.com/v0.10/guide/)** ## Contributing - -1. Submit an issue describing any new features you wish it add or the bug you intend to fix -1. Fork it ( http://github.com/cerebris/jsonapi-resources/fork ) -1. Create your feature branch (`git checkout -b my-new-feature`) -1. Run the full test suite (`rake test`) -1. Fix any failing tests -1. Commit your changes (`git commit -am 'Add some feature'`) -1. Push to the branch (`git push origin my-new-feature`) -1. Create a new Pull Request - -## Did you find a bug? - -* **Ensure the bug was not already reported** by searching on GitHub under [Issues](https://github.com/cerebris/jsonapi-resources/issues). - -* If you're unable to find an open issue addressing the problem, [open a new one](https://github.com/cerebris/jsonapi-resources/issues/new). -Be sure to include a **title and clear description**, as much relevant information as possible, -and a **code sample** or an **executable test case** demonstrating the expected behavior that is not occurring. - -* If possible, use the relevant bug report templates to create the issue. -Simply copy the content of the appropriate template into a .rb file, make the necessary changes to demonstrate the issue, -and **paste the content into the issue description or attach as a file**: - * [**Rails 5** issues](https://github.com/cerebris/jsonapi-resources/blob/master/lib/bug_report_templates/rails_5_master.rb) - +See CONTRIBUTING.md for details. ## License