hanami-validations 0.0.0 → 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,526 @@
1
+ require 'set'
2
+ require 'hanami/utils/attributes'
3
+ require 'hanami/validations/nested_attributes'
4
+
5
+ module Hanami
6
+ module Validations
7
+ # Define attributes and their validations together
8
+ #
9
+ # @since 0.2.2
10
+ # @api private
11
+ module AttributeDefiner
12
+ # @since 0.2.3
13
+ # @api private
14
+ HANAMI_ENTITY_CLASS_NAME = 'Hanami::Entity'.freeze
15
+
16
+ # @since 0.2.3
17
+ # @api private
18
+ HANAMI_ENTITY_ID = 'id'.freeze
19
+
20
+ # Override Ruby's hook for modules.
21
+ #
22
+ # @param base [Class] the target class
23
+ #
24
+ # @since 0.2.2
25
+ # @api private
26
+ #
27
+ # @see http://www.ruby-doc.org/core/Module.html#method-i-included
28
+ def self.included(base)
29
+ base.extend ClassMethods
30
+ base.extend EntityAttributeDefiner if hanami_entity?(base)
31
+ end
32
+
33
+ # Decide if enable the support for `Hanami::Entity`.
34
+ #
35
+ # @param base [Class]
36
+ #
37
+ # @since 0.2.3
38
+ # @api private
39
+ def self.hanami_entity?(base)
40
+ base.included_modules.any? do |m|
41
+ m.to_s == HANAMI_ENTITY_CLASS_NAME
42
+ end
43
+ end
44
+
45
+ # @since 0.2.2
46
+ # @api private
47
+ module ClassMethods
48
+ # Override Ruby's hook for modules.
49
+ #
50
+ # @param base [Class] the target class
51
+ #
52
+ # @since 0.2.2
53
+ # @api private
54
+ #
55
+ # @see http://www.ruby-doc.org/core/Module.html#method-i-extended
56
+ def included(base)
57
+ super
58
+ base.defined_attributes.merge(defined_attributes)
59
+ end
60
+
61
+ # Override Ruby's hook for modules.
62
+ #
63
+ # @param base [Class] the target class
64
+ #
65
+ # @since 0.2.2
66
+ # @api private
67
+ #
68
+ # @see http://www.ruby-doc.org/core/Module.html#method-i-extended
69
+ def inherited(base)
70
+ super
71
+ base.defined_attributes.merge(defined_attributes)
72
+ end
73
+
74
+ # Define an attribute
75
+ #
76
+ # @param name [#to_sym] the name of the attribute
77
+ # @param options [Hash] optional set of validations
78
+ # @option options [Class] :type the Ruby type used to coerce the value
79
+ # @option options [TrueClass,FalseClass] :acceptance requires a non-empty
80
+ # value which coerces to TrueClass
81
+ # @option options [TrueClass,FalseClass] :confirmation requires the value
82
+ # to be confirmed twice
83
+ # @option options [#include?] :exclusion requires the value NOT be
84
+ # included in the given collection
85
+ # @option options [Regexp] :format requires value to match the given
86
+ # Regexp
87
+ # @option options [#include?] :inclusion requires the value BE included in
88
+ # the given collection
89
+ # @option options [TrueClass,FalseClass] :presence requires the value be
90
+ # included in the given collection
91
+ # @option options [Numeric,Range] :size requires value's to be equal or
92
+ # included by the given validator
93
+ #
94
+ # @raise [ArgumentError] if an unknown or mispelled validation is given
95
+ #
96
+ # @since 0.2.2
97
+ #
98
+ # @example Attributes
99
+ # require 'hanami/validations'
100
+ #
101
+ # class Person
102
+ # include Hanami::Validations
103
+ #
104
+ # attribute :name
105
+ # end
106
+ #
107
+ # person = Person.new(name: 'Luca', age: 32)
108
+ # person.name # => "Luca"
109
+ # person.age # => raises NoMethodError because `:age` wasn't defined as attribute.
110
+ #
111
+ # @example Standard coercions
112
+ # require 'hanami/validations'
113
+ #
114
+ # class Person
115
+ # include Hanami::Validations
116
+ #
117
+ # attribute :fav_number, type: Integer
118
+ # end
119
+ #
120
+ # person = Person.new(fav_number: '23')
121
+ # person.valid?
122
+ #
123
+ # person.fav_number # => 23
124
+ #
125
+ # @example Custom coercions
126
+ # require 'hanami/validations'
127
+ #
128
+ # class FavNumber
129
+ # def initialize(number)
130
+ # @number = number
131
+ # end
132
+ # end
133
+ #
134
+ # class BirthDate
135
+ # end
136
+ #
137
+ # class Person
138
+ # include Hanami::Validations
139
+ #
140
+ # attribute :fav_number, type: FavNumber
141
+ # attribute :date, type: BirthDate
142
+ # end
143
+ #
144
+ # person = Person.new(fav_number: '23', date: 'Oct 23, 2014')
145
+ # person.valid?
146
+ #
147
+ # person.fav_number # => 23
148
+ # person.date # => this raises an error, because BirthDate#initialize doesn't accept any arg
149
+ #
150
+ # @example Acceptance
151
+ # require 'hanami/validations'
152
+ #
153
+ # class Signup
154
+ # include Hanami::Validations
155
+ #
156
+ # attribute :terms_of_service, acceptance: true
157
+ # end
158
+ #
159
+ # signup = Signup.new(terms_of_service: '1')
160
+ # signup.valid? # => true
161
+ #
162
+ # signup = Signup.new(terms_of_service: '')
163
+ # signup.valid? # => false
164
+ #
165
+ # @example Confirmation
166
+ # require 'hanami/validations'
167
+ #
168
+ # class Signup
169
+ # include Hanami::Validations
170
+ #
171
+ # attribute :password, confirmation: true
172
+ # end
173
+ #
174
+ # signup = Signup.new(password: 'secret', password_confirmation: 'secret')
175
+ # signup.valid? # => true
176
+ #
177
+ # signup = Signup.new(password: 'secret', password_confirmation: 'x')
178
+ # signup.valid? # => false
179
+ #
180
+ # @example Exclusion
181
+ # require 'hanami/validations'
182
+ #
183
+ # class Signup
184
+ # include Hanami::Validations
185
+ #
186
+ # attribute :music, exclusion: ['pop']
187
+ # end
188
+ #
189
+ # signup = Signup.new(music: 'rock')
190
+ # signup.valid? # => true
191
+ #
192
+ # signup = Signup.new(music: 'pop')
193
+ # signup.valid? # => false
194
+ #
195
+ # @example Format
196
+ # require 'hanami/validations'
197
+ #
198
+ # class Signup
199
+ # include Hanami::Validations
200
+ #
201
+ # attribute :name, format: /\A[a-zA-Z]+\z/
202
+ # end
203
+ #
204
+ # signup = Signup.new(name: 'Luca')
205
+ # signup.valid? # => true
206
+ #
207
+ # signup = Signup.new(name: '23')
208
+ # signup.valid? # => false
209
+ #
210
+ # @example Inclusion
211
+ # require 'hanami/validations'
212
+ #
213
+ # class Signup
214
+ # include Hanami::Validations
215
+ #
216
+ # attribute :age, inclusion: 18..99
217
+ # end
218
+ #
219
+ # signup = Signup.new(age: 32)
220
+ # signup.valid? # => true
221
+ #
222
+ # signup = Signup.new(age: 17)
223
+ # signup.valid? # => false
224
+ #
225
+ # @example Presence
226
+ # require 'hanami/validations'
227
+ #
228
+ # class Signup
229
+ # include Hanami::Validations
230
+ #
231
+ # attribute :name, presence: true
232
+ # end
233
+ #
234
+ # signup = Signup.new(name: 'Luca')
235
+ # signup.valid? # => true
236
+ #
237
+ # signup = Signup.new(name: nil)
238
+ # signup.valid? # => false
239
+ #
240
+ # @example Size
241
+ # require 'hanami/validations'
242
+ #
243
+ # class Signup
244
+ # MEGABYTE = 1024 ** 2
245
+ # include Hanami::Validations
246
+ #
247
+ # attribute :ssn, size: 11 # exact match
248
+ # attribute :password, size: 8..64 # range
249
+ # attribute :avatar, size 1..(5 * MEGABYTE)
250
+ # end
251
+ #
252
+ # signup = Signup.new(password: 'a-very-long-password')
253
+ # signup.valid? # => true
254
+ #
255
+ # signup = Signup.new(password: 'short')
256
+ # signup.valid? # => false
257
+ def attribute(name, options = {}, &block)
258
+ _attribute(name, options, &block)
259
+ end
260
+
261
+ # Define an attribute
262
+ #
263
+ # @see Hanami::Validations::AttributeDefiner#attribute
264
+ #
265
+ # @since 0.3.1
266
+ # @api private
267
+ def _attribute(name, options = {}, &block)
268
+ if block_given?
269
+ define_nested_attribute(name, options, &block)
270
+ validates(name, {})
271
+ else
272
+ define_attribute(name, options)
273
+ validates(name, options)
274
+ end
275
+ end
276
+
277
+ # Set of user defined attributes
278
+ #
279
+ # @return [Array<String>]
280
+ #
281
+ # @since 0.2.2
282
+ # @api private
283
+ def defined_attributes
284
+ @defined_attributes ||= Set.new(super)
285
+ end
286
+
287
+ private
288
+
289
+ # @since 0.2.2
290
+ # @api private
291
+ def define_attribute(name, options)
292
+ type = options.fetch(:type) { nil }
293
+
294
+ define_accessor(name, type)
295
+ defined_attributes.add(name.to_s)
296
+
297
+ if options[:confirmation]
298
+ confirmation_accessor = "#{ name }_confirmation"
299
+ define_accessor(confirmation_accessor, type)
300
+ defined_attributes.add(confirmation_accessor)
301
+ end
302
+ end
303
+
304
+ # @since 0.2.4
305
+ # @api private
306
+ def define_nested_attribute(name, options, &block)
307
+ nested_class = build_validation_class(&block)
308
+ define_lazy_reader(name, nested_class)
309
+ define_coerced_writer(name, nested_class)
310
+ defined_attributes.add(name.to_s)
311
+ validates(name, nested: true)
312
+ end
313
+
314
+ # @since 0.2.2
315
+ # @api private
316
+ def define_accessor(name, type)
317
+ if type
318
+ define_reader(name)
319
+ define_coerced_writer(name, type)
320
+ else
321
+ define_reader(name)
322
+ define_writer(name)
323
+ end
324
+ end
325
+
326
+ # @since 0.2.2
327
+ # @api private
328
+ def define_coerced_writer(name, type)
329
+ define_method("#{ name }=") do |value|
330
+ @attributes.set(name, Hanami::Validations::Coercions.coerce(type, value))
331
+ end
332
+ end
333
+
334
+ # @since 0.2.2
335
+ # @api private
336
+ def define_writer(name)
337
+ define_method("#{ name }=") do |value|
338
+ @attributes.set(name, value)
339
+ end
340
+ end
341
+
342
+ # @since 0.2.2
343
+ # @api private
344
+ def define_reader(name)
345
+ define_method(name) do
346
+ @attributes.get(name)
347
+ end
348
+ end
349
+
350
+ # Defines a reader that will return a new instance of
351
+ # the given type if one is not already present
352
+ #
353
+ # @since 0.2.4
354
+ # @api private
355
+ def define_lazy_reader(name, type)
356
+ define_method(name) do
357
+ value = @attributes.get(name)
358
+ return value if value
359
+
360
+ type.new({}).tap do |val|
361
+ @attributes.set(name, val)
362
+ end
363
+ end
364
+ end
365
+
366
+ # Creates a validation class and configures it with the
367
+ # given block.
368
+ #
369
+ # @since 0.2.4
370
+ # @api private
371
+ def build_validation_class(&blk)
372
+ NestedAttributes.fabricate(&blk)
373
+ end
374
+ end
375
+
376
+ # Support for `Hanami::Entity`
377
+ #
378
+ # @since 0.2.3
379
+ # @api private
380
+ #
381
+ # @example
382
+ # require 'hanami/model'
383
+ # require 'hanami/validations'
384
+ #
385
+ # class Product
386
+ # include Hanami::Entity
387
+ # include Hanami::Validations
388
+ #
389
+ # attribute :name, type: String, presence: true
390
+ # attribute :price, type: Integer, presence: true
391
+ # end
392
+ #
393
+ # product = Product.new(name: 'Computer', price: '100')
394
+ #
395
+ # product.name # => "Computer"
396
+ # product.price # => 100
397
+ # product.valid? # => true
398
+ module EntityAttributeDefiner
399
+ # Override for Module#extend
400
+ #
401
+ # @since 0.2.3
402
+ # @api private
403
+ #
404
+ # @see http://ruby-doc.org/Module.html#method-i-extended
405
+ def self.extended(base)
406
+ base.class_eval do
407
+ include EntityAttributeDefiner::InstanceMethods
408
+ end
409
+ end
410
+
411
+ # @return [Array<String>]
412
+ #
413
+ # @since 0.3.1
414
+ # @api private
415
+ def defined_attributes
416
+ super
417
+ @defined_attributes.merge(attributes.map(&:to_s))
418
+ end
419
+
420
+ # Override attribute accessors function.
421
+ #
422
+ # @since 0.3.1
423
+ #
424
+ # @api private
425
+ # @see Hanami::Model::Entity#define_attr_accessor
426
+ def define_attr_accessor(attr)
427
+ _attribute(attr)
428
+ super
429
+ end
430
+
431
+ # @since 0.2.3
432
+ # @api private
433
+ #
434
+ # @see Hanami::Validations::AttributeDefiner#attribute
435
+ def attribute(name, options = {})
436
+ attributes name
437
+ super
438
+ end
439
+
440
+ # @since 0.2.3
441
+ # @api private
442
+ #
443
+ # @see Hanami::Validations::ClassMethods#validates
444
+ def validates(name, options = {})
445
+ super
446
+ define_attribute(name, options)
447
+ end
448
+
449
+ # @since 0.2.3
450
+ # @api private
451
+ module InstanceMethods
452
+ private
453
+ # @since 0.2.3
454
+ # @api private
455
+ #
456
+ # @see Hanami::Validations::AttributeDefiner#assign_attribute?
457
+ def assign_attribute?(attr)
458
+ super || attr.to_s == HANAMI_ENTITY_ID
459
+ end
460
+
461
+ def initialize(attributes = {})
462
+ super
463
+ @attributes.set(HANAMI_ENTITY_ID, id)
464
+ self.class.attribute(HANAMI_ENTITY_ID)
465
+ end
466
+ end
467
+ end
468
+
469
+ # Create a new instance with the given attributes
470
+ #
471
+ # @param attributes [#to_h] an Hash like object which contains the
472
+ # attributes
473
+ #
474
+ # @since 0.2.2
475
+ #
476
+ # @example Initialize with Hash
477
+ # require 'hanami/validations'
478
+ #
479
+ # class Signup
480
+ # include Hanami::Validations
481
+ #
482
+ # attribute :name
483
+ # end
484
+ #
485
+ # signup = Signup.new(name: 'Luca')
486
+ #
487
+ # @example Initialize with Hash like
488
+ # require 'hanami/validations'
489
+ #
490
+ # class Params
491
+ # def initialize(attributes)
492
+ # @attributes = Hash[*attributes]
493
+ # end
494
+ #
495
+ # def to_h
496
+ # @attributes.to_h
497
+ # end
498
+ # end
499
+ #
500
+ # class Signup
501
+ # include Hanami::Validations
502
+ #
503
+ # attribute :name
504
+ # end
505
+ #
506
+ # params = Params.new([:name, 'Luca'])
507
+ # signup = Signup.new(params)
508
+ #
509
+ # signup.name # => "Luca"
510
+ def initialize(attributes = {})
511
+ @attributes ||= Utils::Attributes.new
512
+
513
+ attributes.to_h.each do |key, value|
514
+ public_send("#{ key }=", value) if assign_attribute?(key)
515
+ end
516
+ end
517
+
518
+ private
519
+ # @since 0.2.2
520
+ # @api private
521
+ def assign_attribute?(attr)
522
+ self.class.defined_attributes.include?(attr.to_s)
523
+ end
524
+ end
525
+ end
526
+ end