philiprehberger-data_mapper 0.2.1 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +20 -5
- data/README.md +88 -8
- data/lib/philiprehberger/data_mapper/computed_definition.rb +18 -0
- data/lib/philiprehberger/data_mapper/field_definition.rb +25 -5
- data/lib/philiprehberger/data_mapper/mapping.rb +50 -28
- data/lib/philiprehberger/data_mapper/mapping_result.rb +18 -0
- data/lib/philiprehberger/data_mapper/parsable.rb +36 -0
- data/lib/philiprehberger/data_mapper/reversible.rb +21 -0
- data/lib/philiprehberger/data_mapper/version.rb +1 -1
- data/lib/philiprehberger/data_mapper.rb +2 -0
- metadata +8 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ee1643e7d20a55c270909711e1427d32e54d9bff3baa3405327a46d415432543
|
|
4
|
+
data.tar.gz: 4549b18b857c1617ea1f93f42dbb5635723e9c27d54c43d24efe01aa3d0eb04e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6df2ccd74efe7f240f1dee1e0b4b773814a0248c77d1d7836f048fb04a864eaec9c85fd3878afe8c1c468d38ef748d1df03189f67823c9eff7a1dd5fd12b8263
|
|
7
|
+
data.tar.gz: 01c75fccffabb8702ed3dc2a6ccc7793067c720ba267adc817068bce34fe5d4dc14ea54f593b50aeaf90b160e555f12f9f081921d1210a24d9a979b05e8b30fc
|
data/CHANGELOG.md
CHANGED
|
@@ -1,16 +1,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.2.1
|
|
4
|
-
|
|
5
|
-
- Add License badge to README
|
|
6
|
-
- Add bug_tracker_uri to gemspec
|
|
7
|
-
|
|
8
3
|
All notable changes to this gem will be documented in this file.
|
|
9
4
|
|
|
10
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
11
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
12
7
|
|
|
13
8
|
## [Unreleased]
|
|
9
|
+
n## [0.3.1] - 2026-03-22
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
- Update rubocop configuration for Windows compatibility
|
|
13
|
+
|
|
14
|
+
## [0.3.0] - 2026-03-17
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
- Conditional mapping with `if:` parameter to include fields only when a condition is met
|
|
18
|
+
- Computed fields via `computed` DSL method for deriving values from the full record
|
|
19
|
+
- Collection mapping with `array_field` DSL method to split string values into arrays
|
|
20
|
+
- Reverse mapping via `reverse` method to transform output back to input schema
|
|
21
|
+
- Validation support with `validate:` parameter and `map_with_validation` method
|
|
22
|
+
- `MappingResult` class wrapping mapped value and collected validation errors
|
|
23
|
+
|
|
24
|
+
## [0.2.1] - 2026-03-16
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
- Add License badge to README
|
|
28
|
+
- Add bug_tracker_uri to gemspec
|
|
14
29
|
|
|
15
30
|
## [0.2.0] - 2026-03-12
|
|
16
31
|
|
data/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
[](https://rubygems.org/gems/philiprehberger-data_mapper)
|
|
5
5
|
[](LICENSE)
|
|
6
6
|
|
|
7
|
-
Data transformation DSL for mapping hashes and CSV rows in Ruby
|
|
7
|
+
Data transformation DSL for mapping hashes and CSV rows in Ruby
|
|
8
8
|
|
|
9
9
|
## Requirements
|
|
10
10
|
|
|
@@ -18,12 +18,6 @@ Add to your Gemfile:
|
|
|
18
18
|
gem "philiprehberger-data_mapper"
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
Then run:
|
|
22
|
-
|
|
23
|
-
```bash
|
|
24
|
-
bundle install
|
|
25
|
-
```
|
|
26
|
-
|
|
27
21
|
Or install directly:
|
|
28
22
|
|
|
29
23
|
```bash
|
|
@@ -114,14 +108,100 @@ mapping.map(input)
|
|
|
114
108
|
|
|
115
109
|
Supported types: `:string`, `:integer`, `:float`, `:boolean`.
|
|
116
110
|
|
|
111
|
+
### Conditional mapping
|
|
112
|
+
|
|
113
|
+
Include a field only when a condition is met using the `if:` parameter:
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
mapping = Philiprehberger::DataMapper.define do
|
|
117
|
+
field :name
|
|
118
|
+
field :role, from: :raw_role, if: ->(record) { record[:admin] }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
mapping.map({ name: "Alice", raw_role: "superuser", admin: true })
|
|
122
|
+
# => { name: "Alice", role: "superuser" }
|
|
123
|
+
|
|
124
|
+
mapping.map({ name: "Bob", raw_role: "superuser", admin: false })
|
|
125
|
+
# => { name: "Bob" }
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### Computed fields
|
|
129
|
+
|
|
130
|
+
Derive fields from the full source record using `computed`:
|
|
131
|
+
|
|
132
|
+
```ruby
|
|
133
|
+
mapping = Philiprehberger::DataMapper.define do
|
|
134
|
+
field :email
|
|
135
|
+
computed(:full_name) { |record| "#{record[:first]} #{record[:last]}" }
|
|
136
|
+
computed(:initials) { |record| "#{record[:first][0]}#{record[:last][0]}" }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
mapping.map({ email: "a@b.com", first: "Alice", last: "Smith" })
|
|
140
|
+
# => { email: "a@b.com", full_name: "Alice Smith", initials: "AS" }
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### Collection mapping
|
|
144
|
+
|
|
145
|
+
Split a single string value into an array using `array_field`:
|
|
146
|
+
|
|
147
|
+
```ruby
|
|
148
|
+
mapping = Philiprehberger::DataMapper.define do
|
|
149
|
+
array_field :tags, from: :tag_csv, split: ","
|
|
150
|
+
array_field :items, from: :item_str, split: "|"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
mapping.map({ tag_csv: "ruby,rails,gem", item_str: "one|two|three" })
|
|
154
|
+
# => { tags: ["ruby", "rails", "gem"], items: ["one", "two", "three"] }
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### Reverse mapping
|
|
158
|
+
|
|
159
|
+
Transform output back to the input schema using inverse field mappings:
|
|
160
|
+
|
|
161
|
+
```ruby
|
|
162
|
+
mapping = Philiprehberger::DataMapper.define do
|
|
163
|
+
field :full_name, from: :name
|
|
164
|
+
field :years, from: :age
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
output = { full_name: "Alice", years: 30 }
|
|
168
|
+
mapping.reverse(output)
|
|
169
|
+
# => { name: "Alice", age: 30 }
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### Validation
|
|
173
|
+
|
|
174
|
+
Validate mapped values using the `validate:` parameter. Use `map_with_validation` to collect errors:
|
|
175
|
+
|
|
176
|
+
```ruby
|
|
177
|
+
mapping = Philiprehberger::DataMapper.define do
|
|
178
|
+
field :age, from: :raw_age, type: :integer, validate: ->(v) { v > 0 }
|
|
179
|
+
field :name, validate: ->(v) { !v.nil? && !v.empty? }
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
result = mapping.map_with_validation({ raw_age: "25", name: "Alice" })
|
|
183
|
+
result.valid? # => true
|
|
184
|
+
result.value # => { age: 25, name: "Alice" }
|
|
185
|
+
result.errors # => []
|
|
186
|
+
|
|
187
|
+
result = mapping.map_with_validation({ raw_age: "-1", name: "" })
|
|
188
|
+
result.valid? # => false
|
|
189
|
+
result.value # => { age: -1, name: "" }
|
|
190
|
+
result.errors # => [{ field: :age, value: -1 }, { field: :name, value: "" }]
|
|
191
|
+
```
|
|
192
|
+
|
|
117
193
|
## API
|
|
118
194
|
|
|
119
195
|
| Method | Description |
|
|
120
196
|
|--------|-------------|
|
|
121
197
|
| `DataMapper.define(&block)` | Create a new mapping with the DSL |
|
|
122
|
-
| `Mapping#field(target, from:, default:, type:, &transform)` | Define a field mapping |
|
|
198
|
+
| `Mapping#field(target, from:, default:, type:, if:, validate:, split:, &transform)` | Define a field mapping |
|
|
199
|
+
| `Mapping#computed(target, &block)` | Define a computed field derived from the full record |
|
|
200
|
+
| `Mapping#array_field(target, from:, split:, &transform)` | Define a field that splits a string into an array |
|
|
123
201
|
| `Mapping#map(hash)` | Apply mapping to a single hash |
|
|
202
|
+
| `Mapping#map_with_validation(hash)` | Apply mapping and return a `MappingResult` with errors |
|
|
124
203
|
| `Mapping#map_all(array)` | Apply mapping to an array of hashes |
|
|
204
|
+
| `Mapping#reverse(hash)` | Transform output hash back to input schema |
|
|
125
205
|
| `Mapping#from_csv(string, headers: true)` | Parse CSV and map each row |
|
|
126
206
|
| `Mapping#from_json(json_string)` | Parse JSON string and map the result |
|
|
127
207
|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Philiprehberger
|
|
4
|
+
module DataMapper
|
|
5
|
+
class ComputedDefinition
|
|
6
|
+
attr_reader :target
|
|
7
|
+
|
|
8
|
+
def initialize(target, &block)
|
|
9
|
+
@target = target
|
|
10
|
+
@block = block
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def compute(record)
|
|
14
|
+
@block.call(record)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -3,26 +3,46 @@
|
|
|
3
3
|
module Philiprehberger
|
|
4
4
|
module DataMapper
|
|
5
5
|
class FieldDefinition
|
|
6
|
-
attr_reader :target, :source, :default, :type
|
|
6
|
+
attr_reader :target, :source, :default, :type, :condition, :validator, :split_delimiter
|
|
7
7
|
|
|
8
8
|
BOOLEAN_TRUE_VALUES = %w[true 1 yes].freeze
|
|
9
9
|
BOOLEAN_FALSE_VALUES = %w[false 0 no].freeze
|
|
10
10
|
|
|
11
|
-
def initialize(target,
|
|
11
|
+
def initialize(target, **opts, &transform)
|
|
12
12
|
@target = target
|
|
13
|
-
@source = from || target
|
|
14
|
-
@default = default
|
|
15
|
-
@type = type
|
|
13
|
+
@source = opts[:from] || target
|
|
14
|
+
@default = opts[:default]
|
|
15
|
+
@type = opts[:type]
|
|
16
|
+
@condition = opts[:if]
|
|
17
|
+
@validator = opts[:validate]
|
|
18
|
+
@split_delimiter = opts[:split]
|
|
16
19
|
@transform = transform
|
|
17
20
|
end
|
|
18
21
|
|
|
19
22
|
def apply(value)
|
|
20
23
|
value = @default if value.nil?
|
|
24
|
+
value = value.to_s.split(@split_delimiter) if @split_delimiter && value.is_a?(String)
|
|
21
25
|
value = @transform.call(value) if @transform
|
|
22
26
|
value = coerce(value) if @type
|
|
23
27
|
value
|
|
24
28
|
end
|
|
25
29
|
|
|
30
|
+
def conditional?
|
|
31
|
+
!@condition.nil?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def include?(record)
|
|
35
|
+
return true unless conditional?
|
|
36
|
+
|
|
37
|
+
@condition.call(record)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def valid?(value)
|
|
41
|
+
return true unless @validator
|
|
42
|
+
|
|
43
|
+
@validator.call(value)
|
|
44
|
+
end
|
|
45
|
+
|
|
26
46
|
private
|
|
27
47
|
|
|
28
48
|
def coerce(value)
|
|
@@ -1,46 +1,78 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
require_relative "parsable"
|
|
4
|
+
require_relative "reversible"
|
|
5
5
|
|
|
6
6
|
module Philiprehberger
|
|
7
7
|
module DataMapper
|
|
8
8
|
class Mapping
|
|
9
|
+
include Parsable
|
|
10
|
+
include Reversible
|
|
11
|
+
|
|
9
12
|
def initialize(&block)
|
|
10
13
|
@fields = []
|
|
14
|
+
@computed_fields = []
|
|
11
15
|
instance_eval(&block) if block
|
|
12
16
|
end
|
|
13
17
|
|
|
14
|
-
def field(target,
|
|
15
|
-
@fields << FieldDefinition.new(target,
|
|
18
|
+
def field(target, ...)
|
|
19
|
+
@fields << FieldDefinition.new(target, ...)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def computed(target, &)
|
|
23
|
+
@computed_fields << ComputedDefinition.new(target, &)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def array_field(target, from: nil, split: ",", &transform)
|
|
27
|
+
field(target, from: from, split: split, &transform)
|
|
16
28
|
end
|
|
17
29
|
|
|
18
30
|
def map(hash)
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
31
|
+
result = map_fields(hash)
|
|
32
|
+
apply_computed(hash, result)
|
|
33
|
+
result
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def map_with_validation(hash)
|
|
37
|
+
result = {}
|
|
38
|
+
errors = collect_errors(hash, result)
|
|
39
|
+
apply_computed(hash, result)
|
|
40
|
+
MappingResult.new(result, errors)
|
|
23
41
|
end
|
|
24
42
|
|
|
25
43
|
def map_all(array)
|
|
26
44
|
array.map { |hash| map(hash) }
|
|
27
45
|
end
|
|
28
46
|
|
|
29
|
-
|
|
30
|
-
rows = CSV.parse(csv_string, headers: headers)
|
|
31
|
-
rows.map { |row| map(row_to_hash(row)) }
|
|
32
|
-
end
|
|
47
|
+
private
|
|
33
48
|
|
|
34
|
-
def
|
|
35
|
-
|
|
49
|
+
def map_fields(hash)
|
|
50
|
+
applicable_fields(hash).each_with_object({}) do |f, result|
|
|
51
|
+
value = dig_value(hash, f.source)
|
|
52
|
+
result[f.target] = f.apply(value)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
36
55
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
56
|
+
def collect_errors(hash, result)
|
|
57
|
+
errors = []
|
|
58
|
+
applicable_fields(hash).each do |f|
|
|
59
|
+
value = dig_value(hash, f.source)
|
|
60
|
+
mapped = f.apply(value)
|
|
61
|
+
errors << { field: f.target, value: mapped } unless f.valid?(mapped)
|
|
62
|
+
result[f.target] = mapped
|
|
40
63
|
end
|
|
64
|
+
errors
|
|
41
65
|
end
|
|
42
66
|
|
|
43
|
-
|
|
67
|
+
def applicable_fields(hash)
|
|
68
|
+
@fields.select { |f| f.include?(hash) }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def apply_computed(hash, result)
|
|
72
|
+
@computed_fields.each do |c|
|
|
73
|
+
result[c.target] = c.compute(hash)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
44
76
|
|
|
45
77
|
def dig_value(hash, source)
|
|
46
78
|
key_str = source.to_s
|
|
@@ -58,16 +90,6 @@ module Philiprehberger
|
|
|
58
90
|
current[key.to_sym] || current[key]
|
|
59
91
|
end
|
|
60
92
|
end
|
|
61
|
-
|
|
62
|
-
def symbolize_keys(hash)
|
|
63
|
-
hash.each_with_object({}) do |(key, value), result|
|
|
64
|
-
result[key.to_sym] = value.is_a?(Hash) ? symbolize_keys(value) : value
|
|
65
|
-
end
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
def row_to_hash(row)
|
|
69
|
-
row.to_h.transform_keys(&:to_sym)
|
|
70
|
-
end
|
|
71
93
|
end
|
|
72
94
|
end
|
|
73
95
|
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Philiprehberger
|
|
4
|
+
module DataMapper
|
|
5
|
+
class MappingResult
|
|
6
|
+
attr_reader :value, :errors
|
|
7
|
+
|
|
8
|
+
def initialize(value, errors = [])
|
|
9
|
+
@value = value
|
|
10
|
+
@errors = errors
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def valid?
|
|
14
|
+
@errors.empty?
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "csv"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Philiprehberger
|
|
7
|
+
module DataMapper
|
|
8
|
+
module Parsable
|
|
9
|
+
def from_csv(csv_string, headers: true)
|
|
10
|
+
rows = CSV.parse(csv_string, headers: headers)
|
|
11
|
+
rows.map { |row| map(row_to_hash(row)) }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def from_json(json_string)
|
|
15
|
+
parsed = JSON.parse(json_string)
|
|
16
|
+
|
|
17
|
+
case parsed
|
|
18
|
+
when Array then map_all(parsed.map { |h| symbolize_keys(h) })
|
|
19
|
+
when Hash then map(symbolize_keys(parsed))
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def symbolize_keys(hash)
|
|
26
|
+
hash.each_with_object({}) do |(key, value), result|
|
|
27
|
+
result[key.to_sym] = value.is_a?(Hash) ? symbolize_keys(value) : value
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def row_to_hash(row)
|
|
32
|
+
row.to_h.transform_keys(&:to_sym)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Philiprehberger
|
|
4
|
+
module DataMapper
|
|
5
|
+
module Reversible
|
|
6
|
+
def reverse(hash)
|
|
7
|
+
build_reverse_mapping(hash)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def build_reverse_mapping(hash)
|
|
13
|
+
@fields.each_with_object({}) do |field, result|
|
|
14
|
+
next unless hash.key?(field.target)
|
|
15
|
+
|
|
16
|
+
result[field.source] = hash[field.target]
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
metadata
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: philiprehberger-data_mapper
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Philip Rehberger
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-03-
|
|
11
|
+
date: 2026-03-22 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: A zero-dependency Ruby gem for transforming data between formats with
|
|
14
|
-
a mapping DSL, field renaming, type conversion, and CSV support.
|
|
14
|
+
a mapping DSL, field renaming, type conversion, validation, and CSV support.
|
|
15
15
|
email:
|
|
16
16
|
- me@philiprehberger.com
|
|
17
17
|
executables: []
|
|
@@ -22,8 +22,12 @@ files:
|
|
|
22
22
|
- LICENSE
|
|
23
23
|
- README.md
|
|
24
24
|
- lib/philiprehberger/data_mapper.rb
|
|
25
|
+
- lib/philiprehberger/data_mapper/computed_definition.rb
|
|
25
26
|
- lib/philiprehberger/data_mapper/field_definition.rb
|
|
26
27
|
- lib/philiprehberger/data_mapper/mapping.rb
|
|
28
|
+
- lib/philiprehberger/data_mapper/mapping_result.rb
|
|
29
|
+
- lib/philiprehberger/data_mapper/parsable.rb
|
|
30
|
+
- lib/philiprehberger/data_mapper/reversible.rb
|
|
27
31
|
- lib/philiprehberger/data_mapper/version.rb
|
|
28
32
|
homepage: https://github.com/philiprehberger/rb-data-mapper
|
|
29
33
|
licenses:
|
|
@@ -32,6 +36,7 @@ metadata:
|
|
|
32
36
|
homepage_uri: https://github.com/philiprehberger/rb-data-mapper
|
|
33
37
|
source_code_uri: https://github.com/philiprehberger/rb-data-mapper
|
|
34
38
|
changelog_uri: https://github.com/philiprehberger/rb-data-mapper/blob/main/CHANGELOG.md
|
|
39
|
+
bug_tracker_uri: https://github.com/philiprehberger/rb-data-mapper/issues
|
|
35
40
|
rubygems_mfa_required: 'true'
|
|
36
41
|
post_install_message:
|
|
37
42
|
rdoc_options: []
|