Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 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
2 changes: 1 addition & 1 deletion codequest_pipes.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Gem::Specification.new do |spec|
spec.name = 'codequest_pipes'
spec.version = '0.3.1.1'
spec.version = '0.3.2'

spec.author = 'codequest'
spec.email = 'hello@codequest.com'
Expand Down
1 change: 1 addition & 0 deletions lib/codequest_pipes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@
module Pipes
class MissingCallMethod < ::Exception; end
class MissingContext < ::Exception; end
class InvalidType < ::Exception; end
end # module Pipes
53 changes: 39 additions & 14 deletions lib/codequest_pipes/context.rb
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
require 'codequest_pipes/context/error_collector'

module Pipes
# Context is an object used to pass data between Pipes. It behaves like an
# OpenStruct except you can write a value only once - this way we prevent
# context keys from being overwritten.
class Context
attr_reader :error

# Override is an exception raised when an attempt is made to override an
# existing Context property.
class Override < ::StandardError; end
Expand All @@ -18,7 +18,7 @@ class ExecutionTerminated < ::StandardError; end
# @param values [Hash]
def initialize(values = {})
add(values)
@error = nil
@error_collector = ErrorCollector.new
end

# Method `add` allows adding new properties (as a Hash) to the Context.
Expand All @@ -32,31 +32,28 @@ def add(values)
end
end

# Quietly fail the pipe, allowing the error to be saved and accessed from
# the Context.
# Quietly fail the pipe. The error will be passed to the error_collector
# and stored in the :base errors collection.
#
# @param error [Any] Error to be set.
## @param error [String]
def halt(error = 'Execution stopped')
@error = error
add_errors(base: error)
end

# Explicitly fail the pipe, allowing the error to be saved and accessed from
# the Context.
#
# @param error [Any] Error to be set.
# Explicitly fail the pipe.
#
# @raise [ExecutionTerminated]
def terminate(error)
halt(error)
fail ExecutionTerminated, error
fail ExecutionTerminated
end

# Check if the Context finished successfully.
# This method smells of :reek:NilCheck
#
# @return [Boolean] Success status.
def success?
@error.nil?
errors.empty?
end

# Check if the Context failed.
Expand All @@ -73,9 +70,37 @@ def failure?
def inspect
keys = methods - Object.methods - Pipes::Context.instance_methods
fields = keys.map { |key| "#{key}=#{public_send(key).inspect}" }
fields << "@error=#{@error.inspect}"
fields << "@errors=#{@errors.inspect}"
object_id_hex = '%x' % (object_id << 1)
"#<Pipes::Context:0x00#{object_id_hex} #{fields.join(', ')}>"
end

# Return errors from ErrorCollector object.
#
# @return [Hash]
def errors
error_collector.errors
end

# This method is added to maintain backwards compatibility - previous
# versions implemented a single @error instance variable of String for error
# storage.
#
# @return [String]
def error
errors[:base]&.first
end

# Add errors to ErrorCollector object.
# It doesn't fail the pipe as opposed to `halt` and `terminate` methods.
#
# @param collectable_errors [Hash]
def add_errors(collectable_errors)
error_collector.add(collectable_errors)
end

private

attr_reader :error_collector
end # class Context
end # module Pipes
20 changes: 20 additions & 0 deletions lib/codequest_pipes/context/error_collector.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
module Pipes
class Context
# ErrorCollector is Context's companion object for storing non-critical
# errors.
class ErrorCollector
attr_reader :errors

def initialize
@errors = {}
end

def add(errors_hash)
errors_hash.map do |key, errors|
@errors[key] ||= []
@errors[key] = @errors[key] | Array(errors)
end
end
end # class ErrorColletor
end # class Context
end # module Pipes
42 changes: 32 additions & 10 deletions lib/codequest_pipes/pipe.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,18 @@ def self.|(other)
end

def self.call(ctx)
return ctx if ctx.error
return ctx if ctx.errors.any?
_validate_ctx(_required_context_elements, ctx)
new(ctx).call
_validate_ctx(_provided_context_elements, ctx)
end

def self.require_context(*args)
_required_context_elements.push(*args)
def self.require_context(*args, **kwargs)
_merge_context_elements(_required_context_elements, args, kwargs)
end

def self.provide_context(*args)
_provided_context_elements.push(*args)
def self.provide_context(*args, **kwargs)
_merge_context_elements(_provided_context_elements, args, kwargs)
end

def self._combine(first, second)
Expand All @@ -50,23 +50,45 @@ def self._check_interface(klass)
private_class_method :_check_interface

def self._required_context_elements
@required_context_elements ||= []
@required_context_elements ||= {}
end
private_class_method :_required_context_elements

def self._provided_context_elements
@provided_context_elements ||= []
@provided_context_elements ||= {}
end
private_class_method :_provided_context_elements

def self._validate_ctx(collection, ctx)
collection.each do |element|
next if ctx.respond_to?(element)
fail MissingContext, "context does not respond to '#{element}'"
collection.each do |element, klass|
_validate_value_presence(ctx, element)
_validate_value_type(ctx, element, klass) if klass
end
end
private_class_method :_validate_ctx

def self._validate_value_presence(ctx, element)
return if ctx.respond_to?(element)
raise MissingContext, "context does not respond to '#{element}'"
end
private_class_method :_validate_value_presence

def self._validate_value_type(ctx, element, klass)
obj = ctx.public_send(element)
return if obj.is_a?(klass)
raise InvalidType,
"'#{element}' has invalid type #{obj.class} (expected: #{klass})"
end
private_class_method :_validate_value_type

def self._merge_context_elements(elements, args, kwargs)
elements.merge!(
**args.map { |a| [a, nil] }.to_h,
**kwargs
)
end
private_class_method :_merge_context_elements

private

def method_missing(name, *args, &block)
Expand Down
25 changes: 23 additions & 2 deletions spec/context_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,34 @@
it 'lists all fields' do
subject.add(bacon: 'yum', raisins: 'bleh')
expect(subject.inspect)
.to match(/bacon=\"yum\", raisins=\"bleh\", @error=nil/)
.to match(/bacon=\"yum\", raisins=\"bleh\", @errors=nil/)
end

it 'lists nested contexts' do
subject.add(nested: Pipes::Context.new(foo: 'bar'))
expect(subject.inspect)
.to match(/nested=#<Pipes::Context:0x\w+ foo="bar", @error=nil>,/)
.to match(/nested=#<Pipes::Context:0x\w+ foo="bar", @errors=nil>,/)
end
end # describe '#inspect'

describe '#add_errors' do
it 'adds error to error_collector' do
subject.add_errors(base: 'Error message')
subject.add_errors(
base: ['Another error message'],
user: 'User error message'
)
expect(subject.errors).to eq(
base: ['Error message', 'Another error message'],
user: ['User error message']
)
end
end # describe '#add_errors'

describe '#halt' do
it 'adds error to error collector :base' do
subject.halt('Some error')
expect(subject.error).to eq('Some error')
end
end # describe '#halt'
end # describe Pipes::Context
2 changes: 1 addition & 1 deletion spec/matcher_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
shared_examples_for 'fails_with_message' do |message|
it 'fails' do
expected_message =
message || /expected #<Pipes::Context:.+ @error=nil> to match/
message || /expected #<Pipes::Context:.+ @errors=nil> to match/
expect { expect(ctx).to match(pipe_context(expected)) }
.to fail_with(expected_message)
end
Expand Down
102 changes: 89 additions & 13 deletions spec/pipe_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,45 @@ def call
end
end

class ProvidingChild < Parent
provide_context :bacon

def call
super
add(bacon: true)
end
end # class ProvidingChild

class ProvidingNumericChild < Parent
provide_context bacon: Numeric

def call
super
add(bacon: 4)
end
end # class ProvidingNumericChild

class NotProvidingChild < Parent
provide_context :bacon
end # class NotProvidingChild

class ProvidingInvalidChild < Parent
provide_context bacon: Numeric

def call
super
add(bacon: "yes, please")
end
end # class ProvidingInvalidChild

class RequiringChild < Parent
require_context :bacon
end # class RequiringChild

class RequiringNumericChild < Parent
require_context bacon: Numeric
end # class RequiringNumericChild

# NoMethodPipe will break with NoMethodError.
class NoMethodPipe < Pipes::Pipe; end

Expand Down Expand Up @@ -55,15 +94,6 @@ class NoMethodPipe < Pipes::Pipe; end

describe '.provide_context' do
context 'when context element provided' do
class ProvidingChild < Parent
provide_context :bacon

def call
super
add(bacon: true)
end
end # class ProvideChild

let(:pipe) { Parent | ProvidingChild }

it 'does not raise' do
Expand All @@ -72,16 +102,62 @@ def call
end # context 'when context element provided'

context 'when context element not provided' do
class NotProvidingChild < Parent
provide_context :bacon
end

let(:pipe) { Parent | NotProvidingChild }

it 'raises MissingContext' do
expect { subject }.to raise_error Pipes::MissingContext
end
end # context 'when context element not provided'

context 'when context element with invalid type provided' do
let(:pipe) { Parent | ProvidingInvalidChild }

it 'raises InvalidType' do
expect { subject }.to raise_error Pipes::InvalidType
end
end # context 'when context element with invalid type provided'

context 'when context element with valid type provided' do
let(:pipe) { Parent | ProvidingNumericChild }

it 'does not raise' do
expect { subject }.to_not raise_error
end
end # context 'when context element with valid type provided'
end # describe '.provide_context'

describe '.require_context' do
context 'when required context element present' do
let(:pipe) { ProvidingChild | RequiringChild }

it 'does not raise' do
expect { subject }.to_not raise_error
end
end # context 'when required context element present'

context 'when required context element missing' do
let(:pipe) { Parent | RequiringChild }

it 'raises MissingContext' do
expect { subject }.to raise_error Pipes::MissingContext
end
end # context 'when context element missing'

context 'when required context element present with invalid type' do
let(:pipe) { ProvidingInvalidChild | RequiringNumericChild }

it 'raises InvalidType' do
expect { subject }.to raise_error Pipes::InvalidType
end
end # context 'when context element present with invalid type'

context 'when required context element present with valid type' do
let(:pipe) { ProvidingNumericChild | RequiringNumericChild }

it 'raises InvalidType' do
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should most likely be it does not raise

expect { subject }.to_not raise_error
end
end # context 'when context element present with valid type'
end # describe '.provide_context'

describe 'pipes declared using Pipe::Closure' do
Expand Down