lazy_mapper 0.1.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 +7 -0
- data/.gitignore +9 -0
- data/Gemfile +20 -0
- data/LICENCE +20 -0
- data/README.md +18 -0
- data/lazy_mapper.gemspec +19 -0
- data/lib/lazy_mapper.rb +341 -0
- data/spec/lazy_mapper_spec.rb +247 -0
- data/spec/spec_helper.rb +15 -0
- metadata +95 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: '08c13aa83740c1719069305ba2be381b32c65ed59017fa251d16a2c5590dd945'
|
4
|
+
data.tar.gz: 5f1febab53594a456df8b842972f8ea26015270fd3043853a991ab7b467eae9e
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: d4c44f93957f6b04c7069b3f7991862f75d6539ab8b2ae70ebbbcdfbfe4080d59548b9a7b2364cc18dd0c16a1b4b223df6417fb039d57fd36718fd17aa36da17
|
7
|
+
data.tar.gz: 05523ef4f8fc5d26a335c0b8be671d8e38e677ada3a350e6817cc1a13691b616ed6d8c0b7617c16735894bfe6e799117076dfd100abe15ab44c5798afa8aa2e3
|
data/.gitignore
ADDED
data/Gemfile
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
source 'https://rubygems.org'
|
2
|
+
|
3
|
+
gemspec
|
4
|
+
|
5
|
+
group :test do
|
6
|
+
gem 'i18n', require: false
|
7
|
+
platform :mri do
|
8
|
+
gem 'simplecov', require: false
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
group :tools do
|
13
|
+
gem 'pry-byebug', platform: :mri
|
14
|
+
gem 'pry', platform: :jruby
|
15
|
+
|
16
|
+
unless ENV['TRAVIS']
|
17
|
+
gem 'mutant', git: 'https://github.com/mbj/mutant'
|
18
|
+
gem 'mutant-rspec', git: 'https://github.com/mbj/mutant'
|
19
|
+
end
|
20
|
+
end
|
data/LICENCE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2018 Bruun Rasmussen A/S
|
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,18 @@
|
|
1
|
+
# LazyModel
|
2
|
+
|
3
|
+
Wraps a JSON object and lazily maps its attributes to rich domain objects using either a set of default mappers (for Ruby's built-in types), or custom mappers specified by the client.
|
4
|
+
|
5
|
+
The mapped values are memoized.
|
6
|
+
|
7
|
+
Example:
|
8
|
+
|
9
|
+
class Foo < LazyMapper
|
10
|
+
one :id, Integer, from: 'iden'
|
11
|
+
one :created_at, Time
|
12
|
+
one :amount, Money, map: Money.method(:parse)
|
13
|
+
many :users, User, map: ->(u) { User.new(u) }
|
14
|
+
end
|
15
|
+
|
16
|
+
## License
|
17
|
+
|
18
|
+
See `LICENSE` file.
|
data/lazy_mapper.gemspec
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
Gem::Specification.new do |spec|
|
2
|
+
spec.name = 'lazy_mapper'
|
3
|
+
spec.version = '0.1.0'
|
4
|
+
spec.summary = "A lazy object mapper"
|
5
|
+
spec.description = "Wraps primitive data in a semantically rich model"
|
6
|
+
spec.authors = ["Adam Lett"]
|
7
|
+
spec.email = 'adam@bruun-rasmussen.dk'
|
8
|
+
spec.homepage = 'https://github.com/bruun-rasmussen/lazy_mapper'
|
9
|
+
spec.license = 'MIT'
|
10
|
+
spec.files = `git ls-files -z`.split("\x0") - ['bin/console']
|
11
|
+
spec.executables = []
|
12
|
+
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
13
|
+
spec.require_paths = ['lib']
|
14
|
+
|
15
|
+
spec.add_runtime_dependency 'activesupport', '>= 3'
|
16
|
+
|
17
|
+
spec.add_development_dependency 'bundler'
|
18
|
+
spec.add_development_dependency 'rspec'
|
19
|
+
end
|
data/lib/lazy_mapper.rb
ADDED
@@ -0,0 +1,341 @@
|
|
1
|
+
require 'bigdecimal'
|
2
|
+
require 'bigdecimal/util'
|
3
|
+
require 'time'
|
4
|
+
require 'active_support/core_ext/class/attribute'
|
5
|
+
|
6
|
+
##
|
7
|
+
# Wraps a JSON object and lazily maps its attributes to domain objects
|
8
|
+
# using either a set of default mappers (for Ruby's built-in types), or
|
9
|
+
# custom mappers specified by the client.
|
10
|
+
#
|
11
|
+
# The mapped values are memoized.
|
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
|
+
|
22
|
+
class LazyMapper
|
23
|
+
|
24
|
+
# Default mappings for built-in types
|
25
|
+
DEFAULT_MAPPINGS = {
|
26
|
+
Object => ->(o) { o },
|
27
|
+
String => ->(s) { s.to_s },
|
28
|
+
Integer => ->(i) { i.to_i },
|
29
|
+
BigDecimal => ->(d) { d.to_d },
|
30
|
+
Float => ->(f) { f.to_f },
|
31
|
+
Symbol => ->(s) { s.to_sym },
|
32
|
+
Hash => ->(h) { h.to_h },
|
33
|
+
Time => Time.method(:iso8601),
|
34
|
+
Date => Date.method(:parse),
|
35
|
+
URI => URI.method(:parse)
|
36
|
+
}.freeze
|
37
|
+
|
38
|
+
# Default values for primitive types
|
39
|
+
DEFAULT_VALUES = {
|
40
|
+
String => '',
|
41
|
+
Integer => 0,
|
42
|
+
Numeric => 0,
|
43
|
+
Float => 0.0,
|
44
|
+
BigDecimal => BigDecimal.new('0'),
|
45
|
+
Array => []
|
46
|
+
}.freeze
|
47
|
+
|
48
|
+
def self.mapper_for(type, mapper)
|
49
|
+
mappers[type] = mapper
|
50
|
+
end
|
51
|
+
|
52
|
+
class_attribute :mappers
|
53
|
+
self.mappers = {}
|
54
|
+
|
55
|
+
attr_reader :mappers
|
56
|
+
|
57
|
+
IVAR = -> name {
|
58
|
+
name_as_str = name.to_s
|
59
|
+
if name_as_str[-1] == '?'
|
60
|
+
name_as_str = name_as_str[0...-1]
|
61
|
+
end
|
62
|
+
|
63
|
+
('@' + name_as_str).freeze
|
64
|
+
}
|
65
|
+
|
66
|
+
WRITER = -> name { (name.to_s.gsub('?', '') + '=').to_sym }
|
67
|
+
|
68
|
+
# = ::new
|
69
|
+
#
|
70
|
+
# Create a new instance by giving a Hash of attribues.
|
71
|
+
#
|
72
|
+
# == Example
|
73
|
+
#
|
74
|
+
# Foo.new :id => 42,
|
75
|
+
# :created_at => Time.parse("2015-07-29 14:07:35 +0200"),
|
76
|
+
# :amount => Money.parse("$2.00"),
|
77
|
+
# :users => [
|
78
|
+
# User.new("id" => 23, "name" => "Adam"),
|
79
|
+
# User.new("id" => 45, "name" => "Ole"),
|
80
|
+
# User.new("id" => 66, "name" => "Anders"),
|
81
|
+
# User.new("id" => 91, "name" => "Kristoffer)
|
82
|
+
# ]
|
83
|
+
|
84
|
+
def initialize(values = {})
|
85
|
+
@json = {}
|
86
|
+
@mappers = {}
|
87
|
+
values.each do |name, value|
|
88
|
+
send(WRITER[name], value)
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
# = ::from_json
|
93
|
+
#
|
94
|
+
# Create a new instance by giving a Hash of unmapped attributes.
|
95
|
+
#
|
96
|
+
# The keys in the Hash are assumed to be camelCased strings.
|
97
|
+
#
|
98
|
+
# == Example
|
99
|
+
#
|
100
|
+
# Foo.from_json "xmlId" => 42,
|
101
|
+
# "createdAt" => "2015-07-29 14:07:35 +0200",
|
102
|
+
# "amount" => "$2.00",
|
103
|
+
# "users" => [
|
104
|
+
# { "id" => 23, "name" => "Adam" },
|
105
|
+
# { "id" => 45, "name" => "Ole" },
|
106
|
+
# { "id" => 66, "name" => "Anders" },
|
107
|
+
# { "id" => 91, "name" => "Kristoffer" }
|
108
|
+
# ]
|
109
|
+
#
|
110
|
+
def self.from_json json, mappers: {}
|
111
|
+
return nil if json.nil?
|
112
|
+
fail TypeError, "#{ json.inspect } is not a Hash" unless json.respond_to? :to_h
|
113
|
+
instance = new
|
114
|
+
instance.send :json=, json.to_h
|
115
|
+
instance.send :mappers=, mappers
|
116
|
+
instance
|
117
|
+
end
|
118
|
+
|
119
|
+
def self.attributes
|
120
|
+
@attributes ||= {}
|
121
|
+
end
|
122
|
+
|
123
|
+
# = ::one
|
124
|
+
#
|
125
|
+
# Defines an attribute
|
126
|
+
#
|
127
|
+
# == Arguments
|
128
|
+
#
|
129
|
+
# +name+ - The name of the attribue
|
130
|
+
#
|
131
|
+
# +type+ - The type of the attribute. If the wrapped value is already of
|
132
|
+
# that type, the mapper is bypassed.
|
133
|
+
#
|
134
|
+
# +from:+ - Specifies the name of the wrapped value in the JSON object.
|
135
|
+
# Defaults to camelCased version of +name+.
|
136
|
+
#
|
137
|
+
# +map:+ - Specifies a custom mapper to apply to the wrapped value. Must be
|
138
|
+
# a Callable. If unspecified, it defaults to the default mapper for the
|
139
|
+
# specified +type+ or simply the identity mapper if no default mapper exists.
|
140
|
+
#
|
141
|
+
# +default:+ - The default value to use, if the wrapped value is not present
|
142
|
+
# in the wrapped JSON object.
|
143
|
+
#
|
144
|
+
# == Example
|
145
|
+
#
|
146
|
+
# class Foo < LazyMapper
|
147
|
+
# one :boss, Person, from: "supervisor", map: ->(p) { Person.new(p) }
|
148
|
+
# # ...
|
149
|
+
# end
|
150
|
+
#
|
151
|
+
def self.one(name, type, from: map_name(name), allow_nil: true, **args)
|
152
|
+
|
153
|
+
ivar = IVAR[name]
|
154
|
+
|
155
|
+
# Define writer
|
156
|
+
define_method(WRITER[name]) { |val|
|
157
|
+
check_type! val, type, allow_nil: allow_nil
|
158
|
+
instance_variable_set(ivar, val)
|
159
|
+
}
|
160
|
+
|
161
|
+
# Define reader
|
162
|
+
define_method(name) {
|
163
|
+
memoize(name, ivar) {
|
164
|
+
unmapped_value = json[from]
|
165
|
+
mapped_value(name, unmapped_value, type, **args)
|
166
|
+
}
|
167
|
+
}
|
168
|
+
|
169
|
+
attributes[name] = type
|
170
|
+
end
|
171
|
+
|
172
|
+
##
|
173
|
+
# Converts a value to true or false according to its truthyness
|
174
|
+
TO_BOOL = -> b { !!b }
|
175
|
+
|
176
|
+
# = ::is
|
177
|
+
#
|
178
|
+
# Defines an boolean attribute
|
179
|
+
#
|
180
|
+
# == Arguments
|
181
|
+
#
|
182
|
+
# +name+ - The name of the attribue
|
183
|
+
#
|
184
|
+
# +from:+ - Specifies the name of the wrapped value in the JSON object.
|
185
|
+
# Defaults to camelCased version of +name+.
|
186
|
+
#
|
187
|
+
# +map:+ - Specifies a custom mapper to apply to the wrapped value. Must be
|
188
|
+
# a Callable.
|
189
|
+
# Defaults to TO_BOOL if unspecified.
|
190
|
+
#
|
191
|
+
# +default:+ - The default value to use if the value is missing. False, if unspecified
|
192
|
+
#
|
193
|
+
# == Example
|
194
|
+
#
|
195
|
+
# class Foo < LazyMapper
|
196
|
+
# is :green?, from: "isGreen", map: ->(x) { !x.zero? }
|
197
|
+
# # ...
|
198
|
+
# end
|
199
|
+
#
|
200
|
+
def self.is name, from: map_name(name), map: TO_BOOL, default: false
|
201
|
+
one name, [TrueClass, FalseClass], from: from, allow_nil: false, map: map, default: default
|
202
|
+
end
|
203
|
+
|
204
|
+
singleton_class.send(:alias_method, :has, :is)
|
205
|
+
|
206
|
+
|
207
|
+
# = ::many
|
208
|
+
#
|
209
|
+
# Wraps a collection
|
210
|
+
#
|
211
|
+
# == Arguments
|
212
|
+
#
|
213
|
+
# +name+ - The name of the attribue
|
214
|
+
#
|
215
|
+
# +type+ - The type of the elemnts in the collection. If an element is
|
216
|
+
# already of that type, the mapper is bypassed for that element.
|
217
|
+
#
|
218
|
+
# +from:+ - Specifies the name of the wrapped array in the JSON object.
|
219
|
+
# Defaults to camelCased version of +name+.
|
220
|
+
#
|
221
|
+
# +map:+ - Specifies a custom mapper to apply to the elements in the wrapped
|
222
|
+
# array. Must respond to +#call+. If unspecified, it defaults to the default
|
223
|
+
# mapper for the specified +type+ or simply the identity mapper if no default
|
224
|
+
# mapper exists.
|
225
|
+
#
|
226
|
+
# +default:+ - The default value to use, if the wrapped value is not present
|
227
|
+
# in the wrapped JSON object.
|
228
|
+
#
|
229
|
+
# == Example
|
230
|
+
#
|
231
|
+
# class Bar < LazyMapper
|
232
|
+
# many :underlings, Person, from: "serfs", map: ->(p) { Person.new(p) }
|
233
|
+
# # ...
|
234
|
+
# end
|
235
|
+
#
|
236
|
+
def self.many(name, type, from: map_name(name), **args)
|
237
|
+
|
238
|
+
# Define setter
|
239
|
+
define_method(WRITER[name]) { |val|
|
240
|
+
check_type! val, Enumerable, allow_nil: false
|
241
|
+
instance_variable_set(IVAR[name], val)
|
242
|
+
}
|
243
|
+
|
244
|
+
# Define getter
|
245
|
+
define_method(name) {
|
246
|
+
memoize(name) {
|
247
|
+
unmapped_value = json[from]
|
248
|
+
if unmapped_value.is_a? Array
|
249
|
+
unmapped_value.map { |v| mapped_value(name, v, type, **args) }
|
250
|
+
else
|
251
|
+
mapped_value name, unmapped_value, Array, **args
|
252
|
+
end
|
253
|
+
}
|
254
|
+
}
|
255
|
+
end
|
256
|
+
|
257
|
+
def add_mapper_for(type, &block)
|
258
|
+
mappers[type] = block
|
259
|
+
end
|
260
|
+
|
261
|
+
def inspect
|
262
|
+
attributes = self.class.attributes
|
263
|
+
if self.class.superclass.respond_to? :attributes
|
264
|
+
attributes = self.class.superclass.attributes.merge attributes
|
265
|
+
end
|
266
|
+
present_attributes = attributes.keys.each_with_object({}) {|name, memo|
|
267
|
+
value = self.send name
|
268
|
+
memo[name] = value unless value.nil?
|
269
|
+
}
|
270
|
+
"<#{ self.class.name } #{ present_attributes.map {|k,v| k.to_s + ': ' + v.inspect }.join(', ') } >"
|
271
|
+
end
|
272
|
+
|
273
|
+
protected
|
274
|
+
|
275
|
+
def json
|
276
|
+
@json ||= {}
|
277
|
+
end
|
278
|
+
|
279
|
+
##
|
280
|
+
# Defines how to map an attribute name
|
281
|
+
# to the corresponding name in the unmapped
|
282
|
+
# JSON object.
|
283
|
+
#
|
284
|
+
# Defaults to #camelize
|
285
|
+
#
|
286
|
+
def self.map_name(name)
|
287
|
+
camelize(name)
|
288
|
+
end
|
289
|
+
|
290
|
+
private
|
291
|
+
|
292
|
+
attr_writer :json
|
293
|
+
attr_writer :mappers
|
294
|
+
|
295
|
+
def mapping_for(name, type)
|
296
|
+
mappers[name] || mappers[type] || self.class.mappers[type] || DEFAULT_MAPPINGS[type]
|
297
|
+
end
|
298
|
+
|
299
|
+
def default_value(type)
|
300
|
+
DEFAULT_VALUES.fetch(type) { nil }
|
301
|
+
end
|
302
|
+
|
303
|
+
def mapped_value(name, unmapped_value, type, map: mapping_for(name, type), default: default_value(type))
|
304
|
+
if unmapped_value.nil?
|
305
|
+
# Duplicate to prevent accidental sharing between instances
|
306
|
+
default.dup
|
307
|
+
else
|
308
|
+
fail ArgumentError, "missing mapper for #{ name } (#{ type }). Unmapped value: #{ unmapped_value.inspect }" if map.nil?
|
309
|
+
result = map.arity > 1 ? map.call(unmapped_value, self) : map.call(unmapped_value)
|
310
|
+
result
|
311
|
+
end
|
312
|
+
end
|
313
|
+
|
314
|
+
def check_type! value, type, allow_nil:
|
315
|
+
permitted_types = allow_nil ? Array(type) + [ NilClass ] : Array(type)
|
316
|
+
fail TypeError.new "#{ self.class.name }: #{ value.inspect } is a #{ value.class } but was supposed to be a #{ humanize_list permitted_types, conjunction: ' or ' }" unless permitted_types.any? value.method(:is_a?)
|
317
|
+
end
|
318
|
+
|
319
|
+
# [1,2,3] -> "1, 2 and 3"
|
320
|
+
# [1, 2] -> "1 and 2"
|
321
|
+
# [1] -> "1"
|
322
|
+
def humanize_list list, separator: ', ', conjunction: ' and '
|
323
|
+
*all_but_last, last = list
|
324
|
+
return last if all_but_last.empty?
|
325
|
+
[ all_but_last.join(separator), last ].join conjunction
|
326
|
+
end
|
327
|
+
|
328
|
+
|
329
|
+
def memoize name, ivar = IVAR[name]
|
330
|
+
send WRITER[name], yield unless instance_variable_defined?(ivar)
|
331
|
+
instance_variable_get(ivar)
|
332
|
+
end
|
333
|
+
|
334
|
+
SNAKE_CASE_PATTERN = /(_[a-z])/
|
335
|
+
|
336
|
+
# "foo_bar_baz" -> "fooBarBaz"
|
337
|
+
# "foo_bar?" -> "fooBar"
|
338
|
+
def self.camelize(name)
|
339
|
+
name.to_s.gsub(SNAKE_CASE_PATTERN) { |x| x[1].upcase }.gsub('?', '')
|
340
|
+
end
|
341
|
+
end
|
@@ -0,0 +1,247 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'spec_helper'
|
4
|
+
require 'lazy_mapper'
|
5
|
+
|
6
|
+
describe LazyMapper do
|
7
|
+
|
8
|
+
describe '.from_json' do
|
9
|
+
|
10
|
+
subject(:instance) { klass.from_json json }
|
11
|
+
|
12
|
+
let(:json) { nil }
|
13
|
+
let(:klass) {
|
14
|
+
t = type
|
15
|
+
m = map
|
16
|
+
Class.new LazyMapper do
|
17
|
+
one :created_at, Date
|
18
|
+
many :updated_at, Date
|
19
|
+
one :foo, t, map: m, default: 666
|
20
|
+
is :blue?
|
21
|
+
end
|
22
|
+
}
|
23
|
+
let(:mapper) { spy 'mapper', map: 42 }
|
24
|
+
let(:map) { ->(x) { mapper.map(x) } }
|
25
|
+
let(:type) { Integer }
|
26
|
+
|
27
|
+
context 'if the supplied data is nil' do
|
28
|
+
it { is_expected.to be_nil }
|
29
|
+
end
|
30
|
+
|
31
|
+
context 'when invalid data is supplied' do
|
32
|
+
let(:json) { 'not a hash' }
|
33
|
+
|
34
|
+
it 'fails with a TypeError' do
|
35
|
+
expect { instance }.to raise_error(TypeError)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
context 'when valid data is supplied' do
|
40
|
+
|
41
|
+
let(:json) {
|
42
|
+
{
|
43
|
+
'createdAt' => '2015-07-27',
|
44
|
+
'updatedAt' => ['2015-01-01', '2015-01-02'],
|
45
|
+
'foo' => '42',
|
46
|
+
'blue' => true
|
47
|
+
}
|
48
|
+
}
|
49
|
+
|
50
|
+
it 'maps JSON attributes to domain objects' do
|
51
|
+
expect(instance.created_at).to eq(Date.new(2015, 7, 27))
|
52
|
+
end
|
53
|
+
|
54
|
+
it 'maps arrays of JSON values to arrays of domain objects' do
|
55
|
+
expect(instance.updated_at).to be_a(Array)
|
56
|
+
expect(instance.updated_at.first).to be_a(Date)
|
57
|
+
expect(instance).to be_blue
|
58
|
+
end
|
59
|
+
|
60
|
+
it 'memoizes mapped value so that potentially expensive mappings are performed just once' do
|
61
|
+
3.times do
|
62
|
+
expect(instance.foo).to eq(42)
|
63
|
+
end
|
64
|
+
expect(mapper).to have_received(:map).exactly(1).times.with('42')
|
65
|
+
end
|
66
|
+
|
67
|
+
context 'if the mapped value is nil' do
|
68
|
+
let(:map) { -> x { mapper.map(x); nil } }
|
69
|
+
|
70
|
+
it 'even memoizes that' do
|
71
|
+
3.times do
|
72
|
+
expect(instance.foo).to be_nil
|
73
|
+
end
|
74
|
+
expect(mapper).to have_received(:map).exactly(1).times.with('42')
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
describe 'the :from option' do
|
80
|
+
|
81
|
+
let(:klass) {
|
82
|
+
Class.new LazyMapper do
|
83
|
+
one :baz, Integer, from: 'BAZ'
|
84
|
+
is :fuzzy?, from: 'hairy'
|
85
|
+
is :sweet?, from: 'sugary'
|
86
|
+
end
|
87
|
+
}
|
88
|
+
|
89
|
+
let(:json) { { 'BAZ' => 999, 'hairy' => true } }
|
90
|
+
|
91
|
+
it 'specifies a different name in the JSON object for the attribute' do
|
92
|
+
expect(instance.baz).to eq(999)
|
93
|
+
end
|
94
|
+
|
95
|
+
it { is_expected.to be_fuzzy }
|
96
|
+
it { is_expected.to_not be_sweet }
|
97
|
+
end
|
98
|
+
|
99
|
+
context "if the mapper doesn't map to the correct type" do
|
100
|
+
|
101
|
+
let(:klass) {
|
102
|
+
Class.new LazyMapper do
|
103
|
+
one :bar, Float, map: ->(x) { x.to_s }
|
104
|
+
end
|
105
|
+
}
|
106
|
+
|
107
|
+
it 'fails with a TypeError when an attribute is accessed' do
|
108
|
+
instance = klass.from_json 'bar' => 42
|
109
|
+
expect { instance.bar }.to raise_error(TypeError)
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
it 'supports adding custom type mappers to instances' do
|
114
|
+
type = Struct.new(:val1, :val2)
|
115
|
+
klass = Class.new LazyMapper do
|
116
|
+
one :composite, type
|
117
|
+
end
|
118
|
+
|
119
|
+
instance = klass.from_json 'composite' => '123 456'
|
120
|
+
instance.add_mapper_for(type) { |unmapped_value| type.new(*unmapped_value.split(' ')) }
|
121
|
+
|
122
|
+
expect(instance.composite).to eq type.new('123', '456')
|
123
|
+
|
124
|
+
instance = klass.new composite: type.new('abc', 'cde')
|
125
|
+
expect(instance.composite).to eq type.new('abc', 'cde')
|
126
|
+
end
|
127
|
+
|
128
|
+
it 'supports adding default mappers to derived classes' do
|
129
|
+
type = Struct.new(:val1, :val2)
|
130
|
+
|
131
|
+
klass = Class.new LazyMapper do
|
132
|
+
mapper_for type, ->(unmapped_value) { type.new(*unmapped_value.split(' ')) }
|
133
|
+
one :composite, type
|
134
|
+
end
|
135
|
+
|
136
|
+
instance = klass.from_json 'composite' => '123 456'
|
137
|
+
expect(instance.composite).to eq type.new('123', '456')
|
138
|
+
end
|
139
|
+
|
140
|
+
it 'supports injection of customer mappers during instantiation' do
|
141
|
+
type = Struct.new(:val1, :val2)
|
142
|
+
klass = Class.new LazyMapper do
|
143
|
+
one :foo, type
|
144
|
+
one :bar, type
|
145
|
+
end
|
146
|
+
|
147
|
+
instance = klass.from_json({ 'foo' => '123 456', 'bar' => 'abc def' },
|
148
|
+
mappers: {
|
149
|
+
foo: ->(f) { type.new(*f.split(' ').reverse) },
|
150
|
+
type => ->(t) { type.new(*t.split(' ')) }
|
151
|
+
})
|
152
|
+
|
153
|
+
expect(instance.foo).to eq type.new('456', '123')
|
154
|
+
expect(instance.bar).to eq type.new('abc', 'def')
|
155
|
+
end
|
156
|
+
|
157
|
+
it 'expects the supplied mapper to return an Array if the unmapped value of a "many" attribute is not an array' do
|
158
|
+
klass = Class.new LazyMapper do
|
159
|
+
many :foos, String, map: ->(v) { return v.split '' }
|
160
|
+
many :bars, String, map: ->(v) { return v }
|
161
|
+
end
|
162
|
+
|
163
|
+
instance = klass.from_json 'foos' => 'abc', 'bars' => 'abc'
|
164
|
+
|
165
|
+
expect(instance.foos).to eq %w[a b c]
|
166
|
+
expect { instance.bars }.to raise_error(TypeError)
|
167
|
+
end
|
168
|
+
end
|
169
|
+
|
170
|
+
context 'construction' do
|
171
|
+
|
172
|
+
subject(:instance) { klass.new values }
|
173
|
+
let(:values) { {} }
|
174
|
+
|
175
|
+
let(:klass) {
|
176
|
+
Class.new LazyMapper do
|
177
|
+
one :title, String
|
178
|
+
one :count, Integer
|
179
|
+
one :rate, Float
|
180
|
+
one :tags, Array
|
181
|
+
one :widget, Object
|
182
|
+
one :things, Array, default: ['something']
|
183
|
+
is :green?
|
184
|
+
has :flowers?
|
185
|
+
end
|
186
|
+
}
|
187
|
+
|
188
|
+
context 'when values are provided' do
|
189
|
+
|
190
|
+
let(:values) {
|
191
|
+
{
|
192
|
+
title: 'A title',
|
193
|
+
count: 42,
|
194
|
+
rate: 3.14,
|
195
|
+
tags: %w[red hot],
|
196
|
+
widget: Date.new,
|
197
|
+
things: %i[one two three],
|
198
|
+
green?: true
|
199
|
+
}
|
200
|
+
}
|
201
|
+
|
202
|
+
it 'uses those values' do
|
203
|
+
values.each do |name, value|
|
204
|
+
expect(instance.send(name)).to eq(value)
|
205
|
+
end
|
206
|
+
end
|
207
|
+
|
208
|
+
context 'if a value given in the constructor is not of the specified type' do
|
209
|
+
let(:values) {
|
210
|
+
{ title: :'Not a string' }
|
211
|
+
}
|
212
|
+
|
213
|
+
it 'fails with a TypeError' do
|
214
|
+
expect { instance }.to raise_error(TypeError)
|
215
|
+
end
|
216
|
+
end
|
217
|
+
end
|
218
|
+
|
219
|
+
context 'when no values are provided' do
|
220
|
+
|
221
|
+
it 'have sensible fallback values for primitive types' do
|
222
|
+
expect(instance.title).to eq('')
|
223
|
+
expect(instance.count).to eq(0)
|
224
|
+
expect(instance.rate).to eq(0.0)
|
225
|
+
expect(instance.widget).to be_nil
|
226
|
+
expect(instance.tags).to eq []
|
227
|
+
end
|
228
|
+
|
229
|
+
it 'use the supplied default values' do
|
230
|
+
expect(instance.things).to eq(['something'])
|
231
|
+
end
|
232
|
+
|
233
|
+
it 'fall back to nil in all other cases' do
|
234
|
+
expect(instance.widget).to be_nil
|
235
|
+
end
|
236
|
+
|
237
|
+
it 'don\'t share their default values between instances' do
|
238
|
+
instance1 = klass.new
|
239
|
+
instance2 = klass.new
|
240
|
+
instance1.tags << 'dirty'
|
241
|
+
instance1.things.pop
|
242
|
+
expect(instance2.tags).to be_empty
|
243
|
+
expect(instance2.things).to_not be_empty
|
244
|
+
end
|
245
|
+
end
|
246
|
+
end
|
247
|
+
end
|
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
$LOAD_PATH.unshift File.expand_path(File.dirname(__FILE__) + '/../lib')
|
4
|
+
$LOAD_PATH.unshift __dir__
|
5
|
+
|
6
|
+
if ENV['RCOV']
|
7
|
+
require 'simplecov'
|
8
|
+
SimpleCov.start do
|
9
|
+
add_filter '/spec/'
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
RSpec.configure do |config|
|
14
|
+
config.pattern = '**{,/*/**}/*{_,.}spec.rb'
|
15
|
+
end
|
metadata
ADDED
@@ -0,0 +1,95 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: lazy_mapper
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Adam Lett
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2018-08-21 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: activesupport
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '3'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '3'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: bundler
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '0'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ">="
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: rspec
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ">="
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ">="
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
55
|
+
description: Wraps primitive data in a semantically rich model
|
56
|
+
email: adam@bruun-rasmussen.dk
|
57
|
+
executables: []
|
58
|
+
extensions: []
|
59
|
+
extra_rdoc_files: []
|
60
|
+
files:
|
61
|
+
- ".gitignore"
|
62
|
+
- Gemfile
|
63
|
+
- LICENCE
|
64
|
+
- README.md
|
65
|
+
- lazy_mapper.gemspec
|
66
|
+
- lib/lazy_mapper.rb
|
67
|
+
- spec/lazy_mapper_spec.rb
|
68
|
+
- spec/spec_helper.rb
|
69
|
+
homepage: https://github.com/bruun-rasmussen/lazy_mapper
|
70
|
+
licenses:
|
71
|
+
- MIT
|
72
|
+
metadata: {}
|
73
|
+
post_install_message:
|
74
|
+
rdoc_options: []
|
75
|
+
require_paths:
|
76
|
+
- lib
|
77
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
78
|
+
requirements:
|
79
|
+
- - ">="
|
80
|
+
- !ruby/object:Gem::Version
|
81
|
+
version: '0'
|
82
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
83
|
+
requirements:
|
84
|
+
- - ">="
|
85
|
+
- !ruby/object:Gem::Version
|
86
|
+
version: '0'
|
87
|
+
requirements: []
|
88
|
+
rubyforge_project:
|
89
|
+
rubygems_version: 2.7.3
|
90
|
+
signing_key:
|
91
|
+
specification_version: 4
|
92
|
+
summary: A lazy object mapper
|
93
|
+
test_files:
|
94
|
+
- spec/lazy_mapper_spec.rb
|
95
|
+
- spec/spec_helper.rb
|