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/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 &lt; 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,3 @@
1
+ module AutoValidate
2
+ VERSION = "0.0.2"
3
+ 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