validation_kit 2.0.0 → 2.0.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 48f6bae26b123232914da0c154b955798b82233cf152c9c0c823f0f223289956
4
- data.tar.gz: 55a3a8f8458f886adbcfb3e2dceb84d7900f206bc0c491d18203dc193ae755e3
3
+ metadata.gz: 83f8486330f7a2179f4655e0cd938355d9b79c9b96279238829b4c3c7a860151
4
+ data.tar.gz: 479ec62978042d99be1efb5985c3efad7e18615f5450374cd131ebd2a907ff06
5
5
  SHA512:
6
- metadata.gz: 81cb679e9504f56963681edf741dd33c93c370664dc9e33c376923c934487a78f8d1e189f0506124889070fa62f3447c05978d8abffc2f5abf71094c4e53eafb
7
- data.tar.gz: b8baa82c5421ae9388f1b36f12be7214613ad7948b8ac2bf5c1e8074a8e768a43a7ec1fa15466c3e06758bdbe58a7428d30a305559468f36be7eb0ac480596ff
6
+ metadata.gz: b2940c397a76ebedbcbcb9802081159eb58b837c7284a6f77e5487081164b23447a30092485b7e765c295af88e8fdb6ba6fd5aac355cf46a1795b7beaad451fd
7
+ data.tar.gz: 5d59577b6a5d73bb96f1063b1d8b8125856d23d4d48d01c7e67584a47067e3df43054db60aa768c4c1b2bf175eea8fff8198000f8d1f0262b2175b3470c59d25
data/CHANGELOG.md CHANGED
@@ -1,5 +1,75 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.0.1 (2026-10-02)
4
+
5
+ ### Breaking changes
6
+
7
+ - The email length limits (254 characters in all, 64 before the `@`) now count
8
+ every character, as RFC 5321 does, including the quotes around a quoted local
9
+ part and each backslash escape. So a quoted local part of 63 characters plus
10
+ its quotes, which used to pass, is now too long. The limits are checked by
11
+ the validator and the new `ValidationKit::EmailValidator.valid_address?`, not
12
+ by `EMAIL_ADDRESS_RE`: code that uses the regex directly no longer gets them,
13
+ and should call `valid_address?` instead.
14
+ - An `area_key:` that isn't an Australian state or territory (NSW, ACT, VIC,
15
+ TAS, QLD, SA, NT, WA, in any case) now raises `ArgumentError` when the model
16
+ is defined, and `format_as_phone` raises for one too. It used to silently add
17
+ the NSW/ACT area code (02), so `"Victoria"` or a typo stored a wrong number.
18
+ Leaving `area_key:` out still means NSW/ACT.
19
+ - A symbol `country:` option that's neither a method on the model nor a
20
+ two-letter country code (such as a misspelled method name,
21
+ `country: :billing_contry`) now raises `ArgumentError` when validating. It
22
+ used to fall back to the model's `country` method, or skip validation
23
+ silently.
24
+ - The bundled mixed case messages no longer start with the attribute's name, so
25
+ `full_messages` names it once ("First name cannot be in all caps", not "First
26
+ name First name cannot be in all caps"), like Rails' own messages:
27
+ - `errors[:first_name]` is now `["cannot be in all caps"]`. Code that shows
28
+ `errors[attribute]` on its own should use `full_messages_for` instead.
29
+ - `attribute_name:` no longer changes the default message. `full_messages`
30
+ uses the attribute's translated name (`human_attribute_name`) instead; to
31
+ rename it, translate the attribute. `attribute_name:` still fills in
32
+ `%{item}` in custom messages and translations, which keep working.
33
+ - Apps that set `errors.format` to `"%{message}"` to hide the doubled name now
34
+ get messages with no name at all, and can drop that setting.
35
+
36
+ ### Changed
37
+
38
+ - US and Canadian phone numbers with extra digits right after the number and no
39
+ extension marker in front of them (any letter, as in `x`, `ext` or
40
+ `extension`, or `#`, `#` or `№`) are now invalid, with or without `set:`. A
41
+ mistyped number such as `212-555-12345` used to pass, and `set:` stored it as
42
+ `(212) 555-1234 5`. This includes non-ASCII digits such as `5`.
43
+ - Without `set:`, a US or Canadian number with letters among its first ten
44
+ digits (`519 444 000 ext 123`) is now invalid, as it already was with `set:`.
45
+ - With `set:`, a stored extension no longer keeps a leading `,` or `;` or
46
+ surrounding spaces (including non-breaking ones): `212-555-1234, ext 5`
47
+ becomes `(212) 555-1234 ext 5`. An extension written with non-ASCII digits
48
+ (`x5`) is kept instead of being dropped. Other marked extensions (`x5`,
49
+ `ext. 99`, `(x5)`, `#5`, …) are stored as before.
50
+ ### Fixed
51
+
52
+ - The email validator still backtracked catastrophically on input with quote
53
+ characters, despite the 2.0.0 fix: `'a"' * 28 + "@"` (57 characters) took
54
+ about a minute to reject. The two length checks at the start of the regex
55
+ caused it; they're now plain length checks, and that input fails in well
56
+ under a millisecond.
57
+ - Email addresses with non-ASCII letters that case-fold to ASCII ones, such as
58
+ `a@ſx.com`, are rejected, like every other non-ASCII address.
59
+ - `country: :CA` (or `:ca`) now validates as Canada, as the README says. A
60
+ symbol was only ever treated as a method name, so without a `CA` method the
61
+ validator used the model's `country` method instead, or skipped validation
62
+ silently.
63
+ - The phone validator's `area_key:` (and `format_as_phone`'s area key) now
64
+ accepts Australian states in any case, as a string or symbol. `:vic` or
65
+ `"vic"` used to silently get the NSW/ACT area code (02).
66
+
67
+ ### Documentation
68
+
69
+ - The README's error-type example used `errors.added?(:first_name, :all_caps)`,
70
+ which is always false for mixed case errors because they carry an `item:`
71
+ option. It now uses `of_kind?`, and explains when `added?` needs `item:`.
72
+
3
73
  ## 2.0.0 (2026-10-01)
4
74
 
5
75
  ### Breaking changes
data/README.md CHANGED
@@ -29,6 +29,10 @@ Person.new(email: "Bob").valid? # => false
29
29
  Person.new(email: "joe@foobar.com").valid? # => true
30
30
  ```
31
31
 
32
+ Addresses are limited to 254 characters, and 64 before the `@` (RFC 5321). To
33
+ check an address outside a model, use
34
+ `ValidationKit::EmailValidator.valid_address?("joe@foobar.com")`.
35
+
32
36
  ## Mixed case
33
37
 
34
38
  Validates that a string isn't all capitals or all lowercase. Values with fewer
@@ -45,11 +49,11 @@ Person.new(first_name: "bob").valid? # => false, "First name cannot be in all lo
45
49
  Person.new(first_name: "Bob").valid? # => true
46
50
  ```
47
51
 
48
- | Option | Description |
49
- | ----------------- | ---------------------------------------------------------------------------------------------- |
50
- | `attribute_name:` | Name to use in the message. Defaults to the translated attribute name, then the humanized one. |
51
- | `all_caps:` | Custom message for all-caps values. |
52
- | `all_lowercase:` | Custom message for all-lowercase values. |
52
+ | Option | Description |
53
+ | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
54
+ | `attribute_name:` | Name for `%{item}` in a custom message, used when the attribute has no translation (a translation wins). It doesn’t change `full_messages`, which use the attribute’s translated name. |
55
+ | `all_caps:` | Custom message for all-caps values. |
56
+ | `all_lowercase:` | Custom message for all-lowercase values. |
53
57
 
54
58
  ## Phone
55
59
 
@@ -62,11 +66,16 @@ class Person < ActiveRecord::Base
62
66
  end
63
67
  ```
64
68
 
65
- | Option | Description |
66
- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------- |
67
- | `country:` | See [Specifying the country](#specifying-the-country). |
68
- | `set:` | Reformat the stored value: `"1-212-555-1234 x5"` becomes `"(212) 555-1234 x5"`, `"0298765432"` becomes `"(02) 9876 5432"`. |
69
- | `area_key:` | For Australian landlines entered without an area code, the state whose area code to add (`"VIC"`, `"QLD"`, …; default NSW/ACT, `02`). |
69
+ | Option | Description |
70
+ | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
71
+ | `country:` | See [Specifying the country](#specifying-the-country). |
72
+ | `set:` | Reformat the stored value: `"1-212-555-1234 x5"` becomes `"(212) 555-1234 x5"`, `"0298765432"` becomes `"(02) 9876 5432"`. |
73
+ | `area_key:` | For Australian landlines entered without an area code, the state whose area code to add: `NSW`, `ACT`, `VIC`, `TAS`, `QLD`, `SA`, `NT` or `WA`, in any case, as a string or symbol (`"VIC"`, `:qld`). It's always a fixed state, not a method name. Leave it out (or blank) for NSW/ACT (`02`); any other value raises `ArgumentError`. |
74
+
75
+ A US or Canadian number may end with an extension, which needs a marker in
76
+ front of its digits: any letter (as in `x`, `ext` or `extension`), or `#`, `#`
77
+ or `№` (`"212-555-1234 ext 5"`). Digits right after the number with no marker,
78
+ as in `"212-555-12345"`, make it invalid.
70
79
 
71
80
  ## Postal code
72
81
 
@@ -92,8 +101,9 @@ case-insensitive (`"CA"`, `"ca"`, or `:CA`), and validation is skipped when the
92
101
  country isn't supported.
93
102
 
94
103
  ```ruby
95
- # A fixed country code
104
+ # A fixed country code, as a string or a two-letter symbol
96
105
  validates :phone, phone: { country: "CA" }
106
+ validates :phone, phone: { country: :ca }
97
107
 
98
108
  # The name of a method on your model that returns the country code
99
109
  validates :phone, phone: { country: :billing_country }
@@ -102,15 +112,29 @@ validates :phone, phone: { country: :billing_country }
102
112
  validates :phone, phone: true
103
113
  ```
104
114
 
115
+ A symbol is a method name if your model has that public method; otherwise a
116
+ two-letter symbol is a country code. Any other symbol (such as a misspelled
117
+ method name) raises `ArgumentError` when validating, rather than skipping
118
+ validation. Two things that check can't catch: a misspelled two-letter method
119
+ name is read as a country code (and skipped if that country isn't supported),
120
+ and with `allow_nil:` or `allow_blank:` it only runs once the attribute has a
121
+ value.
122
+
105
123
  ## Error messages
106
124
 
107
125
  Errors are added with standard types, so the usual ActiveModel tools work:
108
126
 
109
127
  ```ruby
110
- person.errors.added?(:email, :invalid) # email, phone, postal code
111
- person.errors.added?(:first_name, :all_caps) # mixed case (also :all_lowercase)
128
+ person.errors.of_kind?(:email, :invalid) # email, phone, postal code
129
+ person.errors.of_kind?(:first_name, :all_caps) # mixed case (also :all_lowercase)
112
130
  ```
113
131
 
132
+ `of_kind?` checks the attribute and error type. `added?` also compares the
133
+ error's options, and mixed case errors carry an `item:` option (the
134
+ attribute's name, for messages that use `%{item}`; see
135
+ [Translation keys](#translation-keys) for how it's chosen), so for them
136
+ `added?` only matches if you pass the same `item:`.
137
+
114
138
  Pass `message:` (or `all_caps:` / `all_lowercase:` for mixed case) for a custom
115
139
  message, or translate it like any other Rails validation error:
116
140
 
@@ -122,13 +146,14 @@ en:
122
146
  person:
123
147
  attributes:
124
148
  email:
125
- invalid: "Enter your email address"
149
+ invalid: "doesn’t look like an email address"
126
150
  first_name:
127
- all_caps: "Your first name cannot be in all caps"
151
+ all_caps: "can’t be all capitals"
128
152
  ```
129
153
 
130
- Use `activemodel` instead of `activerecord` for classes that aren't
131
- ActiveRecord models. The gem includes English and French messages for the
154
+ Like Rails’ own messages, these leave out the attribute’s name, because
155
+ `full_messages` adds it: "First name can’t be all capitals". Use `activemodel`
156
+ instead of `activerecord` for classes that aren't ActiveRecord models. The gem includes English and French messages for the
132
157
  mixed case errors.
133
158
 
134
159
  ## Translation keys
@@ -146,17 +171,19 @@ other ActiveModel classes:
146
171
 
147
172
  A `message:` option (or `all_caps:` / `all_lowercase:`) skips the lookup.
148
173
 
149
- | Validator | Error type | Default message (en) | Default message (fr) |
150
- | ------------- | ---------------- | ------------------------------------ | ------------------------------------------ |
151
- | `email` | `:invalid` | "is invalid" (from Rails) | from Rails' locale files (e.g. rails-i18n) |
152
- | `phone` | `:invalid` | "is invalid" (from Rails) | from Rails' locale files (e.g. rails-i18n) |
153
- | `postal_code` | `:invalid` | "is invalid" (from Rails) | from Rails' locale files (e.g. rails-i18n) |
154
- | `mixed_case` | `:all_caps` | "%{item} cannot be in all caps" | "%{item} ne peut pas être en majuscules" |
155
- | `mixed_case` | `:all_lowercase` | "%{item} cannot be in all lowercase" | "%{item} ne peut pas être en minuscules" |
156
-
157
- For mixed case, `%{item}` is the attribute's name: its translation at
158
- `activerecord.attributes.person.<attribute>`, then the `attribute_name:`
159
- option, then the humanized attribute name.
174
+ | Validator | Error type | Default message (en) | Default message (fr) |
175
+ | ------------- | ---------------- | ---------------------------- | ------------------------------------------ |
176
+ | `email` | `:invalid` | "is invalid" (from Rails) | from Rails' locale files (e.g. rails-i18n) |
177
+ | `phone` | `:invalid` | "is invalid" (from Rails) | from Rails' locale files (e.g. rails-i18n) |
178
+ | `postal_code` | `:invalid` | "is invalid" (from Rails) | from Rails' locale files (e.g. rails-i18n) |
179
+ | `mixed_case` | `:all_caps` | "cannot be in all caps" | "ne peut pas être en majuscules" |
180
+ | `mixed_case` | `:all_lowercase` | "cannot be in all lowercase" | "ne peut pas être en minuscules" |
181
+
182
+ Like Rails' own messages, the mixed case messages leave out the attribute's
183
+ name, which `full_messages` adds: "First name cannot be in all caps". A custom
184
+ message or translation can include it as `%{item}`: the attribute's
185
+ translation at `activerecord.attributes.person.<attribute>`, then the
186
+ `attribute_name:` option, then the humanized attribute name.
160
187
 
161
188
  ## Credits
162
189
 
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ValidationKit
4
+ # The country: option shared by the phone and postal code validators.
5
+ module CountryOption
6
+ private
7
+
8
+ # The country code to validate against, or nil to skip validation:
9
+ # - a String is the code itself ("CA")
10
+ # - a Symbol is the name of a method on the record that returns the code
11
+ # (:billing_country); if there's no such method, a two-letter symbol is
12
+ # the code itself (:CA), and anything else is a mistake, so it raises
13
+ # rather than silently skipping validation
14
+ # - without the option, the record's country method, if it has one
15
+ def country_for(record)
16
+ option = options[:country]
17
+
18
+ case option
19
+ when String
20
+ option
21
+ when Symbol
22
+ if record.respond_to?(option)
23
+ record.public_send(option)
24
+ elsif option.match?(/\A[a-z]{2}\z/i)
25
+ option
26
+ else
27
+ raise ArgumentError, "country: #{option.inspect} is neither a public method on " \
28
+ "#{record.class.name || record.class.inspect} nor a two-letter country code"
29
+ end
30
+ else
31
+ record.country if record.respond_to?(:country)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -2,10 +2,31 @@
2
2
 
3
3
  module ValidationKit
4
4
  class EmailValidator < ActiveModel::EachValidator
5
- EMAIL_ADDRESS_RE = /\A(?!(?:(?:\x22?\x5C[\x00-\x7E]\x22?)|(?:\x22?[^\x5C\x22]\x22?)){255,})(?!(?:(?:\x22?\x5C[\x00-\x7E]\x22?)|(?:\x22?[^\x5C\x22]\x22?)){65,}@)(?:(?:[\x21\x23-\x27\x2A\x2B\x2D\x2F-\x39\x3D\x3F\x5E-\x7E]+)|(?:\x22(?:[\x01-\x08\x0B\x0C\x0E-\x1F\x21\x23-\x5B\x5D-\x7F]|(?:\x5C[\x00-\x7F]))*\x22))(?:\.(?:(?:[\x21\x23-\x27\x2A\x2B\x2D\x2F-\x39\x3D\x3F\x5E-\x7E]+)|(?:\x22(?:[\x01-\x08\x0B\x0C\x0E-\x1F\x21\x23-\x5B\x5D-\x7F]|(?:\x5C[\x00-\x7F]))*\x22)))*@(?:(?:(?!.*[^.]{64,})(?:(?:xn--)?[a-z0-9]+(?:-[a-z0-9]+)*\.){1,126}(?:(?:[a-z][a-z0-9]*)|(?:(?:xn--)[a-z0-9]+))(?:-[a-z0-9]+)*)|(?:\[(?:(?:IPv6:(?:(?:[a-f0-9]{1,4}(?::[a-f0-9]{1,4}){7})|(?:(?!(?:.*[a-f0-9][:\]]){7,})(?:[a-f0-9]{1,4}(?::[a-f0-9]{1,4}){0,5})?::(?:[a-f0-9]{1,4}(?::[a-f0-9]{1,4}){0,5})?)))|(?:(?:IPv6:(?:(?:[a-f0-9]{1,4}(?::[a-f0-9]{1,4}){5}:)|(?:(?!(?:.*[a-f0-9]:){5,})(?:[a-f0-9]{1,4}(?::[a-f0-9]{1,4}){0,3})?::(?:[a-f0-9]{1,4}(?::[a-f0-9]{1,4}){0,3}:)?)))?(?:(?:25[0-5])|(?:2[0-4][0-9])|(?:1[0-9]{2})|(?:[1-9]?[0-9]))(?:\.(?:(?:25[0-5])|(?:2[0-4][0-9])|(?:1[0-9]{2})|(?:[1-9]?[0-9]))){3}))\]))\z/i
5
+ # RFC 5321's length limits: the whole address, and the part before the @.
6
+ # They're checked in validate_each rather than as lookaheads at the start of
7
+ # EMAIL_ADDRESS_RE, where they backtracked exponentially on input like
8
+ # 'a"a"a"…@' (about a minute for 57 characters).
9
+ MAX_LENGTH = 254
10
+ MAX_LOCAL_PART_LENGTH = 64
11
+
12
+ # The address syntax only; validate_each also applies the length limits.
13
+ EMAIL_ADDRESS_RE = /\A(?:(?:[\x21\x23-\x27\x2A\x2B\x2D\x2F-\x39\x3D\x3F\x5E-\x7E]+)|(?:\x22(?:[\x01-\x08\x0B\x0C\x0E-\x1F\x21\x23-\x5B\x5D-\x7F]|(?:\x5C[\x00-\x7F]))*\x22))(?:\.(?:(?:[\x21\x23-\x27\x2A\x2B\x2D\x2F-\x39\x3D\x3F\x5E-\x7E]+)|(?:\x22(?:[\x01-\x08\x0B\x0C\x0E-\x1F\x21\x23-\x5B\x5D-\x7F]|(?:\x5C[\x00-\x7F]))*\x22)))*@(?:(?:(?!.*[^.]{64,})(?:(?:xn--)?[a-z0-9]+(?:-[a-z0-9]+)*\.){1,126}(?:(?:[a-z][a-z0-9]*)|(?:(?:xn--)[a-z0-9]+))(?:-[a-z0-9]+)*)|(?:\[(?:(?:IPv6:(?:(?:[a-f0-9]{1,4}(?::[a-f0-9]{1,4}){7})|(?:(?!(?:.*[a-f0-9][:\]]){7,})(?:[a-f0-9]{1,4}(?::[a-f0-9]{1,4}){0,5})?::(?:[a-f0-9]{1,4}(?::[a-f0-9]{1,4}){0,5})?)))|(?:(?:IPv6:(?:(?:[a-f0-9]{1,4}(?::[a-f0-9]{1,4}){5}:)|(?:(?!(?:.*[a-f0-9]:){5,})(?:[a-f0-9]{1,4}(?::[a-f0-9]{1,4}){0,3})?::(?:[a-f0-9]{1,4}(?::[a-f0-9]{1,4}){0,3}:)?)))?(?:(?:25[0-5])|(?:2[0-4][0-9])|(?:1[0-9]{2})|(?:[1-9]?[0-9]))(?:\.(?:(?:25[0-5])|(?:2[0-4][0-9])|(?:1[0-9]{2})|(?:[1-9]?[0-9]))){3}))\]))\z/i
14
+
15
+ # Whether address is a valid email address: the syntax and the length
16
+ # limits. For use outside a model; EMAIL_ADDRESS_RE alone skips the limits.
17
+ def self.valid_address?(address)
18
+ address = address.to_s
19
+ # The syntax is ASCII-only, but the regex's /i matching would also let
20
+ # Unicode case-folded letters (ſ, the Kelvin sign) through as s and k.
21
+ return false unless address.ascii_only?
22
+ return false if address.length > MAX_LENGTH
23
+
24
+ # The domain can't contain an @, so the last one ends the local part.
25
+ address.rpartition("@").first.length <= MAX_LOCAL_PART_LENGTH && EMAIL_ADDRESS_RE.match?(address)
26
+ end
6
27
 
7
28
  def validate_each(record, attribute, value)
8
- return if EMAIL_ADDRESS_RE.match?(value.to_s)
29
+ return if self.class.valid_address?(value)
9
30
 
10
31
  record.errors.add(attribute, :invalid, message: options[:message])
11
32
  end
@@ -1,5 +1,5 @@
1
1
  en:
2
2
  errors:
3
3
  messages:
4
- all_caps: "%{item} cannot be in all caps"
5
- all_lowercase: "%{item} cannot be in all lowercase"
4
+ all_caps: "cannot be in all caps"
5
+ all_lowercase: "cannot be in all lowercase"
@@ -1,5 +1,5 @@
1
1
  fr:
2
2
  errors:
3
3
  messages:
4
- all_caps: "%{item} ne peut pas être en majuscules"
5
- all_lowercase: "%{item} ne peut pas être en minuscules"
4
+ all_caps: "ne peut pas être en majuscules"
5
+ all_lowercase: "ne peut pas être en minuscules"
@@ -23,6 +23,9 @@ module ValidationKit
23
23
 
24
24
  return if error.nil?
25
25
 
26
+ # The bundled messages leave the name to full_messages, like Rails' own.
27
+ # item: is still passed for custom messages and translations that use
28
+ # %{item}.
26
29
  record.errors.add(attribute, error, item: item_name(record, attribute), message: options[error])
27
30
  end
28
31
 
@@ -2,6 +2,26 @@
2
2
 
3
3
  module ValidationKit
4
4
  class PhoneValidator < ActiveModel::EachValidator
5
+ include CountryOption
6
+
7
+ # Digits right after a US/CA number with no marker in front of them (any
8
+ # letter, as in x, ext or extension, or #, # or №): a mistyped number like
9
+ # 212-555-12345, not an extension. \p{Nd} also catches non-ASCII digits.
10
+ UNMARKED_US_CA_EXTENSION = /\A[^[:alpha:]##№]*\p{Nd}/
11
+
12
+ # Australian states and territories (the area_key: values), and the
13
+ # landline area code each one adds.
14
+ AU_AREA_CODES = {
15
+ "NSW" => "02", "ACT" => "02", "VIC" => "03", "TAS" => "03",
16
+ "QLD" => "07", "SA" => "08", "NT" => "08", "WA" => "08"
17
+ }.freeze
18
+
19
+ # area_key: is a fixed state, so a typo fails when the model is loaded
20
+ # rather than silently adding the wrong area code.
21
+ def check_validity!
22
+ area_code_for_key(options[:area_key])
23
+ end
24
+
5
25
  def regex_for_country(country_code)
6
26
  country_code = country_code.to_s.strip.upcase
7
27
 
@@ -15,16 +35,7 @@ module ValidationKit
15
35
  end
16
36
 
17
37
  def validate_each(record, attribute, value)
18
- country = if options[:country].is_a?(String)
19
- options[:country]
20
- elsif options[:country].is_a?(Symbol) && record.respond_to?(options[:country])
21
- record.public_send(options[:country])
22
- elsif record.respond_to?(:country)
23
- record.country
24
- else
25
- false
26
- end
27
-
38
+ country = country_for(record)
28
39
  return unless country
29
40
 
30
41
  # accept "us", :US, " US " as well as "US"
@@ -38,7 +49,12 @@ module ValidationKit
38
49
 
39
50
  # allow_blank/allow_nil are handled by ActiveModel on the original value,
40
51
  # so input that only becomes blank once cleaned up (e.g. "n/a") is invalid.
41
- if current_regex.match?(new_value)
52
+ valid = current_regex.match?(new_value)
53
+ # The US/CA regex only checks the start of the digits; format_as_phone is
54
+ # what rejects extra digits that aren't a marked extension.
55
+ valid &&= !format_as_phone(value, country).nil? if %w[US CA].include?(country)
56
+
57
+ if valid
42
58
  if options[:set]
43
59
  formatted_phone = format_as_phone(value, country, options[:area_key])
44
60
  if formatted_phone.nil?
@@ -53,6 +69,8 @@ module ValidationKit
53
69
  end
54
70
 
55
71
  def format_as_phone(arg, country_code = nil, area_key = nil)
72
+ # Checked first, so a bad key always raises, whatever number is passed.
73
+ area_code = area_code_for_key(area_key)
56
74
  country_code = country_code.to_s.strip.upcase
57
75
  return nil if arg.blank? || country_code.blank? || !regex_for_country(country_code)
58
76
 
@@ -64,7 +82,7 @@ module ValidationKit
64
82
  when /\A(?:1300|1800|1900|1902)\d{6}\z/
65
83
  number.insert(4, " ").insert(8, " ")
66
84
  when /\A(?:0?[12378])?[1-9][0-9]{7}\z/
67
- number.insert(0, area_code_for_key(area_key)) if /\A[1-9][0-9]{7}\z/.match?(number)
85
+ number.insert(0, area_code) if /\A[1-9][0-9]{7}\z/.match?(number)
68
86
  number.insert(0, "0") if /\A[12378][1-9][0-9]{7}\z/.match?(number)
69
87
 
70
88
  number.insert(0, "(").insert(3, ") ").insert(9, " ")
@@ -79,7 +97,8 @@ module ValidationKit
79
97
  # if it's too short
80
98
  return nil if number.length < 10
81
99
 
82
- # strip off the leading 1 (country code); any digits beyond ten are an extension
100
+ # strip off the leading 1 (country code); any digits beyond ten are an
101
+ # extension, unless they're unmarked (UNMARKED_US_CA_EXTENSION)
83
102
  leading_one = number.length > 10 && number.start_with?("1")
84
103
  number = number[1..] if leading_one
85
104
 
@@ -87,30 +106,40 @@ module ValidationKit
87
106
  exchange = number[3..5]
88
107
  sln = number[6..9]
89
108
 
90
- if number.length == 10
91
- extension = nil
92
- else
93
- # save everything after the last digit of the number as the extension
94
- national = arg[/\A(?:\D*\d){#{leading_one ? 11 : 10}}/]
95
- # if letters appear among those digits, the extension's digits were
96
- # counted as part of the number (e.g. 519 444 000 ext 123), so return
97
- # nil to error out
98
- return nil if national.sub(/\A\D*/, "").match?(/[[:alpha:]]/)
99
-
100
- extension = " #{arg[national.length..].strip}"
109
+ # everything after the last digit of the number
110
+ national = arg[/\A(?:\D*\d){#{leading_one ? 11 : 10}}/]
111
+ rest = arg[national.length..]
112
+
113
+ # if letters appear among the number's digits, the extension's digits
114
+ # were counted as part of the number (e.g. 519 444 000 ext 123), so
115
+ # return nil to error out
116
+ return nil if number.length > 10 && national.sub(/\A\D*/, "").match?(/[[:alpha:]]/)
117
+
118
+ # Text after the number with no digits in it (e.g. "(cell)") is dropped;
119
+ # with digits, it's the extension, if they're marked as one.
120
+ extension = nil
121
+ if rest.match?(/\p{Nd}/)
122
+ return nil if UNMARKED_US_CA_EXTENSION.match?(rest)
123
+
124
+ # [[:space:]] also covers non-breaking spaces, which strip doesn't
125
+ extension = " #{rest.strip.sub(/\A[[:space:],;]+/, "").sub(/[[:space:]]+\z/, "")}"
101
126
  end
102
127
 
103
128
  "(#{area_code}) #{exchange}-#{sln}#{extension}"
104
129
  end
105
130
  end
106
131
 
132
+ # key is an AU state, in any case, as a string or symbol (always the state
133
+ # itself, never a method name); nil or blank means NSW/ACT. Anything else
134
+ # raises, rather than silently adding the NSW/ACT code (02) to, say, a
135
+ # Victorian number.
107
136
  def area_code_for_key(key)
108
- case key
109
- when "VIC", "TAS" then "03"
110
- when "QLD" then "07"
111
- when "SA", "NT", "WA" then "08"
112
- else
113
- "02" # NSW, ACT, and the default
137
+ state = key.to_s.strip.upcase
138
+ return AU_AREA_CODES["NSW"] if state.empty?
139
+
140
+ AU_AREA_CODES.fetch(state) do
141
+ raise ArgumentError, "area_key: #{key.inspect} isn't an Australian state or territory " \
142
+ "(#{AU_AREA_CODES.keys.join(", ")})"
114
143
  end
115
144
  end
116
145
  end
@@ -2,6 +2,8 @@
2
2
 
3
3
  module ValidationKit
4
4
  class PostalCodeValidator < ActiveModel::EachValidator
5
+ include CountryOption
6
+
5
7
  def postal_code_regex_for_country(country_code)
6
8
  country_code = country_code.to_s.strip.upcase
7
9
 
@@ -31,16 +33,7 @@ module ValidationKit
31
33
  end
32
34
 
33
35
  def validate_each(record, attribute, value)
34
- country = if options[:country].is_a?(String)
35
- options[:country]
36
- elsif options[:country].is_a?(Symbol) && record.respond_to?(options[:country])
37
- record.public_send(options[:country])
38
- elsif record.respond_to?(:country)
39
- record.country
40
- else
41
- false
42
- end
43
-
36
+ country = country_for(record)
44
37
  return unless country
45
38
 
46
39
  # accept "us", :US, " US " as well as "US"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ValidationKit
4
- VERSION = "2.0.0"
4
+ VERSION = "2.0.1"
5
5
  end
@@ -3,6 +3,7 @@
3
3
  require "active_model"
4
4
  require "active_support/core_ext/string/inflections"
5
5
  require "validation_kit/version"
6
+ require "validation_kit/country_option"
6
7
 
7
8
  module ValidationKit
8
9
  VALIDATORS = {} # rubocop:disable Style/MutableConstant -- filled below, then frozen
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: validation_kit
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0
4
+ version: 2.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Wes Morgan
@@ -36,6 +36,7 @@ files:
36
36
  - LICENSE
37
37
  - README.md
38
38
  - lib/validation_kit.rb
39
+ - lib/validation_kit/country_option.rb
39
40
  - lib/validation_kit/email_validator/email_validator.rb
40
41
  - lib/validation_kit/mixed_case_validator/config/locales/en.yml
41
42
  - lib/validation_kit/mixed_case_validator/config/locales/fr.yml
@@ -65,7 +66,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
65
66
  - !ruby/object:Gem::Version
66
67
  version: '0'
67
68
  requirements: []
68
- rubygems_version: 4.0.20
69
+ rubygems_version: 4.0.10
69
70
  specification_version: 4
70
71
  summary: Handy validations for Rails forms
71
72
  test_files: []