lazy_mapper 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|