flows 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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