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.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/deploy.yml +15 -0
  3. data/.github/workflows/doc.yml +25 -0
  4. data/.github/workflows/ruby.yml +20 -0
  5. data/.gitignore +12 -0
  6. data/.ignore +1 -0
  7. data/.rspec +3 -0
  8. data/.rubocop.yml +38 -0
  9. data/.ruby-version +1 -0
  10. data/.travis.yml +7 -0
  11. data/.vim/coc-settings.json +12 -0
  12. data/.vim/install.sh +38 -0
  13. data/.yardopts +4 -0
  14. data/CODE_OF_CONDUCT.md +74 -0
  15. data/Gemfile +19 -0
  16. data/Gemfile.lock +147 -0
  17. data/LICENSE.txt +21 -0
  18. data/README.md +63 -0
  19. data/Rakefile +21 -0
  20. data/attr-gather.gemspec +38 -0
  21. data/bin/console +15 -0
  22. data/bin/setup +8 -0
  23. data/bin/solargraph +29 -0
  24. data/examples/post_enhancer.rb +119 -0
  25. data/examples/post_enhancer.svg +55 -0
  26. data/lib/attr-gather.rb +3 -0
  27. data/lib/attr/gather.rb +16 -0
  28. data/lib/attr/gather/aggregators.rb +31 -0
  29. data/lib/attr/gather/aggregators/base.rb +38 -0
  30. data/lib/attr/gather/aggregators/deep_merge.rb +50 -0
  31. data/lib/attr/gather/aggregators/shallow_merge.rb +40 -0
  32. data/lib/attr/gather/concerns/identifiable.rb +24 -0
  33. data/lib/attr/gather/concerns/registrable.rb +50 -0
  34. data/lib/attr/gather/filters.rb +34 -0
  35. data/lib/attr/gather/filters/base.rb +20 -0
  36. data/lib/attr/gather/filters/contract.rb +60 -0
  37. data/lib/attr/gather/filters/filtering.rb +27 -0
  38. data/lib/attr/gather/filters/noop.rb +14 -0
  39. data/lib/attr/gather/filters/result.rb +23 -0
  40. data/lib/attr/gather/version.rb +7 -0
  41. data/lib/attr/gather/workflow.rb +29 -0
  42. data/lib/attr/gather/workflow/async_task_executor.rb +17 -0
  43. data/lib/attr/gather/workflow/callable.rb +84 -0
  44. data/lib/attr/gather/workflow/dot_serializer.rb +46 -0
  45. data/lib/attr/gather/workflow/dsl.rb +184 -0
  46. data/lib/attr/gather/workflow/graphable.rb +50 -0
  47. data/lib/attr/gather/workflow/task.rb +29 -0
  48. data/lib/attr/gather/workflow/task_execution_result.rb +58 -0
  49. data/lib/attr/gather/workflow/task_executor.rb +31 -0
  50. data/lib/attr/gather/workflow/task_graph.rb +107 -0
  51. 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,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Attr
4
+ module Gather
5
+ module Filters
6
+ # Does not perform any filtering
7
+ class Noop < Base
8
+ def call(input)
9
+ Result.new(input, [])
10
+ end
11
+ end
12
+ end
13
+ end
14
+ 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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Attr
4
+ module Gather
5
+ VERSION = '1.0.0'
6
+ end
7
+ 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