├── Rakefile ├── .rspec ├── lib ├── waterfall │ ├── version.rb │ └── predicates │ │ ├── when_falsy.rb │ │ ├── base.rb │ │ ├── on_dam.rb │ │ ├── when_truthy.rb │ │ └── chain.rb └── waterfall.rb ├── Gemfile ├── .travis.yml ├── .gitignore ├── spec ├── spec_helper.rb ├── service_spec.rb ├── chaining_services_spec.rb └── wf_object_spec.rb ├── Gemfile.lock ├── changelog.md ├── LICENSE.txt ├── waterfall.gemspec └── README.md /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --format progress 3 | -------------------------------------------------------------------------------- /lib/waterfall/version.rb: -------------------------------------------------------------------------------- 1 | module Waterfall 2 | VERSION = "1.3.0" 3 | end 4 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in waterfall.gemspec 4 | gemspec -------------------------------------------------------------------------------- /lib/waterfall/predicates/when_falsy.rb: -------------------------------------------------------------------------------- 1 | require_relative 'when_truthy' 2 | 3 | module Waterfall 4 | class WhenFalsy < WhenTruthy 5 | 6 | def condition? 7 | ! super 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/waterfall/predicates/base.rb: -------------------------------------------------------------------------------- 1 | module Waterfall 2 | class Base 3 | 4 | def waterfall?(obj) 5 | obj.respond_to?(:is_waterfall?) && obj.is_waterfall? 6 | end 7 | 8 | def yield_args 9 | [@root.outflow, @root] 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/waterfall/predicates/on_dam.rb: -------------------------------------------------------------------------------- 1 | module Waterfall 2 | class OnDam < Base 3 | 4 | def initialize(root) 5 | @root = root 6 | end 7 | 8 | def call 9 | return unless @root.dammed? 10 | yield @root.error_pool, @root.error_pool_context, @root.outflow, @root 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/waterfall/predicates/when_truthy.rb: -------------------------------------------------------------------------------- 1 | module Waterfall 2 | class WhenTruthy < Base 3 | 4 | def initialize(root) 5 | @root = root 6 | end 7 | 8 | def call 9 | @output = yield(*yield_args) 10 | end 11 | 12 | def dam 13 | if !@root.dammed? && condition? 14 | @root.dam yield(*yield_args) 15 | end 16 | @root 17 | end 18 | 19 | def condition? 20 | @output 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | env: 3 | global: 4 | - CC_TEST_REPORTER_ID=06ba7b4d95c2d056ef2b66c86e9d69f24dceee54354c1ab2ce1397f8f309b0a4 5 | rvm: 6 | - 2.1.10 7 | - 2.2.8 8 | - 2.3.5 9 | - 2.4.2 10 | before_script: 11 | - curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter 12 | - chmod +x ./cc-test-reporter 13 | - ./cc-test-reporter before-build 14 | script: 15 | - bundle exec rspec 16 | after_script: 17 | - ./cc-test-reporter after-build --exit-code $TRAVIS_TEST_RESULT 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | /.config 4 | /coverage/ 5 | /InstalledFiles 6 | /pkg/ 7 | /spec/reports/ 8 | /test/tmp/ 9 | /test/version_tmp/ 10 | /tmp/ 11 | 12 | ## Specific to RubyMotion: 13 | .dat* 14 | .repl_history 15 | build/ 16 | 17 | ## Documentation cache and generated files: 18 | /.yardoc/ 19 | /_yardoc/ 20 | /doc/ 21 | /rdoc/ 22 | 23 | ## Environment normalisation: 24 | /.bundle/ 25 | /lib/bundler/man/ 26 | 27 | # for a library or gem, you might want to ignore these files since the code is 28 | # intended to run in multiple environments; otherwise, check them in: 29 | # Gemfile.lock 30 | # .ruby-version 31 | # .ruby-gemset 32 | 33 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: 34 | .rvmrc 35 | 36 | .ruby-gemset 37 | 38 | .ruby-version 39 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # This file was generated by the `rspec --init` command. Conventionally, all 2 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. 3 | # Require this file using `require "spec_helper"` to ensure that it is only 4 | # loaded once. 5 | # 6 | # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 7 | 8 | require File.expand_path('../../lib/waterfall', __FILE__) 9 | require 'pry' 10 | 11 | RSpec.configure do |config| 12 | config.run_all_when_everything_filtered = true 13 | config.filter_run :focus 14 | 15 | # Run specs in random order to surface order dependencies. If you find an 16 | # order dependency and want to debug it, you can fix the order by providing 17 | # the seed, which is printed after each run. 18 | # --seed 1234 19 | config.order = 'random' 20 | end 21 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | waterfall (1.3.0) 5 | 6 | GEM 7 | remote: https://rubygems.org/ 8 | specs: 9 | coderay (1.1.0) 10 | diff-lcs (1.3) 11 | method_source (0.8.2) 12 | pry (0.10.1) 13 | coderay (~> 1.1.0) 14 | method_source (~> 0.8.1) 15 | slop (~> 3.4) 16 | pry-nav (0.2.4) 17 | pry (>= 0.9.10, < 0.11.0) 18 | rake (10.3.2) 19 | rspec (3.6.0) 20 | rspec-core (~> 3.6.0) 21 | rspec-expectations (~> 3.6.0) 22 | rspec-mocks (~> 3.6.0) 23 | rspec-core (3.6.0) 24 | rspec-support (~> 3.6.0) 25 | rspec-expectations (3.6.0) 26 | diff-lcs (>= 1.2.0, < 2.0) 27 | rspec-support (~> 3.6.0) 28 | rspec-mocks (3.6.0) 29 | diff-lcs (>= 1.2.0, < 2.0) 30 | rspec-support (~> 3.6.0) 31 | rspec-support (3.6.0) 32 | slop (3.6.0) 33 | 34 | PLATFORMS 35 | ruby 36 | 37 | DEPENDENCIES 38 | bundler (~> 1.14) 39 | pry (> 0.10) 40 | pry-nav 41 | rake 42 | rspec (= 3.6) 43 | waterfall! 44 | 45 | BUNDLED WITH 46 | 1.14.4 47 | -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | ===1.3.0 2 | - Introduced `error_pool_context`, providing with the stacktrace leading to the dam 3 | - BREAKING: changed `on_dam` signature to pass context to the block 4 | - add `Waterfall.caller_locations_length` to limit the number of lines of the stacktrace (default is nil: unlimited) 5 | 6 | ===1.2.1 7 | - Introduced `Waterfall.with_reversible_flow`, makes `reverse_flow` optionnal, may save memory 8 | - outflow is now lazy loaded 9 | 10 | ===1.2.0 11 | - Removed `undam`. 12 | - Introduced `reverse_flow` 13 | 14 | ===1.1.0 15 | - Removed `chain_wf`. 16 | - Introduced `halt_chain` 17 | 18 | ===1.0.6 19 | Alias Wf with Flow 20 | 21 | === 1.0.5 22 | - naming: changed flowing to has_flown to clarify its not related to damming 23 | - spec change 24 | 25 | === 1.0.4 26 | - add clearer error messages 27 | - deprecate chain_wf 28 | - prevent from damming falsy values 29 | 30 | === 1.0.3 31 | - Small refactors 32 | - if waterfall 1 calls waterfall 2 and waterfall2 is dammed, now waterfall1 is able to get values from waterfall2's outflow 33 | 34 | === 1.0.2 35 | Initial release 36 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Benjamin Roth 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /waterfall.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'waterfall/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "waterfall" 8 | spec.version = Waterfall::VERSION 9 | spec.authors = ["Benjamin Roth"] 10 | spec.email = ["benjamin@rubyist.fr"] 11 | spec.description = %q{A slice of functional programming to chain ruby services and blocks. Make them flow!} 12 | spec.summary = %q{A slice of functional programming to chain ruby services and blocks. Make them flow!} 13 | spec.homepage = "https://github.com/apneadiving/waterfall" 14 | spec.license = "MIT" 15 | 16 | spec.files = `git ls-files`.split($/) 17 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 18 | spec.test_files = spec.files.grep(%r{^(spec)/}) 19 | spec.require_paths = ["lib"] 20 | 21 | spec.add_development_dependency "bundler", "~> 1.14" 22 | spec.add_development_dependency "pry", '>0.10' 23 | spec.add_development_dependency "pry-nav" 24 | spec.add_development_dependency "rake" 25 | spec.add_development_dependency "rspec", "3.6" 26 | end 27 | -------------------------------------------------------------------------------- /lib/waterfall/predicates/chain.rb: -------------------------------------------------------------------------------- 1 | module Waterfall 2 | class Chain < Base 3 | 4 | def initialize(root, mapping_or_var_name) 5 | @root, @mapping_or_var_name = root, mapping_or_var_name 6 | end 7 | 8 | def call 9 | output = yield(*yield_args) 10 | 11 | if waterfall?(output) 12 | map_waterfalls(output, @mapping_or_var_name || {}) 13 | else 14 | @root.update_outflow(@mapping_or_var_name, output) if @mapping_or_var_name 15 | end 16 | end 17 | 18 | def map_waterfalls(child_waterfall, mapping) 19 | child_waterfall.call unless child_waterfall.has_flown? 20 | 21 | raise IncorrectChainingArgumentError.new(MAPPING_ERROR_MESSAGE) unless mapping.is_a?(Hash) 22 | 23 | mapping.each do |k, v| 24 | @root.update_outflow(k, child_waterfall.outflow[v]) 25 | end 26 | 27 | @root.send :_add_executed_flow, child_waterfall 28 | 29 | if child_waterfall.dammed? 30 | @root.dam child_waterfall.error_pool, child_waterfall.error_pool_context 31 | end 32 | 33 | self 34 | end 35 | 36 | MAPPING_ERROR_MESSAGE = "When chaining waterfalls, you must pass a mapping hash to pass data from one to the other" 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /spec/service_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe 'Service' do 4 | 5 | let(:service_class) do 6 | Class.new do 7 | include Waterfall 8 | attr_reader :error 9 | 10 | def initialize(options) 11 | @options = { 12 | falsy_check: true, 13 | truthy_check: false 14 | }.merge options 15 | end 16 | 17 | def call 18 | when_falsy { @options[:falsy_check] } 19 | .dam { 'errr1' } 20 | chain(:foo) { 'foo_value' } 21 | when_truthy { @options[:truthy_check] } 22 | .dam { 'errr2' } 23 | chain(:bar) { 'bar_value' } 24 | end 25 | end 26 | end 27 | 28 | let(:service) { service_class.new(options).tap(&:call) } 29 | 30 | it 'a waterfall doesnt flow until one chain is executed' do 31 | expect(service_class.new({}).has_flown?).to be false 32 | end 33 | 34 | context 'default options' do 35 | let(:options) {{}} 36 | 37 | it 'has flown whenever one chain has been executed' do 38 | expect(service.has_flown?).to be true 39 | end 40 | 41 | it 'executes without damming' do 42 | expect(service.outflow.foo).to eq 'foo_value' 43 | expect(service.outflow.bar).to eq 'bar_value' 44 | expect(service.dammed?).to be false 45 | expect(service.error_pool).to eq nil 46 | end 47 | end 48 | 49 | context 'dammed on when_falsy statement' do 50 | let(:options) {{ falsy_check: false }} 51 | it 'stops on dam' do 52 | expect(service.outflow.foo).to eq nil 53 | expect(service.outflow.bar).to eq nil 54 | expect(service.dammed?).to be true 55 | expect(service.error_pool).to eq 'errr1' 56 | end 57 | end 58 | 59 | context 'dammed on when_truthy statement' do 60 | let(:options) {{ truthy_check: true }} 61 | it 'stops on dam' do 62 | expect(service.outflow.foo).to eq 'foo_value' 63 | expect(service.outflow.bar).to eq nil 64 | expect(service.dammed?).to be true 65 | expect(service.error_pool).to eq 'errr2' 66 | end 67 | end 68 | 69 | let(:service_class2) do 70 | Class.new do 71 | include Waterfall 72 | 73 | def initialize(listener, options) 74 | @to_dam, @listener = options[:to_dam], listener 75 | end 76 | 77 | def call 78 | dam('error') if @to_dam 79 | chain { @listener.success } 80 | on_dam { @listener.failure } 81 | end 82 | end 83 | end 84 | 85 | let(:listener) { spy 'listener', is_waterfall?: false } 86 | 87 | it 'sends success message to listener' do 88 | service_class2.new(listener, to_dam: false).call 89 | 90 | expect(listener).to have_received(:success) 91 | end 92 | 93 | it 'sends failure message to listener' do 94 | service_class2.new(listener, to_dam: true).call 95 | 96 | expect(listener).to have_received(:failure) 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /spec/chaining_services_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe 'Chaining services' do 4 | 5 | let(:service_class) do 6 | Class.new do 7 | include Waterfall 8 | def initialize(options = {}) 9 | @options = { 10 | falsy_check: true, 11 | truthy_check: false 12 | }.merge options 13 | end 14 | 15 | def call 16 | when_falsy { @options[:falsy_check] } 17 | .dam { 'errr1' } 18 | chain(:foo) { 'foo_value' } 19 | when_truthy { @options[:truthy_check] } 20 | .dam { 'errr2' } 21 | chain(:bar) { 'bar_value' } 22 | self 23 | end 24 | end 25 | end 26 | 27 | let(:wf) { Flow.new } 28 | let(:listener) { spy 'listener', is_waterfall?: false } 29 | 30 | it 'you dont need to call child waterfalls, just pass the instance' do 31 | service = service_class.new 32 | expect(service.has_flown?).to be false 33 | wf.chain { service } 34 | expect(service.has_flown?).to be true 35 | end 36 | 37 | context 'child executed without damming' do 38 | it 'passes data from one outflow to the other' do 39 | wf 40 | .chain(local_foo: :foo, local_bar: :bar) { service_class.new } 41 | .chain { listener.success } 42 | .on_dam { listener.failure } 43 | 44 | expect(wf.outflow.local_foo).to eq 'foo_value' 45 | expect(wf.outflow.local_bar).to eq 'bar_value' 46 | expect(wf.dammed?).to be false 47 | expect(wf.error_pool).to eq nil 48 | expect(listener).to have_received :success 49 | end 50 | 51 | it 'passes only required data from one outflow to the other' do 52 | wf 53 | .chain(local_foo: :foo) { service_class.new } 54 | .chain { listener.success } 55 | .on_dam { listener.failure } 56 | 57 | expect(wf.outflow.local_foo).to eq 'foo_value' 58 | expect(wf.outflow.local_bar).to eq nil 59 | expect(listener).to have_received :success 60 | end 61 | end 62 | 63 | context 'child dams on when_falsy' do 64 | it 'stops on dam yet passes existing data' do 65 | wf 66 | .chain(local_foo: :foo, local_bar: :bar) { service_class.new(falsy_check: false) } 67 | .chain { listener.success } 68 | .on_dam { listener.failure } 69 | 70 | expect(wf.outflow.local_foo).to eq nil 71 | expect(wf.outflow.local_bar).to eq nil 72 | expect(wf.dammed?).to be true 73 | expect(wf.error_pool).to eq 'errr1' 74 | expect(listener).to have_received :failure 75 | end 76 | end 77 | 78 | context 'dammed on when_truthy statement' do 79 | it 'stops on dam yet passes existing data' do 80 | wf 81 | .chain(local_foo: :foo, local_bar: :bar) { service_class.new(truthy_check: true) } 82 | .chain { listener.success } 83 | .on_dam { listener.failure } 84 | 85 | expect(wf.outflow.local_foo).to eq 'foo_value' 86 | expect(wf.outflow.local_bar).to eq nil 87 | expect(wf.dammed?).to be true 88 | expect(wf.error_pool).to eq 'errr2' 89 | expect(listener).to have_received :failure 90 | end 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /lib/waterfall.rb: -------------------------------------------------------------------------------- 1 | require 'ostruct' 2 | require 'waterfall/version' 3 | require 'waterfall/predicates/base' 4 | require 'waterfall/predicates/on_dam' 5 | require 'waterfall/predicates/when_falsy' 6 | require 'waterfall/predicates/when_truthy' 7 | require 'waterfall/predicates/chain' 8 | 9 | WATERFALL_PATH = "lib/waterfall" 10 | 11 | module Waterfall 12 | 13 | attr_reader :error_pool, :error_pool_context 14 | 15 | class IncorrectDamArgumentError < StandardError; end 16 | class IncorrectChainingArgumentError < StandardError; end 17 | 18 | class << self 19 | attr_accessor :with_reversible_flow 20 | attr_accessor :caller_locations_length 21 | end 22 | @with_reversible_flow = true 23 | @caller_locations_length = nil 24 | 25 | def outflow 26 | @outflow ||= OpenStruct.new({}) 27 | end 28 | 29 | def when_falsy(&block) 30 | ::Waterfall::WhenFalsy.new(self).tap do |handler| 31 | _wf_run { handler.call(&block) } 32 | end 33 | end 34 | 35 | def when_truthy(&block) 36 | ::Waterfall::WhenTruthy.new(self).tap do |handler| 37 | _wf_run { handler.call(&block) } 38 | end 39 | end 40 | 41 | def chain(mapping_or_var_name = nil, &block) 42 | _wf_run do 43 | ::Waterfall::Chain 44 | .new(self, mapping_or_var_name) 45 | .call(&block) 46 | end 47 | end 48 | 49 | def on_dam(&block) 50 | ::Waterfall::OnDam 51 | .new(self) 52 | .call(&block) 53 | self 54 | end 55 | 56 | def dam(obj, context = nil) 57 | raise IncorrectDamArgumentError.new("You cant dam with a falsy object") unless obj 58 | _wf_run do 59 | @error_pool = obj 60 | @error_pool_context = context || _error_pool_context 61 | _reverse_flows(true) 62 | end 63 | end 64 | 65 | def halt_chain(&block) 66 | yield(outflow, error_pool, error_pool_context) 67 | end 68 | 69 | def dammed? 70 | !error_pool.nil? 71 | end 72 | 73 | def is_waterfall? 74 | true 75 | end 76 | 77 | def has_flown? 78 | !! @has_flown 79 | end 80 | 81 | def update_outflow(key, value) 82 | @outflow[key] = value 83 | self 84 | end 85 | 86 | def reverse_flow 87 | end 88 | 89 | protected 90 | 91 | def _reverse_flows(skip_self) 92 | return unless Waterfall.with_reversible_flow 93 | return if @flow_reversed 94 | @flow_reversed = true 95 | reverse_flow unless skip_self 96 | (@_executed_flows || []).reverse_each do |f| 97 | f.send :_reverse_flows, false 98 | end 99 | end 100 | 101 | def _add_executed_flow(flow) 102 | return unless Waterfall.with_reversible_flow 103 | @_executed_flows ||= [] 104 | @_executed_flows.push(flow) 105 | end 106 | 107 | def _wf_run 108 | @has_flown = true 109 | yield unless dammed? 110 | self 111 | end 112 | 113 | def _error_pool_context 114 | caller_locations(1, Waterfall.caller_locations_length).reject do |line| 115 | line.to_s.include?(WATERFALL_PATH) 116 | end 117 | end 118 | end 119 | 120 | class Wf 121 | include Waterfall 122 | def initialize 123 | _wf_run {} 124 | end 125 | end 126 | 127 | Flow = Wf 128 | -------------------------------------------------------------------------------- /spec/wf_object_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Flow do 4 | let(:wf) { Flow.new } 5 | 6 | it 'is aliased' do 7 | expect(Flow).to eq Wf 8 | end 9 | 10 | it 'is a waterfall' do 11 | expect(wf.is_waterfall?).to be true 12 | end 13 | 14 | it 'and a waterfall flows when called' do 15 | expect(wf.has_flown?).to be true 16 | end 17 | 18 | it 'executes code in chain block' do 19 | listener = double 'listener' 20 | expect(listener).to receive :success 21 | 22 | wf.chain { listener.success } 23 | end 24 | 25 | it 'executes code in chain block and store it in outflow upon request' do 26 | wf.chain(:foo) { 1 } 27 | expect(wf.outflow.foo).to eq 1 28 | end 29 | 30 | it 'returns itself to enable chaining' do 31 | expect(wf.chain{ }).to eq wf 32 | expect(wf.on_dam{ }).to eq wf 33 | end 34 | 35 | it 'chain yields outflow and waterfall itself' do 36 | wf.chain do |outflow, waterfall| 37 | expect(outflow).to eq wf.outflow 38 | expect(waterfall).to eq wf 39 | end 40 | end 41 | 42 | it 'isnt dammed by default' do 43 | expect(wf.dammed?).to be false 44 | expect(wf.error_pool).to eq nil 45 | expect(wf.error_pool_context).to be nil 46 | end 47 | 48 | it 'is dammed if you dam it!' do 49 | wf.dam('error') 50 | expect(wf.dammed?).to be true 51 | expect(wf.error_pool).to eq 'error' 52 | expect(wf.error_pool_context).to be_an Array 53 | end 54 | 55 | it 'dam raises if falsy argument sent' do 56 | expect { wf.dam(nil) }.to raise_error(Waterfall::IncorrectDamArgumentError) 57 | end 58 | 59 | it 'can be dammed conditionnaly (falsy)' do 60 | wf.when_falsy { false }.dam { 'error' } 61 | expect(wf.dammed?).to be true 62 | expect(wf.error_pool).to eq 'error' 63 | end 64 | 65 | it 'can be dammed conditionnaly (truthy)' do 66 | wf.when_truthy { true }.dam { 'error' } 67 | expect(wf.dammed?).to be true 68 | expect(wf.error_pool).to eq 'error' 69 | end 70 | 71 | it 'error pool and context values propagate to root flow' do 72 | sub_flow = Flow.new 73 | sub_flow.chain { sub_flow.dam('errr') } 74 | wf.chain { sub_flow } 75 | 76 | expect(wf).to be_dammed 77 | expect(wf.error_pool_context).to eq sub_flow.error_pool_context 78 | end 79 | 80 | it 'doesnt execute chain blocks once dammed' do 81 | expect do 82 | wf.when_falsy { false }.dam { 'error' }.chain { raise 'I should not be executed because of damming before me' } 83 | end.to_not raise_error 84 | end 85 | 86 | it 'doesnt execute on_dam blocks when not dammed' do 87 | expect do 88 | wf.on_dam { raise 'I should not be executed because of damming before me' } 89 | end.to_not raise_error 90 | end 91 | 92 | it 'executes on_dam blocks once dammed' do 93 | listener = spy 'listener' 94 | wf.dam('errr').on_dam { listener.failure } 95 | 96 | expect(listener).to have_received :failure 97 | end 98 | 99 | it 'on_dam blocks yield error pool, error context, outflow and waterfall' do 100 | wf.dam('errr').on_dam do |error_pool, error_pool_context, outflow, waterfall| 101 | expect(error_pool).to eq wf.error_pool 102 | expect(error_pool_context).to eq wf.error_pool_context 103 | expect(outflow).to eq wf.outflow 104 | expect(waterfall).to eq wf 105 | end 106 | end 107 | 108 | it 'raises if chain waterfall without hash mapping' do 109 | expect { wf.chain(:foo) { Flow.new } }.to raise_error(Waterfall::IncorrectChainingArgumentError, Waterfall::Chain::MAPPING_ERROR_MESSAGE) 110 | end 111 | 112 | describe 'halt_chain' do 113 | it "yields expected values" do 114 | wf.chain(:foo) { 1 }.halt_chain do |outflow, error_pool| 115 | expect(outflow).to eq wf.outflow 116 | expect(error_pool).to eq wf.error_pool 117 | end 118 | end 119 | 120 | it "yields expected values even if dammed" do 121 | wf.chain(:foo) { 1 }.dam("errr").halt_chain do |outflow, error_pool, error_pool_context| 122 | expect(outflow).to eq wf.outflow 123 | expect(error_pool).to eq wf.error_pool 124 | expect(error_pool_context).to eq wf.error_pool_context 125 | end 126 | end 127 | 128 | it "returns what the block returns" do 129 | expect(wf.halt_chain { "return value" }).to eq "return value" 130 | end 131 | end 132 | 133 | describe 'reverse_flow' do 134 | let(:parent_flow) { Flow.new } 135 | let(:sub_flow1) { Flow.new } 136 | let(:sub_flow2) { Flow.new } 137 | let(:sub_flow3) { Flow.new } 138 | let(:sub_sub_flow1) { Flow.new } 139 | let(:sub_sub_flow2) { Flow.new } 140 | let(:sub_sub_flow3) { Flow.new } 141 | let(:sub_sub_sub_flow1) { Flow.new } 142 | 143 | def action 144 | parent_flow 145 | .chain { sub_flow1 } 146 | .chain do 147 | sub_flow2 148 | .chain do 149 | sub_sub_flow1.chain { sub_sub_sub_flow1 } 150 | end 151 | .chain { sub_sub_flow2 } 152 | .when_truthy { true }.dam { 'errr' } 153 | .chain { sub_sub_flow3 } 154 | end 155 | .chain { sub_flow3 } 156 | end 157 | 158 | it 'does not trigger reverse_flow on initial dammed flow' do 159 | expect(sub_flow2).to_not receive(:reverse_flow) 160 | 161 | action 162 | end 163 | 164 | it 'does not trigger reverse_flow on parent_flow' do 165 | expect(parent_flow).to_not receive(:reverse_flow) 166 | 167 | action 168 | end 169 | 170 | it 'is called on all executed sub flows' do 171 | expect(sub_sub_flow2).to receive(:reverse_flow).once.ordered 172 | expect(sub_sub_flow1).to receive(:reverse_flow).once.ordered 173 | expect(sub_sub_sub_flow1).to receive(:reverse_flow).once.ordered 174 | expect(sub_flow1).to receive(:reverse_flow).once.ordered 175 | 176 | action 177 | end 178 | 179 | it 'is not called on non executed sub flows' do 180 | expect(sub_sub_flow3).to_not receive(:reverse_flow) 181 | expect(sub_flow3).to_not receive(:reverse_flow) 182 | 183 | action 184 | end 185 | 186 | context "without reversible flow" do 187 | around do |example| 188 | Waterfall.with_reversible_flow = false 189 | example.run 190 | Waterfall.with_reversible_flow = true 191 | end 192 | 193 | it 'doesnt call reverse_flow, ever' do 194 | expect(sub_sub_flow2).to_not receive(:reverse_flow) 195 | expect(sub_sub_flow1).to_not receive(:reverse_flow) 196 | expect(sub_sub_sub_flow1).to_not receive(:reverse_flow) 197 | expect(sub_flow1).to_not receive(:reverse_flow) 198 | 199 | action 200 | end 201 | end 202 | end 203 | end 204 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Code Climate](https://codeclimate.com/github/apneadiving/waterfall/badges/gpa.svg)](https://codeclimate.com/github/apneadiving/waterfall) 2 | [![Test Coverage](https://codeclimate.com/github/apneadiving/waterfall/badges/coverage.svg)](https://codeclimate.com/github/apneadiving/waterfall/coverage) 3 | [![Build Status](https://travis-ci.org/apneadiving/waterfall.svg?branch=master)](https://travis-ci.org/apneadiving/waterfall) 4 | [![Gem Version](https://badge.fury.io/rb/waterfall.svg)](https://badge.fury.io/rb/waterfall) 5 | #### Goal 6 | 7 | Chain ruby commands, and treat them like a flow, which provides a new approach to application control flow. 8 | 9 | When logic is complicated, waterfalls show their true power and let you write intention revealing code. Above all they excel at chaining services. 10 | 11 | #### Material 12 | 13 | Upcoming book about failure management patterns, leveraging the gem: [The Unhappy path](https://leanpub.com/the-unhappy-path) 14 | 15 | General presentation blog post there: [Chain services objects like a boss](https://medium.com/p/chain-service-objects-like-a-boss-35d0b83606ab). 16 | 17 | Reach me [@apneadiving](https://twitter.com/apneadiving) 18 | 19 | 20 | #### Overview 21 | 22 | A waterfall object has its own flow of commands, you can chain your commands and if something wrong happens, you dam the flow which bypasses the rest of the commands. 23 | 24 | Here is a basic representation: 25 | - green, the flow goes on, `chain` by `chain` 26 | - red its bypassed and only `on_dam` blocks are executed. 27 | 28 | ![Waterfall Principle](https://apneadiving.github.io/images/waterfall_principle.png) 29 | 30 | #### Example 31 | 32 | ```ruby 33 | class FetchUser 34 | include Waterfall 35 | 36 | def initialize(user_id) 37 | @user_id = user_id 38 | end 39 | 40 | def call 41 | chain { @response = HTTParty.get("https://jsonplaceholder.typicode.com/users/#{@user_id}") } 42 | when_falsy { @response.success? } 43 | .dam { "Error status #{@response.code}" } 44 | chain(:user) { @response.body } 45 | end 46 | end 47 | ``` 48 | 49 | and call / chain: 50 | 51 | ```ruby 52 | Flow.new 53 | .chain(user1: :user) { FetchUser.new(1) } 54 | .chain(user2: :user) { FetchUser.new(2) } 55 | .chain {|outflow| puts(outflow.user1, outflow.user2) } # report success 56 | .on_dam {|error, context| puts(error, context) } # report error 57 | ``` 58 | 59 | Which works like: 60 | 61 | ![Waterfall Logo](http://apneadiving.github.io/images/waterfall_full_example.png) 62 | 63 | ## Installation 64 | 65 | For installation, in your gemfile: 66 | 67 | gem 'waterfall' 68 | 69 | then `bundle` as usual. 70 | 71 | ## Waterfall mixin 72 | 73 | ### Outputs 74 | 75 | Each waterfall has its own `outflow` and `error_pool`. 76 | 77 | `outflow` is an Openstruct so you can get/set its property like a hash or like a standard object. 78 | 79 | ### Wiki 80 | Wiki contains many details, please check appropriate pages: 81 | 82 | - [Predicates](https://github.com/apneadiving/waterfall/wiki/Predicates) 83 | - [Wf Object](https://github.com/apneadiving/waterfall/wiki/Wf-object) 84 | - [Testing](https://github.com/apneadiving/waterfall/wiki/Testing) 85 | 86 | ### Koans (!) 87 | You can try and exercise your understanding of Waterfall using the [Koans here](https://github.com/apneadiving/waterfall_koans) 88 | 89 | ## Illustration of chaining 90 | Doing 91 | ```ruby 92 | Flow.new 93 | .chain(foo: :bar) { Flow.new.chain(:bar){ 1 } } 94 | ``` 95 | 96 | is the same as doing: 97 | 98 | ```ruby 99 | Flow.new 100 | .chain do |outflow, parent_waterfall| 101 | unless parent_waterfall.dammed? 102 | child = Wf.new.chain(:bar){ 1 } 103 | if child.dammed? 104 | parent_waterfall.dam(child.error_pool) 105 | else 106 | parent_waterfall.ouflow.foo = child.outflow.bar 107 | end 108 | end 109 | end 110 | ``` 111 | 112 | Hopefully you better get the chaining power this way. 113 | 114 | 115 | ## Syntactic sugar 116 | Given: 117 | ```ruby 118 | class MyWaterfall 119 | include Waterfall 120 | def call 121 | self.chain { 1 } 122 | end 123 | end 124 | ``` 125 | You may have noticed that I usually write: 126 | 127 | ```ruby 128 | Flow.new 129 | .chain { MyWaterfall.new } 130 | ``` 131 | instead of 132 | ```ruby 133 | Flow.new 134 | .chain { MyWaterfall.new.call } 135 | ``` 136 | 137 | Both are not really the same: 138 | - the only source of information for the gem is the return value of the block 139 | - if it returns a `Waterfall`, it will apply chaining logic. If ever the waterfall was not executed yet, it will trigger `call`, hence the convention. 140 | - if you call your waterfall object inside the block, the return value would be whatever your `call` method returns. So the gem doesnt know there was a waterfall involved and cannot apply chaining logic... unless you ensure `self` is always returned, which is cumbersome, so it's better to avoid this 141 | 142 | 143 | Syntax advice 144 | ========= 145 | ```ruby 146 | # this is valid 147 | self 148 | .chain { Service1.new } 149 | .chain { Service2.new } 150 | 151 | # this is equivalent 152 | self.chain { Service1.new } 153 | self.chain { Service2.new } 154 | 155 | # this is equivalent too 156 | chain { Service1.new } 157 | chain { Service2.new } 158 | 159 | # this is invalid Ruby due to the extra line 160 | self 161 | .chain { Service1.new } 162 | 163 | .chain { Service2.new } 164 | ``` 165 | 166 | Tips 167 | ========= 168 | ### Error pool 169 | For the error_pool, its up to you. But using Rails, I usually include ActiveModel::Validations in my services. 170 | 171 | Thus you: 172 | 173 | - have a standard way to deal with errors 174 | - can deal with multiple errors 175 | - support I18n out of the box 176 | - can use your model errors out of the box 177 | 178 | ### Conditional Flow 179 | In a service, there is one and single flow, so if you need conditionals to branch off, you can do: 180 | ```ruby 181 | self.chain { Service1.new } 182 | 183 | if foo? 184 | self.chain { Service2.new } 185 | else 186 | self.chain { Service3.new } 187 | end 188 | ``` 189 | ### Halting chain 190 | Sometimes you have a flow and you need a return value. You can use `halt_chain`, which is executed whether or not the flow is dammed. It returns what the block returns. As a consequence, it cannot be chained anymore, so it must be the last command: 191 | 192 | ```ruby 193 | self.halt_chain do |outflow, error_pool| 194 | if error_pool 195 | # what you want to return on error 196 | else 197 | # what you want to return from the outflow 198 | end 199 | end 200 | ``` 201 | 202 | ### Rails and transactions 203 | I'm used to wrap every single object involving database interactions within transactions, so it can be rolled back on error. 204 | Here is my usual setup: 205 | ```ruby 206 | module Waterfall 207 | extend ActiveSupport::Concern 208 | 209 | class Rollback < StandardError; end 210 | 211 | def with_transaction(&block) 212 | ActiveRecord::Base.transaction(requires_new: true) do 213 | yield 214 | on_dam do 215 | raise Waterfall::Rollback 216 | end 217 | end 218 | rescue Waterfall::Rollback 219 | self 220 | end 221 | end 222 | ``` 223 | 224 | And to use it: 225 | ```ruby 226 | class AuthenticateUser 227 | include Waterfall 228 | include ActiveModel::Validations 229 | 230 | validates :user, presence: true 231 | attr_reader :user 232 | 233 | def initialize(email, password) 234 | @email, @password = email, password 235 | end 236 | 237 | def call 238 | with_transaction do 239 | chain { @user = User.authenticate(@email, @password) } 240 | when_falsy { valid? } 241 | .dam { errors } 242 | chain(:user) { user } 243 | end 244 | end 245 | end 246 | ``` 247 | The huge benefit is that if you call services from services, everything will be rolled back. 248 | 249 | ### Undo 250 | 251 | If you get to dam a flow, this would trigger the `reverse_flow` method in all Services previously executed. 252 | 253 | `reverse_flow` is not executed on the service which just failed, consider the `on_dam` hook in this case. 254 | 255 | Take this as a hook to undo whatever you need to undo if things go wrong. Yet, you probably do not need to bother about databases inserts: this is the purpose of `with_transaction`. 256 | 257 | ### FYI 258 | 259 | `Flow` is just an alias for the `Wf` class, so just use the one you prefer :) 260 | 261 | Examples / Presentations 262 | ======================== 263 | - Check the [wiki for other examples](https://github.com/apneadiving/waterfall/wiki/Refactoring-examples). 264 | - [Structure and chain your POROs](http://slides.com/apneadiving/structure-and-chain-your-poros). 265 | - [Service objects implementations](https://slides.com/apneadiving/service-objects-waterfall-rails). 266 | - [Handling error in Rails](https://slides.com/apneadiving/handling-error-in-ruby-rails). 267 | 268 | Thanks 269 | ========= 270 | Huge thanks to [robhorrigan](https://github.com/robhorrigan) for the help during infinite naming brainstorming. 271 | --------------------------------------------------------------------------------