attributor 2.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.
Files changed (57) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +15 -0
  3. data/.rspec +2 -0
  4. data/.travis.yml +4 -0
  5. data/CHANGELOG.md +52 -0
  6. data/Gemfile +3 -0
  7. data/Guardfile +12 -0
  8. data/LICENSE +22 -0
  9. data/README.md +62 -0
  10. data/Rakefile +28 -0
  11. data/attributor.gemspec +40 -0
  12. data/lib/attributor.rb +89 -0
  13. data/lib/attributor/attribute.rb +271 -0
  14. data/lib/attributor/attribute_resolver.rb +116 -0
  15. data/lib/attributor/dsl_compiler.rb +106 -0
  16. data/lib/attributor/exceptions.rb +38 -0
  17. data/lib/attributor/extensions/randexp.rb +10 -0
  18. data/lib/attributor/type.rb +117 -0
  19. data/lib/attributor/types/boolean.rb +26 -0
  20. data/lib/attributor/types/collection.rb +135 -0
  21. data/lib/attributor/types/container.rb +42 -0
  22. data/lib/attributor/types/csv.rb +10 -0
  23. data/lib/attributor/types/date_time.rb +36 -0
  24. data/lib/attributor/types/file_upload.rb +11 -0
  25. data/lib/attributor/types/float.rb +27 -0
  26. data/lib/attributor/types/hash.rb +337 -0
  27. data/lib/attributor/types/ids.rb +26 -0
  28. data/lib/attributor/types/integer.rb +63 -0
  29. data/lib/attributor/types/model.rb +316 -0
  30. data/lib/attributor/types/object.rb +19 -0
  31. data/lib/attributor/types/string.rb +25 -0
  32. data/lib/attributor/types/struct.rb +50 -0
  33. data/lib/attributor/types/tempfile.rb +36 -0
  34. data/lib/attributor/version.rb +3 -0
  35. data/spec/attribute_resolver_spec.rb +227 -0
  36. data/spec/attribute_spec.rb +597 -0
  37. data/spec/attributor_spec.rb +25 -0
  38. data/spec/dsl_compiler_spec.rb +130 -0
  39. data/spec/spec_helper.rb +30 -0
  40. data/spec/support/models.rb +81 -0
  41. data/spec/support/types.rb +21 -0
  42. data/spec/type_spec.rb +134 -0
  43. data/spec/types/boolean_spec.rb +85 -0
  44. data/spec/types/collection_spec.rb +286 -0
  45. data/spec/types/container_spec.rb +49 -0
  46. data/spec/types/csv_spec.rb +17 -0
  47. data/spec/types/date_time_spec.rb +90 -0
  48. data/spec/types/file_upload_spec.rb +6 -0
  49. data/spec/types/float_spec.rb +78 -0
  50. data/spec/types/hash_spec.rb +372 -0
  51. data/spec/types/ids_spec.rb +32 -0
  52. data/spec/types/integer_spec.rb +151 -0
  53. data/spec/types/model_spec.rb +401 -0
  54. data/spec/types/string_spec.rb +55 -0
  55. data/spec/types/struct_spec.rb +189 -0
  56. data/spec/types/tempfile_spec.rb +6 -0
  57. metadata +348 -0
@@ -0,0 +1,32 @@
1
+ require File.join(File.dirname(__FILE__), '..', 'spec_helper.rb')
2
+
3
+ describe Attributor::Ids do
4
+
5
+ context '.for' do
6
+ let(:chickens) { 10.times.collect { Chicken.example } }
7
+
8
+ let(:emails) { chickens.collect(&:email) }
9
+ let(:value) { emails.join(',') }
10
+
11
+ subject!(:ids) { Attributor::Ids.for(Chicken) }
12
+
13
+ its(:member_attribute) { should be(Chicken.attributes[:email]) }
14
+
15
+ it 'loads' do
16
+ ids.load(value).should eq(emails)
17
+ end
18
+
19
+ it 'generates valid examples' do
20
+ ids.validate(ids.example).should be_empty
21
+ end
22
+
23
+ end
24
+
25
+ context 'attempting to define it as a collection using .of(type)' do
26
+ it 'raises an error' do
27
+ expect{
28
+ Attributor::Ids.of(Chicken)
29
+ }.to raise_error(/Defining Ids.of\(type\) is not allowed/)
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,151 @@
1
+ require File.join(File.dirname(__FILE__), '..', 'spec_helper.rb')
2
+
3
+ describe Attributor::Integer do
4
+
5
+ subject(:type) { Attributor::Integer }
6
+
7
+ context '.example' do
8
+
9
+ context 'when :min and :max are unspecified' do
10
+ context 'valid cases' do
11
+ it "returns an Integer in the range [0,#{Attributor::Integer::EXAMPLE_RANGE}]" do
12
+ 20.times do
13
+ value = type.example
14
+ value.should be_a(::Integer)
15
+ value.should <= Attributor::Integer::EXAMPLE_RANGE
16
+ value.should >= 0
17
+ end
18
+ end
19
+ end
20
+ end
21
+
22
+ context 'when :min is unspecified' do
23
+ context 'valid cases' do
24
+ [5, 100000000000000000000, -100000000000000000000].each do |max|
25
+ it "returns an Integer in the range [,#{max.inspect}]" do
26
+ 20.times do
27
+ value = type.example(nil, options: {max: max})
28
+ value.should be_a(::Integer)
29
+ value.should <= max
30
+ value.should >= max - Attributor::Integer::EXAMPLE_RANGE
31
+ end
32
+ end
33
+ end
34
+ end
35
+
36
+ context 'invalid cases' do
37
+ ['invalid', false].each do |max|
38
+ it "raises for the invalid range [,#{max.inspect}]" do
39
+ expect {
40
+ value = type.example(nil, options: {max: max})
41
+ value.should be_a(::Integer)
42
+ }.to raise_error(Attributor::AttributorException, "Invalid range: [, #{max.inspect}]")
43
+ end
44
+ end
45
+ end
46
+ end
47
+
48
+ context 'when :max is unspecified' do
49
+ context 'valid cases' do
50
+ [1, -100000000000000000000, 100000000000000000000].each do |min|
51
+ it "returns an Integer in the range [#{min.inspect},]" do
52
+ 20.times do
53
+ value = type.example(nil, options: {min: min})
54
+ value.should be_a(::Integer)
55
+ value.should <= min + Attributor::Integer::EXAMPLE_RANGE
56
+ value.should >= min
57
+ end
58
+ end
59
+ end
60
+ end
61
+
62
+ context 'invalid cases' do
63
+ ['invalid', false].each do |min|
64
+ it "raises for the invalid range [#{min.inspect},]" do
65
+ expect {
66
+ value = type.example(nil, options: {min: min})
67
+ value.should be_a(::Integer)
68
+ }.to raise_error(Attributor::AttributorException, "Invalid range: [#{min.inspect},]")
69
+ end
70
+ end
71
+ end
72
+ end
73
+
74
+ context 'when :min and :max are specified' do
75
+ context 'valid cases' do
76
+ [
77
+ [1,1],
78
+ [1,5],
79
+ [-2,-2],
80
+ [-3,2],
81
+ [-1000000000000000,1000000000000000]
82
+ ].each do |min, max|
83
+ it "returns an Integer in the range [#{min.inspect},#{max.inspect}]" do
84
+ 20.times do
85
+ value = type.example(nil, options: {max: max, min: min})
86
+ value.should <= max
87
+ value.should >= min
88
+ end
89
+ end
90
+ end
91
+ end
92
+
93
+ context 'invalid cases' do
94
+ [[1,-1], [1,"5"], ["-2",4], [false, false], [true, true]].each do |min, max|
95
+ it "raises for the invalid range [#{min.inspect}, #{max.inspect}]" do
96
+ opts = {options: {max: max, min: min}}
97
+ expect {
98
+ type.example(nil, opts)
99
+ }.to raise_error(Attributor::AttributorException, "Invalid range: [#{min.inspect}, #{max.inspect}]")
100
+ end
101
+ end
102
+ end
103
+
104
+ end
105
+ end
106
+
107
+ context '.load' do
108
+ let(:value) { nil }
109
+
110
+
111
+ context 'for incoming integer values' do
112
+ let(:value) { 1 }
113
+
114
+ it 'returns the incoming value' do
115
+ type.load(value).should be(value)
116
+ end
117
+ end
118
+
119
+ context 'for incoming string values' do
120
+
121
+
122
+ context 'that are valid integers' do
123
+ let(:value) { '1024' }
124
+ it 'decodes it if the string represents an integer' do
125
+ type.load(value).should == 1024
126
+ end
127
+ end
128
+
129
+ context 'that are not valid integers' do
130
+
131
+ context 'with simple alphanumeric text' do
132
+ let(:value) { 'not an integer' }
133
+
134
+ it 'raises an error' do
135
+ expect { type.load(value) }.to raise_error(/invalid value/)
136
+ end
137
+ end
138
+
139
+ context 'with a floating point value' do
140
+ let(:value) { '98.76' }
141
+ it 'raises an error' do
142
+ expect { type.load(value) }.to raise_error(/invalid value/)
143
+ end
144
+ end
145
+
146
+ end
147
+
148
+ end
149
+ end
150
+ end
151
+
@@ -0,0 +1,401 @@
1
+ require File.join(File.dirname(__FILE__), '..', 'spec_helper.rb')
2
+
3
+ describe Attributor::Model do
4
+
5
+ context 'class methods' do
6
+ subject(:chicken) { Chicken }
7
+ let(:context){ ["root","subattr"] }
8
+
9
+ its(:native_type) { should eq(Chicken) }
10
+
11
+ context '.example' do
12
+ subject(:chicken) { Chicken.example }
13
+
14
+ let(:age_opts) { {options: Chicken.attributes[:age].options } }
15
+ let(:age) { /\d{2}/.gen.to_i }
16
+
17
+ context 'for a simple model' do
18
+ it { should be_kind_of(Chicken) }
19
+
20
+ context 'and attribute without :example option' do
21
+ before do
22
+ Attributor::Integer.should_receive(:example).with(kind_of(Array), age_opts).and_return(age)
23
+ end
24
+
25
+ its(:age) { should == age }
26
+ end
27
+
28
+ context 'and attribute with :example options' do
29
+ before do
30
+ Attributor::Integer.should_not_receive(:example) # due to lazy-evaluation of examples
31
+ Attributor::String.should_not_receive(:example) # due to the :example option on the attribute
32
+ end
33
+ its(:email) { should =~ /\w+@.*\.example\.org/ }
34
+ end
35
+
36
+ context 'with given values' do
37
+ let(:name) { 'Sir Clucksalot' }
38
+ subject(:example) { Chicken.example(name: name)}
39
+
40
+ its(:name) {should eq(name) }
41
+ end
42
+ end
43
+
44
+ context 'generating multiple examples' do
45
+ context 'without a context' do
46
+ subject(:other_chicken) { Chicken.example }
47
+ its(:attributes) { should_not eq(chicken.attributes) }
48
+ end
49
+ context 'with identical contexts' do
50
+ let(:example_context) { 'some context' }
51
+ let(:some_chicken) { Chicken.example(example_context) }
52
+ subject(:another_chicken) { Chicken.example(example_context) }
53
+
54
+ its(:attributes) { should eq(some_chicken.attributes) }
55
+ end
56
+
57
+ end
58
+
59
+ context 'with attributes that are also models' do
60
+ subject(:turducken) { Turducken.example }
61
+
62
+ its(:attributes) { should have_key(:chicken) }
63
+ its(:chicken) { should be_kind_of(Chicken)}
64
+ end
65
+
66
+ context 'with infinitely-expanding sub-attributes' do
67
+ let(:model_class) do
68
+ Class.new(Attributor::Model) do
69
+ this = self
70
+ attributes do
71
+ attribute :name, String
72
+ attribute :child, this
73
+ end
74
+ end
75
+ end
76
+
77
+ subject(:example) { model_class.example }
78
+
79
+ it 'terminates example generation at MAX_EXAMPLE_DEPTH' do
80
+ # call .child on example MAX_EXAMPLE_DEPTH times
81
+ terminal_child = Attributor::Model::MAX_EXAMPLE_DEPTH.times.inject(example) do |object, i|
82
+ object.child
83
+ end
84
+ # after which .child will return nil
85
+ terminal_child.child.should be(nil)
86
+ # but simple attributes will be generated
87
+ terminal_child.name.should_not be(nil)
88
+ end
89
+
90
+
91
+ end
92
+ end
93
+
94
+
95
+ context '.definition' do
96
+ subject(:definition) { Chicken.definition }
97
+
98
+ context '#attributes' do
99
+ subject(:attributes) { Chicken.attributes }
100
+ it { should have_key :age }
101
+ it { should have_key :email }
102
+ end
103
+ end
104
+
105
+
106
+ context '.load' do
107
+ let(:age) { 1 }
108
+ let(:email) { "cluck@example.org" }
109
+ let(:hash) { {:age => age, :email => email} }
110
+
111
+ subject(:model) { Chicken.load(hash) }
112
+
113
+ context 'with an instance of the model' do
114
+ it 'returns the instance' do
115
+ Chicken.load(model).should be(model)
116
+ end
117
+ end
118
+
119
+ context 'with a nil value' do
120
+ it 'returns nil' do
121
+ Chicken.load(nil).should be_nil
122
+ end
123
+ end
124
+
125
+ context 'with a JSON-serialized hash' do
126
+ let(:context){ ['root','subattr'] }
127
+ let(:expected_hash) { {"age" => age, "email" => email} }
128
+ let(:json) { hash.to_json }
129
+ before do
130
+ Chicken.should_receive(:from_hash).
131
+ with(expected_hash,context)
132
+ JSON.should_receive(:parse).with(json).and_call_original
133
+ end
134
+
135
+ it 'deserializes and calls from_hash' do
136
+ Chicken.load(json,context)
137
+ end
138
+ end
139
+
140
+ context 'with an invalid JSON string' do
141
+ let(:json) { "{'invalid'}" }
142
+
143
+ it 'catches the error and reports it correctly' do
144
+ JSON.should_receive(:parse).with(json).and_call_original
145
+ expect {
146
+ Chicken.load(json,context)
147
+ }.to raise_error(Attributor::DeserializationError, /Error deserializing a String using JSON.*#{context.join('.')}/)
148
+ end
149
+ end
150
+
151
+
152
+ context 'with an invalid object type' do
153
+ it 'raises some sort of error' do
154
+ expect {
155
+ Chicken.load(Object.new, context)
156
+ }.to raise_error(Attributor::IncompatibleTypeError, /Type Chicken cannot load values of type Object.*#{context.join('.')}/)
157
+ end
158
+ end
159
+
160
+ context "with a hash" do
161
+ context 'for a complete set of attributes' do
162
+ it 'loads the given attributes' do
163
+ model.age.should == age
164
+ model.email.should == email
165
+ end
166
+ end
167
+
168
+ context 'for a subset of attributes' do
169
+ let(:hash) { Hash.new }
170
+
171
+ it 'sets the defaults' do
172
+ model.age.should == 1
173
+ model.email.should == nil
174
+ end
175
+ end
176
+
177
+ context 'for a superset of attributes' do
178
+ let(:hash) { {"invalid_attribute" => "value"} }
179
+
180
+ it 'raises an error' do
181
+ expect {
182
+ Chicken.load(hash, context)
183
+ }.to raise_error(Attributor::AttributorException, /Unknown attributes.*#{context.join('.')}/)
184
+ end
185
+ end
186
+ end
187
+
188
+
189
+ end
190
+
191
+ end
192
+
193
+
194
+ context 'instance methods' do
195
+ subject(:chicken) { Chicken.new }
196
+
197
+ context '#respond_to?' do
198
+ [:age, :email, :age=, :email=].each do |method|
199
+ it { should respond_to(method) }
200
+ end
201
+ end
202
+
203
+ context 'initialize' do
204
+
205
+ subject(:chicken) { Chicken.new( attributes_data ) }
206
+ context 'supports passing an initial hash object for attribute values' do
207
+ let(:attributes_data){ {age: '1', email:'rooster@coup.com'} }
208
+ it 'and sets them in loaded format onto the instance attributes' do
209
+ Chicken.should_receive(:load).with(attributes_data).and_call_original
210
+ attributes_data.keys.each do |attr_name|
211
+ Chicken.attributes[attr_name].should_receive(:load).with(attributes_data[attr_name],instance_of(Array)).and_call_original
212
+ end
213
+ subject.age.should be(1)
214
+ subject.email.should be(attributes_data[:email])
215
+ end
216
+ end
217
+ context 'supports passing a JSON encoded data object' do
218
+ let(:attributes_hash){ {age: 1, email:'rooster@coup.com'} }
219
+ let(:attributes_data){ JSON.dump(attributes_hash) }
220
+ it 'and sets them in loaded format onto the instance attributes' do
221
+ Chicken.should_receive(:load).with(attributes_data).and_call_original
222
+ attributes_hash.keys.each do |attr_name|
223
+ Chicken.attributes[attr_name].should_receive(:load).with(attributes_hash[attr_name],instance_of(Array)).and_call_original
224
+ end
225
+ subject.age.should be(1)
226
+ subject.email.should == attributes_hash[:email]
227
+ end
228
+ end
229
+ context 'supports passing a native model for the data object' do
230
+ let(:attributes_data){ Chicken.example }
231
+ it 'sets a new instance pointing to the exact same attributes (careful about modifications!)' do
232
+ attributes_data.attributes.each do |attr_name, attr_value|
233
+ subject.send(attr_name).should be(attr_value)
234
+ end
235
+ end
236
+ end
237
+ end
238
+
239
+ context 'getting and setting attributes' do
240
+ context 'for valid attributes' do
241
+ let(:age) { 1 }
242
+ it 'gets and sets attributes' do
243
+ chicken.age = age
244
+ chicken.age.should == age
245
+ end
246
+ end
247
+
248
+ context 'setting nil' do
249
+ it 'assigns the default value if there is one' do
250
+ chicken.age = nil
251
+ chicken.age.should == 1
252
+ end
253
+
254
+ it 'sets the value to nil if there is no default' do
255
+ chicken.email = nil
256
+ chicken.email.should == nil
257
+ end
258
+
259
+ end
260
+
261
+ context 'for unknown attributes' do
262
+ it 'raises an exception' do
263
+ expect {
264
+ chicken.invalid_attribute = 'value'
265
+ }.to raise_error(NoMethodError, /undefined method/)
266
+ end
267
+ end
268
+
269
+ context 'for false attributes' do
270
+ subject(:person) { Person.example(okay: false) }
271
+ it 'properly memoizes the value' do
272
+ person.okay.should be(false)
273
+ person.okay.should be(false) # second call to ensure we hit the memoized value
274
+ end
275
+ end
276
+ end
277
+
278
+ end
279
+
280
+
281
+ context 'validation' do
282
+ context 'for simple models' do
283
+ context 'that are valid' do
284
+ subject(:chicken) { Chicken.example }
285
+ its(:validate) { should be_empty}
286
+ end
287
+ context 'that are invalid' do
288
+ subject(:chicken) { Chicken.example(age: 150) }
289
+ its(:validate) { should_not be_empty }
290
+ end
291
+ end
292
+
293
+ context 'for models with circular sub-attributes' do
294
+ context 'that are valid' do
295
+ subject(:person) { Person.example }
296
+ its(:validate) { should be_empty}
297
+ end
298
+
299
+ context 'that are invalid' do
300
+ subject(:person) do
301
+ # TODO: support this? Person.example(title: 'dude', address: {name: 'ME'} )
302
+
303
+ obj = Person.example(title: 'dude')
304
+ obj.address.state = 'ME'
305
+ obj
306
+ end
307
+
308
+ its(:validate) { should have(2).items }
309
+
310
+ it 'recursively-validates sub-attributes with the right context' do
311
+ title_error, state_error = person.validate('person')
312
+ title_error.should =~ /^Attribute person\.title:/
313
+ state_error.should =~ /^Attribute person\.address\.state:/
314
+ end
315
+
316
+ end
317
+
318
+
319
+ end
320
+ end
321
+
322
+
323
+ context '#dump' do
324
+
325
+
326
+ context 'with circular references' do
327
+ subject(:person) { Person.example }
328
+ let(:output) { person.dump }
329
+
330
+ it 'terminates' do
331
+ expect {
332
+ Person.example.dump
333
+ }.to_not raise_error
334
+ end
335
+
336
+ it 'outputs "..." for circular references' do
337
+ person.address.person.should be(person)
338
+ output[:address][:person].should eq(Attributor::Model::CIRCULAR_REFERENCE_MARKER)
339
+ end
340
+
341
+ end
342
+
343
+ end
344
+
345
+ context 'extending' do
346
+ subject(:model) do
347
+ Class.new(Attributor::Model) do
348
+ attributes do
349
+ attribute :id, Integer
350
+ attribute :timestamps do
351
+ attribute :created_at, DateTime
352
+ end
353
+ end
354
+ end
355
+ end
356
+
357
+ context 'adding a top-level attribute' do
358
+ before do
359
+ model.attributes do
360
+ attribute :name, String
361
+ end
362
+ end
363
+
364
+ it 'adds the attribute' do
365
+ model.attributes.keys.should =~ [:id, :name, :timestamps]
366
+ end
367
+
368
+ end
369
+
370
+ context 'adding to an inner-Struct' do
371
+ before do
372
+ model.attributes do
373
+ attribute :timestamps, Struct do
374
+ attribute :updated_at, DateTime
375
+ end
376
+ end
377
+ end
378
+
379
+ it 'merges with sub-attributes' do
380
+ model.attributes[:timestamps].attributes.keys.should =~ [:created_at, :updated_at]
381
+ end
382
+ end
383
+
384
+
385
+ context 'redefining an attribute' do
386
+ before do
387
+ model.attributes do
388
+ attribute :id, String
389
+ end
390
+ end
391
+
392
+ it 'works' do
393
+ model.attributes[:id].type.should be(Attributor::String)
394
+ end
395
+
396
+ end
397
+
398
+ end
399
+
400
+
401
+ end