attr-gather 1.1.3 → 1.5.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.github/mergify.yml +4 -1
- data/.github/workflows/doc.yml +1 -1
- data/.github/workflows/ruby.yml +3 -0
- data/.rubocop.yml +2 -1
- data/.ruby-version +1 -1
- data/Gemfile.lock +69 -60
- data/README.md +142 -4
- data/Rakefile +1 -1
- data/attr-gather.gemspec +2 -0
- data/examples/post_enhancer.rb +1 -1
- data/lib/attr/gather/aggregators.rb +1 -0
- data/lib/attr/gather/aggregators/base.rb +7 -9
- data/lib/attr/gather/aggregators/deep_merge.rb +33 -18
- data/lib/attr/gather/aggregators/shallow_merge.rb +2 -13
- data/lib/attr/gather/filters/contract.rb +1 -1
- data/lib/attr/gather/filters/noop.rb +2 -0
- data/lib/attr/gather/version.rb +1 -1
- data/lib/attr/gather/workflow/callable.rb +18 -22
- data/lib/attr/gather/workflow/dot_serializer.rb +1 -2
- data/lib/attr/gather/workflow/dsl.rb +68 -9
- data/lib/attr/gather/workflow/task.rb +15 -2
- data/lib/attr/gather/workflow/task_graph.rb +15 -2
- metadata +3 -6
- data/lib/attr/gather/workflow/async_task_executor.rb +0 -17
- data/lib/attr/gather/workflow/task_execution_result.rb +0 -77
- data/lib/attr/gather/workflow/task_executor.rb +0 -31
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: d5a781371ace4374427a2510da9860b10014caf039fcfd4aa620886f252d57a9
|
4
|
+
data.tar.gz: a0d1d564f206b30246e67d017e8a814c43529b120c6ff8499ef0198d5847ad7a
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 52823ab8e9b562de9b23b8bdbe6bc08354bb85f71bd51580ab746fd4adce30cade4d27d55c3bf67a294bbb101f61eae485bc006537c89aefcba57ca28f3e8e39
|
7
|
+
data.tar.gz: '0998be6201c8b8292297390064233d0ad04c17b5b8d84ceebf76f0766e5d84fdfafb7edc0d2a4621825f2efafd66b192f89a3fbd38d6462b16ecbecbea7abc01'
|
data/.github/mergify.yml
CHANGED
@@ -13,7 +13,10 @@ pull_request_rules:
|
|
13
13
|
conditions:
|
14
14
|
- author~=^dependabot(|-preview)\[bot\]$
|
15
15
|
- "#approved-reviews-by>=1"
|
16
|
-
- "check-success
|
16
|
+
- "check-success~=build-test-lint (2.4.x)"
|
17
|
+
- "check-success~=build-test-lint (2.5.x)"
|
18
|
+
- "check-success~=build-test-lint (2.6.x)"
|
19
|
+
- "check-success~=build-test-lint (2.7.x)"
|
17
20
|
actions:
|
18
21
|
merge:
|
19
22
|
method: squash
|
data/.github/workflows/doc.yml
CHANGED
@@ -16,7 +16,7 @@ jobs:
|
|
16
16
|
bundle install --without=local --jobs 4 --retry 3
|
17
17
|
bundle exec yard
|
18
18
|
- name: Deploy Docs
|
19
|
-
uses: JamesIves/github-pages-deploy-action@
|
19
|
+
uses: JamesIves/github-pages-deploy-action@4.1.3
|
20
20
|
if: github.ref == 'master'
|
21
21
|
env:
|
22
22
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
data/.github/workflows/ruby.yml
CHANGED
data/.rubocop.yml
CHANGED
@@ -5,6 +5,7 @@ AllCops:
|
|
5
5
|
UseCache: true
|
6
6
|
CacheRootDirectory: './tmp/cache'
|
7
7
|
TargetRubyVersion: 2.5
|
8
|
+
NewCops: enable
|
8
9
|
|
9
10
|
Metrics/BlockLength:
|
10
11
|
Exclude:
|
@@ -14,7 +15,7 @@ Metrics/ModuleLength:
|
|
14
15
|
Exclude:
|
15
16
|
- spec/**/*.rb
|
16
17
|
|
17
|
-
|
18
|
+
Layout/LineLength:
|
18
19
|
Exclude:
|
19
20
|
- ./attr-gather.gemspec
|
20
21
|
- ./bin/**/*
|
data/.ruby-version
CHANGED
@@ -1 +1 @@
|
|
1
|
-
2.6.
|
1
|
+
2.6.6
|
data/Gemfile.lock
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
PATH
|
2
2
|
remote: .
|
3
3
|
specs:
|
4
|
-
attr-gather (1.1
|
4
|
+
attr-gather (1.5.1)
|
5
5
|
dry-container (~> 0.7)
|
6
6
|
|
7
7
|
GEM
|
@@ -9,46 +9,42 @@ GEM
|
|
9
9
|
specs:
|
10
10
|
addressable (2.7.0)
|
11
11
|
public_suffix (>= 2.0.2, < 5.0)
|
12
|
-
ast (2.4.
|
12
|
+
ast (2.4.2)
|
13
13
|
backport (1.1.2)
|
14
|
-
benchmark (0.1.
|
14
|
+
benchmark (0.1.1)
|
15
15
|
coderay (1.1.3)
|
16
|
-
concurrent-ruby (1.1.
|
17
|
-
diff-lcs (1.
|
16
|
+
concurrent-ruby (1.1.8)
|
17
|
+
diff-lcs (1.4.4)
|
18
18
|
domain_name (0.5.20190701)
|
19
19
|
unf (>= 0.0.5, < 1.0.0)
|
20
|
-
dry-configurable (0.
|
20
|
+
dry-configurable (0.12.1)
|
21
21
|
concurrent-ruby (~> 1.0)
|
22
|
-
dry-core (~> 0.
|
23
|
-
dry-equalizer (~> 0.2)
|
22
|
+
dry-core (~> 0.5, >= 0.5.0)
|
24
23
|
dry-container (0.7.2)
|
25
24
|
concurrent-ruby (~> 1.0)
|
26
25
|
dry-configurable (~> 0.1, >= 0.1.3)
|
27
|
-
dry-core (0.
|
26
|
+
dry-core (0.6.0)
|
28
27
|
concurrent-ruby (~> 1.0)
|
29
28
|
dry-equalizer (0.3.0)
|
30
29
|
dry-inflector (0.2.0)
|
31
30
|
dry-initializer (3.0.4)
|
32
|
-
dry-logic (1.0
|
31
|
+
dry-logic (1.2.0)
|
33
32
|
concurrent-ruby (~> 1.0)
|
34
|
-
dry-core (~> 0.
|
35
|
-
|
36
|
-
dry-schema (1.5.5)
|
33
|
+
dry-core (~> 0.5, >= 0.5)
|
34
|
+
dry-schema (1.6.2)
|
37
35
|
concurrent-ruby (~> 1.0)
|
38
36
|
dry-configurable (~> 0.8, >= 0.8.3)
|
39
|
-
dry-core (~> 0.
|
40
|
-
dry-equalizer (~> 0.2)
|
37
|
+
dry-core (~> 0.5, >= 0.5)
|
41
38
|
dry-initializer (~> 3.0)
|
42
39
|
dry-logic (~> 1.0)
|
43
|
-
dry-types (~> 1.
|
44
|
-
dry-types (1.
|
40
|
+
dry-types (~> 1.5)
|
41
|
+
dry-types (1.5.1)
|
45
42
|
concurrent-ruby (~> 1.0)
|
46
43
|
dry-container (~> 0.3)
|
47
|
-
dry-core (~> 0.
|
48
|
-
dry-equalizer (~> 0.3)
|
44
|
+
dry-core (~> 0.5, >= 0.5)
|
49
45
|
dry-inflector (~> 0.1, >= 0.1.2)
|
50
46
|
dry-logic (~> 1.0, >= 1.0.2)
|
51
|
-
dry-validation (1.
|
47
|
+
dry-validation (1.6.0)
|
52
48
|
concurrent-ruby (~> 1.0)
|
53
49
|
dry-container (~> 0.7, >= 0.7.1)
|
54
50
|
dry-core (~> 0.4)
|
@@ -56,81 +52,94 @@ GEM
|
|
56
52
|
dry-initializer (~> 3.0)
|
57
53
|
dry-schema (~> 1.5, >= 1.5.2)
|
58
54
|
e2mmap (0.1.0)
|
59
|
-
ffi (1.
|
55
|
+
ffi (1.15.1)
|
60
56
|
ffi-compiler (1.0.1)
|
61
57
|
ffi (>= 1.0.0)
|
62
58
|
rake
|
63
|
-
http (
|
59
|
+
http (5.0.0)
|
64
60
|
addressable (~> 2.3)
|
65
61
|
http-cookie (~> 1.0)
|
66
62
|
http-form_data (~> 2.2)
|
67
|
-
|
63
|
+
llhttp-ffi (~> 0.0.1)
|
68
64
|
http-cookie (1.0.3)
|
69
65
|
domain_name (~> 0.5)
|
70
66
|
http-form_data (2.3.0)
|
71
|
-
http-parser (1.2.1)
|
72
|
-
ffi-compiler (>= 1.0, < 2.0)
|
73
67
|
jaro_winkler (1.5.4)
|
74
|
-
|
68
|
+
kramdown (2.3.1)
|
69
|
+
rexml
|
70
|
+
kramdown-parser-gfm (1.1.0)
|
71
|
+
kramdown (~> 2.0)
|
72
|
+
llhttp-ffi (0.0.1)
|
73
|
+
ffi-compiler (~> 1.0)
|
74
|
+
rake (~> 13.0)
|
75
75
|
method_source (1.0.0)
|
76
|
-
mini_portile2 (2.
|
77
|
-
nokogiri (1.
|
78
|
-
mini_portile2 (~> 2.
|
79
|
-
|
80
|
-
|
76
|
+
mini_portile2 (2.5.3)
|
77
|
+
nokogiri (1.11.7)
|
78
|
+
mini_portile2 (~> 2.5.0)
|
79
|
+
racc (~> 1.4)
|
80
|
+
parallel (1.20.1)
|
81
|
+
parser (3.0.1.1)
|
81
82
|
ast (~> 2.4.1)
|
82
|
-
pry (0.
|
83
|
+
pry (0.14.1)
|
83
84
|
coderay (~> 1.1)
|
84
85
|
method_source (~> 1.0)
|
85
86
|
public_suffix (4.0.6)
|
87
|
+
racc (1.5.2)
|
86
88
|
rainbow (3.0.0)
|
87
|
-
rake (13.0.
|
89
|
+
rake (13.0.3)
|
90
|
+
regexp_parser (2.1.1)
|
88
91
|
reverse_markdown (2.0.0)
|
89
92
|
nokogiri
|
90
|
-
|
91
|
-
|
92
|
-
rspec-
|
93
|
-
rspec-
|
94
|
-
|
95
|
-
|
96
|
-
|
93
|
+
rexml (3.2.5)
|
94
|
+
rspec (3.10.0)
|
95
|
+
rspec-core (~> 3.10.0)
|
96
|
+
rspec-expectations (~> 3.10.0)
|
97
|
+
rspec-mocks (~> 3.10.0)
|
98
|
+
rspec-core (3.10.1)
|
99
|
+
rspec-support (~> 3.10.0)
|
100
|
+
rspec-expectations (3.10.1)
|
97
101
|
diff-lcs (>= 1.2.0, < 2.0)
|
98
|
-
rspec-support (~> 3.
|
99
|
-
rspec-mocks (3.
|
102
|
+
rspec-support (~> 3.10.0)
|
103
|
+
rspec-mocks (3.10.2)
|
100
104
|
diff-lcs (>= 1.2.0, < 2.0)
|
101
|
-
rspec-support (~> 3.
|
102
|
-
rspec-support (3.
|
103
|
-
rubocop (
|
104
|
-
jaro_winkler (~> 1.5.1)
|
105
|
+
rspec-support (~> 3.10.0)
|
106
|
+
rspec-support (3.10.2)
|
107
|
+
rubocop (1.16.0)
|
105
108
|
parallel (~> 1.10)
|
106
|
-
parser (>=
|
109
|
+
parser (>= 3.0.0.0)
|
107
110
|
rainbow (>= 2.2.2, < 4.0)
|
111
|
+
regexp_parser (>= 1.8, < 3.0)
|
112
|
+
rexml
|
113
|
+
rubocop-ast (>= 1.7.0, < 2.0)
|
108
114
|
ruby-progressbar (~> 1.7)
|
109
|
-
unicode-display_width (>= 1.4.0, <
|
110
|
-
rubocop-
|
111
|
-
|
112
|
-
|
113
|
-
|
115
|
+
unicode-display_width (>= 1.4.0, < 3.0)
|
116
|
+
rubocop-ast (1.7.0)
|
117
|
+
parser (>= 3.0.1.1)
|
118
|
+
rubocop-performance (1.11.3)
|
119
|
+
rubocop (>= 1.7.0, < 2.0)
|
120
|
+
rubocop-ast (>= 0.4.0)
|
121
|
+
ruby-progressbar (1.11.0)
|
122
|
+
solargraph (0.41.1)
|
114
123
|
backport (~> 1.1)
|
115
124
|
benchmark
|
116
125
|
bundler (>= 1.17.2)
|
117
126
|
e2mmap
|
118
127
|
jaro_winkler (~> 1.5)
|
119
|
-
|
120
|
-
|
121
|
-
parser (~>
|
128
|
+
kramdown (~> 2.3)
|
129
|
+
kramdown-parser-gfm (~> 1.1)
|
130
|
+
parser (~> 3.0)
|
122
131
|
reverse_markdown (>= 1.0.5, < 3)
|
123
|
-
rubocop (
|
132
|
+
rubocop (>= 0.52)
|
124
133
|
thor (~> 1.0)
|
125
134
|
tilt (~> 2.0)
|
126
135
|
yard (~> 0.9, >= 0.9.24)
|
127
|
-
thor (1.0
|
136
|
+
thor (1.1.0)
|
128
137
|
tilt (2.0.10)
|
129
138
|
unf (0.1.4)
|
130
139
|
unf_ext
|
131
140
|
unf_ext (0.0.7.7)
|
132
|
-
unicode-display_width (
|
133
|
-
yard (0.9.
|
141
|
+
unicode-display_width (2.0.0)
|
142
|
+
yard (0.9.26)
|
134
143
|
|
135
144
|
PLATFORMS
|
136
145
|
ruby
|
@@ -149,4 +158,4 @@ DEPENDENCIES
|
|
149
158
|
yard
|
150
159
|
|
151
160
|
BUNDLED WITH
|
152
|
-
2.
|
161
|
+
2.2.11
|
data/README.md
CHANGED
@@ -1,10 +1,148 @@
|
|
1
1
|
# attr-gather
|
2
2
|
|
3
|
-
|
3
|
+
![Actions Status](https://github.com/ianks/attr-gather/workflows/Build%20+%20Test%20+%20Lint/badge.svg)
|
4
|
+
[![Maintainability](https://api.codeclimate.com/v1/badges/b825a3bc37ad6a76e005/maintainability)](https://codeclimate.com/github/ianks/attr-gather/maintainability)
|
4
5
|
|
5
|
-
A gem for creating
|
6
|
-
|
7
|
-
|
6
|
+
A gem for creating workflows that "enhance" entities with extra attributes. At a high level, [attr-gather](https://github.com/ianks/attr-gather) provides a process to fetch information from many data sources (such as third party APIs, legacy databases, etc.) in a fully parallelized fashion.
|
7
|
+
|
8
|
+
## Usage
|
9
|
+
|
10
|
+
### Defining your workflow
|
11
|
+
|
12
|
+
```ruby
|
13
|
+
# define a workflow
|
14
|
+
class EnhanceProfile
|
15
|
+
include Attr::Gather::Workflow
|
16
|
+
|
17
|
+
# contains all the task implementations
|
18
|
+
container TasksContainer
|
19
|
+
|
20
|
+
# filter out invalid data using a Dry::Validation::Contract
|
21
|
+
# anything that doesn't match this schema will be filtered out
|
22
|
+
filter_with_contract do
|
23
|
+
params do
|
24
|
+
required(:user_id).filled(:integer)
|
25
|
+
|
26
|
+
optional(:user).hash do
|
27
|
+
optional(:name).filled(:string)
|
28
|
+
optional(:email).filled(:string)
|
29
|
+
optional(:gravatar).filled(:string)
|
30
|
+
optional(:email_info).hash do
|
31
|
+
optional(:deliverable).filled(:bool?)
|
32
|
+
optional(:free).filled(:bool?)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
# each task returns a hash of data that will be merged into the result
|
39
|
+
task :fetch_post do |t|
|
40
|
+
t.depends_on = []
|
41
|
+
end
|
42
|
+
|
43
|
+
# will run in parallel
|
44
|
+
task :fetch_user do |t|
|
45
|
+
t.depends_on = [:fetch_post]
|
46
|
+
end
|
47
|
+
|
48
|
+
# will run in parallel
|
49
|
+
task :fetch_email_info do |t|
|
50
|
+
t.depends_on = [:fetch_user]
|
51
|
+
end
|
52
|
+
end
|
53
|
+
```
|
54
|
+
|
55
|
+
### Defining some tasks
|
56
|
+
|
57
|
+
```ruby
|
58
|
+
class PostFetcher
|
59
|
+
def call(attrs)
|
60
|
+
res = HTTP.get("https://jsonplaceholder.typicode.com/posts/#{attrs[:id]}")
|
61
|
+
post = JSON.parse(res.to_s, symbolize_names: true)
|
62
|
+
|
63
|
+
{ title: post[:title], user_id: post[:userId], body: post[:body] }
|
64
|
+
end
|
65
|
+
end
|
66
|
+
```
|
67
|
+
|
68
|
+
```ruby
|
69
|
+
class UserFetcher
|
70
|
+
# will have access to the PostFetcher attributes here
|
71
|
+
def call(attrs)
|
72
|
+
res = HTTP.get("https://jsonplaceholder.typicode.com/users/#{attrs[:user_id]}")
|
73
|
+
user = JSON.parse(res.to_s, symbolize_names: true)
|
74
|
+
|
75
|
+
{ user: { name: user[:name], email: user[:email] } }
|
76
|
+
end
|
77
|
+
end
|
78
|
+
```
|
79
|
+
|
80
|
+
```ruby
|
81
|
+
class EmailInfoFetcher
|
82
|
+
# will have access to the PostFetcher attributes here
|
83
|
+
def call(user:)
|
84
|
+
res = HTTP.timeout(3).get("https://api.trumail.io/v2/lookups/json?email=#{user[:email]}")
|
85
|
+
info = JSON.parse(res.to_s, symbolize_names: true)
|
86
|
+
|
87
|
+
# will deep merge with the final result
|
88
|
+
{ user: { email_info: { deliverable: info[:deliverable], free: info[:free] } } }
|
89
|
+
end
|
90
|
+
end
|
91
|
+
```
|
92
|
+
|
93
|
+
### Registering your tasks
|
94
|
+
|
95
|
+
```ruby
|
96
|
+
class MyContainer
|
97
|
+
extend Dry::Container::Mixin
|
98
|
+
|
99
|
+
register :fetch_post, PostFetcher
|
100
|
+
register :fetch_user, UserFetcher
|
101
|
+
register :fetch_email_info, EmailInfoFetcher
|
102
|
+
end
|
103
|
+
```
|
104
|
+
|
105
|
+
### Run it!
|
106
|
+
|
107
|
+
```ruby
|
108
|
+
enhancer = EnhanceUserProfile.new
|
109
|
+
enhancer.call(id: 12).value!
|
110
|
+
```
|
111
|
+
|
112
|
+
And this is the result...
|
113
|
+
|
114
|
+
```ruby
|
115
|
+
{
|
116
|
+
:id => 12,
|
117
|
+
:user_id => 2,
|
118
|
+
:user => {
|
119
|
+
:email => "Shanna@melissa.tv",
|
120
|
+
:name => "Ervin Howell",
|
121
|
+
:email_info => { :deliverable => true, :free => true },
|
122
|
+
:gravatar => "https://www.gravatar.com/avatar/241af7d19a0a7438794aef21e4e19b79"
|
123
|
+
}
|
124
|
+
}
|
125
|
+
```
|
126
|
+
|
127
|
+
You can even preview it as an SVG!
|
128
|
+
|
129
|
+
```ruby
|
130
|
+
enhancer.to_dot(preview: true) # requires graphviz (brew install graphviz)
|
131
|
+
```
|
132
|
+
|
133
|
+
## Features
|
134
|
+
|
135
|
+
- Offers DSL for defining workflows and merging the results from each task
|
136
|
+
- Execution engine optimally parallelizes the execution of the workflow dependency graph using [concurrent-ruby](https://github.com/ruby-concurrency/concurrent-ruby) Promises
|
137
|
+
- Very easy to unit test
|
138
|
+
- Ability to filter out bad/junky data using [dry-validation](https://dry-rb.org/gems/dry-validation) contracts
|
139
|
+
|
140
|
+
## What are the main difference between this Ruby project and similar ones?
|
141
|
+
|
142
|
+
- Operates on a single entity rather than a list, so easily adoptable in existing systems
|
143
|
+
- Focuses on the "fetching" and filtering of data solely, and not transformation or storage
|
144
|
+
- Focuses on having a clean PORO interface to make testing simple
|
145
|
+
- Provides a declarative interface for merging results from many sources (APIs, legacy databases, etc.) which allows for prioritization
|
8
146
|
|
9
147
|
## Links
|
10
148
|
|
data/Rakefile
CHANGED
data/attr-gather.gemspec
CHANGED
@@ -15,6 +15,8 @@ Gem::Specification.new do |spec|
|
|
15
15
|
spec.homepage = 'https://github.com/ianks/attr-gather'
|
16
16
|
spec.license = 'MIT'
|
17
17
|
|
18
|
+
spec.required_ruby_version = '>= 2.5'
|
19
|
+
|
18
20
|
# spec.metadata["allowed_push_host"] = "TODO: Set to 'http://mygemserver.com'"
|
19
21
|
|
20
22
|
spec.metadata['homepage_uri'] = spec.homepage
|
data/examples/post_enhancer.rb
CHANGED
@@ -28,7 +28,7 @@ class MyContainer
|
|
28
28
|
end
|
29
29
|
|
30
30
|
register :email_info do |user:, **_attrs|
|
31
|
-
res = HTTP.
|
31
|
+
res = HTTP.get("https://api.trumail.io/v2/lookups/json?email=#{user[:email]}")
|
32
32
|
info = JSON.parse(res.to_s, symbolize_names: true)
|
33
33
|
|
34
34
|
{ user: { email_info: { deliverable: info[:deliverable], free: info[:free] } } }
|
@@ -13,28 +13,26 @@ module Attr
|
|
13
13
|
class Base
|
14
14
|
attr_accessor :filter
|
15
15
|
|
16
|
-
NOOP_FILTER
|
16
|
+
NOOP_FILTER = Filters::Noop.new
|
17
17
|
|
18
18
|
def initialize(**opts)
|
19
19
|
@filter = opts.delete(:filter) || NOOP_FILTER
|
20
20
|
end
|
21
21
|
|
22
|
+
def with(**opts)
|
23
|
+
self.class.new(filter: @filter, **opts)
|
24
|
+
end
|
25
|
+
|
22
26
|
def call(_original_input, _results_array)
|
23
27
|
raise NotImplementedError
|
24
28
|
end
|
25
29
|
|
26
30
|
private
|
27
31
|
|
28
|
-
def wrap_result(result)
|
29
|
-
Concurrent::Promise.fulfill(result)
|
30
|
-
end
|
31
|
-
|
32
32
|
def unwrap_result(res)
|
33
|
-
|
34
|
-
|
35
|
-
return unvalidated if filter.nil?
|
33
|
+
return res if filter.nil?
|
36
34
|
|
37
|
-
filter.call(
|
35
|
+
filter.call(res).value
|
38
36
|
end
|
39
37
|
end
|
40
38
|
end
|
@@ -12,49 +12,64 @@ module Attr
|
|
12
12
|
# Initialize a new DeepMerge aggregator
|
13
13
|
#
|
14
14
|
# @param reverse [Boolean] deep merge results in reverse order
|
15
|
-
# @param merge_input [Boolean]
|
15
|
+
# @param merge_input [Boolean] merge the result with the initial input
|
16
|
+
# @param array_strategy [Symbol] strategy for handling arrays, one of (:concat, :overwrite)
|
16
17
|
#
|
17
18
|
# @api private
|
18
|
-
def initialize(reverse: false, merge_input: true, **)
|
19
|
+
def initialize(reverse: false, merge_input: true, array_strategy: :concat, **)
|
20
|
+
unless ARRAY_STRATEGY.include?(array_strategy)
|
21
|
+
raise ArgumentError, 'array_strategy must be one of: :concat, :overwrite'
|
22
|
+
end
|
23
|
+
|
19
24
|
@reverse = reverse
|
20
25
|
@merge_input = merge_input
|
26
|
+
@array_strategy = array_strategy
|
27
|
+
|
21
28
|
super
|
22
29
|
end
|
23
30
|
|
24
31
|
def call(input, execution_results)
|
25
32
|
execution_results = execution_results.reverse_each if reverse?
|
26
|
-
initial = unwrap_initial_input(input)
|
27
33
|
|
28
|
-
|
34
|
+
execution_results.reduce(@merge_input ? input : EMPTY_HASH) do |memo, res|
|
29
35
|
deep_merge(memo, unwrap_result(res))
|
30
36
|
end
|
31
|
-
|
32
|
-
wrap_result(result)
|
33
37
|
end
|
34
38
|
|
35
39
|
private
|
36
40
|
|
37
|
-
|
38
|
-
merge_input? ? filter.call(input.dup).value : {}
|
39
|
-
end
|
40
|
-
|
41
|
-
def reverse?
|
42
|
-
@reverse
|
43
|
-
end
|
41
|
+
ARRAY_STRATEGY = %i[concat overwrite].freeze
|
44
42
|
|
45
|
-
|
46
|
-
@merge_input
|
47
|
-
end
|
43
|
+
private_constant :ARRAY_STRATEGY
|
48
44
|
|
49
45
|
def deep_merge(hash, other)
|
50
|
-
|
46
|
+
hash.to_h.merge(other) do |_, orig, new|
|
51
47
|
if orig.respond_to?(:to_hash) && new.respond_to?(:to_hash)
|
52
|
-
deep_merge(
|
48
|
+
deep_merge(orig.to_h, new.to_h)
|
49
|
+
elsif concattable?(orig, new)
|
50
|
+
orig + new
|
53
51
|
else
|
54
52
|
new
|
55
53
|
end
|
56
54
|
end
|
57
55
|
end
|
56
|
+
|
57
|
+
def concattable?(orig, new)
|
58
|
+
return false unless @array_strategy == :concat
|
59
|
+
|
60
|
+
concattable_class?(orig) && concattable_class?(new)
|
61
|
+
end
|
62
|
+
|
63
|
+
def concattable_class?(obj)
|
64
|
+
return true if obj.is_a?(Array)
|
65
|
+
return true if obj.is_a?(Set)
|
66
|
+
|
67
|
+
false
|
68
|
+
end
|
69
|
+
|
70
|
+
def reverse?
|
71
|
+
@reverse
|
72
|
+
end
|
58
73
|
end
|
59
74
|
end
|
60
75
|
end
|
@@ -12,7 +12,7 @@ module Attr
|
|
12
12
|
# Initialize a new DeepMerge aggregator
|
13
13
|
#
|
14
14
|
# @param reverse [Boolean] merge results in reverse order
|
15
|
-
# @param merge_input [Boolean]
|
15
|
+
# @param merge_input [Boolean] merge the result with the initial input
|
16
16
|
#
|
17
17
|
# @api private
|
18
18
|
def initialize(reverse: false, merge_input: true, **)
|
@@ -23,28 +23,17 @@ module Attr
|
|
23
23
|
|
24
24
|
def call(input, execution_results)
|
25
25
|
execution_results = execution_results.reverse_each if reverse?
|
26
|
-
initial = unwrap_initial_input(input)
|
27
26
|
|
28
|
-
|
27
|
+
execution_results.reduce(@merge_input ? input : EMPTY_HASH) do |memo, res|
|
29
28
|
memo.merge(unwrap_result(res))
|
30
29
|
end
|
31
|
-
|
32
|
-
wrap_result(result)
|
33
30
|
end
|
34
31
|
|
35
32
|
private
|
36
33
|
|
37
|
-
def unwrap_initial_input(input)
|
38
|
-
merge_input? ? filter.call(input.dup).value : {}
|
39
|
-
end
|
40
|
-
|
41
34
|
def reverse?
|
42
35
|
@reverse
|
43
36
|
end
|
44
|
-
|
45
|
-
def merge_input?
|
46
|
-
@merge_input
|
47
|
-
end
|
48
37
|
end
|
49
38
|
end
|
50
39
|
end
|
data/lib/attr/gather/version.rb
CHANGED
@@ -1,7 +1,6 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require '
|
4
|
-
require 'attr/gather/workflow/async_task_executor'
|
3
|
+
require 'concurrent/promise'
|
5
4
|
|
6
5
|
module Attr
|
7
6
|
module Gather
|
@@ -21,42 +20,39 @@ module Attr
|
|
21
20
|
#
|
22
21
|
# @param input [Hash]
|
23
22
|
#
|
24
|
-
# @return [Concurrent::Promise]
|
23
|
+
# @return [Concurrent::Promise<Hash>]
|
25
24
|
#
|
26
25
|
# @note For more information, check out {https://dry-rb.org/gems/dry-monads/1.0/result}
|
27
26
|
#
|
28
27
|
# @api public
|
29
28
|
def call(input)
|
30
|
-
|
29
|
+
task_promises = {}
|
31
30
|
|
32
|
-
|
33
|
-
|
34
|
-
final_results << executor_results
|
35
|
-
aggregator.call(aggregated_input, executor_results).value!
|
31
|
+
final_results = self.class.tasks.to_a.map do |task|
|
32
|
+
task_promises[task] = execute_task(input, task, task_promises)
|
36
33
|
end
|
37
34
|
|
38
|
-
|
35
|
+
Concurrent::Promise.zip(*final_results).then do |results|
|
36
|
+
aggregator.call(input, results)
|
37
|
+
end
|
39
38
|
end
|
40
39
|
|
41
40
|
private
|
42
41
|
|
43
|
-
# Enumator for task batches
|
44
|
-
#
|
45
|
-
# @return [Enumerator]
|
46
|
-
#
|
47
|
-
# @api private
|
48
|
-
def each_task_batch
|
49
|
-
self.class.tasks.each_batch
|
50
|
-
end
|
51
|
-
|
52
42
|
# Executes a batch of tasks
|
53
43
|
#
|
54
44
|
# @return [Array<TaskExecutionResult>]
|
55
45
|
#
|
56
46
|
# @api private
|
57
|
-
def
|
58
|
-
|
59
|
-
|
47
|
+
def execute_task(initial_input, task, task_promises)
|
48
|
+
task_proc = container.resolve(task.name)
|
49
|
+
dep_promises = task.depends_on.map { |t| task_promises[t] }
|
50
|
+
input_promise = Concurrent::Promise.zip(*dep_promises)
|
51
|
+
|
52
|
+
input_promise.then do |results|
|
53
|
+
dep_input = aggregator.call(initial_input, results)
|
54
|
+
task_proc.call(dep_input)
|
55
|
+
end
|
60
56
|
end
|
61
57
|
|
62
58
|
# @api private
|
@@ -69,7 +65,7 @@ module Attr
|
|
69
65
|
return @aggregator if defined?(@aggregator) && !@aggregator.nil?
|
70
66
|
|
71
67
|
@aggregator = self.class.aggregator
|
72
|
-
@aggregator.filter ||= filter
|
68
|
+
@aggregator.filter ||= filter if @aggregator.respond_to?(:filter=)
|
73
69
|
|
74
70
|
@aggregator
|
75
71
|
end
|
@@ -33,8 +33,7 @@ module Attr
|
|
33
33
|
|
34
34
|
def serialize_row(task)
|
35
35
|
row = all_dependants_for_task(task).map { |dt| [task, dt] }
|
36
|
-
|
37
|
-
lines
|
36
|
+
row.map { |item| "#{item.map(&:name).join(' -> ')};" }
|
38
37
|
end
|
39
38
|
|
40
39
|
def all_dependants_for_task(input_task)
|
@@ -16,7 +16,7 @@ module Attr
|
|
16
16
|
#
|
17
17
|
# @example
|
18
18
|
# class EnhanceUserProfile
|
19
|
-
#
|
19
|
+
# include Attr::Gather::Workflow
|
20
20
|
#
|
21
21
|
# # ...
|
22
22
|
#
|
@@ -36,12 +36,62 @@ module Attr
|
|
36
36
|
#
|
37
37
|
# @api public
|
38
38
|
def task(task_name, opts = EMPTY_HASH)
|
39
|
-
|
40
|
-
yield
|
41
|
-
tasks <<
|
39
|
+
conf = OpenStruct.new
|
40
|
+
yield conf
|
41
|
+
tasks << ({ name: task_name, **opts, **conf.to_h })
|
42
42
|
self
|
43
43
|
end
|
44
44
|
|
45
|
+
# Defines a task with name and options
|
46
|
+
#
|
47
|
+
# @param task_name [Symbol] the name of the task
|
48
|
+
#
|
49
|
+
# @example
|
50
|
+
# class EnhanceUserProfile
|
51
|
+
# include Attr::Gather::Workflow
|
52
|
+
#
|
53
|
+
# # ...
|
54
|
+
#
|
55
|
+
# fetch :user_info do |t|
|
56
|
+
# t.depends_on = [:fetch_gravatar_info]
|
57
|
+
# end
|
58
|
+
# end
|
59
|
+
#
|
60
|
+
# Calling `fetch` will yield a task object which you can configure like
|
61
|
+
# a PORO. Tasks will be registered for execution in the workflow.
|
62
|
+
#
|
63
|
+
# @yield [Attr::Gather::Workflow::Task] A task to configure
|
64
|
+
#
|
65
|
+
# @api public
|
66
|
+
def fetch(task_name, opts = EMPTY_HASH)
|
67
|
+
task(task_name, opts)
|
68
|
+
end
|
69
|
+
|
70
|
+
# Defines a task with name and options
|
71
|
+
#
|
72
|
+
# @param task_name [Symbol] the name of the task
|
73
|
+
#
|
74
|
+
# @example
|
75
|
+
# class EnhanceUserProfile
|
76
|
+
# include Attr::Gather::Workflow
|
77
|
+
#
|
78
|
+
# # ...
|
79
|
+
#
|
80
|
+
# step :fetch_user_info do |t|
|
81
|
+
# t.depends_on = [:email_info]
|
82
|
+
# end
|
83
|
+
# end
|
84
|
+
#
|
85
|
+
# Calling `step` will yield a task object which you can configure like
|
86
|
+
# a PORO. Tasks will be registered for execution in the workflow.
|
87
|
+
#
|
88
|
+
# @yield [Attr::Gather::Workflow::Task] A task to configure
|
89
|
+
#
|
90
|
+
# @api public
|
91
|
+
def step(task_name, opts = EMPTY_HASH)
|
92
|
+
task(task_name, opts)
|
93
|
+
end
|
94
|
+
|
45
95
|
# Defines a container for task dependencies
|
46
96
|
#
|
47
97
|
# Using a container makes it easy to re-use workflows with different
|
@@ -55,7 +105,7 @@ module Attr
|
|
55
105
|
# end
|
56
106
|
#
|
57
107
|
# class EnhanceUserProfile
|
58
|
-
#
|
108
|
+
# include Attr::Gather::Workflow
|
59
109
|
#
|
60
110
|
# container LegacySystem
|
61
111
|
# end
|
@@ -79,17 +129,26 @@ module Attr
|
|
79
129
|
#
|
80
130
|
# @example
|
81
131
|
# class EnhanceUserProfile
|
82
|
-
#
|
132
|
+
# include Attr::Gather::Workflow
|
83
133
|
#
|
84
134
|
# aggregator :deep_merge
|
85
135
|
# end
|
86
136
|
#
|
137
|
+
# @example
|
138
|
+
# class EnhanceUserProfile
|
139
|
+
# include Attr::Gather::Workflow
|
140
|
+
#
|
141
|
+
# aggregator MyCustomAggregator
|
142
|
+
# end
|
143
|
+
#
|
87
144
|
# @param agg [#call] the aggregator to use
|
88
145
|
#
|
89
146
|
# @api public
|
90
147
|
def aggregator(agg = nil, opts = EMPTY_HASH)
|
91
148
|
@aggregator = if agg.nil? && !defined?(@aggregator)
|
92
149
|
Aggregators.default
|
150
|
+
elsif agg.respond_to?(:new)
|
151
|
+
agg.new(filter: filter, **opts)
|
93
152
|
elsif agg
|
94
153
|
Aggregators.resolve(agg, filter: filter, **opts)
|
95
154
|
else
|
@@ -119,7 +178,7 @@ module Attr
|
|
119
178
|
# end
|
120
179
|
#
|
121
180
|
# class EnhanceUserProfile
|
122
|
-
#
|
181
|
+
# include Attr::Gather::Workflow
|
123
182
|
#
|
124
183
|
# # Any of the key/value pairs that had validation errors will be
|
125
184
|
# # filtered from the output.
|
@@ -151,7 +210,7 @@ module Attr
|
|
151
210
|
# @example
|
152
211
|
#
|
153
212
|
# class EnhanceUserProfile
|
154
|
-
#
|
213
|
+
# include Attr::Gather::Workflow
|
155
214
|
#
|
156
215
|
# # Any of the key/value pairs that had validation errors will be
|
157
216
|
# # filtered from the output.
|
@@ -172,7 +231,7 @@ module Attr
|
|
172
231
|
#
|
173
232
|
# @api public
|
174
233
|
def filter_with_contract(arg = nil, &blk)
|
175
|
-
contract =
|
234
|
+
contract = blk ? build_inline_contract_filter(&blk) : arg
|
176
235
|
filter(:contract, contract)
|
177
236
|
end
|
178
237
|
|
@@ -1,19 +1,32 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require 'dry-equalizer'
|
4
|
+
|
3
5
|
module Attr
|
4
6
|
module Gather
|
5
7
|
module Workflow
|
6
8
|
# @api private
|
7
9
|
class Task
|
8
|
-
|
10
|
+
send :include, Dry::Equalizer(:name, :depends_on)
|
11
|
+
|
12
|
+
attr_accessor :name, :depends_on
|
9
13
|
|
14
|
+
# Initialize a new DeepMerge aggregator
|
15
|
+
#
|
16
|
+
# @param name [String] name of the task
|
17
|
+
# @param depends_on [Array<Task>] tasks needed before running this task
|
18
|
+
#
|
19
|
+
# @api private
|
10
20
|
def initialize(name:, depends_on: [])
|
11
21
|
@name = name
|
12
22
|
@depends_on = depends_on
|
13
23
|
end
|
14
24
|
|
25
|
+
# Check if this task depends on a given task
|
26
|
+
#
|
27
|
+
# @param other_task [Task] task to check
|
15
28
|
def depends_on?(other_task)
|
16
|
-
depends_on.include?(other_task
|
29
|
+
depends_on.include?(other_task)
|
17
30
|
end
|
18
31
|
|
19
32
|
def fullfilled_given_remaining_tasks?(task_list)
|
@@ -9,6 +9,7 @@ module Attr
|
|
9
9
|
# @api private
|
10
10
|
class TaskGraph
|
11
11
|
class UnfinishableError < StandardError; end
|
12
|
+
|
12
13
|
class InvalidTaskDepedencyError < StandardError; end
|
13
14
|
|
14
15
|
include TSort
|
@@ -20,7 +21,9 @@ module Attr
|
|
20
21
|
tasks.each { |t| self << t }
|
21
22
|
end
|
22
23
|
|
23
|
-
def <<(
|
24
|
+
def <<(hash)
|
25
|
+
name, depends_on = hash.values_at :name, :depends_on
|
26
|
+
task = build_task(name, depends_on)
|
24
27
|
validate_for_insert!(task)
|
25
28
|
|
26
29
|
registered_tasks.each do |t|
|
@@ -68,6 +71,16 @@ module Attr
|
|
68
71
|
|
69
72
|
private
|
70
73
|
|
74
|
+
def build_task(name, depends_on)
|
75
|
+
deps = depends_on.map do |dep_name|
|
76
|
+
registered_tasks.find do |task|
|
77
|
+
task.name == dep_name
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
Task.new(name: name, depends_on: deps)
|
82
|
+
end
|
83
|
+
|
71
84
|
def tsort_each_child(node, &blk)
|
72
85
|
to_h[node].each(&blk)
|
73
86
|
end
|
@@ -99,7 +112,7 @@ module Attr
|
|
99
112
|
end
|
100
113
|
|
101
114
|
def depended_on_tasks_exist?(task)
|
102
|
-
task.depends_on.all? { |t| registered_tasks.
|
115
|
+
task.depends_on.all? { |t| registered_tasks.include?(t) }
|
103
116
|
end
|
104
117
|
end
|
105
118
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: attr-gather
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.1
|
4
|
+
version: 1.5.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Ian Ker-Seymer
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2021-06-03 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: bundler
|
@@ -115,14 +115,11 @@ files:
|
|
115
115
|
- lib/attr/gather/filters/result.rb
|
116
116
|
- lib/attr/gather/version.rb
|
117
117
|
- lib/attr/gather/workflow.rb
|
118
|
-
- lib/attr/gather/workflow/async_task_executor.rb
|
119
118
|
- lib/attr/gather/workflow/callable.rb
|
120
119
|
- lib/attr/gather/workflow/dot_serializer.rb
|
121
120
|
- lib/attr/gather/workflow/dsl.rb
|
122
121
|
- lib/attr/gather/workflow/graphable.rb
|
123
122
|
- lib/attr/gather/workflow/task.rb
|
124
|
-
- lib/attr/gather/workflow/task_execution_result.rb
|
125
|
-
- lib/attr/gather/workflow/task_executor.rb
|
126
123
|
- lib/attr/gather/workflow/task_graph.rb
|
127
124
|
homepage: https://github.com/ianks/attr-gather
|
128
125
|
licenses:
|
@@ -138,7 +135,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
138
135
|
requirements:
|
139
136
|
- - ">="
|
140
137
|
- !ruby/object:Gem::Version
|
141
|
-
version: '
|
138
|
+
version: '2.5'
|
142
139
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
143
140
|
requirements:
|
144
141
|
- - ">="
|
@@ -1,17 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
require 'attr/gather/workflow/task_executor'
|
4
|
-
|
5
|
-
module Attr
|
6
|
-
module Gather
|
7
|
-
module Workflow
|
8
|
-
# @api private
|
9
|
-
class AsyncTaskExecutor < TaskExecutor
|
10
|
-
def initialize(batch, container:)
|
11
|
-
super(batch, container: container)
|
12
|
-
@executor = :io
|
13
|
-
end
|
14
|
-
end
|
15
|
-
end
|
16
|
-
end
|
17
|
-
end
|
@@ -1,77 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
module Attr
|
4
|
-
module Gather
|
5
|
-
module Workflow
|
6
|
-
# A wrapper containing information and results of a task execution
|
7
|
-
#
|
8
|
-
# @!attribute [r] started_at
|
9
|
-
# @return [Time] time which the execution occured
|
10
|
-
#
|
11
|
-
# @!attribute [r] task
|
12
|
-
# @return [Attr::Gather::Workflow::Task] task that was run
|
13
|
-
#
|
14
|
-
# @!attribute [r] result
|
15
|
-
# @return [Concurrent::Promise] the result promise of the the task
|
16
|
-
#
|
17
|
-
# @api public
|
18
|
-
class TaskExecutionResult
|
19
|
-
include Concerns::Identifiable
|
20
|
-
|
21
|
-
attr_reader :task, :result, :started_at, :uuid
|
22
|
-
|
23
|
-
def initialize(task, result, started_at: Time.now, uuid: SecureRandom.uuid) # rubocop:disable Metrics/LineLength
|
24
|
-
@task = task
|
25
|
-
@result = result
|
26
|
-
@started_at = started_at
|
27
|
-
@uuid = uuid
|
28
|
-
end
|
29
|
-
|
30
|
-
# @!attribute [r] state
|
31
|
-
# @return [:unscheduled, :pending, :processing, :rejected, :fulfilled]
|
32
|
-
def state
|
33
|
-
result.state
|
34
|
-
end
|
35
|
-
|
36
|
-
# Extracts the result, this is an unsafe operation that blocks the
|
37
|
-
# operation, and returns either the value or an exception.
|
38
|
-
#
|
39
|
-
# @note For more information, check out {https://ruby-concurrency.github.io/concurrent-ruby/1.1.5/Concurrent/Concern/Obligation.html#value!-instance_method}
|
40
|
-
def value!
|
41
|
-
result.value!
|
42
|
-
end
|
43
|
-
|
44
|
-
# Chain a new block result to be executed after resolution
|
45
|
-
#
|
46
|
-
# @return [TaskExecutionResult] the new task execution result
|
47
|
-
# @yield The block operation to be performed asynchronously.
|
48
|
-
def then(*args, &block)
|
49
|
-
new_result = result.then(*args, &block)
|
50
|
-
self.class.new(task, new_result, started_at: @started_at, uuid: @uuid)
|
51
|
-
end
|
52
|
-
|
53
|
-
# Catch an async exception when a failure occurs
|
54
|
-
#
|
55
|
-
# @return [TaskExecutionResult] the new task execution result
|
56
|
-
# @yield The block operation to be performed asynchronously.
|
57
|
-
def catch(*args, &block)
|
58
|
-
new_result = result.catch(*args, &block)
|
59
|
-
self.class.new(task, new_result, started_at: @started_at, uuid: @uuid)
|
60
|
-
end
|
61
|
-
|
62
|
-
# Executes a block after the result is fulfilled
|
63
|
-
# Represents the TaskExecutionResult as a hash
|
64
|
-
#
|
65
|
-
# @return [Hash]
|
66
|
-
def as_json
|
67
|
-
value = result.value
|
68
|
-
|
69
|
-
{ started_at: started_at,
|
70
|
-
task: task.as_json,
|
71
|
-
state: state,
|
72
|
-
value: value }
|
73
|
-
end
|
74
|
-
end
|
75
|
-
end
|
76
|
-
end
|
77
|
-
end
|
@@ -1,31 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
require 'concurrent'
|
4
|
-
require 'attr/gather/workflow/task_execution_result'
|
5
|
-
|
6
|
-
module Attr
|
7
|
-
module Gather
|
8
|
-
module Workflow
|
9
|
-
# @api private
|
10
|
-
class TaskExecutor
|
11
|
-
attr_reader :batch, :container, :executor
|
12
|
-
|
13
|
-
def initialize(batch, container:)
|
14
|
-
@batch = batch
|
15
|
-
@container = container
|
16
|
-
@executor = :immediate
|
17
|
-
end
|
18
|
-
|
19
|
-
def call(input)
|
20
|
-
batch.map do |task|
|
21
|
-
task_proc = container.resolve(task.name)
|
22
|
-
result = Concurrent::Promise.execute(executor: executor) do
|
23
|
-
task_proc.call(input)
|
24
|
-
end
|
25
|
-
TaskExecutionResult.new(task, result)
|
26
|
-
end
|
27
|
-
end
|
28
|
-
end
|
29
|
-
end
|
30
|
-
end
|
31
|
-
end
|