reforge 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.github/workflows/ci.yml +76 -0
- data/.gitignore +13 -0
- data/.rspec +2 -0
- data/.rubocop.yml +13 -0
- data/.ruby-version +1 -0
- data/.tool-versions +1 -0
- data/CHANGELOG.md +0 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +8 -0
- data/Gemfile.lock +89 -0
- data/INTRODUCTION.md +274 -0
- data/LICENSE.txt +21 -0
- data/README.md +44 -0
- data/Rakefile +12 -0
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/lib/reforge.rb +17 -0
- data/lib/reforge/configuration.rb +6 -0
- data/lib/reforge/transformation.rb +23 -0
- data/lib/reforge/transformation/dsl.rb +51 -0
- data/lib/reforge/transformation/transform.rb +43 -0
- data/lib/reforge/transformation/transform/factories.rb +61 -0
- data/lib/reforge/transformation/transform/memo.rb +46 -0
- data/lib/reforge/transformation/tree.rb +88 -0
- data/lib/reforge/transformation/tree/aggregate_node.rb +65 -0
- data/lib/reforge/transformation/tree/aggregate_node/array_node.rb +27 -0
- data/lib/reforge/transformation/tree/aggregate_node/factories.rb +32 -0
- data/lib/reforge/transformation/tree/aggregate_node/hash_node.rb +25 -0
- data/lib/reforge/transformation/tree/transform_node.rb +33 -0
- data/lib/reforge/zeitwerk.rb +16 -0
- data/reforge.gemspec +47 -0
- metadata +203 -0
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2020 Nate Eizenga
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,44 @@
|
|
1
|
+
# Reforge
|
2
|
+
|
3
|
+
[![Gem Version](https://badge.fury.io/rb/reforge.svg)](https://badge.fury.io/rb/reforge)
|
4
|
+
[![Actions Status](https://github.com/eizengan/reforge/workflows/CI/badge.svg)](https://github.com/eizengan/reforge/actions)
|
5
|
+
[![Maintainability](https://api.codeclimate.com/v1/badges/ca2883109cdb44f8cc9e/maintainability)](https://codeclimate.com/github/eizengan/reforge/maintainability)
|
6
|
+
[![Test Coverage](https://api.codeclimate.com/v1/badges/ca2883109cdb44f8cc9e/test_coverage)](https://codeclimate.com/github/eizengan/reforge/test_coverage)
|
7
|
+
|
8
|
+
Reforge provides simple, concise, DSL-driven data transformation for Ruby. Just describe how to obtain data from a source, and then where to place that data in the result - no boilerplate required!
|
9
|
+
|
10
|
+
## Installation
|
11
|
+
|
12
|
+
Add this line to your application's Gemfile:
|
13
|
+
|
14
|
+
```ruby
|
15
|
+
gem 'reforge'
|
16
|
+
```
|
17
|
+
|
18
|
+
And then execute:
|
19
|
+
|
20
|
+
$ bundle
|
21
|
+
|
22
|
+
Or install it yourself as:
|
23
|
+
|
24
|
+
$ gem install reforge
|
25
|
+
|
26
|
+
## Usage
|
27
|
+
|
28
|
+
See the [introduction](INTRODUCTION.md) for information on how to create transformations.
|
29
|
+
|
30
|
+
## License
|
31
|
+
|
32
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
33
|
+
|
34
|
+
|
35
|
+
## Code of Conduct
|
36
|
+
|
37
|
+
This project is intended to be a safe, welcoming space for collaboration. Everyone interacting in official project channels is expected to follow the [Code of Conduct](https://github.com/eizengan/reforge/blob/main/CODE_OF_CONDUCT.md) outlined by [Contributor Covenant](http://contributor-covenant.org).
|
38
|
+
|
39
|
+
|
40
|
+
## Contributing
|
41
|
+
|
42
|
+
Bug reports and pull requests are welcome on the project's [GitHub repository](https://github.com/eizengan/reforge). The current state of development is publicly visible on the project's [Trello board](https://trello.com/b/5hmYBrgt/reforge).
|
43
|
+
|
44
|
+
After checking out the repo, run `bin/setup` to install dependencies. Afterward, run `bundle exec rake` to lint the code and run tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. To install the code as a gem onto your local machine, run `bundle exec rake install`.
|
data/Rakefile
ADDED
@@ -0,0 +1,12 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "bundler/gem_tasks"
|
4
|
+
require "rspec/core/rake_task"
|
5
|
+
require "rubocop/rake_task"
|
6
|
+
|
7
|
+
RSpec::Core::RakeTask.new(:rspec)
|
8
|
+
RuboCop::RakeTask.new(:rubocop) do |t|
|
9
|
+
t.options = ["--parallel"]
|
10
|
+
end
|
11
|
+
|
12
|
+
task default: %i[rubocop rspec]
|
data/bin/console
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require "bundler/setup"
|
5
|
+
require "reforge"
|
6
|
+
|
7
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
8
|
+
# with your gem easier. You can also use a different console, if you like.
|
9
|
+
|
10
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
11
|
+
require "pry"
|
12
|
+
Pry.start
|
13
|
+
|
14
|
+
# require "irb"
|
15
|
+
# IRB.start(__FILE__)
|
data/bin/setup
ADDED
data/lib/reforge.rb
ADDED
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "reforge/zeitwerk"
|
4
|
+
|
5
|
+
module Reforge
|
6
|
+
def self.configure
|
7
|
+
yield configuration if block_given?
|
8
|
+
end
|
9
|
+
|
10
|
+
def self.configuration
|
11
|
+
@configuration ||= Configuration.new
|
12
|
+
end
|
13
|
+
|
14
|
+
def self.configuration=(configuration)
|
15
|
+
@configuration = configuration
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Reforge
|
4
|
+
class Transformation
|
5
|
+
extend DSL
|
6
|
+
|
7
|
+
def self.call(*sources)
|
8
|
+
new.call(*sources)
|
9
|
+
end
|
10
|
+
|
11
|
+
def call(*sources)
|
12
|
+
return tree.call(sources[0]) if sources.size == 1
|
13
|
+
|
14
|
+
sources.map { |source| tree.call(source) }
|
15
|
+
end
|
16
|
+
|
17
|
+
private
|
18
|
+
|
19
|
+
def tree
|
20
|
+
@tree ||= self.class.create_tree
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Reforge
|
4
|
+
class Transformation
|
5
|
+
class TreeCreationError < StandardError; end
|
6
|
+
|
7
|
+
module DSL
|
8
|
+
def extract(path = nil, from:, memoize: nil)
|
9
|
+
transform_definitions.push(
|
10
|
+
{
|
11
|
+
path: path,
|
12
|
+
transform: from,
|
13
|
+
memoize: memoize
|
14
|
+
}.compact
|
15
|
+
)
|
16
|
+
end
|
17
|
+
|
18
|
+
# TRICKY: we want to support e.g. the following equivalent calls:
|
19
|
+
# - transform key: 0, into: [:foo, :bar]
|
20
|
+
# - transform ->(source) { source[0] }, into: [:foo, :bar]
|
21
|
+
# but in the former case the arguments are collapsed into a single hash which is used as the transform arg. By
|
22
|
+
# using **transform_hash we can avoid this behavior, but as a result the transform could be in either the
|
23
|
+
# transform argument or the transform_hash
|
24
|
+
def transform(transform = nil, into: nil, memoize: nil, **transform_hash)
|
25
|
+
transform_definitions.push(
|
26
|
+
{
|
27
|
+
path: into,
|
28
|
+
transform: transform || transform_hash,
|
29
|
+
memoize: memoize
|
30
|
+
}.compact
|
31
|
+
)
|
32
|
+
end
|
33
|
+
|
34
|
+
def create_tree
|
35
|
+
transform_definitions.each_with_object(Tree.new) do |transform_definition, tree|
|
36
|
+
transform = Transform.new(
|
37
|
+
transform_definition[:transform],
|
38
|
+
memoize: transform_definition[:memoize]
|
39
|
+
)
|
40
|
+
tree.attach_transform(*transform_definition[:path], transform)
|
41
|
+
rescue StandardError => e
|
42
|
+
raise TreeCreationError, "Failed to attach node at path #{[*transform_definition[:path]]} - #{e.message}"
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
def transform_definitions
|
47
|
+
@transform_definitions ||= []
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Reforge
|
4
|
+
class Transformation
|
5
|
+
class Transform
|
6
|
+
include Factories
|
7
|
+
|
8
|
+
attr_reader :transform
|
9
|
+
|
10
|
+
def initialize(transform, memoize: nil)
|
11
|
+
transform = transform_proc_from(transform) if transform.is_a?(Hash)
|
12
|
+
validate_transform!(transform)
|
13
|
+
|
14
|
+
@transform = transform
|
15
|
+
@memo = Memo.from(memoize) if memoize
|
16
|
+
end
|
17
|
+
|
18
|
+
def call(source)
|
19
|
+
if @memo.nil?
|
20
|
+
call_transform(source)
|
21
|
+
else
|
22
|
+
@memo[source] ||= call_transform(source)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
def call_transform(source)
|
29
|
+
if @transform.arity.zero?
|
30
|
+
@transform.call
|
31
|
+
else
|
32
|
+
@transform.call(source)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def validate_transform!(transform)
|
37
|
+
return if transform.respond_to?(:call)
|
38
|
+
|
39
|
+
raise ArgumentError, "The transform must be callable"
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Reforge
|
4
|
+
class Transformation
|
5
|
+
class Transform
|
6
|
+
module Factories
|
7
|
+
# TODO: here we code to the least common denominator: everything is a proc. This likely works slower than
|
8
|
+
# something specialized to each individual case. This could be of concern since these will be called
|
9
|
+
# per-transform, per-source
|
10
|
+
TRANSFORM_PROC_FACTORIES = {
|
11
|
+
attribute: ->(attributes, **config) { attribute_transform_for(attributes, **config) },
|
12
|
+
key: ->(keys, **config) { key_transform_for(keys, **config) },
|
13
|
+
value: ->(value, **_config) { value_transform_for(value) }
|
14
|
+
}.freeze
|
15
|
+
TRANSFORM_TYPES = TRANSFORM_PROC_FACTORIES.keys.freeze
|
16
|
+
|
17
|
+
def transform_proc_from(config)
|
18
|
+
validate_config!(config)
|
19
|
+
|
20
|
+
type = config.keys.detect { |key| TRANSFORM_TYPES.include?(key) }
|
21
|
+
args = config[type]
|
22
|
+
config = config.reject { |k, _v| k == type }
|
23
|
+
|
24
|
+
TRANSFORM_PROC_FACTORIES[type].call(args, **config)
|
25
|
+
end
|
26
|
+
|
27
|
+
private_class_method def self.attribute_transform_for(attributes, propogate_nil: false)
|
28
|
+
recursive_method_call(:send, attributes, propogate_nil: propogate_nil)
|
29
|
+
end
|
30
|
+
|
31
|
+
private_class_method def self.key_transform_for(keys, propogate_nil: false)
|
32
|
+
recursive_method_call(:[], keys, propogate_nil: propogate_nil)
|
33
|
+
end
|
34
|
+
|
35
|
+
private_class_method def self.value_transform_for(value)
|
36
|
+
->(_source) { value }
|
37
|
+
end
|
38
|
+
|
39
|
+
private_class_method def self.recursive_method_call(method, arguments, propogate_nil: false)
|
40
|
+
# TRICKY: this could be a single argument or an array of many, so we circumvent the destinction by wrapping
|
41
|
+
# single arguments in an array
|
42
|
+
arguments = [arguments] unless arguments.respond_to?(:reduce)
|
43
|
+
|
44
|
+
if propogate_nil
|
45
|
+
->(source) { arguments.reduce(source) { |object, argument| object&.send(method, argument) } }
|
46
|
+
else
|
47
|
+
->(source) { arguments.reduce(source) { |object, argument| object.send(method, argument) } }
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
private
|
52
|
+
|
53
|
+
def validate_config!(config)
|
54
|
+
return if config.is_a?(Hash) && config.keys.count { |key| TRANSFORM_TYPES.include?(key) } == 1
|
55
|
+
|
56
|
+
raise ArgumentError, "The transform configuration hash must define exactly one transform type"
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Reforge
|
4
|
+
class Transformation
|
5
|
+
class Transform
|
6
|
+
class Memo
|
7
|
+
CONSTANT_TRANSFORM = ->(_source) { :constant }.freeze
|
8
|
+
IDENTITY_TRANSFORM = ->(source) { source }.freeze
|
9
|
+
|
10
|
+
# TODO: here we code to the least common denominator: everything is a proc. This likely works slower than
|
11
|
+
# something specialized to each individual case This could be of concern since these will be called
|
12
|
+
# per-transform, per-source
|
13
|
+
def self.from(memoize)
|
14
|
+
if memoize.is_a?(Hash) # rubocop:disable Style/CaseLikeIf:
|
15
|
+
Memo.new(memoize[:by])
|
16
|
+
elsif memoize == :first
|
17
|
+
Memo.new(CONSTANT_TRANSFORM)
|
18
|
+
elsif memoize == true
|
19
|
+
Memo.new(IDENTITY_TRANSFORM)
|
20
|
+
else
|
21
|
+
raise ArgumentError, "The memoize option should be true, :first, or a valid configuration hash"
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def initialize(key_transform)
|
26
|
+
@memo = {}
|
27
|
+
@key_transform = Transform.new(key_transform)
|
28
|
+
rescue ArgumentError
|
29
|
+
# TRICKY: Transform didn't like key_transform, but we want to raise an error specific to Memo, not the one
|
30
|
+
# directly from Transform
|
31
|
+
raise ArgumentError, "The memoize option should be true, :first, or a valid configuration hash"
|
32
|
+
end
|
33
|
+
|
34
|
+
def [](source)
|
35
|
+
key = @key_transform.call(source)
|
36
|
+
@memo[key]
|
37
|
+
end
|
38
|
+
|
39
|
+
def []=(source, value)
|
40
|
+
key = @key_transform.call(source)
|
41
|
+
@memo[key] = value
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,88 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Reforge
|
4
|
+
class Transformation
|
5
|
+
class Tree
|
6
|
+
class NodeRedefinitionError < StandardError; end
|
7
|
+
class PathPartError < StandardError; end
|
8
|
+
|
9
|
+
attr_reader :root
|
10
|
+
|
11
|
+
def attach_transform(*path)
|
12
|
+
validate_path!(*path)
|
13
|
+
|
14
|
+
# TRICKY: A single-step path means we are wrapping a single transform. We set the root node accordingly,
|
15
|
+
# allowing it to fail loudly if it is being redefined
|
16
|
+
#
|
17
|
+
# A multi-step path means we are wrapping a branching tree with transforms at its leaf nodes. We only
|
18
|
+
# initialize the root node if we have not done so already, and then begin attaching the nodes necessitated by
|
19
|
+
# the supplied path. The nodes are expected to fail loudly if their attachment rules are violated
|
20
|
+
if path.size == 1
|
21
|
+
initialize_root(path[0])
|
22
|
+
else
|
23
|
+
initialize_root(path[0]) if root.nil?
|
24
|
+
attach_nodes(*path)
|
25
|
+
end
|
26
|
+
|
27
|
+
nil
|
28
|
+
end
|
29
|
+
|
30
|
+
def call(source)
|
31
|
+
root.call(source)
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
def validate_path!(*path, transform)
|
37
|
+
raise ArgumentError, "The path must end with a Transform" unless transform.is_a?(Transform)
|
38
|
+
|
39
|
+
path.each { |path_part| validate_path_part!(path_part) }
|
40
|
+
end
|
41
|
+
|
42
|
+
def validate_path_part!(path_part)
|
43
|
+
return if AggregateNode::IMPLEMENTATION_TYPES.include?(path_part.class)
|
44
|
+
|
45
|
+
raise ArgumentError, "The path includes '#{path_part}' which has unknown key type #{path_part.class}"
|
46
|
+
end
|
47
|
+
|
48
|
+
def initialize_root(path_part)
|
49
|
+
raise NodeRedefinitionError, "The root node has already been defined" unless root.nil?
|
50
|
+
|
51
|
+
@root = create_node(path_part)
|
52
|
+
end
|
53
|
+
|
54
|
+
def attach_nodes(*path, transform)
|
55
|
+
node = root
|
56
|
+
|
57
|
+
# TRICKY: we need two contiguous steps in the path to create and attach a node. The first tells where on the
|
58
|
+
# parent node to attach the new node, and the second allows us to infer which type of node we need to attach.
|
59
|
+
#
|
60
|
+
# As an example, in attach_nodes(:foo, 0, :bar, transform) the pair of steps [:foo, 0] would tell us we need to
|
61
|
+
# attach an ArrayNode (inferred by 0) at root's :foo index. We then move to [0, :bar], which tell us we need to
|
62
|
+
# attach a HashNode (inferred by :bar) at the ArrayNode's 0 index. Finally we move to [:bar, transform], which
|
63
|
+
# tells us to attach an TransformNode (inferred by transform) at the HashNode's :bar index
|
64
|
+
#
|
65
|
+
# To fulfill this requirement we turn the arguments to this method into offset arrays and zip them together. Use
|
66
|
+
# of ||= is inappropriate during TransformNode attachment because it will not attempt to create and attach the
|
67
|
+
# node if a child with the same key already exists, so we just use = below
|
68
|
+
parent_path_parts = path[0..-2]
|
69
|
+
child_path_parts = path[1..]
|
70
|
+
parent_path_parts.zip(child_path_parts).each do |parent_path_part, child_path_part|
|
71
|
+
node = node[parent_path_part] ||= create_node(child_path_part)
|
72
|
+
end
|
73
|
+
|
74
|
+
node[path[-1]] = create_node(transform)
|
75
|
+
end
|
76
|
+
|
77
|
+
def create_node(path_part)
|
78
|
+
if AggregateNode::IMPLEMENTATION_TYPES.include?(path_part.class)
|
79
|
+
AggregateNode.new(path_part.class)
|
80
|
+
elsif path_part.is_a?(Transform)
|
81
|
+
TransformNode.new(path_part)
|
82
|
+
else
|
83
|
+
raise PathPartError, "Cannot create node from path_part type #{step.class}"
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|