semantic_attributes 1.0.2
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +2 -0
- data/Gemfile +3 -0
- data/Gemfile.lock +99 -0
- data/MIT-LICENSE +20 -0
- data/README +54 -0
- data/Rakefile +37 -0
- data/gist.rdoc +208 -0
- data/lib/active_record/validation_recursion_control.rb +33 -0
- data/lib/core_ext/class.rb +14 -0
- data/lib/predicates/aliased.rb +22 -0
- data/lib/predicates/association.rb +43 -0
- data/lib/predicates/base.rb +93 -0
- data/lib/predicates/blacklisted.rb +23 -0
- data/lib/predicates/domain.rb +31 -0
- data/lib/predicates/email.rb +42 -0
- data/lib/predicates/enumerated.rb +23 -0
- data/lib/predicates/hex_color.rb +24 -0
- data/lib/predicates/length.rb +71 -0
- data/lib/predicates/number.rb +104 -0
- data/lib/predicates/pattern.rb +22 -0
- data/lib/predicates/phone_number.rb +62 -0
- data/lib/predicates/required.rb +22 -0
- data/lib/predicates/same_as.rb +17 -0
- data/lib/predicates/size.rb +2 -0
- data/lib/predicates/time.rb +43 -0
- data/lib/predicates/unique.rb +71 -0
- data/lib/predicates/url.rb +62 -0
- data/lib/predicates/usa_state.rb +87 -0
- data/lib/predicates/usa_zip_code.rb +25 -0
- data/lib/predicates/whitelisted.rb +2 -0
- data/lib/predicates.rb +3 -0
- data/lib/semantic_attributes/attribute.rb +46 -0
- data/lib/semantic_attributes/attribute_formats.rb +67 -0
- data/lib/semantic_attributes/locale/en.yml +31 -0
- data/lib/semantic_attributes/predicates.rb +170 -0
- data/lib/semantic_attributes/set.rb +40 -0
- data/lib/semantic_attributes/version.rb +3 -0
- data/lib/semantic_attributes.rb +37 -0
- data/semantic_attributes.gemspec +29 -0
- data/test/db/database.yml +3 -0
- data/test/db/models.rb +38 -0
- data/test/db/schema.rb +33 -0
- data/test/fixtures/addresses.yml +15 -0
- data/test/fixtures/roles.yml +4 -0
- data/test/fixtures/roles_users.yml +6 -0
- data/test/fixtures/services.yml +6 -0
- data/test/fixtures/subscriptions.yml +16 -0
- data/test/fixtures/users.yml +20 -0
- data/test/test_helper.rb +67 -0
- data/test/unit/active_record_predicates_test.rb +88 -0
- data/test/unit/attribute_formats_test.rb +40 -0
- data/test/unit/inheritance_test.rb +23 -0
- data/test/unit/predicates/aliased_test.rb +17 -0
- data/test/unit/predicates/association_predicate_test.rb +51 -0
- data/test/unit/predicates/base_test.rb +53 -0
- data/test/unit/predicates/blacklisted_predicate_test.rb +28 -0
- data/test/unit/predicates/domain_predicate_test.rb +27 -0
- data/test/unit/predicates/email_test.rb +82 -0
- data/test/unit/predicates/enumerated_predicate_test.rb +22 -0
- data/test/unit/predicates/hex_color_predicate_test.rb +29 -0
- data/test/unit/predicates/length_predicate_test.rb +85 -0
- data/test/unit/predicates/number_test.rb +109 -0
- data/test/unit/predicates/pattern_predicate_test.rb +29 -0
- data/test/unit/predicates/phone_number_predicate_test.rb +41 -0
- data/test/unit/predicates/required_predicate_test.rb +13 -0
- data/test/unit/predicates/same_as_predicate_test.rb +19 -0
- data/test/unit/predicates/time_test.rb +49 -0
- data/test/unit/predicates/unique_test.rb +58 -0
- data/test/unit/predicates/url_test.rb +86 -0
- data/test/unit/predicates/usa_state_test.rb +31 -0
- data/test/unit/predicates/usa_zip_code_test.rb +42 -0
- data/test/unit/semantic_attribute_test.rb +18 -0
- data/test/unit/semantic_attributes_test.rb +29 -0
- data/test/unit/validations_test.rb +121 -0
- metadata +235 -0
@@ -0,0 +1,42 @@
|
|
1
|
+
# Describes a regular expression pattern for email addresses, based on RFC2822 and RFC3696.
|
2
|
+
# Adapted from Alex Dunae's validates_email_format_of plugin v1.2.2, available under MIT License at http://code.dunae.ca/validates_email_format_of.html
|
3
|
+
#
|
4
|
+
# ==Options
|
5
|
+
# * :with_mx_record [boolean, default false] - whether to verify the email's domain using a dns lookup for an mx record. This requires the Unix `dig` command. In Debian this is part of the dnsutils package. NOTE: RFC2821 states that when no MX records are listed, an A record may be used instead. This means that a missing MX record may not mean an invalid email address! Use at your own risk.
|
6
|
+
#
|
7
|
+
# == Example
|
8
|
+
# field_is_an_email :with_mx_record => true
|
9
|
+
class Predicates::Email < Predicates::Base
|
10
|
+
attr_accessor :with_mx_record
|
11
|
+
|
12
|
+
def validate(value, record)
|
13
|
+
# local part max is 64 chars, domain part max is 255 chars
|
14
|
+
domain, local = value.reverse.split('@', 2)
|
15
|
+
|
16
|
+
valid = value.match(EmailAddressPattern)
|
17
|
+
valid &&= !(value.match /\.\./)
|
18
|
+
valid &&= (domain.length <= 255)
|
19
|
+
valid &&= (local.length <= 64)
|
20
|
+
|
21
|
+
if valid and self.with_mx_record
|
22
|
+
mx_record = `dig #{value.split('@').last} mx +noall +short`
|
23
|
+
valid &&= (!mx_record.empty?)
|
24
|
+
end
|
25
|
+
|
26
|
+
valid
|
27
|
+
end
|
28
|
+
|
29
|
+
def error_message
|
30
|
+
@error_message || :email
|
31
|
+
end
|
32
|
+
|
33
|
+
EmailAddressPattern = begin
|
34
|
+
local_part_special_chars = Regexp.escape('!#$%&\'*-/=?+-^_`{|}~')
|
35
|
+
local_part_unquoted = '(([[:alnum:]' + local_part_special_chars + ']+[\.\+]+))*[[:alnum:]' + local_part_special_chars + '+]+'
|
36
|
+
local_part_quoted = '\"(([[:alnum:]' + local_part_special_chars + '\.\+]*|(\\\\[\x00-\xFF]))*)\"'
|
37
|
+
Regexp.new(
|
38
|
+
'^((' + local_part_unquoted + ')|(' + local_part_quoted + ')+)@(((\w+\-+)|(\w+\.))*\w{1,63}\.[a-z]{2,6}$)',
|
39
|
+
Regexp::EXTENDED | Regexp::IGNORECASE, "n"
|
40
|
+
)
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
# A basic pattern where the values are enumerated.
|
2
|
+
#
|
3
|
+
# ==Options
|
4
|
+
# * :options - a complete collection of values that the attribute may contain
|
5
|
+
#
|
6
|
+
# ==Example
|
7
|
+
# field_is_enumerated :options => ['allowed_value_one', 'allowed_value_two']
|
8
|
+
class Predicates::Enumerated < Predicates::Base
|
9
|
+
attr_accessor :options
|
10
|
+
|
11
|
+
def initialize(attr, options = {})
|
12
|
+
options[:or_empty] ||= false
|
13
|
+
super(attr, options)
|
14
|
+
end
|
15
|
+
|
16
|
+
def error_message
|
17
|
+
@error_message || :inclusion
|
18
|
+
end
|
19
|
+
|
20
|
+
def validate(value, record)
|
21
|
+
self.options.include? value
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
# Defines a field as a hex color. These are hexadecimal strings from 3 to 6 characters long. All hex colors will be saved in the database with a preceding pound sign (e.g. #123456). Also, #123456 is actually a pretty cool color. You should try it.
|
2
|
+
class Predicates::HexColor < Predicates::Pattern
|
3
|
+
# a fixed regexp
|
4
|
+
def like
|
5
|
+
@like ||= /\A#[0-9a-fA-F]{6}\Z/
|
6
|
+
end
|
7
|
+
|
8
|
+
def error_message
|
9
|
+
@error_message ||= :hex
|
10
|
+
end
|
11
|
+
|
12
|
+
def normalize(value)
|
13
|
+
return value if value.blank?
|
14
|
+
|
15
|
+
# ensure leading pound sign
|
16
|
+
value = "##{value}" unless value[0].chr == '#'
|
17
|
+
# expand from three characters to six characters
|
18
|
+
if value =~ /\A#[0-9a-fA-F]{3}\Z/
|
19
|
+
value = "##{value[1].chr * 2}#{value[2].chr * 2}#{value[3].chr * 2}"
|
20
|
+
end
|
21
|
+
|
22
|
+
value
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,71 @@
|
|
1
|
+
# Lets you declare a min/max/exact length for something. This works for arrays and strings both.
|
2
|
+
#
|
3
|
+
# ==Options
|
4
|
+
# * :above [integer] - when the attribute has a minimum
|
5
|
+
# * :below [integer] - when the attribute has a maximum
|
6
|
+
# * :range [range] - when the attribute has a minimum and a maximum
|
7
|
+
# * :exactly [integer] - when the attribute must be exactly some length
|
8
|
+
#
|
9
|
+
# ==Examples
|
10
|
+
# field_has_length :exactly => 3
|
11
|
+
# field_has_length :above => 5
|
12
|
+
# field_has_length :range => 4..8
|
13
|
+
class Predicates::Length < Predicates::Base
|
14
|
+
# when the length has just a min
|
15
|
+
attr_accessor :above
|
16
|
+
|
17
|
+
# when the length has just a max
|
18
|
+
attr_accessor :below
|
19
|
+
|
20
|
+
# when the length has both a max and a min
|
21
|
+
attr_accessor :range
|
22
|
+
|
23
|
+
# when the length must be exact
|
24
|
+
attr_accessor :exactly
|
25
|
+
|
26
|
+
def error_message
|
27
|
+
@error_message || range_description
|
28
|
+
end
|
29
|
+
|
30
|
+
def error_binds
|
31
|
+
self.range ?
|
32
|
+
{:min => self.range.first, :max => self.range.last} :
|
33
|
+
{:min => self.above, :max => self.below, :count => self.exactly}
|
34
|
+
end
|
35
|
+
|
36
|
+
def validate(value, record)
|
37
|
+
l = tokenize(value).length
|
38
|
+
if self.exactly
|
39
|
+
l == self.exactly
|
40
|
+
elsif self.range
|
41
|
+
self.range.include? l
|
42
|
+
elsif self.above
|
43
|
+
l > self.above
|
44
|
+
elsif self.below
|
45
|
+
l < self.below
|
46
|
+
else
|
47
|
+
true
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
def normalize(v)
|
52
|
+
(v and v.is_a? String) ? v.gsub("\r\n", "\n") : v
|
53
|
+
end
|
54
|
+
|
55
|
+
protected
|
56
|
+
|
57
|
+
def tokenize(value)
|
58
|
+
case value
|
59
|
+
when Array, Hash then value
|
60
|
+
else value.to_s.mb_chars
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
def range_description
|
65
|
+
return :inexact_length if self.exactly
|
66
|
+
return :wrong_length if self.range
|
67
|
+
return :too_short if self.above
|
68
|
+
return :too_long if self.below
|
69
|
+
raise 'undetermined range'
|
70
|
+
end
|
71
|
+
end
|
@@ -0,0 +1,104 @@
|
|
1
|
+
# Describes an attribute as a number. You may specify boundaries for the number (min, max, or both), and also specify whether it must be an integer.
|
2
|
+
#
|
3
|
+
# ==Options
|
4
|
+
# * :integer [boolean, default: false] - if the number is an integer (or float/decimal)
|
5
|
+
# * :above [integer, float] - when the number has a minimum
|
6
|
+
# * :below [integer, float] - when the number has a maximum
|
7
|
+
# * :range [range] - when the number has a minimum and a maximum
|
8
|
+
# * :inclusive [boolean, default: false] - if your maximum or minimum is also an allowed value. Does not work with :range.
|
9
|
+
# * :at_least [integer, float] - an easy way to say :above and :inclusive
|
10
|
+
# * :no_more_than [integer, float] - an easy way to say :below and :inclusive
|
11
|
+
#
|
12
|
+
# ==Examples
|
13
|
+
# field_is_a_number :integer => true
|
14
|
+
# field_is_a_number :range => 1..5, :integer => true
|
15
|
+
# field_is_a_number :above => 4.5
|
16
|
+
# field_is_a_number :below => 4.5, :inclusive => true
|
17
|
+
class Predicates::Number < Predicates::Base
|
18
|
+
# whether to require an integer value
|
19
|
+
attr_accessor :integer
|
20
|
+
|
21
|
+
# when the number has a minimum value, but no maximum
|
22
|
+
attr_accessor :above
|
23
|
+
|
24
|
+
# when the number has a maximum value, but no minimum
|
25
|
+
attr_accessor :below
|
26
|
+
|
27
|
+
# when the number has both a maximum and a minimum value
|
28
|
+
attr_accessor :range
|
29
|
+
|
30
|
+
# meant to be used with :above and :below, when you want the endpoint to be inclusive.
|
31
|
+
# with the :range option you can just specify inclusion using the standard Ruby range syntax.
|
32
|
+
attr_accessor :inclusive
|
33
|
+
|
34
|
+
def at_least=(val)
|
35
|
+
self.above = val
|
36
|
+
self.inclusive = true
|
37
|
+
end
|
38
|
+
|
39
|
+
def no_more_than=(val)
|
40
|
+
self.below = val
|
41
|
+
self.inclusive = true
|
42
|
+
end
|
43
|
+
|
44
|
+
def error_message
|
45
|
+
@error_message || range_description
|
46
|
+
end
|
47
|
+
|
48
|
+
def error_binds
|
49
|
+
self.range ?
|
50
|
+
{:min => self.range.first, :max => self.range.last} :
|
51
|
+
{:min => self.above, :max => self.below}
|
52
|
+
end
|
53
|
+
|
54
|
+
def validate(value, record)
|
55
|
+
# check data type
|
56
|
+
valid = if self.integer
|
57
|
+
# if it must be an integer, do a regexp check for digits only
|
58
|
+
value.to_s.match(/\A[+-]?[0-9]+\Z/) unless value.is_a? Hash or value.is_a? Array
|
59
|
+
else
|
60
|
+
# if it can also be a float or decimal, then try a conversion
|
61
|
+
begin
|
62
|
+
Kernel.Float(value)
|
63
|
+
true
|
64
|
+
rescue ArgumentError, TypeError
|
65
|
+
false
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
# if it's the right data type, then also check boundaries
|
70
|
+
valid &&= if self.range
|
71
|
+
self.range.include? value
|
72
|
+
elsif self.above
|
73
|
+
operator = self.inclusive ? :>= : :>
|
74
|
+
value.send operator, self.above
|
75
|
+
elsif self.below
|
76
|
+
operator = self.inclusive ? :<= : :<
|
77
|
+
value.send operator, self.below
|
78
|
+
else
|
79
|
+
true
|
80
|
+
end
|
81
|
+
|
82
|
+
valid
|
83
|
+
end
|
84
|
+
|
85
|
+
protected
|
86
|
+
|
87
|
+
def range_description
|
88
|
+
# if it has two endpoints
|
89
|
+
if self.range
|
90
|
+
return :between
|
91
|
+
end
|
92
|
+
# if it only has one inclusive endpoint
|
93
|
+
if inclusive
|
94
|
+
return :greater_than_or_equal_to if self.above
|
95
|
+
return :less_than_or_equal_to if self.below
|
96
|
+
# if it only has one exclusive endpoint
|
97
|
+
else
|
98
|
+
return :greater_than if self.above
|
99
|
+
return :less_than if self.below
|
100
|
+
end
|
101
|
+
# if it has no endpoints
|
102
|
+
:not_a_number
|
103
|
+
end
|
104
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# Provides a generic pattern predicate, which is extended and used by other pattern-based validations.
|
2
|
+
#
|
3
|
+
# WARNING: If you define a pattern, you probably want to use \A and \Z instead of ^ and $. The latter anchors are per-line, which means that multi-line strings can sneak stuff past your regular expression. For example:
|
4
|
+
#
|
5
|
+
# @predicate.like = /^hello world$/
|
6
|
+
# assert @predicate.validate("malicious\nhello world\ntext", nil), 'must match only one line'
|
7
|
+
# @predicate.like = /\Ahello world\Z/
|
8
|
+
# assert !@predicate.validate("malicious\nhello world\ntext", nil), 'must match entire string'
|
9
|
+
#
|
10
|
+
# ==Options
|
11
|
+
# * :like - a regular expression matching pattern
|
12
|
+
#
|
13
|
+
# == Example
|
14
|
+
# field_has_a_pattern :like => /\Aim in ur [a-z]+, [a-z]+ ur [a-z]+\Z/
|
15
|
+
class Predicates::Pattern < Predicates::Base
|
16
|
+
attr_accessor :like
|
17
|
+
|
18
|
+
def validate(value, record)
|
19
|
+
value = value.to_s if [Symbol, Fixnum].include?(value.class)
|
20
|
+
value.match(self.like)
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
# Defines a field as a phone number. Currently it assumes the phone number fits the North American Numbering Plan. Future support of other plans will be implemented by calling code, e.g. +44 (UK) or +33 (FR). These will act as triggers for localized phone number validation.
|
2
|
+
#
|
3
|
+
# ==Options
|
4
|
+
# * implied_country_code [integer, default: 1 (North America)]
|
5
|
+
#
|
6
|
+
# ==Example
|
7
|
+
# field_is_a_phone_number
|
8
|
+
class Predicates::PhoneNumber < Predicates::Base
|
9
|
+
attr_accessor :implied_country_code
|
10
|
+
|
11
|
+
def initialize(attr, options = {})
|
12
|
+
options[:implied_country_code] ||= 1
|
13
|
+
super(attr, options)
|
14
|
+
end
|
15
|
+
|
16
|
+
def error_message
|
17
|
+
@error_message ||= :phone
|
18
|
+
end
|
19
|
+
|
20
|
+
def validate(value, record)
|
21
|
+
case value
|
22
|
+
when Patterns::NANP
|
23
|
+
match = $~
|
24
|
+
valid = !match.nil?
|
25
|
+
valid &&= (match[2] != '555' or match[3][0..1] != '01') # 555-01xx are reserved (http://en.wikipedia.org/wiki/555_telephone_number)
|
26
|
+
|
27
|
+
else
|
28
|
+
valid = false
|
29
|
+
end
|
30
|
+
|
31
|
+
valid
|
32
|
+
end
|
33
|
+
|
34
|
+
# check country code, then format differently for each country
|
35
|
+
def to_human(value)
|
36
|
+
case value
|
37
|
+
when Patterns::NANP
|
38
|
+
m = $~
|
39
|
+
"(#{m[1]}) #{m[2]}-#{m[3]}"
|
40
|
+
|
41
|
+
else
|
42
|
+
value
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
# strip out all non-numeric characters except a leading +
|
47
|
+
def normalize(value)
|
48
|
+
return value if value.blank?
|
49
|
+
|
50
|
+
value = "+#{value}" if value.to_s[0..0] == '1' # north american bias
|
51
|
+
value = "+#{implied_country_code}#{value}" unless value.to_s[0..0] == '+'
|
52
|
+
|
53
|
+
leading_plus = (value[0..0] == '+')
|
54
|
+
value.gsub!(/[^0-9]/, '')
|
55
|
+
value = "+#{value}" if leading_plus
|
56
|
+
value
|
57
|
+
end
|
58
|
+
|
59
|
+
class Patterns
|
60
|
+
NANP = /\A\+1([2-9][0-8][0-9])([2-9][0-9]{2})([0-9]{4})\Z/
|
61
|
+
end
|
62
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# Marks an attribute as being required. This is really just a shortcut for using the or_empty? setting.
|
2
|
+
#
|
3
|
+
# Example:
|
4
|
+
# class Comment < ActiveRecord::Base
|
5
|
+
# has_one :owner
|
6
|
+
# subject_is_required
|
7
|
+
# owner_is_required
|
8
|
+
# end
|
9
|
+
class Predicates::Required < Predicates::Base
|
10
|
+
# this permanently sets :or_empty to false
|
11
|
+
def allow_empty?
|
12
|
+
false
|
13
|
+
end
|
14
|
+
|
15
|
+
def error_message
|
16
|
+
@error_message || :required
|
17
|
+
end
|
18
|
+
|
19
|
+
def validate(value, record)
|
20
|
+
!value.blank?
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# Requires that one field must be the same as another field. Useful when combined with virtual attributes to describe things like password or email confirmation.
|
2
|
+
#
|
3
|
+
# ==Options
|
4
|
+
#
|
5
|
+
# ==Example
|
6
|
+
# field_is_same_as :method => :other_field
|
7
|
+
class Predicates::SameAs < Predicates::Base
|
8
|
+
attr_accessor :method
|
9
|
+
|
10
|
+
def error_message
|
11
|
+
@error_message ||= :same_as
|
12
|
+
end
|
13
|
+
|
14
|
+
def validate(value, record)
|
15
|
+
value == record.send(self.method)
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
# Describes a field as a Time field. Currently this means that it has both a time and a date.
|
2
|
+
#
|
3
|
+
# ==Options
|
4
|
+
# * :before [time] - specifies that the value must predate the given time (as object or string)
|
5
|
+
# * :after [time] - specifies that the value must postdate the given time (as object or string)
|
6
|
+
# * :distance [range] - specifies a range of seconds that provide an minimum and maximum time value to be calculated from the current time.
|
7
|
+
class Predicates::Time < Predicates::Base
|
8
|
+
# specifies a time that must postdate any valid value
|
9
|
+
def before=(val)
|
10
|
+
@before = val.is_a?(Time) ? val : Time.parse(val)
|
11
|
+
end
|
12
|
+
attr_reader :before
|
13
|
+
|
14
|
+
# specifies a time that must predate any valid value
|
15
|
+
def after=(val)
|
16
|
+
@after = val.is_a?(Time) ? val : Time.parse(val)
|
17
|
+
end
|
18
|
+
attr_reader :after
|
19
|
+
|
20
|
+
# specifies a range of seconds where the lower boundary
|
21
|
+
# is the minimum time value and the upper boundary is the
|
22
|
+
# maximum time value, both interpreted from the current
|
23
|
+
# time.
|
24
|
+
attr_accessor :distance
|
25
|
+
|
26
|
+
def error_message
|
27
|
+
@error_message || :time
|
28
|
+
end
|
29
|
+
|
30
|
+
def validate(value, record)
|
31
|
+
valid = value.is_a? Time
|
32
|
+
valid &&= (value < self.before) if self.before
|
33
|
+
valid &&= (value > self.after) if self.after
|
34
|
+
|
35
|
+
if self.distance
|
36
|
+
now = Time.zone.now
|
37
|
+
valid &&= (value >= now + self.distance.begin)
|
38
|
+
valid &&= (value <= now + self.distance.end)
|
39
|
+
end
|
40
|
+
|
41
|
+
valid
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,71 @@
|
|
1
|
+
# Describes an attribute as being unique, possibly within a certain scope.
|
2
|
+
#
|
3
|
+
# ==Options
|
4
|
+
# * :scope [array] - a list of other fields that define the context for uniqueness. it's like defining a multi-column uniqueness constraint.
|
5
|
+
# * :case_sensitive [boolean, default false] - whether case matters for uniqueness.
|
6
|
+
#
|
7
|
+
# ==Examples
|
8
|
+
# field_is_unique :case_sensitive => true
|
9
|
+
# field_is_unique :scope => [:other_field, :another_other_field]
|
10
|
+
class Predicates::Unique < Predicates::Base
|
11
|
+
attr_accessor :case_sensitive
|
12
|
+
attr_accessor :scope
|
13
|
+
|
14
|
+
def initialize(attribute, options = {})
|
15
|
+
defaults = {
|
16
|
+
:scope => [],
|
17
|
+
:case_sensitive => false
|
18
|
+
}
|
19
|
+
super attribute, defaults.merge(options)
|
20
|
+
end
|
21
|
+
|
22
|
+
def error_message
|
23
|
+
@error_message || :taken
|
24
|
+
end
|
25
|
+
|
26
|
+
def validate(value, record)
|
27
|
+
klass = record.class
|
28
|
+
|
29
|
+
# merge all the scope fields with this one. they must all be unique together.
|
30
|
+
# no special treatment -- case sensitivity applies to all or none.
|
31
|
+
values = [scope].flatten.collect{ |attr| [attr, record.send(attr)] }
|
32
|
+
values << [@attribute, value]
|
33
|
+
|
34
|
+
sql = values.map do |(attr, attr_value)|
|
35
|
+
comparison_for(attr, attr_value, klass)
|
36
|
+
end
|
37
|
+
|
38
|
+
unless record.new_record?
|
39
|
+
sql << klass.send(:sanitize_sql, ["#{klass.quoted_table_name}.#{klass.primary_key} <> ?", record.id])
|
40
|
+
end
|
41
|
+
|
42
|
+
!klass.where(sql.join(" AND ")).exists?
|
43
|
+
end
|
44
|
+
|
45
|
+
protected
|
46
|
+
|
47
|
+
def comparison_for(field, value, klass)
|
48
|
+
quoted_field = "#{klass.quoted_table_name}.#{klass.connection.quote_column_name(field)}"
|
49
|
+
|
50
|
+
if klass.columns_hash[field.to_s].text?
|
51
|
+
if case_sensitive
|
52
|
+
# case sensitive text comparison in any database
|
53
|
+
klass.send(:sanitize_sql, ["#{quoted_field} #{klass.connection.case_sensitive_equality_operator} ?", value])
|
54
|
+
elsif mysql?(klass.connection)
|
55
|
+
# case INsensitive text comparison in mysql - yes this is a database specific optimization. i'm always open to better ways. :)
|
56
|
+
klass.send(:sanitize_sql, ["#{quoted_field} = ?", value])
|
57
|
+
else
|
58
|
+
# case INsensitive text comparison in most databases
|
59
|
+
klass.send(:sanitize_sql, ["LOWER(#{quoted_field}) = ?", value.to_s.downcase])
|
60
|
+
end
|
61
|
+
else
|
62
|
+
# non-text comparison
|
63
|
+
klass.send(:sanitize_sql, {field => value})
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
def mysql?(connection)
|
68
|
+
(defined?(ActiveRecord::ConnectionAdapters::MysqlAdapter) and connection.is_a?(ActiveRecord::ConnectionAdapters::MysqlAdapter)) or
|
69
|
+
(defined?(ActiveRecord::ConnectionAdapters::Mysql2Adapter) and connection.is_a?(ActiveRecord::ConnectionAdapters::Mysql2Adapter))
|
70
|
+
end
|
71
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
require 'uri'
|
2
|
+
|
3
|
+
# Defines a field as a URL.
|
4
|
+
#
|
5
|
+
# ==Options
|
6
|
+
# :domains [array, default nil] - a whitelist of allowed domains (e.g. ['com', 'net', 'org']). set to nil to allow all domains.
|
7
|
+
# :schemes [array, default ['http', 'https']] - a whitelist of allowed schemes. set to nil to allow all schemes.
|
8
|
+
# :ports [array, default nil] - a whitelist of allowed ports. set to nil to allow all ports.
|
9
|
+
# :allow_ip_address [boolean, default true] - whether to allow ip addresses instead to domain names.
|
10
|
+
# :implied_scheme [string, symbol, default 'http'] - what scheme to assume if non is present.
|
11
|
+
#
|
12
|
+
# ==Examples
|
13
|
+
# # if you need an ftp url
|
14
|
+
# field_is_an_url :schemes => ['ftp']
|
15
|
+
#
|
16
|
+
# # if you want to require https
|
17
|
+
# field_is_an_url :schemes => ['https'], :implied_scheme => 'https', :ports => [443]
|
18
|
+
class Predicates::Url < Predicates::Base
|
19
|
+
attr_accessor :domains
|
20
|
+
attr_accessor :allow_ip_address
|
21
|
+
attr_accessor :schemes
|
22
|
+
attr_accessor :ports
|
23
|
+
attr_accessor :implied_scheme
|
24
|
+
|
25
|
+
def initialize(attr, options = {})
|
26
|
+
defaults = {
|
27
|
+
:allow_ip_address => true,
|
28
|
+
:schemes => ['http', 'https'],
|
29
|
+
:implied_scheme => 'http'
|
30
|
+
}
|
31
|
+
|
32
|
+
super attr, defaults.merge(options)
|
33
|
+
end
|
34
|
+
|
35
|
+
def error_message
|
36
|
+
@error_message || :url
|
37
|
+
end
|
38
|
+
|
39
|
+
def validate(value, record)
|
40
|
+
url = URI.parse(value)
|
41
|
+
tld = (url.host && url.host.match(/\..+\Z/)) ? url.host.split('.').last : nil
|
42
|
+
|
43
|
+
valid = true
|
44
|
+
valid &&= (!tld.blank?)
|
45
|
+
valid &&= (!self.schemes or self.schemes.include? url.scheme)
|
46
|
+
valid &&= (!self.domains or self.domains.include? tld)
|
47
|
+
valid &&= (!self.ports or self.ports.include? url.port)
|
48
|
+
valid &&= (self.allow_ip_address or not url.host =~ /^([0-9]{1,3}\.){3}[0-9]{1,3}$/)
|
49
|
+
|
50
|
+
valid
|
51
|
+
rescue URI::InvalidURIError, URI::InvalidComponentError
|
52
|
+
false
|
53
|
+
end
|
54
|
+
|
55
|
+
def normalize(v)
|
56
|
+
url = URI.parse(v)
|
57
|
+
url = URI.parse("#{self.implied_scheme}://#{v}") if self.implied_scheme and not (url.scheme and url.host)
|
58
|
+
url.to_s
|
59
|
+
rescue URI::InvalidURIError, URI::InvalidComponentError
|
60
|
+
v
|
61
|
+
end
|
62
|
+
end
|
@@ -0,0 +1,87 @@
|
|
1
|
+
# Requires that an attribute be in a list of valid USA states.
|
2
|
+
# Machine format is the abbreviation, human format is the whole word.
|
3
|
+
# List acquired from: http://www.usps.com/ncsc/lookups/abbr_state.txt
|
4
|
+
#
|
5
|
+
# ==Options
|
6
|
+
# * :with_territories [boolean, default false] - whether to allow territories
|
7
|
+
class Predicates::UsaState < Predicates::Aliased
|
8
|
+
# a boolean for whether to allow united states territories. default is false.
|
9
|
+
attr_writer :with_territories
|
10
|
+
def with_territories?
|
11
|
+
@with_territories ? true : false
|
12
|
+
end
|
13
|
+
|
14
|
+
def options
|
15
|
+
@options ||= STATES.merge(with_territories? ? TERRITORIES : {})
|
16
|
+
end
|
17
|
+
undef_method :options=
|
18
|
+
|
19
|
+
def error_message
|
20
|
+
@error_message || with_territories? ? :us_state_or_territory : :us_state
|
21
|
+
end
|
22
|
+
|
23
|
+
TERRITORIES = {
|
24
|
+
'American Samoa' => 'AS',
|
25
|
+
'Federated States of Micronesia' => 'FM',
|
26
|
+
'Guam' => 'GU',
|
27
|
+
'Marshall Islands' => 'MH',
|
28
|
+
'Northern Mariana Islands' => 'MP',
|
29
|
+
'Palau' => 'PW',
|
30
|
+
'Puerto Rico' => 'PR',
|
31
|
+
'Virgin Islands' => 'VI'
|
32
|
+
}
|
33
|
+
|
34
|
+
STATES = {
|
35
|
+
'Alabama' => 'AL',
|
36
|
+
'Alaska' => 'AK',
|
37
|
+
'Arizona' => 'AZ',
|
38
|
+
'Arkansas' => 'AR',
|
39
|
+
'California' => 'CA',
|
40
|
+
'Colorado' => 'CO',
|
41
|
+
'Connecticut' => 'CT',
|
42
|
+
'Delaware' => 'DE',
|
43
|
+
'District of Columbia' => 'DC',
|
44
|
+
'Florida' => 'FL',
|
45
|
+
'Georgia' => 'GA',
|
46
|
+
'Hawaii' => 'HI',
|
47
|
+
'Idaho' => 'ID',
|
48
|
+
'Illinois' => 'IL',
|
49
|
+
'Indiana' => 'IN',
|
50
|
+
'Iowa' => 'IA',
|
51
|
+
'Kansas' => 'KS',
|
52
|
+
'Kentucky' => 'KY',
|
53
|
+
'Louisiana' => 'LA',
|
54
|
+
'Maine' => 'ME',
|
55
|
+
'Maryland' => 'MD',
|
56
|
+
'Massachusetts' => 'MA',
|
57
|
+
'Michigan' => 'MI',
|
58
|
+
'Minnesota' => 'MN',
|
59
|
+
'Mississippi' => 'MS',
|
60
|
+
'Missouri' => 'MO',
|
61
|
+
'Montana' => 'MT',
|
62
|
+
'Nebraska' => 'NE',
|
63
|
+
'Nevaga' => 'NV',
|
64
|
+
'New Hampshire' => 'NH',
|
65
|
+
'New Jersey' => 'NJ',
|
66
|
+
'New Mexico' => 'NM',
|
67
|
+
'New York' => 'NY',
|
68
|
+
'North Carolina' => 'NC',
|
69
|
+
'North Dakota' => 'ND',
|
70
|
+
'Ohio' => 'OH',
|
71
|
+
'Oklahoma' => 'OK',
|
72
|
+
'Oregon' => 'OR',
|
73
|
+
'Pennsylvania' => 'PA',
|
74
|
+
'Rhode Island' => 'RI',
|
75
|
+
'South Carolina' => 'SC',
|
76
|
+
'South Dakota' => 'SD',
|
77
|
+
'Tennessee' => 'TN',
|
78
|
+
'Texas' => 'TX',
|
79
|
+
'Utah' => 'UT',
|
80
|
+
'Vermont' => 'VT',
|
81
|
+
'Virginia' => 'VA',
|
82
|
+
'Washington' => 'WA',
|
83
|
+
'West Virginia' => 'WV',
|
84
|
+
'Wisconsin' => 'WI',
|
85
|
+
'Wyoming' => 'WY'
|
86
|
+
}
|
87
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# A United States zip code.
|
2
|
+
# Only validates format, does not attempt any verification for *actual* zip codes.
|
3
|
+
# Validates via regular expression.
|
4
|
+
#
|
5
|
+
# ==Options
|
6
|
+
# * :extended [:allowed, :required, or false (default)] - whether to allow (or require!) the extended (+4) zip code format
|
7
|
+
class Predicates::UsaZipCode < Predicates::Pattern
|
8
|
+
attr_accessor :extended
|
9
|
+
|
10
|
+
def like
|
11
|
+
return @like unless @like.nil?
|
12
|
+
pattern = '[0-9]{5}'
|
13
|
+
if extended
|
14
|
+
pattern += '(-[0-9]{4})'
|
15
|
+
pattern += '?' unless extended == :required
|
16
|
+
end
|
17
|
+
|
18
|
+
@like = /\A#{pattern}\Z/
|
19
|
+
end
|
20
|
+
undef_method :like=
|
21
|
+
|
22
|
+
def error_message
|
23
|
+
@error_message || :us_zip_code
|
24
|
+
end
|
25
|
+
end
|