diff --git a/lib/blueprinter/base.rb b/lib/blueprinter/base.rb index 08ca8c462..fdb6e0619 100644 --- a/lib/blueprinter/base.rb +++ b/lib/blueprinter/base.rb @@ -6,7 +6,6 @@ require_relative 'reflection' require_relative 'rendering' require_relative 'view_collection' -require_relative 'view_wrapper' module Blueprinter class Base @@ -372,6 +371,17 @@ def view_collection @_view_collection ||= ViewCollection.new end + # For compatibility with V2's DSL + # + # @return [Blueprinter::Base] An anonymous subclass that renders a specific view + def [](view_name) + raise Errors::UnknownView, "View '#{view_name}' could not be found in Blueprint '#{name}'" unless view? view_name + + Class.new(self) do + @forced_view_name = view_name + end + end + private attr_accessor :view_scope @@ -389,14 +399,5 @@ def association_extractor @_association_extractor ||= AssociationExtractor.new end end - - # For compatibility with V2 - # - # @return [Blueprinter::ViewWrapper] - def self.[](view_name) - raise Errors::UnknownView, "View '#{view_name}' could not be found in Blueprint '#{name}'" unless view? view_name - - ViewWrapper.new(self, view_name) - end end end diff --git a/lib/blueprinter/empty_types.rb b/lib/blueprinter/empty_types.rb index 0a881a7c4..a0f9aabf1 100644 --- a/lib/blueprinter/empty_types.rb +++ b/lib/blueprinter/empty_types.rb @@ -9,6 +9,7 @@ module Blueprinter module EmptyTypes include TypeHelpers + extend self private diff --git a/lib/blueprinter/extension.rb b/lib/blueprinter/extension.rb index eddc1872d..0407b1cc1 100644 --- a/lib/blueprinter/extension.rb +++ b/lib/blueprinter/extension.rb @@ -18,8 +18,6 @@ module Blueprinter # - pre_render # class Extension - include V2::Helpers - HOOKS = %i[ around_hook around_result @@ -41,53 +39,13 @@ def self.hooks # If this returns true, around_hook will not be called when this extension's hooks are run. Used by core extensions. def hidden? = false - # around_result TODO - # @param context [Blueprinter::V2::Context::Result] - - # around_serialize_object: Runs around serialization of a Blueprint object. - # @param context [Blueprinter::V2::Context::Object] - - # around_serialize_collection: Runs around serialization of a Blueprint collection. - # @param context [Blueprinter::V2::Context::Object] - - # around_blueprint: Runs around serialization of every Blueprint. - # @param context [Blueprinter::V2::Context::Object] - - # around_field_value TODO - - # around_object_value TODO - - # around_collection_value TODO - - # blueprint_fields: Returns the fields that should be included in the correct order. Default is all fields in the order - # in which they were defined. - # NOTE If there are multiple blueprint_fields hooks, only the last one is called. - # NOTE Only runs once per Blueprint per render. - # @param context [Blueprinter::V2::Context::Render] - # @return [Array] - - # blueprint_setup: Called once per blueprint per render. A common use is to pre-calculate certain options - # and cache them in context.data, so we don't have to recalculate them for every field. - # @param context [Blueprinter::V2::Context::Render] - - # around_hook: Instrument extension hook calls. MUST yield! - # @param extension [Blueprinter::Extension] Instance of the extension - # @param hook [Symbol] Name of hook being called - - # pre_render: Called eary during "render" in V1, this method receives the object to be rendered and - # may return a modified (or new) object to be rendered. - # @param object [Object] The object to be rendered - # @param blueprint [Class] The Blueprinter class - # @param view [Symbol] The blueprint view - # @param options [Hash] Options passed to "render" - # @return [Object] The object to continue rendering - - private + # Skip the current field and halt further field hooks + def skip! = throw V2::Serializer::SIGNAL, V2::Serializer::SIG_SKIP # Helper for around_result hooks to declare that a result is "final" - def final(val) = V2::Context::Final.new(val) + def serialized(val) = V2::Context::Serialized.new(val) - # Helper for around_result hooks to check if a previous hook has declared a result "final" - def final?(val) = val.is_a? V2::Context::Final + # Helper for around_result hooks to check if a previous hook has declared a result "serialized" + def serialized?(val) = val.is_a? V2::Context::Serialized end end diff --git a/lib/blueprinter/extensions.rb b/lib/blueprinter/extensions.rb index ed8c2a62a..dd20c7df8 100644 --- a/lib/blueprinter/extensions.rb +++ b/lib/blueprinter/extensions.rb @@ -4,6 +4,12 @@ module Blueprinter # Optional extensions for applications to pull in module Extensions autoload :FieldOrder, 'blueprinter/extensions/field_order' + autoload :LegacyConditionals, 'blueprinter/extensions/legacy_conditionals' + autoload :LegacyDefaultIf, 'blueprinter/extensions/legacy_default_if' + autoload :LegacyDynamicOptions, 'blueprinter/extensions/legacy_dynamic_options' + autoload :LegacyExtractorOption, 'blueprinter/extensions/legacy_extractor_option' + autoload :LegacyRenameField, 'blueprinter/extensions/legacy_rename_field' + autoload :LegacyTransformer, 'blueprinter/extensions/legacy_transformer' autoload :MultiJson, 'blueprinter/extensions/multi_json' autoload :OpenTelemetry, 'blueprinter/extensions/open_telemetry' autoload :ViewOption, 'blueprinter/extensions/view_option' diff --git a/lib/blueprinter/extensions/field_order.rb b/lib/blueprinter/extensions/field_order.rb index 62891619b..7fe6a58e1 100644 --- a/lib/blueprinter/extensions/field_order.rb +++ b/lib/blueprinter/extensions/field_order.rb @@ -16,7 +16,7 @@ def initialize(&sorter) @sorter = sorter end - # @param ctx [Blueprinter::V2::Context::Render] + # @param ctx [Blueprinter::V2::Context::Init] def around_blueprint_init(ctx) ctx.fields = ctx.fields.sort(&@sorter) yield ctx diff --git a/lib/blueprinter/extensions/legacy_conditionals.rb b/lib/blueprinter/extensions/legacy_conditionals.rb new file mode 100644 index 000000000..ae48d57e4 --- /dev/null +++ b/lib/blueprinter/extensions/legacy_conditionals.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Blueprinter + module Extensions + # + # Support for Legacy/V1's legacy conditionals. V2 continue to work. + # + class LegacyConditionals < Extension + # @!visibility private + V1_ARITY = 3 + + # @param ctx [Blueprinter::V2::Context::Init] + # @!visibility private + def around_blueprint_init(ctx) + # Convert blueprint if/unless options + ctx.blueprint.options[:if] = convert_v1(ctx.blueprint.options[:if]) if ctx.blueprint.options[:if] + ctx.blueprint.options[:unless] = convert_v1(ctx.blueprint.options[:unless]) if ctx.blueprint.options[:unless] + + # Convert field if/unless options + ctx.fields.each do |field| + field.options[:if] = convert_v1(field.options[:if]) if field.options[:if] + field.options[:unless] = convert_v1(field.options[:unless]) if field.options[:unless] + end + + yield ctx + end + + private + + def convert_v1(cond) + if cond.arity == V1_ARITY + ->(ctx) { cond.call(ctx.field.source, ctx.object, ctx.options) } + else + cond + end + end + end + end +end diff --git a/lib/blueprinter/extensions/legacy_default_if.rb b/lib/blueprinter/extensions/legacy_default_if.rb new file mode 100644 index 000000000..e3ee1abf4 --- /dev/null +++ b/lib/blueprinter/extensions/legacy_default_if.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Blueprinter + module Extensions + # + # Support for Legacy/V1's `default_if` options. + # + class LegacyDefaultIf < Extension + # @param ctx [Blueprinter::V2::Context::Init] + # @!visibility private + def around_blueprint_init(ctx) + if (default_if = ctx.blueprint.options[:default_if]) + ctx.blueprint.options[:default_if] = convert_v1(default_if) + end + + ctx.fields.each do |field| + if (default_if = field.options[:default_if]) + field.options[:default_if] = convert_v1(default_if) + end + end + + yield ctx + end + + private + + def convert_v1(cond) + case cond + when ::Blueprinter::EMPTY_COLLECTION, ::Blueprinter::EMPTY_HASH, ::Blueprinter::EMPTY_STRING + ->(_ctx, value) { EmptyTypes.send(:use_default_value?, value, cond) } + else + cond + end + end + end + end +end diff --git a/lib/blueprinter/extensions/legacy_dynamic_options.rb b/lib/blueprinter/extensions/legacy_dynamic_options.rb new file mode 100644 index 000000000..10f60bc25 --- /dev/null +++ b/lib/blueprinter/extensions/legacy_dynamic_options.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Blueprinter + module Extensions + # + # Support for Legacy/V1's `:options` option on associations. + # + class LegacyDynamicOptions < Extension + # @!visibility private + def apply(ctx) + ctx.fields.each do |field| + additional_opts = + case (opts = field.options[:options]) + when Hash then opts + when Proc then opts.call(ctx.object) + end + ctx.options = ctx.options.merge(additional_opts).freeze if additional_opts + end + yield ctx + end + + # @!visibility private + alias around_serialize_object apply + + # @!visibility private + alias around_serialize_collection apply + end + end +end diff --git a/lib/blueprinter/extensions/legacy_extractor_option.rb b/lib/blueprinter/extensions/legacy_extractor_option.rb new file mode 100644 index 000000000..183227e4c --- /dev/null +++ b/lib/blueprinter/extensions/legacy_extractor_option.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Blueprinter + module Extensions + # + # Support for Legacy/V1's `extractor` option. + # + class LegacyExtractorOption < Extension + # @!visibility private + def extract(ctx) + extractor_class = ctx.field.options[:extractor] || ctx.blueprint.options[:extractor] + return yield ctx if extractor_class.nil? + + extractor = ctx.store[extractor_class.object_id] ||= extractor_class.new + extractor.extract(ctx.field.source, ctx.object, ctx.options, ctx.field.options) + end + + # @!visibility private + alias around_field_value extract + + # @!visibility private + alias around_object_value extract + + # @!visibility private + alias around_collection_value extract + end + end +end diff --git a/lib/blueprinter/extensions/legacy_rename_field.rb b/lib/blueprinter/extensions/legacy_rename_field.rb new file mode 100644 index 000000000..b76d850b7 --- /dev/null +++ b/lib/blueprinter/extensions/legacy_rename_field.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Blueprinter + module Extensions + # + # Support for Legacy/V1's `name` option. + # + class LegacyRenameField < Extension + # @param ctx [Blueprinter::V2::Context::Init] + # @!visibility private + def around_blueprint_init(ctx) + ctx.fields.each do |field| + if (name = field.options[:name]) + field.source = field.name + field.name = name + end + end + yield ctx + end + end + end +end diff --git a/lib/blueprinter/extensions/legacy_transformer.rb b/lib/blueprinter/extensions/legacy_transformer.rb new file mode 100644 index 000000000..f19f28c56 --- /dev/null +++ b/lib/blueprinter/extensions/legacy_transformer.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Blueprinter + module Extensions + # + # Support for Legacy/V1's transformers. + # + class LegacyTransformer < Extension + # @param *transformers [Class] One or more transformers (Blueprinter::Transformer) + def initialize(*transformers) + @transformers = transformers + end + + # @param ctx [Blueprinter::V2::Context::Object] + # @!visibility private + def around_blueprint(ctx) + hash = yield ctx + @transformers.each do |klass| + transformer = ctx.store[klass.object_id] ||= klass.new + transformer.transform(hash, ctx.object, ctx.options) + end + hash + end + end + end +end diff --git a/lib/blueprinter/extensions/multi_json.rb b/lib/blueprinter/extensions/multi_json.rb index 8542f95eb..e684e87d9 100644 --- a/lib/blueprinter/extensions/multi_json.rb +++ b/lib/blueprinter/extensions/multi_json.rb @@ -17,15 +17,11 @@ def initialize(options = {}) # @param ctx [Blueprinter::V2::Context::Result] def around_result(ctx) - case ctx.format - when :json - ctx.format = :hash - result = yield ctx - opts = ctx.options[:multi_json] ? @options.merge(ctx.options[:multi_json]) : @options - final ::MultiJson.dump(result, opts) - else - yield ctx - end + result = yield ctx + return result unless ctx.format == :json + + opts = ctx.options[:multi_json] ? @options.merge(ctx.options[:multi_json]) : @options + serialized ::MultiJson.dump(result, opts) end end end diff --git a/lib/blueprinter/extensions/view_option.rb b/lib/blueprinter/extensions/view_option.rb index 6528ab225..a5a5a5806 100644 --- a/lib/blueprinter/extensions/view_option.rb +++ b/lib/blueprinter/extensions/view_option.rb @@ -3,7 +3,7 @@ module Blueprinter module Extensions # - # An optional, built-in extension for a ":view" option on render. + # Support for Legacy/V1's `:view` option on `render`. # class ViewOption < Extension # @param ctx [Blueprinter::V2::Context::Result] diff --git a/lib/blueprinter/extractors/association_extractor.rb b/lib/blueprinter/extractors/association_extractor.rb index 4fede7d01..692d25993 100644 --- a/lib/blueprinter/extractors/association_extractor.rb +++ b/lib/blueprinter/extractors/association_extractor.rb @@ -53,11 +53,10 @@ def extract_v2(value, blueprint, local_options, options) store = local_options[:v2_store] || {} depth = local_options[:v2_depth] || 1 instances = local_options[:v2_instances] || V2::InstanceCache.new - serializer = instances.serializer(blueprint[view], local_options.except(:v2_instances), store, depth + 1) if value.is_a?(Enumerable) && !value.is_a?(Hash) - serializer.collection(value, depth: depth + 1) + blueprint[view].serializer.collection(value, local_options, instances:, store:, depth: depth + 1) else - serializer.object(value, depth: depth + 1) + blueprint[view].serializer.object(value, local_options, instances:, store:, depth: depth + 1) end end diff --git a/lib/blueprinter/hooks.rb b/lib/blueprinter/hooks.rb index 06c524bd6..f3e43e790 100644 --- a/lib/blueprinter/hooks.rb +++ b/lib/blueprinter/hooks.rb @@ -10,7 +10,6 @@ def initialize(extensions) ext.class.hooks.each { |hook| @hooks[hook] << ext } end @hooks.freeze - @reversed_hooks ||= @hooks.transform_values(&:reverse).freeze @hook_around_hook = registered? :around_hook end @@ -38,43 +37,54 @@ def [](hook) = @hooks.fetch(hook) # @param require_yield [Boolean] Throw an exception if a hook doesn't yield # @return [Object] Object returned from the outer hook (or from the given block, if there are no hooks) # - def around(hook, ctx, require_yield: false, &inner) + def around(hook, ctx, require_yield: false, &) hooks = @hooks.fetch(hook) - _around(hooks, hook, 0, ctx, ctx.class, inner, require_yield:) + _around(hooks, hook, 0, ctx, ctx.class, require_yield:, &) end private - def call(ext, hook, ctx, &) - return ext.public_send(hook, ctx, &) if !@hook_around_hook || ext.hidden? || hook == :around_hook - - result = nil - hooks = @hooks.fetch(:around_hook) - hook_ctx = V2::Context::Hook.new(ctx.blueprint, ctx.fields, ctx.options, ext, hook) - _around(hooks, :around_hook, 0, hook_ctx, NilClass, lambda do |_| - result = ext.public_send(hook, ctx, &) - end, require_yield: true) - result - end - - def _around(hooks, hook, idx, ctx, expected_yield, inner, require_yield: false) + # Runs hooks recursively + def _around(hooks, hook, idx, ctx, expected_yield, require_yield: false, &) ext = hooks[idx] - return inner.call(ctx) if ext.nil? + return yield ctx if ext.nil? yielded = false result = call(ext, hook, ctx) do |yielded_ctx| - yielded = true + yielded ||= true unless yielded_ctx.is_a? expected_yield msg = "should yield `#{expected_yield.name}` but yielded `#{yielded_ctx.inspect}`" raise Errors::ExtensionHook.new(ext, hook, msg) end - ctx = yielded_ctx.dup if yielded_ctx - _around(hooks, hook, idx + 1, ctx, expected_yield, inner, require_yield:) + ctx = yielded_ctx if yielded_ctx + _around(hooks, hook, idx + 1, ctx, expected_yield, require_yield:, &) end raise Errors::ExtensionHook.new(ext, hook, 'did not yield') if require_yield && !yielded result end + + # Calls a hook on an extension. If the `around_hook` hook is registered it's wrapped around the call. + def call(ext, hook, ctx, &) + return ext.public_send(hook, ctx, &) if !@hook_around_hook || ext.hidden? || hook == :around_hook + + hooks = @hooks.fetch(:around_hook) + # Hacky, but re-using this context object saves tons of time + hook_ctx = Thread.current[:_blueprinter_hook_ctx] ||= V2::Context::Hook.new + hook_ctx.blueprint = ctx.blueprint + hook_ctx.fields = ctx.fields + hook_ctx.options = ctx.options + hook_ctx.extension = ext + hook_ctx.hook = hook + hook_ctx.store = ctx.store + hook_ctx.depth = ctx.depth + result = nil + _around(hooks, :around_hook, 0, hook_ctx, NilClass, require_yield: true) do + # return the inner hook's value, not around_hook's + result = ext.public_send(hook, ctx, &) + end + result + end end end diff --git a/lib/blueprinter/rendering.rb b/lib/blueprinter/rendering.rb index db6cc5c38..ec7316fdc 100644 --- a/lib/blueprinter/rendering.rb +++ b/lib/blueprinter/rendering.rb @@ -91,6 +91,7 @@ def render_as_json(object, options = {}) # additional key value pairs will be exposed during serialization. # @return [Hash] def hashify(object, view_name:, local_options:) + view_name = @forced_view_name if @forced_view_name # V2-forward compatibility raise BlueprinterError, "View '#{view_name}' is not defined" unless view_collection.view?(view_name) hooks = Blueprinter.configuration.hooks[:pre_render] diff --git a/lib/blueprinter/v2.rb b/lib/blueprinter/v2.rb index 77c2332a0..0bebc497f 100644 --- a/lib/blueprinter/v2.rb +++ b/lib/blueprinter/v2.rb @@ -9,14 +9,15 @@ module V2 autoload :Context, 'blueprinter/v2/context' autoload :DSL, 'blueprinter/v2/dsl' autoload :Extensions, 'blueprinter/v2/extensions' - autoload :Extractor, 'blueprinter/v2/extractor' + autoload :Extractors, 'blueprinter/v2/extractors' + autoload :FieldLogic, 'blueprinter/v2/field_logic' autoload :FieldSerializers, 'blueprinter/v2/field_serializers' autoload :Formatter, 'blueprinter/v2/formatter' - autoload :Helpers, 'blueprinter/v2/helpers' autoload :InstanceCache, 'blueprinter/v2/instance_cache' autoload :Reflection, 'blueprinter/v2/reflection' autoload :Render, 'blueprinter/v2/render' + autoload :Rendering, 'blueprinter/v2/rendering' autoload :Serializer, 'blueprinter/v2/serializer' - autoload :ViewBuilder, 'blueprinter/v2/view_builder' + autoload :Specification, 'blueprinter/v2/specification' end end diff --git a/lib/blueprinter/v2/base.rb b/lib/blueprinter/v2/base.rb index fc963ea78..f5ac21e04 100644 --- a/lib/blueprinter/v2/base.rb +++ b/lib/blueprinter/v2/base.rb @@ -1,134 +1,146 @@ # frozen_string_literal: true -require 'blueprinter/v2/instance_cache' -require 'blueprinter/v2/render' +require 'blueprinter/v2/specification' module Blueprinter module V2 # Base class for V2 Blueprints class Base extend DSL + extend Rendering extend Reflection - include Helpers class << self - # @return [Hash] Options set on this Blueprint - attr_accessor :options - # @return [Array] Extensions set on this Blueprint - attr_accessor :extensions - # @return [Symbol] The name of this view, e.g. :default, :"foo.bar" + # @return [Symbol] The name of this view (`:default`, `:foo`) attr_accessor :view_name - # @return [String] The fully-qualified name, e.g. "MyBlueprint", or "MyBlueprint.foo.bar" + # @return [Symbol] The full name of this view, including any parent views (`:default`, `:foo`, `:foo.bar`) + attr_accessor :view_path + # @return [String] The fully-qualified name (`MyBlueprint`, `MyBlueprint.foo.bar`) attr_accessor :blueprint_name - # @api private - attr_accessor :views, :schema, :excludes, :formatters, :partials, :appended_partials, :eval_mutex - end - - self.views = ViewBuilder.new(self) - self.schema = {} - self.excludes = [] - self.formatters = {} - self.partials = {} - self.appended_partials = [] - self.extensions = [] - self.options = {} - self.blueprint_name = name - self.view_name = :default - self.eval_mutex = Mutex.new - - # Initialize subclass - def self.inherited(subclass) - subclass.views = views.dup_for(subclass) - subclass.schema = schema.transform_values(&:dup) - subclass.excludes = [] - subclass.formatters = formatters.dup - subclass.partials = partials.dup - subclass.appended_partials = [] - subclass.extensions = extensions.dup - subclass.options = options.dup - subclass.blueprint_name = subclass.name || blueprint_name - subclass.view_name = :default - subclass.eval_mutex = Mutex.new - end - - # A descriptive name for the Blueprint view, e.g. "WidgetBlueprint.extended" - def self.inspect = blueprint_name + # @!visibility private + attr_accessor :nodes + + # Initialize subclass + def inherited(subclass) + subclass.nodes = [] + subclass.children = { default: subclass } + subclass.blueprint_name = subclass.name || blueprint_name + subclass.view_path = :default + subclass.view_name = :default + subclass.eval_mutex = Mutex.new + subclass.children_mutex = Mutex.new + end - # A descriptive name for the Blueprint view, e.g. "WidgetBlueprint.extended" - def self.to_s = blueprint_name + def serializer + eval! unless evaled? + @serializer + end - # Set the view name - # @api private - def self.append_name(name) - self.blueprint_name = "#{blueprint_name}.#{name}" - self.view_name = blueprint_name.sub(/^[^.]+\./, '').to_sym - end + # A descriptive name for the Blueprint view, e.g. "WidgetBlueprint.extended" + def inspect = blueprint_name + + # A descriptive name for the Blueprint view, e.g. "WidgetBlueprint.extended" + def to_s = blueprint_name + + # + # Access a child view. + # + # MyBlueprint[:extended] + # MyBlueprint["extended.plus"] or MyBlueprint[:extended][:plus] + # + # @param name [Symbol|String] Name of the view, e.g. :extended, "extended.plus" + # @return [Class] A descendent of Blueprinter::V2::Base + # + def [](name) + name, name_tail = name.to_s.split('.', 2) + name = name.to_sym + + unless children.key? name + children_mutex.synchronize do + next if children.key? name + + # If the Blueprint has already been evaluated, throw an error if the view isn't defined. + # Otherwise, create a child that *may* be removed post-eval if it's found to be invalid. + # This allows Blueprints to reference their own views in associations (without using a Proc). + invalid = evaled? && !spec.view_defs.key?(name) + raise Errors::UnknownView, "View '#{name}' not found in Blueprint '#{self}'" if invalid + + child = Class.new(self) + child.blueprint_name = "#{child.blueprint_name}.#{name}" + child.view_path = child.blueprint_name.sub(/^[^.]+\./, '').to_sym + child.view_name = name + children[name] = child + end + end + + child = children.fetch(name) + name_tail ? child[name_tail] : child + end - # - # Access a child view. - # - # MyBlueprint[:extended] - # MyBlueprint["extended.plus"] or MyBlueprint[:extended][:plus] - # - # @param name [Symbol|String] Name of the view, e.g. :extended, "extended.plus" - # @return [Class] A descendent of Blueprinter::V2::Base - # - def self.[](name) - eval! unless @evaled - child, children = name.to_s.split('.', 2) - view = views[child.to_sym] || raise(Errors::UnknownView, "View '#{child}' could not be found in Blueprint '#{self}'") - children ? view[children] : view - end + # Returns (generating if necessary) the evaluated Blueprint specification + # @!visibility private + # @return [Blueprinter::V2::Specification::Spec] + def spec + eval! unless @spec + @spec + end - def self.render(obj, options = {}) - if obj.is_a?(Enumerable) && !obj.is_a?(Hash) - render_collection(obj, options) - else - render_object(obj, options) + # Apply partials and field exclusions + # @api private + def eval!(lock: true) + return if evaled? || self == V2::Base + + if lock + eval_mutex.synchronize { run_eval! unless evaled? } + else + run_eval! + end end - end - def self.render_object(obj, options = {}) - instances = InstanceCache.new - Render.new(obj, options, blueprint: self, instances:, collection: false) - end + protected - def self.render_collection(objs, options = {}) - instances = InstanceCache.new - Render.new(objs, options, blueprint: self, instances:, collection: true) - end + # @!visibility private + attr_writer :children, :eval_mutex, :children_mutex + + private - # Apply partials and field exclusions - # @api private - def self.eval!(lock: true) - return if @evaled + attr_reader :children, :eval_mutex, :children_mutex + attr_writer :spec - if lock - eval_mutex.synchronize { run_eval! unless @evaled } - else - run_eval! + def run_eval! + superclass.eval! + self.spec = Specification.new(self).generate + nodes.clear.freeze + cleanup_children! + @serializer = Serializer.new(self) end - end - # @api private - def self.run_eval! - appended_partials.each(&method(:apply_partial!)) - excludes.each { |f| schema.delete f } - extensions.freeze - options.freeze - formatters.freeze - schema.freeze - schema.each_value do |f| - f.options&.freeze - f.freeze + # Before eval we allow Base#[] to accept any view name. (This allows Blueprints to self-reference their own views + # on associations.) After eval, we know which ones weren't valid. + def cleanup_children! + spec.view_defs.each_key { |name| self[name] } # ensure all child classes are created + children_mutex.synchronize do + children.delete_if { |name| !spec.view_defs.key?(name) && name != :default } + children.freeze + end end - @evaled = true + + def evaled? = !@serializer.nil? end - # @api private - def self.apply_partial!(name) - p = partials[name] || raise(Errors::UnknownPartial, "Partial '#{name}' could not be found in Blueprint '#{self}'") - class_eval(&p) + self.nodes = [].freeze + self.spec = Specification::Spec + .new(nodes: [], options: {}, extensions: [], formatters: {}, schema: {}, view_defs: {}).freeze + self.blueprint_name = name + self.view_path = :default + self.view_name = :default + + # @return [Hash] Copy of options set on the class. Frozen after `around_blueprint_init` hooks run. + attr_reader :options + + # @!visibility private + def initialize + @options = self.class.spec.options.dup end # A descriptive name for the Blueprint view, e.g. "#" diff --git a/lib/blueprinter/v2/context.rb b/lib/blueprinter/v2/context.rb index 49450ce37..62fdc43fa 100644 --- a/lib/blueprinter/v2/context.rb +++ b/lib/blueprinter/v2/context.rb @@ -2,20 +2,24 @@ module Blueprinter module V2 - # Defines structs passed to extension hooks, extractors, and field blocks. + # Structs passed to if/unless/default Procs, field definition blocks, and extension hooks. module Context # # The blueprint being rendered along with options passed to render/render_object/render_collection. # # @!attribute [r] blueprint - # @return [Blueprinter::V2::Base] Instance of the outer Blueprint class - # @!attribute [r] fields - # @return [Array] + # @return [Blueprinter::V2::Base] Instance of the outer Blueprint class. You may modify the `blueprint.options` Hash. + # @!attribute [rw] fields + # @return [Array] The fields to serialize, in the order they'll be serialized # @!attribute [r] options # @return [Hash] Options passed to `render` + # @!attribute [r] store + # @return [Hash] Arbitrary store available for this render + # @!attribute [r] depth + # @return [Integer] Current serialization depth # - Render = Struct.new(:blueprint, :fields, :options, :store, :depth) do - (members - %i[fields options]).each do |attr| + Init = Struct.new(:blueprint, :fields, :options, :store, :depth) do + (members - %i[fields]).each do |attr| remove_method("#{attr}=") define_method("#{attr}=") { |_| raise BlueprinterError, "Context field `#{attr}` is immutable" } end @@ -27,22 +31,21 @@ module Context # @!attribute [r] blueprint # @return [Blueprinter::V2::Base] Instance of the outer Blueprint class # @!attribute [r] fields - # @return [Array] + # @return [Array] # @!attribute [r] options - # @return [Hash] Options passed to `render` + # @return [Hash] Options passed to `render` (frozen) # @!attribute [r] extension # @return [Blueprinter::Extension] Instance of the extension running # @!attribute [r] hook # @return [Symbol] Name of the symbol being called # @!attribute [r] depth # @return [Integer] Blueprint depth (1-indexed) + # @!attribute [r] store + # @return [Hash] Arbitrary store available for this render + # @!attribute [r] depth + # @return [Integer] Current serialization depth # - Hook = Struct.new(:blueprint, :fields, :options, :extension, :hook, :store, :depth) do - members.each do |attr| - remove_method("#{attr}=") - define_method("#{attr}=") { |_| raise BlueprinterError, "Context field `#{attr}` is immutable" } - end - end + Hook = Struct.new(:blueprint, :fields, :options, :extension, :hook, :store, :depth) # # The object or collection currently being serialized. @@ -50,49 +53,62 @@ module Context # @!attribute [r] blueprint # @return [Blueprinter::V2::Base] Instance of the current Blueprint class # @!attribute [r] fields - # @return [Array] - # @!attribute [r] options - # @return [Hash] Options passed to `render` - # @!attribute [r] object - # @return [Object] The object or collection that's currently being rendered + # @return [Array] + # @!attribute [rw] options + # @return [Hash] Options passed to `render` (frozen but can be replaced) + # @!attribute [rw] object + # @return [Object] The object or collection that's currently being rendered. Can be replaced. # @!attribute [r] parent # @return [Blueprinter::V2::Context::Parent] Information about the parent, if any + # @!attribute [r] store + # @return [Hash] Arbitrary store available for this render # @!attribute [r] depth # @return [Integer] Blueprint depth (1-indexed) # Object = Struct.new(:blueprint, :fields, :options, :object, :parent, :store, :depth) do - (members - %i[object]).each do |attr| + (members - %i[object options]).each do |attr| remove_method("#{attr}=") define_method("#{attr}=") { |_| raise BlueprinterError, "Context field `#{attr}` is immutable" } end end + # + # The parent blueprint, field, and object. + # + # @!attribute [r] blueprint + # @return [Blueprinter::V2::Base] The parent's Blueprint instance + # @!attribute [r] field + # @return [Blueprinter::V2::Fields::Field] The parent field + # @!attribute [r] object + # @return [Object] The parent object + # Parent = Struct.new(:blueprint, :field, :object) do - members.each do |attr| + (members - %i[field object]).each do |attr| remove_method("#{attr}=") define_method("#{attr}=") { |_| raise BlueprinterError, "Parent field `#{attr}` is immutable" } end end # - # The current field. + # The field currently being serialized. # # @!attribute [r] blueprint # @return [Blueprinter::V2::Base] Instance of the current Blueprint class # @!attribute [r] fields - # @return [Array] + # @return [Array] # @!attribute [r] options - # @return [Hash] Options passed to `render` + # @return [Hash] Options passed to `render` (frozen) # @!attribute [r] object # @return [Object] The object or collection that's currently being rendered # @!attribute [r] field - # @return [Blueprinter::V2::Fields::Field|Blueprinter::V2::Fields::Object|Blueprinter::V2::Fields::Collection] The - # field that's currently being evaluated + # @return [Blueprinter::V2::Fields::Field] The field that's currently being evaluated + # @!attribute [r] store + # @return [Hash] Arbitrary store available for this render # @!attribute [r] depth # @return [Integer] Blueprint depth (1-indexed) # Field = Struct.new(:blueprint, :fields, :options, :object, :field, :store, :depth) do - (members - %i[field]).each do |attr| + (members - %i[field object]).each do |attr| remove_method("#{attr}=") define_method("#{attr}=") { |_| raise BlueprinterError, "Context field `#{attr}` is immutable" } end @@ -101,16 +117,19 @@ module Context # # A serialized object/collection. This may be the outer object/collection or a nested one. # - # @!attribute [r] blueprint - # @return [Blueprinter::V2::Base] Instance of the current Blueprint class + # @!attribute [rw] blueprint + # @return [Blueprinter::V2::Base] Instance of the current Blueprint class. If replaced, the render is aborted and a + # new one begun. # @!attribute [r] fields - # @return [Array] - # @!attribute [r] options - # @return [Hash] Options passed to `render` - # @!attribute [r] object - # @return [Object] The object or collection that's currently being rendered - # @!attribute [r] format - # @return [Symbol] Requested format of result, e.g. :json + # @return [Array] + # @!attribute [rw] options + # @return [Hash] Options passed to `render`. Can be modified. + # @!attribute [rw] object + # @return [Object] The object or collection that's currently being rendered. Can be replaced. + # @!attribute [rw] format + # @return [Symbol] Requested format of result, e.g. :json. Can be replaced. + # @!attribute [r] store + # @return [Hash] Arbitrary store available for this render # Result = Struct.new(:blueprint, :fields, :options, :object, :format, :store) do (members - %i[blueprint options object format]).each do |attr| @@ -120,7 +139,9 @@ module Context end # Represents the final result of a render call that shouldn't be further modified by extensions - Final = Struct.new(:value) + # @!attribute [r] value + # @return [Object] + Serialized = Struct.new(:value) end end end diff --git a/lib/blueprinter/v2/dsl.rb b/lib/blueprinter/v2/dsl.rb index 45216fe9f..8fd0166cb 100644 --- a/lib/blueprinter/v2/dsl.rb +++ b/lib/blueprinter/v2/dsl.rb @@ -1,9 +1,29 @@ # frozen_string_literal: true +require 'set' + module Blueprinter module V2 # Methods for defining Blueprint fields and views + # rubocop:disable Metrics/ModuleLength module DSL + # @!visibility private + module Nodes + Use = Struct.new(:name, :exclusions, :callsite) + Exclude = Struct.new(:name) + Partial = Struct.new(:name, :block) + View = Struct.new(:name, :block) + Format = Struct.new(:klass, :fmt) + SetOpt = Struct.new(:key, :val) + SetDynamicOpt = Struct.new(:key, :block) + UnsetOpt = Struct.new(:key) + AppendExt = Struct.new(:ext) + PrependExt = Struct.new(:ext) + RemExt = Struct.new(:klass) + RemDynamicExt = Struct.new(:block) + Flag = Struct.new(:name) + end + # @api private BLUEPRINT_ARRAY_OR_CLASS_ERR = 'Blueprint must be a Blueprint class or an Array containing a Blueprint class' @@ -12,15 +32,15 @@ module DSL # appended. # # @param name [Symbol] Name of the view - # @param empty [Boolean] Don't inherit fields from ancestors (default false) # @yield Define the view in the block # - def view(name, empty: nil, &definition) + def view(name, &definition) + name = name.to_sym + raise Errors::InvalidBlueprint, 'You may not redefine the default view' if name == :default raise Errors::InvalidBlueprint, "View name may not contain '.'" if name.to_s =~ /\./ - name = name.to_sym - partials[name] = definition - views[name] = ViewBuilder::Def.new(definition:, empty:) + partial(name, &definition) + nodes << Nodes::View.new(name, definition) end # @@ -30,25 +50,27 @@ def view(name, empty: nil, &definition) # @yield Define a new partial in the block # def partial(name, &definition) - partials[name.to_sym] = definition - end - - # - # Append one or more partials to this view. - # - # @param names [Array] One or more partial names - # - def use(*names) - names.each { |name| appended_partials << name.to_sym } + nodes << Nodes::Partial.new(name.to_sym, definition) end # - # Insert one or more partials in this view. + # Include one or more partials. # - # @param names [Array] One or more partial names + # @param *names [Symbol] One or more partial names + # @param exclude [Array] Names of fields or associations to exclude from the partial(s) + # @param fields [true | false] If false, no fields from the partial(s) will be used + # @param options [true | false] If false, no options from the partial(s) will be used + # @param extensions [true | false] If false, no extensions from the partial(s) will be used + # @param formatters [true | false] If false, no formatters from the partial(s) will be used # - def use!(*names) - names.each(&method(:apply_partial!)) + def use(*names, exclude: [], fields: true, options: true, extensions: true, formatters: true) + callsite = caller[0] + exclusions = Specification::Exclusions.new( + field_names: Set.new(exclude), fields: !fields, options: !options, extensions: !extensions, formatters: !formatters + ) + names.each do |name| + nodes << Nodes::Use.new(name.to_sym, exclusions, callsite) + end end # @@ -59,17 +81,16 @@ def use!(*names) # @yield Do formatting in the block instead # def format(klass, formatter_method = nil, &formatter_block) - formatters[klass] = formatter_method || formatter_block + nodes << Nodes::Format.new(klass, formatter_method&.to_sym || formatter_block) end # - # Define an anonymous extension and add it to the current context. It will be initialized - # once per render. + # Define an anonymous extension and add it to the current context. # # class WidgetBlueprint < ApplicationBlueprint # extension do # # modify every object before serialization - # def around_blueprint(ctx) + # def around_serialize_object(ctx) # object = modify ctx.object # yield object # end @@ -78,34 +99,32 @@ def format(klass, formatter_method = nil, &formatter_block) # def extension(&block) bp_name = blueprint_name - extensions << Class.new(Extension) do + add Class.new(Extension) { @blueprint_name = bp_name def self.name = "#{@blueprint_name} extension" class_eval(&block) - end + }.new end # # Define a field. # # @param name [Symbol] Name of the field - # @param from [Symbol] Optionally specify a different method to call to get the value for "name" - # @param extractor [Class] Extractor class to use for this field + # @param source [Symbol] Optionally specify a different method/Hash key to call to get the value for "name" # @param default [Object | Symbol | Proc] Value to use if the field is nil, or if `default_if` returns true # @param default_if [Symbol | Proc] Return true to use the value in `default` # @param exclude_if_nil [Boolean] Don't include field if the value is nil - # @param exclude_if_empty [Boolean] Don't include field if the value is nil or `empty?` # @param if [Symbol | Proc] Only include the field if it returns true # @param unless [Symbol | Proc] Include the field unless it returns true # @yield [Blueprinter::V2::Context] Generate the value from the block - # @return [Blueprinter::V2::Fields::Field] # - def field(name, from: name, **options, &definition) + def field(name, source: name, **options, &definition) name = name.to_sym - schema[name] = Fields::Field.new( + nodes << Fields::Field.new( + type: :field, name: name, - from: from.to_sym, - from_str: from.to_s, + source: source.to_sym, + source_str: source.to_s, value_proc: definition, options: options.dup ) @@ -114,10 +133,25 @@ def field(name, from: name, **options, &definition) # # Add multiple fields at once. # - def fields(*names) + # @param name [Symbol] Name of the field + # @param default [Object | Symbol | Proc] Value to use if the field is nil, or if `default_if` returns true + # @param default_if [Symbol | Proc] Return true to use the value in `default` + # @param exclude_if_nil [Boolean] Don't include field if the value is nil + # @param if [Symbol | Proc] Only include the field if it returns true + # @param unless [Symbol | Proc] Include the field unless it returns true + # @yield [Blueprinter::V2::Context] Generate the value from the block + # + def fields(*names, **options, &definition) names.each do |name| name = name.to_sym - schema[name] = Fields::Field.new(name: name, from: name, from_str: name.to_s, options: {}) + nodes << Fields::Field.new( + type: :field, + name: name, + source: name, + source_str: name.to_s, + options: options, + value_proc: definition + ) end end @@ -125,39 +159,99 @@ def fields(*names) # Defines an association to an object or collection. # # @param name [Symbol] Name of the association - # @param blueprint [Class|Array] Blueprint class to use (object). For a collection, wrap the blueprint in an - # array. - # @param from [Symbol] Optionally specify a different method to call to get the value for "name" - # @param extractor [Class] Extractor class to use for this field + # @param blueprint [Class|Proc|Array] Blueprint class to use. For a collection, wrap the blueprint in an + # array. You may also pass a Proc that returns a Blueprint. + # @param source [Symbol] Optionally specify a different method/Hash key to call to get the value for "name" # @param default [Object | Symbol | Proc] Value to use if the field is nil, or if `default_if` returns true # @param default_if [Symbol | Proc] Return true to use the value in `default` # @param exclude_if_nil [Boolean] Don't include field if the value is nil - # @param exclude_if_empty [Boolean] Don't include field if the value is nil or `empty?` # @param if [Symbol | Proc] Only include the field if it returns true # @param unless [Symbol | Proc] Include the field unless it returns true # @yield [Blueprinter::V2::Context] Generate the value from the block # - def association(name, blueprint, from: name, **options, &definition) + def association(name, blueprint, source: name, **options, &definition) name = name.to_sym is_collection, blueprint_class = parse_blueprint(blueprint) - type = is_collection ? Fields::Collection : Fields::Object - schema[name] = type.new( + nodes << Fields::Field.new( + type: is_collection ? :collection : :object, name: name, blueprint: blueprint_class, - from: from.to_sym, - from_str: from.to_s, + source: source.to_sym, + source_str: source.to_s, value_proc: definition, options: options.dup ) end # - # Exclude parent fields and associations from this view. + # Excludes the given fields and associations from parent Blueprints or views. Or categorically exclude things. + # + # Note: Does **not** affect fields, options, etc. coming from partials. + # + # @param *names [Symbol] Fields or associations to exclude + # @param fields [true | false] Exclude all fields + # @param options [true | false] Exclude all options + # @param extensions [true | false] Exclude all extensions + # @param formatters [true | false] Exclude all formatters + # + def exclude(*names, fields: false, options: false, extensions: false, formatters: false) + names.each { |name| nodes << Nodes::Exclude.new(name.to_sym) } + nodes << Nodes::Flag.new(:exclude_fields) if fields + nodes << Nodes::Flag.new(:exclude_options) if options + nodes << Nodes::Flag.new(:exclude_extensions) if extensions + nodes << Nodes::Flag.new(:exclude_formatters) if formatters + end + + alias excludes exclude + + # + # Set an option value. + # + # @param key [Symbol] Option name + # @param value [Object | nil] Object value + # @yield [Object | nil] Get the current value then return the value you want + # + def set(key, value = nil, &block) + node = block ? Nodes::SetDynamicOpt.new(key, block) : Nodes::SetOpt.new(key, value) + nodes << node + end + + # + # Clear the given options. + # + # @param *keys [Symbol] + # + def unset(*keys) + keys.each { |key| nodes << Nodes::UnsetOpt.new(key) } + end + + # + # Adds one or more extensions. + # + # @param *extensions [Blueprinter::Extension] Extension instances to add + # @param prepend [true | false] Add this extension before all others + # + def add(*extensions, prepend: false) + if block_given? + raise BlueprinterError, 'Blueprinter::DSL#add does not accept a block. Did you mean to pass it to an extension?' + end + + extensions.reverse! if prepend + extensions.each do |ext| + node = prepend ? Nodes::PrependExt.new(ext) : Nodes::AppendExt.new(ext) + nodes << node + end + end + + # + # Removes extensions of the given classes, or that satisfy the given block. # - # @param name [Array] One or more fields or associations to exclude + # @param *klasses [Class] + # @yield [Blueprinter::Extension] Return true if the given extension should be removed # - def exclude(*names) - self.excludes += names.map(&:to_sym) + def remove(*klasses, &reject) + klasses.each { |klass| nodes << Nodes::RemExt.new(klass) } + nodes << Nodes::RemDynamicExt.new(reject) if reject end private @@ -173,10 +267,11 @@ def parse_blueprint(blueprint) end is_bp_class = assoc_arg.is_a?(Class) && (assoc_arg < V2::Base || assoc_arg < Blueprinter::Base) - raise ArgumentError, BLUEPRINT_ARRAY_OR_CLASS_ERR unless is_bp_class || assoc_arg.is_a?(ViewWrapper) + raise ArgumentError, BLUEPRINT_ARRAY_OR_CLASS_ERR unless is_bp_class || assoc_arg.is_a?(Proc) [is_collection, assoc_arg] end end + # rubocop:enable Metrics/ModuleLength end end diff --git a/lib/blueprinter/v2/extensions.rb b/lib/blueprinter/v2/extensions.rb index 6717387e8..67b6bc2bd 100644 --- a/lib/blueprinter/v2/extensions.rb +++ b/lib/blueprinter/v2/extensions.rb @@ -5,10 +5,8 @@ module V2 module Extensions # Core functionality built with extensions module Core - autoload :Conditionals, 'blueprinter/v2/extensions/core/conditionals' - autoload :Defaults, 'blueprinter/v2/extensions/core/defaults' - autoload :Json, 'blueprinter/v2/extensions/core/json' - autoload :Wrapper, 'blueprinter/v2/extensions/core/wrapper' + autoload :Format, 'blueprinter/v2/extensions/core/format' + autoload :Root, 'blueprinter/v2/extensions/core/root' end end end diff --git a/lib/blueprinter/v2/extensions/core/conditionals.rb b/lib/blueprinter/v2/extensions/core/conditionals.rb deleted file mode 100644 index fdb13fd78..000000000 --- a/lib/blueprinter/v2/extensions/core/conditionals.rb +++ /dev/null @@ -1,106 +0,0 @@ -# frozen_string_literal: true - -module Blueprinter - module V2 - module Extensions - module Core - # - # A core extension that skips fields based on given options. - # - class Conditionals < Extension - def initialize - @if = {}.compare_by_identity - @unless = {}.compare_by_identity - @skip_nil = {}.compare_by_identity - @skip_empty = {}.compare_by_identity - end - - # @param ctx [Blueprinter::V2::Context::Field] - # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity,Style/MultilineTernaryOperator,Style/RedundantLineContinuation - def around_field_value(ctx) - value = yield ctx - if value.nil? && @skip_nil[ctx.field] - skip - elsif @skip_empty[ctx.field] - skip if value.nil? || (value.respond_to?(:empty?) && value.empty?) - end - - if (cond = @if[ctx.field]) - result = cond.is_a?(Proc) \ - ? ctx.blueprint.instance_exec(value, ctx, &cond) \ - : ctx.blueprint.public_send(cond, value, ctx) - skip unless result - end - if (cond = @unless[ctx.field]) - result = cond.is_a?(Proc) \ - ? ctx.blueprint.instance_exec(value, ctx, &cond) \ - : ctx.blueprint.public_send(cond, value, ctx) - skip if result - end - value - end - # rubocop:enable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity,Style/MultilineTernaryOperator,Style/RedundantLineContinuation - - alias around_object_value around_field_value - alias around_collection_value around_field_value - - # It's significantly faster to evaluate these options once and store them - # @param ctx [Blueprinter::V2::Context::Render] - def around_blueprint_init(ctx) - ref = ctx.blueprint.class.reflections[:default] - setup_fields(ctx, ref) - setup_objects(ctx, ref) - setup_collections(ctx, ref) - end - - def hidden? = true - - private - - # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity - def setup_fields(ctx, ref) - bp_class = ctx.blueprint.class - ref.fields.each_value do |field| - @if[field] = ctx.options[:field_if] || field.options[:if] || bp_class.options[:field_if] - @unless[field] = ctx.options[:field_unless] || field.options[:unless] || bp_class.options[:field_unless] - @skip_nil[field] = - ctx.options[:exclude_if_nil] || field.options[:exclude_if_nil] || bp_class.options[:exclude_if_nil] - @skip_empty[field] = - ctx.options[:exclude_if_empty] || field.options[:exclude_if_empty] || bp_class.options[:exclude_if_empty] - end - end - # rubocop:enable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity - - # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity - def setup_objects(ctx, ref) - bp_class = ctx.blueprint.class - ref.objects.each_value do |field| - @if[field] = ctx.options[:object_if] || field.options[:if] || bp_class.options[:object_if] - @unless[field] = ctx.options[:object_unless] || field.options[:unless] || bp_class.options[:object_unless] - @skip_nil[field] = - ctx.options[:exclude_if_nil] || field.options[:exclude_if_nil] || bp_class.options[:exclude_if_nil] - @skip_empty[field] = - ctx.options[:exclude_if_empty] || field.options[:exclude_if_empty] || bp_class.options[:exclude_if_empty] - end - end - # rubocop:enable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity - - # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity - def setup_collections(ctx, ref) - bp_class = ctx.blueprint.class - ref.collections.each_value do |field| - @if[field] = ctx.options[:collection_if] || field.options[:if] || bp_class.options[:collection_if] - @unless[field] = - ctx.options[:collection_unless] || field.options[:unless] || bp_class.options[:collection_unless] - @skip_nil[field] = - ctx.options[:exclude_if_nil] || field.options[:exclude_if_nil] || bp_class.options[:exclude_if_nil] - @skip_empty[field] = - ctx.options[:exclude_if_empty] || field.options[:exclude_if_empty] || bp_class.options[:exclude_if_empty] - end - end - # rubocop:enable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity - end - end - end - end -end diff --git a/lib/blueprinter/v2/extensions/core/defaults.rb b/lib/blueprinter/v2/extensions/core/defaults.rb deleted file mode 100644 index d50105de8..000000000 --- a/lib/blueprinter/v2/extensions/core/defaults.rb +++ /dev/null @@ -1,99 +0,0 @@ -# frozen_string_literal: true - -module Blueprinter - module V2 - module Extensions - module Core - # - # A core extension that applies defaults values. - # - class Defaults < Extension - def initialize = @config = {}.compare_by_identity - - # @param ctx [Blueprinter::V2::Context::Field] - def around_field_value(ctx) - value = yield ctx - config = @config[ctx.field] - default_if = config[:default_if] - return value unless value.nil? || (default_if && use_default?(default_if, value, ctx)) - - get_default(config[:default], value, ctx) - end - - # @param ctx [Blueprinter::V2::Context::Field] - def around_object_value(ctx) - value = yield ctx - config = @config[ctx.field] - default_if = config[:default_if] - return value unless value.nil? || (default_if && use_default?(default_if, value, ctx)) - - get_default(config[:default], value, ctx) - end - - # @param ctx [Blueprinter::V2::Context::Field] - def around_collection_value(ctx) - value = yield ctx - config = @config[ctx.field] - default_if = config[:default_if] - return value unless value.nil? || (default_if && use_default?(default_if, value, ctx)) - - get_default(config[:default], value, ctx) - end - - # It's significantly faster to evaluate these options once and store them in the context - # @param ctx [Blueprinter::V2::Context::Render] - def around_blueprint_init(ctx) - ref = ctx.blueprint.class.reflections[:default] - ref.fields.each_value { |field| setup_field ctx, field } - ref.objects.each_value { |object| setup_object ctx, object } - ref.collections.each_value { |collection| setup_collection ctx, collection } - end - - def hidden? = true - - private - - def setup_field(ctx, field) - bp_class = ctx.blueprint.class - config = (@config[field] ||= {}) - config[:default] = ctx.options[:field_default] || field.options[:default] || bp_class.options[:field_default] - config[:default_if] = - ctx.options[:field_default_if] || field.options[:default_if] || bp_class.options[:field_default_if] - end - - def setup_object(ctx, field) - bp_class = ctx.blueprint.class - config = (@config[field] ||= {}) - config[:default] = ctx.options[:object_default] || field.options[:default] || bp_class.options[:object_default] - config[:default_if] = - ctx.options[:object_default_if] || field.options[:default_if] || bp_class.options[:object_default_if] - end - - def setup_collection(ctx, field) - bp_class = ctx.blueprint.class - config = (@config[field] ||= {}) - config[:default] = - ctx.options[:collection_default] || field.options[:default] || bp_class.options[:collection_default] - config[:default_if] = - ctx.options[:collection_default_if] || field.options[:default_if] || bp_class.options[:collection_default_if] - end - - def get_default(default_value, value, ctx) - case default_value - when Proc then ctx.blueprint.instance_exec(value, ctx, &default_value) - when Symbol then ctx.blueprint.public_send(default_value, value, ctx) - else default_value - end - end - - def use_default?(cond, value, ctx) - case cond - when Proc then ctx.blueprint.instance_exec(value, ctx, &cond) - else ctx.blueprint.public_send(cond, value, ctx) - end - end - end - end - end - end -end diff --git a/lib/blueprinter/v2/extensions/core/json.rb b/lib/blueprinter/v2/extensions/core/format.rb similarity index 71% rename from lib/blueprinter/v2/extensions/core/json.rb rename to lib/blueprinter/v2/extensions/core/format.rb index ca6f76763..4b5e63d98 100644 --- a/lib/blueprinter/v2/extensions/core/json.rb +++ b/lib/blueprinter/v2/extensions/core/format.rb @@ -7,17 +7,17 @@ module V2 module Extensions module Core # - # A core extension for serializing results to JSON. + # A core extension that supports serializing to :json and :hash. # - class Json < Extension + class Format < Extension # @param ctx [Blueprinter::V2::Context::Result] def around_result(ctx) result = yield ctx - return result if final? result + return result if serialized? result case ctx.format when :hash then result - when :json then final ::JSON.dump result + when :json then serialized ::JSON.dump result else raise BlueprinterError, "Unrecognized serialization format `#{ctx.format.inspect}`" end end diff --git a/lib/blueprinter/v2/extensions/core/root.rb b/lib/blueprinter/v2/extensions/core/root.rb new file mode 100644 index 000000000..ffe4b264c --- /dev/null +++ b/lib/blueprinter/v2/extensions/core/root.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Blueprinter + module V2 + module Extensions + module Core + # + # A core extension for wrapping the result with metadata. + # + class Root < Extension + # @param ctx [Blueprinter::V2::Context::Result] + def around_result(ctx) + result = yield ctx + root_name = ctx.options[:root] + return result if serialized?(result) || !root_name + + root = { root_name => result } + root[:meta] = ctx.options[:meta] if ctx.options[:meta] + root + end + + def hidden? = true + end + end + end + end +end diff --git a/lib/blueprinter/v2/extensions/core/wrapper.rb b/lib/blueprinter/v2/extensions/core/wrapper.rb deleted file mode 100644 index 0e08852b5..000000000 --- a/lib/blueprinter/v2/extensions/core/wrapper.rb +++ /dev/null @@ -1,38 +0,0 @@ -# frozen_string_literal: true - -module Blueprinter - module V2 - module Extensions - module Core - # - # A core extension for wrapping the result with metadata. - # - class Wrapper < Extension - # @param ctx [Blueprinter::V2::Context::Result] - def around_result(ctx) - result = yield ctx - return result if final? result - - root_name = ctx.options[:root] || ctx.blueprint.class.options[:root] - return result if root_name.nil? || ctx.options[:root] == false - - wrap result, root_name, ctx - end - - def hidden? = true - - private - - def wrap(result, root_name, ctx) - root = { root_name => result } - if (meta = ctx.options[:meta] || ctx.blueprint.class.options[:meta]) - meta = ctx.blueprint.instance_exec(ctx, &meta) if meta.is_a? Proc - root[:meta] = meta - end - root - end - end - end - end - end -end diff --git a/lib/blueprinter/v2/extractors.rb b/lib/blueprinter/v2/extractors.rb new file mode 100644 index 000000000..7bf448fa6 --- /dev/null +++ b/lib/blueprinter/v2/extractors.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Blueprinter + module V2 + # Field value extractors. They must all conform to the same "interface". + # rubocop:disable Lint/UnusedMethodArgument + module Extractors + # Extracts field value from a Proc + module Proc + # @param field [Blueprinter::V2::Fields] The field to extract + # @param object [Object] The object to extract the field from + # @param ctx [Blueprinter::V2::Context::Field] + # @return [Object] The field value + def self.extract(field, object, ctx:) + field.value_proc.call(object, ctx) + end + end + + # Extracts field value from a Hash or object + module Property + # @param field [Blueprinter::V2::Fields] The field to extract + # @param object [Object] The object to extract the field from + # @param ctx [Blueprinter::V2::Context::Field] Unused + # @return [Object] The field value + def self.extract(field, object, ctx: nil) + if object.is_a? Hash + object[field.source] || object[field.source_str] + else + object.public_send(field.source) + end + end + end + end + # rubocop:enable Lint/UnusedMethodArgument + end +end diff --git a/lib/blueprinter/v2/field_logic.rb b/lib/blueprinter/v2/field_logic.rb new file mode 100644 index 000000000..ea8abc6d3 --- /dev/null +++ b/lib/blueprinter/v2/field_logic.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module Blueprinter + module V2 + module FieldLogic + # Returns true if the field should be skipped. Based on the return value of field-level or Blueprint-level + # "if" and "unless" options. + # + # @param ctx [Blueprinter::V2::Context::Field] + # @param field [Blueprinter::V2::Fields] Internal field definition (has extra, private, attrs) + # @return [True|False] + def self.skip?(ctx, field) + if (cond = field._merged_options[:if]) + result = cond.is_a?(Proc) ? cond.call(ctx) : ctx.blueprint.public_send(cond, ctx) + return true unless result + end + + if (cond = field._merged_options[:unless]) + result = cond.is_a?(Proc) ? cond.call(ctx) : ctx.blueprint.public_send(cond, ctx) + return true if result + end + + false + end + + # Returns the given value or a default value. Default values are pulled from the field-level or Blueprint-level + # "default" option. It will be used if the given value is nil or if the "default_if" option evaluates truthy. + # + # @param field [Blueprinter::V2::Fields::Field] + # @param value [Object] The current field value + # @param ctx [Blueprinter::V2::Context::Field | nil] + # @return [Object] The final field value + def self.value_or_default(field, value, ctx: nil) + default_if = field._merged_options[:default_if] + return value unless value.nil? || (default_if && use_default?(default_if, ctx, value)) + + case (default_value = field._merged_options[:default]) + when Proc then default_value.call(ctx) + when Symbol then ctx.blueprint.public_send(default_value, ctx) + else default_value + end + end + + # Returns true if a default value should be used. + # + # @param cond [Proc|Symbol] + # @param value [Object] The current field value + # @param ctx [Blueprinter::V2::Context::Field] + # @return [True|False] + def self.use_default?(cond, ctx, value) + case cond + when Proc then cond.call(ctx, value) + else ctx.blueprint.public_send(cond, ctx, value) + end + end + end + end +end diff --git a/lib/blueprinter/v2/field_serializers.rb b/lib/blueprinter/v2/field_serializers.rb index 655b4c0de..9982ef335 100644 --- a/lib/blueprinter/v2/field_serializers.rb +++ b/lib/blueprinter/v2/field_serializers.rb @@ -2,11 +2,54 @@ module Blueprinter module V2 + # rubocop:disable Lint/UnusedMethodArgument module FieldSerializers - autoload :Base, 'blueprinter/v2/field_serializers/base' - autoload :Collection, 'blueprinter/v2/field_serializers/collection' - autoload :Field, 'blueprinter/v2/field_serializers/field' - autoload :Object, 'blueprinter/v2/field_serializers/object' + # Serializer for V2 objects + module Object + def self.serialize(blueprint_class, value, options, parent:, instances:, store:, depth:) + blueprint_class.serializer.object(value, options, parent:, instances:, store:, depth:) + end + end + + # Serializer for V2 collections + module Collection + def self.serialize(blueprint_class, value, options, parent:, instances:, store:, depth:) + blueprint_class.serializer.collection(value, options, parent:, instances:, store:, depth:) + end + end + + # Serializer for any Blueprint in a Proc + module ProcObject + def self.serialize(blueprint_proc, value, options, parent:, instances:, store:, depth:) + blueprint_class = blueprint_proc.arity.zero? ? blueprint_proc.call : blueprint_proc.call(value) + if blueprint_class < ::Blueprinter::Base + V1Association.serialize(blueprint_class, value, options, parent:, instances:, store:, depth:) + else + Object.serialize(blueprint_class, value, options, parent:, instances:, store:, depth:) + end + end + end + + # Serializer for any Blueprint in a Proc + module ProcCollection + def self.serialize(blueprint_proc, value, options, parent:, instances:, store:, depth:) + blueprint_class = blueprint_proc.arity.zero? ? blueprint_proc.call : blueprint_proc.call(value) + if blueprint_class < ::Blueprinter::Base + V1Association.serialize(blueprint_class, value, options, parent:, instances:, store:, depth:) + else + Collection.serialize(blueprint_class, value, options, parent:, instances:, store:, depth:) + end + end + end + + # Serializer for V1 associations + module V1Association + def self.serialize(blueprint_class, value, options, parent:, instances:, store:, depth:) + opts = { v2_instances: instances, v2_depth: depth, v2_store: store } + blueprint_class.hashify(value, view_name: :default, local_options: options.dup.merge(opts)) + end + end end + # rubocop:enable Lint/UnusedMethodArgument end end diff --git a/lib/blueprinter/v2/field_serializers/base.rb b/lib/blueprinter/v2/field_serializers/base.rb deleted file mode 100644 index 2e6d9b575..000000000 --- a/lib/blueprinter/v2/field_serializers/base.rb +++ /dev/null @@ -1,35 +0,0 @@ -# frozen_string_literal: true - -module Blueprinter - module V2 - module FieldSerializers - class Base - attr_reader :field - - def initialize(field, serializer) - @field = field - @instances = serializer.instances - @hooks = serializer.hooks - @defaults = serializer.defaults - @conditionals = serializer.conditionals - @formatter = serializer.formatter - find_used_hooks! - end - - # @param ctx [Blueprinter::V2::Context::Field] - def extract(ctx) - field = ctx.field - object = ctx.object - - if field.value_proc - ctx.blueprint.instance_exec(ctx.object, ctx, &field.value_proc) - elsif object.is_a? Hash - object[field.from] || object[field.from_str] - else - object.public_send(field.from) - end - end - end - end - end -end diff --git a/lib/blueprinter/v2/field_serializers/collection.rb b/lib/blueprinter/v2/field_serializers/collection.rb deleted file mode 100644 index 808f33fab..000000000 --- a/lib/blueprinter/v2/field_serializers/collection.rb +++ /dev/null @@ -1,46 +0,0 @@ -# frozen_string_literal: true - -module Blueprinter - module V2 - module FieldSerializers - # Serializesr for collection fields - class Collection < Base - def serialize(ctx) - # perf boost from "unrolled" built-in hooks - value = @conditionals.around_collection_value(ctx) do |ctx| - @defaults.around_collection_value(ctx) do |ctx| - # perf boost by skipping `around` when no extensions use it - if @hook_around_collection_value - @hooks.around(:around_collection_value, ctx) do |ctx| - extract ctx - end - else - extract ctx - end - end - end - value.nil? ? nil : blueprint_value(value, ctx) - end - - private - - def blueprint_value(value, ctx) - field_blueprint = ctx.field.blueprint - if @instances.blueprint(field_blueprint).is_a? V2::Base - parent = Context::Parent.new(ctx.blueprint.class, ctx.field, ctx.object) - child_serializer = @instances.serializer(field_blueprint, ctx.options, ctx.store, ctx.depth + 1) - child_serializer.collection(value, parent:, depth: ctx.depth + 1) - else - opts = { v2_instances: @instances, v2_depth: ctx.depth, v2_store: ctx.store } - field_blueprint.hashify(value, view_name: :default, local_options: ctx.options.dup.merge(opts)) - end - end - - # We save a lot of time by skipping hooks that aren't used - def find_used_hooks! - @hook_around_collection_value = @hooks.registered? :around_collection_value - end - end - end - end -end diff --git a/lib/blueprinter/v2/field_serializers/field.rb b/lib/blueprinter/v2/field_serializers/field.rb deleted file mode 100644 index d7e4a7c68..000000000 --- a/lib/blueprinter/v2/field_serializers/field.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true - -module Blueprinter - module V2 - module FieldSerializers - # Serializesr for regular fields - class Field < Base - def serialize(ctx) - # perf boost from "unrolled" built-in hooks - value = @conditionals.around_field_value(ctx) do |ctx| - @defaults.around_field_value(ctx) do |ctx| - # perf boost by skipping `around` when no extensions use it - if @hook_around_field_value - @hooks.around(:around_field_value, ctx) do |ctx| - extract ctx - end - else - extract ctx - end - end - end - @formatter.call(value, ctx) - end - - private - - # We save a lot of time by skipping hooks that aren't used - def find_used_hooks! - @hook_around_field_value = @hooks.registered? :around_field_value - end - end - end - end -end diff --git a/lib/blueprinter/v2/field_serializers/object.rb b/lib/blueprinter/v2/field_serializers/object.rb deleted file mode 100644 index efe4b8679..000000000 --- a/lib/blueprinter/v2/field_serializers/object.rb +++ /dev/null @@ -1,46 +0,0 @@ -# frozen_string_literal: true - -module Blueprinter - module V2 - module FieldSerializers - # Serializesr for object fields - class Object < Base - def serialize(ctx) - # perf boost from "unrolled" built-in hooks - value = @conditionals.around_object_value(ctx) do |ctx| - @defaults.around_object_value(ctx) do |ctx| - # perf boost by skipping `around` when no extensions use it - if @hook_around_object_value - @hooks.around(:around_object_value, ctx) do |ctx| - extract ctx - end - else - extract ctx - end - end - end - value.nil? ? nil : blueprint_value(value, ctx) - end - - private - - def blueprint_value(value, ctx) - field_blueprint = ctx.field.blueprint - if @instances.blueprint(field_blueprint).is_a? V2::Base - parent = Context::Parent.new(ctx.blueprint.class, ctx.field, ctx.object) - child_serializer = @instances.serializer(field_blueprint, ctx.options, ctx.store, ctx.depth + 1) - child_serializer.object(value, parent:, depth: ctx.depth + 1) - else - opts = { v2_instances: @instances, v2_depth: ctx.depth, v2_store: ctx.store } - field_blueprint.hashify(value, view_name: :default, local_options: ctx.options.dup.merge(opts)) - end - end - - # We save a lot of time by skipping hooks that aren't used - def find_used_hooks! - @hook_around_object_value = @hooks.registered? :around_object_value - end - end - end - end -end diff --git a/lib/blueprinter/v2/fields.rb b/lib/blueprinter/v2/fields.rb index 1e611fa27..4bb9d302c 100644 --- a/lib/blueprinter/v2/fields.rb +++ b/lib/blueprinter/v2/fields.rb @@ -2,60 +2,121 @@ module Blueprinter module V2 + # Representaions of declared fields module Fields + # Common methods for Field and Configurable module Helpers - # @return [True|False] Returns true if this is a regular field + # @return [true | false] Returns true if this is a regular field def field? = type == :field - # @return [True|False] Returns true if this is an object field + # @return [true | false] Returns true if this is an object field def object? = type == :object - # @return [True|False] Returns true if this is a collection field + # @return [true | false] Returns true if this is a collection field def collection? = type == :collection + + # @return [true | false] Returns true if this is an object or collection field + def association? = object? || collection? end + # The internal representation of a field or association. Also exposed via reflections. + # + # @!attribute [r] type + # @return [:field | :object | :collection] The type of field + # @!attribute [r] name + # @return [Symbol] Name of field in result + # @!attribute [r] source + # @return [Symbol] Method name/Hash key to pull the field value from + # @!attribute [r] source_str + # @return [String] Same as `source` but a string + # @!attribute [r] options + # @return [Hash] Options defined on the field + # @!attribute [r] value_proc + # @return [Proc | nil] A proc to extract the value + # @!attribute [r] blueprint + # @return [Class | nil] Blueprint to serialize with (objects and collections only) Field = Struct.new( + :type, :name, - :from, - :from_str, - :value_proc, + :source, + :source_str, :options, + :value_proc, + :blueprint, + :_merged_options, + :_has_conditional, + :_has_default, + :_extractor, + :_serializer, keyword_init: true ) do include Helpers - # @return [Symbol] :field - def type = :field + # Returns a copy of this field that extensions can modify + # @!visibility private + def to_configurable + Configurable.new(type, name, source, options.dup, value_proc, blueprint, self) + end end - Object = Struct.new( - :name, - :blueprint, - :from, - :from_str, - :value_proc, - :options, - keyword_init: true - ) do + # Representation of a field that's modifiable inside `around_blueprint_init` hooks. + # + # @!attribute [r] type + # @return [:field | :object | :collection] The type of field + # @!attribute [rw] name + # @return [Symbol] Name of field in result + # @!attribute [rw] source + # @return [Symbol] Method name/Hash key to pull the field value from + # @!attribute [rw] options + # @return [Hash] Options defined on the field + # @!attribute [rw] value_proc + # @return [Proc | nil] A proc to extract the value + # @!attribute [r] blueprint + # @return [Class | nil] Blueprint to serialize with (objects and collections only) + Configurable = Struct.new(:type, :name, :source, :options, :value_proc, :blueprint, :_original) do include Helpers - # @return [Symbol] :object - def type = :object - end + # Remove setters from field that shouldn't be changed + static_members = %i[type blueprint _original] + dynamic_members = members - static_members + static_members.each { |member| remove_method "#{member}=" } - Collection = Struct.new( - :name, - :blueprint, - :from, - :from_str, - :value_proc, - :options, - keyword_init: true - ) do - include Helpers + # @!visibility private + def changed? + @changed || options != _original.options + end + + # @!visibility private + def to_internal + Field.new( + type:, + name: name.to_sym, + source: source.to_sym, + source_str: source == _original.source ? _original.source_str : source.to_s, + options:, + value_proc:, + blueprint: + ) + end + + # rubocop:disable Lint/UselessAccessModifier + + private + + # rubocop:enable Lint/UselessAccessModifier + + dynamic_members.each do |member| + alias_method :"set_#{member}", :"#{member}=" + end + + public - # @return [Symbol] :collection - def type = :collection + dynamic_members.each do |member| + define_method :"#{member}=" do |val| + @changed = true + send(:"set_#{member}", val) + end + end end end end diff --git a/lib/blueprinter/v2/formatter.rb b/lib/blueprinter/v2/formatter.rb index 3c5f2891d..c4568b10a 100644 --- a/lib/blueprinter/v2/formatter.rb +++ b/lib/blueprinter/v2/formatter.rb @@ -4,21 +4,24 @@ module Blueprinter module V2 # An interface for formatting values class Formatter - def initialize(blueprint) - @formatters = blueprint.formatters + # @return [Array] + def initialize(formatters) + @formatters = formatters end + def any? = @formatters.any? + + # @param blueprint [Blueprinter::V2::Base] Blueprint instance # @param value - # @param ctx [Blueprinter::V2::Context::Field] - def call(value, ctx) + def call(blueprint, value) fmt = @formatters[value.class] case fmt when nil value when Proc - ctx.blueprint.instance_exec(value, &fmt) + blueprint.instance_exec(value, &fmt) when Symbol, String - ctx.blueprint.public_send(fmt, value) + blueprint.public_send(fmt, value) end end end diff --git a/lib/blueprinter/v2/helpers.rb b/lib/blueprinter/v2/helpers.rb deleted file mode 100644 index 4fa60e92b..000000000 --- a/lib/blueprinter/v2/helpers.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -module Blueprinter - module V2 - module Helpers - def skip = throw Serializer::SIGNAL, Serializer::SIG_SKIP - end - end -end diff --git a/lib/blueprinter/v2/instance_cache.rb b/lib/blueprinter/v2/instance_cache.rb index 4617630d4..e3d00e160 100644 --- a/lib/blueprinter/v2/instance_cache.rb +++ b/lib/blueprinter/v2/instance_cache.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require 'blueprinter/view_wrapper' - module Blueprinter module V2 # @@ -10,35 +8,11 @@ module V2 # class InstanceCache def initialize - @blueprints = {}.compare_by_identity - @serializers = {}.compare_by_identity - @extensions = {}.compare_by_identity + @instances = {}.compare_by_identity end def blueprint(blueprint_class) - case blueprint_class - when ViewWrapper - blueprint_class - else - @blueprints[blueprint_class] ||= blueprint_class.new - end - end - - def serializer(blueprint_class, options, store, initial_depth) - @serializers[blueprint_class] ||= Serializer.new(blueprint_class, options, self, store:, initial_depth:) - end - - def extension(ext) - case ext - when Extension - ext - when Class - @extensions[ext] ||= ext.new - when Proc - @extensions[ext] ||= ext.call - else - raise ArgumentError, "Unsupported extension type '#{ext.class.name}'" - end + @instances[blueprint_class] ||= blueprint_class.new end end end diff --git a/lib/blueprinter/v2/reflection.rb b/lib/blueprinter/v2/reflection.rb index 8c03808a0..d753fd5cc 100644 --- a/lib/blueprinter/v2/reflection.rb +++ b/lib/blueprinter/v2/reflection.rb @@ -10,7 +10,7 @@ module Reflection # @return [Hash] # def reflections - eval! unless @evaled + eval! unless evaled? @_reflections ||= flatten_children(self, :default).freeze end @@ -18,10 +18,11 @@ def reflections # @api private def flatten_children(parent, child_name, path = []) ref_key = path.empty? ? child_name : path.join('.').to_sym - child_view = parent.views.fetch(child_name) - child_ref = View.new(child_view, ref_key) + child_view = parent[child_name] + child_view.eval! + child_ref = View.new(child_view.spec, ref_key) - child_view.views.reduce({ ref_key => child_ref }) do |acc, (name, _)| + child_view.spec.view_defs.reduce({ ref_key => child_ref }) do |acc, (name, _)| children = name == :default ? {} : flatten_children(child_view, name, path + [name]) acc.merge(children) end @@ -33,28 +34,33 @@ def flatten_children(parent, child_name, path = []) class View # @return [Symbol] Name of the view attr_reader :name + # @return [Hash] Options defined on the view or inherited from the parent + attr_reader :options # @return [Hash] Fields defined on the view + attr_reader :extensions + # @return [Array] Extensions defined on the view attr_reader :fields - # @return [Hash] Associations to single objects defined on the view + # @return [Hash] Associations to single objects defined on the view attr_reader :objects - # @return [Hash] Associations to collections defined on the view + # @return [Hash] Associations to collections defined on the view attr_reader :collections - # @return [Hash] All associations - # defined on the view + # @return [Hash] All associations defined on the view attr_reader :associations - # @return [Array] + # @return [Array] # All fields, objects, and collections in the order they were defined attr_reader :ordered # @param blueprint [Class] A subclass of Blueprinter::V2::Base # @param name [Symbol] Name of the view # @api private - def initialize(blueprint, name) + def initialize(spec, name) @name = name - @ordered = blueprint.schema.values.freeze - @fields = blueprint.schema.select { |_, f| f.field? }.freeze - @objects = blueprint.schema.select { |_, f| f.object? }.freeze - @collections = blueprint.schema.select { |_, f| f.collection? }.freeze + @options = spec.options + @extensions = spec.extensions + @ordered = spec.schema.values.freeze + @fields = ordered.select(&:field?).to_h { |f| [f.name, f] }.freeze + @objects = ordered.select(&:object?).to_h { |f| [f.name, f] }.freeze + @collections = ordered.select(&:collection?).to_h { |f| [f.name, f] }.freeze @associations = objects.merge(collections).freeze end end diff --git a/lib/blueprinter/v2/render.rb b/lib/blueprinter/v2/render.rb index 4e1213429..c40928193 100644 --- a/lib/blueprinter/v2/render.rb +++ b/lib/blueprinter/v2/render.rb @@ -9,8 +9,9 @@ class Render def initialize(object, options, blueprint:, collection:, instances:) @object = object - @options = options.dup.freeze - @blueprint = blueprint + @options = options.dup + @blueprint_class = blueprint + @serializer = blueprint.serializer @instances = instances @collection = collection @store = {} @@ -30,31 +31,38 @@ def to_json(_arg = nil) = to :json # or change the way :json and :hash behave. # @return [Object] def to(format) - serializer = @instances.serializer(@blueprint, @options, store, 1) - ctx = Context::Result.new(serializer.blueprint, serializer.fields, @options, @object, format, store) - result = serializer.hooks.around(:around_result, ctx) do |new_ctx| - if new_ctx.blueprint != serializer.blueprint - blueprint = new_ctx.blueprint.is_a?(Class) ? new_ctx.blueprint : new_ctx.blueprint.class - render = Render.new(new_ctx.object, new_ctx.options, blueprint:, collection: @collection, instances: @instances) - return render.to new_ctx.format - end + blueprint = @instances.blueprint(@blueprint_class) + result = + if @serializer.hooks.registered? :around_result + ctx = Context::Result.new(blueprint, @serializer.default_fields, @options, @object, format, store) + @serializer.hooks.around(:around_result, ctx) do |new_ctx| + if new_ctx.blueprint != blueprint + blueprint = new_ctx.blueprint.is_a?(Class) ? new_ctx.blueprint : new_ctx.blueprint.class + render = Render.new(new_ctx.object, new_ctx.options, blueprint:, collection: @collection, + instances: @instances) + return render.to new_ctx.format + end - @object = new_ctx.object - serializer.options = new_ctx.options - serialize serializer - end - result.is_a?(Context::Final) ? result.value : result + @object = new_ctx.object + @options = new_ctx.options.dup unless new_ctx.options == @options + serialize + end + else + serialize + end + result.is_a?(Context::Serialized) ? result.value : result end alias to_h to_hash private - def serialize(serializer) + def serialize + @options.freeze if @collection - serializer.collection(@object, depth: 1) + @serializer.collection(@object, @options, store:, instances: @instances) else - serializer.object(@object, depth: 1) + @serializer.object(@object, @options, store:, instances: @instances) end end end diff --git a/lib/blueprinter/v2/rendering.rb b/lib/blueprinter/v2/rendering.rb new file mode 100644 index 000000000..c640459d8 --- /dev/null +++ b/lib/blueprinter/v2/rendering.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'blueprinter/v2/instance_cache' +require 'blueprinter/v2/render' + +module Blueprinter + module V2 + # Render methods for V2 + module Rendering + def render(obj, options = {}) + if obj.is_a?(Enumerable) && !obj.is_a?(Hash) + render_collection(obj, options) + else + render_object(obj, options) + end + end + + def render_object(obj, options = {}) + instances = InstanceCache.new + Render.new(obj, options, blueprint: self, instances:, collection: false) + end + + def render_collection(objs, options = {}) + instances = InstanceCache.new + Render.new(objs, options, blueprint: self, instances:, collection: true) + end + + def render_as_hash(obj, options = {}) + render(obj, options).to_hash + end + + def render_as_json(obj, options = {}) + render(obj, options).to_hash.as_json + end + end + end +end diff --git a/lib/blueprinter/v2/serializer.rb b/lib/blueprinter/v2/serializer.rb index 2b07ac208..80470d977 100644 --- a/lib/blueprinter/v2/serializer.rb +++ b/lib/blueprinter/v2/serializer.rb @@ -1,138 +1,237 @@ # frozen_string_literal: true -require 'blueprinter/hooks' require 'blueprinter/v2/formatter' -require 'blueprinter/v2/field_serializers/field' -require 'blueprinter/v2/field_serializers/object' -require 'blueprinter/v2/field_serializers/collection' -require 'blueprinter/v2/extensions/core/defaults' -require 'blueprinter/v2/extensions/core/conditionals' -require 'blueprinter/v2/extensions/core/json' -require 'blueprinter/v2/extensions/core/wrapper' +require 'blueprinter/hooks' module Blueprinter module V2 - # - # The serializer for a given Blueprint. Takes in an object with options and serializes it to a Hash. - # - # NOTE: The instance is re-used for the duration of the render. - # + # rubocop:disable Metrics/ClassLength + # @api private class Serializer SIGNAL = :_blueprinter_signal - SIG_SKIP = :_blueprinter_skip_field_sig - - attr_reader :blueprint, :fields, :instances, :store, :formatter, :hooks, :defaults, :conditionals - attr_accessor :options - - # @param options [Hash] Options passed from the callsite - def initialize(blueprint_class, options, instances, store:, initial_depth:) - @blueprint = instances.blueprint(blueprint_class) - @options = options - @instances = instances - @formatter = Formatter.new(blueprint.class) - @hooks = Hooks.new(extensions) - @defaults = Extensions::Core::Defaults.new - @conditionals = Extensions::Core::Conditionals.new - @fields = @blueprint.class.reflections[:default].ordered - @store = store - @field_serializers = blueprint_init initial_depth + SIG_SKIP = :_blueprinter_signal_skip + Config = Struct.new(:blueprint, :fields, :options, :needs_field_ctx, keyword_init: true) + + attr_reader :hooks, :formatter + + def initialize(blueprint_class) + spec = blueprint_class.spec + @blueprint_class = blueprint_class + @formatter = Formatter.new(spec.formatters) + @format = @formatter.any? + @hooks = Hooks.new([Extensions::Core::Format.new, *spec.extensions, Extensions::Core::Root.new]) + finalize_fields! spec.schema.each_value.freeze, spec.options find_used_hooks! + @needs_field_ctx = needs_field_ctx? default_fields end - # - # Serialize a single object to a Hash. - # - # @param object [Object] The object to serialize - # @param depth [Object] Depth of this object in the serialization tree - # @param parent [Blueprinter::V2::Context::Parent] Information about the container object (if any) - # @return [Hash] The serialized object - # - def object(object, depth:, parent: nil) + def object(object, options, instances:, store:, depth: 1, parent: nil) + config = store[@blueprint_class.object_id] ||= blueprint_init(instances, options, store:, depth:) + field_ctx = config.needs_field_ctx ? build_field_ctx(config, store:, depth:) : nil + blueprint_ctx = @hook_around_blueprint ? build_object_ctx(config, parent:, store:, depth:) : nil + if @hook_around_serialize_object - ctx = Context::Object.new(@blueprint, @fields, @options, object, parent, store, depth) + ctx = build_object_ctx(config, object:, parent:, store:, depth:) @hooks.around(:around_serialize_object, ctx) do |ctx| - serialize_object(ctx.object, depth:, parent:) + config.options = ctx.options.freeze + blueprint_ctx&.options = config.options + serialize(config, ctx.object, instances:, store:, depth:, field_ctx:, ctx: blueprint_ctx) end else - serialize_object(object, depth:, parent:) + serialize(config, object, instances:, store:, depth:, field_ctx:, ctx: blueprint_ctx) end end - # - # Serialize a collection of objects to a Hash. - # - # @param collection [Enumerable] The collection to serialize - # @param depth [Object] Depth of this object in the serialization tree - # @param parent [Blueprinter::V2::Context::Parent] Information about the container object (if any) - # @return [Enumerable] The serialized hashes - # - def collection(collection, depth:, parent: nil) + def collection(objects, options, instances:, store:, depth: 1, parent: nil) + config = store[@blueprint_class.object_id] ||= blueprint_init(instances, options, store:, depth:) if @hook_around_serialize_collection - ctx = Context::Object.new(@blueprint, @fields, @options, collection, parent, store, depth) + ctx = build_object_ctx(config, object: objects, parent:, store:, depth:) @hooks.around(:around_serialize_collection, ctx) do |ctx| - ctx.object.map { |object| serialize_object(object, depth:, parent:) }.to_a + config.options = ctx.options.freeze + serialize_collection(config, ctx.object, parent:, instances:, store:, depth:) end else - collection.map { |object| serialize_object(object, depth:, parent:) }.to_a + serialize_collection(config, objects, parent:, instances:, store:, depth:) end end + def default_fields + @_default_fields ||= @blueprint_class.spec.schema.values.freeze + end + private - def serialize_object(object, depth:, parent: nil) + def serialize_collection(config, objects, parent:, instances:, store:, depth:) + ctx = @hook_around_blueprint ? build_object_ctx(config, parent:, store:, depth:) : nil + field_ctx = config.needs_field_ctx ? build_field_ctx(config, store:, depth:) : nil + + objects.map do |object| + serialize(config, object, instances:, store:, depth:, field_ctx:, ctx:) + end.to_a + end + + def build_field_ctx(config, store:, depth:) + Context::Field.new(config.blueprint, config.fields, config.options, nil, nil, store, depth) + end + + def build_object_ctx(config, store:, depth:, object: nil, parent: nil) + Context::Object.new(config.blueprint, config.fields, config.options, object, parent, store, depth) + end + + def serialize(config, object, instances:, store:, depth:, ctx: nil, field_ctx: nil) if @hook_around_blueprint - ctx = Context::Object.new(@blueprint, @fields, @options, object, parent, store, depth) + ctx.object = object @hooks.around(:around_blueprint, ctx) do |ctx| - serialize(ctx.object, depth:) + config.options = ctx.options.freeze + _serialize(config, ctx.object, instances:, store:, depth:, ctx: field_ctx) end else - serialize(object, depth:) + _serialize(config, object, instances:, store:, depth:, ctx: field_ctx) end end - def serialize(object, depth:) - ctx = Context::Field.new(@blueprint, @fields, @options, object, nil, store, depth) - @field_serializers.each_with_object({}) do |field_conf, result| - ctx.field = field_conf.field - value = catch(SIGNAL) { field_conf.serialize ctx } - result[ctx.field.name] = value unless value == SIG_SKIP - end - end + # Long and ugly for performance + # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity + def _serialize(config, object, instances:, store:, depth:, ctx: nil) + ctx&.object = object + parent = nil + # rubocop:disable Metrics/BlockLength + config.fields.each_with_object({}) do |field, result| + ctx&.field = field + next if field._has_conditional && FieldLogic.skip?(ctx, field) - def extensions - extensions = @blueprint.class.extensions.map { |ext| @instances.extension ext } - [*extensions, Extensions::Core::Json.new, Extensions::Core::Wrapper.new] - end + # extract value + if (field_hook = @field_hooks[field.type]) + value = catch SIGNAL do + @hooks.around(field_hook, ctx) do + val = field._extractor.extract(field, object, ctx:) + field._has_default ? FieldLogic.value_or_default(field, val, ctx:) : val + end + end + next if value == SIG_SKIP + else + value = field._extractor.extract(field, object, ctx:) + value = FieldLogic.value_or_default(field, value, ctx:) if field._has_default + end - # Allow extensions to do time-saving prep work on the current context - def blueprint_init(depth) - ctx = Context::Render.new(@blueprint, fields, @options, store, depth) - @conditionals.around_blueprint_init ctx - @defaults.around_blueprint_init ctx - @hooks.around(:around_blueprint_init, ctx, require_yield: true) do |ctx| - @options = ctx.options - @fields = ctx.fields + # format/serialize and set value + result[field.name] = + if value.nil? + field._merged_options[:exclude_if_nil] ? next : nil + elsif field.type == :field + @format ? @formatter.call(config.blueprint, value) : value + else + parent ||= Context::Parent.new(@blueprint_class) + parent.field = field + parent.object = object + field._serializer.serialize(field.blueprint, value, config.options, parent:, instances:, store:, + depth: depth + 1) + end end - @fields.map { |field| field_serializer field }.freeze + # rubocop:enable Metrics/BlockLength end + # rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity - def field_serializer(field) - case field.type - when :field - FieldSerializers::Field.new(field, self) - when :object - FieldSerializers::Object.new(field, self) - when :collection - FieldSerializers::Collection.new(field, self) + # Runs any `around_blueprint_init` hooks on this Blueprint and returns the configuration that should be used. + # The `around_blueprint_init` hooks may modify the blueprint's options or fields (for that render only). + # + # Only runs the FIRST time a given Blueprint is used during a given render. + # + # @param blueprint [Blueprinter::V2::Base] The Blueprint instance + # @param options [Hash] Options given to `render` + # @param store [Hash] The context store for this render + # @param depth [Integer] Current serialization depth + # @return [Blueprinter::V2::Serializer::Config] + def blueprint_init(instances, options, store:, depth:) + blueprint = instances.blueprint(@blueprint_class) + config = Config.new(blueprint:, fields: default_fields, options:, needs_field_ctx: @needs_field_ctx) + + if @hook_around_blueprint_init + fields = config.fields.map(&:to_configurable) + ctx = Context::Init.new(blueprint, fields, options, store, depth) + @hooks.around(:around_blueprint_init, ctx, require_yield: true) do |ctx| + changed = ctx.blueprint.options != @blueprint_class.spec.options + config.blueprint.options.freeze + config.fields = ctx.fields.map do |f| + changed ||= f.changed? + changed ? f.to_internal : f._original + end.freeze + + if changed + finalize_fields! config.fields, config.blueprint.options + config.needs_field_ctx = needs_field_ctx? config.fields + end + end end + + config.options.freeze + config end - # We save a lot of time by skipping hooks that aren't used + # Save time by skipping hooks that aren't used def find_used_hooks! @hook_around_serialize_object = @hooks.registered? :around_serialize_object @hook_around_serialize_collection = @hooks.registered? :around_serialize_collection @hook_around_blueprint = @hooks.registered? :around_blueprint + @hook_around_blueprint_init = @hooks.registered? :around_blueprint_init + @field_hooks = { + field: @hooks.registered?(:around_field_value) ? :around_field_value : nil, + object: @hooks.registered?(:around_object_value) ? :around_object_value : nil, + collection: @hooks.registered?(:around_collection_value) ? :around_collection_value : nil + }.freeze + end + + # Skip Context::Field allocation when no field hooks, conditionals, callable defaults, or Proc extractors are in play + # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + def needs_field_ctx?(fields) + @field_hooks.values.any? || fields.any? do |f| + default = f._merged_options[:default] + f._has_conditional || !!f._merged_options[:default_if] || default.is_a?(Proc) || default.is_a?(Symbol) || + (!!f.value_proc && f.value_proc.arity != 0 && f.value_proc.arity != 1) + end + end + # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + + # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + def finalize_fields!(fields, blueprint_opts) + fields.each do |field| + next if field.frozen? + + # copy blueprint options down to each field (faster b/c we can check exactly one Hash) + field._merged_options = field.options.dup + field._merged_options[:if] ||= blueprint_opts[:if] if blueprint_opts.key? :if + field._merged_options[:unless] ||= blueprint_opts[:unless] if blueprint_opts.key? :unless + field._merged_options[:default_if] ||= blueprint_opts[:default_if] if blueprint_opts.key? :default_if + field._merged_options[:default] = blueprint_opts[:default] if blueprint_opts.key?(:default) && + !field.options.key?(:default) + field._merged_options[:exclude_if_nil] = blueprint_opts[:exclude_if_nil] if blueprint_opts.key?(:exclude_if_nil) && + !field.options.key?(:exclude_if_nil) + + # precompute some checks + field._extractor = field.value_proc ? Extractors::Proc : Extractors::Property + field._has_conditional = field._merged_options.key?(:if) || field._merged_options.key?(:unless) + field._has_default = field._merged_options.key?(:default) + field._serializer = field_serializer(field) if field.association? + + # freeze everything + field.options.freeze + field._merged_options.freeze + field.source_str.freeze + field.freeze + end + end + # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + + def field_serializer(field) + if field.blueprint.is_a? Proc + field.collection? ? FieldSerializers::ProcCollection : FieldSerializers::ProcObject + elsif field.blueprint < ::Blueprinter::Base + FieldSerializers::V1Association + else + field.collection? ? FieldSerializers::Collection : FieldSerializers::Object + end end end + # rubocop:enable Metrics/ClassLength end end diff --git a/lib/blueprinter/v2/specification.rb b/lib/blueprinter/v2/specification.rb new file mode 100644 index 000000000..c7e45f1fa --- /dev/null +++ b/lib/blueprinter/v2/specification.rb @@ -0,0 +1,160 @@ +# frozen_string_literal: true + +require 'set' + +module Blueprinter + module V2 + # Evaluates a Blueprint's AST nodes + # @!visibility private + class Specification + Spec = Struct.new(:nodes, :options, :extensions, :formatters, :schema, :view_defs, keyword_init: true) + Exclusions = Struct.new(:field_names, :fields, :options, :extensions, :formatters, keyword_init: true) + + def initialize(blueprint) + self.parent = blueprint.superclass.spec + self.blueprint = blueprint + eval_view! if view? + self.nodes = inherit(DSL::Nodes::Partial) + blueprint.nodes + self.nodes = expand_partials + nodes.unshift(*exclude(inherit(Fields::Field), parent_exclusions)) + nodes.unshift(*inherit(DSL::Nodes::View)) if blueprint? + nodes.freeze + end + + # Returns the full specification for a Blueprint/view + def generate + Spec.new( + nodes:, + options: options.freeze, + extensions: extensions.freeze, + formatters: formatters.freeze, + schema: schema.freeze, + view_defs: view_defs.freeze + ).freeze + end + + private + + attr_accessor :blueprint, :parent, :nodes + + # Returns the declared options for this blueprint/view + def options + initial_val = flag?(:exclude_options) ? {} : parent.options.dup + nodes.each_with_object(initial_val) do |node, acc| + case node + when DSL::Nodes::SetOpt + acc[node.key] = node.val + when DSL::Nodes::SetDynamicOpt + acc[node.key] = node.block.call(acc[node.key]) + when DSL::Nodes::UnsetOpt + acc.delete node.key + end + end + end + + # Returns the declared extensions for this blueprint/view + # rubocop:disable Metrics/CyclomaticComplexity + def extensions + initial_val = flag?(:exclude_extensions) ? [] : parent.extensions.dup + nodes.each_with_object(initial_val) do |node, acc| + case node + when DSL::Nodes::AppendExt + acc.push(node.ext) + when DSL::Nodes::PrependExt + acc.unshift(node.ext) + when DSL::Nodes::RemExt + acc.reject! { |ext| ext.is_a? node.klass } + when DSL::Nodes::RemDynamicExt + acc.reject!(&node.block) + end + end + end + # rubocop:enable Metrics/CyclomaticComplexity + + # Returns the declared formatters for this blueprint/view + def formatters + initial_val = flag?(:exclude_formatters) ? {} : parent.formatters.dup + local_formatters = nodes.grep(DSL::Nodes::Format).to_h { |n| [n.klass, n.fmt] } + initial_val.merge(local_formatters) + end + + # Returns the declared fields and associations for this blueprint/view + def schema + nodes.grep(Fields::Field).to_h { |n| [n.name, n] } + end + + # Returns a Hash of view definition blocks keyed by name + def view_defs + nodes.grep(DSL::Nodes::View).each_with_object({}) do |node, acc| + acc[node.name] ||= [] + acc[node.name] << node.block if node.block + end + end + + # Return nodes, replacing any `use` nodes with the partial's nodes + def expand_partials(partials = self.partials) + nodes.each_with_object([]) do |node, acc| + # Leave other node types as-is + unless node.is_a? DSL::Nodes::Use + acc << node + next + end + + # Eval the partial, temporarily leaving `blueprint.nodes` and `self.nodes` holding only the partial's nodes + p = partials[node.name] || + raise(Errors::UnknownPartial, "No '#{node.name}' partial in Blueprint '#{blueprint}' (#{node.callsite})") + blueprint.nodes = [] + blueprint.class_eval(&p) + self.nodes = exclude(blueprint.nodes, node.exclusions) + + # Call `expand_partials` again on the partial's nodes, in case the partial used partials. Then append to all nodes. + partials = partials.merge self.partials + acc.concat(expand_partials(partials)) + end + end + + # Return nodes with certain (or all) field nodes excluded + def exclude(nodes, exclusions) + nodes.reject do |n| + case n + when Fields::Field + exclusions.fields || exclusions.field_names.include?(n.name) + when DSL::Nodes::SetOpt, DSL::Nodes::SetDynamicOpt + exclusions.options + when DSL::Nodes::AppendExt, DSL::Nodes::PrependExt + exclusions.extensions + when DSL::Nodes::Format + exclusions.formatters + else + false + end + end + end + + # Things that should be excluded from the parent + def parent_exclusions + Exclusions.new( + field_names: Set.new(nodes.grep(DSL::Nodes::Exclude).map(&:name)), + fields: flag?(:exclude_fields), + options: flag?(:exclude_options), + extensions: flag?(:exclude_extensions), + formatters: flag?(:exclude_formatters) + ) + end + + # Grab and run this view's block(s) from the parent + def eval_view! + blocks = parent.view_defs[blueprint.view_name] || + raise(Errors::UnknownView, "View '#{blueprint.view_name}' not found in Blueprint '#{blueprint.superclass}'") + + blocks.each { |b| blueprint.class_eval(&b) } + end + + def view? = !blueprint? + def blueprint? = blueprint.view_name == :default + def flag?(name) = nodes.grep(DSL::Nodes::Flag).any? { |n| n.name == name } + def partials = nodes.grep(DSL::Nodes::Partial).to_h { |n| [n.name, n.block] } + def inherit(node_type) = parent.nodes.grep(node_type) + end + end +end diff --git a/lib/blueprinter/v2/view_builder.rb b/lib/blueprinter/v2/view_builder.rb deleted file mode 100644 index d3d191578..000000000 --- a/lib/blueprinter/v2/view_builder.rb +++ /dev/null @@ -1,101 +0,0 @@ -# frozen_string_literal: true - -module Blueprinter - module V2 - # - # A Hash-like class that holds a Blueprint's views, but defers evaluation of their - # definitions until they're first accessed. - # - # This allows views to trivially inherit parent fields, etc even when they're defined AFTER the view. - # - class ViewBuilder - include Enumerable - - Def = Struct.new(:definition, :empty, keyword_init: true) - - # @param parent [Class] A subclass of Blueprinter::V2::Base - def initialize(parent) - @parent = parent - @mut = Mutex.new - reset - end - - # - # Add a view definition. - # - # @param name [Symbol] - # @param definition [Blueprinter::V2::ViewBuilder::Def] - # - def []=(name, definition) - name = name.to_sym - raise Errors::InvalidBlueprint, 'You may not redefine the default view' if name == :default - - @pending[name] ||= [] - @pending[name] << definition - end - - # - # Return, and build if necessary, the view. - # - # @param name [Symbol] Name of the view - # @return [Class] An anonymous subclass of @parent - # - def [](name) - name = name.to_sym - if !@views.key?(name) && @pending.key?(name) - @mut.synchronize do - next if @views.key?(name) - - view = Class.new(@parent) - @views[name] = view - build_view name - view.eval!(lock: false) - end - end - @views[name] - end - - # Works like Hash#fetch - def fetch(name) - self[name] || raise(KeyError, "View '#{name}' not found") - end - - # Yield each name and view - def each(&block) - enum = Enumerator.new do |y| - y.yield(:default, self[:default]) - @pending.each_key { |name| y.yield(name, self[name]) } - end - block ? enum.each(&block) : enum - end - - # Create a duplicate of this builder with a different default view - def dup_for(blueprint) - builder = self.class.new(blueprint) - @pending.each do |name, defs| - defs.each { |d| builder[name] = d } - end - builder - end - - # Clear everything but the default view - def reset - @views = { default: @parent } - @pending = {} - end - - private - - def build_view(name) - defs = @pending[name] - empty = defs.reduce(false) { |acc, d| d.empty.nil? ? acc : d.empty } - - view = @views.fetch name - view.views.reset - view.append_name(name) - view.schema.clear if empty - defs.each { |d| view.class_eval(&d.definition) if d.definition } - end - end - end -end diff --git a/lib/blueprinter/view_wrapper.rb b/lib/blueprinter/view_wrapper.rb deleted file mode 100644 index d0b567be4..000000000 --- a/lib/blueprinter/view_wrapper.rb +++ /dev/null @@ -1,35 +0,0 @@ -# frozen_string_literal: true - -module Blueprinter - # Wraps a V1 Blueprint and view into a single object with a Blueprint interface. Used by V2 interop. - class ViewWrapper - attr_reader :blueprint, :view_name - - def initialize(blueprint, view_name) - @blueprint = blueprint - @view_name = view_name - end - - # rubocop:disable Lint/UnusedMethodArgument - def hashify(object, view_name:, local_options:) - blueprint.hashify(object, view_name: @view_name, local_options:) - end - # rubocop:enable Lint/UnusedMethodArgument - - def render(object, options = {}) - blueprint.render(object, { view: view_name }.merge(options)) - end - - def render_as_hash(object, options = {}) - blueprint.render_as_hash(object, { view: view_name }.merge(options)) - end - - def render_as_json(object, options = {}) - blueprint.render_as_json(object, { view: view_name }.merge(options)) - end - - def reflections - blueprint.reflections - end - end -end diff --git a/spec/benchmarks/speedtest.rb b/spec/benchmarks/speedtest.rb index 28d49b295..71410de9b 100644 --- a/spec/benchmarks/speedtest.rb +++ b/spec/benchmarks/speedtest.rb @@ -3,7 +3,7 @@ require 'benchmark' require 'blueprinter' -NUM_FIELDS = 10 +NUM_FIELDS = 20 NUM_OBJECTS = 5 NUM_COLLECTIONS = 2 @@ -40,6 +40,7 @@ class WidgetBlueprintV2 < ApplicationBlueprintV2 puts "#{NUM_FIELDS} fields, #{NUM_OBJECTS} objects, #{NUM_COLLECTIONS} collections" +M = 100 results = Benchmark.bmbm do |x| widgets = 100_000.times.map do |n| {}.merge( @@ -49,24 +50,17 @@ class WidgetBlueprintV2 < ApplicationBlueprintV2 ) end - [ - [10_000, 10], - [1000, 100], - [500, 100], - [250, 100], - [100, 250], - [25, 500], - [5, 1000], - [1, 1000], - ].each do |(n, m)| + [1000, 500, 250, 100, 25, 5, 1].each do |n| fmt_n = n.to_s.chars.reverse.each_slice(3).map(&:join).join(',').reverse list = widgets[0,n] - x.report "#{fmt_n} widgets #{m}x: V1" do - m.times { WidgetBlueprintV1.render_as_hash(list) } + x.report "#{fmt_n} widgets #{M}x: V1" do + M.times { WidgetBlueprintV1.render_as_hash(list) } + # M.times { list.each { |w| WidgetBlueprintV1.render_as_hash(w) } } end - x.report "#{fmt_n} widgets #{m}x: V2" do - m.times { WidgetBlueprintV2.render(list).to_hash } + x.report "#{fmt_n} widgets #{M}x: V2" do + M.times { WidgetBlueprintV2.render(list).to_hash } + # M.times { list.each { |w| WidgetBlueprintV2.render(w).to_hash } } end end end @@ -78,13 +72,9 @@ class WidgetBlueprintV2 < ApplicationBlueprintV2 v1 = (a.label =~ /V1/ ? a : b).real v2 = (a.label =~ /V2/ ? a : b).real - if v2 < v1 - n = (100 - (v2 / v1) * 100).round(2) - pcnt = ('%0.2f' % n).rjust(5, '0') - puts "#{label} V2 #{pcnt}% faster (#{'%.4f' % (v1 - v2)} sec)" - else - n = (100 - (v1 / v2) * 100).round(2) - pcnt = ('%0.2f' % n).rjust(5, '0') - puts "#{label} V2 #{pcnt}% slower (#{'%.4f' % (v2 - v1)} sec)" - end + n = ((v2 - v1) / v1) * 100 + pct = ('%0.2f' % n.abs).rjust(5, '0') + sign = n < 0 ? "-" : "+" + + puts "#{label} V2 change: #{sign}#{pct}%" end diff --git a/spec/extensions/field_order_spec.rb b/spec/extensions/field_order_spec.rb index da7404172..d63c79abd 100644 --- a/spec/extensions/field_order_spec.rb +++ b/spec/extensions/field_order_spec.rb @@ -1,9 +1,6 @@ # frozen_string_literal: true describe Blueprinter::Extensions::FieldOrder do - let(:instances) { Blueprinter::V2::InstanceCache.new } - let(:serializer) { Blueprinter::V2::Serializer.new(blueprint, {}, instances, store: {}, initial_depth: 1) } - let(:context) { Blueprinter::V2::Context::Render.new(serializer.blueprint, serializer.fields, {}, 1) } let(:blueprint) do Class.new(Blueprinter::V2::Base) do field :foo @@ -13,13 +10,13 @@ end it 'sorts fields alphabetically' do - ext = described_class.new { |a, b| a.name <=> b.name } - ctx = ext.around_blueprint_init(context) { |ctx| ctx } - expect(ctx.fields.map(&:name)).to eq %i(bar foo id) + blueprint.add described_class.new { |a, b| a.name <=> b.name } + result = blueprint.render({}).to_json + expect(result).to eq '{"bar":null,"foo":null,"id":null}' end it 'sorts fields alphabetically with id first' do - ext = described_class.new do |a, b| + blueprint.add described_class.new { |a, b| if a.name == :id -1 elsif b.name == :id @@ -27,8 +24,8 @@ else a.name <=> b.name end - end - ctx = ext.around_blueprint_init(context) { |ctx| ctx } - expect(ctx.fields.map(&:name)).to eq %i(id bar foo) + } + result = blueprint.render({}).to_json + expect(result).to eq '{"id":null,"bar":null,"foo":null}' end end diff --git a/spec/extensions/hooks_spec.rb b/spec/extensions/hooks_spec.rb index 30e221521..945d89c9d 100644 --- a/spec/extensions/hooks_spec.rb +++ b/spec/extensions/hooks_spec.rb @@ -8,13 +8,11 @@ fields :name, :description end end - let(:serializer) { Blueprinter::V2::Serializer.new(blueprint, {}, instances, store: {}, initial_depth: 1) } - let(:field) { Blueprinter::V2::Fields::Field.new(name: :foo, from: :foo) } + let(:serializer) { blueprint.serializer } + let(:field) { Blueprinter::V2::Fields::Field.new(name: :foo, source: :foo) } let(:object) { { foo: 'Foo' } } - let(:render_ctx) { Blueprinter::V2::Context::Render } let(:object_ctx) { Blueprinter::V2::Context::Object } let(:field_ctx) { Blueprinter::V2::Context::Field } - let(:result_ctx) { Blueprinter::V2::Context::Result } let(:instances) { Blueprinter::V2::InstanceCache.new } let(:ext1) do Class.new(Blueprinter::Extension) do @@ -114,7 +112,7 @@ def around_serialize_object(ctx) it 'runs nested hooks' do log = [] extensions = [ext_a.new(log), ext_b.new(log), ext_c.new(log)] - ctx = object_ctx.new(serializer.blueprint, serializer.fields, {}, { n: 0 }) + ctx = object_ctx.new(blueprint.new, serializer.default_fields, {}, { n: 0 }) hooks = described_class.new extensions res = hooks.around(:around_serialize_object, ctx) do |ctx| log << 'INNER' @@ -127,7 +125,7 @@ def around_serialize_object(ctx) it 'runs with no hooks' do log = [] extensions = [] - ctx = object_ctx.new(serializer.blueprint, serializer.fields, {}, { n: 0 }) + ctx = object_ctx.new(blueprint.new, serializer.default_fields, {}, { n: 0 }) hooks = described_class.new extensions res = hooks.around(:around_serialize_object, ctx) do |ctx| log << 'INNER' @@ -140,7 +138,7 @@ def around_serialize_object(ctx) it 'returns early when not yielding' do log = [] extensions = [ext_a.new(log), ext_b.new(log), cache_ext.new(log), ext_c.new(log)] - ctx = object_ctx.new(serializer.blueprint, serializer.fields, {}, { n: 0 }) + ctx = object_ctx.new(blueprint.new, serializer.default_fields, {}, { n: 0 }) hooks = described_class.new extensions res = hooks.around(:around_serialize_object, ctx) do |ctx| log << 'INNER' @@ -153,10 +151,10 @@ def around_serialize_object(ctx) it 'bypasses parent hooks with a skip in a nested hook' do log = [] ext = Class.new(Blueprinter::Extension) do - def around_serialize_object(_ctx) = skip + def around_serialize_object(_ctx) = skip! end extensions = [ext_a.new(log), ext_b.new(log), ext.new, ext_c.new(log)] - ctx = object_ctx.new(serializer.blueprint, serializer.fields, {}, { n: 0 }) + ctx = object_ctx.new(blueprint.new, serializer.default_fields, {}, { n: 0 }) hooks = described_class.new extensions res = catch Blueprinter::V2::Serializer::SIGNAL do hooks.around(:around_serialize_object, ctx) do |ctx| @@ -170,7 +168,7 @@ def around_serialize_object(_ctx) = skip it 'bypasses parent hooks with a skip in inner block' do log = [] extensions = [ext_a.new(log), ext_b.new(log), ext_c.new(log)] - ctx = object_ctx.new(serializer.blueprint, serializer.fields, {}, { n: 0 }) + ctx = object_ctx.new(blueprint.new, serializer.default_fields, {}, { n: 0 }) hooks = described_class.new extensions res = catch Blueprinter::V2::Serializer::SIGNAL do hooks.around(:around_serialize_object, ctx) do |ctx| @@ -187,7 +185,7 @@ def around_serialize_object(_ctx) = skip def around_serialize_object(_ctx) = yield "oops" end - ctx = object_ctx.new(serializer.blueprint, serializer.fields, {}, { n: 0 }) + ctx = object_ctx.new(blueprint.new, serializer.default_fields, {}, { n: 0 }) hooks = described_class.new [ext.new] expect do hooks.around(:around_serialize_object, ctx, &:object) @@ -219,7 +217,7 @@ def around_hook(ctx) log = [] hooks = described_class.new [ext1.new(log)] - ctx = object_ctx.new(serializer.blueprint, serializer.fields, {}, object) + ctx = object_ctx.new(blueprint.new, serializer.default_fields, {}, object) res = hooks.around(:around_serialize_object, ctx) do |ctx| log << 'INNER' ctx.object @@ -238,7 +236,7 @@ def around_hook(ctx) it 'is skipped for hidden extensions' do ext1.class_eval { def hidden? = true } log = [] - ctx = field_ctx.new(serializer.blueprint, serializer.fields, {}, { foo: 'Foo' }, field, 42) + ctx = field_ctx.new(blueprint.new, serializer.default_fields, {}, { foo: 'Foo' }, field, 42) hooks = described_class.new [ext1.new(log)] res = hooks.around(:around_serialize_object, ctx) do |ctx| @@ -258,7 +256,7 @@ def around_hook(ctx) def around_hook(ctx) = nil end log = [] - ctx = field_ctx.new(serializer.blueprint, serializer.fields, {}, { foo: 'Foo' }, field, 42) + ctx = field_ctx.new(blueprint.new, serializer.default_fields, {}, {}, { foo: 'Foo' }, field, 42) hooks = described_class.new [ext1.new(log), ext.new] expect do diff --git a/spec/extensions/legacy_conditionals_spec.rb b/spec/extensions/legacy_conditionals_spec.rb new file mode 100644 index 000000000..787020eb1 --- /dev/null +++ b/spec/extensions/legacy_conditionals_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +describe Blueprinter::Extensions::LegacyConditionals do + subject { described_class.new } + let(:blueprint) do + Class.new(Blueprinter::V2::Base) do + field :name + # All conditionals are designed to hide their fields + field :desc_if, if: ->(_ctx) { false } + field :desc_unless, unless: ->(_ctx) { true } + field :legacy_desc_if, if: ->(field, object, _options) do + field != :legacy_desc_if || object[:name] != 'Foo' + end + field :legacy_desc_unless, unless: ->(field, object, _options) do + field == :legacy_desc_unless && object[:name] == 'Foo' + end + end + end + + it 'respects both V1 and V2 conditionals' do + blueprint.add subject + result = blueprint.render({ name: 'Foo' }).to_hash + expect(result).to eq({ name: 'Foo' }) + end +end diff --git a/spec/extensions/legacy_default_if_spec.rb b/spec/extensions/legacy_default_if_spec.rb new file mode 100644 index 000000000..83afd8c62 --- /dev/null +++ b/spec/extensions/legacy_default_if_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'blueprinter/empty_types' + +describe Blueprinter::Extensions::LegacyDefaultIf do + subject { described_class.new } + let(:blueprint) do + Class.new(Blueprinter::V2::Base) do + field :name, default: "No name", default_if: ->(_ctx, val) { val.empty? } + field :desc, default: "No desc", default_if: Blueprinter::EMPTY_STRING + end + end + + it 'respects both V1 and V2 options' do + blueprint.add subject + result = blueprint.render({ name: '', desc: '' }).to_hash + expect(result).to eq({ name: 'No name', desc: 'No desc' }) + end +end diff --git a/spec/extensions/legacy_dynamic_options_spec.rb b/spec/extensions/legacy_dynamic_options_spec.rb new file mode 100644 index 000000000..9c7fa4daa --- /dev/null +++ b/spec/extensions/legacy_dynamic_options_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +describe Blueprinter::Extensions::LegacyDynamicOptions do + subject { described_class.new } + let(:category_blueprint) do + Class.new(Blueprinter::V2::Base) do + field :name + field(:val) { |_, ctx| ctx.options[:dynamic_val] } + end + end + + it 'applies a Hash to the options' do + test = self + blueprint = Class.new(Blueprinter::V2::Base) do + add test.subject + association :category, test.category_blueprint, options: { dynamic_val: 42 } + end + + result = blueprint.render({ category: { name: 'Foo' } }).to_hash + expect(result).to eq({ category: { name: 'Foo', val: 42 } }) + end + + it 'applies a Proc to the options' do + test = self + blueprint = Class.new(Blueprinter::V2::Base) do + add test.subject + association :category, test.category_blueprint, options: ->(widget) { { dynamic_val: widget.fetch(:id) + 1 } } + end + result = blueprint.render({ id: 41, category: { name: 'Foo' } }).to_hash + expect(result).to eq({ category: { name: 'Foo', val: 42 } }) + end + + it 'applies a Proc to the options (collections)' do + test = self + blueprint = Class.new(Blueprinter::V2::Base) do + add test.subject + association :categories, [test.category_blueprint], options: ->(widget) { { dynamic_val: widget.fetch(:id) + 1 } } + end + result = blueprint.render({ id: 41, categories: [{ name: 'Foo' }] }).to_hash + expect(result).to eq({ categories: [{ name: 'Foo', val: 42 }] }) + end +end diff --git a/spec/extensions/legacy_extractor_option_spec.rb b/spec/extensions/legacy_extractor_option_spec.rb new file mode 100644 index 000000000..062b73cae --- /dev/null +++ b/spec/extensions/legacy_extractor_option_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require 'blueprinter/empty_types' + +describe Blueprinter::Extensions::LegacyExtractorOption do + subject { described_class.new } + let(:extractor) do + Class.new do + def extract(field_name, object, local_options, options) + "Extracted: #{object[field_name]}" + end + end + end + + let(:extractor2) do + Class.new do + def extract(field_name, object, local_options, options) + "Xtracted: #{object[field_name]}" + end + end + end + + let(:blueprint) do + extractor = self.extractor + extractor2 = self.extractor2 + Class.new(Blueprinter::V2::Base) do + field :name + field :desc, extractor: extractor + + view :with_extractor do + set :extractor, extractor + field :foo, extractor: extractor2 + end + end + end + + it 'respects the field option' do + blueprint.add subject + result = blueprint.render({ name: 'foo', desc: 'bar' }).to_hash + expect(result).to eq({ name: 'foo', desc: 'Extracted: bar' }) + end + + it 'respects the blueprint option' do + blueprint.add subject + result = blueprint[:with_extractor].render({ name: 'foo', desc: 'bar', foo: 'asdf' }).to_hash + expect(result).to eq({ + name: 'Extracted: foo', + desc: 'Extracted: bar', + foo: 'Xtracted: asdf' + }) + end +end diff --git a/spec/extensions/legacy_rename_field_spec.rb b/spec/extensions/legacy_rename_field_spec.rb new file mode 100644 index 000000000..4211f17c8 --- /dev/null +++ b/spec/extensions/legacy_rename_field_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +describe Blueprinter::Extensions::LegacyRenameField do + subject { described_class.new } + let(:blueprint) do + Class.new(Blueprinter::V2::Base) do + field :name + field :desc_v2, source: :description_v2 + field :description_v1, name: :desc_v1 + end + end + + it "respects V1's name option" do + blueprint.add subject + result = blueprint.render({ + name: 'Foo', + description_v2: 'Desc V2', + description_v1: 'Desc V1' + }).to_hash + + expect(result).to eq({ + name: 'Foo', + desc_v2: 'Desc V2', + desc_v1: 'Desc V1' + }) + end +end diff --git a/spec/extensions/legacy_transformer_spec.rb b/spec/extensions/legacy_transformer_spec.rb new file mode 100644 index 000000000..6aa4cd566 --- /dev/null +++ b/spec/extensions/legacy_transformer_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +describe Blueprinter::Extensions::LegacyTransformer do + let(:blueprint) do + Class.new(Blueprinter::V2::Base) do + fields :id, :name + end + end + + let(:transformer1) do + Class.new do + def transform(hash, object, options) + hash.transform_values!(&:to_s) + end + end + end + + let(:transformer2) do + Class.new do + def transform(hash, object, options) + hash.transform_values!(&:downcase) + end + end + end + + it 'applies mutliple transformers' do + blueprint.add described_class.new(transformer1, transformer2) + result = blueprint.render({ id: 42, name: 'Foo' }).to_hash + expect(result).to eq({ id: '42', name: 'foo' }) + end +end diff --git a/spec/extensions/multi_json_spec.rb b/spec/extensions/multi_json_spec.rb index 17a566ca4..4dd684b2a 100644 --- a/spec/extensions/multi_json_spec.rb +++ b/spec/extensions/multi_json_spec.rb @@ -18,7 +18,7 @@ it 'renders JSON from a blueprint' do mj_blueprint = Class.new(blueprint) do - extensions << Blueprinter::Extensions::MultiJson.new + add Blueprinter::Extensions::MultiJson.new end widget = { id: 42, name: 'Foo', junk: true } @@ -28,7 +28,7 @@ it 'passes global options to MultiJson.dump' do mj_blueprint = Class.new(blueprint) do - extensions << Blueprinter::Extensions::MultiJson.new({ pretty: true }) + add Blueprinter::Extensions::MultiJson.new({ pretty: true }) end widget = { id: 42, name: 'Foo', junk: true } @@ -38,7 +38,7 @@ it 'passes local options to MultiJson.dump' do mj_blueprint = Class.new(blueprint) do - extensions << Blueprinter::Extensions::MultiJson.new({ pretty: true }) + add Blueprinter::Extensions::MultiJson.new({ pretty: true }) end widget = { id: 42, name: 'Foo', junk: true } diff --git a/spec/extensions/open_telemetry_spec.rb b/spec/extensions/open_telemetry_spec.rb index bac2839af..f7738a770 100644 --- a/spec/extensions/open_telemetry_spec.rb +++ b/spec/extensions/open_telemetry_spec.rb @@ -56,8 +56,8 @@ def around_hook(ctx) it 'fires during render' do log = [] meta_ext = meta_extension.new(log) - blueprint.extensions << subject << meta_ext - sub_blueprint.extensions << subject << meta_ext + blueprint.add subject, meta_ext + sub_blueprint.add subject, meta_ext attributes = { 'library.name' => 'Blueprinter', 'library.version' => Blueprinter::VERSION } object = { foo: 'Foo', foo_obj: { name: 'Bar1' }, foos: [{ name: 'Bar2' }] } expect_any_instance_of(OpenTelemetry::Internal::ProxyTracer). diff --git a/spec/extensions/view_option_spec.rb b/spec/extensions/view_option_spec.rb index 66c94a221..c9b3de568 100644 --- a/spec/extensions/view_option_spec.rb +++ b/spec/extensions/view_option_spec.rb @@ -1,10 +1,7 @@ # frozen_string_literal: true describe Blueprinter::Extensions::ViewOption do - let(:instances) { Blueprinter::V2::InstanceCache.new } - let(:serializer) { Blueprinter::V2::Serializer.new(blueprint, {}, instances, store: {}, initial_depth: 1) } - let(:context) { Blueprinter::V2::Context::Result } - let(:object) { { id: 42, foo: 'Foo', bar: 'Bar' } } + subject { described_class.new } let(:blueprint) do Class.new(Blueprinter::V2::Base) do view :foo do @@ -17,14 +14,14 @@ end it 'does nothing by default' do - ctx = context.new(serializer.blueprint, [], {}, :json) - new_ctx = described_class.new.around_result(ctx) { |ctx| ctx } - expect(new_ctx.blueprint.class).to be serializer.blueprint.class + blueprint.add subject + result = blueprint.render({}).to_hash + expect(result).to eq({}) end it 'finds a nested view' do - ctx = context.new(serializer.blueprint, [], { view: 'foo.bar' }, :json) - new_ctx = described_class.new.around_result(ctx) { |ctx| ctx } - expect(new_ctx.blueprint.class).to be blueprint['foo.bar'] + blueprint.add subject + result = blueprint.render({}, view: 'foo.bar').to_hash + expect(result).to eq({ foo: nil, bar: nil }) end end diff --git a/spec/integrations/v1_v2_compat_spec.rb b/spec/integrations/v1_v2_compat_spec.rb index d5b6d0dfe..ce707f466 100644 --- a/spec/integrations/v1_v2_compat_spec.rb +++ b/spec/integrations/v1_v2_compat_spec.rb @@ -178,7 +178,7 @@ blueprints = {} blueprints[:barprint] = Class.new(Blueprinter::V2::Base) do bp = blueprints - options[:exclude_if_nil] = true + set :exclude_if_nil, true field :name do |obj, ctx| "#{obj[:name]} - #{ctx.options[:tag]}" end @@ -201,23 +201,21 @@ let(:instances) do Blueprinter::V2::InstanceCache.new.tap do |instances| - def instances.serializers = @serializers - def instances.blueprints = @blueprints + def instances.blueprints = @instances end end - it 'should use the same V2 Serializer and Blueprint instances through V1' do + it 'should use the same V2 Blueprint instances through V1' do barprint = blueprints[:barprint][:extended] - bar_serializer = instances.serializer(barprint, { tag: 'X' }, {}, 1) - - res = bar_serializer.object({ + res = barprint.serializer.object({ name: 'Bar 1', foo: { name: 'Foo 1', bar1: { name: 'Bar 2' }, bar2: { name: 'Bar 2' } } - }, depth: 1) + }, { tag: 'X' }, instances:, store: {}, depth: 1) + expect(res).to eq({ name: 'Bar 1 - X', foo: { @@ -226,14 +224,11 @@ def instances.blueprints = @blueprints bar2: { name: 'Bar 2 - X' } } }) - - expect(instances.serializers[barprint]).to be_a Blueprinter::V2::Serializer expect(instances.blueprints[barprint]).to be_a barprint end - it 'should use the same V2 Serializer and Blueprint instances from V1' do - def instances.serializers = @serializers - def instances.blueprints = @blueprints + it 'should use the same V2 Blueprint instances from V1' do + def instances.blueprints = @instances def instances.blueprint_calls = @blueprint_calls ||= {} def instances.blueprint(klass) blueprint_calls[klass] ||= 0 @@ -266,7 +261,6 @@ def instances.blueprint(klass) }) barprint = blueprints[:barprint][:extended] - expect(instances.serializers[barprint]).to be_a Blueprinter::V2::Serializer expect(instances.blueprints[barprint]).to be_a barprint expect(instances.blueprint_calls[barprint].size).to eq 8 end diff --git a/spec/support/extension_helpers.rb b/spec/support/extension_helpers.rb index 42114095e..09dd9c2d1 100644 --- a/spec/support/extension_helpers.rb +++ b/spec/support/extension_helpers.rb @@ -5,6 +5,7 @@ def self.included(klass) klass.class_eval do subject { described_class.new } + let(:store) { {} } let(:instances) { Blueprinter::V2::InstanceCache.new } let(:sub_blueprint) do @@ -23,19 +24,18 @@ def self.included(klass) association :foos, [test.sub_blueprint] field(:foo2) { |obj, _ctx| "value: #{obj[:foo]}" } - association(:foo_obj2, test.sub_blueprint) { |obj, _ctx| { name: "name: #{obj[:foo_obj][:name]}" } } - association(:foos2, [test.sub_blueprint]) { |obj, _ctx| [{ name: "nums: #{obj[:foos].map { |x| x[:num] }.map(&:to_s).join(',')}" }] } - - def was(val, _ctx) - "was #{val.inspect}" - end - - def is?(val, expected_val) - val == expected_val + association(:foo_obj2, test.sub_blueprint) { |obj, _ctx| { name: "name: #{obj.dig(:foo_obj, :name)}" } } + association(:foos2, [test.sub_blueprint]) { |obj, _ctx| [{ name: "nums: #{obj[:foos]&.map { |x| x[:num] }&.map(&:to_s)&.join(',')}" }] } + + def was(ctx) + obj = ctx.object + field = ctx.field.source + was_val = obj.is_a?(Hash) ? obj[field] : obj.public_send(field) + "was #{was_val.inspect}" end - def foo?(val, _ctx) - is? val, 'Foo' + def foo?(ctx) + ctx.object[ctx.field.source] == 'Foo' end def name_foo?(val, _ctx) @@ -50,10 +50,11 @@ def names_foo?(val, _ctx) end end - def prepare(blueprint, options, ctx_type, *args) - serializer = instances.serializer(blueprint, options, {}, 1) - ctx = Blueprinter::V2::Context::Render.new(serializer.blueprint, serializer.fields, options, 1) + def prepare(blueprint_class, options, ctx_type, *args) + serializer = blueprint_class.serializer + blueprint = instances.blueprint(blueprint_class) + ctx = Blueprinter::V2::Context::Init.new(blueprint, blueprint_class.spec.options.dup, serializer.default_fields.map(&:to_configurable), options, 1) subject.around_blueprint_init(ctx) { yield ctx } if subject.respond_to?(:around_blueprint_init) - ctx_type.new(serializer.blueprint, serializer.fields, options, *args, 1) + ctx_type.new(blueprint, serializer.default_fields, options, *args, 1) end end diff --git a/spec/units/view_wrapper_spec.rb b/spec/units/view_wrapper_spec.rb deleted file mode 100644 index ebaa679fb..000000000 --- a/spec/units/view_wrapper_spec.rb +++ /dev/null @@ -1,58 +0,0 @@ -# frozen_string_literal: true - -require 'blueprinter/view_wrapper' - -describe Blueprinter::ViewWrapper do - let(:blueprint) do - Class.new(Blueprinter::Base) do - field :name - view :extended do - field :description - end - end - end - - let(:input) { { name: 'foo', description: 'bar' } } - let(:output) { { description: 'bar', name: 'foo' } } - let(:wrapper) { described_class.new(blueprint, :extended) } - - context 'render' do - it 'should render a view' do - expect(wrapper.render(input)).to eq output.to_json - end - - it 'should render a view with options' do - expect(wrapper.render(input, root: :data)).to eq({ data: output }.to_json) - end - end - - context 'render_as_hash' do - it 'should render a view' do - expect(wrapper.render_as_hash(input)).to eq output - end - - it 'should render a view with options' do - expect(wrapper.render_as_hash(input, root: :data)).to eq({ data: output }) - end - end - - it 'should return the reflections' do - expect(wrapper.reflections[:extended].fields.keys).to match_array [:name, :description] - end - - it 'should fetch the default view from a blueprint' do - default = blueprint[:default] - expect(default.blueprint).to eq blueprint - expect(default.view_name).to eq :default - end - - it 'should fetch a given view from a blueprint' do - extended = blueprint[:extended] - expect(extended.blueprint).to eq blueprint - expect(extended.view_name).to eq :extended - end - - it 'should raise an error if the view does not exist' do - expect { blueprint[:foo] }.to raise_error(Blueprinter::Errors::UnknownView) - end -end diff --git a/spec/v2/declarative_api_spec.rb b/spec/v2/declarative_api_spec.rb index 7802cd270..8654c5e73 100644 --- a/spec/v2/declarative_api_spec.rb +++ b/spec/v2/declarative_api_spec.rb @@ -67,25 +67,4 @@ refs = blueprint[:"foo.bar"].reflections expect(refs[:default].fields.keys.sort).to eq %i(name foo bar description).sort end - - it "excludes fields added after the exclude statement" do - blueprint = Class.new(Blueprinter::V2::Base) do - field :id - field :name - - view :foo do - exclude :name, :description2, :description3 - use :desc - field :description3 - end - - partial :desc do - field :description - field :description2 - end - end - - refs = blueprint.reflections - expect(refs[:foo].fields.keys.sort).to eq %i(id description).sort - end end diff --git a/spec/v2/exclusions_spec.rb b/spec/v2/exclusions_spec.rb new file mode 100644 index 000000000..3a481c33d --- /dev/null +++ b/spec/v2/exclusions_spec.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +describe "Blueprinter::V2 Exclusions" do + let(:application_blueprint) do + Class.new(Blueprinter::V2::Base) do + add Blueprinter::Extensions::ViewOption.new + set :my_opt, true + format(TrueClass) { "Y" } + fields :id, :created_at, :updated_at + end + end + + it "excludes from parent class" do + blueprint = Class.new(application_blueprint) do + add Blueprinter::Extensions::FieldOrder.new { |a, b| a.name <=> b.name } + set :foo, "foo" + field :name + exclude fields: true, options: true, extensions: true, formatters: true + end + + ref = blueprint.reflections[:default] + expect(ref.fields.keys).to eq %i[name] + expect(ref.options).to eq({ foo: "foo" }) + expect(ref.extensions.map(&:class).map(&:name)).to eq %w[Blueprinter::Extensions::FieldOrder] + expect(blueprint.spec.formatters).to eq({}) + end + + it "allows a locally defined field" do + blueprint = Class.new(application_blueprint) do + add Blueprinter::Extensions::FieldOrder.new { |a, b| a.name <=> b.name } + set :foo, "foo" + fields :name, :created_at + exclude :created_at, :updated_at + end + + ref = blueprint.reflections[:default] + expect(ref.fields.keys).to match_array %i[id name created_at] + end + + it "excludes from view parent" do + blueprint = Class.new(application_blueprint) do + add Blueprinter::Extensions::FieldOrder.new { |a, b| a.name <=> b.name } + set :foo, "foo" + field :name + + view :extended do + add Blueprinter::Extensions::MultiJson.new + set :bar, "bar" + field :description + exclude fields: true, options: true, extensions: true, formatters: true + end + end + + ref = blueprint.reflections[:extended] + expect(ref.fields.keys).to eq %i[description] + expect(ref.options).to eq({ bar: "bar" }) + expect(ref.extensions.map(&:class).map(&:name)).to eq %w[Blueprinter::Extensions::MultiJson] + expect(blueprint[:extended].spec.formatters).to eq({}) + end + + it "excludes from parent view" do + blueprint = Class.new(application_blueprint) do + view :extended do + add Blueprinter::Extensions::FieldOrder.new { |a, b| a.name <=> b.name } + set :foo, "foo" + field :name + + view :plus do + add Blueprinter::Extensions::MultiJson.new + set :bar, "bar" + field :description + exclude fields: true, options: true, extensions: true, formatters: true + end + end + end + + ref = blueprint.reflections[:"extended.plus"] + expect(ref.fields.keys).to eq %i[description] + expect(ref.options).to eq({ bar: "bar" }) + expect(ref.extensions.map(&:class).map(&:name)).to eq %w[Blueprinter::Extensions::MultiJson] + expect(blueprint[:"extended.plus"].spec.formatters).to eq({}) + end + + it "exclusions can be added by a partial" do + blueprint = Class.new(application_blueprint) do + add Blueprinter::Extensions::FieldOrder.new { |a, b| a.name <=> b.name } + set :foo, "foo" + field :name + + use :my_partial + + partial :my_partial do + exclude fields: true, options: true, extensions: true, formatters: true + end + end + + ref = blueprint.reflections[:default] + expect(ref.fields.keys).to eq %i[name] + expect(ref.options).to eq({ foo: "foo" }) + expect(ref.extensions.map(&:class).map(&:name)).to eq %w[Blueprinter::Extensions::FieldOrder] + expect(blueprint.spec.formatters).to eq({}) + end +end diff --git a/spec/v2/extension_dsl_spec.rb b/spec/v2/extension_dsl_spec.rb index 20203f6e5..f195f03c3 100644 --- a/spec/v2/extension_dsl_spec.rb +++ b/spec/v2/extension_dsl_spec.rb @@ -10,7 +10,7 @@ def self.blueprint_name = "NameBlueprint" def around_field_value(ctx) = yield(ctx).upcase end extension do - def around_blueprint(ctx) + def around_serialize_object(ctx) res = yield ctx { data: res } end @@ -19,18 +19,114 @@ def around_blueprint(ctx) end it "defines multiple extensions" do - serializer = Blueprinter::V2::Serializer.new(blueprint, {}, instances, store: {}, initial_depth: 1) - expect(blueprint.extensions.size).to eq 2 + ref = blueprint.reflections[:default] + serializer = Blueprinter::V2::Serializer.new(blueprint) + expect(ref.extensions.size).to eq 2 expect(serializer.hooks.registered? :around_field_value).to be true - expect(serializer.hooks.registered? :around_blueprint).to be true - end - - it "names the extensions" do - expect(blueprint.extensions.map(&:name)).to eq ["NameBlueprint extension", "NameBlueprint extension"] + expect(serializer.hooks.registered? :around_serialize_object).to be true + expect(serializer.hooks.registered? :around_serialize_collection).to be false end it "runs the extensions" do res = blueprint.render({ name: "Foo" }).to_hash expect(res).to eq({ data: { name: "FOO" } }) end + + it "add appends extensions" do + ext1 = Class.new(Blueprinter::Extension) + ext2 = Class.new(Blueprinter::Extension) + ext3 = Class.new(Blueprinter::Extension) + + blueprint = Class.new(Blueprinter::V2::Base) do + add ext1.new + + view :extended do + add ext2.new, ext3.new + end + end + + ref = blueprint.reflections + expect(ref[:default].extensions.map(&:class)).to eq [ext1] + expect(ref[:extended].extensions.map(&:class)).to eq [ext1, ext2, ext3] + end + + it "add prepends extensions" do + ext1 = Class.new(Blueprinter::Extension) + ext2 = Class.new(Blueprinter::Extension) + ext3 = Class.new(Blueprinter::Extension) + + blueprint = Class.new(Blueprinter::V2::Base) do + add ext1.new + + view :extended do + add ext2.new, ext3.new, prepend: true + end + end + + ref = blueprint.reflections + expect(ref[:default].extensions.map(&:class)).to eq [ext1] + expect(ref[:extended].extensions.map(&:class)).to eq [ext2, ext3, ext1] + end + + it "add throws an exception if someone accidentally passes a block" do + ext1 = Class.new(Blueprinter::Extension) + expect do + Class.new(Blueprinter::V2::Base) do + add ext1.new do + end + end + end.to raise_error(Blueprinter::BlueprinterError, /add does not accept a block/) + end + + it "remove removes extensions by class" do + ext1 = Class.new(Blueprinter::Extension) + ext2 = Class.new(Blueprinter::Extension) + ext3 = Class.new(Blueprinter::Extension) + + blueprint = Class.new(Blueprinter::V2::Base) do + add ext1.new, ext2.new, ext3.new + + view :extended do + remove ext2, ext3 + end + end + + ref = blueprint.reflections + expect(ref[:default].extensions.map(&:class)).to eq [ext1, ext2, ext3] + expect(ref[:extended].extensions.map(&:class)).to eq [ext1] + end + + it "remove removes extensions with a block" do + ext1 = Class.new(Blueprinter::Extension) + ext2 = Class.new(Blueprinter::Extension) + ext3 = Class.new(Blueprinter::Extension) + + blueprint = Class.new(Blueprinter::V2::Base) do + add ext1.new, ext2.new, ext3.new + + view :extended do + remove { |ext| ext.is_a? ext3 } + end + end + + ref = blueprint.reflections + expect(ref[:default].extensions.map(&:class)).to eq [ext1, ext2, ext3] + expect(ref[:extended].extensions.map(&:class)).to eq [ext1, ext2] + end + + it "exclude removes all inherited extensions" do + ext1 = Class.new(Blueprinter::Extension) + + blueprint = Class.new(Blueprinter::V2::Base) do + add ext1.new + + view :extended do + exclude extensions: true + end + end + + ref = blueprint.reflections + expect(ref[:default].extensions.map(&:class)).to eq [ext1] + expect(ref[:extended].extensions.map(&:class)).to eq [] + end end diff --git a/spec/v2/extensions/core/defaults_spec.rb b/spec/v2/extensions/core/defaults_spec.rb deleted file mode 100644 index dea31f371..000000000 --- a/spec/v2/extensions/core/defaults_spec.rb +++ /dev/null @@ -1,683 +0,0 @@ -# frozen_string_literal: true - -describe Blueprinter::V2::Extensions::Core::Defaults do - include ExtensionHelpers - - context 'fields' do - let(:field) { blueprint.reflections[:default].fields[:foo] } - let(:object) { { foo: 'Foo' } } - - it 'passes values through by default' do - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = subject.around_field_value(ctx) { 'Foo' } - expect(value).to eq 'Foo' - end - - it 'passes values through by with defaults given' do - blueprint.options[:field_default] = 'Bar' - blueprint.field :foo, default: 'Bar' - ctx = prepare(blueprint, { field_default: 'Bar' }, Blueprinter::V2::Context::Field, object, field) - value = subject.around_field_value(ctx) { 'Foo' } - expect(value).to eq 'Foo' - end - - it 'passes values through with false default_ifs given' do - blueprint.options[:field_default] = 'Bar' - blueprint.options[:field_default_if] = ->(_, _) { false } - blueprint.field :foo, default: 'Bar', default_if: ->(_, _) { false } - ctx = prepare(blueprint, { field_default: 'Bar', field_default_if: ->(_, _) { false } }, Blueprinter::V2::Context::Field, object, field) - value = subject.around_field_value(ctx) { 'Foo' } - expect(value).to eq 'Foo' - end - - it 'passes nil through by default' do - object[:foo] = nil - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = subject.around_field_value(ctx) { nil } - expect(value).to be_nil - end - - it 'uses options field_default' do - object[:foo] = nil - ctx = prepare(blueprint, { field_default: 'Bar' }, Blueprinter::V2::Context::Field, object, field) - value = subject.around_field_value(ctx) { nil } - expect(value).to eq 'Bar' - end - - it 'uses options field_default (Proc)' do - object[:foo] = nil - ctx = prepare(blueprint, { field_default: ->(val, ctx) { "Bar (was #{val.inspect})"} }, Blueprinter::V2::Context::Field, object, field) - value = subject.around_field_value(ctx) { nil } - expect(value).to eq 'Bar (was nil)' - end - - it 'uses options field_default (Symbol)' do - object[:foo] = nil - ctx = prepare(blueprint, { field_default: :was }, Blueprinter::V2::Context::Field, object, field) - value = subject.around_field_value(ctx) { nil } - expect(value).to eq 'was nil' - end - - it 'uses field options default' do - object[:foo] = nil - blueprint.field :foo, default: 'Bar' - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = subject.around_field_value(ctx) { nil } - expect(value).to eq 'Bar' - end - - it 'uses field options default (Proc)' do - object[:foo] = nil - blueprint.field :foo, default: ->(val, ctx) { "Bar (was #{val.inspect})"} - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = subject.around_field_value(ctx) { nil } - expect(value).to eq 'Bar (was nil)' - end - - it 'uses field options default (Symbol)' do - object[:foo] = nil - blueprint.field :foo, default: :was - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = subject.around_field_value(ctx) { nil } - expect(value).to eq 'was nil' - end - - it 'uses blueprint options field_default' do - object[:foo] = nil - blueprint.options[:field_default] = 'Bar' - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = subject.around_field_value(ctx) { nil } - expect(value).to eq 'Bar' - end - - it 'uses blueprint options field_default (Proc)' do - object[:foo] = nil - blueprint.options[:field_default] = ->(val, ctx) { "Bar (was #{val.inspect})" } - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = subject.around_field_value(ctx) { nil } - expect(value).to eq 'Bar (was nil)' - end - - it 'uses blueprint options field_default (Symbol)' do - object[:foo] = nil - blueprint.options[:field_default] = :was - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = subject.around_field_value(ctx) { nil } - expect(value).to eq 'was nil' - end - - it 'checks with options field_default_if (default = options field_default)' do - ctx = prepare(blueprint, { field_default: 'Bar', field_default_if: ->(val, ctx) { is? val, 'Foo' } }, Blueprinter::V2::Context::Field, object, field) - value = subject.around_field_value(ctx) { nil } - expect(value).to eq 'Bar' - - ctx = prepare(blueprint, { field_default: 'Bar', field_default_if: :foo? }, Blueprinter::V2::Context::Field, object, field) - value = subject.around_field_value(ctx) { nil } - expect(value).to eq 'Bar' - end - - it 'checks with options field_default_if (default = field options default)' do - blueprint.field :foo, default: 'Bar' - ctx = prepare(blueprint, { field_default_if: ->(val, _ctx) { is? val, 'Foo' } }, Blueprinter::V2::Context::Field, object, field) - value = subject.around_field_value(ctx) { 'Foo' } - expect(value).to eq 'Bar' - - ctx = prepare(blueprint, { field_default_if: :foo? }, Blueprinter::V2::Context::Field, object, field) - value = subject.around_field_value(ctx) { 'Foo' } - expect(value).to eq 'Bar' - end - - it 'checks with options field_default_if (default = blueprint options field_default)' do - blueprint.options[:field_default] = 'Bar' - ctx = prepare(blueprint, { field_default_if: ->(val, _ctx) { is? val, 'Foo' } }, Blueprinter::V2::Context::Field, object, field) - value = subject.around_field_value(ctx) { 'Foo' } - expect(value).to eq 'Bar' - - ctx = prepare(blueprint, { field_default_if: :foo? }, Blueprinter::V2::Context::Field, object, field) - value = subject.around_field_value(ctx) { 'Foo' } - expect(value).to eq 'Bar' - end - - it 'checks with field options default_if (Proc) (default = options field_default)' do - blueprint.field :foo, default_if: ->(val, _ctx) { is? val, 'Foo' } - ctx = prepare(blueprint, { field_default: 'Bar' }, Blueprinter::V2::Context::Field, object, field) - value = subject.around_field_value(ctx) { 'Foo' } - expect(value).to eq 'Bar' - end - - it 'checks with field options default_if (Symbol) (default = options field_default)' do - blueprint.field :foo, default_if: :foo? - ctx = prepare(blueprint, { field_default: 'Bar' }, Blueprinter::V2::Context::Field, object, field) - value = subject.around_field_value(ctx) { 'Foo' } - expect(value).to eq 'Bar' - end - - it 'checks with field options default_if (Proc) (default = field options default)' do - blueprint.field :foo, default: 'Bar', default_if: ->(val, _ctx) { is? val, 'Foo' } - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = subject.around_field_value(ctx) { 'Foo' } - expect(value).to eq 'Bar' - end - - it 'checks with field options default_if (Symbol) (default = field options default)' do - blueprint.field :foo, default: 'Bar', default_if: :foo? - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = subject.around_field_value(ctx) { 'Foo' } - expect(value).to eq 'Bar' - end - - it 'checks with field options default_if (Proc) (default = blueprint options field_default)' do - blueprint.field :foo, default: 'Bar', default_if: ->(val, _ctx) { is? val, 'Foo' } - blueprint.options[:field_default] = 'Bar' - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = subject.around_field_value(ctx) { 'Foo' } - expect(value).to eq 'Bar' - end - - it 'checks with field options default_if (Symbol) (default = blueprint options field_default)' do - blueprint.field :foo, default: 'Bar', default_if: :foo? - blueprint.options[:field_default] = 'Bar' - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = subject.around_field_value(ctx) { 'Foo' } - expect(value).to eq 'Bar' - end - - it 'checks with blueprint options field_default_if (Proc) (default = options field_default)' do - blueprint.options[:field_default_if] = ->(val, _ctx) { is? val, 'Foo' } - ctx = prepare(blueprint, { field_default: 'Bar' }, Blueprinter::V2::Context::Field, object, field) - value = subject.around_field_value(ctx) { 'Foo' } - expect(value).to eq 'Bar' - end - - it 'checks with blueprint options field_default_if (Symbol) (default = options field_default)' do - blueprint.options[:field_default_if] = :foo? - ctx = prepare(blueprint, { field_default: 'Bar' }, Blueprinter::V2::Context::Field, object, field) - value = subject.around_field_value(ctx) { 'Foo' } - expect(value).to eq 'Bar' - end - - it 'checks with blueprint options field_default_if (Proc) (default = field options default)' do - blueprint.options[:field_default_if] = ->(val, _ctx) { is? val, 'Foo' } - blueprint.field :foo, default: 'Bar' - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = subject.around_field_value(ctx) { nil } - expect(value).to eq 'Bar' - end - - it 'checks with blueprint options field_default_if (Symbol) (default = field options default)' do - blueprint.options[:field_default_if] = :foo? - blueprint.field :foo, default: 'Bar' - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = subject.around_field_value(ctx) { nil } - expect(value).to eq 'Bar' - end - - it 'checks with blueprint options field_default_if (Proc) (default = blueprint options field_default)' do - blueprint.options[:field_default_if] = ->(val, _ctx) { is? val, 'Foo' } - blueprint.options[:field_default] = 'Bar' - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = subject.around_field_value(ctx) { nil } - expect(value).to eq 'Bar' - end - - it 'checks with blueprint options field_default_if (Symbol) (default = blueprint options field_default)' do - blueprint.options[:field_default_if] = :foo? - blueprint.options[:field_default] = 'Bar' - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = subject.around_field_value(ctx) { nil } - expect(value).to eq 'Bar' - end - end - - context 'objects' do - let(:field) { blueprint.reflections[:default].objects[:foo_obj] } - let(:object) { { foo_obj: 'Foo' } } - - it 'passes values through by default' do - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = subject.around_object_value(ctx) { 'Foo' } - expect(value).to eq 'Foo' - end - - it 'passes values through by with defaults given' do - blueprint.options[:object_default] = 'Bar' - blueprint.association :foo_obj, sub_blueprint, default: 'Bar' - ctx = prepare(blueprint, { object_default: 'Bar' }, Blueprinter::V2::Context::Field, object, field) - value = subject.around_object_value(ctx) { 'Foo' } - expect(value).to eq 'Foo' - end - - it 'passes values through with false default_ifs given' do - blueprint.options[:object_default] = 'Bar' - blueprint.options[:object_default_if] = ->(_, _) { false } - blueprint.association :foo_obj, sub_blueprint, default: 'Bar', default_if: ->(_, _) { false } - ctx = prepare(blueprint, { object_default: 'Bar', object_default_if: ->(_, _) { false } }, Blueprinter::V2::Context::Field, object, field) - value = subject.around_object_value(ctx) { 'Foo' } - expect(value).to eq 'Foo' - end - - it 'passes nil through by default' do - object[:foo_obj] = nil - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = subject.around_object_value(ctx) { nil } - expect(value).to be_nil - end - - it 'uses options object_default' do - object[:foo_obj] = nil - ctx = prepare(blueprint, { object_default: 'Bar' }, Blueprinter::V2::Context::Field, object, field) - value = subject.around_object_value(ctx) { nil } - expect(value).to eq 'Bar' - end - - it 'uses options object_default (Proc)' do - object[:foo_obj] = nil - ctx = prepare(blueprint, { object_default: ->(val, _ctx) { "Bar (was #{val.inspect})" } }, Blueprinter::V2::Context::Field, object, field) - value = subject.around_object_value(ctx) { nil } - expect(value).to eq 'Bar (was nil)' - end - - it 'uses options object_default (Symbol)' do - object[:foo_obj] = nil - ctx = prepare(blueprint, { object_default: :was }, Blueprinter::V2::Context::Field, object, field) - value = subject.around_object_value(ctx) { nil } - expect(value).to eq 'was nil' - end - - it 'uses field options default' do - object[:foo_obj] = nil - blueprint.association :foo_obj, sub_blueprint, default: 'Bar' - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = subject.around_object_value(ctx) { nil } - expect(value).to eq 'Bar' - end - - it 'uses field options default (Proc)' do - object[:foo_obj] = nil - blueprint.association :foo_obj, sub_blueprint, default: ->(val, _ctx) { "Bar (was #{val.inspect})" } - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = subject.around_object_value(ctx) { nil } - expect(value).to eq 'Bar (was nil)' - end - - it 'uses field options default (Symbol)' do - object[:foo_obj] = nil - blueprint.association :foo_obj, sub_blueprint, default: :was - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = subject.around_object_value(ctx) { nil } - expect(value).to eq 'was nil' - end - - it 'uses blueprint options object_default' do - object[:foo_obj] = nil - blueprint.options[:object_default] = 'Bar' - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = subject.around_object_value(ctx) { nil } - expect(value).to eq 'Bar' - end - - it 'uses blueprint options object_default (Proc)' do - object[:foo_obj] = nil - blueprint.options[:object_default] = ->(val, _ctx) { "Bar (was #{val.inspect})" } - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = subject.around_object_value(ctx) { nil } - expect(value).to eq 'Bar (was nil)' - end - - it 'uses blueprint options object_default (Symbol)' do - object[:foo_obj] = nil - blueprint.options[:object_default] = :was - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = subject.around_object_value(ctx) { nil } - expect(value).to eq 'was nil' - end - - it 'checks with options object_default_if (default = options object_default)' do - ctx = prepare(blueprint, { object_default: 'Bar', object_default_if: ->(val, _ctx) { is? val, 'Foo' } }, Blueprinter::V2::Context::Field, object, field) - value = subject.around_object_value(ctx) { nil } - expect(value).to eq 'Bar' - - ctx = prepare(blueprint, { object_default: 'Bar', object_default_if: :foo? }, Blueprinter::V2::Context::Field, object, field) - value = subject.around_object_value(ctx) { nil } - expect(value).to eq 'Bar' - end - - it 'checks with options object_default_if (default = field options default)' do - blueprint.association :foo_obj, sub_blueprint, default: 'Bar' - ctx = prepare(blueprint, { object_default_if: ->(val, _ctx) { is? val, 'Foo' } }, Blueprinter::V2::Context::Field, object, field) - value = subject.around_object_value(ctx) { nil } - expect(value).to eq 'Bar' - - ctx = prepare(blueprint, { object_default_if: :foo? }, Blueprinter::V2::Context::Field, object, field) - value = subject.around_object_value(ctx) { nil } - expect(value).to eq 'Bar' - end - - it 'checks with options object_default_if (default = blueprint options object_default)' do - blueprint.options[:object_default] = 'Bar' - ctx = prepare(blueprint, { object_default_if: ->(val, _ctx) { is? val, 'Foo' } }, Blueprinter::V2::Context::Field, object, field) - value = subject.around_object_value(ctx) { nil } - expect(value).to eq 'Bar' - - ctx = prepare(blueprint, { object_default_if: :foo? }, Blueprinter::V2::Context::Field, object, field) - value = subject.around_object_value(ctx) { nil } - expect(value).to eq 'Bar' - end - - it 'checks with field options default_if (Proc) (default = options object_default)' do - blueprint.association :foo_obj, sub_blueprint, default_if: ->(val, _ctx) { is? val, 'Foo' } - ctx = prepare(blueprint, { object_default: 'Bar' }, Blueprinter::V2::Context::Field, object, field) - value = subject.around_object_value(ctx) { nil } - expect(value).to eq 'Bar' - end - - it 'checks with field options default_if (Symbol) (default = options object_default)' do - blueprint.association :foo_obj, sub_blueprint, default_if: :foo? - ctx = prepare(blueprint, { object_default: 'Bar' }, Blueprinter::V2::Context::Field, object, field) - value = subject.around_object_value(ctx) { nil } - expect(value).to eq 'Bar' - end - - it 'checks with field options default_if (Proc) (default = field options default)' do - blueprint.association :foo_obj, sub_blueprint, default: 'Bar', default_if: ->(val, _ctx) { is? val, 'Foo' } - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = subject.around_object_value(ctx) { nil } - expect(value).to eq 'Bar' - end - - it 'checks with field options default_if (Symbol) (default = field options default)' do - blueprint.association :foo_obj, sub_blueprint, default: 'Bar', default_if: :foo? - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = subject.around_object_value(ctx) { nil } - expect(value).to eq 'Bar' - end - - it 'checks with field options default_if (Proc) (default = blueprint options object_default)' do - blueprint.association :foo_obj, sub_blueprint, default_if: ->(val, _ctx) { is? val, 'Foo' } - blueprint.options[:object_default] = 'Bar' - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = subject.around_object_value(ctx) { nil } - expect(value).to eq 'Bar' - end - - it 'checks with field options default_if (Symbol) (default = blueprint options object_default)' do - blueprint.association :foo_obj, sub_blueprint, default_if: :foo? - blueprint.options[:object_default] = 'Bar' - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = subject.around_object_value(ctx) { nil } - expect(value).to eq 'Bar' - end - - it 'checks with blueprint options object_default_if (Proc) (default = options object_default)' do - blueprint.options[:object_default_if] = ->(val, _ctx) { is? val, 'Foo' } - ctx = prepare(blueprint, { object_default: 'Bar' }, Blueprinter::V2::Context::Field, object, field) - value = subject.around_object_value(ctx) { nil } - expect(value).to eq 'Bar' - end - - it 'checks with blueprint options object_default_if (Symbol) (default = options object_default)' do - blueprint.options[:object_default_if] = :foo? - ctx = prepare(blueprint, { object_default: 'Bar' }, Blueprinter::V2::Context::Field, object, field) - value = subject.around_object_value(ctx) { nil } - expect(value).to eq 'Bar' - end - - it 'checks with blueprint options object_default_if (Proc) (default = field options default)' do - blueprint.options[:object_default_if] = ->(val, _ctx) { is? val, 'Foo' } - blueprint.association :foo_obj, sub_blueprint, default: 'Bar' - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = subject.around_object_value(ctx) { nil } - expect(value).to eq 'Bar' - end - - it 'checks with blueprint options object_default_if (Symbol) (default = field options default)' do - blueprint.options[:object_default_if] = :foo? - blueprint.association :foo_obj, sub_blueprint, default: 'Bar' - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = subject.around_object_value(ctx) { nil } - expect(value).to eq 'Bar' - end - - it 'checks with blueprint options object_default_if (Proc) (default = blueprint options object_default)' do - blueprint.options[:object_default_if] = ->(val, ctx) { is? val, 'Foo' } - blueprint.options[:object_default] = 'Bar' - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = subject.around_object_value(ctx) { nil } - expect(value).to eq 'Bar' - end - - it 'checks with blueprint options object_default_if (Symbol) (default = blueprint options object_default)' do - blueprint.options[:object_default_if] = :foo? - blueprint.options[:object_default] = 'Bar' - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = subject.around_object_value(ctx) { nil } - expect(value).to eq 'Bar' - end - end - - context 'collections' do - let(:field) { blueprint.reflections[:default].collections[:foos] } - let(:object) { { foos: 'Foo' } } - - it 'passes values through by default' do - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = subject.around_object_value(ctx) { 'Foo' } - expect(value).to eq 'Foo' - end - - it 'passes values through by with defaults given' do - blueprint.options[:collection_default] = 'Bar' - blueprint.association :foos, [sub_blueprint], default: 'Bar' - ctx = prepare(blueprint, { collection_default: 'Bar' }, Blueprinter::V2::Context::Field, object, field) - value = subject.around_object_value(ctx) { 'Foo' } - expect(value).to eq 'Foo' - end - - it 'passes values through with false default_ifs given' do - blueprint.options[:collection_default] = 'Bar' - blueprint.options[:collection_default_if] = ->(_, _) { false } - blueprint.association :foos, [sub_blueprint], default: 'Bar', default_if: ->(_, _) { false } - ctx = prepare(blueprint, { collection_default: 'Bar', collection_default_if: ->_, (_) { false } }, Blueprinter::V2::Context::Field, object, field) - value = subject.around_object_value(ctx) { 'Foo' } - expect(value).to eq 'Foo' - end - - it 'passes nil through by default' do - object[:foos] = nil - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = subject.around_object_value(ctx) { nil } - expect(value).to be_nil - end - - it 'uses options collection_default' do - object[:foos] = nil - ctx = prepare(blueprint, { collection_default: 'Bar' }, Blueprinter::V2::Context::Field, object, field) - value = subject.around_object_value(ctx) { nil } - expect(value).to eq 'Bar' - end - - it 'uses options collection_default (Proc)' do - object[:foos] = nil - ctx = prepare(blueprint, { collection_default: ->(val, _ctx) { "Bar (was #{val.inspect})" } }, Blueprinter::V2::Context::Field, object, field) - value = subject.around_object_value(ctx) { nil } - expect(value).to eq 'Bar (was nil)' - end - - it 'uses options collection_default (Symbol)' do - object[:foos] = nil - ctx = prepare(blueprint, { collection_default: :was }, Blueprinter::V2::Context::Field, object, field) - value = subject.around_object_value(ctx) { nil } - expect(value).to eq 'was nil' - end - - it 'uses field options default' do - object[:foos] = nil - blueprint.association :foos, [sub_blueprint], default: 'Bar' - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = subject.around_object_value(ctx) { nil } - expect(value).to eq 'Bar' - end - - it 'uses field options default (Proc)' do - object[:foos] = nil - blueprint.association :foos, [sub_blueprint], default: ->(val, _ctx) { "Bar (was #{val.inspect})"} - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = subject.around_object_value(ctx) { nil } - expect(value).to eq 'Bar (was nil)' - end - - it 'uses field options default (Symbol)' do - object[:foos] = nil - blueprint.association :foos, [sub_blueprint], default: :was - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = subject.around_object_value(ctx) { nil } - expect(value).to eq 'was nil' - end - - it 'uses blueprint options collection_default' do - object[:foos] = nil - blueprint.options[:collection_default] = 'Bar' - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = subject.around_object_value(ctx) { nil } - expect(value).to eq 'Bar' - end - - it 'uses blueprint options collection_default (Proc)' do - object[:foos] = nil - blueprint.options[:collection_default] = ->(val, _ctx) { "Bar (was #{val.inspect})" } - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = subject.around_object_value(ctx) { nil } - expect(value).to eq 'Bar (was nil)' - end - - it 'uses blueprint options collection_default (Symbol)' do - object[:foos] = nil - blueprint.options[:collection_default] = :was - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = subject.around_object_value(ctx) { nil } - expect(value).to eq 'was nil' - end - - it 'checks with options collection_default_if (default = options collection_default)' do - ctx = prepare(blueprint, { collection_default: 'Bar', collection_default_if: ->(val, _ctx) { is? val, 'Foo' } }, Blueprinter::V2::Context::Field, object, field) - value = subject.around_object_value(ctx) { nil } - expect(value).to eq 'Bar' - - ctx = prepare(blueprint, { collection_default: 'Bar', collection_default_if: :foo? }, Blueprinter::V2::Context::Field, object, field) - value = subject.around_object_value(ctx) { nil } - expect(value).to eq 'Bar' - end - - it 'checks with options collection_default_if (default = field options default)' do - blueprint.association :foos, [sub_blueprint], default: 'Bar' - ctx = prepare(blueprint, { collection_default_if: ->(val, _ctx) { is? val, 'Foo' } }, Blueprinter::V2::Context::Field, object, field) - value = subject.around_object_value(ctx) { nil } - expect(value).to eq 'Bar' - - ctx = prepare(blueprint, { collection_default_if: :foo? }, Blueprinter::V2::Context::Field, object, field) - value = subject.around_object_value(ctx) { nil } - expect(value).to eq 'Bar' - end - - it 'checks with options collection_default_if (default = blueprint options collection_default)' do - blueprint.options[:collection_default] = 'Bar' - ctx = prepare(blueprint, { collection_default_if: ->(val, _ctx) { is? val, 'Foo' } }, Blueprinter::V2::Context::Field, object, field) - value = subject.around_object_value(ctx) { nil } - expect(value).to eq 'Bar' - - ctx = prepare(blueprint, { collection_default_if: :foo? }, Blueprinter::V2::Context::Field, object, field) - value = subject.around_object_value(ctx) { nil } - expect(value).to eq 'Bar' - end - - it 'checks with field options default_if (Proc) (default = options collection_default)' do - blueprint.association :foos, [sub_blueprint], default_if: ->(val, _ctx) { is? val, 'Foo' } - ctx = prepare(blueprint, { collection_default: 'Bar' }, Blueprinter::V2::Context::Field, object, field) - value = subject.around_object_value(ctx) { nil } - expect(value).to eq 'Bar' - end - - it 'checks with field options default_if (Symbol) (default = options collection_default)' do - blueprint.association :foos, [sub_blueprint], default_if: :foo? - ctx = prepare(blueprint, { collection_default: 'Bar' }, Blueprinter::V2::Context::Field, object, field) - value = subject.around_object_value(ctx) { nil } - expect(value).to eq 'Bar' - end - - it 'checks with field options default_if (Proc) (default = field options default)' do - blueprint.association :foos, [sub_blueprint], default_if: ->(val, _ctx) { is? val, 'Foo' }, default: 'Bar' - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = subject.around_object_value(ctx) { nil } - expect(value).to eq 'Bar' - end - - it 'checks with field options default_if (Symbol) (default = field options default)' do - blueprint.association :foos, [sub_blueprint], default_if: :foo?, default: 'Bar' - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = subject.around_object_value(ctx) { nil } - expect(value).to eq 'Bar' - end - - it 'checks with field options default_if (Proc) (default = blueprint options collection_default)' do - blueprint.association :foos, [sub_blueprint], default_if: ->(val, _ctx) { is? val, 'Foo' } - blueprint.options[:collection_default] = 'Bar' - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = subject.around_object_value(ctx) { nil } - expect(value).to eq 'Bar' - end - - it 'checks with field options default_if (Symbol) (default = blueprint options collection_default)' do - blueprint.association :foos, [sub_blueprint], default_if: :foo? - blueprint.options[:collection_default] = 'Bar' - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = subject.around_object_value(ctx) { nil } - expect(value).to eq 'Bar' - end - - it 'checks with blueprint options collection_default_if (Proc) (default = options collection_default)' do - blueprint.options[:collection_default_if] = ->(val, _ctx) { is? val, 'Foo' } - ctx = prepare(blueprint, { collection_default: 'Bar' }, Blueprinter::V2::Context::Field, object, field) - value = subject.around_object_value(ctx) { nil } - expect(value).to eq 'Bar' - end - - it 'checks with blueprint options collection_default_if (Symbol) (default = options collection_default)' do - blueprint.options[:collection_default_if] = :foo? - ctx = prepare(blueprint, { collection_default: 'Bar' }, Blueprinter::V2::Context::Field, object, field) - value = subject.around_object_value(ctx) { nil } - expect(value).to eq 'Bar' - end - - it 'checks with blueprint options collection_default_if (Proc) (default = field options default)' do - blueprint.options[:collection_default_if] = ->(val, _ctx) { is? val, 'Foo' } - blueprint.association :foos, [sub_blueprint], default: 'Bar' - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = subject.around_object_value(ctx) { nil } - expect(value).to eq 'Bar' - end - - it 'checks with blueprint options collection_default_if (Symbol) (default = field options default)' do - blueprint.options[:collection_default_if] = :foo? - blueprint.association :foos, [sub_blueprint], default: 'Bar' - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = subject.around_object_value(ctx) { nil } - expect(value).to eq 'Bar' - end - - it 'checks with blueprint options collection_default_if (Proc) (default = blueprint options collection_default)' do - blueprint.options[:collection_default_if] = ->(val, _ctx) { is? val, 'Foo' } - blueprint.options[:collection_default] = 'Bar' - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = subject.around_object_value(ctx) { nil } - expect(value).to eq 'Bar' - end - - it 'checks with blueprint options collection_default_if (Symbol) (default = blueprint options collection_default)' do - blueprint.options[:collection_default_if] = :foo? - blueprint.options[:collection_default] = 'Bar' - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = subject.around_object_value(ctx) { nil } - expect(value).to eq 'Bar' - end - end -end diff --git a/spec/v2/extensions/core/exclude_if_empty_spec.rb b/spec/v2/extensions/core/exclude_if_empty_spec.rb deleted file mode 100644 index c1ef36503..000000000 --- a/spec/v2/extensions/core/exclude_if_empty_spec.rb +++ /dev/null @@ -1,236 +0,0 @@ -# frozen_string_literal: true - -describe Blueprinter::V2::Extensions::Core::Conditionals do - include ExtensionHelpers - let(:object) { { foo: 'Foo' } } - let(:sig) { Blueprinter::V2::Serializer::SIGNAL } - let(:skip_field) { Blueprinter::V2::Serializer::SIG_SKIP } - - context 'fields' do - let(:field) { blueprint.reflections[:default].fields[:foo] } - - it 'are allowed by default' do - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { 'Foo' } } - expect(value).to eq 'Foo' - end - - it 'are allowed by default if nil' do - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { nil } } - expect(value).to be nil - end - - it 'are allowed with options set' do - ctx = prepare(blueprint, { exclude_if_empty: true }, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { 'Foo' } } - expect(value).to eq 'Foo' - end - - it 'are excluded with options set if nil' do - ctx = prepare(blueprint, { exclude_if_empty: true }, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { nil } } - expect(value).to eq skip_field - end - - it 'are excluded with options set if empty' do - ctx = prepare(blueprint, { exclude_if_empty: true }, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { [] } } - expect(value).to eq skip_field - end - - it 'are allowed with field options set' do - blueprint.field :foo, exclude_if_empty: true - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { 'Foo' } } - expect(value).to eq 'Foo' - end - - it 'are excluded with field options set if nil' do - blueprint.field :foo, exclude_if_empty: true - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { nil } } - expect(value).to eq skip_field - end - - it 'are excluded with field options set if empty' do - blueprint.field :foo, exclude_if_empty: true - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { [] } } - expect(value).to eq skip_field - end - - it 'are allowed with blueprint options set' do - blueprint.options[:exclude_if_empty] = true - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { 'Foo' } } - expect(value).to eq 'Foo' - end - - it 'are excluded with blueprint options set if nil' do - blueprint.options[:exclude_if_empty] = true - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { nil } } - expect(value).to eq skip_field - end - - it 'are excluded with blueprint options set if empty' do - blueprint.options[:exclude_if_empty] = true - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { [] } } - expect(value).to eq skip_field - end - end - - context 'objects' do - let(:field) { blueprint.reflections[:default].objects[:foo_obj] } - - it 'are allowed by default' do - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { { name: 'Foo' } } } - expect(value).to eq({ name: 'Foo' }) - end - - it 'are allowed by default if nil' do - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { nil } } - expect(value).to be nil - end - - it 'are allowed with options set' do - ctx = prepare(blueprint, { exclude_if_empty: true }, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { { name: 'Foo' } } } - expect(value).to eq({ name: 'Foo' }) - end - - it 'are excluded with options set if nil' do - ctx = prepare(blueprint, { exclude_if_empty: true }, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { nil } } - expect(value).to eq skip_field - end - - it 'are excluded with options set if empty' do - ctx = prepare(blueprint, { exclude_if_empty: true }, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { {} } } - expect(value).to eq skip_field - end - - it 'are allowed with field options set' do - blueprint.association :foo_obj, sub_blueprint, exclude_if_empty: true - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { { name: 'Foo' } } } - expect(value).to eq({ name: 'Foo' }) - end - - it 'are excluded with field options set if nil' do - blueprint.association :foo_obj, sub_blueprint, exclude_if_empty: true - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { nil } } - expect(value).to eq skip_field - end - - it 'are excluded with field options set if empty' do - blueprint.association :foo_obj, sub_blueprint, exclude_if_empty: true - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { {} } } - expect(value).to eq skip_field - end - - it 'are allowed with blueprint options set' do - blueprint.options[:exclude_if_empty] = true - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { { name: 'Foo' } } } - expect(value).to eq({ name: 'Foo' }) - end - - it 'are excluded with blueprint options set if nil' do - blueprint.options[:exclude_if_empty] = true - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { nil } } - expect(value).to eq skip_field - end - - it 'are excluded with blueprint options set if empty' do - blueprint.options[:exclude_if_empty] = true - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { {} } } - expect(value).to eq skip_field - end - end - - context 'collections' do - let(:field) { blueprint.reflections[:default].collections[:foos] } - - it 'are allowed by default' do - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { [{ name: 'Foo' }] } } - expect(value).to eq([{ name: 'Foo' }]) - end - - it 'are allowed by default if nil' do - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { nil } } - expect(value).to be nil - end - - it 'are allowed with options set' do - ctx = prepare(blueprint, { exclude_if_empty: true }, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { [{ name: 'Foo' }] } } - expect(value).to eq([{ name: 'Foo' }]) - end - - it 'are excluded with options set if nil' do - ctx = prepare(blueprint, { exclude_if_empty: true }, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { nil } } - expect(value).to eq skip_field - end - - it 'are excluded with options set if empty' do - ctx = prepare(blueprint, { exclude_if_empty: true }, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { [] } } - expect(value).to eq skip_field - end - - it 'are allowed with field options set' do - blueprint.association :foos, [sub_blueprint], exclude_if_empty: true - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { [{ name: 'Foo' }] } } - expect(value).to eq([{ name: 'Foo' }]) - end - - it 'are excluded with field options set if nil' do - blueprint.association :foos, [sub_blueprint], exclude_if_empty: true - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { nil } } - expect(value).to eq skip_field - end - - it 'are excluded with field options set if empty' do - blueprint.association :foos, [sub_blueprint], exclude_if_empty: true - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { [] } } - expect(value).to eq skip_field - end - - it 'are allowed with blueprint options set' do - blueprint.options[:exclude_if_empty] = true - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { [{ name: 'Foo' }] } } - expect(value).to eq([{ name: 'Foo' }]) - end - - it 'are excluded with blueprint options set if nil' do - blueprint.options[:exclude_if_empty] = true - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { nil } } - expect(value).to eq skip_field - end - - it 'are excluded with blueprint options set if empty' do - blueprint.options[:exclude_if_empty] = true - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { [] } } - expect(value).to eq skip_field - end - end -end diff --git a/spec/v2/extensions/core/exclude_if_nil_spec.rb b/spec/v2/extensions/core/exclude_if_nil_spec.rb deleted file mode 100644 index 9b9282b68..000000000 --- a/spec/v2/extensions/core/exclude_if_nil_spec.rb +++ /dev/null @@ -1,176 +0,0 @@ -# frozen_string_literal: true - -describe Blueprinter::V2::Extensions::Core::Conditionals do - include ExtensionHelpers - let(:object) { { foo: 'Foo' } } - let(:sig) { Blueprinter::V2::Serializer::SIGNAL } - let(:skip_field) { Blueprinter::V2::Serializer::SIG_SKIP } - - context 'fields' do - let(:field) { blueprint.reflections[:default].fields[:foo] } - - it 'are allowed by default' do - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { 'Foo' } } - expect(value).to eq 'Foo' - end - - it 'are allowed by default if nil' do - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { nil } } - expect(value).to be nil - end - - it 'are allowed with options set' do - ctx = prepare(blueprint, { exclude_if_nil: true }, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { 'Foo' } } - expect(value).to eq 'Foo' - end - - it 'are excluded with options set if nil' do - ctx = prepare(blueprint, { exclude_if_nil: true }, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { nil } } - expect(value).to eq skip_field - end - - it 'are allowed with field options set' do - blueprint.field :foo, exclude_if_nil: true - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { 'Foo' } } - expect(value).to eq 'Foo' - end - - it 'are excluded with field options set if nil' do - blueprint.field :foo, exclude_if_nil: true - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { nil } } - expect(value).to eq skip_field - end - - it 'are allowed with blueprint options set' do - blueprint.options[:exclude_if_nil] = true - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { 'Foo' } } - expect(value).to eq 'Foo' - end - - it 'are excluded with blueprint options set if nil' do - blueprint.options[:exclude_if_nil] = true - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { nil } } - expect(value).to eq skip_field - end - end - - context 'objects' do - let(:field) { blueprint.reflections[:default].objects[:foo_obj] } - - it 'are allowed by default' do - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { { name: 'Foo' } } } - expect(value).to eq({ name: 'Foo' }) - end - - it 'are allowed by default if nil' do - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { nil } } - expect(value).to be nil - end - - it 'are allowed with options set' do - ctx = prepare(blueprint, { exclude_if_nil: true }, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { { name: 'Foo' } } } - expect(value).to eq({ name: 'Foo' }) - end - - it 'are excluded with options set if nil' do - ctx = prepare(blueprint, { exclude_if_nil: true }, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { nil } } - expect(value).to eq skip_field - end - - it 'are allowed with field options set' do - blueprint.association :foo_obj, sub_blueprint, exclude_if_nil: true - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { { name: 'Foo' } } } - expect(value).to eq({ name: 'Foo' }) - end - - it 'are excluded with field options set if nil' do - blueprint.association :foo_obj, sub_blueprint, exclude_if_nil: true - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { nil } } - expect(value).to eq skip_field - end - - it 'are allowed with blueprint options set' do - blueprint.options[:exclude_if_nil] = true - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { { name: 'Foo' } } } - expect(value).to eq({ name: 'Foo' }) - end - - it 'are excluded with blueprint options set if nil' do - blueprint.options[:exclude_if_nil] = true - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { nil } } - expect(value).to eq skip_field - end - end - - context 'collections' do - let(:field) { blueprint.reflections[:default].collections[:foos] } - - it 'are allowed by default' do - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { [{ name: 'Foo' }] } } - expect(value).to eq([{ name: 'Foo' }]) - end - - it 'are allowed by default if nil' do - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { nil } } - expect(value).to be nil - end - - it 'are allowed with options set' do - ctx = prepare(blueprint, { exclude_if_nil: true }, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { [{ name: 'Foo' }] } } - expect(value).to eq([{ name: 'Foo' }]) - end - - it 'are excluded with options set if nil' do - ctx = prepare(blueprint, { exclude_if_nil: true }, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { nil } } - expect(value).to eq skip_field - end - - it 'are allowed with field options set' do - blueprint.association :foos, [sub_blueprint], exclude_if_nil: true - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { [{ name: 'Foo' }] } } - expect(value).to eq([{ name: 'Foo' }]) - end - - it 'are excluded with field options set if nil' do - blueprint.association :foos, [sub_blueprint], exclude_if_nil: true - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { nil } } - expect(value).to eq skip_field - end - - it 'are allowed with blueprint options set' do - blueprint.options[:exclude_if_nil] = true - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { [{ name: 'Foo' }] } } - expect(value).to eq([{ name: 'Foo' }]) - end - - it 'are excluded with blueprint options set if nil' do - blueprint.options[:exclude_if_nil] = true - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { nil } } - expect(value).to eq skip_field - end - end -end diff --git a/spec/v2/extensions/core/json_spec.rb b/spec/v2/extensions/core/format_spec.rb similarity index 95% rename from spec/v2/extensions/core/json_spec.rb rename to spec/v2/extensions/core/format_spec.rb index d9b39c3cb..08b2d8757 100644 --- a/spec/v2/extensions/core/json_spec.rb +++ b/spec/v2/extensions/core/format_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -describe Blueprinter::V2::Extensions::Core::Json do +describe Blueprinter::V2::Extensions::Core::Format do subject { described_class.new } let(:context) { Blueprinter::V2::Context::Result } let(:object) { { name: 'Foo' } } diff --git a/spec/v2/extensions/core/if_conditionals_spec.rb b/spec/v2/extensions/core/if_conditionals_spec.rb deleted file mode 100644 index 044fbdfc7..000000000 --- a/spec/v2/extensions/core/if_conditionals_spec.rb +++ /dev/null @@ -1,212 +0,0 @@ -# frozen_string_literal: true - -describe Blueprinter::V2::Extensions::Core::Conditionals do - include ExtensionHelpers - let(:object) { { foo: 'Foo' } } - let(:sig) { Blueprinter::V2::Serializer::SIGNAL } - let(:skip_field) { Blueprinter::V2::Serializer::SIG_SKIP } - - context 'fields' do - let(:field) { blueprint.reflections[:default].fields[:foo] } - - it 'are allowed by default' do - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_object_value(ctx) { 'Foo' } } - expect(value).to eq 'Foo' - end - - it 'checks options field_if (Proc)' do - ctx = prepare(blueprint, { field_if: ->(val, ctx) { foo? val, ctx } }, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_object_value(ctx) { 'Foo' } } - expect(value).to eq 'Foo' - - value = catch(sig) { subject.around_object_value(ctx) { 'Bar' } } - expect(value).to eq skip_field - end - - it 'checks field options if (Proc)' do - blueprint.field :foo, if: ->(val, ctx) { foo? val, ctx } - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_object_value(ctx) { 'Foo' } } - expect(value).to eq 'Foo' - - value = catch(sig) { subject.around_object_value(ctx) { 'Bar' } } - expect(value).to eq skip_field - end - - it 'checks blueprint options field_if (Proc)' do - blueprint.options[:field_if] = ->(val, ctx) { foo? val, ctx } - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_object_value(ctx) { 'Foo' } } - expect(value).to eq 'Foo' - - value = catch(sig) { subject.around_object_value(ctx) { 'Bar' } } - expect(value).to eq skip_field - end - - it 'checks options field_if (Symbol)' do - ctx = prepare(blueprint, { field_if: :foo? }, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_object_value(ctx) { 'Foo' } } - expect(value).to eq 'Foo' - - value = catch(sig) { subject.around_object_value(ctx) { 'Bar' } } - expect(value).to eq skip_field - end - - it 'checks field options if (Symbol)' do - blueprint.field :foo, if: :foo? - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_object_value(ctx) { 'Foo' } } - expect(value).to eq 'Foo' - - value = catch(sig) { subject.around_object_value(ctx) { 'Bar' } } - expect(value).to eq skip_field - end - - it 'checks blueprint options field_if (Symbol)' do - blueprint.options[:field_if] = :foo? - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_object_value(ctx) { 'Foo' } } - expect(value).to eq 'Foo' - - value = catch(sig) { subject.around_object_value(ctx) { 'Bar' } } - expect(value).to eq skip_field - end - end - - context 'objects' do - let(:field) { blueprint.reflections[:default].objects[:foo_obj] } - - it 'are allowed by default' do - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_object_value(ctx) { { name: 'Foo' } } } - expect(value).to eq({ name: 'Foo' }) - end - - it 'checks options object_if (Proc)' do - ctx = prepare(blueprint, { object_if: ->(val, ctx) { name_foo? val, ctx } }, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_object_value(ctx) { { name: 'Foo' } } } - expect(value).to eq({ name: 'Foo' }) - - value = catch(sig) { subject.around_object_value(ctx) { { name: 'Bar' } } } - expect(value).to eq skip_field - end - - it 'checks field options if (Proc)' do - blueprint.association :foo_obj, sub_blueprint, if: ->(val, ctx) { name_foo? val, ctx } - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_object_value(ctx) { { name: 'Foo' } } } - expect(value).to eq({ name: 'Foo' }) - - value = catch(sig) { subject.around_object_value(ctx) { { name: 'Bar' } } } - expect(value).to eq skip_field - end - - it 'checks blueprint options object_if (Proc)' do - blueprint.options[:object_if] = ->(val, ctx) { name_foo? val, ctx } - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_object_value(ctx) { { name: 'Foo' } } } - expect(value).to eq({ name: 'Foo' }) - - value = catch(sig) { subject.around_object_value(ctx) { { name: 'Bar' } } } - expect(value).to eq skip_field - end - - it 'checks options object_if (Symbol)' do - ctx = prepare(blueprint, { object_if: :name_foo? }, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_object_value(ctx) { { name: 'Foo' } } } - expect(value).to eq({ name: 'Foo' }) - - value = catch(sig) { subject.around_object_value(ctx) { { name: 'Bar' } } } - expect(value).to eq skip_field - end - - it 'checks field options if (Symbol)' do - blueprint.association :foo_obj, sub_blueprint, if: :name_foo? - ctx = prepare(blueprint, { object_if: :name_foo? }, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_object_value(ctx) { { name: 'Foo' } } } - expect(value).to eq({ name: 'Foo' }) - - value = catch(sig) { subject.around_object_value(ctx) { { name: 'Bar' } } } - expect(value).to eq skip_field - end - - it 'checks blueprint options object_if (Symbol)' do - blueprint.options[:object_if] = :name_foo? - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_object_value(ctx) { { name: 'Foo' } } } - expect(value).to eq({ name: 'Foo' }) - - value = catch(sig) { subject.around_object_value(ctx) { { name: 'Bar' } } } - expect(value).to eq skip_field - end - end - - context 'collections' do - let(:field) { blueprint.reflections[:default].collections[:foos] } - - it 'are allowed by default' do - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_object_value(ctx) { [{ name: 'Foo' }] } } - expect(value).to eq [{ name: 'Foo' }] - end - - it 'checks options collection_if (Proc)' do - ctx = prepare(blueprint, { collection_if: ->(val, ctx) { names_foo? val, ctx } }, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_object_value(ctx) { [{ name: 'Foo' }] } } - expect(value).to eq [{ name: 'Foo' }] - - value = catch(sig) { subject.around_object_value(ctx) { [{ name: 'Bar' }] } } - expect(value).to eq skip_field - end - - it 'checks field options if (Proc)' do - blueprint.association :foos, [sub_blueprint], if: ->(val, ctx) { names_foo? val, ctx } - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_object_value(ctx) { [{ name: 'Foo' }] } } - expect(value).to eq [{ name: 'Foo' }] - - value = catch(sig) { subject.around_object_value(ctx) { [{ name: 'Bar' }] } } - expect(value).to eq skip_field - end - - it 'checks blueprint options collection_if (Proc)' do - blueprint.options[:collection_if] = ->(val, ctx) { names_foo? val, ctx } - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_object_value(ctx) { [{ name: 'Foo' }] } } - expect(value).to eq [{ name: 'Foo' }] - - value = catch(sig) { subject.around_object_value(ctx) { [{ name: 'Bar' }] } } - expect(value).to eq skip_field - end - - it 'checks options collection_if (Symbol)' do - ctx = prepare(blueprint, { collection_if: :names_foo? }, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_object_value(ctx) { [{ name: 'Foo' }] } } - expect(value).to eq [{ name: 'Foo' }] - - value = catch(sig) { subject.around_object_value(ctx) { [{ name: 'Bar' }] } } - expect(value).to eq skip_field - end - - it 'checks field options if (Symbol)' do - blueprint.association :foos, [sub_blueprint], if: :names_foo? - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_object_value(ctx) { [{ name: 'Foo' }] } } - expect(value).to eq [{ name: 'Foo' }] - - value = catch(sig) { subject.around_object_value(ctx) { [{ name: 'Bar' }] } } - expect(value).to eq skip_field - end - - it 'checks blueprint options collection_if (Symbol)' do - blueprint.options[:collection_if] = :names_foo? - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_object_value(ctx) { [{ name: 'Foo' }] } } - expect(value).to eq [{ name: 'Foo' }] - - value = catch(sig) { subject.around_object_value(ctx) { [{ name: 'Bar' }] } } - expect(value).to eq skip_field - end - end -end diff --git a/spec/v2/extensions/core/unless_conditionals_spec.rb b/spec/v2/extensions/core/unless_conditionals_spec.rb deleted file mode 100644 index 3918773b1..000000000 --- a/spec/v2/extensions/core/unless_conditionals_spec.rb +++ /dev/null @@ -1,212 +0,0 @@ -# frozen_string_literal: true - -describe Blueprinter::V2::Extensions::Core::Conditionals do - include ExtensionHelpers - let(:object) { { foo: 'Foo' } } - let(:sig) { Blueprinter::V2::Serializer::SIGNAL } - let(:skip_field) { Blueprinter::V2::Serializer::SIG_SKIP } - - context 'fields' do - let(:field) { blueprint.reflections[:default].fields[:foo] } - - it 'are allowed by default' do - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { 'Foo' } } - expect(value).to eq 'Foo' - end - - it 'checks options field_unless (Proc)' do - ctx = prepare(blueprint, { field_unless: ->(val, ctx) { foo? val, ctx } }, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { 'Foo' } } - expect(value).to eq skip_field - - value = catch(sig) { subject.around_field_value(ctx) { 'Bar' } } - expect(value).to eq 'Bar' - end - - it 'checks field options unless (Proc)' do - blueprint.field :foo, unless: ->(val, ctx) { foo? val, ctx } - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { 'Foo' } } - expect(value).to eq skip_field - - value = catch(sig) { subject.around_field_value(ctx) { 'Bar' } } - expect(value).to eq 'Bar' - end - - it 'checks blueprint options field_unless (Proc)' do - blueprint.options[:field_unless] = ->(val, ctx) { foo? val, ctx } - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { 'Foo' } } - expect(value).to eq skip_field - - value = catch(sig) { subject.around_field_value(ctx) { 'Bar' } } - expect(value).to eq 'Bar' - end - - it 'checks options field_unless (Symbol)' do - ctx = prepare(blueprint, { field_unless: :foo? }, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { 'Foo' } } - expect(value).to eq skip_field - - value = catch(sig) { subject.around_field_value(ctx) { 'Bar' } } - expect(value).to eq 'Bar' - end - - it 'checks field options unless (Symbol)' do - blueprint.field :foo, unless: :foo? - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { 'Foo' } } - expect(value).to eq skip_field - - value = catch(sig) { subject.around_field_value(ctx) { 'Bar' } } - expect(value).to eq 'Bar' - end - - it 'checks blueprint options field_unless (Symbol)' do - blueprint.options[:field_unless] = :foo? - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { 'Foo' } } - expect(value).to eq skip_field - - value = catch(sig) { subject.around_field_value(ctx) { 'Bar' } } - expect(value).to eq 'Bar' - end - end - - context 'objects' do - let(:field) { blueprint.reflections[:default].objects[:foo_obj] } - - it 'are allowed by default' do - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { 'Foo' } } - expect(value).to eq 'Foo' - end - - it 'checks options object_unless (Proc)' do - ctx = prepare(blueprint, { object_unless: ->(val, ctx) { name_foo? val, ctx } }, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { { name: 'Foo' } } } - expect(value).to eq skip_field - - value = catch(sig) { subject.around_field_value(ctx) { { name: 'Bar' } } } - expect(value).to eq({ name: 'Bar' }) - end - - it 'checks field options unless (Proc)' do - blueprint.association :foo_obj, sub_blueprint, unless: ->(val, ctx) { name_foo? val, ctx } - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { { name: 'Foo' } } } - expect(value).to eq skip_field - - value = catch(sig) { subject.around_field_value(ctx) { { name: 'Bar' } } } - expect(value).to eq({ name: 'Bar' }) - end - - it 'checks blueprint options object_unless (Proc)' do - blueprint.options[:object_unless] = ->(val, ctx) { name_foo? val, ctx } - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { { name: 'Foo' } } } - expect(value).to eq skip_field - - value = catch(sig) { subject.around_field_value(ctx) { { name: 'Bar' } } } - expect(value).to eq({ name: 'Bar' }) - end - - it 'checks options object_unless (Symbol)' do - ctx = prepare(blueprint, { object_unless: :name_foo? }, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { { name: 'Foo' } } } - expect(value).to eq skip_field - - value = catch(sig) { subject.around_field_value(ctx) { { name: 'Bar' } } } - expect(value).to eq({ name: 'Bar' }) - end - - it 'checks field options unless (Symbol)' do - blueprint.association :foo_obj, sub_blueprint, unless: :name_foo? - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { { name: 'Foo' } } } - expect(value).to eq skip_field - - value = catch(sig) { subject.around_field_value(ctx) { { name: 'Bar' } } } - expect(value).to eq({ name: 'Bar' }) - end - - it 'checks blueprint options object_unless (Symbol)' do - blueprint.options[:object_unless] = :name_foo? - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { { name: 'Foo' } } } - expect(value).to eq skip_field - - value = catch(sig) { subject.around_field_value(ctx) { { name: 'Bar' } } } - expect(value).to eq({ name: 'Bar' }) - end - end - - context 'collections' do - let(:field) { blueprint.reflections[:default].collections[:foos] } - - it 'are allowed by default' do - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { [{ name: 'Foo' }] } } - expect(value).to eq([{ name: 'Foo' }]) - end - - it 'checks options collection_unless (Proc)' do - ctx = prepare(blueprint, { collection_unless: ->(val, ctx) { names_foo? val, ctx } }, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { [{ name: 'Foo' }] } } - expect(value).to eq skip_field - - value = catch(sig) { subject.around_field_value(ctx) { [{ name: 'Bar' }] } } - expect(value).to eq([{ name: 'Bar' }]) - end - - it 'checks field options unless (Proc)' do - blueprint.association :foos, [sub_blueprint], unless: ->(val, ctx) { names_foo? val, ctx } - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { [{ name: 'Foo' }] } } - expect(value).to eq skip_field - - value = catch(sig) { subject.around_field_value(ctx) { [{ name: 'Bar' }] } } - expect(value).to eq([{ name: 'Bar' }]) - end - - it 'checks blueprint options collection_unless (Proc)' do - blueprint.options[:collection_unless] = ->(val, ctx) { names_foo? val, ctx } - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { [{ name: 'Foo' }] } } - expect(value).to eq skip_field - - value = catch(sig) { subject.around_field_value(ctx) { [{ name: 'Bar' }] } } - expect(value).to eq([{ name: 'Bar' }]) - end - - it 'checks options collection_unless (Symbol)' do - ctx = prepare(blueprint, { collection_unless: :names_foo? }, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { [{ name: 'Foo' }] } } - expect(value).to eq skip_field - - value = catch(sig) { subject.around_field_value(ctx) { [{ name: 'Bar' }] } } - expect(value).to eq([{ name: 'Bar' }]) - end - - it 'checks field options unless (Symbol)' do - blueprint.association :foos, [sub_blueprint], unless: :names_foo? - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { [{ name: 'Foo' }] } } - expect(value).to eq skip_field - - value = catch(sig) { subject.around_field_value(ctx) { [{ name: 'Bar' }] } } - expect(value).to eq([{ name: 'Bar' }]) - end - - it 'checks blueprint options collection_unless (Symbol)' do - blueprint.options[:collection_unless] = :names_foo? - ctx = prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field) - value = catch(sig) { subject.around_field_value(ctx) { [{ name: 'Foo' }] } } - expect(value).to eq skip_field - - value = catch(sig) { subject.around_field_value(ctx) { [{ name: 'Bar' }] } } - expect(value).to eq([{ name: 'Bar' }]) - end - end -end diff --git a/spec/v2/extensions/core/wrapper_spec.rb b/spec/v2/extensions/core/wrapper_spec.rb deleted file mode 100644 index a0688bd08..000000000 --- a/spec/v2/extensions/core/wrapper_spec.rb +++ /dev/null @@ -1,75 +0,0 @@ -# frozen_string_literal: true - -describe Blueprinter::V2::Extensions::Core::Wrapper do - subject { described_class.new } - let(:context) { Blueprinter::V2::Context::Result } - let(:object) { { name: 'Foo' } } - let(:fields) { blueprint.reflections[:default].ordered } - let(:blueprint) do - Class.new(Blueprinter::V2::Base) do - field :name - - def meta_links - { links: [] } - end - end - end - - it 'passes through the result by default' do - ctx = context.new(blueprint.new, fields, {}, object, :json) - result = subject.around_result(ctx) { |ctx| ctx.object } - expect(result).to eq({ name: 'Foo' }) - end - - it 'looks for a root option in the blueprint' do - blueprint.options[:root] = :data - ctx = context.new(blueprint.new, fields, {}, object, :json) - result = subject.around_result(ctx) { |ctx| ctx.object } - expect(result).to eq({ data: { name: 'Foo' } }) - end - - it 'looks for a root option in the options over the blueprint' do - blueprint.options[:root] = :data - ctx = context.new(blueprint.new, fields, { root: :root }, object, :json) - result = subject.around_result(ctx) { |ctx| ctx.object } - expect(result).to eq({ root: { name: 'Foo' } }) - end - - it 'allows a false root option to override the blueprint' do - blueprint.options[:root] = :data - ctx = context.new(blueprint.new, fields, { root: false }, object, :json) - result = subject.around_result(ctx) { |ctx| ctx.object } - expect(result).to eq({ name: 'Foo' }) - end - - it 'looks for a meta option in the blueprint' do - blueprint.options[:root] = :data - blueprint.options[:meta] = { links: [] } - ctx = context.new(blueprint.new, fields, {}, object, :json) - result = subject.around_result(ctx) { |ctx| ctx.object } - expect(result).to eq({ data: { name: 'Foo' }, meta: { links: [] } }) - end - - it 'looks for a meta option in the options over the blueprint' do - blueprint.options[:root] = :data - blueprint.options[:meta] = { links: [] } - ctx = context.new(blueprint.new, fields, { root: :root, meta: { linkz: [] }}, object, 1) - result = subject.around_result(ctx) { |ctx| ctx.object } - expect(result).to eq({ root: { name: 'Foo' }, meta: { linkz: [] } }) - end - - it 'looks for a meta Proc option in the blueprint' do - blueprint.options[:root] = :data - blueprint.options[:meta] = ->(ctx) { meta_links } - ctx = context.new(blueprint.new, fields, {}, object, :json) - result = subject.around_result(ctx) { |ctx| ctx.object } - expect(result).to eq({ data: { name: 'Foo' }, meta: { links: [] } }) - end - - it 'looks for a meta Proc option in the options' do - blueprint.options[:root] = :data - ctx = context.new(blueprint.new, fields, { root: :root, meta: ->(ctx) { meta_links } }, object, :json) - result = subject.around_result(ctx) { |ctx| ctx.object } - expect(result).to eq({ root: { name: 'Foo' }, meta: { links: [] } }) - end -end diff --git a/spec/v2/extraction_spec.rb b/spec/v2/extraction_spec.rb index 845e98d70..beae13971 100644 --- a/spec/v2/extraction_spec.rb +++ b/spec/v2/extraction_spec.rb @@ -6,35 +6,82 @@ field :foo end end + let(:store) { {} } let(:instances) { Blueprinter::V2::InstanceCache.new } - let(:serializer) { Blueprinter::V2::Serializer.new(blueprint, {}, instances, store: {}, initial_depth: 1) } + let(:serializer) { blueprint.serializer } it 'extracts from a Symbol Hash' do object = { foo: 'Foo' } - result = serializer.object(object, depth: 1) + result = serializer.object(object, {}, instances:, store:, depth: 1) expect(result).to eq({ foo: 'Foo' }) end it 'extracts from a String Hash' do object = { 'foo' => 'Foo' } - result = serializer.object(object, depth: 1) + result = serializer.object(object, {}, instances:, store:, depth: 1) expect(result).to eq({ foo: 'Foo' }) end it 'extracts from an object' do object = Struct.new(:foo).new('Foo') - result = serializer.object(object, depth: 1) + result = serializer.object(object, {}, instances:, store:, depth: 1) expect(result).to eq({ foo: 'Foo' }) end - it 'extracts using a proc' do + it 'extracts using a block with zero args' do blueprint = Class.new(Blueprinter::V2::Base) do - field(:foo) { |obj, _ctx| "#{obj[:foo]}!" } + field(:foo) { "!" } end - serializer = Blueprinter::V2::Serializer.new(blueprint, {}, instances, store: {}, initial_depth: 1) object = { foo: 'Foo' } - result = serializer.object(object, depth: 1) + result = blueprint.serializer.object(object, {}, instances:, store:, depth: 1) + expect(result).to eq({ foo: '!' }) + end + + it 'extracts using a block with one arg' do + blueprint = Class.new(Blueprinter::V2::Base) do + field(:foo) { |obj| "#{obj[:foo]}!" } + end + + object = { foo: 'Foo' } + result = blueprint.serializer.object(object, {}, instances:, store:, depth: 1) + expect(result).to eq({ foo: 'Foo!' }) + end + + it 'extracts using a block with two args' do + blueprint = Class.new(Blueprinter::V2::Base) do + field(:foo) { |obj, ctx| "#{obj[ctx.field.source]}!" } + end + + object = { foo: 'Foo' } + result = blueprint.serializer.object(object, {}, instances:, store:, depth: 1) + expect(result).to eq({ foo: 'Foo!' }) + end + + it 'extracts using a block with one+ args' do + blueprint = Class.new(Blueprinter::V2::Base) do + field(:foo) do |obj, *args| + ctx = args[0] + "#{obj[ctx.field.source]}!" + end + end + + object = { foo: 'Foo' } + result = blueprint.serializer.object(object, {}, instances:, store:, depth: 1) + expect(result).to eq({ foo: 'Foo!' }) + end + + it 'extracts using a block with N args' do + blueprint = Class.new(Blueprinter::V2::Base) do + field(:foo) do |*args| + obj = args[0] + ctx = args[1] + "#{obj[ctx.field.source]}!" + end + end + + object = { foo: 'Foo' } + result = blueprint.serializer.object(object, {}, instances:, store:, depth: 1) expect(result).to eq({ foo: 'Foo!' }) end end diff --git a/spec/v2/field_logic/defaults_spec.rb b/spec/v2/field_logic/defaults_spec.rb new file mode 100644 index 000000000..49ad95b72 --- /dev/null +++ b/spec/v2/field_logic/defaults_spec.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +describe Blueprinter::V2::FieldLogic do + include ExtensionHelpers + + let(:subject) { described_class } + + context "value_or_default" do + let(:field) { blueprint.reflections[:default].fields.fetch(:foo) } + let(:object) { { foo: 'Foo' } } + let(:ctx) { prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field, {}) } + + it 'passes values through by default' do + ctx + value = subject.value_or_default(field, 'Foo') + expect(value).to eq 'Foo' + end + + it 'passes values through by with defaults given' do + blueprint.field :foo, default: 'Bar' + ctx + value = subject.value_or_default(field, 'Foo') + expect(value).to eq 'Foo' + end + + it 'passes values through with false default_ifs given' do + blueprint.field :foo, default: 'Bar', default_if: ->(_, _) { false } + value = subject.value_or_default(field, 'Foo', ctx:) + expect(value).to eq 'Foo' + end + + it 'passes nil through by default' do + object[:foo] = nil + ctx + value = subject.value_or_default(field, nil) + expect(value).to be_nil + end + + it 'uses field options default' do + object[:foo] = nil + blueprint.field :foo, default: 'Bar' + value = subject.value_or_default(field, nil, ctx:) + expect(value).to eq 'Bar' + end + + it 'uses field options default (Proc)' do + object[:foo] = nil + blueprint.field :foo, default: ->(ctx) do + was_val = ctx.object[ctx.field.source] + "Bar (was #{was_val.inspect})" + end + value = subject.value_or_default(field, nil, ctx:) + expect(value).to eq 'Bar (was nil)' + end + + it 'uses field options default (Symbol)' do + object[:foo] = nil + blueprint.field :foo, default: :was + value = subject.value_or_default(field, nil, ctx:) + expect(value).to eq 'was nil' + end + + it 'checks with field options default_if (Proc) (default = field options default)' do + blueprint.field :foo, default: 'Bar', default_if: ->(_ctx, val) { val == 'Foo' } + value = subject.value_or_default(field, 'Foo', ctx:) + expect(value).to eq 'Bar' + end + + it 'checks with field options default_if (Symbol) (default = blueprint options default)' do + blueprint.set :default, 'Bar' + blueprint.field :foo, default_if: ->(_ctx, val) { val == 'Foo' } + value = subject.value_or_default(field, 'Foo', ctx:) + expect(value).to eq 'Bar' + end + + it 'checks with blueprint options default_if (Proc) (default = field options default)' do + blueprint.set :default_if, ->(_ctx, val) { val == 'Foo' } + blueprint.field :foo, default: 'Bar' + value = subject.value_or_default(field, 'Foo', ctx:) + expect(value).to eq 'Bar' + end + + it 'checks with blueprint options default_if (Symbol) (default = blueprint options default)' do + blueprint.set :default, 'Bar' + blueprint.set :default_if, ->(_ctx, val) { val == 'Foo' } + blueprint.field :foo + value = subject.value_or_default(field, 'Foo', ctx:) + expect(value).to eq 'Bar' + end + end +end diff --git a/spec/v2/field_logic/exclude_if_nil_spec.rb b/spec/v2/field_logic/exclude_if_nil_spec.rb new file mode 100644 index 000000000..f5a44bda4 --- /dev/null +++ b/spec/v2/field_logic/exclude_if_nil_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +describe Blueprinter::V2::FieldLogic do + include ExtensionHelpers + + let(:subject) { blueprint.serializer } + let(:object) { { foo: 'Foo' } } + + # This code now lives in Serializer for perf reasons, but the tests are here since it's still "field logic" + context 'exclude_if_nil' do + it 'allows by default' do + res = subject.object(object, {}, instances:, store:, depth: 1) + expect(res).to include(:foo) + end + + it 'checks field options (true)' do + blueprint.field :foo, exclude_if_nil: true + res = subject.object(object, {}, instances:, store:, depth: 1) + expect(res).to include(:foo) + + object[:foo] = nil + res = subject.object(object, {}, instances:, store:, depth: 1) + expect(res).to_not include(:foo) + end + + it 'checks field options (false)' do + blueprint.field :foo, exclude_if_nil: false + res = subject.object(object, {}, instances:, store:, depth: 1) + expect(res).to include(:foo) + + object[:foo] = nil + res = subject.object(object, {}, instances:, store:, depth: 1) + expect(res).to include(:foo) + end + + it 'checks blueprint options (true)' do + blueprint.set :exclude_if_nil, true + res = subject.object(object, {}, instances:, store:, depth: 1) + expect(res).to include(:foo) + + object[:foo] = nil + res = subject.object(object, {}, instances:, store:, depth: 1) + expect(res).to_not include(:foo) + end + + it 'checks blueprint options (false)' do + blueprint.set :exclude_if_nil, false + res = subject.object(object, {}, instances:, store:, depth: 1) + expect(res).to include(:foo) + + object[:foo] = nil + res = subject.object(object, {}, instances:, store:, depth: 1) + expect(res).to include(:foo) + end + + it 'field options take priority over blueprint options' do + blueprint.set :exclude_if_nil, true + blueprint.field :foo, exclude_if_nil: false + res = subject.object(object, {}, instances:, store:, depth: 1) + expect(res).to include(:foo) + + object[:foo] = nil + res = subject.object(object, {}, instances:, store:, depth: 1) + expect(res).to include(:foo) + end + end +end diff --git a/spec/v2/field_logic/if_conditionals_spec.rb b/spec/v2/field_logic/if_conditionals_spec.rb new file mode 100644 index 000000000..b66f6954e --- /dev/null +++ b/spec/v2/field_logic/if_conditionals_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +describe Blueprinter::V2::FieldLogic do + include ExtensionHelpers + + let(:subject) { described_class } + let(:object) { { foo: 'Foo' } } + let(:field_ref) { blueprint.reflections[:default].fields[:foo] } + let(:field) { blueprint.spec.schema.fetch(:foo) } + let(:ctx) { prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field_ref) } + + context 'skip? (if)' do + it 'allows by default' do + skip = subject.skip?(ctx, field) + expect(skip).to be false + end + + it 'checks field options (Proc)' do + blueprint.field :foo, if: ->(ctx) { ctx.object[ctx.field.source] == 'Foo' } + skip = subject.skip?(ctx, field) + expect(skip).to be false + + object[:foo] = 'Bar' + skip = subject.skip?(ctx, field) + expect(skip).to be true + end + + it 'checks field options (Symbol)' do + blueprint.field :foo, if: :foo? + skip = subject.skip?(ctx, field) + expect(skip).to be false + + object[:foo] = 'Bar' + skip = subject.skip?(ctx, field) + expect(skip).to be true + end + + it 'checks blueprint options (Proc)' do + blueprint.set :if, ->(ctx) { ctx.object[ctx.field.source] == 'Foo' } + skip = subject.skip?(ctx, field) + expect(skip).to be false + + object[:foo] = 'Bar' + skip = subject.skip?(ctx, field) + expect(skip).to be true + end + + it 'checks blueprint options (Symbol)' do + blueprint.set :if, :foo? + skip = subject.skip?(ctx, field) + expect(skip).to be false + + object[:foo] = 'Bar' + skip = subject.skip?(ctx, field) + expect(skip).to be true + end + + it 'field options take priority over blueprint options' do + blueprint.set :if, ->(_ctx) { false } + blueprint.field :foo, if: :foo? + skip = subject.skip?(ctx, field) + expect(skip).to be false + + object[:foo] = 'Bar' + skip = subject.skip?(ctx, field) + expect(skip).to be true + end + end +end diff --git a/spec/v2/field_logic/unless_conditionals_spec.rb b/spec/v2/field_logic/unless_conditionals_spec.rb new file mode 100644 index 000000000..794d007db --- /dev/null +++ b/spec/v2/field_logic/unless_conditionals_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +describe Blueprinter::V2::FieldLogic do + include ExtensionHelpers + + let(:subject) { described_class } + let(:object) { { foo: 'Foo' } } + let(:field_ref) { blueprint.reflections[:default].fields[:foo] } + let(:field) { blueprint.spec.schema.fetch(:foo) } + let(:ctx) { prepare(blueprint, {}, Blueprinter::V2::Context::Field, object, field_ref) } + + context 'skip? (unless)' do + it 'allows by default' do + skip = subject.skip?(ctx, field) + expect(skip).to be false + end + + it 'checks field options (Proc)' do + blueprint.field :foo, unless: ->(ctx) { ctx.object[ctx.field.source] == 'Foo' } + skip = subject.skip?(ctx, field) + expect(skip).to be true + + object[:foo] = 'Bar' + skip = subject.skip?(ctx, field) + expect(skip).to be false + end + + it 'checks field options (Symbol)' do + blueprint.field :foo, unless: :foo? + skip = subject.skip?(ctx, field) + expect(skip).to be true + + object[:foo] = 'Bar' + skip = subject.skip?(ctx, field) + expect(skip).to be false + end + + it 'checks blueprint options (Proc)' do + blueprint.set :unless, ->(ctx) { ctx.object[ctx.field.source] == 'Foo' } + skip = subject.skip?(ctx, field) + expect(skip).to be true + + object[:foo] = 'Bar' + skip = subject.skip?(ctx, field) + expect(skip).to be false + end + + it 'checks blueprint options (Symbol)' do + blueprint.set :unless, :foo? + skip = subject.skip?(ctx, field) + expect(skip).to be true + + object[:foo] = 'Bar' + skip = subject.skip?(ctx, field) + expect(skip).to be false + end + + it 'field options take priority over blueprint options' do + blueprint.set :unless, ->(_ctx) { false } + blueprint.field :foo, unless: :foo? + skip = subject.skip?(ctx, field) + expect(skip).to be true + + object[:foo] = 'Bar' + skip = subject.skip?(ctx, field) + expect(skip).to be false + end + end +end diff --git a/spec/v2/fields_spec.rb b/spec/v2/fields_spec.rb index 782a8397a..c16bae60e 100644 --- a/spec/v2/fields_spec.rb +++ b/spec/v2/fields_spec.rb @@ -7,41 +7,43 @@ it "adds fields with options" do blueprint = Class.new(Blueprinter::V2::Base) do field :name - field :description, from: :desc, if: -> { true } + field :description, source: :desc, if: ->(_ctx) { true } field(:foo) { "foo" } end ref = blueprint.reflections[:default] - expect(ref.fields[:name].class.name).to eq "Blueprinter::V2::Fields::Field" + expect(ref.fields[:name].type).to eq :field expect(ref.fields[:name].name).to eq :name - expect(ref.fields[:name].from).to eq :name + expect(ref.fields[:name].source).to eq :name expect(ref.fields[:description].name).to eq :description - expect(ref.fields[:description].from).to eq :desc + expect(ref.fields[:description].source).to eq :desc expect(ref.fields[:description].options[:if].class.name).to eq "Proc" expect(ref.fields[:foo].name).to eq :foo expect(ref.fields[:foo].value_proc.class.name).to eq "Proc" end - it 'adds multiple fields' do + it 'adds multiple fields with options' do blueprint = Class.new(Blueprinter::V2::Base) do - fields :name, :description, :status + fields :name, :description, exclude_if_nil: true + fields(:status) { "foo" } end ref = blueprint.reflections[:default] - expect(ref.fields[:name].class.name).to eq "Blueprinter::V2::Fields::Field" + expect(ref.fields[:name].type).to eq :field expect(ref.fields[:name].name).to eq :name - expect(ref.fields[:name].from).to eq :name - expect(ref.fields[:name].options).to eq({}) + expect(ref.fields[:name].source).to eq :name + expect(ref.fields[:name].options).to eq({ exclude_if_nil: true }) - expect(ref.fields[:description].class.name).to eq "Blueprinter::V2::Fields::Field" + expect(ref.fields[:description].type).to eq :field expect(ref.fields[:description].name).to eq :description - expect(ref.fields[:description].from).to eq :description - expect(ref.fields[:description].options).to eq({}) + expect(ref.fields[:description].source).to eq :description + expect(ref.fields[:description].options).to eq({ exclude_if_nil: true }) - expect(ref.fields[:status].class.name).to eq "Blueprinter::V2::Fields::Field" + expect(ref.fields[:status].type).to eq :field expect(ref.fields[:status].name).to eq :status - expect(ref.fields[:status].from).to eq :status + expect(ref.fields[:status].source).to eq :status expect(ref.fields[:status].options).to eq({}) + expect(ref.fields[:status].value_proc).to_not be nil end end @@ -51,17 +53,17 @@ widget_blueprint = Class.new(Blueprinter::V2::Base) blueprint = Class.new(Blueprinter::V2::Base) do association :category, category_blueprint - association :widgets, [widget_blueprint], from: :foo, if: -> { true } + association :widgets, [widget_blueprint], source: :foo, if: -> { true } association(:foo, widget_blueprint) { {foo: "bar"} } end ref = blueprint.reflections[:default] - expect(ref.objects[:category].class.name).to eq "Blueprinter::V2::Fields::Object" + expect(ref.objects[:category].type).to eq :object expect(ref.objects[:category].name).to eq :category - expect(ref.objects[:category].from).to eq :category + expect(ref.objects[:category].source).to eq :category expect(ref.objects[:category].blueprint).to eq category_blueprint expect(ref.collections[:widgets].name).to eq :widgets - expect(ref.collections[:widgets].from).to eq :foo + expect(ref.collections[:widgets].source).to eq :foo expect(ref.collections[:widgets].blueprint).to eq widget_blueprint expect(ref.collections[:widgets].options[:if].class.name).to eq "Proc" expect(ref.objects[:foo].name).to eq :foo @@ -101,63 +103,6 @@ expect(refs[:"extended.plus"].fields.keys.sort).to eq %i(name description foo).sort end - it "excludes specified fields and associations from the parent class" do - application_blueprint = Class.new(Blueprinter::V2::Base) do - field :id - field :foo - end - blueprint = Class.new(application_blueprint) do - exclude :foo - field :name - end - - refs = blueprint.reflections - expect(refs[:default].fields.keys.sort).to eq %i(id name).sort - end - - it "excludes specified fields and associations from the parent view" do - category_blueprint = Class.new(Blueprinter::V2::Base) - widget_blueprint = Class.new(Blueprinter::V2::Base) - blueprint = Class.new(Blueprinter::V2::Base) do - field :id - field :name - association :category, category_blueprint - association :widgets, [widget_blueprint] - - view :foo do - exclude :name, :category - field :description - end - end - - refs = blueprint.reflections - expect(refs[:default].fields.keys.sort).to eq %i(id name).sort - expect(refs[:default].objects.keys.sort).to eq %i(category).sort - expect(refs[:default].collections.keys.sort).to eq %i(widgets).sort - expect(refs[:foo].fields.keys.sort).to eq %i(id description).sort - expect(refs[:foo].objects.keys.sort).to eq %i().sort - expect(refs[:foo].collections.keys.sort).to eq %i(widgets).sort - end - - it "excludes specified fields and associations from partials" do - blueprint = Class.new(Blueprinter::V2::Base) do - partial :desc do - field :short_desc - field :long_desc - end - - field :name - - view :foo do - exclude :short_desc - use :desc - end - end - - refs = blueprint.reflections - expect(refs[:foo].fields.keys).to eq %i(name long_desc) - end - context 'formatters' do let(:blueprint) do Class.new(Blueprinter::V2::Base) do @@ -170,18 +115,21 @@ def fmt_date(d) it 'adds a block formatter' do iso8601 = ->(x, _opts) { x.iso8601 } blueprint.format(Date, &iso8601) - expect(blueprint.formatters[Date]).to eq iso8601 + blueprint.eval! + expect(blueprint.spec.formatters[Date]).to eq iso8601 end it 'adds a method formatter' do blueprint.format(Date, :fmt_date) - expect(blueprint.formatters[Date]).to eq :fmt_date + blueprint.eval! + expect(blueprint.spec.formatters[Date]).to eq :fmt_date end it 'are inherited' do blueprint.format(Date, :fmt_date) child = Class.new(blueprint) - expect(child.formatters[Date]).to eq :fmt_date + child.eval! + expect(child.spec.formatters[Date]).to eq :fmt_date end end end diff --git a/spec/v2/formatter_spec.rb b/spec/v2/formatter_spec.rb index 44b0feea5..dbf50bb14 100644 --- a/spec/v2/formatter_spec.rb +++ b/spec/v2/formatter_spec.rb @@ -3,9 +3,6 @@ require 'date' describe Blueprinter::V2::Formatter do - let(:field) { Blueprinter::V2::Fields::Field.new(name: :foo, from: :foo, from_str: 'foo') } - let(:object) { { foo: 'Foo' } } - let(:context) { Blueprinter::V2::Context::Field } let(:blueprint) do Class.new(Blueprinter::V2::Base) do format(Date) { |date| date.iso8601 } @@ -17,24 +14,23 @@ def yes(_) end end + let(:formatter) do + blueprint.eval! + described_class.new(blueprint.spec.formatters) + end + it 'calls proc formatters' do - formatter = described_class.new(blueprint) value = Date.new(2024, 10, 1) - ctx = context.new(blueprint.new, [], {}, object, field) - expect(formatter.call(value, ctx)).to eq '2024-10-01' + expect(formatter.call(blueprint.new, value)).to eq '2024-10-01' end it 'calls instance method formatters' do - formatter = described_class.new(blueprint) value = true - ctx = context.new(blueprint.new, [], {}, object, field) - expect(formatter.call(value, ctx)).to eq "Yes" + expect(formatter.call(blueprint.new, value)).to eq "Yes" end it "passes through values it doesn't know about" do - formatter = described_class.new(blueprint) value = "foo" - ctx = context.new(blueprint.new, [], {}, object, field) - expect(formatter.call(value, ctx)).to eq "foo" + expect(formatter.call(blueprint.new, value)).to eq "foo" end end diff --git a/spec/v2/instance_cache_spec.rb b/spec/v2/instance_cache_spec.rb index ee8f1c322..21f921c48 100644 --- a/spec/v2/instance_cache_spec.rb +++ b/spec/v2/instance_cache_spec.rb @@ -18,59 +18,4 @@ expect(blueprint2).to be blueprint1 end end - - context '#serializer' do - it "returns a new instance of Serializer" do - widget_serializer = subject.serializer(widget_blueprint, { foo: true }, store, 1) - expect(widget_serializer).to be_a Blueprinter::V2::Serializer - expect(widget_serializer.blueprint.class).to be widget_blueprint - expect(widget_serializer.options).to eq({ foo: true }) - expect(widget_serializer.instances).to be subject - - category_serializer = subject.serializer(category_blueprint, { foo: false }, store, 1) - expect(category_serializer).to be_a Blueprinter::V2::Serializer - expect(category_serializer.blueprint.class).to be category_blueprint - expect(category_serializer.options).to eq({ foo: false }) - expect(category_serializer.instances).to be subject - end - - it "returns the same instance of Serializer" do - widget_serializer1 = subject.serializer(widget_blueprint, { foo: true }, store, 1) - widget_serializer2 = subject.serializer(widget_blueprint, { foo: false }, store, 1) - expect(widget_serializer2).to be widget_serializer1 - end - end - - context "#extension" do - let(:extension_a) { Class.new(Blueprinter::Extension) } - let(:extension_b) { Class.new(Blueprinter::Extension) } - - it "returns an existing extension instance" do - ext = extension_a.new - expect(subject.extension(ext)).to be ext - end - - it "returns a new instance of an Extension subclass" do - expect(subject.extension(extension_a)).to be_a extension_a - expect(subject.extension(extension_b)).to be_a extension_b - end - - it "returns the same instance of a Blueprint subclass" do - ext1 = subject.extension extension_a - ext2 = subject.extension extension_a - expect(ext2).to be ext1 - end - - it "returns a new instance of an Extension subclass from a proc" do - expect(subject.extension(-> { extension_a.new })).to be_a extension_a - expect(subject.extension(-> { extension_b.new })).to be_a extension_b - end - - it "returns the same instance of a Blueprint subclass from a proc" do - p = -> { extension_a.new } - ext1 = subject.extension p - ext2 = subject.extension p - expect(ext2).to be ext1 - end - end end diff --git a/spec/v2/name_spec.rb b/spec/v2/name_spec.rb index 9c52ccf40..fcb7c49a1 100644 --- a/spec/v2/name_spec.rb +++ b/spec/v2/name_spec.rb @@ -10,6 +10,7 @@ class NamedBlueprint < Blueprinter::V2::Base expect(NamedBlueprint.to_s).to eq "NamedBlueprint" expect(NamedBlueprint.inspect).to eq "NamedBlueprint" expect(NamedBlueprint.blueprint_name).to eq "NamedBlueprint" + expect(NamedBlueprint.view_path).to eq :default expect(NamedBlueprint.view_name).to eq :default end @@ -17,13 +18,13 @@ class NamedBlueprint < Blueprinter::V2::Base expect(NamedBlueprint[:extended].to_s).to eq "NamedBlueprint.extended" expect(NamedBlueprint[:extended].inspect).to eq "NamedBlueprint.extended" expect(NamedBlueprint[:extended].blueprint_name).to eq "NamedBlueprint.extended" + expect(NamedBlueprint[:extended].view_path).to eq :extended expect(NamedBlueprint[:extended].view_name).to eq :extended end - it 'raises for an invalid view name' do - expect { NamedBlueprint[:wrong_name] }.to raise_error( + it "doesn't raise an error for an invalid view name" do + expect { NamedBlueprint[:wrong_name] }.to_not raise_error( Blueprinter::Errors::UnknownView, - "View 'wrong_name' could not be found in Blueprint 'NamedBlueprint'" ) end @@ -70,11 +71,13 @@ class NamedBlueprint < Blueprinter::V2::Base it 'has no base name' do expect(blueprint.blueprint_name).to eq "Blueprinter::V2::Base" + expect(blueprint.view_path).to eq :default expect(blueprint.view_name).to eq :default end it 'has a view by name' do expect(blueprint[:extended].blueprint_name).to eq "Blueprinter::V2::Base.extended" + expect(blueprint[:extended].view_path).to eq :extended expect(blueprint[:extended].view_name).to eq :extended end end @@ -94,27 +97,34 @@ class NamedBlueprint < Blueprinter::V2::Base it 'finds deeply nested names' do expect(blueprint.blueprint_name).to eq "MyBlueprint" + expect(blueprint.view_path).to eq :default expect(blueprint.view_name).to eq :default expect(blueprint[:foo].blueprint_name).to eq "MyBlueprint.foo" + expect(blueprint[:foo].view_path).to eq :foo expect(blueprint[:foo].view_name).to eq :foo expect(blueprint[:foo][:bar].blueprint_name).to eq "MyBlueprint.foo.bar" - expect(blueprint[:foo][:bar].view_name).to eq :"foo.bar" + expect(blueprint[:foo][:bar].view_path).to eq :"foo.bar" + expect(blueprint[:foo][:bar].view_name).to eq :bar expect(blueprint[:foo][:bar][:zorp].blueprint_name).to eq "MyBlueprint.foo.bar.zorp" - expect(blueprint[:foo][:bar][:zorp].view_name).to eq :"foo.bar.zorp" + expect(blueprint[:foo][:bar][:zorp].view_path).to eq :"foo.bar.zorp" + expect(blueprint[:foo][:bar][:zorp].view_name).to eq :zorp end it 'finds deeply nested names using dot syntax' do expect(blueprint["foo"].blueprint_name).to eq "MyBlueprint.foo" + expect(blueprint["foo"].view_path).to eq :foo expect(blueprint["foo"].view_name).to eq :foo expect(blueprint["foo.bar"].blueprint_name).to eq "MyBlueprint.foo.bar" - expect(blueprint["foo.bar"].view_name).to eq :"foo.bar" + expect(blueprint["foo.bar"].view_path).to eq :"foo.bar" + expect(blueprint["foo.bar"].view_name).to eq :bar expect(blueprint["foo.bar.zorp"].blueprint_name).to eq "MyBlueprint.foo.bar.zorp" - expect(blueprint["foo.bar.zorp"].view_name).to eq :"foo.bar.zorp" + expect(blueprint["foo.bar.zorp"].view_path).to eq :"foo.bar.zorp" + expect(blueprint["foo.bar.zorp"].view_name).to eq :zorp end end @@ -132,14 +142,15 @@ class NamedBlueprint < Blueprinter::V2::Base foo_bar_name = nil bp = Class.new(Blueprinter::V2::Base) do - default_name = view_name + default_name = view_path view :foo do - foo_name = view_name + foo_name = view_path view :bar do - foo_bar_name = view_name + foo_bar_name = view_path end end end + bp.reflections bp[:default] bp[:foo] diff --git a/spec/v2/options_dsl_spec.rb b/spec/v2/options_dsl_spec.rb new file mode 100644 index 000000000..433a699d8 --- /dev/null +++ b/spec/v2/options_dsl_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +describe "Blueprinter::V2 Options" do + it "set overrides options" do + blueprint = Class.new(Blueprinter::V2::Base) do + set :foo, "foo" + + view :extended do + set :foo, "bar" + end + end + + refs = blueprint.reflections + expect(refs[:default].options).to eq({ foo: "foo" }) + expect(refs[:extended].options).to eq({ foo: "bar" }) + end + + it "set can take a block to access the current option value" do + blueprint = Class.new(Blueprinter::V2::Base) do + set :foo, "foo" + + view :extended do + set :foo do |val| + "#{val}bar" + end + end + end + + refs = blueprint.reflections + expect(refs[:default].options[:foo]).to eq "foo" + expect(refs[:extended].options[:foo]).to eq "foobar" + end + + it "unset unsets options" do + blueprint = Class.new(Blueprinter::V2::Base) do + set :foo, "foo" + set :bar, "bar" + + view :extended do + set :zorp, "zorp" + unset :bar, :zorp + end + end + + refs = blueprint.reflections + expect(refs[:default].options).to eq({ foo: "foo", bar: "bar" }) + expect(refs[:extended].options).to eq({ foo: "foo" }) + end + + it "excludes all inherited options" do + blueprint = Class.new(Blueprinter::V2::Base) do + set :foo, "foo" + set :bar, "bar" + + view :extended do + exclude options: true + set :zorp, "zorp" + end + end + + refs = blueprint.reflections + expect(refs[:default].options).to eq({ foo: "foo", bar: "bar" }) + expect(refs[:extended].options).to eq({ zorp: "zorp" }) + end +end diff --git a/spec/v2/partials_spec.rb b/spec/v2/partials_spec.rb index 7b509b4ac..c7c2a5d54 100644 --- a/spec/v2/partials_spec.rb +++ b/spec/v2/partials_spec.rb @@ -40,6 +40,40 @@ expect(refs[:extended].fields.keys.sort).to eq %i(name description tags).sort end + it "can have certain fields excluded" do + blueprint = Class.new(Blueprinter::V2::Base) do + use :my_partial, exclude: [:foo] + field :name + + partial :my_partial do + fields :foo, :bar + end + end + + ref = blueprint.reflections[:default] + expect(ref.fields.keys).to match_array %i[name bar] + end + + it "can have things excluded categorically" do + blueprint = Class.new(Blueprinter::V2::Base) do + use :my_partial, fields: false, options: false, extensions: false, formatters: false + field :name + + partial :my_partial do + set :exclude_if_nil, true + add Blueprinter::Extensions::MultiJson.new + format(TrueClass) { "Y" } + fields :foo, :bar + end + end + + ref = blueprint.reflections[:default] + expect(ref.fields.keys).to match_array %i[name] + expect(ref.options).to eq({}) + expect(ref.extensions).to eq([]) + expect(blueprint.spec.formatters).to eq ({}) + end + it "allows use statements to be nested" do blueprint = Class.new(Blueprinter::V2::Base) do field :name @@ -64,6 +98,30 @@ expect(refs[:default].fields.keys.sort).to eq %i(name foo bar zorp).sort end + it "allows partials to be nested" do + blueprint = Class.new(Blueprinter::V2::Base) do + field :name + use :outer_partial + use :inner_partial + use :inner_partial2 + + partial :outer_partial do + field :summary + + partial :inner_partial do + field :description + + partial :inner_partial2 do + field :price + end + end + end + end + + ref = blueprint.reflections[:default] + expect(ref.fields.keys).to eq %i[name summary description price] + end + it "allows a view to be defined in a partial" do blueprint = Class.new(Blueprinter::V2::Base) do field :name @@ -94,11 +152,14 @@ it "throws an error for an invalid partial name" do blueprint = Class.new(Blueprinter::V2::Base) do + self.blueprint_name = 'MyBlueprint' view :foo do - use :description + use :bar end end - expect { blueprint[:foo] }.to raise_error(Blueprinter::Errors::UnknownPartial) + expect do + blueprint[:foo].render({}) + end.to raise_error(Blueprinter::Errors::UnknownPartial, /No 'bar' partial in Blueprint 'MyBlueprint\.foo'/) end it 'creates an implicit partial for every view' do @@ -122,53 +183,112 @@ end context 'precedence' do - it 'partials override what the view inherits' do + it 'partials can override what the view inherits' do blueprint = Class.new(Blueprinter::V2::Base) do - field :name + add Blueprinter::Extensions::FieldOrder.new { |a, b| a.name <=> b.name } + set :foo, true + format Time, :iso8601 + fields :name, :description view :foo do use :non_empty_name end partial :non_empty_name do + add Blueprinter::Extensions::MultiJson.new + set :foo, false + format Time, :to_i field :name, exclude_if_empty: true + exclude :description end end - view = blueprint.reflections[:foo] - expect(view.fields[:name].options).to eq({ exclude_if_empty: true }) + foo = blueprint.reflections[:foo] + expect(foo.extensions.map(&(:class))).to eq [Blueprinter::Extensions::FieldOrder, Blueprinter::Extensions::MultiJson] + expect(foo.options[:foo]).to be false + expect(foo.fields[:name].options).to eq({ exclude_if_empty: true }) + expect(foo.fields.keys).to eq %i[name] + + expect(blueprint[:foo].spec.formatters).to eq({ Time => :to_i }) end - it '`use` overrides the view' do + it '`use` overrides what comes before' do blueprint = Class.new(Blueprinter::V2::Base) do view :foo do - use :non_empty_name + add Blueprinter::Extensions::FieldOrder.new { |a, b| a.name <=> b.name } + set :foo, true + format Time, :iso8601 field :name + use :non_empty_name end partial :non_empty_name do + add Blueprinter::Extensions::MultiJson.new + set :foo, false + format Time, :to_i field :name, exclude_if_empty: true end end view = blueprint.reflections[:foo] + expect(view.extensions.map(&(:class))).to eq [Blueprinter::Extensions::FieldOrder, Blueprinter::Extensions::MultiJson] + expect(view.options[:foo]).to be false expect(view.fields[:name].options).to eq({ exclude_if_empty: true }) + expect(blueprint[:foo].spec.formatters).to eq({ Time => :to_i }) end - it '`use!` allows the view to override' do + it '`use` can be overridden' do blueprint = Class.new(Blueprinter::V2::Base) do view :foo do - use! :non_empty_name + use :non_empty_name + add Blueprinter::Extensions::FieldOrder.new { |a, b| a.name <=> b.name } + set :foo, true + format Time, :iso8601 field :name end partial :non_empty_name do + add Blueprinter::Extensions::MultiJson.new + set :foo, false + format Time, :to_i field :name, exclude_if_empty: true end end view = blueprint.reflections[:foo] + expect(view.extensions.map(&(:class))).to eq [Blueprinter::Extensions::MultiJson, Blueprinter::Extensions::FieldOrder] + expect(view.options[:foo]).to be true expect(view.fields[:name].options).to eq({}) + expect(blueprint[:foo].spec.formatters).to eq({ Time => :iso8601 }) + end + end + + context 'sharing with Ruby modules' do + let(:shared_partials) do + Module.new do + def self.included(klass) + klass.class_eval do + partial :external_partial do + field :external_field + end + end + end + end + end + + let(:blueprint_a) do + tests = self + Class.new(Blueprinter::V2::Base) { include tests.shared_partials; use :external_partial } + end + + let(:blueprint_b) do + tests = self + Class.new(Blueprinter::V2::Base) { include tests.shared_partials; use :external_partial } + end + + it 'allows importing external partials' do + expect(blueprint_a.reflections[:default].fields.keys).to eq %i[external_field] + expect(blueprint_b.reflections[:default].fields.keys).to eq %i[external_field] end end end diff --git a/spec/v2/reflection_spec.rb b/spec/v2/reflection_spec.rb index eaa9364ea..0409819fe 100644 --- a/spec/v2/reflection_spec.rb +++ b/spec/v2/reflection_spec.rb @@ -1,12 +1,27 @@ # frozen_string_literal: true describe "Blueprinter::V2::Reflection" do - let(:blueprint) do + let(:application_blueprint) do Class.new(Blueprinter::V2::Base) do - view :foo - view :bar do - view :foo do - view :borp + use :asdf + + partial :asdf do + view :identity do + view :foo + end + end + end + end + + let(:blueprint) do + Class.new(application_blueprint) do + view :a + view :b do + set :foo, 'foo' + view :c do + set :foo, 'bar' + set :exclude_if_nil, true + view :d end end end @@ -16,10 +31,12 @@ view_names = blueprint.reflections.keys expect(view_names.sort).to eq %i( default - foo - bar - bar.foo - bar.foo.borp + identity + identity.foo + a + b + b.c + b.c.d ).sort end @@ -27,54 +44,64 @@ view_names = blueprint.reflections.values.map(&:name) expect(view_names.sort).to eq %i( default - foo - bar - bar.foo - bar.foo.borp + identity + identity.foo + a + b + b.c + b.c.d ).sort end it "finds nested view keys" do - bar_view_names = blueprint[:bar].reflections.keys + bar_view_names = blueprint[:b].reflections.keys expect(bar_view_names.sort).to eq %i( default - foo - foo.borp + c + c.d ).sort - bar_foo_view_names = blueprint[:"bar.foo"].reflections.keys + bar_foo_view_names = blueprint[:"b.c"].reflections.keys expect(bar_foo_view_names.sort).to eq %i( default - borp + d ).sort end it "finds nested view names" do - bar_view_names = blueprint[:bar].reflections.values.map(&:name) + bar_view_names = blueprint[:b].reflections.values.map(&:name) expect(bar_view_names.sort).to eq %i( default - foo - foo.borp + c + c.d ).sort - bar_foo_view_names = blueprint[:"bar.foo"].reflections.values.map(&:name) + bar_foo_view_names = blueprint[:"b.c"].reflections.values.map(&:name) expect(bar_foo_view_names.sort).to eq %i( default - borp + d ).sort end + it 'has options' do + expect(blueprint.reflections[:default].options).to eq({}) + expect(blueprint.reflections[:b].options).to eq({ foo: 'foo' }) + expect(blueprint.reflections[:"b.c"].options).to eq({ foo: 'bar', exclude_if_nil: true }) + expect(blueprint.reflections[:"b.c.d"].options).to eq({ foo: 'bar', exclude_if_nil: true }) + end + context 'fields and associations' do let(:category_blueprint) { Class.new(Blueprinter::V2::Base) } let(:widget_blueprint) { Class.new(Blueprinter::V2::Base) } let(:blueprint) do test = self Class.new(Blueprinter::V2::Base) do - field :name - association :category, test.category_blueprint + set :if, ->(_ctx) { true } + field :name, default: 'None' + association :category, test.category_blueprint, default: { name: 'None' } view :extended do - association :widgets, [test.widget_blueprint] + association :widgets, [test.widget_blueprint], default: [] field :description end end @@ -97,5 +124,16 @@ names = blueprint.reflections[:extended].ordered.map(&:name) expect(names).to eq %i(name category widgets description) end + + it 'retain their original options' do + name = blueprint.reflections[:default].fields[:name] + expect(name.options).to eq({ default: 'None' }) + + category = blueprint.reflections[:default].objects[:category] + expect(category.options).to eq({ default: { name: 'None' } }) + + widgets = blueprint.reflections[:extended].collections[:widgets] + expect(widgets.options).to eq({ default: [] }) + end end end diff --git a/spec/v2/render_spec.rb b/spec/v2/render_spec.rb index 0fdfe4945..ae122417a 100644 --- a/spec/v2/render_spec.rb +++ b/spec/v2/render_spec.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'json' +require 'yaml' describe Blueprinter::V2::Render do let(:application_blueprint) do @@ -10,7 +11,7 @@ let(:category_blueprint) do Class.new(application_blueprint) do self.blueprint_name = 'CategoryBlueprint' - field :name, from: :n + field :name, source: :n end end @@ -19,11 +20,11 @@ Class.new(application_blueprint) do self.blueprint_name = 'WidgetBlueprint' field :name - field :desc, from: :description + field :desc, source: :description association :category, test.category_blueprint view :extended do - field :long_desc do |_ctx| + field :long_desc do |_obj, _ctx| 'Long desc' end end @@ -112,14 +113,8 @@ it 'runs around the entire result' do widget_blueprint.extension do def around_result(ctx) - case ctx.format - when :json - ctx.format = :hash - result = yield(ctx).merge({ foo: 'bar' }) - JSON.dump result - else - yield ctx - end + result = yield ctx + result.merge({ foo: 'bar' }) end end widget = { name: 'Foo', description: 'About', category: { n: 'Bar' } } @@ -261,6 +256,24 @@ def around_result(ctx) expect { render.to_hash }.to raise_error(Blueprinter::BlueprinterError, 'Unrecognized serialization format `:yaml`') end + it 'can output a custom format' do + widget_blueprint.extension do + def around_result(ctx) + case ctx.format + when :yaml + result = yield ctx + serialized YAML.dump result + else + yield ctx + end + end + end + + widget = { name: 'Foo', description: 'About', category: { n: 'Bar' } } + render = described_class.new(widget, { num: 42 }, blueprint: widget_blueprint, collection: false, instances:) + expect(render.to(:yaml)).to eq({ name: 'Foo', desc: 'About', category: { name: 'Bar' } }.to_yaml) + end + context '#store' do let(:blueprint) do Class.new(application_blueprint) do @@ -289,14 +302,14 @@ def around_result(ctx) ext = Class.new(Blueprinter::Extension) do def initialize(log) = @log = log - def around_blueprint_init(ctx) + def around_result(ctx) ctx.store[:log] = @log - ctx.store[:log] << "around_blueprint_init (#{ctx.blueprint})" + ctx.store[:log] << "around_result (#{ctx.blueprint})" yield ctx end - def around_result(ctx) - ctx.store[:log] << "around_result (#{ctx.blueprint})" + def around_blueprint_init(ctx) + ctx.store[:log] << "around_blueprint_init (#{ctx.blueprint})" yield ctx end @@ -310,11 +323,6 @@ def around_serialize_collection(ctx) yield ctx end - def around_blueprint(ctx) - ctx.store[:log] << "around_blueprint (#{ctx.blueprint})" - yield ctx - end - def around_field_value(ctx) ctx.store[:log] << "around_field_value (#{ctx.blueprint})" yield ctx @@ -330,7 +338,7 @@ def around_collection_value(ctx) yield ctx end end - application_blueprint.extensions << ext.new(log) + application_blueprint.add ext.new(log) widget_blueprint[:with_parts].render({ name: 'Widget A', @@ -340,23 +348,19 @@ def around_collection_value(ctx) }).to_hash expect(log).to eq [ - 'around_blueprint_init (WidgetBlueprint.with_parts)', 'around_result (WidgetBlueprint.with_parts)', + 'around_blueprint_init (WidgetBlueprint.with_parts)', 'around_serialize_object (WidgetBlueprint.with_parts)', - 'around_blueprint (WidgetBlueprint.with_parts)', 'around_field_value (WidgetBlueprint.with_parts)', 'around_field_value (WidgetBlueprint.with_parts)', 'around_object_value (WidgetBlueprint.with_parts)', 'around_blueprint_init (CategoryBlueprint)', 'around_serialize_object (CategoryBlueprint)', - 'around_blueprint (CategoryBlueprint)', 'around_field_value (CategoryBlueprint)', 'around_collection_value (WidgetBlueprint.with_parts)', 'around_blueprint_init (PartBlueprint)', 'around_serialize_collection (PartBlueprint)', - 'around_blueprint (PartBlueprint)', 'around_field_value (PartBlueprint)', - 'around_blueprint (PartBlueprint)', 'around_field_value (PartBlueprint)' ] end diff --git a/spec/v2/rendering_spec.rb b/spec/v2/rendering_spec.rb index 6dad4ffee..036e3ad00 100644 --- a/spec/v2/rendering_spec.rb +++ b/spec/v2/rendering_spec.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require 'active_support/json' + describe "Blueprinter::V2 Rendering" do let(:category_blueprint) do Class.new(Blueprinter::V2::Base) do @@ -17,7 +19,7 @@ test = self Class.new(Blueprinter::V2::Base) do field :name - association :cat, test.category_blueprint, from: :category + association :cat, test.category_blueprint, source: :category association :parts, [test.part_blueprint] end end @@ -85,4 +87,26 @@ }] }.to_json) end + + it 'responds to legacy render_as_hash' do + result = widget_blueprint.render_as_hash(widget, { root: :data }) + expect(result).to eq({ + data: { + name: 'Foo', + cat: { name: 'Bar' }, + parts: [{ num: 42 }, { num: 43 }] + } + }) + end + + it 'responds to legacy render_as_json' do + result = widget_blueprint.render_as_json(widget, { root: :data }) + expect(result).to eq({ + data: { + name: 'Foo', + cat: { name: 'Bar' }, + parts: [{ num: 42 }, { num: 43 }] + } + }.as_json) + end end diff --git a/spec/v2/serializer_spec.rb b/spec/v2/serializer_spec.rb index 4c501cc25..629f94261 100644 --- a/spec/v2/serializer_spec.rb +++ b/spec/v2/serializer_spec.rb @@ -36,11 +36,12 @@ end let(:instances) { Blueprinter::V2::InstanceCache.new } + let(:store) { {} } it 'works with nil values' do widget = { name: nil, category: nil } - result = described_class.new(widget_blueprint, {}, instances, store: {}, initial_depth: 1).object(widget, depth: 1) + result = widget_blueprint.serializer.object(widget, {}, instances:, store:, depth: 1) expect(result).to eq({ name: nil, category: nil, @@ -60,17 +61,17 @@ def around_blueprint_init(ctx) end def around_field_value(ctx) - name = ctx.object.fetch(ctx.field.from) + name = ctx.object.fetch(ctx.field.source) "#{@prefix} of #{name}" end def around_object_value(ctx) - obj = ctx.object.fetch(ctx.field.from) + obj = ctx.object.fetch(ctx.field.source) { name: "#{@prefix} of #{obj[:name]}" } end def around_collection_value(ctx) - collection = ctx.object.fetch(ctx.field.from) + collection = ctx.object.fetch(ctx.field.source) collection.each_with_index.map { |_, i| { num: i + 1 } } end end @@ -86,7 +87,7 @@ def around_collection_value(ctx) end it 'extracts values and serialize nested Blueprints' do - result = described_class.new(widget_blueprint, {}, instances, store: {}, initial_depth: 1).object(widget, depth: 1) + result = widget_blueprint.serializer.object(widget, {}, instances:, store:, depth: 1) expect(result).to eq({ name: 'Foo', category: { name: 'Bar' }, @@ -103,7 +104,7 @@ def around_collection_value(ctx) association(:parts, [test.part_blueprint]) { |obj, _ctx| obj[:parts].each_with_index.map { |_, i| { num: i + 1 } } } end - result = described_class.new(block_blueprint, {}, instances, store: {}, initial_depth: 1).object(widget, depth: 1) + result = block_blueprint.serializer.object(widget, {}, instances:, store:, depth: 1) expect(result).to eq({ name: 'Name of Foo', category: { name: 'Name of Bar' }, @@ -115,13 +116,13 @@ def around_collection_value(ctx) test = self blueprint = Class.new(application_blueprint) do self.blueprint_name = "BlockBlueprint" - extensions << test.name_of_extractor.new(prefix: 'X') + add test.name_of_extractor.new(prefix: 'X') field :name association :category, test.category_blueprint association :parts, [test.part_blueprint] end - result = described_class.new(blueprint, {}, instances, store: {}, initial_depth: 1).object(widget, depth: 1) + result = blueprint.serializer.object(widget, {}, instances:, store:, depth: 1) expect(result).to eq({ name: 'X of Foo', category: { name: 'X of Bar' }, @@ -130,56 +131,71 @@ def around_collection_value(ctx) end end - it 'enables the if conditionals extension' do + it 'respects if conditionals on fields' do widget_blueprint = Class.new(Blueprinter::V2::Base) do field :name - field :desc, if: ->(_val, ctx) { ctx.options[:n] > 42 } + field :desc, if: ->(ctx) { ctx.options[:n] > 42 } end - result = described_class.new(widget_blueprint, { n: 42 }, instances, store: {}, initial_depth: 1).object({ name: 'Foo', desc: 'Bar' }, depth: 1) + result = widget_blueprint.serializer.object({ name: 'Foo', desc: 'Bar' }, { n: 42 }, instances:, store:, depth: 1) expect(result).to eq({ name: 'Foo' }) end - it 'enables the unless conditionals extension' do + it 'respects unless conditionals on fields' do widget_blueprint = Class.new(Blueprinter::V2::Base) do field :name - field :desc, unless: ->(_val, ctx) { ctx.options[:n] > 42 } + field :desc, unless: ->(ctx) { ctx.options[:n] > 42 } end - result = described_class.new(widget_blueprint, { n: 43 }, instances, store: {}, initial_depth: 1).object({ name: 'Foo', desc: 'Bar' }, depth: 1) + result = widget_blueprint.serializer.object({ name: 'Foo', desc: 'Bar' }, { n: 43 }, instances:, store:, depth: 1) expect(result).to eq({ name: 'Foo' }) end - it 'enables the default values extension' do + it 'respects conditionals copied down from blueprints options' do + widget_blueprint = Class.new(Blueprinter::V2::Base) do + set :if, ->(ctx) { ctx.options[:n] > 42 } + field :name, if: ->(_ctx) { true } + field :desc + end + + result = widget_blueprint.serializer.object({ name: 'Foo', desc: 'Bar' }, { n: 42 }, instances:, store:, depth: 1) + expect(result).to eq({ name: 'Foo' }) + end + + it 'respects default values' do widget_blueprint = Class.new(Blueprinter::V2::Base) do field :name field :desc, default: 'Description!' end - result = described_class.new(widget_blueprint, {}, instances, store: {}, initial_depth: 1).object({ name: 'Foo' }, depth: 1) + result = widget_blueprint.serializer.object({ name: 'Foo' }, {}, instances:, store:, depth: 1) expect(result).to eq({ name: 'Foo', desc: 'Description!' }) end - it 'enables the exclude if empty extension' do + it 'respects default values copied down from blueprint options' do widget_blueprint = Class.new(Blueprinter::V2::Base) do - field :name, exclude_if_empty: true - field :desc, exclude_if_empty: true + set :default, 'Description!' + field :name + field :desc end - result = described_class.new(widget_blueprint, {}, instances, store: {}, initial_depth: 1).object({ name: 'Foo', desc: "" }, depth: 1) - expect(result).to eq({ name: 'Foo' }) + result = widget_blueprint.serializer.object({ name: 'Foo' }, {}, instances:, store:, depth: 1) + expect(result).to eq({ + name: 'Foo', + desc: 'Description!' + }) end - it 'enables the exclude if nil extension' do + it 'respects the exclude_if_nil option' do widget_blueprint = Class.new(Blueprinter::V2::Base) do field :name, exclude_if_nil: true field :desc, exclude_if_nil: true end - result = described_class.new(widget_blueprint, {}, instances, store: {}, initial_depth: 1).object({ name: 'Foo', desc: nil }, depth: 1) + result = widget_blueprint.serializer.object({ name: 'Foo', desc: nil }, {}, instances:, store:, depth: 1) expect(result).to eq({ name: 'Foo' }) end @@ -190,20 +206,16 @@ def around_serialize_object(ctx) = yield ctx ext2 = Class.new(Blueprinter::Extension) do def around_serialize_collection(ctx) = yield ctx end - ext3 = Class.new(Blueprinter::Extension) do - def around_blueprint(ctx) = yield ctx - end - category_blueprint.extensions << ext1 << ext2.new << -> { ext3.new } - serializer = described_class.new(category_blueprint, {}, instances, store: {}, initial_depth: 1) + category_blueprint.add ext1.new, ext2.new + serializer = category_blueprint.serializer expect(serializer.hooks.registered? :around_serialize_object).to be true expect(serializer.hooks.registered? :around_serialize_collection).to be true - expect(serializer.hooks.registered? :around_blueprint).to be true end it 'formats fields' do widget = { name: 'Foo', created_on: Date.new(2024, 10, 31) } - result = described_class.new(widget_blueprint[:extended], {}, instances, store: {}, initial_depth: 1).object(widget, depth: 1) + result = widget_blueprint[:extended].serializer.object(widget, {}, instances:, store:, depth: 1) expect(result).to eq({ category: nil, name: 'Foo', @@ -216,7 +228,7 @@ def around_blueprint(ctx) = yield ctx ext1 = Class.new(Blueprinter::Extension) do def around_field_value(ctx) value = yield ctx - value == '?' ? skip : value + value == '?' ? skip! : value end end ext2 = Class.new(Blueprinter::Extension) do @@ -225,10 +237,10 @@ def around_field_value(ctx) value.is_a?(Date) ? value + 10 : '?' end end - widget_blueprint.extensions << ext1.new << ext2.new + widget_blueprint.add ext1.new, ext2.new widget = { name: 'Foo', created_on: Date.new(2024, 10, 31) } - result = described_class.new(widget_blueprint[:extended], {}, instances, store: {}, initial_depth: 1).object(widget, depth: 1) + result = widget_blueprint[:extended].serializer.object(widget, {}, instances:, store:, depth: 1) expect(result).to eq({ category: nil, created_on: 'Sun Nov 10, 2024', @@ -240,7 +252,7 @@ def around_field_value(ctx) ext1 = Class.new(Blueprinter::Extension) do def around_object_value(ctx) value = yield ctx - value[:name] == 'Bar' ? skip : value + value[:name] == 'Bar' ? skip! : value end end ext2 = Class.new(Blueprinter::Extension) do @@ -248,10 +260,10 @@ def around_object_value(_ctx) { name: 'Bar' } end end - widget_blueprint.extensions << ext1.new << ext2.new + widget_blueprint.add ext1.new, ext2.new widget = { name: 'Foo', category: { name: 'Cat' } } - result = described_class.new(widget_blueprint, {}, instances, store: {}, initial_depth: 1).object(widget, depth: 1) + result = widget_blueprint.serializer.object(widget, {}, instances:, store:, depth: 1) expect(result).to eq({ name: 'Foo', parts: nil }) end @@ -262,60 +274,71 @@ def around_collection_value(ctx) = yield(ctx) << { num: 43 } ext2 = Class.new(Blueprinter::Extension) do def around_collection_value(ctx) value = yield ctx - value.empty? ? skip : value + value.empty? ? skip! : value end end ext3 = Class.new(Blueprinter::Extension) do def around_collection_value(ctx) = yield(ctx)[1..] end - widget_blueprint.extensions << ext1.new << ext2.new << ext3.new + widget_blueprint.add ext1.new, ext2.new, ext3.new widget = { name: 'Foo', parts: [{ num: 42 }] } - result = described_class.new(widget_blueprint, {}, instances, store: {}, initial_depth: 1).object(widget, depth: 1) + result = widget_blueprint.serializer.object(widget, {}, instances:, store:, depth: 1) expect(result).to eq({ name: 'Foo', category: nil }) widget = { name: 'Foo', parts: [{ num: 41 }, { num: 42 }] } - result = described_class.new(widget_blueprint, {}, instances, store: {}, initial_depth: 1).object(widget, depth: 1) + result = widget_blueprint.serializer.object(widget, {}, instances:, store:, depth: 1) expect(result).to eq({ name: 'Foo', category: nil, parts: [{ num: 42 }, { num: 43 }] }) end - it 'evaluates value hooks before exclusion hooks' do + it 'evaluates default before exclude_if_nil' do widget_blueprint = Class.new(Blueprinter::V2::Base) do field :name - field :desc, default: 'Bar', if: ->(val, _ctx) { !val.nil? } + field :desc, default: 'Bar', exclude_if_nil: true end widget = { name: 'Foo', desc: nil } - result = described_class.new(widget_blueprint, {}, instances, store: {}, initial_depth: 1).object(widget, depth: 1) + result = widget_blueprint.serializer.object(widget, {}, instances:, store:, depth: 1) expect(result).to eq({ name: 'Foo', desc: 'Bar' }) end it 'evaluates both ifs and unlesses' do widget_blueprint = Class.new(Blueprinter::V2::Base) do - field :name, if: ->(_val, ctx) { ctx.options[:n] > 42 } - field :desc, unless: ->(_val, ctx) { ctx.options[:n] < 43 } + field :name, if: ->(ctx) { ctx.options[:n] > 42 } + field :desc, unless: ->(ctx) { ctx.options[:n] < 43 } field :zorp, - if: ->(_val, ctx) { ctx.options[:n] > 40 }, - unless: ->(_val, ctx) { ctx.options[:m] == 42 } + if: ->(ctx) { ctx.options[:n] > 40 }, + unless: ->(ctx) { ctx.options[:m] == 42 } end - result = described_class.new(widget_blueprint, { n: 42, m: 42 }, instances, store: {}, initial_depth: 1).object( + result = widget_blueprint.serializer.object( { name: 'Foo', desc: 'Bar', zorp: 'Zorp' }, + { n: 42, m: 42 }, + instances:, + store:, depth: 1 ) expect(result).to eq({}) end - it 'runs around_serialize_object and around_blueprint' do + it 'uses the core root extension' do + result = category_blueprint.render({ name: 'Foo' }, { root: :data }).to_hash + expect(result).to eq({ data: { name: 'Foo' } }) + + result = category_blueprint.render({ name: 'Foo' }, { root: :data, meta: { links: [] } }).to_hash + expect(result).to eq({ data: { name: 'Foo' }, meta: { links: [] } }) + end + + it 'runs around_blueprint_init, around_serialize_object, around_serialize_collection, and around_blueprint' do ext = Class.new(Blueprinter::Extension) do def initialize(log) @log = log end def around_blueprint_init(ctx) - @log << 'around_blueprint_init: a' + @log << "around_blueprint_init (#{ctx.blueprint.class}): a" yield ctx - @log << 'around_blueprint_init: b' + @log << "around_blueprint_init (#{ctx.blueprint.class}): b" end def around_serialize_object(ctx) @@ -325,45 +348,10 @@ def around_serialize_object(ctx) res end - def around_blueprint(ctx) - @log << "around_blueprint (#{ctx.object[:name]}): a" - res = yield ctx - @log << "around_blueprint (#{ctx.object[:name]}): b" - res - end - end - log = [] - widget_blueprint.extensions << ext.new(log) - widget = { name: 'Foo', category: { name: 'Bar' }, parts: [{ num: 42 }, { num: 43 }] } - - result = described_class.new(widget_blueprint, {}, instances, store: {}, initial_depth: 1).object(widget, depth: 1) - expect(result).to eq(widget) - expect(log).to eq [ - 'around_blueprint_init: a', - 'around_blueprint_init: b', - 'around_serialize_object (Foo): a', - 'around_blueprint (Foo): a', - 'around_blueprint (Foo): b', - 'around_serialize_object (Foo): b', - ] - end - - it 'runs around_serialize_collection and around_blueprint' do - ext = Class.new(Blueprinter::Extension) do - def initialize(log) - @log = log - end - - def around_blueprint_init(ctx) - @log << 'around_blueprint_init: a' - yield ctx - @log << 'around_blueprint_init: b' - end - def around_serialize_collection(ctx) - @log << "around_serialize_collection (#{ctx.object.map { |x| x[:name] }.join(',')}): a" + @log << "around_serialize_collection (#{ctx.object.size}): a" res = yield ctx - @log << "around_serialize_collection (#{ctx.object.map { |x| x[:name] }.join(',')}): b" + @log << "around_serialize_collection (#{ctx.object.size}): b" res end @@ -375,23 +363,32 @@ def around_blueprint(ctx) end end log = [] - widget_blueprint.extensions << ext.new(log) - widgets = [ - { name: 'Foo', category: { name: 'Bar' }, parts: [{ num: 42 }, { num: 43 }] }, - { name: 'Bar', category: { name: 'Bar' }, parts: [{ num: 43 }, { num: 43 }] }, - ] + application_blueprint.add ext.new(log) + widget = { name: 'Foo', category: { name: 'Bar' }, parts: [{ num: 42 }, { num: 43 }] } - result = described_class.new(widget_blueprint, {}, instances, store: {}, initial_depth: 1).collection(widgets, depth: 1) - expect(result).to eq(widgets) + result = widget_blueprint.serializer.object(widget, {}, instances:, store:, depth: 1) + expect(result).to eq(widget) expect(log).to eq [ - 'around_blueprint_init: a', - 'around_blueprint_init: b', - 'around_serialize_collection (Foo,Bar): a', - 'around_blueprint (Foo): a', - 'around_blueprint (Foo): b', - 'around_blueprint (Bar): a', - 'around_blueprint (Bar): b', - 'around_serialize_collection (Foo,Bar): b', + "around_blueprint_init (WidgetBlueprint): a", + "around_blueprint_init (WidgetBlueprint): b", + "around_serialize_object (Foo): a", + "around_blueprint (Foo): a", + "around_blueprint_init (CategoryBlueprint): a", + "around_blueprint_init (CategoryBlueprint): b", + "around_serialize_object (Bar): a", + "around_blueprint (Bar): a", + "around_blueprint (Bar): b", + "around_serialize_object (Bar): b", + "around_blueprint_init (PartBlueprint): a", + "around_blueprint_init (PartBlueprint): b", + "around_serialize_collection (2): a", + "around_blueprint (): a", + "around_blueprint (): b", + "around_blueprint (): a", + "around_blueprint (): b", + "around_serialize_collection (2): b", + "around_blueprint (Foo): b", + "around_serialize_object (Foo): b" ] end @@ -400,8 +397,11 @@ def around_blueprint(ctx) field :description end - result = described_class.new(blueprint, {}, instances, store: {}, initial_depth: 1).object( + result = blueprint.serializer.object( { description: 'A widget', category: { name: 'Cat' }, parts: [{ num: 42 }], name: 'Foo' }, + {}, + instances:, + store:, depth: 1 ) expect(result.to_json).to eq({ @@ -421,13 +421,16 @@ def around_blueprint_init(ctx) yield ctx end end - application_blueprint.extensions << ext.new(log) + application_blueprint.add ext.new(log) - described_class.new(widget_blueprint, {}, instances, store: {}, initial_depth: 1).collection( + widget_blueprint.serializer.collection( [ { name: 'A', description: 'Widget A', category: { name: 'Cat' }, parts: [{ num: 42 }, { num: 43 }] }, { name: 'B', description: 'Widget B', category: { name: 'Cat' }, parts: [{ num: 43 }, { num: 44 }] }, ], + {}, + instances:, + store:, depth: 1 ) @@ -442,34 +445,85 @@ def around_blueprint_init(ctx) ext = Class.new(Blueprinter::Extension) do def around_blueprint_init(ctx) = true end - application_blueprint.extensions << ext.new + application_blueprint.add ext.new expect do - described_class.new(widget_blueprint, {}, instances, store: {}, initial_depth: 1).object({}, depth: 1) + widget_blueprint.serializer.object({}, {}, instances:, store:, depth: 1) end.to raise_error(Blueprinter::Errors::ExtensionHook, /did not yield/) end - it 'uses the same serializer and blueprint instances throughout, for a given blueprint' do + it "allows around_blueprint_init to modify blueprint options and fields" do + ext = Class.new(Blueprinter::Extension) do + def around_blueprint_init(ctx) + ctx.blueprint.options[:exclude_if_nil] = true + ctx.fields.sort_by!(&:name) + ctx.fields.find { |f| f.name == :description }.options[:exclude_if_nil] = false + yield ctx + end + end + + blueprint = Class.new(application_blueprint) do + add ext.new + fields :id, :name, :summary, :description + end + + result = blueprint.render({ id: 42, name: nil, summary: 'foo', description: nil }).to_json + expect(result).to eq({ + description: nil, + id: 42, + summary: 'foo' + }.to_json) + + expect(blueprint.reflections[:default].options).to eq({}) + expect(blueprint.reflections[:default].fields[:description].options).to eq({}) + end + + it 'uses the same blueprint instance throughout' do blueprint = Class.new(Blueprinter::V2::Base) do self.blueprint_name = 'Foo' field :name association :child, self end instances = Blueprinter::V2::InstanceCache.new - def instances.serializers = @serializers - def instances.blueprints = @blueprints + def instances.store = @instances - serializer = instances.serializer(blueprint, { foo: 'bar' }, {}, 1) - res = serializer.object({ name: 'A', child: { name: 'B', child: { name: 'C' } } }, depth: 1) + res = blueprint.serializer.object({ name: 'A', child: { name: 'B', child: { name: 'C' } } }, {}, instances:, store:, depth: 1) expect(res).to eq({ name: 'A', child: { name: 'B', child: { name: 'C', child: nil } } }) - blueprint_serializers = instances.serializers.count - expect(blueprint_serializers).to eq 1 - - blueprint_instances = instances.blueprints.count + blueprint_instances = instances.store.count expect(blueprint_instances).to eq 1 end + it 'uses Blueprints from a Proc (0 args)' do + blueprint = Class.new(Blueprinter::V2::Base) do + field :name + association :child, -> { self } + end + + res = blueprint.serializer.object({ name: 'A', child: { name: 'B' } }, {}, instances:, store:, depth: 1) + expect(res).to eq({ name: 'A', child: { name: 'B', child: nil } }) + end + + it 'uses Blueprints from a Proc (1 arg)' do + blueprint = Class.new(Blueprinter::V2::Base) do + field :name + association :child, ->(object) { object.fetch(:blueprint) } + end + + res = blueprint.serializer.object({ name: 'A', child: { name: 'B', blueprint: } }, {}, instances:, store:, depth: 1) + expect(res).to eq({ name: 'A', child: { name: 'B', child: nil } }) + end + + it 'uses Blueprints from a Proc (collection)' do + blueprint = Class.new(Blueprinter::V2::Base) do + field :name + association :children, [-> { self }] + end + + res = blueprint.serializer.object({ name: 'A', children: [{ name: 'B' }] }, {}, instances:, store:, depth: 1) + expect(res).to eq({ name: 'A', children: [{ name: 'B', children: nil }] }) + end + it 'passes objects information about the parent' do ext = Class.new(Blueprinter::Extension) do def initialize(log) = @log = log @@ -493,12 +547,12 @@ def around_serialize_collection(ctx) end end log = [] - category_blueprint.extensions << ext.new(log) - part_blueprint.extensions << ext.new(log) - serializer = described_class.new(widget_blueprint, {}, instances, store: {}, initial_depth: 1) + category_blueprint.add ext.new(log) + part_blueprint.add ext.new(log) + serializer = widget_blueprint.serializer widget = { name: 'Foo', category: { name: 'Bar' }, parts: [{ num: 42 }] } - result = serializer.object(widget, depth: 1) + result = serializer.object(widget, {}, instances:, store:, depth: 1) expect(log).to eq [ "Object Parent Blueprint: WidgetBlueprint", "Object Parent field: category", diff --git a/spec/v2/view_builder_spec.rb b/spec/v2/view_builder_spec.rb deleted file mode 100644 index e17c56187..000000000 --- a/spec/v2/view_builder_spec.rb +++ /dev/null @@ -1,133 +0,0 @@ -# frozen_string_literal: true - -describe Blueprinter::V2::ViewBuilder do - let(:builder) do - Blueprinter::V2::ViewBuilder.new(blueprint) - end - - let(:blueprint) do - Class.new(Blueprinter::V2::Base) do - field :id - field :name - end - end - - it "aliases the parent as the default view" do - default = builder[:default] - expect(default).to eq blueprint - end - - it "stores, but doesn't evaluates, a view" do - calls = 0 - d = proc do - field :description - calls += 1 - end - builder[:foo] = definition(d) - - expect(calls).to eq 0 - end - - it "evaluates a view on first access" do - calls = 0 - d = proc do - field :description - calls += 1 - end - builder[:foo] = definition(d) - - view = builder[:foo] - builder[:foo] - expect(calls).to eq 1 - expect(view.reflections[:default].fields.keys.sort).to eq %i(id name description).sort - end - - it "fetches a view" do - d = proc { field :description } - builder[:foo] = definition(d) - - view = builder.fetch(:foo) - expect(view.reflections[:default].fields.keys.sort).to eq %i(id name description).sort - end - - it "throws an error when fetching an invalid name" do - expect { builder.fetch(:foo) }.to raise_error KeyError - end - - it "iterates over each view" do - d = proc { field :description } - builder[:foo] = definition(d) - builder[:bar] = definition(d) - - keys = builder.each.map { |name, _| name } - expect(keys.sort).to eq %i(default foo bar).sort - end - - it "doesn't throw an error if you try to redefine an existing view" do - d = proc { field :description } - builder[:foo] = definition(d) - expect do - d = proc { field :description } - builder[:foo] = definition(d) - end.to_not raise_error - end - - it "throws an error if you try to define the default view" do - d = proc { field :description } - expect { - builder[:default] = definition(d) - }.to raise_error Blueprinter::Errors::InvalidBlueprint - end - - context "reset" do - it "clears all views but default" do - d = proc { field :description } - builder[:foo] = definition(d) - builder[:bar] = definition(d) - builder.reset - - expect(builder[:foo]).to be_nil - expect(builder[:bar]).to be_nil - expect(builder[:default]).to eq blueprint - end - end - - context "dup_for" do - let(:blueprint2) { Class.new(blueprint) { field :description } } - - it "duplicates views for another blueprint" do - d = proc { field :description } - builder[:foo] = definition(d) - builder[:bar] = definition(d) - builder2 = builder.dup_for(blueprint2) - - expect(builder[:default]).to eq blueprint - expect(builder2[:default]).to eq blueprint2 - expect(builder2[:foo]).to_not be_nil - expect(builder2[:bar]).to_not be_nil - end - end - - it "handles cyclic references" do - widget_blueprint = nil - category_blueprint = Class.new(Blueprinter::V2::Base) do - self.blueprint_name = "CategoryBlueprint" - view :cyclic do - association :widgets, [widget_blueprint[:cyclic]] - end - end - widget_blueprint = Class.new(Blueprinter::V2::Base) do - self.blueprint_name = "WidgetBlueprint" - view :cyclic do - association :category, category_blueprint[:cyclic] - end - end - expect do - widget_blueprint[:cyclic].reflections - end.to_not raise_error - end - - def definition(definition) - described_class::Def.new(definition:, empty: false) - end -end diff --git a/spec/v2/view_spec.rb b/spec/v2/view_spec.rb index 7c5e61474..57a96a141 100644 --- a/spec/v2/view_spec.rb +++ b/spec/v2/view_spec.rb @@ -25,6 +25,21 @@ end end + it "invalid views can be referenced before eval" do + expect { blueprint[:"asdf"] }.to_not raise_error + expect { blueprint[:"asdf.zxcv"] }.to_not raise_error + expect { blueprint[:"extended.plus3"] }.to_not raise_error + end + + it "throws if a view doesn't exist AFTER it's been evaled" do + blueprint.reflections + expect { blueprint[:"asdf"] }.to raise_error(Blueprinter::Errors::UnknownView) + expect { blueprint[:"asdf.zxcv"] }.to raise_error(Blueprinter::Errors::UnknownView) + + blueprint[:extended].reflections + expect { blueprint[:"extended.plus3"] }.to raise_error(Blueprinter::Errors::UnknownView) + end + it "are inherited by other blueprints" do blueprint2 = Class.new(blueprint) do view :foo do @@ -41,15 +56,20 @@ context "fields, options, and extensions" do let(:application_blueprint) do Class.new(Blueprinter::V2::Base) do - options[:exclude_if_nil] = true - extensions << Class.new(Blueprinter::Extension).new + self.blueprint_name = 'ApplicationBlueprint' + set :exclude_if_nil, true + add Class.new(Blueprinter::Extension).new fields :id - view(:identifier, empty: true) { field :id } + view :identifier do + exclude fields: true + field :id + end end end let(:blueprint) do Class.new(application_blueprint) do + self.blueprint_name = 'MyBlueprint' fields :name, :date view :extended do @@ -59,13 +79,15 @@ end it "inherits options" do - expect(blueprint.options).to eq({ exclude_if_nil: true }) - expect(blueprint[:extended].options).to eq({ exclude_if_nil: true }) + ref = blueprint.reflections + expect(ref[:default].options).to eq({ exclude_if_nil: true }) + expect(ref[:extended].options).to eq({ exclude_if_nil: true }) end it "inherits extensions" do - expect(blueprint.extensions.size).to eq 1 - expect(blueprint[:extended].extensions.size).to eq 1 + ref = blueprint.reflections + expect(ref[:default].extensions.size).to eq 1 + expect(ref[:extended].extensions.size).to eq 1 end it "inherits fields by default" do @@ -92,14 +114,63 @@ expect(bp2.reflections[:foo].fields.keys.sort).to eq %i(description id name) end - it "can be extended" do - bp1 = Class.new(Blueprinter::V2::Base) do - view(:foo) { fields :id, :name } + it "throws an error if you try to define the default view" do + expect do + Class.new(Blueprinter::V2::Base) do + view :default do + field :name + end + end + end.to raise_error Blueprinter::Errors::InvalidBlueprint + end + + it "handles cyclic references" do + widget_blueprint = nil + category_blueprint = Class.new(Blueprinter::V2::Base) do + self.blueprint_name = "CategoryBlueprint" + view :cyclic do + association :widgets, [widget_blueprint[:cyclic]] + end end - bp2 = Class.new(bp1) do - view(:foo) { field :description } + widget_blueprint = Class.new(Blueprinter::V2::Base) do + self.blueprint_name = "WidgetBlueprint" + view :cyclic do + association :category, category_blueprint[:cyclic] + end end - expect(bp2.reflections[:foo].fields.keys.sort).to eq %i(description id name) + expect do + widget_blueprint[:cyclic].reflections + end.to_not raise_error + end + + it "allows blueprints to reference their own views" do + blueprint = Class.new(Blueprinter::V2::Base) do + set :exclude_if_nil, true + + field :name + association :child, self[:extended] + + view :extended do + field :description + end + end + + result = blueprint.render({ + name: 'Foo', + description: 'About Foo', + child: { + name: 'Bar', + description: 'About Bar' + } + }).to_h + + expect(result).to eq({ + name: 'Foo', + child: { + name: 'Bar', + description: 'About Bar' + } + }) end end end