validation_hints 0.2.3 → 6.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.
@@ -1,67 +1,37 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module ActiveModel
2
- # == Active Model Hints
4
+ # Introspects declared validators and builds proactive hint messages (before +valid?+ fails).
3
5
  #
4
- # p = Person.new
5
- # p.hints
6
- # p.hints[:name]
7
- #
8
- # more documentation needed
9
-
6
+ # Conditional options (+:if+, +:unless+, +:on+) are not evaluated; hints reflect static rules.
7
+ # For +format+ validators, prefer a custom +message:+ or per-attribute I18n keys.
10
8
  class Hints
11
9
  include Enumerable
12
10
 
13
- CALLBACKS_OPTIONS = [:if, :unless, :on, :allow_nil, :allow_blank, :strict]
14
- MESSAGES_FOR_VALIDATORS = %w(confirmation acceptance presence uniqueness format associated numericality)
15
- VALIDATORS_WITHOUT_MAIN_KEYS = %w(exclusion format inclusion length numericality)
16
- # and these? validates_with validates_each
17
- MESSAGES_FOR_OPTIONS = %w(within in is minimum maximum greater_than greater_than_or_equal_to equal_to less_than less_than_or_equal_to odd even only_integer)
18
- OPTIONS_THAT_WE_DONT_USE_YET = {
19
- :acceptance => :acceptance
11
+ MESSAGES_FOR_OPTIONS = %w[
12
+ within in is minimum maximum greater_than greater_than_or_equal_to
13
+ equal_to less_than less_than_or_equal_to odd even only_integer
14
+ ].freeze
20
15
 
21
- }
22
- VALIDATORS_THAT_WE_DONT_KNOW_WHAT_TO_DO_WITH = %w(validates_associated)
16
+ VALIDATORS_WITHOUT_MAIN_KEYS = %w[exclusion format inclusion length numericality].freeze
23
17
 
24
- # Should virtual element for
25
- # validates :email, :confirmation => true
26
- # validates :email_confirmation, :presence => true
27
- # also have a hint?
18
+ RANGE_OPTIONS = %w[within in].freeze
28
19
 
29
20
  attr_reader :messages
30
21
 
31
- # Pass in the instance of the object that is using the errors object.
32
- #
33
- # class Person
34
- # def initialize
35
- # @errors = ActiveModel::Errors.new(self)
36
- # end
37
- # end
38
22
  def initialize(base)
39
- @base = base
40
- @messages = ActiveSupport::OrderedHash.new
41
- @base.attributes.keys.each do |a|
42
- @messages[a.to_sym] = hints_for(a.to_sym)
23
+ @base = base
24
+ @messages = {}
25
+ attribute_names_for_hints.each do |attribute|
26
+ @messages[attribute] = hints_for(attribute)
43
27
  end
44
28
  end
45
29
 
46
30
  def hints_for(attribute)
47
- result = Array.new
48
- @base.class.validators_on(attribute).map do |v|
49
- validator = v.class.to_s.split('::').last.underscore.gsub('_validator','')
50
- if v.options[:message].is_a?(Symbol)
51
- message_key = [validator, v.options[:message]].join('.') # if a message was supplied as a symbol, we use it instead
52
- result << generate_message(attribute, message_key, v.options)
53
- else
54
- message_key = validator
55
- message_key = [validator, ".must_be_a_number"].join('.') if validator == 'numericality' # create an option for numericality; the way YAML works a key (numericality) with subkeys (greater_than, etc etc) can not have a string itself. So we create a subkey for numericality
56
- result << generate_message(attribute, message_key, v.options) unless VALIDATORS_WITHOUT_MAIN_KEYS.include?(validator)
57
- v.options.each do |o|
58
- if MESSAGES_FOR_OPTIONS.include?(o.first.to_s)
59
- count = o.last
60
- count = (o.last.to_sentence if %w(inclusion exclusion).include?(validator)) rescue o.last
61
- result << generate_message(attribute, [ validator, o.first.to_s ].join('.'), { :count => count } )
62
- end
63
- end
64
- end
31
+ attribute = attribute.to_sym
32
+ result = []
33
+ @base.class.validators_on(attribute).each do |validator|
34
+ result.concat(messages_for_validator(attribute, validator))
65
35
  end
66
36
  result
67
37
  end
@@ -71,143 +41,74 @@ module ActiveModel
71
41
  end
72
42
 
73
43
  def initialize_dup(other)
74
- @messages = other.messages.dup
44
+ @messages = other.messages.transform_values(&:dup)
75
45
  end
76
46
 
77
- # Backport dup from 1.9 so that #initialize_dup gets called
78
- unless Object.respond_to?(:initialize_dup)
79
- def dup # :nodoc:
80
- copy = super
81
- copy.initialize_dup(self)
82
- copy
83
- end
84
- end
85
-
86
- # Clear the messages
87
47
  def clear
88
48
  messages.clear
89
49
  end
90
50
 
91
- # Do the hint messages include an hint with key +hint+?
92
- def include?(hint)
93
- (v = messages[hint]) && v.any?
51
+ def include?(attribute)
52
+ (value = messages[attribute.to_sym]) && value.any?
94
53
  end
95
- alias :has_key? :include?
54
+ alias has_key? include?
96
55
 
97
- # Get messages for +key+
98
56
  def get(key)
99
57
  messages[key]
100
58
  end
101
59
 
102
- # Set messages for +key+ to +value+
103
60
  def set(key, value)
104
61
  messages[key] = value
105
62
  end
106
63
 
107
- # Delete messages for +key+
108
64
  def delete(key)
109
65
  messages.delete(key)
110
66
  end
111
67
 
112
- # When passed a symbol or a name of a method, returns an array of hints
113
- # for the method.
114
- #
115
- # p.hints[:name] # => ["can not be nil"]
116
- # p.hints['name'] # => ["can not be nil"]
117
68
  def [](attribute)
118
69
  get(attribute.to_sym) || set(attribute.to_sym, [])
119
70
  end
120
71
 
121
- # Adds to the supplied attribute the supplied hint message.
122
- #
123
- # p.hints[:name] = "must be set"
124
- # p.hints[:name] # => ['must be set']
125
72
  def []=(attribute, hint)
126
73
  self[attribute] << hint
127
74
  end
128
75
 
129
- # Iterates through each hint key, value pair in the hint messages hash.
130
- # Yields the attribute and the hint for that attribute. If the attribute
131
- # has more than one hint message, yields once for each hint message.
132
- #
133
- # p.hints.add(:name, "can't be blank")
134
- # p.hints.each do |attribute, hints_array|
135
- # # Will yield :name and "can't be blank"
136
- # end
137
- #
138
- # p.hints.add(:name, "must be specified")
139
- # p.hints.each do |attribute, hints_array|
140
- # # Will yield :name and "can't be blank"
141
- # # then yield :name and "must be specified"
142
- # end
143
76
  def each
144
77
  messages.each_key do |attribute|
145
78
  self[attribute].each { |hint| yield attribute, hint }
146
79
  end
147
80
  end
148
81
 
149
- # Returns the number of error messages.
150
- #
151
- # p.hints.add(:name, "can't be blank")
152
- # p.hints.size # => 1
153
- # p.hints.add(:name, "must be specified")
154
- # p.hints.size # => 2
155
82
  def size
156
83
  values.flatten.size
157
84
  end
158
85
 
159
- # Returns all message values
160
86
  def values
161
87
  messages.values
162
88
  end
163
89
 
164
- # Returns all message keys
165
90
  def keys
166
91
  messages.keys
167
92
  end
168
93
 
169
- # Returns an array of hint messages, with the attribute name included
170
- #
171
- # p.hints.add(:name, "can't be blank")
172
- # p.hints.add(:name, "must be specified")
173
- # p.hints.to_a # => ["name can't be blank", "name must be specified"]
174
94
  def to_a
175
95
  full_messages
176
96
  end
177
97
 
178
- # Returns the number of hint messages.
179
- # p.hints.add(:name, "can't be blank")
180
- # p.hints.count # => 1
181
- # p.hints.add(:name, "must be specified")
182
- # p.hints.count # => 2
183
98
  def count
184
99
  to_a.size
185
100
  end
186
101
 
187
- # Returns true if no hints are found, false otherwise.
188
- # If the hint message is a string it can be empty.
189
102
  def empty?
190
- all? { |k, v| v && v.empty? && !v.is_a?(String) }
103
+ messages.values.all?(&:empty?)
191
104
  end
192
105
  alias_method :blank?, :empty?
193
106
 
194
- # Returns an xml formatted representation of the hints hash.
195
- #
196
- # p.hints.add(:name, "can't be blank")
197
- # p.hints.add(:name, "must be specified")
198
- # p.hints.to_xml
199
- # # =>
200
- # # <?xml version=\"1.0\" encoding=\"UTF-8\"?>
201
- # # <hints>
202
- # # <hint>name can't be blank</hint>
203
- # # <hint>name must be specified</hint>
204
- # # </hints>
205
- def to_xml(options={})
206
- to_a.to_xml options.reverse_merge(:root => "hints", :skip_types => true)
207
- end
208
-
209
- # Returns an ActiveSupport::OrderedHash that can be used as the JSON representation for this object.
210
- def as_json(options=nil)
107
+ def to_xml(options = {})
108
+ to_a.to_xml(options.reverse_merge(root: "hints", skip_types: true))
109
+ end
110
+
111
+ def as_json(_options = nil)
211
112
  to_hash
212
113
  end
213
114
 
@@ -215,13 +116,7 @@ module ActiveModel
215
116
  messages.dup
216
117
  end
217
118
 
218
- # Adds +message+ to the hint messages on +attribute+. More than one hint can be added to the same
219
- # +attribute+.
220
- # If no +message+ is supplied, <tt>:invalid</tt> is assumed.
221
- #
222
- # If +message+ is a symbol, it will be translated using the appropriate scope (see +translate_hint+).
223
- # If +message+ is a proc, it will be called, allowing for things like <tt>Time.now</tt> to be used within an hint.
224
- def add(attribute, message = nil, options = {})
119
+ def add(attribute, message = :invalid, options = {})
225
120
  message = normalize_message(attribute, message, options)
226
121
  if options[:strict]
227
122
  raise ActiveModel::StrictValidationFailed, full_message(attribute, message)
@@ -230,95 +125,187 @@ module ActiveModel
230
125
  self[attribute] << message
231
126
  end
232
127
 
233
- # Will add an hint message to each of the attributes in +attributes+ that is empty.
234
128
  def add_on_empty(attributes, options = {})
235
- [attributes].flatten.each do |attribute|
129
+ Array(attributes).each do |attribute|
236
130
  value = @base.send(:read_attribute_for_validation, attribute)
237
131
  is_empty = value.respond_to?(:empty?) ? value.empty? : false
238
132
  add(attribute, :empty, options) if value.nil? || is_empty
239
133
  end
240
134
  end
241
135
 
242
- # Will add an hint message to each of the attributes in +attributes+ that is blank (using Object#blank?).
243
136
  def add_on_blank(attributes, options = {})
244
- [attributes].flatten.each do |attribute|
137
+ Array(attributes).each do |attribute|
245
138
  value = @base.send(:read_attribute_for_validation, attribute)
246
139
  add(attribute, :blank, options) if value.blank?
247
140
  end
248
141
  end
249
142
 
250
- # Returns true if an hint on the attribute with the given message is present, false otherwise.
251
- # +message+ is treated the same as for +add+.
252
- # p.hints.add :name, :blank
253
- # p.hints.added? :name, :blank # => true
254
- def added?(attribute, message = nil, options = {})
143
+ def added?(attribute, message = :invalid, options = {})
255
144
  message = normalize_message(attribute, message, options)
256
- self[attribute].include? message
257
- end
258
-
259
- # Returns all the full hint messages in an array.
260
- #
261
- # class Company
262
- # validates_presence_of :name, :address, :email
263
- # validates_length_of :name, :in => 5..30
264
- # end
265
- #
266
- # company = Company.create(:address => '123 First St.')
267
- # company.hints.full_messages # =>
268
- # ["Name is too short (minimum is 5 characters)", "Name can't be blank", "Email can't be blank"]
145
+ self[attribute].include?(message)
146
+ end
147
+
269
148
  def full_messages
270
149
  map { |attribute, message| full_message(attribute, message) }
271
150
  end
272
151
 
273
- # Returns a full message for a given attribute.
274
- #
275
- # company.hints.full_message(:name, "is invalid") # =>
276
- # "Name is invalid"
277
152
  def full_message(attribute, message)
278
153
  return message if attribute == :base
279
- attr_name = attribute.to_s.gsub('.', '_').humanize
280
- attr_name = @base.class.human_attribute_name(attribute, :default => attr_name)
281
- I18n.t(:"hints.format",
282
- :default => "%{attribute} %{message}",
283
- :attribute => attr_name,
284
- :message => message
285
- )
154
+
155
+ attr_name = attribute.to_s.tr(".", "_").humanize
156
+ attr_name = @base.class.human_attribute_name(attribute, default: attr_name)
157
+ I18n.t(
158
+ :"hints.format",
159
+ default: "%{attribute} %{message}",
160
+ attribute: attr_name,
161
+ message: message
162
+ )
286
163
  end
287
164
 
288
165
  def generate_message(attribute, type, options = {})
289
- #options.delete(:message) if options[:message].is_a?(Symbol)
290
- if @base.class.respond_to?(:i18n_scope)
291
- defaults = @base.class.lookup_ancestors.map do |klass|
292
- [ :"#{@base.class.i18n_scope}.hints.models.#{klass.model_name.i18n_key}.attributes.#{attribute}.#{type}",
293
- :"#{@base.class.i18n_scope}.hints.models.#{klass.model_name.i18n_key}.#{type}" ]
166
+ type = options.delete(:message) if options[:message].is_a?(Symbol)
167
+ value = (attribute != :base ? @base.read_attribute_for_validation(attribute) : nil)
168
+
169
+ interpolation = {
170
+ model: @base.model_name.human,
171
+ attribute: @base.class.human_attribute_name(attribute, base: @base),
172
+ value: value,
173
+ object: @base,
174
+ count: options[:count],
175
+ minimum: options[:minimum],
176
+ maximum: options[:maximum]
177
+ }.compact
178
+
179
+ defaults = i18n_defaults(attribute, type, options)
180
+ key = defaults.shift
181
+
182
+ I18n.translate(key, **interpolation.merge(default: defaults))
183
+ end
184
+
185
+ private
186
+
187
+ def attribute_names_for_hints
188
+ from_record =
189
+ if @base.respond_to?(:attributes)
190
+ @base.attributes.keys
191
+ else
192
+ []
193
+ end
194
+
195
+ from_validators = @base.class.validators.flat_map(&:attributes).map(&:to_s)
196
+ (from_record + from_validators).map(&:to_sym).uniq
197
+ end
198
+
199
+ def messages_for_validator(attribute, validator)
200
+ key = validator_key(validator)
201
+ options = validator.options
202
+ result = []
203
+
204
+ if options[:allow_blank] && key == "presence"
205
+ return result
206
+ end
207
+
208
+ if options[:message].is_a?(Symbol)
209
+ message_key = "#{key}.#{options[:message]}"
210
+ result << generate_message(attribute, message_key, options)
211
+ return result
212
+ end
213
+
214
+ message_key = key
215
+ message_key = "numericality.must_be_a_number" if key == "numericality"
216
+ unless VALIDATORS_WITHOUT_MAIN_KEYS.include?(key)
217
+ result << generate_message(attribute, message_key, options)
218
+ end
219
+
220
+ if key == "length" && options[:minimum] && options[:maximum]
221
+ result << generate_message(
222
+ attribute,
223
+ "length.within",
224
+ minimum: options[:minimum],
225
+ maximum: options[:maximum]
226
+ )
227
+ return result
228
+ end
229
+
230
+ options.each do |option, value|
231
+ next unless MESSAGES_FOR_OPTIONS.include?(option.to_s)
232
+
233
+ if RANGE_OPTIONS.include?(option.to_s) && value.is_a?(Range)
234
+ result.concat(range_hint_messages(attribute, key, value))
235
+ else
236
+ count = inclusion_exclusion_count(key, value)
237
+ result << generate_message(
238
+ attribute,
239
+ "#{key}.#{option}",
240
+ options.merge(count: count)
241
+ )
294
242
  end
243
+ end
244
+
245
+ result
246
+ end
247
+
248
+ def range_hint_messages(attribute, validator_key, range)
249
+ minimum = range.min
250
+ maximum = range.max
251
+ maximum -= 1 if range.exclude_end?
252
+
253
+ if validator_key == "length"
254
+ [
255
+ generate_message(attribute, "#{validator_key}.within", minimum: minimum, maximum: maximum),
256
+ ]
295
257
  else
296
- defaults = []
258
+ [
259
+ generate_message(attribute, "#{validator_key}.minimum", count: minimum),
260
+ generate_message(attribute, "#{validator_key}.maximum", count: maximum)
261
+ ]
297
262
  end
263
+ end
298
264
 
299
- defaults << options[:message] # defaults << options.delete(:message)
300
- defaults << :"#{@base.class.i18n_scope}.hints.messages.#{type}" if @base.class.respond_to?(:i18n_scope)
301
- defaults << :"hints.attributes.#{attribute}.#{type}"
302
- defaults << :"hints.messages.#{type}"
265
+ def inclusion_exclusion_count(validator_key, value)
266
+ return value.to_sentence if %w[inclusion exclusion].include?(validator_key) && value.respond_to?(:to_sentence)
303
267
 
304
- defaults.compact!
305
- defaults.flatten!
268
+ value
269
+ end
306
270
 
307
- key = defaults.shift
271
+ def validator_key(validator)
272
+ validator.class.name.demodulize.underscore.delete_suffix("_validator")
273
+ end
274
+
275
+ def i18n_defaults(attribute, type, options)
276
+ attribute_name = attribute.to_s.delete_suffix("[]").remove(/\[\d+\]/)
308
277
 
309
- # options = {
310
- # :default => defaults,
311
- # :model => @base.class.model_name.human,
312
- # :attribute => @base.class.human_attribute_name(attribute),
313
- # }.merge(options)
314
- I18n.translate( key,
315
- default: defaults,
316
- model: @base.class.model_name.human,
317
- attribute: @base.class.human_attribute_name(attribute),
318
- count: options[:count],
319
- )
278
+ if @base.class.respond_to?(:i18n_scope)
279
+ scope = @base.class.i18n_scope
280
+ model_defaults = @base.class.lookup_ancestors.flat_map do |klass|
281
+ [
282
+ :"#{scope}.hints.models.#{klass.model_name.i18n_key}.attributes.#{attribute_name}.#{type}",
283
+ :"#{scope}.hints.models.#{klass.model_name.i18n_key}.#{type}"
284
+ ]
285
+ end
286
+ else
287
+ model_defaults = []
288
+ end
289
+
290
+ defaults = model_defaults
291
+ defaults << options[:message] if options[:message]
292
+ defaults << :"#{@base.class.i18n_scope}.hints.messages.#{type}" if @base.class.respond_to?(:i18n_scope)
293
+ defaults << :"hints.attributes.#{attribute_name}.#{type}"
294
+ defaults << :"hints.messages.#{type}"
295
+ defaults.compact.flatten
320
296
  end
321
297
 
298
+ def normalize_message(attribute, message, options = {})
299
+ case message
300
+ when Symbol
301
+ generate_message(attribute, message, options)
302
+ when Proc
303
+ message.call
304
+ when nil
305
+ generate_message(attribute, :invalid, options)
306
+ else
307
+ message
308
+ end
309
+ end
322
310
  end
323
-
324
311
  end
@@ -1,21 +1,17 @@
1
- ## YAML Template.
2
1
  ---
3
2
  en:
4
- activerecord:
5
- less_than_or_equal_to: "must be less than or equal to %{count}"
6
3
  hints:
7
- # The default format to use in full error messages.
8
4
  format: "%{attribute} %{message}"
9
5
 
10
- # The values :model, :attribute and :value are always available for interpolation
11
- # The value :count is available when applicable. Can be used for pluralization.
12
6
  messages:
13
- is_a_curacao_id_number: 'hello world!'
7
+ invalid: "is invalid"
8
+ blank: "can't be blank"
9
+ empty: "can't be empty"
14
10
  inclusion:
15
11
  in: "must be one of %{count}"
16
12
  exclusion:
17
13
  in: "must not be one of %{count}"
18
- format: "what's this"
14
+ format: "must match the required format"
19
15
  associated: "is invalid"
20
16
  uniqueness: "must be unique"
21
17
  confirmation: "doesn't match confirmation"
@@ -25,7 +21,7 @@ en:
25
21
  maximum: "must not be longer than %{count} characters"
26
22
  minimum: "must not be shorter than %{count} characters"
27
23
  is: "must be exactly %{count} characters"
28
- radiolaria: "must be between %{minimum} and %{maximum} characters"
24
+ within: "must be between %{minimum} and %{maximum} characters"
29
25
  numericality:
30
26
  must_be_a_number: "must be a number"
31
27
  only_integer: "must be an integer"
@@ -36,4 +32,3 @@ en:
36
32
  less_than_or_equal_to: "must be less than or equal to %{count}"
37
33
  odd: "must be odd"
38
34
  even: "must be even"
39
-
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ValidationHints
4
+ class Railtie < Rails::Railtie
5
+ initializer "validation_hints.i18n" do
6
+ ValidationHints.load_i18n!
7
+ end
8
+
9
+ ActiveSupport.on_load(:active_model) do
10
+ ValidationHints::ValidationsPatch.apply!
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ValidationHints
4
+ module ValidationsPatch
5
+ def self.apply!
6
+ return if applied?
7
+
8
+ ActiveModel::Validations::ClassMethods.module_eval do
9
+ def has_validations?
10
+ !validators.empty?
11
+ end
12
+
13
+ def has_validations_for?(attribute)
14
+ !validators_on(attribute).empty?
15
+ end
16
+ end
17
+
18
+ ActiveModel::Validations.module_eval do
19
+ def has_validations?
20
+ self.class.has_validations?
21
+ end
22
+
23
+ def has_validations_for?(attribute)
24
+ self.class.has_validations_for?(attribute)
25
+ end
26
+
27
+ def hints
28
+ @hints ||= ActiveModel::Hints.new(self)
29
+ end
30
+ end
31
+
32
+ @applied = true
33
+ end
34
+
35
+ def self.applied?
36
+ @applied
37
+ end
38
+ end
39
+ end
@@ -1,4 +1,4 @@
1
1
  # -*- encoding : utf-8 -*-
2
2
  module ValidationHints
3
- VERSION = "0.2.3"
3
+ VERSION = "6.1.0"
4
4
  end
@@ -1,39 +1,26 @@
1
- # -*- encoding : utf-8 -*-
1
+ # frozen_string_literal: true
2
2
 
3
- require 'validation_hints/version'
4
- require 'active_model/hints'
3
+ require "validation_hints/version"
4
+ require "active_model/hints"
5
5
 
6
- module ActiveModel
6
+ module ValidationHints
7
+ LOCALE_PATH = File.expand_path("validation_hints/locale/en.yml", __dir__)
7
8
 
8
- module Validations
9
-
10
- module ClassMethods
11
-
12
- def has_validations?
13
- ! self.validators.empty?
14
- end
15
-
16
- def has_validations_for?(attribute)
17
- ! self.validators_on(attribute).empty?
18
- end
19
-
20
- end
21
-
22
- def has_validations?
23
- self.class.has_validations?
24
- end
25
-
26
- def has_validations_for?(attribute)
27
- self.class.has_validations_for?(attribute)
28
- end
29
-
30
- def hints
31
- @hints ||= Hints.new(self)
32
- end
9
+ def self.load_i18n!
10
+ return if @i18n_loaded
33
11
 
12
+ require "i18n"
13
+ I18n.load_path << LOCALE_PATH unless I18n.load_path.include?(LOCALE_PATH)
14
+ @i18n_loaded = true
34
15
  end
35
-
36
16
  end
37
17
 
38
- require 'active_support/i18n'
39
- I18n.load_path << File.dirname(__FILE__) + '/validation_hints/locale/en.yml'
18
+ require "validation_hints/validations_patch"
19
+
20
+ if defined?(Rails::Railtie)
21
+ require "validation_hints/railtie"
22
+ else
23
+ require "active_model"
24
+ ValidationHints::ValidationsPatch.apply!
25
+ ValidationHints.load_i18n!
26
+ end