hanami-validations 0.0.0 → 0.5.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.
@@ -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