Skip to content

Commit

Permalink
Apply collection detection in Decanter::Base (#119)
Browse files Browse the repository at this point in the history
* Add rspec config option

* Add collection detection as separate module

* Only test core module in decanter_core_spec

* Add specs for collection detection

* Revert extensions changes

* Bump patch

* Fix invalid spec

* Update documentation

* Remove unnecessary require

* Force collection option to be nil or a boolean
  • Loading branch information
chawes13 authored Apr 13, 2021
1 parent 74b30db commit 28c3938
Show file tree
Hide file tree
Showing 11 changed files with 129 additions and 114 deletions.
2 changes: 1 addition & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
decanter (3.5.0)
decanter (3.5.1)
actionpack (>= 4.2.10)
activesupport
rails-html-sanitizer (>= 1.0.4)
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ If this option is not provided, autodetect logic is used to determine if the pro
- `nil` or not provided: will try to autodetect single vs collection
- `true` will always treat the incoming params args as *collection*
- `false` will always treat incoming params args as *single object*
- `truthy` will raise an error

### Nested resources

Expand Down
1 change: 0 additions & 1 deletion lib/decanter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,6 @@ def config

require 'decanter/version'
require 'decanter/configuration'
require 'decanter/core'
require 'decanter/base'
require 'decanter/extensions'
require 'decanter/exceptions'
Expand Down
2 changes: 2 additions & 0 deletions lib/decanter/base.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
require 'decanter/core'
require 'decanter/collection_detection'

module Decanter
class Base
include Core
include CollectionDetection
end
end
26 changes: 26 additions & 0 deletions lib/decanter/collection_detection.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
module Decanter
module CollectionDetection
def self.included(base)
base.extend(ClassMethods)
end

module ClassMethods
def decant(args, **options)
return super(args) unless collection?(args, options[:is_collection])

args.map { |resource| super(resource) }
end

private

# leveraging the approach used in the [fast JSON API gem](https://github.com/Netflix/fast_jsonapi#collection-serialization)
def collection?(args, collection_option = nil)
raise(ArgumentError, "#{name}: Unknown collection option value: #{collection_option}") unless [true, false, nil].include? collection_option

return collection_option unless collection_option.nil?

args.respond_to?(:size) && !args.respond_to?(:each_pair)
end
end
end
end
21 changes: 2 additions & 19 deletions lib/decanter/extensions.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
module Decanter
module Extensions

def self.included(base)
base.extend(ClassMethods)
end
Expand Down Expand Up @@ -34,29 +33,13 @@ def decant_create!(args, **options)
.save!(context: options[:context])
end

def decant(args, options={})
is_collection?(args, options[:is_collection]) ? decant_collection(args, options) : decant_args(args, options)
end

def decant_collection(args, options)
args.map { |resource| decant_args(resource, options) }
end

def decant_args(args, options)
if specified_decanter = options[:decanter]
def decant(args, options = {})
if (specified_decanter = options[:decanter])
Decanter.decanter_from(specified_decanter)
else
Decanter.decanter_for(self)
end.decant(args)
end

private

# leveraging the approach used in the [fast JSON API gem](https://github.com/Netflix/fast_jsonapi#collection-serialization)
def is_collection?(args, collection_option=nil)
return collection_option[:is_collection] unless collection_option.nil?
args.respond_to?(:size) && !args.respond_to?(:each_pair)
end
end

module ActiveRecordExtensions
Expand Down
2 changes: 1 addition & 1 deletion lib/decanter/version.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
module Decanter
VERSION = '3.5.0'.freeze
VERSION = '3.5.1'.freeze
end
76 changes: 76 additions & 0 deletions spec/decanter/decanter_collection_detection_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
require 'spec_helper'

describe Decanter::CollectionDetection do
let(:base_decanter) {
stub_const('BaseDecanter', Class.new)
}

let(:decanter) {
stub_const('TripDecanter', base_decanter.new)
TripDecanter.class_eval { include Decanter::CollectionDetection }
}
let(:args) { { destination: 'Hawaii' } }

before(:each) {
allow(base_decanter).to receive(:decant)
}

describe '#decant' do
context 'when args are a single hash' do
it 'calls decant on the entire element' do
decanter.decant(args)
expect(base_decanter).to have_received(:decant).once.with(args)
end
end

context 'when no collection option is passed' do
context 'and args are a collection' do
let(:args) { [{ destination: 'Hawaii' }, { destination: 'Denver' }] }

it 'calls decant with each element' do
decanter.decant(args)
expect(base_decanter).to have_received(:decant).with(args.first)
expect(base_decanter).to have_received(:decant).with(args.second)
end
end

context 'and args are not a collection' do
let(:args) { { "0": [{ destination: 'Hawaii' }] } }
it 'calls decant on the entire element' do
decanter.decant(args)
expect(base_decanter).to have_received(:decant).once.with(args)
end
end
end

context 'when the collection option is passed' do
let(:fake_collection) { double('fake_collection') }
let(:args) { fake_collection }

before(:each) do
allow(fake_collection).to receive(:map).and_yield(1).and_yield(2)
end

context 'and the value is true' do
it 'is considered a collection' do
decanter.decant(args, is_collection: true)
expect(base_decanter).to have_received(:decant).with(1)
expect(base_decanter).to have_received(:decant).with(2)
end
end

context 'and the value is false' do
it 'is not considered a collection' do
decanter.decant(args, is_collection: false)
expect(base_decanter).to have_received(:decant).once.with(args)
end
end

context 'and the value is truthy' do
it 'raises an error' do
expect { decanter.decant(args, is_collection: 'yes') }.to raise_error(ArgumentError)
end
end
end
end
end
17 changes: 11 additions & 6 deletions spec/decanter/decanter_core_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

describe Decanter::Core do

let(:dummy) { Class.new(Decanter::Base) }
let(:dummy) { Class.new { include Decanter::Core } }

before(:each) do
Decanter::Core.class_variable_set(:@@handlers, {})
Expand Down Expand Up @@ -576,7 +576,8 @@ def self.name

context 'when inputs are required' do
let(:decanter) {
Class.new(Decanter::Base) do
Class.new do
include Decanter::Core
input :name, :pass, required: true
end
}
Expand Down Expand Up @@ -604,7 +605,8 @@ def self.name

context 'when params keys are strings' do
let(:decanter) {
Class.new(Decanter::Base) do
Class.new do
include Decanter::Core
input :name, :string
input :description, :string
end
Expand All @@ -621,7 +623,8 @@ def self.name

context 'and when inputs are strings' do
let(:decanter) {
Class.new(Decanter::Base) do
Class.new do
include Decanter::Core
input 'name', :string
input 'description', :string
end
Expand Down Expand Up @@ -650,7 +653,8 @@ def self.name

context 'with missing non-required args' do
let(:decanter) {
Class.new(Decanter::Base) do
Class.new do
include Decanter::Core
input :name, :string
input :description, :string
end
Expand All @@ -665,7 +669,8 @@ def self.name

context 'with key having a :default_value in the decanter' do
let(:decanter) {
Class.new(Decanter::Base) do
Class.new do
include Decanter::Core
input :name, :string, default_value: 'foo'
input :cost, :float, default_value: '99.99'
input :description, :string
Expand Down
91 changes: 5 additions & 86 deletions spec/decanter/decanter_extensions_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,61 +3,6 @@
describe Decanter::Extensions do

describe '#decant' do
let(:options) { { } }
let(:dummy_class) { Class.new { include Decanter::Extensions } }

context 'when args is a single resource' do
let(:args) { { } }

before(:each) do
allow(dummy_class).to receive(:decant_args).and_return(true)
end

it 'calls decant_args with the args and options' do
dummy_class.decant(args, options)
expect(dummy_class)
.to have_received(:decant_args)
.with(args, options)
end
end

context 'when args is a collection' do
let(:args) { [{}, {}] }

before(:each) do
allow(dummy_class).to receive(:decant_collection).and_return(true)
end

it 'calls decant_collection with the args and options' do
dummy_class.decant(args, options)
expect(dummy_class)
.to have_received(:decant_collection)
.with(args, options)
end
end
end

describe '#decant_collection' do
let!(:args) { [{ foo: 'bar' }, { foo: 'baz' }] }
let(:options) { { } }
let(:dummy_class) { Class.new { include Decanter::Extensions } }

before(:each) do
allow(dummy_class).to receive(:decant_args).and_return(true)
end

it 'calls decant_args on each element in the collection' do
dummy_class.decant_collection(args, options)
expect(dummy_class)
.to have_received(:decant_args)
.with(args[0], options)
expect(dummy_class)
.to have_received(:decant_args)
.with(args[1], options)
end
end

describe '#decant_args' do
let(:args) { { } }
let(:decanter) { class_double('Decanter::Base', decant: true) }

Expand All @@ -70,15 +15,15 @@

it 'calls Decanter.decanter_from with the specified decanter' do
dummy_class = Class.new { include Decanter::Extensions }
dummy_class.decant_args(args, options)
dummy_class.decant(args, options)
expect(Decanter)
.to have_received(:decanter_from)
.with(options[:decanter])
end

it 'calls decant on the returned decanter with the args' do
dummy_class = Class.new { include Decanter::Extensions }
dummy_class.decant_args(args, options)
dummy_class.decant(args, options)
expect(decanter)
.to have_received(:decant)
.with(args)
Expand All @@ -94,49 +39,23 @@

it 'calls Decanter.decanter_for with self' do
dummy_class = Class.new { include Decanter::Extensions }
dummy_class.decant_args(args, options)
dummy_class.decant(args, options)
expect(Decanter)
.to have_received(:decanter_for)
.with(dummy_class)
end

it 'calls decant on the returned decanter with the args' do
dummy_class = Class.new { include Decanter::Extensions }
dummy_class.decant_args(args, options)
dummy_class.decant(args, options)
expect(decanter)
.to have_received(:decant)
.with(args)
end
end
end

describe '#is_collection?' do
let(:singular_args) { { foo: 'bar' } }
let(:collection_args) { [{ foo: 'bar' }, { foo: 'baz' }] }
let(:dummy_class) { Class.new { include Decanter::Extensions } }

context 'true' do
it 'when options[:is_collection] is nil and collection is provided' do
expect(dummy_class.send(:is_collection?, collection_args)).to be(true)
end

it 'when options[:is_collection] is true' do
expect(dummy_class.send(:is_collection?, singular_args, { is_collection: true })).to be(true)
end
end

context 'false' do
it 'when options[:is_collection] is nil and single object is provided' do
expect(dummy_class.send(:is_collection?, singular_args)).to be(false)
end

it 'when options[:is_collection] is false' do
expect(dummy_class.send(:is_collection?, collection_args, { is_collection: false })).to be(false)
end
end
end

context '' do
context 'ActiveRecord::Persistence' do
let(:dummy_class) { Class.new { include Decanter::Extensions } }
let(:dummy_instance) { dummy_class.new }

Expand Down
4 changes: 4 additions & 0 deletions spec/spec_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,7 @@

$LOAD_PATH.unshift File.expand_path('../../lib', __FILE__)
require 'decanter'

RSpec.configure do |config|
config.filter_run_when_matching focus: true
end

0 comments on commit 28c3938

Please sign in to comment.