semantic_attributes 1.0.2
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.
- 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
|