├── .github
└── workflows
│ └── prs.yml
├── .gitignore
├── .simplecov
├── Gemfile
├── MIT-LICENSE
├── README.md
├── Rakefile
├── gemfiles
├── Gemfile.base
├── activerecord-5.2
│ ├── Gemfile.base
│ ├── Gemfile.mysql2
│ ├── Gemfile.postgresql
│ └── Gemfile.sqlite3
├── activerecord-6.0
│ ├── Gemfile.base
│ ├── Gemfile.mysql2
│ ├── Gemfile.postgresql
│ └── Gemfile.sqlite3
├── activerecord-6.1
│ ├── Gemfile.base
│ ├── Gemfile.mysql2
│ ├── Gemfile.postgresql
│ └── Gemfile.sqlite3
└── activerecord-7.0
│ ├── Gemfile.base
│ ├── Gemfile.mysql2
│ ├── Gemfile.postgresql
│ └── Gemfile.sqlite3
├── init.rb
├── lib
├── schema_validations.rb
└── schema_validations
│ ├── active_record
│ ├── type.rb
│ └── validations.rb
│ ├── railtie.rb
│ ├── validators
│ └── not_nil_validator.rb
│ └── version.rb
├── schema_dev.yml
├── schema_validations.gemspec
└── spec
├── spec_helper.rb
├── support
└── active_model.rb
└── validations_spec.rb
/.github/workflows/prs.yml:
--------------------------------------------------------------------------------
1 | # This file was auto-generated by the schema_dev tool, based on the data in
2 | # ./schema_dev.yml
3 | # Please do not edit this file; any changes will be overwritten next time
4 | # schema_dev gets run.
5 | ---
6 | name: CI PR Builds
7 | 'on':
8 | push:
9 | branches:
10 | - master
11 | pull_request:
12 | concurrency:
13 | group: ci-${{ github.ref }}
14 | cancel-in-progress: true
15 | jobs:
16 | test:
17 | runs-on: ubuntu-latest
18 | strategy:
19 | fail-fast: false
20 | matrix:
21 | ruby:
22 | - '2.5'
23 | - '2.7'
24 | - '3.0'
25 | - '3.1'
26 | activerecord:
27 | - '5.2'
28 | - '6.0'
29 | - '6.1'
30 | - '7.0'
31 | db:
32 | - mysql2
33 | - sqlite3
34 | - skip
35 | dbversion:
36 | - skip
37 | exclude:
38 | - ruby: '3.0'
39 | activerecord: '5.2'
40 | - ruby: '3.1'
41 | activerecord: '5.2'
42 | - ruby: '2.5'
43 | activerecord: '7.0'
44 | - db: skip
45 | dbversion: skip
46 | include:
47 | - ruby: '2.5'
48 | activerecord: '5.2'
49 | db: postgresql
50 | dbversion: '9.6'
51 | - ruby: '2.5'
52 | activerecord: '6.0'
53 | db: postgresql
54 | dbversion: '9.6'
55 | - ruby: '2.5'
56 | activerecord: '6.1'
57 | db: postgresql
58 | dbversion: '9.6'
59 | - ruby: '2.7'
60 | activerecord: '5.2'
61 | db: postgresql
62 | dbversion: '9.6'
63 | - ruby: '2.7'
64 | activerecord: '6.0'
65 | db: postgresql
66 | dbversion: '9.6'
67 | - ruby: '2.7'
68 | activerecord: '6.1'
69 | db: postgresql
70 | dbversion: '9.6'
71 | - ruby: '2.7'
72 | activerecord: '7.0'
73 | db: postgresql
74 | dbversion: '9.6'
75 | - ruby: '3.0'
76 | activerecord: '6.0'
77 | db: postgresql
78 | dbversion: '9.6'
79 | - ruby: '3.0'
80 | activerecord: '6.1'
81 | db: postgresql
82 | dbversion: '9.6'
83 | - ruby: '3.0'
84 | activerecord: '7.0'
85 | db: postgresql
86 | dbversion: '9.6'
87 | - ruby: '3.1'
88 | activerecord: '6.0'
89 | db: postgresql
90 | dbversion: '9.6'
91 | - ruby: '3.1'
92 | activerecord: '6.1'
93 | db: postgresql
94 | dbversion: '9.6'
95 | - ruby: '3.1'
96 | activerecord: '7.0'
97 | db: postgresql
98 | dbversion: '9.6'
99 | env:
100 | BUNDLE_GEMFILE: "${{ github.workspace }}/gemfiles/activerecord-${{ matrix.activerecord }}/Gemfile.${{ matrix.db }}"
101 | MYSQL_DB_HOST: 127.0.0.1
102 | MYSQL_DB_USER: root
103 | MYSQL_DB_PASS: database
104 | POSTGRESQL_DB_HOST: 127.0.0.1
105 | POSTGRESQL_DB_USER: schema_plus_test
106 | POSTGRESQL_DB_PASS: database
107 | steps:
108 | - uses: actions/checkout@v2
109 | - name: Set up Ruby
110 | uses: ruby/setup-ruby@v1
111 | with:
112 | ruby-version: "${{ matrix.ruby }}"
113 | bundler-cache: true
114 | - name: Run bundle update
115 | run: bundle update
116 | - name: Start Mysql
117 | if: matrix.db == 'mysql2'
118 | run: |
119 | docker run --rm --detach \
120 | -e MYSQL_ROOT_PASSWORD=$MYSQL_DB_PASS \
121 | -p 3306:3306 \
122 | --health-cmd "mysqladmin ping --host=127.0.0.1 --password=$MYSQL_DB_PASS --silent" \
123 | --health-interval 5s \
124 | --health-timeout 5s \
125 | --health-retries 5 \
126 | --name database mysql:5.6
127 | - name: Start Postgresql
128 | if: matrix.db == 'postgresql'
129 | run: |
130 | docker run --rm --detach \
131 | -e POSTGRES_USER=$POSTGRESQL_DB_USER \
132 | -e POSTGRES_PASSWORD=$POSTGRESQL_DB_PASS \
133 | -p 5432:5432 \
134 | --health-cmd "pg_isready -q" \
135 | --health-interval 5s \
136 | --health-timeout 5s \
137 | --health-retries 5 \
138 | --name database postgres:${{ matrix.dbversion }}
139 | - name: Wait for database to start
140 | if: "(matrix.db == 'postgresql' || matrix.db == 'mysql2')"
141 | run: |
142 | COUNT=0
143 | ATTEMPTS=20
144 | until [[ $COUNT -eq $ATTEMPTS ]]; do
145 | [ "$(docker inspect -f {{.State.Health.Status}} database)" == "healthy" ] && break
146 | echo $(( COUNT++ )) > /dev/null
147 | sleep 2
148 | done
149 | - name: Create testing database
150 | if: "(matrix.db == 'postgresql' || matrix.db == 'mysql2')"
151 | run: bundle exec rake create_ci_database
152 | - name: Run tests
153 | run: bundle exec rake spec
154 | - name: Shutdown database
155 | if: always() && (matrix.db == 'postgresql' || matrix.db == 'mysql2')
156 | run: docker stop database
157 | - name: Coveralls Parallel
158 | if: "${{ !env.ACT }}"
159 | uses: coverallsapp/github-action@master
160 | with:
161 | github-token: "${{ secrets.GITHUB_TOKEN }}"
162 | flag-name: run-${{ matrix.ruby }}-${{ matrix.activerecord }}-${{ matrix.db }}-${{ matrix.dbversion }}
163 | parallel: true
164 | finish:
165 | needs: test
166 | runs-on: ubuntu-latest
167 | steps:
168 | - name: Coveralls Finished
169 | if: "${{ !env.ACT }}"
170 | uses: coverallsapp/github-action@master
171 | with:
172 | github-token: "${{ secrets.GITHUB_TOKEN }}"
173 | parallel-finished: true
174 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | ## MAC OS
2 | .DS_Store
3 |
4 | ## TEXTMATE
5 | *.tmproj
6 | tmtags
7 |
8 | ## EMACS
9 | *~
10 | \#*
11 | .\#*
12 |
13 | ## VIM
14 | .*.sw?
15 |
16 | ## PROJECT::GENERAL
17 | coverage
18 | rdoc
19 | pkg
20 | .byebug_history
21 |
22 | ## PROJECT::SPECIFIC
23 | .rvmrc
24 | *.log
25 | tmp/
26 | Gemfile.local
27 | Gemfile.lock
28 | gemfiles/*.lock
29 | gemfiles/**/*.lock
30 | /.idea
31 |
--------------------------------------------------------------------------------
/.simplecov:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | SimpleCov.configure do
4 | enable_coverage :branch
5 | add_filter '/spec/'
6 |
7 | add_group 'Binaries', '/bin/'
8 | add_group 'Libraries', '/lib/'
9 |
10 | if ENV['CI']
11 | require 'simplecov-lcov'
12 |
13 | SimpleCov::Formatter::LcovFormatter.config do |c|
14 | c.report_with_single_file = true
15 | c.single_report_path = 'coverage/lcov.info'
16 | end
17 |
18 | formatter SimpleCov::Formatter::LcovFormatter
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | source "http://rubygems.org"
4 |
5 | gemspec
6 |
7 | gemfile_local = File.expand_path '../Gemfile.local', __FILE__
8 | eval File.read(gemfile_local), binding, gemfile_local if File.exist? gemfile_local
9 |
--------------------------------------------------------------------------------
/MIT-LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2006 RedHill Consulting, Pty. Ltd.
2 | Copyright (c) 2011 Ronen Barzel & Michał Łomnicki
3 |
4 | Permission is hereby granted, free of charge, to any person obtaining
5 | a copy of this software and associated documentation files (the
6 | "Software"), to deal in the Software without restriction, including
7 | without limitation the rights to use, copy, modify, merge, publish,
8 | distribute, sublicense, and/or sell copies of the Software, and to
9 | permit persons to whom the Software is furnished to do so, subject to
10 | the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be
13 | included in all copies or substantial portions of the Software.
14 |
15 | Except as contained in this notice, the name(s) of the above copyright
16 | holders shall not be used in advertising or otherwise to promote the sale,
17 | use or other dealings in this Software without prior written authorization.
18 |
19 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
20 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
21 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
22 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
23 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
24 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
25 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
26 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # SchemaValidations
2 |
3 | SchemaValidations is an ActiveRecord extension that keeps your model class
4 | definitions simpler and more DRY, by automatically defining validations based
5 | on the database schema.
6 |
7 | [](http://badge.fury.io/rb/schema_validations)
8 | [](http://github.com/SchemaPlus/schema_validations/actions)
9 | [](https://coveralls.io/github/SchemaPlus/schema_validations)
10 |
11 |
12 | ## Overview
13 |
14 | One of the great things about Rails (ActiveRecord, in particular) is that it
15 | inspects the database and automatically defines accessors for all your
16 | columns, keeping your model class definitions simple and DRY. That's great
17 | for simple data columns, but where it falls down is when your table contains
18 | constraints.
19 |
20 | ```ruby
21 | create_table :users do |t|
22 | t.string :email, null: false, limit: 30
23 | t.boolean :confirmed, null: false
24 | end
25 | ```
26 |
27 | In that case the constraints `null: false`, `limit: 30` and `:boolean` must be validated on the model level, to avoid ugly database exceptions:
28 |
29 | ```ruby
30 | class User < ActiveRecord::Base
31 | validates :email, presence: true, length: { maximum: 30 }
32 | validates :confirmed, presence: true, inclusion: { in: [true, false] }
33 | end
34 | ```
35 |
36 | ...which isn't the most DRY approach.
37 |
38 | SchemaValidations aims to DRY up your models, doing that boring work for you. It inspects the database and automatically creates validations based on the schema. After installing it your model is as simple as it can be.
39 |
40 | ```ruby
41 | class User < ActiveRecord::Base
42 | end
43 | ```
44 |
45 | Validations are there but they are created by schema_validations under the
46 | hood.
47 |
48 | ## Installation
49 |
50 | Simply add schema_validations to your Gemfile.
51 |
52 | ```ruby
53 | gem "schema_validations"
54 | ```
55 |
56 | ## Which validations are covered?
57 |
58 | Constraints:
59 |
60 | | Constraint | Validation |
61 | |---------------------|---------------------------------------------------|
62 | | `null: false` | `validates ... presence: true` |
63 | | `limit: 100` | `validates ... length: { maximum: 100 }` |
64 | | `unique: true` | `validates ... uniqueness: true` |
65 | | `unique: true, case_sensitive: false`
(If [schema_plus_pg_indexes](https://github.com/SchemaPlus/schema_plus_pg_indexes) is also in use) | `validates ... uniqueness: { case_sensitive: false }` |
66 |
67 | Data types:
68 |
69 | | Type | Validation |
70 | |--------------------|------------------------------------------------------------------------------------------------------|
71 | | `:boolean` | `:validates ... inclusion: { in: [true, false] }` |
72 | | `:float` | `:validates ... numericality: true` |
73 | | `:integer` | `:validates ... numericality: { only_integer: true, greater_than_or_equal_to: ..., less_than: ... }` |
74 | | `:decimal, precision: ...` | `:validates ... numericality: { greater_than: ..., less_than: ... }` |
75 |
76 |
77 | ## What if I want something special?
78 |
79 | SchemaValidations' behavior can be configured globally and per-model.
80 |
81 | ### Global configuration
82 |
83 | In an initializer, such as `config/initializers/schema_validations.rb`, you can set any of these options. The default values are shown.
84 |
85 | ```ruby
86 | SchemaValidations.setup do |config|
87 |
88 | # Whether to automatically create validations based on database constraints.
89 | # (Can be set false globally to disable the gem by default, and set true per-model to enable.)
90 | config.auto_create = true
91 |
92 | # Restricts the set of field names to include in automatic validation.
93 | # Value is a single name, an array of names, or nil.
94 | config.only = nil
95 |
96 | # Restricts the set of validation types to include in automatic validation.
97 | # Value is a single type, an array of types, or nil.
98 | # A type is specified as, e.g., `:validates_presence_of` or simply `:presence`.
99 | config.only_type = nil
100 |
101 | # A list of field names to exclude from automatic validation.
102 | # Value is a single name, an array of names, or nil.
103 | # (Providing a value per-model will completely replace a globally-configured list)
104 | config.except = nil
105 |
106 | # A list of validation types to exclude from automatic validation.
107 | # Value is a single type, an array of types, or nil.
108 | # (Providing a value per-model will completely replace a globally-configured list)
109 | config.except_type = nil
110 |
111 | # The base set of field names to always exclude from automatic validation.
112 | # Value is a single name, an array of names, or nil.
113 | # (This whitelist applies after all other considerations, global or per-model)
114 | config.whitelist = [:created_at, :updated_at, :created_on, :updated_on]
115 |
116 | # The base set of validation types to always exclude from automatic validation.
117 | # Value is a single type, an array of types, or nil.
118 | # (This whitelist applies after all other considerations, global or per-model)
119 | config.whitelist_type = nil
120 | end
121 | ```
122 |
123 | ### Per-model validation
124 |
125 | You can override the global configuration per-model, using the `schema_validations` class method. All global configuration options are available as keyword options. For example:
126 |
127 | ##### Disable per model:
128 | ```ruby
129 | class User < ActiveRecord::Base
130 | schema_validations auto_create: false
131 | end
132 | ```
133 |
134 | ##### Use a custom validation rather than schema_validations automatic default:
135 | ```ruby
136 | class User < ActiveRecord::Base
137 | schema_validations except: :email # don't create default validation for email
138 | validates :email, presence: true, length: { in: 5..30 }
139 | end
140 | ```
141 |
142 | ##### Include validations every field, without a whitelist:
143 |
144 | ```ruby
145 | class User < ActiveRecord::Base
146 | schema_validations whitelist: nil
147 | end
148 | ```
149 |
150 |
151 |
152 | ## How do I know what it did?
153 | If you're curious (or dubious) about what validations SchemaValidations
154 | defines, you can check the log file. For every assocation that
155 | SchemaValidations defines, it generates a debug entry in the log such as
156 |
157 | ```
158 | [schema_validations] Article.validates_length_of :title, :allow_nil=>true, :maximum=>50
159 | ```
160 |
161 | which shows the exact validation definition call.
162 |
163 |
164 | SchemaValidations defines the validations lazily for each class, only creating
165 | them when they are needed (in order to validate a record of the class, or in response
166 | to introspection on the class). So you may need to search through the log
167 | file for "schema_validations" to find all the validations, and some classes'
168 | validations may not be defined at all if they were never needed for the logged
169 | use case.
170 |
171 | ## Compatibility
172 |
173 | As of version 1.2.0, SchemaValidations supports and is tested on:
174 |
175 |
176 |
177 | * ruby **2.5** with activerecord **5.2**, using **mysql2**, **postgresql:9.6** or **sqlite3**
178 | * ruby **2.5** with activerecord **6.0**, using **mysql2**, **postgresql:9.6** or **sqlite3**
179 | * ruby **2.5** with activerecord **6.1**, using **mysql2**, **postgresql:9.6** or **sqlite3**
180 | * ruby **2.7** with activerecord **5.2**, using **mysql2**, **postgresql:9.6** or **sqlite3**
181 | * ruby **2.7** with activerecord **6.0**, using **mysql2**, **postgresql:9.6** or **sqlite3**
182 | * ruby **2.7** with activerecord **6.1**, using **mysql2**, **postgresql:9.6** or **sqlite3**
183 | * ruby **2.7** with activerecord **7.0**, using **mysql2**, **postgresql:9.6** or **sqlite3**
184 | * ruby **3.0** with activerecord **6.0**, using **mysql2**, **postgresql:9.6** or **sqlite3**
185 | * ruby **3.0** with activerecord **6.1**, using **mysql2**, **postgresql:9.6** or **sqlite3**
186 | * ruby **3.0** with activerecord **7.0**, using **mysql2**, **postgresql:9.6** or **sqlite3**
187 | * ruby **3.1** with activerecord **6.0**, using **mysql2**, **postgresql:9.6** or **sqlite3**
188 | * ruby **3.1** with activerecord **6.1**, using **mysql2**, **postgresql:9.6** or **sqlite3**
189 | * ruby **3.1** with activerecord **7.0**, using **mysql2**, **postgresql:9.6** or **sqlite3**
190 |
191 |
192 |
193 | Earlier versions of SchemaValidations supported:
194 |
195 | * rails 3.2, 4.1, and 4.2.0
196 | * MRI ruby 1.9.3 and 2.1.5
197 |
198 |
199 | ## Release Notes
200 |
201 | ### 2.4.1
202 |
203 | * Add AR 6.1 and 7.0
204 | * add ruby 3.1
205 |
206 | ### 2.4.0
207 |
208 | * Add AR 6.0
209 | * Add Ruby 3.0
210 | * Remove support for AR < 5.2
211 | * Remove support for Ruby < 2.5
212 |
213 | ### 2.3.0
214 |
215 | * Works with AR 5.1.
216 | * No longer testing rails 4.2
217 |
218 | ### 2.2.1
219 |
220 | * Bug fix: don't create presence validation for `null: false` with a
221 | default defined (#18, #49)
222 |
223 | ### 2.2.0
224 |
225 | * Works with AR 5.0. Thanks to [@plicjo](https://github.coms/plicjo).
226 | * Works with `:money` type
227 | * Bug fix when logger is nil. Thanks to [@gamecreature](https://github.com/gamecreature).
228 |
229 | ### 2.1.1
230 |
231 | * Bug fix for `:decimal` when `precision` is nil (#37)
232 |
233 | ### 2.1.0
234 |
235 | * Added `:decimal` range validation. Thanks to [@felixbuenemann](https://github.com/felixbuenemann)
236 |
237 | ### 2.0.2
238 |
239 | * Use schema_monkey rather than Railties
240 |
241 | ### 2.0.1
242 |
243 | * Bug fix: Don't crash when optimistic locking is in use (#8)
244 |
245 | ### 2.0.0
246 |
247 | This major version is backwards compatible for most uses. Only those who specified a per-model `:except` clause would be affected.
248 |
249 | * Add whitelist configuration option (thanks to [@allenwq](https://github.com/allenwq)). Previously, overriding `:except` per-model would clobber the default values. E.g. using the documented example `except: :mail` would accidentally cause validations to be issued `updated_at` to be validated. Now `:except` works more naturally. This is however technically a breaking change, hence the version bump.
250 |
251 | ### 1.4.0
252 |
253 | * Add support for case-insensitive uniqueness. Thanks to [allenwq](https://github.com/allenwq)
254 |
255 | ### 1.3.1
256 |
257 | * Change log level from 'info' to 'debug', since there's no need to clutter production logs with this sort of development info. Thanks to [@obduk](https://github.com/obduk)
258 |
259 | ### 1.3.0
260 |
261 | * Add range checks to integer validations. Thanks to [@lowjoel](https://github.com/lowjoel)
262 |
263 | ### 1.2.0
264 |
265 | * No longer pull in schema_plus's auto-foreign key behavior. Limited to AR >= 4.2.1
266 |
267 | ### 1.1.0
268 |
269 | * Works with Rails 4.2.
270 |
271 | ### 1.0.1
272 |
273 | * Fix enums in Rails 4.1. Thanks to [@lowjoel](https://github.com/lowjoel)
274 |
275 | ### 1.0.0
276 |
277 | * Works with Rails 4.0. Thanks to [@davll](https://github.com/davll)
278 | * No longer support Rails < 3.2 or Ruby < 1.9.3
279 |
280 | ### 0.2.2
281 |
282 | * Rails 2.3 compatibility (check for Rails::Railties symbol). thanks to https://github.com/thehappycoder
283 |
284 | ### 0.2.0
285 |
286 | * New feature: ActiveRecord#validators and ActiveRecord#validators_on now ensure schema_validations are loaded
287 |
288 | ## History
289 |
290 | * SchemaValidations is derived from the "Red Hill On Rails" plugin
291 | schema_validations originally created by harukizaemon
292 | (https://github.com/harukizaemon)
293 |
294 | * SchemaValidations was created in 2011 by Michał Łomnicki and Ronen Barzel
295 |
296 |
297 | ## Testing
298 |
299 | Are you interested in contributing to schema_validations? Thanks! Please follow
300 | the standard protocol: fork, feature branch, develop, push, and issue pull request.
301 |
302 | Some things to know about to help you develop and test:
303 |
304 |
305 |
306 | * **schema_dev**: SchemaValidations uses [schema_dev](https://github.com/SchemaPlus/schema_dev) to
307 | facilitate running rspec tests on the matrix of ruby, activerecord, and database
308 | versions that the gem supports, both locally and on
309 | [github actions](https://github.com/SchemaPlus/schema_validations/actions)
310 |
311 | To to run rspec locally on the full matrix, do:
312 |
313 | $ schema_dev bundle install
314 | $ schema_dev rspec
315 |
316 | You can also run on just one configuration at a time; For info, see `schema_dev --help` or the [schema_dev](https://github.com/SchemaPlus/schema_dev) README.
317 |
318 | The matrix of configurations is specified in `schema_dev.yml` in
319 | the project root.
320 |
321 |
322 |
323 | Code coverage results will be in coverage/index.html -- it should be at 100% coverage.
324 |
325 | ## License
326 |
327 | This gem is released under the MIT license.
328 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'bundler'
4 | Bundler::GemHelper.install_tasks
5 |
6 | require 'rspec/core/rake_task'
7 | RSpec::Core::RakeTask.new(:spec)
8 |
9 | require 'schema_dev/tasks'
10 |
11 | task :default => :spec
12 |
13 | require 'rdoc/task'
14 | Rake::RDocTask.new do |rdoc|
15 | version = File.exist?('VERSION') ? File.read('VERSION') : ""
16 |
17 | rdoc.rdoc_dir = 'rdoc'
18 | rdoc.title = "schema_validations #{version}"
19 | rdoc.rdoc_files.include('README*')
20 | rdoc.rdoc_files.include('lib/**/*.rb')
21 | end
22 |
--------------------------------------------------------------------------------
/gemfiles/Gemfile.base:
--------------------------------------------------------------------------------
1 | source 'https://rubygems.org'
2 | gemspec path: File.expand_path('..', __FILE__)
3 |
4 | File.exist?(gemfile_local = File.expand_path('../Gemfile.local', __FILE__)) and eval File.read(gemfile_local), binding, gemfile_local
5 |
--------------------------------------------------------------------------------
/gemfiles/activerecord-5.2/Gemfile.base:
--------------------------------------------------------------------------------
1 | base_gemfile = File.expand_path('../../Gemfile.base', __FILE__)
2 | eval File.read(base_gemfile)
3 |
4 | gem "activerecord", ">= 5.2.0.beta0", "< 5.3"
5 |
--------------------------------------------------------------------------------
/gemfiles/activerecord-5.2/Gemfile.mysql2:
--------------------------------------------------------------------------------
1 | base_gemfile = File.expand_path('../Gemfile.base', __FILE__)
2 | eval File.read(base_gemfile), binding, base_gemfile
3 |
4 | platform :ruby do
5 | gem "mysql2"
6 | end
7 |
8 | platform :jruby do
9 | gem 'activerecord-jdbcmysql-adapter'
10 | end
11 |
--------------------------------------------------------------------------------
/gemfiles/activerecord-5.2/Gemfile.postgresql:
--------------------------------------------------------------------------------
1 | base_gemfile = File.expand_path('../Gemfile.base', __FILE__)
2 | eval File.read(base_gemfile), binding, base_gemfile
3 |
4 | platform :ruby do
5 | gem "pg"
6 | end
7 |
8 | platform :jruby do
9 | gem 'activerecord-jdbcpostgresql-adapter'
10 | end
11 |
--------------------------------------------------------------------------------
/gemfiles/activerecord-5.2/Gemfile.sqlite3:
--------------------------------------------------------------------------------
1 | base_gemfile = File.expand_path('../Gemfile.base', __FILE__)
2 | eval File.read(base_gemfile), binding, base_gemfile
3 |
4 | platform :ruby do
5 | gem "sqlite3"
6 | end
7 |
8 | platform :jruby do
9 | gem 'activerecord-jdbcsqlite3-adapter', '>=1.3.0.beta2'
10 | end
11 |
--------------------------------------------------------------------------------
/gemfiles/activerecord-6.0/Gemfile.base:
--------------------------------------------------------------------------------
1 | base_gemfile = File.expand_path('../../Gemfile.base', __FILE__)
2 | eval File.read(base_gemfile)
3 |
4 | gem "activerecord", ">= 6.0", "< 6.1"
5 |
--------------------------------------------------------------------------------
/gemfiles/activerecord-6.0/Gemfile.mysql2:
--------------------------------------------------------------------------------
1 | base_gemfile = File.expand_path('../Gemfile.base', __FILE__)
2 | eval File.read(base_gemfile), binding, base_gemfile
3 |
4 | platform :ruby do
5 | gem "mysql2"
6 | end
7 |
8 | platform :jruby do
9 | gem 'activerecord-jdbcmysql-adapter'
10 | end
11 |
--------------------------------------------------------------------------------
/gemfiles/activerecord-6.0/Gemfile.postgresql:
--------------------------------------------------------------------------------
1 | base_gemfile = File.expand_path('../Gemfile.base', __FILE__)
2 | eval File.read(base_gemfile), binding, base_gemfile
3 |
4 | platform :ruby do
5 | gem "pg"
6 | end
7 |
8 | platform :jruby do
9 | gem 'activerecord-jdbcpostgresql-adapter'
10 | end
11 |
--------------------------------------------------------------------------------
/gemfiles/activerecord-6.0/Gemfile.sqlite3:
--------------------------------------------------------------------------------
1 | base_gemfile = File.expand_path('../Gemfile.base', __FILE__)
2 | eval File.read(base_gemfile), binding, base_gemfile
3 |
4 | platform :ruby do
5 | gem "sqlite3"
6 | end
7 |
8 | platform :jruby do
9 | gem 'activerecord-jdbcsqlite3-adapter', '>=1.3.0.beta2'
10 | end
11 |
--------------------------------------------------------------------------------
/gemfiles/activerecord-6.1/Gemfile.base:
--------------------------------------------------------------------------------
1 | base_gemfile = File.expand_path('../../Gemfile.base', __FILE__)
2 | eval File.read(base_gemfile)
3 |
4 | gem "activerecord", ">= 6.1", "< 6.2"
5 |
--------------------------------------------------------------------------------
/gemfiles/activerecord-6.1/Gemfile.mysql2:
--------------------------------------------------------------------------------
1 | base_gemfile = File.expand_path('../Gemfile.base', __FILE__)
2 | eval File.read(base_gemfile), binding, base_gemfile
3 |
4 | platform :ruby do
5 | gem "mysql2"
6 | end
7 |
8 | platform :jruby do
9 | gem 'activerecord-jdbcmysql-adapter'
10 | end
11 |
--------------------------------------------------------------------------------
/gemfiles/activerecord-6.1/Gemfile.postgresql:
--------------------------------------------------------------------------------
1 | base_gemfile = File.expand_path('../Gemfile.base', __FILE__)
2 | eval File.read(base_gemfile), binding, base_gemfile
3 |
4 | platform :ruby do
5 | gem "pg"
6 | end
7 |
8 | platform :jruby do
9 | gem 'activerecord-jdbcpostgresql-adapter'
10 | end
11 |
--------------------------------------------------------------------------------
/gemfiles/activerecord-6.1/Gemfile.sqlite3:
--------------------------------------------------------------------------------
1 | base_gemfile = File.expand_path('../Gemfile.base', __FILE__)
2 | eval File.read(base_gemfile), binding, base_gemfile
3 |
4 | platform :ruby do
5 | gem "sqlite3"
6 | end
7 |
8 | platform :jruby do
9 | gem 'activerecord-jdbcsqlite3-adapter', '>=1.3.0.beta2'
10 | end
11 |
--------------------------------------------------------------------------------
/gemfiles/activerecord-7.0/Gemfile.base:
--------------------------------------------------------------------------------
1 | base_gemfile = File.expand_path('../../Gemfile.base', __FILE__)
2 | eval File.read(base_gemfile)
3 |
4 | gem "activerecord", ">= 7.0", "< 7.1"
5 |
--------------------------------------------------------------------------------
/gemfiles/activerecord-7.0/Gemfile.mysql2:
--------------------------------------------------------------------------------
1 | base_gemfile = File.expand_path('../Gemfile.base', __FILE__)
2 | eval File.read(base_gemfile), binding, base_gemfile
3 |
4 | platform :ruby do
5 | gem "mysql2"
6 | end
7 |
8 | platform :jruby do
9 | gem 'activerecord-jdbcmysql-adapter'
10 | end
11 |
--------------------------------------------------------------------------------
/gemfiles/activerecord-7.0/Gemfile.postgresql:
--------------------------------------------------------------------------------
1 | base_gemfile = File.expand_path('../Gemfile.base', __FILE__)
2 | eval File.read(base_gemfile), binding, base_gemfile
3 |
4 | platform :ruby do
5 | gem "pg"
6 | end
7 |
8 | platform :jruby do
9 | gem 'activerecord-jdbcpostgresql-adapter'
10 | end
11 |
--------------------------------------------------------------------------------
/gemfiles/activerecord-7.0/Gemfile.sqlite3:
--------------------------------------------------------------------------------
1 | base_gemfile = File.expand_path('../Gemfile.base', __FILE__)
2 | eval File.read(base_gemfile), binding, base_gemfile
3 |
4 | platform :ruby do
5 | gem "sqlite3"
6 | end
7 |
8 | platform :jruby do
9 | gem 'activerecord-jdbcsqlite3-adapter', '>=1.3.0.beta2'
10 | end
11 |
--------------------------------------------------------------------------------
/init.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'schema_validations' unless defined?(SchemaValidations)
4 |
--------------------------------------------------------------------------------
/lib/schema_validations.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'valuable'
4 |
5 | require 'schema_plus_columns'
6 | require 'schema_validations/version'
7 | require 'schema_validations/validators/not_nil_validator'
8 | require 'schema_validations/active_record/validations'
9 | require 'schema_validations/active_record/type'
10 |
11 | module SchemaValidations
12 |
13 | # The configuation options for SchemaValidations. Set them globally in
14 | # config/initializers/schema_validations.rb, e.g.:
15 | #
16 | # SchemaValidations.setup do |config|
17 | # config.auto_create = false
18 | # end
19 | #
20 | # or override them per-model, e.g.:
21 | #
22 | # class MyModel < ActiveRecord::Base
23 | # schema_validations :only => [:name, :active]
24 | # end
25 | #
26 | class Config < Valuable
27 | ##
28 | # :attr_accessor: auto_create
29 | #
30 | # Whether to automatically create validations based on database constraints.
31 | # Boolean, default is +true+.
32 | has_value :auto_create, :klass => :boolean, :default => true
33 |
34 | ##
35 | # :attr_accessor: only
36 | #
37 | # List of field names to include in automatic validation.
38 | # Value is a single name, and array of names, or +nil+. Default is +nil+.
39 | has_value :only, :default => nil
40 |
41 | ##
42 | # :attr_accessor: whitelist
43 | #
44 | # List of field names to exclude from automatic validation.
45 | # Value is a single name, an array of names, or +nil+. Default is [:created_at, :updated_at, :created_on, :updated_on].
46 | has_value :whitelist, :default => [:created_at, :updated_at, :created_on, :updated_on]
47 |
48 | ##
49 | # :attr_accessor: except
50 | #
51 | # List of field names to exclude from automatic validation.
52 | # Value is a single name, and array of names, or +nil+. Default is +nil+.
53 | has_value :except, :default => nil
54 |
55 | ##
56 | # :attr_accessor: whitelist_type
57 | #
58 | # List of validation types to exclude from automatic validation.
59 | # Value is a single type, and array of types, or +nil+. Default is +nil+.
60 | # A type is specified as, e.g., +:validates_presence_of+ or simply +:presence+.
61 | has_value :whitelist_type, :default => nil
62 |
63 | ##
64 | # :attr_accessor: except_type
65 | #
66 | # List of validation types to exclude from automatic validation.
67 | # Value is a single type, and array of types, or +nil+. Default is +nil+.
68 | # A type is specified as, e.g., +:validates_presence_of+ or simply +:presence+.
69 | has_value :except_type, :default => nil
70 |
71 | ##
72 | # :attr_accessor: only_type
73 | #
74 | # List of validation types to include in automatic validation.
75 | # Value is a single type, and array of types, or +nil+. Default is +nil+.
76 | # A type is specified as, e.g., +:validates_presence_of+ or simply +:presence+.
77 | has_value :only_type, :default => nil
78 |
79 | def dup #:nodoc:
80 | self.class.new(Hash[attributes.collect{ |key, val| [key, Valuable === val ? val.class.new(val.attributes) : val] }])
81 | end
82 |
83 | def update_attributes(opts)#:nodoc:
84 | opts = opts.dup
85 | opts.keys.each { |key| self.send(key).update_attributes(opts.delete(key)) if self.class.attributes.include? key and Hash === opts[key] }
86 | super(opts)
87 | self
88 | end
89 |
90 | def merge(opts)#:nodoc:
91 | dup.update_attributes(opts)
92 | end
93 |
94 | end
95 |
96 | # Returns the global configuration, i.e., the singleton instance of Config
97 | def self.config
98 | @config ||= Config.new
99 | end
100 |
101 | # Initialization block is passed a global Config instance that can be
102 | # used to configure SchemaValidations behavior. E.g., if you want to
103 | # disable automation creation validations put the following in
104 | # config/initializers/schema_validations.rb :
105 | #
106 | # SchemaValidations.setup do |config|
107 | # config.auto_create = false
108 | # end
109 | #
110 | def self.setup # :yields: config
111 | yield config
112 | end
113 |
114 | end
115 |
116 | SchemaMonkey.register SchemaValidations
117 |
--------------------------------------------------------------------------------
/lib/schema_validations/active_record/type.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module SchemaValidations
4 | module ActiveRecord
5 | module Type
6 |
7 | module Integer
8 | def self.prepended(base)
9 | base.class_eval do
10 | public :range
11 | end
12 | end
13 | end
14 | end
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/lib/schema_validations/active_record/validations.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module SchemaValidations
4 | module ActiveRecord
5 | module Base
6 |
7 | def load_schema_validations
8 | self.class.send :load_schema_validations
9 | end
10 |
11 | module ClassMethods
12 |
13 | def self.extended(base)
14 | base.class_eval do
15 | class_attribute :schema_validations_loaded
16 | end
17 | end
18 |
19 | def inherited(subclass) # :nodoc:
20 | super
21 | before_validation :load_schema_validations unless schema_validations_loaded?
22 | end
23 |
24 | def validators
25 | load_schema_validations unless schema_validations_loaded?
26 | super
27 | end
28 |
29 | def validators_on(*args)
30 | load_schema_validations unless schema_validations_loaded?
31 | super
32 | end
33 |
34 | # Per-model override of Config options. Use via, e.g.
35 | # class MyModel < ActiveRecord::Base
36 | # schema_validations :auto_create => false
37 | # end
38 | #
39 | # If :auto_create is not specified, it is implicitly
40 | # specified as true. This allows the "non-invasive" style of using
41 | # SchemaValidations in which you set the global Config to
42 | # auto_create = false, then in any model that you want auto
43 | # validations you simply do:
44 | #
45 | # class MyModel < ActiveRecord::Base
46 | # schema_validations
47 | # end
48 | #
49 | # Of course other options can be passed, such as
50 | #
51 | # class MyModel < ActiveRecord::Base
52 | # schema_validations :except_type => :validates_presence_of
53 | # end
54 | #
55 | #
56 | def schema_validations(opts={})
57 | @schema_validations_config = SchemaValidations.config.merge({:auto_create => true}.merge(opts))
58 | end
59 |
60 | def schema_validations_config # :nodoc:
61 | @schema_validations_config ||= SchemaValidations.config.dup
62 | end
63 |
64 | private
65 | # Adds schema-based validations to model.
66 | # Attributes as well as associations are validated.
67 | # For instance if there is column
68 | #
69 | # email NOT NULL
70 | #
71 | # defined at database-level it will be translated to
72 | #
73 | # validates_presence_of :email
74 | #
75 | # If there is an association named user
76 | # based on user_id NOT NULL it will be translated to
77 | #
78 | # validates_presence_of :user
79 | #
80 | # Note it uses the name of association (user) not the column name (user_id).
81 | # Only belongs_to associations are validated.
82 | #
83 | # This accepts following options:
84 | # * :only - auto-validate only given attributes
85 | # * :except - auto-validate all but given attributes
86 | #
87 | def load_schema_validations #:nodoc:
88 | # Don't bother if: it's already been loaded; the class is abstract; not a base class; or the table doesn't exist
89 | return unless create_schema_validations?
90 | load_column_validations
91 | load_association_validations
92 | self.schema_validations_loaded = true
93 | end
94 |
95 | def load_column_validations #:nodoc:
96 | content_columns.each do |column|
97 | name = column.name.to_sym
98 |
99 | # Data-type validation
100 | datatype = case
101 | when respond_to?(:defined_enums) && defined_enums.has_key?(column.name) then :enum
102 | when column.type == :integer then :integer
103 | when column.type == :decimal || column.type == :money then :decimal
104 | when column.type == :float then :numeric
105 | when column.type == :text || column.type == :string then :text
106 | when column.type == :boolean then :boolean
107 | end
108 |
109 | case datatype
110 | when :integer
111 | load_integer_column_validations(name, column)
112 | when :decimal
113 | if column.precision
114 | limit = 10 ** (column.precision - (column.scale || 0))
115 | validate_logged :validates_numericality_of, name, :allow_nil => true, :greater_than => -limit, :less_than => limit
116 | end
117 | when :numeric
118 | validate_logged :validates_numericality_of, name, :allow_nil => true
119 | when :text
120 | validate_logged :validates_length_of, name, :allow_nil => true, :maximum => column.limit if column.limit
121 | end
122 |
123 | # NOT NULL constraints
124 | if column.required_on
125 | if datatype == :boolean
126 | validate_logged :validates_inclusion_of, name, :in => [true, false], :message => :blank
127 | else
128 | if !column.default.nil? && column.default.blank?
129 | validate_logged :validates_with, SchemaValidations::Validators::NotNilValidator, attributes: [name]
130 | else
131 | # Validate presence
132 | validate_logged :validates_presence_of, name
133 | end
134 | end
135 | end
136 |
137 | # UNIQUE constraints
138 | add_uniqueness_validation(column) if column.unique?
139 | end
140 | end
141 |
142 | def load_integer_column_validations(name, column) # :nodoc:
143 | integer_range = ::ActiveRecord::Type::Integer.new.range
144 | # The Ruby Range object does not support excluding the beginning of a Range,
145 | # so we always include :greater_than_or_equal_to
146 | options = { :allow_nil => true, :only_integer => true, greater_than_or_equal_to: integer_range.begin }
147 |
148 | if integer_range.exclude_end?
149 | options[:less_than] = integer_range.end
150 | else
151 | options[:less_than_or_equal_to] = integer_range.end
152 | end
153 |
154 | validate_logged :validates_numericality_of, name, options
155 | end
156 |
157 | def load_association_validations #:nodoc:
158 | reflect_on_all_associations(:belongs_to).each do |association|
159 | # :primary_key_name was deprecated (noisily) in rails 3.1
160 | foreign_key_method = (association.respond_to? :foreign_key) ? :foreign_key : :primary_key_name
161 | column = columns_hash[association.send(foreign_key_method).to_s]
162 | next unless column
163 |
164 | # NOT NULL constraints
165 | validate_logged :validates_presence_of, association.name if column.required_on
166 |
167 | # UNIQUE constraints
168 | add_uniqueness_validation(column) if column.unique?
169 | end
170 | end
171 |
172 | def add_uniqueness_validation(column) #:nodoc:
173 | scope = column.unique_scope.map(&:to_sym)
174 | name = column.name.to_sym
175 |
176 | options = {}
177 | options[:scope] = scope if scope.any?
178 | options[:allow_nil] = true
179 | options[:case_sensitive] = false if has_case_insensitive_index?(column, scope)
180 | options[:if] = (proc do |record|
181 | if scope.all? { |scope_sym| record.public_send(:"#{scope_sym}?") }
182 | record.public_send(:"#{column.name}_changed?")
183 | else
184 | false
185 | end
186 | end)
187 |
188 | validate_logged :validates_uniqueness_of, name, options
189 | end
190 |
191 | def has_case_insensitive_index?(column, scope)
192 | indexed_columns = (scope + [column.name]).map(&:to_sym).sort
193 | index = column.indexes.select { |i| i.unique && i.columns.map(&:to_sym).sort == indexed_columns }.first
194 |
195 | index && index.respond_to?(:case_sensitive?) && !index.case_sensitive?
196 | end
197 |
198 | def create_schema_validations? #:nodoc:
199 | schema_validations_config.auto_create? && !(schema_validations_loaded || abstract_class? || name.blank? || !table_exists?)
200 | end
201 |
202 | def validate_logged(method, arg, opts={}) #:nodoc:
203 | if _filter_validation(method, arg)
204 | msg = "[schema_validations] #{self.name}.#{method} #{arg.inspect}"
205 | msg += ", #{opts.inspect[1...-1]}" if opts.any?
206 | logger.debug msg if logger
207 | send method, arg, opts
208 | end
209 | end
210 |
211 | def _filter_validation(macro, name) #:nodoc:
212 | config = schema_validations_config
213 | types = [macro]
214 | if match = macro.to_s.match(/^validates_(.*)_of$/)
215 | types << match[1].to_sym
216 | end
217 | return false if config.only and not Array.wrap(config.only).include?(name)
218 | return false if config.except and Array.wrap(config.except).include?(name)
219 | return false if config.whitelist and Array.wrap(config.whitelist).include?(name)
220 | return false if config.only_type and not (Array.wrap(config.only_type) & types).any?
221 | return false if config.except_type and (Array.wrap(config.except_type) & types).any?
222 | return false if config.whitelist_type and (Array.wrap(config.whitelist_type) & types).any?
223 | return true
224 | end
225 |
226 | end
227 | end
228 |
229 | end
230 | end
231 |
--------------------------------------------------------------------------------
/lib/schema_validations/railtie.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module SchemaValidations
4 | class Railtie < Rails::Railtie #:nodoc:
5 |
6 | initializer 'schema_validations.insert', :after => "schema_plus.insert" do
7 | ActiveSupport.on_load(:active_record) do
8 | SchemaValidations.insert
9 | end
10 | end
11 |
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/lib/schema_validations/validators/not_nil_validator.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module SchemaValidations
4 | module Validators
5 | # Validates that the field is not nil?
6 | # (Unlike the standard PresenceValidator which uses #blank?)
7 | class NotNilValidator < ActiveModel::EachValidator
8 | if Gem::Version.new(::ActiveRecord::VERSION::STRING) < Gem::Version.new('6.1')
9 | def validate_each(record, attr_name, value)
10 | record.errors.add(attr_name, :blank, options) if value.nil?
11 | end
12 | else
13 | def validate_each(record, attr_name, value)
14 | record.errors.add(attr_name, :blank, **options) if value.nil?
15 | end
16 | end
17 | end
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/lib/schema_validations/version.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module SchemaValidations
4 | VERSION = "2.4.1"
5 | end
6 |
--------------------------------------------------------------------------------
/schema_dev.yml:
--------------------------------------------------------------------------------
1 | ruby:
2 | - 2.5
3 | - 2.7
4 | - 3.0
5 | - 3.1
6 | activerecord:
7 | - 5.2
8 | - 6.0
9 | - 6.1
10 | - 7.0
11 | db:
12 | - mysql2
13 | - postgresql
14 | - sqlite3
15 |
--------------------------------------------------------------------------------
/schema_validations.gemspec:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | $:.push File.expand_path("../lib", __FILE__)
4 | require "schema_validations/version"
5 |
6 | Gem::Specification.new do |gem|
7 | gem.name = "schema_validations"
8 | gem.version = SchemaValidations::VERSION
9 | gem.platform = Gem::Platform::RUBY
10 | gem.authors = ["Ronen Barzel", "Michał Łomnicki"]
11 | gem.email = ["ronen@barzel.org", "michal.lomnicki@gmail.com"]
12 | gem.homepage = "https://github.com/SchemaPlus/schema_validations"
13 | gem.summary = "Automatically creates validations basing on the database schema."
14 | gem.description = "SchemaValidations extends ActiveRecord to automatically create validations by inspecting the database schema. This makes your models more DRY as you no longer need to duplicate NOT NULL, unique, numeric and varchar constraints on the model level."
15 | gem.license = 'MIT'
16 |
17 | gem.files = `git ls-files`.split("\n")
18 | gem.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
19 | gem.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
20 | gem.require_paths = ["lib"]
21 |
22 | gem.required_ruby_version = '>= 2.5'
23 |
24 | gem.add_dependency 'schema_plus_columns', '~> 1.0.1'
25 | gem.add_dependency 'activerecord', '>= 5.2', '< 7.1'
26 | gem.add_dependency 'valuable'
27 |
28 | gem.add_development_dependency 'rake', '~> 13.0'
29 | gem.add_development_dependency 'rspec', '~> 3.0'
30 | gem.add_development_dependency 'schema_dev', '~> 4.2.0'
31 | end
32 |
--------------------------------------------------------------------------------
/spec/spec_helper.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'simplecov'
4 | SimpleCov.start unless SimpleCov.running
5 |
6 | $LOAD_PATH.unshift(File.dirname(__FILE__))
7 | $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
8 |
9 | require 'rspec'
10 | require 'active_record'
11 | require 'schema_validations'
12 | require 'schema_dev/rspec'
13 |
14 | SchemaDev::Rspec.setup
15 |
16 | RSpec.configure do |config|
17 | config.around(:each) do |example|
18 | ActiveRecord::Migration.suppress_messages do
19 | example.run
20 | ensure
21 | ActiveRecord::Base.connection.tables.each do |table|
22 | ActiveRecord::Migration.drop_table table, force: :cascade
23 | end
24 | end
25 | end
26 | end
27 |
28 | # avoid deprecation warnings
29 | I18n.enforce_available_locales = true
30 |
31 | Dir[File.dirname(__FILE__) + "/support/**/*.rb"].each { |f| require f }
32 |
33 | def define_schema(config={}, &block)
34 | ActiveRecord::Migration.suppress_messages do
35 | ActiveRecord::Schema.define do
36 | connection.tables.each do |table|
37 | drop_table table, force: :cascade
38 | end
39 | instance_eval &block
40 | end
41 | end
42 | end
43 |
44 | SimpleCov.command_name "[Ruby #{RUBY_VERSION} - ActiveRecord #{::ActiveRecord::VERSION::STRING}]"
45 |
--------------------------------------------------------------------------------
/spec/support/active_model.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # ported from rspec-rails
4 | # There is no reason to install whole gem as we
5 | # need only that tiny helper
6 | class ::ActiveRecord::Base
7 |
8 | def error_on(attribute)
9 | self.valid?
10 | [self.errors[attribute]].flatten.compact
11 | end
12 |
13 | alias :errors_on :error_on
14 |
15 | end
16 |
--------------------------------------------------------------------------------
/spec/validations_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require File.expand_path(File.dirname(__FILE__) + '/spec_helper')
4 |
5 | describe "Validations" do
6 | def stub_model(name, base = ActiveRecord::Base, &block)
7 | klass = Class.new(base)
8 |
9 | if block_given?
10 | klass.instance_eval(&block)
11 | end
12 |
13 | stub_const(name, klass)
14 | end
15 |
16 | before(:each) do
17 | define_schema do
18 |
19 | create_table :articles, force: true do |t|
20 | t.string :title, limit: 50
21 | t.text :content, null: false
22 | t.integer :state
23 | t.integer :votes
24 | t.float :average_mark, null: false
25 | t.boolean :active, null: false
26 | t.decimal :max10, precision: 2, scale: 1
27 | t.decimal :arbitrary, precision: nil, scale: nil
28 | t.decimal :max100, precision: 2, scale: nil
29 | end
30 | add_index :articles, :title, unique: true
31 | add_index :articles, [:state, :active], unique: true
32 |
33 | create_table :reviews, force: true do |t|
34 | t.integer :article_id, null: false
35 | t.string :author, null: false
36 | t.string :content, limit: 200
37 | t.string :type
38 | t.timestamps null: false
39 | end
40 | add_index :reviews, :article_id, unique: true
41 |
42 | create_table :article_reviews, force: true do |t|
43 | t.integer :article_id
44 | t.integer :review_id
45 | end
46 | add_index :article_reviews, [:article_id, :review_id], unique: true
47 | end
48 | end
49 |
50 | context "auto-created" do
51 | before(:each) do
52 | with_auto_validations do
53 | stub_model('Article')
54 |
55 | stub_model('Review') do
56 | belongs_to :article
57 | belongs_to :news_article, class_name: 'Article', foreign_key: :article_id
58 | schema_validations except: :content
59 | end
60 |
61 | stub_model('ArticleReview') do
62 | belongs_to :article
63 | belongs_to :review
64 | end
65 | end
66 | end
67 |
68 | it "should create validations for introspection with validators" do
69 | expect(Article.validators.map{|v| v.class.name.demodulize}.uniq).to match_array(%W[
70 | InclusionValidator
71 | LengthValidator
72 | NumericalityValidator
73 | PresenceValidator
74 | UniquenessValidator
75 | ])
76 | end
77 |
78 | it "should create validations for introspection with validators_on" do
79 | expected = case
80 | when SchemaDev::Rspec::Helpers.mysql? then %W[PresenceValidator LengthValidator]
81 | else %W[PresenceValidator]
82 | end
83 | expect(Article.validators_on(:content).map{|v| v.class.name.demodulize}.uniq).to match_array(expected)
84 | end
85 |
86 | it "should be valid with valid attributes" do
87 | expect(Article.new(valid_article_attributes)).to be_valid
88 | end
89 |
90 | it "should validate content presence" do
91 | expect(Article.new.error_on(:content).size).to eq(1)
92 | end
93 |
94 | it "should check title length" do
95 | expect(Article.new(title: 'a' * 100).error_on(:title).size).to eq(1)
96 | end
97 |
98 | it "should validate state numericality" do
99 | expect(Article.new(state: 'unknown').error_on(:state).size).to eq(1)
100 | end
101 |
102 | it "should validate if state is integer" do
103 | expect(Article.new(state: 1.23).error_on(:state).size).to eq(1)
104 | end
105 |
106 | it "should validate the range of votes" do
107 | expect(Article.new(votes: 2147483648).error_on(:votes).size).to eq(1)
108 | expect(Article.new(votes: -2147483649).error_on(:votes).size).to eq(1)
109 | end
110 |
111 | it "can include the end range" do
112 | allow_any_instance_of(::ActiveRecord::Type::Integer).to receive(:range).and_return(-2147483648..2147483648)
113 |
114 | expect(Article.new(votes: 2147483648).error_on(:votes).size).to eq(0)
115 | end
116 |
117 | it "should validate the range of decimal precision with scale" do
118 | expect(Article.new(max10: 10).error_on(:max10).size).to eq(1)
119 | expect(Article.new(max10: 5).error_on(:max10).size).to eq(0)
120 | expect(Article.new(max10: -10).error_on(:max10).size).to eq(1)
121 | end
122 |
123 | it "should validate the range of decimal precision without scale" do
124 | expect(Article.new(max100: 100).error_on(:max100).size).to eq(1)
125 | expect(Article.new(max100: 50).error_on(:max100).size).to eq(0)
126 | expect(Article.new(max100: -100).error_on(:max100).size).to eq(1)
127 | end
128 |
129 | it "should not validate the range of arbitrary decimal", mysql: :skip do # mysql provides a default precision
130 | expect(Article.new(arbitrary: Float::MAX).error_on(:arbitrary).size).to eq(0)
131 | end
132 |
133 | it "should validate average_mark numericality" do
134 | expect(Article.new(average_mark: "high").error_on(:average_mark).size).to eq(1)
135 | end
136 |
137 | it "should validate boolean fields" do
138 | expect(Article.new(active: nil).error_on(:active).size).to eq(1)
139 | end
140 |
141 | it "should validate title uniqueness" do
142 | article1 = Article.create(valid_article_attributes)
143 | article2 = Article.new(title: valid_article_attributes[:title])
144 | expect(article2.error_on(:title).size).to eq(1)
145 | article1.destroy
146 | end
147 |
148 | it "should validate state uniqueness in scope of 'active' value" do
149 | article1 = Article.create(valid_article_attributes)
150 | article2 = Article.new(valid_article_attributes.merge(title: 'SchemaPlus 2.0 released'))
151 | expect(article2).not_to be_valid
152 | article2.toggle(:active)
153 | expect(article2).to be_valid
154 | article1.destroy
155 | end
156 |
157 | it "should validate presence of belongs_to association" do
158 | review = Review.new
159 | expect(review.error_on(:article).size).to eq(1)
160 | end
161 |
162 | it "should validate uniqueness of belongs_to association" do
163 | article = Article.create(valid_article_attributes)
164 | expect(article).to be_valid
165 | review1 = Review.create(article: article, author: 'michal')
166 | expect(review1).to be_valid
167 | review2 = Review.new(article: article, author: 'michal')
168 | expect(review2.error_on(:article_id).size).to be >= 1
169 | end
170 |
171 | it "should validate associations with unmatched column and name" do
172 | expect(Review.new.error_on(:news_article).size).to eq(1)
173 | end
174 |
175 | it "should not validate uniqueness when scope is absent" do
176 | article_review_1 = ArticleReview.create(article_id: 1, review_id: nil)
177 | expect(article_review_1).to be_valid
178 |
179 | article_review_2 = ArticleReview.create(article_id: 1, review_id: nil)
180 | expect(article_review_2).to be_valid
181 |
182 | article_review_3 = ArticleReview.create(article_id: nil, review_id: 1)
183 | expect(article_review_3).to be_valid
184 |
185 | article_review_4 = ArticleReview.create(article_id: nil, review_id: 1)
186 | expect(article_review_4).to be_valid
187 | end
188 |
189 | context 'when NOT NULL validations' do
190 | before(:each) do
191 | ActiveRecord::Schema.define do
192 | create_table :anti_nulls, force: true do |t|
193 | t.string :no_default, null: false
194 | t.string :blank_default, default: '', null: false
195 | t.string :non_blank_default, default: 'not blank', null: false
196 | end
197 | end
198 | with_auto_validations do
199 | stub_model('AntiNull') do
200 | def self.all_blank
201 | @all_blank ||= AntiNull.new(
202 | no_default: '',
203 | blank_default: '',
204 | non_blank_default: ''
205 | )
206 | end
207 |
208 | def self.all_non_blank
209 | @all_non_blank ||= AntiNull.new(
210 | no_default: 'foo',
211 | blank_default: 'bar',
212 | non_blank_default: 'baz'
213 | )
214 | end
215 |
216 | def self.all_nil
217 | @all_nil ||= AntiNull.new(
218 | no_default: nil,
219 | blank_default: nil,
220 | non_blank_default: nil
221 | )
222 | end
223 |
224 | def self.non_null_with(**fields)
225 | opts = { no_default: 'foo' }.merge!(fields)
226 | AntiNull.new **opts
227 | end
228 | end
229 | end
230 |
231 |
232 | end
233 |
234 | it 'should fail validation on empty fields only if the default value is not blank' do
235 | expect(AntiNull.all_nil.error_on(:no_default).size).to eq(1)
236 | expect(AntiNull.all_nil.error_on(:blank_default).size).to eq(1)
237 | expect(AntiNull.all_nil.error_on(:non_blank_default).size).to eq(1)
238 | end
239 |
240 | it 'should fail validation on empty fields only if the default value is not blank' do
241 | expect(AntiNull.all_blank.error_on(:no_default).size).to eq(1)
242 | expect(AntiNull.all_blank.error_on(:non_blank_default).size).to eq(1)
243 | expect(AntiNull.all_blank.error_on(:blank_default)).to be_empty
244 | end
245 |
246 | it 'should not fail if fields are neither nil nor empty' do
247 | expect(AntiNull.all_non_blank).to be_valid
248 | end
249 |
250 | end
251 | end
252 |
253 | context "auto-created but changed" do
254 | before(:each) do
255 | with_auto_validations do
256 | stub_model('Article')
257 | stub_model('Review') do
258 | belongs_to :article
259 | belongs_to :news_article, class_name: 'Article', foreign_key: :article_id
260 | end
261 | end
262 | @too_big_content = 'a' * 1000
263 | end
264 |
265 | it "would normally have an error" do
266 | @review = Review.new(content: @too_big_content)
267 | expect(@review.error_on(:content).size).to eq(1)
268 | expect(@review.error_on(:author).size).to eq(1)
269 | end
270 |
271 | it "shouldn't validate fields passed to :except option" do
272 | Review.schema_validations except: :content
273 | @review = Review.new(content: @too_big_content)
274 | expect(@review.errors_on(:content).size).to eq(0)
275 | expect(@review.error_on(:author).size).to eq(1)
276 | end
277 |
278 | it "shouldn't validate the fields in default whitelist" do
279 | Review.schema_validations except: :content
280 | expect(Review.new.error_on(:updated_at).size).to eq(0)
281 | expect(Review.new.error_on(:created_at).size).to eq(0)
282 | end
283 |
284 | it "shouldn't validate the fields in whitelist" do
285 | Review.schema_validations except: :content, whitelist: [:updated_at]
286 | expect(Review.new.error_on(:updated_at).size).to eq(0)
287 | expect(Review.new.error_on(:created_at).size).to eq(1)
288 | end
289 |
290 | it "shouldn't validate types passed to :except_type option using full validation" do
291 | Review.schema_validations except_type: :validates_length_of
292 | @review = Review.new(content: @too_big_content)
293 | expect(@review.errors_on(:content).size).to eq(0)
294 | expect(@review.error_on(:author).size).to eq(1)
295 | end
296 |
297 | it "shouldn't validate types passed to :except_type option using shorthand" do
298 | Review.schema_validations except_type: :length
299 | @review = Review.new(content: @too_big_content)
300 | expect(@review.errors_on(:content).size).to eq(0)
301 | expect(@review.error_on(:author).size).to eq(1)
302 | end
303 |
304 | it "should only validate type passed to :only_type option" do
305 | Review.schema_validations only_type: :length
306 | @review = Review.new(content: @too_big_content)
307 | expect(@review.error_on(:content).size).to eq(1)
308 | expect(@review.errors_on(:author).size).to eq(0)
309 | end
310 |
311 |
312 | it "shouldn't create validations if locally disabled" do
313 | Review.schema_validations auto_create: false
314 | @review = Review.new(content: @too_big_content)
315 | expect(@review.errors_on(:content).size).to eq(0)
316 | expect(@review.error_on(:author).size).to eq(0)
317 | end
318 | end
319 |
320 | context "auto-created disabled" do
321 | around(:each) do |example|
322 | with_auto_validations(false, &example)
323 | end
324 |
325 | before(:each) do
326 | stub_model('Article')
327 | stub_model('Review') do
328 | belongs_to :article
329 | belongs_to :news_article, class_name: 'Article', foreign_key: :article_id
330 | end
331 | @too_big_content = 'a' * 1000
332 | end
333 |
334 | it "should not create validation" do
335 | expect(Review.new(content: @too_big_title).errors_on(:content).size).to eq(0)
336 | end
337 |
338 | it "should create validation if locally enabled explicitly" do
339 | Review.schema_validations auto_create: true
340 | expect(Review.new(content: @too_big_content).error_on(:content).size).to eq(1)
341 | end
342 |
343 | it "should create validation if locally enabled implicitly" do
344 | Review.schema_validations
345 | expect(Review.new(content: @too_big_content).error_on(:content).size).to eq(1)
346 | end
347 |
348 | end
349 |
350 | context "manually invoked" do
351 | before(:each) do
352 | stub_model('Article')
353 | Article.schema_validations only: [:title, :state]
354 |
355 | stub_model('Review') do
356 | belongs_to :dummy_association
357 | schema_validations except: :content
358 | end
359 | end
360 |
361 | it "should validate fields passed to :only option" do
362 | too_big_title = 'a' * 100
363 | wrong_state = 'unknown'
364 | article = Article.new(title: too_big_title, state: wrong_state)
365 | expect(article.error_on(:title).size).to eq(1)
366 | expect(article.error_on(:state).size).to eq(1)
367 | end
368 |
369 | it "shouldn't validate skipped fields" do
370 | article = Article.new
371 | expect(article.errors_on(:content).size).to eq(0)
372 | expect(article.errors_on(:average_mark).size).to eq(0)
373 | end
374 |
375 | it "shouldn't validate association on unexisting column" do
376 | expect(Review.new.errors_on(:dummy_association).size).to eq(0)
377 | end
378 |
379 | it "shouldn't validate fields passed to :except option" do
380 | expect(Review.new.errors_on(:content).size).to eq(0)
381 | end
382 |
383 | it "should validate all fields but passed to :except option" do
384 | expect(Review.new.error_on(:author).size).to eq(1)
385 | end
386 |
387 | end
388 |
389 | context "manually invoked" do
390 | before(:each) do
391 | stub_model('Review') do
392 | belongs_to :article
393 | end
394 | @columns = Review.content_columns.dup
395 | Review.schema_validations only: [:title]
396 | end
397 |
398 | it "shouldn't validate associations not included in :only option" do
399 | expect(Review.new.errors_on(:article).size).to eq(0)
400 | end
401 |
402 | it "shouldn't change content columns of the model" do
403 | expect(@columns).to eq(Review.content_columns)
404 | end
405 |
406 | end
407 |
408 | context "when used with STI" do
409 | around(:each) { |example| with_auto_validations(&example) }
410 |
411 | it "should set validations on base class" do
412 | stub_model('Review')
413 | stub_model('PremiumReview', Review)
414 | PremiumReview.new
415 | expect(Review.new.error_on(:author).size).to eq(1)
416 | end
417 |
418 | it "shouldn't create doubled validations" do
419 | stub_model('Review')
420 | Review.new
421 | stub_model('PremiumReview', Review)
422 | expect(PremiumReview.new.error_on(:author).size).to eq(1)
423 | end
424 |
425 | end
426 |
427 | context "when used with enum" do
428 | it "does not validate numericality" do
429 | stub_model('Article') do
430 | enum state: [:happy, :sad]
431 | end
432 | expect(Article.new(valid_article_attributes.merge(state: :happy))).to be_valid
433 | end
434 | end if ActiveRecord::Base.respond_to? :enum
435 |
436 | context 'with case sensitive options' do
437 | before do
438 | allow_any_instance_of(ActiveRecord::ConnectionAdapters::IndexDefinition).to receive(:case_sensitive?).and_return(false)
439 | end
440 |
441 | context 'without scope' do
442 | before do
443 | ActiveRecord::Schema.define do
444 | create_table :books, force: true do |t|
445 | t.string :title
446 | end
447 |
448 | add_index :books, :title, unique: true
449 | end
450 |
451 | with_auto_validations do
452 | stub_model('Book') do; end
453 | end
454 | end
455 |
456 | it "should validate the uniqueness in a case insensitive manner" do
457 | mixed_case_title = 'Schema Validations'
458 | Book.create(title: mixed_case_title)
459 |
460 | expect(Book.new(title: mixed_case_title)).not_to be_valid
461 | expect(Book.new(title: mixed_case_title.downcase)).not_to be_valid
462 | end
463 | end
464 |
465 | context 'within a scope' do
466 | before do
467 | ActiveRecord::Schema.define do
468 | create_table :folders, force: true do |t|
469 | t.integer :parent_id
470 | t.string :name
471 | end
472 |
473 | add_index :folders, [:parent_id, :name], unique: true
474 | end
475 |
476 | with_auto_validations do
477 | stub_model('Folder') do
478 | belongs_to :parent, class_name: 'Folder'
479 | end
480 | end
481 | end
482 |
483 | it "should validate the uniqueness in a case insensitive manner" do
484 | mixed_case_name = 'Schema Validations'
485 | parent_folder = Folder.create
486 | Folder.create(parent: parent_folder, name: mixed_case_name)
487 |
488 | expect(Folder.new(parent: parent_folder, name: mixed_case_name)).not_to be_valid
489 | expect(Folder.new(parent: parent_folder, name: mixed_case_name.downcase)).not_to be_valid
490 | end
491 | end
492 | end
493 |
494 | context 'with optimistic locking' do
495 | before do
496 | ActiveRecord::Schema.define do
497 | create_table :optimistics, force: true do |t|
498 | t.integer :lock_version
499 | end
500 | end
501 | with_auto_validations do
502 | stub_model('Optimistic') do; end
503 | end
504 | end
505 | it 'should not crash' do
506 | expect(Optimistic.new).to be_valid
507 | end
508 | end
509 |
510 | protected
511 | def with_auto_validations(value = true)
512 | old_value = SchemaValidations.config.auto_create
513 | begin
514 | SchemaValidations.setup do |config|
515 | config.auto_create = value
516 | end
517 | yield
518 | ensure
519 | SchemaValidations.config.auto_create = old_value
520 | end
521 | end
522 |
523 |
524 | def valid_article_attributes
525 | {
526 | title: 'SchemaPlus released!',
527 | content: "Database matters. Get full use of it but don't write unecessary code. Get SchemaPlus!",
528 | state: 3,
529 | average_mark: 9.78,
530 | active: true
531 | }
532 | end
533 |
534 |
535 | end
536 |
--------------------------------------------------------------------------------