flows 0.1.0 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/build.yml +43 -0
  3. data/.mdlrc +1 -0
  4. data/.rubocop.yml +25 -0
  5. data/Gemfile +6 -0
  6. data/Gemfile.lock +80 -25
  7. data/README.md +170 -44
  8. data/bin/benchmark +65 -42
  9. data/bin/examples.rb +37 -1
  10. data/bin/profile_10steps +48 -6
  11. data/docs/.nojekyll +0 -0
  12. data/docs/CNAME +1 -0
  13. data/docs/README.md +197 -0
  14. data/docs/_sidebar.md +26 -0
  15. data/docs/contributing/benchmarks_profiling.md +3 -0
  16. data/docs/contributing/local_development.md +3 -0
  17. data/docs/flow/direct_usage.md +3 -0
  18. data/docs/flow/general_idea.md +3 -0
  19. data/docs/index.html +30 -0
  20. data/docs/operation/basic_usage.md +1 -0
  21. data/docs/operation/inject_steps.md +3 -0
  22. data/docs/operation/lambda_steps.md +3 -0
  23. data/docs/operation/result_shapes.md +3 -0
  24. data/docs/operation/routing_tracks.md +3 -0
  25. data/docs/operation/wrapping_steps.md +3 -0
  26. data/docs/overview/performance.md +336 -0
  27. data/docs/railway/basic_usage.md +232 -0
  28. data/docs/result_objects/basic_usage.md +196 -0
  29. data/docs/result_objects/do_notation.md +139 -0
  30. data/flows.gemspec +2 -0
  31. data/forspell.dict +8 -0
  32. data/lefthook.yml +12 -0
  33. data/lib/flows.rb +2 -0
  34. data/lib/flows/flow.rb +1 -1
  35. data/lib/flows/operation.rb +1 -3
  36. data/lib/flows/operation/builder.rb +2 -2
  37. data/lib/flows/operation/dsl.rb +21 -0
  38. data/lib/flows/railway.rb +48 -0
  39. data/lib/flows/railway/builder.rb +68 -0
  40. data/lib/flows/railway/dsl.rb +28 -0
  41. data/lib/flows/railway/errors.rb +21 -0
  42. data/lib/flows/railway/executor.rb +23 -0
  43. data/lib/flows/result.rb +1 -0
  44. data/lib/flows/result/do.rb +30 -0
  45. data/lib/flows/result_router.rb +1 -1
  46. data/lib/flows/version.rb +1 -1
  47. metadata +59 -3
  48. data/.travis.yml +0 -8
@@ -0,0 +1,196 @@
1
+ # Result Object :: Basic Usage
2
+
3
+ Result Object is a way of presenting the result of a calculation. The result may be successful or failed.
4
+ For example, if you calculate expression `a / b`:
5
+
6
+ * for `a = 6` and `b = 2` result will be successful with data `3`.
7
+ * for `a = 6` and `b = 0` result will be failed with data, for example, `"Cannot divide by zero"`.
8
+
9
+ Examples of such approach may be found in other libraries and languages:
10
+
11
+ * [Either Monad](https://hackage.haskell.org/package/category-extras-0.52.0/docs/Control-Monad-Either.html) in Haskell
12
+ * [Result Type](https://doc.rust-lang.org/std/result/enum.Result.html) in Rust
13
+ * [Faraday gem](https://www.rubydoc.info/gems/faraday/Faraday/Response) has `Faraday::Response` object which contains data and status
14
+ * [dry-rb Result Monad](https://dry-rb.org/gems/dry-monads/result/) has `Dry::Monads::Result`
15
+
16
+ So, why do you need Result Object? Why not just return `nil` on a failure or raise an error (like in the standard library)? Here are several reasons:
17
+
18
+ * raising errors and exceptions isn't a very convenient and explicit way to handle errors. Moreover, it is slow and looks like `goto`. However, it is still a good way to abort execution on an unexpected error.
19
+ * returning `nil` does not work when you have to deal with different types of errors or an error has some data payload.
20
+ * using specific Result Objects (like `Faraday::Response`) brings inconsistency - you have to learn how to deal with each new type of Result.
21
+
22
+ That's why `Flows` should have Result Object implementation. If any executable Flows entity will return Result Object with the same API - composing your app components becomes trivial. Result Objects should also be as fast and lightweight as possible.
23
+
24
+ Flows' implementation is inspired mainly by [Rust Result Type](https://doc.rust-lang.org/std/result/enum.Result.html) and focused on following features:
25
+
26
+ * use idiomatic Ruby: no methods named with first capital letter (`Name(1, 2)`), etc.
27
+ * provide convenient helpers for `case` and `===` (case equality) for matching results and writing routing logic
28
+ * provide helpers for convenient creation of Result Objects
29
+ * Result Object may be successful (`Ok`) or failure (`Err`)
30
+ * Result Object has an status (some symbol: `:saved`, `:zero_division_error`)
31
+ * status usage is optional. Default statuses for successful and failure results are `:success` and `:failure`
32
+ * result may have metadata. Metadata is something unrelated to your business logic (execution time, for example, or some info about who created this result).
33
+ * different accessors for successful and failure results - prevents treating failure results as successful and vice versa.
34
+
35
+ ## Class Diagram
36
+
37
+ Class UML diagram describing current implementation:
38
+
39
+ ```plantuml
40
+ @startuml
41
+ class Flows::Result<Abstract Class> {
42
+ .. Constructor ..
43
+ {static} new(Symbol status, Hash data, Hash metadata)
44
+ .. Success checks ..
45
+ {abstract} bool ok?()
46
+ {abstract} bool err?()
47
+ .. Result data access ..
48
+ Symbol status()
49
+ {abstract} Hash unwrap()
50
+ {abstract} Hash error()
51
+ .. Metadata ..
52
+ Hash meta()
53
+ }
54
+
55
+ class Flows::Result::Ok {
56
+ true ok?()
57
+ false err?()
58
+ ..
59
+ Hash unwrap()
60
+ [raise exception] error()
61
+ }
62
+
63
+ class Flows::Result::Err {
64
+ false ok?()
65
+ true err?()
66
+ ..
67
+ [raise exception] unwrap()
68
+ Hash error()
69
+ }
70
+
71
+ Flows::Result --> Flows::Result::Ok
72
+ Flows::Result --> Flows::Result::Err
73
+ @enduml
74
+ ```
75
+
76
+ ## Creating Results
77
+
78
+ Most flexible and verbose way of creating Result Objects is creating via `.new`:
79
+
80
+ ```ruby
81
+ # Successful result with data {a: 1}
82
+ Flows::Result::Ok.new(a: 1)
83
+
84
+ # Failure result with data {msg: 'error'}
85
+ Flows::Result::Err.new(msg: 'error')
86
+
87
+ # Successful result with data {a: 1} and status `:done`
88
+ Flows::Result::Ok.new({ a: 1 }, status: :done)
89
+
90
+ # Failure result with data {msg: 'error'} and status `:http_error`
91
+ Flows::Result::Err.new({ msg: 'error' }, status: :http_error)
92
+
93
+ # Successful result with data {a: 1} and metadata `{ time: 123 }`
94
+ Flows::Result::Ok.new({ a: 1 }, meta: { time: 123 })
95
+
96
+ # Failure result with data {msg: 'error'} and metadata `{ time: 123 }`
97
+ Flows::Result::Err.new({ msg: 'error' }, meta: { time: 123 })
98
+ ```
99
+
100
+ More convenient and short way is to use helpers:
101
+
102
+ ```ruby
103
+ include Flows::Result::Helpers
104
+
105
+ # Successful result with data {a: 1}
106
+ ok(a: 1)
107
+
108
+ # Failure result with data {msg: 'error'}
109
+ err(msg: 'error')
110
+
111
+ # Successful result with data {a: 1} and status `:done`
112
+ ok(:done, a: 1)
113
+
114
+ # Failure result with data {msg: 'error'} and status `:http_error`
115
+ err(:http_error, msg: 'error')
116
+ ```
117
+
118
+ You cannot provide metadata using helpers and it's ok: you shouldn't populate metadata in your business code.
119
+ Metadata is designed to use in library code and when you have to provide some metadata from your library - just use `.new` instead of helpers.
120
+
121
+ ## Inspecting Results
122
+
123
+ Behaviour of any result object:
124
+
125
+ ```ruby
126
+ result.status # returns status, example: `:success`
127
+
128
+ result.meta # returns metadata, example: `{}`
129
+ ```
130
+
131
+ Behaviour specific to successful results:
132
+
133
+ ```ruby
134
+ result.ok? # true
135
+
136
+ result.err? # false
137
+
138
+ result.unwrap # returns result data
139
+
140
+ result.error # raises exception
141
+ ```
142
+
143
+ Behaviour specific to failure results:
144
+
145
+ ```ruby
146
+ result.ok? # false
147
+
148
+ result.err? # true
149
+
150
+ result.unwrap # raises exception
151
+
152
+ result.error # returns result data
153
+ ```
154
+
155
+ ## Matching Results
156
+
157
+ Basic matching results using `case`:
158
+
159
+ ```ruby
160
+ case result
161
+ when Flows::Result::Ok then do_job
162
+ when Flows::Results::Err then give_up
163
+ end
164
+ ```
165
+
166
+ But this is too verbose. For this case helpers has methods for matching. Example above may be rewritten like this:
167
+
168
+ ```ruby
169
+ include Flows::Result::Helpers
170
+
171
+ case result
172
+ when match_ok then do_job
173
+ when match_err then give_up
174
+ end
175
+ ```
176
+
177
+ Moreover, you may specify status when using helper matchers:
178
+
179
+ ```ruby
180
+ include Flows::Result::Helpers
181
+
182
+ case result
183
+ when match_ok(:create) then do_create
184
+ when match_ok(:update) then do_update
185
+ when match_err(:http_error) then retry
186
+ when match_err then give_up
187
+ end
188
+ ```
189
+
190
+ ## General Recommendations
191
+
192
+ Let's assume that you have some code returning Result Object.
193
+
194
+ * if error happened and may be handled somehow - return failure result
195
+ * if error happened and cannot be handled - raise exception to abort execution
196
+ * if you don't handle any errors for now - don't check result type and use `#unwrap` to access data. It will raise exception when called on a failure result.
@@ -0,0 +1,139 @@
1
+ # Result Object :: Do Notation
2
+
3
+ This functionality aims to simplify common control flow pattern: when you have to stop execution on a first failure and return this failure.
4
+ Do Notation inspired by [Do Notation in dry-rb](https://dry-rb.org/gems/dry-monads/do-notation/) and [Haskell do keyword](https://wiki.haskell.org/Keywords#do).
5
+
6
+ Sometimes you have to write something like this:
7
+
8
+ ```ruby
9
+ class Something
10
+ include Flows::Result::Helpers
11
+
12
+ def do_job
13
+ user_result = fetch_user
14
+ return user_result if user_result.err?
15
+
16
+ data_result = fetch_data
17
+ return data_result if data_result.err?
18
+
19
+ calculation_result = calculation(user_result.unwrap[:user], data_result.unwrap)
20
+ return calculation_result if user_result.err?
21
+
22
+ ok(data: calculation_result.unwrap[:some_field])
23
+ end
24
+
25
+ private
26
+
27
+ def fetch_user
28
+ # returns Ok or Err
29
+ end
30
+
31
+ def fetch_data
32
+ # returns Ok or Err
33
+ end
34
+
35
+ def calculation(_user, _data)
36
+ # returns Ok or Err
37
+ end
38
+ end
39
+ ```
40
+
41
+ The main idea of the code above is to stop method execution and return failed Result Object if one of the sub-operations is failed. At the moment of failure.
42
+
43
+ By using Do Notation feature you may rewrite it like this:
44
+
45
+ ```ruby
46
+ class SomethingWithDoNotation
47
+ include Flows::Result::Helpers
48
+ include Flows::Result::Do # enable Do Notation
49
+
50
+ do_for(:do_job) # changes behaviour of `yield` in this method
51
+ def do_job
52
+ user, = yield :user, fetch_user # yield here returns array of one element
53
+ data = yield fetch_data # yield here returns a Hash
54
+
55
+ ok(data: yield(:some_field, calculation(user, data))[0])
56
+ end
57
+
58
+ # private method definitions
59
+ end
60
+ ```
61
+
62
+ or like this:
63
+
64
+ ```ruby
65
+ do_for(:do_job)
66
+ def do_job
67
+ user = yield(fetch_user)[:user] # yield here and below returns a Hash
68
+ data = yield fetch_data
69
+
70
+ ok(data: yield(calculation(user, data))[:some_field])
71
+ end
72
+ ```
73
+
74
+ `do_for(:do_job)` makes some simple magic here and allows you to use `yield` inside `do_job` in a non standard way:
75
+ to unpack results or instantly leave a method if a failed result provided.
76
+
77
+ ## How to use it
78
+
79
+ First of all, you have to include `Flows::Result::Do` mixin into your class or module. It adds `do_for` class method.
80
+ `do_for` accepts method name as an argument and changes behaviour of `yield` inside this method. By the way, when you are using
81
+ `do_for` you cannot pass a block to modified method anymore.
82
+
83
+ Then `do_for` method should be used to enable Do Notation for certain methods.
84
+
85
+ ```ruby
86
+ class MyClass
87
+ include Flows::Result::Do
88
+
89
+ do_for(:my_method_1)
90
+ def my_method_1
91
+ # some code
92
+ end
93
+
94
+ do_for(:my_method_2)
95
+ def my_method_2
96
+ # some code
97
+ end
98
+ end
99
+ ```
100
+
101
+ `yield` in such methods starts working by following rules:
102
+
103
+ ```ruby
104
+ ok_result = Flows::Result::Ok.new(a: 1, b: 2)
105
+ err_result = Flows::Result::Err.new(x: 1, y: 2)
106
+
107
+ # following three lines are equivalent
108
+ yield(ok_result)
109
+ ok_result.unwrap
110
+ { a: 1, b: 2 }
111
+
112
+ # following three lines are equivalent
113
+ yield(:a, :b, ok_result)
114
+ ok_result.unwrap.values_at(:a, :b)
115
+ [1, 2]
116
+
117
+ # following two lines are equivalent
118
+ yield(err_result)
119
+ return err_result
120
+
121
+ # following two lines are equivalent
122
+ yield(:x, :y, err_result)
123
+ return err_result
124
+ ```
125
+
126
+ As you may see, `yield` has two forms of usage:
127
+
128
+ * `yield(result_value)` - returns unwrapped data Hash for successful results or,
129
+ in case of failed result, stops method execution and returns failed `result_value` as a method result.
130
+ * `yield(*keys, result_value)` - returns unwrapped data under provided keys as Array for successful results or,
131
+ in case of failed result, stops method execution and returns failed `result_value` as a method result.
132
+
133
+ ## How it works
134
+
135
+ Under the hood `Flows::Result::Do` creates a module and prepends it to your class or module.
136
+ Invoking of `do_for(:method_name)` adds special wrapper method to the prepended module. So, when you perform call to
137
+ `YourClassOrModule#method_name` - you execute wrapper in the prepended module.
138
+
139
+ Check out source code for implementation details.
@@ -25,7 +25,9 @@ Gem::Specification.new do |spec| # rubocop:disable Metrics/BlockLength
25
25
  spec.add_development_dependency 'rake', '~> 10.0'
26
26
  spec.add_development_dependency 'rspec', '~> 3.0'
27
27
 
28
+ spec.add_development_dependency 'forspell', '~> 0.0.8'
28
29
  spec.add_development_dependency 'rubocop'
30
+ spec.add_development_dependency 'rubocop-md'
29
31
  spec.add_development_dependency 'rubocop-performance'
30
32
  spec.add_development_dependency 'rubocop-rspec'
31
33
 
@@ -0,0 +1,8 @@
1
+ # Format: one word per line. Empty lines and #-comments are supported too.
2
+ # If you want to add word with its forms, you can write 'word: example' (without quotes) on the line,
3
+ # where 'example' is existing word with the same possible forms (endings) as your word.
4
+ # Example: deduplicate: duplicate
5
+ linter
6
+ linters
7
+ matchers
8
+ superset
@@ -0,0 +1,12 @@
1
+ pre-commit:
2
+ parallel: true
3
+ commands:
4
+ rubocop:
5
+ glob: "{*.rb,*.md,*.gemspec,Gemfile,Rakefile}"
6
+ run: bundle exec rubocop {staged_files}
7
+ markdownlinter:
8
+ glob: "*.md"
9
+ run: bundle exec mdl {staged_files}
10
+ forspell:
11
+ glob: "{*.md,*.rb}"
12
+ run: bundle exec forspell {staged_files}
@@ -6,8 +6,10 @@ require 'flows/version'
6
6
 
7
7
  require 'flows/router'
8
8
  require 'flows/result_router'
9
+
9
10
  require 'flows/node'
10
11
  require 'flows/flow'
11
12
 
12
13
  require 'flows/result'
14
+ require 'flows/railway'
13
15
  require 'flows/operation'
@@ -1,5 +1,5 @@
1
1
  module Flows
2
- # Simple sequentional flow
2
+ # Simple sequential flow
3
3
  class Flow
4
4
  def initialize(start_node:, nodes:)
5
5
  @start_node = start_node
@@ -5,11 +5,9 @@ require_relative 'operation/builder'
5
5
  require_relative 'operation/executor'
6
6
 
7
7
  module Flows
8
- # Operaion DSL
8
+ # Operation DSL
9
9
  module Operation
10
10
  def self.included(mod)
11
- mod.instance_variable_set(:@steps, [])
12
- mod.instance_variable_set(:@track_path, [])
13
11
  mod.extend ::Flows::Operation::DSL
14
12
  end
15
13
 
@@ -54,8 +54,8 @@ module Flows
54
54
  end
55
55
 
56
56
  def resolve_bodies!
57
- @steps.map! do |step|
58
- step.merge(
57
+ @steps.each do |step|
58
+ step.merge!(
59
59
  body: step[:custom_body] || resolve_body_from_source(step[:name])
60
60
  )
61
61
  end
@@ -4,6 +4,20 @@ module Flows
4
4
  module DSL
5
5
  attr_reader :steps, :ok_shapes, :err_shapes
6
6
 
7
+ def self.extended(mod, steps = nil, ok_shapes = nil, err_shapes = nil)
8
+ mod.instance_variable_set(:@steps, steps || [])
9
+ mod.instance_variable_set(:@track_path, [])
10
+ mod.instance_variable_set(:@ok_shapes, ok_shapes)
11
+ mod.instance_variable_set(:@err_shapes, err_shapes)
12
+
13
+ mod.class_exec do
14
+ def self.inherited(subclass)
15
+ ::Flows::Operation::DSL.extended(subclass, steps.map(&:dup), ok_shapes, err_shapes)
16
+ super
17
+ end
18
+ end
19
+ end
20
+
7
21
  include Flows::Result::Helpers
8
22
 
9
23
  def step(name, custom_body_or_routes = nil, custom_routes = nil)
@@ -30,6 +44,13 @@ module Flows
30
44
  @track_path = track_path_before
31
45
  end
32
46
 
47
+ def routes(routes_hash)
48
+ routes_hash
49
+ end
50
+
51
+ alias when_ok match_ok
52
+ alias when_err match_err
53
+
33
54
  def wrap(name, custom_body = nil, &block)
34
55
  @steps << make_step(name, type: :wrapper, custom_body: custom_body, block: block)
35
56
  end