lazy_mapper 0.3.2 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 702528c73dfd2a707526f0e792472df55077dd97bf4cf034f6187b97f885ff41
4
- data.tar.gz: c2b4708374ca625b5e81a6645cf279854241721705bfafdfee3f258f27f2ab8a
3
+ metadata.gz: caacc733f90e5a2fc3b40bc026ea256db983ba02af30c643942a5c5cd3b24223
4
+ data.tar.gz: 5038181e2d90b18bb2ae24c8be4e460b85e91a304269184ebf50092d997a5476
5
5
  SHA512:
6
- metadata.gz: 0540ab08d1cdcaac33e99876987df7d64ed6f4a31c4bac7f03581cf2d7ec64ee45ac35c9692614c6b78b8ca85952803fc5eab7f61818f790f784490b7c804271
7
- data.tar.gz: e22803584b494a25427b059fa32ae059091ab614b72ef2293581dd81486a05b6193372f32b4b576262f7b8f876c74d9b41e1660cce43355472fcf5f1fbcbce31
6
+ metadata.gz: 5e0d6d4ec22ace2d4c67a9c7a791bf89fde2de9c5bdc0e0191d126fa6b16fac946b2812046835a873286c44f94f4b737a68495220da4377c695ac4e1b0a9e3a3
7
+ data.tar.gz: 840263f4c9bdbecc26d0466c3f21fa0d6a56502a8c9bf4155d0d8a8372b27cf1a2114d91fcdc6b6e117faa72768cac917f313cb0b798a4b1c726a6b235cbe54c
data/.gitignore CHANGED
@@ -9,3 +9,4 @@ doc/
9
9
  .idea/
10
10
  Gemfile.lock
11
11
  *.gem
12
+ .ruby-version
data/CHANGELOG.md ADDED
@@ -0,0 +1,32 @@
1
+ #0.4.0
2
+
3
+ ## Changed
4
+
5
+ * Removed deprecated factory method `.from_json`
6
+ * Enable inheritance of mappers added after the derived class is defined
7
+
8
+ #0.3.2
9
+
10
+ ## Fixed
11
+
12
+ * No more BigDecimal deprecation warning
13
+
14
+ #0.3.1
15
+
16
+ ## Fixed
17
+
18
+ * Collections no longer missing from `#to_h` and `#inspect`
19
+
20
+ #0.3.0
21
+
22
+ ## Changed
23
+ * `.from_json` renamed to `.from`. `.from_json` is kept as an alias to the new method.
24
+
25
+ ## Added
26
+
27
+ * `#to_h` method
28
+
29
+ #0.2.1
30
+
31
+ (No user visible changes)
32
+
data/README.md CHANGED
@@ -6,7 +6,7 @@ The mapped values are memoized.
6
6
 
7
7
  Example:
8
8
 
9
- class Foo < LazyMapper
9
+ class Foo < LazyMapper::Model
10
10
  one :id, Integer, from: 'iden'
11
11
  one :created_at, Time
12
12
  one :amount, Money, map: Money.method(:parse)
@@ -15,8 +15,12 @@ Example:
15
15
 
16
16
  ## Documentation
17
17
 
18
- See [RubyDoc](https://www.rubydoc.info/gems/lazy_mapper/0.3.1)
18
+ See [RubyDoc](https://www.rubydoc.info/gems/lazy_mapper/0.4.0)
19
19
 
20
20
  ## License
21
21
 
22
22
  See LICENSE file.
23
+
24
+ ## Changes
25
+
26
+ See CHANGES.md
data/lazy_mapper.gemspec CHANGED
@@ -1,6 +1,11 @@
1
+ lib = File.expand_path('../lib', __FILE__)
2
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
+
4
+ require 'lazy_mapper/version'
5
+
1
6
  Gem::Specification.new do |spec|
2
7
  spec.name = 'lazy_mapper'
3
- spec.version = '0.3.2'
8
+ spec.version = LazyMapper::VERSION
4
9
  spec.summary = 'A lazy object mapper'
5
10
  spec.description = 'Wraps primitive data in a semantically rich model'
6
11
  spec.authors = ['Adam Lett']
data/lib/lazy_mapper.rb CHANGED
@@ -1,403 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'bigdecimal'
4
- require 'bigdecimal/util'
5
- require 'time'
6
-
7
- #
8
- # Wraps a Hash or Hash-like data structure of primitive values and lazily maps
9
- # its attributes to semantically rich domain objects using either a set of
10
- # default mappers (for Ruby's built-in value types), or custom mappers which
11
- # can be added either at the class level or at the instance level.
12
- #
13
- # Example:
14
- # class Foo < LazyMapper
15
- # one :id, Integer, from: 'xmlId'
16
- # one :created_at, Time
17
- # one :amount, Money, map: Money.method(:parse)
18
- # many :users, User, map: ->(u) { User.new(u) }
19
- # end
20
- #
21
3
  class LazyMapper
22
-
23
- #
24
- # Default mappings for built-in types
25
- #
26
- DEFAULT_MAPPINGS = {
27
- Object => :itself.to_proc,
28
- String => :to_s.to_proc,
29
- Integer => :to_i.to_proc,
30
- BigDecimal => :to_d.to_proc,
31
- Float => :to_f.to_proc,
32
- Symbol => :to_sym.to_proc,
33
- Hash => :to_h.to_proc,
34
- Time => Time.method(:iso8601),
35
- Date => Date.method(:parse),
36
- URI => URI.method(:parse)
37
- }.freeze
38
-
39
- #
40
- # Adds (or overrides) a default type for a given type
41
- #
42
- def self.default_value_for type, value
43
- default_values[type] = value
44
- end
45
-
46
- def self.default_values
47
- @default_values ||= DEFAULT_VALUES
48
- end
49
-
50
- #
51
- # Default values for built-in value types
52
- #
53
- DEFAULT_VALUES = {
54
- String => '',
55
- Integer => 0,
56
- Numeric => 0,
57
- Float => 0.0,
58
- BigDecimal => BigDecimal(0),
59
- Array => []
60
- }.freeze
61
-
62
- #
63
- # Adds a mapper for a give type
64
- #
65
- def self.mapper_for(type, mapper)
66
- mappers[type] = mapper
67
- end
68
-
69
- def self.mappers
70
- @mappers ||= DEFAULT_MAPPINGS
71
- end
72
-
73
- def self.attributes
74
- @attributes ||= {}
75
- end
76
-
77
- def self.inherited klass # :nodoc:
78
- # Make the subclass "inherit" the values of these class instance variables
79
- %i[
80
- mappers
81
- default_values
82
- attributes
83
- ].each do |s|
84
- klass.instance_variable_set IVAR[s], self.send(s).dup
85
- end
86
- end
87
-
88
- def mappers
89
- @mappers ||= self.class.mappers
90
- end
91
-
92
- IVAR = lambda { |name| # :nodoc:
93
- name_as_str = name.to_s
94
- name_as_str = name_as_str[0...-1] if name_as_str[-1] == '?'
95
-
96
- ('@' + name_as_str).freeze
97
- }
98
-
99
- WRITER = -> name { (name.to_s.delete('?') + '=').to_sym }
100
-
101
- #
102
- # Creates a new instance by giving a Hash of attribues.
103
- #
104
- # Attribute values are type checked according to how they were defined.
105
- #
106
- # Fails with +TypeError+, if a value doesn't have the expected type.
107
- #
108
- # == Example
109
- #
110
- # Foo.new :id => 42,
111
- # :created_at => Time.parse("2015-07-29 14:07:35 +0200"),
112
- # :amount => Money.parse("$2.00"),
113
- # :users => [
114
- # User.new("id" => 23, "name" => "Adam"),
115
- # User.new("id" => 45, "name" => "Ole"),
116
- # User.new("id" => 66, "name" => "Anders"),
117
- # User.new("id" => 91, "name" => "Kristoffer)
118
- # ]
119
-
120
- def initialize(values = {})
121
- @mappers = {}
122
- values.each do |name, value|
123
- send(WRITER[name], value)
124
- end
125
- end
126
-
127
- #
128
- # Create a new instance by giving a Hash of unmapped attributes.
129
- #
130
- # The keys in the Hash are assumed to be camelCased strings.
131
- #
132
- # == Arguments
133
- #
134
- # +unmapped_data+ - The unmapped data as a Hash(-like object). Must respond to #to_h.
135
- # Keys are assumed to be camelCased string
136
- #
137
- # +mappers:+ - Optional instance-level mappers.
138
- # Keys can either be classes or symbols corresponding to named attributes.
139
- #
140
- #
141
- # == Example
142
- #
143
- # Foo.from({
144
- # "xmlId" => 42,
145
- # "createdAt" => "2015-07-29 14:07:35 +0200",
146
- # "amount" => "$2.00",
147
- # "users" => [
148
- # { "id" => 23, "name" => "Adam" },
149
- # { "id" => 45, "name" => "Ole" },
150
- # { "id" => 66, "name" => "Anders" },
151
- # { "id" => 91, "name" => "Kristoffer" } ]},
152
- # mappers: {
153
- # :amount => -> x { Money.new(x) },
154
- # User => User.method(:new) })
155
- #
156
- def self.from unmapped_data, mappers: {}
157
- return nil if unmapped_data.nil?
158
- fail TypeError, "#{ unmapped_data.inspect } is not a Hash" unless unmapped_data.respond_to? :to_h
159
-
160
- instance = new
161
- instance.send :unmapped_data=, unmapped_data.to_h
162
- instance.send :mappers=, mappers
163
- instance
164
- end
165
-
166
- def self.from_json *args, &block # :nodoc:
167
- warn "#{ self }.from_json is deprecated. Use #{ self }.from instead."
168
- from(*args, &block)
169
- end
170
-
171
- #
172
- # Defines an attribute and creates a reader and a writer for it.
173
- # The writer verifies the type of it's supplied value.
174
- #
175
- # == Arguments
176
- #
177
- # +name+ - The name of the attribue
178
- #
179
- # +type+ - The type of the attribute. If the wrapped value is already of that type, the mapper is bypassed.
180
- # If the type is allowed be one of several, use an Array to to specify which ones
181
- #
182
- # +from:+ - Specifies the name of the wrapped value in the JSON object. Defaults to camelCased version of +name+.
183
- #
184
- # +map:+ - Specifies a custom mapper to apply to the wrapped value.
185
- # If unspecified, it defaults to the default mapper for the specified +type+ or simply the identity mapper
186
- # if no default mapper exists.
187
- #
188
- # +default:+ - The default value to use, if the wrapped value is not present in the wrapped JSON object.
189
- #
190
- # +allow_nil:+ - If true, allows the mapped value to be nil. Defaults to true.
191
- #
192
- # == Example
193
- #
194
- # class Foo < LazyMapper
195
- # one :boss, Person, from: "supervisor", map: ->(p) { Person.new(p) }
196
- # one :weapon, [BladedWeapon, Firearm], default: Sixshooter.new
197
- # # ...
198
- # end
199
- #
200
- def self.one(name, type, from: map_name(name), allow_nil: true, **args)
201
-
202
- ivar = IVAR[name]
203
-
204
- # Define writer
205
- define_method(WRITER[name]) { |val|
206
- check_type! val, type, allow_nil: allow_nil
207
- instance_variable_set(ivar, val)
208
- }
209
-
210
- # Define reader
211
- define_method(name) {
212
- memoize(name, ivar) {
213
- unmapped_value = unmapped_data[from]
214
- mapped_value(name, unmapped_value, type, **args)
215
- }
216
- }
217
-
218
- attributes[name] = type
219
- end
220
-
221
- #
222
- # Converts a value to +true+ or +false+ according to its truthyness
223
- #
224
- TO_BOOL = -> b { !!b }
225
-
226
- #
227
- # Defines an boolean attribute
228
- #
229
- # == Arguments
230
- #
231
- # +name+ - The name of the attribue
232
- #
233
- # +from:+ - Specifies the name of the wrapped value in the JSON object.
234
- # Defaults to camelCased version of +name+.
235
- #
236
- # +map:+ - Specifies a custom mapper to apply to the wrapped value. Must be a Callable.
237
- # Defaults to TO_BOOL if unspecified.
238
- #
239
- # +default:+ The default value to use if the value is missing. False, if unspecified
240
- #
241
- # == Example
242
- #
243
- # class Foo < LazyMapper
244
- # is :green?, from: "isGreen", map: ->(x) { !x.zero? }
245
- # # ...
246
- # end
247
- #
248
- def self.is name, from: map_name(name), map: TO_BOOL, default: false
249
- one name, [TrueClass, FalseClass], from: from, allow_nil: false, map: map, default: default
250
- end
251
-
252
- singleton_class.send(:alias_method, :has, :is)
253
-
254
- #
255
- # Defines a collection attribute
256
- #
257
- # == Arguments
258
- #
259
- # +name+ - The name of the attribute
260
- #
261
- # +type+ - The type of the elements in the collection.
262
- #
263
- # +from:+ - Specifies the name of the wrapped array in the unmapped data.
264
- # Defaults to camelCased version of +name+.
265
- #
266
- # +map:+ - Specifies a custom mapper to apply to each elements in the wrapped collection.
267
- # If unspecified, it defaults to the default mapper for the specified +type+ or simply the identity mapper
268
- # if no default mapper exists.
269
- #
270
- # +default:+ - The default value to use, if the unmapped value is missing.
271
- #
272
- # == Example
273
- #
274
- # class Bar < LazyMapper
275
- # many :underlings, Person, from: "serfs", map: ->(p) { Person.new(p) }
276
- # # ...
277
- # end
278
- #
279
- def self.many(name, type, from: map_name(name), **args)
280
-
281
- # Define setter
282
- define_method(WRITER[name]) { |val|
283
- check_type! val, Enumerable, allow_nil: false
284
- instance_variable_set(IVAR[name], val)
285
- }
286
-
287
- # Define getter
288
- define_method(name) {
289
- memoize(name) {
290
- unmapped_value = unmapped_data[from]
291
- if unmapped_value.is_a? Array
292
- unmapped_value.map { |v| mapped_value(name, v, type, **args) }
293
- else
294
- mapped_value name, unmapped_value, Array, **args
295
- end
296
- }
297
- }
298
-
299
- attributes[name] = Array
300
- end
301
-
302
- #
303
- # Adds an instance-level type mapper
304
- #
305
- def add_mapper_for(type, &block)
306
- mappers[type] = block
307
- end
308
-
309
- def to_h
310
- attributes.each_with_object({}) { |(key, _value), h|
311
- h[key] = self.send key
312
- }
313
- end
314
-
315
- def inspect
316
- @__under_inspection__ ||= 0
317
- return "<#{ self.class.name } ... >" if @__under_inspection__.positive?
318
-
319
- @__under_inspection__ += 1
320
- present_attributes = attributes.keys.each_with_object({}) { |name, memo|
321
- ivar = IVAR[name]
322
- next unless self.instance_variable_defined? ivar
323
-
324
- memo[name] = self.instance_variable_get ivar
325
- }
326
-
327
- res = "<#{ self.class.name } #{ present_attributes.map { |k, v| k.to_s + ': ' + v.inspect }.join(', ') } >"
328
- @__under_inspection__ -= 1
329
- res
330
- end
331
-
332
- #
333
- # Defines how to map an attribute name
334
- # to the corresponding name in the unmapped
335
- # JSON object.
336
- #
337
- # Defaults to CAMELIZE
338
- #
339
- def self.map_name(name)
340
- CAMELIZE[name]
341
- end
342
-
343
- private
344
-
345
- attr_writer :unmapped_data
346
- attr_writer :mappers
347
-
348
- def unmapped_data
349
- @unmapped_data ||= {}
350
- end
351
-
352
- def mapping_for(name, type)
353
- mappers[name] || mappers[type] || self.class.mappers[type]
354
- end
355
-
356
- def default_value(type)
357
- self.class.default_values[type]
358
- end
359
-
360
- def attributes
361
- self.class.attributes
362
- end
363
-
364
- def mapped_value(name, unmapped_value, type, map: mapping_for(name, type), default: default_value(type))
365
- return default.dup if unmapped_value.nil? # Duplicate to prevent accidental sharing between instances
366
-
367
- if map.nil?
368
- fail ArgumentError, "missing mapper for #{ name } (#{ type }). "\
369
- "Unmapped value: #{ unmapped_value.inspect }"
370
- end
371
-
372
- return map.call(unmapped_value, self) if map.arity > 1
373
-
374
- map.call(unmapped_value)
375
- end
376
-
377
- def check_type! value, type, allow_nil:
378
- permitted_types = allow_nil ? Array(type) + [ NilClass ] : Array(type)
379
- return if permitted_types.any? value.method(:is_a?)
380
-
381
- fail TypeError.new "#{ self.class.name }: "\
382
- "#{ value.inspect } is a #{ value.class } "\
383
- "but was supposed to be a #{ humanize_list permitted_types, conjunction: ' or ' }"
384
- end
385
-
386
- # [1,2,3] -> "1, 2 and 3"
387
- # [1, 2] -> "1 and 2"
388
- # [1] -> "1"
389
- def humanize_list list, separator: ', ', conjunction: ' and '
390
- *all_but_last, last = list
391
- return last if all_but_last.empty?
392
-
393
- [ all_but_last.join(separator), last ].join conjunction
394
- end
395
-
396
- def memoize name, ivar = IVAR[name]
397
- send WRITER[name], yield unless instance_variable_defined?(ivar)
398
- instance_variable_get(ivar)
399
- end
400
-
401
- SNAKE_CASE_PATTERN = /(_[a-z])/ # :nodoc:
402
- CAMELIZE = -> name { name.to_s.gsub(SNAKE_CASE_PATTERN) { |x| x[1].upcase }.delete('?') }
403
4
  end
5
+
6
+ require 'lazy_mapper/version'
7
+ require 'lazy_mapper/defaults'
8
+ require 'lazy_mapper/lazy_mapper'
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bigdecimal'
4
+ require 'bigdecimal/util'
5
+ require 'time'
6
+
7
+ class LazyMapper
8
+ #
9
+ # Default mappings for built-in types
10
+ #
11
+ DEFAULT_MAPPINGS = {
12
+ Object => :itself.to_proc,
13
+ String => :to_s.to_proc,
14
+ Integer => :to_i.to_proc,
15
+ BigDecimal => :to_d.to_proc,
16
+ Float => :to_f.to_proc,
17
+ Symbol => :to_sym.to_proc,
18
+ Hash => :to_h.to_proc,
19
+ Time => Time.method(:iso8601),
20
+ Date => Date.method(:parse),
21
+ URI => URI.method(:parse)
22
+ }.freeze
23
+
24
+ #
25
+ # Default values for built-in value types
26
+ #
27
+ DEFAULT_VALUES = {
28
+ String => '',
29
+ Integer => 0,
30
+ Numeric => 0,
31
+ Float => 0.0,
32
+ BigDecimal => BigDecimal(0),
33
+ Array => []
34
+ }.freeze
35
+ end
@@ -0,0 +1,375 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # Wraps a Hash or Hash-like data structure of primitive values and lazily maps
5
+ # its attributes to semantically rich domain objects using either a set of
6
+ # default mappers (for Ruby's built-in value types), or custom mappers which
7
+ # can be added either at the class level or at the instance level.
8
+ #
9
+ # Example:
10
+ # class Foo < LazyMapper
11
+ # one :id, Integer, from: 'xmlId'
12
+ # one :created_at, Time
13
+ # one :amount, Money, map: Money.method(:parse)
14
+ # many :users, User, map: ->(u) { User.new(u) }
15
+ # end
16
+ #
17
+ class LazyMapper
18
+
19
+ #
20
+ # Adds (or overrides) a default type for a given type
21
+ #
22
+ def self.default_value_for type, value
23
+ default_values[type] = value
24
+ end
25
+
26
+ def self.default_values
27
+ @default_values ||= DEFAULT_VALUES
28
+ end
29
+
30
+ #
31
+ # Adds a mapper for a give type
32
+ #
33
+ def self.mapper_for(type, mapper)
34
+ mappers[type] = mapper
35
+ end
36
+
37
+ def self.mappers
38
+ @mappers ||= DEFAULT_MAPPINGS
39
+ end
40
+
41
+ def self.attributes
42
+ @attributes ||= {}
43
+ end
44
+
45
+ def self.inherited klass # :nodoc:
46
+ # Make the subclass "inherit" the values of these class instance variables
47
+ %i[
48
+ default_values
49
+ attributes
50
+ ].each do |s|
51
+ klass.instance_variable_set IVAR[s], self.send(s).dup
52
+ end
53
+
54
+ # If a mapper is does not exist in the derived class, look it up in this class
55
+ klass.instance_variable_set('@mappers', Hash.new { |_mappers, type| mappers[type] })
56
+ end
57
+
58
+ def mappers
59
+ @mappers ||= self.class.mappers
60
+ end
61
+
62
+ IVAR = lambda { |name| # :nodoc:
63
+ name_as_str = name.to_s
64
+ name_as_str = name_as_str[0...-1] if name_as_str[-1] == '?'
65
+
66
+ ('@' + name_as_str).freeze
67
+ }
68
+
69
+ WRITER = -> name { (name.to_s.delete('?') + '=').to_sym }
70
+
71
+ #
72
+ # Creates a new instance by giving a Hash of attribues.
73
+ #
74
+ # Attribute values are type checked according to how they were defined.
75
+ #
76
+ # Fails with +TypeError+, if a value doesn't have the expected type.
77
+ #
78
+ # == Example
79
+ #
80
+ # Foo.new :id => 42,
81
+ # :created_at => Time.parse("2015-07-29 14:07:35 +0200"),
82
+ # :amount => Money.parse("$2.00"),
83
+ # :users => [
84
+ # User.new("id" => 23, "name" => "Adam"),
85
+ # User.new("id" => 45, "name" => "Ole"),
86
+ # User.new("id" => 66, "name" => "Anders"),
87
+ # User.new("id" => 91, "name" => "Kristoffer)
88
+ # ]
89
+
90
+ def initialize(values = {})
91
+ @mappers = {}
92
+ values.each do |name, value|
93
+ send(WRITER[name], value)
94
+ end
95
+ end
96
+
97
+ #
98
+ # Create a new instance by giving a Hash of unmapped attributes.
99
+ #
100
+ # The keys in the Hash are assumed to be camelCased strings.
101
+ #
102
+ # == Arguments
103
+ #
104
+ # +unmapped_data+ - The unmapped data as a Hash(-like object). Must respond to #to_h.
105
+ # Keys are assumed to be camelCased string
106
+ #
107
+ # +mappers:+ - Optional instance-level mappers.
108
+ # Keys can either be classes or symbols corresponding to named attributes.
109
+ #
110
+ #
111
+ # == Example
112
+ #
113
+ # Foo.from({
114
+ # "xmlId" => 42,
115
+ # "createdAt" => "2015-07-29 14:07:35 +0200",
116
+ # "amount" => "$2.00",
117
+ # "users" => [
118
+ # { "id" => 23, "name" => "Adam" },
119
+ # { "id" => 45, "name" => "Ole" },
120
+ # { "id" => 66, "name" => "Anders" },
121
+ # { "id" => 91, "name" => "Kristoffer" } ]},
122
+ # mappers: {
123
+ # :amount => -> x { Money.new(x) },
124
+ # User => User.method(:new) })
125
+ #
126
+ def self.from unmapped_data, mappers: {}
127
+ return nil if unmapped_data.nil?
128
+ fail TypeError, "#{ unmapped_data.inspect } is not a Hash" unless unmapped_data.respond_to? :to_h
129
+
130
+ instance = new
131
+ instance.send :unmapped_data=, unmapped_data.to_h
132
+ instance.send :mappers=, mappers
133
+ instance
134
+ end
135
+
136
+ #
137
+ # Defines an attribute and creates a reader and a writer for it.
138
+ # The writer verifies the type of it's supplied value.
139
+ #
140
+ # == Arguments
141
+ #
142
+ # +name+ - The name of the attribue
143
+ #
144
+ # +type+ - The type of the attribute. If the wrapped value is already of that type, the mapper is bypassed.
145
+ # If the type is allowed be one of several, use an Array to to specify which ones
146
+ #
147
+ # +from:+ - Specifies the name of the wrapped value in the JSON object. Defaults to camelCased version of +name+.
148
+ #
149
+ # +map:+ - Specifies a custom mapper to apply to the wrapped value.
150
+ # If unspecified, it defaults to the default mapper for the specified +type+ or simply the identity mapper
151
+ # if no default mapper exists.
152
+ #
153
+ # +default:+ - The default value to use, if the wrapped value is not present in the wrapped JSON object.
154
+ #
155
+ # +allow_nil:+ - If true, allows the mapped value to be nil. Defaults to true.
156
+ #
157
+ # == Example
158
+ #
159
+ # class Foo < LazyMapper
160
+ # one :boss, Person, from: "supervisor", map: ->(p) { Person.new(p) }
161
+ # one :weapon, [BladedWeapon, Firearm], default: Sixshooter.new
162
+ # # ...
163
+ # end
164
+ #
165
+ def self.one(name, type, from: map_name(name), allow_nil: true, **args)
166
+
167
+ ivar = IVAR[name]
168
+
169
+ # Define writer
170
+ define_method(WRITER[name]) { |val|
171
+ check_type! val, type, allow_nil: allow_nil
172
+ instance_variable_set(ivar, val)
173
+ }
174
+
175
+ # Define reader
176
+ define_method(name) {
177
+ memoize(name, ivar) {
178
+ unmapped_value = unmapped_data[from]
179
+ mapped_value(name, unmapped_value, type, **args)
180
+ }
181
+ }
182
+
183
+ attributes[name] = type
184
+ end
185
+
186
+ #
187
+ # Converts a value to +true+ or +false+ according to its truthyness
188
+ #
189
+ TO_BOOL = -> b { !!b }
190
+
191
+ #
192
+ # Defines an boolean attribute
193
+ #
194
+ # == Arguments
195
+ #
196
+ # +name+ - The name of the attribue
197
+ #
198
+ # +from:+ - Specifies the name of the wrapped value in the JSON object.
199
+ # Defaults to camelCased version of +name+.
200
+ #
201
+ # +map:+ - Specifies a custom mapper to apply to the wrapped value. Must be a Callable.
202
+ # Defaults to TO_BOOL if unspecified.
203
+ #
204
+ # +default:+ The default value to use if the value is missing. False, if unspecified
205
+ #
206
+ # == Example
207
+ #
208
+ # class Foo < LazyMapper
209
+ # is :green?, from: "isGreen", map: ->(x) { !x.zero? }
210
+ # # ...
211
+ # end
212
+ #
213
+ def self.is name, from: map_name(name), map: TO_BOOL, default: false
214
+ one name, [TrueClass, FalseClass], from: from, allow_nil: false, map: map, default: default
215
+ end
216
+
217
+ singleton_class.send(:alias_method, :has, :is)
218
+
219
+ #
220
+ # Defines a collection attribute
221
+ #
222
+ # == Arguments
223
+ #
224
+ # +name+ - The name of the attribute
225
+ #
226
+ # +type+ - The type of the elements in the collection.
227
+ #
228
+ # +from:+ - Specifies the name of the wrapped array in the unmapped data.
229
+ # Defaults to camelCased version of +name+.
230
+ #
231
+ # +map:+ - Specifies a custom mapper to apply to each elements in the wrapped collection.
232
+ # If unspecified, it defaults to the default mapper for the specified +type+ or simply the identity mapper
233
+ # if no default mapper exists.
234
+ #
235
+ # +default:+ - The default value to use, if the unmapped value is missing.
236
+ #
237
+ # == Example
238
+ #
239
+ # class Bar < LazyMapper
240
+ # many :underlings, Person, from: "serfs", map: ->(p) { Person.new(p) }
241
+ # # ...
242
+ # end
243
+ #
244
+ def self.many(name, type, from: map_name(name), **args)
245
+
246
+ # Define setter
247
+ define_method(WRITER[name]) { |val|
248
+ check_type! val, Enumerable, allow_nil: false
249
+ instance_variable_set(IVAR[name], val)
250
+ }
251
+
252
+ # Define getter
253
+ define_method(name) {
254
+ memoize(name) {
255
+ unmapped_value = unmapped_data[from]
256
+ if unmapped_value.is_a? Array
257
+ unmapped_value.map { |v| mapped_value(name, v, type, **args) }
258
+ else
259
+ mapped_value name, unmapped_value, Array, **args
260
+ end
261
+ }
262
+ }
263
+
264
+ attributes[name] = Array
265
+ end
266
+
267
+ #
268
+ # Adds an instance-level type mapper
269
+ #
270
+ def add_mapper_for(type, &block)
271
+ mappers[type] = block
272
+ end
273
+
274
+ #
275
+ # Returns a +Hash+ with keys corresponding to attribute names, and values
276
+ # corresponding to *mapped* attribute values.
277
+ #
278
+ # Note: This will eagerly map all attributes that haven't yet been mapped
279
+ #
280
+ def to_h
281
+ attributes.each_with_object({}) { |(key, _value), h|
282
+ h[key] = self.send key
283
+ }
284
+ end
285
+
286
+ def inspect
287
+ @__under_inspection__ ||= 0
288
+ return "<#{ self.class.name } ... >" if @__under_inspection__.positive?
289
+
290
+ @__under_inspection__ += 1
291
+ present_attributes = attributes.keys.each_with_object({}) { |name, memo|
292
+ ivar = IVAR[name]
293
+ next unless self.instance_variable_defined? ivar
294
+
295
+ memo[name] = self.instance_variable_get ivar
296
+ }
297
+
298
+ res = "<#{ self.class.name } #{ present_attributes.map { |k, v| k.to_s + ': ' + v.inspect }.join(', ') } >"
299
+ @__under_inspection__ -= 1
300
+ res
301
+ end
302
+
303
+ #
304
+ # Defines how to map an attribute name
305
+ # to the corresponding name in the unmapped
306
+ # JSON object.
307
+ #
308
+ # Defaults to CAMELIZE
309
+ #
310
+ def self.map_name(name)
311
+ CAMELIZE[name]
312
+ end
313
+
314
+ private
315
+
316
+ attr_writer :unmapped_data
317
+ attr_writer :mappers
318
+
319
+ def unmapped_data
320
+ @unmapped_data ||= {}
321
+ end
322
+
323
+ def mapping_for(name, type)
324
+ mappers[name] || mappers[type] || self.class.mappers[type]
325
+ end
326
+
327
+ def default_value(type)
328
+ self.class.default_values[type]
329
+ end
330
+
331
+ def attributes
332
+ self.class.attributes
333
+ end
334
+
335
+ def mapped_value(name, unmapped_value, type, map: mapping_for(name, type), default: default_value(type))
336
+ return default.dup if unmapped_value.nil? # Duplicate to prevent accidental sharing between instances
337
+
338
+ if map.nil?
339
+ fail ArgumentError, "missing mapper for #{ name } (#{ type }). "\
340
+ "Unmapped value: #{ unmapped_value.inspect }"
341
+ end
342
+
343
+ return map.call(unmapped_value, self) if map.arity > 1
344
+
345
+ map.call(unmapped_value)
346
+ end
347
+
348
+ def check_type! value, type, allow_nil:
349
+ permitted_types = allow_nil ? Array(type) + [ NilClass ] : Array(type)
350
+ return if permitted_types.any? value.method(:is_a?)
351
+
352
+ fail TypeError.new "#{ self.class.name }: "\
353
+ "#{ value.inspect } is a #{ value.class } "\
354
+ "but was supposed to be a #{ humanize_list permitted_types, conjunction: ' or ' }"
355
+ end
356
+
357
+ # [1,2,3] -> "1, 2 and 3"
358
+ # [1, 2] -> "1 and 2"
359
+ # [1] -> "1"
360
+ def humanize_list terms, separator: ', ', conjunction: ' and '
361
+ *all_but_last, last = terms
362
+ return last if all_but_last.empty?
363
+
364
+ [ all_but_last.join(separator), last ].join conjunction
365
+ end
366
+
367
+ def memoize name, ivar = IVAR[name]
368
+ send WRITER[name], yield unless instance_variable_defined?(ivar)
369
+ instance_variable_get(ivar)
370
+ end
371
+
372
+ SNAKE_CASE_PATTERN = /(_[a-z])/ # :nodoc:
373
+ CAMELIZE = -> name { name.to_s.gsub(SNAKE_CASE_PATTERN) { |x| x[1].upcase }.delete('?') }
374
+
375
+ end
@@ -0,0 +1,3 @@
1
+ class LazyMapper
2
+ VERSION = '0.4.0'
3
+ end
@@ -20,7 +20,7 @@ describe LazyMapper do
20
20
  let(:klass) {
21
21
  t = type
22
22
  m = map
23
- Class.new LazyMapper do
23
+ Class.new described_class do
24
24
  one :created_at, Date
25
25
  many :updated_at, Date
26
26
  one :foo, t, map: m, default: 666
@@ -92,14 +92,14 @@ describe LazyMapper do
92
92
 
93
93
  let(:klass_foo) {
94
94
  b = bar_builder
95
- Class.new LazyMapper do
95
+ Class.new described_class do
96
96
  one :bar, Object, map: b
97
97
  end
98
98
  }
99
99
 
100
100
  let(:klass_bar) {
101
101
  b = foo_builder
102
- Class.new LazyMapper do
102
+ Class.new described_class do
103
103
  one :foo, Object, map: b
104
104
  end
105
105
  }
@@ -116,7 +116,7 @@ describe LazyMapper do
116
116
  describe 'the :from option' do
117
117
 
118
118
  let(:klass) {
119
- Class.new LazyMapper do
119
+ Class.new described_class do
120
120
  one :baz, Integer, from: 'BAZ'
121
121
  is :fuzzy?, from: 'hairy'
122
122
  is :sweet?, from: 'sugary'
@@ -136,7 +136,7 @@ describe LazyMapper do
136
136
  context "if the mapper doesn't map to the correct type" do
137
137
 
138
138
  let(:klass) {
139
- Class.new LazyMapper do
139
+ Class.new described_class do
140
140
  one :bar, Float, map: ->(x) { x.to_s }
141
141
  end
142
142
  }
@@ -149,7 +149,7 @@ describe LazyMapper do
149
149
 
150
150
  it 'supports adding custom type mappers to instances' do
151
151
  type = Struct.new(:val1, :val2)
152
- klass = Class.new LazyMapper do
152
+ klass = Class.new described_class do
153
153
  one :composite, type
154
154
  end
155
155
 
@@ -164,7 +164,7 @@ describe LazyMapper do
164
164
 
165
165
  it 'supports injection of customer mappers during instantiation' do
166
166
  type = Struct.new(:val1, :val2)
167
- klass = Class.new LazyMapper do
167
+ klass = Class.new described_class do
168
168
  one :foo, type
169
169
  one :bar, type
170
170
  end
@@ -180,7 +180,7 @@ describe LazyMapper do
180
180
  end
181
181
 
182
182
  it 'expects the supplied mapper to return an Array if the unmapped value of a "many" attribute is not an array' do
183
- klass = Class.new LazyMapper do
183
+ klass = Class.new described_class do
184
184
  many :foos, String, map: ->(v) { return v.split '' }
185
185
  many :bars, String, map: ->(v) { return v }
186
186
  end
@@ -194,9 +194,11 @@ describe LazyMapper do
194
194
  context 'when it is derived from another LazyMapper' do
195
195
  let(:klass) { Class.new(base) }
196
196
  let(:composite_type) { Struct.new(:val1, :val2) }
197
+ let(:new_type) { Class.new }
198
+ let(:new_type_mapper) { new_type.method(:new) }
197
199
  let(:base) {
198
200
  type = composite_type
199
- Class.new(LazyMapper) do
201
+ Class.new(described_class) do
200
202
  default_value_for type, type.new('321', '123')
201
203
  mapper_for type, ->(unmapped_value) { type.new(*unmapped_value.split(' ')) }
202
204
  one :composite, type
@@ -215,6 +217,13 @@ describe LazyMapper do
215
217
  it 'inherits default mappers' do
216
218
  expect(klass.from('composite' => 'abc def').composite).to eq composite_type.new('abc', 'def')
217
219
  end
220
+
221
+ it 'inherits default mappers that are added to its parent after it has been defined', wip: true do
222
+ expect(klass.mappers[new_type]).to be_nil
223
+ base.mapper_for new_type, new_type_mapper
224
+ expect(base.mappers[new_type]).to eq new_type_mapper
225
+ expect(klass.mappers[new_type]).to eq new_type_mapper
226
+ end
218
227
  end
219
228
  end
220
229
 
@@ -224,7 +233,7 @@ describe LazyMapper do
224
233
  let(:values) { {} }
225
234
 
226
235
  let(:klass) {
227
- Class.new LazyMapper do
236
+ Class.new described_class do
228
237
  one :title, String
229
238
  one :count, Integer
230
239
  one :rate, Float
@@ -270,7 +279,7 @@ describe LazyMapper do
270
279
 
271
280
  context 'when no values are provided' do
272
281
 
273
- it 'have sensible fallback values for primitive types' do
282
+ it 'has sensible default values for primitive types' do
274
283
  expect(instance.title).to eq('')
275
284
  expect(instance.count).to eq(0)
276
285
  expect(instance.rate).to eq(0.0)
@@ -278,15 +287,15 @@ describe LazyMapper do
278
287
  expect(instance.tags).to eq []
279
288
  end
280
289
 
281
- it 'use the supplied default values' do
290
+ it 'uses the supplied default values' do
282
291
  expect(instance.things).to eq(['something'])
283
292
  end
284
293
 
285
- it 'fall back to nil in all other cases' do
294
+ it 'falls back to nil in all other cases' do
286
295
  expect(instance.widget).to be_nil
287
296
  end
288
297
 
289
- it 'don\'t share their default values between instances' do
298
+ it 'dosn\'t share its default values with other instances' do
290
299
  instance1 = klass.new
291
300
  instance2 = klass.new
292
301
  instance1.tags << 'dirty'
data/spec/spec_helper.rb CHANGED
@@ -1,8 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- $LOAD_PATH.unshift File.expand_path(File.dirname(__FILE__) + '/../lib')
4
- $LOAD_PATH.unshift __dir__
5
-
6
3
  if ENV['RCOV']
7
4
  require 'simplecov'
8
5
  SimpleCov.start do
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lazy_mapper
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.2
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Adam Lett
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2019-03-05 00:00:00.000000000 Z
11
+ date: 2019-04-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -47,11 +47,15 @@ files:
47
47
  - ".gitignore"
48
48
  - ".rubocop.yml"
49
49
  - ".ruby-version"
50
+ - CHANGELOG.md
50
51
  - Gemfile
51
52
  - LICENCE
52
53
  - README.md
53
54
  - lazy_mapper.gemspec
54
55
  - lib/lazy_mapper.rb
56
+ - lib/lazy_mapper/defaults.rb
57
+ - lib/lazy_mapper/lazy_mapper.rb
58
+ - lib/lazy_mapper/version.rb
55
59
  - spec/lazy_mapper_spec.rb
56
60
  - spec/spec_helper.rb
57
61
  homepage: https://github.com/bruun-rasmussen/lazy_mapper