auto_validate 0.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 +4 -0
- data/Gemfile +4 -0
- data/LICENSE +2693 -0
- data/README.md +48 -0
- data/Rakefile +16 -0
- data/auto_validate.gemspec +30 -0
- data/lib/auto_validate.rb +216 -0
- data/lib/auto_validate/version.rb +3 -0
- data/lib/tasks/prepare.rake +13 -0
- data/test/auto_validate/null_constraint_test.rb +28 -0
- data/test/auto_validate/numericality_test.rb +30 -0
- data/test/auto_validate/uniqueness_test.rb +51 -0
- data/test/connection.rb +4 -0
- data/test/dummy_migrations/postgresql.sql +46 -0
- data/test/dummy_models/no_table.rb +3 -0
- data/test/dummy_models/promo_code.rb +3 -0
- data/test/dummy_models/tag.rb +5 -0
- data/test/dummy_models/tagging.rb +5 -0
- data/test/dummy_models/user.rb +8 -0
- data/test/dummy_models/widget_request.rb +5 -0
- data/test/factories/user_factory.rb +0 -0
- data/test/test_helper.rb +32 -0
- metadata +173 -0
data/README.md
ADDED
@@ -0,0 +1,48 @@
|
|
1
|
+
# Auto validation #
|
2
|
+
|
3
|
+
Automatically use PostgreSQL table constraints as model validations
|
4
|
+
|
5
|
+
Tests run on Ruby 1.9.2 using Rails 3.2.1
|
6
|
+
|
7
|
+
Currently supports the following constraints:
|
8
|
+
|
9
|
+
* NULL constraint
|
10
|
+
* Numeric fields
|
11
|
+
* Single column indexes (including lower indexes)
|
12
|
+
* Multi column indexes
|
13
|
+
|
14
|
+
## TODO: ##
|
15
|
+
|
16
|
+
### Immediate TODOs ###
|
17
|
+
|
18
|
+
* More efficient index validation
|
19
|
+
* simple check constraints in PostgreSQL
|
20
|
+
* Reduce the number of queries to get the information that is needed
|
21
|
+
|
22
|
+
### Long term TODOs ###
|
23
|
+
* Add support for MySQL
|
24
|
+
|
25
|
+
### Very long term TODOs ###
|
26
|
+
* Add support for DataMapper
|
27
|
+
* Add support for Sequel
|
28
|
+
|
29
|
+
## INSTALL ##
|
30
|
+
|
31
|
+
To install simply add the following to your Gemfile
|
32
|
+
|
33
|
+
`gem 'auto_validate', :git =>
|
34
|
+
"git://github.com/omarqureshi/auto_validate.git"`
|
35
|
+
|
36
|
+
## USAGE ##
|
37
|
+
|
38
|
+
Simply insert auto_validate in your class
|
39
|
+
|
40
|
+
<pre><code>class Foo < ActiveRecord::Base
|
41
|
+
auto_validate
|
42
|
+
end
|
43
|
+
</code></pre>
|
44
|
+
|
45
|
+
## TESTS ##
|
46
|
+
|
47
|
+
Before running tests, ensure you run `rake test:prepare` so that the
|
48
|
+
test database is created
|
data/Rakefile
ADDED
@@ -0,0 +1,16 @@
|
|
1
|
+
require 'bundler/setup'
|
2
|
+
require 'bundler/gem_tasks'
|
3
|
+
require 'rake/testtask'
|
4
|
+
require 'rake/dsl_definition'
|
5
|
+
|
6
|
+
$LOAD_PATH.unshift("lib")
|
7
|
+
load 'tasks/prepare.rake'
|
8
|
+
|
9
|
+
Rake::TestTask.new do |t|
|
10
|
+
t.libs << 'lib' << 'test'
|
11
|
+
t.pattern = 'test/**/*_test.rb'
|
12
|
+
t.verbose = false
|
13
|
+
end
|
14
|
+
|
15
|
+
desc 'Default: run tests'
|
16
|
+
task :default => [:test]
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
$:.push File.expand_path("../lib", __FILE__)
|
3
|
+
require "auto_validate/version"
|
4
|
+
|
5
|
+
Gem::Specification.new do |s|
|
6
|
+
s.name = "auto_validate"
|
7
|
+
s.version = AutoValidate::VERSION
|
8
|
+
s.authors = ["Omar Qureshi"]
|
9
|
+
s.email = ["omar@omarqureshi.net"]
|
10
|
+
s.homepage = "http://omarqureshi.net"
|
11
|
+
s.summary = %q{Automatic validations for ActiveRecord}
|
12
|
+
s.description = %q{This gem looks at the schema for any validations that it can easily apply based on known db constraints and adds application level validations for them}
|
13
|
+
|
14
|
+
s.rubyforge_project = "auto_validate"
|
15
|
+
|
16
|
+
s.files = `git ls-files`.split("\n")
|
17
|
+
s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
18
|
+
s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
|
19
|
+
s.require_paths = ["lib"]
|
20
|
+
|
21
|
+
s.add_development_dependency "rake", ">=0.9.2"
|
22
|
+
s.add_development_dependency "shoulda"
|
23
|
+
s.add_development_dependency "shoulda-matchers"
|
24
|
+
s.add_development_dependency "shoulda-context"
|
25
|
+
s.add_development_dependency "pg"
|
26
|
+
s.add_development_dependency "factory_girl"
|
27
|
+
s.add_development_dependency "bcrypt-ruby"
|
28
|
+
s.add_development_dependency "database_cleaner"
|
29
|
+
s.add_runtime_dependency "rails"
|
30
|
+
end
|
@@ -0,0 +1,216 @@
|
|
1
|
+
require "auto_validate/version"
|
2
|
+
require "active_record"
|
3
|
+
|
4
|
+
# First run for this application will be JUST PostgreSQL, just as a
|
5
|
+
# proof of concept - currently all tests are running solely against
|
6
|
+
# 9.1, though a lot of this should work for anything over 8
|
7
|
+
#
|
8
|
+
# Reference for this class is at
|
9
|
+
# http://www.postgresql.org/docs/current/static/catalog-pg-constraint.html
|
10
|
+
#
|
11
|
+
# For null constraints, something like this is possible:
|
12
|
+
#
|
13
|
+
# select pg_attribute.*
|
14
|
+
# from pg_attribute, pg_class
|
15
|
+
# where pg_class.oid = pg_attribute.attrelid
|
16
|
+
# and relname = 'users' and attnum > 0 and not attisdropped;
|
17
|
+
#
|
18
|
+
# Also for null constraints, don't bother to check for primary key -
|
19
|
+
# just assume that its an auto-increment, for now.
|
20
|
+
#
|
21
|
+
# Boolean null constraints are also a bit messy, don't bother with
|
22
|
+
# them also. Instead, do a validates_inclusion_of :in => [true, false]
|
23
|
+
# instead
|
24
|
+
#
|
25
|
+
# Example output for the above query:
|
26
|
+
#
|
27
|
+
# attname | attnotnull | atthasdef
|
28
|
+
# ------------------+------------+-----------
|
29
|
+
# id | t | t
|
30
|
+
# email | t | f
|
31
|
+
# crypted_password | t | f
|
32
|
+
# admin | t | t
|
33
|
+
#
|
34
|
+
# Indexes on the other hand are a bit more different.
|
35
|
+
#
|
36
|
+
# There are two tables that are pretty important for this, both
|
37
|
+
# pg_index and pg_indexes. What could be done would be to look at
|
38
|
+
# pg_index and try to work out the keys for the index by looking at
|
39
|
+
# indkey, however, this doesn't quite work so well for expression
|
40
|
+
# based indexes.
|
41
|
+
#
|
42
|
+
# In order to deal with those, I've ended up parsing the indexdef on
|
43
|
+
# pg_indexes. Which, although may be slower, would result in one
|
44
|
+
# database query rather than two. Eventually what could be done is one
|
45
|
+
# query with a join when the indkey is 0
|
46
|
+
#
|
47
|
+
#
|
48
|
+
# something like:
|
49
|
+
#
|
50
|
+
# select *
|
51
|
+
# from pg_index, pg_class, pg_indexes
|
52
|
+
# where pg_class.oid = pg_index.indexrelid
|
53
|
+
# and pg_indexes.indexname = pg_class.relname
|
54
|
+
# and pg_indexes.tablename = '#{self.table_name}'
|
55
|
+
# and not pg_index.indisprimary
|
56
|
+
# and pg_index.indisunique
|
57
|
+
#
|
58
|
+
# maybe?
|
59
|
+
#
|
60
|
+
# TODO:
|
61
|
+
# * Check constraints with an in clause
|
62
|
+
# * Unique index checks without having to parse the SQL
|
63
|
+
# * Figure out how to reuse the pg_attr query that ActiveRecord uses
|
64
|
+
# in Rails 3.0+ or indeed modify it so that there are less table
|
65
|
+
# introspection queries to be done.
|
66
|
+
|
67
|
+
|
68
|
+
module AutoValidate
|
69
|
+
mattr_accessor :attributes, :indexes, :defined_primary_key
|
70
|
+
|
71
|
+
def auto_validate
|
72
|
+
if connection.table_exists?(table_name)
|
73
|
+
self.defined_primary_key = locate_primary_key
|
74
|
+
self.attributes = load_attributes
|
75
|
+
self.indexes = load_unique_indexes
|
76
|
+
add_validates_presence_of_and_boolean_validations
|
77
|
+
add_validates_uniqueness_of
|
78
|
+
add_validates_numericality_of
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
private
|
83
|
+
|
84
|
+
def load_attributes
|
85
|
+
str = "and attname != '#{defined_primary_key}'" if defined_primary_key
|
86
|
+
connection.execute <<EOS
|
87
|
+
select pg_attribute.*, typcategory
|
88
|
+
from pg_attribute, pg_class, pg_type
|
89
|
+
where pg_class.oid = pg_attribute.attrelid
|
90
|
+
and pg_type.oid = pg_attribute.atttypid
|
91
|
+
and relname = '#{self.table_name}'
|
92
|
+
and attnum > 0
|
93
|
+
and not attisdropped
|
94
|
+
#{str};
|
95
|
+
EOS
|
96
|
+
end
|
97
|
+
|
98
|
+
def load_unique_indexes
|
99
|
+
# alternatively - fetch all indexes in a single query?
|
100
|
+
res = connection.select_values <<EOS
|
101
|
+
select indexdef
|
102
|
+
from pg_index, pg_class, pg_indexes
|
103
|
+
where pg_class.oid = pg_index.indexrelid
|
104
|
+
and pg_indexes.indexname = pg_class.relname
|
105
|
+
and pg_indexes.tablename = '#{self.table_name}'
|
106
|
+
and not pg_index.indisprimary
|
107
|
+
and pg_index.indisunique
|
108
|
+
EOS
|
109
|
+
end
|
110
|
+
|
111
|
+
def locate_primary_key
|
112
|
+
connection.select_value <<EOS
|
113
|
+
select pg_attribute.attname
|
114
|
+
from pg_index, pg_class, pg_attribute
|
115
|
+
where
|
116
|
+
pg_class.oid = '#{self.table_name}'::regclass
|
117
|
+
and indrelid = pg_class.oid
|
118
|
+
and pg_attribute.attrelid = pg_class.oid
|
119
|
+
and pg_attribute.attnum = any(pg_index.indkey)
|
120
|
+
and indisprimary
|
121
|
+
EOS
|
122
|
+
end
|
123
|
+
|
124
|
+
def add_validates_presence_of_and_boolean_validations
|
125
|
+
attributes.each do |res|
|
126
|
+
if res["atttypid"] == boolean_type
|
127
|
+
self.class_eval do
|
128
|
+
validates_inclusion_of res["attname"].to_sym, :in => [true, false]
|
129
|
+
end
|
130
|
+
else
|
131
|
+
if res["attnotnull"] == 't' && res["atthasdef"] == 'f'
|
132
|
+
self.class_eval do
|
133
|
+
validates_presence_of res["attname"].to_sym
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|
137
|
+
end
|
138
|
+
end
|
139
|
+
|
140
|
+
def add_validates_uniqueness_of
|
141
|
+
indexes.reduce({}) do |mem, res|
|
142
|
+
res = res.gsub(/CREATE UNIQUE INDEX ([a-zA-Z0-9_]*) ON ([a-zA-Z0-9_]*) USING ([a-zA-Z0-9]*) \(/, "")
|
143
|
+
res = res[0..-2]
|
144
|
+
res = res.split(/(\(|\))/).reduce([]) do |mem, res|
|
145
|
+
mem << res unless ["(", ")"].include?(res)
|
146
|
+
mem
|
147
|
+
end
|
148
|
+
case res.size
|
149
|
+
when 1
|
150
|
+
add_case_sensitive_validates_uniqueness_of(res.first)
|
151
|
+
when 2
|
152
|
+
if res[0].downcase == "lower"
|
153
|
+
add_case_insensitive_validates_uniqueness_of(res.last)
|
154
|
+
end
|
155
|
+
end
|
156
|
+
end
|
157
|
+
end
|
158
|
+
|
159
|
+
def add_case_sensitive_validates_uniqueness_of(attr)
|
160
|
+
index = multicolumn_index(attr)
|
161
|
+
scope = index[1..-1]
|
162
|
+
self.class_eval do
|
163
|
+
if scope.empty?
|
164
|
+
validates_uniqueness_of index[0].to_sym
|
165
|
+
else
|
166
|
+
validates_uniqueness_of index[0].to_sym, :scope => scope
|
167
|
+
end
|
168
|
+
end
|
169
|
+
end
|
170
|
+
|
171
|
+
def add_case_insensitive_validates_uniqueness_of(attr)
|
172
|
+
index = multicolumn_index(attr)
|
173
|
+
scope = index[1..-1]
|
174
|
+
self.class_eval do
|
175
|
+
if scope.empty?
|
176
|
+
validates_uniqueness_of attr.to_sym, :case_sensitive => false
|
177
|
+
else
|
178
|
+
validates_uniqueness_of attr.to_sym, :case_sensitive => false, :scope => scope
|
179
|
+
end
|
180
|
+
end
|
181
|
+
end
|
182
|
+
|
183
|
+
def multicolumn_index(attr)
|
184
|
+
attr.split(",").map(&:strip)
|
185
|
+
end
|
186
|
+
|
187
|
+
def add_validates_numericality_of
|
188
|
+
attributes.each do |attribute|
|
189
|
+
if attribute["typcategory"] == "N"
|
190
|
+
# convert attlen to number bit count, use as power of 2 and
|
191
|
+
# divide by 2 to take into account negativity - 1
|
192
|
+
# i.e. for integer (attlen 4)
|
193
|
+
# this becomes 2 ** (4*8) then divide by 2 and -1 so that the
|
194
|
+
# range of -2147483648 to +2147483647 is captured
|
195
|
+
maxsize = ((2 ** (attribute["attlen"].to_i * 8)) / 2) - 1
|
196
|
+
minsize = 0-maxsize-1
|
197
|
+
self.class_eval do
|
198
|
+
validates_numericality_of attribute["attname"].to_sym, :less_than => maxsize, :greater_than => minsize
|
199
|
+
end
|
200
|
+
end
|
201
|
+
end
|
202
|
+
end
|
203
|
+
|
204
|
+
def boolean_type
|
205
|
+
unless defined? @_boolean_type
|
206
|
+
@_boolean_type = connection.select_value <<EOS
|
207
|
+
select oid from pg_type where typname = 'bool'
|
208
|
+
EOS
|
209
|
+
end
|
210
|
+
end
|
211
|
+
|
212
|
+
end
|
213
|
+
|
214
|
+
class ActiveRecord::Base
|
215
|
+
extend AutoValidate
|
216
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
namespace :test do
|
2
|
+
desc "Prepare the database"
|
3
|
+
task :prepare do
|
4
|
+
`dropdb auto-validate`
|
5
|
+
`createdb auto-validate`
|
6
|
+
file = File.join(File.dirname(__FILE__),
|
7
|
+
"..", "..",
|
8
|
+
"test",
|
9
|
+
"dummy_migrations",
|
10
|
+
"postgresql.sql")
|
11
|
+
`psql auto-validate -f #{file}`
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
require 'test_helper'
|
2
|
+
|
3
|
+
class NullConstraintTest < Test::Unit::TestCase
|
4
|
+
|
5
|
+
def test_should_not_allow_nil_email
|
6
|
+
f = User.new(:password => "password",
|
7
|
+
:password_confirmation => "password",
|
8
|
+
:admin => false)
|
9
|
+
deny f.save
|
10
|
+
assert f.errors[:email].any?
|
11
|
+
end
|
12
|
+
|
13
|
+
def test_should_not_allow_nil_password_digest
|
14
|
+
f = User.new(:email => "foo@bar.com",
|
15
|
+
:admin => false)
|
16
|
+
deny f.save
|
17
|
+
assert f.errors[:password_digest].any?
|
18
|
+
end
|
19
|
+
|
20
|
+
def test_should_allow_nil_admin
|
21
|
+
f = User.new(:email => "test@test.com",
|
22
|
+
:password => "password",
|
23
|
+
:password_confirmation => "password")
|
24
|
+
assert f.save
|
25
|
+
deny f.errors[:admin].any?
|
26
|
+
end
|
27
|
+
|
28
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
require 'test_helper'
|
2
|
+
|
3
|
+
class NumericalityTest < Test::Unit::TestCase
|
4
|
+
|
5
|
+
def test_should_not_allow_less_than_1_widget
|
6
|
+
u = User.create(:email => "test@test.com",
|
7
|
+
:password => "test",
|
8
|
+
:password_confirmation => "test")
|
9
|
+
wr = u.widget_requests.build(:quantity => 0, :user_id => u.id)
|
10
|
+
deny wr.valid?
|
11
|
+
end
|
12
|
+
|
13
|
+
def test_should_not_allow_more_widgets_than_maximum_integer_size
|
14
|
+
u = User.create(:email => "test@test.com",
|
15
|
+
:password => "test",
|
16
|
+
:password_confirmation => "test")
|
17
|
+
wr = u.widget_requests.build(:quantity => 100000000000000000000000000000000000000000000000000, :user_id => u.id)
|
18
|
+
deny wr.valid?
|
19
|
+
end
|
20
|
+
|
21
|
+
def test_should_allow_normal_amount_of_widgets
|
22
|
+
u = User.create(:email => "test@test.com",
|
23
|
+
:password => "test",
|
24
|
+
:password_confirmation => "test")
|
25
|
+
wr = u.widget_requests.build(:quantity => 1, :user_id => u.id)
|
26
|
+
assert wr.valid?
|
27
|
+
end
|
28
|
+
|
29
|
+
|
30
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
require 'test_helper'
|
2
|
+
|
3
|
+
class UniquenessTest < Test::Unit::TestCase
|
4
|
+
|
5
|
+
def test_should_not_be_able_to_use_the_same_email_address_twice
|
6
|
+
f = User.new(:email => "test@test.com",
|
7
|
+
:password => "test",
|
8
|
+
:password_confirmation => "test")
|
9
|
+
assert f.save
|
10
|
+
g = User.new(:email => "test@test.com",
|
11
|
+
:password => "test",
|
12
|
+
:password_confirmation => "test")
|
13
|
+
deny g.save
|
14
|
+
assert g.errors[:email].any?
|
15
|
+
end
|
16
|
+
|
17
|
+
def test_should_correctly_enforce_case_insensitivity_for_lower_unique_index
|
18
|
+
f = User.new(:email => "test@test.com",
|
19
|
+
:password => "test",
|
20
|
+
:password_confirmation => "test")
|
21
|
+
assert f.save
|
22
|
+
g = User.new(:email => "TEST@test.com",
|
23
|
+
:password => "test",
|
24
|
+
:password_confirmation => "test")
|
25
|
+
deny g.save
|
26
|
+
assert g.errors[:email].any?
|
27
|
+
end
|
28
|
+
|
29
|
+
def test_should_correctly_enforce_case_sensitivity_for_unique_index
|
30
|
+
p = PromoCode.new(:code => "FOOBAR",
|
31
|
+
:description => "Adds foos to your bar")
|
32
|
+
assert p.save
|
33
|
+
q = PromoCode.new(:code => "FOOBAR",
|
34
|
+
:description => "Adds foos to your bar")
|
35
|
+
deny q.save
|
36
|
+
assert q.errors[:code].any?
|
37
|
+
end
|
38
|
+
|
39
|
+
def test_should_correcty_enforce_uniqueness_on_multicolumn_indexes
|
40
|
+
f = User.new(:email => "test@test.com",
|
41
|
+
:password => "test",
|
42
|
+
:password_confirmation => "test")
|
43
|
+
assert f.save
|
44
|
+
t = Tag.create(:name => "Foo")
|
45
|
+
tagging = f.taggings.build(:tag => t)
|
46
|
+
assert tagging.save
|
47
|
+
another_tagging = f.taggings.build(:tag => t)
|
48
|
+
deny another_tagging.save
|
49
|
+
end
|
50
|
+
|
51
|
+
end
|