Simplify decorator inferral

This commit is contained in:
Andrew Haines 2012-12-19 21:45:22 +00:00
parent a84d958497
commit a70bc5d098
6 changed files with 167 additions and 338 deletions

View File

@ -3,20 +3,19 @@ module Draper
include Enumerable include Enumerable
include ViewHelpers include ViewHelpers
attr_accessor :source, :context, :decorator_class attr_accessor :source, :context
alias_method :to_source, :source alias_method :to_source, :source
delegate :as_json, *(Array.instance_methods - Object.instance_methods), to: :decorated_collection array_methods = Array.instance_methods - Object.instance_methods
delegate :as_json, *array_methods, to: :decorated_collection
# @param source collection to decorate # @param source collection to decorate
# @param [Hash] options (optional) # @option options [Class] :with the class used to decorate items
# @option options [Class, Symbol] :with the class used to decorate
# items, or `:infer` to call each item's `decorate` method instead
# @option options [Hash] :context context available to each item's decorator # @option options [Hash] :context context available to each item's decorator
def initialize(source, options = {}) def initialize(source, options = {})
options.assert_valid_keys(:with, :context) options.assert_valid_keys(:with, :context)
@source = source @source = source
@decorator_class = options.fetch(:with) { self.class.inferred_decorator_class } @decorator_class = options[:with]
@context = options.fetch(:context, {}) @context = options.fetch(:context, {})
end end
@ -62,14 +61,14 @@ module Draper
each {|item| item.context = value } if @decorated_collection each {|item| item.context = value } if @decorated_collection
end end
def decorator_class
@decorator_class ||= self.class.inferred_decorator_class
end
protected protected
def decorate_item(item) def decorate_item(item)
if decorator_class == :infer item_decorator.call(item, context: context)
item.decorate(context: context)
else
decorator_class.decorate(item, context: context)
end
end end
def self.inferred_decorator_class def self.inferred_decorator_class
@ -85,5 +84,15 @@ module Draper
def self.decorator_uninferrable def self.decorator_uninferrable
raise Draper::UninferrableDecoratorError.new(self) raise Draper::UninferrableDecoratorError.new(self)
end end
private
def item_decorator
@item_decorator ||= begin
decorator_class.method(:decorate)
rescue Draper::UninferrableDecoratorError
->(item, options) { item.decorate(options) }
end
end
end end
end end

View File

@ -1,70 +1,70 @@
module Draper module Draper
class DecoratedAssociation class DecoratedAssociation
attr_reader :base, :association, :options def initialize(owner, association, options)
def initialize(base, association, options)
@base = base
@association = association
options.assert_valid_keys(:with, :scope, :context) options.assert_valid_keys(:with, :scope, :context)
@options = options
@owner = owner
@association = association
@decorator_class = options[:with]
@scope = options[:scope]
@context = options.fetch(:context, owner.context)
end end
def call def call
return undecorated if undecorated.nil? return undecorated if undecorated.nil?
decorate decorated
end end
def source def context
base.source return @context.call(owner.context) if @context.respond_to?(:call)
@context
end end
private private
attr_reader :owner, :association, :decorator_class, :scope
def source
owner.source
end
def undecorated def undecorated
@undecorated ||= begin @undecorated ||= begin
associated = source.send(association) associated = source.send(association)
associated = associated.send(options[:scope]) if options[:scope] associated = associated.send(scope) if scope
associated associated
end end
end end
def decorate def decorated
@decorated ||= decorator_class.send(decorate_method, undecorated, decorator_options) @decorated ||= decorator.call(undecorated, context: context)
end
def decorate_method
if collection? && decorator_class.respond_to?(:decorate_collection)
:decorate_collection
else
:decorate
end
end end
def collection? def collection?
undecorated.respond_to?(:first) undecorated.respond_to?(:first)
end end
def decorator_class def decorator
return options[:with] if options[:with] return collection_decorator if collection?
if collection? if decorator_class
options[:with] = :infer decorator_class.method(:decorate)
Draper::CollectionDecorator
else else
undecorated.decorator_class ->(item, options) { item.decorate(options) }
end end
end end
def decorator_options def collection_decorator
decorator_class # Ensures options[:with] = :infer for unspecified collections klass = decorator_class || Draper::CollectionDecorator
dec_options = collection? ? options.slice(:with, :context) : options.slice(:context) if klass.respond_to?(:decorate_collection)
dec_options[:context] = base.context unless dec_options.key?(:context) klass.method(:decorate_collection)
if dec_options[:context].respond_to?(:call) else
dec_options[:context] = dec_options[:context].call(base.context) klass.method(:decorate)
end end
dec_options
end end
end end
end end

View File

@ -23,7 +23,6 @@ module Draper
# multiple places in the chain. # multiple places in the chain.
# #
# @param [Object] source object to decorate # @param [Object] source object to decorate
# @param [Hash] options (optional)
# @option options [Hash] :context context available to the decorator # @option options [Hash] :context context available to the decorator
def initialize(source, options = {}) def initialize(source, options = {})
options.assert_valid_keys(:context) options.assert_valid_keys(:context)
@ -136,8 +135,8 @@ module Draper
# @param [Object] source collection to decorate # @param [Object] source collection to decorate
# @param [Hash] options passed to each item's decorator (except # @param [Hash] options passed to each item's decorator (except
# for the keys listed below) # for the keys listed below)
# @option options [Class,Symbol] :with (self) the class used to decorate # @option options [Class] :with (self) the class used to decorate
# items, or `:infer` to call each item's `decorate` method instead # items
# @option options [Hash] :context context available to decorated items # @option options [Hash] :context context available to decorated items
def self.decorate_collection(source, options = {}) def self.decorate_collection(source, options = {})
options.assert_valid_keys(:with, :context) options.assert_valid_keys(:with, :context)

View File

@ -79,40 +79,6 @@ describe Draper::CollectionDecorator do
expect { Draper::CollectionDecorator.new(source, valid_options.merge(foo: 'bar')) }.to raise_error(ArgumentError, 'Unknown key: foo') expect { Draper::CollectionDecorator.new(source, valid_options.merge(foo: 'bar')) }.to raise_error(ArgumentError, 'Unknown key: foo')
end end
end end
context "when the :with option is given" do
context "and the decorator can't be inferred from the class" do
subject { Draper::CollectionDecorator.new(source, with: ProductDecorator) }
it "uses the :with option" do
subject.decorator_class.should be ProductDecorator
end
end
context "and the decorator is inferrable from the class" do
subject { ProductsDecorator.new(source, with: SpecificProductDecorator) }
it "uses the :with option" do
subject.decorator_class.should be SpecificProductDecorator
end
end
end
context "when the :with option is not given" do
context "and the decorator can't be inferred from the class" do
it "raises an UninferrableDecoratorError" do
expect{Draper::CollectionDecorator.new(source)}.to raise_error Draper::UninferrableDecoratorError
end
end
context "and the decorator is inferrable from the class" do
subject { ProductsDecorator.new(source) }
it "infers the decorator" do
subject.decorator_class.should be ProductDecorator
end
end
end
end end
describe "#source" do describe "#source" do
@ -125,6 +91,52 @@ describe Draper::CollectionDecorator do
end end
end end
describe "item decoration" do
subject { subject_class.new(source, options) }
let(:decorator_classes) { subject.decorated_collection.map(&:class) }
let(:source) { [Product.new, Widget.new] }
context "when the :with option was given" do
let(:options) { {with: SpecificProductDecorator} }
context "and the decorator can't be inferred from the class" do
let(:subject_class) { Draper::CollectionDecorator }
it "uses the :with option" do
decorator_classes.should == [SpecificProductDecorator, SpecificProductDecorator]
end
end
context "and the decorator is inferrable from the class" do
let(:subject_class) { ProductsDecorator }
it "uses the :with option" do
decorator_classes.should == [SpecificProductDecorator, SpecificProductDecorator]
end
end
end
context "when the :with option was not given" do
let(:options) { {} }
context "and the decorator can't be inferred from the class" do
let(:subject_class) { Draper::CollectionDecorator }
it "infers the decorator from each item" do
decorator_classes.should == [ProductDecorator, WidgetDecorator]
end
end
context "and the decorator is inferrable from the class" do
let(:subject_class) { ProductsDecorator}
it "infers the decorator" do
decorator_classes.should == [ProductDecorator, ProductDecorator]
end
end
end
end
describe "#find" do describe "#find" do
context "with a block" do context "with a block" do
it "decorates Enumerable#find" do it "decorates Enumerable#find" do

View File

@ -1,313 +1,131 @@
require 'spec_helper' require 'spec_helper'
describe Draper::DecoratedAssociation do describe Draper::DecoratedAssociation do
let(:decorated_association) { Draper::DecoratedAssociation.new(base, association, options) } let(:decorated_association) { Draper::DecoratedAssociation.new(owner, :association, options) }
let(:source) { Product.new } let(:source) { Product.new }
let(:base) { source.decorate } let(:owner) { source.decorate }
let(:options) { {} } let(:options) { {} }
describe "#initialize" do describe "#initialize" do
describe "options validation" do describe "options validation" do
let(:association) { :similar_products }
let(:valid_options) { {with: ProductDecorator, scope: :foo, context: {}} } let(:valid_options) { {with: ProductDecorator, scope: :foo, context: {}} }
it "does not raise error on valid options" do it "does not raise error on valid options" do
expect { Draper::DecoratedAssociation.new(base, association, valid_options) }.to_not raise_error expect { Draper::DecoratedAssociation.new(owner, :association, valid_options) }.to_not raise_error
end end
it "raises error on invalid options" do it "raises error on invalid options" do
expect { Draper::DecoratedAssociation.new(base, association, valid_options.merge(foo: 'bar')) }.to raise_error(ArgumentError, 'Unknown key: foo') expect { Draper::DecoratedAssociation.new(owner, :association, valid_options.merge(foo: 'bar')) }.to raise_error(ArgumentError, 'Unknown key: foo')
end end
end end
end end
describe "#base" do
subject { decorated_association.base }
let(:association) { :similar_products }
it "returns the base decorator" do
should be base
end
it "returns a Decorator" do
subject.class.should == ProductDecorator
end
end
describe "#source" do
subject { decorated_association.source }
let(:association) { :similar_products }
it "returns the base decorator's source" do
should be base.source
end
it "returns a Model" do
subject.class.should == Product
end
end
describe "#call" do describe "#call" do
subject { decorated_association.call } let(:context) { {foo: "bar"} }
let(:expected_options) { {context: context} }
context "for an ActiveModel collection association" do before do
let(:association) { :similar_products } source.stub association: associated
decorated_association.stub context: context
end
context "when the association is not empty" do context "for a singular association" do
it "decorates the collection" do let(:associated) { Product.new }
subject.should be_a Draper::CollectionDecorator
context "when :with option was given" do
let(:options) { {with: decorator} }
let(:decorator) { SpecificProductDecorator }
it "uses the specified decorator" do
decorator.should_receive(:decorate).with(associated, expected_options).and_return(:decorated)
decorated_association.call.should be :decorated
end end
end
context "when :with option was not given" do
it "infers the decorator" do it "infers the decorator" do
subject.decorator_class.should be :infer associated.should_receive(:decorate).with(expected_options).and_return(:decorated)
end decorated_association.call.should be :decorated
end
context "when the association is empty" do
it "returns an empty collection decorator" do
source.stub(:similar_products).and_return([])
subject.should be_a Draper::CollectionDecorator
subject.should be_empty
subject.first.should be_nil
end end
end end
end end
context "for non-ActiveModel collection associations" do context "for a collection association" do
let(:association) { :poro_similar_products } let(:associated) { [Product.new, Widget.new] }
context "when the association is not empty" do context "when :with option is a collection decorator" do
it "decorates the collection" do let(:options) { {with: collection_decorator} }
subject.should be_a Draper::CollectionDecorator let(:collection_decorator) { ProductsDecorator }
end
it "infers the decorator" do it "uses the specified decorator" do
subject.decorator_class.should be :infer collection_decorator.should_receive(:decorate).with(associated, expected_options).and_return(:decorated_collection)
decorated_association.call.should be :decorated_collection
end end
end end
context "when the association is empty" do context "when :with option is a singular decorator" do
it "returns an empty collection decorator" do let(:options) { {with: decorator} }
source.stub(:poro_similar_products).and_return([]) let(:decorator) { SpecificProductDecorator }
subject.should be_a Draper::CollectionDecorator
subject.should be_empty
subject.first.should be_nil
end
end
end
context "for an ActiveModel singular association" do it "uses a CollectionDecorator of the specified decorator" do
let(:association) { :previous_version } decorator.should_receive(:decorate_collection).with(associated, expected_options).and_return(:decorated_collection)
decorated_association.call.should be :decorated_collection
context "when the association is present" do
it "decorates the association" do
subject.should be_decorated_with ProductDecorator
end end
end end
context "when the association is absent" do context "when :with option was not given" do
it "doesn't decorate the association" do it "uses a CollectionDecorator of inferred decorators" do
source.stub(:previous_version).and_return(nil) Draper::CollectionDecorator.should_receive(:decorate).with(associated, expected_options).and_return(:decorated_collection)
subject.should be_nil decorated_association.call.should be :decorated_collection
end end
end end
end end
context "for a non-ActiveModel singular association" do
let(:association) { :poro_previous_version }
context "when the association is present" do
it "decorates the association" do
subject.should be_decorated_with ProductDecorator
end
end
context "when the association is absent" do
it "doesn't decorate the association" do
source.stub(:poro_previous_version).and_return(nil)
subject.should be_nil
end
end
end
context "when a decorator is specified" do
let(:options) { {with: SpecificProductDecorator} }
context "for a singular association" do
let(:association) { :previous_version }
it "decorates with the specified decorator" do
subject.should be_decorated_with SpecificProductDecorator
end
end
context "for a collection association" do
let(:association) { :similar_products}
it "decorates with a collection of the specifed decorators" do
subject.should be_a Draper::CollectionDecorator
subject.decorator_class.should be SpecificProductDecorator
end
end
end
context "when a collection decorator is specified" do
let(:association) { :similar_products }
let(:options) { {with: ProductsDecorator} }
it "decorates with the specified decorator" do
subject.should be_a ProductsDecorator
end
end
context "with a scope" do context "with a scope" do
let(:association) { :thing } let(:associated) { [] }
let(:options) { {scope: :foo} } let(:options) { {scope: :foo} }
it "applies the scope before decoration" do it "applies the scope before decoration" do
scoped = [SomeThing.new] scoped = [:scoped]
SomeThing.any_instance.should_receive(:foo).and_return(scoped) associated.should_receive(:foo).and_return(scoped)
subject.source.should be scoped decorated_association.call.source.should be scoped
end
end
context "base has context" do
let(:association) { :similar_products }
let(:base) { source.decorate(context: {some: 'context'}) }
context "when no context is specified" do
it "it should inherit context from base" do
subject.context.should == {some: 'context'}
end
it "it should share context hash with base" do
subject.context.should be base.context
end
end
context "when static context is specified" do
let(:options) { {context: {other: 'context'}} }
it "it should get context from static option" do
subject.context.should == {other: 'context'}
end
end
context "when lambda context is specified" do
let(:options) { {context: lambda {|context| context.merge(other: 'protext')}} }
it "it should get generated context" do
subject.context.should == {some: 'context', other: 'protext'}
end
end end
end end
end end
describe "#decorator_options" do describe "#context" do
subject { decorated_association.send(:decorator_options) } before { owner.stub context: :owner_context }
context "collection association" do context "when :context option was given" do
let(:association) { :similar_products } let(:options) { {context: context} }
context "no options" do context "and is callable" do
it "should return default options" do let(:context) { ->(*){ :dynamic_context } }
should == {with: :infer, context: {}}
it "calls it with the owner's context" do
context.should_receive(:call).with(:owner_context)
decorated_association.context
end end
it "should set with: to :infer" do it "returns the lambda's return value" do
decorated_association.send(:options).should == options decorated_association.context.should be :dynamic_context
subject
decorated_association.send(:options).should == {with: :infer}
end end
end end
context "option with: ProductDecorator" do context "and is not callable" do
let(:options) { {with: ProductDecorator} } let(:context) { :static_context }
it "should pass with: from options" do
should == {with: ProductDecorator, context: {}}
end
end
context "option scope: :to_a" do it "returns the specified value" do
let(:options) { {scope: :to_a} } decorated_association.context.should be :static_context
it "should strip scope: from options" do
decorated_association.send(:options).should == options
should == {with: :infer, context: {}}
end
end
context "base has context" do
let(:base) { source.decorate(context: {some: 'context'}) }
context "no options" do
it "should return context from base" do
should == {with: :infer, context: {some: 'context'}}
end
end
context "option context: {other: 'context'}" do
let(:options) { {context: {other: 'context'}} }
it "should return specified context" do
should == {with: :infer, context: {other: 'context'}}
end
end
context "option context: lambda" do
let(:options) { {context: lambda {|context| context.merge(other: 'protext')}} }
it "should return specified context" do
should == {with: :infer, context: {some: 'context', other: 'protext'}}
end
end end
end end
end end
context "singular association" do context "when :context option was not given" do
let(:association) { :previous_version } it "returns the owner's context" do
decorated_association.context.should be :owner_context
context "no options" do
it "should return default options" do
should == {context: {}}
end
end
context "option with: ProductDecorator" do
let(:options) { {with: ProductDecorator} }
it "should strip with: from options" do
should == {context: {}}
end
end
context "option scope: :decorate" do
let(:options) { {scope: :decorate} }
it "should strip scope: from options" do
decorated_association.send(:options).should == options
should == {context: {}}
end
end
context "base has context" do
let(:base) { source.decorate(context: {some: 'context'}) }
context "no options" do
it "should return context from base" do
should == {context: {some: 'context'}}
end
end
context "option context: {other: 'context'}" do
let(:options) { {context: {other: 'context'}} }
it "should return specified context" do
should == {context: {other: 'context'}}
end
end
context "option context: lambda" do
let(:options) { {context: lambda {|context| context.merge(other: 'protext')}} }
it "should return specified context" do
should == {context: {some: 'context', other: 'protext'}}
end
end
end end
end end
end end
end end

View File

@ -101,15 +101,6 @@ describe Draper::Decorator do
subject.each {|item| item.should be_a ProductDecorator} subject.each {|item| item.should be_a ProductDecorator}
end end
context "when given :with => :infer" do
subject { ProductDecorator.decorate_collection(source, with: :infer) }
it "infers the item decorators" do
subject.first.should be_a ProductDecorator
subject.last.should be_a WidgetDecorator
end
end
context "with context" do context "with context" do
subject { ProductDecorator.decorate_collection(source, with: :infer, context: {some: 'context'}) } subject { ProductDecorator.decorate_collection(source, with: :infer, context: {some: 'context'}) }