From 7af7baea2019696a7f3f577432094ebfcd51824e Mon Sep 17 00:00:00 2001 From: Jordan Hollinger Date: Mon, 5 Aug 2024 12:22:25 -0400 Subject: [PATCH] Break V2 into modules. Start on reflection Signed-off-by: Jordan Hollinger --- lib/blueprinter/v2.rb | 73 ++++++++++++-------------------- lib/blueprinter/v2/dsl.rb | 44 +++++++++++++++++++ lib/blueprinter/v2/reflection.rb | 49 +++++++++++++++++++++ spec/v2/name_spec.rb | 45 ++++++++++++-------- spec/v2/reflection_spec.rb | 23 ++++++++++ 5 files changed, 170 insertions(+), 64 deletions(-) create mode 100644 lib/blueprinter/v2/dsl.rb create mode 100644 lib/blueprinter/v2/reflection.rb create mode 100644 spec/v2/reflection_spec.rb diff --git a/lib/blueprinter/v2.rb b/lib/blueprinter/v2.rb index 76c169f4..9a141d9b 100644 --- a/lib/blueprinter/v2.rb +++ b/lib/blueprinter/v2.rb @@ -1,36 +1,53 @@ # frozen_string_literal: true +require 'blueprinter/v2/dsl' +require 'blueprinter/v2/options' +require 'blueprinter/v2/reflection' + module Blueprinter class V2 - autoload :Options, 'blueprinter/v2/options' + extend DSL + extend Reflection class << self - attr_accessor :views, :fields, :extensions, :options, :blueprint_name + attr_accessor :views, :fields, :extensions, :options + # The fully-qualified name, e.g. "MyBlueprint", or "MyBlueprint.foo.bar" + attr_accessor :blueprint_name + # Name of the view, e.g. :default, :foo, :"foo.bar" + attr_accessor :view_name end self.views = {} self.fields = {} self.extensions = [] self.options = Options::Options.new(Options::DEFAULTS) - self.blueprint_name = [] + self.blueprint_name = name + self.view_name = :default # Initialize subclass def self.inherited(subclass) subclass.views = { default: subclass } - subclass.fields = fields.dup + subclass.fields = fields.transform_values(&:dup) subclass.extensions = extensions.dup subclass.options = options.dup - subclass.blueprint_name = subclass.name ? [subclass.name] : blueprint_name.dup + subclass.blueprint_name = subclass.name || blueprint_name + subclass.view_name = subclass.name ? :default : view_name end - # A descriptive name for the Blueprint view, e.g. "WidgetBlueprint:extended" + # A descriptive name for the Blueprint view, e.g. "WidgetBlueprint.extended" def self.inspect - to_s + blueprint_name end - # A descriptive name for the Blueprint view, e.g. "WidgetBlueprint:extended" + # A descriptive name for the Blueprint view, e.g. "WidgetBlueprint.extended" def self.to_s - blueprint_name.join '.' + blueprint_name + end + + # Append the sub-view name to blueprint_name and view_name + def self.append_name(name) + self.blueprint_name = "#{blueprint_name}.#{name}" + self.view_name = view_name == :default ? name : :"#{view_name}.#{name}" end # Access a child view @@ -44,42 +61,6 @@ def self.[](view) end end - # Define a new child view, which is a subclass of self - def self.view(name, &definition) - raise Errors::InvalidBlueprint, "View name may not contain '.'" if name.to_s =~ /\./ - - name = name.to_sym - views[name] = Class.new(self) - views[name].blueprint_name << name - views[name].class_eval(&definition) if definition - views[name] - end - - # Define a field - # rubocop:todo Lint/UnusedMethodArgument - def self.field(name, options = {}) - fields[name.to_sym] = 'TODO' - end - - # Define an association - def self.association(name, blueprint, options = {}) - fields[name.to_sym] = 'TODO' - end - - # Exclude fields/associations - def self.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? - end - def self.render(obj, options = {}) new.render(obj, options) end @@ -95,7 +76,5 @@ def render(obj, options = {}) # a field/association block calling "render" again), and others to be called on every # nested Blueprint. This would fix some persistent issues with blueprinter-activerecord. end - - # rubocop:enable Lint/UnusedMethodArgument end end diff --git a/lib/blueprinter/v2/dsl.rb b/lib/blueprinter/v2/dsl.rb new file mode 100644 index 00000000..fd1a2abf --- /dev/null +++ b/lib/blueprinter/v2/dsl.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Blueprinter + class V2 + module DSL + # Define a new child view, which is a subclass of self + 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 + end + + # Define a field + # rubocop:todo Lint/UnusedMethodArgument + def field(name, options = {}) + fields[name.to_sym] = 'TODO' + end + + # Define an association + def association(name, blueprint, options = {}) + fields[name.to_sym] = 'TODO' + end + + # Exclude fields/associations + 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? + end + + # rubocop:enable Lint/UnusedMethodArgument + end + end +end diff --git a/lib/blueprinter/v2/reflection.rb b/lib/blueprinter/v2/reflection.rb new file mode 100644 index 00000000..ed1ee8ea --- /dev/null +++ b/lib/blueprinter/v2/reflection.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module Blueprinter + class V2 + module Reflection + def self.extended(klass) + klass.class_eval do + private_class_method :flattened_views + end + end + + # + # Returns a Hash of views keyed by name. + # + # @return [Hash] + # + def reflections + @reflections ||= flattened_views(views) + end + + # Builds a flat Hash of nested views + def flattened_views(views, acc = {}) + views.each_with_object(acc) do |(_, blueprint), obj| + obj[blueprint.view_name] = View.new(blueprint) + children = blueprint.views.except(:default) + flattened_views(children, obj) + end + end + + # + # Represents a view within a Blueprint. + # + class View + # @return [Symbol] Name of the view + attr_reader :name + # @return [Hash] Fields defined on the view + attr_reader :fields + # @return [Hash] Associations defined on the view + attr_reader :associations + + def initialize(blueprint) + @name = blueprint.view_name + @fields = {} # TODO: get non-association fields from blueprint.fields + @associations = {} # TODO: get association fields from blueprint.fields + end + end + end + end +end diff --git a/spec/v2/name_spec.rb b/spec/v2/name_spec.rb index 5f53e5b4..fa755f57 100644 --- a/spec/v2/name_spec.rb +++ b/spec/v2/name_spec.rb @@ -9,11 +9,15 @@ class NamedBlueprint < Blueprinter::V2 it 'should have a base name' do expect(NamedBlueprint.to_s).to eq "NamedBlueprint" expect(NamedBlueprint.inspect).to eq "NamedBlueprint" + expect(NamedBlueprint.blueprint_name).to eq "NamedBlueprint" + expect(NamedBlueprint.view_name).to eq :default end it 'should find a view by name' do expect(NamedBlueprint[:extended].to_s).to eq "NamedBlueprint.extended" expect(NamedBlueprint[:extended].inspect).to eq "NamedBlueprint.extended" + expect(NamedBlueprint[:extended].blueprint_name).to eq "NamedBlueprint.extended" + expect(NamedBlueprint[:extended].view_name).to eq :extended end it 'should raise for an invalid view name' do @@ -27,7 +31,7 @@ class NamedBlueprint < Blueprinter::V2 context 'manually named Blueprints' do let(:blueprint) do Class.new(Blueprinter::V2) do - blueprint_name << "MyBlueprint" + self.blueprint_name = "MyBlueprint" view :extended end end @@ -35,11 +39,13 @@ class NamedBlueprint < Blueprinter::V2 it 'should have no base name' do expect(blueprint.to_s).to eq "MyBlueprint" expect(blueprint.inspect).to eq "MyBlueprint" + expect(blueprint.view_name).to eq :default end it 'should find a view by name' do expect(blueprint[:extended].to_s).to eq "MyBlueprint.extended" expect(blueprint[:extended].inspect).to eq "MyBlueprint.extended" + expect(blueprint[:extended].view_name).to eq :extended end end @@ -51,20 +57,20 @@ class NamedBlueprint < Blueprinter::V2 end it 'should have no base name' do - expect(blueprint.to_s).to eq "" - expect(blueprint.inspect).to eq "" + expect(blueprint.blueprint_name).to eq "Blueprinter::V2" + expect(blueprint.view_name).to eq :default end it 'should find a view by name' do - expect(blueprint[:extended].to_s).to eq "extended" - expect(blueprint[:extended].inspect).to eq "extended" + expect(blueprint[:extended].blueprint_name).to eq "Blueprinter::V2.extended" + expect(blueprint[:extended].view_name).to eq :extended end end context 'deeply nested Blueprints' do let(:blueprint) do Class.new(Blueprinter::V2) do - blueprint_name << "MyBlueprint" + self.blueprint_name = "MyBlueprint" view :foo do view :bar do @@ -75,23 +81,28 @@ class NamedBlueprint < Blueprinter::V2 end it 'should find deeply nested names' do - expect(blueprint.to_s).to eq "MyBlueprint" - expect(blueprint.inspect).to eq "MyBlueprint" + expect(blueprint.blueprint_name).to eq "MyBlueprint" + expect(blueprint.view_name).to eq :default - expect(blueprint[:foo].to_s).to eq "MyBlueprint.foo" - expect(blueprint[:foo].inspect).to eq "MyBlueprint.foo" + expect(blueprint[:foo].blueprint_name).to eq "MyBlueprint.foo" + expect(blueprint[:foo].view_name).to eq :foo - expect(blueprint[:foo][:bar].to_s).to eq "MyBlueprint.foo.bar" - expect(blueprint[:foo][:bar].inspect).to eq "MyBlueprint.foo.bar" + expect(blueprint[:foo][:bar].blueprint_name).to eq "MyBlueprint.foo.bar" + expect(blueprint[:foo][:bar].view_name).to eq :"foo.bar" - expect(blueprint[:foo][:bar][:zorp].to_s).to eq "MyBlueprint.foo.bar.zorp" - expect(blueprint[:foo][:bar][:zorp].inspect).to eq "MyBlueprint.foo.bar.zorp" + expect(blueprint[:foo][:bar][:zorp].blueprint_name).to eq "MyBlueprint.foo.bar.zorp" + expect(blueprint[:foo][:bar][:zorp].view_name).to eq :"foo.bar.zorp" end it 'should find deeply nested names using dot syntax' do - expect(blueprint["foo"].to_s).to eq "MyBlueprint.foo" - expect(blueprint["foo.bar"].to_s).to eq "MyBlueprint.foo.bar" - expect(blueprint["foo.bar.zorp"].to_s).to eq "MyBlueprint.foo.bar.zorp" + expect(blueprint["foo"].blueprint_name).to eq "MyBlueprint.foo" + expect(blueprint["foo"].view_name).to eq :foo + + expect(blueprint["foo.bar"].blueprint_name).to eq "MyBlueprint.foo.bar" + expect(blueprint["foo.bar"].view_name).to eq :"foo.bar" + + expect(blueprint["foo.bar.zorp"].blueprint_name).to eq "MyBlueprint.foo.bar.zorp" + expect(blueprint["foo.bar.zorp"].view_name).to eq :"foo.bar.zorp" end end diff --git a/spec/v2/reflection_spec.rb b/spec/v2/reflection_spec.rb new file mode 100644 index 00000000..7d6b4ca0 --- /dev/null +++ b/spec/v2/reflection_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +describe "Blueprinter::V2 Reflection" do + it "should find all view names" do + blueprint = Class.new(Blueprinter::V2) do + view :foo + view :bar do + view :foo do + view :borp + end + end + end + + view_names = blueprint.reflections.keys + expect(view_names.sort).to eq %i( + default + foo + bar + bar.foo + bar.foo.borp + ).sort + end +end