├── .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 | [![Gem Version](https://badge.fury.io/rb/schema_validations.svg)](http://badge.fury.io/rb/schema_validations) 8 | [![Build Status](https://github.com/SchemaPlus/schema_validations/actions/workflows/pr.yml/badge.svg)](http://github.com/SchemaPlus/schema_validations/actions) 9 | [![Coverage Status](https://coveralls.io/github/SchemaPlus/schema_validations/badge.svg)](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 | --------------------------------------------------------------------------------