Skip to content

Commit

Permalink
JIT evaluation of 'view', 'exclude', 'use', etc so that order doesn't…
Browse files Browse the repository at this point in the history
… matter

Signed-off-by: Jordan Hollinger <[email protected]>
  • Loading branch information
jhollinger committed Aug 25, 2024
1 parent c2db5e5 commit 6ad1a78
Show file tree
Hide file tree
Showing 7 changed files with 321 additions and 79 deletions.
45 changes: 41 additions & 4 deletions lib/blueprinter/v2.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand All @@ -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
Expand All @@ -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)
Expand Down
24 changes: 3 additions & 21 deletions lib/blueprinter/v2/dsl.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

#
Expand All @@ -39,12 +35,7 @@ def partial(name, &definition)
# @param name [Array<Symbol>] 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

#
Expand Down Expand Up @@ -97,16 +88,7 @@ def association(name, blueprint, from: name, view: nil, **options, &definition)
# @param name [Array<Symbol>] 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
Expand Down
22 changes: 10 additions & 12 deletions lib/blueprinter/v2/reflection.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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<Symbol, Blueprinter::V2::Reflection::View>]
#
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

#
Expand All @@ -47,6 +41,10 @@ class View
# @return [Hash<Symbol, Blueprinter::V2::Association>] 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 }
Expand Down
68 changes: 68 additions & 0 deletions lib/blueprinter/v2/view_builder.rb
Original file line number Diff line number Diff line change
@@ -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
69 changes: 69 additions & 0 deletions spec/v2/declarative_api_spec.rb
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit 6ad1a78

Please sign in to comment.