attr-gather 1.1.3 → 1.5.1

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.
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