philiprehberger-data_mapper 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +16 -5
- data/README.md +87 -1
- 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 +7 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 733532dbc2fd5c16c4067433a3c09f73a293691bc83e2977abeb5058194cb27b
|
|
4
|
+
data.tar.gz: edb9db9094b27203b7b3b395d0766cd9526c1996ee0943ea190b2253d001d8a5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f75f4ed87fdee1160d66dd3e8101a8553c4ec37f6b3a88e450b52492430725744b98f721995a7bb4b0a5cd9423170eae691cc5407648bf9ab7e7039b516b23ca
|
|
7
|
+
data.tar.gz: 5f53956dd0beecf8dc0a3ba191f70e7e11f17347b6224be58330753b7f61abcb9eb7f589a6b905ffc3aacfad5d65932a8a89f631c36fe7ff60d51c158505b672
|
data/CHANGELOG.md
CHANGED
|
@@ -1,10 +1,5 @@
|
|
|
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/),
|
|
@@ -12,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
12
7
|
|
|
13
8
|
## [Unreleased]
|
|
14
9
|
|
|
10
|
+
## [0.3.0] - 2026-03-17
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Conditional mapping with `if:` parameter to include fields only when a condition is met
|
|
14
|
+
- Computed fields via `computed` DSL method for deriving values from the full record
|
|
15
|
+
- Collection mapping with `array_field` DSL method to split string values into arrays
|
|
16
|
+
- Reverse mapping via `reverse` method to transform output back to input schema
|
|
17
|
+
- Validation support with `validate:` parameter and `map_with_validation` method
|
|
18
|
+
- `MappingResult` class wrapping mapped value and collected validation errors
|
|
19
|
+
|
|
20
|
+
## [0.2.1] - 2026-03-14
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
- Add License badge to README
|
|
24
|
+
- Add bug_tracker_uri to gemspec
|
|
25
|
+
|
|
15
26
|
## [0.2.0] - 2026-03-12
|
|
16
27
|
|
|
17
28
|
### Added
|
data/README.md
CHANGED
|
@@ -114,14 +114,100 @@ mapping.map(input)
|
|
|
114
114
|
|
|
115
115
|
Supported types: `:string`, `:integer`, `:float`, `:boolean`.
|
|
116
116
|
|
|
117
|
+
### Conditional mapping
|
|
118
|
+
|
|
119
|
+
Include a field only when a condition is met using the `if:` parameter:
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
mapping = Philiprehberger::DataMapper.define do
|
|
123
|
+
field :name
|
|
124
|
+
field :role, from: :raw_role, if: ->(record) { record[:admin] }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
mapping.map({ name: "Alice", raw_role: "superuser", admin: true })
|
|
128
|
+
# => { name: "Alice", role: "superuser" }
|
|
129
|
+
|
|
130
|
+
mapping.map({ name: "Bob", raw_role: "superuser", admin: false })
|
|
131
|
+
# => { name: "Bob" }
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### Computed fields
|
|
135
|
+
|
|
136
|
+
Derive fields from the full source record using `computed`:
|
|
137
|
+
|
|
138
|
+
```ruby
|
|
139
|
+
mapping = Philiprehberger::DataMapper.define do
|
|
140
|
+
field :email
|
|
141
|
+
computed(:full_name) { |record| "#{record[:first]} #{record[:last]}" }
|
|
142
|
+
computed(:initials) { |record| "#{record[:first][0]}#{record[:last][0]}" }
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
mapping.map({ email: "a@b.com", first: "Alice", last: "Smith" })
|
|
146
|
+
# => { email: "a@b.com", full_name: "Alice Smith", initials: "AS" }
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### Collection mapping
|
|
150
|
+
|
|
151
|
+
Split a single string value into an array using `array_field`:
|
|
152
|
+
|
|
153
|
+
```ruby
|
|
154
|
+
mapping = Philiprehberger::DataMapper.define do
|
|
155
|
+
array_field :tags, from: :tag_csv, split: ","
|
|
156
|
+
array_field :items, from: :item_str, split: "|"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
mapping.map({ tag_csv: "ruby,rails,gem", item_str: "one|two|three" })
|
|
160
|
+
# => { tags: ["ruby", "rails", "gem"], items: ["one", "two", "three"] }
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### Reverse mapping
|
|
164
|
+
|
|
165
|
+
Transform output back to the input schema using inverse field mappings:
|
|
166
|
+
|
|
167
|
+
```ruby
|
|
168
|
+
mapping = Philiprehberger::DataMapper.define do
|
|
169
|
+
field :full_name, from: :name
|
|
170
|
+
field :years, from: :age
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
output = { full_name: "Alice", years: 30 }
|
|
174
|
+
mapping.reverse(output)
|
|
175
|
+
# => { name: "Alice", age: 30 }
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### Validation
|
|
179
|
+
|
|
180
|
+
Validate mapped values using the `validate:` parameter. Use `map_with_validation` to collect errors:
|
|
181
|
+
|
|
182
|
+
```ruby
|
|
183
|
+
mapping = Philiprehberger::DataMapper.define do
|
|
184
|
+
field :age, from: :raw_age, type: :integer, validate: ->(v) { v > 0 }
|
|
185
|
+
field :name, validate: ->(v) { !v.nil? && !v.empty? }
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
result = mapping.map_with_validation({ raw_age: "25", name: "Alice" })
|
|
189
|
+
result.valid? # => true
|
|
190
|
+
result.value # => { age: 25, name: "Alice" }
|
|
191
|
+
result.errors # => []
|
|
192
|
+
|
|
193
|
+
result = mapping.map_with_validation({ raw_age: "-1", name: "" })
|
|
194
|
+
result.valid? # => false
|
|
195
|
+
result.value # => { age: -1, name: "" }
|
|
196
|
+
result.errors # => [{ field: :age, value: -1 }, { field: :name, value: "" }]
|
|
197
|
+
```
|
|
198
|
+
|
|
117
199
|
## API
|
|
118
200
|
|
|
119
201
|
| Method | Description |
|
|
120
202
|
|--------|-------------|
|
|
121
203
|
| `DataMapper.define(&block)` | Create a new mapping with the DSL |
|
|
122
|
-
| `Mapping#field(target, from:, default:, type:, &transform)` | Define a field mapping |
|
|
204
|
+
| `Mapping#field(target, from:, default:, type:, if:, validate:, split:, &transform)` | Define a field mapping |
|
|
205
|
+
| `Mapping#computed(target, &block)` | Define a computed field derived from the full record |
|
|
206
|
+
| `Mapping#array_field(target, from:, split:, &transform)` | Define a field that splits a string into an array |
|
|
123
207
|
| `Mapping#map(hash)` | Apply mapping to a single hash |
|
|
208
|
+
| `Mapping#map_with_validation(hash)` | Apply mapping and return a `MappingResult` with errors |
|
|
124
209
|
| `Mapping#map_all(array)` | Apply mapping to an array of hashes |
|
|
210
|
+
| `Mapping#reverse(hash)` | Transform output hash back to input schema |
|
|
125
211
|
| `Mapping#from_csv(string, headers: true)` | Parse CSV and map each row |
|
|
126
212
|
| `Mapping#from_json(json_string)` | Parse JSON string and map the result |
|
|
127
213
|
|
|
@@ -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.0
|
|
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-17 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:
|