From 969296e0cd900873f15bf5b4a0d191ef9f4a617d Mon Sep 17 00:00:00 2001 From: Jordan Hollinger Date: Fri, 1 Nov 2024 07:51:03 -0400 Subject: [PATCH 01/16] V2 extension hooks and a hook runner Signed-off-by: Jordan Hollinger --- lib/blueprinter.rb | 1 + lib/blueprinter/base.rb | 2 +- lib/blueprinter/configuration.rb | 10 +- lib/blueprinter/extension.rb | 161 ++++++++++++++++++++++++++++- lib/blueprinter/extensions.rb | 37 ------- lib/blueprinter/hooks.rb | 68 ++++++++++++ lib/blueprinter/v2/fields.rb | 2 + spec/extensions/extension_spec.rb | 81 +++++++++++++++ spec/extensions/hooks_spec.rb | 109 +++++++++++++++++++ spec/units/extensions_spec.rb | 126 ---------------------- spec/units/pre_render_hook_spec.rb | 92 +++++++++++++++++ 11 files changed, 520 insertions(+), 169 deletions(-) delete mode 100644 lib/blueprinter/extensions.rb create mode 100644 lib/blueprinter/hooks.rb create mode 100644 spec/extensions/extension_spec.rb create mode 100644 spec/extensions/hooks_spec.rb delete mode 100644 spec/units/extensions_spec.rb create mode 100644 spec/units/pre_render_hook_spec.rb diff --git a/lib/blueprinter.rb b/lib/blueprinter.rb index 330691a8..17c0b1e9 100644 --- a/lib/blueprinter.rb +++ b/lib/blueprinter.rb @@ -6,6 +6,7 @@ module Blueprinter autoload :Configuration, 'blueprinter/configuration' autoload :Errors, 'blueprinter/errors' autoload :Extension, 'blueprinter/extension' + autoload :Hooks, 'blueprinter/hooks' autoload :Transformer, 'blueprinter/transformer' autoload :V2, 'blueprinter/v2' diff --git a/lib/blueprinter/base.rb b/lib/blueprinter/base.rb index b37961c0..ca4ba97e 100644 --- a/lib/blueprinter/base.rb +++ b/lib/blueprinter/base.rb @@ -246,7 +246,7 @@ def self.render_as_json(object, options = {}) def self.prepare(object, view_name:, local_options:, root: nil, meta: nil) raise BlueprinterError, "View '#{view_name}' is not defined" unless view_collection.view? view_name - object = Blueprinter.configuration.extensions.pre_render(object, self, view_name, local_options) + object = Blueprinter.configuration.hooks.reduce(:pre_render, object) { |val| [val, self, view_name, local_options] } data = prepare_data(object, view_name, local_options) prepend_root_and_meta(data, root, meta) end diff --git a/lib/blueprinter/configuration.rb b/lib/blueprinter/configuration.rb index ce19178e..fe2fef26 100644 --- a/lib/blueprinter/configuration.rb +++ b/lib/blueprinter/configuration.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require 'json' -require 'blueprinter/extensions' +require 'blueprinter/hooks' require 'blueprinter/extractors/auto_extractor' module Blueprinter @@ -27,11 +27,15 @@ def initialize end def extensions - @extensions ||= Extensions.new + @extensions ||= [] end def extensions=(list) - @extensions = Extensions.new(list) + @extensions = list + end + + def hooks + @hooks ||= Blueprinter::Hooks.new(extensions) end def array_like_classes diff --git a/lib/blueprinter/extension.rb b/lib/blueprinter/extension.rb index 2d4059ec..efa5ce5a 100644 --- a/lib/blueprinter/extension.rb +++ b/lib/blueprinter/extension.rb @@ -2,11 +2,168 @@ module Blueprinter # - # Base class for all extensions. All extension methods are implemented as no-ops. + # Base class for all extensions. + # + # V2 hook call order: + # - sort_fields + # - collection? (skipped if calling render_object/render_collection) + # - input_object | input_collection + # - blueprint_input + # - field_value + # - exclude_field? + # - object_value + # - exclude_object? + # - collection_value + # - exclude_collection? + # - blueprint_output + # - output_object | output_collection + # + # V1 hook call order: + # - pre_render # class Extension # - # Called eary during "render", this method receives the object to be rendered and + # Returns fields in the order they should appear. Default is the order in which they were defined. + # + # @param fields [ArrayBlueprinter::V2::Object|Blueprinter::V2::Collection] + # @return [ArrayBlueprinter::V2::Object|Blueprinter::V2::Collection] + # + def sort_fields(fields) + fields + end + + # + # Returns true if the given object should be treated as a collection (i.e. supports `map { |obj| ... }`). + # + # @param [Object] + # @return [Boolean] + # + def collection?(_object) + false + end + + # + # Modify or replace the object passed to render/render_object. + # + # @param context [Blueprinter::V2::Context] + # @return [Object] + # + def input_object(context) + context.object + end + + # + # Modify or replace the collection passed to render/render_collection. + # + # @param context [Blueprinter::V2::Context] + # @return [Object] + # + def input_collection(context) + context.object + end + + # + # Modify or replace the object result before final render (e.g. to JSON). + # + # @param context [Blueprinter::V2::Context] + # @return [Object] + # + def output_object(context) + context.value + end + + # + # Modify or replace the collection result before final render (e.g. to JSON). + # + # @param context [Blueprinter::V2::Context] + # @return [Object] + # + def output_collection(context) + context.value + end + + # + # Modify or replace an object right before it's serialized by a Blueprint. + # + # @param context [Blueprinter::V2::Context] + # @return [Object] + # + def blueprint_input(context) + context.object + end + + # + # Modify or replace the serialized output from any Blueprint. + # + # @param context [Blueprinter::V2::Context] + # @return [Object] + # + def blueprint_output(context) + context.value + end + + # + # Modify or replace the value used for the field. + # + # @param context [Blueprinter::V2::Context] + # @return [Object] + # + def field_value(context) + context.value + end + + # + # Modify or replace the value used for the object. + # + # @param context [Blueprinter::V2::Context] + # @return [Object] + # + def object_value(context) + context.value + end + + # + # Modify or replace the value used for the collection. + # + # @param context [Blueprinter::V2::Context] + # @return [Object] + # + def collection_value(context) + context.value + end + + # + # Return true to exclude this field from the result. + # + # @param _context [Blueprinter::V2::Context] + # @return [Boolean] + # + def exclude_field?(_context) + false + end + + # + # Return true to exclude this object from the result. + # + # @param _context [Blueprinter::V2::Context] + # @return [Boolean] + # + def exclude_object?(_context) + false + end + + # + # Return true to exclude this collection from the result. + # + # @param _context [Blueprinter::V2::Context] + # @return [Boolean] + # + def exclude_collection?(_context) + false + end + + # + # 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 diff --git a/lib/blueprinter/extensions.rb b/lib/blueprinter/extensions.rb deleted file mode 100644 index 5b49209f..00000000 --- a/lib/blueprinter/extensions.rb +++ /dev/null @@ -1,37 +0,0 @@ -# frozen_string_literal: true - -module Blueprinter - # - # Stores and runs Blueprinter extensions. An extension is any object that implements one or more of the - # extension methods: - # - # The Render Extension intercepts an object before rendering begins. The return value from this - # method is what is ultimately rendered. - # - # def pre_render(object, blueprint, view, options) - # # returns original, modified, or new object - # end - # - class Extensions - def initialize(extensions = []) - @extensions = extensions - end - - def to_a - @extensions.dup - end - - # Appends an extension - def <<(ext) - @extensions << ext - self - end - - # Runs the object through all Render Extensions and returns the final result - def pre_render(object, blueprint, view, options = {}) - @extensions.reduce(object) do |acc, ext| - ext.pre_render(acc, blueprint, view, options) - end - end - end -end diff --git a/lib/blueprinter/hooks.rb b/lib/blueprinter/hooks.rb new file mode 100644 index 00000000..8779638a --- /dev/null +++ b/lib/blueprinter/hooks.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +module Blueprinter + # An interface for running extension hooks efficiently + class Hooks + def initialize(extensions) + @hooks = Extension. + public_instance_methods(false). + each_with_object({}) do |hook, acc| + acc[hook] = extensions.select { |ext| ext.class.public_instance_methods(false).include? hook } + end + end + + # + # Return true if any of "hook" returns truthy. + # + # @param hook [Symbol] Name of hook to call + # @param arg [Object] Argument to hook + # @return [Boolean] + # + def any?(hook, arg) + @hooks.fetch(hook).any? { |ext| ext.public_send(hook, arg) } + end + + # + # Run only the last-added instance of the hook. + # + # @param hook [Symbol] Name of hook to call + # @param *args Any args for the hook + # @return The hook return value, or nil if there was no hook + # + def last(hook, *args) + @hooks.fetch(hook).last&.public_send(hook, *args) + end + + # + # Call the hooks in series, passing the output of one to the block, which returns the args for the next. + # + # If the hook requires multiple arguments, the block should return an array. + # + # @param hook [Symbol] Name of hook to call + # @param initial_value [Object] The starting value for the block + # @return [Object] The last hook's return value + # + def reduce(hook, initial_value) + @hooks.fetch(hook).reduce(initial_value) do |val, ext| + args = yield val + args.is_a?(Array) ? ext.public_send(hook, *args) : ext.public_send(hook, args) + end + end + + # + # An optimized version of reduce for hooks that are in the hot path. It accepts a + # Blueprinter::V2::Context and returns an attribute from it. + # + # @param hook [Symbol] Name of hook to call + # @param target_obj [Object] The argument to the hooks (usually a Blueprinter::V2::Context) + # @param target_attr [Symbol] The attribute on target_obj to update with the hook return value + # @return [Object] The last hook's return value + # + def reduce_into(hook, target_obj, target_attr) + @hooks.fetch(hook).each do |ext| + target_obj[target_attr] = ext.public_send(hook, target_obj) + end + target_obj[target_attr] + end + end +end diff --git a/lib/blueprinter/v2/fields.rb b/lib/blueprinter/v2/fields.rb index 47326813..60e1fb71 100644 --- a/lib/blueprinter/v2/fields.rb +++ b/lib/blueprinter/v2/fields.rb @@ -27,5 +27,7 @@ module V2 :options, keyword_init: true ) + + Context = Struct.new(:blueprint, :field, :value, :object, :options, :instances) end end diff --git a/spec/extensions/extension_spec.rb b/spec/extensions/extension_spec.rb new file mode 100644 index 00000000..056eda28 --- /dev/null +++ b/spec/extensions/extension_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +describe Blueprinter::Extension do + subject { Class.new(described_class) } + + context 'hooks' do + let(:blueprint) { Class.new(Blueprinter::V2::Base) } + let(:field) { Blueprinter::V2::Field.new(name: :foo, from: :foo) } + let(:object) { { foo: 'Foo' } } + let(:context) { Blueprinter::V2::Context } + + it 'should default sort fields to the given order' do + fields = [Blueprinter::V2::Field.new(name: :foo), Blueprinter::V2::Field.new(name: :bar)] + expect(subject.new.sort_fields(fields)).to eq fields + end + + it 'should default collections? to false' do + expect(subject.new.collection? object).to be false + end + + it 'should default input_object to the given object' do + ctx = context.new(blueprint.new, nil, nil, object, {}) + expect(subject.new.input_object(ctx)).to eq object + end + + it 'should default input_collection to the given object' do + ctx = context.new(blueprint.new, nil, nil, [object], {}) + expect(subject.new.input_object(ctx)).to eq [object] + end + + it 'should default output_object to the given value' do + ctx = context.new(blueprint.new, nil, { foo: 'Foo' }, object, {}) + expect(subject.new.output_object(ctx)).to eq({ foo: 'Foo' }) + end + + it 'should default output_collection to the given value' do + ctx = context.new(blueprint.new, nil, [{ foo: 'Foo' }], [object], {}) + expect(subject.new.output_collection(ctx)).to eq([{ foo: 'Foo' }]) + end + + it 'should default blueprint_input to the given object' do + ctx = context.new(blueprint.new, nil, nil, object, {}) + expect(subject.new.blueprint_input(ctx)).to eq object + end + + it 'should default blueprint_output to the given value' do + ctx = context.new(blueprint.new, nil, { foo: 'Foo' }, object, {}) + expect(subject.new.blueprint_output(ctx)).to eq({ foo: 'Foo' }) + end + + it 'should default field_value to the given value' do + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.new.field_value(ctx)).to be 'Foo' + end + + it 'should default object_value to the given value' do + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.new.object_value(ctx)).to be 'Foo' + end + + it 'should default collection_value to the given value' do + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.new.collection_value(ctx)).to be 'Foo' + end + + it 'should default exclude_field? to false' do + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.new.exclude_field?(ctx)).to be false + end + + it 'should default exclude_object? to false' do + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.new.exclude_object?(ctx)).to be false + end + + it 'should default exclude_collection? to false' do + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.new.exclude_collection?(ctx)).to be false + end + end +end diff --git a/spec/extensions/hooks_spec.rb b/spec/extensions/hooks_spec.rb new file mode 100644 index 00000000..2aea224b --- /dev/null +++ b/spec/extensions/hooks_spec.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +describe Blueprinter::Hooks do + let(:blueprint) { Class.new(Blueprinter::V2::Base) } + let(:field) { Blueprinter::V2::Field.new(name: :foo, from: :foo) } + let(:object) { { foo: 'Foo' } } + let(:context) { Blueprinter::V2::Context } + let(:ext1) do + Class.new(Blueprinter::Extension) do + def output_object(context) + context.value[:n] += 1 if context.value[:n] + context.value + end + + def exclude_field?(context) + context.value.nil? + end + end + end + let(:ext2) do + Class.new(Blueprinter::Extension) do + def exclude_field?(context) + context.value == "" || context.value == [] + end + end + end + + context 'any?' do + it 'should return true if any hook returns true' do + hooks = described_class.new [ext1.new, ext2.new] + ctx = context.new(blueprint.new, field, nil, object, {}) + expect(hooks.any?(:exclude_field?, ctx)).to be true + end + + it 'should return false if no hooks return true' do + hooks = described_class.new [ext1.new, ext2.new] + ctx = context.new(blueprint.new, field, { name: 'Foo' }, object, {}) + expect(hooks.any?(:exclude_field?, ctx)).to be false + end + + it 'should return false if there are no extensions' do + hooks = described_class.new [] + ctx = context.new(blueprint.new, field, nil, object, {}) + expect(hooks.any?(:exclude_field?, ctx)).to be false + end + end + + context 'last' do + it 'should return the value from the last hook' do + hooks = described_class.new [ext1.new, ext2.new] + ctx = context.new(blueprint.new, field, '', object, {}) + result = hooks.last(:exclude_field?, ctx) + expect(result).to be true + end + + it 'should reutrn nil if there are no hooks' do + hooks = described_class.new [] + ctx = context.new(blueprint.new, field, { name: 'Foo', n: 0 }, object, {}) + result = hooks.last(:exclude_field?, ctx) + expect(result).to be nil + end + end + + context 'reduce' do + it 'should return the final value' do + hooks = described_class.new [ext1.new, ext2.new, ext1.new, ext1.new] + ctx = context.new(blueprint.new, field, { name: 'Foo', n: 0 }, object, {}) + result = hooks.reduce(:output_object, ctx.value) { |val| ctx.value = val; ctx } + expect(result).to eq({ name: 'Foo', n: 3 }) + end + + it 'should expand a returned array into args' do + hooks = described_class.new [ext1.new, ext2.new, ext1.new, ext1.new] + ctx = context.new(blueprint.new, field, { name: 'Foo', n: 0 }, object, {}) + result = hooks.reduce(:output_object, ctx.value) { |val| ctx.value = val; [ctx] } + expect(result).to eq({ name: 'Foo', n: 3 }) + end + + it 'should return the initial value if there are no hooks' do + hooks = described_class.new [] + ctx = context.new(blueprint.new, field, { name: 'Foo' }, object, {}) + result = hooks.reduce(:output_object, ctx.value) { |val| ctx.value = val; ctx } + expect(result).to eq({ name: 'Foo' }) + end + end + + context 'reduce_into' do + it 'should return the final value' do + hooks = described_class.new [ext1.new, ext2.new, ext1.new, ext1.new] + ctx = context.new(blueprint.new, field, { name: 'Foo', n: 0 }, object, {}) + result = hooks.reduce_into(:output_object, ctx, :value) + expect(result).to eq({ name: 'Foo', n: 3 }) + end + + it 'should expand a returned array into args' do + hooks = described_class.new [ext1.new, ext2.new, ext1.new, ext1.new] + ctx = context.new(blueprint.new, field, { name: 'Foo', n: 0 }, object, {}) + result = hooks.reduce_into(:output_object, ctx, :value) + expect(result).to eq({ name: 'Foo', n: 3 }) + end + + it 'should return the initial value if there are no hooks' do + hooks = described_class.new [] + ctx = context.new(blueprint.new, field, { name: 'Foo' }, object, {}) + result = hooks.reduce_into(:output_object, ctx, :value) + expect(result).to eq({ name: 'Foo' }) + end + end +end diff --git a/spec/units/extensions_spec.rb b/spec/units/extensions_spec.rb deleted file mode 100644 index 09a341b3..00000000 --- a/spec/units/extensions_spec.rb +++ /dev/null @@ -1,126 +0,0 @@ -# frozen_string_literal: true - -require 'ostruct' -require 'blueprinter/extensions' - -describe Blueprinter::Extensions do - let(:all_extensions) { - [ - foo_extension.new, - bar_extension.new, - zar_extension.new, - ] - } - - let(:foo_extension) { - Class.new(Blueprinter::Extension) do - def pre_render(object, _blueprint, _view, _options) - obj = object.dup - obj.foo = "Foo" - obj - end - end - } - - let(:bar_extension) { - Class.new(Blueprinter::Extension) do - def pre_render(object, _blueprint, _view, _options) - obj = object.dup - obj.bar = "Bar" - obj - end - end - } - - let(:zar_extension) { - Class.new(Blueprinter::Extension) do - def self.something_else(object, _blueprint, _view, _options) - object - end - end - } - - it 'should append extensions' do - extensions = Blueprinter::Extensions.new - extensions << foo_extension.new - extensions << bar_extension.new - extensions << zar_extension.new - expect(extensions.to_a.map(&:class)).to eq [ - foo_extension, - bar_extension, - zar_extension, - ] - end - - it "should initialize with extensions, removing any that don't have recognized extension methods" do - extensions = Blueprinter::Extensions.new(all_extensions) - expect(extensions.to_a.map(&:class)).to eq [ - foo_extension, - bar_extension, - zar_extension, - ] - end - - context '#pre_render' do - before :each do - Blueprinter.configure do |config| - config.extensions = all_extensions - end - end - - after :each do - Blueprinter.configure do |config| - config.extensions = [] - end - end - - let(:test_blueprint) { - Class.new(Blueprinter::Base) do - field :id - field :name - field :foo - - view :with_bar do - field :bar - end - end - } - - it 'should run all pre_render extensions' do - extensions = Blueprinter::Extensions.new(all_extensions) - obj = OpenStruct.new(id: 42, name: 'Jack') - obj = extensions.pre_render(obj, test_blueprint, :default, {}) - expect(obj.id).to be 42 - expect(obj.name).to eq 'Jack' - expect(obj.foo).to eq 'Foo' - expect(obj.bar).to eq 'Bar' - end - - it 'should run with Blueprinter.render using default view' do - obj = OpenStruct.new(id: 42, name: 'Jack') - res = JSON.parse(test_blueprint.render(obj)) - expect(res['id']).to be 42 - expect(res['name']).to eq 'Jack' - expect(res['foo']).to eq 'Foo' - expect(res['bar']).to be_nil - end - - it 'should run with Blueprinter.render using with_bar view' do - obj = OpenStruct.new(id: 42, name: 'Jack') - res = JSON.parse(test_blueprint.render(obj, view: :with_bar)) - expect(res['id']).to be 42 - expect(res['name']).to eq 'Jack' - expect(res['foo']).to eq 'Foo' - expect(res['bar']).to eq 'Bar' - end - - it 'should run with Blueprinter.render_as_hash' do - obj = OpenStruct.new(id: 42, name: 'Jack') - res = test_blueprint.render_as_hash(obj, view: :with_bar) - expect(res[:id]).to be 42 - expect(res[:name]).to eq 'Jack' - expect(res[:foo]).to eq 'Foo' - expect(res[:bar]).to eq 'Bar' - end - end -end diff --git a/spec/units/pre_render_hook_spec.rb b/spec/units/pre_render_hook_spec.rb new file mode 100644 index 00000000..48449e9f --- /dev/null +++ b/spec/units/pre_render_hook_spec.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require 'ostruct' + +describe 'V1 pre_render hook' do + let(:all_extensions) { + [ + foo_extension.new, + bar_extension.new, + zar_extension.new, + ] + } + + let(:foo_extension) { + Class.new(Blueprinter::Extension) do + def pre_render(object, _blueprint, _view, _options) + obj = object.dup + obj.foo = "Foo" + obj + end + end + } + + let(:bar_extension) { + Class.new(Blueprinter::Extension) do + def pre_render(object, _blueprint, _view, _options) + obj = object.dup + obj.bar = "Bar" + obj + end + end + } + + let(:zar_extension) { + Class.new(Blueprinter::Extension) do + def self.something_else(object, _blueprint, _view, _options) + object + end + end + } + + before :each do + Blueprinter.configure do |config| + config.extensions = all_extensions + end + end + + after :each do + Blueprinter.configure do |config| + config.extensions = [] + end + end + + let(:test_blueprint) { + Class.new(Blueprinter::Base) do + field :id + field :name + field :foo + + view :with_bar do + field :bar + end + end + } + + it 'should run with Blueprinter.render using default view' do + obj = OpenStruct.new(id: 42, name: 'Jack') + res = JSON.parse(test_blueprint.render(obj)) + expect(res['id']).to be 42 + expect(res['name']).to eq 'Jack' + expect(res['foo']).to eq 'Foo' + expect(res['bar']).to be_nil + end + + it 'should run with Blueprinter.render using with_bar view' do + obj = OpenStruct.new(id: 42, name: 'Jack') + res = JSON.parse(test_blueprint.render(obj, view: :with_bar)) + expect(res['id']).to be 42 + expect(res['name']).to eq 'Jack' + expect(res['foo']).to eq 'Foo' + expect(res['bar']).to eq 'Bar' + end + + it 'should run with Blueprinter.render_as_hash' do + obj = OpenStruct.new(id: 42, name: 'Jack') + res = test_blueprint.render_as_hash(obj, view: :with_bar) + expect(res[:id]).to be 42 + expect(res[:name]).to eq 'Jack' + expect(res[:foo]).to eq 'Foo' + expect(res[:bar]).to eq 'Bar' + end +end From 8a5a29223f1530f9bc6d24b3867dc4742c0ea2ce Mon Sep 17 00:00:00 2001 From: Jordan Hollinger Date: Fri, 1 Nov 2024 08:10:34 -0400 Subject: [PATCH 02/16] Class-based formatters for V2 Signed-off-by: Jordan Hollinger --- lib/blueprinter/v2.rb | 1 + lib/blueprinter/v2/base.rb | 13 ++++++++++++- lib/blueprinter/v2/dsl.rb | 11 +++++++++++ lib/blueprinter/v2/formatter.rb | 25 ++++++++++++++++++++++++ spec/v2/fields_spec.rb | 29 ++++++++++++++++++++++++++++ spec/v2/formatter_spec.rb | 34 +++++++++++++++++++++++++++++++++ 6 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 lib/blueprinter/v2/formatter.rb create mode 100644 spec/v2/formatter_spec.rb diff --git a/lib/blueprinter/v2.rb b/lib/blueprinter/v2.rb index c9adf3d6..6066c6e3 100644 --- a/lib/blueprinter/v2.rb +++ b/lib/blueprinter/v2.rb @@ -6,6 +6,7 @@ module Blueprinter module V2 autoload :Base, 'blueprinter/v2/base' autoload :DSL, 'blueprinter/v2/dsl' + autoload :Formatter, 'blueprinter/v2/formatter' autoload :Reflection, 'blueprinter/v2/reflection' autoload :ViewBuilder, 'blueprinter/v2/view_builder' end diff --git a/lib/blueprinter/v2/base.rb b/lib/blueprinter/v2/base.rb index c21f1c4c..8e69c5cd 100644 --- a/lib/blueprinter/v2/base.rb +++ b/lib/blueprinter/v2/base.rb @@ -17,12 +17,13 @@ class << self # @api private The fully-qualified name, e.g. "MyBlueprint", or "MyBlueprint.foo.bar" attr_accessor :blueprint_name # @api private - attr_accessor :views, :schema, :excludes, :partials, :used_partials, :eval_mutex + attr_accessor :views, :schema, :excludes, :formatters, :partials, :used_partials, :eval_mutex end self.views = ViewBuilder.new(self) self.schema = {} self.excludes = [] + self.formatters = {} self.partials = {} self.used_partials = [] self.extensions = [] @@ -36,6 +37,7 @@ def self.inherited(subclass) subclass.views = ViewBuilder.new(subclass) subclass.schema = schema.transform_values(&:dup) subclass.excludes = [] + subclass.formatters = formatters.dup subclass.partials = partials.dup subclass.used_partials = [] subclass.extensions = extensions.dup @@ -116,6 +118,15 @@ def self.run_eval! end excludes.each { |f| schema.delete f } + extensions.freeze + options.freeze + formatters.freeze + schema.freeze + schema.each do |_, f| + f.options&.freeze + f.freeze + end + @evaled = true end diff --git a/lib/blueprinter/v2/dsl.rb b/lib/blueprinter/v2/dsl.rb index fdd277a0..5ca492ad 100644 --- a/lib/blueprinter/v2/dsl.rb +++ b/lib/blueprinter/v2/dsl.rb @@ -37,6 +37,17 @@ def use(*names) names.each { |name| used_partials << name.to_sym } end + # + # Add a formatter for field values of the given class. + # + # @param klass [Class] The class of objects to format + # @param formatter_method [Symbol] Name of a public instance method to call for formatting + # @yield Do formatting in the block instead + # + def format(klass, formatter_method = nil, &formatter_block) + formatters[klass] = formatter_method || formatter_block + end + # # Define a field. # diff --git a/lib/blueprinter/v2/formatter.rb b/lib/blueprinter/v2/formatter.rb new file mode 100644 index 00000000..4060152d --- /dev/null +++ b/lib/blueprinter/v2/formatter.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Blueprinter + module V2 + # An interface for formatting values + class Formatter + def initialize(blueprint) + @formatters = blueprint.formatters + end + + # @param ctx [Blueprinter::V2::Context] + def call(ctx) + fmt = @formatters[ctx.value.class] + case fmt + when nil + ctx.value + when Proc + ctx.blueprint.instance_exec(ctx.value, &fmt) + when Symbol, String + ctx.blueprint.public_send(fmt, ctx.value) + end + end + end + end +end diff --git a/spec/v2/fields_spec.rb b/spec/v2/fields_spec.rb index 4cca7ab0..842a7760 100644 --- a/spec/v2/fields_spec.rb +++ b/spec/v2/fields_spec.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require 'date' + describe "Blueprinter::V2 Fields" do context "fields" do it "should add fields with options" do @@ -152,4 +154,31 @@ 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 + def fmt_date(d) + d.iso8601 + end + end + end + + it 'should add a block formatter' do + iso8601 = ->(x, _opts) { x.iso8601 } + blueprint.format(Date, &iso8601) + expect(blueprint.formatters[Date]).to eq iso8601 + end + + it 'should add a method formatter' do + blueprint.format(Date, :fmt_date) + expect(blueprint.formatters[Date]).to eq :fmt_date + end + + it 'should be inherited' do + blueprint.format(Date, :fmt_date) + child = Class.new(blueprint) + expect(child.formatters[Date]).to eq :fmt_date + end + end end diff --git a/spec/v2/formatter_spec.rb b/spec/v2/formatter_spec.rb new file mode 100644 index 00000000..414c2172 --- /dev/null +++ b/spec/v2/formatter_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require 'date' + +describe Blueprinter::V2::Formatter do + let(:field) { Blueprinter::V2::Field.new(name: :foo, from: :foo) } + let(:object) { { foo: 'Foo' } } + let(:context) { Blueprinter::V2::Context } + let(:blueprint) do + Class.new(Blueprinter::V2::Base) do + format(Date) { |date| date.iso8601 } + format TrueClass, :yes + + def yes(_) + "Yes" + end + end + end + + it 'should call proc formatters' do + formatter = described_class.new(blueprint) + expect(formatter.call(context.new(blueprint.new, field, Date.new(2024, 10, 1), object, {}))).to eq '2024-10-01' + end + + it 'should call instance method formatters' do + formatter = described_class.new(blueprint) + expect(formatter.call(context.new(blueprint.new, field, true, object, {}))).to eq "Yes" + end + + it "should pass through values it doesn't know about" do + formatter = described_class.new(blueprint) + expect(formatter.call(context.new(blueprint.new, field, "foo", object, {}))).to eq "foo" + end +end From 66433ed5883a5010b7ea86560e719e834e994a28 Mon Sep 17 00:00:00 2001 From: Jordan Hollinger Date: Fri, 1 Nov 2024 08:18:46 -0400 Subject: [PATCH 03/16] V2 extractor Signed-off-by: Jordan Hollinger --- lib/blueprinter/v2.rb | 1 + lib/blueprinter/v2/extractor.rb | 26 +++++++++++ spec/v2/extractor_spec.rb | 82 +++++++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+) create mode 100644 lib/blueprinter/v2/extractor.rb create mode 100644 spec/v2/extractor_spec.rb diff --git a/lib/blueprinter/v2.rb b/lib/blueprinter/v2.rb index 6066c6e3..c7f30af5 100644 --- a/lib/blueprinter/v2.rb +++ b/lib/blueprinter/v2.rb @@ -6,6 +6,7 @@ module Blueprinter module V2 autoload :Base, 'blueprinter/v2/base' autoload :DSL, 'blueprinter/v2/dsl' + autoload :Extractor, 'blueprinter/v2/extractor' autoload :Formatter, 'blueprinter/v2/formatter' autoload :Reflection, 'blueprinter/v2/reflection' autoload :ViewBuilder, 'blueprinter/v2/view_builder' diff --git a/lib/blueprinter/v2/extractor.rb b/lib/blueprinter/v2/extractor.rb new file mode 100644 index 00000000..5402e888 --- /dev/null +++ b/lib/blueprinter/v2/extractor.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Blueprinter + module V2 + # The default extractor and base class for custom extractors + class Extractor + def field(blueprint, field, object, options) + if field.value_proc + blueprint.instance_exec(object, options, &field.value_proc) + elsif object.is_a? Hash + object[field.from] + else + object.public_send(field.from) + end + end + + def object(blueprint, field, object, options) + field(blueprint, field, object, options) + end + + def collection(blueprint, field, object, options) + field(blueprint, field, object, options) + end + end + end +end diff --git a/spec/v2/extractor_spec.rb b/spec/v2/extractor_spec.rb new file mode 100644 index 00000000..75364719 --- /dev/null +++ b/spec/v2/extractor_spec.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +describe Blueprinter::V2::Extractor do + subject { described_class.new } + + let(:blueprint) do + Class.new(Blueprinter::V2::Base) do + def upcase(str) + str.upcase + end + end + end + + context 'field' do + it "should extract using a block" do + field = Blueprinter::V2::Field.new(from: :foo, value_proc: ->(obj, _opts) { upcase obj[:foo] }) + obj = { foo: 'bar' } + val = subject.field(blueprint.new, field, obj, {}) + expect(val).to eq 'BAR' + end + + it "should extract using a Hash key" do + field = Blueprinter::V2::Field.new(from: :foo) + obj = { foo: 'bar' } + val = subject.field(blueprint.new, field, obj, {}) + expect(val).to eq 'bar' + end + + it "should extract using a method name" do + field = Blueprinter::V2::Field.new(from: :name) + obj = Struct.new(:name).new("Foo") + val = subject.field(blueprint.new, field, obj, {}) + expect(val).to eq 'Foo' + end + end + + context 'object' do + it "should extract using a block" do + field = Blueprinter::V2::ObjectField.new(from: :foo, value_proc: ->(obj, _opts) { upcase obj[:foo] }) + obj = { foo: 'bar' } + val = subject.object(blueprint.new, field, obj, {}) + expect(val).to eq 'BAR' + end + + it "should extract using a Hash key" do + field = Blueprinter::V2::Field.new(from: :foo) + obj = { foo: 'bar' } + val = subject.object(blueprint.new, field, obj, {}) + expect(val).to eq 'bar' + end + + it "should extract using a method name" do + field = Blueprinter::V2::Field.new(from: :name) + obj = Struct.new(:name).new("Foo") + val = subject.object(blueprint.new, field, obj, {}) + expect(val).to eq 'Foo' + end + end + + context 'collection' do + it "should extract using a block" do + field = Blueprinter::V2::Collection.new(from: :foo, value_proc: ->(obj, _opts) { upcase obj[:foo] }) + obj = { foo: 'bar' } + val = subject.collection(blueprint.new, field, obj, {}) + expect(val).to eq 'BAR' + end + + it "should extract using a Hash key" do + field = Blueprinter::V2::Field.new(from: :foo) + obj = { foo: 'bar' } + val = subject.collection(blueprint.new, field, obj, {}) + expect(val).to eq 'bar' + end + + it "should extract using a method name" do + field = Blueprinter::V2::Field.new(from: :name) + obj = Struct.new(:name).new("Foo") + val = subject.collection(blueprint.new, field, obj, {}) + expect(val).to eq 'Foo' + end + end +end From a27abd7dba3097fc323acc52145b1e6f48735416 Mon Sep 17 00:00:00 2001 From: Jordan Hollinger Date: Fri, 1 Nov 2024 09:18:33 -0400 Subject: [PATCH 04/16] V2 Serializer and renderer Signed-off-by: Jordan Hollinger --- lib/blueprinter/v2.rb | 4 + lib/blueprinter/v2/base.rb | 21 ++-- lib/blueprinter/v2/dsl.rb | 3 + lib/blueprinter/v2/extensions.rb | 9 ++ lib/blueprinter/v2/extensions/values.rb | 41 +++++++ lib/blueprinter/v2/instance_cache.rb | 19 ++++ lib/blueprinter/v2/reflection.rb | 3 + lib/blueprinter/v2/render.rb | 42 ++++++++ lib/blueprinter/v2/serializer.rb | 61 +++++++++++ spec/v2/extensions/extraction_spec.rb | 101 ++++++++++++++++++ spec/v2/instance_cache_spec.rb | 22 ++++ spec/v2/reflection_spec.rb | 66 +++++++++--- spec/v2/render_spec.rb | 112 ++++++++++++++++++++ spec/v2/rendering_spec.rb | 46 ++++++++ spec/v2/serializer_spec.rb | 135 ++++++++++++++++++++++++ 15 files changed, 661 insertions(+), 24 deletions(-) create mode 100644 lib/blueprinter/v2/extensions.rb create mode 100644 lib/blueprinter/v2/extensions/values.rb create mode 100644 lib/blueprinter/v2/instance_cache.rb create mode 100644 lib/blueprinter/v2/render.rb create mode 100644 lib/blueprinter/v2/serializer.rb create mode 100644 spec/v2/extensions/extraction_spec.rb create mode 100644 spec/v2/instance_cache_spec.rb create mode 100644 spec/v2/render_spec.rb create mode 100644 spec/v2/rendering_spec.rb create mode 100644 spec/v2/serializer_spec.rb diff --git a/lib/blueprinter/v2.rb b/lib/blueprinter/v2.rb index c7f30af5..e79e6199 100644 --- a/lib/blueprinter/v2.rb +++ b/lib/blueprinter/v2.rb @@ -6,9 +6,13 @@ module Blueprinter module V2 autoload :Base, 'blueprinter/v2/base' autoload :DSL, 'blueprinter/v2/dsl' + autoload :Extensions, 'blueprinter/v2/extensions' autoload :Extractor, 'blueprinter/v2/extractor' autoload :Formatter, 'blueprinter/v2/formatter' + autoload :InstanceCache, 'blueprinter/v2/instance_cache' autoload :Reflection, 'blueprinter/v2/reflection' + autoload :Render, 'blueprinter/v2/render' + autoload :Serializer, 'blueprinter/v2/serializer' autoload :ViewBuilder, 'blueprinter/v2/view_builder' end end diff --git a/lib/blueprinter/v2/base.rb b/lib/blueprinter/v2/base.rb index 8e69c5cd..7ab7b8b1 100644 --- a/lib/blueprinter/v2/base.rb +++ b/lib/blueprinter/v2/base.rb @@ -1,5 +1,8 @@ # frozen_string_literal: true +require 'blueprinter/v2/render' +require 'blueprinter/v2/serializer' + module Blueprinter module V2 # Base class for V2 Blueprints @@ -81,7 +84,7 @@ def self.[](name) end def self.render(obj, options = {}) - if array_like? obj + if serializer.hooks.any?(:collection?, obj) render_collection(obj, options) else render_object(obj, options) @@ -89,11 +92,17 @@ def self.render(obj, options = {}) end def self.render_object(obj, options = {}) - # TODO call external renderer + Render.new(obj, options, serializer: serializer, collection: false) end def self.render_collection(objs, options = {}) - # TODO call external renderer + Render.new(objs, options, serializer: serializer, collection: true) + end + + # @api private + def self.serializer + eval! unless @evaled + @serializer end # Apply partials and field exclusions @@ -127,13 +136,9 @@ def self.run_eval! f.freeze end + @serializer = Serializer.new(self) @evaled = true end - - # @api private - def self.array_like?(obj) - # TODO - end end end end diff --git a/lib/blueprinter/v2/dsl.rb b/lib/blueprinter/v2/dsl.rb index 5ca492ad..24aab159 100644 --- a/lib/blueprinter/v2/dsl.rb +++ b/lib/blueprinter/v2/dsl.rb @@ -53,6 +53,7 @@ def format(klass, formatter_method = nil, &formatter_block) # # @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 # @yield [TODO] Generate the value from the block # @return [Blueprinter::V2::Field] # @@ -82,6 +83,7 @@ def fields(*names) # @param name [Symbol] Name of the association # @param blueprint [Class|Proc] Blueprint class to use, or one defined with a Proc # @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 # @yield [TODO] Generate the value from the block # @return [Blueprinter::V2::ObjectField] # @@ -102,6 +104,7 @@ def object(name, blueprint, from: name, **options, &definition) # @param name [Symbol] Name of the association # @param blueprint [Class|Proc] Blueprint class to use, or one defined with a Proc # @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 # @yield [TODO] Generate the value from the block # @return [Blueprinter::V2::Collection] # diff --git a/lib/blueprinter/v2/extensions.rb b/lib/blueprinter/v2/extensions.rb new file mode 100644 index 00000000..714fd6eb --- /dev/null +++ b/lib/blueprinter/v2/extensions.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Blueprinter + module V2 + module Extensions + autoload :Values, 'blueprinter/v2/extensions/values' + end + end +end diff --git a/lib/blueprinter/v2/extensions/values.rb b/lib/blueprinter/v2/extensions/values.rb new file mode 100644 index 00000000..77e0e9a2 --- /dev/null +++ b/lib/blueprinter/v2/extensions/values.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Blueprinter + module V2 + module Extensions + class Values < Extension + def collection?(object) + case object + when Array, Set then true + else false + end + end + + # @param ctx [Blueprinter::V2::Context] + def field_value(ctx) + extractor = get_extractor ctx + extractor.field(ctx.blueprint, ctx.field, ctx.object, ctx.options) + end + + # @param ctx [Blueprinter::V2::Context] + def object_value(ctx) + extractor = get_extractor ctx + extractor.object(ctx.blueprint, ctx.field, ctx.object, ctx.options) + end + + # @param ctx [Blueprinter::V2::Context] + def collection_value(ctx) + extractor = get_extractor ctx + extractor.collection(ctx.blueprint, ctx.field, ctx.object, ctx.options) + end + + private + + def get_extractor(ctx) + klass = ctx.field.options[:extractor] || ctx.blueprint.class.options[:extractor] || Extractor + ctx.instances[klass] + end + end + end + end +end diff --git a/lib/blueprinter/v2/instance_cache.rb b/lib/blueprinter/v2/instance_cache.rb new file mode 100644 index 00000000..3b00e4e5 --- /dev/null +++ b/lib/blueprinter/v2/instance_cache.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Blueprinter + module V2 + class InstanceCache + def initialize + @cache = {} + end + + def [](obj) + if obj.is_a? Class + @cache[obj] ||= obj.new + else + obj + end + end + end + end +end diff --git a/lib/blueprinter/v2/reflection.rb b/lib/blueprinter/v2/reflection.rb index 50161fbe..ca90aff5 100644 --- a/lib/blueprinter/v2/reflection.rb +++ b/lib/blueprinter/v2/reflection.rb @@ -39,6 +39,8 @@ class View attr_reader :objects # @return [Hash] Associations to collections defined on the view attr_reader :collections + # @return [Array] All fields, objects, and collections ordered by the field_order extension hook (default is the order in which they were defined) + attr_reader :sorted # @param blueprint [Class] A subclass of Blueprinter::V2::Base @@ -46,6 +48,7 @@ class View # @api private def initialize(blueprint, name) @name = name + @sorted = blueprint.serializer.hooks.last(:sort_fields, blueprint.schema.values) || blueprint.schema.values @fields = blueprint.schema.select { |_, f| f.is_a? Field } @objects = blueprint.schema.select { |_, f| f.is_a? ObjectField } @collections = blueprint.schema.select { |_, f| f.is_a? Collection } diff --git a/lib/blueprinter/v2/render.rb b/lib/blueprinter/v2/render.rb new file mode 100644 index 00000000..5d2c7223 --- /dev/null +++ b/lib/blueprinter/v2/render.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require 'json' # TODO replace with multi json +require 'blueprinter/v2/instance_cache' + +module Blueprinter + module V2 + class Render + def initialize(object, options, serializer:, collection:) + @object = object + @options = options.dup.freeze + @serializer = serializer + @collection = collection + end + + def to_hash + instance_cache = InstanceCache.new + blueprint = instance_cache[@serializer.blueprint] + pre_hook = @collection ? :input_collection : :input_object + post_hook = @collection ? :output_collection : :output_object + + ctx = Context.new(blueprint, nil, nil, @object, @options, instance_cache) + object = @serializer.hooks.reduce_into(pre_hook, ctx, :object) + + result = + if @collection + object.map { |obj| @serializer.call(obj, @options, instance_cache) } + else + @serializer.call(object, @options, instance_cache) + end + + ctx = Context.new(blueprint, nil, result, object, @options, instance_cache) + @serializer.hooks.reduce_into(post_hook, ctx, :value) + end + + def to_json + # TODO MultiJson.dump to_hash + to_hash.to_json + end + end + end +end diff --git a/lib/blueprinter/v2/serializer.rb b/lib/blueprinter/v2/serializer.rb new file mode 100644 index 00000000..d11d0bef --- /dev/null +++ b/lib/blueprinter/v2/serializer.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'blueprinter/hooks' +require 'blueprinter/v2/formatter' + +module Blueprinter + module V2 + class Serializer + # Core extensions that must run at the start of serialization + CORE_START = [ + Extensions::Values + ].freeze + + # Core extensions that must run at the end of serialization + CORE_FINISH = [].freeze + + attr_reader :blueprint, :formatter, :hooks + + def initialize(blueprint) + @hooks = Hooks.new(CORE_START.map(&:new) + blueprint.extensions + CORE_FINISH.map(&:new)) + @formatter = Formatter.new(blueprint) + @blueprint = blueprint + end + + def call(object, options, instances) + context = Context.new(instances[blueprint], nil, nil, object, options, instances) + hooks.reduce_into(:blueprint_input, context, :object) + + result = blueprint.reflections[:default].sorted.each_with_object({}) do |field, acc| + context.field = field + context.value = nil + + case field + when Field + hooks.reduce_into(:field_value, context, :value) + value = formatter.call(context) + acc[field.name] = value unless hooks.any?(:exclude_field?, context) + when ObjectField + value = hooks.reduce_into(:object_value, context, :value) + next if hooks.any?(:exclude_object?, context) + + v2 = instances[field.blueprint].is_a? V2::Base + value = v2 ? field.blueprint.serializer.call(value, options, instances) : field.blueprint.render(value, options) if value + acc[field.name] = value + when Collection + value = hooks.reduce_into(:collection_value, context, :value) + next if hooks.any?(:exclude_collection?, context) + + v2 = instances[field.blueprint].is_a? V2::Base + value = v2 ? value.map { |val| field.blueprint.serializer.call(val, options, instances) } : field.blueprint.render(value, options) if value + acc[field.name] = value + end + end + + context.field = nil + context.value = result + hooks.reduce_into(:blueprint_output, context, :value) + end + end + end +end diff --git a/spec/v2/extensions/extraction_spec.rb b/spec/v2/extensions/extraction_spec.rb new file mode 100644 index 00000000..014ecd32 --- /dev/null +++ b/spec/v2/extensions/extraction_spec.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +describe Blueprinter::V2::Extensions::Values do + subject { described_class.new } + let(:instance_cache) { Blueprinter::V2::InstanceCache.new } + let(:context) { Blueprinter::V2::Context } + let(:blueprint) { Class.new(Blueprinter::V2::Base) } + let(:object) { { name: 'Foo', category: { name: 'Bar' }, parts: [{ num: 42 }] } } + let(:my_extractor) do + Class.new(Blueprinter::V2::Extractor) do + def field(_blueprint, field, obj, _options) + obj[field.from].upcase + end + + def object(_blueprint, field, obj, _options) + val = obj[field.from] + val.transform_values { |v| v.upcase } + end + + def collection(_blueprint, field, obj, _options) + vals = obj[field.from] + vals.map { |val| val.transform_values { |v| v * 2 } } + end + end + end + + it 'should detect hashes as objects' do + expect(subject.collection? object).to be false + end + + it 'should detect arrays as collections' do + expect(subject.collection? [object]).to be true + end + + it 'should detect sets as collections' do + expect(subject.collection? Set.new([object])).to be true + end + + context 'fields' do + let(:field) { Blueprinter::V2::Field.new(name: :name, from: :name, options: {}) } + + it 'should extract a field with the default extractor' do + ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + expect(subject.field_value ctx).to eq 'Foo' + end + + it 'should extract a field with the field options extractor' do + field.options[:extractor] = my_extractor + ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + expect(subject.field_value ctx).to eq 'FOO' + end + + it 'should extract a field with the blueprint options extractor' do + blueprint.options[:extractor] = my_extractor + ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + expect(subject.field_value ctx).to eq 'FOO' + end + end + + context 'objects' do + let(:field) { Blueprinter::V2::ObjectField.new(name: :category, from: :category, options: {}) } + + it 'should extract an object the default extractor' do + ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + expect(subject.object_value ctx).to eq({ name: 'Bar' }) + end + + it 'should extract an object the field options extractor' do + field.options[:extractor] = my_extractor + ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + expect(subject.object_value ctx).to eq({ name: 'BAR' }) + end + + it 'should extract an object the blueprint options extractor' do + blueprint.options[:extractor] = my_extractor + ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + expect(subject.object_value ctx).to eq({ name: 'BAR' }) + end + end + + context 'collections' do + let(:field) { Blueprinter::V2::Collection.new(name: :parts, from: :parts, options: {}) } + + it 'should extract an object the default extractor' do + ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + expect(subject.collection_value ctx).to eq [{ num: 42 }] + end + + it 'should extract an object the field options extractor' do + field.options[:extractor] = my_extractor + ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + expect(subject.collection_value ctx).to eq [{ num: 84 }] + end + + it 'should extract an object the blueprint options extractor' do + blueprint.options[:extractor] = my_extractor + ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + expect(subject.collection_value ctx).to eq [{ num: 84 }] + end + end +end diff --git a/spec/v2/instance_cache_spec.rb b/spec/v2/instance_cache_spec.rb new file mode 100644 index 00000000..3292d6be --- /dev/null +++ b/spec/v2/instance_cache_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +describe Blueprinter::V2::InstanceCache do + subject { described_class.new } + + it "should return a new instance" do + klass = Class.new + expect(subject[klass]).to be_a klass + end + + it "should return the cached instance" do + klass = Class.new + res1 = subject[klass] + res2 = subject[klass] + expect(res2.object_id).to eq res1.object_id + end + + it "should return x if x is an instance" do + x = proc { "foo" } + expect(subject[x]).to eq x + end +end diff --git a/spec/v2/reflection_spec.rb b/spec/v2/reflection_spec.rb index ec6d2840..b83b9502 100644 --- a/spec/v2/reflection_spec.rb +++ b/spec/v2/reflection_spec.rb @@ -64,25 +64,59 @@ ).sort end - it "should find fields and associations" do - category_blueprint = Class.new(Blueprinter::V2::Base) - widget_blueprint = Class.new(Blueprinter::V2::Base) - blueprint = Class.new(Blueprinter::V2::Base) do - field :name - object :category, category_blueprint - - view :extended do - field :description - collection :widgets, widget_blueprint + 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 + object :category, test.category_blueprint + + view :extended do + collection :widgets, test.widget_blueprint + field :description + end end end - expect(blueprint.reflections[:default].fields.keys).to eq %i(name) - expect(blueprint.reflections[:default].objects.keys).to eq %i(category) - expect(blueprint.reflections[:default].collections.keys).to eq %i() + it 'should be found' do + expect(blueprint.reflections[:default].fields.keys).to eq %i(name) + expect(blueprint.reflections[:default].objects.keys).to eq %i(category) + expect(blueprint.reflections[:default].collections.keys).to eq %i() + + expect(blueprint.reflections[:extended].fields.keys).to eq %i(name description) + expect(blueprint.reflections[:extended].objects.keys).to eq %i(category) + expect(blueprint.reflections[:extended].collections.keys).to eq %i(widgets) + end + + it 'should be in the default order' do + names = blueprint.reflections[:default].sorted.map(&:name) + expect(names).to eq %i(name category) + + names = blueprint.reflections[:extended].sorted.map(&:name) + expect(names).to eq %i(name category widgets description) + end - expect(blueprint.reflections[:extended].fields.keys).to eq %i(name description) - expect(blueprint.reflections[:extended].objects.keys).to eq %i(category) - expect(blueprint.reflections[:extended].collections.keys).to eq %i(widgets) + it 'should be in a custom order' do + ext = Class.new(Blueprinter::Extension) do + def initialize(&sorter) + @sorter = sorter + end + + def sort_fields(fields) + fields.sort(&@sorter) + end + end + + blueprint.extensions << ext.new { |a, b| b.name <=> a.name } + blueprint.extensions << ext.new { |a, b| a.name <=> b.name } + + names = blueprint.reflections[:default].sorted.map(&:name) + expect(names).to eq %i(category name) + + names = blueprint.reflections[:extended].sorted.map(&:name) + expect(names).to eq %i(category description name widgets) + end end end diff --git a/spec/v2/render_spec.rb b/spec/v2/render_spec.rb new file mode 100644 index 00000000..95b26dbd --- /dev/null +++ b/spec/v2/render_spec.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +describe Blueprinter::V2::Render do + let(:category_blueprint) do + Class.new(Blueprinter::V2::Base) do + field :name, from: :n + end + end + + let(:widget_blueprint) do + test = self + Class.new(Blueprinter::V2::Base) do + field :name + field :desc, from: :description + object :category, test.category_blueprint + end + end + + it 'should render an object to a hash' do + serializer = Blueprinter::V2::Serializer.new(widget_blueprint) + widget = { name: 'Foo', description: 'About', category: { n: 'Bar' } } + render = described_class.new(widget, {}, serializer: serializer, collection: false) + + expect(render.to_hash).to eq({ + name: 'Foo', + desc: 'About', + category: { name: 'Bar' } + }) + end + + it 'should render a collection to a hash' do + serializer = Blueprinter::V2::Serializer.new(widget_blueprint) + widgets = [ + { name: 'Foo', description: 'About', category: { n: 'Bar' } }, + { name: 'Foo 2', description: 'About 2', category: { n: 'Bar 2' } }, + ] + render = described_class.new(widgets, {}, serializer: serializer, collection: true) + + expect(render.to_hash).to eq([ + { + name: 'Foo', + desc: 'About', + category: { name: 'Bar' } + }, + { + name: 'Foo 2', + desc: 'About 2', + category: { name: 'Bar 2' } + }, + ]) + end + + it 'should render an object to JSON' do + serializer = Blueprinter::V2::Serializer.new(widget_blueprint) + widget = { name: 'Foo', description: 'About', category: { n: 'Bar' } } + render = described_class.new(widget, {}, serializer: serializer, collection: false) + + expect(render.to_json).to eq({ + name: 'Foo', + desc: 'About', + category: { name: 'Bar' } + }.to_json) + end + + it 'should render a collection to JSON' do + serializer = Blueprinter::V2::Serializer.new(widget_blueprint) + widget = { name: 'Foo', description: 'About', category: { n: 'Bar' } } + render = described_class.new([widget], {}, serializer: serializer, collection: true) + + expect(render.to_json).to eq([{ + name: 'Foo', + desc: 'About', + category: { name: 'Bar' } + }].to_json) + end + + it 'should call input hooks on objects' do + ext = Class.new(Blueprinter::Extension) do + def input_object(ctx) + { name: ctx.object[:name] } + end + end + widget_blueprint.extensions << ext.new + serializer = Blueprinter::V2::Serializer.new(widget_blueprint) + widget = { name: 'Foo', description: 'About', category: { n: 'Bar' } } + render = described_class.new(widget, {}, serializer: serializer, collection: false) + + expect(render.to_hash).to eq({ + name: 'Foo', + desc: nil, + category: nil + }) + end + + it 'should call input hooks on collections' do + ext = Class.new(Blueprinter::Extension) do + def input_collection(ctx) + ctx.object.map { |obj| { name: obj[:name] } } + end + end + widget_blueprint.extensions << ext.new + serializer = Blueprinter::V2::Serializer.new(widget_blueprint) + widgets = [{ name: 'Foo', description: 'About', category: { n: 'Bar' } }] + render = described_class.new(widgets, {}, serializer: serializer, collection: true) + + expect(render.to_hash).to eq([{ + name: 'Foo', + desc: nil, + category: nil + }]) + end +end diff --git a/spec/v2/rendering_spec.rb b/spec/v2/rendering_spec.rb new file mode 100644 index 00000000..88f8568f --- /dev/null +++ b/spec/v2/rendering_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +describe "Blueprinter::V2 Rendering" do + let(:category_blueprint) do + Class.new(Blueprinter::V2::Base) do + field :name + end + end + + let(:part_blueprint) do + Class.new(Blueprinter::V2::Base) do + field :num + end + end + + let(:widget_blueprint) do + test = self + Class.new(Blueprinter::V2::Base) do + field :name + object :cat, test.category_blueprint, from: :category + collection :parts, test.part_blueprint + end + end + + let(:widget) { { name: 'Foo', category: { name: 'Bar' }, parts: [{ num: 42 }, { num: 43 }] } } + + it 'should auto-detect an object' do + result = widget_blueprint.render(widget, {}).to_hash + expect(result).to eq({ + name: 'Foo', + cat: { name: 'Bar' }, + parts: [{ num: 42 }, { num: 43 }] + }) + end + + it 'should auto-detect array collections' do + result = widget_blueprint.render([widget], {}).to_hash + expect(result).to eq([ + { + name: 'Foo', + cat: { name: 'Bar' }, + parts: [{ num: 42 }, { num: 43 }] + } + ]) + end +end diff --git a/spec/v2/serializer_spec.rb b/spec/v2/serializer_spec.rb new file mode 100644 index 00000000..9cba2e24 --- /dev/null +++ b/spec/v2/serializer_spec.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +require 'date' +require 'json' + +describe Blueprinter::V2::Serializer do + let(:category_blueprint) do + Class.new(Blueprinter::V2::Base) do + field :name + end + end + + let(:part_blueprint) do + Class.new(Blueprinter::V2::Base) do + field :num + end + end + + let(:widget_blueprint) do + test = self + Class.new(Blueprinter::V2::Base) do + field :name + object :category, test.category_blueprint + collection :parts, test.part_blueprint + end + end + + let(:instance_cache) { Blueprinter::V2::InstanceCache.new } + + it 'should work with nil values' do + test = self + widget = { name: nil, category: nil } + + result = described_class.new(widget_blueprint).call(widget, {}, instance_cache) + expect(result).to eq({ + name: nil, + category: nil, + parts: nil + }) + end + + it 'should extract values and serialize nested Blueprints' do + test = self + widget = { + name: 'Foo', + extra: 'bar', + category: { name: 'Bar', extra: 'bar' }, + parts: [{ num: 42, extra: 'bar' }, { num: 43 }] + } + + result = described_class.new(widget_blueprint).call(widget, {}, instance_cache) + expect(result).to eq({ + name: 'Foo', + category: { name: 'Bar' }, + parts: [{ num: 42 }, { num: 43 }] + }) + end + + it 'should format fields' do + widget_blueprint = Class.new(Blueprinter::V2::Base) do + format(Date) { |date| date.strftime('%a %b %e, %Y') } + field :name + field :created_on + end + widget = { name: 'Foo', created_on: Date.new(2024, 10, 31) } + + result = described_class.new(widget_blueprint).call(widget, {}, instance_cache) + expect(result).to eq({ + name: 'Foo', + created_on: 'Thu Oct 31, 2024' + }) + end + + it 'should run blueprint_input hooks before anything else' do + ext = Class.new(Blueprinter::Extension) do + def blueprint_input(_ctx) + { name: 'Foo' } + end + end + widget_blueprint.extensions << ext.new + + result = described_class.new(widget_blueprint).call( + { category: { name: 'Cat' }, parts: [{ num: 42 }] }, + {}, + instance_cache + ) + expect(result).to eq({ name: 'Foo', category: nil, parts: nil }) + end + + it 'should run blueprint_output hooks after everything else' do + ext = Class.new(Blueprinter::Extension) do + def blueprint_output(_ctx) + { name: 'Foo' } + end + end + widget_blueprint.extensions << ext.new + + result = described_class.new(widget_blueprint).call( + { category: { name: 'Cat' }, parts: [{ num: 42 }] }, + {}, + instance_cache + ) + expect(result).to eq({ name: 'Foo' }) + end + + it 'should put fields in the order they were defined' do + blueprint = Class.new(widget_blueprint) do + field :description + end + + result = described_class.new(blueprint).call( + { description: 'A widget', category: { name: 'Cat' }, parts: [{ num: 42 }], name: 'Foo' }, + {}, + instance_cache + ) + expect(result.to_json).to eq({ + name: 'Foo', + category: { name: 'Cat' }, + parts: [{ num: 42 }], + description: 'A widget' + }.to_json) + end + + context 'V1 child Blueprints' do + it 'should serialize objects' + + it 'should be nil if the object is nil' + + it 'should serialize collections' + + it 'should be nil if the collection is nil' + + it 'should be an empty array if the collection is empty' + end +end From d50948f83e3521f422d4d960a75f43a072f538f0 Mon Sep 17 00:00:00 2001 From: Jordan Hollinger Date: Fri, 1 Nov 2024 10:01:40 -0400 Subject: [PATCH 05/16] Default values for V2 Signed-off-by: Jordan Hollinger --- lib/blueprinter/v2/dsl.rb | 6 + lib/blueprinter/v2/extensions/values.rb | 39 +- spec/v2/extensions/default_values_spec.rb | 567 ++++++++++++++++++++++ spec/v2/serializer_spec.rb | 13 + 4 files changed, 622 insertions(+), 3 deletions(-) create mode 100644 spec/v2/extensions/default_values_spec.rb diff --git a/lib/blueprinter/v2/dsl.rb b/lib/blueprinter/v2/dsl.rb index 24aab159..8a1860f6 100644 --- a/lib/blueprinter/v2/dsl.rb +++ b/lib/blueprinter/v2/dsl.rb @@ -54,6 +54,8 @@ def format(klass, formatter_method = nil, &formatter_block) # @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 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` # @yield [TODO] Generate the value from the block # @return [Blueprinter::V2::Field] # @@ -84,6 +86,8 @@ def fields(*names) # @param blueprint [Class|Proc] Blueprint class to use, or one defined with a Proc # @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 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` # @yield [TODO] Generate the value from the block # @return [Blueprinter::V2::ObjectField] # @@ -105,6 +109,8 @@ def object(name, blueprint, from: name, **options, &definition) # @param blueprint [Class|Proc] Blueprint class to use, or one defined with a Proc # @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 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` # @yield [TODO] Generate the value from the block # @return [Blueprinter::V2::Collection] # diff --git a/lib/blueprinter/v2/extensions/values.rb b/lib/blueprinter/v2/extensions/values.rb index 77e0e9a2..f45e5f71 100644 --- a/lib/blueprinter/v2/extensions/values.rb +++ b/lib/blueprinter/v2/extensions/values.rb @@ -14,23 +14,56 @@ def collection?(object) # @param ctx [Blueprinter::V2::Context] def field_value(ctx) extractor = get_extractor ctx - extractor.field(ctx.blueprint, ctx.field, ctx.object, ctx.options) + value = extractor.field(ctx.blueprint, ctx.field, ctx.object, ctx.options) + + default_if = ctx.options[:field_default_if] || ctx.field.options[:default_if] || ctx.blueprint.class.options[:field_default_if] + return value unless value.nil? || (default_if && use_default?(default_if, ctx)) + + default = ctx.options[:field_default] || ctx.field.options[:default] || ctx.blueprint.class.options[:field_default] + get_default(default, ctx) end # @param ctx [Blueprinter::V2::Context] def object_value(ctx) extractor = get_extractor ctx - extractor.object(ctx.blueprint, ctx.field, ctx.object, ctx.options) + value = extractor.object(ctx.blueprint, ctx.field, ctx.object, ctx.options) + + default_if = ctx.options[:object_default_if] || ctx.field.options[:default_if] || ctx.blueprint.class.options[:object_default_if] + return value unless value.nil? || (default_if && use_default?(default_if, ctx)) + + default = ctx.options[:object_default] || ctx.field.options[:default] || ctx.blueprint.class.options[:object_default] + get_default(default, ctx) end # @param ctx [Blueprinter::V2::Context] def collection_value(ctx) extractor = get_extractor ctx - extractor.collection(ctx.blueprint, ctx.field, ctx.object, ctx.options) + value = extractor.collection(ctx.blueprint, ctx.field, ctx.object, ctx.options) + + default_if = ctx.options[:collection_default_if] || ctx.field.options[:default_if] || ctx.blueprint.class.options[:collection_default_if] + return value unless value.nil? || (default_if && use_default?(default_if, ctx)) + + default = ctx.options[:collection_default] || ctx.field.options[:default] || ctx.blueprint.class.options[:collection_default] + get_default(default, ctx) end private + def get_default(value, ctx) + case value + when Proc then ctx.blueprint.instance_exec(ctx, &value) + when Symbol then ctx.blueprint.public_send(value, ctx) + else value + end + end + + def use_default?(cond, ctx) + case cond + when Proc then ctx.blueprint.instance_exec(ctx, &cond) + else ctx.blueprint.public_send(cond, ctx) + end + end + def get_extractor(ctx) klass = ctx.field.options[:extractor] || ctx.blueprint.class.options[:extractor] || Extractor ctx.instances[klass] diff --git a/spec/v2/extensions/default_values_spec.rb b/spec/v2/extensions/default_values_spec.rb new file mode 100644 index 00000000..70e34c0a --- /dev/null +++ b/spec/v2/extensions/default_values_spec.rb @@ -0,0 +1,567 @@ +# frozen_string_literal: true + +describe Blueprinter::V2::Extensions::Values do + subject { described_class.new } + let(:context) { Blueprinter::V2::Context } + let(:instance_cache) { Blueprinter::V2::InstanceCache.new } + let(:blueprint) do + Class.new(Blueprinter::V2::Base) do + def was(ctx) + "was #{ctx.value.inspect}" + end + + def is?(ctx, val) + ctx.value == val + end + + def foo?(ctx) + is? ctx, 'Foo' + end + end + end + let(:object) { { name: 'Foo' } } + + context 'fields' do + let(:field) { Blueprinter::V2::Field.new(name: :name, from: :name, options: {}) } + + it 'should pass values through by default' do + ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + expect(subject.field_value ctx).to eq 'Foo' + end + + it 'should pass values through by with defaults given' do + blueprint.options[:field_default] = 'Bar' + field.options[:default] = 'Bar' + ctx = context.new(blueprint.new, field, nil, object, { field_default: 'Bar' }, instance_cache) + expect(subject.field_value ctx).to eq 'Foo' + end + + it 'should pass values through with false default_ifs given' do + blueprint.options[:field_default] = 'Bar' + blueprint.options[:field_default_if] = ->(_) { false } + field.options[:default] = 'Bar' + field.options[:default_if] = ->(_) { false } + ctx = context.new(blueprint.new, field, nil, object, { field_default: 'Bar', field_default_if: ->(_) { false } }, instance_cache) + expect(subject.field_value ctx).to eq 'Foo' + end + + it 'should pass nil through by default' do + object[:name] = nil + ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + expect(subject.field_value ctx).to be_nil + end + + it 'should use options field_default' do + object[:name] = nil + ctx = context.new(blueprint.new, field, nil, object, { field_default: 'Bar' }, instance_cache) + expect(subject.field_value ctx).to eq 'Bar' + end + + it 'should use options field_default (Proc)' do + object[:name] = nil + ctx = context.new(blueprint.new, field, nil, object, { field_default: ->(ctx) { "Bar (#{was ctx})"} }, instance_cache) + expect(subject.field_value ctx).to eq 'Bar (was nil)' + end + + it 'should use options field_default (Symbol)' do + object[:name] = nil + ctx = context.new(blueprint.new, field, nil, object, { field_default: :was }, instance_cache) + expect(subject.field_value ctx).to eq 'was nil' + end + + it 'should use field options default' do + object[:name] = nil + field.options[:default] = 'Bar' + ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + expect(subject.field_value ctx).to eq 'Bar' + end + + it 'should use field options default (Proc)' do + object[:name] = nil + field.options[:default] = ->(ctx) { "Bar (was #{ctx.value.inspect})"} + ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + expect(subject.field_value ctx).to eq 'Bar (was nil)' + end + + it 'should use field options default (Symbol)' do + object[:name] = nil + field.options[:default] = :was + ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + expect(subject.field_value ctx).to eq 'was nil' + end + + it 'should use blueprint options field_default' do + object[:name] = nil + blueprint.options[:field_default] = 'Bar' + ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + expect(subject.field_value ctx).to eq 'Bar' + end + + it 'should use blueprint options field_default (Proc)' do + object[:name] = nil + blueprint.options[:field_default] = ->(ctx) { "Bar (#{was ctx})" } + ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + expect(subject.field_value ctx).to eq 'Bar (was nil)' + end + + it 'should use blueprint options field_default (Symbol)' do + object[:name] = nil + blueprint.options[:field_default] = :was + ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + expect(subject.field_value ctx).to eq 'was nil' + end + + it 'should check with options field_default_if (default = options field_default)' do + ctx = context.new(blueprint.new, field, 'Foo', object, { field_default: 'Bar', field_default_if: ->(ctx) { is? ctx, 'Foo' } }, instance_cache) + expect(subject.field_value ctx).to eq 'Bar' + + ctx = context.new(blueprint.new, field, 'Foo', object, { field_default: 'Bar', field_default_if: :foo? }, instance_cache) + expect(subject.field_value ctx).to eq 'Bar' + end + + it 'should check with options field_default_if (default = field options default)' do + field.options[:default] = 'Bar' + ctx = context.new(blueprint.new, field, 'Foo', object, { field_default_if: ->(ctx) { is? ctx, 'Foo' } }, instance_cache) + expect(subject.field_value ctx).to eq 'Bar' + + ctx = context.new(blueprint.new, field, 'Foo', object, { field_default_if: :foo? }, instance_cache) + expect(subject.field_value ctx).to eq 'Bar' + end + + it 'should check with options field_default_if (default = blueprint options field_default)' do + blueprint.options[:field_default] = 'Bar' + ctx = context.new(blueprint.new, field, 'Foo', object, { field_default_if: ->(ctx) { is? ctx, 'Foo' } }, instance_cache) + expect(subject.field_value ctx).to eq 'Bar' + + ctx = context.new(blueprint.new, field, 'Foo', object, { field_default_if: :foo? }, instance_cache) + expect(subject.field_value ctx).to eq 'Bar' + end + + it 'should check with field options default_if (default = options field_default)' do + field.options[:default_if] = ->(ctx) { is? ctx, 'Foo' } + ctx = context.new(blueprint.new, field, 'Foo', object, { field_default: 'Bar' }, instance_cache) + expect(subject.field_value ctx).to eq 'Bar' + + field.options[:default_if] = :foo? + ctx = context.new(blueprint.new, field, 'Foo', object, { field_default: 'Bar' }, instance_cache) + expect(subject.field_value ctx).to eq 'Bar' + end + + it 'should check with field options default_if (default = field options default)' do + field.options[:default_if] = ->(ctx) { is? ctx, 'Foo' } + field.options[:default] = 'Bar' + ctx = context.new(blueprint.new, field, 'Foo', object, {}, instance_cache) + expect(subject.field_value ctx).to eq 'Bar' + + field.options[:default_if] = :foo? + ctx = context.new(blueprint.new, field, 'Foo', object, {}, instance_cache) + expect(subject.field_value ctx).to eq 'Bar' + end + + it 'should check with field options default_if (default = blueprint options field_default)' do + field.options[:default_if] = ->(ctx) { is? ctx, 'Foo' } + blueprint.options[:field_default] = 'Bar' + ctx = context.new(blueprint.new, field, 'Foo', object, {}, instance_cache) + expect(subject.field_value ctx).to eq 'Bar' + + field.options[:default_if] = :foo? + ctx = context.new(blueprint.new, field, 'Foo', object, {}, instance_cache) + expect(subject.field_value ctx).to eq 'Bar' + end + + it 'should check with blueprint options field_default_if (default = options field_default)' do + blueprint.options[:field_default_if] = ->(ctx) { is? ctx, 'Foo' } + ctx = context.new(blueprint.new, field, 'Foo', object, { field_default: 'Bar' }, instance_cache) + expect(subject.field_value ctx).to eq 'Bar' + + blueprint.options[:field_default_if] = :foo? + ctx = context.new(blueprint.new, field, 'Foo', object, { field_default: 'Bar' }, instance_cache) + expect(subject.field_value ctx).to eq 'Bar' + end + + it 'should check with blueprint options field_default_if (default = field options default)' do + blueprint.options[:field_default_if] = ->(ctx) { is? ctx, 'Foo' } + field.options[:default] = 'Bar' + ctx = context.new(blueprint.new, field, 'Foo', object, {}, instance_cache) + expect(subject.field_value ctx).to eq 'Bar' + + blueprint.options[:field_default_if] = :foo? + ctx = context.new(blueprint.new, field, 'Foo', object, {}, instance_cache) + expect(subject.field_value ctx).to eq 'Bar' + end + + it 'should check with blueprint options field_default_if (default = blueprint options field_default)' do + blueprint.options[:field_default_if] = ->(ctx) { is? ctx, 'Foo' } + blueprint.options[:field_default] = 'Bar' + ctx = context.new(blueprint.new, field, 'Foo', object, {}, instance_cache) + expect(subject.field_value ctx).to eq 'Bar' + + blueprint.options[:field_default_if] = :foo? + ctx = context.new(blueprint.new, field, 'Foo', object, {}, instance_cache) + expect(subject.field_value ctx).to eq 'Bar' + end + end + + context 'objects' do + let(:field) { Blueprinter::V2::ObjectField.new(name: :name, from: :name, options: {}) } + + it 'should pass values through by default' do + ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + expect(subject.object_value ctx).to eq 'Foo' + end + + it 'should pass values through by with defaults given' do + blueprint.options[:object_default] = 'Bar' + field.options[:default] = 'Bar' + ctx = context.new(blueprint.new, field, nil, object, { object_default: 'Bar' }, instance_cache) + expect(subject.object_value ctx).to eq 'Foo' + end + + it 'should pass values through with false default_ifs given' do + blueprint.options[:object_default] = 'Bar' + blueprint.options[:object_default_if] = ->(_) { false } + field.options[:default] = 'Bar' + field.options[:default_if] = ->(_) { false } + ctx = context.new(blueprint.new, field, nil, object, { object_default: 'Bar', object_default_if: ->(_) { false } }, instance_cache) + expect(subject.object_value ctx).to eq 'Foo' + end + + it 'should pass nil through by default' do + object[:name] = nil + ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + expect(subject.object_value ctx).to be_nil + end + + it 'should use options object_default' do + object[:name] = nil + ctx = context.new(blueprint.new, field, nil, object, { object_default: 'Bar' }, instance_cache) + expect(subject.object_value ctx).to eq 'Bar' + end + + it 'should use options object_default (Proc)' do + object[:name] = nil + ctx = context.new(blueprint.new, field, nil, object, { object_default: ->(ctx) { "Bar (#{was ctx})" } }, instance_cache) + expect(subject.object_value ctx).to eq 'Bar (was nil)' + end + + it 'should use options object_default (Symbol)' do + object[:name] = nil + ctx = context.new(blueprint.new, field, nil, object, { object_default: :was }, instance_cache) + expect(subject.object_value ctx).to eq 'was nil' + end + + it 'should use field options default' do + object[:name] = nil + field.options[:default] = 'Bar' + ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + expect(subject.object_value ctx).to eq 'Bar' + end + + it 'should use field options default (Proc)' do + object[:name] = nil + field.options[:default] = ->(ctx) { "Bar (was #{ctx.value.inspect})"} + ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + expect(subject.object_value ctx).to eq 'Bar (was nil)' + end + + it 'should use field options default (Symbol)' do + object[:name] = nil + field.options[:default] = :was + ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + expect(subject.object_value ctx).to eq 'was nil' + end + + it 'should use blueprint options object_default' do + object[:name] = nil + blueprint.options[:object_default] = 'Bar' + ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + expect(subject.object_value ctx).to eq 'Bar' + end + + it 'should use blueprint options object_default (Proc)' do + object[:name] = nil + blueprint.options[:object_default] = ->(ctx) { "Bar (#{was ctx})" } + ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + expect(subject.object_value ctx).to eq 'Bar (was nil)' + end + + it 'should use blueprint options object_default (Symbol)' do + object[:name] = nil + blueprint.options[:object_default] = :was + ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + expect(subject.object_value ctx).to eq 'was nil' + end + + it 'should check with options object_default_if (default = options object_default)' do + ctx = context.new(blueprint.new, field, 'Foo', object, { object_default: 'Bar', object_default_if: ->(ctx) { is? ctx, 'Foo' } }, instance_cache) + expect(subject.object_value ctx).to eq 'Bar' + + ctx = context.new(blueprint.new, field, 'Foo', object, { object_default: 'Bar', object_default_if: :foo? }, instance_cache) + expect(subject.object_value ctx).to eq 'Bar' + end + + it 'should check with options object_default_if (default = field options default)' do + field.options[:default] = 'Bar' + ctx = context.new(blueprint.new, field, 'Foo', object, { object_default_if: ->(ctx) { is? ctx, 'Foo' } }, instance_cache) + expect(subject.object_value ctx).to eq 'Bar' + + ctx = context.new(blueprint.new, field, 'Foo', object, { object_default_if: :foo? }, instance_cache) + expect(subject.object_value ctx).to eq 'Bar' + end + + it 'should check with options object_default_if (default = blueprint options object_default)' do + blueprint.options[:object_default] = 'Bar' + ctx = context.new(blueprint.new, field, 'Foo', object, { object_default_if: ->(ctx) { is? ctx, 'Foo' } }, instance_cache) + expect(subject.object_value ctx).to eq 'Bar' + + ctx = context.new(blueprint.new, field, 'Foo', object, { object_default_if: :foo? }, instance_cache) + expect(subject.object_value ctx).to eq 'Bar' + end + + it 'should check with field options default_if (default = options object_default)' do + field.options[:default_if] = ->(ctx) { is? ctx, 'Foo' } + ctx = context.new(blueprint.new, field, 'Foo', object, { object_default: 'Bar' }, instance_cache) + expect(subject.object_value ctx).to eq 'Bar' + + field.options[:default_if] = :foo? + ctx = context.new(blueprint.new, field, 'Foo', object, { object_default: 'Bar' }, instance_cache) + expect(subject.object_value ctx).to eq 'Bar' + end + + it 'should check with field options default_if (default = field options default)' do + field.options[:default_if] = ->(ctx) { is? ctx, 'Foo' } + field.options[:default] = 'Bar' + ctx = context.new(blueprint.new, field, 'Foo', object, {}, instance_cache) + expect(subject.object_value ctx).to eq 'Bar' + + field.options[:default_if] = :foo? + ctx = context.new(blueprint.new, field, 'Foo', object, {}, instance_cache) + expect(subject.object_value ctx).to eq 'Bar' + end + + it 'should check with field options default_if (default = blueprint options object_default)' do + field.options[:default_if] = ->(ctx) { is? ctx, 'Foo' } + blueprint.options[:object_default] = 'Bar' + ctx = context.new(blueprint.new, field, 'Foo', object, {}, instance_cache) + expect(subject.object_value ctx).to eq 'Bar' + + field.options[:default_if] = :foo? + blueprint.options[:object_default] = 'Bar' + ctx = context.new(blueprint.new, field, 'Foo', object, {}, instance_cache) + expect(subject.object_value ctx).to eq 'Bar' + end + + it 'should check with blueprint options object_default_if (default = options object_default)' do + blueprint.options[:object_default_if] = ->(ctx) { is? ctx, 'Foo' } + ctx = context.new(blueprint.new, field, 'Foo', object, { object_default: 'Bar' }, instance_cache) + expect(subject.object_value ctx).to eq 'Bar' + + blueprint.options[:object_default_if] = :foo? + ctx = context.new(blueprint.new, field, 'Foo', object, { object_default: 'Bar' }, instance_cache) + expect(subject.object_value ctx).to eq 'Bar' + end + + it 'should check with blueprint options object_default_if (default = field options default)' do + blueprint.options[:object_default_if] = ->(ctx) { is? ctx, 'Foo' } + field.options[:default] = 'Bar' + ctx = context.new(blueprint.new, field, 'Foo', object, {}, instance_cache) + expect(subject.object_value ctx).to eq 'Bar' + + blueprint.options[:object_default_if] = :foo? + ctx = context.new(blueprint.new, field, 'Foo', object, {}, instance_cache) + expect(subject.object_value ctx).to eq 'Bar' + end + + it 'should check with blueprint options object_default_if (default = blueprint options object_default)' do + blueprint.options[:object_default_if] = ->(ctx) { is? ctx, 'Foo' } + blueprint.options[:object_default] = 'Bar' + ctx = context.new(blueprint.new, field, 'Foo', object, {}, instance_cache) + expect(subject.object_value ctx).to eq 'Bar' + + blueprint.options[:object_default_if] = :foo? + ctx = context.new(blueprint.new, field, 'Foo', object, {}, instance_cache) + expect(subject.object_value ctx).to eq 'Bar' + end + end + + context 'collections' do + let(:field) { Blueprinter::V2::Collection.new(name: :name, from: :name, options: {}) } + + it 'should pass values through by default' do + ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + expect(subject.collection_value ctx).to eq 'Foo' + end + + it 'should pass values through by with defaults given' do + blueprint.options[:collection_default] = 'Bar' + field.options[:default] = 'Bar' + ctx = context.new(blueprint.new, field, nil, object, { collection_default: 'Bar' }, instance_cache) + expect(subject.collection_value ctx).to eq 'Foo' + end + + it 'should pass values through with false default_ifs given' do + blueprint.options[:collection_default] = 'Bar' + blueprint.options[:collection_default_if] = ->(_) { false } + field.options[:default] = 'Bar' + field.options[:default_if] = ->(_) { false } + ctx = context.new(blueprint.new, field, nil, object, { collection_default: 'Bar', collection_default_if: ->(_) { false } }, instance_cache) + expect(subject.collection_value ctx).to eq 'Foo' + end + + it 'should pass nil through by default' do + object[:name] = nil + ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + expect(subject.collection_value ctx).to be_nil + end + + it 'should use options collection_default' do + object[:name] = nil + ctx = context.new(blueprint.new, field, nil, object, { collection_default: 'Bar' }, instance_cache) + expect(subject.collection_value ctx).to eq 'Bar' + end + + it 'should use options collection_default (Proc)' do + object[:name] = nil + ctx = context.new(blueprint.new, field, nil, object, { collection_default: ->(ctx) { "Bar (#{was ctx})" } }, instance_cache) + expect(subject.collection_value ctx).to eq 'Bar (was nil)' + end + + it 'should use options collection_default (Symbol)' do + object[:name] = nil + ctx = context.new(blueprint.new, field, nil, object, { collection_default: :was }, instance_cache) + expect(subject.collection_value ctx).to eq 'was nil' + end + + it 'should use field options default' do + object[:name] = nil + field.options[:default] = 'Bar' + ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + expect(subject.collection_value ctx).to eq 'Bar' + end + + it 'should use field options default (Proc)' do + object[:name] = nil + field.options[:default] = ->(ctx) { "Bar (was #{ctx.value.inspect})"} + ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + expect(subject.collection_value ctx).to eq 'Bar (was nil)' + end + + it 'should use field options default (Symbol)' do + object[:name] = nil + field.options[:default] = :was + ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + expect(subject.collection_value ctx).to eq 'was nil' + end + + it 'should use blueprint options collection_default' do + object[:name] = nil + blueprint.options[:collection_default] = 'Bar' + ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + expect(subject.collection_value ctx).to eq 'Bar' + end + + it 'should use blueprint options collection_default (Proc)' do + object[:name] = nil + blueprint.options[:collection_default] = ->(ctx) { "Bar (#{was ctx})" } + ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + expect(subject.collection_value ctx).to eq 'Bar (was nil)' + end + + it 'should use blueprint options collection_default (Symbol)' do + object[:name] = nil + blueprint.options[:collection_default] = :was + ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + expect(subject.collection_value ctx).to eq 'was nil' + end + + it 'should check with options collection_default_if (default = options collection_default)' do + ctx = context.new(blueprint.new, field, 'Foo', object, { collection_default: 'Bar', collection_default_if: ->(ctx) { is? ctx, 'Foo' } }, instance_cache) + expect(subject.collection_value ctx).to eq 'Bar' + + ctx = context.new(blueprint.new, field, 'Foo', object, { collection_default: 'Bar', collection_default_if: :foo? }, instance_cache) + expect(subject.collection_value ctx).to eq 'Bar' + end + + it 'should check with options collection_default_if (default = field options default)' do + field.options[:default] = 'Bar' + ctx = context.new(blueprint.new, field, 'Foo', object, { collection_default_if: ->(ctx) { is? ctx, 'Foo' } }, instance_cache) + expect(subject.collection_value ctx).to eq 'Bar' + + ctx = context.new(blueprint.new, field, 'Foo', object, { collection_default_if: :foo? }, instance_cache) + expect(subject.collection_value ctx).to eq 'Bar' + end + + it 'should check with options collection_default_if (default = blueprint options collection_default)' do + blueprint.options[:collection_default] = 'Bar' + ctx = context.new(blueprint.new, field, 'Foo', object, { collection_default_if: ->(ctx) { is? ctx, 'Foo' } }, instance_cache) + expect(subject.collection_value ctx).to eq 'Bar' + + ctx = context.new(blueprint.new, field, 'Foo', object, { collection_default_if: :foo? }, instance_cache) + expect(subject.collection_value ctx).to eq 'Bar' + end + + it 'should check with field options default_if (default = options collection_default)' do + field.options[:default_if] = ->(ctx) { is? ctx, 'Foo' } + ctx = context.new(blueprint.new, field, 'Foo', object, { collection_default: 'Bar' }, instance_cache) + expect(subject.collection_value ctx).to eq 'Bar' + + field.options[:default_if] = :foo? + ctx = context.new(blueprint.new, field, 'Foo', object, { collection_default: 'Bar' }, instance_cache) + expect(subject.collection_value ctx).to eq 'Bar' + end + + it 'should check with field options default_if (default = field options default)' do + field.options[:default_if] = ->(ctx) { is? ctx, 'Foo' } + field.options[:default] = 'Bar' + ctx = context.new(blueprint.new, field, 'Foo', object, {}, instance_cache) + expect(subject.collection_value ctx).to eq 'Bar' + + field.options[:default_if] = :foo? + ctx = context.new(blueprint.new, field, 'Foo', object, {}, instance_cache) + expect(subject.collection_value ctx).to eq 'Bar' + end + + it 'should check with field options default_if (default = blueprint options collection_default)' do + field.options[:default_if] = ->(ctx) { is? ctx, 'Foo' } + blueprint.options[:collection_default] = 'Bar' + ctx = context.new(blueprint.new, field, 'Foo', object, {}, instance_cache) + expect(subject.collection_value ctx).to eq 'Bar' + + field.options[:default_if] = :foo? + ctx = context.new(blueprint.new, field, 'Foo', object, {}, instance_cache) + expect(subject.collection_value ctx).to eq 'Bar' + end + + it 'should check with blueprint options collection_default_if (default = options collection_default)' do + blueprint.options[:collection_default_if] = ->(ctx) { is? ctx, 'Foo' } + ctx = context.new(blueprint.new, field, 'Foo', object, { collection_default: 'Bar' }, instance_cache) + expect(subject.collection_value ctx).to eq 'Bar' + + blueprint.options[:collection_default_if] = :foo? + ctx = context.new(blueprint.new, field, 'Foo', object, { collection_default: 'Bar' }, instance_cache) + expect(subject.collection_value ctx).to eq 'Bar' + end + + it 'should check with blueprint options collection_default_if (default = field options default)' do + blueprint.options[:collection_default_if] = ->(ctx) { is? ctx, 'Foo' } + field.options[:default] = 'Bar' + ctx = context.new(blueprint.new, field, 'Foo', object, {}, instance_cache) + expect(subject.collection_value ctx).to eq 'Bar' + + blueprint.options[:collection_default_if] = :foo? + ctx = context.new(blueprint.new, field, 'Foo', object, {}, instance_cache) + expect(subject.collection_value ctx).to eq 'Bar' + end + + it 'should check with blueprint options collection_default_if (default = blueprint options collection_default)' do + blueprint.options[:collection_default_if] = ->(ctx) { is? ctx, 'Foo' } + blueprint.options[:collection_default] = 'Bar' + ctx = context.new(blueprint.new, field, 'Foo', object, {}, instance_cache) + expect(subject.collection_value ctx).to eq 'Bar' + + blueprint.options[:collection_default_if] = :foo? + ctx = context.new(blueprint.new, field, 'Foo', object, {}, instance_cache) + expect(subject.collection_value ctx).to eq 'Bar' + end + end +end diff --git a/spec/v2/serializer_spec.rb b/spec/v2/serializer_spec.rb index 9cba2e24..8fc2c0ba 100644 --- a/spec/v2/serializer_spec.rb +++ b/spec/v2/serializer_spec.rb @@ -56,6 +56,19 @@ }) end + it 'should enable the default values extension' do + widget_blueprint = Class.new(Blueprinter::V2::Base) do + field :name + field :desc, default: 'Description!' + end + + result = described_class.new(widget_blueprint).call({ name: 'Foo' }, {}, instance_cache) + expect(result).to eq({ + name: 'Foo', + desc: 'Description!' + }) + end + it 'should format fields' do widget_blueprint = Class.new(Blueprinter::V2::Base) do format(Date) { |date| date.strftime('%a %b %e, %Y') } From 55d3cb9645cd163d78d31ad922ecef94425afdb4 Mon Sep 17 00:00:00 2001 From: Jordan Hollinger Date: Fri, 1 Nov 2024 10:09:35 -0400 Subject: [PATCH 06/16] if/unless and exclude_if_nil/exclude_if_empty options for V2 Signed-off-by: Jordan Hollinger --- lib/blueprinter/v2/dsl.rb | 12 ++ lib/blueprinter/v2/extensions.rb | 1 + lib/blueprinter/v2/extensions/exclusions.rb | 62 ++++++ lib/blueprinter/v2/serializer.rb | 3 +- spec/v2/extensions/exclude_if_empty_spec.rb | 198 ++++++++++++++++++ spec/v2/extensions/exclude_if_nil_spec.rb | 147 +++++++++++++ spec/v2/extensions/if_conditionals_spec.rb | 197 +++++++++++++++++ .../v2/extensions/unless_conditionals_spec.rb | 197 +++++++++++++++++ spec/v2/serializer_spec.rb | 76 +++++++ 9 files changed, 892 insertions(+), 1 deletion(-) create mode 100644 lib/blueprinter/v2/extensions/exclusions.rb create mode 100644 spec/v2/extensions/exclude_if_empty_spec.rb create mode 100644 spec/v2/extensions/exclude_if_nil_spec.rb create mode 100644 spec/v2/extensions/if_conditionals_spec.rb create mode 100644 spec/v2/extensions/unless_conditionals_spec.rb diff --git a/lib/blueprinter/v2/dsl.rb b/lib/blueprinter/v2/dsl.rb index 8a1860f6..e5c6afdc 100644 --- a/lib/blueprinter/v2/dsl.rb +++ b/lib/blueprinter/v2/dsl.rb @@ -56,6 +56,10 @@ def format(klass, formatter_method = nil, &formatter_block) # @param extractor [Class] Extractor class to use for this 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 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 [TODO] Generate the value from the block # @return [Blueprinter::V2::Field] # @@ -88,6 +92,10 @@ def fields(*names) # @param extractor [Class] Extractor class to use for this 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 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 [TODO] Generate the value from the block # @return [Blueprinter::V2::ObjectField] # @@ -111,6 +119,10 @@ def object(name, blueprint, from: name, **options, &definition) # @param extractor [Class] Extractor class to use for this 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 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 [TODO] Generate the value from the block # @return [Blueprinter::V2::Collection] # diff --git a/lib/blueprinter/v2/extensions.rb b/lib/blueprinter/v2/extensions.rb index 714fd6eb..fc01f0a3 100644 --- a/lib/blueprinter/v2/extensions.rb +++ b/lib/blueprinter/v2/extensions.rb @@ -3,6 +3,7 @@ module Blueprinter module V2 module Extensions + autoload :Exclusions, 'blueprinter/v2/extensions/exclusions' autoload :Values, 'blueprinter/v2/extensions/values' end end diff --git a/lib/blueprinter/v2/extensions/exclusions.rb b/lib/blueprinter/v2/extensions/exclusions.rb new file mode 100644 index 00000000..0ff057dc --- /dev/null +++ b/lib/blueprinter/v2/extensions/exclusions.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module Blueprinter + module V2 + module Extensions + class Exclusions < Extension + # @param ctx [Blueprinter::V2::Context] + def exclude_field?(ctx) + return true if exclude_if_nil_or_empty? ctx + if (cond = ctx.options[:field_if] || ctx.field.options[:if] || ctx.blueprint.class.options[:field_if]) + result = cond.is_a?(Proc) ? ctx.blueprint.instance_exec(ctx, &cond) : ctx.blueprint.public_send(cond, ctx) + return true if !result + end + if (cond = ctx.options[:field_unless] || ctx.field.options[:unless] || ctx.blueprint.class.options[:field_unless]) + result = cond.is_a?(Proc) ? ctx.blueprint.instance_exec(ctx, &cond) : ctx.blueprint.public_send(cond, ctx) + return true if result + end + false + end + + # @param ctx [Blueprinter::V2::Context] + def exclude_object?(ctx) + return true if exclude_if_nil_or_empty? ctx + if (cond = ctx.options[:object_if] || ctx.field.options[:if] || ctx.blueprint.class.options[:object_if]) + result = cond.is_a?(Proc) ? ctx.blueprint.instance_exec(ctx, &cond) : ctx.blueprint.public_send(cond, ctx) + return true if !result + end + if (cond = ctx.options[:object_unless] || ctx.field.options[:unless] || ctx.blueprint.class.options[:object_unless]) + result = cond.is_a?(Proc) ? ctx.blueprint.instance_exec(ctx, &cond) : ctx.blueprint.public_send(cond, ctx) + return true if result + end + false + end + + # @param ctx [Blueprinter::V2::Context] + def exclude_collection?(ctx) + return true if exclude_if_nil_or_empty? ctx + if (cond = ctx.options[:collection_if] || ctx.field.options[:if] || ctx.blueprint.class.options[:collection_if]) + result = cond.is_a?(Proc) ? ctx.blueprint.instance_exec(ctx, &cond) : ctx.blueprint.public_send(cond, ctx) + return true if !result + end + if (cond = ctx.options[:collection_unless] || ctx.field.options[:unless] || ctx.blueprint.class.options[:collection_unless]) + result = cond.is_a?(Proc) ? ctx.blueprint.instance_exec(ctx, &cond) : ctx.blueprint.public_send(cond, ctx) + return true if result + end + false + end + + private + + def exclude_if_nil_or_empty?(ctx) + if ctx.value.nil? && (ctx.options[:exclude_if_nil] || ctx.field.options[:exclude_if_nil] || ctx.blueprint.class.options[:exclude_if_nil]) + return true + elsif ctx.options[:exclude_if_empty] || ctx.field.options[:exclude_if_empty] || ctx.blueprint.class.options[:exclude_if_empty] + return true if ctx.value.nil? || (ctx.value.respond_to?(:empty?) && ctx.value.empty?) + end + false + end + end + end + end +end diff --git a/lib/blueprinter/v2/serializer.rb b/lib/blueprinter/v2/serializer.rb index d11d0bef..9985cba1 100644 --- a/lib/blueprinter/v2/serializer.rb +++ b/lib/blueprinter/v2/serializer.rb @@ -8,7 +8,8 @@ module V2 class Serializer # Core extensions that must run at the start of serialization CORE_START = [ - Extensions::Values + Extensions::Values, + Extensions::Exclusions ].freeze # Core extensions that must run at the end of serialization diff --git a/spec/v2/extensions/exclude_if_empty_spec.rb b/spec/v2/extensions/exclude_if_empty_spec.rb new file mode 100644 index 00000000..8c912438 --- /dev/null +++ b/spec/v2/extensions/exclude_if_empty_spec.rb @@ -0,0 +1,198 @@ +# frozen_string_literal: true + +describe Blueprinter::V2::Extensions::Exclusions do + subject { described_class.new } + let(:context) { Blueprinter::V2::Context } + let(:blueprint) { Class.new(Blueprinter::V2::Base) } + let(:field) { Blueprinter::V2::Field.new(name: :name, from: :name, options: {}) } + let(:object) { { name: 'Foo' } } + + context 'fields' do + it 'should be allowed by default' do + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.exclude_field? ctx).to be false + end + + it 'should be allowed by default if nil' do + ctx = context.new(blueprint.new, field, nil, object, {}) + expect(subject.exclude_field? ctx).to be false + end + + it 'should be allowed with options set' do + ctx = context.new(blueprint.new, field, 'Foo', object, { exclude_if_empty: true }) + expect(subject.exclude_field? ctx).to be false + end + + it 'should be excluded with options set if nil' do + ctx = context.new(blueprint.new, field, nil, object, { exclude_if_empty: true }) + expect(subject.exclude_field? ctx).to be true + end + + it 'should be excluded with options set if empty' do + ctx = context.new(blueprint.new, field, [], object, { exclude_if_empty: true }) + expect(subject.exclude_field? ctx).to be true + end + + it 'should be allowed with field options set' do + field.options[:exclude_if_empty] = true + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.exclude_field? ctx).to be false + end + + it 'should be excluded with field options set if nil' do + field.options[:exclude_if_empty] = true + ctx = context.new(blueprint.new, field, nil, object, {}) + expect(subject.exclude_field? ctx).to be true + end + + it 'should be excluded with field options set if empty' do + field.options[:exclude_if_empty] = true + ctx = context.new(blueprint.new, field, [], object, {}) + expect(subject.exclude_field? ctx).to be true + end + + it 'should be allowed with blueprint options set' do + blueprint.options[:exclude_if_empty] = true + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.exclude_field? ctx).to be false + end + + it 'should be excluded with blueprint options set if nil' do + blueprint.options[:exclude_if_empty] = true + ctx = context.new(blueprint.new, field, nil, object, {}) + expect(subject.exclude_field? ctx).to be true + end + + it 'should be excluded with blueprint options set if empty' do + blueprint.options[:exclude_if_empty] = true + ctx = context.new(blueprint.new, field, [], object, {}) + expect(subject.exclude_field? ctx).to be true + end + end + + context 'objects' do + it 'should be allowed by default' do + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.exclude_object? ctx).to be false + end + + it 'should be allowed by default if nil' do + ctx = context.new(blueprint.new, field, nil, object, {}) + expect(subject.exclude_object? ctx).to be false + end + + it 'should be allowed with options set' do + ctx = context.new(blueprint.new, field, 'Foo', object, { exclude_if_empty: true }) + expect(subject.exclude_object? ctx).to be false + end + + it 'should be excluded with options set if nil' do + ctx = context.new(blueprint.new, field, nil, object, { exclude_if_empty: true }) + expect(subject.exclude_object? ctx).to be true + end + + it 'should be excluded with options set if empty' do + ctx = context.new(blueprint.new, field, [], object, { exclude_if_empty: true }) + expect(subject.exclude_object? ctx).to be true + end + + it 'should be allowed with field options set' do + field.options[:exclude_if_empty] = true + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.exclude_object? ctx).to be false + end + + it 'should be excluded with field options set if nil' do + field.options[:exclude_if_empty] = true + ctx = context.new(blueprint.new, field, nil, object, {}) + expect(subject.exclude_object? ctx).to be true + end + + it 'should be excluded with field options set if empty' do + field.options[:exclude_if_empty] = true + ctx = context.new(blueprint.new, field, [], object, {}) + expect(subject.exclude_object? ctx).to be true + end + + it 'should be allowed with blueprint options set' do + blueprint.options[:exclude_if_empty] = true + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.exclude_object? ctx).to be false + end + + it 'should be excluded with blueprint options set if nil' do + blueprint.options[:exclude_if_empty] = true + ctx = context.new(blueprint.new, field, nil, object, {}) + expect(subject.exclude_object? ctx).to be true + end + + it 'should be excluded with blueprint options set if empty' do + blueprint.options[:exclude_if_empty] = true + ctx = context.new(blueprint.new, field, [], object, {}) + expect(subject.exclude_object? ctx).to be true + end + end + + context 'collections' do + it 'should be allowed by default' do + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.exclude_collection? ctx).to be false + end + + it 'should be allowed by default if nil' do + ctx = context.new(blueprint.new, field, nil, object, {}) + expect(subject.exclude_collection? ctx).to be false + end + + it 'should be allowed with options set' do + ctx = context.new(blueprint.new, field, 'Foo', object, { exclude_if_empty: true }) + expect(subject.exclude_collection? ctx).to be false + end + + it 'should be excluded with options set if nil' do + ctx = context.new(blueprint.new, field, nil, object, { exclude_if_empty: true }) + expect(subject.exclude_collection? ctx).to be true + end + + it 'should be excluded with options set if empty' do + ctx = context.new(blueprint.new, field, [], object, { exclude_if_empty: true }) + expect(subject.exclude_collection? ctx).to be true + end + + it 'should be allowed with field options set' do + field.options[:exclude_if_empty] = true + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.exclude_collection? ctx).to be false + end + + it 'should be excluded with field options set if nil' do + field.options[:exclude_if_empty] = true + ctx = context.new(blueprint.new, field, nil, object, {}) + expect(subject.exclude_collection? ctx).to be true + end + + it 'should be excluded with field options set if empty' do + field.options[:exclude_if_empty] = true + ctx = context.new(blueprint.new, field, [], object, {}) + expect(subject.exclude_collection? ctx).to be true + end + + it 'should be allowed with blueprint options set' do + blueprint.options[:exclude_if_empty] = true + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.exclude_collection? ctx).to be false + end + + it 'should be excluded with blueprint options set if nil' do + blueprint.options[:exclude_if_empty] = true + ctx = context.new(blueprint.new, field, nil, object, {}) + expect(subject.exclude_collection? ctx).to be true + end + + it 'should be excluded with blueprint options set if empty' do + blueprint.options[:exclude_if_empty] = true + ctx = context.new(blueprint.new, field, [], object, {}) + expect(subject.exclude_collection? ctx).to be true + end + end +end diff --git a/spec/v2/extensions/exclude_if_nil_spec.rb b/spec/v2/extensions/exclude_if_nil_spec.rb new file mode 100644 index 00000000..53330ade --- /dev/null +++ b/spec/v2/extensions/exclude_if_nil_spec.rb @@ -0,0 +1,147 @@ +# frozen_string_literal: true + +describe Blueprinter::V2::Extensions::Exclusions do + subject { described_class.new } + let(:context) { Blueprinter::V2::Context } + let(:blueprint) { Class.new(Blueprinter::V2::Base) } + let(:field) { Blueprinter::V2::Field.new(name: :name, from: :name, options: {}) } + let(:object) { { name: 'Foo' } } + + context 'fields' do + it 'should be allowed by default' do + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.exclude_field? ctx).to be false + end + + it 'should be allowed by default if nil' do + ctx = context.new(blueprint.new, field, nil, object, {}) + expect(subject.exclude_field? ctx).to be false + end + + it 'should be allowed with options set' do + ctx = context.new(blueprint.new, field, 'Foo', object, { exclude_if_nil: true }) + expect(subject.exclude_field? ctx).to be false + end + + it 'should be excluded with options set if nil' do + ctx = context.new(blueprint.new, field, nil, object, { exclude_if_nil: true }) + expect(subject.exclude_field? ctx).to be true + end + + it 'should be allowed with field options set' do + field.options[:exclude_if_nil] = true + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.exclude_field? ctx).to be false + end + + it 'should be excluded with field options set if nil' do + field.options[:exclude_if_nil] = true + ctx = context.new(blueprint.new, field, nil, object, {}) + expect(subject.exclude_field? ctx).to be true + end + + it 'should be allowed with blueprint options set' do + blueprint.options[:exclude_if_nil] = true + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.exclude_field? ctx).to be false + end + + it 'should be excluded with blueprint options set if nil' do + blueprint.options[:exclude_if_nil] = true + ctx = context.new(blueprint.new, field, nil, object, {}) + expect(subject.exclude_field? ctx).to be true + end + end + + context 'objects' do + it 'should be allowed by default' do + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.exclude_object? ctx).to be false + end + + it 'should be allowed by default if nil' do + ctx = context.new(blueprint.new, field, nil, object, {}) + expect(subject.exclude_object? ctx).to be false + end + + it 'should be allowed with options set' do + ctx = context.new(blueprint.new, field, 'Foo', object, { exclude_if_nil: true }) + expect(subject.exclude_object? ctx).to be false + end + + it 'should be excluded with options set if nil' do + ctx = context.new(blueprint.new, field, nil, object, { exclude_if_nil: true }) + expect(subject.exclude_object? ctx).to be true + end + + it 'should be allowed with field options set' do + field.options[:exclude_if_nil] = true + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.exclude_object? ctx).to be false + end + + it 'should be excluded with field options set if nil' do + field.options[:exclude_if_nil] = true + ctx = context.new(blueprint.new, field, nil, object, {}) + expect(subject.exclude_object? ctx).to be true + end + + it 'should be allowed with blueprint options set' do + blueprint.options[:exclude_if_nil] = true + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.exclude_object? ctx).to be false + end + + it 'should be excluded with blueprint options set if nil' do + blueprint.options[:exclude_if_nil] = true + ctx = context.new(blueprint.new, field, nil, object, {}) + expect(subject.exclude_object? ctx).to be true + end + end + + context 'collections' do + it 'should be allowed by default' do + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.exclude_collection? ctx).to be false + end + + it 'should be allowed by default if nil' do + ctx = context.new(blueprint.new, field, nil, object, {}) + expect(subject.exclude_collection? ctx).to be false + end + + it 'should be allowed with options set' do + ctx = context.new(blueprint.new, field, 'Foo', object, { exclude_if_nil: true }) + expect(subject.exclude_collection? ctx).to be false + end + + it 'should be excluded with options set if nil' do + ctx = context.new(blueprint.new, field, nil, object, { exclude_if_nil: true }) + expect(subject.exclude_collection? ctx).to be true + end + + it 'should be allowed with field options set' do + field.options[:exclude_if_nil] = true + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.exclude_collection? ctx).to be false + end + + it 'should be excluded with field options set if nil' do + field.options[:exclude_if_nil] = true + ctx = context.new(blueprint.new, field, nil, object, {}) + expect(subject.exclude_collection? ctx).to be true + end + + it 'should be allowed with blueprint options set' do + blueprint.options[:exclude_if_nil] = true + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.exclude_collection? ctx).to be false + end + + it 'should be excluded with blueprint options set if nil' do + blueprint.options[:exclude_if_nil] = true + ctx = context.new(blueprint.new, field, nil, object, {}) + expect(subject.exclude_collection? ctx).to be true + end + end +end diff --git a/spec/v2/extensions/if_conditionals_spec.rb b/spec/v2/extensions/if_conditionals_spec.rb new file mode 100644 index 00000000..5589720c --- /dev/null +++ b/spec/v2/extensions/if_conditionals_spec.rb @@ -0,0 +1,197 @@ +# frozen_string_literal: true + +describe Blueprinter::V2::Extensions::Exclusions do + subject { described_class.new } + let(:context) { Blueprinter::V2::Context } + let(:object) { { name: 'Foo' } } + let(:blueprint) do + Class.new(Blueprinter::V2::Base) do + def foo?(ctx) + ctx.value == :foo + end + end + end + + context 'fields' do + let(:field) { Blueprinter::V2::Field.new(name: :name, from: :name, options: {}) } + + it 'should be allowed by default' do + ctx = context.new(blueprint.new, field, :foo, object, {}) + expect(subject.exclude_field? ctx).to be false + end + + it 'should check options field_if (Proc)' do + ctx = context.new(blueprint.new, field, :foo, object, { field_if: ->(ctx) { foo? ctx } }) + expect(subject.exclude_field? ctx).to be false + + ctx = context.new(blueprint.new, field, :bar, object, { field_if: ->(ctx) { foo? ctx } }) + expect(subject.exclude_field? ctx).to be true + end + + it 'should check field options if (Proc)' do + field.options[:if] = ->(ctx) { foo? ctx } + ctx = context.new(blueprint.new, field, :foo, object, {}) + expect(subject.exclude_field? ctx).to be false + + ctx = context.new(blueprint.new, field, :bar, object, {}) + expect(subject.exclude_field? ctx).to be true + end + + it 'should check blueprint options field_if (Proc)' do + blueprint.options[:field_if] = ->(ctx) { foo? ctx } + ctx = context.new(blueprint.new, field, :foo, object, {}) + expect(subject.exclude_field? ctx).to be false + + ctx = context.new(blueprint.new, field, :bar, object, {}) + expect(subject.exclude_field? ctx).to be true + end + + it 'should check options field_if (Symbol)' do + ctx = context.new(blueprint.new, field, :foo, object, { field_if: :foo? }) + expect(subject.exclude_field? ctx).to be false + + ctx = context.new(blueprint.new, field, :bar, object, { field_if: :foo? }) + expect(subject.exclude_field? ctx).to be true + end + + it 'should check field options if (Symbol)' do + field.options[:if] = :foo? + ctx = context.new(blueprint.new, field, :foo, object, {}) + expect(subject.exclude_field? ctx).to be false + + ctx = context.new(blueprint.new, field, :bar, object, {}) + expect(subject.exclude_field? ctx).to be true + end + + it 'should check blueprint options field_if (Symbol)' do + blueprint.options[:field_if] = :foo? + ctx = context.new(blueprint.new, field, :foo, object, {}) + expect(subject.exclude_field? ctx).to be false + + ctx = context.new(blueprint.new, field, :bar, object, {}) + expect(subject.exclude_field? ctx).to be true + end + end + + context 'objects' do + let(:field) { Blueprinter::V2::ObjectField.new(name: :name, from: :name, options: {}) } + + it 'should be allowed by default' do + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.exclude_object? ctx).to be false + end + + it 'should check options object_if (Proc)' do + ctx = context.new(blueprint.new, field, :foo, object, { object_if: ->(ctx) { foo? ctx } }) + expect(subject.exclude_object? ctx).to be false + + ctx = context.new(blueprint.new, field, :bar, object, { object_if: ->(ctx) { foo? ctx } }) + expect(subject.exclude_object? ctx).to be true + end + + it 'should check field options if (Proc)' do + field.options[:if] = ->(ctx) { foo? ctx } + ctx = context.new(blueprint.new, field, :foo, object, {}) + expect(subject.exclude_object? ctx).to be false + + ctx = context.new(blueprint.new, field, :bar, object, {}) + expect(subject.exclude_object? ctx).to be true + end + + it 'should check blueprint options object_if (Proc)' do + blueprint.options[:object_if] = ->(ctx) { foo? ctx } + ctx = context.new(blueprint.new, field, :foo, object, {}) + expect(subject.exclude_object? ctx).to be false + + ctx = context.new(blueprint.new, field, :bar, object, {}) + expect(subject.exclude_object? ctx).to be true + end + + it 'should check options object_if (Symbol)' do + ctx = context.new(blueprint.new, field, :foo, object, { object_if: :foo? }) + expect(subject.exclude_object? ctx).to be false + + ctx = context.new(blueprint.new, field, :bar, object, { object_if: :foo? }) + expect(subject.exclude_object? ctx).to be true + end + + it 'should check field options if (Symbol)' do + field.options[:if] = :foo? + ctx = context.new(blueprint.new, field, :foo, object, {}) + expect(subject.exclude_object? ctx).to be false + + ctx = context.new(blueprint.new, field, :bar, object, {}) + expect(subject.exclude_object? ctx).to be true + end + + it 'should check blueprint options object_if (Symbol)' do + blueprint.options[:object_if] = :foo? + ctx = context.new(blueprint.new, field, :foo, object, {}) + expect(subject.exclude_object? ctx).to be false + + ctx = context.new(blueprint.new, field, :bar, object, {}) + expect(subject.exclude_object? ctx).to be true + end + end + + context 'collections' do + let(:field) { Blueprinter::V2::Collection.new(name: :name, from: :name, options: {}) } + + it 'should be allowed by default' do + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.exclude_collection? ctx).to be false + end + + it 'should check options collection_if (Proc)' do + ctx = context.new(blueprint.new, field, :foo, object, { collection_if: ->(ctx) { foo? ctx } }) + expect(subject.exclude_collection? ctx).to be false + + ctx = context.new(blueprint.new, field, :bar, object, { collection_if: ->(ctx) { foo? ctx } }) + expect(subject.exclude_collection? ctx).to be true + end + + it 'should check field options if (Proc)' do + field.options[:if] = ->(ctx) { foo? ctx } + ctx = context.new(blueprint.new, field, :foo, object, {}) + expect(subject.exclude_collection? ctx).to be false + + ctx = context.new(blueprint.new, field, :bar, object, {}) + expect(subject.exclude_collection? ctx).to be true + end + + it 'should check blueprint options collection_if (Proc)' do + blueprint.options[:collection_if] = ->(ctx) { foo? ctx } + ctx = context.new(blueprint.new, field, :foo, object, {}) + expect(subject.exclude_collection? ctx).to be false + + ctx = context.new(blueprint.new, field, :bar, object, {}) + expect(subject.exclude_collection? ctx).to be true + end + + it 'should check options collection_if (Symbol)' do + ctx = context.new(blueprint.new, field, :foo, object, { collection_if: :foo? }) + expect(subject.exclude_collection? ctx).to be false + + ctx = context.new(blueprint.new, field, :bar, object, { collection_if: :foo? }) + expect(subject.exclude_collection? ctx).to be true + end + + it 'should check field options if (Symbol)' do + field.options[:if] = :foo? + ctx = context.new(blueprint.new, field, :foo, object, {}) + expect(subject.exclude_collection? ctx).to be false + + ctx = context.new(blueprint.new, field, :bar, object, {}) + expect(subject.exclude_collection? ctx).to be true + end + + it 'should check blueprint options collection_if (Symbol)' do + blueprint.options[:collection_if] = :foo? + ctx = context.new(blueprint.new, field, :foo, object, {}) + expect(subject.exclude_collection? ctx).to be false + + ctx = context.new(blueprint.new, field, :bar, object, {}) + expect(subject.exclude_collection? ctx).to be true + end + end +end diff --git a/spec/v2/extensions/unless_conditionals_spec.rb b/spec/v2/extensions/unless_conditionals_spec.rb new file mode 100644 index 00000000..70225501 --- /dev/null +++ b/spec/v2/extensions/unless_conditionals_spec.rb @@ -0,0 +1,197 @@ +# frozen_string_literal: true + +describe Blueprinter::V2::Extensions::Exclusions do + subject { described_class.new } + let(:context) { Blueprinter::V2::Context } + let(:object) { { name: 'Foo' } } + let(:blueprint) do + Class.new(Blueprinter::V2::Base) do + def foo?(ctx) + ctx.value == :foo + end + end + end + + context 'fields' do + let(:field) { Blueprinter::V2::Field.new(name: :name, from: :name, options: {}) } + + it 'should be allowed by default' do + ctx = context.new(blueprint.new, field, :foo, object, {}) + expect(subject.exclude_field? ctx).to be false + end + + it 'should check options field_unless (Proc)' do + ctx = context.new(blueprint.new, field, :foo, object, { field_unless: ->(ctx) { foo? ctx } }) + expect(subject.exclude_field? ctx).to be true + + ctx = context.new(blueprint.new, field, :bar, object, { field_unless: ->(ctx) { foo? ctx } }) + expect(subject.exclude_field? ctx).to be false + end + + it 'should check field options unless (Proc)' do + field.options[:unless] = ->(ctx) { foo? ctx } + ctx = context.new(blueprint.new, field, :foo, object, {}) + expect(subject.exclude_field? ctx).to be true + + ctx = context.new(blueprint.new, field, :bar, object, {}) + expect(subject.exclude_field? ctx).to be false + end + + it 'should check blueprint options field_unless (Proc)' do + blueprint.options[:field_unless] = ->(ctx) { foo? ctx } + ctx = context.new(blueprint.new, field, :foo, object, {}) + expect(subject.exclude_field? ctx).to be true + + ctx = context.new(blueprint.new, field, :bar, object, {}) + expect(subject.exclude_field? ctx).to be false + end + + it 'should check options field_unless (Symbol)' do + ctx = context.new(blueprint.new, field, :foo, object, { field_unless: :foo? }) + expect(subject.exclude_field? ctx).to be true + + ctx = context.new(blueprint.new, field, :bar, object, { field_unless: :foo? }) + expect(subject.exclude_field? ctx).to be false + end + + it 'should check field options unless (Symbol)' do + field.options[:unless] = :foo? + ctx = context.new(blueprint.new, field, :foo, object, {}) + expect(subject.exclude_field? ctx).to be true + + ctx = context.new(blueprint.new, field, :bar, object, {}) + expect(subject.exclude_field? ctx).to be false + end + + it 'should check blueprint options field_unless (Symbol)' do + blueprint.options[:field_unless] = :foo? + ctx = context.new(blueprint.new, field, :foo, object, {}) + expect(subject.exclude_field? ctx).to be true + + ctx = context.new(blueprint.new, field, :bar, object, {}) + expect(subject.exclude_field? ctx).to be false + end + end + + context 'objects' do + let(:field) { Blueprinter::V2::ObjectField.new(name: :name, from: :name, options: {}) } + + it 'should be allowed by default' do + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.exclude_object? ctx).to be false + end + + it 'should check options object_unless (Proc)' do + ctx = context.new(blueprint.new, field, :foo, object, { object_unless: ->(ctx) { foo? ctx } }) + expect(subject.exclude_object? ctx).to be true + + ctx = context.new(blueprint.new, field, :bar, object, { object_unless: ->(ctx) { foo? ctx } }) + expect(subject.exclude_object? ctx).to be false + end + + it 'should check field options unless (Proc)' do + field.options[:unless] = ->(ctx) { foo? ctx } + ctx = context.new(blueprint.new, field, :foo, object, {}) + expect(subject.exclude_object? ctx).to be true + + ctx = context.new(blueprint.new, field, :bar, object, {}) + expect(subject.exclude_object? ctx).to be false + end + + it 'should check blueprint options object_unless (Proc)' do + blueprint.options[:object_unless] = ->(ctx) { foo? ctx } + ctx = context.new(blueprint.new, field, :foo, object, {}) + expect(subject.exclude_object? ctx).to be true + + ctx = context.new(blueprint.new, field, :bar, object, {}) + expect(subject.exclude_object? ctx).to be false + end + + it 'should check options object_unless (Symbol)' do + ctx = context.new(blueprint.new, field, :foo, object, { object_unless: :foo? }) + expect(subject.exclude_object? ctx).to be true + + ctx = context.new(blueprint.new, field, :bar, object, { object_unless: :foo? }) + expect(subject.exclude_object? ctx).to be false + end + + it 'should check field options unless (Symbol)' do + field.options[:unless] = :foo? + ctx = context.new(blueprint.new, field, :foo, object, {}) + expect(subject.exclude_object? ctx).to be true + + ctx = context.new(blueprint.new, field, :bar, object, {}) + expect(subject.exclude_object? ctx).to be false + end + + it 'should check blueprint options object_unless (Symbol)' do + blueprint.options[:object_unless] = :foo? + ctx = context.new(blueprint.new, field, :foo, object, {}) + expect(subject.exclude_object? ctx).to be true + + ctx = context.new(blueprint.new, field, :bar, object, {}) + expect(subject.exclude_object? ctx).to be false + end + end + + context 'collections' do + let(:field) { Blueprinter::V2::Collection.new(name: :name, from: :name, options: {}) } + + it 'should be allowed by default' do + ctx = context.new(blueprint.new, field, 'Foo', object, {}) + expect(subject.exclude_collection? ctx).to be false + end + + it 'should check options collection_unless (Proc)' do + ctx = context.new(blueprint.new, field, :foo, object, { collection_unless: ->(ctx) { foo? ctx } }) + expect(subject.exclude_collection? ctx).to be true + + ctx = context.new(blueprint.new, field, :bar, object, { collection_unless: ->(ctx) { foo? ctx } }) + expect(subject.exclude_collection? ctx).to be false + end + + it 'should check field options unless (Proc)' do + field.options[:unless] = ->(ctx) { foo? ctx } + ctx = context.new(blueprint.new, field, :foo, object, {}) + expect(subject.exclude_collection? ctx).to be true + + ctx = context.new(blueprint.new, field, :bar, object, {}) + expect(subject.exclude_collection? ctx).to be false + end + + it 'should check blueprint options collection_unless (Proc)' do + blueprint.options[:collection_unless] = ->(ctx) { foo? ctx } + ctx = context.new(blueprint.new, field, :foo, object, {}) + expect(subject.exclude_collection? ctx).to be true + + ctx = context.new(blueprint.new, field, :bar, object, {}) + expect(subject.exclude_collection? ctx).to be false + end + + it 'should check options collection_unless (Symbol)' do + ctx = context.new(blueprint.new, field, :foo, object, { collection_unless: :foo? }) + expect(subject.exclude_collection? ctx).to be true + + ctx = context.new(blueprint.new, field, :bar, object, { collection_unless: :foo? }) + expect(subject.exclude_collection? ctx).to be false + end + + it 'should check field options unless (Symbol)' do + field.options[:unless] = :foo? + ctx = context.new(blueprint.new, field, :foo, object, {}) + expect(subject.exclude_collection? ctx).to be true + + ctx = context.new(blueprint.new, field, :bar, object, {}) + expect(subject.exclude_collection? ctx).to be false + end + + it 'should check blueprint options collection_unless (Symbol)' do + blueprint.options[:collection_unless] = :foo? + ctx = context.new(blueprint.new, field, :foo, object, {}) + expect(subject.exclude_collection? ctx).to be true + + ctx = context.new(blueprint.new, field, :bar, object, {}) + expect(subject.exclude_collection? ctx).to be false + end + end +end diff --git a/spec/v2/serializer_spec.rb b/spec/v2/serializer_spec.rb index 8fc2c0ba..f629820b 100644 --- a/spec/v2/serializer_spec.rb +++ b/spec/v2/serializer_spec.rb @@ -56,6 +56,34 @@ }) end + it 'should enable the if conditionals extension' do + widget_blueprint = Class.new(Blueprinter::V2::Base) do + field :name + field :desc, if: ->(ctx) { ctx.options[:n] > 42 } + end + + result = described_class.new(widget_blueprint).call( + { name: 'Foo', desc: 'Bar' }, + { n: 42 }, + instance_cache + ) + expect(result).to eq({ name: 'Foo' }) + end + + it 'should enable the unless conditionals extension' do + widget_blueprint = Class.new(Blueprinter::V2::Base) do + field :name + field :desc, unless: ->(ctx) { ctx.options[:n] > 42 } + end + + result = described_class.new(widget_blueprint).call( + { name: 'Foo', desc: 'Bar' }, + { n: 43 }, + instance_cache + ) + expect(result).to eq({ name: 'Foo' }) + end + it 'should enable the default values extension' do widget_blueprint = Class.new(Blueprinter::V2::Base) do field :name @@ -69,6 +97,26 @@ }) end + it 'should enable the exclude if empty extension' do + widget_blueprint = Class.new(Blueprinter::V2::Base) do + field :name, exclude_if_empty: true + field :desc, exclude_if_empty: true + end + + result = described_class.new(widget_blueprint).call({ name: 'Foo', desc: "" }, {}, instance_cache) + expect(result).to eq({ name: 'Foo' }) + end + + it 'should enable the exclude if nil extension' 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).call({ name: 'Foo', desc: nil }, {}, instance_cache) + expect(result).to eq({ name: 'Foo' }) + end + it 'should format fields' do widget_blueprint = Class.new(Blueprinter::V2::Base) do format(Date) { |date| date.strftime('%a %b %e, %Y') } @@ -84,6 +132,34 @@ }) end + it 'should evaluate value hooks before exclusion hooks' do + widget_blueprint = Class.new(Blueprinter::V2::Base) do + field :name + field :desc, default: 'Bar', if: ->(ctx) { !ctx.value.nil? } + end + widget = { name: 'Foo', desc: nil } + + result = described_class.new(widget_blueprint).call(widget, {}, instance_cache) + expect(result).to eq({ name: 'Foo', desc: 'Bar' }) + end + + it 'should evaluate both ifs and unlesses' do + widget_blueprint = Class.new(Blueprinter::V2::Base) do + field :name, if: ->(ctx) { ctx.options[:n] > 42 } + field :desc, unless: ->(ctx) { ctx.options[:n] < 43 } + field :zorp, + if: ->(ctx) { ctx.options[:n] > 40 }, + unless: ->(ctx) { ctx.options[:m] == 42 } + end + + result = described_class.new(widget_blueprint).call( + { name: 'Foo', desc: 'Bar', zorp: 'Zorp' }, + { n: 42, m: 42 }, + instance_cache + ) + expect(result).to eq({}) + end + it 'should run blueprint_input hooks before anything else' do ext = Class.new(Blueprinter::Extension) do def blueprint_input(_ctx) From 2dc81abd6d904aff6b50560263b95cf3a77d436d Mon Sep 17 00:00:00 2001 From: Jordan Hollinger Date: Fri, 1 Nov 2024 10:28:33 -0400 Subject: [PATCH 07/16] Root option for V2 Signed-off-by: Jordan Hollinger --- lib/blueprinter/v2/extensions.rb | 1 + lib/blueprinter/v2/extensions/output.rb | 25 ++++++++ lib/blueprinter/v2/serializer.rb | 4 +- spec/v2/extensions/output_spec.rb | 79 +++++++++++++++++++++++++ spec/v2/render_spec.rb | 28 +++++++++ spec/v2/rendering_spec.rb | 22 +++++++ 6 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 lib/blueprinter/v2/extensions/output.rb create mode 100644 spec/v2/extensions/output_spec.rb diff --git a/lib/blueprinter/v2/extensions.rb b/lib/blueprinter/v2/extensions.rb index fc01f0a3..85c0c519 100644 --- a/lib/blueprinter/v2/extensions.rb +++ b/lib/blueprinter/v2/extensions.rb @@ -4,6 +4,7 @@ module Blueprinter module V2 module Extensions autoload :Exclusions, 'blueprinter/v2/extensions/exclusions' + autoload :Output, 'blueprinter/v2/extensions/output' autoload :Values, 'blueprinter/v2/extensions/values' end end diff --git a/lib/blueprinter/v2/extensions/output.rb b/lib/blueprinter/v2/extensions/output.rb new file mode 100644 index 00000000..2ea48341 --- /dev/null +++ b/lib/blueprinter/v2/extensions/output.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Blueprinter + module V2 + module Extensions + class Output < Extension + def output_object(ctx) + root_name = ctx.options[:root] || ctx.blueprint.class.options[:root] + return ctx.value if root_name.nil? + + root = { root_name => ctx.value } + 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 + + def output_collection(ctx) + output_object ctx + end + end + end + end +end diff --git a/lib/blueprinter/v2/serializer.rb b/lib/blueprinter/v2/serializer.rb index 9985cba1..41cb51b9 100644 --- a/lib/blueprinter/v2/serializer.rb +++ b/lib/blueprinter/v2/serializer.rb @@ -13,7 +13,9 @@ class Serializer ].freeze # Core extensions that must run at the end of serialization - CORE_FINISH = [].freeze + CORE_FINISH = [ + Extensions::Output + ].freeze attr_reader :blueprint, :formatter, :hooks diff --git a/spec/v2/extensions/output_spec.rb b/spec/v2/extensions/output_spec.rb new file mode 100644 index 00000000..fe906167 --- /dev/null +++ b/spec/v2/extensions/output_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +describe Blueprinter::V2::Extensions::Output do + subject { described_class.new } + let(:context) { Blueprinter::V2::Context } + let(:blueprint) do + Class.new(Blueprinter::V2::Base) do + field :name + + def meta_links + { links: [] } + end + end + end + + it 'should pass through the result by default for objects' do + ctx = context.new(blueprint.new, nil, { name: 'Foo' }, nil, {}) + result = subject.output_object(ctx) + expect(result).to eq({ name: 'Foo' }) + end + + it 'should pass through the result by default for collections' do + ctx = context.new(blueprint.new, nil, [{ name: 'Foo' }], nil, {}) + result = subject.output_collection(ctx) + expect(result).to eq([{ name: 'Foo' }]) + end + + it 'should look for a root option in the blueprint for objects' do + blueprint.options[:root] = :data + ctx = context.new(blueprint.new, nil, { name: 'Foo' }, nil, {}) + result = subject.output_object(ctx) + expect(result).to eq({ data: { name: 'Foo' } }) + end + + it 'should look for a root option in the blueprint for collections' do + blueprint.options[:root] = :data + ctx = context.new(blueprint.new, nil, [{ name: 'Foo' }], nil, {}) + result = subject.output_collection(ctx) + expect(result).to eq({ data: [{ name: 'Foo' }] }) + end + + it 'should look for a root option in the options over the blueprint' do + blueprint.options[:root] = :data + ctx = context.new(blueprint.new, nil, { name: 'Foo' }, nil, { root: :root }) + result = subject.output_object(ctx) + expect(result).to eq({ root: { name: 'Foo' } }) + end + + it 'should look for a meta option in the blueprint' do + blueprint.options[:root] = :data + blueprint.options[:meta] = { links: [] } + ctx = context.new(blueprint.new, nil, { name: 'Foo' }, nil, {}) + result = subject.output_object(ctx) + expect(result).to eq({ data: { name: 'Foo' }, meta: { links: [] } }) + end + + it 'should look for a meta option in the options over the blueprint' do + blueprint.options[:root] = :data + blueprint.options[:meta] = { links: [] } + ctx = context.new(blueprint.new, nil, { name: 'Foo' }, nil, { root: :root, meta: { linkz: [] }}) + result = subject.output_object(ctx) + expect(result).to eq({ root: { name: 'Foo' }, meta: { linkz: [] } }) + end + + it 'should look for a meta Proc option in the blueprint' do + blueprint.options[:root] = :data + blueprint.options[:meta] = ->(ctx) { meta_links } + ctx = context.new(blueprint.new, nil, { name: 'Foo' }, nil, {}) + result = subject.output_object(ctx) + expect(result).to eq({ data: { name: 'Foo' }, meta: { links: [] } }) + end + + it 'should look for a meta Proc option in the options' do + blueprint.options[:root] = :data + ctx = context.new(blueprint.new, nil, { name: 'Foo' }, nil, { root: :root, meta: ->(ctx) { meta_links } }) + result = subject.output_object(ctx) + expect(result).to eq({ root: { name: 'Foo' }, meta: { links: [] } }) + end +end diff --git a/spec/v2/render_spec.rb b/spec/v2/render_spec.rb index 95b26dbd..2158586a 100644 --- a/spec/v2/render_spec.rb +++ b/spec/v2/render_spec.rb @@ -109,4 +109,32 @@ def input_collection(ctx) category: nil }]) end + + it 'should call output hooks for objects' do + serializer = Blueprinter::V2::Serializer.new(widget_blueprint) + widget = { name: 'Foo', description: 'About', category: { n: 'Bar' } } + render = described_class.new(widget, { root: :data }, serializer: serializer, collection: false) + + expect(render.to_hash).to eq({ + data: { + name: 'Foo', + desc: 'About', + category: { name: 'Bar' } + } + }) + end + + it 'should call output hooks for collections' do + serializer = Blueprinter::V2::Serializer.new(widget_blueprint) + widget = { name: 'Foo', description: 'About', category: { n: 'Bar' } } + render = described_class.new([widget], { root: :data }, serializer: serializer, collection: true) + + expect(render.to_hash).to eq({ + data: [{ + name: 'Foo', + desc: 'About', + category: { name: 'Bar' } + }] + }) + end end diff --git a/spec/v2/rendering_spec.rb b/spec/v2/rendering_spec.rb index 88f8568f..5d204b07 100644 --- a/spec/v2/rendering_spec.rb +++ b/spec/v2/rendering_spec.rb @@ -43,4 +43,26 @@ } ]) end + + it 'should render an object with options' do + result = widget_blueprint.render_object(widget, { root: :data }).to_hash + expect(result).to eq({ + data: { + name: 'Foo', + cat: { name: 'Bar' }, + parts: [{ num: 42 }, { num: 43 }] + } + }) + end + + it 'should render a collection with options' do + result = widget_blueprint.render_collection([widget], { root: :data }).to_hash + expect(result).to eq({ + data: [{ + name: 'Foo', + cat: { name: 'Bar' }, + parts: [{ num: 42 }, { num: 43 }] + }] + }) + end end From b54fb05d87944a1be5e80b74fc1dc5feed55e6b5 Mon Sep 17 00:00:00 2001 From: Jordan Hollinger Date: Fri, 1 Nov 2024 10:33:53 -0400 Subject: [PATCH 08/16] Field order extension for V2 Signed-off-by: Jordan Hollinger --- lib/blueprinter/v2/extensions.rb | 1 + lib/blueprinter/v2/extensions/field_order.rb | 17 +++++++++++ spec/v2/extensions/field_order_spec.rb | 31 ++++++++++++++++++++ 3 files changed, 49 insertions(+) create mode 100644 lib/blueprinter/v2/extensions/field_order.rb create mode 100644 spec/v2/extensions/field_order_spec.rb diff --git a/lib/blueprinter/v2/extensions.rb b/lib/blueprinter/v2/extensions.rb index 85c0c519..caee1395 100644 --- a/lib/blueprinter/v2/extensions.rb +++ b/lib/blueprinter/v2/extensions.rb @@ -4,6 +4,7 @@ module Blueprinter module V2 module Extensions autoload :Exclusions, 'blueprinter/v2/extensions/exclusions' + autoload :FieldOrder, 'blueprinter/v2/extensions/field_order' autoload :Output, 'blueprinter/v2/extensions/output' autoload :Values, 'blueprinter/v2/extensions/values' end diff --git a/lib/blueprinter/v2/extensions/field_order.rb b/lib/blueprinter/v2/extensions/field_order.rb new file mode 100644 index 00000000..987ac2fe --- /dev/null +++ b/lib/blueprinter/v2/extensions/field_order.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Blueprinter + module V2 + module Extensions + class FieldOrder < Extension + def initialize(&sorter) + @sorter = sorter + end + + def sort_fields(fields) + fields.sort(&@sorter) + end + end + end + end +end diff --git a/spec/v2/extensions/field_order_spec.rb b/spec/v2/extensions/field_order_spec.rb new file mode 100644 index 00000000..8858e882 --- /dev/null +++ b/spec/v2/extensions/field_order_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +describe Blueprinter::V2::Extensions::FieldOrder do + let(:blueprint) do + Class.new(Blueprinter::V2::Base) do + field :foo + field :id + object :bar, self + end + end + + it 'should sort fields alphabetically' do + ext = described_class.new { |a, b| a.name <=> b.name } + result = ext.sort_fields(blueprint.schema.values) + expect(result.map(&:name)).to eq %i(bar foo id) + end + + it 'should sort fields alphabetically with id first' do + ext = described_class.new do |a, b| + if a.name == :id + -1 + elsif b.name == :id + 1 + else + a.name <=> b.name + end + end + result = ext.sort_fields(blueprint.schema.values) + expect(result.map(&:name)).to eq %i(id bar foo) + end +end From 9df415bf92d2a34355ad6fe1114d3f4ddda126d5 Mon Sep 17 00:00:00 2001 From: Jordan Hollinger Date: Fri, 1 Nov 2024 10:35:56 -0400 Subject: [PATCH 09/16] Add V1/V2 speed benchmark Signed-off-by: Jordan Hollinger --- Rakefile | 6 ++ spec/benchmarks/speedtest.rb | 153 +++++++++++++++++++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 spec/benchmarks/speedtest.rb diff --git a/Rakefile b/Rakefile index 75478643..207a21d0 100644 --- a/Rakefile +++ b/Rakefile @@ -39,4 +39,10 @@ Rake::TestTask.new(:benchmarks) do |t| t.verbose = false end +Rake::TestTask.new(:speedtest) do |t| + t.libs.append('lib', 'spec') + t.pattern = 'spec/benchmarks/speedtest.rb' + t.verbose = false +end + task default: %i[spec rubocop] diff --git a/spec/benchmarks/speedtest.rb b/spec/benchmarks/speedtest.rb new file mode 100644 index 00000000..6dcd2130 --- /dev/null +++ b/spec/benchmarks/speedtest.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true + +require 'benchmark' +require 'blueprinter' + +class CategoryBlueprintV1 < Blueprinter::Base + field :name +end + +class PartBlueprintV1 < Blueprinter::Base + field :num +end + +class WidgetBlueprintV1 < Blueprinter::Base + field :name1 + field :name2 + field :name3 + field :name4 + field :name5 + field :name6 + field :name7 + field :name8 + field :name9 + field :name10 + association :category, blueprint: CategoryBlueprintV1 + association :parts, blueprint: PartBlueprintV1 +end + +class CategoryBlueprintV2 < Blueprinter::V2::Base + field :name +end + +class PartBlueprintV2 < Blueprinter::V2::Base + field :num +end + +class WidgetBlueprintV2 < Blueprinter::V2::Base + field :name1 + field :name2 + field :name3 + field :name4 + field :name5 + field :name6 + field :name7 + field :name8 + field :name9 + field :name10 + object :category, CategoryBlueprintV2 + collection :parts, PartBlueprintV2 +end + +results = Benchmark.bmbm do |x| + widgets = 100_000.times.map do |n| + { + name1: "Widget #{n}", + name2: "Widget #{n}", + name3: "Widget #{n}", + name4: "Widget #{n}", + name5: "Widget #{n}", + name6: "Widget #{n}", + name7: "Widget #{n}", + name8: "Widget #{n}", + name9: "Widget #{n}", + name10: "Widget #{n}", + category: { name: "Category #{n % 50}" }, + parts: (1..rand(1..10)).map { |n| { num: n } } + } + end + + x.report 'Massive: V1' do + WidgetBlueprintV1.render(widgets) + end + + x.report 'Massive: V2' do + WidgetBlueprintV2.render(widgets).to_hash + end + + x.report 'Large: V1' do + list = widgets[0,10_000] + 10.times { WidgetBlueprintV1.render(list) } + end + + x.report 'Large: V2' do + list = widgets[0,10_000] + 10.times { WidgetBlueprintV2.render(list).to_hash } + end + + x.report 'Medium: V1' do + list = widgets[0,1000] + 100.times { WidgetBlueprintV1.render(list) } + end + + x.report 'Medium: V2' do + list = widgets[0,1000] + 100.times { WidgetBlueprintV2.render(list).to_hash } + end + + x.report 'Small: V1' do + list = widgets[0,100] + 100.times { WidgetBlueprintV1.render(list) } + end + + x.report 'Small: V2' do + list = widgets[0,100] + 100.times { WidgetBlueprintV2.render(list).to_hash } + end + + x.report 'Tiny: V1' do + list = widgets[0,25] + 100.times { WidgetBlueprintV1.render(list) } + end + + x.report 'Tiny: V2' do + list = widgets[0,25] + 100.times { WidgetBlueprintV2.render(list).to_hash } + end + + x.report 'Micro: V1' do + list = widgets[0,5] + 100.times { WidgetBlueprintV1.render(list) } + end + + x.report 'Micro: V2' do + list = widgets[0,5] + 100.times { WidgetBlueprintV2.render(list).to_hash } + end + + x.report 'Nano: V1' do + list = widgets[0,1] + 100.times { WidgetBlueprintV1.render(list) } + end + + x.report 'Nano: V2' do + list = widgets[0,1] + 100.times { WidgetBlueprintV2.render(list).to_hash } + end +end + +puts "" +results. + group_by { |res| res.label[/.+:/].ljust 8 }. + each do |label, (a, b)| + v1 = (a.label =~ /V1/ ? a : b).real + v2 = (a.label =~ /V2/ ? a : b).real + + if v2 < v1 + n = (100 - (v2 / v1) * 100).round(2) + puts "#{label} V2 #{'%2.2f' % n}% faster (#{'%.4f' % (v1 - v2)} sec)" + else + n = (100 - (v1 / v2) * 100).round(2) + puts "#{label} V2 #{'%2.2f' % n}% slower (#{'%.4f' % (v2 - v1)} sec)" + end + end From db9a0b9e8a6f864579a890a9e8fb21d67efffde2 Mon Sep 17 00:00:00 2001 From: Jordan Hollinger Date: Tue, 5 Nov 2024 12:03:23 -0500 Subject: [PATCH 10/16] Allow data to be stored in Context#store Signed-off-by: Jordan Hollinger --- lib/blueprinter/extension.rb | 4 +-- lib/blueprinter/v2/fields.rb | 2 +- lib/blueprinter/v2/render.rb | 8 ++--- lib/blueprinter/v2/serializer.rb | 8 ++--- spec/v2/rendering_spec.rb | 55 ++++++++++++++++++++++++++++++++ spec/v2/serializer_spec.rb | 32 +++++++++++-------- 6 files changed, 85 insertions(+), 24 deletions(-) diff --git a/lib/blueprinter/extension.rb b/lib/blueprinter/extension.rb index efa5ce5a..dd595000 100644 --- a/lib/blueprinter/extension.rb +++ b/lib/blueprinter/extension.rb @@ -25,8 +25,8 @@ class Extension # # Returns fields in the order they should appear. Default is the order in which they were defined. # - # @param fields [ArrayBlueprinter::V2::Object|Blueprinter::V2::Collection] - # @return [ArrayBlueprinter::V2::Object|Blueprinter::V2::Collection] + # @param fields [Array] + # @return [Array] # def sort_fields(fields) fields diff --git a/lib/blueprinter/v2/fields.rb b/lib/blueprinter/v2/fields.rb index 60e1fb71..e40ff7fb 100644 --- a/lib/blueprinter/v2/fields.rb +++ b/lib/blueprinter/v2/fields.rb @@ -28,6 +28,6 @@ module V2 keyword_init: true ) - Context = Struct.new(:blueprint, :field, :value, :object, :options, :instances) + Context = Struct.new(:blueprint, :field, :value, :object, :options, :instances, :store) end end diff --git a/lib/blueprinter/v2/render.rb b/lib/blueprinter/v2/render.rb index 5d2c7223..def7bbd6 100644 --- a/lib/blueprinter/v2/render.rb +++ b/lib/blueprinter/v2/render.rb @@ -19,17 +19,17 @@ def to_hash pre_hook = @collection ? :input_collection : :input_object post_hook = @collection ? :output_collection : :output_object - ctx = Context.new(blueprint, nil, nil, @object, @options, instance_cache) + ctx = Context.new(blueprint, nil, nil, @object, @options, instance_cache, {}) object = @serializer.hooks.reduce_into(pre_hook, ctx, :object) result = if @collection - object.map { |obj| @serializer.call(obj, @options, instance_cache) } + object.map { |obj| @serializer.call(obj, @options, instance_cache, ctx.store) } else - @serializer.call(object, @options, instance_cache) + @serializer.call(object, @options, instance_cache, ctx.store) end - ctx = Context.new(blueprint, nil, result, object, @options, instance_cache) + ctx = Context.new(blueprint, nil, result, object, @options, instance_cache, ctx.store) @serializer.hooks.reduce_into(post_hook, ctx, :value) end diff --git a/lib/blueprinter/v2/serializer.rb b/lib/blueprinter/v2/serializer.rb index 41cb51b9..4154b98b 100644 --- a/lib/blueprinter/v2/serializer.rb +++ b/lib/blueprinter/v2/serializer.rb @@ -25,8 +25,8 @@ def initialize(blueprint) @blueprint = blueprint end - def call(object, options, instances) - context = Context.new(instances[blueprint], nil, nil, object, options, instances) + def call(object, options, instances, store) + context = Context.new(instances[blueprint], nil, nil, object, options, instances, store) hooks.reduce_into(:blueprint_input, context, :object) result = blueprint.reflections[:default].sorted.each_with_object({}) do |field, acc| @@ -43,14 +43,14 @@ def call(object, options, instances) next if hooks.any?(:exclude_object?, context) v2 = instances[field.blueprint].is_a? V2::Base - value = v2 ? field.blueprint.serializer.call(value, options, instances) : field.blueprint.render(value, options) if value + value = v2 ? field.blueprint.serializer.call(value, options, instances, store) : field.blueprint.render(value, options) if value acc[field.name] = value when Collection value = hooks.reduce_into(:collection_value, context, :value) next if hooks.any?(:exclude_collection?, context) v2 = instances[field.blueprint].is_a? V2::Base - value = v2 ? value.map { |val| field.blueprint.serializer.call(val, options, instances) } : field.blueprint.render(value, options) if value + value = v2 ? value.map { |val| field.blueprint.serializer.call(val, options, instances, store) } : field.blueprint.render(value, options) if value acc[field.name] = value end end diff --git a/spec/v2/rendering_spec.rb b/spec/v2/rendering_spec.rb index 5d204b07..c8ea50f3 100644 --- a/spec/v2/rendering_spec.rb +++ b/spec/v2/rendering_spec.rb @@ -65,4 +65,59 @@ }] }) end + + it 'should use the same Context.store Hash throughout' do + ext = Class.new(Blueprinter::Extension) do + def initialize(log) + @log = log + end + + def input_collection(ctx) + @log.clear + ctx.store[:log] = @log + ctx.object + end + end + category_blueprint = Class.new(Blueprinter::V2::Base) do + field :name, if: ->(ctx) { ctx.store[:log] << ctx.value } + end + part_blueprint = Class.new(Blueprinter::V2::Base) do + field :num, if: ->(ctx) { ctx.store[:log] << ctx.value } + end + log = [] + widget_blueprint = Class.new(Blueprinter::V2::Base) do + extensions << ext.new(log) + field :name, if: ->(ctx) { ctx.store[:log] << ctx.value } + object :category, category_blueprint, if: ->(ctx) { ctx.store[:log] << ctx.value } + collection :parts, part_blueprint, if: ->(ctx) { ctx.store[:log] << ctx.value } + end + + widget_blueprint.render_collection([ + { + name: 'Widget A', + category: { name: 'Category 1' }, + parts: [{ num: 42 }, { num: 43 }] + }, + { + name: 'Widget B', + category: { name: 'Category 2' }, + parts: [{ num: 43 }, { num: 44 }] + }, + ]).to_hash + + expect(log).to eq [ + 'Widget A', + { name: 'Category 1' }, + 'Category 1', + [{ num: 42 }, { num: 43 }], + 42, + 43, + 'Widget B', + { name: 'Category 2' }, + 'Category 2', + [{ num: 43 }, { num: 44 }], + 43, + 44 + ] + end end diff --git a/spec/v2/serializer_spec.rb b/spec/v2/serializer_spec.rb index f629820b..f68bfae4 100644 --- a/spec/v2/serializer_spec.rb +++ b/spec/v2/serializer_spec.rb @@ -31,7 +31,7 @@ test = self widget = { name: nil, category: nil } - result = described_class.new(widget_blueprint).call(widget, {}, instance_cache) + result = described_class.new(widget_blueprint).call(widget, {}, instance_cache, {}) expect(result).to eq({ name: nil, category: nil, @@ -48,7 +48,7 @@ parts: [{ num: 42, extra: 'bar' }, { num: 43 }] } - result = described_class.new(widget_blueprint).call(widget, {}, instance_cache) + result = described_class.new(widget_blueprint).call(widget, {}, instance_cache, {}) expect(result).to eq({ name: 'Foo', category: { name: 'Bar' }, @@ -65,7 +65,8 @@ result = described_class.new(widget_blueprint).call( { name: 'Foo', desc: 'Bar' }, { n: 42 }, - instance_cache + instance_cache, + {} ) expect(result).to eq({ name: 'Foo' }) end @@ -79,7 +80,8 @@ result = described_class.new(widget_blueprint).call( { name: 'Foo', desc: 'Bar' }, { n: 43 }, - instance_cache + instance_cache, + {} ) expect(result).to eq({ name: 'Foo' }) end @@ -90,7 +92,7 @@ field :desc, default: 'Description!' end - result = described_class.new(widget_blueprint).call({ name: 'Foo' }, {}, instance_cache) + result = described_class.new(widget_blueprint).call({ name: 'Foo' }, {}, instance_cache, {}) expect(result).to eq({ name: 'Foo', desc: 'Description!' @@ -103,7 +105,7 @@ field :desc, exclude_if_empty: true end - result = described_class.new(widget_blueprint).call({ name: 'Foo', desc: "" }, {}, instance_cache) + result = described_class.new(widget_blueprint).call({ name: 'Foo', desc: "" }, {}, instance_cache, {}) expect(result).to eq({ name: 'Foo' }) end @@ -113,7 +115,7 @@ field :desc, exclude_if_nil: true end - result = described_class.new(widget_blueprint).call({ name: 'Foo', desc: nil }, {}, instance_cache) + result = described_class.new(widget_blueprint).call({ name: 'Foo', desc: nil }, {}, instance_cache, {}) expect(result).to eq({ name: 'Foo' }) end @@ -125,7 +127,7 @@ end widget = { name: 'Foo', created_on: Date.new(2024, 10, 31) } - result = described_class.new(widget_blueprint).call(widget, {}, instance_cache) + result = described_class.new(widget_blueprint).call(widget, {}, instance_cache, {}) expect(result).to eq({ name: 'Foo', created_on: 'Thu Oct 31, 2024' @@ -139,7 +141,7 @@ end widget = { name: 'Foo', desc: nil } - result = described_class.new(widget_blueprint).call(widget, {}, instance_cache) + result = described_class.new(widget_blueprint).call(widget, {}, instance_cache, {}) expect(result).to eq({ name: 'Foo', desc: 'Bar' }) end @@ -155,7 +157,8 @@ result = described_class.new(widget_blueprint).call( { name: 'Foo', desc: 'Bar', zorp: 'Zorp' }, { n: 42, m: 42 }, - instance_cache + instance_cache, + {} ) expect(result).to eq({}) end @@ -171,7 +174,8 @@ def blueprint_input(_ctx) result = described_class.new(widget_blueprint).call( { category: { name: 'Cat' }, parts: [{ num: 42 }] }, {}, - instance_cache + instance_cache, + {} ) expect(result).to eq({ name: 'Foo', category: nil, parts: nil }) end @@ -187,7 +191,8 @@ def blueprint_output(_ctx) result = described_class.new(widget_blueprint).call( { category: { name: 'Cat' }, parts: [{ num: 42 }] }, {}, - instance_cache + instance_cache, + {} ) expect(result).to eq({ name: 'Foo' }) end @@ -200,7 +205,8 @@ def blueprint_output(_ctx) result = described_class.new(blueprint).call( { description: 'A widget', category: { name: 'Cat' }, parts: [{ num: 42 }], name: 'Foo' }, {}, - instance_cache + instance_cache, + {} ) expect(result.to_json).to eq({ name: 'Foo', From b0c8e1b583ecf9e8ebbdf84b010b957919e30020 Mon Sep 17 00:00:00 2001 From: Jordan Hollinger Date: Thu, 7 Nov 2024 16:55:33 -0500 Subject: [PATCH 11/16] Bugfix to 'fields' DSL method Signed-off-by: Jordan Hollinger --- lib/blueprinter/v2/dsl.rb | 2 +- spec/v2/fields_spec.rb | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/blueprinter/v2/dsl.rb b/lib/blueprinter/v2/dsl.rb index e5c6afdc..dbe4792d 100644 --- a/lib/blueprinter/v2/dsl.rb +++ b/lib/blueprinter/v2/dsl.rb @@ -79,7 +79,7 @@ def field(name, from: name, **options, &definition) def fields(*names) names.each do |name| name = name.to_sym - schema[name] = Field.new(name: name, options: {}) + schema[name] = Field.new(name: name, from: name, options: {}) end end diff --git a/spec/v2/fields_spec.rb b/spec/v2/fields_spec.rb index 842a7760..03858058 100644 --- a/spec/v2/fields_spec.rb +++ b/spec/v2/fields_spec.rb @@ -30,14 +30,17 @@ ref = blueprint.reflections[:default] expect(ref.fields[:name].class.name).to eq "Blueprinter::V2::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[:description].class.name).to eq "Blueprinter::V2::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[:status].class.name).to eq "Blueprinter::V2::Field" expect(ref.fields[:status].name).to eq :status + expect(ref.fields[:status].from).to eq :status expect(ref.fields[:status].options).to eq({}) end end From 6ad83a5389734664c3e9ca22f42713158a18dd3b Mon Sep 17 00:00:00 2001 From: Jordan Hollinger Date: Tue, 5 Nov 2024 16:43:50 -0500 Subject: [PATCH 12/16] More performance with 'prepare' hook Signed-off-by: Jordan Hollinger --- lib/blueprinter/extension.rb | 9 + lib/blueprinter/hooks.rb | 4 + lib/blueprinter/v2/extensions/exclusions.rb | 67 +-- lib/blueprinter/v2/extensions/values.rb | 65 ++- lib/blueprinter/v2/serializer.rb | 4 + spec/benchmarks/speedtest.rb | 140 ++---- spec/support/extension_helpers.rb | 51 ++ spec/v2/extensions/default_values_spec.rb | 441 +++++++++--------- spec/v2/extensions/exclude_if_empty_spec.rb | 97 ++-- spec/v2/extensions/exclude_if_nil_spec.rb | 73 +-- spec/v2/extensions/extraction_spec.rb | 38 +- spec/v2/extensions/if_conditionals_spec.rb | 116 +++-- .../v2/extensions/unless_conditionals_spec.rb | 116 +++-- 13 files changed, 629 insertions(+), 592 deletions(-) create mode 100644 spec/support/extension_helpers.rb diff --git a/lib/blueprinter/extension.rb b/lib/blueprinter/extension.rb index dd595000..a32c7986 100644 --- a/lib/blueprinter/extension.rb +++ b/lib/blueprinter/extension.rb @@ -8,6 +8,7 @@ module Blueprinter # - sort_fields # - collection? (skipped if calling render_object/render_collection) # - input_object | input_collection + # - prepare # - blueprint_input # - field_value # - exclude_field? @@ -42,6 +43,14 @@ def collection?(_object) false end + # + # 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] + # + def prepare(context); end + # # Modify or replace the object passed to render/render_object. # diff --git a/lib/blueprinter/hooks.rb b/lib/blueprinter/hooks.rb index 8779638a..385beca1 100644 --- a/lib/blueprinter/hooks.rb +++ b/lib/blueprinter/hooks.rb @@ -11,6 +11,10 @@ def initialize(extensions) end end + def each(hook, arg) + @hooks.fetch(hook).each { |ext| ext.public_send(hook, arg) } + end + # # Return true if any of "hook" returns truthy. # diff --git a/lib/blueprinter/v2/extensions/exclusions.rb b/lib/blueprinter/v2/extensions/exclusions.rb index 0ff057dc..97c8fbfb 100644 --- a/lib/blueprinter/v2/extensions/exclusions.rb +++ b/lib/blueprinter/v2/extensions/exclusions.rb @@ -6,12 +6,18 @@ module Extensions class Exclusions < Extension # @param ctx [Blueprinter::V2::Context] def exclude_field?(ctx) - return true if exclude_if_nil_or_empty? ctx - if (cond = ctx.options[:field_if] || ctx.field.options[:if] || ctx.blueprint.class.options[:field_if]) + data = ctx.store[ctx.field.object_id] + if ctx.value.nil? && data[:exclude_if_nil] + return true + elsif data[:exclude_if_empty] + return true if ctx.value.nil? || (ctx.value.respond_to?(:empty?) && ctx.value.empty?) + end + + if (cond = data[:if]) result = cond.is_a?(Proc) ? ctx.blueprint.instance_exec(ctx, &cond) : ctx.blueprint.public_send(cond, ctx) return true if !result end - if (cond = ctx.options[:field_unless] || ctx.field.options[:unless] || ctx.blueprint.class.options[:field_unless]) + if (cond = data[:unless]) result = cond.is_a?(Proc) ? ctx.blueprint.instance_exec(ctx, &cond) : ctx.blueprint.public_send(cond, ctx) return true if result end @@ -20,41 +26,42 @@ def exclude_field?(ctx) # @param ctx [Blueprinter::V2::Context] def exclude_object?(ctx) - return true if exclude_if_nil_or_empty? ctx - if (cond = ctx.options[:object_if] || ctx.field.options[:if] || ctx.blueprint.class.options[:object_if]) - result = cond.is_a?(Proc) ? ctx.blueprint.instance_exec(ctx, &cond) : ctx.blueprint.public_send(cond, ctx) - return true if !result - end - if (cond = ctx.options[:object_unless] || ctx.field.options[:unless] || ctx.blueprint.class.options[:object_unless]) - result = cond.is_a?(Proc) ? ctx.blueprint.instance_exec(ctx, &cond) : ctx.blueprint.public_send(cond, ctx) - return true if result - end - false + exclude_field? ctx end # @param ctx [Blueprinter::V2::Context] def exclude_collection?(ctx) - return true if exclude_if_nil_or_empty? ctx - if (cond = ctx.options[:collection_if] || ctx.field.options[:if] || ctx.blueprint.class.options[:collection_if]) - result = cond.is_a?(Proc) ? ctx.blueprint.instance_exec(ctx, &cond) : ctx.blueprint.public_send(cond, ctx) - return true if !result - end - if (cond = ctx.options[:collection_unless] || ctx.field.options[:unless] || ctx.blueprint.class.options[:collection_unless]) - result = cond.is_a?(Proc) ? ctx.blueprint.instance_exec(ctx, &cond) : ctx.blueprint.public_send(cond, ctx) - return true if result - end - false + exclude_field? ctx end - private + # @param ctx [Blueprinter::V2::Context] + def prepare(ctx) + bp_class = ctx.blueprint.class + ref = bp_class.reflections[:default] - def exclude_if_nil_or_empty?(ctx) - if ctx.value.nil? && (ctx.options[:exclude_if_nil] || ctx.field.options[:exclude_if_nil] || ctx.blueprint.class.options[:exclude_if_nil]) - return true - elsif ctx.options[:exclude_if_empty] || ctx.field.options[:exclude_if_empty] || ctx.blueprint.class.options[:exclude_if_empty] - return true if ctx.value.nil? || (ctx.value.respond_to?(:empty?) && ctx.value.empty?) + ref.fields.each_value do |field| + ctx.store[field.object_id] ||= {} + ctx.store[field.object_id][:if] = ctx.options[:field_if] || field.options[:if] || bp_class.options[:field_if] + ctx.store[field.object_id][:unless] = ctx.options[:field_unless] || field.options[:unless] || bp_class.options[:field_unless] + ctx.store[field.object_id][:exclude_if_nil] = ctx.options[:exclude_if_nil] || field.options[:exclude_if_nil] || bp_class.options[:exclude_if_nil] + ctx.store[field.object_id][:exclude_if_empty] = ctx.options[:exclude_if_empty] || field.options[:exclude_if_empty] || bp_class.options[:exclude_if_empty] + end + + ref.objects.each_value do |field| + ctx.store[field.object_id] ||= {} + ctx.store[field.object_id][:if] = ctx.options[:object_if] || field.options[:if] || bp_class.options[:object_if] + ctx.store[field.object_id][:unless] = ctx.options[:object_unless] || field.options[:unless] || bp_class.options[:object_unless] + ctx.store[field.object_id][:exclude_if_nil] = ctx.options[:exclude_if_nil] || field.options[:exclude_if_nil] || bp_class.options[:exclude_if_nil] + ctx.store[field.object_id][:exclude_if_empty] = ctx.options[:exclude_if_empty] || field.options[:exclude_if_empty] || bp_class.options[:exclude_if_empty] + end + + ref.collections.each_value do |field| + ctx.store[field.object_id] ||= {} + ctx.store[field.object_id][:if] = ctx.options[:collection_if] || field.options[:if] || bp_class.options[:collection_if] + ctx.store[field.object_id][:unless] = ctx.options[:collection_unless] || field.options[:unless] || bp_class.options[:collection_unless] + ctx.store[field.object_id][:exclude_if_nil] = ctx.options[:exclude_if_nil] || field.options[:exclude_if_nil] || bp_class.options[:exclude_if_nil] + ctx.store[field.object_id][:exclude_if_empty] = ctx.options[:exclude_if_empty] || field.options[:exclude_if_empty] || bp_class.options[:exclude_if_empty] end - false end end end diff --git a/lib/blueprinter/v2/extensions/values.rb b/lib/blueprinter/v2/extensions/values.rb index f45e5f71..f31e99c1 100644 --- a/lib/blueprinter/v2/extensions/values.rb +++ b/lib/blueprinter/v2/extensions/values.rb @@ -13,38 +13,62 @@ def collection?(object) # @param ctx [Blueprinter::V2::Context] def field_value(ctx) - extractor = get_extractor ctx - value = extractor.field(ctx.blueprint, ctx.field, ctx.object, ctx.options) + data = ctx.store[ctx.field.object_id] + ctx.value = data[:extractor].field(ctx.blueprint, ctx.field, ctx.object, ctx.options) - default_if = ctx.options[:field_default_if] || ctx.field.options[:default_if] || ctx.blueprint.class.options[:field_default_if] - return value unless value.nil? || (default_if && use_default?(default_if, ctx)) + default_if = data[:default_if] + return ctx.value unless ctx.value.nil? || (default_if && use_default?(default_if, ctx)) - default = ctx.options[:field_default] || ctx.field.options[:default] || ctx.blueprint.class.options[:field_default] - get_default(default, ctx) + get_default(data[:default], ctx) end # @param ctx [Blueprinter::V2::Context] def object_value(ctx) - extractor = get_extractor ctx - value = extractor.object(ctx.blueprint, ctx.field, ctx.object, ctx.options) + data = ctx.store[ctx.field.object_id] + ctx.value = data[:extractor].object(ctx.blueprint, ctx.field, ctx.object, ctx.options) - default_if = ctx.options[:object_default_if] || ctx.field.options[:default_if] || ctx.blueprint.class.options[:object_default_if] - return value unless value.nil? || (default_if && use_default?(default_if, ctx)) + default_if = data[:default_if] + return ctx.value unless ctx.value.nil? || (default_if && use_default?(default_if, ctx)) - default = ctx.options[:object_default] || ctx.field.options[:default] || ctx.blueprint.class.options[:object_default] - get_default(default, ctx) + get_default(data[:default], ctx) end # @param ctx [Blueprinter::V2::Context] def collection_value(ctx) - extractor = get_extractor ctx - value = extractor.collection(ctx.blueprint, ctx.field, ctx.object, ctx.options) + data = ctx.store[ctx.field.object_id] + ctx.value = data[:extractor].collection(ctx.blueprint, ctx.field, ctx.object, ctx.options) - default_if = ctx.options[:collection_default_if] || ctx.field.options[:default_if] || ctx.blueprint.class.options[:collection_default_if] - return value unless value.nil? || (default_if && use_default?(default_if, ctx)) + default_if = data[:default_if] + return ctx.value unless ctx.value.nil? || (default_if && use_default?(default_if, ctx)) - default = ctx.options[:collection_default] || ctx.field.options[:default] || ctx.blueprint.class.options[:collection_default] - get_default(default, ctx) + get_default(data[:default], ctx) + end + + # @param ctx [Blueprinter::V2::Context] + def prepare(ctx) + bp_class = ctx.blueprint.class + ref = bp_class.reflections[:default] + + ref.fields.each_value do |field| + ctx.store[field.object_id] ||= {} + ctx.store[field.object_id][:extractor] = ctx.instances[field.options[:extractor] || bp_class.options[:extractor] || Extractor] + ctx.store[field.object_id][:default_if] = ctx.options[:field_default_if] || field.options[:default_if] || bp_class.options[:field_default_if] + ctx.store[field.object_id][:default] = ctx.options[:field_default] || field.options[:default] || bp_class.options[:field_default] + end + + ref.objects.each_value do |field| + ctx.store[field.object_id] ||= {} + ctx.store[field.object_id][:extractor] = ctx.instances[field.options[:extractor] || bp_class.options[:extractor] || Extractor] + ctx.store[field.object_id][:default_if] = ctx.options[:object_default_if] || field.options[:default_if] || bp_class.options[:object_default_if] + ctx.store[field.object_id][:default] = ctx.options[:object_default] || field.options[:default] || bp_class.options[:object_default] + end + + ref.collections.each_value do |field| + ctx.store[field.object_id] ||= {} + ctx.store[field.object_id][:extractor] = ctx.instances[field.options[:extractor] || bp_class.options[:extractor] || Extractor] + ctx.store[field.object_id][:default_if] = ctx.options[:collection_default_if] || field.options[:default_if] || bp_class.options[:collection_default_if] + ctx.store[field.object_id][:default] = ctx.options[:collection_default] || field.options[:default] || bp_class.options[:collection_default] + end end private @@ -63,11 +87,6 @@ def use_default?(cond, ctx) else ctx.blueprint.public_send(cond, ctx) end end - - def get_extractor(ctx) - klass = ctx.field.options[:extractor] || ctx.blueprint.class.options[:extractor] || Extractor - ctx.instances[klass] - end end end end diff --git a/lib/blueprinter/v2/serializer.rb b/lib/blueprinter/v2/serializer.rb index 4154b98b..52fb724d 100644 --- a/lib/blueprinter/v2/serializer.rb +++ b/lib/blueprinter/v2/serializer.rb @@ -27,6 +27,10 @@ def initialize(blueprint) def call(object, options, instances, store) context = Context.new(instances[blueprint], nil, nil, object, options, instances, store) + if !context.store[blueprint.object_id] + hooks.each(:prepare, context) + context.store[blueprint.object_id] = true + end hooks.reduce_into(:blueprint_input, context, :object) result = blueprint.reflections[:default].sorted.each_with_object({}) do |field, acc| diff --git a/spec/benchmarks/speedtest.rb b/spec/benchmarks/speedtest.rb index 6dcd2130..5639a364 100644 --- a/spec/benchmarks/speedtest.rb +++ b/spec/benchmarks/speedtest.rb @@ -3,6 +3,8 @@ require 'benchmark' require 'blueprinter' +NAME_FIELDS = 10 + class CategoryBlueprintV1 < Blueprinter::Base field :name end @@ -12,16 +14,7 @@ class PartBlueprintV1 < Blueprinter::Base end class WidgetBlueprintV1 < Blueprinter::Base - field :name1 - field :name2 - field :name3 - field :name4 - field :name5 - field :name6 - field :name7 - field :name8 - field :name9 - field :name10 + NAME_FIELDS.times { |i| field :"name#{i}" } association :category, blueprint: CategoryBlueprintV1 association :parts, blueprint: PartBlueprintV1 end @@ -35,119 +28,58 @@ class PartBlueprintV2 < Blueprinter::V2::Base end class WidgetBlueprintV2 < Blueprinter::V2::Base - field :name1 - field :name2 - field :name3 - field :name4 - field :name5 - field :name6 - field :name7 - field :name8 - field :name9 - field :name10 + NAME_FIELDS.times { |i| field :"name#{i}" } object :category, CategoryBlueprintV2 collection :parts, PartBlueprintV2 end results = Benchmark.bmbm do |x| widgets = 100_000.times.map do |n| - { - name1: "Widget #{n}", - name2: "Widget #{n}", - name3: "Widget #{n}", - name4: "Widget #{n}", - name5: "Widget #{n}", - name6: "Widget #{n}", - name7: "Widget #{n}", - name8: "Widget #{n}", - name9: "Widget #{n}", - name10: "Widget #{n}", - category: { name: "Category #{n % 50}" }, - parts: (1..rand(1..10)).map { |n| { num: n } } - } - end - - x.report 'Massive: V1' do - WidgetBlueprintV1.render(widgets) - end - - x.report 'Massive: V2' do - WidgetBlueprintV2.render(widgets).to_hash - end - - x.report 'Large: V1' do - list = widgets[0,10_000] - 10.times { WidgetBlueprintV1.render(list) } - end - - x.report 'Large: V2' do - list = widgets[0,10_000] - 10.times { WidgetBlueprintV2.render(list).to_hash } - end - - x.report 'Medium: V1' do - list = widgets[0,1000] - 100.times { WidgetBlueprintV1.render(list) } - end - - x.report 'Medium: V2' do - list = widgets[0,1000] - 100.times { WidgetBlueprintV2.render(list).to_hash } - end - - x.report 'Small: V1' do - list = widgets[0,100] - 100.times { WidgetBlueprintV1.render(list) } - end - - x.report 'Small: V2' do - list = widgets[0,100] - 100.times { WidgetBlueprintV2.render(list).to_hash } - end - - x.report 'Tiny: V1' do - list = widgets[0,25] - 100.times { WidgetBlueprintV1.render(list) } - end - - x.report 'Tiny: V2' do - list = widgets[0,25] - 100.times { WidgetBlueprintV2.render(list).to_hash } - end - - x.report 'Micro: V1' do - list = widgets[0,5] - 100.times { WidgetBlueprintV1.render(list) } - end - - x.report 'Micro: V2' do - list = widgets[0,5] - 100.times { WidgetBlueprintV2.render(list).to_hash } - end - - x.report 'Nano: V1' do - list = widgets[0,1] - 100.times { WidgetBlueprintV1.render(list) } - end + NAME_FIELDS.times. + each_with_object({}) { |i, obj| obj[:"name#{i}"] = "Widget #{n}" }. + merge({ + category: { name: "Category #{n % 50}" }, + parts: (1..rand(1..10)).map { |n| { num: n } } + }) + end + + [ + [100_000, 1], + [10_000, 10], + [1000, 100], + [500, 100], + [250, 100], + [100, 100], + [25, 100], + [5, 100], + [1, 100], + ].each do |(n, m)| + fmt_n = n.to_s.chars.reverse.each_slice(3).map(&:join).join(',').reverse + list = widgets[0,n] + x.report "#{fmt_n} widgets: V1" do + m.times { WidgetBlueprintV1.render(list) } + end - x.report 'Nano: V2' do - list = widgets[0,1] - 100.times { WidgetBlueprintV2.render(list).to_hash } + x.report "#{fmt_n} widgets: V2" do + m.times { WidgetBlueprintV2.render(list).to_hash } + end end end puts "" results. - group_by { |res| res.label[/.+:/].ljust 8 }. + group_by { |res| res.label[/.+:/].ljust 16 }. each do |label, (a, b)| v1 = (a.label =~ /V1/ ? a : b).real v2 = (a.label =~ /V2/ ? a : b).real if v2 < v1 n = (100 - (v2 / v1) * 100).round(2) - puts "#{label} V2 #{'%2.2f' % n}% faster (#{'%.4f' % (v1 - v2)} sec)" + pcnt = ('%0.2f' % n).rjust(5, '0') + puts "#{label} V2 #{pcnt}% faster (#{'%.4f' % (v1 - v2)} sec)" else n = (100 - (v1 / v2) * 100).round(2) - puts "#{label} V2 #{'%2.2f' % n}% slower (#{'%.4f' % (v2 - v1)} sec)" + pcnt = ('%0.2f' % n).rjust(5, '0') + puts "#{label} V2 #{pcnt}% slower (#{'%.4f' % (v2 - v1)} sec)" end end diff --git a/spec/support/extension_helpers.rb b/spec/support/extension_helpers.rb new file mode 100644 index 00000000..eae66b2c --- /dev/null +++ b/spec/support/extension_helpers.rb @@ -0,0 +1,51 @@ +module ExtensionHelpers + def self.included(klass) + klass.class_eval do + subject { described_class.new } + + let(:sub_blueprint) do + Class.new(Blueprinter::V2::Base) do + field :name + end + end + + let(:blueprint) do + test = self + Class.new(Blueprinter::V2::Base) do + fields :foo, :bar + object :foo_obj, test.sub_blueprint + collection :foos, test.sub_blueprint + + def was(ctx) + "was #{ctx.value.inspect}" + end + + def is?(ctx, val) + ctx.value == val + end + + def foo?(ctx) + is? ctx, 'Foo' + end + + def name_foo?(ctx) + ctx.value[:name] == 'Foo' + end + + def names_foo?(ctx) + ctx.value.all? { |v| v[:name] == 'Foo' } + end + end + end + end + end + + def prepare(blueprint, field, value, object, options) + instances = Blueprinter::V2::InstanceCache.new + ctx = Blueprinter::V2::Context.new(blueprint.new, nil, nil, object, options, instances, {}) + subject.prepare ctx + ctx.field = field + ctx.value = value + ctx + end +end diff --git a/spec/v2/extensions/default_values_spec.rb b/spec/v2/extensions/default_values_spec.rb index 70e34c0a..64d4d1cb 100644 --- a/spec/v2/extensions/default_values_spec.rb +++ b/spec/v2/extensions/default_values_spec.rb @@ -1,566 +1,589 @@ # frozen_string_literal: true describe Blueprinter::V2::Extensions::Values do - subject { described_class.new } - let(:context) { Blueprinter::V2::Context } - let(:instance_cache) { Blueprinter::V2::InstanceCache.new } - let(:blueprint) do - Class.new(Blueprinter::V2::Base) do - def was(ctx) - "was #{ctx.value.inspect}" - end - - def is?(ctx, val) - ctx.value == val - end - - def foo?(ctx) - is? ctx, 'Foo' - end - end - end - let(:object) { { name: 'Foo' } } + include ExtensionHelpers context 'fields' do - let(:field) { Blueprinter::V2::Field.new(name: :name, from: :name, options: {}) } + let(:field) { blueprint.reflections[:default].fields[:foo] } + let(:object) { { foo: 'Foo' } } it 'should pass values through by default' do - ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.field_value ctx).to eq 'Foo' end it 'should pass values through by with defaults given' do blueprint.options[:field_default] = 'Bar' - field.options[:default] = 'Bar' - ctx = context.new(blueprint.new, field, nil, object, { field_default: 'Bar' }, instance_cache) + blueprint.field :foo, default: 'Bar' + ctx = prepare(blueprint, field, 'Foo', object, { field_default: 'Bar' }) expect(subject.field_value ctx).to eq 'Foo' end it 'should pass values through with false default_ifs given' do blueprint.options[:field_default] = 'Bar' blueprint.options[:field_default_if] = ->(_) { false } - field.options[:default] = 'Bar' - field.options[:default_if] = ->(_) { false } - ctx = context.new(blueprint.new, field, nil, object, { field_default: 'Bar', field_default_if: ->(_) { false } }, instance_cache) + blueprint.field :foo, default: 'Bar', default_if: ->(_) { false } + ctx = prepare(blueprint, field, nil, object, { field_default: 'Bar', field_default_if: ->(_) { false } }) expect(subject.field_value ctx).to eq 'Foo' end it 'should pass nil through by default' do - object[:name] = nil - ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + object[:foo] = nil + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.field_value ctx).to be_nil end it 'should use options field_default' do - object[:name] = nil - ctx = context.new(blueprint.new, field, nil, object, { field_default: 'Bar' }, instance_cache) + object[:foo] = nil + ctx = prepare(blueprint, field, nil, object, { field_default: 'Bar' }) expect(subject.field_value ctx).to eq 'Bar' end it 'should use options field_default (Proc)' do - object[:name] = nil - ctx = context.new(blueprint.new, field, nil, object, { field_default: ->(ctx) { "Bar (#{was ctx})"} }, instance_cache) + object[:foo] = nil + ctx = prepare(blueprint, field, nil, object, { field_default: ->(ctx) { "Bar (#{was ctx})"} }) expect(subject.field_value ctx).to eq 'Bar (was nil)' end it 'should use options field_default (Symbol)' do - object[:name] = nil - ctx = context.new(blueprint.new, field, nil, object, { field_default: :was }, instance_cache) + object[:foo] = nil + ctx = prepare(blueprint, field, nil, object, { field_default: :was }) expect(subject.field_value ctx).to eq 'was nil' end it 'should use field options default' do - object[:name] = nil - field.options[:default] = 'Bar' - ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + object[:foo] = nil + blueprint.field :foo, default: 'Bar' + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.field_value ctx).to eq 'Bar' end it 'should use field options default (Proc)' do - object[:name] = nil - field.options[:default] = ->(ctx) { "Bar (was #{ctx.value.inspect})"} - ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + object[:foo] = nil + blueprint.field :foo, default: ->(ctx) { "Bar (was #{ctx.value.inspect})"} + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.field_value ctx).to eq 'Bar (was nil)' end it 'should use field options default (Symbol)' do - object[:name] = nil - field.options[:default] = :was - ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + object[:foo] = nil + blueprint.field :foo, default: :was + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.field_value ctx).to eq 'was nil' - end + end it 'should use blueprint options field_default' do - object[:name] = nil + object[:foo] = nil blueprint.options[:field_default] = 'Bar' - ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.field_value ctx).to eq 'Bar' end it 'should use blueprint options field_default (Proc)' do - object[:name] = nil + object[:foo] = nil blueprint.options[:field_default] = ->(ctx) { "Bar (#{was ctx})" } - ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.field_value ctx).to eq 'Bar (was nil)' end it 'should use blueprint options field_default (Symbol)' do - object[:name] = nil + object[:foo] = nil blueprint.options[:field_default] = :was - ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.field_value ctx).to eq 'was nil' end it 'should check with options field_default_if (default = options field_default)' do - ctx = context.new(blueprint.new, field, 'Foo', object, { field_default: 'Bar', field_default_if: ->(ctx) { is? ctx, 'Foo' } }, instance_cache) + ctx = prepare(blueprint, field, nil, object, { field_default: 'Bar', field_default_if: ->(ctx) { is? ctx, 'Foo' } }) expect(subject.field_value ctx).to eq 'Bar' - ctx = context.new(blueprint.new, field, 'Foo', object, { field_default: 'Bar', field_default_if: :foo? }, instance_cache) + ctx = prepare(blueprint, field, nil, object, { field_default: 'Bar', field_default_if: :foo? }) expect(subject.field_value ctx).to eq 'Bar' end it 'should check with options field_default_if (default = field options default)' do - field.options[:default] = 'Bar' - ctx = context.new(blueprint.new, field, 'Foo', object, { field_default_if: ->(ctx) { is? ctx, 'Foo' } }, instance_cache) + blueprint.field :foo, default: 'Bar' + ctx = prepare(blueprint, field, 'Foo', object, { field_default_if: ->(ctx) { is? ctx, 'Foo' } }) expect(subject.field_value ctx).to eq 'Bar' - ctx = context.new(blueprint.new, field, 'Foo', object, { field_default_if: :foo? }, instance_cache) + ctx = prepare(blueprint, field, 'Foo', object, { field_default_if: :foo? }) expect(subject.field_value ctx).to eq 'Bar' end it 'should check with options field_default_if (default = blueprint options field_default)' do blueprint.options[:field_default] = 'Bar' - ctx = context.new(blueprint.new, field, 'Foo', object, { field_default_if: ->(ctx) { is? ctx, 'Foo' } }, instance_cache) + ctx = prepare(blueprint, field, 'Foo', object, { field_default_if: ->(ctx) { is? ctx, 'Foo' } }) expect(subject.field_value ctx).to eq 'Bar' - ctx = context.new(blueprint.new, field, 'Foo', object, { field_default_if: :foo? }, instance_cache) + ctx = prepare(blueprint, field, 'Foo', object, { field_default_if: :foo? }) expect(subject.field_value ctx).to eq 'Bar' end - it 'should check with field options default_if (default = options field_default)' do - field.options[:default_if] = ->(ctx) { is? ctx, 'Foo' } - ctx = context.new(blueprint.new, field, 'Foo', object, { field_default: 'Bar' }, instance_cache) + it 'should check with field options default_if (Proc) (default = options field_default)' do + blueprint.field :foo, default_if: ->(ctx) { is? ctx, 'Foo' } + ctx = prepare(blueprint, field, 'Foo', object, { field_default: 'Bar' }) expect(subject.field_value ctx).to eq 'Bar' + end - field.options[:default_if] = :foo? - ctx = context.new(blueprint.new, field, 'Foo', object, { field_default: 'Bar' }, instance_cache) + it 'should check with field options default_if (Symbol) (default = options field_default)' do + blueprint.field :foo, default_if: :foo? + ctx = prepare(blueprint, field, 'Foo', object, { field_default: 'Bar' }) expect(subject.field_value ctx).to eq 'Bar' end - it 'should check with field options default_if (default = field options default)' do - field.options[:default_if] = ->(ctx) { is? ctx, 'Foo' } - field.options[:default] = 'Bar' - ctx = context.new(blueprint.new, field, 'Foo', object, {}, instance_cache) + it 'should check with field options default_if (Proc) (default = field options default)' do + blueprint.field :foo, default: 'Bar', default_if: ->(ctx) { is? ctx, 'Foo' } + ctx = prepare(blueprint, field, 'Foo', object, {}) expect(subject.field_value ctx).to eq 'Bar' + end - field.options[:default_if] = :foo? - ctx = context.new(blueprint.new, field, 'Foo', object, {}, instance_cache) + it 'should check with field options default_if (Symbol) (default = field options default)' do + blueprint.field :foo, default: 'Bar', default_if: :foo? + ctx = prepare(blueprint, field, 'Foo', object, {}) expect(subject.field_value ctx).to eq 'Bar' end - it 'should check with field options default_if (default = blueprint options field_default)' do - field.options[:default_if] = ->(ctx) { is? ctx, 'Foo' } + it 'should check with field options default_if (Proc) (default = blueprint options field_default)' do + blueprint.field :foo, default: 'Bar', default_if: ->(ctx) { is? ctx, 'Foo' } blueprint.options[:field_default] = 'Bar' - ctx = context.new(blueprint.new, field, 'Foo', object, {}, instance_cache) + ctx = prepare(blueprint, field, 'Foo', object, {}) expect(subject.field_value ctx).to eq 'Bar' + end - field.options[:default_if] = :foo? - ctx = context.new(blueprint.new, field, 'Foo', object, {}, instance_cache) + it 'should check 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, field, 'Foo', object, {}) expect(subject.field_value ctx).to eq 'Bar' end - it 'should check with blueprint options field_default_if (default = options field_default)' do + it 'should check with blueprint options field_default_if (Proc) (default = options field_default)' do blueprint.options[:field_default_if] = ->(ctx) { is? ctx, 'Foo' } - ctx = context.new(blueprint.new, field, 'Foo', object, { field_default: 'Bar' }, instance_cache) + ctx = prepare(blueprint, field, 'Foo', object, { field_default: 'Bar' }) expect(subject.field_value ctx).to eq 'Bar' + end + it 'should check with blueprint options field_default_if (Symbol) (default = options field_default)' do blueprint.options[:field_default_if] = :foo? - ctx = context.new(blueprint.new, field, 'Foo', object, { field_default: 'Bar' }, instance_cache) + ctx = prepare(blueprint, field, 'Foo', object, { field_default: 'Bar' }) expect(subject.field_value ctx).to eq 'Bar' end - it 'should check with blueprint options field_default_if (default = field options default)' do + it 'should check with blueprint options field_default_if (Proc) (default = field options default)' do blueprint.options[:field_default_if] = ->(ctx) { is? ctx, 'Foo' } - field.options[:default] = 'Bar' - ctx = context.new(blueprint.new, field, 'Foo', object, {}, instance_cache) + blueprint.field :foo, default: 'Bar' + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.field_value ctx).to eq 'Bar' + end + it 'should check with blueprint options field_default_if (Symbol) (default = field options default)' do blueprint.options[:field_default_if] = :foo? - ctx = context.new(blueprint.new, field, 'Foo', object, {}, instance_cache) + blueprint.field :foo, default: 'Bar' + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.field_value ctx).to eq 'Bar' end - it 'should check with blueprint options field_default_if (default = blueprint options field_default)' do + it 'should check with blueprint options field_default_if (Proc) (default = blueprint options field_default)' do blueprint.options[:field_default_if] = ->(ctx) { is? ctx, 'Foo' } blueprint.options[:field_default] = 'Bar' - ctx = context.new(blueprint.new, field, 'Foo', object, {}, instance_cache) + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.field_value ctx).to eq 'Bar' + end + it 'should check with blueprint options field_default_if (Symbol) (default = blueprint options field_default)' do blueprint.options[:field_default_if] = :foo? - ctx = context.new(blueprint.new, field, 'Foo', object, {}, instance_cache) + blueprint.options[:field_default] = 'Bar' + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.field_value ctx).to eq 'Bar' end end context 'objects' do - let(:field) { Blueprinter::V2::ObjectField.new(name: :name, from: :name, options: {}) } + let(:field) { blueprint.reflections[:default].objects[:foo_obj] } + let(:object) { { foo_obj: 'Foo' } } it 'should pass values through by default' do - ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.object_value ctx).to eq 'Foo' end it 'should pass values through by with defaults given' do blueprint.options[:object_default] = 'Bar' - field.options[:default] = 'Bar' - ctx = context.new(blueprint.new, field, nil, object, { object_default: 'Bar' }, instance_cache) + blueprint.object :foo_obj, sub_blueprint, default: 'Bar' + ctx = prepare(blueprint, field, nil, object, { object_default: 'Bar' }) expect(subject.object_value ctx).to eq 'Foo' end it 'should pass values through with false default_ifs given' do blueprint.options[:object_default] = 'Bar' blueprint.options[:object_default_if] = ->(_) { false } - field.options[:default] = 'Bar' - field.options[:default_if] = ->(_) { false } - ctx = context.new(blueprint.new, field, nil, object, { object_default: 'Bar', object_default_if: ->(_) { false } }, instance_cache) + blueprint.object :foo_obj, sub_blueprint, default: 'Bar', default_if: ->(_) { false } + ctx = prepare(blueprint, field, nil, object, { object_default: 'Bar', object_default_if: ->(_) { false } }) expect(subject.object_value ctx).to eq 'Foo' end it 'should pass nil through by default' do - object[:name] = nil - ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + object[:foo_obj] = nil + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.object_value ctx).to be_nil end it 'should use options object_default' do - object[:name] = nil - ctx = context.new(blueprint.new, field, nil, object, { object_default: 'Bar' }, instance_cache) + object[:foo_obj] = nil + ctx = prepare(blueprint, field, nil, object, { object_default: 'Bar' }) expect(subject.object_value ctx).to eq 'Bar' end it 'should use options object_default (Proc)' do - object[:name] = nil - ctx = context.new(blueprint.new, field, nil, object, { object_default: ->(ctx) { "Bar (#{was ctx})" } }, instance_cache) + object[:foo_obj] = nil + ctx = prepare(blueprint, field, nil, object, { object_default: ->(ctx) { "Bar (#{was ctx})" } }) expect(subject.object_value ctx).to eq 'Bar (was nil)' end it 'should use options object_default (Symbol)' do - object[:name] = nil - ctx = context.new(blueprint.new, field, nil, object, { object_default: :was }, instance_cache) + object[:foo_obj] = nil + ctx = prepare(blueprint, field, nil, object, { object_default: :was }) expect(subject.object_value ctx).to eq 'was nil' end it 'should use field options default' do - object[:name] = nil - field.options[:default] = 'Bar' - ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + object[:foo_obj] = nil + blueprint.object :foo_obj, sub_blueprint, default: 'Bar' + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.object_value ctx).to eq 'Bar' end it 'should use field options default (Proc)' do - object[:name] = nil - field.options[:default] = ->(ctx) { "Bar (was #{ctx.value.inspect})"} - ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + object[:foo_obj] = nil + blueprint.object :foo_obj, sub_blueprint, default: ->(ctx) { "Bar (was #{ctx.value.inspect})" } + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.object_value ctx).to eq 'Bar (was nil)' end it 'should use field options default (Symbol)' do - object[:name] = nil - field.options[:default] = :was - ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + object[:foo_obj] = nil + blueprint.object :foo_obj, sub_blueprint, default: :was + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.object_value ctx).to eq 'was nil' end it 'should use blueprint options object_default' do - object[:name] = nil + object[:foo_obj] = nil blueprint.options[:object_default] = 'Bar' - ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.object_value ctx).to eq 'Bar' end it 'should use blueprint options object_default (Proc)' do - object[:name] = nil + object[:foo_obj] = nil blueprint.options[:object_default] = ->(ctx) { "Bar (#{was ctx})" } - ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.object_value ctx).to eq 'Bar (was nil)' end it 'should use blueprint options object_default (Symbol)' do - object[:name] = nil + object[:foo_obj] = nil blueprint.options[:object_default] = :was - ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.object_value ctx).to eq 'was nil' end it 'should check with options object_default_if (default = options object_default)' do - ctx = context.new(blueprint.new, field, 'Foo', object, { object_default: 'Bar', object_default_if: ->(ctx) { is? ctx, 'Foo' } }, instance_cache) + ctx = prepare(blueprint, field, nil, object, { object_default: 'Bar', object_default_if: ->(ctx) { is? ctx, 'Foo' } }) expect(subject.object_value ctx).to eq 'Bar' - ctx = context.new(blueprint.new, field, 'Foo', object, { object_default: 'Bar', object_default_if: :foo? }, instance_cache) + ctx = prepare(blueprint, field, nil, object, { object_default: 'Bar', object_default_if: :foo? }) expect(subject.object_value ctx).to eq 'Bar' end it 'should check with options object_default_if (default = field options default)' do - field.options[:default] = 'Bar' - ctx = context.new(blueprint.new, field, 'Foo', object, { object_default_if: ->(ctx) { is? ctx, 'Foo' } }, instance_cache) + blueprint.object :foo_obj, sub_blueprint, default: 'Bar' + ctx = prepare(blueprint, field, nil, object, { object_default_if: ->(ctx) { is? ctx, 'Foo' } }) expect(subject.object_value ctx).to eq 'Bar' - ctx = context.new(blueprint.new, field, 'Foo', object, { object_default_if: :foo? }, instance_cache) + ctx = prepare(blueprint, field, nil, object, { object_default_if: :foo? }) expect(subject.object_value ctx).to eq 'Bar' end it 'should check with options object_default_if (default = blueprint options object_default)' do blueprint.options[:object_default] = 'Bar' - ctx = context.new(blueprint.new, field, 'Foo', object, { object_default_if: ->(ctx) { is? ctx, 'Foo' } }, instance_cache) + ctx = prepare(blueprint, field, nil, object, { object_default_if: ->(ctx) { is? ctx, 'Foo' } }) expect(subject.object_value ctx).to eq 'Bar' - ctx = context.new(blueprint.new, field, 'Foo', object, { object_default_if: :foo? }, instance_cache) + ctx = prepare(blueprint, field, nil, object, { object_default_if: :foo? }) expect(subject.object_value ctx).to eq 'Bar' end - it 'should check with field options default_if (default = options object_default)' do - field.options[:default_if] = ->(ctx) { is? ctx, 'Foo' } - ctx = context.new(blueprint.new, field, 'Foo', object, { object_default: 'Bar' }, instance_cache) + it 'should check with field options default_if (Proc) (default = options object_default)' do + blueprint.object :foo_obj, sub_blueprint, default_if: ->(ctx) { is? ctx, 'Foo' } + ctx = prepare(blueprint, field, nil, object, { object_default: 'Bar' }) expect(subject.object_value ctx).to eq 'Bar' + end - field.options[:default_if] = :foo? - ctx = context.new(blueprint.new, field, 'Foo', object, { object_default: 'Bar' }, instance_cache) + it 'should check with field options default_if (Symbol) (default = options object_default)' do + blueprint.object :foo_obj, sub_blueprint, default_if: :foo? + ctx = prepare(blueprint, field, nil, object, { object_default: 'Bar' }) expect(subject.object_value ctx).to eq 'Bar' end - it 'should check with field options default_if (default = field options default)' do - field.options[:default_if] = ->(ctx) { is? ctx, 'Foo' } - field.options[:default] = 'Bar' - ctx = context.new(blueprint.new, field, 'Foo', object, {}, instance_cache) + it 'should check with field options default_if (Proc) (default = field options default)' do + blueprint.object :foo_obj, sub_blueprint, default: 'Bar', default_if: ->(ctx) { is? ctx, 'Foo' } + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.object_value ctx).to eq 'Bar' + end - field.options[:default_if] = :foo? - ctx = context.new(blueprint.new, field, 'Foo', object, {}, instance_cache) + it 'should check with field options default_if (Symbol) (default = field options default)' do + blueprint.object :foo_obj, sub_blueprint, default: 'Bar', default_if: :foo? + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.object_value ctx).to eq 'Bar' end - it 'should check with field options default_if (default = blueprint options object_default)' do - field.options[:default_if] = ->(ctx) { is? ctx, 'Foo' } + it 'should check with field options default_if (Proc) (default = blueprint options object_default)' do + blueprint.object :foo_obj, sub_blueprint, default_if: ->(ctx) { is? ctx, 'Foo' } blueprint.options[:object_default] = 'Bar' - ctx = context.new(blueprint.new, field, 'Foo', object, {}, instance_cache) + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.object_value ctx).to eq 'Bar' + end - field.options[:default_if] = :foo? + it 'should check with field options default_if (Symbol) (default = blueprint options object_default)' do + blueprint.object :foo_obj, sub_blueprint, default_if: :foo? blueprint.options[:object_default] = 'Bar' - ctx = context.new(blueprint.new, field, 'Foo', object, {}, instance_cache) + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.object_value ctx).to eq 'Bar' end - it 'should check with blueprint options object_default_if (default = options object_default)' do + it 'should check with blueprint options object_default_if (Proc) (default = options object_default)' do blueprint.options[:object_default_if] = ->(ctx) { is? ctx, 'Foo' } - ctx = context.new(blueprint.new, field, 'Foo', object, { object_default: 'Bar' }, instance_cache) + ctx = prepare(blueprint, field, nil, object, { object_default: 'Bar' }) expect(subject.object_value ctx).to eq 'Bar' + end + it 'should check with blueprint options object_default_if (Symbol) (default = options object_default)' do blueprint.options[:object_default_if] = :foo? - ctx = context.new(blueprint.new, field, 'Foo', object, { object_default: 'Bar' }, instance_cache) + ctx = prepare(blueprint, field, nil, object, { object_default: 'Bar' }) expect(subject.object_value ctx).to eq 'Bar' end - it 'should check with blueprint options object_default_if (default = field options default)' do + it 'should check with blueprint options object_default_if (Proc) (default = field options default)' do blueprint.options[:object_default_if] = ->(ctx) { is? ctx, 'Foo' } - field.options[:default] = 'Bar' - ctx = context.new(blueprint.new, field, 'Foo', object, {}, instance_cache) + blueprint.object :foo_obj, sub_blueprint, default: 'Bar' + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.object_value ctx).to eq 'Bar' + end + it 'should check with blueprint options object_default_if (Symbol) (default = field options default)' do blueprint.options[:object_default_if] = :foo? - ctx = context.new(blueprint.new, field, 'Foo', object, {}, instance_cache) + blueprint.object :foo_obj, sub_blueprint, default: 'Bar' + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.object_value ctx).to eq 'Bar' end - it 'should check with blueprint options object_default_if (default = blueprint options object_default)' do + it 'should check with blueprint options object_default_if (Proc) (default = blueprint options object_default)' do blueprint.options[:object_default_if] = ->(ctx) { is? ctx, 'Foo' } blueprint.options[:object_default] = 'Bar' - ctx = context.new(blueprint.new, field, 'Foo', object, {}, instance_cache) + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.object_value ctx).to eq 'Bar' + end + it 'should check with blueprint options object_default_if (Symbol) (default = blueprint options object_default)' do blueprint.options[:object_default_if] = :foo? - ctx = context.new(blueprint.new, field, 'Foo', object, {}, instance_cache) + blueprint.options[:object_default] = 'Bar' + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.object_value ctx).to eq 'Bar' end end context 'collections' do - let(:field) { Blueprinter::V2::Collection.new(name: :name, from: :name, options: {}) } + let(:field) { blueprint.reflections[:default].collections[:foos] } + let(:object) { { foos: 'Foo' } } it 'should pass values through by default' do - ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.collection_value ctx).to eq 'Foo' end it 'should pass values through by with defaults given' do blueprint.options[:collection_default] = 'Bar' - field.options[:default] = 'Bar' - ctx = context.new(blueprint.new, field, nil, object, { collection_default: 'Bar' }, instance_cache) + blueprint.collection :foos, sub_blueprint, default: 'Bar' + ctx = prepare(blueprint, field, nil, object, { collection_default: 'Bar' }) expect(subject.collection_value ctx).to eq 'Foo' end it 'should pass values through with false default_ifs given' do blueprint.options[:collection_default] = 'Bar' blueprint.options[:collection_default_if] = ->(_) { false } - field.options[:default] = 'Bar' - field.options[:default_if] = ->(_) { false } - ctx = context.new(blueprint.new, field, nil, object, { collection_default: 'Bar', collection_default_if: ->(_) { false } }, instance_cache) + blueprint.collection :foos, sub_blueprint, default: 'Bar', default_if: ->(_) { false } + ctx = prepare(blueprint, field, nil, object, { collection_default: 'Bar', collection_default_if: ->(_) { false } }) expect(subject.collection_value ctx).to eq 'Foo' end it 'should pass nil through by default' do - object[:name] = nil - ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + object[:foos] = nil + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.collection_value ctx).to be_nil end it 'should use options collection_default' do - object[:name] = nil - ctx = context.new(blueprint.new, field, nil, object, { collection_default: 'Bar' }, instance_cache) + object[:foos] = nil + ctx = prepare(blueprint, field, nil, object, { collection_default: 'Bar' }) expect(subject.collection_value ctx).to eq 'Bar' end it 'should use options collection_default (Proc)' do - object[:name] = nil - ctx = context.new(blueprint.new, field, nil, object, { collection_default: ->(ctx) { "Bar (#{was ctx})" } }, instance_cache) + object[:foos] = nil + ctx = prepare(blueprint, field, nil, object, { collection_default: ->(ctx) { "Bar (#{was ctx})" } }) expect(subject.collection_value ctx).to eq 'Bar (was nil)' end it 'should use options collection_default (Symbol)' do - object[:name] = nil - ctx = context.new(blueprint.new, field, nil, object, { collection_default: :was }, instance_cache) + object[:foos] = nil + ctx = prepare(blueprint, field, nil, object, { collection_default: :was }) expect(subject.collection_value ctx).to eq 'was nil' end it 'should use field options default' do - object[:name] = nil - field.options[:default] = 'Bar' - ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + object[:foos] = nil + blueprint.collection :foos, sub_blueprint, default: 'Bar' + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.collection_value ctx).to eq 'Bar' end it 'should use field options default (Proc)' do - object[:name] = nil - field.options[:default] = ->(ctx) { "Bar (was #{ctx.value.inspect})"} - ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + object[:foos] = nil + blueprint.collection :foos, sub_blueprint, default: ->(ctx) { "Bar (was #{ctx.value.inspect})"} + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.collection_value ctx).to eq 'Bar (was nil)' end it 'should use field options default (Symbol)' do - object[:name] = nil - field.options[:default] = :was - ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + object[:foos] = nil + blueprint.collection :foos, sub_blueprint, default: :was + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.collection_value ctx).to eq 'was nil' end it 'should use blueprint options collection_default' do - object[:name] = nil + object[:foos] = nil blueprint.options[:collection_default] = 'Bar' - ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.collection_value ctx).to eq 'Bar' end it 'should use blueprint options collection_default (Proc)' do - object[:name] = nil + object[:foos] = nil blueprint.options[:collection_default] = ->(ctx) { "Bar (#{was ctx})" } - ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.collection_value ctx).to eq 'Bar (was nil)' end it 'should use blueprint options collection_default (Symbol)' do - object[:name] = nil + object[:foos] = nil blueprint.options[:collection_default] = :was - ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.collection_value ctx).to eq 'was nil' end it 'should check with options collection_default_if (default = options collection_default)' do - ctx = context.new(blueprint.new, field, 'Foo', object, { collection_default: 'Bar', collection_default_if: ->(ctx) { is? ctx, 'Foo' } }, instance_cache) + ctx = prepare(blueprint, field, nil, object, { collection_default: 'Bar', collection_default_if: ->(ctx) { is? ctx, 'Foo' } }) expect(subject.collection_value ctx).to eq 'Bar' - ctx = context.new(blueprint.new, field, 'Foo', object, { collection_default: 'Bar', collection_default_if: :foo? }, instance_cache) + ctx = prepare(blueprint, field, nil, object, { collection_default: 'Bar', collection_default_if: :foo? }) expect(subject.collection_value ctx).to eq 'Bar' end it 'should check with options collection_default_if (default = field options default)' do - field.options[:default] = 'Bar' - ctx = context.new(blueprint.new, field, 'Foo', object, { collection_default_if: ->(ctx) { is? ctx, 'Foo' } }, instance_cache) + blueprint.collection :foos, sub_blueprint, default: 'Bar' + ctx = prepare(blueprint, field, nil, object, { collection_default_if: ->(ctx) { is? ctx, 'Foo' } }) expect(subject.collection_value ctx).to eq 'Bar' - ctx = context.new(blueprint.new, field, 'Foo', object, { collection_default_if: :foo? }, instance_cache) + ctx = prepare(blueprint, field, nil, object, { collection_default_if: :foo? }) expect(subject.collection_value ctx).to eq 'Bar' end it 'should check with options collection_default_if (default = blueprint options collection_default)' do blueprint.options[:collection_default] = 'Bar' - ctx = context.new(blueprint.new, field, 'Foo', object, { collection_default_if: ->(ctx) { is? ctx, 'Foo' } }, instance_cache) + ctx = prepare(blueprint, field, nil, object, { collection_default_if: ->(ctx) { is? ctx, 'Foo' } }) expect(subject.collection_value ctx).to eq 'Bar' - ctx = context.new(blueprint.new, field, 'Foo', object, { collection_default_if: :foo? }, instance_cache) + ctx = prepare(blueprint, field, nil, object, { collection_default_if: :foo? }) expect(subject.collection_value ctx).to eq 'Bar' end - it 'should check with field options default_if (default = options collection_default)' do - field.options[:default_if] = ->(ctx) { is? ctx, 'Foo' } - ctx = context.new(blueprint.new, field, 'Foo', object, { collection_default: 'Bar' }, instance_cache) + it 'should check with field options default_if (Proc) (default = options collection_default)' do + blueprint.collection :foos, sub_blueprint, default_if: ->(ctx) { is? ctx, 'Foo' } + ctx = prepare(blueprint, field, nil, object, { collection_default: 'Bar' }) expect(subject.collection_value ctx).to eq 'Bar' + end - field.options[:default_if] = :foo? - ctx = context.new(blueprint.new, field, 'Foo', object, { collection_default: 'Bar' }, instance_cache) + it 'should check with field options default_if (Symbol) (default = options collection_default)' do + blueprint.collection :foos, sub_blueprint, default_if: :foo? + ctx = prepare(blueprint, field, nil, object, { collection_default: 'Bar' }) expect(subject.collection_value ctx).to eq 'Bar' end - it 'should check with field options default_if (default = field options default)' do - field.options[:default_if] = ->(ctx) { is? ctx, 'Foo' } - field.options[:default] = 'Bar' - ctx = context.new(blueprint.new, field, 'Foo', object, {}, instance_cache) + it 'should check with field options default_if (Proc) (default = field options default)' do + blueprint.collection :foos, sub_blueprint, default_if: ->(ctx) { is? ctx, 'Foo' }, default: 'Bar' + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.collection_value ctx).to eq 'Bar' + end - field.options[:default_if] = :foo? - ctx = context.new(blueprint.new, field, 'Foo', object, {}, instance_cache) + it 'should check with field options default_if (Symbol) (default = field options default)' do + blueprint.collection :foos, sub_blueprint, default_if: :foo?, default: 'Bar' + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.collection_value ctx).to eq 'Bar' end - it 'should check with field options default_if (default = blueprint options collection_default)' do - field.options[:default_if] = ->(ctx) { is? ctx, 'Foo' } + it 'should check with field options default_if (Proc) (default = blueprint options collection_default)' do + blueprint.collection :foos, sub_blueprint, default_if: ->(ctx) { is? ctx, 'Foo' } blueprint.options[:collection_default] = 'Bar' - ctx = context.new(blueprint.new, field, 'Foo', object, {}, instance_cache) + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.collection_value ctx).to eq 'Bar' + end - field.options[:default_if] = :foo? - ctx = context.new(blueprint.new, field, 'Foo', object, {}, instance_cache) + it 'should check with field options default_if (Symbol) (default = blueprint options collection_default)' do + blueprint.collection :foos, sub_blueprint, default_if: :foo? + blueprint.options[:collection_default] = 'Bar' + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.collection_value ctx).to eq 'Bar' end - it 'should check with blueprint options collection_default_if (default = options collection_default)' do + it 'should check with blueprint options collection_default_if (Proc) (default = options collection_default)' do blueprint.options[:collection_default_if] = ->(ctx) { is? ctx, 'Foo' } - ctx = context.new(blueprint.new, field, 'Foo', object, { collection_default: 'Bar' }, instance_cache) + ctx = prepare(blueprint, field, nil, object, { collection_default: 'Bar' }) expect(subject.collection_value ctx).to eq 'Bar' + end + it 'should check with blueprint options collection_default_if (Symbol) (default = options collection_default)' do blueprint.options[:collection_default_if] = :foo? - ctx = context.new(blueprint.new, field, 'Foo', object, { collection_default: 'Bar' }, instance_cache) + ctx = prepare(blueprint, field, nil, object, { collection_default: 'Bar' }) expect(subject.collection_value ctx).to eq 'Bar' end - it 'should check with blueprint options collection_default_if (default = field options default)' do + it 'should check with blueprint options collection_default_if (Proc) (default = field options default)' do blueprint.options[:collection_default_if] = ->(ctx) { is? ctx, 'Foo' } - field.options[:default] = 'Bar' - ctx = context.new(blueprint.new, field, 'Foo', object, {}, instance_cache) + blueprint.collection :foos, sub_blueprint, default: 'Bar' + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.collection_value ctx).to eq 'Bar' + end + it 'should check with blueprint options collection_default_if (Symbol) (default = field options default)' do blueprint.options[:collection_default_if] = :foo? - ctx = context.new(blueprint.new, field, 'Foo', object, {}, instance_cache) + blueprint.collection :foos, sub_blueprint, default: 'Bar' + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.collection_value ctx).to eq 'Bar' end - it 'should check with blueprint options collection_default_if (default = blueprint options collection_default)' do + it 'should check with blueprint options collection_default_if (Proc) (default = blueprint options collection_default)' do blueprint.options[:collection_default_if] = ->(ctx) { is? ctx, 'Foo' } blueprint.options[:collection_default] = 'Bar' - ctx = context.new(blueprint.new, field, 'Foo', object, {}, instance_cache) + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.collection_value ctx).to eq 'Bar' + end + it 'should check with blueprint options collection_default_if (Symbol) (default = blueprint options collection_default)' do blueprint.options[:collection_default_if] = :foo? - ctx = context.new(blueprint.new, field, 'Foo', object, {}, instance_cache) + blueprint.options[:collection_default] = 'Bar' + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.collection_value ctx).to eq 'Bar' end end diff --git a/spec/v2/extensions/exclude_if_empty_spec.rb b/spec/v2/extensions/exclude_if_empty_spec.rb index 8c912438..c98c2aa7 100644 --- a/spec/v2/extensions/exclude_if_empty_spec.rb +++ b/spec/v2/extensions/exclude_if_empty_spec.rb @@ -1,197 +1,200 @@ # frozen_string_literal: true describe Blueprinter::V2::Extensions::Exclusions do - subject { described_class.new } - let(:context) { Blueprinter::V2::Context } - let(:blueprint) { Class.new(Blueprinter::V2::Base) } - let(:field) { Blueprinter::V2::Field.new(name: :name, from: :name, options: {}) } - let(:object) { { name: 'Foo' } } + include ExtensionHelpers + let(:object) { { foo: 'Foo' } } context 'fields' do + let(:field) { blueprint.reflections[:default].fields[:foo] } + it 'should be allowed by default' do - ctx = context.new(blueprint.new, field, 'Foo', object, {}) + ctx = prepare(blueprint, field, 'Foo', object, {}) expect(subject.exclude_field? ctx).to be false end it 'should be allowed by default if nil' do - ctx = context.new(blueprint.new, field, nil, object, {}) + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.exclude_field? ctx).to be false end it 'should be allowed with options set' do - ctx = context.new(blueprint.new, field, 'Foo', object, { exclude_if_empty: true }) + ctx = prepare(blueprint, field, 'Foo', object, { exclude_if_empty: true }) expect(subject.exclude_field? ctx).to be false end it 'should be excluded with options set if nil' do - ctx = context.new(blueprint.new, field, nil, object, { exclude_if_empty: true }) + ctx = prepare(blueprint, field, nil, object, { exclude_if_empty: true }) expect(subject.exclude_field? ctx).to be true end it 'should be excluded with options set if empty' do - ctx = context.new(blueprint.new, field, [], object, { exclude_if_empty: true }) + ctx = prepare(blueprint, field, [], object, { exclude_if_empty: true }) expect(subject.exclude_field? ctx).to be true end it 'should be allowed with field options set' do - field.options[:exclude_if_empty] = true - ctx = context.new(blueprint.new, field, 'Foo', object, {}) + blueprint.field :foo, exclude_if_empty: true + ctx = prepare(blueprint, field, 'Foo', object, {}) expect(subject.exclude_field? ctx).to be false end it 'should be excluded with field options set if nil' do - field.options[:exclude_if_empty] = true - ctx = context.new(blueprint.new, field, nil, object, {}) + blueprint.field :foo, exclude_if_empty: true + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.exclude_field? ctx).to be true end it 'should be excluded with field options set if empty' do - field.options[:exclude_if_empty] = true - ctx = context.new(blueprint.new, field, [], object, {}) + blueprint.field :foo, exclude_if_empty: true + ctx = prepare(blueprint, field, [], object, {}) expect(subject.exclude_field? ctx).to be true end it 'should be allowed with blueprint options set' do blueprint.options[:exclude_if_empty] = true - ctx = context.new(blueprint.new, field, 'Foo', object, {}) + ctx = prepare(blueprint, field, 'Foo', object, {}) expect(subject.exclude_field? ctx).to be false end it 'should be excluded with blueprint options set if nil' do blueprint.options[:exclude_if_empty] = true - ctx = context.new(blueprint.new, field, nil, object, {}) + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.exclude_field? ctx).to be true end it 'should be excluded with blueprint options set if empty' do blueprint.options[:exclude_if_empty] = true - ctx = context.new(blueprint.new, field, [], object, {}) + ctx = prepare(blueprint, field, [], object, {}) expect(subject.exclude_field? ctx).to be true end end context 'objects' do + let(:field) { blueprint.reflections[:default].objects[:foo_obj] } + it 'should be allowed by default' do - ctx = context.new(blueprint.new, field, 'Foo', object, {}) + ctx = prepare(blueprint, field, { name: 'Foo' }, object, {}) expect(subject.exclude_object? ctx).to be false end it 'should be allowed by default if nil' do - ctx = context.new(blueprint.new, field, nil, object, {}) + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.exclude_object? ctx).to be false end it 'should be allowed with options set' do - ctx = context.new(blueprint.new, field, 'Foo', object, { exclude_if_empty: true }) + ctx = prepare(blueprint, field, { name: 'Foo' }, object, { exclude_if_empty: true }) expect(subject.exclude_object? ctx).to be false end it 'should be excluded with options set if nil' do - ctx = context.new(blueprint.new, field, nil, object, { exclude_if_empty: true }) + ctx = prepare(blueprint, field, nil, object, { exclude_if_empty: true }) expect(subject.exclude_object? ctx).to be true end it 'should be excluded with options set if empty' do - ctx = context.new(blueprint.new, field, [], object, { exclude_if_empty: true }) + ctx = prepare(blueprint, field, {}, object, { exclude_if_empty: true }) expect(subject.exclude_object? ctx).to be true end it 'should be allowed with field options set' do - field.options[:exclude_if_empty] = true - ctx = context.new(blueprint.new, field, 'Foo', object, {}) + blueprint.object :foo_obj, sub_blueprint, exclude_if_empty: true + ctx = prepare(blueprint, field, { name: 'Foo' }, object, {}) expect(subject.exclude_object? ctx).to be false end it 'should be excluded with field options set if nil' do - field.options[:exclude_if_empty] = true - ctx = context.new(blueprint.new, field, nil, object, {}) + blueprint.object :foo_obj, sub_blueprint, exclude_if_empty: true + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.exclude_object? ctx).to be true end it 'should be excluded with field options set if empty' do - field.options[:exclude_if_empty] = true - ctx = context.new(blueprint.new, field, [], object, {}) + blueprint.object :foo_obj, sub_blueprint, exclude_if_empty: true + ctx = prepare(blueprint, field, {}, object, {}) expect(subject.exclude_object? ctx).to be true end it 'should be allowed with blueprint options set' do blueprint.options[:exclude_if_empty] = true - ctx = context.new(blueprint.new, field, 'Foo', object, {}) + ctx = prepare(blueprint, field, { name: 'Foo' }, object, {}) expect(subject.exclude_object? ctx).to be false end it 'should be excluded with blueprint options set if nil' do blueprint.options[:exclude_if_empty] = true - ctx = context.new(blueprint.new, field, nil, object, {}) + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.exclude_object? ctx).to be true end it 'should be excluded with blueprint options set if empty' do blueprint.options[:exclude_if_empty] = true - ctx = context.new(blueprint.new, field, [], object, {}) + ctx = prepare(blueprint, field, {}, object, {}) expect(subject.exclude_object? ctx).to be true end end context 'collections' do + let(:field) { blueprint.reflections[:default].collections[:foos] } + it 'should be allowed by default' do - ctx = context.new(blueprint.new, field, 'Foo', object, {}) + ctx = prepare(blueprint, field, [{ name: 'Foo' }], object, {}) expect(subject.exclude_collection? ctx).to be false end it 'should be allowed by default if nil' do - ctx = context.new(blueprint.new, field, nil, object, {}) + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.exclude_collection? ctx).to be false end it 'should be allowed with options set' do - ctx = context.new(blueprint.new, field, 'Foo', object, { exclude_if_empty: true }) + ctx = prepare(blueprint, field, [{ name: 'Foo' }], object, { exclude_if_empty: true }) expect(subject.exclude_collection? ctx).to be false end it 'should be excluded with options set if nil' do - ctx = context.new(blueprint.new, field, nil, object, { exclude_if_empty: true }) + ctx = prepare(blueprint, field, nil, object, { exclude_if_empty: true }) expect(subject.exclude_collection? ctx).to be true end it 'should be excluded with options set if empty' do - ctx = context.new(blueprint.new, field, [], object, { exclude_if_empty: true }) + ctx = prepare(blueprint, field, [], object, { exclude_if_empty: true }) expect(subject.exclude_collection? ctx).to be true end it 'should be allowed with field options set' do - field.options[:exclude_if_empty] = true - ctx = context.new(blueprint.new, field, 'Foo', object, {}) + blueprint.collection :foos, sub_blueprint, exclude_if_empty: true + ctx = prepare(blueprint, field, [{ name: 'Foo' }], object, {}) expect(subject.exclude_collection? ctx).to be false end it 'should be excluded with field options set if nil' do - field.options[:exclude_if_empty] = true - ctx = context.new(blueprint.new, field, nil, object, {}) + blueprint.collection :foos, sub_blueprint, exclude_if_empty: true + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.exclude_collection? ctx).to be true end it 'should be excluded with field options set if empty' do - field.options[:exclude_if_empty] = true - ctx = context.new(blueprint.new, field, [], object, {}) + blueprint.collection :foos, sub_blueprint, exclude_if_empty: true + ctx = prepare(blueprint, field, [], object, {}) expect(subject.exclude_collection? ctx).to be true end it 'should be allowed with blueprint options set' do blueprint.options[:exclude_if_empty] = true - ctx = context.new(blueprint.new, field, 'Foo', object, {}) + ctx = prepare(blueprint, field, [{ name: 'Foo' }], object, {}) expect(subject.exclude_collection? ctx).to be false end it 'should be excluded with blueprint options set if nil' do blueprint.options[:exclude_if_empty] = true - ctx = context.new(blueprint.new, field, nil, object, {}) + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.exclude_collection? ctx).to be true end it 'should be excluded with blueprint options set if empty' do blueprint.options[:exclude_if_empty] = true - ctx = context.new(blueprint.new, field, [], object, {}) + ctx = prepare(blueprint, field, [], object, {}) expect(subject.exclude_collection? ctx).to be true end end diff --git a/spec/v2/extensions/exclude_if_nil_spec.rb b/spec/v2/extensions/exclude_if_nil_spec.rb index 53330ade..b266f86b 100644 --- a/spec/v2/extensions/exclude_if_nil_spec.rb +++ b/spec/v2/extensions/exclude_if_nil_spec.rb @@ -1,146 +1,149 @@ # frozen_string_literal: true describe Blueprinter::V2::Extensions::Exclusions do - subject { described_class.new } - let(:context) { Blueprinter::V2::Context } - let(:blueprint) { Class.new(Blueprinter::V2::Base) } - let(:field) { Blueprinter::V2::Field.new(name: :name, from: :name, options: {}) } - let(:object) { { name: 'Foo' } } + include ExtensionHelpers + let(:object) { { foo: 'Foo' } } context 'fields' do + let(:field) { blueprint.reflections[:default].fields[:foo] } + it 'should be allowed by default' do - ctx = context.new(blueprint.new, field, 'Foo', object, {}) + ctx = prepare(blueprint, field, 'Foo', object, {}) expect(subject.exclude_field? ctx).to be false end it 'should be allowed by default if nil' do - ctx = context.new(blueprint.new, field, nil, object, {}) + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.exclude_field? ctx).to be false end it 'should be allowed with options set' do - ctx = context.new(blueprint.new, field, 'Foo', object, { exclude_if_nil: true }) + ctx = prepare(blueprint, field, 'Foo', object, { exclude_if_nil: true }) expect(subject.exclude_field? ctx).to be false end it 'should be excluded with options set if nil' do - ctx = context.new(blueprint.new, field, nil, object, { exclude_if_nil: true }) + ctx = prepare(blueprint, field, nil, object, { exclude_if_nil: true }) expect(subject.exclude_field? ctx).to be true end it 'should be allowed with field options set' do - field.options[:exclude_if_nil] = true - ctx = context.new(blueprint.new, field, 'Foo', object, {}) + blueprint.field :foo, exclude_if_nil: true + ctx = prepare(blueprint, field, 'Foo', object, {}) expect(subject.exclude_field? ctx).to be false end it 'should be excluded with field options set if nil' do - field.options[:exclude_if_nil] = true - ctx = context.new(blueprint.new, field, nil, object, {}) + blueprint.field :foo, exclude_if_nil: true + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.exclude_field? ctx).to be true end it 'should be allowed with blueprint options set' do blueprint.options[:exclude_if_nil] = true - ctx = context.new(blueprint.new, field, 'Foo', object, {}) + ctx = prepare(blueprint, field, 'Foo', object, {}) expect(subject.exclude_field? ctx).to be false end it 'should be excluded with blueprint options set if nil' do blueprint.options[:exclude_if_nil] = true - ctx = context.new(blueprint.new, field, nil, object, {}) + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.exclude_field? ctx).to be true end end context 'objects' do + let(:field) { blueprint.reflections[:default].objects[:foo_obj] } + it 'should be allowed by default' do - ctx = context.new(blueprint.new, field, 'Foo', object, {}) + ctx = prepare(blueprint, field, { name: 'Foo' }, object, {}) expect(subject.exclude_object? ctx).to be false end it 'should be allowed by default if nil' do - ctx = context.new(blueprint.new, field, nil, object, {}) + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.exclude_object? ctx).to be false end it 'should be allowed with options set' do - ctx = context.new(blueprint.new, field, 'Foo', object, { exclude_if_nil: true }) + ctx = prepare(blueprint, field, { name: 'Foo' }, object, { exclude_if_nil: true }) expect(subject.exclude_object? ctx).to be false end it 'should be excluded with options set if nil' do - ctx = context.new(blueprint.new, field, nil, object, { exclude_if_nil: true }) + ctx = prepare(blueprint, field, nil, object, { exclude_if_nil: true }) expect(subject.exclude_object? ctx).to be true end it 'should be allowed with field options set' do - field.options[:exclude_if_nil] = true - ctx = context.new(blueprint.new, field, 'Foo', object, {}) + blueprint.object :foo_obj, sub_blueprint, exclude_if_nil: true + ctx = prepare(blueprint, field, { name: 'Foo' }, object, {}) expect(subject.exclude_object? ctx).to be false end it 'should be excluded with field options set if nil' do - field.options[:exclude_if_nil] = true - ctx = context.new(blueprint.new, field, nil, object, {}) + blueprint.object :foo_obj, sub_blueprint, exclude_if_nil: true + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.exclude_object? ctx).to be true end it 'should be allowed with blueprint options set' do blueprint.options[:exclude_if_nil] = true - ctx = context.new(blueprint.new, field, 'Foo', object, {}) + ctx = prepare(blueprint, field, { name: 'Foo' }, object, {}) expect(subject.exclude_object? ctx).to be false end it 'should be excluded with blueprint options set if nil' do blueprint.options[:exclude_if_nil] = true - ctx = context.new(blueprint.new, field, nil, object, {}) + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.exclude_object? ctx).to be true end end context 'collections' do + let(:field) { blueprint.reflections[:default].collections[:foos] } + it 'should be allowed by default' do - ctx = context.new(blueprint.new, field, 'Foo', object, {}) + ctx = prepare(blueprint, field, [{ name: 'Foo' }], object, {}) expect(subject.exclude_collection? ctx).to be false end it 'should be allowed by default if nil' do - ctx = context.new(blueprint.new, field, nil, object, {}) + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.exclude_collection? ctx).to be false end it 'should be allowed with options set' do - ctx = context.new(blueprint.new, field, 'Foo', object, { exclude_if_nil: true }) + ctx = prepare(blueprint, field, [{ name: 'Foo' }], object, { exclude_if_nil: true }) expect(subject.exclude_collection? ctx).to be false end it 'should be excluded with options set if nil' do - ctx = context.new(blueprint.new, field, nil, object, { exclude_if_nil: true }) + ctx = prepare(blueprint, field, nil, object, { exclude_if_nil: true }) expect(subject.exclude_collection? ctx).to be true end it 'should be allowed with field options set' do - field.options[:exclude_if_nil] = true - ctx = context.new(blueprint.new, field, 'Foo', object, {}) + blueprint.collection :foos, sub_blueprint, exclude_if_nil: true + ctx = prepare(blueprint, field, [{ name: 'Foo' }], object, {}) expect(subject.exclude_collection? ctx).to be false end it 'should be excluded with field options set if nil' do - field.options[:exclude_if_nil] = true - ctx = context.new(blueprint.new, field, nil, object, {}) + blueprint.collection :foos, sub_blueprint, exclude_if_nil: true + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.exclude_collection? ctx).to be true end it 'should be allowed with blueprint options set' do blueprint.options[:exclude_if_nil] = true - ctx = context.new(blueprint.new, field, 'Foo', object, {}) + ctx = prepare(blueprint, field, [{ name: 'Foo' }], object, {}) expect(subject.exclude_collection? ctx).to be false end it 'should be excluded with blueprint options set if nil' do blueprint.options[:exclude_if_nil] = true - ctx = context.new(blueprint.new, field, nil, object, {}) + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.exclude_collection? ctx).to be true end end diff --git a/spec/v2/extensions/extraction_spec.rb b/spec/v2/extensions/extraction_spec.rb index 014ecd32..5e69fe0a 100644 --- a/spec/v2/extensions/extraction_spec.rb +++ b/spec/v2/extensions/extraction_spec.rb @@ -1,11 +1,9 @@ # frozen_string_literal: true describe Blueprinter::V2::Extensions::Values do - subject { described_class.new } - let(:instance_cache) { Blueprinter::V2::InstanceCache.new } - let(:context) { Blueprinter::V2::Context } - let(:blueprint) { Class.new(Blueprinter::V2::Base) } - let(:object) { { name: 'Foo', category: { name: 'Bar' }, parts: [{ num: 42 }] } } + include ExtensionHelpers + + let(:object) { { foo: 'Foo', foo_obj: { name: 'Bar' }, foos: [{ num: 42 }] } } let(:my_extractor) do Class.new(Blueprinter::V2::Extractor) do def field(_blueprint, field, obj, _options) @@ -37,64 +35,64 @@ def collection(_blueprint, field, obj, _options) end context 'fields' do - let(:field) { Blueprinter::V2::Field.new(name: :name, from: :name, options: {}) } + let(:field) { blueprint.reflections[:default].fields[:foo] } it 'should extract a field with the default extractor' do - ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.field_value ctx).to eq 'Foo' end it 'should extract a field with the field options extractor' do - field.options[:extractor] = my_extractor - ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + blueprint.field :foo, extractor: my_extractor + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.field_value ctx).to eq 'FOO' end it 'should extract a field with the blueprint options extractor' do blueprint.options[:extractor] = my_extractor - ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.field_value ctx).to eq 'FOO' end end context 'objects' do - let(:field) { Blueprinter::V2::ObjectField.new(name: :category, from: :category, options: {}) } + let(:field) { blueprint.reflections[:default].objects[:foo_obj] } it 'should extract an object the default extractor' do - ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.object_value ctx).to eq({ name: 'Bar' }) end it 'should extract an object the field options extractor' do - field.options[:extractor] = my_extractor - ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + blueprint.object :foo_obj, sub_blueprint, extractor: my_extractor + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.object_value ctx).to eq({ name: 'BAR' }) end it 'should extract an object the blueprint options extractor' do blueprint.options[:extractor] = my_extractor - ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.object_value ctx).to eq({ name: 'BAR' }) end end context 'collections' do - let(:field) { Blueprinter::V2::Collection.new(name: :parts, from: :parts, options: {}) } + let(:field) { blueprint.reflections[:default].collections[:foos] } it 'should extract an object the default extractor' do - ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.collection_value ctx).to eq [{ num: 42 }] end it 'should extract an object the field options extractor' do - field.options[:extractor] = my_extractor - ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + blueprint.collection :foos, sub_blueprint, extractor: my_extractor + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.collection_value ctx).to eq [{ num: 84 }] end it 'should extract an object the blueprint options extractor' do blueprint.options[:extractor] = my_extractor - ctx = context.new(blueprint.new, field, nil, object, {}, instance_cache) + ctx = prepare(blueprint, field, nil, object, {}) expect(subject.collection_value ctx).to eq [{ num: 84 }] end end diff --git a/spec/v2/extensions/if_conditionals_spec.rb b/spec/v2/extensions/if_conditionals_spec.rb index 5589720c..954c347c 100644 --- a/spec/v2/extensions/if_conditionals_spec.rb +++ b/spec/v2/extensions/if_conditionals_spec.rb @@ -1,196 +1,188 @@ # frozen_string_literal: true describe Blueprinter::V2::Extensions::Exclusions do - subject { described_class.new } - let(:context) { Blueprinter::V2::Context } - let(:object) { { name: 'Foo' } } - let(:blueprint) do - Class.new(Blueprinter::V2::Base) do - def foo?(ctx) - ctx.value == :foo - end - end - end + include ExtensionHelpers + let(:object) { { foo: 'Foo' } } context 'fields' do - let(:field) { Blueprinter::V2::Field.new(name: :name, from: :name, options: {}) } + let(:field) { blueprint.reflections[:default].fields[:foo] } it 'should be allowed by default' do - ctx = context.new(blueprint.new, field, :foo, object, {}) + ctx = prepare(blueprint, field, 'Foo', object, {}) expect(subject.exclude_field? ctx).to be false end it 'should check options field_if (Proc)' do - ctx = context.new(blueprint.new, field, :foo, object, { field_if: ->(ctx) { foo? ctx } }) + ctx = prepare(blueprint, field, 'Foo', object, { field_if: ->(ctx) { foo? ctx } }) expect(subject.exclude_field? ctx).to be false - ctx = context.new(blueprint.new, field, :bar, object, { field_if: ->(ctx) { foo? ctx } }) + ctx = prepare(blueprint, field, 'Bar', object, { field_if: ->(ctx) { foo? ctx } }) expect(subject.exclude_field? ctx).to be true end it 'should check field options if (Proc)' do - field.options[:if] = ->(ctx) { foo? ctx } - ctx = context.new(blueprint.new, field, :foo, object, {}) + blueprint.field :foo, if: ->(ctx) { foo? ctx } + ctx = prepare(blueprint, field, 'Foo', object, {}) expect(subject.exclude_field? ctx).to be false - ctx = context.new(blueprint.new, field, :bar, object, {}) + ctx = prepare(blueprint, field, 'Bar', object, {}) expect(subject.exclude_field? ctx).to be true end it 'should check blueprint options field_if (Proc)' do blueprint.options[:field_if] = ->(ctx) { foo? ctx } - ctx = context.new(blueprint.new, field, :foo, object, {}) + ctx = prepare(blueprint, field, 'Foo', object, {}) expect(subject.exclude_field? ctx).to be false - ctx = context.new(blueprint.new, field, :bar, object, {}) + ctx = prepare(blueprint, field, 'Bar', object, {}) expect(subject.exclude_field? ctx).to be true end it 'should check options field_if (Symbol)' do - ctx = context.new(blueprint.new, field, :foo, object, { field_if: :foo? }) + ctx = prepare(blueprint, field, 'Foo', object, { field_if: :foo? }) expect(subject.exclude_field? ctx).to be false - ctx = context.new(blueprint.new, field, :bar, object, { field_if: :foo? }) + ctx = prepare(blueprint, field, 'Bar', object, { field_if: :foo? }) expect(subject.exclude_field? ctx).to be true end it 'should check field options if (Symbol)' do - field.options[:if] = :foo? - ctx = context.new(blueprint.new, field, :foo, object, {}) + blueprint.field :foo, if: :foo? + ctx = prepare(blueprint, field, 'Foo', object, {}) expect(subject.exclude_field? ctx).to be false - ctx = context.new(blueprint.new, field, :bar, object, {}) + ctx = prepare(blueprint, field, 'Bar', object, {}) expect(subject.exclude_field? ctx).to be true end it 'should check blueprint options field_if (Symbol)' do blueprint.options[:field_if] = :foo? - ctx = context.new(blueprint.new, field, :foo, object, {}) + ctx = prepare(blueprint, field, 'Foo', object, {}) expect(subject.exclude_field? ctx).to be false - ctx = context.new(blueprint.new, field, :bar, object, {}) + ctx = prepare(blueprint, field, 'Bar', object, {}) expect(subject.exclude_field? ctx).to be true end end context 'objects' do - let(:field) { Blueprinter::V2::ObjectField.new(name: :name, from: :name, options: {}) } + let(:field) { blueprint.reflections[:default].objects[:foo_obj] } it 'should be allowed by default' do - ctx = context.new(blueprint.new, field, 'Foo', object, {}) + ctx = prepare(blueprint, field, { name: 'Foo' }, object, {}) expect(subject.exclude_object? ctx).to be false end it 'should check options object_if (Proc)' do - ctx = context.new(blueprint.new, field, :foo, object, { object_if: ->(ctx) { foo? ctx } }) + ctx = prepare(blueprint, field, { name: 'Foo' }, object, { object_if: ->(ctx) { name_foo? ctx } }) expect(subject.exclude_object? ctx).to be false - ctx = context.new(blueprint.new, field, :bar, object, { object_if: ->(ctx) { foo? ctx } }) + ctx = prepare(blueprint, field, { name: 'Bar' }, object, { object_if: ->(ctx) { name_foo? ctx } }) expect(subject.exclude_object? ctx).to be true end it 'should check field options if (Proc)' do - field.options[:if] = ->(ctx) { foo? ctx } - ctx = context.new(blueprint.new, field, :foo, object, {}) + blueprint.object :foo_obj, sub_blueprint, if: ->(ctx) { name_foo? ctx } + ctx = prepare(blueprint, field, { name: 'Foo' }, object, {}) expect(subject.exclude_object? ctx).to be false - ctx = context.new(blueprint.new, field, :bar, object, {}) + ctx = prepare(blueprint, field, { name: 'Bar' }, object, {}) expect(subject.exclude_object? ctx).to be true end it 'should check blueprint options object_if (Proc)' do - blueprint.options[:object_if] = ->(ctx) { foo? ctx } - ctx = context.new(blueprint.new, field, :foo, object, {}) + blueprint.options[:object_if] = ->(ctx) { name_foo? ctx } + ctx = prepare(blueprint, field, { name: 'Foo' }, object, {}) expect(subject.exclude_object? ctx).to be false - ctx = context.new(blueprint.new, field, :bar, object, {}) + ctx = prepare(blueprint, field, { name: 'Bar' }, object, {}) expect(subject.exclude_object? ctx).to be true end it 'should check options object_if (Symbol)' do - ctx = context.new(blueprint.new, field, :foo, object, { object_if: :foo? }) + ctx = prepare(blueprint, field, { name: 'Foo' }, object, { object_if: :name_foo? }) expect(subject.exclude_object? ctx).to be false - ctx = context.new(blueprint.new, field, :bar, object, { object_if: :foo? }) + ctx = prepare(blueprint, field, { name: 'Bar' }, object, { object_if: :name_foo? }) expect(subject.exclude_object? ctx).to be true end it 'should check field options if (Symbol)' do - field.options[:if] = :foo? - ctx = context.new(blueprint.new, field, :foo, object, {}) + blueprint.object :foo_obj, sub_blueprint, if: :name_foo? + ctx = prepare(blueprint, field, { name: 'Foo' }, object, { object_if: :name_foo? }) expect(subject.exclude_object? ctx).to be false - ctx = context.new(blueprint.new, field, :bar, object, {}) + ctx = prepare(blueprint, field, { name: 'Bar' }, object, { object_if: :name_foo? }) expect(subject.exclude_object? ctx).to be true end it 'should check blueprint options object_if (Symbol)' do - blueprint.options[:object_if] = :foo? - ctx = context.new(blueprint.new, field, :foo, object, {}) + blueprint.options[:object_if] = :name_foo? + ctx = prepare(blueprint, field, { name: 'Foo' }, object, {}) expect(subject.exclude_object? ctx).to be false - ctx = context.new(blueprint.new, field, :bar, object, {}) + ctx = prepare(blueprint, field, { name: 'Bar' }, object, {}) expect(subject.exclude_object? ctx).to be true end end context 'collections' do - let(:field) { Blueprinter::V2::Collection.new(name: :name, from: :name, options: {}) } + let(:field) { blueprint.reflections[:default].collections[:foos] } it 'should be allowed by default' do - ctx = context.new(blueprint.new, field, 'Foo', object, {}) + ctx = prepare(blueprint, field, [{ name: 'Foo' }], object, {}) expect(subject.exclude_collection? ctx).to be false end it 'should check options collection_if (Proc)' do - ctx = context.new(blueprint.new, field, :foo, object, { collection_if: ->(ctx) { foo? ctx } }) + ctx = prepare(blueprint, field, [{ name: 'Foo' }], object, { collection_if: ->(ctx) { names_foo? ctx } }) expect(subject.exclude_collection? ctx).to be false - ctx = context.new(blueprint.new, field, :bar, object, { collection_if: ->(ctx) { foo? ctx } }) + ctx = prepare(blueprint, field, [{ name: 'Bar' }], object, { collection_if: ->(ctx) { names_foo? ctx } }) expect(subject.exclude_collection? ctx).to be true end it 'should check field options if (Proc)' do - field.options[:if] = ->(ctx) { foo? ctx } - ctx = context.new(blueprint.new, field, :foo, object, {}) + blueprint.collection :foos, sub_blueprint, if: ->(ctx) { names_foo? ctx } + ctx = prepare(blueprint, field, [{ name: 'Foo' }], object, {}) expect(subject.exclude_collection? ctx).to be false - ctx = context.new(blueprint.new, field, :bar, object, {}) + ctx = prepare(blueprint, field, [{ name: 'Bar' }], object, {}) expect(subject.exclude_collection? ctx).to be true end it 'should check blueprint options collection_if (Proc)' do - blueprint.options[:collection_if] = ->(ctx) { foo? ctx } - ctx = context.new(blueprint.new, field, :foo, object, {}) + blueprint.options[:collection_if] = ->(ctx) { names_foo? ctx } + ctx = prepare(blueprint, field, [{ name: 'Foo' }], object, {}) expect(subject.exclude_collection? ctx).to be false - ctx = context.new(blueprint.new, field, :bar, object, {}) + ctx = prepare(blueprint, field, [{ name: 'Bar' }], object, {}) expect(subject.exclude_collection? ctx).to be true end it 'should check options collection_if (Symbol)' do - ctx = context.new(blueprint.new, field, :foo, object, { collection_if: :foo? }) + ctx = prepare(blueprint, field, [{ name: 'Foo' }], object, { collection_if: :names_foo? }) expect(subject.exclude_collection? ctx).to be false - ctx = context.new(blueprint.new, field, :bar, object, { collection_if: :foo? }) + ctx = prepare(blueprint, field, [{ name: 'Bar' }], object, { collection_if: :names_foo? }) expect(subject.exclude_collection? ctx).to be true end it 'should check field options if (Symbol)' do - field.options[:if] = :foo? - ctx = context.new(blueprint.new, field, :foo, object, {}) + blueprint.collection :foos, sub_blueprint, if: :names_foo? + ctx = prepare(blueprint, field, [{ name: 'Foo' }], object, {}) expect(subject.exclude_collection? ctx).to be false - ctx = context.new(blueprint.new, field, :bar, object, {}) + ctx = prepare(blueprint, field, [{ name: 'Bar' }], object, {}) expect(subject.exclude_collection? ctx).to be true end it 'should check blueprint options collection_if (Symbol)' do - blueprint.options[:collection_if] = :foo? - ctx = context.new(blueprint.new, field, :foo, object, {}) + blueprint.options[:collection_if] = :names_foo? + ctx = prepare(blueprint, field, [{ name: 'Foo' }], object, {}) expect(subject.exclude_collection? ctx).to be false - ctx = context.new(blueprint.new, field, :bar, object, {}) + ctx = prepare(blueprint, field, [{ name: 'Bar' }], object, {}) expect(subject.exclude_collection? ctx).to be true end end diff --git a/spec/v2/extensions/unless_conditionals_spec.rb b/spec/v2/extensions/unless_conditionals_spec.rb index 70225501..6b034faf 100644 --- a/spec/v2/extensions/unless_conditionals_spec.rb +++ b/spec/v2/extensions/unless_conditionals_spec.rb @@ -1,196 +1,188 @@ # frozen_string_literal: true describe Blueprinter::V2::Extensions::Exclusions do - subject { described_class.new } - let(:context) { Blueprinter::V2::Context } - let(:object) { { name: 'Foo' } } - let(:blueprint) do - Class.new(Blueprinter::V2::Base) do - def foo?(ctx) - ctx.value == :foo - end - end - end + include ExtensionHelpers + let(:object) { { foo: 'Foo' } } context 'fields' do - let(:field) { Blueprinter::V2::Field.new(name: :name, from: :name, options: {}) } + let(:field) { blueprint.reflections[:default].fields[:foo] } it 'should be allowed by default' do - ctx = context.new(blueprint.new, field, :foo, object, {}) + ctx = prepare(blueprint, field, 'Foo', object, {}) expect(subject.exclude_field? ctx).to be false end it 'should check options field_unless (Proc)' do - ctx = context.new(blueprint.new, field, :foo, object, { field_unless: ->(ctx) { foo? ctx } }) + ctx = prepare(blueprint, field, 'Foo', object, { field_unless: ->(ctx) { foo? ctx } }) expect(subject.exclude_field? ctx).to be true - ctx = context.new(blueprint.new, field, :bar, object, { field_unless: ->(ctx) { foo? ctx } }) + ctx = prepare(blueprint, field, 'Bar', object, { field_unless: ->(ctx) { foo? ctx } }) expect(subject.exclude_field? ctx).to be false end it 'should check field options unless (Proc)' do - field.options[:unless] = ->(ctx) { foo? ctx } - ctx = context.new(blueprint.new, field, :foo, object, {}) + blueprint.field :foo, unless: ->(ctx) { foo? ctx } + ctx = prepare(blueprint, field, 'Foo', object, {}) expect(subject.exclude_field? ctx).to be true - ctx = context.new(blueprint.new, field, :bar, object, {}) + ctx = prepare(blueprint, field, 'Bar', object, {}) expect(subject.exclude_field? ctx).to be false end it 'should check blueprint options field_unless (Proc)' do blueprint.options[:field_unless] = ->(ctx) { foo? ctx } - ctx = context.new(blueprint.new, field, :foo, object, {}) + ctx = prepare(blueprint, field, 'Foo', object, {}) expect(subject.exclude_field? ctx).to be true - ctx = context.new(blueprint.new, field, :bar, object, {}) + ctx = prepare(blueprint, field, 'Bar', object, {}) expect(subject.exclude_field? ctx).to be false end it 'should check options field_unless (Symbol)' do - ctx = context.new(blueprint.new, field, :foo, object, { field_unless: :foo? }) + ctx = prepare(blueprint, field, 'Foo', object, { field_unless: :foo? }) expect(subject.exclude_field? ctx).to be true - ctx = context.new(blueprint.new, field, :bar, object, { field_unless: :foo? }) + ctx = prepare(blueprint, field, 'Bar', object, { field_unless: :foo? }) expect(subject.exclude_field? ctx).to be false end it 'should check field options unless (Symbol)' do - field.options[:unless] = :foo? - ctx = context.new(blueprint.new, field, :foo, object, {}) + blueprint.field :foo, unless: :foo? + ctx = prepare(blueprint, field, 'Foo', object, {}) expect(subject.exclude_field? ctx).to be true - ctx = context.new(blueprint.new, field, :bar, object, {}) + ctx = prepare(blueprint, field, 'Bar', object, {}) expect(subject.exclude_field? ctx).to be false end it 'should check blueprint options field_unless (Symbol)' do blueprint.options[:field_unless] = :foo? - ctx = context.new(blueprint.new, field, :foo, object, {}) + ctx = prepare(blueprint, field, 'Foo', object, {}) expect(subject.exclude_field? ctx).to be true - ctx = context.new(blueprint.new, field, :bar, object, {}) + ctx = prepare(blueprint, field, 'Bar', object, {}) expect(subject.exclude_field? ctx).to be false end end context 'objects' do - let(:field) { Blueprinter::V2::ObjectField.new(name: :name, from: :name, options: {}) } + let(:field) { blueprint.reflections[:default].objects[:foo_obj] } it 'should be allowed by default' do - ctx = context.new(blueprint.new, field, 'Foo', object, {}) + ctx = prepare(blueprint, field, { name: 'Foo' }, object, {}) expect(subject.exclude_object? ctx).to be false end it 'should check options object_unless (Proc)' do - ctx = context.new(blueprint.new, field, :foo, object, { object_unless: ->(ctx) { foo? ctx } }) + ctx = prepare(blueprint, field, { name: 'Foo' }, object, { object_unless: ->(ctx) { name_foo? ctx } }) expect(subject.exclude_object? ctx).to be true - ctx = context.new(blueprint.new, field, :bar, object, { object_unless: ->(ctx) { foo? ctx } }) + ctx = prepare(blueprint, field, { name: 'Bar' }, object, { object_unless: ->(ctx) { name_foo? ctx } }) expect(subject.exclude_object? ctx).to be false end it 'should check field options unless (Proc)' do - field.options[:unless] = ->(ctx) { foo? ctx } - ctx = context.new(blueprint.new, field, :foo, object, {}) + blueprint.object :foo_obj, sub_blueprint, unless: ->(ctx) { name_foo? ctx } + ctx = prepare(blueprint, field, { name: 'Foo' }, object, {}) expect(subject.exclude_object? ctx).to be true - ctx = context.new(blueprint.new, field, :bar, object, {}) + ctx = prepare(blueprint, field, { name: 'Bar' }, object, {}) expect(subject.exclude_object? ctx).to be false end it 'should check blueprint options object_unless (Proc)' do - blueprint.options[:object_unless] = ->(ctx) { foo? ctx } - ctx = context.new(blueprint.new, field, :foo, object, {}) + blueprint.options[:object_unless] = ->(ctx) { name_foo? ctx } + ctx = prepare(blueprint, field, { name: 'Foo' }, object, {}) expect(subject.exclude_object? ctx).to be true - ctx = context.new(blueprint.new, field, :bar, object, {}) + ctx = prepare(blueprint, field, { name: 'Bar' }, object, {}) expect(subject.exclude_object? ctx).to be false end it 'should check options object_unless (Symbol)' do - ctx = context.new(blueprint.new, field, :foo, object, { object_unless: :foo? }) + ctx = prepare(blueprint, field, { name: 'Foo' }, object, { object_unless: :name_foo? }) expect(subject.exclude_object? ctx).to be true - ctx = context.new(blueprint.new, field, :bar, object, { object_unless: :foo? }) + ctx = prepare(blueprint, field, { name: 'Bar' }, object, { object_unless: :name_foo? }) expect(subject.exclude_object? ctx).to be false end it 'should check field options unless (Symbol)' do - field.options[:unless] = :foo? - ctx = context.new(blueprint.new, field, :foo, object, {}) + blueprint.object :foo_obj, sub_blueprint, unless: :name_foo? + ctx = prepare(blueprint, field, { name: 'Foo' }, object, {}) expect(subject.exclude_object? ctx).to be true - ctx = context.new(blueprint.new, field, :bar, object, {}) + ctx = prepare(blueprint, field, { name: 'Bar' }, object, {}) expect(subject.exclude_object? ctx).to be false end it 'should check blueprint options object_unless (Symbol)' do - blueprint.options[:object_unless] = :foo? - ctx = context.new(blueprint.new, field, :foo, object, {}) + blueprint.options[:object_unless] = :name_foo? + ctx = prepare(blueprint, field, { name: 'Foo' }, object, {}) expect(subject.exclude_object? ctx).to be true - ctx = context.new(blueprint.new, field, :bar, object, {}) + ctx = prepare(blueprint, field, { name: 'Bar' }, object, {}) expect(subject.exclude_object? ctx).to be false end end context 'collections' do - let(:field) { Blueprinter::V2::Collection.new(name: :name, from: :name, options: {}) } + let(:field) { blueprint.reflections[:default].collections[:foos] } it 'should be allowed by default' do - ctx = context.new(blueprint.new, field, 'Foo', object, {}) + ctx = prepare(blueprint, field, [{ name: 'Foo' }], object, {}) expect(subject.exclude_collection? ctx).to be false end it 'should check options collection_unless (Proc)' do - ctx = context.new(blueprint.new, field, :foo, object, { collection_unless: ->(ctx) { foo? ctx } }) + ctx = prepare(blueprint, field, [{ name: 'Foo' }], object, { collection_unless: ->(ctx) { names_foo? ctx } }) expect(subject.exclude_collection? ctx).to be true - ctx = context.new(blueprint.new, field, :bar, object, { collection_unless: ->(ctx) { foo? ctx } }) + ctx = prepare(blueprint, field, [{ name: 'Bar' }], object, { collection_unless: ->(ctx) { names_foo? ctx } }) expect(subject.exclude_collection? ctx).to be false end it 'should check field options unless (Proc)' do - field.options[:unless] = ->(ctx) { foo? ctx } - ctx = context.new(blueprint.new, field, :foo, object, {}) + blueprint.collection :foos, sub_blueprint, unless: ->(ctx) { names_foo? ctx } + ctx = prepare(blueprint, field, [{ name: 'Foo' }], object, {}) expect(subject.exclude_collection? ctx).to be true - ctx = context.new(blueprint.new, field, :bar, object, {}) + ctx = prepare(blueprint, field, [{ name: 'Bar' }], object, {}) expect(subject.exclude_collection? ctx).to be false end it 'should check blueprint options collection_unless (Proc)' do - blueprint.options[:collection_unless] = ->(ctx) { foo? ctx } - ctx = context.new(blueprint.new, field, :foo, object, {}) + blueprint.options[:collection_unless] = ->(ctx) { names_foo? ctx } + ctx = prepare(blueprint, field, [{ name: 'Foo' }], object, {}) expect(subject.exclude_collection? ctx).to be true - ctx = context.new(blueprint.new, field, :bar, object, {}) + ctx = prepare(blueprint, field, [{ name: 'Bar' }], object, {}) expect(subject.exclude_collection? ctx).to be false end it 'should check options collection_unless (Symbol)' do - ctx = context.new(blueprint.new, field, :foo, object, { collection_unless: :foo? }) + ctx = prepare(blueprint, field, [{ name: 'Foo' }], object, { collection_unless: :names_foo? }) expect(subject.exclude_collection? ctx).to be true - ctx = context.new(blueprint.new, field, :bar, object, { collection_unless: :foo? }) + ctx = prepare(blueprint, field, [{ name: 'Bar' }], object, { collection_unless: :names_foo? }) expect(subject.exclude_collection? ctx).to be false end it 'should check field options unless (Symbol)' do - field.options[:unless] = :foo? - ctx = context.new(blueprint.new, field, :foo, object, {}) + blueprint.collection :foos, sub_blueprint, unless: :names_foo? + ctx = prepare(blueprint, field, [{ name: 'Foo' }], object, {}) expect(subject.exclude_collection? ctx).to be true - ctx = context.new(blueprint.new, field, :bar, object, {}) + ctx = prepare(blueprint, field, [{ name: 'Bar' }], object, {}) expect(subject.exclude_collection? ctx).to be false end it 'should check blueprint options collection_unless (Symbol)' do - blueprint.options[:collection_unless] = :foo? - ctx = context.new(blueprint.new, field, :foo, object, {}) + blueprint.options[:collection_unless] = :names_foo? + ctx = prepare(blueprint, field, [{ name: 'Foo' }], object, {}) expect(subject.exclude_collection? ctx).to be true - ctx = context.new(blueprint.new, field, :bar, object, {}) + ctx = prepare(blueprint, field, [{ name: 'Bar' }], object, {}) expect(subject.exclude_collection? ctx).to be false end end From e3783f224669e35145b8823196d98b83b4dc8609 Mon Sep 17 00:00:00 2001 From: Jordan Hollinger Date: Fri, 8 Nov 2024 16:54:57 -0500 Subject: [PATCH 13/16] Major perf improvements: Unroll Values and Exclusions extension. Block hooks that aren't used Signed-off-by: Jordan Hollinger --- lib/blueprinter/hooks.rb | 16 ++++ lib/blueprinter/v2/extensions.rb | 1 + lib/blueprinter/v2/extensions/collections.rb | 18 ++++ lib/blueprinter/v2/extensions/values.rb | 7 -- lib/blueprinter/v2/instance_cache.rb | 2 +- lib/blueprinter/v2/render.rb | 3 +- lib/blueprinter/v2/serializer.rb | 86 ++++++++++++-------- spec/benchmarks/speedtest.rb | 53 ++++++------ spec/extensions/hooks_spec.rb | 7 ++ spec/v2/extensions/collections_spec.rb | 44 ++++++++++ spec/v2/extensions/extraction_spec.rb | 12 --- 11 files changed, 168 insertions(+), 81 deletions(-) create mode 100644 lib/blueprinter/v2/extensions/collections.rb create mode 100644 spec/v2/extensions/collections_spec.rb diff --git a/lib/blueprinter/hooks.rb b/lib/blueprinter/hooks.rb index 385beca1..28096352 100644 --- a/lib/blueprinter/hooks.rb +++ b/lib/blueprinter/hooks.rb @@ -11,6 +11,22 @@ def initialize(extensions) end end + # + # Checks if any hooks of the given name are registered. + # + # @param hook [Symbol] Name of hook to call + # @return [Boolean] + # + def has?(hook) + @hooks.fetch(hook).any? + end + + # + # Runs each hook. + # + # @param hook [Symbol] Name of hook to call + # @param arg [Object] Argument to hook + # def each(hook, arg) @hooks.fetch(hook).each { |ext| ext.public_send(hook, arg) } end diff --git a/lib/blueprinter/v2/extensions.rb b/lib/blueprinter/v2/extensions.rb index caee1395..6565309b 100644 --- a/lib/blueprinter/v2/extensions.rb +++ b/lib/blueprinter/v2/extensions.rb @@ -3,6 +3,7 @@ module Blueprinter module V2 module Extensions + autoload :Collections, 'blueprinter/v2/extensions/collections' autoload :Exclusions, 'blueprinter/v2/extensions/exclusions' autoload :FieldOrder, 'blueprinter/v2/extensions/field_order' autoload :Output, 'blueprinter/v2/extensions/output' diff --git a/lib/blueprinter/v2/extensions/collections.rb b/lib/blueprinter/v2/extensions/collections.rb new file mode 100644 index 00000000..b01e79f3 --- /dev/null +++ b/lib/blueprinter/v2/extensions/collections.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'set' + +module Blueprinter + module V2 + module Extensions + class Collections < Extension + def collection?(object) + case object + when Array, Set, Enumerator then true + else false + end + end + end + end + end +end diff --git a/lib/blueprinter/v2/extensions/values.rb b/lib/blueprinter/v2/extensions/values.rb index f31e99c1..1752ebf8 100644 --- a/lib/blueprinter/v2/extensions/values.rb +++ b/lib/blueprinter/v2/extensions/values.rb @@ -4,13 +4,6 @@ module Blueprinter module V2 module Extensions class Values < Extension - def collection?(object) - case object - when Array, Set then true - else false - end - end - # @param ctx [Blueprinter::V2::Context] def field_value(ctx) data = ctx.store[ctx.field.object_id] diff --git a/lib/blueprinter/v2/instance_cache.rb b/lib/blueprinter/v2/instance_cache.rb index 3b00e4e5..85a655db 100644 --- a/lib/blueprinter/v2/instance_cache.rb +++ b/lib/blueprinter/v2/instance_cache.rb @@ -9,7 +9,7 @@ def initialize def [](obj) if obj.is_a? Class - @cache[obj] ||= obj.new + @cache[obj.object_id] ||= obj.new else obj end diff --git a/lib/blueprinter/v2/render.rb b/lib/blueprinter/v2/render.rb index def7bbd6..2bbf894e 100644 --- a/lib/blueprinter/v2/render.rb +++ b/lib/blueprinter/v2/render.rb @@ -22,14 +22,13 @@ def to_hash ctx = Context.new(blueprint, nil, nil, @object, @options, instance_cache, {}) object = @serializer.hooks.reduce_into(pre_hook, ctx, :object) - result = + ctx.value = if @collection object.map { |obj| @serializer.call(obj, @options, instance_cache, ctx.store) } else @serializer.call(object, @options, instance_cache, ctx.store) end - ctx = Context.new(blueprint, nil, result, object, @options, instance_cache, ctx.store) @serializer.hooks.reduce_into(post_hook, ctx, :value) end diff --git a/lib/blueprinter/v2/serializer.rb b/lib/blueprinter/v2/serializer.rb index 52fb724d..8abfa343 100644 --- a/lib/blueprinter/v2/serializer.rb +++ b/lib/blueprinter/v2/serializer.rb @@ -6,62 +6,78 @@ module Blueprinter module V2 class Serializer - # Core extensions that must run at the start of serialization - CORE_START = [ - Extensions::Values, - Extensions::Exclusions - ].freeze - - # Core extensions that must run at the end of serialization - CORE_FINISH = [ - Extensions::Output - ].freeze - - attr_reader :blueprint, :formatter, :hooks + attr_reader :blueprint, :formatter, :hooks, :values, :exclusions def initialize(blueprint) - @hooks = Hooks.new(CORE_START.map(&:new) + blueprint.extensions + CORE_FINISH.map(&:new)) + @hooks = Hooks.new([Extensions::Collections.new] + blueprint.extensions + [Extensions::Output.new]) @formatter = Formatter.new(blueprint) @blueprint = blueprint + # "Unroll" these hooks for a significant speed boost + @values = Extensions::Values.new + @exclusions = Extensions::Exclusions.new + block_unused_hooks! end def call(object, options, instances, store) - context = Context.new(instances[blueprint], nil, nil, object, options, instances, store) - if !context.store[blueprint.object_id] - hooks.each(:prepare, context) - context.store[blueprint.object_id] = true - end - hooks.reduce_into(:blueprint_input, context, :object) + ctx = Context.new(instances[blueprint], nil, nil, object, options, instances, store) + ctx.store[blueprint.object_id] ||= prepare! ctx + hooks.reduce_into(:blueprint_input, ctx, :object) if @run_blueprint_input result = blueprint.reflections[:default].sorted.each_with_object({}) do |field, acc| - context.field = field - context.value = nil + ctx.field = field + ctx.value = nil case field when Field - hooks.reduce_into(:field_value, context, :value) - value = formatter.call(context) - acc[field.name] = value unless hooks.any?(:exclude_field?, context) + ctx.value = values.field_value ctx + hooks.reduce_into(:field_value, ctx, :value) if @run_field_value + ctx.value = formatter.call(ctx) + next if exclusions.exclude_field?(ctx) || (@run_exclude_field && hooks.any?(:exclude_field?, ctx)) + + acc[field.name] = ctx.value when ObjectField - value = hooks.reduce_into(:object_value, context, :value) - next if hooks.any?(:exclude_object?, context) + ctx.value = values.object_value ctx + hooks.reduce_into(:object_value, ctx, :value) if @run_object_value + next if exclusions.exclude_object?(ctx) || (@run_exclude_object && hooks.any?(:exclude_object?, ctx)) v2 = instances[field.blueprint].is_a? V2::Base - value = v2 ? field.blueprint.serializer.call(value, options, instances, store) : field.blueprint.render(value, options) if value - acc[field.name] = value + ctx.value = v2 ? field.blueprint.serializer.call(ctx.value, options, instances, store) : field.blueprint.render(ctx.value, options) if ctx.value + acc[field.name] = ctx.value when Collection - value = hooks.reduce_into(:collection_value, context, :value) - next if hooks.any?(:exclude_collection?, context) + ctx.value = values.collection_value ctx + hooks.reduce_into(:collection_value, ctx, :value) if @run_collection_value + next if exclusions.exclude_collection?(ctx) || (@run_exclude_collection && hooks.any?(:exclude_collection?, ctx)) v2 = instances[field.blueprint].is_a? V2::Base - value = v2 ? value.map { |val| field.blueprint.serializer.call(val, options, instances, store) } : field.blueprint.render(value, options) if value - acc[field.name] = value + ctx.value = v2 ? ctx.value.map { |val| field.blueprint.serializer.call(val, options, instances, store) } : field.blueprint.render(ctx.value, options) if ctx.value + acc[field.name] = ctx.value end end - context.field = nil - context.value = result - hooks.reduce_into(:blueprint_output, context, :value) + ctx.field = nil + ctx.value = result + @run_blueprint_output ? hooks.reduce_into(:blueprint_output, ctx, :value) : ctx.value + end + + private + + def prepare!(ctx) + values.prepare ctx + exclusions.prepare ctx + hooks.each(:prepare, ctx) if @run_prepare + true + end + + def block_unused_hooks! + @run_prepare = hooks.has? :prepare + @run_blueprint_input = hooks.has? :blueprint_input + @run_blueprint_output = hooks.has? :blueprint_output + @run_field_value = hooks.has? :field_value + @run_object_value = hooks.has? :object_value + @run_collection_value = hooks.has? :collection_value + @run_exclude_field = hooks.has? :exclude_field? + @run_exclude_object = hooks.has? :exclude_object? + @run_exclude_collection = hooks.has? :exclude_collection? end end end diff --git a/spec/benchmarks/speedtest.rb b/spec/benchmarks/speedtest.rb index 5639a364..7fb04ddf 100644 --- a/spec/benchmarks/speedtest.rb +++ b/spec/benchmarks/speedtest.rb @@ -3,7 +3,9 @@ require 'benchmark' require 'blueprinter' -NAME_FIELDS = 10 +NUM_FIELDS = 10 +NUM_OBJECTS = 5 +NUM_COLLECTIONS = 2 class CategoryBlueprintV1 < Blueprinter::Base field :name @@ -14,53 +16,56 @@ class PartBlueprintV1 < Blueprinter::Base end class WidgetBlueprintV1 < Blueprinter::Base - NAME_FIELDS.times { |i| field :"name#{i}" } - association :category, blueprint: CategoryBlueprintV1 - association :parts, blueprint: PartBlueprintV1 + NUM_FIELDS.times { |i| field :"name_#{i}" } + NUM_OBJECTS.times { |i| association :"category_#{i}", blueprint: CategoryBlueprintV1 } + NUM_COLLECTIONS.times { |i| association :"parts_#{i}", blueprint: PartBlueprintV1 } end -class CategoryBlueprintV2 < Blueprinter::V2::Base +class ApplicationBlueprintV2 < Blueprinter::V2::Base +end + +class CategoryBlueprintV2 < ApplicationBlueprintV2 field :name end -class PartBlueprintV2 < Blueprinter::V2::Base +class PartBlueprintV2 < ApplicationBlueprintV2 field :num end -class WidgetBlueprintV2 < Blueprinter::V2::Base - NAME_FIELDS.times { |i| field :"name#{i}" } - object :category, CategoryBlueprintV2 - collection :parts, PartBlueprintV2 +class WidgetBlueprintV2 < ApplicationBlueprintV2 + NUM_FIELDS.times { |i| field :"name_#{i}" } + NUM_OBJECTS.times { |i| object :"category_#{i}", CategoryBlueprintV2 } + NUM_COLLECTIONS.times { |i| collection :"parts_#{i}", PartBlueprintV2 } end +puts "#{NUM_FIELDS} fields, #{NUM_OBJECTS} objects, #{NUM_COLLECTIONS} collections" + results = Benchmark.bmbm do |x| widgets = 100_000.times.map do |n| - NAME_FIELDS.times. - each_with_object({}) { |i, obj| obj[:"name#{i}"] = "Widget #{n}" }. - merge({ - category: { name: "Category #{n % 50}" }, - parts: (1..rand(1..10)).map { |n| { num: n } } - }) + {}.merge( + NUM_FIELDS.times.each_with_object({}) { |i, obj| obj[:"name_#{i}"] = "Widget #{n}" }, + NUM_OBJECTS.times.each_with_object({}) { |i, obj| obj[:"category_#{i}"] = { name: "Category #{n % 50}" } }, + NUM_COLLECTIONS.times.each_with_object({}) { |i, obj| obj[:"parts_#{i}"] = (1..rand(1..10)).map { |n| { num: n } } }, + ) end [ - [100_000, 1], [10_000, 10], [1000, 100], [500, 100], [250, 100], - [100, 100], - [25, 100], - [5, 100], - [1, 100], + [100, 250], + [25, 500], + [5, 1000], + [1, 1000], ].each do |(n, m)| fmt_n = n.to_s.chars.reverse.each_slice(3).map(&:join).join(',').reverse list = widgets[0,n] - x.report "#{fmt_n} widgets: V1" do - m.times { WidgetBlueprintV1.render(list) } + x.report "#{fmt_n} widgets #{m}x: V1" do + m.times { WidgetBlueprintV1.render_as_hash(list) } end - x.report "#{fmt_n} widgets: V2" do + x.report "#{fmt_n} widgets #{m}x: V2" do m.times { WidgetBlueprintV2.render(list).to_hash } end end diff --git a/spec/extensions/hooks_spec.rb b/spec/extensions/hooks_spec.rb index 2aea224b..10932b44 100644 --- a/spec/extensions/hooks_spec.rb +++ b/spec/extensions/hooks_spec.rb @@ -25,6 +25,13 @@ def exclude_field?(context) end end + it 'should know whether it contains certain hooks' do + hooks = described_class.new [ext1.new, ext2.new] + expect(hooks.has? :output_object).to be true + expect(hooks.has? :exclude_field?).to be true + expect(hooks.has? :exclude_collection?).to be false + end + context 'any?' do it 'should return true if any hook returns true' do hooks = described_class.new [ext1.new, ext2.new] diff --git a/spec/v2/extensions/collections_spec.rb b/spec/v2/extensions/collections_spec.rb new file mode 100644 index 00000000..88ec6f6a --- /dev/null +++ b/spec/v2/extensions/collections_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require 'ostruct' + +describe Blueprinter::V2::Extensions::Collections do + include ExtensionHelpers + + subject { described_class.new } + + it 'should recognize an Array as a collection' do + expect(subject.collection? []).to be true + end + + it 'should recognize a Set as a collection' do + expect(subject.collection? Set.new).to be true + end + + it 'should recognize an Enumerator as a collection' do + enum = Enumerator.new { |y| y << 'foo' } + expect(subject.collection? enum).to be true + end + + it 'should recognize an integer as an object' do + expect(subject.collection? 5).to be false + end + + it 'should recognize a String as an object' do + expect(subject.collection? 'foo').to be false + end + + it 'should recognize a Hash as an object' do + expect(subject.collection?({})).to be false + end + + it 'should recognize a Struct as an object' do + x = Struct.new(:foo) + expect(subject.collection? x.new).to be false + end + + it 'should recognize an OpenStruct as an object' do + x = OpenStruct.new + expect(subject.collection? x).to be false + end +end diff --git a/spec/v2/extensions/extraction_spec.rb b/spec/v2/extensions/extraction_spec.rb index 5e69fe0a..781f29d2 100644 --- a/spec/v2/extensions/extraction_spec.rb +++ b/spec/v2/extensions/extraction_spec.rb @@ -22,18 +22,6 @@ def collection(_blueprint, field, obj, _options) end end - it 'should detect hashes as objects' do - expect(subject.collection? object).to be false - end - - it 'should detect arrays as collections' do - expect(subject.collection? [object]).to be true - end - - it 'should detect sets as collections' do - expect(subject.collection? Set.new([object])).to be true - end - context 'fields' do let(:field) { blueprint.reflections[:default].fields[:foo] } From db3b45dfa5ff001e699f85f0e9d66d1af0219e53 Mon Sep 17 00:00:00 2001 From: Jordan Hollinger Date: Sun, 10 Nov 2024 22:23:10 -0500 Subject: [PATCH 14/16] Pass a Context to V2 extractor Signed-off-by: Jordan Hollinger --- lib/blueprinter/v2/extensions/values.rb | 6 ++--- lib/blueprinter/v2/extractor.rb | 23 +++++++++-------- lib/blueprinter/v2/fields.rb | 13 ++++++++++ spec/v2/extensions/extraction_spec.rb | 12 ++++----- spec/v2/extractor_spec.rb | 34 ++++++++++++++----------- 5 files changed, 54 insertions(+), 34 deletions(-) diff --git a/lib/blueprinter/v2/extensions/values.rb b/lib/blueprinter/v2/extensions/values.rb index 1752ebf8..86216b54 100644 --- a/lib/blueprinter/v2/extensions/values.rb +++ b/lib/blueprinter/v2/extensions/values.rb @@ -7,7 +7,7 @@ class Values < Extension # @param ctx [Blueprinter::V2::Context] def field_value(ctx) data = ctx.store[ctx.field.object_id] - ctx.value = data[:extractor].field(ctx.blueprint, ctx.field, ctx.object, ctx.options) + ctx.value = data[:extractor].field(ctx) default_if = data[:default_if] return ctx.value unless ctx.value.nil? || (default_if && use_default?(default_if, ctx)) @@ -18,7 +18,7 @@ def field_value(ctx) # @param ctx [Blueprinter::V2::Context] def object_value(ctx) data = ctx.store[ctx.field.object_id] - ctx.value = data[:extractor].object(ctx.blueprint, ctx.field, ctx.object, ctx.options) + ctx.value = data[:extractor].object(ctx) default_if = data[:default_if] return ctx.value unless ctx.value.nil? || (default_if && use_default?(default_if, ctx)) @@ -29,7 +29,7 @@ def object_value(ctx) # @param ctx [Blueprinter::V2::Context] def collection_value(ctx) data = ctx.store[ctx.field.object_id] - ctx.value = data[:extractor].collection(ctx.blueprint, ctx.field, ctx.object, ctx.options) + ctx.value = data[:extractor].collection(ctx) default_if = data[:default_if] return ctx.value unless ctx.value.nil? || (default_if && use_default?(default_if, ctx)) diff --git a/lib/blueprinter/v2/extractor.rb b/lib/blueprinter/v2/extractor.rb index 5402e888..251bfbb3 100644 --- a/lib/blueprinter/v2/extractor.rb +++ b/lib/blueprinter/v2/extractor.rb @@ -4,22 +4,25 @@ module Blueprinter module V2 # The default extractor and base class for custom extractors class Extractor - def field(blueprint, field, object, options) - if field.value_proc - blueprint.instance_exec(object, options, &field.value_proc) - elsif object.is_a? Hash - object[field.from] + # @param ctx [Blueprinter::V2::Context] + def field(ctx) + if ctx.field.value_proc + ctx.blueprint.instance_exec(ctx.object, ctx.options, &ctx.field.value_proc) + elsif ctx.object.is_a? Hash + ctx.object[ctx.field.from] else - object.public_send(field.from) + ctx.object.public_send(ctx.field.from) end end - def object(blueprint, field, object, options) - field(blueprint, field, object, options) + # @param ctx [Blueprinter::V2::Context] + def object(ctx) + field ctx end - def collection(blueprint, field, object, options) - field(blueprint, field, object, options) + # @param ctx [Blueprinter::V2::Context] + def collection(ctx) + field ctx end end end diff --git a/lib/blueprinter/v2/fields.rb b/lib/blueprinter/v2/fields.rb index e40ff7fb..fd83e348 100644 --- a/lib/blueprinter/v2/fields.rb +++ b/lib/blueprinter/v2/fields.rb @@ -28,6 +28,19 @@ module V2 keyword_init: true ) + # + # The Context struct is used for most extension hooks and all extractor methods. + # Some fields are always present, others are context-dependant. Each hook and extractor + # method will separately document which fields to expect and any special meanings. + # + # blueprint = Instance of the current Blueprint class (always) + # field = Field | ObjectField | Collection (optional) + # value = The current value of `field` or the Blueprint output (optional) + # object = The object currently being evaluated (e.g. passed to `render` or from an association) (optional) + # options = Options passed to `render` (always) + # instances = Allows this entire render to share instances of Blueprints and Extractors (always) + # store = A Hash to for extensions, etc to cache render data in (always) + # Context = Struct.new(:blueprint, :field, :value, :object, :options, :instances, :store) end end diff --git a/spec/v2/extensions/extraction_spec.rb b/spec/v2/extensions/extraction_spec.rb index 781f29d2..184a8d67 100644 --- a/spec/v2/extensions/extraction_spec.rb +++ b/spec/v2/extensions/extraction_spec.rb @@ -6,17 +6,17 @@ let(:object) { { foo: 'Foo', foo_obj: { name: 'Bar' }, foos: [{ num: 42 }] } } let(:my_extractor) do Class.new(Blueprinter::V2::Extractor) do - def field(_blueprint, field, obj, _options) - obj[field.from].upcase + def field(ctx) + ctx.object[ctx.field.from].upcase end - def object(_blueprint, field, obj, _options) - val = obj[field.from] + def object(ctx) + val = ctx.object[ctx.field.from] val.transform_values { |v| v.upcase } end - def collection(_blueprint, field, obj, _options) - vals = obj[field.from] + def collection(ctx) + vals = ctx.object[ctx.field.from] vals.map { |val| val.transform_values { |v| v * 2 } } end end diff --git a/spec/v2/extractor_spec.rb b/spec/v2/extractor_spec.rb index 75364719..d792f3c6 100644 --- a/spec/v2/extractor_spec.rb +++ b/spec/v2/extractor_spec.rb @@ -2,6 +2,7 @@ describe Blueprinter::V2::Extractor do subject { described_class.new } + let(:context) { Blueprinter::V2::Context } let(:blueprint) do Class.new(Blueprinter::V2::Base) do @@ -14,22 +15,23 @@ def upcase(str) context 'field' do it "should extract using a block" do field = Blueprinter::V2::Field.new(from: :foo, value_proc: ->(obj, _opts) { upcase obj[:foo] }) - obj = { foo: 'bar' } - val = subject.field(blueprint.new, field, obj, {}) + ctx = context.new(blueprint.new, field, nil, { foo: 'bar' }, {}) + val = subject.field(ctx) expect(val).to eq 'BAR' end it "should extract using a Hash key" do field = Blueprinter::V2::Field.new(from: :foo) - obj = { foo: 'bar' } - val = subject.field(blueprint.new, field, obj, {}) + ctx = context.new(blueprint.new, field, nil, { foo: 'bar' }, {}) + val = subject.field(ctx) expect(val).to eq 'bar' end it "should extract using a method name" do field = Blueprinter::V2::Field.new(from: :name) obj = Struct.new(:name).new("Foo") - val = subject.field(blueprint.new, field, obj, {}) + ctx = context.new(blueprint.new, field, nil, obj, {}) + val = subject.field(ctx) expect(val).to eq 'Foo' end end @@ -37,22 +39,23 @@ def upcase(str) context 'object' do it "should extract using a block" do field = Blueprinter::V2::ObjectField.new(from: :foo, value_proc: ->(obj, _opts) { upcase obj[:foo] }) - obj = { foo: 'bar' } - val = subject.object(blueprint.new, field, obj, {}) + ctx = context.new(blueprint.new, field, nil, { foo: 'bar' }, {}) + val = subject.object(ctx) expect(val).to eq 'BAR' end it "should extract using a Hash key" do field = Blueprinter::V2::Field.new(from: :foo) - obj = { foo: 'bar' } - val = subject.object(blueprint.new, field, obj, {}) + ctx = context.new(blueprint.new, field, nil, { foo: 'bar' }, {}) + val = subject.object(ctx) expect(val).to eq 'bar' end it "should extract using a method name" do field = Blueprinter::V2::Field.new(from: :name) obj = Struct.new(:name).new("Foo") - val = subject.object(blueprint.new, field, obj, {}) + ctx = context.new(blueprint.new, field, nil, obj, {}) + val = subject.object(ctx) expect(val).to eq 'Foo' end end @@ -60,22 +63,23 @@ def upcase(str) context 'collection' do it "should extract using a block" do field = Blueprinter::V2::Collection.new(from: :foo, value_proc: ->(obj, _opts) { upcase obj[:foo] }) - obj = { foo: 'bar' } - val = subject.collection(blueprint.new, field, obj, {}) + ctx = context.new(blueprint.new, field, nil, { foo: 'bar' }, {}) + val = subject.collection(ctx) expect(val).to eq 'BAR' end it "should extract using a Hash key" do field = Blueprinter::V2::Field.new(from: :foo) - obj = { foo: 'bar' } - val = subject.collection(blueprint.new, field, obj, {}) + ctx = context.new(blueprint.new, field, nil, { foo: 'bar' }, {}) + val = subject.collection(ctx) expect(val).to eq 'bar' end it "should extract using a method name" do field = Blueprinter::V2::Field.new(from: :name) obj = Struct.new(:name).new("Foo") - val = subject.collection(blueprint.new, field, obj, {}) + ctx = context.new(blueprint.new, field, nil, obj, {}) + val = subject.collection(ctx) expect(val).to eq 'Foo' end end From d0198a6d799182b14dac96f93d06c2b86976a722 Mon Sep 17 00:00:00 2001 From: Jordan Hollinger Date: Tue, 12 Nov 2024 08:33:11 -0500 Subject: [PATCH 15/16] Replace sort_fields hook with blueprint_fields Signed-off-by: Jordan Hollinger --- lib/blueprinter/extension.rb | 24 +++++++++-------- lib/blueprinter/v2/extensions/collections.rb | 4 +++ lib/blueprinter/v2/extensions/field_order.rb | 4 +-- lib/blueprinter/v2/reflection.rb | 6 ++--- lib/blueprinter/v2/serializer.rb | 10 +++++--- spec/extensions/extension_spec.rb | 6 ++--- spec/v2/extensions/collections_spec.rb | 11 ++++++++ spec/v2/extensions/field_order_spec.rb | 5 ++-- spec/v2/reflection_spec.rb | 27 +++----------------- spec/v2/serializer_spec.rb | 21 +++++++++++++++ 10 files changed, 69 insertions(+), 49 deletions(-) diff --git a/lib/blueprinter/extension.rb b/lib/blueprinter/extension.rb index a32c7986..d5db666e 100644 --- a/lib/blueprinter/extension.rb +++ b/lib/blueprinter/extension.rb @@ -5,10 +5,10 @@ module Blueprinter # Base class for all extensions. # # V2 hook call order: - # - sort_fields # - collection? (skipped if calling render_object/render_collection) # - input_object | input_collection # - prepare + # - blueprint_fields # - blueprint_input # - field_value # - exclude_field? @@ -23,16 +23,6 @@ module Blueprinter # - pre_render # class Extension - # - # Returns fields in the order they should appear. Default is the order in which they were defined. - # - # @param fields [Array] - # @return [Array] - # - def sort_fields(fields) - fields - end - # # Returns true if the given object should be treated as a collection (i.e. supports `map { |obj| ... }`). # @@ -51,6 +41,18 @@ def collection?(_object) # def prepare(context); end + # + # Returns the fields that should be included in the correct order. Default is all fields in the order in which they were defined. + # + # NOTE Only runs once per Blueprint per render. + # + # @param context [Blueprinter::V2::Context] + # @return [Array] + # + def blueprint_fields(ctx) + [] + end + # # Modify or replace the object passed to render/render_object. # diff --git a/lib/blueprinter/v2/extensions/collections.rb b/lib/blueprinter/v2/extensions/collections.rb index b01e79f3..52e30846 100644 --- a/lib/blueprinter/v2/extensions/collections.rb +++ b/lib/blueprinter/v2/extensions/collections.rb @@ -12,6 +12,10 @@ def collection?(object) else false end end + + def blueprint_fields(ctx) + ctx.blueprint.class.reflections[:default].ordered + end end end end diff --git a/lib/blueprinter/v2/extensions/field_order.rb b/lib/blueprinter/v2/extensions/field_order.rb index 987ac2fe..b5cb94c7 100644 --- a/lib/blueprinter/v2/extensions/field_order.rb +++ b/lib/blueprinter/v2/extensions/field_order.rb @@ -8,8 +8,8 @@ def initialize(&sorter) @sorter = sorter end - def sort_fields(fields) - fields.sort(&@sorter) + def blueprint_fields(ctx) + ctx.blueprint.class.reflections[:default].ordered.sort(&@sorter) end end end diff --git a/lib/blueprinter/v2/reflection.rb b/lib/blueprinter/v2/reflection.rb index ca90aff5..92d9d8e4 100644 --- a/lib/blueprinter/v2/reflection.rb +++ b/lib/blueprinter/v2/reflection.rb @@ -39,8 +39,8 @@ class View attr_reader :objects # @return [Hash] Associations to collections defined on the view attr_reader :collections - # @return [Array] All fields, objects, and collections ordered by the field_order extension hook (default is the order in which they were defined) - attr_reader :sorted + # @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 @@ -48,7 +48,7 @@ class View # @api private def initialize(blueprint, name) @name = name - @sorted = blueprint.serializer.hooks.last(:sort_fields, blueprint.schema.values) || blueprint.schema.values + @ordered = blueprint.schema.values @fields = blueprint.schema.select { |_, f| f.is_a? Field } @objects = blueprint.schema.select { |_, f| f.is_a? ObjectField } @collections = blueprint.schema.select { |_, f| f.is_a? Collection } diff --git a/lib/blueprinter/v2/serializer.rb b/lib/blueprinter/v2/serializer.rb index 8abfa343..ecf2b905 100644 --- a/lib/blueprinter/v2/serializer.rb +++ b/lib/blueprinter/v2/serializer.rb @@ -19,11 +19,13 @@ def initialize(blueprint) end def call(object, options, instances, store) - ctx = Context.new(instances[blueprint], nil, nil, object, options, instances, store) - ctx.store[blueprint.object_id] ||= prepare! ctx + ctx = Context.new(instances[blueprint], nil, nil, nil, options, instances, store) + store[blueprint.object_id] ||= prepare! ctx + + ctx.object = object hooks.reduce_into(:blueprint_input, ctx, :object) if @run_blueprint_input - result = blueprint.reflections[:default].sorted.each_with_object({}) do |field, acc| + result = ctx.store[blueprint.object_id][:fields].each_with_object({}) do |field, acc| ctx.field = field ctx.value = nil @@ -65,7 +67,7 @@ def prepare!(ctx) values.prepare ctx exclusions.prepare ctx hooks.each(:prepare, ctx) if @run_prepare - true + { fields: hooks.last(:blueprint_fields, ctx).freeze }.freeze end def block_unused_hooks! diff --git a/spec/extensions/extension_spec.rb b/spec/extensions/extension_spec.rb index 056eda28..18c2099e 100644 --- a/spec/extensions/extension_spec.rb +++ b/spec/extensions/extension_spec.rb @@ -9,9 +9,9 @@ let(:object) { { foo: 'Foo' } } let(:context) { Blueprinter::V2::Context } - it 'should default sort fields to the given order' do - fields = [Blueprinter::V2::Field.new(name: :foo), Blueprinter::V2::Field.new(name: :bar)] - expect(subject.new.sort_fields(fields)).to eq fields + it 'should default to no fields' do + ctx = context.new(blueprint.new, nil, nil, object, {}) + expect(subject.new.blueprint_fields(ctx)).to eq [] end it 'should default collections? to false' do diff --git a/spec/v2/extensions/collections_spec.rb b/spec/v2/extensions/collections_spec.rb index 88ec6f6a..78fbcc9f 100644 --- a/spec/v2/extensions/collections_spec.rb +++ b/spec/v2/extensions/collections_spec.rb @@ -41,4 +41,15 @@ x = OpenStruct.new expect(subject.collection? x).to be false end + + it 'should return all fields in the order they were defined' do + blueprint = Class.new(Blueprinter::V2::Base) do + field :name + object :category, self + collection :parts, self + end + ctx = Blueprinter::V2::Context.new(blueprint.new, nil, nil, nil, {}, {}, {}) + + expect(subject.blueprint_fields(ctx).map(&:name)).to eq %i(name category parts) + end end diff --git a/spec/v2/extensions/field_order_spec.rb b/spec/v2/extensions/field_order_spec.rb index 8858e882..0727a316 100644 --- a/spec/v2/extensions/field_order_spec.rb +++ b/spec/v2/extensions/field_order_spec.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true describe Blueprinter::V2::Extensions::FieldOrder do + let(:context) { Blueprinter::V2::Context.new(blueprint.new, nil, nil, nil, {}, {}, {}) } let(:blueprint) do Class.new(Blueprinter::V2::Base) do field :foo @@ -11,7 +12,7 @@ it 'should sort fields alphabetically' do ext = described_class.new { |a, b| a.name <=> b.name } - result = ext.sort_fields(blueprint.schema.values) + result = ext.blueprint_fields(context) expect(result.map(&:name)).to eq %i(bar foo id) end @@ -25,7 +26,7 @@ a.name <=> b.name end end - result = ext.sort_fields(blueprint.schema.values) + result = ext.blueprint_fields(context) expect(result.map(&:name)).to eq %i(id bar foo) end end diff --git a/spec/v2/reflection_spec.rb b/spec/v2/reflection_spec.rb index b83b9502..8644a237 100644 --- a/spec/v2/reflection_spec.rb +++ b/spec/v2/reflection_spec.rb @@ -90,33 +90,12 @@ expect(blueprint.reflections[:extended].collections.keys).to eq %i(widgets) end - it 'should be in the default order' do - names = blueprint.reflections[:default].sorted.map(&:name) + it 'should be in the definition order' do + names = blueprint.reflections[:default].ordered.map(&:name) expect(names).to eq %i(name category) - names = blueprint.reflections[:extended].sorted.map(&:name) + names = blueprint.reflections[:extended].ordered.map(&:name) expect(names).to eq %i(name category widgets description) end - - it 'should be in a custom order' do - ext = Class.new(Blueprinter::Extension) do - def initialize(&sorter) - @sorter = sorter - end - - def sort_fields(fields) - fields.sort(&@sorter) - end - end - - blueprint.extensions << ext.new { |a, b| b.name <=> a.name } - blueprint.extensions << ext.new { |a, b| a.name <=> b.name } - - names = blueprint.reflections[:default].sorted.map(&:name) - expect(names).to eq %i(category name) - - names = blueprint.reflections[:extended].sorted.map(&:name) - expect(names).to eq %i(category description name widgets) - end end end diff --git a/spec/v2/serializer_spec.rb b/spec/v2/serializer_spec.rb index f68bfae4..94155f58 100644 --- a/spec/v2/serializer_spec.rb +++ b/spec/v2/serializer_spec.rb @@ -56,6 +56,27 @@ }) end + it 'should respect the blueprint_fields hook' do + ext = Class.new(Blueprinter::Extension) do + def blueprint_fields(ctx) + ctx.blueprint.class.reflections[:default].ordered.sort_by(&:name) + end + end + widget_blueprint.extensions << ext.new + widget = { + name: 'Foo', + category: { name: 'Bar' }, + parts: [{ num: 42 }, { num: 43 }] + } + + result = described_class.new(widget_blueprint).call(widget, {}, instance_cache, {}) + expect(result.to_json).to eq({ + category: { name: 'Bar' }, + name: 'Foo', + parts: [{ num: 42 }, { num: 43 }] + }.to_json) + end + it 'should enable the if conditionals extension' do widget_blueprint = Class.new(Blueprinter::V2::Base) do field :name From cced12ed2f78053e83af1b4286e82ea7e93b4da7 Mon Sep 17 00:00:00 2001 From: Jordan Hollinger Date: Fri, 15 Nov 2024 23:51:43 -0500 Subject: [PATCH 16/16] Add around and around_blueprint hooks (primarily for instrumenting) Signed-off-by: Jordan Hollinger --- lib/blueprinter/extension.rb | 54 ++++++++++----- lib/blueprinter/hooks.rb | 20 ++++++ lib/blueprinter/v2/extensions.rb | 4 +- .../v2/extensions/{output.rb => postlude.rb} | 3 +- .../extensions/{collections.rb => prelude.rb} | 3 +- lib/blueprinter/v2/render.rb | 20 +++--- lib/blueprinter/v2/serializer.rb | 15 ++++- spec/extensions/hooks_spec.rb | 67 +++++++++++++++++++ spec/v2/extensions/output_spec.rb | 2 +- .../{collections_spec.rb => prelude_spec.rb} | 2 +- spec/v2/render_spec.rb | 45 +++++++++++++ spec/v2/serializer_spec.rb | 41 ++++++++++++ 12 files changed, 241 insertions(+), 35 deletions(-) rename lib/blueprinter/v2/extensions/{output.rb => postlude.rb} (87%) rename lib/blueprinter/v2/extensions/{collections.rb => prelude.rb} (82%) rename spec/v2/extensions/{collections_spec.rb => prelude_spec.rb} (96%) diff --git a/lib/blueprinter/extension.rb b/lib/blueprinter/extension.rb index d5db666e..62493f6e 100644 --- a/lib/blueprinter/extension.rb +++ b/lib/blueprinter/extension.rb @@ -6,18 +6,20 @@ module Blueprinter # # V2 hook call order: # - collection? (skipped if calling render_object/render_collection) - # - input_object | input_collection - # - prepare - # - blueprint_fields - # - blueprint_input - # - field_value - # - exclude_field? - # - object_value - # - exclude_object? - # - collection_value - # - exclude_collection? - # - blueprint_output - # - output_object | output_collection + # - around + # - input_object | input_collection + # - around_blueprint + # - prepare (only first time during a given render) + # - blueprint_fields (only first time during a given render) + # - blueprint_input + # - field_value + # - exclude_field? + # - object_value + # - exclude_object? + # - collection_value + # - exclude_collection? + # - blueprint_output + # - output_object | output_collection # # V1 hook call order: # - pre_render @@ -26,30 +28,48 @@ class Extension # # Returns true if the given object should be treated as a collection (i.e. supports `map { |obj| ... }`). # - # @param [Object] + # @param _object [Object] # @return [Boolean] # def collection?(_object) false end + # + # Runs around the entire rendering process. MUST yield! + # + # @param _context [Blueprinter::V2::Context] + # + def around(_context) + yield + end + + # + # Runs around any Blueprint serialization. Surrounds the `prepare` through `blueprint_output` hooks. MUST yield! + # + # @param _context [Blueprinter::V2::Context] + # + def around_blueprint(_context) + yield + end + # # 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] + # @param _context [Blueprinter::V2::Context] # - def prepare(context); end + def prepare(_context); end # # Returns the fields that should be included in the correct order. Default is all fields in the order in which they were defined. # # NOTE Only runs once per Blueprint per render. # - # @param context [Blueprinter::V2::Context] + # @param _context [Blueprinter::V2::Context] # @return [Array] # - def blueprint_fields(ctx) + def blueprint_fields(_context) [] end diff --git a/lib/blueprinter/hooks.rb b/lib/blueprinter/hooks.rb index 28096352..1e04268b 100644 --- a/lib/blueprinter/hooks.rb +++ b/lib/blueprinter/hooks.rb @@ -84,5 +84,25 @@ def reduce_into(hook, target_obj, target_attr) end target_obj[target_attr] end + + # + # Runs nested hooks that yield. A block MUST be passed, and it will be run at the "apex" of + # the nested hooks. + # + # @param hook [Symbol] Name of hook to call + # @param arg [Object] Argument to hook + # @return [Object] The return value from the block passed to this method + # + def around(hook, arg) + result = nil + @hooks.fetch(hook).reverse.reduce(-> { result = yield }) do |f, ext| + proc do + yielded = false + ext.public_send(hook, arg) { yielded = true; f.call } + raise BlueprinterError, "Extension hook '#{ext.class.name}##{hook}' did not yield" unless yielded + end + end.call + result + end end end diff --git a/lib/blueprinter/v2/extensions.rb b/lib/blueprinter/v2/extensions.rb index 6565309b..59676f68 100644 --- a/lib/blueprinter/v2/extensions.rb +++ b/lib/blueprinter/v2/extensions.rb @@ -3,10 +3,10 @@ module Blueprinter module V2 module Extensions - autoload :Collections, 'blueprinter/v2/extensions/collections' + autoload :Postlude, 'blueprinter/v2/extensions/postlude' + autoload :Prelude, 'blueprinter/v2/extensions/prelude' autoload :Exclusions, 'blueprinter/v2/extensions/exclusions' autoload :FieldOrder, 'blueprinter/v2/extensions/field_order' - autoload :Output, 'blueprinter/v2/extensions/output' autoload :Values, 'blueprinter/v2/extensions/values' end end diff --git a/lib/blueprinter/v2/extensions/output.rb b/lib/blueprinter/v2/extensions/postlude.rb similarity index 87% rename from lib/blueprinter/v2/extensions/output.rb rename to lib/blueprinter/v2/extensions/postlude.rb index 2ea48341..1eb1a228 100644 --- a/lib/blueprinter/v2/extensions/output.rb +++ b/lib/blueprinter/v2/extensions/postlude.rb @@ -3,7 +3,8 @@ module Blueprinter module V2 module Extensions - class Output < Extension + # Hooks that should run after everything else + class Postlude < Extension def output_object(ctx) root_name = ctx.options[:root] || ctx.blueprint.class.options[:root] return ctx.value if root_name.nil? diff --git a/lib/blueprinter/v2/extensions/collections.rb b/lib/blueprinter/v2/extensions/prelude.rb similarity index 82% rename from lib/blueprinter/v2/extensions/collections.rb rename to lib/blueprinter/v2/extensions/prelude.rb index 52e30846..aececff7 100644 --- a/lib/blueprinter/v2/extensions/collections.rb +++ b/lib/blueprinter/v2/extensions/prelude.rb @@ -5,7 +5,8 @@ module Blueprinter module V2 module Extensions - class Collections < Extension + # Hooks that should run before anything else + class Prelude < Extension def collection?(object) case object when Array, Set, Enumerator then true diff --git a/lib/blueprinter/v2/render.rb b/lib/blueprinter/v2/render.rb index 2bbf894e..d4858faa 100644 --- a/lib/blueprinter/v2/render.rb +++ b/lib/blueprinter/v2/render.rb @@ -20,16 +20,16 @@ def to_hash post_hook = @collection ? :output_collection : :output_object ctx = Context.new(blueprint, nil, nil, @object, @options, instance_cache, {}) - object = @serializer.hooks.reduce_into(pre_hook, ctx, :object) - - ctx.value = - if @collection - object.map { |obj| @serializer.call(obj, @options, instance_cache, ctx.store) } - else - @serializer.call(object, @options, instance_cache, ctx.store) - end - - @serializer.hooks.reduce_into(post_hook, ctx, :value) + @serializer.hooks.around(:around, ctx) do + object = @serializer.hooks.reduce_into(pre_hook, ctx, :object) + ctx.value = + if @collection + object.map { |obj| @serializer.call(obj, @options, instance_cache, ctx.store) } + else + @serializer.call(object, @options, instance_cache, ctx.store) + end + @serializer.hooks.reduce_into(post_hook, ctx, :value) + end end def to_json diff --git a/lib/blueprinter/v2/serializer.rb b/lib/blueprinter/v2/serializer.rb index ecf2b905..ae55ce5c 100644 --- a/lib/blueprinter/v2/serializer.rb +++ b/lib/blueprinter/v2/serializer.rb @@ -9,7 +9,7 @@ class Serializer attr_reader :blueprint, :formatter, :hooks, :values, :exclusions def initialize(blueprint) - @hooks = Hooks.new([Extensions::Collections.new] + blueprint.extensions + [Extensions::Output.new]) + @hooks = Hooks.new([Extensions::Prelude.new] + blueprint.extensions + [Extensions::Postlude.new]) @formatter = Formatter.new(blueprint) @blueprint = blueprint # "Unroll" these hooks for a significant speed boost @@ -19,9 +19,19 @@ def initialize(blueprint) end def call(object, options, instances, store) + if @run_around_blueprint + ctx = Context.new(instances[blueprint], nil, nil, object, options, instances, store) + hooks.around(:around_blueprint, ctx) { call_blueprint(object, options, instances, store) } + else + call_blueprint(object, options, instances, store) + end + end + + private + + def call_blueprint(object, options, instances, store) ctx = Context.new(instances[blueprint], nil, nil, nil, options, instances, store) store[blueprint.object_id] ||= prepare! ctx - ctx.object = object hooks.reduce_into(:blueprint_input, ctx, :object) if @run_blueprint_input @@ -71,6 +81,7 @@ def prepare!(ctx) end def block_unused_hooks! + @run_around_blueprint = hooks.has? :around_blueprint @run_prepare = hooks.has? :prepare @run_blueprint_input = hooks.has? :blueprint_input @run_blueprint_output = hooks.has? :blueprint_output diff --git a/spec/extensions/hooks_spec.rb b/spec/extensions/hooks_spec.rb index 10932b44..7c13eb45 100644 --- a/spec/extensions/hooks_spec.rb +++ b/spec/extensions/hooks_spec.rb @@ -113,4 +113,71 @@ def exclude_field?(context) expect(result).to eq({ name: 'Foo' }) end end + + context 'around' do + let(:ext_a) do + Class.new(Blueprinter::Extension) do + def initialize(log) + @log = log + end + + def around_blueprint(ctx) + @log << "A: #{ctx.store[:value]}" + yield + @log << "A END" + end + end + end + + let(:ext_b) do + Class.new(ext_a) do + def around_blueprint(ctx) + @log << "B: #{ctx.store[:value]}" + yield + @log << "B END" + end + end + end + + let(:ext_c) do + Class.new(ext_a) do + def around_blueprint(ctx) + @log << "C: #{ctx.store[:value]}" + yield + @log << "C END" + end + end + end + + it 'should nest calls' do + log = [] + ctx = Blueprinter::V2::Context.new(nil, nil, nil, nil, nil, nil, { value: 42 }) + hooks = described_class.new [ext_a.new(log), ext_b.new(log), ext_c.new(log)] + hooks.around(:around_blueprint, ctx) { log << 'INNER' } + expect(log).to eq ['A: 42', 'B: 42', 'C: 42', 'INNER', 'C END', 'B END', 'A END',] + end + + it 'should return the inner value' do + ctx = Blueprinter::V2::Context.new(nil, nil, nil, nil, nil, nil, {}) + hooks = described_class.new [ext_a.new([]), ext_b.new([]), ext_c.new([])] + result = hooks.around(:around_blueprint, ctx) { 42 } + expect(result).to eq 42 + end + + it 'should return the inner with no hooks' do + ctx = Blueprinter::V2::Context.new(nil, nil, nil, nil, nil, nil, {}) + hooks = described_class.new [] + result = hooks.around(:around_blueprint, ctx) { 42 } + expect(result).to eq 42 + end + + it "should raise if a hook doesn't yield" do + ext = Class.new(Blueprinter::Extension) do + def around_blueprint(_ctx); end + end + ctx = Blueprinter::V2::Context.new(nil, nil, nil, nil, nil, nil, {}) + hooks = described_class.new [ext.new] + expect { hooks.around(:around_blueprint, ctx) { 42 } }.to raise_error Blueprinter::BlueprinterError + end + end end diff --git a/spec/v2/extensions/output_spec.rb b/spec/v2/extensions/output_spec.rb index fe906167..8f9ebd55 100644 --- a/spec/v2/extensions/output_spec.rb +++ b/spec/v2/extensions/output_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -describe Blueprinter::V2::Extensions::Output do +describe Blueprinter::V2::Extensions::Postlude do subject { described_class.new } let(:context) { Blueprinter::V2::Context } let(:blueprint) do diff --git a/spec/v2/extensions/collections_spec.rb b/spec/v2/extensions/prelude_spec.rb similarity index 96% rename from spec/v2/extensions/collections_spec.rb rename to spec/v2/extensions/prelude_spec.rb index 78fbcc9f..40e95e93 100644 --- a/spec/v2/extensions/collections_spec.rb +++ b/spec/v2/extensions/prelude_spec.rb @@ -2,7 +2,7 @@ require 'ostruct' -describe Blueprinter::V2::Extensions::Collections do +describe Blueprinter::V2::Extensions::Prelude do include ExtensionHelpers subject { described_class.new } diff --git a/spec/v2/render_spec.rb b/spec/v2/render_spec.rb index 2158586a..673e7b47 100644 --- a/spec/v2/render_spec.rb +++ b/spec/v2/render_spec.rb @@ -137,4 +137,49 @@ def input_collection(ctx) }] }) end + + it 'should run the around hook around all other render hooks' do + ext = Class.new(Blueprinter::Extension) do + def initialize(log) + @log = log + end + + def around(ctx) + @log << 'around: a' + yield + @log << 'around: b' + end + + def input_object(ctx) + @log << 'input_object' + ctx.object + end + + def input_collection(ctx) + @log << 'input_collection' + ctx.object + end + + def output_object(ctx) + @log << 'output_object' + ctx.value + end + + def output_collection(ctx) + @log << 'output_collection' + ctx.value + end + end + log = [] + category_blueprint.extensions << ext.new(log) + serializer = Blueprinter::V2::Serializer.new(category_blueprint) + result = described_class.new({ n: 'Foo' }, {}, serializer: serializer, collection: false).to_hash + expect(result).to eq({ name: 'Foo' }) + expect(log).to eq ['around: a', 'input_object', 'output_object', 'around: b'] + + log.clear + result = described_class.new([{ n: 'Foo' }], {}, serializer: serializer, collection: true).to_hash + expect(result).to eq([{ name: 'Foo' }]) + expect(log).to eq ['around: a', 'input_collection', 'output_collection', 'around: b'] + end end diff --git a/spec/v2/serializer_spec.rb b/spec/v2/serializer_spec.rb index 94155f58..e8cc9474 100644 --- a/spec/v2/serializer_spec.rb +++ b/spec/v2/serializer_spec.rb @@ -218,6 +218,47 @@ def blueprint_output(_ctx) expect(result).to eq({ name: 'Foo' }) end + it 'should run around_blueprint around all other serializer hooks' do + ext = Class.new(Blueprinter::Extension) do + def initialize(log) + @log = log + end + + def around_blueprint(ctx) + @log << "around_blueprint (#{ctx.object[:name]}): a" + yield + @log << "around_blueprint (#{ctx.object[:name]}): b" + end + + def prepare(ctx) + @log << "prepare (#{ctx.object || "sans object"})" + end + + def blueprint_input(ctx) + @log << 'blueprint_input' + ctx.object + end + + def blueprint_output(ctx) + @log << 'blueprint_output' + ctx.value + 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).call(widget, {}, instance_cache, {}) + expect(result).to eq(widget) + expect(log).to eq [ + 'around_blueprint (Foo): a', + 'prepare (sans object)', + 'blueprint_input', + 'blueprint_output', + 'around_blueprint (Foo): b', + ] + end + it 'should put fields in the order they were defined' do blueprint = Class.new(widget_blueprint) do field :description