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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 07ef681505341f374e4374d1baf4b5a8630a34b5dc87dc4fcaec159b2a7efeb2
4
- data.tar.gz: 680bc4891c2a140b6af5e780d97dba80e7ceb5aa750f8a0fe88dffcf7327c9f5
3
+ metadata.gz: d5a781371ace4374427a2510da9860b10014caf039fcfd4aa620886f252d57a9
4
+ data.tar.gz: a0d1d564f206b30246e67d017e8a814c43529b120c6ff8499ef0198d5847ad7a
5
5
  SHA512:
6
- metadata.gz: 4a634990d091aff5b530c06341e2cb2fdbad34cbec9f4b4dee073245822f45633fab79cbb8c4b76ee95dcda62d56a5b1ec856ac4b7500d04f9478a548664e070
7
- data.tar.gz: 6c1dddb4c8478d28ced0677c2b1b4b86343404f645721dde7af77ed9cdc44e880f1ece2fd3a271b26ed85b2325b7b8d4c5ce6cffaa5e1e9b4c2b589c0430dfb7
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=build-test-lint"
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
@@ -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@master
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 }}
@@ -4,6 +4,9 @@ on: [push]
4
4
  jobs:
5
5
  build-test-lint:
6
6
  runs-on: ubuntu-latest
7
+ strategy:
8
+ matrix:
9
+ ruby: ["2.5.x", "2.6.x", "2.7.x"]
7
10
  steps:
8
11
  - uses: actions/checkout@v1
9
12
  - name: Set up Ruby 2.6
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
- Metrics/LineLength:
18
+ Layout/LineLength:
18
19
  Exclude:
19
20
  - ./attr-gather.gemspec
20
21
  - ./bin/**/*
data/.ruby-version CHANGED
@@ -1 +1 @@
1
- 2.6.5
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.3)
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.1)
12
+ ast (2.4.2)
13
13
  backport (1.1.2)
14
- benchmark (0.1.0)
14
+ benchmark (0.1.1)
15
15
  coderay (1.1.3)
16
- concurrent-ruby (1.1.7)
17
- diff-lcs (1.3)
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.11.6)
20
+ dry-configurable (0.12.1)
21
21
  concurrent-ruby (~> 1.0)
22
- dry-core (~> 0.4, >= 0.4.7)
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.4.9)
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.8)
31
+ dry-logic (1.2.0)
33
32
  concurrent-ruby (~> 1.0)
34
- dry-core (~> 0.2)
35
- dry-equalizer (~> 0.2)
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.4)
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.4)
44
- dry-types (1.4.0)
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.4, >= 0.4.4)
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.5.6)
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.13.1)
55
+ ffi (1.15.1)
60
56
  ffi-compiler (1.0.1)
61
57
  ffi (>= 1.0.0)
62
58
  rake
63
- http (4.4.1)
59
+ http (5.0.0)
64
60
  addressable (~> 2.3)
65
61
  http-cookie (~> 1.0)
66
62
  http-form_data (~> 2.2)
67
- http-parser (~> 1.2.0)
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
- maruku (0.7.3)
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.4.0)
77
- nokogiri (1.10.10)
78
- mini_portile2 (~> 2.4.0)
79
- parallel (1.19.2)
80
- parser (2.7.2.0)
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.13.1)
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.1)
89
+ rake (13.0.3)
90
+ regexp_parser (2.1.1)
88
91
  reverse_markdown (2.0.0)
89
92
  nokogiri
90
- rspec (3.9.0)
91
- rspec-core (~> 3.9.0)
92
- rspec-expectations (~> 3.9.0)
93
- rspec-mocks (~> 3.9.0)
94
- rspec-core (3.9.0)
95
- rspec-support (~> 3.9.0)
96
- rspec-expectations (3.9.0)
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.9.0)
99
- rspec-mocks (3.9.0)
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.9.0)
102
- rspec-support (3.9.0)
103
- rubocop (0.75.1)
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 (>= 2.6)
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, < 1.7)
110
- rubocop-performance (1.5.1)
111
- rubocop (>= 0.71.0)
112
- ruby-progressbar (1.10.1)
113
- solargraph (0.39.17)
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
- maruku (~> 0.7, >= 0.7.3)
120
- nokogiri (~> 1.9, >= 1.9.1)
121
- parser (~> 2.3)
128
+ kramdown (~> 2.3)
129
+ kramdown-parser-gfm (~> 1.1)
130
+ parser (~> 3.0)
122
131
  reverse_markdown (>= 1.0.5, < 3)
123
- rubocop (~> 0.52)
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.1)
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 (1.6.1)
133
- yard (0.9.25)
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.1.4
161
+ 2.2.11
data/README.md CHANGED
@@ -1,10 +1,148 @@
1
1
  # attr-gather
2
2
 
3
- [![Actions Status](https://github.com/ianks/attr-gather/workflows/.github/workflows/ruby.yml/badge.svg)](https://github.com/ianks/attr-gather/actions)
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 simple workflows to enhance entities with extra attributes.
6
- At a high level, `attr-gather` provides a process to sync attributes from many
7
- sources (third party APIs, legacy databases, etc).
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
@@ -23,7 +23,7 @@ rescue LoadError
23
23
  warn 'Could not load yarddoc rake task'
24
24
  end
25
25
 
26
- task default: %i[spec lint]
26
+ task default: %i[spec lint doc]
27
27
 
28
28
  namespace :examples do
29
29
  desc 'Run all examples'
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
@@ -28,7 +28,7 @@ class MyContainer
28
28
  end
29
29
 
30
30
  register :email_info do |user:, **_attrs|
31
- res = HTTP.timeout(3).get("https://api.trumail.io/v2/lookups/json?email=#{user[:email]}")
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] } } }
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'attr/gather/aggregators/base'
3
4
  require 'attr/gather/concerns/registrable'
4
5
 
5
6
  module Attr
@@ -13,28 +13,26 @@ module Attr
13
13
  class Base
14
14
  attr_accessor :filter
15
15
 
16
- NOOP_FILTER ||= Filters::Noop.new
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
- unvalidated = res.result.value!
34
-
35
- return unvalidated if filter.nil?
33
+ return res if filter.nil?
36
34
 
37
- filter.call(unvalidated).value
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] include input in aggregation result
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
- result = execution_results.reduce(initial) do |memo, res|
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
- def unwrap_initial_input(input)
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
- def merge_input?
46
- @merge_input
47
- end
43
+ private_constant :ARRAY_STRATEGY
48
44
 
49
45
  def deep_merge(hash, other)
50
- Hash[hash].merge(other) do |_, orig, new|
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(Hash[orig], Hash[new])
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] include input in aggregation result
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
- result = execution_results.reduce(initial) do |memo, res|
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
@@ -14,8 +14,8 @@ module Attr
14
14
  # @param dry_contract [Dry::Contract]
15
15
  def initialize(dry_contract)
16
16
  validate_dry_contract!(dry_contract)
17
-
18
17
  @dry_contract = dry_contract
18
+ super()
19
19
  end
20
20
 
21
21
  def call(input)
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'attr/gather/filters/base'
4
+
3
5
  module Attr
4
6
  module Gather
5
7
  module Filters
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Attr
4
4
  module Gather
5
- VERSION = '1.1.3'
5
+ VERSION = '1.5.1'
6
6
  end
7
7
  end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'attr/gather/workflow/task_executor'
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
- final_results = []
29
+ task_promises = {}
31
30
 
32
- each_task_batch.reduce(input.dup) do |aggregated_input, batch|
33
- executor_results = execute_batch(aggregated_input, batch)
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
- aggregator.call(input.dup, final_results.flatten(1))
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 execute_batch(aggregated_input, batch)
58
- executor = AsyncTaskExecutor.new(batch, container: container)
59
- executor.call(aggregated_input)
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
- lines = row.map { |item| item.map(&:name).join(' -> ') + ';' }
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
- # extend Attr::Gather::Workflow
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
- task = Task.new(name: task_name, **opts)
40
- yield task
41
- tasks << task
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
- # extend Attr::Gather::Workflow
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
- # extend Attr::Gather::Workflow
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
- # extend Attr::Gather::Workflow
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
- # extend Attr::Gather::Workflow
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 = block_given? ? build_inline_contract_filter(&blk) : arg
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
- attr_accessor :depends_on, :name
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.name)
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 <<(task)
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.map(&:name).include?(t) }
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.3
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: 2020-10-22 00:00:00.000000000 Z
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: '0'
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