diff --git a/.rubocop.yml b/.rubocop.yml index 8d7551a5..82f07656 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,10 +1,13 @@ AllCops: TargetRubyVersion: 2.1 Exclude: - - config/initializers/forbidden_yaml.rb - !ruby/regexp /(vendor|bundle|bin|db|tmp)\/.*/ DisplayCopNames: true DisplayStyleGuide: true + # https://github.com/bbatsov/rubocop/blob/master/manual/caching.md + # https://github.com/bbatsov/rubocop/blob/e8680418b351491e111a18cf5b453fc07a3c5239/config/default.yml#L60-L77 + UseCache: true + CacheRootDirectory: tmp Rails: Enabled: true diff --git a/CHANGELOG.md b/CHANGELOG.md index bea66047..c0ed98b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ ## 0.10.x -### [master (unreleased)](https://github.com/rails-api/active_model_serializers/compare/v0.10.5...master) +### [master (unreleased)](https://github.com/rails-api/active_model_serializers/compare/v0.10.6...master) Breaking changes: @@ -14,6 +14,20 @@ Fixes: Misc: +### [v0.10.6 (2017-05-01)](https://github.com/rails-api/active_model_serializers/compare/v0.10.5...v0.10.6) + +Fixes: + +- [#1857](https://github.com/rails-api/active_model_serializers/pull/1857) JSON:API does not load belongs_to relation to get identifier id. (@bf4) +- [#2119](https://github.com/rails-api/active_model_serializers/pull/2119) JSON:API returns null resource object identifier when 'id' is null. (@bf4) +- [#2093](https://github.com/rails-api/active_model_serializers/pull/2093) undef problematic Serializer methods: display, select. (@bf4) + +Misc: + +- [#2104](https://github.com/rails-api/active_model_serializers/pull/2104) Documentation for serializers and rendering. (@cassidycodes) +- [#2081](https://github.com/rails-api/active_model_serializers/pull/2081) Documentation for `include` option in adapters. (@charlie-wasp) +- [#2120](https://github.com/rails-api/active_model_serializers/pull/2120) Documentation for association options: foreign_key, type, class_name, namespace. (@bf4) + ### [v0.10.5 (2017-03-07)](https://github.com/rails-api/active_model_serializers/compare/v0.10.4...v0.10.5) Breaking changes: @@ -81,7 +95,7 @@ Misc: - [#1878](https://github.com/rails-api/active_model_serializers/pull/1878) Cache key generation for serializers now uses `ActiveSupport::Cache.expand_cache_key` instead of `Array#join` by default and is also overridable. This change should be backward-compatible. (@markiz) -- [#1799](https://github.com/rails-api/active_model_serializers/pull/1799) Add documentation for setting the adapter. (@ScottKbka) +- [#1799](https://github.com/rails-api/active_model_serializers/pull/1799) Add documentation for setting the adapter. (@cassidycodes) - [#1909](https://github.com/rails-api/active_model_serializers/pull/1909) Add documentation for relationship links. (@vasilakisfil, @NullVoxPopuli) - [#1959](https://github.com/rails-api/active_model_serializers/pull/1959) Add documentation for root. (@shunsuke227ono) - [#1967](https://github.com/rails-api/active_model_serializers/pull/1967) Improve type method documentation. (@yukideluxe) diff --git a/README.md b/README.md index 6b4b2966..6f186396 100644 --- a/README.md +++ b/README.md @@ -90,8 +90,8 @@ reading documentation for our `master`, which may include features that have not been released yet. Please see below for the documentation relevant to you. - [0.10 (master) Documentation](https://github.com/rails-api/active_model_serializers/tree/master) -- [0.10.4 (latest release) Documentation](https://github.com/rails-api/active_model_serializers/tree/v0.10.4) - - [![API Docs](http://img.shields.io/badge/yard-docs-blue.svg)](http://www.rubydoc.info/gems/active_model_serializers/0.10.4) +- [0.10.6 (latest release) Documentation](https://github.com/rails-api/active_model_serializers/tree/v0.10.6) + - [![API Docs](http://img.shields.io/badge/yard-docs-blue.svg)](http://www.rubydoc.info/gems/active_model_serializers/0.10.6) - [Guides](docs) - [0.9 (0-9-stable) Documentation](https://github.com/rails-api/active_model_serializers/tree/0-9-stable) - [![API Docs](http://img.shields.io/badge/yard-docs-blue.svg)](http://www.rubydoc.info/github/rails-api/active_model_serializers/0-9-stable) diff --git a/Rakefile b/Rakefile index 912c7fcb..6ba0c2bc 100644 --- a/Rakefile +++ b/Rakefile @@ -7,6 +7,7 @@ begin require 'simplecov' rescue LoadError # rubocop:disable Lint/HandleExceptions end +import('lib/tasks/rubocop.rake') Bundler::GemHelper.install_tasks @@ -30,36 +31,6 @@ namespace :yard do end end -begin - require 'rubocop' - require 'rubocop/rake_task' -rescue LoadError # rubocop:disable Lint/HandleExceptions -else - Rake::Task[:rubocop].clear if Rake::Task.task_defined?(:rubocop) - require 'rbconfig' - # https://github.com/bundler/bundler/blob/1b3eb2465a/lib/bundler/constants.rb#L2 - windows_platforms = /(msdos|mswin|djgpp|mingw)/ - if RbConfig::CONFIG['host_os'] =~ windows_platforms - desc 'No-op rubocop on Windows-- unsupported platform' - task :rubocop do - puts 'Skipping rubocop on Windows' - end - elsif defined?(::Rubinius) - desc 'No-op rubocop to avoid rbx segfault' - task :rubocop do - puts 'Skipping rubocop on rbx due to segfault' - puts 'https://github.com/rubinius/rubinius/issues/3499' - end - else - Rake::Task[:rubocop].clear if Rake::Task.task_defined?(:rubocop) - desc 'Execute rubocop' - RuboCop::RakeTask.new(:rubocop) do |task| - task.options = ['--rails', '--display-cop-names', '--display-style-guide'] - task.fail_on_error = true - end - end -end - require 'rake/testtask' Rake::TestTask.new(:test) do |t| diff --git a/active_model_serializers.gemspec b/active_model_serializers.gemspec index 108f166a..805c99c8 100644 --- a/active_model_serializers.gemspec +++ b/active_model_serializers.gemspec @@ -57,7 +57,7 @@ Gem::Specification.new do |spec| spec.add_development_dependency 'bundler', '~> 1.6' spec.add_development_dependency 'simplecov', '~> 0.11' spec.add_development_dependency 'timecop', '~> 0.7' - spec.add_development_dependency 'grape', ['>= 0.13', '< 1.0'] + spec.add_development_dependency 'grape', ['>= 0.13', '< 0.19.1'] spec.add_development_dependency 'json_schema' spec.add_development_dependency 'rake', ['>= 10.0', '< 12.0'] end diff --git a/bin/rubocop b/bin/rubocop new file mode 100755 index 00000000..269f8954 --- /dev/null +++ b/bin/rubocop @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# +# Usage: +# bin/rubocop [-A|-t|-h] +# bin/rubocop [file or path] [cli options] +# +# Options: +# Autocorrect -A +# AutoGenConfig -t +# Usage -h,--help,help + +set -e + +case $1 in + -A) + echo "Rubocop autocorrect is ON" >&2 + bundle exec rake -f lib/tasks/rubocop.rake rubocop:auto_correct + ;; + + -t) + echo "Rubocop is generating a new TODO" >&2 + bundle exec rake -f lib/tasks/rubocop.rake rubocop:auto_gen_config + ;; + + -h|--help|help) + sed -ne '/^#/!q;s/.\{1,2\}//;1d;p' < "$0" + ;; + + *) + # with no args, run vanilla rubocop + # else assume we're passing in arbitrary arguments + if [ -z "$1" ]; then + bundle exec rake -f lib/tasks/rubocop.rake rubocop + else + bundle exec rubocop "$@" + fi + ;; +esac diff --git a/docs/general/adapters.md b/docs/general/adapters.md index 6ae1d27a..84fc4e62 100644 --- a/docs/general/adapters.md +++ b/docs/general/adapters.md @@ -141,18 +141,25 @@ This adapter follows **version 1.0** of the [format specified](../jsonapi/schema } ``` -#### Included +### Include option -It will include the associated resources in the `"included"` member -when the resource names are included in the `include` option. -Including nested associated resources is also supported. +Which [serializer associations](https://github.com/rails-api/active_model_serializers/blob/master/docs/general/serializers.md#associations) are rendered can be specified using the `include` option. The option usage is consistent with [the include option in the JSON API spec](http://jsonapi.org/format/#fetching-includes), and is available in all adapters. +Example of the usage: ```ruby render json: @posts, include: ['author', 'comments', 'comments.author'] # or render json: @posts, include: 'author,comments,comments.author' ``` +The format of the `include` option can be either: + +- a String composed of a comma-separated list of [relationship paths](http://jsonapi.org/format/#fetching-includes). +- an Array of Symbols and Hashes. +- a mix of both. + +An empty string or an empty array will prevent rendering of any associations. + In addition, two types of wildcards may be used: - `*` includes one level of associations. @@ -164,11 +171,6 @@ These can be combined with other paths. render json: @posts, include: '**' # or '*' for a single layer ``` -The format of the `include` option can be either: - -- a String composed of a comma-separated list of [relationship paths](http://jsonapi.org/format/#fetching-includes). -- an Array of Symbols and Hashes. -- a mix of both. The following would render posts and include: @@ -182,6 +184,20 @@ It could be combined, like above, with other paths in any combination desired. render json: @posts, include: 'author.comments.**' ``` +**Note:** Wildcards are ActiveModelSerializers-specific, they are not part of the JSON API spec. + +The default include for the JSON API adapter is no associations. The default for the JSON and Attributes adapters is all associations. + +For the JSON API adapter associated resources will be gathered in the `"included"` member. For the JSON and Attributes +adapters associated resources will be rendered among the other attributes. + +Only for the JSON API adapter you can specify, which attributes of associated resources will be rendered. This feature +is called [sparse fieldset](http://jsonapi.org/format/#fetching-sparse-fieldsets): + +```ruby + render json: @posts, include: 'comments', fields: { comments: ['content', 'created_at'] } +``` + ##### Security Considerations Since the included options may come from the query params (i.e. user-controller): diff --git a/docs/general/getting_started.md b/docs/general/getting_started.md index ac6d5f79..b39cd283 100644 --- a/docs/general/getting_started.md +++ b/docs/general/getting_started.md @@ -37,7 +37,7 @@ and class CommentSerializer < ActiveModel::Serializer attributes :name, :body - belongs_to :post_id + belongs_to :post end ``` diff --git a/docs/general/rendering.md b/docs/general/rendering.md index 21120a5a..af2d886f 100644 --- a/docs/general/rendering.md +++ b/docs/general/rendering.md @@ -203,7 +203,7 @@ link(:link_name) { url_for(controller: 'controller_name', action: 'index', only_ #### include -PR please :) +See [Adapters: Include Option](/docs/general/adapters.md#include-option). #### Overriding the root key @@ -260,15 +260,29 @@ Note that by using a string and symbol, Ruby will assume the namespace is define #### serializer -PR please :) +Specify which serializer to use if you want to use a serializer other than the default. + +For a single resource: + +```ruby +@post = Post.first +render json: @post, serializer: SpecialPostSerializer +``` + +To specify which serializer to use on individual items in a collection (i.e., an `index` action), use `each_serializer`: + +```ruby +@posts = Post.all +render json: @posts, each_serializer: SpecialPostSerializer +``` #### scope -PR please :) +See [Serializers: Scope](/docs/general/serializers.md#scope). #### scope_name -PR please :) +See [Serializers: Scope](/docs/general/serializers.md#scope). ## Using a serializer without `render` diff --git a/docs/general/serializers.md b/docs/general/serializers.md index bb6e21be..5b23ba0f 100644 --- a/docs/general/serializers.md +++ b/docs/general/serializers.md @@ -64,6 +64,10 @@ Where: - `unless:` - `virtual_value:` - `polymorphic:` defines if polymorphic relation type should be nested in serialized association. + - `type:` the resource type as used by JSON:API, especially on a `belongs_to` relationship. + - `class_name:` used to determine `type` when `type` not given + - `foreign_key:` used by JSON:API on a `belongs_to` relationship to avoid unnecessarily loading the association object. + - `namespace:` used when looking up the serializer and `serializer` is not given. Falls back to the parent serializer's `:namespace` instance options, which, when present, comes from the render options. See [Rendering#namespace](rendering.md#namespace] for more details. - optional: `&block` is a context that returns the association's attributes. - prevents `association_name` method from being called. - return value of block is used as the association value. @@ -382,11 +386,26 @@ The serialized value for a given key. e.g. `read_attribute_for_serialization(:ti #### #links -PR please :) +Allows you to modify the `links` node. By default, this node will be populated with the attributes set using the [::link](#link) method. Using `links: nil` will remove the `links` node. + +```ruby +ActiveModelSerializers::SerializableResource.new( + @post, + adapter: :json_api, + links: { + self: { + href: 'http://example.com/posts', + meta: { + stuff: 'value' + } + } + } +) +``` #### #json_key -PR please :) +Returns the key used by the adapter as the resource root. See [root](#root) for more information. ## Examples diff --git a/docs/howto/add_pagination_links.md b/docs/howto/add_pagination_links.md index 69d290c2..e2792383 100644 --- a/docs/howto/add_pagination_links.md +++ b/docs/howto/add_pagination_links.md @@ -72,7 +72,7 @@ ActiveModelSerializers pagination relies on a paginated collection with the meth ### JSON adapter -If you are using `JSON` adapter, pagination links will not be included automatically, but it is possible to do so using `meta` key. +If you are not using `JSON` adapter, pagination links will not be included automatically, but it is possible to do so using `meta` key. Add this method to your base API controller. diff --git a/docs/integrations/ember-and-json-api.md b/docs/integrations/ember-and-json-api.md index 57454b72..eb7f1ade 100644 --- a/docs/integrations/ember-and-json-api.md +++ b/docs/integrations/ember-and-json-api.md @@ -74,6 +74,9 @@ Then, in your controller you can tell rails you're accepting and rendering the j end ``` +#### Note: +In Rails 5, the "unsafe" method ( `jsonapi_parse!` vs the safe `jsonapi_parse`) throws an `InvalidDocument` exception when the payload does not meet basic criteria for JSON API deserialization. + ### Adapter Changes diff --git a/lib/active_model/serializer.rb b/lib/active_model/serializer.rb index 0d94bfb5..9d00e6fb 100644 --- a/lib/active_model/serializer.rb +++ b/lib/active_model/serializer.rb @@ -4,13 +4,7 @@ require 'active_model/serializer/collection_serializer' require 'active_model/serializer/array_serializer' require 'active_model/serializer/error_serializer' require 'active_model/serializer/errors_serializer' -require 'active_model/serializer/concerns/associations' -require 'active_model/serializer/concerns/attributes' require 'active_model/serializer/concerns/caching' -require 'active_model/serializer/concerns/configuration' -require 'active_model/serializer/concerns/links' -require 'active_model/serializer/concerns/meta' -require 'active_model/serializer/concerns/type' require 'active_model/serializer/fieldset' require 'active_model/serializer/lint' @@ -18,33 +12,40 @@ require 'active_model/serializer/lint' # reified when subclassed to decorate a resource. module ActiveModel class Serializer + undef_method :select, :display # These IO methods, which are mixed into Kernel, + # sometimes conflict with attribute names. We don't need these IO methods. + # @see #serializable_hash for more details on these valid keys. SERIALIZABLE_HASH_VALID_KEYS = [:only, :except, :methods, :include, :root].freeze extend ActiveSupport::Autoload autoload :Adapter autoload :Null - include Configuration - include Associations - include Attributes + autoload :Attribute + autoload :Association + autoload :Reflection + autoload :SingularReflection + autoload :CollectionReflection + autoload :BelongsToReflection + autoload :HasOneReflection + autoload :HasManyReflection + include ActiveSupport::Configurable include Caching - include Links - include Meta - include Type # @param resource [ActiveRecord::Base, ActiveModelSerializers::Model] # @return [ActiveModel::Serializer] # Preferentially returns - # 1. resource.serializer + # 1. resource.serializer_class # 2. ArraySerializer when resource is a collection # 3. options[:serializer] # 4. lookup serializer when resource is a Class - def self.serializer_for(resource, options = {}) - if resource.respond_to?(:serializer_class) - resource.serializer_class - elsif resource.respond_to?(:to_ary) + def self.serializer_for(resource_or_class, options = {}) + if resource_or_class.respond_to?(:serializer_class) + resource_or_class.serializer_class + elsif resource_or_class.respond_to?(:to_ary) config.collection_serializer else - options.fetch(:serializer) { get_serializer_for(resource.class, options[:namespace]) } + resource_class = resource_or_class.class == Class ? resource_or_class : resource_or_class.class + options.fetch(:serializer) { get_serializer_for(resource_class, options[:namespace]) } end end @@ -91,6 +92,8 @@ module ActiveModel serializer_class elsif klass.superclass get_serializer_for(klass.superclass) + else + nil # No serializer found end end end @@ -111,6 +114,193 @@ module ActiveModel @serialization_adapter_instance ||= ActiveModelSerializers::Adapter::Attributes end + # Preferred interface is ActiveModelSerializers.config + # BEGIN DEFAULT CONFIGURATION + config.collection_serializer = ActiveModel::Serializer::CollectionSerializer + config.serializer_lookup_enabled = true + + # @deprecated Use {#config.collection_serializer=} instead of this. Is + # compatibility layer for ArraySerializer. + def config.array_serializer=(collection_serializer) + self.collection_serializer = collection_serializer + end + + # @deprecated Use {#config.collection_serializer} instead of this. Is + # compatibility layer for ArraySerializer. + def config.array_serializer + collection_serializer + end + + config.default_includes = '*' + config.adapter = :attributes + config.key_transform = nil + config.jsonapi_pagination_links_enabled = true + config.jsonapi_resource_type = :plural + config.jsonapi_namespace_separator = '-'.freeze + config.jsonapi_version = '1.0' + config.jsonapi_toplevel_meta = {} + # Make JSON API top-level jsonapi member opt-in + # ref: http://jsonapi.org/format/#document-top-level + config.jsonapi_include_toplevel_object = false + config.include_data_default = true + + # For configuring how serializers are found. + # This should be an array of procs. + # + # The priority of the output is that the first item + # in the evaluated result array will take precedence + # over other possible serializer paths. + # + # i.e.: First match wins. + # + # @example output + # => [ + # "CustomNamespace::ResourceSerializer", + # "ParentSerializer::ResourceSerializer", + # "ResourceNamespace::ResourceSerializer" , + # "ResourceSerializer"] + # + # If CustomNamespace::ResourceSerializer exists, it will be used + # for serialization + config.serializer_lookup_chain = ActiveModelSerializers::LookupChain::DEFAULT.dup + + config.schema_path = 'test/support/schemas' + # END DEFAULT CONFIGURATION + + with_options instance_writer: false, instance_reader: false do |serializer| + serializer.class_attribute :_attributes_data # @api private + self._attributes_data ||= {} + end + with_options instance_writer: false, instance_reader: true do |serializer| + serializer.class_attribute :_reflections + self._reflections ||= {} + serializer.class_attribute :_links # @api private + self._links ||= {} + serializer.class_attribute :_meta # @api private + serializer.class_attribute :_type # @api private + end + + def self.inherited(base) + super + base._attributes_data = _attributes_data.dup + base._reflections = _reflections.dup + base._links = _links.dup + end + + # @return [Array] Key names of declared attributes + # @see Serializer::attribute + def self._attributes + _attributes_data.keys + end + + # BEGIN SERIALIZER MACROS + + # @example + # class AdminAuthorSerializer < ActiveModel::Serializer + # attributes :id, :name, :recent_edits + def self.attributes(*attrs) + attrs = attrs.first if attrs.first.class == Array + + attrs.each do |attr| + attribute(attr) + end + end + + # @example + # class AdminAuthorSerializer < ActiveModel::Serializer + # attributes :id, :recent_edits + # attribute :name, key: :title + # + # attribute :full_name do + # "#{object.first_name} #{object.last_name}" + # end + # + # def recent_edits + # object.edits.last(5) + # end + def self.attribute(attr, options = {}, &block) + key = options.fetch(:key, attr) + _attributes_data[key] = Attribute.new(attr, options, block) + end + + # @param [Symbol] name of the association + # @param [Hash any>] options for the reflection + # @return [void] + # + # @example + # has_many :comments, serializer: CommentSummarySerializer + # + def self.has_many(name, options = {}, &block) # rubocop:disable Style/PredicateName + associate(HasManyReflection.new(name, options, block)) + end + + # @param [Symbol] name of the association + # @param [Hash any>] options for the reflection + # @return [void] + # + # @example + # belongs_to :author, serializer: AuthorSerializer + # + def self.belongs_to(name, options = {}, &block) + associate(BelongsToReflection.new(name, options, block)) + end + + # @param [Symbol] name of the association + # @param [Hash any>] options for the reflection + # @return [void] + # + # @example + # has_one :author, serializer: AuthorSerializer + # + def self.has_one(name, options = {}, &block) # rubocop:disable Style/PredicateName + associate(HasOneReflection.new(name, options, block)) + end + + # Add reflection and define {name} accessor. + # @param [ActiveModel::Serializer::Reflection] reflection + # @return [void] + # + # @api private + def self.associate(reflection) + key = reflection.options[:key] || reflection.name + self._reflections[key] = reflection + end + private_class_method :associate + + # Define a link on a serializer. + # @example + # link(:self) { resource_url(object) } + # @example + # link(:self) { "http://example.com/resource/#{object.id}" } + # @example + # link :resource, "http://example.com/resource" + # + def self.link(name, value = nil, &block) + _links[name] = block || value + end + + # Set the JSON API meta attribute of a serializer. + # @example + # class AdminAuthorSerializer < ActiveModel::Serializer + # meta { stuff: 'value' } + # @example + # meta do + # { comment_count: object.comments.count } + # end + def self.meta(value = nil, &block) + self._meta = block || value + end + + # Set the JSON API type of a serializer. + # @example + # class AdminAuthorSerializer < ActiveModel::Serializer + # type 'authors' + def self.type(type) + self._type = type && type.to_s + end + + # END SERIALIZER MACROS + attr_accessor :object, :root, :scope # `scope_name` is set as :current_user by default in the controller. @@ -131,53 +321,49 @@ module ActiveModel true end + # Return the +attributes+ of +object+ as presented + # by the serializer. + def attributes(requested_attrs = nil, reload = false) + @attributes = nil if reload + @attributes ||= self.class._attributes_data.each_with_object({}) do |(key, attr), hash| + next if attr.excluded?(self) + next unless requested_attrs.nil? || requested_attrs.include?(key) + hash[key] = attr.value(self) + end + end + + # @param [JSONAPI::IncludeDirective] include_directive (defaults to the + # +default_include_directive+ config value when not provided) + # @return [Enumerator] + def associations(include_directive = ActiveModelSerializers.default_include_directive, include_slice = nil) + include_slice ||= include_directive + return Enumerator.new unless object + + Enumerator.new do |y| + self.class._reflections.each do |key, reflection| + next if reflection.excluded?(self) + next unless include_directive.key?(key) + + association = reflection.build_association(self, instance_options, include_slice) + y.yield association + end + end + end + # @return [Hash] containing the attributes and first level # associations, similar to how ActiveModel::Serializers::JSON is used # in ActiveRecord::Base. - # - # TODO: Include ActiveModel::Serializers::JSON. - # So that the below is true: - # @param options [nil, Hash] The same valid options passed to `serializable_hash` - # (:only, :except, :methods, and :include). - # - # See - # https://github.com/rails/rails/blob/v5.0.0.beta2/activemodel/lib/active_model/serializers/json.rb#L17-L101 - # https://github.com/rails/rails/blob/v5.0.0.beta2/activemodel/lib/active_model/serialization.rb#L85-L123 - # https://github.com/rails/rails/blob/v5.0.0.beta2/activerecord/lib/active_record/serialization.rb#L11-L17 - # https://github.com/rails/rails/blob/v5.0.0.beta2/activesupport/lib/active_support/core_ext/object/json.rb#L147-L162 - # - # @example - # # The :only and :except options can be used to limit the attributes included, and work - # # similar to the attributes method. - # serializer.as_json(only: [:id, :name]) - # serializer.as_json(except: [:id, :created_at, :age]) - # - # # To include the result of some method calls on the model use :methods: - # serializer.as_json(methods: :permalink) - # - # # To include associations use :include: - # serializer.as_json(include: :posts) - # # Second level and higher order associations work as well: - # serializer.as_json(include: { posts: { include: { comments: { only: :body } }, only: :title } }) def serializable_hash(adapter_options = nil, options = {}, adapter_instance = self.class.serialization_adapter_instance) adapter_options ||= {} options[:include_directive] ||= ActiveModel::Serializer.include_directive_from_options(adapter_options) - cached_attributes = adapter_options[:cached_attributes] ||= {} - resource = fetch_attributes(options[:fields], cached_attributes, adapter_instance) - relationships = resource_relationships(adapter_options, options, adapter_instance) + resource = attributes_hash(adapter_options, options, adapter_instance) + relationships = associations_hash(adapter_options, options, adapter_instance) resource.merge(relationships) end alias to_hash serializable_hash alias to_h serializable_hash # @see #serializable_hash - # TODO: When moving attributes adapter logic here, @see #serializable_hash - # So that the below is true: - # @param options [nil, Hash] The same valid options passed to `as_json` - # (:root, :only, :except, :methods, and :include). - # The default for `root` is nil. - # The default value for include_root is false. You can change it to true if the given - # JSON string includes a single root node. def as_json(adapter_opts = nil) serializable_hash(adapter_opts) end @@ -196,32 +382,24 @@ module ActiveModel end # @api private - def resource_relationships(adapter_options, options, adapter_instance) - relationships = {} - include_directive = options.fetch(:include_directive) - associations(include_directive).each do |association| - adapter_opts = adapter_options.merge(include_directive: include_directive[association.key]) - relationships[association.key] ||= relationship_value_for(association, adapter_opts, adapter_instance) + def attributes_hash(_adapter_options, options, adapter_instance) + if self.class.cache_enabled? + fetch_attributes(options[:fields], options[:cached_attributes] || {}, adapter_instance) + elsif self.class.fragment_cache_enabled? + fetch_attributes_fragment(adapter_instance, options[:cached_attributes] || {}) + else + attributes(options[:fields], true) end - - relationships end # @api private - def relationship_value_for(association, adapter_options, adapter_instance) - return association.options[:virtual_value] if association.options[:virtual_value] - association_serializer = association.serializer - association_object = association_serializer && association_serializer.object - return unless association_object - - relationship_value = association_serializer.serializable_hash(adapter_options, {}, adapter_instance) - - if association.options[:polymorphic] && relationship_value - polymorphic_type = association_object.class.name.underscore - relationship_value = { type: polymorphic_type, polymorphic_type.to_sym => relationship_value } + def associations_hash(adapter_options, options, adapter_instance) + include_directive = options.fetch(:include_directive) + include_slice = options[:include_slice] + associations(include_directive, include_slice).each_with_object({}) do |association, relationships| + adapter_opts = adapter_options.merge(include_directive: include_directive[association.key], adapter_instance: adapter_instance) + relationships[association.key] = association.serializable_hash(adapter_opts, adapter_instance) end - - relationship_value end protected diff --git a/lib/active_model/serializer/association.rb b/lib/active_model/serializer/association.rb index b2e18392..7ce82316 100644 --- a/lib/active_model/serializer/association.rb +++ b/lib/active_model/serializer/association.rb @@ -1,34 +1,71 @@ +require 'active_model/serializer/lazy_association' + module ActiveModel class Serializer # This class holds all information about serializer's association. # - # @attr [Symbol] name - # @attr [Hash{Symbol => Object}] options - # @attr [block] - # - # @example - # Association.new(:comments, { serializer: CommentSummarySerializer }) - # - class Association < Field - # @return [Symbol] - def key - options.fetch(:key, name) + # @api private + Association = Struct.new(:reflection, :association_options) do + attr_reader :lazy_association + delegate :object, :include_data?, :virtual_value, :collection?, to: :lazy_association + + def initialize(*) + super + @lazy_association = LazyAssociation.new(reflection, association_options) end - # @return [ActiveModel::Serializer, nil] - def serializer - options[:serializer] + # @return [Symbol] + delegate :name, to: :reflection + + # @return [Symbol] + def key + reflection_options.fetch(:key, name) + end + + # @return [True,False] + def key? + reflection_options.key?(:key) end # @return [Hash] def links - options.fetch(:links) || {} + reflection_options.fetch(:links) || {} end # @return [Hash, nil] + # This gets mutated, so cannot use the cached reflection_options def meta - options[:meta] + reflection.options[:meta] end + + def belongs_to? + reflection.foreign_key_on == :self + end + + def polymorphic? + true == reflection_options[:polymorphic] + end + + # @api private + def serializable_hash(adapter_options, adapter_instance) + association_serializer = lazy_association.serializer + return virtual_value if virtual_value + association_object = association_serializer && association_serializer.object + return unless association_object + + serialization = association_serializer.serializable_hash(adapter_options, {}, adapter_instance) + + if polymorphic? && serialization + polymorphic_type = association_object.class.name.underscore + serialization = { type: polymorphic_type, polymorphic_type.to_sym => serialization } + end + + serialization + end + + private + + delegate :reflection_options, to: :lazy_association end end end diff --git a/lib/active_model/serializer/belongs_to_reflection.rb b/lib/active_model/serializer/belongs_to_reflection.rb index a014b7a5..04bbc6fc 100644 --- a/lib/active_model/serializer/belongs_to_reflection.rb +++ b/lib/active_model/serializer/belongs_to_reflection.rb @@ -1,7 +1,11 @@ module ActiveModel class Serializer # @api private - class BelongsToReflection < SingularReflection + class BelongsToReflection < Reflection + # @api private + def foreign_key_on + :self + end end end end diff --git a/lib/active_model/serializer/collection_reflection.rb b/lib/active_model/serializer/collection_reflection.rb deleted file mode 100644 index 3436becf..00000000 --- a/lib/active_model/serializer/collection_reflection.rb +++ /dev/null @@ -1,7 +0,0 @@ -module ActiveModel - class Serializer - # @api private - class CollectionReflection < Reflection - end - end -end diff --git a/lib/active_model/serializer/concerns/associations.rb b/lib/active_model/serializer/concerns/associations.rb deleted file mode 100644 index ce0ea21f..00000000 --- a/lib/active_model/serializer/concerns/associations.rb +++ /dev/null @@ -1,102 +0,0 @@ -module ActiveModel - class Serializer - # Defines an association in the object should be rendered. - # - # The serializer object should implement the association name - # as a method which should return an array when invoked. If a method - # with the association name does not exist, the association name is - # dispatched to the serialized object. - # - module Associations - extend ActiveSupport::Concern - - included do - with_options instance_writer: false, instance_reader: true do |serializer| - serializer.class_attribute :_reflections - self._reflections ||= {} - end - - extend ActiveSupport::Autoload - autoload :Association - autoload :Reflection - autoload :SingularReflection - autoload :CollectionReflection - autoload :BelongsToReflection - autoload :HasOneReflection - autoload :HasManyReflection - end - - module ClassMethods - def inherited(base) - super - base._reflections = _reflections.dup - end - - # @param [Symbol] name of the association - # @param [Hash any>] options for the reflection - # @return [void] - # - # @example - # has_many :comments, serializer: CommentSummarySerializer - # - def has_many(name, options = {}, &block) # rubocop:disable Style/PredicateName - associate(HasManyReflection.new(name, options, block)) - end - - # @param [Symbol] name of the association - # @param [Hash any>] options for the reflection - # @return [void] - # - # @example - # belongs_to :author, serializer: AuthorSerializer - # - def belongs_to(name, options = {}, &block) - associate(BelongsToReflection.new(name, options, block)) - end - - # @param [Symbol] name of the association - # @param [Hash any>] options for the reflection - # @return [void] - # - # @example - # has_one :author, serializer: AuthorSerializer - # - def has_one(name, options = {}, &block) # rubocop:disable Style/PredicateName - associate(HasOneReflection.new(name, options, block)) - end - - private - - # Add reflection and define {name} accessor. - # @param [ActiveModel::Serializer::Reflection] reflection - # @return [void] - # - # @api private - # - def associate(reflection) - key = reflection.options[:key] || reflection.name - self._reflections[key] = reflection - end - end - - # @param [JSONAPI::IncludeDirective] include_directive (defaults to the - # +default_include_directive+ config value when not provided) - # @return [Enumerator] - # - def associations(include_directive = ActiveModelSerializers.default_include_directive, include_slice = nil) - include_slice ||= include_directive - return unless object - - Enumerator.new do |y| - self.class._reflections.values.each do |reflection| - next if reflection.excluded?(self) - key = reflection.options.fetch(:key, reflection.name) - next unless include_directive.key?(key) - - y.yield reflection.build_association(self, instance_options, include_slice) - end - end - end - end - end -end diff --git a/lib/active_model/serializer/concerns/attributes.rb b/lib/active_model/serializer/concerns/attributes.rb deleted file mode 100644 index 6ee2732f..00000000 --- a/lib/active_model/serializer/concerns/attributes.rb +++ /dev/null @@ -1,82 +0,0 @@ -module ActiveModel - class Serializer - module Attributes - extend ActiveSupport::Concern - - included do - with_options instance_writer: false, instance_reader: false do |serializer| - serializer.class_attribute :_attributes_data # @api private - self._attributes_data ||= {} - end - - extend ActiveSupport::Autoload - autoload :Attribute - - # Return the +attributes+ of +object+ as presented - # by the serializer. - def attributes(requested_attrs = nil, reload = false) - @attributes = nil if reload - @attributes ||= self.class._attributes_data.each_with_object({}) do |(key, attr), hash| - next if attr.excluded?(self) - next unless requested_attrs.nil? || requested_attrs.include?(key) - hash[key] = attr.value(self) - end - end - end - - module ClassMethods - def inherited(base) - super - base._attributes_data = _attributes_data.dup - end - - # @example - # class AdminAuthorSerializer < ActiveModel::Serializer - # attributes :id, :name, :recent_edits - def attributes(*attrs) - attrs = attrs.first if attrs.first.class == Array - - attrs.each do |attr| - attribute(attr) - end - end - - # @example - # class AdminAuthorSerializer < ActiveModel::Serializer - # attributes :id, :recent_edits - # attribute :name, key: :title - # - # attribute :full_name do - # "#{object.first_name} #{object.last_name}" - # end - # - # def recent_edits - # object.edits.last(5) - # end - def attribute(attr, options = {}, &block) - key = options.fetch(:key, attr) - _attributes_data[key] = Attribute.new(attr, options, block) - end - - # @api private - # keys of attributes - # @see Serializer::attribute - def _attributes - _attributes_data.keys - end - - # @api private - # maps attribute value to explicit key name - # @see Serializer::attribute - # @see FragmentCache#fragment_serializer - def _attributes_keys - _attributes_data - .each_with_object({}) do |(key, attr), hash| - next if key == attr.name - hash[attr.name] = { key: key } - end - end - end - end - end -end diff --git a/lib/active_model/serializer/concerns/caching.rb b/lib/active_model/serializer/concerns/caching.rb index 4809f4cb..2a030b68 100644 --- a/lib/active_model/serializer/concerns/caching.rb +++ b/lib/active_model/serializer/concerns/caching.rb @@ -40,9 +40,9 @@ module ActiveModel module ClassMethods def inherited(base) - super caller_line = caller[1] base._cache_digest_file_path = caller_line + super end def _cache_digest @@ -68,6 +68,18 @@ module ActiveModel _cache_options && _cache_options[:skip_digest] end + # @api private + # maps attribute value to explicit key name + # @see Serializer::attribute + # @see Serializer::fragmented_attributes + def _attributes_keys + _attributes_data + .each_with_object({}) do |(key, attr), hash| + next if key == attr.name + hash[attr.name] = { key: key } + end + end + def fragmented_attributes cached = _cache_only ? _cache_only : _attributes - _cache_except cached = cached.map! { |field| _attributes_keys.fetch(field, field) } @@ -158,6 +170,7 @@ module ActiveModel # Read cache from cache_store # @return [Hash] + # Used in CollectionSerializer to set :cached_attributes def cache_read_multi(collection_serializer, adapter_instance, include_directive) return {} if ActiveModelSerializers.config.cache_store.blank? @@ -180,12 +193,14 @@ module ActiveModel cache_keys << object_cache_key(serializer, adapter_instance) serializer.associations(include_directive).each do |association| - if association.serializer.respond_to?(:each) - association.serializer.each do |sub_serializer| + # TODO(BF): Process relationship without evaluating lazy_association + association_serializer = association.lazy_association.serializer + if association_serializer.respond_to?(:each) + association_serializer.each do |sub_serializer| cache_keys << object_cache_key(sub_serializer, adapter_instance) end else - cache_keys << object_cache_key(association.serializer, adapter_instance) + cache_keys << object_cache_key(association_serializer, adapter_instance) end end end @@ -203,23 +218,18 @@ module ActiveModel ### INSTANCE METHODS def fetch_attributes(fields, cached_attributes, adapter_instance) - if serializer_class.cache_enabled? - key = cache_key(adapter_instance) - cached_attributes.fetch(key) do - serializer_class.cache_store.fetch(key, serializer_class._cache_options) do - attributes(fields, true) - end + key = cache_key(adapter_instance) + cached_attributes.fetch(key) do + fetch(adapter_instance, serializer_class._cache_options, key) do + attributes(fields, true) end - elsif serializer_class.fragment_cache_enabled? - fetch_attributes_fragment(adapter_instance, cached_attributes) - else - attributes(fields, true) end end - def fetch(adapter_instance, cache_options = serializer_class._cache_options) + def fetch(adapter_instance, cache_options = serializer_class._cache_options, key = nil) if serializer_class.cache_store - serializer_class.cache_store.fetch(cache_key(adapter_instance), cache_options) do + key ||= cache_key(adapter_instance) + serializer_class.cache_store.fetch(key, cache_options) do yield end else @@ -230,7 +240,6 @@ module ActiveModel # 1. Determine cached fields from serializer class options # 2. Get non_cached_fields and fetch cache_fields # 3. Merge the two hashes using adapter_instance#fragment_cache - # rubocop:disable Metrics/AbcSize def fetch_attributes_fragment(adapter_instance, cached_attributes = {}) serializer_class._cache_options ||= {} serializer_class._cache_options[:key] = serializer_class._cache_key if serializer_class._cache_key @@ -239,22 +248,21 @@ module ActiveModel non_cached_fields = fields[:non_cached].dup non_cached_hash = attributes(non_cached_fields, true) include_directive = JSONAPI::IncludeDirective.new(non_cached_fields - non_cached_hash.keys) - non_cached_hash.merge! resource_relationships({}, { include_directive: include_directive }, adapter_instance) + non_cached_hash.merge! associations_hash({}, { include_directive: include_directive }, adapter_instance) cached_fields = fields[:cached].dup key = cache_key(adapter_instance) cached_hash = cached_attributes.fetch(key) do - serializer_class.cache_store.fetch(key, serializer_class._cache_options) do + fetch(adapter_instance, serializer_class._cache_options, key) do hash = attributes(cached_fields, true) include_directive = JSONAPI::IncludeDirective.new(cached_fields - hash.keys) - hash.merge! resource_relationships({}, { include_directive: include_directive }, adapter_instance) + hash.merge! associations_hash({}, { include_directive: include_directive }, adapter_instance) end end # Merge both results adapter_instance.fragment_cache(cached_hash, non_cached_hash) end - # rubocop:enable Metrics/AbcSize def cache_key(adapter_instance) return @cache_key if defined?(@cache_key) diff --git a/lib/active_model/serializer/concerns/configuration.rb b/lib/active_model/serializer/concerns/configuration.rb deleted file mode 100644 index d6d3c610..00000000 --- a/lib/active_model/serializer/concerns/configuration.rb +++ /dev/null @@ -1,59 +0,0 @@ -module ActiveModel - class Serializer - module Configuration - include ActiveSupport::Configurable - extend ActiveSupport::Concern - - # Configuration options may also be set in - # Serializers and Adapters - included do |base| - config = base.config - config.collection_serializer = ActiveModel::Serializer::CollectionSerializer - config.serializer_lookup_enabled = true - - def config.array_serializer=(collection_serializer) - self.collection_serializer = collection_serializer - end - - def config.array_serializer - collection_serializer - end - - config.default_includes = '*' - config.adapter = :attributes - config.key_transform = nil - config.jsonapi_pagination_links_enabled = true - config.jsonapi_resource_type = :plural - config.jsonapi_namespace_separator = '-'.freeze - config.jsonapi_version = '1.0' - config.jsonapi_toplevel_meta = {} - # Make JSON API top-level jsonapi member opt-in - # ref: http://jsonapi.org/format/#document-top-level - config.jsonapi_include_toplevel_object = false - config.include_data_default = true - - # For configuring how serializers are found. - # This should be an array of procs. - # - # The priority of the output is that the first item - # in the evaluated result array will take precedence - # over other possible serializer paths. - # - # i.e.: First match wins. - # - # @example output - # => [ - # "CustomNamespace::ResourceSerializer", - # "ParentSerializer::ResourceSerializer", - # "ResourceNamespace::ResourceSerializer" , - # "ResourceSerializer"] - # - # If CustomNamespace::ResourceSerializer exists, it will be used - # for serialization - config.serializer_lookup_chain = ActiveModelSerializers::LookupChain::DEFAULT.dup - - config.schema_path = 'test/support/schemas' - end - end - end -end diff --git a/lib/active_model/serializer/concerns/links.rb b/lib/active_model/serializer/concerns/links.rb deleted file mode 100644 index 1322adb0..00000000 --- a/lib/active_model/serializer/concerns/links.rb +++ /dev/null @@ -1,35 +0,0 @@ -module ActiveModel - class Serializer - module Links - extend ActiveSupport::Concern - - included do - with_options instance_writer: false, instance_reader: true do |serializer| - serializer.class_attribute :_links # @api private - self._links ||= {} - end - - extend ActiveSupport::Autoload - end - - module ClassMethods - def inherited(base) - super - base._links = _links.dup - end - - # Define a link on a serializer. - # @example - # link(:self) { resource_url(object) } - # @example - # link(:self) { "http://example.com/resource/#{object.id}" } - # @example - # link :resource, "http://example.com/resource" - # - def link(name, value = nil, &block) - _links[name] = block || value - end - end - end - end -end diff --git a/lib/active_model/serializer/concerns/meta.rb b/lib/active_model/serializer/concerns/meta.rb deleted file mode 100644 index 5160585e..00000000 --- a/lib/active_model/serializer/concerns/meta.rb +++ /dev/null @@ -1,29 +0,0 @@ -module ActiveModel - class Serializer - module Meta - extend ActiveSupport::Concern - - included do - with_options instance_writer: false, instance_reader: true do |serializer| - serializer.class_attribute :_meta # @api private - end - - extend ActiveSupport::Autoload - end - - module ClassMethods - # Set the JSON API meta attribute of a serializer. - # @example - # class AdminAuthorSerializer < ActiveModel::Serializer - # meta { stuff: 'value' } - # @example - # meta do - # { comment_count: object.comments.count } - # end - def meta(value = nil, &block) - self._meta = block || value - end - end - end - end -end diff --git a/lib/active_model/serializer/concerns/type.rb b/lib/active_model/serializer/concerns/type.rb deleted file mode 100644 index c37c9af8..00000000 --- a/lib/active_model/serializer/concerns/type.rb +++ /dev/null @@ -1,25 +0,0 @@ -module ActiveModel - class Serializer - module Type - extend ActiveSupport::Concern - - included do - with_options instance_writer: false, instance_reader: true do |serializer| - serializer.class_attribute :_type # @api private - end - - extend ActiveSupport::Autoload - end - - module ClassMethods - # Set the JSON API type of a serializer. - # @example - # class AdminAuthorSerializer < ActiveModel::Serializer - # type 'authors' - def type(type) - self._type = type && type.to_s - end - end - end - end -end diff --git a/lib/active_model/serializer/has_many_reflection.rb b/lib/active_model/serializer/has_many_reflection.rb index 60ccc481..99f6f63c 100644 --- a/lib/active_model/serializer/has_many_reflection.rb +++ b/lib/active_model/serializer/has_many_reflection.rb @@ -1,7 +1,10 @@ module ActiveModel class Serializer # @api private - class HasManyReflection < CollectionReflection + class HasManyReflection < Reflection + def collection? + true + end end end end diff --git a/lib/active_model/serializer/has_one_reflection.rb b/lib/active_model/serializer/has_one_reflection.rb index bf41b1d1..a385009b 100644 --- a/lib/active_model/serializer/has_one_reflection.rb +++ b/lib/active_model/serializer/has_one_reflection.rb @@ -1,7 +1,7 @@ module ActiveModel class Serializer # @api private - class HasOneReflection < SingularReflection + class HasOneReflection < Reflection end end end diff --git a/lib/active_model/serializer/lazy_association.rb b/lib/active_model/serializer/lazy_association.rb new file mode 100644 index 00000000..8c4dad61 --- /dev/null +++ b/lib/active_model/serializer/lazy_association.rb @@ -0,0 +1,95 @@ +module ActiveModel + class Serializer + # @api private + LazyAssociation = Struct.new(:reflection, :association_options) do + REFLECTION_OPTIONS = %i(key links polymorphic meta serializer virtual_value namespace).freeze + + delegate :collection?, to: :reflection + + def reflection_options + @reflection_options ||= reflection.options.dup.reject { |k, _| !REFLECTION_OPTIONS.include?(k) } + end + + def object + @object ||= reflection.value( + association_options.fetch(:parent_serializer), + association_options.fetch(:include_slice) + ) + end + alias_method :eval_reflection_block, :object + + def include_data? + eval_reflection_block if reflection.block + reflection.include_data?( + association_options.fetch(:include_slice) + ) + end + + # @return [ActiveModel::Serializer, nil] + def serializer + return @serializer if defined?(@serializer) + if serializer_class + serialize_object!(object) + elsif !object.nil? && !object.instance_of?(Object) + cached_result[:virtual_value] = object + end + @serializer = cached_result[:serializer] + end + + def virtual_value + cached_result[:virtual_value] || reflection_options[:virtual_value] + end + + def serializer_class + return @serializer_class if defined?(@serializer_class) + serializer_for_options = { namespace: namespace } + serializer_for_options[:serializer] = reflection_options[:serializer] if reflection_options.key?(:serializer) + @serializer_class = association_options.fetch(:parent_serializer).class.serializer_for(object, serializer_for_options) + end + + private + + def cached_result + @cached_result ||= {} + end + + def serialize_object!(object) + if collection? + if (serializer = instantiate_collection_serializer(object)).nil? + # BUG: per #2027, JSON API resource relationships are only id and type, and hence either + # *require* a serializer or we need to be a little clever about figuring out the id/type. + # In either case, returning the raw virtual value will almost always be incorrect. + # + # Should be reflection_options[:virtual_value] or adapter needs to figure out what to do + # with an object that is non-nil and has no defined serializer. + cached_result[:virtual_value] = object.try(:as_json) || object + else + cached_result[:serializer] = serializer + end + else + cached_result[:serializer] = instantiate_serializer(object) + end + end + + def instantiate_serializer(object) + serializer_options = association_options.fetch(:parent_serializer_options).except(:serializer) + serializer_options[:serializer_context_class] = association_options.fetch(:parent_serializer).class + serializer = reflection_options.fetch(:serializer, nil) + serializer_options[:serializer] = serializer if serializer + serializer_class.new(object, serializer_options) + end + + def instantiate_collection_serializer(object) + serializer = catch(:no_serializer) do + instantiate_serializer(object) + end + serializer + end + + def namespace + reflection_options[:namespace] || + association_options.fetch(:parent_serializer_options)[:namespace] + end + end + end +end diff --git a/lib/active_model/serializer/reflection.rb b/lib/active_model/serializer/reflection.rb index 96645bd7..2e5cc2a1 100644 --- a/lib/active_model/serializer/reflection.rb +++ b/lib/active_model/serializer/reflection.rb @@ -1,4 +1,5 @@ require 'active_model/serializer/field' +require 'active_model/serializer/association' module ActiveModel class Serializer @@ -8,12 +9,26 @@ module ActiveModel # @example # class PostSerializer < ActiveModel::Serializer # has_one :author, serializer: AuthorSerializer + # belongs_to :boss, type: :users, foreign_key: :boss_id # has_many :comments # has_many :comments, key: :last_comments do # object.comments.last(1) # end # has_many :secret_meta_data, if: :is_admin? # + # has_one :blog do |serializer| + # meta count: object.roles.count + # serializer.cached_blog + # end + # + # private + # + # def cached_blog + # cache_store.fetch("cached_blog:#{object.updated_at}") do + # Blog.find(object.blog_id) + # end + # end + # # def is_admin? # current_user.admin? # end @@ -23,52 +38,118 @@ module ActiveModel # 1) as 'comments' and named 'comments'. # 2) as 'object.comments.last(1)' and named 'last_comments'. # - # PostSerializer._reflections #=> - # # [ - # # HasOneReflection.new(:author, serializer: AuthorSerializer), - # # HasManyReflection.new(:comments) - # # HasManyReflection.new(:comments, { key: :last_comments }, #) - # # HasManyReflection.new(:secret_meta_data, { if: :is_admin? }) - # # ] + # PostSerializer._reflections # => + # # { + # # author: HasOneReflection.new(:author, serializer: AuthorSerializer), + # # comments: HasManyReflection.new(:comments) + # # last_comments: HasManyReflection.new(:comments, { key: :last_comments }, #) + # # secret_meta_data: HasManyReflection.new(:secret_meta_data, { if: :is_admin? }) + # # } # # So you can inspect reflections in your Adapters. - # class Reflection < Field + attr_reader :foreign_key, :type + def initialize(*) super - @_links = {} - @_include_data = Serializer.config.include_data_default - @_meta = nil + options[:links] = {} + options[:include_data_setting] = Serializer.config.include_data_default + options[:meta] = nil + @type = options.fetch(:type) do + class_name = options.fetch(:class_name, name.to_s.camelize.singularize) + class_name.underscore.pluralize.to_sym + end + @foreign_key = options.fetch(:foreign_key) do + if collection? + "#{name.to_s.singularize}_ids".to_sym + else + "#{name}_id".to_sym + end + end end - def link(name, value = nil, &block) - @_links[name] = block || value + # @api public + # @example + # has_one :blog do + # include_data false + # link :self, 'a link' + # link :related, 'another link' + # link :self, '//example.com/link_author/relationships/bio' + # id = object.profile.id + # link :related do + # "//example.com/profiles/#{id}" if id != 123 + # end + # link :related do + # ids = object.likes.map(&:id).join(',') + # href "//example.com/likes/#{ids}" + # meta ids: ids + # end + # end + def link(name, value = nil) + options[:links][name] = block_given? ? Proc.new : value :nil end - def meta(value = nil, &block) - @_meta = block || value + # @api public + # @example + # has_one :blog do + # include_data false + # meta(id: object.blog.id) + # meta liked: object.likes.any? + # link :self do + # href object.blog.id.to_s + # meta(id: object.blog.id) + # end + def meta(value = nil) + options[:meta] = block_given? ? Proc.new : value :nil end + # @api public + # @example + # has_one :blog do + # include_data false + # link :self, 'a link' + # link :related, 'another link' + # end + # + # has_one :blog do + # include_data false + # link :self, 'a link' + # link :related, 'another link' + # end + # + # belongs_to :reviewer do + # meta name: 'Dan Brown' + # include_data true + # end + # + # has_many :tags, serializer: TagSerializer do + # link :self, '//example.com/link_author/relationships/tags' + # include_data :if_sideloaded + # end def include_data(value = true) - @_include_data = value + options[:include_data_setting] = value :nil end + def collection? + false + end + + def include_data?(include_slice) + include_data_setting = options[:include_data_setting] + case include_data_setting + when :if_sideloaded then include_slice.key?(name) + when true then true + when false then false + else fail ArgumentError, "Unknown include_data_setting '#{include_data_setting.inspect}'" + end + end + # @param serializer [ActiveModel::Serializer] # @yield [ActiveModel::Serializer] # @return [:nil, associated resource or resource collection] - # @example - # has_one :blog do |serializer| - # serializer.cached_blog - # end - # - # def cached_blog - # cache_store.fetch("cached_blog:#{object.updated_at}") do - # Blog.find(object.blog_id) - # end - # end def value(serializer, include_slice) @object = serializer.object @scope = serializer.scope @@ -83,6 +164,11 @@ module ActiveModel end end + # @api private + def foreign_key_on + :related + end + # Build association. This method is used internally to # build serializer's association by its reflection. # @@ -103,61 +189,19 @@ module ActiveModel # comments_reflection.build_association(post_serializer, foo: 'bar') # # @api private - # def build_association(parent_serializer, parent_serializer_options, include_slice = {}) - reflection_options = options.dup - - # Pass the parent's namespace onto the child serializer - reflection_options[:namespace] ||= parent_serializer_options[:namespace] - - association_value = value(parent_serializer, include_slice) - serializer_class = parent_serializer.class.serializer_for(association_value, reflection_options) - reflection_options[:include_data] = include_data?(include_slice) - reflection_options[:links] = @_links - reflection_options[:meta] = @_meta - - if serializer_class - serializer = catch(:no_serializer) do - serializer_class.new( - association_value, - serializer_options(parent_serializer, parent_serializer_options, reflection_options) - ) - end - if serializer.nil? - reflection_options[:virtual_value] = association_value.try(:as_json) || association_value - else - reflection_options[:serializer] = serializer - end - elsif !association_value.nil? && !association_value.instance_of?(Object) - reflection_options[:virtual_value] = association_value - end - - block = nil - Association.new(name, reflection_options, block) + association_options = { + parent_serializer: parent_serializer, + parent_serializer_options: parent_serializer_options, + include_slice: include_slice + } + Association.new(self, association_options) end protected + # used in instance exec attr_accessor :object, :scope - - private - - def include_data?(include_slice) - if @_include_data == :if_sideloaded - include_slice.key?(name) - else - @_include_data - end - end - - def serializer_options(parent_serializer, parent_serializer_options, reflection_options) - serializer = reflection_options.fetch(:serializer, nil) - - serializer_options = parent_serializer_options.except(:serializer) - serializer_options[:serializer] = serializer if serializer - serializer_options[:serializer_context_class] = parent_serializer.class - serializer_options - end end end end diff --git a/lib/active_model/serializer/singular_reflection.rb b/lib/active_model/serializer/singular_reflection.rb deleted file mode 100644 index f90ecc21..00000000 --- a/lib/active_model/serializer/singular_reflection.rb +++ /dev/null @@ -1,7 +0,0 @@ -module ActiveModel - class Serializer - # @api private - class SingularReflection < Reflection - end - end -end diff --git a/lib/active_model/serializer/version.rb b/lib/active_model/serializer/version.rb index 209437da..e692240a 100644 --- a/lib/active_model/serializer/version.rb +++ b/lib/active_model/serializer/version.rb @@ -1,5 +1,5 @@ module ActiveModel class Serializer - VERSION = '0.10.5'.freeze + VERSION = '0.10.6'.freeze end end diff --git a/lib/active_model_serializers/adapter/json_api.rb b/lib/active_model_serializers/adapter/json_api.rb index 3d241e34..b225416b 100644 --- a/lib/active_model_serializers/adapter/json_api.rb +++ b/lib/active_model_serializers/adapter/json_api.rb @@ -257,7 +257,8 @@ module ActiveModelSerializers def process_relationships(serializer, include_slice) serializer.associations(include_slice).each do |association| - process_relationship(association.serializer, include_slice[association.key]) + # TODO(BF): Process relationship without evaluating lazy_association + process_relationship(association.lazy_association.serializer, include_slice[association.key]) end end @@ -294,20 +295,8 @@ module ActiveModelSerializers # {http://jsonapi.org/format/#document-resource-objects Document Resource Objects} def resource_object_for(serializer, include_slice = {}) - resource_object = serializer.fetch(self) do - resource_object = ResourceIdentifier.new(serializer, instance_options).as_json + resource_object = data_for(serializer, include_slice) - requested_fields = fieldset && fieldset.fields_for(resource_object[:type]) - attributes = attributes_for(serializer, requested_fields) - resource_object[:attributes] = attributes if attributes.any? - resource_object - end - - requested_associations = fieldset.fields_for(resource_object[:type]) || '*' - relationships = relationships_for(serializer, requested_associations, include_slice) - resource_object[:relationships] = relationships if relationships.any? - - links = links_for(serializer) # toplevel_links # definition: # allOf @@ -321,7 +310,10 @@ module ActiveModelSerializers # prs: # https://github.com/rails-api/active_model_serializers/pull/1247 # https://github.com/rails-api/active_model_serializers/pull/1018 - resource_object[:links] = links if links.any? + if (links = links_for(serializer)).any? + resource_object ||= {} + resource_object[:links] = links + end # toplevel_meta # alias meta @@ -331,12 +323,33 @@ module ActiveModelSerializers # { # :'git-ref' => 'abc123' # } - meta = meta_for(serializer) - resource_object[:meta] = meta unless meta.blank? + if (meta = meta_for(serializer)).present? + resource_object ||= {} + resource_object[:meta] = meta + end resource_object end + def data_for(serializer, include_slice) + data = serializer.fetch(self) do + resource_object = ResourceIdentifier.new(serializer, instance_options).as_json + break nil if resource_object.nil? + + requested_fields = fieldset && fieldset.fields_for(resource_object[:type]) + attributes = attributes_for(serializer, requested_fields) + resource_object[:attributes] = attributes if attributes.any? + resource_object + end + data.tap do |resource_object| + next if resource_object.nil? + # NOTE(BF): the attributes are cached above, separately from the relationships, below. + requested_associations = fieldset.fields_for(resource_object[:type]) || '*' + relationships = relationships_for(serializer, requested_associations, include_slice) + resource_object[:relationships] = relationships if relationships.any? + end + end + # {http://jsonapi.org/format/#document-resource-object-relationships Document Resource Object Relationship} # relationships # definition: diff --git a/lib/active_model_serializers/adapter/json_api/relationship.rb b/lib/active_model_serializers/adapter/json_api/relationship.rb index 0d34cf93..5d7399a3 100644 --- a/lib/active_model_serializers/adapter/json_api/relationship.rb +++ b/lib/active_model_serializers/adapter/json_api/relationship.rb @@ -15,9 +15,7 @@ module ActiveModelSerializers def as_json hash = {} - if association.options[:include_data] - hash[:data] = data_for(association) - end + hash[:data] = data_for(association) if association.include_data? links = links_for(association) hash[:links] = links if links.any? @@ -35,14 +33,45 @@ module ActiveModelSerializers private + # TODO(BF): Avoid db hit on belong_to_ releationship by using foreign_key on self def data_for(association) - serializer = association.serializer - if serializer.respond_to?(:each) - serializer.map { |s| ResourceIdentifier.new(s, serializable_resource_options).as_json } - elsif (virtual_value = association.options[:virtual_value]) + if association.collection? + data_for_many(association) + else + data_for_one(association) + end + end + + def data_for_one(association) + if association.belongs_to? && + parent_serializer.object.respond_to?(association.reflection.foreign_key) + id = parent_serializer.object.send(association.reflection.foreign_key) + type = association.reflection.type.to_s + ResourceIdentifier.for_type_with_id(type, id, serializable_resource_options) + else + # TODO(BF): Process relationship without evaluating lazy_association + serializer = association.lazy_association.serializer + if (virtual_value = association.virtual_value) + virtual_value + elsif serializer && association.object + ResourceIdentifier.new(serializer, serializable_resource_options).as_json + else + nil + end + end + end + + def data_for_many(association) + # TODO(BF): Process relationship without evaluating lazy_association + collection_serializer = association.lazy_association.serializer + if collection_serializer.respond_to?(:each) + collection_serializer.map do |serializer| + ResourceIdentifier.new(serializer, serializable_resource_options).as_json + end + elsif (virtual_value = association.virtual_value) virtual_value - elsif serializer && serializer.object - ResourceIdentifier.new(serializer, serializable_resource_options).as_json + else + [] end end diff --git a/lib/active_model_serializers/adapter/json_api/resource_identifier.rb b/lib/active_model_serializers/adapter/json_api/resource_identifier.rb index af6f5f9e..3a235f2b 100644 --- a/lib/active_model_serializers/adapter/json_api/resource_identifier.rb +++ b/lib/active_model_serializers/adapter/json_api/resource_identifier.rb @@ -22,6 +22,14 @@ module ActiveModelSerializers JsonApi.send(:transform_key_casing!, raw_type, transform_options) end + def self.for_type_with_id(type, id, options) + return nil if id.blank? + { + id: id.to_s, + type: type_for(:no_class_needed, type, options) + } + end + # {http://jsonapi.org/format/#document-resource-identifier-objects Resource Identifier Objects} def initialize(serializer, options) @id = id_for(serializer) @@ -29,6 +37,7 @@ module ActiveModelSerializers end def as_json + return nil if id.blank? { id: id, type: type } end diff --git a/lib/active_model_serializers/model.rb b/lib/active_model_serializers/model.rb index e3c86e98..2ff3d60c 100644 --- a/lib/active_model_serializers/model.rb +++ b/lib/active_model_serializers/model.rb @@ -1,12 +1,13 @@ # ActiveModelSerializers::Model is a convenient superclass for making your models # from Plain-Old Ruby Objects (PORO). It also serves as a reference implementation # that satisfies ActiveModel::Serializer::Lint::Tests. +require 'active_support/core_ext/hash' module ActiveModelSerializers class Model include ActiveModel::Serializers::JSON include ActiveModel::Model - # Declare names of attributes to be included in +sttributes+ hash. + # Declare names of attributes to be included in +attributes+ hash. # Is only available as a class-method since the ActiveModel::Serialization mixin in Rails # uses an +attribute_names+ local variable, which may conflict if we were to add instance methods here. # @@ -19,8 +20,8 @@ module ActiveModelSerializers # Easily declare instance attributes with setters and getters for each. # - # All attributes to initialize an instance must have setters. - # However, the hash turned by +attributes+ instance method will ALWAYS + # To initialize an instance, all attributes must have setters. + # However, the hash returned by +attributes+ instance method will ALWAYS # be the value of the initial attributes, regardless of what accessors are defined. # The only way to change the change the attributes after initialization is # to mutate the +attributes+ directly. @@ -58,7 +59,7 @@ module ActiveModelSerializers # Override the +attributes+ method so that the hash is derived from +attribute_names+. # - # The the fields in +attribute_names+ determines the returned hash. + # The fields in +attribute_names+ determines the returned hash. # +attributes+ are returned frozen to prevent any expectations that mutation affects # the actual values in the model. def attributes diff --git a/lib/tasks/rubocop.rake b/lib/tasks/rubocop.rake new file mode 100644 index 00000000..5c9a1242 --- /dev/null +++ b/lib/tasks/rubocop.rake @@ -0,0 +1,53 @@ +begin + require 'rubocop' + require 'rubocop/rake_task' +rescue LoadError # rubocop:disable Lint/HandleExceptions +else + require 'rbconfig' + # https://github.com/bundler/bundler/blob/1b3eb2465a/lib/bundler/constants.rb#L2 + windows_platforms = /(msdos|mswin|djgpp|mingw)/ + if RbConfig::CONFIG['host_os'] =~ windows_platforms + desc 'No-op rubocop on Windows-- unsupported platform' + task :rubocop do + puts 'Skipping rubocop on Windows' + end + elsif defined?(::Rubinius) + desc 'No-op rubocop to avoid rbx segfault' + task :rubocop do + puts 'Skipping rubocop on rbx due to segfault' + puts 'https://github.com/rubinius/rubinius/issues/3499' + end + else + Rake::Task[:rubocop].clear if Rake::Task.task_defined?(:rubocop) + patterns = [ + 'Gemfile', + 'Rakefile', + 'lib/**/*.{rb,rake}', + 'config/**/*.rb', + 'app/**/*.rb', + 'test/**/*.rb' + ] + desc 'Execute rubocop' + RuboCop::RakeTask.new(:rubocop) do |task| + task.options = ['--rails', '--display-cop-names', '--display-style-guide'] + task.formatters = ['progress'] + task.patterns = patterns + task.fail_on_error = true + end + + namespace :rubocop do + desc 'Auto-gen rubocop config' + task :auto_gen_config do + options = ['--auto-gen-config'].concat patterns + require 'benchmark' + result = 0 + cli = RuboCop::CLI.new + time = Benchmark.realtime do + result = cli.run(options) + end + puts "Finished in #{time} seconds" if cli.options[:debug] + abort('RuboCop failed!') if result.nonzero? + end + end + end +end diff --git a/test/action_controller/adapter_selector_test.rb b/test/action_controller/adapter_selector_test.rb index db93573b..3373de7c 100644 --- a/test/action_controller/adapter_selector_test.rb +++ b/test/action_controller/adapter_selector_test.rb @@ -19,7 +19,7 @@ module ActionController end def render_using_adapter_override - @profile = Profile.new(name: 'Name 1', description: 'Description 1', comments: 'Comments 1') + @profile = Profile.new(id: 'render_using_adapter_override', name: 'Name 1', description: 'Description 1', comments: 'Comments 1') render json: @profile, adapter: :json_api end @@ -41,7 +41,7 @@ module ActionController expected = { data: { - id: @controller.instance_variable_get(:@profile).id.to_s, + id: 'render_using_adapter_override', type: 'profiles', attributes: { name: 'Name 1', diff --git a/test/serializers/associations_test.rb b/test/serializers/associations_test.rb index 90d213dc..c1b164b8 100644 --- a/test/serializers/associations_test.rb +++ b/test/serializers/associations_test.rb @@ -30,18 +30,17 @@ module ActiveModel def test_has_many_and_has_one @author_serializer.associations.each do |association| key = association.key - serializer = association.serializer - options = association.options + serializer = association.lazy_association.serializer case key when :posts - assert_equal true, options.fetch(:include_data) + assert_equal true, association.include_data? assert_kind_of(ActiveModelSerializers.config.collection_serializer, serializer) when :bio - assert_equal true, options.fetch(:include_data) + assert_equal true, association.include_data? assert_nil serializer when :roles - assert_equal true, options.fetch(:include_data) + assert_equal true, association.include_data? assert_kind_of(ActiveModelSerializers.config.collection_serializer, serializer) else flunk "Unknown association: #{key}" @@ -56,12 +55,11 @@ module ActiveModel end post_serializer_class.new(@post).associations.each do |association| key = association.key - serializer = association.serializer - options = association.options + serializer = association.lazy_association.serializer assert_equal :tags, key assert_nil serializer - assert_equal [{ id: 'tagid', name: '#hashtagged' }].to_json, options[:virtual_value].to_json + assert_equal [{ id: 'tagid', name: '#hashtagged' }].to_json, association.virtual_value.to_json end end @@ -70,7 +68,7 @@ module ActiveModel .associations .detect { |assoc| assoc.key == :comments } - comment_serializer = association.serializer.first + comment_serializer = association.lazy_association.serializer.first class << comment_serializer def custom_options instance_options @@ -82,7 +80,7 @@ module ActiveModel def test_belongs_to @comment_serializer.associations.each do |association| key = association.key - serializer = association.serializer + serializer = association.lazy_association.serializer case key when :post @@ -93,7 +91,7 @@ module ActiveModel flunk "Unknown association: #{key}" end - assert_equal true, association.options.fetch(:include_data) + assert_equal true, association.include_data? end end @@ -139,6 +137,34 @@ module ActiveModel assert expected_association_keys.include? :site end + class BelongsToBlogModel < ::Model + attributes :id, :title + associations :blog + end + class BelongsToBlogModelSerializer < ActiveModel::Serializer + type :posts + belongs_to :blog + end + + def test_belongs_to_doesnt_load_record + attributes = { id: 1, title: 'Belongs to Blog', blog: Blog.new(id: 5) } + post = BelongsToBlogModel.new(attributes) + class << post + def blog + fail 'should use blog_id' + end + + def blog_id + 5 + end + end + + actual = serializable(post, adapter: :json_api, serializer: BelongsToBlogModelSerializer).as_json + expected = { data: { id: '1', type: 'posts', relationships: { blog: { data: { id: '5', type: 'blogs' } } } } } + + assert_equal expected, actual + end + class InlineAssociationTestPostSerializer < ActiveModel::Serializer has_many :comments has_many :comments, key: :last_comments do @@ -203,11 +229,11 @@ module ActiveModel @post_serializer.associations.each do |association| case association.key when :comments - assert_instance_of(ResourceNamespace::CommentSerializer, association.serializer.first) + assert_instance_of(ResourceNamespace::CommentSerializer, association.lazy_association.serializer.first) when :author - assert_instance_of(ResourceNamespace::AuthorSerializer, association.serializer) + assert_instance_of(ResourceNamespace::AuthorSerializer, association.lazy_association.serializer) when :description - assert_instance_of(ResourceNamespace::DescriptionSerializer, association.serializer) + assert_instance_of(ResourceNamespace::DescriptionSerializer, association.lazy_association.serializer) else flunk "Unknown association: #{key}" end @@ -245,11 +271,11 @@ module ActiveModel @post_serializer.associations.each do |association| case association.key when :comments - assert_instance_of(PostSerializer::CommentSerializer, association.serializer.first) + assert_instance_of(PostSerializer::CommentSerializer, association.lazy_association.serializer.first) when :author - assert_instance_of(PostSerializer::AuthorSerializer, association.serializer) + assert_instance_of(PostSerializer::AuthorSerializer, association.lazy_association.serializer) when :description - assert_instance_of(PostSerializer::DescriptionSerializer, association.serializer) + assert_instance_of(PostSerializer::DescriptionSerializer, association.lazy_association.serializer) else flunk "Unknown association: #{key}" end @@ -260,7 +286,7 @@ module ActiveModel def test_conditional_associations model = Class.new(::Model) do attributes :true, :false - associations :association + associations :something end.new(true: true, false: false) scenarios = [ @@ -284,7 +310,7 @@ module ActiveModel scenarios.each do |s| serializer = Class.new(ActiveModel::Serializer) do - belongs_to :association, s[:options] + belongs_to :something, s[:options] def true true @@ -296,7 +322,7 @@ module ActiveModel end hash = serializable(model, serializer: serializer).serializable_hash - assert_equal(s[:included], hash.key?(:association), "Error with #{s[:options]}") + assert_equal(s[:included], hash.key?(:something), "Error with #{s[:options]}") end end @@ -341,8 +367,8 @@ module ActiveModel @author_serializer = AuthorSerializer.new(@author) @inherited_post_serializer = InheritedPostSerializer.new(@post) @inherited_author_serializer = InheritedAuthorSerializer.new(@author) - @author_associations = @author_serializer.associations.to_a - @inherited_author_associations = @inherited_author_serializer.associations.to_a + @author_associations = @author_serializer.associations.to_a.sort_by(&:name) + @inherited_author_associations = @inherited_author_serializer.associations.to_a.sort_by(&:name) @post_associations = @post_serializer.associations.to_a @inherited_post_associations = @inherited_post_serializer.associations.to_a end @@ -361,28 +387,35 @@ module ActiveModel test 'a serializer inheriting from another serializer can redefine has_many and has_one associations' do expected = [:roles, :bio].sort - result = (@inherited_author_associations - @author_associations).map(&:name).sort + result = (@inherited_author_associations.map(&:reflection) - @author_associations.map(&:reflection)).map(&:name) assert_equal(result, expected) + assert_equal [true, false, true], @inherited_author_associations.map(&:polymorphic?) + assert_equal [false, false, false], @author_associations.map(&:polymorphic?) end test 'a serializer inheriting from another serializer can redefine belongs_to associations' do assert_equal [:author, :comments, :blog], @post_associations.map(&:name) assert_equal [:author, :comments, :blog, :comments], @inherited_post_associations.map(&:name) - refute @post_associations.detect { |assoc| assoc.name == :author }.options.key?(:polymorphic) - assert_equal true, @inherited_post_associations.detect { |assoc| assoc.name == :author }.options.fetch(:polymorphic) + refute @post_associations.detect { |assoc| assoc.name == :author }.polymorphic? + assert @inherited_post_associations.detect { |assoc| assoc.name == :author }.polymorphic? - refute @post_associations.detect { |assoc| assoc.name == :comments }.options.key?(:key) + refute @post_associations.detect { |assoc| assoc.name == :comments }.key? original_comment_assoc, new_comments_assoc = @inherited_post_associations.select { |assoc| assoc.name == :comments } - refute original_comment_assoc.options.key?(:key) - assert_equal :reviews, new_comments_assoc.options.fetch(:key) + refute original_comment_assoc.key? + assert_equal :reviews, new_comments_assoc.key - assert_equal @post_associations.detect { |assoc| assoc.name == :blog }, @inherited_post_associations.detect { |assoc| assoc.name == :blog } + original_blog = @post_associations.detect { |assoc| assoc.name == :blog } + inherited_blog = @inherited_post_associations.detect { |assoc| assoc.name == :blog } + original_parent_serializer = original_blog.lazy_association.association_options.delete(:parent_serializer) + inherited_parent_serializer = inherited_blog.lazy_association.association_options.delete(:parent_serializer) + assert_equal PostSerializer, original_parent_serializer.class + assert_equal InheritedPostSerializer, inherited_parent_serializer.class end test 'a serializer inheriting from another serializer can have an additional association with the same name but with different key' do expected = [:author, :comments, :blog, :reviews].sort - result = @inherited_post_serializer.associations.map { |a| a.options.fetch(:key, a.name) }.sort + result = @inherited_post_serializer.associations.map(&:key).sort assert_equal(result, expected) end end diff --git a/test/serializers/reflection_test.rb b/test/serializers/reflection_test.rb new file mode 100644 index 00000000..11cb154b --- /dev/null +++ b/test/serializers/reflection_test.rb @@ -0,0 +1,427 @@ +require 'test_helper' +module ActiveModel + class Serializer + class ReflectionTest < ActiveSupport::TestCase + class Blog < ActiveModelSerializers::Model + attributes :id + end + class BlogSerializer < ActiveModel::Serializer + type 'blog' + attributes :id + end + + setup do + @expected_meta = { id: 1 } + @expected_links = { self: 'no_uri_validation' } + @empty_links = {} + model_attributes = { blog: Blog.new(@expected_meta) } + @model = Class.new(ActiveModelSerializers::Model) do + attributes(*model_attributes.keys) + + def self.name + 'TestModel' + end + end.new(model_attributes) + @instance_options = {} + end + + def evaluate_association_value(association) + association.lazy_association.eval_reflection_block + end + + # TODO: Remaining tests + # test_reflection_value_block_with_scope + # test_reflection_value_uses_serializer_instance_method + # test_reflection_excluded_eh_blank_is_false + # test_reflection_excluded_eh_if + # test_reflection_excluded_eh_unless + # test_evaluate_condition_symbol_serializer_method + # test_evaluate_condition_string_serializer_method + # test_evaluate_condition_proc + # test_evaluate_condition_proc_yields_serializer + # test_evaluate_condition_other + # test_options_key + # test_options_polymorphic + # test_options_serializer + # test_options_virtual_value + # test_options_namespace + + def test_reflection_value + serializer_class = Class.new(ActiveModel::Serializer) do + has_one :blog + end + serializer_instance = serializer_class.new(@model, @instance_options) + + # Get Reflection + reflection = serializer_class._reflections.fetch(:blog) + + # Assert + assert_nil reflection.block + assert_equal Serializer.config.include_data_default, reflection.options.fetch(:include_data_setting) + assert_equal true, reflection.options.fetch(:include_data_setting) + + include_slice = :does_not_matter + assert_equal @model.blog, reflection.send(:value, serializer_instance, include_slice) + end + + def test_reflection_value_block + serializer_class = Class.new(ActiveModel::Serializer) do + has_one :blog do + object.blog + end + end + serializer_instance = serializer_class.new(@model, @instance_options) + + # Get Reflection + reflection = serializer_class._reflections.fetch(:blog) + + # Assert + assert_respond_to reflection.block, :call + assert_equal Serializer.config.include_data_default, reflection.options.fetch(:include_data_setting) + assert_equal true, reflection.options.fetch(:include_data_setting) + + include_slice = :does_not_matter + assert_equal @model.blog, reflection.send(:value, serializer_instance, include_slice) + end + + def test_reflection_value_block_with_explicit_include_data_true + serializer_class = Class.new(ActiveModel::Serializer) do + has_one :blog do + include_data true + object.blog + end + end + serializer_instance = serializer_class.new(@model, @instance_options) + + # Get Reflection + reflection = serializer_class._reflections.fetch(:blog) + + # Assert + assert_respond_to reflection.block, :call + assert_equal Serializer.config.include_data_default, reflection.options.fetch(:include_data_setting) + assert_equal true, reflection.options.fetch(:include_data_setting) + + include_slice = :does_not_matter + assert_equal @model.blog, reflection.send(:value, serializer_instance, include_slice) + end + + def test_reflection_value_block_with_include_data_false_mutates_the_reflection_include_data + serializer_class = Class.new(ActiveModel::Serializer) do + has_one :blog do + include_data false + object.blog + end + end + serializer_instance = serializer_class.new(@model, @instance_options) + + # Get Reflection + reflection = serializer_class._reflections.fetch(:blog) + + # Assert + assert_respond_to reflection.block, :call + assert_equal true, reflection.options.fetch(:include_data_setting) + include_slice = :does_not_matter + assert_nil reflection.send(:value, serializer_instance, include_slice) + assert_equal false, reflection.options.fetch(:include_data_setting) + end + + def test_reflection_value_block_with_include_data_if_sideloaded_included_mutates_the_reflection_include_data + serializer_class = Class.new(ActiveModel::Serializer) do + has_one :blog do + include_data :if_sideloaded + object.blog + end + end + serializer_instance = serializer_class.new(@model, @instance_options) + + # Get Reflection + reflection = serializer_class._reflections.fetch(:blog) + + # Assert + assert_respond_to reflection.block, :call + assert_equal true, reflection.options.fetch(:include_data_setting) + include_slice = {} + assert_nil reflection.send(:value, serializer_instance, include_slice) + assert_equal :if_sideloaded, reflection.options.fetch(:include_data_setting) + end + + def test_reflection_value_block_with_include_data_if_sideloaded_excluded_mutates_the_reflection_include_data + serializer_class = Class.new(ActiveModel::Serializer) do + has_one :blog do + include_data :if_sideloaded + object.blog + end + end + serializer_instance = serializer_class.new(@model, @instance_options) + + # Get Reflection + reflection = serializer_class._reflections.fetch(:blog) + + # Assert + assert_respond_to reflection.block, :call + assert_equal true, reflection.options.fetch(:include_data_setting) + include_slice = { blog: :does_not_matter } + assert_equal @model.blog, reflection.send(:value, serializer_instance, include_slice) + assert_equal :if_sideloaded, reflection.options.fetch(:include_data_setting) + end + + def test_reflection_block_with_link_mutates_the_reflection_links + serializer_class = Class.new(ActiveModel::Serializer) do + has_one :blog do + link :self, 'no_uri_validation' + end + end + serializer_instance = serializer_class.new(@model, @instance_options) + + # Get Reflection + reflection = serializer_class._reflections.fetch(:blog) + assert_equal @empty_links, reflection.options.fetch(:links) + + # Build Association + association = reflection.build_association(serializer_instance, @instance_options) + + # Assert association links empty when not yet evaluated + assert_equal @empty_links, reflection.options.fetch(:links) + assert_equal @empty_links, association.links + + evaluate_association_value(association) + + assert_equal @expected_links, association.links + assert_equal @expected_links, reflection.options.fetch(:links) + end + + def test_reflection_block_with_link_block_mutates_the_reflection_links + serializer_class = Class.new(ActiveModel::Serializer) do + has_one :blog do + link :self do + 'no_uri_validation' + end + end + end + serializer_instance = serializer_class.new(@model, @instance_options) + + # Get Reflection + reflection = serializer_class._reflections.fetch(:blog) + assert_equal @empty_links, reflection.options.fetch(:links) + + # Build Association + association = reflection.build_association(serializer_instance, @instance_options) + + # Assert association links empty when not yet evaluated + assert_equal @empty_links, association.links + + evaluate_association_value(association) + + # Assert before instance_eval link + link = association.links.fetch(:self) + assert_respond_to link, :call + assert_respond_to reflection.options.fetch(:links).fetch(:self), :call + + # Assert after instance_eval link + assert_equal @expected_links.fetch(:self), reflection.instance_eval(&link) + assert_respond_to reflection.options.fetch(:links).fetch(:self), :call + end + + def test_reflection_block_with_meta_mutates_the_reflection_meta + serializer_class = Class.new(ActiveModel::Serializer) do + has_one :blog do + meta(id: object.blog.id) + end + end + serializer_instance = serializer_class.new(@model, @instance_options) + + # Get Reflection + reflection = serializer_class._reflections.fetch(:blog) + assert_nil reflection.options.fetch(:meta) + + # Build Association + association = reflection.build_association(serializer_instance, @instance_options) + + evaluate_association_value(association) + + assert_equal @expected_meta, association.meta + assert_equal @expected_meta, reflection.options.fetch(:meta) + end + + def test_reflection_block_with_meta_block_mutates_the_reflection_meta + serializer_class = Class.new(ActiveModel::Serializer) do + has_one :blog do + meta do + { id: object.blog.id } + end + end + end + serializer_instance = serializer_class.new(@model, @instance_options) + + # Get Reflection + reflection = serializer_class._reflections.fetch(:blog) + assert_nil reflection.options.fetch(:meta) + + # Build Association + association = reflection.build_association(serializer_instance, @instance_options) + # Assert before instance_eval meta + + evaluate_association_value(association) + + assert_respond_to association.meta, :call + assert_respond_to reflection.options.fetch(:meta), :call + + # Assert after instance_eval meta + assert_equal @expected_meta, reflection.instance_eval(&association.meta) + assert_respond_to reflection.options.fetch(:meta), :call + assert_respond_to association.meta, :call + end + + # rubocop:disable Metrics/AbcSize + def test_reflection_block_with_meta_in_link_block_mutates_the_reflection_meta + serializer_class = Class.new(ActiveModel::Serializer) do + has_one :blog do + link :self do + meta(id: object.blog.id) + 'no_uri_validation' + end + end + end + serializer_instance = serializer_class.new(@model, @instance_options) + + # Get Reflection + reflection = serializer_class._reflections.fetch(:blog) + assert_nil reflection.options.fetch(:meta) + assert_equal @empty_links, reflection.options.fetch(:links) + + # Build Association + association = reflection.build_association(serializer_instance, @instance_options) + # Assert before instance_eval link meta + assert_nil association.meta + assert_nil reflection.options.fetch(:meta) + + evaluate_association_value(association) + + link = association.links.fetch(:self) + assert_respond_to link, :call + assert_respond_to reflection.options.fetch(:links).fetch(:self), :call + assert_nil reflection.options.fetch(:meta) + + # Assert after instance_eval link + assert_equal 'no_uri_validation', reflection.instance_eval(&link) + assert_equal @expected_meta, reflection.options.fetch(:meta) + assert_equal @expected_meta, association.meta + end + # rubocop:enable Metrics/AbcSize + + # rubocop:disable Metrics/AbcSize + def test_reflection_block_with_meta_block_in_link_block_mutates_the_reflection_meta + serializer_class = Class.new(ActiveModel::Serializer) do + has_one :blog do + link :self do + meta do + { id: object.blog.id } + end + 'no_uri_validation' + end + end + end + serializer_instance = serializer_class.new(@model, @instance_options) + + # Get Reflection + reflection = serializer_class._reflections.fetch(:blog) + assert_nil reflection.options.fetch(:meta) + + # Build Association + association = reflection.build_association(serializer_instance, @instance_options) + assert_nil association.meta + assert_nil reflection.options.fetch(:meta) + + # Assert before instance_eval link + + evaluate_association_value(association) + + link = association.links.fetch(:self) + assert_nil reflection.options.fetch(:meta) + assert_respond_to link, :call + assert_respond_to association.links.fetch(:self), :call + + # Assert after instance_eval link + assert_equal 'no_uri_validation', reflection.instance_eval(&link) + assert_respond_to association.links.fetch(:self), :call + # Assert before instance_eval link meta + assert_respond_to reflection.options.fetch(:meta), :call + assert_respond_to association.meta, :call + + # Assert after instance_eval link meta + assert_equal @expected_meta, reflection.instance_eval(&reflection.options.fetch(:meta)) + assert_respond_to association.meta, :call + end + # rubocop:enable Metrics/AbcSize + + def test_no_href_in_vanilla_reflection + serializer_class = Class.new(ActiveModel::Serializer) do + has_one :blog do + link :self do + href 'no_uri_validation' + end + end + end + serializer_instance = serializer_class.new(@model, @instance_options) + + # Get Reflection + reflection = serializer_class._reflections.fetch(:blog) + assert_equal @empty_links, reflection.options.fetch(:links) + + # Build Association + association = reflection.build_association(serializer_instance, @instance_options) + # Assert before instance_eval link + + evaluate_association_value(association) + + link = association.links.fetch(:self) + assert_respond_to link, :call + + # Assert after instance_eval link + exception = assert_raise(NoMethodError) do + reflection.instance_eval(&link) + end + assert_match(/undefined method `href'/, exception.message) + end + + # rubocop:disable Metrics/AbcSize + def test_mutating_reflection_block_is_not_thread_safe + serializer_class = Class.new(ActiveModel::Serializer) do + has_one :blog do + meta(id: object.blog.id) + end + end + model1_meta = @expected_meta + # Evaluate reflection meta for model with id 1 + serializer_instance = serializer_class.new(@model, @instance_options) + reflection = serializer_class._reflections.fetch(:blog) + assert_nil reflection.options.fetch(:meta) + association = reflection.build_association(serializer_instance, @instance_options) + + evaluate_association_value(association) + + assert_equal model1_meta, association.meta + assert_equal model1_meta, reflection.options.fetch(:meta) + + model2_meta = @expected_meta.merge(id: 2) + # Evaluate reflection meta for model with id 2 + @model.blog.id = 2 + assert_equal 2, @model.blog.id # sanity check + serializer_instance = serializer_class.new(@model, @instance_options) + reflection = serializer_class._reflections.fetch(:blog) + + # WARN: Thread-safety issue + # Before the reflection is evaluated, it has the value from the previous evaluation + assert_equal model1_meta, reflection.options.fetch(:meta) + + association = reflection.build_association(serializer_instance, @instance_options) + + evaluate_association_value(association) + + assert_equal model2_meta, association.meta + assert_equal model2_meta, reflection.options.fetch(:meta) + end + # rubocop:enable Metrics/AbcSize + end + end +end