├── .rspec ├── lib ├── transflow │ ├── version.rb │ ├── errors.rb │ ├── step_dsl.rb │ ├── flow_dsl.rb │ ├── publisher.rb │ └── transaction.rb └── transflow.rb ├── Rakefile ├── bin ├── setup └── console ├── .travis.yml ├── Gemfile ├── spec ├── unit │ ├── kw_args.rb │ ├── publisher_spec.rb │ └── transaction_spec.rb ├── spec_helper.rb └── integration │ ├── injecting_args_spec.rb │ ├── monadic_transflow_spec.rb │ ├── events_spec.rb │ └── transflow_spec.rb ├── .gitignore ├── README.md ├── transflow.gemspec ├── LICENSE └── CHANGELOG.md /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --order random 3 | --warnings 4 | -r ./spec/spec_helper.rb 5 | -------------------------------------------------------------------------------- /lib/transflow/version.rb: -------------------------------------------------------------------------------- 1 | module Transflow 2 | VERSION = '0.3.0'.freeze 3 | end 4 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task :default => :spec 7 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | 5 | bundle install 6 | 7 | # Do any other automated setup that you need to do here 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: ruby 3 | before_install: gem install bundler 4 | rvm: 5 | - 2.0 6 | - 2.1 7 | - 2.2 8 | - rbx-2 9 | - jruby 10 | - ruby-head 11 | - jruby-head 12 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in transflow.gemspec 4 | gemspec 5 | 6 | group :test do 7 | gem 'transproc' 8 | gem 'codeclimate-test-reporter', require: false, platforms: :rbx 9 | end 10 | 11 | group :tools do 12 | gem 'byebug', platforms: :mri 13 | end 14 | -------------------------------------------------------------------------------- /spec/unit/kw_args.rb: -------------------------------------------------------------------------------- 1 | RSpec.shared_context 'with steps accepting kw args' do 2 | let(:step1) { -> i { { i: i, j: i + 1 } } } 3 | let(:step2) { -> i:, j: { i + j } } 4 | let(:step3) { -> i { i + 3 } } 5 | 6 | it 'composes steps and calls them' do 7 | expect(transaction[1]).to be(6) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "transflow" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start 15 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | if RUBY_ENGINE == "rbx" 2 | require "codeclimate-test-reporter" 3 | CodeClimate::TestReporter.start 4 | end 5 | 6 | $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) 7 | require 'transflow' 8 | 9 | begin 10 | require 'byebug' 11 | rescue LoadError; end 12 | 13 | require 'transproc' 14 | 15 | module Test 16 | def self.remove_constants 17 | constants.each(&method(:remove_const)) 18 | end 19 | end 20 | 21 | RSpec.configure do |config| 22 | config.after do 23 | Test.remove_constants 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/transflow/errors.rb: -------------------------------------------------------------------------------- 1 | module Transflow 2 | class TransactionFailedError < StandardError 3 | attr_reader :transaction 4 | 5 | attr_reader :original_error 6 | 7 | def initialize(transaction, original_error) 8 | @transaction = transaction 9 | @original_error = original_error 10 | 11 | super("#{transaction} failed [#{original_error.class}: #{original_error.message}]") 12 | 13 | set_backtrace(original_error.backtrace) 14 | end 15 | end 16 | 17 | class StepError < StandardError 18 | attr_reader :original_error 19 | 20 | def initialize(input = nil) 21 | if input.kind_of?(StandardError) 22 | @original_error = input 23 | super(@original_error.message) 24 | set_backtrace(original_error.backtrace) 25 | else 26 | super(input) 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /.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 | /vendor/bundle 26 | /lib/bundler/man/ 27 | 28 | # for a library or gem, you might want to ignore these files since the code is 29 | # intended to run in multiple environments; otherwise, check them in: 30 | # Gemfile.lock 31 | # .ruby-version 32 | # .ruby-gemset 33 | 34 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: 35 | .rvmrc 36 | ======= 37 | /.bundle/ 38 | /.yardoc 39 | /Gemfile.lock 40 | /_yardoc/ 41 | /coverage/ 42 | /doc/ 43 | /pkg/ 44 | /spec/reports/ 45 | /tmp/ 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [gem]: https://rubygems.org/gems/transflow 2 | [travis]: https://travis-ci.org/solnic/transflow 3 | [gemnasium]: https://gemnasium.com/solnic/transflow 4 | [codeclimate]: https://codeclimate.com/github/solnic/transflow 5 | [inchpages]: http://inch-ci.org/github/solnic/transflow 6 | 7 | # Transflow 8 | 9 | [![Gem Version](https://badge.fury.io/rb/transflow.svg)][gem] 10 | [![Build Status](https://travis-ci.org/solnic/transflow.svg?branch=master)][travis] 11 | [![Dependency Status](https://gemnasium.com/solnic/transflow.png)][gemnasium] 12 | [![Code Climate](https://codeclimate.com/github/solnic/transflow/badges/gpa.svg)][codeclimate] 13 | [![Test Coverage](https://codeclimate.com/github/solnic/transflow/badges/coverage.svg)][codeclimate] 14 | [![Inline docs](http://inch-ci.org/github/solnic/transflow.svg?branch=master)][inchpages] 15 | 16 | # DISCONTINUED 17 | 18 | This project turned out to be an inspiration for creating [dry-transaction](https://github.com/dry-rb/dry-transaction) which eventually has become better, so please check it out! 19 | -------------------------------------------------------------------------------- /transflow.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'transflow/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "transflow" 8 | spec.version = Transflow::VERSION 9 | spec.authors = ["Piotr Solnica"] 10 | spec.email = ["piotr.solnica@gmail.com"] 11 | spec.license = "MIT" 12 | 13 | spec.summary = "Business Transaction Flow DSL" 14 | spec.homepage = "https://github.com/solnic/transflow" 15 | 16 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 17 | spec.bindir = "exe" 18 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 19 | spec.require_paths = ["lib"] 20 | 21 | spec.add_runtime_dependency 'dry-pipeline' 22 | spec.add_runtime_dependency 'wisper' 23 | spec.add_runtime_dependency 'kleisli' 24 | 25 | spec.add_development_dependency "bundler", "~> 1.10" 26 | spec.add_development_dependency "rake", "~> 10.0" 27 | spec.add_development_dependency "rspec", "~> 3.3" 28 | end 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Piotr Solnica 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /lib/transflow/step_dsl.rb: -------------------------------------------------------------------------------- 1 | require 'transflow/publisher' 2 | 3 | module Transflow 4 | class StepDSL 5 | attr_reader :name 6 | 7 | attr_reader :options 8 | 9 | attr_reader :handler 10 | 11 | attr_reader :container 12 | 13 | attr_reader :steps 14 | 15 | attr_reader :publish 16 | 17 | attr_reader :monadic 18 | 19 | def initialize(name, options, container, steps, &block) 20 | @name = name 21 | @options = options 22 | @handler = options.fetch(:with, name) 23 | @publish = options.fetch(:publish, false) 24 | @monadic = options.fetch(:monadic, false) 25 | @container = container 26 | @steps = steps 27 | instance_exec(&block) if block 28 | end 29 | 30 | def step(name, new_options = {}, &block) 31 | self.class.new(name, options.merge(new_options), container, steps, &block).call 32 | end 33 | 34 | def call 35 | operation = container[handler] 36 | 37 | step = 38 | if publish 39 | Publisher[name, operation, monadic: monadic] 40 | else 41 | operation 42 | end 43 | 44 | steps[name] = step 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /spec/integration/injecting_args_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe 'Injecting args into operations' do 2 | it 'allows passing additional args prior calling transaction' do 3 | Test::DB = [] 4 | 5 | operations = { 6 | preprocess_input: -> input { { name: input['name'], email: input['email'] } }, 7 | validate_input: -> emails, input { 8 | emails.is_a?(Array) && emails.include?(input[:email]) ? input : raise( 9 | Transflow::StepError.new('email unknown')) 10 | }, 11 | persist_input: -> input { Test::DB << input } 12 | } 13 | 14 | transflow = Transflow(container: operations) do 15 | step :preprocess, with: :preprocess_input do 16 | step :validate, with: :validate_input do 17 | step :persist, with: :persist_input 18 | end 19 | end 20 | end 21 | 22 | input = { 'name' => 'Jane', 'email' => 'jane@doe.org' } 23 | 24 | transflow.(input, validate: ['jane@doe.org']) 25 | 26 | expect(Test::DB).to include(name: 'Jane', email: 'jane@doe.org') 27 | 28 | expect { 29 | transflow.(input, validate: ['jade@doe.org']) 30 | }.to raise_error(Transflow::TransactionFailedError, /StepError: email unknown/) 31 | end 32 | end 33 | 34 | -------------------------------------------------------------------------------- /lib/transflow/flow_dsl.rb: -------------------------------------------------------------------------------- 1 | require 'transflow/step_dsl' 2 | require 'transflow/transaction' 3 | 4 | module Transflow 5 | # @api private 6 | class FlowDSL 7 | # @api private 8 | attr_reader :options 9 | 10 | # @api private 11 | attr_reader :container 12 | 13 | # @api private 14 | attr_reader :step_map 15 | 16 | # @api private 17 | attr_reader :step_options 18 | 19 | # @api private 20 | def initialize(options, &block) 21 | @options = options 22 | @container = options.fetch(:container) 23 | @step_map = {} 24 | @step_options = {} 25 | instance_exec(&block) 26 | end 27 | 28 | # @api public 29 | def steps(*names) 30 | names.reverse_each { |name| step(name) } 31 | end 32 | 33 | # @api public 34 | def step(name, options = {}, &block) 35 | StepDSL.new(name, step_options.merge(options), container, step_map, &block).call 36 | end 37 | 38 | # @api public 39 | def monadic(value) 40 | step_options.update(monadic: value) 41 | end 42 | 43 | # @api public 44 | def publish(value) 45 | step_options.update(publish: value) 46 | end 47 | 48 | # @api private 49 | def call 50 | Transaction.new(step_map) 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/transflow.rb: -------------------------------------------------------------------------------- 1 | require 'transflow/version' 2 | require 'transflow/flow_dsl' 3 | 4 | # Define a business transaction flow. 5 | # 6 | # A business transaction flow is a simple composition of callable objects that 7 | # receive an input and produce an output. Steps are registered in the same order 8 | # they are defined within the DSL and that's also the order of execution. 9 | # 10 | # Initial input is sent to the first step, its output is sent to the second step 11 | # and so on. 12 | # 13 | # Every step can become a publisher, which means you can broadcast results from 14 | # any step and subscribe event listeners to individual steps. This gives you 15 | # a flexible way of responding to successful or failed execution of individual 16 | # steps. 17 | # 18 | # @example 19 | # container = { do_one: some_obj, do_two: some_obj } 20 | # 21 | # my_business_flow = Transflow(container: container) do 22 | # step(:one, with: :do_one) { step(:two, with: :do_two } 23 | # end 24 | # 25 | # my_business_flow[some_input] 26 | # 27 | # # with events 28 | # 29 | # my_business_flow = Transflow(container: container) do 30 | # step(:one, with: :do_one) { step(:two, with: :do_two, publish: true) } 31 | # end 32 | # 33 | # class Listener 34 | # def self.do_two_success(*args) 35 | # puts ":do_two totally worked and produced: #{args.inspect}!" 36 | # end 37 | # end 38 | # 39 | # my_business_flow.subscribe(do_two: Listener) 40 | # 41 | # my_business_flow[some_input] 42 | # 43 | # @options [Hash] options The option hash 44 | # 45 | # @api public 46 | def Transflow(options = {}, &block) 47 | Transflow::FlowDSL.new(options, &block).call 48 | end 49 | -------------------------------------------------------------------------------- /spec/integration/monadic_transflow_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Transflow do 2 | let(:operations) do 3 | { 4 | validate: -> input { 5 | if input[:name] && input[:email] 6 | Right(input) 7 | else 8 | Left('that is not valid') 9 | end 10 | }, 11 | persist: -> input { 12 | input.fmap { |value| Test::DB << value } 13 | } 14 | } 15 | end 16 | 17 | let(:transflow) do 18 | Transflow(container: operations) do 19 | monadic true 20 | 21 | step :validate, publish: true do 22 | step :persist 23 | end 24 | end 25 | end 26 | 27 | before do 28 | Test::DB = [] 29 | Test::ERRORS = [] 30 | end 31 | 32 | it 'calls all operations and return final result' do 33 | input = { name: 'Jane', email: 'jane@doe.org' } 34 | 35 | transflow[input] 36 | 37 | expect(Test::DB).to include(name: 'Jane', email: 'jane@doe.org') 38 | end 39 | 40 | it 'returns monad' do 41 | input = { name: 'Jane', email: 'jane@doe.org' } 42 | 43 | result = transflow[input].fmap { |db| db[0][:email] } 44 | 45 | expect(result.value).to eql('jane@doe.org') 46 | 47 | input = { email: 'jane@doe.org' } 48 | 49 | result = transflow[input] 50 | 51 | expect(result.value).to eql('that is not valid') 52 | end 53 | 54 | it 'triggers events' do 55 | input = { email: 'jane@doe.org' } 56 | 57 | listener = Module.new do 58 | def self.validate_failure(input, msg) 59 | Test::ERRORS << "#{input[:email]} #{msg}" 60 | end 61 | end 62 | 63 | transflow.subscribe(validate: listener) 64 | 65 | transflow[input] 66 | 67 | expect(Test::DB).to be_empty 68 | expect(Test::ERRORS).to include("jane@doe.org that is not valid") 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 0.3.0 2015-08-19 2 | 3 | ## Added 4 | 5 | - Support for steps that return [kleisli](https://github.com/txus/kleisli) monads (solnic) 6 | - Support for setting default step options via flow DSL (solnic) 7 | - Support for subscribing many listeners to a single step (solnic) 8 | - Support for subscribing one listener to all steps (solnic) 9 | 10 | ## Changed 11 | 12 | - Now step objects are wrapped using `Step` decorator that uses `dry-pipeline` gem (solnic) 13 | - Only `Transflow::StepError` errors can cause transaction failure (solnic) 14 | 15 | [Compare v0.2.0...v0.3.0](https://github.com/solnic/transflow/compare/v0.2.0...v0.3.0) 16 | 17 | # 0.2.0 2015-08-18 18 | 19 | ## Added 20 | 21 | - Support for currying a publisher step (solnic) 22 | 23 | [Compare v0.1.0...v0.2.0](https://github.com/solnic/transflow/compare/v0.1.0...v0.2.0) 24 | 25 | # 0.1.0 2015-08-17 26 | 27 | ## Added 28 | 29 | - `Transaction#call` will raise if options include an unknown step name (solnic) 30 | - `Transflow` support shorter syntax for steps: `steps :one, :two, :three` (solnic) 31 | - `step(name)` defaults to `step(name, with: name)` (solnic) 32 | 33 | ## Fixed 34 | 35 | - `Transaction#to_s` displays steps in the order of execution (solnic) 36 | 37 | ## Internal 38 | 39 | - Organize source code into separate files (solnic) 40 | - Document public interface with YARD (solnic) 41 | - Add unit specs for `Transaction` (solnic) 42 | 43 | [Compare v0.0.2...v0.1.0](https://github.com/solnic/transflow/compare/v0.0.2...v0.1.0) 44 | 45 | # 0.0.2 2015-08-16 46 | 47 | ## Added 48 | 49 | - Ability to pass aditional arguments to specific operations prior calling the 50 | whole transaction (solnic) 51 | 52 | [Compare v0.0.2...v0.0.2](https://github.com/solnic/transflow/compare/v0.0.1...v0.0.2) 53 | 54 | # 0.0.2 2015-08-16 55 | 56 | ## Added 57 | 58 | - Ability to publish events from operations via `publish: true` option (solnic) 59 | - Ability to subscribe to events via `Transflow::Transaction#subscribe` interface (solnic) 60 | 61 | [Compare v0.0.1...v0.0.2](https://github.com/solnic/transflow/compare/v0.0.1...v0.0.2) 62 | 63 | # 0.0.1 2015-08-16 64 | 65 | First public release \o/ 66 | -------------------------------------------------------------------------------- /spec/integration/events_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | RSpec.describe 'Defining events for operations' do 4 | let(:persist_user_listener) do 5 | Module.new { 6 | def self.persist_success(user) 7 | Test::SENT_EMAILS << user[:name] 8 | end 9 | 10 | def self.persist_failure(user, error) 11 | Test::ERRORS << "#{user[:name]} - #{error.message}" 12 | end 13 | } 14 | end 15 | 16 | let(:transflow) do 17 | Transflow(container: operations) do 18 | step :preprocess, with: :preprocess_input do 19 | step :validate, with: :validate_input do 20 | step :persist, with: :persist_input, publish: true 21 | end 22 | end 23 | end 24 | end 25 | 26 | before do 27 | Test::SENT_EMAILS = [] 28 | Test::ERRORS = [] 29 | end 30 | 31 | context 'with success' do 32 | let(:operations) do 33 | { 34 | preprocess_input: -> input { { name: input['name'], email: input['email'] } }, 35 | validate_input: -> input { input }, 36 | persist_input: -> input { input } 37 | } 38 | end 39 | 40 | specify '(⊃。•́‿•̀。)⊃━☆゚.*・。゚' do 41 | transflow.subscribe(persist: persist_user_listener) 42 | 43 | input = { 'name' => 'Jane', 'email' => 'jane@doe.org' } 44 | 45 | transflow[input] 46 | 47 | expect(Test::SENT_EMAILS).to include('Jane') 48 | end 49 | end 50 | 51 | context 'with failure' do 52 | let(:operations) do 53 | { 54 | preprocess_input: -> input { { name: input['name'], email: input['email'] } }, 55 | validate_input: -> input { input }, 56 | persist_input: -> input do 57 | begin 58 | raise StandardError, 'OH NOEZ' 59 | rescue => e 60 | raise Transflow::StepError.new(e) 61 | end 62 | end 63 | } 64 | end 65 | 66 | specify '༼ つ ˵ ╥ ͟ʖ ╥ ˵༽つ' do 67 | transflow.subscribe(persist: persist_user_listener) 68 | 69 | input = { 'name' => 'Jane', 'email' => 'jane@doe.org' } 70 | 71 | expect { transflow[input] }.to raise_error(Transflow::TransactionFailedError) 72 | 73 | expect(Test::SENT_EMAILS).to be_empty 74 | expect(Test::ERRORS).to include("Jane - OH NOEZ") 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /lib/transflow/publisher.rb: -------------------------------------------------------------------------------- 1 | require 'wisper' 2 | require 'kleisli' 3 | 4 | require 'transflow/errors' 5 | 6 | module Transflow 7 | class Publisher 8 | include Wisper::Publisher 9 | 10 | attr_reader :name 11 | 12 | attr_reader :op 13 | 14 | def self.[](name, op, options = {}) 15 | type = 16 | if options[:monadic] 17 | Monadic 18 | else 19 | self 20 | end 21 | type.new(name, op) 22 | end 23 | 24 | class Monadic < Publisher 25 | def call(*args) 26 | op.(*args) 27 | .or { |result| broadcast_failure(*args, result) and Left(result) } 28 | .>-> value { broadcast_success(value) and Right(value) } 29 | end 30 | end 31 | 32 | class Curried < Publisher 33 | attr_reader :publisher 34 | 35 | attr_reader :arity 36 | 37 | attr_reader :curry_args 38 | 39 | def initialize(publisher, curry_args = []) 40 | @publisher = publisher 41 | @arity = publisher.arity 42 | @curry_args = curry_args 43 | end 44 | 45 | def call(*args) 46 | all_args = curry_args + args 47 | 48 | if all_args.size == arity 49 | publisher.call(*all_args) 50 | else 51 | self.class.new(publisher, all_args) 52 | end 53 | end 54 | 55 | def subscribe(*args) 56 | publisher.subscribe(*args) 57 | end 58 | end 59 | 60 | def initialize(name, op) 61 | @name = name 62 | @op = op 63 | end 64 | 65 | def curry 66 | raise "can't curry publisher where operation arity is < 0" if arity < 0 67 | Curried.new(self) 68 | end 69 | 70 | def arity 71 | op.is_a?(Proc) ? op.arity : op.method(:call).arity 72 | end 73 | 74 | def call(*args) 75 | result = op.call(*args) 76 | broadcast_success(result) 77 | result 78 | rescue StepError => err 79 | broadcast_failure(*args, err) and raise(err) 80 | end 81 | alias_method :[], :call 82 | 83 | def subscribe(listeners, *args) 84 | Array(listeners).each { |listener| super(listener, *args) } 85 | end 86 | 87 | private 88 | 89 | def broadcast_success(result) 90 | broadcast(:"#{name}_success", result) 91 | end 92 | 93 | def broadcast_failure(*args, err) 94 | broadcast(:"#{name}_failure", *args, err) 95 | end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /spec/integration/transflow_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Transflow do 2 | shared_context 'a successful transaction' do 3 | it 'calls all operations and return final result' do 4 | input = { 'name' => 'Jane', 'email' => 'jane@doe.org' } 5 | 6 | transflow[input] 7 | 8 | expect(Test::DB).to include(name: 'Jane', email: 'jane@doe.org') 9 | end 10 | end 11 | 12 | let(:transflow) do 13 | Transflow(container: operations) do 14 | step :preprocess, with: :preprocess_input do 15 | step :validate, with: :validate_input do 16 | step :persist, with: :persist_input 17 | end 18 | end 19 | end 20 | end 21 | 22 | before do 23 | Test::DB = [] 24 | end 25 | 26 | context 'with module functions' do 27 | include_context 'a successful transaction' do 28 | let(:operations) do 29 | Module.new do 30 | extend Transproc::Registry 31 | 32 | import :symbolize_keys, from: Transproc::HashTransformations 33 | 34 | def self.preprocess_input(input) 35 | t(:symbolize_keys)[input] 36 | end 37 | 38 | def self.validate_input(input) 39 | raise StepError.new(:validate_input, 'email nil') if input[:email].nil? 40 | input 41 | end 42 | 43 | def self.persist_input(input) 44 | Test::DB << input 45 | end 46 | 47 | def self.handle_validation_error(error) 48 | ERRORS << error.message 49 | end 50 | end 51 | end 52 | end 53 | end 54 | 55 | context 'with custom operation objects' do 56 | include_context 'a successful transaction' do 57 | let(:operations) do 58 | { 59 | preprocess_input: -> input { { name: input['name'], email: input['email'] } }, 60 | validate_input: -> input { input }, 61 | persist_input: -> input { Test::DB << input } 62 | } 63 | end 64 | end 65 | end 66 | 67 | context 'using short DSL' do 68 | include_context 'a successful transaction' do 69 | let(:operations) do 70 | { 71 | preprocess: -> input { { name: input['name'], email: input['email'] } }, 72 | validate: -> input { input }, 73 | persist: -> input { Test::DB << input } 74 | } 75 | end 76 | 77 | let(:transflow) do 78 | Transflow(container: operations) do 79 | publish true 80 | 81 | steps :preprocess, :validate, :persist 82 | end 83 | end 84 | 85 | it 'sets publish to true for all steps' do 86 | listener = spy(:listener) 87 | 88 | transflow.subscribe(preprocess: listener, validate: listener, persist: listener) 89 | 90 | transflow['name' => 'Jane', 'email' => 'jane@doe.org'] 91 | 92 | expect(listener).to have_received(:preprocess_success) 93 | .with(name: 'Jane', email: 'jane@doe.org') 94 | 95 | expect(listener).to have_received(:validate_success) 96 | .with(name: 'Jane', email: 'jane@doe.org') 97 | 98 | expect(listener).to have_received(:persist_success) 99 | .with(Test::DB) 100 | end 101 | end 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /spec/unit/publisher_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Transflow::Publisher do 2 | describe '#curry' do 3 | it 'returns a curried publisher' do 4 | op = -> i, j { i + j } 5 | publisher = Transflow::Publisher.new(:step, op).curry 6 | 7 | expect(publisher.arity).to be(2) 8 | expect(publisher.(1).(2)).to be(3) 9 | end 10 | 11 | it 'supports callable objects' do 12 | op = Class.new { define_method(:call) { |i, j| i + j } }.new 13 | 14 | publisher = Transflow::Publisher.new(:step, op).curry 15 | 16 | expect(publisher.arity).to be(2) 17 | expect(publisher.(1).(2)).to be(3) 18 | end 19 | 20 | it 'raises error when arity is < 0' do 21 | op = -> *args { } 22 | 23 | expect { 24 | Transflow::Publisher.new(:step, op).curry 25 | }.to raise_error(/arity is < 0/) 26 | end 27 | 28 | it 'triggers event listener' do 29 | listener = spy(:listener) 30 | 31 | op = -> i, j { i + j } 32 | publisher = Transflow::Publisher.new(:step, op).curry 33 | 34 | publisher.subscribe(listener) 35 | 36 | expect(publisher.(1).(2)).to be(3) 37 | expect(listener).to have_received(:step_success).with(3) 38 | end 39 | end 40 | 41 | describe '#subscribe' do 42 | it 'subscribes one object' do 43 | publisher = Transflow::Publisher.new(:step, -> {}) 44 | sub = double 45 | 46 | publisher.subscribe(sub) 47 | 48 | expect(publisher.listeners).to eql([sub]) 49 | end 50 | 51 | it 'subscribes many objects' do 52 | publisher = Transflow::Publisher.new(:step, -> {}) 53 | 54 | sub1 = double 55 | sub2 = double 56 | 57 | publisher.subscribe([sub1, sub2]) 58 | 59 | expect(publisher.listeners).to eql([sub1, sub2]) 60 | end 61 | end 62 | 63 | describe '#call' do 64 | context 'using exceptions' do 65 | subject(:publisher) { Transflow::Publisher[:divide, op] } 66 | 67 | let(:listener) { spy(:listener) } 68 | 69 | context 'when step error is raised' do 70 | let(:op) { -> i, j { j.zero? ? raise(Transflow::StepError, error) : i/j } } 71 | let(:error) { "well, j was zero, sorry mate" } 72 | 73 | it 'broadcasts failure' do 74 | publisher.subscribe(listener) 75 | 76 | expect { 77 | expect(publisher.(4, 0).value).to eql(error.value) 78 | }.to raise_error(Transflow::StepError, error) 79 | 80 | expect(listener).to have_received(:divide_failure) 81 | end 82 | end 83 | 84 | context 'when other error is raised' do 85 | let(:op) { -> i, j { i/j } } 86 | 87 | it 'broadcasts failure' do 88 | publisher.subscribe(listener) 89 | 90 | expect { 91 | expect(publisher.(4, 0).value).to eql(error.value) 92 | }.to raise_error(ZeroDivisionError) 93 | 94 | expect(listener).to_not have_received(:divide_failure) 95 | end 96 | end 97 | end 98 | 99 | context 'using monads' do 100 | subject(:publisher) { Transflow::Publisher[:divide, op, monadic: true] } 101 | 102 | let(:listener) { spy(:listener) } 103 | 104 | let(:op) { -> i, j { j.zero? ? error : Right(i / j) } } 105 | let(:error) { Left("well, j was zero, sorry mate") } 106 | 107 | it 'broadcasts success with Right result' do 108 | publisher.subscribe(listener) 109 | 110 | expect(publisher.(4, 2).value).to be(2) 111 | 112 | expect(listener).to have_received(:divide_success).with(2) 113 | end 114 | 115 | it 'broadcasts failure with Left result' do 116 | publisher.subscribe(listener) 117 | 118 | expect(publisher.(4, 0).value).to eql(error.value) 119 | 120 | expect(listener).to have_received(:divide_failure).with(4, 0, error.value) 121 | end 122 | end 123 | end 124 | end 125 | -------------------------------------------------------------------------------- /lib/transflow/transaction.rb: -------------------------------------------------------------------------------- 1 | require 'dry-pipeline' 2 | require 'transflow/errors' 3 | 4 | module Transflow 5 | # Transaction encapsulates calling individual steps registered within a transflow 6 | # constructor. 7 | # 8 | # It's responsible for calling steps in the right order and optionally currying 9 | # arguments for specific steps. 10 | # 11 | # Furthermore you can subscribe event listeners to individual steps within a 12 | # transaction. 13 | # 14 | # @api public 15 | class Transaction 16 | # Step wrapper object which adds `>>` operator 17 | # 18 | # @api private 19 | class Step < Dry::Pipeline 20 | # @api private 21 | def self.[](op) 22 | if op.respond_to?(:>>) 23 | op 24 | else 25 | Step.new(op) 26 | end 27 | end 28 | end 29 | 30 | # @attr_reader [Hash Proc,#call>] steps The step map 31 | # 32 | # @api private 33 | attr_reader :steps 34 | 35 | # @attr_reader [Array] step_names The names of registered steps 36 | # 37 | # @api private 38 | attr_reader :step_names 39 | 40 | # @api private 41 | def initialize(steps) 42 | @steps = steps 43 | @step_names = steps.keys.reverse 44 | end 45 | 46 | # Subscribe event listeners to specific steps 47 | # 48 | # @example 49 | # transaction = Transflow(container: my_container) { 50 | # step(:one) { step(:two, publish: true } 51 | # } 52 | # 53 | # class MyListener 54 | # def self.two_success(*args) 55 | # puts 'yes!' 56 | # end 57 | # 58 | # def self.two_failure(*args) 59 | # puts 'oh noez!' 60 | # end 61 | # end 62 | # 63 | # transaction.subscribe(two: my_listener) 64 | # 65 | # transaction.call(some_input) 66 | # 67 | # @param [Hash Object>] listeners The step=>listener map 68 | # 69 | # @return [self] 70 | # 71 | # @api public 72 | def subscribe(listeners) 73 | if listeners.is_a?(Hash) 74 | listeners.each { |step, listener| steps[step].subscribe(listener) } 75 | else 76 | steps.each { |(_, step)| step.subscribe(listeners) } 77 | end 78 | self 79 | end 80 | 81 | # Call the transaction 82 | # 83 | # Once transaction is called it will call the first step and its result 84 | # will be passed to the second step and so on. 85 | # 86 | # @example 87 | # my_container = { 88 | # add_one: -> i { i + 1 }, 89 | # add_two: -> j { j + 2 } 90 | # } 91 | # 92 | # transaction = Transflow(container: my_container) { 93 | # step(:one, with: :add_one) { step(:two, with: :add_two) } 94 | # } 95 | # 96 | # transaction.call(1) # 4 97 | # 98 | # @param [Object] input The input for the first step 99 | # 100 | # @param [Hash] options The curry-args map, optional 101 | # 102 | # @return [Object] 103 | # 104 | # @raises TransactionFailedError 105 | # 106 | # @api public 107 | def call(input, options = {}) 108 | handler = handler_steps(options).map(&method(:step)).reduce(:>>) 109 | handler.call(input) 110 | rescue StepError => err 111 | raise TransactionFailedError.new(self, err) 112 | end 113 | alias_method :[], :call 114 | 115 | # Coerce a transaction into string representation 116 | # 117 | # @return [String] 118 | # 119 | # @api public 120 | def to_s 121 | "Transaction(#{step_names.join(' => ')})" 122 | end 123 | 124 | private 125 | 126 | # @api private 127 | def handler_steps(options) 128 | if options.any? 129 | assert_valid_options(options) 130 | 131 | steps.map { |(name, op)| 132 | args = options[name] 133 | 134 | if args 135 | op.curry.call(args) 136 | else 137 | op 138 | end 139 | } 140 | else 141 | steps.values 142 | end.reverse 143 | end 144 | 145 | # @api private 146 | def assert_valid_options(options) 147 | options.each_key do |name| 148 | unless step_names.include?(name) 149 | raise ArgumentError, "+#{name}+ is not a valid step name" 150 | end 151 | end 152 | end 153 | 154 | # Wrap a proc into composable transproc function 155 | # 156 | # @param [#call] 157 | # 158 | # @api private 159 | def step(obj) 160 | Step[obj] 161 | end 162 | end 163 | end 164 | -------------------------------------------------------------------------------- /spec/unit/transaction_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative 'kw_args.rb' if RUBY_VERSION > '2.0.0' 2 | 3 | RSpec.describe Transflow::Transaction do 4 | subject(:transaction) { Transflow::Transaction.new(steps) } 5 | 6 | let(:steps) { { three: step3, two: step2, one: step1 } } 7 | 8 | describe '#call' do 9 | context 'with steps accepting a single arg' do 10 | let(:step1) { -> i { i + 1 } } 11 | let(:step2) { -> i { i + 2 } } 12 | let(:step3) { -> i { i + 3 } } 13 | 14 | it 'composes steps and calls them' do 15 | expect(transaction[1]).to be(7) 16 | end 17 | end 18 | 19 | context 'with steps accepting an array' do 20 | let(:step1) { -> arr { arr.map(&:succ) } } 21 | let(:step2) { -> arr { arr.map(&:succ) } } 22 | let(:step3) { -> arr { arr.map(&:succ) } } 23 | 24 | it 'composes steps and calls them' do 25 | expect(transaction[[1, 2, 3]]).to eql([4, 5, 6]) 26 | end 27 | end 28 | 29 | context 'with steps accepting a hash' do 30 | let(:step1) { -> i { { i: i } } } 31 | let(:step2) { -> h { h[:i].succ } } 32 | let(:step3) { -> i { i.succ } } 33 | 34 | it 'composes steps and calls them' do 35 | expect(transaction[1]).to eql(3) 36 | end 37 | end 38 | 39 | context 'with curry args' do 40 | let(:step1) { -> arr { arr.reduce(:+) } } 41 | let(:step2) { -> i, j { i + j } } 42 | let(:step3) { -> i { i.succ } } 43 | 44 | it 'curries provided args for a specific step' do 45 | expect(transaction[[1, 2], two: 2]).to be(6) 46 | end 47 | 48 | it 'raises error when name is not a registered step' do 49 | expect { transaction[[1, 2], oops: 2] }.to raise_error(ArgumentError, /oops/) 50 | end 51 | end 52 | 53 | include_context 'with steps accepting kw args' if RUBY_VERSION > '2.0.0' 54 | 55 | context 'with curried publisher step' do 56 | let(:step1) { -> i { i + 1 } } 57 | let(:step2) { Transflow::Publisher.new(:step2, -> i, j { i * j + 2 }) } 58 | let(:step3) { -> i { i + 3 } } 59 | 60 | let(:listener) { spy(:listener) } 61 | 62 | it 'partially applies provided args for specific steps' do 63 | result = transaction.subscribe(two: listener).call(1, two: 2) 64 | 65 | expect(result).to be(9) 66 | 67 | expect(listener).to have_received(:step2_success) 68 | end 69 | end 70 | 71 | context 'when step error is raised' do 72 | let(:step1) { -> i { i + 1 } } 73 | let(:step2) { -> i { raise Transflow::StepError } } 74 | let(:step3) { -> i { i + 3 } } 75 | 76 | it 'raises transaction failed error' do 77 | expect { transaction[1] }.to raise_error(Transflow::TransactionFailedError) 78 | end 79 | end 80 | end 81 | 82 | describe '#subscribe' do 83 | let(:step1) { instance_double('Transflow::Publisher') } 84 | let(:step2) { instance_double('Transflow::Publisher') } 85 | let(:step3) { instance_double('Transflow::Publisher') } 86 | 87 | it 'subscribes to individual steps' do 88 | listener1 = double(:listener1) 89 | listener3 = double(:listener3) 90 | 91 | expect(step1).to receive(:subscribe).with(listener1) 92 | expect(step2).to_not receive(:subscribe) 93 | expect(step3).to receive(:subscribe).with(listener3) 94 | 95 | transaction.subscribe(one: listener1, three: listener3) 96 | end 97 | 98 | it 'subscribes to all steps' do 99 | listener = double(:listener) 100 | 101 | expect(step1).to receive(:subscribe).with(listener) 102 | expect(step2).to receive(:subscribe).with(listener) 103 | expect(step3).to receive(:subscribe).with(listener) 104 | 105 | transaction.subscribe(listener) 106 | end 107 | 108 | it 'subscribes many listeners to individual steps' do 109 | listener11 = double(:listener11) 110 | listener12 = double(:listener12) 111 | 112 | listener31 = double(:listener31) 113 | listener32 = double(:listener32) 114 | 115 | expect(step1).to receive(:subscribe).with([listener11, listener12]) 116 | 117 | expect(step2).to_not receive(:subscribe) 118 | 119 | expect(step3).to receive(:subscribe).with([listener31, listener32]) 120 | 121 | transaction.subscribe(one: [listener11, listener12], three: [listener31, listener32]) 122 | end 123 | end 124 | 125 | describe '#to_s' do 126 | it 'returns a string representation of a transaction' do 127 | transaction = Transflow::Transaction.new(three: proc {}, two: proc {}, one: proc {}) 128 | 129 | expect(transaction.to_s).to eql('Transaction(one => two => three)') 130 | end 131 | end 132 | end 133 | --------------------------------------------------------------------------------