attr-gather 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.github/workflows/deploy.yml +15 -0
- data/.github/workflows/doc.yml +25 -0
- data/.github/workflows/ruby.yml +20 -0
- data/.gitignore +12 -0
- data/.ignore +1 -0
- data/.rspec +3 -0
- data/.rubocop.yml +38 -0
- data/.ruby-version +1 -0
- data/.travis.yml +7 -0
- data/.vim/coc-settings.json +12 -0
- data/.vim/install.sh +38 -0
- data/.yardopts +4 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +19 -0
- data/Gemfile.lock +147 -0
- data/LICENSE.txt +21 -0
- data/README.md +63 -0
- data/Rakefile +21 -0
- data/attr-gather.gemspec +38 -0
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/bin/solargraph +29 -0
- data/examples/post_enhancer.rb +119 -0
- data/examples/post_enhancer.svg +55 -0
- data/lib/attr-gather.rb +3 -0
- data/lib/attr/gather.rb +16 -0
- data/lib/attr/gather/aggregators.rb +31 -0
- data/lib/attr/gather/aggregators/base.rb +38 -0
- data/lib/attr/gather/aggregators/deep_merge.rb +50 -0
- data/lib/attr/gather/aggregators/shallow_merge.rb +40 -0
- data/lib/attr/gather/concerns/identifiable.rb +24 -0
- data/lib/attr/gather/concerns/registrable.rb +50 -0
- data/lib/attr/gather/filters.rb +34 -0
- data/lib/attr/gather/filters/base.rb +20 -0
- data/lib/attr/gather/filters/contract.rb +60 -0
- data/lib/attr/gather/filters/filtering.rb +27 -0
- data/lib/attr/gather/filters/noop.rb +14 -0
- data/lib/attr/gather/filters/result.rb +23 -0
- data/lib/attr/gather/version.rb +7 -0
- data/lib/attr/gather/workflow.rb +29 -0
- data/lib/attr/gather/workflow/async_task_executor.rb +17 -0
- data/lib/attr/gather/workflow/callable.rb +84 -0
- data/lib/attr/gather/workflow/dot_serializer.rb +46 -0
- data/lib/attr/gather/workflow/dsl.rb +184 -0
- data/lib/attr/gather/workflow/graphable.rb +50 -0
- data/lib/attr/gather/workflow/task.rb +29 -0
- data/lib/attr/gather/workflow/task_execution_result.rb +58 -0
- data/lib/attr/gather/workflow/task_executor.rb +31 -0
- data/lib/attr/gather/workflow/task_graph.rb +107 -0
- metadata +150 -0
@@ -0,0 +1,50 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'attr/gather/aggregators/base'
|
4
|
+
|
5
|
+
module Attr
|
6
|
+
module Gather
|
7
|
+
module Aggregators
|
8
|
+
# Deep merges result hashes
|
9
|
+
#
|
10
|
+
# @api public
|
11
|
+
class DeepMerge < Base
|
12
|
+
# Initialize a new DeepMerge aggregator
|
13
|
+
#
|
14
|
+
# @param reverse [Boolean] deep merge results in reverse order
|
15
|
+
#
|
16
|
+
# @api private
|
17
|
+
def initialize(reverse: false, **)
|
18
|
+
@reverse = reverse
|
19
|
+
super
|
20
|
+
end
|
21
|
+
|
22
|
+
def call(input, execution_results)
|
23
|
+
execution_results = execution_results.reverse_each if reverse?
|
24
|
+
|
25
|
+
result = execution_results.reduce(input.dup) do |memo, res|
|
26
|
+
deep_merge(memo, unwrap_result(res))
|
27
|
+
end
|
28
|
+
|
29
|
+
wrap_result(result)
|
30
|
+
end
|
31
|
+
|
32
|
+
private
|
33
|
+
|
34
|
+
def reverse?
|
35
|
+
@reverse
|
36
|
+
end
|
37
|
+
|
38
|
+
def deep_merge(hash, other)
|
39
|
+
Hash[hash].merge(other) do |_, orig, new|
|
40
|
+
if orig.respond_to?(:to_hash) && new.respond_to?(:to_hash)
|
41
|
+
deep_merge(Hash[orig], Hash[new])
|
42
|
+
else
|
43
|
+
new
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'attr/gather/aggregators/base'
|
4
|
+
|
5
|
+
module Attr
|
6
|
+
module Gather
|
7
|
+
module Aggregators
|
8
|
+
# Shallowly merges results
|
9
|
+
#
|
10
|
+
# @api public
|
11
|
+
class ShallowMerge < Base
|
12
|
+
# Initialize a new DeepMerge aggregator
|
13
|
+
#
|
14
|
+
# @param reverse [Boolean] merge results in reverse order
|
15
|
+
#
|
16
|
+
# @api private
|
17
|
+
def initialize(reverse: false, **)
|
18
|
+
@reverse = reverse
|
19
|
+
super
|
20
|
+
end
|
21
|
+
|
22
|
+
def call(input, execution_results)
|
23
|
+
items = reverse? ? execution_results.reverse_each : execution_results
|
24
|
+
|
25
|
+
result = items.reduce(input.dup) do |memo, res|
|
26
|
+
memo.merge(unwrap_result(res))
|
27
|
+
end
|
28
|
+
|
29
|
+
wrap_result(result)
|
30
|
+
end
|
31
|
+
|
32
|
+
private
|
33
|
+
|
34
|
+
def reverse?
|
35
|
+
@reverse
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'securerandom'
|
4
|
+
|
5
|
+
module Attr
|
6
|
+
module Gather
|
7
|
+
module Concerns
|
8
|
+
# Makes an entity identifiable by adding a #uuid attribute
|
9
|
+
#
|
10
|
+
# @!attribute [r] uuid
|
11
|
+
# @return [String] UUID of the result
|
12
|
+
module Identifiable
|
13
|
+
def initialize(*)
|
14
|
+
@uuid = SecureRandom.uuid
|
15
|
+
super
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.included(klass)
|
19
|
+
klass.attr_reader(:uuid)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Attr
|
4
|
+
module Gather
|
5
|
+
# Makes a module registrable
|
6
|
+
module Registrable
|
7
|
+
# Error raised when item is already registered
|
8
|
+
class AlreadyRegisteredError < Error; end
|
9
|
+
|
10
|
+
# Error raised when item is not found
|
11
|
+
class NotFoundError < Attr::Gather::Error; end
|
12
|
+
|
13
|
+
def self.extended(klass)
|
14
|
+
klass.instance_variable_set(:@__storage__, {})
|
15
|
+
end
|
16
|
+
|
17
|
+
# Register item so it can be accessed by name
|
18
|
+
#
|
19
|
+
# @param name [Symbol] name of the item
|
20
|
+
# @yield [options] block to initialize the item
|
21
|
+
def register(name, &blk)
|
22
|
+
name = name.to_sym
|
23
|
+
|
24
|
+
@__storage__[name] = blk
|
25
|
+
end
|
26
|
+
|
27
|
+
# Resolve a named item
|
28
|
+
#
|
29
|
+
# @param name [Symbol]
|
30
|
+
#
|
31
|
+
# @return [#call]
|
32
|
+
def resolve(name, *args)
|
33
|
+
block = @__storage__.fetch(name) do
|
34
|
+
raise NotFoundError,
|
35
|
+
"no item with name #{name} registered"
|
36
|
+
end
|
37
|
+
|
38
|
+
block.call(*args)
|
39
|
+
end
|
40
|
+
|
41
|
+
# @api private
|
42
|
+
def ensure_name_not_already_registered!(name)
|
43
|
+
return unless @__storage__.key?(name)
|
44
|
+
|
45
|
+
raise AlreadyRegisteredError,
|
46
|
+
"item with name #{name} already registered"
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'attr/gather/filters/base'
|
4
|
+
require 'attr/gather/filters/result'
|
5
|
+
require 'attr/gather/filters/filtering'
|
6
|
+
require 'attr/gather/concerns/registrable'
|
7
|
+
|
8
|
+
module Attr
|
9
|
+
module Gather
|
10
|
+
# Namespace for filters
|
11
|
+
module Filters
|
12
|
+
extend Registrable
|
13
|
+
|
14
|
+
# The default filter if none is specified
|
15
|
+
#
|
16
|
+
# @return [Attr::Gather::Filters::Noop]
|
17
|
+
def self.default
|
18
|
+
@default = resolve(:noop)
|
19
|
+
end
|
20
|
+
|
21
|
+
register(:contract) do |contract|
|
22
|
+
require 'attr/gather/filters/contract'
|
23
|
+
|
24
|
+
Contract.new(contract)
|
25
|
+
end
|
26
|
+
|
27
|
+
register(:noop) do |*|
|
28
|
+
require 'attr/gather/filters/noop'
|
29
|
+
|
30
|
+
Noop.new
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Attr
|
4
|
+
module Gather
|
5
|
+
module Filters
|
6
|
+
# @abstract Subclass and override {#call} to implement
|
7
|
+
# a custom Filter class.
|
8
|
+
class Base
|
9
|
+
# Applies the filter
|
10
|
+
#
|
11
|
+
# @param _input [Hash]
|
12
|
+
#
|
13
|
+
# @return [Attr::Gather::Filter::Result]
|
14
|
+
def call(_input)
|
15
|
+
raise NotImplementedError
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Attr
|
4
|
+
module Gather
|
5
|
+
module Filters
|
6
|
+
# Filters values with a dry-validation contract
|
7
|
+
class Contract < Base
|
8
|
+
class IncompatibleContractError < Error; end
|
9
|
+
|
10
|
+
attr_reader :dry_contract
|
11
|
+
|
12
|
+
# Creates a new instance of the filter
|
13
|
+
#
|
14
|
+
# @param dry_contract [Dry::Contract]
|
15
|
+
def initialize(dry_contract)
|
16
|
+
validate_dry_contract!(dry_contract)
|
17
|
+
|
18
|
+
@dry_contract = dry_contract
|
19
|
+
end
|
20
|
+
|
21
|
+
def call(input)
|
22
|
+
value, filterings = filter_validation_errors input.dup
|
23
|
+
|
24
|
+
Result.new(value, filterings)
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
def filter_validation_errors(unvalidated)
|
30
|
+
contract_result = dry_contract.call(unvalidated)
|
31
|
+
errors = contract_result.errors
|
32
|
+
contract_hash = contract_result.to_h
|
33
|
+
errors.each { |err| filter_error_from_input(err, contract_hash) }
|
34
|
+
filterings = transform_errors_to_filtered_attributes(errors)
|
35
|
+
|
36
|
+
[contract_hash, filterings]
|
37
|
+
end
|
38
|
+
|
39
|
+
def filter_error_from_input(error, input)
|
40
|
+
*path, key_to_delete = error.path
|
41
|
+
target = path.empty? ? input : input.dig(*path)
|
42
|
+
target.delete(key_to_delete)
|
43
|
+
end
|
44
|
+
|
45
|
+
def transform_errors_to_filtered_attributes(errors)
|
46
|
+
errors.map do |err|
|
47
|
+
Filtering.new(err.path, err.text, err.input)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
def validate_dry_contract!(con)
|
52
|
+
return if con.respond_to?(:call) && con.class.respond_to?(:schema)
|
53
|
+
|
54
|
+
raise IncompatibleContractError,
|
55
|
+
"contract is not compatible (#{con.inspect})"
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Attr
|
4
|
+
module Gather
|
5
|
+
module Filters
|
6
|
+
# Information about a filtered item
|
7
|
+
#
|
8
|
+
# @!attribute [r] path
|
9
|
+
# @return [Hash] path of the filtered key
|
10
|
+
#
|
11
|
+
# @!attribute [r] reason
|
12
|
+
# @return [String] why the item was filtered
|
13
|
+
#
|
14
|
+
# @!attribute [r] input
|
15
|
+
# @return [String] input value that was filtered
|
16
|
+
class Filtering
|
17
|
+
attr_reader :path, :reason, :input
|
18
|
+
|
19
|
+
def initialize(path, reason, input)
|
20
|
+
@path = path
|
21
|
+
@reason = reason
|
22
|
+
@input = input
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Attr
|
4
|
+
module Gather
|
5
|
+
module Filters
|
6
|
+
# Result of a filter
|
7
|
+
#
|
8
|
+
# @!attribute [r] value
|
9
|
+
# @return [Hash] the filtered hash
|
10
|
+
#
|
11
|
+
# @!attribute [r] filterings
|
12
|
+
# @return [Array<Attr::Gather::Filtering>] info about filtered items
|
13
|
+
class Result
|
14
|
+
attr_reader :value, :filterings
|
15
|
+
|
16
|
+
def initialize(value, filterings)
|
17
|
+
@value = value
|
18
|
+
@filterings = filterings
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'attr/gather/concerns/identifiable'
|
4
|
+
require 'attr/gather/workflow/task'
|
5
|
+
require 'attr/gather/workflow/task_graph'
|
6
|
+
require 'attr/gather/workflow/dsl'
|
7
|
+
require 'attr/gather/workflow/callable'
|
8
|
+
require 'attr/gather/workflow/graphable'
|
9
|
+
|
10
|
+
module Attr
|
11
|
+
module Gather
|
12
|
+
# Main mixin for functionality. Adds the ability to turn a class into a
|
13
|
+
# workflow.
|
14
|
+
module Workflow
|
15
|
+
# @!parse extend DSL
|
16
|
+
# @!parse include Callable
|
17
|
+
# @!parse extend Graphable::ClassMethods
|
18
|
+
# @!parse include Graphable::InstanceMethods
|
19
|
+
# @!parse include Concerns::Identifiable
|
20
|
+
|
21
|
+
def self.included(klass)
|
22
|
+
klass.extend(DSL)
|
23
|
+
klass.include(Callable)
|
24
|
+
klass.include(Graphable)
|
25
|
+
klass.include(Concerns::Identifiable)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,17 @@
|
|
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(*)
|
11
|
+
super
|
12
|
+
@executor = :io
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,84 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'attr/gather/workflow/task_executor'
|
4
|
+
require 'attr/gather/workflow/async_task_executor'
|
5
|
+
|
6
|
+
module Attr
|
7
|
+
module Gather
|
8
|
+
module Workflow
|
9
|
+
# @api private
|
10
|
+
module Callable
|
11
|
+
# Execute a workflow
|
12
|
+
#
|
13
|
+
# When executing the workflow, tasks are processed in dependant order,
|
14
|
+
# with the outputs of each batch being fed as inputs to the next batch.
|
15
|
+
# This means the you can enhance the data as the task moves through a
|
16
|
+
# workflow, so later tasks can use the enhanced input data.
|
17
|
+
#
|
18
|
+
# @example
|
19
|
+
# enhancer = MyEnhancingWorkflow.new
|
20
|
+
# enhancer.call(user_id: 1).value! # => {user_id: 1, email: 't@t.co}
|
21
|
+
#
|
22
|
+
# @param input [Hash]
|
23
|
+
#
|
24
|
+
# @return [Concurrent::Promise]
|
25
|
+
#
|
26
|
+
# @note For more information, check out {https://dry-rb.org/gems/dry-monads/1.0/result}
|
27
|
+
#
|
28
|
+
# @api public
|
29
|
+
def call(input)
|
30
|
+
final_results = []
|
31
|
+
|
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!
|
36
|
+
end
|
37
|
+
|
38
|
+
aggregator.call(input.dup, final_results.flatten(1))
|
39
|
+
end
|
40
|
+
|
41
|
+
private
|
42
|
+
|
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
|
+
# Executes a batch of tasks
|
53
|
+
#
|
54
|
+
# @return [Array<TaskExecutionResult>]
|
55
|
+
#
|
56
|
+
# @api private
|
57
|
+
def execute_batch(aggregated_input, batch)
|
58
|
+
executor = AsyncTaskExecutor.new(batch, container: container)
|
59
|
+
executor.call(aggregated_input)
|
60
|
+
end
|
61
|
+
|
62
|
+
# @api private
|
63
|
+
def container
|
64
|
+
self.class.container
|
65
|
+
end
|
66
|
+
|
67
|
+
# @api private
|
68
|
+
def aggregator
|
69
|
+
return @aggregator if defined?(@aggregator) && !@aggregator.nil?
|
70
|
+
|
71
|
+
@aggregator = self.class.aggregator
|
72
|
+
@aggregator.filter ||= filter
|
73
|
+
|
74
|
+
@aggregator
|
75
|
+
end
|
76
|
+
|
77
|
+
# @api private
|
78
|
+
def filter
|
79
|
+
self.class.filter
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|