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/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..62493f6e 100644 --- a/lib/blueprinter/extension.rb +++ b/lib/blueprinter/extension.rb @@ -2,11 +2,199 @@ module Blueprinter # - # Base class for all extensions. All extension methods are implemented as no-ops. + # Base class for all extensions. + # + # V2 hook call order: + # - collection? (skipped if calling render_object/render_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 # class Extension # - # Called eary during "render", this method receives the object to be rendered and + # Returns true if the given object should be treated as a collection (i.e. supports `map { |obj| ... }`). + # + # @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] + # + 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(_context) + [] + 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..1e04268b --- /dev/null +++ b/lib/blueprinter/hooks.rb @@ -0,0 +1,108 @@ +# 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 + + # + # 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 + + # + # 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 + + # + # 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.rb b/lib/blueprinter/v2.rb index c9adf3d6..e79e6199 100644 --- a/lib/blueprinter/v2.rb +++ b/lib/blueprinter/v2.rb @@ -6,7 +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 c21f1c4c..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 @@ -17,12 +20,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 +40,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 @@ -79,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) @@ -87,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 @@ -116,12 +127,17 @@ def self.run_eval! end excludes.each { |f| schema.delete f } - @evaled = true - end + extensions.freeze + options.freeze + formatters.freeze + schema.freeze + schema.each do |_, f| + f.options&.freeze + f.freeze + end - # @api private - def self.array_like?(obj) - # TODO + @serializer = Serializer.new(self) + @evaled = true end end end diff --git a/lib/blueprinter/v2/dsl.rb b/lib/blueprinter/v2/dsl.rb index fdd277a0..dbe4792d 100644 --- a/lib/blueprinter/v2/dsl.rb +++ b/lib/blueprinter/v2/dsl.rb @@ -37,11 +37,29 @@ 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. # # @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` + # @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] # @@ -61,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 @@ -71,6 +89,13 @@ 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 + # @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] # @@ -91,6 +116,13 @@ 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 + # @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 new file mode 100644 index 00000000..59676f68 --- /dev/null +++ b/lib/blueprinter/v2/extensions.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Blueprinter + module V2 + module Extensions + 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 :Values, 'blueprinter/v2/extensions/values' + end + end +end diff --git a/lib/blueprinter/v2/extensions/exclusions.rb b/lib/blueprinter/v2/extensions/exclusions.rb new file mode 100644 index 00000000..97c8fbfb --- /dev/null +++ b/lib/blueprinter/v2/extensions/exclusions.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module Blueprinter + module V2 + module Extensions + class Exclusions < Extension + # @param ctx [Blueprinter::V2::Context] + def exclude_field?(ctx) + 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 = data[: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) + exclude_field? ctx + end + + # @param ctx [Blueprinter::V2::Context] + def exclude_collection?(ctx) + exclude_field? 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][: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 + end + end + end + end +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..b5cb94c7 --- /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 blueprint_fields(ctx) + ctx.blueprint.class.reflections[:default].ordered.sort(&@sorter) + end + end + end + end +end diff --git a/lib/blueprinter/v2/extensions/postlude.rb b/lib/blueprinter/v2/extensions/postlude.rb new file mode 100644 index 00000000..1eb1a228 --- /dev/null +++ b/lib/blueprinter/v2/extensions/postlude.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Blueprinter + module V2 + module Extensions + # 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? + + 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/extensions/prelude.rb b/lib/blueprinter/v2/extensions/prelude.rb new file mode 100644 index 00000000..aececff7 --- /dev/null +++ b/lib/blueprinter/v2/extensions/prelude.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'set' + +module Blueprinter + module V2 + module Extensions + # Hooks that should run before anything else + class Prelude < Extension + def collection?(object) + case object + when Array, Set, Enumerator then true + else false + end + end + + def blueprint_fields(ctx) + ctx.blueprint.class.reflections[:default].ordered + end + end + 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..86216b54 --- /dev/null +++ b/lib/blueprinter/v2/extensions/values.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +module Blueprinter + module V2 + module Extensions + 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) + + default_if = data[:default_if] + return ctx.value unless ctx.value.nil? || (default_if && use_default?(default_if, ctx)) + + get_default(data[:default], ctx) + end + + # @param ctx [Blueprinter::V2::Context] + def object_value(ctx) + data = ctx.store[ctx.field.object_id] + 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)) + + get_default(data[:default], ctx) + end + + # @param ctx [Blueprinter::V2::Context] + def collection_value(ctx) + data = ctx.store[ctx.field.object_id] + 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)) + + 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 + + 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 + end + end + end +end diff --git a/lib/blueprinter/v2/extractor.rb b/lib/blueprinter/v2/extractor.rb new file mode 100644 index 00000000..251bfbb3 --- /dev/null +++ b/lib/blueprinter/v2/extractor.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Blueprinter + module V2 + # The default extractor and base class for custom extractors + class Extractor + # @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 + ctx.object.public_send(ctx.field.from) + end + end + + # @param ctx [Blueprinter::V2::Context] + def object(ctx) + field ctx + end + + # @param ctx [Blueprinter::V2::Context] + def collection(ctx) + field ctx + end + end + end +end diff --git a/lib/blueprinter/v2/fields.rb b/lib/blueprinter/v2/fields.rb index 47326813..fd83e348 100644 --- a/lib/blueprinter/v2/fields.rb +++ b/lib/blueprinter/v2/fields.rb @@ -27,5 +27,20 @@ module V2 :options, 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/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/lib/blueprinter/v2/instance_cache.rb b/lib/blueprinter/v2/instance_cache.rb new file mode 100644 index 00000000..85a655db --- /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.object_id] ||= 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..92d9d8e4 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 in the order they were defined + attr_reader :ordered # @param blueprint [Class] A subclass of Blueprinter::V2::Base @@ -46,6 +48,7 @@ class View # @api private def initialize(blueprint, name) @name = name + @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/render.rb b/lib/blueprinter/v2/render.rb new file mode 100644 index 00000000..d4858faa --- /dev/null +++ b/lib/blueprinter/v2/render.rb @@ -0,0 +1,41 @@ +# 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, {}) + @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 + # 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..ae55ce5c --- /dev/null +++ b/lib/blueprinter/v2/serializer.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require 'blueprinter/hooks' +require 'blueprinter/v2/formatter' + +module Blueprinter + module V2 + class Serializer + attr_reader :blueprint, :formatter, :hooks, :values, :exclusions + + def initialize(blueprint) + @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 + @values = Extensions::Values.new + @exclusions = Extensions::Exclusions.new + block_unused_hooks! + 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 + + result = ctx.store[blueprint.object_id][:fields].each_with_object({}) do |field, acc| + ctx.field = field + ctx.value = nil + + case field + when Field + 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 + 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 + 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 + 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 + 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 + + 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 + { fields: hooks.last(:blueprint_fields, ctx).freeze }.freeze + 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 + @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 +end diff --git a/spec/benchmarks/speedtest.rb b/spec/benchmarks/speedtest.rb new file mode 100644 index 00000000..7fb04ddf --- /dev/null +++ b/spec/benchmarks/speedtest.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require 'benchmark' +require 'blueprinter' + +NUM_FIELDS = 10 +NUM_OBJECTS = 5 +NUM_COLLECTIONS = 2 + +class CategoryBlueprintV1 < Blueprinter::Base + field :name +end + +class PartBlueprintV1 < Blueprinter::Base + field :num +end + +class WidgetBlueprintV1 < Blueprinter::Base + 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 ApplicationBlueprintV2 < Blueprinter::V2::Base +end + +class CategoryBlueprintV2 < ApplicationBlueprintV2 + field :name +end + +class PartBlueprintV2 < ApplicationBlueprintV2 + field :num +end + +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| + {}.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 + + [ + [10_000, 10], + [1000, 100], + [500, 100], + [250, 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 #{m}x: V1" do + m.times { WidgetBlueprintV1.render_as_hash(list) } + end + + x.report "#{fmt_n} widgets #{m}x: V2" do + m.times { WidgetBlueprintV2.render(list).to_hash } + end + end +end + +puts "" +results. + 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) + pcnt = ('%0.2f' % n).rjust(5, '0') + puts "#{label} V2 #{pcnt}% faster (#{'%.4f' % (v1 - v2)} sec)" + else + n = (100 - (v1 / v2) * 100).round(2) + pcnt = ('%0.2f' % n).rjust(5, '0') + puts "#{label} V2 #{pcnt}% slower (#{'%.4f' % (v2 - v1)} sec)" + end + end diff --git a/spec/extensions/extension_spec.rb b/spec/extensions/extension_spec.rb new file mode 100644 index 00000000..18c2099e --- /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 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 + 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..7c13eb45 --- /dev/null +++ b/spec/extensions/hooks_spec.rb @@ -0,0 +1,183 @@ +# 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 + + 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] + 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 + + 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/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/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 diff --git a/spec/v2/extensions/default_values_spec.rb b/spec/v2/extensions/default_values_spec.rb new file mode 100644 index 00000000..64d4d1cb --- /dev/null +++ b/spec/v2/extensions/default_values_spec.rb @@ -0,0 +1,590 @@ +# frozen_string_literal: true + +describe Blueprinter::V2::Extensions::Values do + include ExtensionHelpers + + context 'fields' do + let(:field) { blueprint.reflections[:default].fields[:foo] } + let(:object) { { foo: 'Foo' } } + + it 'should pass values through by default' do + 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' + 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 } + 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[: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[: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[: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[: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[: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[: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[:foo] = nil + blueprint.field :foo, default: :was + ctx = prepare(blueprint, field, nil, object, {}) + expect(subject.field_value ctx).to eq 'was nil' + end + + it 'should use blueprint options field_default' do + object[:foo] = nil + blueprint.options[:field_default] = 'Bar' + 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[:foo] = nil + blueprint.options[:field_default] = ->(ctx) { "Bar (#{was ctx})" } + 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[:foo] = nil + blueprint.options[:field_default] = :was + 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 = prepare(blueprint, field, nil, object, { field_default: 'Bar', field_default_if: ->(ctx) { is? ctx, 'Foo' } }) + expect(subject.field_value ctx).to eq 'Bar' + + 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 + 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 = 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 = prepare(blueprint, field, 'Foo', object, { field_default_if: ->(ctx) { is? ctx, 'Foo' } }) + expect(subject.field_value ctx).to eq 'Bar' + + 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 (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 + + 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 (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 + + 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 (Proc) (default = blueprint options field_default)' do + blueprint.field :foo, default: 'Bar', default_if: ->(ctx) { is? ctx, '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 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 (Proc) (default = options field_default)' do + blueprint.options[:field_default_if] = ->(ctx) { is? ctx, 'Foo' } + 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 = 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 (Proc) (default = field options default)' do + blueprint.options[:field_default_if] = ->(ctx) { is? ctx, 'Foo' } + 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? + 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 (Proc) (default = blueprint options field_default)' do + blueprint.options[:field_default_if] = ->(ctx) { is? ctx, 'Foo' } + blueprint.options[:field_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 = blueprint options field_default)' do + blueprint.options[:field_default_if] = :foo? + 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) { blueprint.reflections[:default].objects[:foo_obj] } + let(:object) { { foo_obj: 'Foo' } } + + it 'should pass values through by default' do + 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' + 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 } + 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[: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[: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[: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[: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[: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[: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[: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[:foo_obj] = nil + blueprint.options[:object_default] = 'Bar' + 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[:foo_obj] = nil + blueprint.options[:object_default] = ->(ctx) { "Bar (#{was ctx})" } + 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[:foo_obj] = nil + blueprint.options[:object_default] = :was + 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 = prepare(blueprint, field, nil, object, { object_default: 'Bar', object_default_if: ->(ctx) { is? ctx, 'Foo' } }) + expect(subject.object_value ctx).to eq 'Bar' + + 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 + 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 = 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 = prepare(blueprint, field, nil, object, { object_default_if: ->(ctx) { is? ctx, 'Foo' } }) + expect(subject.object_value ctx).to eq 'Bar' + + 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 (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 + + 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 (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 + + 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 (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 = prepare(blueprint, field, nil, object, {}) + expect(subject.object_value ctx).to eq 'Bar' + end + + 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 = prepare(blueprint, field, nil, object, {}) + expect(subject.object_value ctx).to eq 'Bar' + end + + 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 = 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 = 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 (Proc) (default = field options default)' do + blueprint.options[:object_default_if] = ->(ctx) { is? ctx, 'Foo' } + 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? + 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 (Proc) (default = blueprint options object_default)' do + blueprint.options[:object_default_if] = ->(ctx) { is? ctx, 'Foo' } + blueprint.options[:object_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 = blueprint options object_default)' do + blueprint.options[:object_default_if] = :foo? + 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) { blueprint.reflections[:default].collections[:foos] } + let(:object) { { foos: 'Foo' } } + + it 'should pass values through by default' do + 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' + 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 } + 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[: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[: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[: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[: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[: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[: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[: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[:foos] = nil + blueprint.options[:collection_default] = 'Bar' + 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[:foos] = nil + blueprint.options[:collection_default] = ->(ctx) { "Bar (#{was ctx})" } + 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[:foos] = nil + blueprint.options[:collection_default] = :was + 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 = prepare(blueprint, field, nil, object, { collection_default: 'Bar', collection_default_if: ->(ctx) { is? ctx, 'Foo' } }) + expect(subject.collection_value ctx).to eq 'Bar' + + 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 + 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 = 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 = prepare(blueprint, field, nil, object, { collection_default_if: ->(ctx) { is? ctx, 'Foo' } }) + expect(subject.collection_value ctx).to eq 'Bar' + + 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 (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 + + 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 (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 + + 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 (Proc) (default = blueprint options collection_default)' do + blueprint.collection :foos, sub_blueprint, default_if: ->(ctx) { is? ctx, '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 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 (Proc) (default = options collection_default)' do + blueprint.options[:collection_default_if] = ->(ctx) { is? ctx, 'Foo' } + 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 = 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 (Proc) (default = field options default)' do + blueprint.options[:collection_default_if] = ->(ctx) { is? ctx, 'Foo' } + 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? + 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 (Proc) (default = blueprint options collection_default)' do + blueprint.options[:collection_default_if] = ->(ctx) { is? ctx, '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 (Symbol) (default = blueprint options collection_default)' do + blueprint.options[:collection_default_if] = :foo? + blueprint.options[:collection_default] = 'Bar' + ctx = prepare(blueprint, field, nil, object, {}) + expect(subject.collection_value ctx).to eq 'Bar' + end + end +end 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..c98c2aa7 --- /dev/null +++ b/spec/v2/extensions/exclude_if_empty_spec.rb @@ -0,0 +1,201 @@ +# frozen_string_literal: true + +describe Blueprinter::V2::Extensions::Exclusions do + include ExtensionHelpers + let(:object) { { foo: 'Foo' } } + + context 'fields' do + let(:field) { blueprint.reflections[:default].fields[:foo] } + + it 'should be allowed by default' do + 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 = prepare(blueprint, field, nil, object, {}) + expect(subject.exclude_field? ctx).to be false + end + + it 'should be allowed with options set' do + 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 = 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 = 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 + 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 + 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 + 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 = 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 = 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 = 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 = 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 = prepare(blueprint, field, nil, object, {}) + expect(subject.exclude_object? ctx).to be false + end + + it 'should be allowed with options set' do + 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 = 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 = 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 + 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 + 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 + 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 = 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 = 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 = 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 = 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 = prepare(blueprint, field, nil, object, {}) + expect(subject.exclude_collection? ctx).to be false + end + + it 'should be allowed with options set' do + 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 = 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 = 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 + 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 + 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 + 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 = 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 = 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 = prepare(blueprint, 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..b266f86b --- /dev/null +++ b/spec/v2/extensions/exclude_if_nil_spec.rb @@ -0,0 +1,150 @@ +# frozen_string_literal: true + +describe Blueprinter::V2::Extensions::Exclusions do + include ExtensionHelpers + let(:object) { { foo: 'Foo' } } + + context 'fields' do + let(:field) { blueprint.reflections[:default].fields[:foo] } + + it 'should be allowed by default' do + 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 = prepare(blueprint, field, nil, object, {}) + expect(subject.exclude_field? ctx).to be false + end + + it 'should be allowed with options set' do + 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 = 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 + 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 + 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 = 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 = 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 = 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 = prepare(blueprint, field, nil, object, {}) + expect(subject.exclude_object? ctx).to be false + end + + it 'should be allowed with options set' do + 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 = 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 + 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 + 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 = 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 = 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 = 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 = prepare(blueprint, field, nil, object, {}) + expect(subject.exclude_collection? ctx).to be false + end + + it 'should be allowed with options set' do + 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 = 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 + 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 + 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 = 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 = prepare(blueprint, field, nil, object, {}) + expect(subject.exclude_collection? ctx).to be true + 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..184a8d67 --- /dev/null +++ b/spec/v2/extensions/extraction_spec.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +describe Blueprinter::V2::Extensions::Values do + 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(ctx) + ctx.object[ctx.field.from].upcase + end + + def object(ctx) + val = ctx.object[ctx.field.from] + val.transform_values { |v| v.upcase } + end + + def collection(ctx) + vals = ctx.object[ctx.field.from] + vals.map { |val| val.transform_values { |v| v * 2 } } + end + end + end + + context 'fields' do + let(:field) { blueprint.reflections[:default].fields[:foo] } + + it 'should extract a field with the default extractor' do + 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 + 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 = prepare(blueprint, field, nil, object, {}) + expect(subject.field_value ctx).to eq 'FOO' + end + end + + context 'objects' do + let(:field) { blueprint.reflections[:default].objects[:foo_obj] } + + it 'should extract an object the default extractor' do + 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 + 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 = prepare(blueprint, field, nil, object, {}) + expect(subject.object_value ctx).to eq({ name: 'BAR' }) + end + end + + context 'collections' do + let(:field) { blueprint.reflections[:default].collections[:foos] } + + it 'should extract an object the default extractor' do + 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 + 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 = prepare(blueprint, field, nil, object, {}) + expect(subject.collection_value ctx).to eq [{ num: 84 }] + 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..0727a316 --- /dev/null +++ b/spec/v2/extensions/field_order_spec.rb @@ -0,0 +1,32 @@ +# 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 + 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.blueprint_fields(context) + 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.blueprint_fields(context) + expect(result.map(&:name)).to eq %i(id bar foo) + 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..954c347c --- /dev/null +++ b/spec/v2/extensions/if_conditionals_spec.rb @@ -0,0 +1,189 @@ +# frozen_string_literal: true + +describe Blueprinter::V2::Extensions::Exclusions do + include ExtensionHelpers + let(:object) { { foo: 'Foo' } } + + context 'fields' do + let(:field) { blueprint.reflections[:default].fields[:foo] } + + it 'should be allowed by default' do + ctx = prepare(blueprint, field, 'Foo', object, {}) + expect(subject.exclude_field? ctx).to be false + end + + it 'should check options field_if (Proc)' do + ctx = prepare(blueprint, field, 'Foo', object, { field_if: ->(ctx) { foo? ctx } }) + expect(subject.exclude_field? ctx).to be false + + 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 + blueprint.field :foo, if: ->(ctx) { foo? ctx } + ctx = prepare(blueprint, field, 'Foo', object, {}) + expect(subject.exclude_field? ctx).to be false + + 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 = prepare(blueprint, field, 'Foo', object, {}) + expect(subject.exclude_field? ctx).to be false + + ctx = prepare(blueprint, field, 'Bar', object, {}) + expect(subject.exclude_field? ctx).to be true + end + + it 'should check options field_if (Symbol)' do + ctx = prepare(blueprint, field, 'Foo', object, { field_if: :foo? }) + expect(subject.exclude_field? ctx).to be false + + 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 + blueprint.field :foo, if: :foo? + ctx = prepare(blueprint, field, 'Foo', object, {}) + expect(subject.exclude_field? ctx).to be false + + 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 = prepare(blueprint, field, 'Foo', object, {}) + expect(subject.exclude_field? ctx).to be false + + ctx = prepare(blueprint, field, 'Bar', 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 = prepare(blueprint, field, { name: 'Foo' }, object, {}) + expect(subject.exclude_object? ctx).to be false + end + + it 'should check options object_if (Proc)' do + ctx = prepare(blueprint, field, { name: 'Foo' }, object, { object_if: ->(ctx) { name_foo? ctx } }) + expect(subject.exclude_object? ctx).to be false + + 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 + 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 = 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) { name_foo? ctx } + ctx = prepare(blueprint, field, { name: 'Foo' }, object, {}) + expect(subject.exclude_object? ctx).to be false + + 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 = prepare(blueprint, field, { name: 'Foo' }, object, { object_if: :name_foo? }) + expect(subject.exclude_object? ctx).to be false + + 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 + 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 = 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] = :name_foo? + ctx = prepare(blueprint, field, { name: 'Foo' }, object, {}) + expect(subject.exclude_object? ctx).to be false + + ctx = prepare(blueprint, field, { name: 'Bar' }, 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 = prepare(blueprint, field, [{ name: 'Foo' }], object, {}) + expect(subject.exclude_collection? ctx).to be false + end + + it 'should check options collection_if (Proc)' do + ctx = prepare(blueprint, field, [{ name: 'Foo' }], object, { collection_if: ->(ctx) { names_foo? ctx } }) + expect(subject.exclude_collection? ctx).to be false + + 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 + 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 = 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) { names_foo? ctx } + ctx = prepare(blueprint, field, [{ name: 'Foo' }], object, {}) + expect(subject.exclude_collection? ctx).to be false + + 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 = prepare(blueprint, field, [{ name: 'Foo' }], object, { collection_if: :names_foo? }) + expect(subject.exclude_collection? ctx).to be false + + 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 + blueprint.collection :foos, sub_blueprint, if: :names_foo? + ctx = prepare(blueprint, field, [{ name: 'Foo' }], object, {}) + expect(subject.exclude_collection? ctx).to be false + + 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] = :names_foo? + ctx = prepare(blueprint, field, [{ name: 'Foo' }], object, {}) + expect(subject.exclude_collection? ctx).to be false + + ctx = prepare(blueprint, field, [{ name: 'Bar' }], object, {}) + expect(subject.exclude_collection? ctx).to be true + end + end +end diff --git a/spec/v2/extensions/output_spec.rb b/spec/v2/extensions/output_spec.rb new file mode 100644 index 00000000..8f9ebd55 --- /dev/null +++ b/spec/v2/extensions/output_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +describe Blueprinter::V2::Extensions::Postlude 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/extensions/prelude_spec.rb b/spec/v2/extensions/prelude_spec.rb new file mode 100644 index 00000000..40e95e93 --- /dev/null +++ b/spec/v2/extensions/prelude_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require 'ostruct' + +describe Blueprinter::V2::Extensions::Prelude 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 + + 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/unless_conditionals_spec.rb b/spec/v2/extensions/unless_conditionals_spec.rb new file mode 100644 index 00000000..6b034faf --- /dev/null +++ b/spec/v2/extensions/unless_conditionals_spec.rb @@ -0,0 +1,189 @@ +# frozen_string_literal: true + +describe Blueprinter::V2::Extensions::Exclusions do + include ExtensionHelpers + let(:object) { { foo: 'Foo' } } + + context 'fields' do + let(:field) { blueprint.reflections[:default].fields[:foo] } + + it 'should be allowed by default' do + ctx = prepare(blueprint, field, 'Foo', object, {}) + expect(subject.exclude_field? ctx).to be false + end + + it 'should check options field_unless (Proc)' do + ctx = prepare(blueprint, field, 'Foo', object, { field_unless: ->(ctx) { foo? ctx } }) + expect(subject.exclude_field? ctx).to be true + + 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 + blueprint.field :foo, unless: ->(ctx) { foo? ctx } + ctx = prepare(blueprint, field, 'Foo', object, {}) + expect(subject.exclude_field? ctx).to be true + + 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 = prepare(blueprint, field, 'Foo', object, {}) + expect(subject.exclude_field? ctx).to be true + + ctx = prepare(blueprint, field, 'Bar', object, {}) + expect(subject.exclude_field? ctx).to be false + end + + it 'should check options field_unless (Symbol)' do + ctx = prepare(blueprint, field, 'Foo', object, { field_unless: :foo? }) + expect(subject.exclude_field? ctx).to be true + + 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 + blueprint.field :foo, unless: :foo? + ctx = prepare(blueprint, field, 'Foo', object, {}) + expect(subject.exclude_field? ctx).to be true + + 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 = prepare(blueprint, field, 'Foo', object, {}) + expect(subject.exclude_field? ctx).to be true + + ctx = prepare(blueprint, field, 'Bar', object, {}) + expect(subject.exclude_field? ctx).to be false + end + end + + context 'objects' do + let(:field) { blueprint.reflections[:default].objects[:foo_obj] } + + it 'should be allowed by default' do + 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 = prepare(blueprint, field, { name: 'Foo' }, object, { object_unless: ->(ctx) { name_foo? ctx } }) + expect(subject.exclude_object? ctx).to be true + + 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 + 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 = 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) { name_foo? ctx } + ctx = prepare(blueprint, field, { name: 'Foo' }, object, {}) + expect(subject.exclude_object? ctx).to be true + + 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 = prepare(blueprint, field, { name: 'Foo' }, object, { object_unless: :name_foo? }) + expect(subject.exclude_object? ctx).to be true + + 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 + 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 = 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] = :name_foo? + ctx = prepare(blueprint, field, { name: 'Foo' }, object, {}) + expect(subject.exclude_object? ctx).to be true + + ctx = prepare(blueprint, field, { name: 'Bar' }, object, {}) + expect(subject.exclude_object? ctx).to be false + end + end + + context 'collections' do + let(:field) { blueprint.reflections[:default].collections[:foos] } + + it 'should be allowed by default' do + 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 = prepare(blueprint, field, [{ name: 'Foo' }], object, { collection_unless: ->(ctx) { names_foo? ctx } }) + expect(subject.exclude_collection? ctx).to be true + + 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 + 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 = 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) { names_foo? ctx } + ctx = prepare(blueprint, field, [{ name: 'Foo' }], object, {}) + expect(subject.exclude_collection? ctx).to be true + + 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 = prepare(blueprint, field, [{ name: 'Foo' }], object, { collection_unless: :names_foo? }) + expect(subject.exclude_collection? ctx).to be true + + 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 + blueprint.collection :foos, sub_blueprint, unless: :names_foo? + ctx = prepare(blueprint, field, [{ name: 'Foo' }], object, {}) + expect(subject.exclude_collection? ctx).to be true + + 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] = :names_foo? + ctx = prepare(blueprint, field, [{ name: 'Foo' }], object, {}) + expect(subject.exclude_collection? ctx).to be true + + ctx = prepare(blueprint, field, [{ name: 'Bar' }], object, {}) + expect(subject.exclude_collection? ctx).to be false + end + end +end diff --git a/spec/v2/extractor_spec.rb b/spec/v2/extractor_spec.rb new file mode 100644 index 00000000..d792f3c6 --- /dev/null +++ b/spec/v2/extractor_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +describe Blueprinter::V2::Extractor do + subject { described_class.new } + let(:context) { Blueprinter::V2::Context } + + 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] }) + 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) + 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") + ctx = context.new(blueprint.new, field, nil, obj, {}) + val = subject.field(ctx) + 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] }) + 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) + 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") + ctx = context.new(blueprint.new, field, nil, obj, {}) + val = subject.object(ctx) + 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] }) + 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) + 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") + ctx = context.new(blueprint.new, field, nil, obj, {}) + val = subject.collection(ctx) + expect(val).to eq 'Foo' + end + end +end diff --git a/spec/v2/fields_spec.rb b/spec/v2/fields_spec.rb index 4cca7ab0..03858058 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 @@ -28,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 @@ -152,4 +157,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 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..8644a237 100644 --- a/spec/v2/reflection_spec.rb +++ b/spec/v2/reflection_spec.rb @@ -64,25 +64,38 @@ ).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 + 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 - field :description - collection :widgets, widget_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 - 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 the definition order' do + names = blueprint.reflections[:default].ordered.map(&:name) + expect(names).to eq %i(name category) + + names = blueprint.reflections[:extended].ordered.map(&:name) + expect(names).to eq %i(name category widgets description) + end end end diff --git a/spec/v2/render_spec.rb b/spec/v2/render_spec.rb new file mode 100644 index 00000000..673e7b47 --- /dev/null +++ b/spec/v2/render_spec.rb @@ -0,0 +1,185 @@ +# 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 + + 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 + + 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/rendering_spec.rb b/spec/v2/rendering_spec.rb new file mode 100644 index 00000000..c8ea50f3 --- /dev/null +++ b/spec/v2/rendering_spec.rb @@ -0,0 +1,123 @@ +# 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 + + 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 + + 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 new file mode 100644 index 00000000..e8cc9474 --- /dev/null +++ b/spec/v2/serializer_spec.rb @@ -0,0 +1,292 @@ +# 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 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 + 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 + 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 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') } + 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 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) + { 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 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 + 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