couchrest_model 1.1.0.beta → 1.1.0.beta2

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.
@@ -0,0 +1,524 @@
1
+ # encoding: utf-8
2
+ require File.expand_path('../../spec_helper', __FILE__)
3
+ require File.join(FIXTURE_PATH, 'more', 'cat')
4
+ require File.join(FIXTURE_PATH, 'more', 'person')
5
+ require File.join(FIXTURE_PATH, 'more', 'course')
6
+
7
+ describe "Type Casting" do
8
+
9
+ before(:each) do
10
+ @course = Course.new(:title => 'Relaxation')
11
+ end
12
+
13
+ describe "when value is nil" do
14
+ it "leaves the value unchanged" do
15
+ @course.title = nil
16
+ @course['title'].should == nil
17
+ end
18
+ end
19
+
20
+ describe "when type primitive is an Object" do
21
+ it "it should not cast given value" do
22
+ @course.participants = [{}, 'q', 1]
23
+ @course['participants'].should == [{}, 'q', 1]
24
+ end
25
+
26
+ it "should cast started_on to Date" do
27
+ @course.started_on = Date.today
28
+ @course['started_on'].should be_an_instance_of(Date)
29
+ end
30
+ end
31
+
32
+ describe "when type primitive is a String" do
33
+ it "keeps string value unchanged" do
34
+ value = "1.0"
35
+ @course.title = value
36
+ @course['title'].should equal(value)
37
+ end
38
+
39
+ it "it casts to string representation of the value" do
40
+ @course.title = 1.0
41
+ @course['title'].should eql("1.0")
42
+ end
43
+ end
44
+
45
+ describe 'when type primitive is a Float' do
46
+ it 'returns same value if a float' do
47
+ value = 24.0
48
+ @course.estimate = value
49
+ @course['estimate'].should equal(value)
50
+ end
51
+
52
+ it 'returns float representation of a zero string integer' do
53
+ @course.estimate = '0'
54
+ @course['estimate'].should eql(0.0)
55
+ end
56
+
57
+ it 'returns float representation of a positive string integer' do
58
+ @course.estimate = '24'
59
+ @course['estimate'].should eql(24.0)
60
+ end
61
+
62
+ it 'returns float representation of a negative string integer' do
63
+ @course.estimate = '-24'
64
+ @course['estimate'].should eql(-24.0)
65
+ end
66
+
67
+ it 'returns float representation of a zero string float' do
68
+ @course.estimate = '0.0'
69
+ @course['estimate'].should eql(0.0)
70
+ end
71
+
72
+ it 'returns float representation of a positive string float' do
73
+ @course.estimate = '24.35'
74
+ @course['estimate'].should eql(24.35)
75
+ end
76
+
77
+ it 'returns float representation of a negative string float' do
78
+ @course.estimate = '-24.35'
79
+ @course['estimate'].should eql(-24.35)
80
+ end
81
+
82
+ it 'returns float representation of a zero string float, with no leading digits' do
83
+ @course.estimate = '.0'
84
+ @course['estimate'].should eql(0.0)
85
+ end
86
+
87
+ it 'returns float representation of a positive string float, with no leading digits' do
88
+ @course.estimate = '.41'
89
+ @course['estimate'].should eql(0.41)
90
+ end
91
+
92
+ it 'returns float representation of a zero integer' do
93
+ @course.estimate = 0
94
+ @course['estimate'].should eql(0.0)
95
+ end
96
+
97
+ it 'returns float representation of a positive integer' do
98
+ @course.estimate = 24
99
+ @course['estimate'].should eql(24.0)
100
+ end
101
+
102
+ it 'returns float representation of a negative integer' do
103
+ @course.estimate = -24
104
+ @course['estimate'].should eql(-24.0)
105
+ end
106
+
107
+ it 'returns float representation of a zero decimal' do
108
+ @course.estimate = BigDecimal('0.0')
109
+ @course['estimate'].should eql(0.0)
110
+ end
111
+
112
+ it 'returns float representation of a positive decimal' do
113
+ @course.estimate = BigDecimal('24.35')
114
+ @course['estimate'].should eql(24.35)
115
+ end
116
+
117
+ it 'returns float representation of a negative decimal' do
118
+ @course.estimate = BigDecimal('-24.35')
119
+ @course['estimate'].should eql(-24.35)
120
+ end
121
+
122
+ it 'return float of a number with commas instead of points for decimals' do
123
+ @course.estimate = '23,35'
124
+ @course['estimate'].should eql(23.35)
125
+ end
126
+
127
+ it "should handle numbers with commas and points" do
128
+ @course.estimate = '1,234.00'
129
+ @course.estimate.should eql(1234.00)
130
+ end
131
+
132
+ it "should handle a mis-match of commas and points and maintain the last one" do
133
+ @course.estimate = "1,232.434.123,323"
134
+ @course.estimate.should eql(1232434123.323)
135
+ end
136
+
137
+ it "should handle numbers with whitespace" do
138
+ @course.estimate = " 24.35 "
139
+ @course.estimate.should eql(24.35)
140
+ end
141
+
142
+ [ Object.new, true, '00.0', '0.', '-.0', 'string' ].each do |value|
143
+ it "does not typecast non-numeric value #{value.inspect}" do
144
+ @course.estimate = value
145
+ @course['estimate'].should equal(value)
146
+ end
147
+ end
148
+
149
+ end
150
+
151
+ describe 'when type primitive is a Integer' do
152
+ it 'returns same value if an integer' do
153
+ value = 24
154
+ @course.hours = value
155
+ @course['hours'].should equal(value)
156
+ end
157
+
158
+ it 'returns integer representation of a zero string integer' do
159
+ @course.hours = '0'
160
+ @course['hours'].should eql(0)
161
+ end
162
+
163
+ it 'returns integer representation of a positive string integer' do
164
+ @course.hours = '24'
165
+ @course['hours'].should eql(24)
166
+ end
167
+
168
+ it 'returns integer representation of a negative string integer' do
169
+ @course.hours = '-24'
170
+ @course['hours'].should eql(-24)
171
+ end
172
+
173
+ it 'returns integer representation of a zero string float' do
174
+ @course.hours = '0.0'
175
+ @course['hours'].should eql(0)
176
+ end
177
+
178
+ it 'returns integer representation of a positive string float' do
179
+ @course.hours = '24.35'
180
+ @course['hours'].should eql(24)
181
+ end
182
+
183
+ it 'returns integer representation of a negative string float' do
184
+ @course.hours = '-24.35'
185
+ @course['hours'].should eql(-24)
186
+ end
187
+
188
+ it 'returns integer representation of a zero string float, with no leading digits' do
189
+ @course.hours = '.0'
190
+ @course['hours'].should eql(0)
191
+ end
192
+
193
+ it 'returns integer representation of a positive string float, with no leading digits' do
194
+ @course.hours = '.41'
195
+ @course['hours'].should eql(0)
196
+ end
197
+
198
+ it 'returns integer representation of a zero float' do
199
+ @course.hours = 0.0
200
+ @course['hours'].should eql(0)
201
+ end
202
+
203
+ it 'returns integer representation of a positive float' do
204
+ @course.hours = 24.35
205
+ @course['hours'].should eql(24)
206
+ end
207
+
208
+ it 'returns integer representation of a negative float' do
209
+ @course.hours = -24.35
210
+ @course['hours'].should eql(-24)
211
+ end
212
+
213
+ it 'returns integer representation of a zero decimal' do
214
+ @course.hours = '0.0'
215
+ @course['hours'].should eql(0)
216
+ end
217
+
218
+ it 'returns integer representation of a positive decimal' do
219
+ @course.hours = '24.35'
220
+ @course['hours'].should eql(24)
221
+ end
222
+
223
+ it 'returns integer representation of a negative decimal' do
224
+ @course.hours = '-24.35'
225
+ @course['hours'].should eql(-24)
226
+ end
227
+
228
+ it "should handle numbers with whitespace" do
229
+ @course.hours = " 24 "
230
+ @course['hours'].should eql(24)
231
+ end
232
+
233
+ [ Object.new, true, '00.0', '0.', '-.0', 'string' ].each do |value|
234
+ it "does not typecast non-numeric value #{value.inspect}" do
235
+ @course.hours = value
236
+ @course['hours'].should equal(value)
237
+ end
238
+ end
239
+ end
240
+
241
+ describe 'when type primitive is a BigDecimal' do
242
+ it 'returns same value if a decimal' do
243
+ value = BigDecimal('24.0')
244
+ @course.profit = value
245
+ @course['profit'].should equal(value)
246
+ end
247
+
248
+ it 'returns decimal representation of a zero string integer' do
249
+ @course.profit = '0'
250
+ @course['profit'].should eql(BigDecimal('0.0'))
251
+ end
252
+
253
+ it 'returns decimal representation of a positive string integer' do
254
+ @course.profit = '24'
255
+ @course['profit'].should eql(BigDecimal('24.0'))
256
+ end
257
+
258
+ it 'returns decimal representation of a negative string integer' do
259
+ @course.profit = '-24'
260
+ @course['profit'].should eql(BigDecimal('-24.0'))
261
+ end
262
+
263
+ it 'returns decimal representation of a zero string float' do
264
+ @course.profit = '0.0'
265
+ @course['profit'].should eql(BigDecimal('0.0'))
266
+ end
267
+
268
+ it 'returns decimal representation of a positive string float' do
269
+ @course.profit = '24.35'
270
+ @course['profit'].should eql(BigDecimal('24.35'))
271
+ end
272
+
273
+ it 'returns decimal representation of a negative string float' do
274
+ @course.profit = '-24.35'
275
+ @course['profit'].should eql(BigDecimal('-24.35'))
276
+ end
277
+
278
+ it 'returns decimal representation of a zero string float, with no leading digits' do
279
+ @course.profit = '.0'
280
+ @course['profit'].should eql(BigDecimal('0.0'))
281
+ end
282
+
283
+ it 'returns decimal representation of a positive string float, with no leading digits' do
284
+ @course.profit = '.41'
285
+ @course['profit'].should eql(BigDecimal('0.41'))
286
+ end
287
+
288
+ it 'returns decimal representation of a zero integer' do
289
+ @course.profit = 0
290
+ @course['profit'].should eql(BigDecimal('0.0'))
291
+ end
292
+
293
+ it 'returns decimal representation of a positive integer' do
294
+ @course.profit = 24
295
+ @course['profit'].should eql(BigDecimal('24.0'))
296
+ end
297
+
298
+ it 'returns decimal representation of a negative integer' do
299
+ @course.profit = -24
300
+ @course['profit'].should eql(BigDecimal('-24.0'))
301
+ end
302
+
303
+ it 'returns decimal representation of a zero float' do
304
+ @course.profit = 0.0
305
+ @course['profit'].should eql(BigDecimal('0.0'))
306
+ end
307
+
308
+ it 'returns decimal representation of a positive float' do
309
+ @course.profit = 24.35
310
+ @course['profit'].should eql(BigDecimal('24.35'))
311
+ end
312
+
313
+ it 'returns decimal representation of a negative float' do
314
+ @course.profit = -24.35
315
+ @course['profit'].should eql(BigDecimal('-24.35'))
316
+ end
317
+
318
+ it "should handle numbers with whitespace" do
319
+ @course.profit = " 24.35 "
320
+ @course['profit'].should eql(BigDecimal('24.35'))
321
+ end
322
+
323
+ [ Object.new, true, '00.0', '0.', '-.0', 'string' ].each do |value|
324
+ it "does not typecast non-numeric value #{value.inspect}" do
325
+ @course.profit = value
326
+ @course['profit'].should equal(value)
327
+ end
328
+ end
329
+ end
330
+
331
+ describe 'when type primitive is a DateTime' do
332
+ describe 'and value given as a hash with keys like :year, :month, etc' do
333
+ it 'builds a DateTime instance from hash values' do
334
+ @course.updated_at = {
335
+ :year => '2006',
336
+ :month => '11',
337
+ :day => '23',
338
+ :hour => '12',
339
+ :min => '0',
340
+ :sec => '0'
341
+ }
342
+ result = @course['updated_at']
343
+
344
+ result.should be_kind_of(DateTime)
345
+ result.year.should eql(2006)
346
+ result.month.should eql(11)
347
+ result.day.should eql(23)
348
+ result.hour.should eql(12)
349
+ result.min.should eql(0)
350
+ result.sec.should eql(0)
351
+ end
352
+ end
353
+
354
+ describe 'and value is a string' do
355
+ it 'parses the string' do
356
+ @course.updated_at = 'Dec, 2006'
357
+ @course['updated_at'].month.should == 12
358
+ end
359
+ end
360
+
361
+ it 'does not typecast non-datetime values' do
362
+ @course.updated_at = 'not-datetime'
363
+ @course['updated_at'].should eql('not-datetime')
364
+ end
365
+ end
366
+
367
+ describe 'when type primitive is a Date' do
368
+ describe 'and value given as a hash with keys like :year, :month, etc' do
369
+ it 'builds a Date instance from hash values' do
370
+ @course.started_on = {
371
+ :year => '2007',
372
+ :month => '3',
373
+ :day => '25'
374
+ }
375
+ result = @course['started_on']
376
+
377
+ result.should be_kind_of(Date)
378
+ result.year.should eql(2007)
379
+ result.month.should eql(3)
380
+ result.day.should eql(25)
381
+ end
382
+ end
383
+
384
+ describe 'and value is a string' do
385
+ it 'parses the string' do
386
+ @course.started_on = 'Dec 20th, 2006'
387
+ @course.started_on.month.should == 12
388
+ @course.started_on.day.should == 20
389
+ @course.started_on.year.should == 2006
390
+ end
391
+ end
392
+
393
+ it 'does not typecast non-date values' do
394
+ @course.started_on = 'not-date'
395
+ @course['started_on'].should eql('not-date')
396
+ end
397
+ end
398
+
399
+ describe 'when type primitive is a Time' do
400
+ describe 'and value given as a hash with keys like :year, :month, etc' do
401
+ it 'builds a Time instance from hash values' do
402
+ @course.ends_at = {
403
+ :year => '2006',
404
+ :month => '11',
405
+ :day => '23',
406
+ :hour => '12',
407
+ :min => '0',
408
+ :sec => '0'
409
+ }
410
+ result = @course['ends_at']
411
+
412
+ result.should be_kind_of(Time)
413
+ result.year.should eql(2006)
414
+ result.month.should eql(11)
415
+ result.day.should eql(23)
416
+ result.hour.should eql(12)
417
+ result.min.should eql(0)
418
+ result.sec.should eql(0)
419
+ end
420
+ end
421
+
422
+ describe 'and value is a string' do
423
+ it 'parses the string' do
424
+ t = Time.new(2011, 4, 1, 18, 50, 32, "+02:00")
425
+ @course.ends_at = t.strftime('%Y/%m/%d %H:%M:%S %z')
426
+ @course['ends_at'].year.should eql(t.year)
427
+ @course['ends_at'].month.should eql(t.month)
428
+ @course['ends_at'].day.should eql(t.day)
429
+ @course['ends_at'].hour.should eql(t.hour)
430
+ @course['ends_at'].min.should eql(t.min)
431
+ @course['ends_at'].sec.should eql(t.sec)
432
+ end
433
+ it 'parses the string without offset as UTC' do
434
+ t = Time.now.utc
435
+ @course.ends_at = t.strftime("%Y-%m-%d %H:%M:%S")
436
+ @course.ends_at.utc?.should be_true
437
+ @course['ends_at'].year.should eql(t.year)
438
+ @course['ends_at'].month.should eql(t.month)
439
+ @course['ends_at'].day.should eql(t.day)
440
+ @course['ends_at'].hour.should eql(t.hour)
441
+ @course['ends_at'].min.should eql(t.min)
442
+ @course['ends_at'].sec.should eql(t.sec)
443
+ end
444
+ end
445
+
446
+ it "converts a time value into utc" do
447
+ t = Time.new(2011, 4, 1, 18, 50, 32, "+02:00")
448
+ @course.ends_at = t
449
+ @course.ends_at.utc?.should be_true
450
+ @course.ends_at.to_i.should eql(Time.utc(2011, 4, 1, 16, 50, 32).to_i)
451
+ end
452
+
453
+ if RUBY_VERSION >= "1.9.1"
454
+ # In ruby 1.8.7 Time.parse will always return a value. D'OH
455
+ it 'does not typecast non-time values' do
456
+ @course.ends_at = 'not-time'
457
+ @course['ends_at'].should eql('not-time')
458
+ end
459
+ end
460
+ end
461
+
462
+ describe 'when type primitive is a Class' do
463
+ it 'returns same value if a class' do
464
+ value = Course
465
+ @course.klass = value
466
+ @course['klass'].should equal(value)
467
+ end
468
+
469
+ it 'returns the class if found' do
470
+ @course.klass = 'Course'
471
+ @course['klass'].should eql(Course)
472
+ end
473
+
474
+ it 'does not typecast non-class values' do
475
+ @course.klass = 'NoClass'
476
+ @course['klass'].should eql('NoClass')
477
+ end
478
+ end
479
+
480
+ describe 'when type primitive is a Boolean' do
481
+
482
+ [ true, 'true', 'TRUE', '1', 1, 't', 'T' ].each do |value|
483
+ it "returns true when value is #{value.inspect}" do
484
+ @course.active = value
485
+ @course['active'].should be_true
486
+ end
487
+ end
488
+
489
+ [ false, 'false', 'FALSE', '0', 0, 'f', 'F' ].each do |value|
490
+ it "returns false when value is #{value.inspect}" do
491
+ @course.active = value
492
+ @course['active'].should be_false
493
+ end
494
+ end
495
+
496
+ [ 'string', 2, 1.0, BigDecimal('1.0'), DateTime.now, Time.now, Date.today, Class, Object.new, ].each do |value|
497
+ it "does not typecast value #{value.inspect}" do
498
+ @course.active = value
499
+ @course['active'].should equal(value)
500
+ end
501
+ end
502
+
503
+ it "should respond to requests with ? modifier" do
504
+ @course.active = nil
505
+ @course.active?.should be_false
506
+ @course.active = false
507
+ @course.active?.should be_false
508
+ @course.active = true
509
+ @course.active?.should be_true
510
+ end
511
+
512
+ it "should respond to requests with ? modifier on TrueClass" do
513
+ @course.very_active = nil
514
+ @course.very_active?.should be_false
515
+ @course.very_active = false
516
+ @course.very_active?.should be_false
517
+ @course.very_active = true
518
+ @course.very_active?.should be_true
519
+ end
520
+ end
521
+
522
+ end
523
+
524
+
@@ -12,45 +12,45 @@ describe "Validations" do
12
12
 
13
13
  describe "Uniqueness" do
14
14
 
15
- before(:all) do
16
- @objs = ['title 1', 'title 2', 'title 3'].map{|t| WithUniqueValidation.create(:title => t)}
17
- end
18
-
19
- it "should validate a new unique document" do
20
- @obj = WithUniqueValidation.create(:title => 'title 4')
21
- @obj.new?.should_not be_true
22
- @obj.should be_valid
23
- end
15
+ context "basic" do
16
+ before(:all) do
17
+ @objs = ['title 1', 'title 2', 'title 3'].map{|t| WithUniqueValidation.create(:title => t)}
18
+ end
19
+
20
+ it "should validate a new unique document" do
21
+ @obj = WithUniqueValidation.create(:title => 'title 4')
22
+ @obj.new?.should_not be_true
23
+ @obj.should be_valid
24
+ end
24
25
 
25
- it "should not validate a non-unique document" do
26
- @obj = WithUniqueValidation.create(:title => 'title 1')
27
- @obj.should_not be_valid
28
- @obj.errors[:title].should == ["has already been taken"]
29
- end
26
+ it "should not validate a non-unique document" do
27
+ @obj = WithUniqueValidation.create(:title => 'title 1')
28
+ @obj.should_not be_valid
29
+ @obj.errors[:title].should == ["has already been taken"]
30
+ end
30
31
 
31
- it "should save already created document" do
32
- @obj = @objs.first
33
- @obj.save.should_not be_false
34
- @obj.should be_valid
35
- end
32
+ it "should save already created document" do
33
+ @obj = @objs.first
34
+ @obj.save.should_not be_false
35
+ @obj.should be_valid
36
+ end
36
37
 
37
- it "should allow own view to be specified" do
38
- # validates_uniqueness_of :code, :view => 'all'
39
- WithUniqueValidationView.create(:title => 'title 1', :code => '1234')
40
- @obj = WithUniqueValidationView.new(:title => 'title 5', :code => '1234')
41
- @obj.should_not be_valid
42
- end
38
+ it "should allow own view to be specified" do
39
+ # validates_uniqueness_of :code, :view => 'all'
40
+ WithUniqueValidationView.create(:title => 'title 1', :code => '1234')
41
+ @obj = WithUniqueValidationView.new(:title => 'title 5', :code => '1234')
42
+ @obj.should_not be_valid
43
+ end
43
44
 
44
- it "should raise an error if specified view does not exist" do
45
- WithUniqueValidationView.validates_uniqueness_of :title, :view => 'fooobar'
46
- @obj = WithUniqueValidationView.new(:title => 'title 2', :code => '12345')
47
- lambda {
48
- @obj.valid?
49
- }.should raise_error
50
- end
45
+ it "should raise an error if specified view does not exist" do
46
+ WithUniqueValidationView.validates_uniqueness_of :title, :view => 'fooobar'
47
+ @obj = WithUniqueValidationView.new(:title => 'title 2', :code => '12345')
48
+ lambda {
49
+ @obj.valid?
50
+ }.should raise_error
51
+ end
51
52
 
52
- context "with a pre-defined view" do
53
- it "should not try to create new view" do
53
+ it "should not try to create new view when already defined" do
54
54
  @obj = @objs[1]
55
55
  @obj.class.should_not_receive('view_by')
56
56
  @obj.class.should_receive('has_view?').and_return(true)
@@ -58,7 +58,7 @@ describe "Validations" do
58
58
  @obj.valid?
59
59
  end
60
60
  end
61
-
61
+
62
62
  context "with a proxy parameter" do
63
63
  it "should be used" do
64
64
  @obj = WithUniqueValidationProxy.new(:title => 'test 6')
@@ -77,7 +77,55 @@ describe "Validations" do
77
77
  end
78
78
  end
79
79
 
80
-
80
+ context "when proxied" do
81
+ it "should lookup the model_proxy" do
82
+ mp = mock(:ModelProxy)
83
+ mp.should_receive(:view).and_return({'rows' => []})
84
+ @obj = WithUniqueValidation.new(:title => 'test 8')
85
+ @obj.stub!(:model_proxy).twice.and_return(mp)
86
+ @obj.valid?
87
+ end
88
+ end
89
+
90
+ context "with a scope" do
91
+ before(:all) do
92
+ @objs = [['title 1', 1], ['title 2', 1], ['title 3', 1]].map{|t| WithScopedUniqueValidation.create(:title => t[0], :parent_id => t[1])}
93
+ @objs_nil = [['title 1', nil], ['title 2', nil], ['title 3', nil]].map{|t| WithScopedUniqueValidation.create(:title => t[0], :parent_id => t[1])}
94
+ end
95
+
96
+ it "should create the view" do
97
+ @objs.first.class.has_view?('by_parent_id_and_title')
98
+ end
99
+
100
+ it "should validate unique document" do
101
+ @obj = WithScopedUniqueValidation.create(:title => 'title 4', :parent_id => 1)
102
+ @obj.should be_valid
103
+ end
104
+
105
+ it "should validate unique document outside of scope" do
106
+ @obj = WithScopedUniqueValidation.create(:title => 'title 1', :parent_id => 2)
107
+ @obj.should be_valid
108
+ end
109
+
110
+ it "should validate non-unique document" do
111
+ @obj = WithScopedUniqueValidation.create(:title => 'title 1', :parent_id => 1)
112
+ @obj.should_not be_valid
113
+ @obj.errors[:title].should == ["has already been taken"]
114
+ end
115
+
116
+ it "should validate unique document will nil scope" do
117
+ @obj = WithScopedUniqueValidation.create(:title => 'title 4', :parent_id => nil)
118
+ @obj.should be_valid
119
+ end
120
+
121
+ it "should validate non-unique document with nil scope" do
122
+ @obj = WithScopedUniqueValidation.create(:title => 'title 1', :parent_id => nil)
123
+ @obj.should_not be_valid
124
+ @obj.errors[:title].should == ["has already been taken"]
125
+ end
126
+
127
+ end
128
+
81
129
  end
82
130
 
83
131
  end
@@ -138,4 +138,13 @@ class WithUniqueValidationView < CouchRest::Model::Base
138
138
  validates_uniqueness_of :code, :view => 'all'
139
139
  end
140
140
 
141
+ class WithScopedUniqueValidation < CouchRest::Model::Base
142
+ use_database DB
143
+
144
+ property :parent_id
145
+ property :title
146
+
147
+ validates_uniqueness_of :title, :scope => :parent_id
148
+ end
149
+
141
150