Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

2.0 Base Class Proposal #437

Merged
merged 19 commits into from
Oct 31, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/blueprinter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ module Blueprinter
autoload :Errors, 'blueprinter/errors'
autoload :Extension, 'blueprinter/extension'
autoload :Transformer, 'blueprinter/transformer'
autoload :V2, 'blueprinter/v2'

class << self
# @return [Configuration]
Expand Down
2 changes: 2 additions & 0 deletions lib/blueprinter/errors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,7 @@
module Blueprinter
module Errors
autoload :InvalidBlueprint, 'blueprinter/errors/invalid_blueprint'
autoload :UnknownPartial, 'blueprinter/errors/unknown_partial'
autoload :UnknownView, 'blueprinter/errors/unknown_view'
end
end
7 changes: 7 additions & 0 deletions lib/blueprinter/errors/unknown_partial.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# frozen_string_literal: true

module Blueprinter
module Errors
class UnknownPartial < Blueprinter::BlueprinterError; end
end
end
7 changes: 7 additions & 0 deletions lib/blueprinter/errors/unknown_view.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# frozen_string_literal: true

module Blueprinter
module Errors
class UnknownView < Blueprinter::BlueprinterError; end
end
end
3 changes: 3 additions & 0 deletions lib/blueprinter/v2.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# frozen_string_literal: true

require 'blueprinter/v2/base'
16 changes: 16 additions & 0 deletions lib/blueprinter/v2/association.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# frozen_string_literal: true

module Blueprinter
module V2
Association = Struct.new(
:name,
:blueprint,
:collection,
:legacy_view,
:from,
:value_proc,
:options,
keyword_init: true
)
end
end
127 changes: 127 additions & 0 deletions lib/blueprinter/v2/base.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# frozen_string_literal: true

require 'blueprinter/v2/dsl'
require 'blueprinter/v2/reflection'
require 'blueprinter/v2/view_builder'

module Blueprinter
module V2
# Base class for V2 Blueprints
class Base
extend DSL
extend Reflection

class << self
# 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, :schema, :excludes, :partials, :used_partials, :eval_mutex
end

self.views = ViewBuilder.new(self)
self.schema = {}
self.excludes = []
self.partials = {}
self.used_partials = []
self.extensions = []
self.options = {}
self.blueprint_name = name
self.eval_mutex = Mutex.new

# Initialize subclass
def self.inherited(subclass)
subclass.views = ViewBuilder.new(subclass)
subclass.schema = schema.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"
def self.inspect
blueprint_name
end

# A descriptive name for the Blueprint view, e.g. "WidgetBlueprint.extended"
def self.to_s
blueprint_name
end

# Append the sub-view name to blueprint_name
# @api private
def self.append_name(name)
self.blueprint_name = "#{blueprint_name}.#{name}"
end

#
# Access a child view.
#
# MyBlueprint[:extended]
# MyBlueprint["extended.plus"] or MyBlueprint[:extended][:plus]
#
# @param name [Symbol|String] Name of the view, e.g. :extended, "extended.plus"
# @return [Class] A descendent of Blueprinter::V2::Base
#
def self.[](name)
eval! unless @evaled
child, children = name.to_s.split('.', 2)
view = views[child.to_sym] || raise(Errors::UnknownView, "View '#{child}' could not be found in Blueprint '#{self}'")
children ? view[children] : view
end

def self.render(obj, options = {})
if array_like? obj
render_collection(obj, options)
else
render_object(obj, options)
end
end

def self.render_object(obj, options = {})
# TODO call external renderer
end

def self.render_collection(objs, options = {})
# TODO call external renderer
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| schema.delete f }
@evaled = true
end

# @api private
def self.array_like?(obj)
# TODO
end
end
end
end
125 changes: 125 additions & 0 deletions lib/blueprinter/v2/dsl.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# frozen_string_literal: true

require 'blueprinter/v2/association'
require 'blueprinter/v2/field'

module Blueprinter
module V2
# Methods for defining Blueprint fields and views
module DSL
#
# Define a new child view, which is a subclass of self.
#
# @param name [Symbol] Name of the view
# @yield Define the view in the block
#
def view(name, &definition)
raise Errors::InvalidBlueprint, "View name may not contain '.'" if name.to_s =~ /\./

views[name.to_sym] = definition
end

#
# Define a new partial.
#
# @param name [Symbol] Name of the partial to create or import
# @yield Define a new partial in the block
#
def partial(name, &definition)
partials[name.to_sym] = definition
end

#
# Import a partial into this view.
#
# @param names [Array<Symbol>] One or more partial names
#
def use(*names)
names.each { |name| used_partials << name.to_sym }
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"
# @yield [TODO] Generate the value from the block
# @return [Blueprinter::V2::Field]
#
def field(name, from: name, **options, &definition)
schema[name.to_sym] = Field.new(
name: name,
from: from,
value_proc: definition,
options: options.dup
)
end

#
# Add multiple fields at once.
#
def fields(*names)
names.each do |name|
schema[name.to_sym] = Field.new(name: name, options: {})
end
end

#
# Define an association to a single object.
#
# @param name [Symbol] Name of the association
# @param blueprint [Class|Proc] Blueprint class to use, or one defined with a Proc
# @param view [Symbol] Only for use with legacy (not V2) blueprints
# @param from [Symbol] Optionally specify a different method to call to get the value for "name"
# @yield [TODO] Generate the value from the block
# @return [Blueprinter::V2::Association]
#
def object(name, blueprint, from: name, view: nil, **options, &definition)
raise ArgumentError, 'The :view argument may not be used with V2 Blueprints' if view && blueprint.is_a?(V2)

schema[name.to_sym] = Association.new(
name: name,
blueprint: blueprint,
collection: false,
legacy_view: view,
from: from,
value_proc: definition,
options: options.dup
)
end

#
# Define an association to a collection of objects.
#
# @param name [Symbol] Name of the association
# @param blueprint [Class|Proc] Blueprint class to use, or one defined with a Proc
# @param view [Symbol] Only for use with legacy (not V2) blueprints
# @param from [Symbol] Optionally specify a different method to call to get the value for "name"
# @yield [TODO] Generate the value from the block
# @return [Blueprinter::V2::Association]
#
def collection(name, blueprint, from: name, view: nil, **options, &definition)
raise ArgumentError, 'The :view argument may not be used with V2 Blueprints' if view && blueprint.is_a?(V2)

schema[name.to_sym] = Association.new(
name: name,
blueprint: blueprint,
collection: true,
legacy_view: view,
from: from,
value_proc: definition,
options: options.dup
)
end

#
# Exclude parent fields and associations from this view.
#
# @param name [Array<Symbol>] One or more fields or associations to exclude
#
def exclude(*names)
self.excludes += names.map(&:to_sym)
end
end
end
end
13 changes: 13 additions & 0 deletions lib/blueprinter/v2/field.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# frozen_string_literal: true

module Blueprinter
module V2
Field = Struct.new(
:name,
:from,
:value_proc,
:options,
keyword_init: true
)
end
end
59 changes: 59 additions & 0 deletions lib/blueprinter/v2/reflection.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# frozen_string_literal: true

require 'blueprinter/v2/association'
require 'blueprinter/v2/field'

module Blueprinter
module V2
# API for reflecting on Blueprints
module Reflection
#
# 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.reduce({ ref_key => child_ref }) do |acc, (name, _)|
children = name == :default ? {} : flatten_children(child_view, name, path + [name])
acc.merge(children)
end
end

#
# Represents a view within a Blueprint.
#
class View
# @return [Symbol] Name of the view
attr_reader :name
# @return [Hash<Symbol, Blueprinter::V2::Field>] Fields defined on the view
attr_reader :fields
# @return [Hash<Symbol, Blueprinter::V2::Association>] Associations to single objects defined on the view
attr_reader :objects
# @return [Hash<Symbol, Blueprinter::V2::Association>] Associations to collections defined on the view
attr_reader :collections


# @param blueprint [Class] A subclass of Blueprinter::V2::Base
# @param name [Symbol] Name of the view
# @api private
def initialize(blueprint, name)
@name = name
@fields = blueprint.schema.select { |_, f| f.is_a? Field }
@objects = blueprint.schema.select { |_, f| f.is_a?(Association) && !f.collection }
@collections = blueprint.schema.select { |_, f| f.is_a?(Association) && f.collection }
end
end
end
end
end
Loading