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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c9b536c5754e08456bd41fea7777a7dcf6cbeb55ab0c992f459f060e5832ee8c
4
- data.tar.gz: 90c15710312855a5ed3024bec8e5e72332952a2fc7daed3daa1a4eed8e58aaf7
3
+ metadata.gz: 733532dbc2fd5c16c4067433a3c09f73a293691bc83e2977abeb5058194cb27b
4
+ data.tar.gz: edb9db9094b27203b7b3b395d0766cd9526c1996ee0943ea190b2253d001d8a5
5
5
  SHA512:
6
- metadata.gz: f3f0822f41600c574e975173b1bcaba176a614b3fccaafe88e4df7969c2c2841f165d5544c5beac45a594723b808f85857afbd8ddf590f6a83c8020cb1ae2a71
7
- data.tar.gz: 3eed507cf33a5ecec686831d187cd4fa5bcdf3ff0be7bf311e580c6c5b6c78576d92e03e0b49246f8d78bd89a1a650d9f60a9ed2e866683de3c2229ca9a48919
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, from: nil, default: nil, type: nil, &transform)
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
- require "csv"
4
- require "json"
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, from: nil, default: nil, type: nil, &transform)
15
- @fields << FieldDefinition.new(target, from: from, default: default, type: type, &transform)
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
- @fields.each_with_object({}) do |field, result|
20
- value = dig_value(hash, field.source)
21
- result[field.target] = field.apply(value)
22
- end
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
- def from_csv(csv_string, headers: true)
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 from_json(json_string)
35
- parsed = JSON.parse(json_string)
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
- case parsed
38
- when Array then map_all(parsed.map { |h| symbolize_keys(h) })
39
- when Hash then map(symbolize_keys(parsed))
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
- private
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Philiprehberger
4
4
  module DataMapper
5
- VERSION = "0.2.1"
5
+ VERSION = "0.3.0"
6
6
  end
7
7
  end
@@ -2,6 +2,8 @@
2
2
 
3
3
  require_relative "data_mapper/version"
4
4
  require_relative "data_mapper/field_definition"
5
+ require_relative "data_mapper/computed_definition"
6
+ require_relative "data_mapper/mapping_result"
5
7
  require_relative "data_mapper/mapping"
6
8
 
7
9
  module Philiprehberger
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.2.1
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-16 00:00:00.000000000 Z
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: