From 6ad1a785456a36c282621d7d46a8627aa89e5ea7 Mon Sep 17 00:00:00 2001 From: Jordan Hollinger Date: Sat, 24 Aug 2024 12:45:10 -0400 Subject: [PATCH] JIT evaluation of 'view', 'exclude', 'use', etc so that order doesn't matter Signed-off-by: Jordan Hollinger --- lib/blueprinter/v2.rb | 45 ++++++++++++-- lib/blueprinter/v2/dsl.rb | 24 +------- lib/blueprinter/v2/reflection.rb | 22 ++++--- lib/blueprinter/v2/view_builder.rb | 68 +++++++++++++++++++++ spec/v2/declarative_api_spec.rb | 69 +++++++++++++++++++++ spec/v2/fields_spec.rb | 96 +++++++++++++++++++++--------- spec/v2/partials_spec.rb | 76 ++++++++++++++++++----- 7 files changed, 321 insertions(+), 79 deletions(-) create mode 100644 lib/blueprinter/v2/view_builder.rb create mode 100644 spec/v2/declarative_api_spec.rb diff --git a/lib/blueprinter/v2.rb b/lib/blueprinter/v2.rb index bccf937b..d07bce97 100644 --- a/lib/blueprinter/v2.rb +++ b/lib/blueprinter/v2.rb @@ -3,6 +3,7 @@ require 'blueprinter/v2/dsl' require 'blueprinter/v2/options' require 'blueprinter/v2/reflection' +require 'blueprinter/v2/view_builder' module Blueprinter # Base class for V2 Blueprints @@ -11,26 +12,37 @@ class V2 extend Reflection class << self - attr_accessor :views, :fields, :partials, :extensions, :options - # The fully-qualified name, e.g. "MyBlueprint", or "MyBlueprint.foo.bar" + # Options set on this Blueprint + attr_accessor :options + # Extensions set on this Blueprint + attr_accessor :extensions + # @api private The fully-qualified name, e.g. "MyBlueprint", or "MyBlueprint.foo.bar" attr_accessor :blueprint_name + # @api private + attr_accessor :views, :fields, :excludes, :partials, :used_partials, :eval_mutex end - self.views = {} + self.views = ViewBuilder.new(self) self.fields = {} + self.excludes = [] self.partials = {} + self.used_partials = [] self.extensions = [] self.options = Options.new(DEFAULT_OPTIONS) self.blueprint_name = name + self.eval_mutex = Mutex.new # Initialize subclass def self.inherited(subclass) - subclass.views = { default: subclass } + subclass.views = ViewBuilder.new(subclass) subclass.fields = fields.transform_values(&:dup) + subclass.excludes = [] subclass.partials = partials.dup + subclass.used_partials = [] subclass.extensions = extensions.dup subclass.options = options.dup subclass.blueprint_name = subclass.name || blueprint_name + subclass.eval_mutex = Mutex.new end # A descriptive name for the Blueprint view, e.g. "WidgetBlueprint.extended" @@ -44,6 +56,7 @@ def self.to_s end # Append the sub-view name to blueprint_name + # @api private def self.append_name(name) self.blueprint_name = "#{blueprint_name}.#{name}" end @@ -64,6 +77,30 @@ def self.[](view) end end + # Apply partials and field exclusions + # @api private + def self.eval!(lock = true) + return if @evaled + + if lock + eval_mutex.synchronize { run_eval! unless @evaled } + else + run_eval! + end + end + + # @api private + def self.run_eval! + used_partials.each do |name| + if !(p = partials[name]) + raise Errors::UnknownPartial, "Partial '#{name}' could not be found in Blueprint '#{self}'" + end + class_eval(&p) + end + excludes.each { |f| fields.delete f } + @evaled = true + end + # Render the object def self.render(obj, options = {}) new.render(obj, options) diff --git a/lib/blueprinter/v2/dsl.rb b/lib/blueprinter/v2/dsl.rb index b35cf611..6f990f51 100644 --- a/lib/blueprinter/v2/dsl.rb +++ b/lib/blueprinter/v2/dsl.rb @@ -12,15 +12,11 @@ module DSL # # @param name [Symbol] Name of the view # @yield Define the view in the block - # @return [Class] An annonymous subclass of Blueprinter::V2 # def view(name, &definition) raise Errors::InvalidBlueprint, "View name may not contain '.'" if name.to_s =~ /\./ - view = Class.new(self) - view.append_name(name) - view.class_eval(&definition) if definition - views[name.to_sym] = view + views[name.to_sym] = definition end # @@ -39,12 +35,7 @@ def partial(name, &definition) # @param name [Array] One or more partial names # def use(*names) - names.each do |name| - if !(p = partials[name]) - raise Errors::UnknownPartial, "Partial '#{name}' could not be found in Blueprint '#{self}'. NOTE: partials must be defined before your views!" - end - class_eval(&p) - end + names.each { |name| used_partials << name.to_sym } end # @@ -97,16 +88,7 @@ def association(name, blueprint, from: name, view: nil, **options, &definition) # @param name [Array] One or more fields or associations to exclude # def exclude(*names) - unknown = [] - names.each do |name| - name_sym = name.to_sym - if fields.key? name_sym - fields.delete name_sym - else - unknown << name.to_s - end - end - raise Errors::InvalidBlueprint, "Unknown excluded fields in '#{self}': #{unknown.join(', ')}" if unknown.any? + self.excludes += names.map(&:to_sym) end end end diff --git a/lib/blueprinter/v2/reflection.rb b/lib/blueprinter/v2/reflection.rb index 13a57034..e2f89dc8 100644 --- a/lib/blueprinter/v2/reflection.rb +++ b/lib/blueprinter/v2/reflection.rb @@ -7,33 +7,27 @@ module Blueprinter class V2 # API for reflecting on Blueprints module Reflection - def self.extended(klass) - klass.class_eval do - private_class_method :flatten_children - end - end - # # Returns a Hash of views keyed by name. # # @return [Hash] # def reflections + eval! unless @evaled @reflections ||= flatten_children(self, :default) end # Builds a flat Hash of nested views + # @api private def flatten_children(parent, child_name, path = []) ref_key = path.empty? ? child_name : path.join('.').to_sym child_view = parent.views.fetch(child_name) child_ref = View.new(child_view, ref_key) - child_view.views. - except(:default). - reduce({ ref_key => child_ref }) do |acc, (name, _)| - children = flatten_children(child_view, name, path + [name]) - acc.merge(children) - end + child_view.views.reduce({ ref_key => child_ref }) do |acc, (name, _)| + children = name == :default ? {} : flatten_children(child_view, name, path + [name]) + acc.merge(children) + end end # @@ -47,6 +41,10 @@ class View # @return [Hash] Associations defined on the view attr_reader :associations + + # @param blueprint [Class] A subclass of Blueprinter::V2 + # @param name [Symbol] Name of the view + # @api private def initialize(blueprint, name) @name = name @fields = blueprint.fields.select { |_, f| f.is_a? Field } diff --git a/lib/blueprinter/v2/view_builder.rb b/lib/blueprinter/v2/view_builder.rb new file mode 100644 index 00000000..66d4ea0e --- /dev/null +++ b/lib/blueprinter/v2/view_builder.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +module Blueprinter + class V2 + # + # A Hash-like class that defers evaluation of "view" blocks until they're accessed. + # This allows things like a parent's fields and partials to be defined AFTER views + # and have inheritance still work as expected. + # + class ViewBuilder + include Enumerable + + # @param parent [Class] A subclass of Blueprinter::V2 + def initialize(parent) + @parent = parent + @views = { default: parent } + @pending = {} + @mut = Mutex.new + end + + # + # Add a view definition. + # + # @param name [Symbol] + # @param definition [Proc] + # + def []=(name, definition) + @pending[name.to_sym] = definition + end + + # + # Return, and build if necessary, the view. + # + # @param name [Symbol] Name of the view + # @return [Class] An anonymous subclass of @parent + # + def [](name) + name = name.to_sym + if !@views.key?(name) and @pending.key?(name) + @mut.synchronize do + next if @views.key?(name) + + definition = @pending[name] + view = Class.new(@parent) + view.append_name(name) + view.class_eval(&definition) if definition + view.eval!(false) + @views[name] = view + end + end + @views[name] + end + + # Works like Hash#fetch + def fetch(name) + self[name] || raise(KeyError, "View '#{name}' not found") + end + + def each(&block) + enum = Enumerator.new do |y| + y.yield(:default, self[:default]) + @pending.each { |key, _| y.yield(key, self[key]) } + end + block ? enum.each(&block) : enum + end + end + end +end diff --git a/spec/v2/declarative_api_spec.rb b/spec/v2/declarative_api_spec.rb new file mode 100644 index 00000000..dcb305cf --- /dev/null +++ b/spec/v2/declarative_api_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +describe "Blueprinter::V2 Declarative API" do + it "should inherit fields defined after the view" do + blueprint = Class.new(Blueprinter::V2) do + view :desc do + field :description + end + + field :id + field :name + end + + refs = blueprint.reflections + expect(refs[:desc].fields.keys.sort).to eq %i(id name description).sort + end + + it "should include partials defined after the view" do + blueprint = Class.new(Blueprinter::V2) do + field :name + + view :foo do + use :desc + end + + partial :desc do + field :description + end + end + + refs = blueprint.reflections + expect(refs[:foo].fields.keys.sort).to eq %i(name description).sort + end + + it "should include partials defined after the use statement" do + blueprint = Class.new(Blueprinter::V2) do + field :name + use :desc + + partial :desc do + field :description + end + end + + refs = blueprint.reflections + expect(refs[:default].fields.keys.sort).to eq %i(name description).sort + end + + it "should exclude fields added after the exclude statement" do + blueprint = Class.new(Blueprinter::V2) do + field :id + field :name + + view :foo do + exclude :name, :description2, :description3 + use :desc + field :description3 + end + + partial :desc do + field :description + field :description2 + end + end + + refs = blueprint.reflections + expect(refs[:foo].fields.keys.sort).to eq %i(id description).sort + end +end diff --git a/spec/v2/fields_spec.rb b/spec/v2/fields_spec.rb index 371f0230..6ca337a6 100644 --- a/spec/v2/fields_spec.rb +++ b/spec/v2/fields_spec.rb @@ -8,14 +8,16 @@ field :description, from: :desc, if: -> { true } field(:foo) { "foo" } end - expect(blueprint.fields[:name].class.name).to eq "Blueprinter::V2::Field" - expect(blueprint.fields[:name].name).to eq :name - expect(blueprint.fields[:name].from).to eq :name - expect(blueprint.fields[:description].name).to eq :description - expect(blueprint.fields[:description].from).to eq :desc - expect(blueprint.fields[:description].if_cond.class.name).to eq "Proc" - expect(blueprint.fields[:foo].name).to eq :foo - expect(blueprint.fields[:foo].value_proc.class.name).to eq "Proc" + + 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[:description].name).to eq :description + expect(ref.fields[:description].from).to eq :desc + expect(ref.fields[:description].if_cond.class.name).to eq "Proc" + expect(ref.fields[:foo].name).to eq :foo + expect(ref.fields[:foo].value_proc.class.name).to eq "Proc" end end @@ -28,17 +30,19 @@ association :widgets, widget_blueprint, from: :foo, if: -> { true } association(:foo, widget_blueprint) { {foo: "bar"} } end - expect(blueprint.fields[:category].class.name).to eq "Blueprinter::V2::Association" - expect(blueprint.fields[:category].name).to eq :category - expect(blueprint.fields[:category].from).to eq :category - expect(blueprint.fields[:category].blueprint).to eq category_blueprint - expect(blueprint.fields[:widgets].name).to eq :widgets - expect(blueprint.fields[:widgets].from).to eq :foo - expect(blueprint.fields[:widgets].blueprint).to eq widget_blueprint - expect(blueprint.fields[:widgets].if_cond.class.name).to eq "Proc" - expect(blueprint.fields[:foo].name).to eq :foo - expect(blueprint.fields[:foo].blueprint).to eq widget_blueprint - expect(blueprint.fields[:foo].value_proc.class.name).to eq "Proc" + + ref = blueprint.reflections[:default] + expect(ref.associations[:category].class.name).to eq "Blueprinter::V2::Association" + expect(ref.associations[:category].name).to eq :category + expect(ref.associations[:category].from).to eq :category + expect(ref.associations[:category].blueprint).to eq category_blueprint + expect(ref.associations[:widgets].name).to eq :widgets + expect(ref.associations[:widgets].from).to eq :foo + expect(ref.associations[:widgets].blueprint).to eq widget_blueprint + expect(ref.associations[:widgets].if_cond.class.name).to eq "Proc" + expect(ref.associations[:foo].name).to eq :foo + expect(ref.associations[:foo].blueprint).to eq widget_blueprint + expect(ref.associations[:foo].value_proc.class.name).to eq "Proc" end end @@ -49,7 +53,9 @@ blueprint = Class.new(application_blueprint) do field :name end - expect(blueprint.fields.keys).to eq %i(id name) + + ref = blueprint.reflections[:default] + expect(ref.fields.keys).to eq %i(id name) end it "it should inherit from parent views" do @@ -65,13 +71,27 @@ end end - expect(blueprint.fields.keys).to eq %i(name) - expect(blueprint[:default].fields.keys).to eq %i(name) - expect(blueprint[:extended].fields.keys).to eq %i(name description) - expect(blueprint[:extended][:plus].fields.keys).to eq %i(name description foo) + refs = blueprint.reflections + expect(refs[:default].fields.keys.sort).to eq %i(name).sort + expect(refs[:extended].fields.keys.sort).to eq %i(name description).sort + expect(refs[:"extended.plus"].fields.keys.sort).to eq %i(name description foo).sort + end + + it "should exclude specified fields and associations from the parent class" do + application_blueprint = Class.new(Blueprinter::V2) do + field :id + field :foo + end + blueprint = Class.new(application_blueprint) do + exclude :foo + field :name + end + + refs = blueprint.reflections + expect(refs[:default].fields.keys.sort).to eq %i(id name).sort end - it "should exclude specified views and associations" do + it "should exclude specified fields and associations from the parent view" do category_blueprint = Class.new(Blueprinter::V2) widget_blueprint = Class.new(Blueprinter::V2) blueprint = Class.new(Blueprinter::V2) do @@ -86,7 +106,29 @@ end end - expect(blueprint.fields.keys).to eq %i(id name category widgets) - expect(blueprint[:foo].fields.keys).to eq %i(id widgets description) + refs = blueprint.reflections + expect(refs[:default].fields.keys.sort).to eq %i(id name).sort + expect(refs[:default].associations.keys.sort).to eq %i(category widgets).sort + expect(refs[:foo].fields.keys.sort).to eq %i(id description).sort + expect(refs[:foo].associations.keys.sort).to eq %i(widgets).sort + end + + it "should exclude specified fields and associations from partials" do + blueprint = Class.new(Blueprinter::V2) do + partial :desc do + field :short_desc + field :long_desc + end + + field :name + + view :foo do + exclude :short_desc + use :desc + end + end + + refs = blueprint.reflections + expect(refs[:foo].fields.keys).to eq %i(name long_desc) end end diff --git a/spec/v2/partials_spec.rb b/spec/v2/partials_spec.rb index fe12a898..4fc20ee0 100644 --- a/spec/v2/partials_spec.rb +++ b/spec/v2/partials_spec.rb @@ -18,24 +18,70 @@ end end - expect(blueprint.reflections[:default].fields.keys).to eq %i(name) - expect(blueprint.reflections[:foo].fields.keys.sort).to eq %i( - name - description - ).sort - expect(blueprint.reflections[:bar].fields.keys.sort).to eq %i( - name - description - ).sort + refs = blueprint.reflections + expect(refs[:default].fields.keys.sort).to eq %i(name).sort + expect(refs[:foo].fields.keys.sort).to eq %i(name description).sort + expect(refs[:bar].fields.keys.sort).to eq %i(name description).sort end - it "should throw an error for an invalid partial name" do - expect do - Class.new(Blueprinter::V2) do - view :foo do - use :description + it "should allow use statements to be nested" do + blueprint = Class.new(Blueprinter::V2) do + field :name + use :foo + + partial :foo do + field :foo + use :bar + end + + partial :bar do + field :bar + use :zorp + end + + partial :zorp do + field :zorp + end + end + + refs = blueprint.reflections + expect(refs[:default].fields.keys.sort).to eq %i(name foo bar zorp).sort + end + + it "should allow a view to be defined in a partial" do + blueprint = Class.new(Blueprinter::V2) do + field :name + + view :foo do + use :desc + end + + view :bar do + use :desc + end + + partial :desc do + field :description + + view :extended do + field :long_description end end - end.to raise_error(Blueprinter::Errors::UnknownPartial) + end + + refs = blueprint.reflections + expect(refs[:foo].fields.keys.sort).to eq %i(name description).sort + expect(refs[:bar].fields.keys.sort).to eq %i(name description).sort + expect(refs[:"foo.extended"].fields.keys.sort).to eq %i(name description long_description).sort + expect(refs[:"bar.extended"].fields.keys.sort).to eq %i(name description long_description).sort + end + + it "should throw an error for an invalid partial name" do + blueprint = Class.new(Blueprinter::V2) do + view :foo do + use :description + end + end + expect { blueprint[:foo] }.to raise_error(Blueprinter::Errors::UnknownPartial) end end