Files changed (62) hide show
  1. checksums.yaml +7 -0
  2. data/.codeclimate.yml +12 -0
  3. data/.github/ISSUE_TEMPLATE/----please-don-t-ask-for-support-via-issues.md +10 -0
  4. data/.github/ISSUE_TEMPLATE/---bug-report.md +30 -0
  5. data/.github/ISSUE_TEMPLATE/---feature-request.md +18 -0
  6. data/.github/workflows/custom_ci.yml +66 -0
  7. data/.github/workflows/docsite.yml +34 -0
  8. data/.github/workflows/sync_configs.yml +34 -0
  9. data/.gitignore +16 -0
  10. data/.rspec +4 -0
  11. data/.rubocop.yml +95 -0
  12. data/CHANGELOG.md +3 -0
  13. data/CODE_OF_CONDUCT.md +13 -0
  14. data/CONTRIBUTING.md +29 -0
  15. data/Gemfile +19 -0
  16. data/LICENSE +20 -0
  17. data/README.md +29 -0
  18. data/Rakefile +6 -0
  19. data/docsite/source/built-in-transformations.html.md +47 -0
  20. data/docsite/source/index.html.md +15 -0
  21. data/docsite/source/transformation-objects.html.md +32 -0
  22. data/docsite/source/using-standalone-functions.html.md +82 -0
  23. data/dry-transformer.gemspec +22 -0
  24. data/lib/dry-transformer.rb +3 -0
  25. data/lib/dry/transformer.rb +23 -0
  26. data/lib/dry/transformer/all.rb +11 -0
  27. data/lib/dry/transformer/array.rb +183 -0
  28. data/lib/dry/transformer/array/combine.rb +65 -0
  29. data/lib/dry/transformer/class.rb +56 -0
  30. data/lib/dry/transformer/coercions.rb +196 -0
  31. data/lib/dry/transformer/compiler.rb +47 -0
  32. data/lib/dry/transformer/composite.rb +54 -0
  33. data/lib/dry/transformer/conditional.rb +76 -0
  34. data/lib/dry/transformer/constants.rb +7 -0
  35. data/lib/dry/transformer/error.rb +16 -0
  36. data/lib/dry/transformer/function.rb +109 -0
  37. data/lib/dry/transformer/hash.rb +453 -0
  38. data/lib/dry/transformer/pipe.rb +75 -0
  39. data/lib/dry/transformer/pipe/class_interface.rb +115 -0
  40. data/lib/dry/transformer/pipe/dsl.rb +58 -0
  41. data/lib/dry/transformer/proc.rb +46 -0
  42. data/lib/dry/transformer/recursion.rb +121 -0
  43. data/lib/dry/transformer/registry.rb +150 -0
  44. data/lib/dry/transformer/store.rb +128 -0
  45. data/lib/dry/transformer/version.rb +7 -0
  46. data/spec/spec_helper.rb +31 -0
  47. data/spec/unit/array/combine_spec.rb +224 -0
  48. data/spec/unit/array_transformations_spec.rb +233 -0
  49. data/spec/unit/class_transformations_spec.rb +50 -0
  50. data/spec/unit/coercions_spec.rb +132 -0
  51. data/spec/unit/conditional_spec.rb +48 -0
  52. data/spec/unit/function_not_found_error_spec.rb +12 -0
  53. data/spec/unit/function_spec.rb +193 -0
  54. data/spec/unit/hash_transformations_spec.rb +490 -0
  55. data/spec/unit/proc_transformations_spec.rb +20 -0
  56. data/spec/unit/recursion_spec.rb +145 -0
  57. data/spec/unit/registry_spec.rb +202 -0
  58. data/spec/unit/store_spec.rb +198 -0
  59. data/spec/unit/transformer/class_interface_spec.rb +350 -0
  60. data/spec/unit/transformer/dsl_spec.rb +15 -0
  61. data/spec/unit/transformer/instance_methods_spec.rb +25 -0
  62. metadata +119 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 3b479f91512fd97581875e395c171f70031cd6316e7e546f634285a4db46c183
4
+ data.tar.gz: b6b5dcc266532598a8b656d5a0597809ea90de917b2f624e95886ddadfa1b378
5
+ SHA512:
6
+ metadata.gz: 1e97fff09478334ab2117f61c5133cc2ab9ea11f53ab465dbe766f6b8239e10dc9e7b030b52a33ecd368b57aac71f5b8cb6f05b0e9726e38a705d759696f98c0
7
+ data.tar.gz: 46927ebbf2cc102a4527a0369e6675613301f78f4be6838bc981667b464e515878a8f3fcf470229fb32d8ff3b8cf00dd903cbf3ed20aa35b6cdc0881afe70999
data/.codeclimate.yml ADDED
@@ -0,0 +1,12 @@
1
+ # this file is managed by dry-rb/devtools project
2
+
3
+ version: "2"
4
+
5
+ exclude_patterns:
6
+ - "benchmarks/"
7
+ - "examples/"
8
+ - "spec/"
9
+
10
+ plugins:
11
+ rubocop:
12
+ enabled: true
data/.github/ISSUE_TEMPLATE/----please-don-t-ask-for-support-via-issues.md ADDED
@@ -0,0 +1,10 @@
1
+ ---
2
+ name: "⚠️ Please don't ask for support via issues"
3
+ about: See CONTRIBUTING.md for more information
4
+ title: ''
5
+ labels: ''
6
+ assignees: ''
7
+
8
+ ---
9
+
10
+
data/.github/ISSUE_TEMPLATE/---bug-report.md ADDED
@@ -0,0 +1,30 @@
1
+ ---
2
+ name: "\U0001F41B Bug report"
3
+ about: See CONTRIBUTING.md for more information
4
+ title: ''
5
+ labels: bug
6
+ assignees: ''
7
+
8
+ ---
9
+
10
+ **Before you submit this: WE ONLY ACCEPT BUG REPORTS AND FEATURE REQUESTS**
11
+
12
+ For more information see `CONTRIBUTING.md`.
13
+
14
+ **Describe the bug**
15
+
16
+ A clear and concise description of what the bug is.
17
+
18
+ **To Reproduce**
19
+
20
+ Provide detailed steps to reproduce, an executable script would be best.
21
+
22
+ **Expected behavior**
23
+
24
+ A clear and concise description of what you expected to happen.
25
+
26
+ **Your environment**
27
+
28
+ - Affects my production application: **YES/NO**
29
+ - Ruby version: ...
30
+ - OS: ...
data/.github/ISSUE_TEMPLATE/---feature-request.md ADDED
@@ -0,0 +1,18 @@
1
+ ---
2
+ name: "\U0001F6E0 Feature request"
3
+ about: See CONTRIBUTING.md for more information
4
+ title: ''
5
+ labels: feature
6
+ assignees: ''
7
+
8
+ ---
9
+
10
+ Summary of what the feature is supposed to do.
11
+
12
+ ## Examples
13
+
14
+ Code examples showing how the feature could be used.
15
+
16
+ ## Resources
17
+
18
+ Additional information, like a link to the discussion forum thread where the feature was discussed etc.
data/.github/workflows/custom_ci.yml ADDED
@@ -0,0 +1,66 @@
1
+ # this file is managed by dry-rb/devtools project
2
+
3
+ name: ci
4
+
5
+ on:
6
+ push:
7
+ paths:
8
+ - .github/workflows/custom_ci.yml
9
+ - lib/**
10
+ - spec/**
11
+ - Gemfile
12
+ - "*.gemspec"
13
+
14
+ jobs:
15
+ tests-mri:
16
+ runs-on: ubuntu-latest
17
+ strategy:
18
+ fail-fast: false
19
+ matrix:
20
+ ruby: ["2.6.x", "2.5.x", "2.4.x"]
21
+ include:
22
+ - ruby: "2.6.x"
23
+ coverage: "true"
24
+ steps:
25
+ - uses: actions/checkout@v1
26
+ - name: Set up Ruby
27
+ uses: actions/setup-ruby@v1
28
+ with:
29
+ ruby-version: ${{matrix.ruby}}
30
+ - name: Download test reporter
31
+ if: "matrix.coverage == 'true'"
32
+ run: |
33
+ mkdir -p tmp/
34
+ curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./tmp/cc-test-reporter
35
+ chmod +x ./tmp/cc-test-reporter
36
+ ./tmp/cc-test-reporter before-build
37
+ - name: Bundle install
38
+ run: |
39
+ gem install bundler
40
+ bundle install --jobs 4 --retry 3 --without tools docs benchmarks
41
+ - name: Run all tests
42
+ env:
43
+ COVERAGE: ${{matrix.coverage}}
44
+ CODACY_PROJECT_TOKEN: ${{secrets.CODACY_PROJECT_TOKEN}}
45
+ CODACY_RUN_LOCAL: true
46
+ run: bundle exec rake
47
+ tests-others:
48
+ runs-on: ubuntu-latest
49
+ strategy:
50
+ fail-fast: false
51
+ matrix:
52
+ image: ["jruby:9.2.8", "ruby:rc"]
53
+ container:
54
+ image: ${{matrix.image}}
55
+ steps:
56
+ - uses: actions/checkout@v1
57
+ - name: Install git
58
+ run: |
59
+ apt-get update
60
+ apt-get install -y --no-install-recommends git
61
+ - name: Bundle install
62
+ run: |
63
+ gem install bundler
64
+ bundle install --jobs 4 --retry 3 --without tools docs benchmarks
65
+ - name: Run all tests
66
+ run: bundle exec rake
data/.github/workflows/docsite.yml ADDED
@@ -0,0 +1,34 @@
1
+ # this file is managed by dry-rb/devtools project
2
+
3
+ name: docsite
4
+
5
+ on:
6
+ push:
7
+ paths:
8
+ - docsite/**
9
+ - .github/workflows/docsite.yml
10
+ branches:
11
+ - master
12
+ - release-**
13
+ tags:
14
+
15
+ jobs:
16
+ update-docs:
17
+ runs-on: ubuntu-latest
18
+ steps:
19
+ - uses: actions/checkout@v1
20
+ - name: Set up Ruby
21
+ uses: actions/setup-ruby@v1
22
+ with:
23
+ ruby-version: "2.6.x"
24
+ - name: Install dependencies
25
+ run: |
26
+ gem install bundler
27
+ bundle install --jobs 4 --retry 3 --without benchmarks sql
28
+ - name: Symlink ossy
29
+ run: mkdir -p bin && ln -sf "$(bundle show ossy)/bin/ossy" bin/ossy
30
+ - name: Trigger dry-rb.org deploy
31
+ env:
32
+ GITHUB_LOGIN: dry-bot
33
+ GITHUB_TOKEN: ${{ secrets.GH_PAT }}
34
+ run: bin/ossy github workflow dry-rb/dry-rb.org ci
data/.github/workflows/sync_configs.yml ADDED
@@ -0,0 +1,34 @@
1
+ # this file is managed by dry-rb/devtools project
2
+
3
+ name: sync_configs
4
+
5
+ on:
6
+ repository_dispatch:
7
+
8
+ jobs:
9
+ sync-configs:
10
+ runs-on: ubuntu-latest
11
+ if: github.event.action == 'sync_configs'
12
+ steps:
13
+ - uses: actions/checkout@v1
14
+ - name: Update configuration files from devtools
15
+ env:
16
+ GITHUB_LOGIN: dry-bot
17
+ GITHUB_TOKEN: ${{ secrets.GH_PAT }}
18
+ run: |
19
+ git clone https://github.com/dry-rb/devtools.git tmp/devtools
20
+
21
+ if [ -f ".github/workflows/custom_ci.yml" ]; then
22
+ rsync -av --exclude '.github/workflows/ci.yml' tmp/devtools/shared/ . ;
23
+ else
24
+ rsync -av tmp/devtools/shared/ . ;
25
+ fi
26
+
27
+ git config --local user.email "dry-bot@dry-rb.org"
28
+ git config --local user.name "dry-bot"
29
+ git add -A
30
+ git commit -m "[devtools] config sync" || echo "nothing changed"
31
+ - name: Push changes
32
+ uses: ad-m/github-push-action@master
33
+ with:
34
+ github_token: ${{ secrets.GH_PAT }}
data/.gitignore ADDED
@@ -0,0 +1,16 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /vendor/
5
+ /_yardoc/
6
+ /coverage/
7
+ /doc/
8
+ /pkg/
9
+ /spec/reports/
10
+ /tmp/
11
+ *.bundle
12
+ *.so
13
+ *.o
14
+ *.a
15
+ mkmf.log
16
+
data/.rspec ADDED
@@ -0,0 +1,4 @@
1
+ --color
2
+ --require spec_helper
3
+ --order random
4
+
data/.rubocop.yml ADDED
@@ -0,0 +1,95 @@
1
+ # this file is managed by dry-rb/devtools project
2
+
3
+ AllCops:
4
+ TargetRubyVersion: 2.4
5
+
6
+ Style/EachWithObject:
7
+ Enabled: false
8
+
9
+ Style/StringLiterals:
10
+ Enabled: true
11
+ EnforcedStyle: single_quotes
12
+
13
+ Style/Alias:
14
+ Enabled: false
15
+
16
+ Style/LambdaCall:
17
+ Enabled: false
18
+
19
+ Style/StabbyLambdaParentheses:
20
+ Enabled: false
21
+
22
+ Style/FormatString:
23
+ Enabled: false
24
+
25
+ Style/Documentation:
26
+ Enabled: false
27
+
28
+ Layout/SpaceInLambdaLiteral:
29
+ Enabled: false
30
+
31
+ Layout/MultilineMethodCallIndentation:
32
+ Enabled: true
33
+ EnforcedStyle: indented
34
+
35
+ Metrics/LineLength:
36
+ Max: 100
37
+
38
+ Metrics/MethodLength:
39
+ Max: 22
40
+
41
+ Metrics/ClassLength:
42
+ Max: 150
43
+
44
+ Metrics/AbcSize:
45
+ Max: 20
46
+
47
+ Metrics/BlockLength:
48
+ Enabled: false
49
+
50
+ Metrics/CyclomaticComplexity:
51
+ Enabled: true
52
+ Max: 10
53
+
54
+ Lint/BooleanSymbol:
55
+ Enabled: false
56
+
57
+ Style/AccessModifierDeclarations:
58
+ Enabled: false
59
+
60
+ Style/BlockDelimiters:
61
+ Enabled: false
62
+
63
+ Layout/IndentFirstArrayElement:
64
+ EnforcedStyle: consistent
65
+
66
+ Style/ClassAndModuleChildren:
67
+ Exclude:
68
+ - "spec/**/*_spec.rb"
69
+
70
+ Lint/HandleExceptions:
71
+ Exclude:
72
+ - "spec/spec_helper.rb"
73
+
74
+ Naming/FileName:
75
+ Exclude:
76
+ - "lib/dry-*.rb"
77
+
78
+ Style/SymbolArray:
79
+ Exclude:
80
+ - "spec/**/*_spec.rb"
81
+
82
+ Style/ConditionalAssignment:
83
+ Enabled: false
84
+
85
+ Naming/MethodName:
86
+ Enabled: false
87
+
88
+ Style/AsciiComments:
89
+ Enabled: false
90
+
91
+ Style/DateTime:
92
+ Enabled: false
93
+
94
+ Style/IfUnlessModifier:
95
+ Enabled: false
data/CHANGELOG.md ADDED
@@ -0,0 +1,3 @@
1
+ # v0.1.0 2019-12-28
2
+
3
+ Initial port of the [transproc](https://github.com/solnic/transproc) gem.
data/CODE_OF_CONDUCT.md ADDED
@@ -0,0 +1,13 @@
1
+ # Contributor Code of Conduct
2
+
3
+ As contributors and maintainers of this project, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities.
4
+
5
+ We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, age, or religion.
6
+
7
+ Examples of unacceptable behavior by participants include the use of sexual language or imagery, derogatory comments or personal attacks, trolling, public or private harassment, insults, or other unprofessional conduct.
8
+
9
+ Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. Project maintainers who do not follow the Code of Conduct may be removed from the project team.
10
+
11
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers.
12
+
13
+ This Code of Conduct is adapted from the [Contributor Covenant](http:contributor-covenant.org), version 1.4.0, available at [https://www.contributor-covenant.org/version/1/4/code-of-conduct](https://www.contributor-covenant.org/version/1/4/code-of-conduct)
data/CONTRIBUTING.md ADDED
@@ -0,0 +1,29 @@
1
+ # Issue Guidelines
2
+
3
+ ## Reporting bugs
4
+
5
+ If you found a bug, report an issue and describe what's the expected behavior versus what actually happens. If the bug causes a crash, attach a full backtrace. If possible, a reproduction script showing the problem is highly appreciated.
6
+
7
+ ## Reporting feature requests
8
+
9
+ Report a feature request **only after discussing it first on [discourse.dry-rb.org](https://discourse.dry-rb.org)** where it was accepted. Please provide a concise description of the feature, don't link to a discussion thread, and instead summarize what was discussed.
10
+
11
+ ## Reporting questions, support requests, ideas, concerns etc.
12
+
13
+ **PLEASE DON'T** - use [discourse.dry-rb.org](http://discourse.dry-rb.org) instead.
14
+
15
+ # Pull Request Guidelines
16
+
17
+ A Pull Request will only be accepted if it addresses a specific issue that was reported previously, or fixes typos, mistakes in documentation etc.
18
+
19
+ Other requirements:
20
+
21
+ 1) Do not open a pull request if you can't provide tests along with it. If you have problems writing tests, ask for help in the related issue.
22
+ 2) Follow the style conventions of the surrounding code. In most cases, this is standard ruby style.
23
+ 3) Add API documentation if it's a new feature
24
+ 4) Update API documentation if it changes an existing feature
25
+ 5) Bonus points for sending a PR to [github.com/dry-rb/dry-rb.org](github.com/dry-rb/dry-rb.org) which updates user documentation and guides
26
+
27
+ # Asking for help
28
+
29
+ If these guidelines aren't helpful, and you're stuck, please post a message on [discourse.dry-rb.org](https://discourse.dry-rb.org) or join [our chat](https://dry-rb.zulipchat.com).
data/Gemfile ADDED
@@ -0,0 +1,19 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gemspec
4
+
5
+ gem 'rake'
6
+ gem 'rspec', '~> 3.8'
7
+ gem 'dry-equalizer', '~> 0.2'
8
+
9
+ platform :mri do
10
+ gem 'codacy-coverage', require: false
11
+ gem 'simplecov', require: false
12
+ end
13
+
14
+ group :tools do
15
+ gem 'pry'
16
+ gem 'byebug', platform: :mri
17
+ gem 'benchmark-ips'
18
+ gem 'ossy', git: 'https://github.com/solnic/ossy.git', branch: 'master', platform: :mri
19
+ end
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2015-2019 dry-rb team
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
6
+ this software and associated documentation files (the "Software"), to deal in
7
+ the Software without restriction, including without limitation the rights to
8
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9
+ the Software, and to permit persons to whom the Software is furnished to do so,
10
+ subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ 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, FITNESS
17
+ FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
18
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
19
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
20
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,29 @@
1
+ [gem]: https://rubygems.org/gems/dry-transformer
2
+ [actions]: https://github.com/dry-rb/dry-transformer/actions
3
+ [codacy]: https://www.codacy.com/gh/dry-rb/dry-transformer
4
+ [chat]: https://dry-rb.zulipchat.com
5
+ [inchpages]: http://inch-ci.org/github/dry-rb/dry-transformer
6
+
7
+ # dry-transformer [![Join the chat at https://dry-rb.zulipchat.com](https://img.shields.io/badge/dry--rb-join%20chat-%23346b7a.svg)][chat]
8
+
9
+ [![Gem Version](https://badge.fury.io/rb/dry-transformer.svg)][gem]
10
+ [![CI Status](https://github.com/dry-rb/dry-transformer/workflows/ci/badge.svg)][actions]
11
+ [![Codacy Badge](https://api.codacy.com/project/badge/Grade/22edf59617be4aef97cfbe4e1c99f1ce)][codacy]
12
+ [![Codacy Badge](https://api.codacy.com/project/badge/Coverage/22edf59617be4aef97cfbe4e1c99f1ce)][codacy]
13
+ [![Inline docs](http://inch-ci.org/github/dry-rb/dry-transformer.svg?branch=master)][inchpages]
14
+
15
+ ## Links
16
+
17
+ * [User documentation](http://dry-rb.org/gems/dry-transformer)
18
+ * [API documentation](http://rubydoc.info/gems/dry-transformer)
19
+
20
+ ## Supported Ruby versions
21
+
22
+ This library officially supports following Ruby versions:
23
+
24
+ * MRI >= `2.4`
25
+ * jruby >= `9.2`
26
+
27
+ ## License
28
+
29
+ See `LICENSE` file.
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require 'bundler/gem_tasks'
2
+ require 'rspec/core/rake_task'
3
+
4
+ task default: :spec
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
data/docsite/source/built-in-transformations.html.md ADDED
@@ -0,0 +1,47 @@
1
+ ---
2
+ title: Built-in transformation
3
+ layout: gem-single
4
+ name: dry-transformer
5
+ ---
6
+
7
+ `dry-transformer` comes with a lot of built-in functions. They come in the form of modules with class methods, which you can import into a registry:
8
+
9
+ * [Coercions](https://www.rubydoc.info/gems/dry-transformer/Dry/Transformer/Coercions)
10
+ * [Array transformations](https://www.rubydoc.info/gems/dry-transformer/Dry/Transformer/ArrayTransformations)
11
+ * [Hash transformations](https://www.rubydoc.info/gems/dry-transformer/Dry/Transformer/HashTransformations)
12
+ * [Class transformations](https://www.rubydoc.info/gems/dry-transformer/Dry/Transformer/ClassTransformations)
13
+ * [Proc transformations](https://www.rubydoc.info/gems/dry-transformer/Dry/Transformer/ProcTransformations)
14
+ * [Conditional](https://www.rubydoc.info/gems/dry-transformer/Dry/Transformer/Conditional)
15
+ * [Recursion](https://www.rubydoc.info/gems/dry-transformer/Dry/Transformer/Recursion)
16
+
17
+ You can import everything with:
18
+
19
+ ```ruby
20
+ module T
21
+ extend Dry::Transformer::Registry
22
+
23
+ import Dry::Transformer::Coercions
24
+ import Dry::Transformer::ArrayTransformations
25
+ import Dry::Transformer::HashTransformations
26
+ import Dry::Transformer::ClassTransformations
27
+ import Dry::Transformer::ProcTransformations
28
+ import Dry::Transformer::Conditional
29
+ import Dry::Transformer::Recursion
30
+ end
31
+
32
+ T[:to_string].(:abc) # => 'abc'
33
+ ```
34
+
35
+ Or import selectively with:
36
+
37
+ ```ruby
38
+ module T
39
+ extend Dry::Transformer::Registry
40
+
41
+ import :to_string, from: Dry::Transformer::Coercions, as: :stringify
42
+ end
43
+
44
+ T[:stringify].(:abc) # => 'abc'
45
+ T[:to_string].(:abc)
46
+ # => Dry::Transformer::FunctionNotFoundError: No registered function T[:to_string]
47
+ ```
data/docsite/source/index.html.md ADDED
@@ -0,0 +1,15 @@
1
+ ---
2
+ title: Introduction
3
+ description: Data transformation toolkit
4
+ layout: gem-single
5
+ type: gem
6
+ name: dry-transformer
7
+ sections:
8
+ - transformation-objects
9
+ - built-in-transformations
10
+ - using-standalone-functions
11
+ ---
12
+
13
+ dry-transformer is a library that allows you to compose procs into a functional pipeline using left-to-right function composition.
14
+
15
+ The approach came from Functional Programming, where simple functions are composed into more complex functions in order to transform some data. It works like `|>` in Elixir or `>>` in F#. dry-transformer provides a mechanism to define and compose transformations, along with a number of built-in transformations.
data/docsite/source/transformation-objects.html.md ADDED
@@ -0,0 +1,32 @@
1
+ ---
2
+ title: Transformation objects
3
+ name: dry-transformer
4
+ layout: gem-single
5
+ ---
6
+
7
+ You can define transformation classes using the DSL which converts every method call to its corresponding transformation, and composes these transformations into a transformation pipeline. Here's a simple example where the default registry is used:
8
+
9
+ ```ruby
10
+ class MyMapper < Dry::Transformer[Dry::Transformer::Registry]
11
+ define! do
12
+ map_array do
13
+ symbolize_keys
14
+ rename_keys user_name: :name
15
+ nest :address, [:city, :street, :zipcode]
16
+ end
17
+ end
18
+ end
19
+
20
+ mapper = MyMapper.new
21
+
22
+ mapper.(
23
+ [
24
+ { 'user_name' => 'Jane',
25
+ 'city' => 'NYC',
26
+ 'street' => 'Street 1',
27
+ 'zipcode' => '123'
28
+ }
29
+ ]
30
+ )
31
+ # => [{:name=>"Jane", :address=>{:city=>"NYC", :street=>"Street 1", :zipcode=>"123"}}]
32
+ ```
data/docsite/source/using-standalone-functions.html.md ADDED
@@ -0,0 +1,82 @@
1
+ ---
2
+ title: Using standalone functions
3
+ name: dry-transformer
4
+ layout: gem-single
5
+ ---
6
+
7
+ You can use `dry-transformer` and its function registry feature stand-alone, without the need to define transformation classes. To do so, simply define a module and extend it with the registry API:
8
+
9
+ ``` ruby
10
+ require 'json'
11
+ require 'dry/transformer/all'
12
+
13
+ # create your own local registry for transformation functions
14
+ module Functions
15
+ extend Dry::Transformer::Registry
16
+ end
17
+
18
+ # import necessary functions from other transprocs...
19
+ module Functions
20
+ # import all singleton methods from a module/class
21
+ import Dry::Transformer::HashTransformations
22
+ import Dry::Transformer::ArrayTransformations
23
+ end
24
+
25
+ # ...or from any external library
26
+ require 'dry-inflector'
27
+
28
+ Inflector = Dry::Inflector.new
29
+
30
+ module Functions
31
+ # import only necessary singleton methods from a module/class
32
+ # and rename them locally
33
+ import :camelize, from: Inflector, as: :camel_case
34
+ end
35
+
36
+ def t(*args)
37
+ Functions[*args]
38
+ end
39
+
40
+ # use imported transformation
41
+ transformation = t(:camel_case)
42
+
43
+ transformation.call 'i_am_a_camel'
44
+ # => "IAmACamel"
45
+
46
+ transformation = t(:map_array, (
47
+ t(:symbolize_keys).>> t(:rename_keys, user_name: :user)
48
+ )).>> t(:wrap, :address, [:city, :street, :zipcode])
49
+
50
+ transformation.call(
51
+ [
52
+ { 'user_name' => 'Jane',
53
+ 'city' => 'NYC',
54
+ 'street' => 'Street 1',
55
+ 'zipcode' => '123' }
56
+ ]
57
+ )
58
+ # => [{:user=>"Jane", :address=>{:city=>"NYC", :street=>"Street 1", :zipcode=>"123"}}]
59
+
60
+ # define your own composable transformation easily
61
+ transformation = t(-> v { JSON.dump(v) })
62
+
63
+ transformation.call(name: 'Jane')
64
+ # => "{\"name\":\"Jane\"}"
65
+
66
+ # ...or add it to registered functions via singleton method of the registry
67
+ module Functions
68
+ # ...
69
+
70
+ def self.load_json(v)
71
+ JSON.load(v)
72
+ end
73
+ end
74
+
75
+ # ...or add it to registered functions via .register method
76
+ Functions.register(:load_json) { |v| JSON.load(v) }
77
+
78
+ transformation = t(:load_json) >> t(:map_array, t(:symbolize_keys))
79
+
80
+ transformation.call('[{"name":"Jane"}]')
81
+ # => [{ :name => "Jane" }]
82
+ ```
data/dry-transformer.gemspec ADDED
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ lib = File.expand_path('lib', __dir__)
4
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
+ require 'dry/transformer/version'
6
+
7
+ Gem::Specification.new do |spec|
8
+ spec.name = 'dry-transformer'
9
+ spec.version = Dry::Transformer::VERSION.dup
10
+ spec.authors = ['Piotr Solnica']
11
+ spec.email = ['piotr.solnica@gmail.com']
12
+ spec.summary = 'Data transformation toolkit'
13
+ spec.description = spec.summary
14
+ spec.homepage = 'https://dry-rb.org/gems/dry-transformer/'
15
+ spec.license = 'MIT'
16
+
17
+ spec.files = `git ls-files -z`.split("\x0")
18
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
19
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
20
+ spec.require_paths = ['lib']
21
+ spec.required_ruby_version = '>= 2.3.0'
22
+ end
data/lib/dry-transformer.rb ADDED
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/transformer'
data/lib/dry/transformer.rb ADDED
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/transformer/version'
4
+ require 'dry/transformer/constants'
5
+ require 'dry/transformer/function'
6
+ require 'dry/transformer/error'
7
+ require 'dry/transformer/store'
8
+ require 'dry/transformer/registry'
9
+
10
+ require 'dry/transformer/array'
11
+ require 'dry/transformer/hash'
12
+
13
+ require 'dry/transformer/pipe'
14
+
15
+ module Dry
16
+ module Transformer
17
+ # @api public
18
+ # @see Pipe.[]
19
+ def self.[](registry)
20
+ Pipe[registry]
21
+ end
22
+ end
23
+ end
data/lib/dry/transformer/all.rb ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/transformer'
4
+
5
+ require 'dry/transformer/class'
6
+ require 'dry/transformer/coercions'
7
+ require 'dry/transformer/conditional'
8
+ require 'dry/transformer/array'
9
+ require 'dry/transformer/hash'
10
+ require 'dry/transformer/proc'
11
+ require 'dry/transformer/recursion'
data/lib/dry/transformer/array.rb ADDED
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/transformer/coercions'
4
+ require 'dry/transformer/hash'
5
+ require 'dry/transformer/array/combine'
6
+
7
+ module Dry
8
+ module Transformer
9
+ # Transformation functions for Array objects
10
+ #
11
+ # @example
12
+ # require 'dry/transformer/array'
13
+ #
14
+ # include Dry::Transformer::Helper
15
+ #
16
+ # fn = t(:map_array, t(:symbolize_keys)) >> t(:wrap, :address, [:city, :zipcode])
17
+ #
18
+ # fn.call(
19
+ # [
20
+ # { 'city' => 'Boston', 'zipcode' => '123' },
21
+ # { 'city' => 'NYC', 'zipcode' => '312' }
22
+ # ]
23
+ # )
24
+ # # => [{:address=>{:city=>"Boston", :zipcode=>"123"}}, {:address=>{:city=>"NYC", :zipcode=>"312"}}]
25
+ #
26
+ # @api public
27
+ module ArrayTransformations
28
+ extend Registry
29
+
30
+ # Map array values using transformation function
31
+ #
32
+ # @example
33
+ #
34
+ # fn = Dry::Transformer(:map_array, -> v { v.upcase })
35
+ #
36
+ # fn.call ['foo', 'bar'] # => ["FOO", "BAR"]
37
+ #
38
+ # @param [Array] array The input array
39
+ # @param [Proc] fn The transformation function
40
+ #
41
+ # @return [Array]
42
+ #
43
+ # @api public
44
+ def self.map_array(array, fn)
45
+ Array(array).map { |value| fn[value] }
46
+ end
47
+
48
+ # Wrap array values using HashTransformations.nest function
49
+ #
50
+ # @example
51
+ # fn = Dry::Transformer(:wrap, :address, [:city, :zipcode])
52
+ #
53
+ # fn.call [{ city: 'NYC', zipcode: '123' }]
54
+ # # => [{ address: { city: 'NYC', zipcode: '123' } }]
55
+ #
56
+ # @param [Array] array The input array
57
+ # @param [Object] key The nesting root key
58
+ # @param [Object] keys The nesting value keys
59
+ #
60
+ # @return [Array]
61
+ #
62
+ # @api public
63
+ def self.wrap(array, key, keys)
64
+ nest = HashTransformations[:nest, key, keys]
65
+ array.map { |element| nest.call(element) }
66
+ end
67
+
68
+ # Group array values using provided root key and value keys
69
+ #
70
+ # @example
71
+ # fn = Dry::Transformer(:group, :tags, [:tag])
72
+ #
73
+ # fn.call [
74
+ # { task: 'Group it', tag: 'task' },
75
+ # { task: 'Group it', tag: 'important' }
76
+ # ]
77
+ # # => [{ task: 'Group it', tags: [{ tag: 'task' }, { tag: 'important' }]]
78
+ #
79
+ # @param [Array] array The input array
80
+ # @param [Object] key The nesting root key
81
+ # @param [Object] keys The nesting value keys
82
+ #
83
+ # @return [Array]
84
+ #
85
+ # @api public
86
+ def self.group(array, key, keys)
87
+ grouped = Hash.new { |h, k| h[k] = [] }
88
+ array.each do |hash|
89
+ hash = Hash[hash]
90
+
91
+ old_group = Coercions.to_tuples(hash.delete(key))
92
+ new_group = keys.inject({}) { |a, e| a.merge(e => hash.delete(e)) }
93
+
94
+ grouped[hash] << old_group.map { |item| item.merge(new_group) }
95
+ end
96
+ grouped.map do |root, children|
97
+ root.merge(key => children.flatten)
98
+ end
99
+ end
100
+
101
+ # Ungroup array values using provided root key and value keys
102
+ #
103
+ # @example
104
+ # fn = Dry::Transformer(:ungroup, :tags, [:tag])
105
+ #
106
+ # fn.call [
107
+ # { task: 'Group it', tags: [{ tag: 'task' }, { tag: 'important' }] }
108
+ # ]
109
+ # # => [
110
+ # { task: 'Group it', tag: 'task' },
111
+ # { task: 'Group it', tag: 'important' }
112
+ # ]
113
+ #
114
+ # @param [Array] array The input array
115
+ # @param [Object] key The nesting root key
116
+ # @param [Object] keys The nesting value keys
117
+ #
118
+ # @return [Array]
119
+ #
120
+ # @api public
121
+ def self.ungroup(array, key, keys)
122
+ array.flat_map { |item| HashTransformations.split(item, key, keys) }
123
+ end
124
+
125
+ def self.combine(array, mappings)
126
+ Combine.combine(array, mappings)
127
+ end
128
+
129
+ # Converts the array of hashes to array of values, extracted by given key
130
+ #
131
+ # @example
132
+ # fn = t(:extract_key, :name)
133
+ # fn.call [
134
+ # { name: 'Alice', role: 'sender' },
135
+ # { name: 'Bob', role: 'receiver' },
136
+ # { role: 'listener' }
137
+ # ]
138
+ # # => ['Alice', 'Bob', nil]
139
+ #
140
+ # @param [Array<Hash>] array The input array of hashes
141
+ # @param [Object] key The key to extract values by
142
+ #
143
+ # @return [Array]
144
+ #
145
+ # @api public
146
+ def self.extract_key(array, key)
147
+ map_array(array, ->(v) { v[key] })
148
+ end
149
+
150
+ # Wraps every value of the array to tuple with given key
151
+ #
152
+ # The transformation partially inverses the `extract_key`.
153
+ #
154
+ # @example
155
+ # fn = t(:insert_key, 'name')
156
+ # fn.call ['Alice', 'Bob', nil]
157
+ # # => [{ 'name' => 'Alice' }, { 'name' => 'Bob' }, { 'name' => nil }]
158
+ #
159
+ # @param [Array<Hash>] array The input array of hashes
160
+ # @param [Object] key The key to extract values by
161
+ #
162
+ # @return [Array]
163
+ #
164
+ # @api public
165
+ def self.insert_key(array, key)
166
+ map_array(array, ->(v) { { key => v } })
167
+ end
168
+
169
+ # Adds missing keys with nil value to all tuples in array
170
+ #
171
+ # @param [Array] keys
172
+ #
173
+ # @return [Array]
174
+ #
175
+ # @api public
176
+ #
177
+ def self.add_keys(array, keys)
178
+ base = keys.inject({}) { |a, e| a.merge(e => nil) }
179
+ map_array(array, ->(v) { base.merge(v) })
180
+ end
181
+ end
182
+ end
183
+ end
data/lib/dry/transformer/array/combine.rb ADDED
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dry
4
+ module Transformer
5
+ module ArrayTransformations
6
+ class Combine
7
+ EMPTY_ARRAY = [].freeze
8
+
9
+ class << self
10
+ def combine(array, mappings)
11
+ root, nodes = array
12
+ return EMPTY_ARRAY if root.nil?
13
+ return root if nodes.nil?
14
+
15
+ groups = group_nodes(nodes, mappings)
16
+
17
+ root.map do |element|
18
+ element.dup.tap { |copy| add_groups_to_element(copy, groups, mappings) }
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def add_groups_to_element(element, groups, mappings)
25
+ groups.each_with_index do |candidates, index|
26
+ mapping = mappings[index]
27
+ resource_key = mapping[0]
28
+ element[resource_key] = element_candidates(element, candidates, mapping[1].keys)
29
+ end
30
+ end
31
+
32
+ def element_candidates(element, candidates, keys)
33
+ candidates[element_candidates_key(element, keys)] || EMPTY_ARRAY
34
+ end
35
+
36
+ def group_nodes(nodes, mappings)
37
+ nodes.each_with_index.map do |candidates, index|
38
+ mapping = mappings[index]
39
+ group_candidates(candidates, mapping)
40
+ end
41
+ end
42
+
43
+ def group_candidates(candidates, mapping)
44
+ nested_mapping = mapping[2]
45
+ candidates = combine(candidates, nested_mapping) unless nested_mapping.nil?
46
+ group_candidates_by_keys(candidates, mapping[1].values)
47
+ end
48
+
49
+ def group_candidates_by_keys(candidates, keys)
50
+ return candidates.group_by { |a| a.values_at(*keys) } if keys.size > 1
51
+
52
+ key = keys.first
53
+ candidates.group_by { |a| a[key] }
54
+ end
55
+
56
+ def element_candidates_key(element, keys)
57
+ return element.values_at(*keys) if keys.size > 1
58
+
59
+ element[keys.first]
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
data/lib/dry/transformer/class.rb ADDED
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dry
4
+ module Transformer
5
+ # Transformation functions for Classes
6
+ #
7
+ # @example
8
+ # require 'dry/transformer/class'
9
+ #
10
+ # include Dry::Transformer::Helper
11
+ #
12
+ # fn = t(:constructor_inject, Struct)
13
+ #
14
+ # fn['User', :name, :age]
15
+ # # => Struct::User
16
+ #
17
+ # @api public
18
+ module ClassTransformations
19
+ extend Registry
20
+
21
+ # Inject given arguments into the constructor of the class
22
+ #
23
+ # @example
24
+ # Transproct(:constructor_inject, Struct)['User', :name, :age]
25
+ # # => Struct::User
26
+ #
27
+ # @param [*Mixed] A list of arguments to inject
28
+ #
29
+ # @return [Object] An instance of the given klass
30
+ #
31
+ # @api public
32
+ def self.constructor_inject(*args, klass)
33
+ klass.new(*args)
34
+ end
35
+
36
+ # Set instance variables from the hash argument (key/value pairs) on the object
37
+ #
38
+ # @example
39
+ # Dry::Transformer(:set_ivars, Object)[name: 'Jane', age: 25]
40
+ # # => #<Object:0x007f411d06a210 @name="Jane", @age=25>
41
+ #
42
+ # @param [Object]
43
+ #
44
+ # @return [Object]
45
+ #
46
+ # @api public
47
+ def self.set_ivars(ivar_hash, klass)
48
+ object = klass.allocate
49
+ ivar_hash.each do |ivar_name, ivar_value|
50
+ object.instance_variable_set("@#{ivar_name}", ivar_value)
51
+ end
52
+ object
53
+ end
54
+ end
55
+ end
56
+ end
data/lib/dry/transformer/coercions.rb ADDED
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+ require 'time'
5
+ require 'bigdecimal'
6
+ require 'bigdecimal/util'
7
+
8
+ module Dry
9
+ module Transformer
10
+ # Coercion functions for common types
11
+ #
12
+ # @api public
13
+ module Coercions
14
+ extend Registry
15
+
16
+ TRUE_VALUES = [true, 1, '1', 'on', 't', 'true', 'y', 'yes'].freeze
17
+ FALSE_VALUES = [false, 0, '0', 'off', 'f', 'false', 'n', 'no', nil].freeze
18
+
19
+ BOOLEAN_MAP = Hash[
20
+ TRUE_VALUES.product([true]) + FALSE_VALUES.product([false])
21
+ ].freeze
22
+
23
+ # Does nothing and returns a value
24
+ #
25
+ # @example
26
+ # fn = Coercions[:identity]
27
+ # fn[:foo] # => :foo
28
+ #
29
+ # @param [Object] value
30
+ #
31
+ # @return [Object]
32
+ #
33
+ # @api public
34
+ def self.identity(value = nil)
35
+ value
36
+ end
37
+
38
+ # Coerce value into a string
39
+ #
40
+ # @example
41
+ # Dry::Transformer(:to_string)[1]
42
+ # # => "1"
43
+ #
44
+ # @param [Object] value The input value
45
+ #
46
+ # @return [String]
47
+ #
48
+ # @api public
49
+ def self.to_string(value)
50
+ value.to_s
51
+ end
52
+
53
+ # Coerce value into a symbol
54
+ #
55
+ # @example
56
+ # Dry::Transformer(:to_symbol)['foo']
57
+ # # => :foo
58
+ #
59
+ # @param [#to_s] value The input value
60
+ #
61
+ # @return [Symbol]
62
+ #
63
+ # @api public
64
+ def self.to_symbol(value)
65
+ value.to_s.to_sym
66
+ end
67
+
68
+ # Coerce value into a integer
69
+ #
70
+ # @example
71
+ # Dry::Transformer(:to_integer)['1']
72
+ # # => 1
73
+ #
74
+ # @param [Object] value The input value
75
+ #
76
+ # @return [Integer]
77
+ #
78
+ # @api public
79
+ def self.to_integer(value)
80
+ value.to_i
81
+ end
82
+
83
+ # Coerce value into a float
84
+ #
85
+ # @example
86
+ # Dry::Transformer(:to_float)['1.2']
87
+ # # => 1.2
88
+ #
89
+ # @param [Object] value The input value
90
+ #
91
+ # @return [Float]
92
+ #
93
+ # @api public
94
+ def self.to_float(value)
95
+ value.to_f
96
+ end
97
+
98
+ # Coerce value into a decimal
99
+ #
100
+ # @example
101
+ # Dry::Transformer(:to_decimal)[1.2]
102
+ # # => #<BigDecimal:7fca32acea50,'0.12E1',18(36)>
103
+ #
104
+ # @param [Object] value The input value
105
+ #
106
+ # @return [Decimal]
107
+ #
108
+ # @api public
109
+ def self.to_decimal(value)
110
+ value.to_d
111
+ end
112
+
113
+ # Coerce value into a boolean
114
+ #
115
+ # @example
116
+ # Dry::Transformer(:to_boolean)['true']
117
+ # # => true
118
+ # Dry::Transformer(:to_boolean)['f']
119
+ # # => false
120
+ #
121
+ # @param [Object] value The input value
122
+ #
123
+ # @return [TrueClass,FalseClass]
124
+ #
125
+ # @api public
126
+ def self.to_boolean(value)
127
+ BOOLEAN_MAP.fetch(value)
128
+ end
129
+
130
+ # Coerce value into a date
131
+ #
132
+ # @example
133
+ # Dry::Transformer(:to_date)['2015-04-14']
134
+ # # => #<Date: 2015-04-14 ((2457127j,0s,0n),+0s,2299161j)>
135
+ #
136
+ # @param [Object] value The input value
137
+ #
138
+ # @return [Date]
139
+ #
140
+ # @api public
141
+ def self.to_date(value)
142
+ Date.parse(value)
143
+ end
144
+
145
+ # Coerce value into a time
146
+ #
147
+ # @example
148
+ # Dry::Transformer(:to_time)['2015-04-14 12:01:45']
149
+ # # => 2015-04-14 12:01:45 +0200
150
+ #
151
+ # @param [Object] value The input value
152
+ #
153
+ # @return [Time]
154
+ #
155
+ # @api public
156
+ def self.to_time(value)
157
+ Time.parse(value)
158
+ end
159
+
160
+ # Coerce value into a datetime
161
+ #
162
+ # @example
163
+ # Dry::Transformer(:to_datetime)['2015-04-14 12:01:45']
164
+ # # => #<DateTime: 2015-04-14T12:01:45+00:00 ((2457127j,43305s,0n),+0s,2299161j)>
165
+ #
166
+ # @param [Object] value The input value
167
+ #
168
+ # @return [DateTime]
169
+ #
170
+ # @api public
171
+ def self.to_datetime(value)
172
+ DateTime.parse(value)
173
+ end
174
+
175
+ # Coerce value into an array containing tuples only
176
+ #
177
+ # If the source is not an array, or doesn't contain a tuple, returns
178
+ # an array with one empty tuple
179
+ #
180
+ # @example
181
+ # Dry::Transformer(:to_tuples)[:foo] # => [{}]
182
+ # Dry::Transformer(:to_tuples)[[]] # => [{}]
183
+ # Dry::Transformer(:to_tuples)[[{ foo: :FOO, :bar }]] # => [{ foo: :FOO }]
184
+ #
185
+ # @param [Object] value
186
+ #
187
+ # @return [Array<Hash>]
188
+ #
189
+ def self.to_tuples(value)
190
+ array = value.is_a?(Array) ? Array[*value] : [{}]
191
+ array.select! { |item| item.is_a?(Hash) }
192
+ array.any? ? array : [{}]
193
+ end
194
+ end
195
+ end
196
+ end
data/lib/dry/transformer/compiler.rb ADDED
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dry
4
+ module Transformer
5
+ # @api private
6
+ class Compiler
7
+ InvalidFunctionNameError = Class.new(StandardError)
8
+
9
+ attr_reader :registry, :transformer
10
+
11
+ def initialize(registry, transformer = nil)
12
+ @registry = registry
13
+ @transformer = transformer
14
+ end
15
+
16
+ def call(ast)
17
+ ast.map(&method(:visit)).reduce(:>>)
18
+ end
19
+
20
+ def visit(node)
21
+ id, *rest = node
22
+ public_send(:"visit_#{id}", *rest)
23
+ end
24
+
25
+ def visit_fn(node)
26
+ name, rest = node
27
+ args = rest.map { |arg| visit(arg) }
28
+
29
+ if registry.contain?(name)
30
+ registry[name, *args]
31
+ elsif transformer.respond_to?(name)
32
+ Function.new(transformer.method(name), name: name, args: args)
33
+ else
34
+ raise InvalidFunctionNameError, "function name +#{name}+ is not valid"
35
+ end
36
+ end
37
+
38
+ def visit_arg(arg)
39
+ arg
40
+ end
41
+
42
+ def visit_t(node)
43
+ call(node)
44
+ end
45
+ end
46
+ end
47
+ end
data/lib/dry/transformer/composite.rb ADDED
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dry
4
+ module Transformer
5
+ # Composition of two functions
6
+ #
7
+ # @api private
8
+ class Composite
9
+ # @return [Proc]
10
+ #
11
+ # @api private
12
+ attr_reader :left
13
+
14
+ # @return [Proc]
15
+ #
16
+ # @api private
17
+ attr_reader :right
18
+
19
+ # @api private
20
+ def initialize(left, right)
21
+ @left = left
22
+ @right = right
23
+ end
24
+
25
+ # Call right side with the result from the left side
26
+ #
27
+ # @param [Object] value The input value
28
+ #
29
+ # @return [Object]
30
+ #
31
+ # @api public
32
+ def call(value)
33
+ right.call(left.call(value))
34
+ end
35
+ alias_method :[], :call
36
+
37
+ # @see Function#compose
38
+ #
39
+ # @api public
40
+ def compose(other)
41
+ self.class.new(self, other)
42
+ end
43
+ alias_method :+, :compose
44
+ alias_method :>>, :compose
45
+
46
+ # @see Function#to_ast
47
+ #
48
+ # @api public
49
+ def to_ast
50
+ left.to_ast << right.to_ast
51
+ end
52
+ end
53
+ end
54
+ end
data/lib/dry/transformer/conditional.rb ADDED
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dry
4
+ module Transformer
5
+ # Conditional transformation functions
6
+ #
7
+ # @example
8
+ # require 'dry/transformer/conditional'
9
+ #
10
+ # include Dry::Transformer::Helper
11
+ #
12
+ # fn = t(:guard, -> s { s.is_a?(::String) }, -> s { s.to_sym })
13
+ #
14
+ # [fn[2], fn['Jane']]
15
+ # # => [2, :Jane]
16
+ #
17
+ # @api public
18
+ module Conditional
19
+ extend Registry
20
+
21
+ # Negates the result of transformation
22
+ #
23
+ # @example
24
+ # fn = Conditional[:not, -> value { value.is_a? ::String }]
25
+ # fn[:foo] # => true
26
+ # fn["foo"] # => false
27
+ #
28
+ # @param [Object] value
29
+ # @param [Proc] fn
30
+ #
31
+ # @return [Boolean]
32
+ #
33
+ # @api public
34
+ def self.not(value, fn)
35
+ !fn[value]
36
+ end
37
+
38
+ # Apply the transformation function to subject if the predicate returns true, or return un-modified
39
+ #
40
+ # @example
41
+ # [2, 'Jane'].map do |subject|
42
+ # Dry::Transformer(:guard, -> s { s.is_a?(::String) }, -> s { s.to_sym })[subject]
43
+ # end
44
+ # # => [2, :Jane]
45
+ #
46
+ # @param [Mixed]
47
+ #
48
+ # @return [Mixed]
49
+ #
50
+ # @api public
51
+ def self.guard(value, predicate, fn)
52
+ predicate[value] ? fn[value] : value
53
+ end
54
+
55
+ # Calls a function when type-check passes
56
+ #
57
+ # @example
58
+ # fn = Dry::Transformer(:is, Array, -> arr { arr.map(&:upcase) })
59
+ # fn.call(['a', 'b', 'c']) # => ['A', 'B', 'C']
60
+ #
61
+ # fn = Dry::Transformer(:is, Array, -> arr { arr.map(&:upcase) })
62
+ # fn.call('foo') # => "foo"
63
+ #
64
+ # @param [Object]
65
+ # @param [Class]
66
+ # @param [Proc]
67
+ #
68
+ # @return [Object]
69
+ #
70
+ # @api public
71
+ def self.is(value, type, fn)
72
+ guard(value, -> v { v.is_a?(type) }, fn)
73
+ end
74
+ end
75
+ end
76
+ end
data/lib/dry/transformer/constants.rb ADDED
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dry
4
+ module Transformer
5
+ Undefined = Object.new.freeze
6
+ end
7
+ end
data/lib/dry/transformer/error.rb ADDED
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dry
4
+ module Transformer
5
+ Error = Class.new(StandardError)
6
+ FunctionAlreadyRegisteredError = Class.new(Error)
7
+
8
+ class FunctionNotFoundError < Error
9
+ def initialize(function, source = nil)
10
+ return super "No registered function #{source}[:#{function}]" if source
11
+
12
+ super "No globally registered function for #{function}"
13
+ end
14
+ end
15
+ end
16
+ end
data/lib/dry/transformer/function.rb ADDED
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/transformer/composite'
4
+
5
+ module Dry
6
+ module Transformer
7
+ # Transformation proc wrapper allowing composition of multiple procs into
8
+ # a data-transformation pipeline.
9
+ #
10
+ # This is used by Dry::Transformer to wrap registered methods.
11
+ #
12
+ # @api private
13
+ class Function
14
+ # Wrapped proc or another composite function
15
+ #
16
+ # @return [Proc,Composed]
17
+ #
18
+ # @api private
19
+ attr_reader :fn
20
+
21
+ # Additional arguments that will be passed to the wrapped proc
22
+ #
23
+ # @return [Array]
24
+ #
25
+ # @api private
26
+ attr_reader :args
27
+
28
+ # @!attribute [r] name
29
+ #
30
+ # @return [<type] The name of the function
31
+ #
32
+ # @api public
33
+ attr_reader :name
34
+
35
+ # @api private
36
+ def initialize(fn, options = {})
37
+ @fn = fn
38
+ @args = options.fetch(:args, [])
39
+ @name = options.fetch(:name, fn)
40
+ end
41
+
42
+ # Call the wrapped proc
43
+ #
44
+ # @param [Object] value The input value
45
+ #
46
+ # @alias []
47
+ #
48
+ # @api public
49
+ def call(*value)
50
+ fn.call(*value, *args)
51
+ end
52
+ alias_method :[], :call
53
+
54
+ # Compose this function with another function or a proc
55
+ #
56
+ # @param [Proc,Function]
57
+ #
58
+ # @return [Composite]
59
+ #
60
+ # @alias :>>
61
+ #
62
+ # @api public
63
+ def compose(other)
64
+ Composite.new(self, other)
65
+ end
66
+ alias_method :+, :compose
67
+ alias_method :>>, :compose
68
+
69
+ # Return a new fn with curried args
70
+ #
71
+ # @return [Function]
72
+ #
73
+ # @api private
74
+ def with(*args)
75
+ self.class.new(fn, name: name, args: args)
76
+ end
77
+
78
+ # @api public
79
+ def ==(other)
80
+ return false unless other.instance_of?(self.class)
81
+
82
+ [fn, name, args] == [other.fn, other.name, other.args]
83
+ end
84
+ alias_method :eql?, :==
85
+
86
+ # Return a simple AST representation of this function
87
+ #
88
+ # @return [Array]
89
+ #
90
+ # @api public
91
+ def to_ast
92
+ args_ast = args.map { |arg| arg.respond_to?(:to_ast) ? arg.to_ast : arg }
93
+ [name, args_ast]
94
+ end
95
+
96
+ # Converts a transproc to a simple proc
97
+ #
98
+ # @return [Proc]
99
+ #
100
+ def to_proc
101
+ if !args.empty?
102
+ proc { |*value| fn.call(*value, *args) }
103
+ else
104
+ fn.to_proc
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
data/lib/dry/transformer/hash.rb ADDED
@@ -0,0 +1,453 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/transformer/coercions'
4
+
5
+ module Dry
6
+ module Transformer
7
+ # Transformation functions for Hash objects
8
+ #
9
+ # @example
10
+ # require 'dry/transformer/hash'
11
+ #
12
+ # include Dry::Transformer::Helper
13
+ #
14
+ # fn = t(:symbolize_keys) >> t(:nest, :address, [:street, :zipcode])
15
+ #
16
+ # fn["street" => "Street 1", "zipcode" => "123"]
17
+ # # => {:address => {:street => "Street 1", :zipcode => "123"}}
18
+ #
19
+ # @api public
20
+ module HashTransformations
21
+ extend Registry
22
+
23
+ if RUBY_VERSION >= '2.5'
24
+ # Map all keys in a hash with the provided transformation function
25
+ #
26
+ # @example
27
+ # Dry::Transformer(:map_keys, -> s { s.upcase })['name' => 'Jane']
28
+ # # => {"NAME" => "Jane"}
29
+ #
30
+ # @param [Hash]
31
+ #
32
+ # @return [Hash]
33
+ #
34
+ # @api public
35
+ def self.map_keys(source_hash, fn)
36
+ Hash[source_hash].transform_keys!(&fn)
37
+ end
38
+ else
39
+ def self.map_keys(source_hash, fn)
40
+ Hash[source_hash].tap do |hash|
41
+ hash.keys.each { |key| hash[fn[key]] = hash.delete(key) }
42
+ end
43
+ end
44
+ end
45
+
46
+ # Symbolize all keys in a hash
47
+ #
48
+ # @example
49
+ # Dry::Transformer(:symbolize_keys)['name' => 'Jane']
50
+ # # => {:name => "Jane"}
51
+ #
52
+ # @param [Hash]
53
+ #
54
+ # @return [Hash]
55
+ #
56
+ # @api public
57
+ def self.symbolize_keys(hash)
58
+ map_keys(hash, Coercions[:to_symbol].fn)
59
+ end
60
+
61
+ # Symbolize keys in a hash recursively
62
+ #
63
+ # @example
64
+ #
65
+ # input = { 'foo' => 'bar', 'baz' => [{ 'one' => 1 }] }
66
+ #
67
+ # t(:deep_symbolize_keys)[input]
68
+ # # => { :foo => "bar", :baz => [{ :one => 1 }] }
69
+ #
70
+ # @param [Hash]
71
+ #
72
+ # @return [Hash]
73
+ #
74
+ # @api public
75
+ def self.deep_symbolize_keys(hash)
76
+ hash.each_with_object({}) do |(key, value), output|
77
+ output[key.to_sym] =
78
+ case value
79
+ when Hash
80
+ deep_symbolize_keys(value)
81
+ when Array
82
+ value.map { |item|
83
+ item.is_a?(Hash) ? deep_symbolize_keys(item) : item
84
+ }
85
+ else
86
+ value
87
+ end
88
+ end
89
+ end
90
+
91
+ # Stringify all keys in a hash
92
+ #
93
+ # @example
94
+ # Dry::Transformer(:stringify_keys)[:name => 'Jane']
95
+ # # => {"name" => "Jane"}
96
+ #
97
+ # @param [Hash]
98
+ #
99
+ # @return [Hash]
100
+ #
101
+ # @api public
102
+ def self.stringify_keys(hash)
103
+ map_keys(hash, Coercions[:to_string].fn)
104
+ end
105
+
106
+ # Stringify keys in a hash recursively
107
+ #
108
+ # @example
109
+ # input = { :foo => "bar", :baz => [{ :one => 1 }] }
110
+ #
111
+ # t(:deep_stringify_keys)[input]
112
+ # # => { "foo" => "bar", "baz" => [{ "one" => 1 }] }
113
+ #
114
+ # @param [Hash]
115
+ #
116
+ # @return [Hash]
117
+ #
118
+ # @api public
119
+ def self.deep_stringify_keys(hash)
120
+ hash.each_with_object({}) do |(key, value), output|
121
+ output[key.to_s] =
122
+ case value
123
+ when Hash
124
+ deep_stringify_keys(value)
125
+ when Array
126
+ value.map { |item|
127
+ item.is_a?(Hash) ? deep_stringify_keys(item) : item
128
+ }
129
+ else
130
+ value
131
+ end
132
+ end
133
+ end
134
+
135
+ if RUBY_VERSION >= '2.4'
136
+ # Map all values in a hash using transformation function
137
+ #
138
+ # @example
139
+ # Dry::Transformer(:map_values, -> v { v.upcase })[:name => 'Jane']
140
+ # # => {"name" => "JANE"}
141
+ #
142
+ # @param [Hash]
143
+ #
144
+ # @return [Hash]
145
+ #
146
+ # @api public
147
+ def self.map_values(source_hash, fn)
148
+ Hash[source_hash].transform_values!(&fn)
149
+ end
150
+ else
151
+ def self.map_values(source_hash, fn)
152
+ Hash[source_hash].tap do |hash|
153
+ hash.each { |key, value| hash[key] = fn[value] }
154
+ end
155
+ end
156
+ end
157
+
158
+ # Rename all keys in a hash using provided mapping hash
159
+ #
160
+ # @example
161
+ # Dry::Transformer(:rename_keys, user_name: :name)[user_name: 'Jane']
162
+ # # => {:name => "Jane"}
163
+ #
164
+ # @param [Hash] source_hash The input hash
165
+ # @param [Hash] mapping The key-rename mapping
166
+ #
167
+ # @return [Hash]
168
+ #
169
+ # @api public
170
+ def self.rename_keys(source_hash, mapping)
171
+ Hash[source_hash].tap do |hash|
172
+ mapping.each { |k, v| hash[v] = hash.delete(k) if hash.key?(k) }
173
+ end
174
+ end
175
+
176
+ # Copy all keys in a hash using provided mapping hash
177
+ #
178
+ # @example
179
+ # Dry::Transformer(:copy_keys, user_name: :name)[user_name: 'Jane']
180
+ # # => {:user_name => "Jane", :name => "Jane"}
181
+ #
182
+ # @param [Hash] source_hash The input hash
183
+ # @param [Hash] mapping The key-copy mapping
184
+ #
185
+ # @return [Hash]
186
+ #
187
+ # @api public
188
+ def self.copy_keys(source_hash, mapping)
189
+ Hash[source_hash].tap do |hash|
190
+ mapping.each do |original_key, new_keys|
191
+ [*new_keys].each do |new_key|
192
+ hash[new_key] = hash[original_key]
193
+ end
194
+ end
195
+ end
196
+ end
197
+
198
+ # Rejects specified keys from a hash
199
+ #
200
+ # @example
201
+ # Dry::Transformer(:reject_keys, [:name])[name: 'Jane', email: 'jane@doe.org']
202
+ # # => {:email => "jane@doe.org"}
203
+ #
204
+ # @param [Hash] hash The input hash
205
+ # @param [Array] keys The keys to be rejected
206
+ #
207
+ # @return [Hash]
208
+ #
209
+ # @api public
210
+ def self.reject_keys(hash, keys)
211
+ Hash[hash].reject { |k, _| keys.include?(k) }
212
+ end
213
+
214
+ if RUBY_VERSION >= '2.5'
215
+ # Accepts specified keys from a hash
216
+ #
217
+ # @example
218
+ # Dry::Transformer(:accept_keys, [:name])[name: 'Jane', email: 'jane@doe.org']
219
+ # # => {:name=>"Jane"}
220
+ #
221
+ # @param [Hash] hash The input hash
222
+ # @param [Array] keys The keys to be accepted
223
+ #
224
+ # @return [Hash]
225
+ #
226
+ # @api public
227
+ def self.accept_keys(hash, keys)
228
+ Hash[hash].slice(*keys)
229
+ end
230
+ else
231
+ def self.accept_keys(hash, keys)
232
+ reject_keys(hash, hash.keys - keys)
233
+ end
234
+ end
235
+
236
+ # Map a key in a hash with the provided transformation function
237
+ #
238
+ # @example
239
+ # Dry::Transformer(:map_value, 'name', -> s { s.upcase })['name' => 'jane']
240
+ # # => {"name" => "JANE"}
241
+ #
242
+ # @param [Hash]
243
+ #
244
+ # @return [Hash]
245
+ #
246
+ # @api public
247
+ def self.map_value(hash, key, fn)
248
+ hash.merge(key => fn[hash[key]])
249
+ end
250
+
251
+ # Nest values from specified keys under a new key
252
+ #
253
+ # @example
254
+ # Dry::Transformer(:nest, :address, [:street, :zipcode])[street: 'Street', zipcode: '123']
255
+ # # => {address: {street: "Street", zipcode: "123"}}
256
+ #
257
+ # @param [Hash]
258
+ #
259
+ # @return [Hash]
260
+ #
261
+ # @api public
262
+ def self.nest(hash, root, keys)
263
+ child = {}
264
+
265
+ keys.each do |key|
266
+ child[key] = hash[key] if hash.key?(key)
267
+ end
268
+
269
+ output = Hash[hash]
270
+
271
+ child.each_key { |key| output.delete(key) }
272
+
273
+ old_root = hash[root]
274
+
275
+ if old_root.is_a?(Hash)
276
+ output[root] = old_root.merge(child)
277
+ else
278
+ output[root] = child
279
+ end
280
+
281
+ output
282
+ end
283
+
284
+ # Collapse a nested hash from a specified key
285
+ #
286
+ # @example
287
+ # Dry::Transformer(:unwrap, :address, [:street, :zipcode])[address: { street: 'Street', zipcode: '123' }]
288
+ # # => {street: "Street", zipcode: "123"}
289
+ #
290
+ # @param [Hash] source_hash
291
+ # @param [Mixed] root The root key to unwrap values from
292
+ # @param [Array] selected The keys that should be unwrapped (optional)
293
+ # @param [Hash] options hash of options (optional)
294
+ # @option options [Boolean] :prefix if true, unwrapped keys will be prefixed
295
+ # with the root key followed by an underscore (_)
296
+ #
297
+ # @return [Hash]
298
+ #
299
+ # @api public
300
+ def self.unwrap(source_hash, root, selected = nil, prefix: false)
301
+ return source_hash unless source_hash[root]
302
+
303
+ add_prefix = lambda do |key|
304
+ combined = [root, key].join('_')
305
+ root.is_a?(::Symbol) ? combined.to_sym : combined
306
+ end
307
+
308
+ Hash[source_hash].merge(root => Hash[source_hash[root]]).tap do |hash|
309
+ nested_hash = hash[root]
310
+ keys = nested_hash.keys
311
+ keys &= selected if selected
312
+ new_keys = prefix ? keys.map(&add_prefix) : keys
313
+
314
+ hash.update(Hash[new_keys.zip(keys.map { |key| nested_hash.delete(key) })])
315
+ hash.delete(root) if nested_hash.empty?
316
+ end
317
+ end
318
+
319
+ # Folds array of tuples to array of values from a specified key
320
+ #
321
+ # @example
322
+ # source = {
323
+ # name: "Jane",
324
+ # tasks: [{ title: "be nice", priority: 1 }, { title: "sleep well" }]
325
+ # }
326
+ # Dry::Transformer(:fold, :tasks, :title)[source]
327
+ # # => { name: "Jane", tasks: ["be nice", "sleep well"] }
328
+ # Dry::Transformer(:fold, :tasks, :priority)[source]
329
+ # # => { name: "Jane", tasks: [1, nil] }
330
+ #
331
+ # @param [Hash] hash
332
+ # @param [Object] key The key to fold values to
333
+ # @param [Object] tuple_key The key to take folded values from
334
+ #
335
+ # @return [Hash]
336
+ #
337
+ # @api public
338
+ def self.fold(hash, key, tuple_key)
339
+ hash.merge(key => ArrayTransformations.extract_key(hash[key], tuple_key))
340
+ end
341
+
342
+ # Splits hash to array by all values from a specified key
343
+ #
344
+ # The operation adds missing keys extracted from the array to regularize the output.
345
+ #
346
+ # @example
347
+ # input = {
348
+ # name: 'Joe',
349
+ # tasks: [
350
+ # { title: 'sleep well', priority: 1 },
351
+ # { title: 'be nice', priority: 2 },
352
+ # { priority: 2 },
353
+ # { title: 'be cool' }
354
+ # ]
355
+ # }
356
+ # Dry::Transformer(:split, :tasks, [:priority])[input]
357
+ # => [
358
+ # { name: 'Joe', priority: 1, tasks: [{ title: 'sleep well' }] },
359
+ # { name: 'Joe', priority: 2, tasks: [{ title: 'be nice' }, { title: nil }] },
360
+ # { name: 'Joe', priority: nil, tasks: [{ title: 'be cool' }] }
361
+ # ]
362
+ #
363
+ # @param [Hash] hash
364
+ # @param [Object] key The key to split a hash by
365
+ # @param [Array] subkeys The list of subkeys to be extracted from key
366
+ #
367
+ # @return [Array<Hash>]
368
+ #
369
+ # @api public
370
+ def self.split(hash, key, keys)
371
+ list = Array(hash[key])
372
+ return [hash.reject { |k, _| k == key }] if list.empty?
373
+
374
+ existing = list.flat_map(&:keys).uniq
375
+ grouped = existing - keys
376
+ ungrouped = existing & keys
377
+
378
+ list = ArrayTransformations.group(list, key, grouped) if grouped.any?
379
+ list = list.map { |item| item.merge(reject_keys(hash, [key])) }
380
+ ArrayTransformations.add_keys(list, ungrouped)
381
+ end
382
+
383
+ # Recursively evaluate hash values if they are procs/lambdas
384
+ #
385
+ # @example
386
+ # hash = {
387
+ # num: -> i { i + 1 },
388
+ # str: -> i { "num #{i}" }
389
+ # }
390
+ #
391
+ # t(:eval_values, 1)[hash]
392
+ # # => {:num => 2, :str => "num 1" }
393
+ #
394
+ # # with filters
395
+ # t(:eval_values, 1, [:str])[hash]
396
+ # # => {:num => #{still a proc}, :str => "num 1" }
397
+ #
398
+ # @param [Hash]
399
+ # @param [Array,Object] args Anything that should be passed to procs
400
+ # @param [Array] filters A list of attribute names that should be evaluated
401
+ #
402
+ # @api public
403
+ def self.eval_values(hash, args, filters = [])
404
+ hash.each_with_object({}) do |(key, value), output|
405
+ output[key] =
406
+ case value
407
+ when Proc
408
+ if filters.empty? || filters.include?(key)
409
+ value.call(*args)
410
+ else
411
+ value
412
+ end
413
+ when Hash
414
+ eval_values(value, args, filters)
415
+ when Array
416
+ value.map { |item|
417
+ item.is_a?(Hash) ? eval_values(item, args, filters) : item
418
+ }
419
+ else
420
+ value
421
+ end
422
+ end
423
+ end
424
+
425
+ # Merge a hash recursively
426
+ #
427
+ # @example
428
+ #
429
+ # input = { 'foo' => 'bar', 'baz' => { 'one' => 1 } }
430
+ # other = { 'foo' => 'buz', 'baz' => { :one => 'one', :two => 2 } }
431
+ #
432
+ # t(:deep_merge)[input, other]
433
+ # # => { 'foo' => "buz", :baz => { :one => 'one', 'one' => 1, :two => 2 } }
434
+ #
435
+ # @param [Hash]
436
+ # @param [Hash]
437
+ #
438
+ # @return [Hash]
439
+ #
440
+ # @api public
441
+ def self.deep_merge(hash, other)
442
+ Hash[hash].merge(other) do |_, original_value, new_value|
443
+ if original_value.respond_to?(:to_hash) &&
444
+ new_value.respond_to?(:to_hash)
445
+ deep_merge(Hash[original_value], Hash[new_value])
446
+ else
447
+ new_value
448
+ end
449
+ end
450
+ end
451
+ end
452
+ end
453
+ end
data/lib/dry/transformer/pipe.rb ADDED
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/transformer/pipe/class_interface'
4
+
5
+ module Dry
6
+ module Transformer
7
+ # Pipe class for defining transprocs with a class DSL.
8
+ #
9
+ # @example
10
+ # require 'anima'
11
+ # require 'dry/transformer/all'
12
+ #
13
+ # class User
14
+ # include Anima.new(:name, :address)
15
+ # end
16
+ #
17
+ # class Address
18
+ # include Anima.new(:city, :street, :zipcode)
19
+ # end
20
+ #
21
+ # class UsersMapper < Dry::Transformer::Pipe
22
+ # map_array do
23
+ # symbolize_keys
24
+ # rename_keys user_name: :name
25
+ # nest :address, %i(city street zipcode)
26
+ # map_value :address do
27
+ # constructor_inject Address
28
+ # end
29
+ # constructor_inject User
30
+ # end
31
+ # end
32
+ #
33
+ # UsersMapper.new.call(
34
+ # [
35
+ # { 'user_name' => 'Jane',
36
+ # 'city' => 'NYC',
37
+ # 'street' => 'Street 1',
38
+ # 'zipcode' => '123'
39
+ # }
40
+ # ]
41
+ # )
42
+ # # => [
43
+ # #<User
44
+ # name="Jane"
45
+ # address=#<Address city="NYC" street="Street 1" zipcode="123">>
46
+ # ]
47
+ #
48
+ # @api public
49
+ class Pipe
50
+ extend ClassInterface
51
+
52
+ attr_reader :transproc
53
+
54
+ # Execute the transformation pipeline with the given input.
55
+ #
56
+ # @example
57
+ #
58
+ # class SymbolizeKeys < Dry::Transformer
59
+ # symbolize_keys
60
+ # end
61
+ #
62
+ # SymbolizeKeys.new.call('name' => 'Jane')
63
+ # # => {:name=>"Jane"}
64
+ #
65
+ # @param [mixed] input The input to pass to the pipeline
66
+ #
67
+ # @return [mixed] output The output returned from the pipeline
68
+ #
69
+ # @api public
70
+ def call(input)
71
+ transproc.call(input)
72
+ end
73
+ end
74
+ end
75
+ end
data/lib/dry/transformer/pipe/class_interface.rb ADDED
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/transformer/pipe/dsl'
4
+
5
+ module Dry
6
+ module Transformer
7
+ class Pipe
8
+ # @api public
9
+ module ClassInterface
10
+ # @api private
11
+ attr_reader :dsl
12
+
13
+ # Return a base Dry::Transformer class with the
14
+ # container configured to the passed argument.
15
+ #
16
+ # @example
17
+ #
18
+ # class MyTransformer < Dry::Transformer[Transproc]
19
+ # end
20
+ #
21
+ # @param [Transproc::Registry] container
22
+ # The container to resolve transprocs from
23
+ #
24
+ # @return [subclass of Dry::Transformer]
25
+ #
26
+ # @api public
27
+ def [](container)
28
+ klass = Class.new(self)
29
+ klass.container(container)
30
+ klass
31
+ end
32
+
33
+ # @api private
34
+ def inherited(subclass)
35
+ super
36
+
37
+ subclass.container(@container) if defined?(@container)
38
+
39
+ subclass.instance_variable_set('@dsl', dsl.dup) if dsl
40
+ end
41
+
42
+ # Get or set the container to resolve transprocs from.
43
+ #
44
+ # @example
45
+ #
46
+ # # Setter
47
+ # Dry::Transformer.container(Transproc)
48
+ # # => Transproc
49
+ #
50
+ # # Getter
51
+ # Dry::Transformer.container
52
+ # # => Transproc
53
+ #
54
+ # @param [Transproc::Registry] container
55
+ # The container to resolve transprocs from
56
+ #
57
+ # @return [Transproc::Registry]
58
+ #
59
+ # @api private
60
+ def container(container = Undefined)
61
+ if container.equal?(Undefined)
62
+ @container ||= Module.new.extend(Dry::Transformer::Registry)
63
+ else
64
+ @container = container
65
+ end
66
+ end
67
+
68
+ # @api public
69
+ def import(*args)
70
+ container.import(*args)
71
+ end
72
+
73
+ # @api public
74
+ def define!(&block)
75
+ @dsl ||= DSL.new(container)
76
+ @dsl.instance_eval(&block)
77
+ self
78
+ end
79
+
80
+ # @api public
81
+ def new(*)
82
+ super.tap do |transformer|
83
+ transformer.instance_variable_set('@transproc', dsl.(transformer)) if dsl
84
+ end
85
+ end
86
+ ruby2_keywords(:new) if respond_to?(:ruby2_keywords, true)
87
+
88
+ # Get a transformation from the container,
89
+ # without adding it to the transformation pipeline
90
+ #
91
+ # @example
92
+ #
93
+ # class Stringify < Dry::Transformer
94
+ # map_values t(:to_string)
95
+ # end
96
+ #
97
+ # Stringify.new.call(a: 1, b: 2)
98
+ # # => {a: '1', b: '2'}
99
+ #
100
+ # @param [Proc, Symbol] fn
101
+ # A proc, a name of the module's own function, or a name of imported
102
+ # procedure from another module
103
+ # @param [Object, Array] args
104
+ # Args to be carried by the transproc
105
+ #
106
+ # @return [Transproc::Function]
107
+ #
108
+ # @api public
109
+ def t(fn, *args)
110
+ container[fn, *args]
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
data/lib/dry/transformer/pipe/dsl.rb ADDED
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/transformer/compiler'
4
+
5
+ module Dry
6
+ module Transformer
7
+ class Pipe
8
+ # @api public
9
+ class DSL
10
+ # @api private
11
+ attr_reader :container
12
+
13
+ # @api private
14
+ attr_reader :ast
15
+
16
+ # @api private
17
+ def initialize(container, ast: [], &block)
18
+ @container = container
19
+ @ast = ast
20
+ instance_eval(&block) if block
21
+ end
22
+
23
+ # @api public
24
+ def t(name, *args)
25
+ container[name, *args]
26
+ end
27
+
28
+ # @api private
29
+ def dup
30
+ self.class.new(container, ast: ast.dup)
31
+ end
32
+
33
+ # @api private
34
+ def call(transformer)
35
+ Compiler.new(container, transformer).(ast)
36
+ end
37
+
38
+ private
39
+
40
+ # @api private
41
+ def node(&block)
42
+ [:t, self.class.new(container, &block).ast]
43
+ end
44
+
45
+ # @api private
46
+ def respond_to_missing?(method, _include_private = false)
47
+ super || container.contain?(method)
48
+ end
49
+
50
+ # @api private
51
+ def method_missing(meth, *args, &block)
52
+ arg_nodes = *args.map { |a| [:arg, a] }
53
+ ast << [:fn, (block ? [meth, [*arg_nodes, node(&block)]] : [meth, arg_nodes])]
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
data/lib/dry/transformer/proc.rb ADDED
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dry
4
+ module Transformer
5
+ # Transformation functions for Procs
6
+ #
7
+ # @example
8
+ # require 'ostruct'
9
+ # require 'dry/transformer/proc'
10
+ #
11
+ # include Dry::Transformer::Helper
12
+ #
13
+ # fn = t(
14
+ # :map_value,
15
+ # 'foo_bar',
16
+ # t(:bind, OpenStruct.new(prefix: 'foo'), -> s { [prefix, s].join('_') })
17
+ # )
18
+ #
19
+ # fn["foo_bar" => "bar"]
20
+ # # => {"foo_bar" => "foo_bar"}
21
+ #
22
+ # @api public
23
+ module ProcTransformations
24
+ extend Registry
25
+
26
+ # Change the binding for the given function
27
+ #
28
+ # @example
29
+ # Dry::Transformer(
30
+ # :bind,
31
+ # OpenStruct.new(prefix: 'foo'),
32
+ # -> s { [prefix, s].join('_') }
33
+ # )['bar']
34
+ # # => "foo_bar"
35
+ #
36
+ # @param [Proc]
37
+ #
38
+ # @return [Proc]
39
+ #
40
+ # @api public
41
+ def self.bind(value, binding, fn)
42
+ binding.instance_exec(value, &fn)
43
+ end
44
+ end
45
+ end
46
+ end
data/lib/dry/transformer/recursion.rb ADDED
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/transformer/conditional'
4
+
5
+ module Dry
6
+ module Transformer
7
+ # Recursive transformation functions
8
+ #
9
+ # @example
10
+ # require 'dry/transformer/recursion'
11
+ #
12
+ # include Dry::Transformer::Helper
13
+ #
14
+ # fn = t(:hash_recursion, t(:symbolize_keys))
15
+ #
16
+ # fn["name" => "Jane", "address" => { "street" => "Street 1" }]
17
+ # # => {:name=>"Jane", :address=>{:street=>"Street 1"}}
18
+ #
19
+ # @api public
20
+ module Recursion
21
+ extend Registry
22
+
23
+ IF_ENUMERABLE = -> fn { Conditional[:is, Enumerable, fn] }
24
+
25
+ IF_ARRAY = -> fn { Conditional[:is, Array, fn] }
26
+
27
+ IF_HASH = -> fn { Conditional[:is, Hash, fn] }
28
+
29
+ # Recursively apply the provided transformation function to an enumerable
30
+ #
31
+ # @example
32
+ # Dry::Transformer(:recursion, Dry::Transformer(:is, ::Hash, Dry::Transformer(:symbolize_keys)))[
33
+ # {
34
+ # 'id' => 1,
35
+ # 'name' => 'Jane',
36
+ # 'tasks' => [
37
+ # { 'id' => 1, 'description' => 'Write some code' },
38
+ # { 'id' => 2, 'description' => 'Write some more code' }
39
+ # ]
40
+ # }
41
+ # ]
42
+ # => {
43
+ # :id=>1,
44
+ # :name=>"Jane",
45
+ # :tasks=>[
46
+ # {:id=>1, :description=>"Write some code"},
47
+ # {:id=>2, :description=>"Write some more code"}
48
+ # ]
49
+ # }
50
+ #
51
+ # @param [Enumerable]
52
+ #
53
+ # @return [Enumerable]
54
+ #
55
+ # @api public
56
+ def self.recursion(value, fn)
57
+ result = fn[value]
58
+ guarded = IF_ENUMERABLE[-> v { recursion(v, fn) }]
59
+
60
+ case result
61
+ when ::Hash
62
+ result.keys.each do |key|
63
+ result[key] = guarded[result.delete(key)]
64
+ end
65
+ when ::Array
66
+ result.map! do |item|
67
+ guarded[item]
68
+ end
69
+ end
70
+
71
+ result
72
+ end
73
+
74
+ # Recursively apply the provided transformation function to an array
75
+ #
76
+ # @example
77
+ # Dry::Transformer(:array_recursion, -> s { s.compact })[
78
+ # [['Joe', 'Jane', nil], ['Smith', 'Doe', nil]]
79
+ # ]
80
+ # # => [["Joe", "Jane"], ["Smith", "Doe"]]
81
+ #
82
+ # @param [Array]
83
+ #
84
+ # @return [Array]
85
+ #
86
+ # @api public
87
+ def self.array_recursion(value, fn)
88
+ result = fn[value]
89
+ guarded = IF_ARRAY[-> v { array_recursion(v, fn) }]
90
+
91
+ result.map! do |item|
92
+ guarded[item]
93
+ end
94
+ end
95
+
96
+ # Recursively apply the provided transformation function to a hash
97
+ #
98
+ # @example
99
+ # Dry::Transformer(:hash_recursion, Dry::Transformer(:symbolize_keys))[
100
+ # ["name" => "Jane", "address" => { "street" => "Street 1", "zipcode" => "123" }]
101
+ # ]
102
+ # # => {:name=>"Jane", :address=>{:street=>"Street 1", :zipcode=>"123"}}
103
+ #
104
+ # @param [Hash]
105
+ #
106
+ # @return [Hash]
107
+ #
108
+ # @api public
109
+ def self.hash_recursion(value, fn)
110
+ result = fn[value]
111
+ guarded = IF_HASH[-> v { hash_recursion(v, fn) }]
112
+
113
+ result.keys.each do |key|
114
+ result[key] = guarded[result.delete(key)]
115
+ end
116
+
117
+ result
118
+ end
119
+ end
120
+ end
121
+ end
data/lib/dry/transformer/registry.rb ADDED
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dry
4
+ module Transformer
5
+ # Container to define transproc functions in, and access them via `[]` method
6
+ # from the outside of the module
7
+ #
8
+ # @example
9
+ # module FooMethods
10
+ # extend Dry::Transformer::Registry
11
+ #
12
+ # def self.foo(name, prefix)
13
+ # [prefix, '_', name].join
14
+ # end
15
+ # end
16
+ #
17
+ # fn = FooMethods[:foo, 'baz']
18
+ # fn['qux'] # => 'qux_baz'
19
+ #
20
+ # module BarMethods
21
+ # extend FooMethods
22
+ #
23
+ # def self.bar(*args)
24
+ # foo(*args).upcase
25
+ # end
26
+ # end
27
+ #
28
+ # fn = BarMethods[:foo, 'baz']
29
+ # fn['qux'] # => 'qux_baz'
30
+ #
31
+ # fn = BarMethods[:bar, 'baz']
32
+ # fn['qux'] # => 'QUX_BAZ'
33
+ #
34
+ # @api public
35
+ module Registry
36
+ # Builds the transformation
37
+ #
38
+ # @param [Proc, Symbol] fn
39
+ # A proc, a name of the module's own function, or a name of imported
40
+ # procedure from another module
41
+ # @param [Object, Array] args
42
+ # Args to be carried by the transproc
43
+ #
44
+ # @return [Dry::Transformer::Function]
45
+ #
46
+ # @alias :t
47
+ #
48
+ def [](fn, *args)
49
+ fetched = fetch(fn)
50
+
51
+ return Function.new(fetched, args: args, name: fn) unless already_wrapped?(fetched)
52
+
53
+ args.empty? ? fetched : fetched.with(*args)
54
+ end
55
+ alias_method :t, :[]
56
+
57
+ # Returns wether the registry contains such transformation by its key
58
+ #
59
+ # @param [Symbol] key
60
+ #
61
+ # @return [Boolean]
62
+ #
63
+ def contain?(key)
64
+ respond_to?(key) || store.contain?(key)
65
+ end
66
+
67
+ # Register a new function
68
+ #
69
+ # @example
70
+ # store.register(:to_json, -> v { v.to_json })
71
+
72
+ # store.register(:to_json) { |v| v.to_json }
73
+ #
74
+ def register(name, fn = nil, &block)
75
+ if contain?(name)
76
+ raise FunctionAlreadyRegisteredError, "Function #{name} is already defined"
77
+ end
78
+
79
+ @store = store.register(name, fn, &block)
80
+ self
81
+ end
82
+
83
+ # Imports either a method (converted to a proc) from another module, or
84
+ # all methods from that module.
85
+ #
86
+ # If the external module is a registry, looks for its imports too.
87
+ #
88
+ # @overload import(source)
89
+ # Loads all methods from the source object
90
+ #
91
+ # @param [Object] source
92
+ #
93
+ # @overload import(*names, **options)
94
+ # Loads selected methods from the source object
95
+ #
96
+ # @param [Array<Symbol>] names
97
+ # @param [Hash] options
98
+ # @options options [Object] :from The source object
99
+ #
100
+ # @overload import(name, **options)
101
+ # Loads selected methods from the source object
102
+ #
103
+ # @param [Symbol] name
104
+ # @param [Hash] options
105
+ # @options options [Object] :from The source object
106
+ # @options options [Object] :as The new name for the transformation
107
+ #
108
+ # @return [itself] self
109
+ #
110
+ # @alias :import
111
+ #
112
+ def import(*args)
113
+ @store = store.import(*args)
114
+ self
115
+ end
116
+ alias_method :uses, :import
117
+
118
+ # The store of procedures imported from external modules
119
+ #
120
+ # @return [Dry::Transformer::Store]
121
+ #
122
+ def store
123
+ @store ||= Store.new
124
+ end
125
+
126
+ # Gets the procedure for creating a transproc
127
+ #
128
+ # @param [#call, Symbol] fn
129
+ # Either the procedure, or the name of the method of the current module,
130
+ # or the registered key of imported procedure in a store.
131
+ #
132
+ # @return [#call]
133
+ #
134
+ def fetch(fn)
135
+ return fn unless fn.instance_of? Symbol
136
+
137
+ respond_to?(fn) ? method(fn) : store.fetch(fn)
138
+ rescue StandardError
139
+ raise FunctionNotFoundError.new(fn, self)
140
+ end
141
+
142
+ private
143
+
144
+ # @api private
145
+ def already_wrapped?(func)
146
+ func.is_a?(Dry::Transformer::Function) || func.is_a?(Dry::Transformer::Composite)
147
+ end
148
+ end
149
+ end
150
+ end
data/lib/dry/transformer/store.rb ADDED
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dry
4
+ module Transformer
5
+ # Immutable collection of named procedures from external modules
6
+ #
7
+ # @api private
8
+ #
9
+ class Store
10
+ # @!attribute [r] methods
11
+ #
12
+ # @return [Hash] The associated list of imported procedures
13
+ #
14
+ attr_reader :methods
15
+
16
+ # @!scope class
17
+ # @!name new(methods = {})
18
+ # Creates an immutable store with a hash of procedures
19
+ #
20
+ # @param [Hash] methods
21
+ #
22
+ # @return [Dry::Transformer::Store]
23
+
24
+ # @private
25
+ def initialize(methods = {})
26
+ @methods = methods.dup.freeze
27
+ freeze
28
+ end
29
+
30
+ # Returns a procedure by its key in the collection
31
+ #
32
+ # @param [Symbol] key
33
+ #
34
+ # @return [Proc]
35
+ #
36
+ def fetch(key)
37
+ methods.fetch(key)
38
+ end
39
+
40
+ # Returns wether the collection contains such procedure by its key
41
+ #
42
+ # @param [Symbol] key
43
+ #
44
+ # @return [Boolean]
45
+ #
46
+ def contain?(key)
47
+ methods.key?(key)
48
+ end
49
+
50
+ # Register a new function
51
+ #
52
+ # @example
53
+ # store.register(:to_json, -> v { v.to_json })
54
+
55
+ # store.register(:to_json) { |v| v.to_json }
56
+ #
57
+ def register(name, fn = nil, &block)
58
+ self.class.new(methods.merge(name => fn || block))
59
+ end
60
+
61
+ # Imports proc(s) to the collection from another module
62
+ #
63
+ # @private
64
+ #
65
+ def import(*args)
66
+ first = args.first
67
+ return import_all(first) if first.instance_of?(Module)
68
+
69
+ opts = args.pop
70
+ source = opts.fetch(:from)
71
+ rename = opts.fetch(:as) { first.to_sym }
72
+
73
+ return import_methods(source, args) if args.count > 1
74
+
75
+ import_method(source, first, rename)
76
+ end
77
+
78
+ protected
79
+
80
+ # Creates new immutable collection from the current one,
81
+ # updated with either the module's singleton method,
82
+ # or the proc having been imported from another module.
83
+ #
84
+ # @param [Module] source
85
+ # @param [Symbol] name
86
+ # @param [Symbol] new_name
87
+ #
88
+ # @return [Dry::Transformer::Store]
89
+ #
90
+ def import_method(source, name, new_name = name)
91
+ from = name.to_sym
92
+ to = new_name.to_sym
93
+
94
+ fn = source.is_a?(Registry) ? source.fetch(from) : source.method(from)
95
+ self.class.new(methods.merge(to => fn))
96
+ end
97
+
98
+ # Creates new immutable collection from the current one,
99
+ # updated with either the module's singleton methods,
100
+ # or the procs having been imported from another module.
101
+ #
102
+ # @param [Module] source
103
+ # @param [Array<Symbol>] names
104
+ #
105
+ # @return [Dry::Transformer::Store]
106
+ #
107
+ def import_methods(source, names)
108
+ names.inject(self) { |a, e| a.import_method(source, e) }
109
+ end
110
+
111
+ # Creates new immutable collection from the current one,
112
+ # updated with all singleton methods and imported methods
113
+ # from the other module
114
+ #
115
+ # @param [Module] source The module to import procedures from
116
+ #
117
+ # @return [Dry::Transformer::Store]
118
+ #
119
+ def import_all(source)
120
+ names = source.public_methods - Registry.instance_methods - Module.methods
121
+ names -= [:initialize] # for compatibility with Rubinius
122
+ names += source.store.methods.keys if source.is_a? Registry
123
+
124
+ import_methods(source, names)
125
+ end
126
+ end
127
+ end
128
+ end
data/lib/dry/transformer/version.rb ADDED
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dry
4
+ module Transformer
5
+ VERSION = '0.1.0'
6
+ end
7
+ end
data/spec/spec_helper.rb ADDED
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ if ENV['COVERAGE'] == 'true'
4
+ require 'codacy-coverage'
5
+ Codacy::Reporter.start
6
+ end
7
+
8
+ begin
9
+ require 'byebug'
10
+ rescue LoadError;end
11
+
12
+ require 'dry/transformer/all'
13
+
14
+ root = Pathname(__FILE__).dirname
15
+ Dir[root.join('support/*.rb').to_s].each { |f| require f }
16
+
17
+ # Namespace holding all objects created during specs
18
+ module Test
19
+ def self.remove_constants
20
+ constants.each(&method(:remove_const))
21
+ end
22
+ end
23
+
24
+ RSpec.configure do |config|
25
+ config.after do
26
+ Test.remove_constants
27
+ end
28
+
29
+ config.disable_monkey_patching!
30
+ config.warnings = true
31
+ end
data/spec/unit/array/combine_spec.rb ADDED
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'forwardable'
4
+
5
+ RSpec.describe Dry::Transformer::ArrayTransformations do
6
+ describe '.combine' do
7
+ subject(:result) { described_class.t(:combine, mappings)[input] }
8
+
9
+ let(:input) { [[]] }