├── .circleci └── config.yml ├── .codeclimate.yml ├── .gitignore ├── .rspec ├── .rubocop-todo.yml ├── .rubocop.yml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── benchmarks ├── composed.png ├── composed_benchmarks.rb ├── configurations.rb ├── database_cleaner.rb ├── db_belongs_to.png ├── db_belongs_to_benchmark.rb ├── gc_suite.rb ├── uniqueness_validator_benchmark.rb └── validates_db_uniqueness_of.png ├── database_validations.gemspec ├── example ├── Gemfile ├── README.md ├── Rakefile ├── app │ ├── assets │ │ ├── config │ │ │ └── manifest.js │ │ ├── images │ │ │ └── .keep │ │ ├── javascripts │ │ │ ├── application.js │ │ │ ├── cable.js │ │ │ └── channels │ │ │ │ └── .keep │ │ └── stylesheets │ │ │ └── application.css │ ├── channels │ │ └── application_cable │ │ │ ├── channel.rb │ │ │ └── connection.rb │ ├── controllers │ │ ├── application_controller.rb │ │ └── concerns │ │ │ └── .keep │ ├── helpers │ │ └── application_helper.rb │ ├── jobs │ │ └── application_job.rb │ ├── mailers │ │ └── application_mailer.rb │ ├── models │ │ ├── application_record.rb │ │ ├── company.rb │ │ ├── concerns │ │ │ └── .keep │ │ ├── country.rb │ │ └── user.rb │ └── views │ │ └── layouts │ │ ├── application.html.erb │ │ ├── mailer.html.erb │ │ └── mailer.text.erb ├── bin │ ├── bundle │ ├── rails │ ├── rake │ ├── setup │ ├── spring │ ├── update │ └── yarn ├── config.ru ├── config │ ├── application.rb │ ├── boot.rb │ ├── cable.yml │ ├── credentials.yml.enc │ ├── database.yml.example │ ├── environment.rb │ ├── environments │ │ ├── development.rb │ │ ├── production.rb │ │ └── test.rb │ ├── initializers │ │ ├── application_controller_renderer.rb │ │ ├── assets.rb │ │ ├── backtrace_silencers.rb │ │ ├── content_security_policy.rb │ │ ├── cookies_serializer.rb │ │ ├── filter_parameter_logging.rb │ │ ├── inflections.rb │ │ ├── mime_types.rb │ │ └── wrap_parameters.rb │ ├── locales │ │ └── en.yml │ ├── master.key │ ├── puma.rb │ ├── routes.rb │ ├── spring.rb │ └── storage.yml ├── db │ ├── migrate │ │ ├── 20181129192033_create_companies.rb │ │ ├── 20181129192038_create_countries.rb │ │ └── 20181129192039_create_users.rb │ ├── schema.rb │ └── seeds.rb ├── lib │ ├── assets │ │ └── .keep │ └── tasks │ │ └── .keep ├── log │ └── .keep ├── package.json ├── public │ ├── 404.html │ ├── 422.html │ ├── 500.html │ ├── apple-touch-icon-precomposed.png │ ├── apple-touch-icon.png │ ├── favicon.ico │ └── robots.txt ├── storage │ └── .keep └── vendor │ └── .keep ├── gemfiles ├── rails42.gemfile ├── rails52.gemfile ├── rails61.gemfile └── railsmaster.gemfile ├── lib ├── database_validations.rb └── database_validations │ ├── lib │ ├── adapters.rb │ ├── adapters │ │ ├── base_adapter.rb │ │ ├── mysql_adapter.rb │ │ ├── postgresql_adapter.rb │ │ └── sqlite_adapter.rb │ ├── attribute_validator.rb │ ├── checkers │ │ ├── db_presence_validator.rb │ │ └── db_uniqueness_validator.rb │ ├── errors.rb │ ├── injector.rb │ ├── key_generator.rb │ ├── presence_key_extractor.rb │ ├── rescuer.rb │ ├── storage.rb │ ├── uniqueness_key_extractor.rb │ ├── validations.rb │ └── validators │ │ ├── db_presence_validator.rb │ │ └── db_uniqueness_validator.rb │ ├── rails │ └── railtie.rb │ ├── rspec │ ├── matchers.rb │ └── uniqueness_validator_matcher.rb │ ├── rubocop │ ├── cop │ │ ├── belongs_to.rb │ │ └── uniqueness_of.rb │ └── cops.rb │ ├── tasks │ └── database_validations.rake │ └── version.rb └── spec ├── database_validations_spec.rb ├── matchers └── uniqueness_validator_matcher_spec.rb ├── rubocop ├── cop │ ├── belongs_to_spec.rb │ └── uniqueness_of_spec.rb └── spec_helper.rb ├── shared └── raise_index_not_found.rb ├── spec_helper.rb └── validations ├── db_belongs_to_spec.rb ├── validates_db_presence_of_spec.rb └── validates_db_uniqueness_of_spec.rb /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | ruby-env: &ruby-env 4 | BUNDLE_JOBS: 3 5 | BUNDLE_RETRY: 3 6 | BUNDLE_PATH: vendor/bundle 7 | DB_HOST: 127.0.0.1 8 | DB_USER: root 9 | DB_PASSWORD: test 10 | 11 | postgres-env: &postgres-env 12 | POSTGRES_USER: root 13 | POSTGRES_DB: database_validations_test 14 | POSTGRES_PASSWORD: test 15 | 16 | mysql-env: &mysql-env 17 | MYSQL_ROOT_HOST: '%' 18 | MYSQL_ROOT_PASSWORD: test 19 | MYSQL_DATABASE: database_validations_test 20 | 21 | jobs: 22 | rails42: 23 | parallelism: 3 24 | docker: 25 | - image: circleci/ruby:2.4 26 | environment: 27 | <<: *ruby-env 28 | GEMFILE_PATH: gemfiles/rails42.gemfile 29 | 30 | - image: circleci/postgres:9.6 31 | environment: 32 | <<: *postgres-env 33 | 34 | - image: circleci/mysql:5.6 35 | environment: 36 | <<: *mysql-env 37 | 38 | steps: &steps 39 | - checkout 40 | 41 | - run: 42 | name: Install bundler 43 | command: gem install bundler 44 | 45 | - run: 46 | name: Which bundler? 47 | command: bundle -v 48 | 49 | - run: 50 | name: Bundle Install 51 | command: bundle install 52 | 53 | - run: 54 | name: Wait for PostgreSQL DB 55 | command: dockerize -wait tcp://localhost:5432 -timeout 2m 56 | 57 | - run: 58 | name: Wait for MySQL DB 59 | command: dockerize -wait tcp://localhost:3306 -timeout 2m 60 | 61 | - run: 62 | name: Run rspec in parallel 63 | command: | 64 | bundle exec rspec --profile 10 \ 65 | --format RspecJunitFormatter \ 66 | --out test_results/rspec.xml \ 67 | --format progress \ 68 | $(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings) 69 | 70 | - store_test_results: 71 | path: test_results 72 | 73 | rails52: 74 | parallelism: 3 75 | docker: 76 | - image: circleci/ruby:2.5 77 | environment: 78 | <<: *ruby-env 79 | GEMFILE_PATH: gemfiles/rails52.gemfile 80 | 81 | - image: circleci/postgres:9.6 82 | environment: 83 | <<: *postgres-env 84 | 85 | - image: circleci/mysql:5.6 86 | environment: 87 | <<: *mysql-env 88 | 89 | steps: *steps 90 | 91 | rails61: 92 | parallelism: 3 93 | docker: 94 | - image: circleci/ruby:2.7 95 | environment: 96 | <<: *ruby-env 97 | GEMFILE_PATH: gemfiles/rails61.gemfile 98 | 99 | - image: circleci/postgres:9.6 100 | environment: 101 | <<: *postgres-env 102 | 103 | - image: circleci/mysql:5.6 104 | environment: 105 | <<: *mysql-env 106 | 107 | steps: *steps 108 | 109 | railsmaster: 110 | parallelism: 3 111 | docker: 112 | - image: circleci/ruby:latest 113 | environment: 114 | <<: *ruby-env 115 | GEMFILE_PATH: gemfiles/railsmaster.gemfile 116 | 117 | - image: circleci/postgres:9.6 118 | environment: 119 | <<: *postgres-env 120 | 121 | - image: circleci/mysql:5.6 122 | environment: 123 | <<: *mysql-env 124 | 125 | steps: *steps 126 | 127 | workflows: 128 | version: 2 129 | rails42: 130 | jobs: 131 | - rails42 132 | - rails52 133 | - rails61 134 | - railsmaster 135 | -------------------------------------------------------------------------------- /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | plugins: 2 | rubocop: 3 | enabled: true 4 | channel: rubocop-0-64 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | 10 | /Gemfile.lock 11 | 12 | # IDEA configurations 13 | /.idea/ 14 | 15 | # rspec failure tracking 16 | .rspec_status 17 | 18 | # Example 19 | example/db/*.sqlite3 20 | example/tmp 21 | example/log 22 | example/Gemfile.lock 23 | example/config/database.yml 24 | 25 | .ruby-version 26 | .ruby-gemset 27 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /.rubocop-todo.yml: -------------------------------------------------------------------------------- 1 | Metrics/LineLength: 2 | Max: 140 3 | Exclude: 4 | - 'benchmarks/*.rb' 5 | 6 | Metrics/MethodLength: 7 | Max: 15 8 | 9 | Metrics/BlockLength: 10 | Exclude: 11 | - 'spec/**/*_spec.rb' 12 | - 'benchmarks/*.rb' 13 | 14 | Style/Documentation: 15 | Enabled: false 16 | 17 | RSpec/NestedGroups: 18 | Max: 6 19 | 20 | RSpec/ExampleLength: 21 | Enabled: false 22 | 23 | RSpec/DescribeClass: 24 | Enabled: false 25 | 26 | Style/Semicolon: 27 | Exclude: 28 | - 'benchmarks/*.rb' 29 | 30 | Style/NumericPredicate: 31 | Exclude: 32 | - 'benchmarks/*.rb' 33 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_from: .rubocop-todo.yml 2 | require: 3 | - rubocop-rspec 4 | 5 | AllCops: 6 | DisplayCopNames: true 7 | TargetRubyVersion: 2.5 8 | Include: 9 | - '**/*.rb' 10 | - 'Gemfile' 11 | - 'Rakefile' 12 | - 'database_validations.gemspec' 13 | Exclude: 14 | - 'vendor/bundle/**/*' 15 | - 'gemfiles/**/*' 16 | 17 | Naming/FileName: 18 | Exclude: 19 | - 'example/Gemfile' 20 | - 'example/Rakefile' 21 | 22 | Style/FrozenStringLiteralComment: 23 | Enabled: false 24 | 25 | RSpec/MultipleExpectations: 26 | Enabled: false 27 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [1.1.1] - 14-03-2022 4 | ### Improvements 5 | 6 | - Skip checking of foreign keys and unique indexes presence for abstract classes. 7 | 8 | ## [1.1.0] - 19-11-2021 9 | ### Improvements 10 | 11 | - Add `rescue: :default|:always` option to `DbUniquenessValidator`. 12 | 13 | ## [1.0.1] 14 | ### Fixes 15 | 16 | - Add support of Ruby 3. Thanks [John Duff](https://github.com/jduff) for the contribution. 17 | 18 | ## [1.0.0] 19 | ### Fixes 20 | 21 | - Remove deprecation warning when `connection_config` is used in Rails 6.1 (Use connection_db_config instead). Thanks [Alfonso Uceda](https://github.com/AlfonsoUceda) for the contribution. 22 | 23 | ## [0.9.4] - 30-09-20 24 | ### Fixes 25 | 26 | - Respect `validate: false` option when using `save/save!` for Rails 5+. Thanks [Arkadiy Zabazhanov](https://github.com/pyromaniac) for the contribution. 27 | 28 | ## [0.9.3] - 24-09-20 29 | ### Improvements 30 | 31 | - Add support of different `mode` to `DbUniquenessValidator`. Thanks [Arkadiy Zabazhanov](https://github.com/pyromaniac) for the contribution. 32 | 33 | ## [0.9.2] - 16-09-20 34 | ### Improvements 35 | 36 | - Fix a warning message from newest Ruby version 37 | 38 | ## [0.9.1] - 24-06-20 39 | ### Improvements 40 | 41 | - Fix support of newest MySQL version 42 | - Add case sensitive option to `validate_db_uniqueness_of` RSpec matcher 43 | 44 | ## [0.9.0] - 28-07-19 45 | ### Improvements 46 | 47 | - Change the way of storing database validations 48 | - Improve performance 49 | - Refactor 50 | - New syntax sugar 51 | 52 | ## [0.8.10] - 21-02-19 53 | ### Improvements 54 | - Internal improvements 55 | - We raise an error if `scope` or `where` options are missed for the `validates_db_uniqueness_of` 56 | 57 | ## [0.8.9] - 13-02-19 58 | ### Bugs 59 | - Hot-fix for `validate_db_uniqueness_of` RSpec matcher 60 | 61 | ## (removed) [0.8.8] - 13-02-19 62 | ### Bugs 63 | - Hot-fix for `validates_db_uniqueness_of` 64 | 65 | ## (removed) [0.8.7] - 13-02-19 66 | ### Improvements 67 | - Refactor and performance improvement 68 | 69 | ## [0.8.6] - 11-02-19 70 | ### Improvements 71 | - Refactor and slight performance improvement 72 | 73 | ## [0.8.5] - 05-02-19 74 | ### Bugs 75 | - Fix a behavior for 3rd parties such as `simple_form` 76 | 77 | ## [0.8.4] - 06-02-19 78 | ### Bugs 79 | - Fix a bug for `db_belongs_to`, validation should check `blank?` not `nil?` 80 | 81 | ## [0.8.3] - 05-02-19 82 | ### Bugs 83 | - Fix bug for `db_belongs_to` when we skip other validations if the relation is missing 84 | 85 | ## [0.8.2] - 10-01-18 86 | ### Bugs 87 | - Fix RuboCop cop for `validates_db_uniqueness_of` to catch `validates_uniqueness_of` definition too. 88 | 89 | ## [0.8.1] - 09-01-18 90 | ### Features 91 | - Add RuboCop cop for `db_belongs_to` and `validates_db_uniqueness_of` 92 | 93 | ## [0.8.0] - 30-11-18 94 | ### Features 95 | - Add `db_belongs_to` 96 | 97 | ## [0.7.3] - 2018-10-18 98 | ### Features 99 | - Add support of `case_sensitive` option for `valid?` for `PostgreSQL` 100 | 101 | ## [0.7.2] - 2018-10-17 102 | ### Features 103 | - Extend RSpec matcher to accept instance of model 104 | 105 | ## [0.7.1] - 2018-10-17 106 | ### Bugs 107 | - Fix rake task issue for rails 108 | - Adjust RSpec matcher to support `index_name` option 109 | 110 | ## [0.7.0] - 2018-10-16 111 | ### Features 112 | - Add support of `index_name` option to `PostgreSQL` and `MySQL` databases 113 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at lawliet.djez@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | git_source(:github) { |repo_name| "https://github.com/#{repo_name}" } 4 | 5 | # Specify your gem's dependencies in database_validations.gemspec 6 | gemspec 7 | 8 | group :test do 9 | gem 'rspec_junit_formatter', '~> 0.4.1' 10 | end 11 | 12 | eval(File.read(ENV['GEMFILE_PATH'])) if ENV['GEMFILE_PATH'] # rubocop:disable Security/Eval 13 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Toptal 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DatabaseValidations 2 | 3 | [![CircleCI](https://circleci.com/gh/toptal/database_validations/tree/master.svg?style=svg)](https://circleci.com/gh/toptal/database_validations/tree/master) 4 | [![Gem Version](https://badge.fury.io/rb/database_validations.svg)](https://badge.fury.io/rb/database_validations) 5 | [![Maintainability](https://api.codeclimate.com/v1/badges/a7df40a29c63f7ba518b/maintainability)](https://codeclimate.com/github/toptal/database_validations/maintainability) 6 | 7 | DatabaseValidations helps you to keep the database consistency with better performance. 8 | Right now, it supports only ActiveRecord. 9 | 10 | *The more you use the gem, the more performance increase you have. Try it now!* 11 | 12 | ## Installation 13 | 14 | Add this line to your application's Gemfile: 15 | 16 | ```ruby 17 | gem 'database_validations' 18 | ``` 19 | 20 | And then execute: 21 | 22 | ```bash 23 | bundle 24 | ``` 25 | 26 | Or install it yourself as: 27 | 28 | ```bash 29 | gem install database_validations 30 | ``` 31 | 32 | Have a look at [example](example) application for details. 33 | 34 | ## Benchmark ([code](benchmarks/composed_benchmarks.rb)) 35 | 36 | Imagine, you have `User` model defines as 37 | 38 | ```ruby 39 | class User < ActiveRecord::Base 40 | validates :email, :full_name, uniqueness: true 41 | 42 | belongs_to :company 43 | belongs_to :country 44 | end 45 | ``` 46 | 47 | and then replace with 48 | 49 | ```ruby 50 | class User < ActiveRecord::Base 51 | validates :email, :full_name, db_uniqueness: true 52 | # OR 53 | # validates_db_uniqueness_of :email, :full_name 54 | 55 | db_belongs_to :company 56 | db_belongs_to :country 57 | # OR 58 | # belongs_to :company 59 | # belongs_to :country 60 | # validates :company, :country, db_presence: true 61 | end 62 | ``` 63 | 64 | you will get the following performance improvement: 65 | 66 | ![](benchmarks/composed.png) 67 | 68 | ## Caveats 69 | 70 | - `db_belongs_to` doesn't work with SQLite due to a poor error message. 71 | - In Rails 4, the gem validations work differently than the ActiveRecord ones when `validate: false` option is passed to `save`/`save!`. They incorrectly return a validation message instead of raising a proper constraint violation exception. In Rails >= 5 they correctly raise the exceptions they supposed to. 72 | 73 | ## db_belongs_to 74 | 75 | Supported databases are `PostgreSQL` and `MySQL`. 76 | **Note**: Unfortunately, `SQLite` raises a poor error message 77 | by which we can not determine exact foreign key which raised an error. 78 | 79 | ### Usage 80 | 81 | ```ruby 82 | class User < ActiveRecord::Base 83 | db_belongs_to :company 84 | end 85 | 86 | user = User.create(company_id: nil) 87 | # => false 88 | user.errors.messages 89 | # => {:company=>["must exist"]} 90 | ``` 91 | 92 | ### Problem 93 | 94 | ActiveRecord's `belongs_to` has `optional: false` by default. Unfortunately, this 95 | approach does not ensure existence of the related object. For example, we can skip 96 | validations or remove the related object after we save the object. After that, our 97 | database becomes inconsistent because we assume the object has his relation but it 98 | does not. 99 | 100 | `db_belongs_to` solves the problem using foreign key constraints in the database 101 | also providing backward compatibility with nice validations errors. 102 | 103 | ### Pros and Cons 104 | 105 | **Advantages**: 106 | - Ensures relation existence because it uses foreign keys constraints. 107 | - Checks the existence of proper foreign key constraint at the boot time. 108 | Use `ENV['SKIP_DB_UNIQUENESS_VALIDATOR_INDEX_CHECK'] = 'true'` if you want to 109 | skip it in some cases. (For example, when you run migrations.) _Note:_ we skip it for the abstract classes. 110 | - It's almost two times faster because it skips unnecessary SQL query. See benchmarks 111 | below for details. 112 | 113 | **Disadvantages**: 114 | - Cannot handle multiple database validations at once because database 115 | raises only one error per query. 116 | 117 | ### Configuration options 118 | 119 | | Option name | PostgreSQL | MySQL | 120 | | ------------- | :--------: | :---: | 121 | | class_name | + | + | 122 | | foreign_key | + | + | 123 | | foreign_type | - | - | 124 | | primary_key | + | + | 125 | | dependent | + | + | 126 | | counter_cache | + | + | 127 | | polymorphic | - | - | 128 | | validate | + | + | 129 | | autosave | + | + | 130 | | touch | + | + | 131 | | inverse_of | + | + | 132 | | optional | - | - | 133 | | required | - | - | 134 | | default | + | + | 135 | 136 | ### Benchmarks ([code](benchmarks/db_belongs_to_benchmark.rb)) 137 | 138 | ![](benchmarks/db_belongs_to.png) 139 | 140 | ## validates_db_uniqueness_of 141 | 142 | Supported databases are `PostgreSQL`, `MySQL` and `SQLite`. 143 | 144 | ### Usage 145 | 146 | ```ruby 147 | class User < ActiveRecord::Base 148 | validates :email, db_uniqueness: true 149 | # The same as following: 150 | # validates :email, uniqueness: {case_sensitive: true, allow_nil: true, allow_blank: false} 151 | end 152 | 153 | original = User.create(email: 'email@mail.com') 154 | dupe = User.create(email: 'email@mail.com') 155 | # => false 156 | dupe.errors.messages 157 | # => {:email=>["has already been taken"]} 158 | User.create!(email: 'email@mail.com') 159 | # => ActiveRecord::RecordInvalid Validation failed: email has already been taken 160 | ``` 161 | 162 | Complete `case_sensitive` replacement example (for `PostgreSQL` only): 163 | 164 | ```ruby 165 | validates :slug, uniqueness: { case_sensitive: false, scope: :field } 166 | ``` 167 | 168 | Should be replaced by: 169 | 170 | ```ruby 171 | validates :slug, db_uniqueness: {index_name: :unique_index, case_sensitive: false, scope: :field} 172 | ``` 173 | 174 | **Keep in mind**: because `valid?` method uses default validator you should: 175 | 176 | - if your index has many fields, provide proper `scope` option 177 | - if your index has lower function, provide `case_sensitive` option 178 | - if your index has where condition, provide proper `where` option 179 | 180 | ### Problem 181 | 182 | Unfortunately, ActiveRecord's `validates_uniqueness_of` approach does not ensure 183 | uniqueness. For example, we can skip validations or create two records in parallel 184 | queries. After that, our database becomes inconsistent because we assume some uniqueness 185 | over the table but it has duplicates. 186 | 187 | `validates_db_uniqueness_of` solves the problem using unique index constraints 188 | in the database also providing backward compatibility with nice validations errors. 189 | 190 | ### Advantages 191 | 192 | - Ensures uniqueness because it uses unique constraints. 193 | - Checks the existence of proper unique index at the boot time. 194 | Use `ENV['SKIP_DB_UNIQUENESS_VALIDATOR_INDEX_CHECK'] = 'true'` 195 | if you want to skip it in some cases. (For example, when you run migrations.) _Note:_ we skip it for the abstract classes. 196 | - It's two times faster in average because it skips unnecessary SQL query. See benchmarks below for details. 197 | - It has different [modes](#modes) so you can pick up the best for your needs. 198 | 199 | ### Configuration options 200 | 201 | | Option name | PostgreSQL | MySQL | SQLite | 202 | | -------------- | :--------: | :---: | :----: | 203 | | mode | + | + | + | 204 | | scope | + | + | + | 205 | | message | + | + | + | 206 | | if | + | + | + | 207 | | unless | + | + | + | 208 | | index_name | + | + | - | 209 | | where | + | - | - | 210 | | case_sensitive | + | - | - | 211 | | allow_nil | - | - | - | 212 | | allow_blank | - | - | - | 213 | 214 | ### Rescue option 215 | 216 | The validation has an option `:rescue` with two values: 217 | - `:default` (default option) that follows default ActiveRecord behavior. It respects `validate: false` option for `save/save!` (for example, this is being used for nested associations) 218 | - `:always` that catches database constraint errors and turns them to ActiveRecord validations filling `.errors` properly. 219 | 220 | You may want to use `rescue: :always` in case you save nested associations with `accepts_nested_attributes_for` helper and you want the validation to happen automatically when a user 221 | provides duplicated data in the same request. 222 | 223 | ### Modes 224 | 225 | There are 3 `mode` options: 226 | 227 | - `:optimized` - the default one. In this mode it turns DB constraint exceptions into proper validation messages. 228 | - `:enhanced` - a combination of the standard uniqueness validation and the db uniqueness validation. Runs a query first but also rescues from exception. The preferable mode for user-facing validations. 229 | - `:standard` - in this mode works pretty much the same way as `validates_uniqueness_of` (except the index existence check). 230 | 231 | ### Benchmark ([code](benchmarks/uniqueness_validator_benchmark.rb)) 232 | 233 | ![](benchmarks/validates_db_uniqueness_of.png) 234 | 235 | ## Testing (RSpec) 236 | 237 | Add `require database_validations/rspec/matchers'` to your `spec` file. 238 | 239 | ### validate_db_uniqueness_of 240 | 241 | Example: 242 | 243 | ```ruby 244 | class User < ActiveRecord::Base 245 | validates_db_uniqueness_of :field, message: 'duplicate', where: '(some_field IS NULL)', scope: :another_field, index_name: :unique_index 246 | end 247 | 248 | describe 'validations' do 249 | subject { User } 250 | 251 | it { is_expected.to validate_db_uniqueness_of(:field).with_message('duplicate').with_where('(some_field IS NULL)').scoped_to(:another_field).with_index(:unique_index) } 252 | end 253 | ``` 254 | 255 | ## Using with RuboCop 256 | 257 | DatabaseValidations provides custom cops for RuboCop to help you consistently apply the improvements. 258 | To use all of them, use `rubocop --require database_validations/rubocop/cops` or add to your `.rubocop.yml` file: 259 | 260 | ```yaml 261 | require: 262 | - database_validations/rubocop/cops 263 | ``` 264 | 265 | Or you case use some specific cop directly: 266 | ```yaml 267 | require: 268 | - database_validations/rubocop/cop/belongs_to 269 | - database_validations/rubocop/cop/uniqueness_of 270 | ``` 271 | 272 | ## Development 273 | 274 | You need to have installed and running `postgresql` and `mysql`. 275 | And for each adapter manually create a database called `database_validations_test` accessible by your local user. 276 | 277 | Then, run `rake spec` to run the tests. 278 | 279 | To check the conformance with the style guides, run: 280 | 281 | ```bash 282 | rubocop 283 | ``` 284 | 285 | To run benchmarks, run: 286 | 287 | ```bash 288 | ruby -I lib benchmarks/composed_benchmarks.rb 289 | ``` 290 | 291 | To install this gem onto your local machine, run `bundle exec rake install`. 292 | To release a new version, update the version number in `version.rb`, and then 293 | run `bundle exec rake release`, which will create a git tag for the version, 294 | push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). 295 | 296 | ## Contributing 297 | 298 | [Bug reports](https://github.com/toptal/database_validations/issues) 299 | and [pull requests](https://github.com/toptal/database_validations/pulls) are 300 | welcome on GitHub. This project is intended to be a safe, welcoming space for 301 | collaboration, and contributors are expected to adhere 302 | to the [Contributor Covenant](http://contributor-covenant.org) code of conduct. 303 | 304 | ## License 305 | 306 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 307 | 308 | ## Code of Conduct 309 | 310 | Everyone interacting in the DatabaseValidations project’s codebases, issue trackers, chat rooms and mailing 311 | lists is expected to follow the [code of conduct](https://github.com/toptal/database_validations/blob/master/CODE_OF_CONDUCT.md). 312 | 313 | ## Contributors 314 | 315 | - [Evgeniy Demin](https://github.com/djezzzl) (author) 316 | - [Filipp Pirozhkov](https://github.com/pirj) 317 | - [Maxim Krizhanovski](https://github.com/Darhazer) 318 | - [Alfonso Uceda](https://github.com/AlfonsoUceda) 319 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | require 'rspec/core/rake_task' 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task default: :spec 7 | -------------------------------------------------------------------------------- /benchmarks/composed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/toptal/database_validations/33e196d2a86ba141a4e5b97b0278a048afff3a33/benchmarks/composed.png -------------------------------------------------------------------------------- /benchmarks/composed_benchmarks.rb: -------------------------------------------------------------------------------- 1 | require 'benchmark/ips' 2 | require 'database_validations' 3 | require_relative 'configurations' 4 | require_relative 'gc_suite' 5 | require_relative 'database_cleaner' 6 | 7 | [postgresql_configuration, mysql_configuration].each do |database_configuration| 8 | ActiveRecord::Base.establish_connection(database_configuration) 9 | clear_database!(database_configuration) 10 | 11 | ActiveRecord::Schema.define(version: 1) do 12 | create_table :companies 13 | create_table :countries 14 | 15 | create_table :users_1 do |t| 16 | t.string :email 17 | t.string :full_name 18 | t.belongs_to :country 19 | t.belongs_to :company 20 | t.index :email 21 | t.index :full_name 22 | end 23 | 24 | create_table :users_2 do |t| 25 | t.string :email 26 | t.string :full_name 27 | t.belongs_to :country, foreign_key: true 28 | t.belongs_to :company, foreign_key: true 29 | t.index :email, unique: true 30 | t.index :full_name, unique: true 31 | end 32 | end 33 | ActiveRecord::Schema.verbose = false 34 | ActiveRecord::Base.logger = nil 35 | 36 | class Company < ActiveRecord::Base 37 | end 38 | 39 | class Country < ActiveRecord::Base 40 | end 41 | 42 | class Users1 < ActiveRecord::Base 43 | self.table_name = :users_1 44 | 45 | validates_uniqueness_of :email 46 | validates_uniqueness_of :full_name 47 | 48 | belongs_to :company, optional: false 49 | belongs_to :country, optional: false 50 | end 51 | 52 | class Users2 < ActiveRecord::Base 53 | self.table_name = :users_2 54 | 55 | validates_db_uniqueness_of :email 56 | validates_db_uniqueness_of :full_name 57 | 58 | db_belongs_to :company 59 | db_belongs_to :country 60 | end 61 | 62 | # ===Benchmarks=== 63 | suite = GCSuite.new 64 | company = Company.create! 65 | country = Country.create! 66 | field = 0 67 | 68 | # ===Save only valid=== 69 | Benchmark.ips do |x| 70 | x.config(suite: suite) 71 | 72 | x.report("#{database_configuration[:adapter]} optimistic: without gem") { field += 1; Users1.create(company_id: company.id, country_id: country.id, full_name: field.to_s, email: field.to_s) } 73 | x.report("#{database_configuration[:adapter]} optimistic: with gem") { field += 1; Users2.create(company_id: company.id, country_id: country.id, full_name: field.to_s, email: field.to_s) } 74 | 75 | x.report("#{database_configuration[:adapter]} realistic: without gem") { field += 1; field % 100 == 0 ? Users1.create(company_id: -1, country_id: -1, full_name: 'invalid', email: 'invalid') : Users1.create(company_id: company.id, country_id: country.id, full_name: field.to_s, email: field.to_s) } 76 | x.report("#{database_configuration[:adapter]} realistic: with gem") { field += 1; field % 100 == 0 ? Users2.create(company_id: -1, country_id: -1, full_name: 'invalid', email: 'invalid') : Users2.create(company_id: company.id, country_id: country.id, full_name: field.to_s, email: field.to_s) } 77 | 78 | x.report("#{database_configuration[:adapter]} pessimistic: without gem") { Users1.create(company_id: -1, country_id: -1, full_name: 'invalid', email: 'invalid') } 79 | x.report("#{database_configuration[:adapter]} pessimistic: with gem") { Users2.create(company_id: -1, country_id: -1, full_name: 'invalid', email: 'invalid') } 80 | end 81 | 82 | # Clear the DB 83 | ActiveRecord::Schema.define(version: 2) do 84 | drop_table :users_1, if_exists: true, force: :cascade 85 | drop_table :users_2, if_exists: true, force: :cascade 86 | drop_table :companies, if_exists: true, force: :cascade 87 | drop_table :countries, if_exists: true, force: :cascade 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /benchmarks/configurations.rb: -------------------------------------------------------------------------------- 1 | def postgresql_configuration 2 | { 3 | adapter: 'postgresql', 4 | database: 'database_validations_test', 5 | host: ENV['PGHOST'] || '127.0.0.1' 6 | } 7 | end 8 | 9 | def mysql_configuration 10 | { 11 | adapter: 'mysql2', 12 | database: 'database_validations_test', 13 | host: ENV['MYSQLHOST'] || '127.0.0.1' 14 | } 15 | end 16 | 17 | def sqlite_configuration 18 | { 19 | adapter: 'sqlite3', 20 | database: ':memory:' 21 | } 22 | end 23 | -------------------------------------------------------------------------------- /benchmarks/database_cleaner.rb: -------------------------------------------------------------------------------- 1 | def clear_database!(configuration) 2 | ActiveRecord::Base.connection.execute 'SET FOREIGN_KEY_CHECKS=0;' if configuration[:adapter] == 'mysql2' 3 | ActiveRecord::Base.connection.tables.each do |table| 4 | ActiveRecord::Base.connection.drop_table(table, force: :cascade) 5 | end 6 | ActiveRecord::Base.connection.execute 'SET FOREIGN_KEY_CHECKS=1;' if configuration[:adapter] == 'mysql2' 7 | end 8 | -------------------------------------------------------------------------------- /benchmarks/db_belongs_to.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/toptal/database_validations/33e196d2a86ba141a4e5b97b0278a048afff3a33/benchmarks/db_belongs_to.png -------------------------------------------------------------------------------- /benchmarks/db_belongs_to_benchmark.rb: -------------------------------------------------------------------------------- 1 | require 'benchmark/ips' 2 | require 'database_validations' 3 | require_relative 'configurations' 4 | require_relative 'gc_suite' 5 | require_relative 'database_cleaner' 6 | 7 | [postgresql_configuration, mysql_configuration].each do |database_configuration| 8 | ActiveRecord::Base.establish_connection(database_configuration) 9 | clear_database!(database_configuration) 10 | ActiveRecord::Schema.define(version: 1) do 11 | create_table :companies 12 | 13 | create_table :users_1 do |t| 14 | t.belongs_to :company 15 | end 16 | 17 | create_table :users_2 do |t| 18 | t.belongs_to :company, foreign_key: true 19 | end 20 | end 21 | ActiveRecord::Schema.verbose = false 22 | ActiveRecord::Base.logger = nil 23 | 24 | class Company < ActiveRecord::Base 25 | end 26 | 27 | class Users1 < ActiveRecord::Base 28 | self.table_name = :users_1 29 | belongs_to :company, optional: false 30 | end 31 | 32 | class Users2 < ActiveRecord::Base 33 | self.table_name = :users_2 34 | db_belongs_to :company 35 | end 36 | 37 | # ===Benchmarks=== 38 | suite = GCSuite.new 39 | company = Company.create! 40 | field = 0 41 | 42 | Benchmark.ips do |x| 43 | x.config(suite: suite) 44 | 45 | x.report("#{database_configuration[:adapter]} only existing: belongs_to") { Users1.create(company_id: company.id) } 46 | x.report("#{database_configuration[:adapter]} only existing: db_belongs_to") { Users2.create(company_id: company.id) } 47 | 48 | x.report("#{database_configuration[:adapter]} each hundredth does not exist: belongs_to") { field += 1; field % 100 == 0 ? Users1.create(company_id: -1) : Users1.create(company_id: company.id) } 49 | x.report("#{database_configuration[:adapter]} each hundredth does not exist: db_belongs_to") { field += 1; field % 100 == 0 ? Users2.create(company_id: -1) : Users2.create(company_id: company.id) } 50 | 51 | x.report("#{database_configuration[:adapter]} only missing: belongs_to") { Users1.create(company_id: -1) } 52 | x.report("#{database_configuration[:adapter]} only missing: db_belongs_to") { Users2.create(company_id: -1) } 53 | end 54 | 55 | # Clear the DB 56 | ActiveRecord::Schema.define(version: 2) do 57 | drop_table :users_1, if_exists: true, force: :cascade 58 | drop_table :users_2, if_exists: true, force: :cascade 59 | drop_table :companies, if_exists: true, force: :cascade 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /benchmarks/gc_suite.rb: -------------------------------------------------------------------------------- 1 | # ===Setups=== 2 | # Enable and start GC before each job run. Disable GC afterwards. 3 | class GCSuite 4 | def warming(*) 5 | run_gc 6 | end 7 | 8 | def running(*) 9 | run_gc 10 | end 11 | 12 | def warmup_stats(*); end 13 | 14 | def add_report(*); end 15 | 16 | private 17 | 18 | def run_gc 19 | GC.enable 20 | GC.start 21 | GC.disable 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /benchmarks/uniqueness_validator_benchmark.rb: -------------------------------------------------------------------------------- 1 | require 'benchmark/ips' 2 | require 'database_validations' 3 | require_relative 'configurations' 4 | require_relative 'gc_suite' 5 | require_relative 'database_cleaner' 6 | 7 | [sqlite_configuration, postgresql_configuration, mysql_configuration].each do |database_configuration| 8 | ActiveRecord::Base.establish_connection(database_configuration) 9 | clear_database!(database_configuration) 10 | ActiveRecord::Schema.define(version: 1) do 11 | create_table :entities do |t| 12 | t.integer :field 13 | t.index [:field], unique: true 14 | end 15 | end 16 | ActiveRecord::Schema.verbose = false 17 | ActiveRecord::Base.logger = nil 18 | 19 | class Entity < ActiveRecord::Base 20 | reset_column_information 21 | end 22 | 23 | class DbValidation < Entity 24 | validates_db_uniqueness_of :field 25 | end 26 | 27 | class AppValidation < Entity 28 | validates_uniqueness_of :field 29 | end 30 | 31 | # ===Benchmarks=== 32 | suite = GCSuite.new 33 | field = 0 34 | Entity.create(field: field) 35 | 36 | Benchmark.ips do |x| 37 | x.config(suite: suite) 38 | 39 | x.report("#{database_configuration[:adapter]} only unique: validates_uniqueness_of") { field += 1; AppValidation.create(field: field) } 40 | x.report("#{database_configuration[:adapter]} only unique: validates_db_uniqueness_of") { field += 1; DbValidation.create(field: field) } 41 | 42 | x.report("#{database_configuration[:adapter]} each hundredth is a duplicate: validates_uniqueness_of") { field += 1; AppValidation.create(field: (field % 100 == 0 ? 0 : field)) } 43 | x.report("#{database_configuration[:adapter]} each hundredth is a duplicate: validates_db_uniqueness_of") { field += 1; DbValidation.create(field: (field % 100 == 0 ? 0 : field)) } 44 | 45 | x.report("#{database_configuration[:adapter]} only duplicates: validates_uniqueness_of") { AppValidation.create(field: 0) } 46 | x.report("#{database_configuration[:adapter]} only duplicates: validates_db_uniqueness_of") { DbValidation.create(field: 0) } 47 | end 48 | 49 | # Clear the DB 50 | ActiveRecord::Schema.define(version: 1) do 51 | drop_table :entities, if_exists: true, force: :cascade 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /benchmarks/validates_db_uniqueness_of.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/toptal/database_validations/33e196d2a86ba141a4e5b97b0278a048afff3a33/benchmarks/validates_db_uniqueness_of.png -------------------------------------------------------------------------------- /database_validations.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path('lib', __dir__) 2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 3 | require 'database_validations/version' 4 | 5 | Gem::Specification.new do |spec| # rubocop:disable Metrics/BlockLength 6 | spec.name = 'database_validations' 7 | spec.version = DatabaseValidations::VERSION 8 | spec.authors = ['Evgeniy Demin'] 9 | spec.email = ['lawliet.djez@gmail.com'] 10 | spec.summary = 'Provide compatibility between database constraints 11 | and ActiveRecord validations with better performance and consistency.' 12 | spec.description = "ActiveRecord provides validations on app level but it won't guarantee the 13 | consistent. In some cases, like `validates_uniqueness_of` it executes 14 | additional SQL query to the database and that is not very efficient. 15 | 16 | The main goal of the gem is to provide compatibility between database constraints 17 | and ActiveRecord validations with better performance and consistency." 18 | spec.homepage = 'https://github.com/toptal/database_validations' 19 | spec.license = 'MIT' 20 | spec.files = Dir['lib/**/*'] 21 | spec.require_paths = ['lib'] 22 | 23 | spec.add_dependency 'activerecord', '>= 4.2.0' 24 | 25 | spec.add_development_dependency 'benchmark-ips', '~> 2.7' 26 | spec.add_development_dependency 'bundler', '~> 2.0' 27 | spec.add_development_dependency 'db-query-matchers', '>= 0.9' 28 | spec.add_development_dependency 'mysql2' 29 | spec.add_development_dependency 'pg' 30 | spec.add_development_dependency 'rake', '~> 13.0' 31 | spec.add_development_dependency 'rspec', '~> 3.0' 32 | spec.add_development_dependency 'rubocop', '~> 0.60' 33 | spec.add_development_dependency 'rubocop-rspec', '~> 1.30' 34 | spec.add_development_dependency 'sqlite3', '~> 1.3' 35 | end 36 | -------------------------------------------------------------------------------- /example/Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | git_source(:github) { |repo| "https://github.com/#{repo}.git" } 3 | 4 | gem 'database_validations', path: '../' 5 | 6 | # Bundle edge Rails instead: gem 'rails', github: 'rails/rails' 7 | gem 'rails', '~> 5.2.1' 8 | # Use sqlite3 as the database for Active Record 9 | gem 'pg' 10 | # Use Puma as the app server 11 | gem 'puma', '~> 3.11' 12 | # Use SCSS for stylesheets 13 | gem 'sass-rails', '~> 5.0' 14 | # Use Uglifier as compressor for JavaScript assets 15 | gem 'uglifier', '>= 1.3.0' 16 | # See https://github.com/rails/execjs#readme for more supported runtimes 17 | # gem 'mini_racer', platforms: :ruby 18 | 19 | # Use CoffeeScript for .coffee assets and views 20 | gem 'coffee-rails', '~> 4.2' 21 | # Turbolinks makes navigating your web application faster. Read more: https://github.com/turbolinks/turbolinks 22 | gem 'turbolinks', '~> 5' 23 | # Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder 24 | gem 'jbuilder', '~> 2.5' 25 | # Use Redis adapter to run Action Cable in production 26 | # gem 'redis', '~> 4.0' 27 | # Use ActiveModel has_secure_password 28 | # gem 'bcrypt', '~> 3.1.7' 29 | 30 | # Use ActiveStorage variant 31 | # gem 'mini_magick', '~> 4.8' 32 | 33 | # Use Capistrano for deployment 34 | # gem 'capistrano-rails', group: :development 35 | 36 | # Reduces boot times through caching; required in config/boot.rb 37 | gem 'bootsnap', '>= 1.1.0', require: false 38 | 39 | group :development, :test do 40 | # Call 'byebug' anywhere in the code to stop execution and get a debugger console 41 | gem 'byebug', platforms: %i[mri mingw x64_mingw] 42 | end 43 | 44 | group :development do 45 | # Access an interactive console on exception pages or by calling 'console' anywhere in the code. 46 | gem 'listen', '>= 3.0.5', '< 3.2' 47 | gem 'web-console', '>= 3.3.0' 48 | # Spring speeds up development by keeping your application running in the background. Read more: https://github.com/rails/spring 49 | gem 'spring' 50 | gem 'spring-watcher-listen', '~> 2.0.0' 51 | end 52 | 53 | group :test do 54 | # Adds support for Capybara system testing and selenium driver 55 | gem 'capybara', '>= 2.15' 56 | gem 'selenium-webdriver' 57 | # Easy installation and use of chromedriver to run system tests with Chrome 58 | gem 'chromedriver-helper' 59 | end 60 | 61 | # Windows does not include zoneinfo files, so bundle the tzinfo-data gem 62 | gem 'tzinfo-data', platforms: %i[mingw mswin x64_mingw jruby] 63 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # README 2 | 3 | This README would normally document whatever steps are necessary to get the 4 | application up and running. 5 | 6 | Things you may want to cover: 7 | 8 | * Ruby version 9 | 10 | * System dependencies 11 | 12 | * Configuration 13 | 14 | * Database creation 15 | 16 | * Database initialization 17 | 18 | * How to run the test suite 19 | 20 | * Services (job queues, cache servers, search engines, etc.) 21 | 22 | * Deployment instructions 23 | 24 | * ... 25 | -------------------------------------------------------------------------------- /example/Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require_relative 'config/application' 5 | 6 | Rails.application.load_tasks 7 | -------------------------------------------------------------------------------- /example/app/assets/config/manifest.js: -------------------------------------------------------------------------------- 1 | //= link_tree ../images 2 | //= link_directory ../javascripts .js 3 | //= link_directory ../stylesheets .css 4 | -------------------------------------------------------------------------------- /example/app/assets/images/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/toptal/database_validations/33e196d2a86ba141a4e5b97b0278a048afff3a33/example/app/assets/images/.keep -------------------------------------------------------------------------------- /example/app/assets/javascripts/application.js: -------------------------------------------------------------------------------- 1 | // This is a manifest file that'll be compiled into application.js, which will include all the files 2 | // listed below. 3 | // 4 | // Any JavaScript/Coffee file within this directory, lib/assets/javascripts, or any plugin's 5 | // vendor/assets/javascripts directory can be referenced here using a relative path. 6 | // 7 | // It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the 8 | // compiled file. JavaScript code in this file should be added after the last require_* statement. 9 | // 10 | // Read Sprockets README (https://github.com/rails/sprockets#sprockets-directives) for details 11 | // about supported directives. 12 | // 13 | //= require rails-ujs 14 | //= require activestorage 15 | //= require turbolinks 16 | //= require_tree . 17 | -------------------------------------------------------------------------------- /example/app/assets/javascripts/cable.js: -------------------------------------------------------------------------------- 1 | // Action Cable provides the framework to deal with WebSockets in Rails. 2 | // You can generate new channels where WebSocket features live using the `rails generate channel` command. 3 | // 4 | //= require action_cable 5 | //= require_self 6 | //= require_tree ./channels 7 | 8 | (function() { 9 | this.App || (this.App = {}); 10 | 11 | App.cable = ActionCable.createConsumer(); 12 | 13 | }).call(this); 14 | -------------------------------------------------------------------------------- /example/app/assets/javascripts/channels/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/toptal/database_validations/33e196d2a86ba141a4e5b97b0278a048afff3a33/example/app/assets/javascripts/channels/.keep -------------------------------------------------------------------------------- /example/app/assets/stylesheets/application.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a manifest file that'll be compiled into application.css, which will include all the files 3 | * listed below. 4 | * 5 | * Any CSS and SCSS file within this directory, lib/assets/stylesheets, or any plugin's 6 | * vendor/assets/stylesheets directory can be referenced here using a relative path. 7 | * 8 | * You're free to add application-wide styles to this file and they'll appear at the bottom of the 9 | * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS 10 | * files in this directory. Styles in this file should be added after the last require_* statement. 11 | * It is generally better to create a new file per style scope. 12 | * 13 | *= require_tree . 14 | *= require_self 15 | */ 16 | -------------------------------------------------------------------------------- /example/app/channels/application_cable/channel.rb: -------------------------------------------------------------------------------- 1 | module ApplicationCable 2 | class Channel < ActionCable::Channel::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /example/app/channels/application_cable/connection.rb: -------------------------------------------------------------------------------- 1 | module ApplicationCable 2 | class Connection < ActionCable::Connection::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /example/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | end 3 | -------------------------------------------------------------------------------- /example/app/controllers/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/toptal/database_validations/33e196d2a86ba141a4e5b97b0278a048afff3a33/example/app/controllers/concerns/.keep -------------------------------------------------------------------------------- /example/app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ApplicationHelper 2 | end 3 | -------------------------------------------------------------------------------- /example/app/jobs/application_job.rb: -------------------------------------------------------------------------------- 1 | class ApplicationJob < ActiveJob::Base 2 | end 3 | -------------------------------------------------------------------------------- /example/app/mailers/application_mailer.rb: -------------------------------------------------------------------------------- 1 | class ApplicationMailer < ActionMailer::Base 2 | default from: 'from@example.com' 3 | layout 'mailer' 4 | end 5 | -------------------------------------------------------------------------------- /example/app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | class ApplicationRecord < ActiveRecord::Base 2 | self.abstract_class = true 3 | end 4 | -------------------------------------------------------------------------------- /example/app/models/company.rb: -------------------------------------------------------------------------------- 1 | class Company < ApplicationRecord 2 | end 3 | -------------------------------------------------------------------------------- /example/app/models/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/toptal/database_validations/33e196d2a86ba141a4e5b97b0278a048afff3a33/example/app/models/concerns/.keep -------------------------------------------------------------------------------- /example/app/models/country.rb: -------------------------------------------------------------------------------- 1 | class Country < ApplicationRecord 2 | has_many :users 3 | 4 | accepts_nested_attributes_for :users 5 | end 6 | -------------------------------------------------------------------------------- /example/app/models/user.rb: -------------------------------------------------------------------------------- 1 | class User < ApplicationRecord 2 | db_belongs_to :country 3 | db_belongs_to :company 4 | 5 | validates :full_name, db_uniqueness: true 6 | validates :email, db_uniqueness: { rescue: :always } 7 | end 8 | -------------------------------------------------------------------------------- /example/app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Example 5 | <%= csrf_meta_tags %> 6 | <%= csp_meta_tag %> 7 | 8 | <%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %> 9 | <%= javascript_include_tag 'application', 'data-turbolinks-track': 'reload' %> 10 | 11 | 12 | 13 | <%= yield %> 14 | 15 | 16 | -------------------------------------------------------------------------------- /example/app/views/layouts/mailer.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 10 | 11 | <%= yield %> 12 | 13 | 14 | -------------------------------------------------------------------------------- /example/app/views/layouts/mailer.text.erb: -------------------------------------------------------------------------------- 1 | <%= yield %> 2 | -------------------------------------------------------------------------------- /example/bin/bundle: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) 3 | load Gem.bin_path('bundler', 'bundle') 4 | -------------------------------------------------------------------------------- /example/bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | begin 3 | load File.expand_path('spring', __dir__) 4 | rescue LoadError => e 5 | raise unless e.message.include?('spring') 6 | end 7 | APP_PATH = File.expand_path('../config/application', __dir__) 8 | require_relative '../config/boot' 9 | require 'rails/commands' 10 | -------------------------------------------------------------------------------- /example/bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | begin 3 | load File.expand_path('spring', __dir__) 4 | rescue LoadError => e 5 | raise unless e.message.include?('spring') 6 | end 7 | require_relative '../config/boot' 8 | require 'rake' 9 | Rake.application.run 10 | -------------------------------------------------------------------------------- /example/bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # path to your application root. 3 | APP_ROOT = File.expand_path('..', __dir__) 4 | 5 | def system!(*args) 6 | system(*args) || abort("\n== Command #{args} failed ==") 7 | end 8 | 9 | chdir APP_ROOT do 10 | # This script is a starting point to setup your application. 11 | # Add necessary setup steps to this file. 12 | 13 | puts '== Installing dependencies ==' 14 | system! 'gem install bundler --conservative' 15 | system('bundle check') || system!('bundle install') 16 | 17 | # Install JavaScript dependencies if using Yarn 18 | # system('bin/yarn') 19 | 20 | # puts "\n== Copying sample files ==" 21 | # unless File.exist?('config/database.yml') 22 | # cp 'config/database.yml.sample', 'config/database.yml' 23 | # end 24 | 25 | puts "\n== Preparing database ==" 26 | system! 'bin/rails db:setup' 27 | 28 | puts "\n== Removing old logs and tempfiles ==" 29 | system! 'bin/rails log:clear tmp:clear' 30 | 31 | puts "\n== Restarting application server ==" 32 | system! 'bin/rails restart' 33 | end 34 | -------------------------------------------------------------------------------- /example/bin/spring: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | # This file loads spring without using Bundler, in order to be fast. 4 | # It gets overwritten when you run the `spring binstub` command. 5 | 6 | unless defined?(Spring) 7 | require 'rubygems' 8 | require 'bundler' 9 | 10 | lockfile = Bundler::LockfileParser.new(Bundler.default_lockfile.read) 11 | spring = lockfile.specs.detect { |spec| spec.name == 'spring' } 12 | if spring 13 | Gem.use_paths Gem.dir, Bundler.bundle_path.to_s, *Gem.path 14 | gem 'spring', spring.version 15 | require 'spring/binstub' 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /example/bin/update: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # path to your application root. 3 | APP_ROOT = File.expand_path('..', __dir__) 4 | 5 | def system!(*args) 6 | system(*args) || abort("\n== Command #{args} failed ==") 7 | end 8 | 9 | chdir APP_ROOT do 10 | # This script is a way to update your development environment automatically. 11 | # Add necessary update steps to this file. 12 | 13 | puts '== Installing dependencies ==' 14 | system! 'gem install bundler --conservative' 15 | system('bundle check') || system!('bundle install') 16 | 17 | # Install JavaScript dependencies if using Yarn 18 | # system('bin/yarn') 19 | 20 | puts "\n== Updating database ==" 21 | system! 'bin/rails db:migrate' 22 | 23 | puts "\n== Removing old logs and tempfiles ==" 24 | system! 'bin/rails log:clear tmp:clear' 25 | 26 | puts "\n== Restarting application server ==" 27 | system! 'bin/rails restart' 28 | end 29 | -------------------------------------------------------------------------------- /example/bin/yarn: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_ROOT = File.expand_path('..', __dir__) 3 | Dir.chdir(APP_ROOT) do 4 | exec 'yarnpkg', *ARGV 5 | rescue Errno::ENOENT 6 | warn 'Yarn executable was not detected in the system.' 7 | warn 'Download Yarn at https://yarnpkg.com/en/docs/install' 8 | exit 1 9 | end 10 | -------------------------------------------------------------------------------- /example/config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require_relative 'config/environment' 4 | 5 | run Rails.application 6 | -------------------------------------------------------------------------------- /example/config/application.rb: -------------------------------------------------------------------------------- 1 | require_relative 'boot' 2 | 3 | require 'rails/all' 4 | 5 | # Require the gems listed in Gemfile, including any gems 6 | # you've limited to :test, :development, or :production. 7 | Bundler.require(*Rails.groups) 8 | 9 | module Example 10 | class Application < Rails::Application 11 | # Initialize configuration defaults for originally generated Rails version. 12 | config.load_defaults 5.2 13 | 14 | # Settings in config/environments/* take precedence over those specified here. 15 | # Application configuration can go into files in config/initializers 16 | # -- all .rb files in that directory are automatically loaded after loading 17 | # the framework and any gems in your application. 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /example/config/boot.rb: -------------------------------------------------------------------------------- 1 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) 2 | 3 | require 'bundler/setup' # Set up gems listed in the Gemfile. 4 | require 'bootsnap/setup' # Speed up boot time by caching expensive operations. 5 | -------------------------------------------------------------------------------- /example/config/cable.yml: -------------------------------------------------------------------------------- 1 | development: 2 | adapter: async 3 | 4 | test: 5 | adapter: async 6 | 7 | production: 8 | adapter: redis 9 | url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> 10 | channel_prefix: example_production 11 | -------------------------------------------------------------------------------- /example/config/credentials.yml.enc: -------------------------------------------------------------------------------- 1 | LtzjJQRbxwuzfw4As6t+k4Cp2ZUWccAvRHJ3Ti++NOWIKdnjMAuYzQ7VR1++1C5UrccAOU6pYqHWzuO2LRdNOUEwXY+yBmhiXJyV5I53jegTrlNZdP+en4L9fWLkeo8yilwZUQ7bOjpD/LVvRbHQcJ7MLiD1puJzZUBGjDbdz/P5c4rNzyC3RYSfKwWTQU8CNM1lOWSB3srUT6u00q58Hu0Ooj9limkdQ5aq4s1WNlvLF3KLS9szC9xphzkEx2tlapCGSxsTg0Y6tHN/NYHsrn5SNDd6dpgdmZuY24jJY4fZl6k4gEHcLCL7ZvK6VlA4fukKrOrapaCiyp4d9q1PIkQlsSId1P4H8o5tqEwIAarS6yjrbQvC4TLCzBPXm/7G7SQwDAlP9mjuHAEnsriXDUZGYQKtLso8Cs9c--pyxby+TIxtw+4T3f--JmOZUv/iwMDl9xilak7hvg== -------------------------------------------------------------------------------- /example/config/database.yml.example: -------------------------------------------------------------------------------- 1 | default: &default 2 | adapter: postgresql 3 | pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> 4 | timeout: 5000 5 | database: database_validations_test 6 | 7 | development: 8 | <<: *default 9 | -------------------------------------------------------------------------------- /example/config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require_relative 'application' 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /example/config/environments/development.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # In the development environment your application's code is reloaded on 5 | # every request. This slows down response time but is perfect for development 6 | # since you don't have to restart the web server when you make code changes. 7 | config.cache_classes = false 8 | 9 | # Do not eager load code on boot. 10 | config.eager_load = false 11 | 12 | # Show full error reports. 13 | config.consider_all_requests_local = true 14 | 15 | # Enable/disable caching. By default caching is disabled. 16 | # Run rails dev:cache to toggle caching. 17 | if Rails.root.join('tmp', 'caching-dev.txt').exist? 18 | config.action_controller.perform_caching = true 19 | 20 | config.cache_store = :memory_store 21 | config.public_file_server.headers = { 22 | 'Cache-Control' => "public, max-age=#{2.days.to_i}" 23 | } 24 | else 25 | config.action_controller.perform_caching = false 26 | 27 | config.cache_store = :null_store 28 | end 29 | 30 | # Store uploaded files on the local file system (see config/storage.yml for options) 31 | config.active_storage.service = :local 32 | 33 | # Don't care if the mailer can't send. 34 | config.action_mailer.raise_delivery_errors = false 35 | 36 | config.action_mailer.perform_caching = false 37 | 38 | # Print deprecation notices to the Rails logger. 39 | config.active_support.deprecation = :log 40 | 41 | # Raise an error on page load if there are pending migrations. 42 | config.active_record.migration_error = :page_load 43 | 44 | # Highlight code that triggered database queries in logs. 45 | config.active_record.verbose_query_logs = true 46 | 47 | # Debug mode disables concatenation and preprocessing of assets. 48 | # This option may cause significant delays in view rendering with a large 49 | # number of complex assets. 50 | config.assets.debug = true 51 | 52 | # Suppress logger output for asset requests. 53 | config.assets.quiet = true 54 | 55 | # Raises error for missing translations 56 | # config.action_view.raise_on_missing_translations = true 57 | 58 | # Use an evented file watcher to asynchronously detect changes in source code, 59 | # routes, locales, etc. This feature depends on the listen gem. 60 | config.file_watcher = ActiveSupport::EventedFileUpdateChecker 61 | end 62 | -------------------------------------------------------------------------------- /example/config/environments/production.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # Code is not reloaded between requests. 5 | config.cache_classes = true 6 | 7 | # Eager load code on boot. This eager loads most of Rails and 8 | # your application in memory, allowing both threaded web servers 9 | # and those relying on copy on write to perform better. 10 | # Rake tasks automatically ignore this option for performance. 11 | config.eager_load = true 12 | 13 | # Full error reports are disabled and caching is turned on. 14 | config.consider_all_requests_local = false 15 | config.action_controller.perform_caching = true 16 | 17 | # Ensures that a master key has been made available in either ENV["RAILS_MASTER_KEY"] 18 | # or in config/master.key. This key is used to decrypt credentials (and other encrypted files). 19 | # config.require_master_key = true 20 | 21 | # Disable serving static files from the `/public` folder by default since 22 | # Apache or NGINX already handles this. 23 | config.public_file_server.enabled = ENV['RAILS_SERVE_STATIC_FILES'].present? 24 | 25 | # Compress JavaScripts and CSS. 26 | config.assets.js_compressor = :uglifier 27 | # config.assets.css_compressor = :sass 28 | 29 | # Do not fallback to assets pipeline if a precompiled asset is missed. 30 | config.assets.compile = false 31 | 32 | # `config.assets.precompile` and `config.assets.version` have moved to config/initializers/assets.rb 33 | 34 | # Enable serving of images, stylesheets, and JavaScripts from an asset server. 35 | # config.action_controller.asset_host = 'http://assets.example.com' 36 | 37 | # Specifies the header that your server uses for sending files. 38 | # config.action_dispatch.x_sendfile_header = 'X-Sendfile' # for Apache 39 | # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for NGINX 40 | 41 | # Store uploaded files on the local file system (see config/storage.yml for options) 42 | config.active_storage.service = :local 43 | 44 | # Mount Action Cable outside main process or domain 45 | # config.action_cable.mount_path = nil 46 | # config.action_cable.url = 'wss://example.com/cable' 47 | # config.action_cable.allowed_request_origins = [ 'http://example.com', /http:\/\/example.*/ ] 48 | 49 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. 50 | # config.force_ssl = true 51 | 52 | # Use the lowest log level to ensure availability of diagnostic information 53 | # when problems arise. 54 | config.log_level = :debug 55 | 56 | # Prepend all log lines with the following tags. 57 | config.log_tags = [:request_id] 58 | 59 | # Use a different cache store in production. 60 | # config.cache_store = :mem_cache_store 61 | 62 | # Use a real queuing backend for Active Job (and separate queues per environment) 63 | # config.active_job.queue_adapter = :resque 64 | # config.active_job.queue_name_prefix = "example_#{Rails.env}" 65 | 66 | config.action_mailer.perform_caching = false 67 | 68 | # Ignore bad email addresses and do not raise email delivery errors. 69 | # Set this to true and configure the email server for immediate delivery to raise delivery errors. 70 | # config.action_mailer.raise_delivery_errors = false 71 | 72 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to 73 | # the I18n.default_locale when a translation cannot be found). 74 | config.i18n.fallbacks = true 75 | 76 | # Send deprecation notices to registered listeners. 77 | config.active_support.deprecation = :notify 78 | 79 | # Use default logging formatter so that PID and timestamp are not suppressed. 80 | config.log_formatter = ::Logger::Formatter.new 81 | 82 | # Use a different logger for distributed setups. 83 | # require 'syslog/logger' 84 | # config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new 'app-name') 85 | 86 | if ENV['RAILS_LOG_TO_STDOUT'].present? 87 | logger = ActiveSupport::Logger.new(STDOUT) 88 | logger.formatter = config.log_formatter 89 | config.logger = ActiveSupport::TaggedLogging.new(logger) 90 | end 91 | 92 | # Do not dump schema after migrations. 93 | config.active_record.dump_schema_after_migration = false 94 | end 95 | -------------------------------------------------------------------------------- /example/config/environments/test.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # The test environment is used exclusively to run your application's 5 | # test suite. You never need to work with it otherwise. Remember that 6 | # your test database is "scratch space" for the test suite and is wiped 7 | # and recreated between test runs. Don't rely on the data there! 8 | config.cache_classes = true 9 | 10 | # Do not eager load code on boot. This avoids loading your whole application 11 | # just for the purpose of running a single test. If you are using a tool that 12 | # preloads Rails for running tests, you may have to set it to true. 13 | config.eager_load = false 14 | 15 | # Configure public file server for tests with Cache-Control for performance. 16 | config.public_file_server.enabled = true 17 | config.public_file_server.headers = { 18 | 'Cache-Control' => "public, max-age=#{1.hour.to_i}" 19 | } 20 | 21 | # Show full error reports and disable caching. 22 | config.consider_all_requests_local = true 23 | config.action_controller.perform_caching = false 24 | 25 | # Raise exceptions instead of rendering exception templates. 26 | config.action_dispatch.show_exceptions = false 27 | 28 | # Disable request forgery protection in test environment. 29 | config.action_controller.allow_forgery_protection = false 30 | 31 | # Store uploaded files on the local file system in a temporary directory 32 | config.active_storage.service = :test 33 | 34 | config.action_mailer.perform_caching = false 35 | 36 | # Tell Action Mailer not to deliver emails to the real world. 37 | # The :test delivery method accumulates sent emails in the 38 | # ActionMailer::Base.deliveries array. 39 | config.action_mailer.delivery_method = :test 40 | 41 | # Print deprecation notices to the stderr. 42 | config.active_support.deprecation = :stderr 43 | 44 | # Raises error for missing translations 45 | # config.action_view.raise_on_missing_translations = true 46 | end 47 | -------------------------------------------------------------------------------- /example/config/initializers/application_controller_renderer.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # ActiveSupport::Reloader.to_prepare do 4 | # ApplicationController.renderer.defaults.merge!( 5 | # http_host: 'example.org', 6 | # https: false 7 | # ) 8 | # end 9 | -------------------------------------------------------------------------------- /example/config/initializers/assets.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Version of your assets, change this if you want to expire all your assets. 4 | Rails.application.config.assets.version = '1.0' 5 | 6 | # Add additional assets to the asset load path. 7 | # Rails.application.config.assets.paths << Emoji.images_path 8 | # Add Yarn node_modules folder to the asset load path. 9 | Rails.application.config.assets.paths << Rails.root.join('node_modules') 10 | 11 | # Precompile additional assets. 12 | # application.js, application.css, and all non-JS/CSS in the app/assets 13 | # folder are already added. 14 | # Rails.application.config.assets.precompile += %w( admin.js admin.css ) 15 | -------------------------------------------------------------------------------- /example/config/initializers/backtrace_silencers.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. 4 | # Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ } 5 | 6 | # You can also remove all the silencers if you're trying to debug a problem that might stem from framework code. 7 | # Rails.backtrace_cleaner.remove_silencers! 8 | -------------------------------------------------------------------------------- /example/config/initializers/content_security_policy.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Define an application-wide content security policy 4 | # For further information see the following documentation 5 | # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy 6 | 7 | # Rails.application.config.content_security_policy do |policy| 8 | # policy.default_src :self, :https 9 | # policy.font_src :self, :https, :data 10 | # policy.img_src :self, :https, :data 11 | # policy.object_src :none 12 | # policy.script_src :self, :https 13 | # policy.style_src :self, :https 14 | 15 | # # Specify URI for violation reports 16 | # # policy.report_uri "/csp-violation-report-endpoint" 17 | # end 18 | 19 | # If you are using UJS then enable automatic nonce generation 20 | # Rails.application.config.content_security_policy_nonce_generator = -> request { SecureRandom.base64(16) } 21 | 22 | # Report CSP violations to a specified URI 23 | # For further information see the following documentation: 24 | # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy-Report-Only 25 | # Rails.application.config.content_security_policy_report_only = true 26 | -------------------------------------------------------------------------------- /example/config/initializers/cookies_serializer.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Specify a serializer for the signed and encrypted cookie jars. 4 | # Valid options are :json, :marshal, and :hybrid. 5 | Rails.application.config.action_dispatch.cookies_serializer = :json 6 | -------------------------------------------------------------------------------- /example/config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Configure sensitive parameters which will be filtered from the log file. 4 | Rails.application.config.filter_parameters += [:password] 5 | -------------------------------------------------------------------------------- /example/config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new inflection rules using the following format. Inflections 4 | # are locale specific, and you may define rules for as many different 5 | # locales as you wish. All of these examples are active by default: 6 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 7 | # inflect.plural /^(ox)$/i, '\1en' 8 | # inflect.singular /^(ox)en/i, '\1' 9 | # inflect.irregular 'person', 'people' 10 | # inflect.uncountable %w( fish sheep ) 11 | # end 12 | 13 | # These inflection rules are supported but not enabled by default: 14 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 15 | # inflect.acronym 'RESTful' 16 | # end 17 | -------------------------------------------------------------------------------- /example/config/initializers/mime_types.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new mime types for use in respond_to blocks: 4 | # Mime::Type.register "text/richtext", :rtf 5 | -------------------------------------------------------------------------------- /example/config/initializers/wrap_parameters.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # This file contains settings for ActionController::ParamsWrapper which 4 | # is enabled by default. 5 | 6 | # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array. 7 | ActiveSupport.on_load(:action_controller) do 8 | wrap_parameters format: [:json] 9 | end 10 | 11 | # To enable root element in JSON for ActiveRecord objects. 12 | # ActiveSupport.on_load(:active_record) do 13 | # self.include_root_in_json = true 14 | # end 15 | -------------------------------------------------------------------------------- /example/config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization 2 | # and are automatically loaded by Rails. If you want to use locales other 3 | # than English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t 'hello' 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t('hello') %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # The following keys must be escaped otherwise they will not be retrieved by 20 | # the default I18n backend: 21 | # 22 | # true, false, on, off, yes, no 23 | # 24 | # Instead, surround them with single quotes. 25 | # 26 | # en: 27 | # 'true': 'foo' 28 | # 29 | # To learn more, please read the Rails Internationalization guide 30 | # available at http://guides.rubyonrails.org/i18n.html. 31 | 32 | en: 33 | hello: "Hello world" 34 | -------------------------------------------------------------------------------- /example/config/master.key: -------------------------------------------------------------------------------- 1 | 73dcf50a57b1c189359fe92155d72915 -------------------------------------------------------------------------------- /example/config/puma.rb: -------------------------------------------------------------------------------- 1 | # Puma can serve each request in a thread from an internal thread pool. 2 | # The `threads` method setting takes two numbers: a minimum and maximum. 3 | # Any libraries that use thread pools should be configured to match 4 | # the maximum value specified for Puma. Default is set to 5 threads for minimum 5 | # and maximum; this matches the default thread size of Active Record. 6 | # 7 | threads_count = ENV.fetch('RAILS_MAX_THREADS') { 5 } 8 | threads threads_count, threads_count 9 | 10 | # Specifies the `port` that Puma will listen on to receive requests; default is 3000. 11 | # 12 | port ENV.fetch('PORT') { 3000 } 13 | 14 | # Specifies the `environment` that Puma will run in. 15 | # 16 | environment ENV.fetch('RAILS_ENV') { 'development' } 17 | 18 | # Specifies the number of `workers` to boot in clustered mode. 19 | # Workers are forked webserver processes. If using threads and workers together 20 | # the concurrency of the application would be max `threads` * `workers`. 21 | # Workers do not work on JRuby or Windows (both of which do not support 22 | # processes). 23 | # 24 | # workers ENV.fetch("WEB_CONCURRENCY") { 2 } 25 | 26 | # Use the `preload_app!` method when specifying a `workers` number. 27 | # This directive tells Puma to first boot the application and load code 28 | # before forking the application. This takes advantage of Copy On Write 29 | # process behavior so workers use less memory. 30 | # 31 | # preload_app! 32 | 33 | # Allow puma to be restarted by `rails restart` command. 34 | plugin :tmp_restart 35 | -------------------------------------------------------------------------------- /example/config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | # For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html 3 | end 4 | -------------------------------------------------------------------------------- /example/config/spring.rb: -------------------------------------------------------------------------------- 1 | %w[ 2 | .ruby-version 3 | .rbenv-vars 4 | tmp/restart.txt 5 | tmp/caching-dev.txt 6 | ].each { |path| Spring.watch(path) } 7 | -------------------------------------------------------------------------------- /example/config/storage.yml: -------------------------------------------------------------------------------- 1 | test: 2 | service: Disk 3 | root: <%= Rails.root.join("tmp/storage") %> 4 | 5 | local: 6 | service: Disk 7 | root: <%= Rails.root.join("storage") %> 8 | 9 | # Use rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key) 10 | # amazon: 11 | # service: S3 12 | # access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> 13 | # secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> 14 | # region: us-east-1 15 | # bucket: your_own_bucket 16 | 17 | # Remember not to checkin your GCS keyfile to a repository 18 | # google: 19 | # service: GCS 20 | # project: your_project 21 | # credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> 22 | # bucket: your_own_bucket 23 | 24 | # Use rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) 25 | # microsoft: 26 | # service: AzureStorage 27 | # storage_account_name: your_account_name 28 | # storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %> 29 | # container: your_container_name 30 | 31 | # mirror: 32 | # service: Mirror 33 | # primary: local 34 | # mirrors: [ amazon, google, microsoft ] 35 | -------------------------------------------------------------------------------- /example/db/migrate/20181129192033_create_companies.rb: -------------------------------------------------------------------------------- 1 | class CreateCompanies < ActiveRecord::Migration[5.2] 2 | def change 3 | create_table :companies do |t| 4 | t.string :email 5 | t.timestamps 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /example/db/migrate/20181129192038_create_countries.rb: -------------------------------------------------------------------------------- 1 | class CreateCountries < ActiveRecord::Migration[5.2] 2 | def change 3 | create_table :countries do |t| # rubocop:disable Style/SymbolProc 4 | t.timestamps 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /example/db/migrate/20181129192039_create_users.rb: -------------------------------------------------------------------------------- 1 | class CreateUsers < ActiveRecord::Migration[5.2] 2 | def change 3 | create_table :users do |t| 4 | t.string :email 5 | t.string :full_name 6 | t.belongs_to :company, foreign_key: true 7 | t.belongs_to :country, foreign_key: true 8 | t.index :email, unique: true 9 | t.index :full_name, unique: true 10 | t.timestamps 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /example/db/schema.rb: -------------------------------------------------------------------------------- 1 | # This file is auto-generated from the current state of the database. Instead 2 | # of editing this file, please use the migrations feature of Active Record to 3 | # incrementally modify your database, and then regenerate this schema definition. 4 | # 5 | # Note that this schema.rb definition is the authoritative source for your 6 | # database schema. If you need to create the application database on another 7 | # system, you should be using db:schema:load, not running all the migrations 8 | # from scratch. The latter is a flawed and unsustainable approach (the more migrations 9 | # you'll amass, the slower it'll run and the greater likelihood for issues). 10 | # 11 | # It's strongly recommended that you check this file into your version control system. 12 | 13 | ActiveRecord::Schema.define(version: 2018_11_29_192039) do 14 | 15 | # These are extensions that must be enabled in order to support this database 16 | enable_extension "plpgsql" 17 | 18 | create_table "companies", force: :cascade do |t| 19 | t.string "email" 20 | t.datetime "created_at", null: false 21 | t.datetime "updated_at", null: false 22 | end 23 | 24 | create_table "countries", force: :cascade do |t| 25 | t.datetime "created_at", null: false 26 | t.datetime "updated_at", null: false 27 | end 28 | 29 | create_table "users", force: :cascade do |t| 30 | t.string "email" 31 | t.string "full_name" 32 | t.bigint "company_id" 33 | t.bigint "country_id" 34 | t.datetime "created_at", null: false 35 | t.datetime "updated_at", null: false 36 | t.index ["company_id"], name: "index_users_on_company_id" 37 | t.index ["country_id"], name: "index_users_on_country_id" 38 | t.index ["email"], name: "index_users_on_email", unique: true 39 | t.index ["full_name"], name: "index_users_on_full_name", unique: true 40 | end 41 | 42 | add_foreign_key "users", "companies" 43 | add_foreign_key "users", "countries" 44 | end 45 | -------------------------------------------------------------------------------- /example/db/seeds.rb: -------------------------------------------------------------------------------- 1 | # This file should contain all the record creation needed to seed the database with its default values. 2 | # The data can then be loaded with the rails db:seed command (or created alongside the database with db:setup). 3 | # 4 | # Examples: 5 | # 6 | # movies = Movie.create([{ name: 'Star Wars' }, { name: 'Lord of the Rings' }]) 7 | # Character.create(name: 'Luke', movie: movies.first) 8 | -------------------------------------------------------------------------------- /example/lib/assets/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/toptal/database_validations/33e196d2a86ba141a4e5b97b0278a048afff3a33/example/lib/assets/.keep -------------------------------------------------------------------------------- /example/lib/tasks/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/toptal/database_validations/33e196d2a86ba141a4e5b97b0278a048afff3a33/example/lib/tasks/.keep -------------------------------------------------------------------------------- /example/log/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/toptal/database_validations/33e196d2a86ba141a4e5b97b0278a048afff3a33/example/log/.keep -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "private": true, 4 | "dependencies": {} 5 | } 6 | -------------------------------------------------------------------------------- /example/public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The page you were looking for doesn't exist (404) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The page you were looking for doesn't exist.

62 |

You may have mistyped the address or the page may have moved.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /example/public/422.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The change you wanted was rejected (422) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The change you wanted was rejected.

62 |

Maybe you tried to change something you didn't have access to.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /example/public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | We're sorry, but something went wrong (500) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

We're sorry, but something went wrong.

62 |
63 |

If you are the application owner check the logs for more information.

64 |
65 | 66 | 67 | -------------------------------------------------------------------------------- /example/public/apple-touch-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/toptal/database_validations/33e196d2a86ba141a4e5b97b0278a048afff3a33/example/public/apple-touch-icon-precomposed.png -------------------------------------------------------------------------------- /example/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/toptal/database_validations/33e196d2a86ba141a4e5b97b0278a048afff3a33/example/public/apple-touch-icon.png -------------------------------------------------------------------------------- /example/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/toptal/database_validations/33e196d2a86ba141a4e5b97b0278a048afff3a33/example/public/favicon.ico -------------------------------------------------------------------------------- /example/public/robots.txt: -------------------------------------------------------------------------------- 1 | # See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | -------------------------------------------------------------------------------- /example/storage/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/toptal/database_validations/33e196d2a86ba141a4e5b97b0278a048afff3a33/example/storage/.keep -------------------------------------------------------------------------------- /example/vendor/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/toptal/database_validations/33e196d2a86ba141a4e5b97b0278a048afff3a33/example/vendor/.keep -------------------------------------------------------------------------------- /gemfiles/rails42.gemfile: -------------------------------------------------------------------------------- 1 | gem 'activerecord', '~> 4.2' 2 | gem 'pg', '< 1' 3 | gem 'mysql2', '>= 0.3.13', '< 0.6.0' 4 | gem 'sqlite3', '< 1.4' 5 | -------------------------------------------------------------------------------- /gemfiles/rails52.gemfile: -------------------------------------------------------------------------------- 1 | gem 'activerecord', '~> 5.2.0' 2 | -------------------------------------------------------------------------------- /gemfiles/rails61.gemfile: -------------------------------------------------------------------------------- 1 | gem 'activerecord', '>= 6.1' 2 | -------------------------------------------------------------------------------- /gemfiles/railsmaster.gemfile: -------------------------------------------------------------------------------- 1 | gem 'activerecord', git: 'https://github.com/rails/rails', branch: 'main' 2 | -------------------------------------------------------------------------------- /lib/database_validations.rb: -------------------------------------------------------------------------------- 1 | require 'active_record' 2 | 3 | require 'database_validations/version' 4 | 5 | require 'database_validations/rails/railtie' if defined?(Rails) 6 | 7 | require 'database_validations/lib/checkers/db_uniqueness_validator' 8 | require 'database_validations/lib/checkers/db_presence_validator' 9 | 10 | require 'database_validations/lib/validators/db_uniqueness_validator' 11 | require 'database_validations/lib/validators/db_presence_validator' 12 | 13 | require 'database_validations/lib/storage' 14 | require 'database_validations/lib/attribute_validator' 15 | require 'database_validations/lib/key_generator' 16 | require 'database_validations/lib/uniqueness_key_extractor' 17 | require 'database_validations/lib/presence_key_extractor' 18 | require 'database_validations/lib/validations' 19 | require 'database_validations/lib/errors' 20 | require 'database_validations/lib/rescuer' 21 | require 'database_validations/lib/injector' 22 | require 'database_validations/lib/adapters' 23 | 24 | module DatabaseValidations 25 | extend ActiveSupport::Concern 26 | end 27 | 28 | ActiveRecord::Base.include(DatabaseValidations) if defined?(ActiveRecord::Base) 29 | -------------------------------------------------------------------------------- /lib/database_validations/lib/adapters.rb: -------------------------------------------------------------------------------- 1 | require 'database_validations/lib/adapters/base_adapter' 2 | require 'database_validations/lib/adapters/sqlite_adapter' 3 | require 'database_validations/lib/adapters/postgresql_adapter' 4 | require 'database_validations/lib/adapters/mysql_adapter' 5 | 6 | module DatabaseValidations 7 | module Adapters 8 | module_function 9 | 10 | def factory(model) 11 | database = if ActiveRecord.version < Gem::Version.new('6.1.0') 12 | model.connection_config[:adapter].downcase.to_sym 13 | else 14 | model.connection_db_config.adapter.downcase.to_sym 15 | end 16 | 17 | case database 18 | when SqliteAdapter::ADAPTER then SqliteAdapter 19 | when PostgresqlAdapter::ADAPTER then PostgresqlAdapter 20 | when MysqlAdapter::ADAPTER then MysqlAdapter 21 | else 22 | raise Errors::UnknownDatabase, database 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/database_validations/lib/adapters/base_adapter.rb: -------------------------------------------------------------------------------- 1 | module DatabaseValidations 2 | module Adapters 3 | class BaseAdapter 4 | SUPPORTED_OPTIONS = [].freeze 5 | ADAPTER = :base 6 | 7 | def initialize(model) 8 | @model = model 9 | end 10 | 11 | # @param [String] index_name 12 | def find_unique_index_by_name(index_name) 13 | unique_indexes.find { |index| index.name == index_name } 14 | end 15 | 16 | # @param [Array] columns 17 | # @param [String] where 18 | def find_unique_index(columns, where) 19 | unique_indexes.find { |index| Array.wrap(index.columns).map(&:to_s).sort == columns && index.where == where } 20 | end 21 | 22 | def unique_indexes 23 | connection = model.connection 24 | 25 | if connection.schema_cache.respond_to?(:indexes) 26 | # Rails 6 only 27 | connection.schema_cache.indexes(model.table_name).select(&:unique) 28 | else 29 | connection.indexes(model.table_name).select(&:unique) 30 | end 31 | end 32 | 33 | def foreign_keys 34 | model.connection.foreign_keys(model.table_name) 35 | end 36 | 37 | def find_foreign_key_by_column(column) 38 | foreign_keys.find { |foreign_key| foreign_key.column.to_s == column.to_s } 39 | end 40 | 41 | # @return [String] 42 | def table_name 43 | model.table_name 44 | end 45 | 46 | private 47 | 48 | attr_reader :model 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/database_validations/lib/adapters/mysql_adapter.rb: -------------------------------------------------------------------------------- 1 | module DatabaseValidations 2 | module Adapters 3 | class MysqlAdapter < BaseAdapter 4 | ADAPTER = :mysql2 5 | 6 | class << self 7 | def unique_index_name(error_message) 8 | error_message[/key '([^']+)'/, 1]&.split('.')&.last 9 | end 10 | 11 | def unique_error_columns(_error_message); end 12 | 13 | def foreign_key_error_column(error_message) 14 | error_message[/FOREIGN KEY \(`([^`]+)`\)/, 1] 15 | end 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/database_validations/lib/adapters/postgresql_adapter.rb: -------------------------------------------------------------------------------- 1 | module DatabaseValidations 2 | module Adapters 3 | class PostgresqlAdapter < BaseAdapter 4 | ADAPTER = :postgresql 5 | 6 | class << self 7 | def unique_index_name(error_message) 8 | error_message[/unique constraint "([^"]+)"/, 1] 9 | end 10 | 11 | def unique_error_columns(error_message) 12 | error_message[/Key \((.+)\)=/, 1].split(', ') 13 | end 14 | 15 | def foreign_key_error_column(error_message) 16 | error_message[/Key \(([^)]+)\)/, 1] 17 | end 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/database_validations/lib/adapters/sqlite_adapter.rb: -------------------------------------------------------------------------------- 1 | module DatabaseValidations 2 | module Adapters 3 | class SqliteAdapter < BaseAdapter 4 | ADAPTER = :sqlite3 5 | 6 | class << self 7 | def unique_index_name(_error_message); end 8 | 9 | def unique_error_columns(error_message) 10 | error_message.scan(/\w+\.([^,:]+)/).flatten 11 | end 12 | 13 | def foreign_key_error_column(error_message) 14 | error_message[/\("([^"]+)"\) VALUES/, 1] 15 | end 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/database_validations/lib/attribute_validator.rb: -------------------------------------------------------------------------------- 1 | module DatabaseValidations 2 | AttributeValidator = Struct.new(:attribute, :validator) 3 | end 4 | -------------------------------------------------------------------------------- /lib/database_validations/lib/checkers/db_presence_validator.rb: -------------------------------------------------------------------------------- 1 | module DatabaseValidations 2 | module Checkers 3 | class DbPresenceValidator 4 | attr_reader :validator 5 | 6 | # @param [DatabaseValidations::DbPresenceValidator] 7 | def self.validate!(validator) 8 | new(validator).validate! 9 | end 10 | 11 | # @param [DatabaseValidations::DbPresenceValidator] 12 | def initialize(validator) 13 | @validator = validator 14 | end 15 | 16 | def validate! 17 | return if ENV['SKIP_DB_UNIQUENESS_VALIDATOR_INDEX_CHECK'] 18 | 19 | validate_foreign_keys! unless validator.klass.abstract_class? 20 | end 21 | 22 | private 23 | 24 | def validate_foreign_keys!(adapter = Adapters::BaseAdapter.new(validator.klass)) 25 | validator.attributes.each do |attribute| 26 | reflection = validator.klass._reflect_on_association(attribute) 27 | 28 | next unless reflection 29 | next if adapter.find_foreign_key_by_column(reflection.foreign_key) 30 | 31 | raise Errors::ForeignKeyNotFound.new(reflection.foreign_key, adapter.foreign_keys) 32 | end 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/database_validations/lib/checkers/db_uniqueness_validator.rb: -------------------------------------------------------------------------------- 1 | module DatabaseValidations 2 | module Checkers 3 | class DbUniquenessValidator 4 | attr_reader :validator 5 | 6 | # @param [DatabaseValidations::DbUniquenessValidator] 7 | def self.validate!(validator) 8 | new(validator).validate! 9 | end 10 | 11 | # @param [DatabaseValidations::DbUniquenessValidator] 12 | def initialize(validator) 13 | @validator = validator 14 | end 15 | 16 | def validate! 17 | validate_index_usage! 18 | 19 | return if ENV['SKIP_DB_UNIQUENESS_VALIDATOR_INDEX_CHECK'] 20 | 21 | validate_indexes!(validator.klass) unless validator.klass.abstract_class? 22 | end 23 | 24 | private 25 | 26 | def valid_index?(columns, index) 27 | index_columns_size = index.columns.is_a?(Array) ? index.columns.size : (index.columns.count(',') + 1) 28 | 29 | (columns.size == index_columns_size) && (validator.where.nil? == index.where.nil?) 30 | end 31 | 32 | def validate_index_usage! 33 | return unless validator.index_name.present? && validator.attributes.size > 1 34 | 35 | raise ArgumentError, "When index_name is provided validator can have only one attribute. See #{validator.inspect}" 36 | end 37 | 38 | def validate_indexes!(klass) # rubocop:disable Metrics/AbcSize 39 | adapter = Adapters::BaseAdapter.new(klass) 40 | 41 | validator.attributes.map do |attribute| 42 | columns = KeyGenerator.unify_columns(attribute, validator.options[:scope]) 43 | index = validator.index_name ? adapter.find_unique_index_by_name(validator.index_name.to_s) : adapter.find_unique_index(columns, validator.where) # rubocop:disable Metrics/LineLength 44 | raise Errors::IndexNotFound.new(columns, validator.where, validator.index_name, adapter.unique_indexes, adapter.table_name) unless index && valid_index?(columns, index) # rubocop:disable Metrics/LineLength 45 | end 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/database_validations/lib/errors.rb: -------------------------------------------------------------------------------- 1 | module DatabaseValidations 2 | module Errors 3 | class Base < StandardError 4 | def env_message 5 | "Use ENV['SKIP_DB_UNIQUENESS_VALIDATOR_INDEX_CHECK']=true in case you want to skip the check. For example, when you run migrations." 6 | end 7 | end 8 | 9 | class IndexNotFound < Base 10 | attr_reader :columns, :where_clause, :index_name, :available_indexes, :table_name 11 | 12 | def initialize(columns, where_clause, index_name, available_indexes, table_name) 13 | @columns = columns 14 | @where_clause = where_clause 15 | @available_indexes = available_indexes 16 | @index_name = index_name 17 | 18 | text = if index_name 19 | "No unique index found with name: \"#{index_name}\" in table \"#{table_name}\". "\ 20 | "Available indexes are: #{self.available_indexes.map(&:name)}. " 21 | else 22 | available_indexes = self.available_indexes.map { |ind| columns_and_where_text(ind.columns, ind.where) }.join(', ') 23 | "No unique index found with #{columns_and_where_text(columns, where_clause)} in table \"#{table_name}\". "\ 24 | "Available indexes are: [#{available_indexes}]. " 25 | end 26 | 27 | super text + env_message 28 | end 29 | 30 | def columns_and_where_text(columns, where) 31 | "columns: #{columns}#{" and where: #{where}" if where}" 32 | end 33 | end 34 | 35 | class UnknownDatabase < Base 36 | attr_reader :database 37 | 38 | def initialize(database) 39 | @database = database 40 | super "Unknown database: #{self.database}" 41 | end 42 | end 43 | 44 | class ForeignKeyNotFound < Base 45 | attr_reader :column, :foreign_keys 46 | 47 | def initialize(column, foreign_keys) 48 | @column = column 49 | @foreign_keys = foreign_keys 50 | 51 | super "No foreign key found with column: \"#{column}\". Found foreign keys are: #{foreign_keys}. " + env_message 52 | end 53 | end 54 | 55 | class UnsupportedDatabase < Base 56 | attr_reader :database, :method 57 | 58 | def initialize(method, database) 59 | @database = database 60 | @method = method 61 | super "Database #{database} doesn't support #{method}" 62 | end 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/database_validations/lib/injector.rb: -------------------------------------------------------------------------------- 1 | module DatabaseValidations 2 | module Injector 3 | module_function 4 | 5 | # @param [ActiveRecord::Base] model 6 | def inject(model) 7 | return if model.method_defined?(:valid_without_database_validations?) 8 | 9 | model.__send__(:alias_method, :valid_without_database_validations?, :valid?) 10 | model.include(DatabaseValidations::Validations) 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/database_validations/lib/key_generator.rb: -------------------------------------------------------------------------------- 1 | module DatabaseValidations 2 | module KeyGenerator 3 | module_function 4 | 5 | # @param [String] index_name 6 | # 7 | # @return [String] 8 | def for_unique_index(index_name) 9 | generate_key(:unique_index, index_name) 10 | end 11 | 12 | # @return [String] 13 | def for_db_uniqueness(*columns) 14 | generate_key(:db_uniqueness, columns) 15 | end 16 | 17 | # @return [String] 18 | def for_db_presence(column) 19 | generate_key(:db_presence, column) 20 | end 21 | 22 | # @return [String] 23 | def generate_key(type, *args) 24 | [type, *unify_columns(args)].join('__') 25 | end 26 | 27 | # @return [String] 28 | def unify_columns(*args) 29 | args.flatten.compact.map(&:to_s).sort 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/database_validations/lib/presence_key_extractor.rb: -------------------------------------------------------------------------------- 1 | module DatabaseValidations 2 | module PresenceKeyExtractor 3 | module_function 4 | 5 | # @param [DatabaseValidations::DbPresenceValidator] 6 | # 7 | # @return [Hash] 8 | def attribute_by_key(validator) 9 | validator.attributes.map do |attribute| 10 | reflection = validator.klass._reflect_on_association(attribute) 11 | 12 | key = reflection ? reflection.foreign_key : attribute 13 | 14 | [KeyGenerator.for_db_presence(key), attribute] 15 | end.to_h 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/database_validations/lib/rescuer.rb: -------------------------------------------------------------------------------- 1 | module DatabaseValidations 2 | module Rescuer 3 | module_function 4 | 5 | def handled?(instance, error, validate) 6 | Storage.prepare(instance.class) unless Storage.prepared?(instance.class) 7 | 8 | case error 9 | when ActiveRecord::RecordNotUnique 10 | process(validate, instance, error, for_unique_index: :unique_index_name, for_db_uniqueness: :unique_error_columns) 11 | when ActiveRecord::InvalidForeignKey 12 | process(validate, instance, error, for_db_presence: :foreign_key_error_column) 13 | else false 14 | end 15 | end 16 | 17 | def process(validate, instance, error, key_types) 18 | adapter = Adapters.factory(instance.class) 19 | 20 | keys = key_types.map do |key_generator, error_processor| 21 | KeyGenerator.public_send(key_generator, adapter.public_send(error_processor, error.message)) 22 | end 23 | 24 | keys.each do |key| 25 | attribute_validator = instance._db_validators[key] 26 | 27 | next unless attribute_validator 28 | 29 | return process_validator(validate, instance, attribute_validator) 30 | end 31 | 32 | false 33 | end 34 | 35 | def process_validator(validate, instance, attribute_validator) 36 | return false unless attribute_validator.validator.perform_rescue?(validate) 37 | 38 | attribute_validator.validator.apply_error(instance, attribute_validator.attribute) 39 | true 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/database_validations/lib/storage.rb: -------------------------------------------------------------------------------- 1 | module DatabaseValidations 2 | module Storage 3 | module_function 4 | 5 | def prepare(model) 6 | model.class_attribute :_db_validators, instance_writer: false 7 | model._db_validators = {} 8 | 9 | model.validators.each do |validator| 10 | case validator 11 | when DbUniquenessValidator then process(validator, UniquenessKeyExtractor, model) 12 | when DbPresenceValidator then process(validator, PresenceKeyExtractor, model) 13 | else next 14 | end 15 | end 16 | end 17 | 18 | def process(validator, extractor, model) 19 | extractor.attribute_by_key(validator).each do |key, attribute| 20 | model._db_validators[key] = AttributeValidator.new(attribute, validator) 21 | end 22 | end 23 | 24 | def prepared?(model) 25 | model.respond_to?(:_db_validators) 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/database_validations/lib/uniqueness_key_extractor.rb: -------------------------------------------------------------------------------- 1 | module DatabaseValidations 2 | module UniquenessKeyExtractor 3 | module_function 4 | 5 | # @param [DatabaseValidations::DbUniquenessValidator] 6 | # 7 | # @return [Hash] 8 | def attribute_by_columns_keys(validator) 9 | validator.attributes.map do |attribute| 10 | [KeyGenerator.for_db_uniqueness(attribute, Array.wrap(validator.options[:scope])), attribute] 11 | end.to_h 12 | end 13 | 14 | # @param [DatabaseValidations::DbUniquenessValidator] 15 | # 16 | # @return [Hash] 17 | def attribute_by_indexes_keys(validator) # rubocop:disable Metrics/AbcSize 18 | adapter = Adapters::BaseAdapter.new(validator.klass) 19 | 20 | if validator.index_name 21 | [[KeyGenerator.for_unique_index(validator.index_name), validator.attributes[0]]].to_h 22 | else 23 | validator.attributes.map do |attribute| 24 | columns = KeyGenerator.unify_columns(attribute, validator.options[:scope]) 25 | index = adapter.find_unique_index(columns, validator.where) 26 | [KeyGenerator.for_unique_index(index.name), attribute] 27 | end.to_h 28 | end 29 | end 30 | 31 | # @param [DatabaseValidations::DbUniquenessValidator] 32 | # 33 | # @return [Hash] 34 | def attribute_by_key(validator) 35 | attribute_by_columns_keys(validator).merge(attribute_by_indexes_keys(validator)) 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/database_validations/lib/validations.rb: -------------------------------------------------------------------------------- 1 | module DatabaseValidations 2 | module Validations 3 | extend ActiveSupport::Concern 4 | 5 | included do 6 | alias_method :validate, :valid? 7 | end 8 | 9 | attr_accessor :_database_validations_fallback 10 | 11 | def valid?(context = nil) 12 | self._database_validations_fallback = true 13 | super(context) 14 | end 15 | 16 | def create_or_update(*args, &block) 17 | options = args.extract_options! 18 | rescue_from_database_exceptions(options[:validate]) { super } 19 | end 20 | 21 | private 22 | 23 | def rescue_from_database_exceptions(validate, &block) 24 | self._database_validations_fallback = false 25 | self.class.connection.transaction(requires_new: true, &block) 26 | rescue ActiveRecord::InvalidForeignKey, ActiveRecord::RecordNotUnique => e 27 | raise e unless Rescuer.handled?(self, e, validate) 28 | 29 | raise ActiveRecord::RecordInvalid, self 30 | end 31 | 32 | def perform_validations(options = {}) 33 | options[:validate] == false || valid_without_database_validations?(options[:context]) 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/database_validations/lib/validators/db_presence_validator.rb: -------------------------------------------------------------------------------- 1 | module DatabaseValidations 2 | class DbPresenceValidator < ActiveRecord::Validations::PresenceValidator 3 | REFLECTION_MESSAGE = ActiveRecord::VERSION::MAJOR < 5 ? :blank : :required 4 | 5 | attr_reader :klass 6 | 7 | # Used to make 3rd party libraries work correctly 8 | # 9 | # @return [Symbol] 10 | def self.kind 11 | :presence 12 | end 13 | 14 | # @param [Hash] options 15 | def initialize(options) 16 | @klass = options[:class] 17 | 18 | super 19 | 20 | Injector.inject(klass) 21 | Checkers::DbPresenceValidator.validate!(self) 22 | end 23 | 24 | def perform_rescue?(validate) 25 | validate != false 26 | end 27 | 28 | # TODO: add support of optional db_belongs_to 29 | def validate(record) 30 | if record._database_validations_fallback 31 | super 32 | else 33 | attributes.each do |attribute| 34 | reflection = record.class._reflect_on_association(attribute) 35 | 36 | next if reflection && record.public_send(reflection.foreign_key).present? 37 | 38 | validate_each(record, attribute, record.public_send(attribute)) 39 | end 40 | end 41 | end 42 | 43 | def apply_error(instance, attribute) 44 | # Helps to avoid querying the database when attribute is association 45 | instance.send("#{attribute}=", nil) 46 | instance.errors.add(attribute, :blank, message: REFLECTION_MESSAGE) 47 | end 48 | end 49 | 50 | module ClassMethods 51 | def validates_db_presence_of(*attr_names) 52 | validates_with(DatabaseValidations::DbPresenceValidator, _merge_attributes(attr_names)) 53 | end 54 | 55 | def db_belongs_to(name, scope = nil, **options) 56 | if ActiveRecord::VERSION::MAJOR < 5 57 | options[:required] = false 58 | else 59 | options[:optional] = true 60 | end 61 | 62 | belongs_to(name, scope, **options) 63 | 64 | validates_with DatabaseValidations::DbPresenceValidator, _merge_attributes([name, message: DatabaseValidations::DbPresenceValidator::REFLECTION_MESSAGE]) # rubocop:disable Metrics/LineLength 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /lib/database_validations/lib/validators/db_uniqueness_validator.rb: -------------------------------------------------------------------------------- 1 | module DatabaseValidations 2 | class DbUniquenessValidator < ActiveRecord::Validations::UniquenessValidator 3 | DEFAULT_MODE = :optimized 4 | 5 | attr_reader :index_name, :where, :klass 6 | 7 | # Used to make 3rd party libraries work correctly 8 | # 9 | # @return [Symbol] 10 | def self.kind 11 | :uniqueness 12 | end 13 | 14 | # @param [Hash] options 15 | def initialize(options) 16 | options[:allow_nil] = true 17 | options[:allow_blank] = false 18 | 19 | if options.key?(:where) 20 | condition = options[:where] 21 | options[:conditions] = -> { where(condition) } 22 | end 23 | 24 | handle_custom_options(options) 25 | 26 | super 27 | 28 | Injector.inject(klass) 29 | Checkers::DbUniquenessValidator.validate!(self) 30 | end 31 | 32 | def perform_rescue?(validate) 33 | (validate != false && @mode != :standard) || @rescue == :always 34 | end 35 | 36 | def validate(record) 37 | super if perform_query? || record._database_validations_fallback 38 | end 39 | 40 | def apply_error(instance, attribute) 41 | error_options = options.except(:case_sensitive, :scope, :conditions) 42 | error_options[:value] = instance.public_send(attribute) 43 | 44 | instance.errors.add(attribute, :taken, **error_options) 45 | end 46 | 47 | private 48 | 49 | def handle_custom_options(options) 50 | @index_name = options.delete(:index_name) if options.key?(:index_name) 51 | @where = options.delete(:where) if options.key?(:where) 52 | @mode = (options.delete(:mode).presence || DEFAULT_MODE).to_sym 53 | @rescue = (options.delete(:rescue).presence || :default).to_sym 54 | end 55 | 56 | def perform_query? 57 | @mode != :optimized 58 | end 59 | end 60 | 61 | module ClassMethods 62 | def validates_db_uniqueness_of(*attr_names) 63 | validates_with(DatabaseValidations::DbUniquenessValidator, _merge_attributes(attr_names)) 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /lib/database_validations/rails/railtie.rb: -------------------------------------------------------------------------------- 1 | require 'active_record/railtie' 2 | 3 | module DatabaseValidations 4 | class Railtie < Rails::Railtie 5 | rake_tasks do 6 | load 'database_validations/tasks/database_validations.rake' 7 | 8 | Rake.application.in_namespace(:db) do |namespace| 9 | namespace.tasks.each do |task| 10 | task.enhance %w[database_validations:skip_db_uniqueness_validator_index_check] 11 | end 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/database_validations/rspec/matchers.rb: -------------------------------------------------------------------------------- 1 | require 'database_validations/rspec/uniqueness_validator_matcher' 2 | -------------------------------------------------------------------------------- /lib/database_validations/rspec/uniqueness_validator_matcher.rb: -------------------------------------------------------------------------------- 1 | # @scope Models 2 | # 3 | # Matches when model or instance of model validates database uniqueness of some field. 4 | # 5 | # Modifiers: 6 | # * `with_message(message)` -- specifies a message of the error; 7 | # * `scoped_to(scope)` -- specifies a scope for the validator; 8 | # * `with_where(where)` -- specifies a where condition for the validator; 9 | # * `with_index(index_name)` -- specifies an index name for the validator; 10 | # * `case_insensitive` -- specifies case insensitivity for the validator; 11 | # 12 | # Example: 13 | # 14 | # ```ruby 15 | # it { expect(Model).to validate_db_uniqueness_of(:field) } 16 | # ``` 17 | # 18 | # is the same as 19 | # 20 | # ```ruby 21 | # it { expect(Model.new).to validate_db_uniqueness_of(:field) } 22 | # ``` 23 | RSpec::Matchers.define :validate_db_uniqueness_of do |field| # rubocop:disable Metrics/BlockLength 24 | chain(:with_message) do |message| 25 | @message = message 26 | end 27 | 28 | chain(:scoped_to) do |*scope| 29 | @scope = scope.flatten 30 | end 31 | 32 | chain(:with_where) do |where| 33 | @where = where 34 | end 35 | 36 | chain(:with_index) do |index_name| 37 | @index_name = index_name 38 | end 39 | 40 | chain(:case_insensitive) do 41 | @case_sensitive = false 42 | end 43 | 44 | chain(:case_sensitive) do 45 | @case_sensitive = true 46 | end 47 | 48 | match do |object| 49 | @validators = [] 50 | 51 | model = object.is_a?(Class) ? object : object.class 52 | 53 | model.validators.grep(DatabaseValidations::DbUniquenessValidator).each do |validator| 54 | validator.attributes.each do |attribute| 55 | @validators << { 56 | field: attribute, 57 | scope: Array.wrap(validator.options[:scope]), 58 | where: validator.where, 59 | message: validator.options[:message], 60 | index_name: validator.index_name, 61 | case_sensitive: validator.options[:case_sensitive] 62 | } 63 | end 64 | end 65 | 66 | case_sensitive_default = ActiveRecord::VERSION::MAJOR >= 6 ? nil : true 67 | 68 | @validators.include?( 69 | field: field, 70 | scope: Array.wrap(@scope), 71 | where: @where, 72 | message: @message, 73 | index_name: @index_name, 74 | case_sensitive: @case_sensitive.nil? ? case_sensitive_default : @case_sensitive 75 | ) 76 | end 77 | 78 | description do 79 | desc = "validate database uniqueness of #{field}. " 80 | desc += 'With options - ' if @message || @scope || @where 81 | desc += "message: '#{@message}'; " if @message 82 | desc += "scope: #{@scope}; " if @scope 83 | desc += "where: '#{@where}'; " if @where 84 | desc += "index_name: '#{@index_name}'; " if @index_name 85 | desc += 'be case insensitive.' unless @case_sensitive 86 | desc += 'be case sensitive.' if @case_sensitive 87 | desc 88 | end 89 | 90 | failure_message do 91 | <<-TEXT 92 | There is no such database uniqueness validator. 93 | Available validators are: #{@validators}. 94 | TEXT 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /lib/database_validations/rubocop/cop/belongs_to.rb: -------------------------------------------------------------------------------- 1 | module RuboCop 2 | module Cop 3 | module DatabaseValidations 4 | # Use `db_belongs`_to instead of `belongs_to`. 5 | # 6 | # @example 7 | # # bad 8 | # belongs_to :company 9 | # 10 | # # good 11 | # db_belongs_to :company 12 | # 13 | class BelongsTo < Cop 14 | MSG = 'Use `db_belongs_to`.'.freeze 15 | 16 | NON_SUPPORTED_OPTIONS = %i[ 17 | optional 18 | required 19 | polymorphic 20 | foreign_type 21 | ].freeze 22 | 23 | def_node_matcher :belongs_to?, '(send nil? :belongs_to ...)' 24 | def_node_matcher :option_name, '(pair (sym $_) ...)' 25 | 26 | def on_send(node) 27 | return unless belongs_to?(node) 28 | return unless supported?(node) 29 | 30 | add_offense(node, location: :selector) 31 | end 32 | 33 | private 34 | 35 | def supported?(node) 36 | options = node.arguments.last 37 | return true unless options.hash_type? 38 | 39 | options.each_child_node.none? do |option| 40 | NON_SUPPORTED_OPTIONS.include? option_name(option) 41 | end 42 | end 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/database_validations/rubocop/cop/uniqueness_of.rb: -------------------------------------------------------------------------------- 1 | module RuboCop 2 | module Cop 3 | module DatabaseValidations 4 | # Use `validates_db_uniqueness_of` for uniqueness validation. 5 | # 6 | # @example 7 | # # bad 8 | # validates :slug, uniqueness: true 9 | # validates :address, uniqueness: { scope: :user_id } 10 | # validates_uniqueness_of :title 11 | # 12 | # # good 13 | # validates_db_uniqueness_of :slug 14 | # validates_db_uniqueness_of :address, scope: :user_id 15 | # validates_db_uniqueness_of :title 16 | # 17 | class UniquenessOf < Cop 18 | MSG = 'Use `validates_db_uniqueness_of`.'.freeze 19 | 20 | def_node_matcher :uniquness_validation?, '(pair (sym :uniqueness) _)' 21 | 22 | def on_send(node) 23 | if node.method_name == :validates_uniqueness_of 24 | add_offense(node, location: :selector) 25 | elsif node.method_name == :validates 26 | uniqueness(node) do |option| 27 | add_offense(option) 28 | end 29 | end 30 | end 31 | 32 | private 33 | 34 | def uniqueness(node) 35 | options = node.last_argument 36 | return unless options.hash_type? 37 | 38 | options.each_child_node(:pair) do |pair| 39 | yield pair if uniquness_validation?(pair) 40 | end 41 | end 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/database_validations/rubocop/cops.rb: -------------------------------------------------------------------------------- 1 | require 'database_validations/rubocop/cop/belongs_to' 2 | require 'database_validations/rubocop/cop/uniqueness_of' 3 | -------------------------------------------------------------------------------- /lib/database_validations/tasks/database_validations.rake: -------------------------------------------------------------------------------- 1 | namespace :database_validations do 2 | task :skip_db_uniqueness_validator_index_check do 3 | ENV['SKIP_DB_UNIQUENESS_VALIDATOR_INDEX_CHECK'] = 'true' 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /lib/database_validations/version.rb: -------------------------------------------------------------------------------- 1 | module DatabaseValidations 2 | VERSION = '1.1.1'.freeze 3 | end 4 | -------------------------------------------------------------------------------- /spec/database_validations_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe DatabaseValidations do 2 | it 'has a version number' do 3 | expect(DatabaseValidations::VERSION).not_to be nil 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/matchers/uniqueness_validator_matcher_spec.rb: -------------------------------------------------------------------------------- 1 | require 'database_validations/rspec/matchers' 2 | 3 | RSpec.describe 'validate_db_uniqueness_of' do 4 | before do 5 | define_database(sqlite_configuration) 6 | 7 | define_table(:entities) do |t| 8 | t.string :field 9 | end 10 | 11 | allow(ENV).to receive(:[]).with('SKIP_DB_UNIQUENESS_VALIDATOR_INDEX_CHECK').and_return('true') 12 | end 13 | 14 | context 'when only field is provided' do 15 | subject { define_class { validates_db_uniqueness_of :field } } 16 | 17 | it { is_expected.to validate_db_uniqueness_of :field } 18 | it { is_expected.not_to validate_db_uniqueness_of :wrong } 19 | end 20 | 21 | context 'when message option is specified' do 22 | subject { define_class { validates_db_uniqueness_of :field, message: 'duplicated' } } 23 | 24 | it { is_expected.to validate_db_uniqueness_of(:field).with_message('duplicated') } 25 | it { is_expected.not_to validate_db_uniqueness_of(:field).with_message('wrong') } 26 | end 27 | 28 | context 'when scope option is specified' do 29 | subject { define_class { validates_db_uniqueness_of :field, scope: :another } } 30 | 31 | it { is_expected.to validate_db_uniqueness_of(:field).scoped_to(:another) } 32 | it { is_expected.not_to validate_db_uniqueness_of(:field).scoped_to(:wrong) } 33 | end 34 | 35 | context 'when where option is specified' do 36 | subject { define_class { validates_db_uniqueness_of :field, where: 'another IS NULL' } } 37 | 38 | before do 39 | allow_any_instance_of(DatabaseValidations::Adapters::BaseAdapter) # rubocop:disable RSpec/AnyInstance 40 | .to receive(:support_option?).and_return(true) 41 | end 42 | 43 | it { is_expected.to validate_db_uniqueness_of(:field).with_where('another IS NULL') } 44 | it { is_expected.not_to validate_db_uniqueness_of(:field).with_where('another IS NOT NULL') } 45 | end 46 | 47 | context 'when index_name option is specified' do 48 | subject { define_class { validates_db_uniqueness_of :field, index_name: :unique_index } } 49 | 50 | before do 51 | allow_any_instance_of(DatabaseValidations::Adapters::BaseAdapter) # rubocop:disable RSpec/AnyInstance 52 | .to receive(:support_option?).and_return(true) 53 | end 54 | 55 | it { is_expected.to validate_db_uniqueness_of(:field).with_index(:unique_index) } 56 | it { is_expected.not_to validate_db_uniqueness_of(:field).with_index(:another_index) } 57 | end 58 | 59 | context 'when case_sensitive option is false' do 60 | subject { define_class { validates_db_uniqueness_of :field, case_sensitive: false } } 61 | 62 | before do 63 | allow_any_instance_of(DatabaseValidations::Adapters::BaseAdapter) # rubocop:disable RSpec/AnyInstance 64 | .to receive(:support_option?).and_return(true) 65 | end 66 | 67 | it { is_expected.to validate_db_uniqueness_of(:field).case_insensitive } 68 | end 69 | 70 | context 'when case_sensitive option is true' do 71 | subject { define_class { validates_db_uniqueness_of :field, case_sensitive: true } } 72 | 73 | before do 74 | allow_any_instance_of(DatabaseValidations::Adapters::BaseAdapter) # rubocop:disable RSpec/AnyInstance 75 | .to receive(:support_option?).and_return(true) 76 | end 77 | 78 | it { is_expected.to validate_db_uniqueness_of(:field).case_sensitive } 79 | end 80 | 81 | context 'when instance of model is provided' do 82 | subject { define_class { validates_db_uniqueness_of :field }.new } 83 | 84 | it { is_expected.to validate_db_uniqueness_of(:field) } 85 | it { is_expected.not_to validate_db_uniqueness_of(:another_field) } 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /spec/rubocop/cop/belongs_to_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rubocop/spec_helper' 2 | 3 | RSpec.describe RuboCop::Cop::DatabaseValidations::BelongsTo do # rubocop:disable RSpec/FilePath 4 | subject(:cop) { described_class.new } 5 | 6 | it 'detects `belongs_to`: true``' do 7 | expect_offense(<<-RUBY) 8 | belongs_to :company 9 | ^^^^^^^^^^ Use `db_belongs_to`. 10 | RUBY 11 | end 12 | 13 | it 'detects `belongs_to` with an option' do 14 | expect_offense(<<-RUBY) 15 | belongs_to :company, touch: true 16 | ^^^^^^^^^^ Use `db_belongs_to`. 17 | RUBY 18 | end 19 | 20 | it 'ignores `belongs_to` with optional' do 21 | expect_no_offenses(<<-RUBY) 22 | belongs_to :company, optional: true 23 | RUBY 24 | end 25 | 26 | it 'ignores `belongs_to` with required' do 27 | expect_no_offenses(<<-RUBY) 28 | belongs_to :company, required: true 29 | RUBY 30 | end 31 | 32 | it 'ignores `belongs_to` with polymorphic' do 33 | expect_no_offenses(<<-RUBY) 34 | belongs_to :company, polymorphic: true 35 | RUBY 36 | end 37 | 38 | it 'ignores `belongs_to` with foreign_type' do 39 | expect_no_offenses(<<-RUBY) 40 | belongs_to :role, foreign_type: User 41 | RUBY 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /spec/rubocop/cop/uniqueness_of_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rubocop/spec_helper' 2 | 3 | RSpec.describe RuboCop::Cop::DatabaseValidations::UniquenessOf do # rubocop:disable RSpec/FilePath 4 | subject(:cop) { described_class.new } 5 | 6 | it 'detects `uniqueness: true`' do 7 | expect_offense(<<-RUBY) 8 | validates :slug, uniqueness: true 9 | ^^^^^^^^^^^^^^^^ Use `validates_db_uniqueness_of`. 10 | RUBY 11 | end 12 | 13 | it 'detects `uniqueness` on multiple fields' do 14 | expect_offense(<<-RUBY) 15 | validates :code, :name, uniqueness: true 16 | ^^^^^^^^^^^^^^^^ Use `validates_db_uniqueness_of`. 17 | RUBY 18 | end 19 | 20 | it 'detects conditional uniqeuness valudation' do 21 | expect_offense(<<-RUBY) 22 | validates :main, uniqueness: {scope: :client_id}, if: -> { main? && main_changed? } 23 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Use `validates_db_uniqueness_of`. 24 | RUBY 25 | end 26 | 27 | it 'detects `validates_uniqueness_of`' do 28 | expect_offense(<<-RUBY) 29 | validates_uniqueness_of :title, :slug 30 | ^^^^^^^^^^^^^^^^^^^^^^^ Use `validates_db_uniqueness_of`. 31 | RUBY 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /spec/rubocop/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'rubocop' 2 | require 'rubocop/rspec/support' 3 | require 'database_validations/rubocop/cops' 4 | 5 | RSpec.shared_context 'with rubocop config', :rubocop_config do 6 | let(:config) { RuboCop::Config.new(described_class.cop_name => cop_config) } 7 | end 8 | 9 | RSpec.configure do |config| 10 | config.include RuboCop::RSpec::ExpectOffense 11 | end 12 | -------------------------------------------------------------------------------- /spec/shared/raise_index_not_found.rb: -------------------------------------------------------------------------------- 1 | RSpec::Matchers.define :raise_index_not_found do |message = nil| 2 | match do |actual| 3 | expect { actual.call }.to raise_error(DatabaseValidations::Errors::IndexNotFound, message_matcher(message)) 4 | end 5 | 6 | def message_matcher(message) 7 | text = ' '\ 8 | 'Use ENV[\'SKIP_DB_UNIQUENESS_VALIDATOR_INDEX_CHECK\']=true in case you want to skip the check. '\ 9 | 'For example, when you run migrations.'.freeze 10 | 11 | if message 12 | start_with(message) 13 | .and end_with(text) 14 | else 15 | end_with(text) 16 | end 17 | end 18 | 19 | supports_block_expectations 20 | end 21 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'bundler/setup' 2 | require 'database_validations' 3 | require 'shared/raise_index_not_found' 4 | require 'db-query-matchers' 5 | 6 | # Use this constant to enable Rails 5+ compatible specs 7 | RAILS_5 = ActiveRecord::VERSION::MAJOR >= 5 8 | 9 | DBQueryMatchers.configure do |config| 10 | config.schemaless = true 11 | end 12 | 13 | RSpec.configure do |config| 14 | # Enable flags like --only-failures and --next-failure 15 | config.example_status_persistence_file_path = '.rspec_status' 16 | 17 | # Disable RSpec exposing methods globally on `Module` and `main` 18 | config.disable_monkey_patching! 19 | 20 | config.expect_with :rspec do |c| 21 | c.syntax = :expect 22 | end 23 | end 24 | 25 | def clear_database!(configuration) 26 | ActiveRecord::Base.connection.execute 'SET FOREIGN_KEY_CHECKS=0;' if configuration[:adapter] == 'mysql2' 27 | ActiveRecord::Base.connection.tables.each do |table| 28 | ActiveRecord::Base.connection.drop_table(table, force: :cascade) 29 | end 30 | ActiveRecord::Base.connection.execute 'SET FOREIGN_KEY_CHECKS=1;' if configuration[:adapter] == 'mysql2' 31 | end 32 | 33 | def define_database(configuration) 34 | ActiveRecord::Base.establish_connection(configuration) 35 | ActiveRecord::Schema.verbose = false 36 | 37 | clear_database!(configuration) 38 | end 39 | 40 | def define_table(table_name = :entities, &block) 41 | ActiveRecord::Schema.define(version: 1) do 42 | create_table table_name do |t| 43 | block.call(t) 44 | end 45 | end 46 | end 47 | 48 | def define_class(parent = ActiveRecord::Base, table_name = :entities, &block) 49 | Class.new(parent) do |klass| 50 | self.table_name = table_name 51 | 52 | def self.model_name 53 | ActiveModel::Name.new(self, nil, 'temp') 54 | end 55 | 56 | reset_column_information 57 | 58 | klass.instance_exec(&block) if block_given? 59 | end 60 | end 61 | 62 | def rescue_error 63 | yield 64 | rescue ActiveRecord::RecordInvalid => e 65 | e.message 66 | end 67 | 68 | def postgresql_configuration 69 | { 70 | adapter: 'postgresql', 71 | database: 'database_validations_test', 72 | host: ENV['DB_HOST'] || '127.0.0.1', 73 | username: ENV['DB_USER'], 74 | password: ENV['DB_PASSWORD'] 75 | } 76 | end 77 | 78 | def mysql_configuration 79 | { 80 | adapter: 'mysql2', 81 | database: 'database_validations_test', 82 | host: ENV['DB_HOST'] || '127.0.0.1', 83 | username: ENV['DB_USER'], 84 | password: ENV['DB_PASSWORD'] 85 | } 86 | end 87 | 88 | def sqlite_configuration 89 | { 90 | adapter: 'sqlite3', 91 | database: ':memory:' 92 | } 93 | end 94 | -------------------------------------------------------------------------------- /spec/validations/db_belongs_to_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe 'db_belongs_to' do 2 | class Company < ActiveRecord::Base; end # rubocop:disable RSpec/LeakyConstantDeclaration 3 | class BelongsUser < ActiveRecord::Base; end # rubocop:disable RSpec/LeakyConstantDeclaration 4 | class DbBelongsUser < ActiveRecord::Base; end # rubocop:disable RSpec/LeakyConstantDeclaration 5 | 6 | let(:company_klass) { define_class(Company, :companies) } 7 | 8 | let(:belongs_to_user_klass) do 9 | define_class(BelongsUser, :belongs_users) do 10 | def name 11 | 'BelongsToUserTemp' 12 | end 13 | 14 | if RAILS_5 15 | belongs_to :company, optional: false 16 | else 17 | belongs_to :company, required: true 18 | end 19 | end 20 | end 21 | 22 | let(:belongs_to_user_with_fk_klass) do 23 | define_class(DbBelongsUser, :db_belongs_users) do 24 | def name 25 | 'BelongsToUserWithFKTemp' 26 | end 27 | 28 | if RAILS_5 29 | belongs_to :company, optional: false 30 | else 31 | belongs_to :company, required: true 32 | end 33 | end 34 | end 35 | 36 | let(:db_belongs_to_user_klass) do 37 | define_class(DbBelongsUser, :db_belongs_users) do 38 | def name 39 | 'DbBelongsToUserTemp' 40 | end 41 | 42 | db_belongs_to :company 43 | end 44 | end 45 | 46 | def define_tables 47 | ActiveRecord::Schema.define(version: 1) do 48 | create_table :companies 49 | 50 | create_table :belongs_users do |t| 51 | t.belongs_to :company 52 | end 53 | 54 | create_table :db_belongs_users do |t| 55 | t.belongs_to :company, foreign_key: true 56 | end 57 | end 58 | end 59 | 60 | shared_examples 'works as belongs_to' do 61 | shared_examples 'with company_id provided' do |method, field, company_id| 62 | context "#{method} on #{field} with #{company_id.inspect}" do 63 | specify do 64 | # Hack 65 | validate_db_queries = !(method == :valid? && [:existing_id, -1].include?(company_id)) 66 | 67 | company_id = company_klass.create.id if company_id == :existing_id 68 | company_id = company_klass.create if company_id == :existing_company 69 | company_id = company_klass.new if company_id == :built 70 | 71 | old = belongs_to_user_klass.new(field => company_id) 72 | new = db_belongs_to_user_klass.new(field => company_id) 73 | 74 | new_err = nil 75 | 76 | old_err = rescue_error { old.send(method) } 77 | 78 | if validate_db_queries 79 | expect { new_err = rescue_error { new.send(method) } }.not_to make_database_queries(matching: 'SELECT') 80 | else 81 | new_err = rescue_error { new.send(method) } 82 | end 83 | 84 | expect(new_err).to eq(old_err) 85 | expect(new.errors.messages).to eq(old.errors.messages) 86 | expect(new.persisted?).to eq(old.persisted?) 87 | end 88 | end 89 | end 90 | 91 | shared_examples 'pack' do |method| 92 | include_examples 'with company_id provided', method, :company_id, :existing_id 93 | include_examples 'with company_id provided', method, :company, :existing_company 94 | include_examples 'with company_id provided', method, :company_id, -1 95 | include_examples 'with company_id provided', method, :company_id, nil 96 | include_examples 'with company_id provided', method, :company, :built 97 | include_examples 'with company_id provided', method, :company, nil 98 | end 99 | 100 | describe 'valid?' do 101 | include_examples 'pack', :valid? 102 | end 103 | 104 | describe 'save' do 105 | include_examples 'pack', :save 106 | 107 | if RAILS_5 108 | it 'respects validate: false' do 109 | expect { belongs_to_user_with_fk_klass.new(company_id: -1).save(validate: false) } 110 | .to raise_error(ActiveRecord::InvalidForeignKey) 111 | expect { db_belongs_to_user_klass.new(company_id: -1).save(validate: false) } 112 | .to raise_error(ActiveRecord::InvalidForeignKey) 113 | end 114 | end 115 | end 116 | 117 | describe 'save!' do 118 | include_examples 'pack', :save! 119 | 120 | if RAILS_5 121 | it 'respects validate: false' do 122 | expect { belongs_to_user_with_fk_klass.new(company_id: -1).save!(validate: false) } 123 | .to raise_error(ActiveRecord::InvalidForeignKey) 124 | expect { db_belongs_to_user_klass.new(company_id: -1).save!(validate: false) } 125 | .to raise_error(ActiveRecord::InvalidForeignKey) 126 | end 127 | end 128 | end 129 | 130 | describe 'check foreign key' do 131 | context 'when SKIP_DB_UNIQUENESS_VALIDATOR_INDEX_CHECK is provided' do 132 | before { allow(ENV).to receive(:[]).with('SKIP_DB_UNIQUENESS_VALIDATOR_INDEX_CHECK').and_return('true') } 133 | 134 | it 'does not raise an error' do 135 | expect do 136 | Class.new(belongs_to_user_klass) { |klass| klass.db_belongs_to :company } 137 | end.not_to raise_error 138 | end 139 | end 140 | 141 | context 'when SKIP_DB_UNIQUENESS_VALIDATOR_INDEX_CHECK is missed' do 142 | it 'raise an error' do 143 | expect do 144 | Class.new(belongs_to_user_klass) { |klass| klass.db_belongs_to :company } 145 | end.to raise_error DatabaseValidations::Errors::ForeignKeyNotFound 146 | end 147 | end 148 | end 149 | end 150 | 151 | describe 'postgresql' do 152 | before do 153 | define_database(postgresql_configuration) 154 | define_tables 155 | end 156 | 157 | include_examples 'works as belongs_to' 158 | end 159 | 160 | # TODO: validate options 161 | # describe 'sqlite3' do 162 | # before do 163 | # define_database(sqlite_configuration) 164 | # define_tables 165 | # end 166 | # 167 | # specify do 168 | # expect { db_belongs_to_user_klass }.to raise_error DatabaseValidations::Errors::UnsupportedDatabase 169 | # end 170 | # end 171 | 172 | describe 'mysql' do 173 | before do 174 | define_database(mysql_configuration) 175 | define_tables 176 | end 177 | 178 | include_examples 'works as belongs_to' 179 | end 180 | end 181 | -------------------------------------------------------------------------------- /spec/validations/validates_db_presence_of_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe '.validates_db_presence_of' do 2 | before do 3 | define_database(postgresql_configuration) 4 | 5 | define_table do |t| 6 | t.string :field 7 | end 8 | end 9 | 10 | context 'when cover simple field' do 11 | let(:simple_klass) { define_class { validates :field, presence: true } } 12 | let(:db_klass) { define_class { validates :field, db_presence: true } } 13 | 14 | %w[save save! valid?].each do |method| 15 | context "with #{method}" do 16 | it 'works for fields' do 17 | simple = simple_klass.new 18 | db = db_klass.new 19 | 20 | simple_result = rescue_error { simple.public_send(method) } 21 | db_result = rescue_error { db.public_send(method) } 22 | 23 | expect(db.errors.messages).to eq(simple.errors.messages) 24 | expect(db_result).to eq(simple_result) 25 | expect(db.persisted?).to eq(simple.persisted?) 26 | end 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/validations/validates_db_uniqueness_of_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe '.validates_db_uniqueness_of' do 2 | let(:parent_class) { define_class } 3 | 4 | shared_examples 'works as expected' do 5 | shared_examples 'ActiveRecord::Validation' do |skip_persisted: false, query_count: 1| 6 | define_negated_matcher :not_change, :change 7 | define_negated_matcher :not_make_database_queries, :make_database_queries 8 | 9 | before { parent_class.create!(persisted_attrs) unless skip_persisted } 10 | 11 | describe 'valid?' do 12 | it 'makes a query for validation' do 13 | expect { db_uniqueness.new(persisted_attrs).valid? } 14 | .to make_database_queries(matching: /SELECT (?!sql)/, count: query_count) 15 | expect { app_uniqueness.new(persisted_attrs).valid? } 16 | .to make_database_queries(matching: /SELECT (?!sql)/, count: query_count) 17 | end 18 | 19 | it 'returns false' do 20 | expect(db_uniqueness.new(persisted_attrs).valid?).to eq(false) 21 | expect(app_uniqueness.new(persisted_attrs).valid?).to eq(false) 22 | end 23 | 24 | it 'has exactly the same errors' do 25 | new = db_uniqueness.new(persisted_attrs).tap(&:valid?) 26 | old = app_uniqueness.new(persisted_attrs).tap(&:valid?) 27 | 28 | expect(old.errors.messages.sort).to eq(new.errors.messages.sort) 29 | RAILS_5 && (expect(old.errors.details.sort).to eq(new.errors.details.sort)) 30 | end 31 | 32 | context 'when wrapped by transaction' do 33 | it 'does not break transaction' do 34 | old = app_uniqueness.new(persisted_attrs) 35 | new = db_uniqueness.new(persisted_attrs) 36 | 37 | ActiveRecord::Base.connection.transaction do 38 | new.valid? 39 | old.valid? 40 | end 41 | 42 | expect(old.errors.messages.sort).to eq(new.errors.messages.sort) 43 | RAILS_5 && (expect(old.errors.details.sort).to eq(new.errors.details.sort)) 44 | end 45 | end 46 | end 47 | 48 | describe 'create/save/update' do 49 | it 'does not make a query for validation' do 50 | expect { db_uniqueness.create(persisted_attrs) } 51 | .to not_make_database_queries(matching: /SELECT (?!sql)/) 52 | .and not_change(parent_class, :count) 53 | expect { app_uniqueness.create(persisted_attrs) } 54 | .to make_database_queries(matching: /SELECT (?!sql)/, count: query_count) 55 | .and not_change(parent_class, :count) 56 | end 57 | 58 | if RAILS_5 59 | it 'respects validate: false' do 60 | expect { db_uniqueness.new(persisted_attrs).save(validate: false) } 61 | .to raise_error(ActiveRecord::RecordNotUnique) 62 | .and not_change(parent_class, :count) 63 | expect { app_uniqueness.new(persisted_attrs).save(validate: false) } 64 | .to raise_error(ActiveRecord::RecordNotUnique) 65 | .and not_change(parent_class, :count) 66 | end 67 | end 68 | 69 | # Database raise only one unique constraint error per query 70 | # That means we can't catch all validations at once if there are more than one 71 | it 'has (almost) the same errors' do 72 | new = db_uniqueness.create(persisted_attrs) 73 | old = app_uniqueness.create(persisted_attrs) 74 | 75 | expect(new.errors.messages).to be_present 76 | RAILS_5 && (expect(new.errors.details).to be_present) 77 | 78 | expect(old.errors.messages.to_h).to include(new.errors.messages.to_h) 79 | RAILS_5 && (expect(old.errors.details.to_h).to include(new.errors.details.to_h)) 80 | end 81 | 82 | context 'when wrapped by transaction' do 83 | it 'does not break transaction' do 84 | new = db_uniqueness.new(persisted_attrs) 85 | old = app_uniqueness.new(persisted_attrs) 86 | 87 | ActiveRecord::Base.connection.transaction do 88 | new.save 89 | old.save 90 | end 91 | 92 | expect(new.errors.messages).to be_present 93 | RAILS_5 && (expect(new.errors.details).to be_present) 94 | 95 | expect(old.errors.messages.to_h).to include(new.errors.messages.to_h) 96 | RAILS_5 && (expect(old.errors.details.to_h).to include(new.errors.details.to_h)) 97 | end 98 | end 99 | end 100 | 101 | describe 'create!/save!/update!' do 102 | it 'does not make a query for validation' do 103 | expect { db_uniqueness.create!(persisted_attrs) } 104 | .to raise_error(ActiveRecord::RecordInvalid) 105 | .and not_make_database_queries(matching: /SELECT (?!sql)/) 106 | .and not_change(parent_class, :count) 107 | 108 | expect { app_uniqueness.create!(persisted_attrs) } 109 | .to raise_error(ActiveRecord::RecordInvalid) 110 | .and make_database_queries(matching: /SELECT (?!sql)/, count: query_count) 111 | .and not_change(parent_class, :count) 112 | end 113 | 114 | if RAILS_5 115 | it 'respects validate: false' do 116 | expect { db_uniqueness.new(persisted_attrs).save!(validate: false) } 117 | .to raise_error(ActiveRecord::RecordNotUnique) 118 | .and not_change(parent_class, :count) 119 | expect { app_uniqueness.new(persisted_attrs).save!(validate: false) } 120 | .to raise_error(ActiveRecord::RecordNotUnique) 121 | .and not_change(parent_class, :count) 122 | end 123 | end 124 | 125 | def catch_error_message 126 | yield 127 | rescue ActiveRecord::RecordInvalid => e 128 | e.message.sub('Validation failed: ', '') 129 | end 130 | 131 | # Database raise only one unique constraint error per query 132 | # That means we can't catch all validations at once if there are more than one 133 | it 'raises validation error' do 134 | new = catch_error_message { db_uniqueness.create!(persisted_attrs) } 135 | old = catch_error_message { app_uniqueness.create!(persisted_attrs) } 136 | 137 | expect(new).to be_present 138 | expect(old).to include(new) 139 | end 140 | 141 | context 'when wrapped by transaction' do 142 | it 'breaks transaction properly' do 143 | new = db_uniqueness.new(persisted_attrs) 144 | old = app_uniqueness.new(persisted_attrs) 145 | 146 | error_message = catch_error_message do 147 | ActiveRecord::Base.connection.transaction do 148 | new.save! 149 | old.save! 150 | end 151 | end 152 | 153 | expect(error_message).to be_present 154 | end 155 | end 156 | end 157 | end 158 | 159 | context 'when SKIP_DB_UNIQUENESS_VALIDATOR_INDEX_CHECK is provided' do 160 | before do 161 | define_table do |t| 162 | t.string :field 163 | end 164 | allow(ENV).to receive(:[]).with('SKIP_DB_UNIQUENESS_VALIDATOR_INDEX_CHECK').and_return('true') 165 | end 166 | 167 | it 'does not raise an error' do 168 | expect do 169 | define_class { validates_db_uniqueness_of :field } 170 | end.not_to raise_error 171 | end 172 | end 173 | 174 | shared_examples 'when condition options return false' do 175 | describe '#valid?' do 176 | it 'skips querying the database' do 177 | StringIO.open do |io| 178 | klass.superclass.logger = Logger.new(io) 179 | expect { klass.new(field: 0).valid? }.not_to change(io, :string) 180 | klass.superclass.logger = nil 181 | end 182 | end 183 | end 184 | end 185 | 186 | shared_examples 'when condition options return true' do 187 | describe '#valid?' do 188 | it 'queries the database' do 189 | StringIO.open do |io| 190 | klass.superclass.logger = Logger.new(io) 191 | expect { klass.new(field: 0).valid? }.to change(io, :string) 192 | klass.superclass.logger = Logger.new(nil) 193 | end 194 | end 195 | end 196 | end 197 | 198 | context 'when condition options are passed' do 199 | before do 200 | define_table do |t| 201 | t.string :field 202 | t.index [:field], unique: true 203 | end 204 | end 205 | 206 | context 'when if option is passed' do 207 | context 'when if is a symbol' do 208 | context 'when method returns false' do 209 | let(:klass) do 210 | define_class do 211 | validates_db_uniqueness_of :field, if: :skip 212 | define_method(:skip) { false } 213 | end 214 | end 215 | 216 | include_examples 'when condition options return false' 217 | end 218 | 219 | context 'when method returns true' do 220 | let(:klass) do 221 | define_class do 222 | validates_db_uniqueness_of :field, if: :skip 223 | define_method(:skip) { true } 224 | end 225 | end 226 | 227 | include_examples 'when condition options return true' 228 | end 229 | end 230 | 231 | context 'when if is a proc' do 232 | context 'when proc has argument' do 233 | context 'when proc returns false' do 234 | let(:klass) do 235 | define_class do 236 | validates_db_uniqueness_of :field, if: ->(entity) { entity.nil? } 237 | end 238 | end 239 | 240 | include_examples 'when condition options return false' 241 | end 242 | 243 | context 'when proc returns true' do 244 | let(:klass) do 245 | define_class do 246 | validates_db_uniqueness_of :field, if: ->(entity) { !entity.nil? } 247 | end 248 | end 249 | 250 | include_examples 'when condition options return true' 251 | end 252 | end 253 | 254 | context 'when proc has no argument' do 255 | context 'when proc returns false' do 256 | let(:klass) do 257 | define_class do 258 | validates_db_uniqueness_of :field, if: -> { nil? } 259 | end 260 | end 261 | 262 | include_examples 'when condition options return false' 263 | end 264 | 265 | context 'when proc returns true' do 266 | let(:klass) do 267 | define_class do 268 | validates_db_uniqueness_of :field, if: -> { !nil? } 269 | end 270 | end 271 | 272 | include_examples 'when condition options return true' 273 | end 274 | end 275 | end 276 | end 277 | 278 | context 'when unless option is passed' do 279 | context 'when unless is a symbol' do 280 | context 'when method returns true' do 281 | let(:klass) do 282 | define_class do 283 | validates_db_uniqueness_of :field, unless: :skip 284 | define_method(:skip) { true } 285 | end 286 | end 287 | 288 | include_examples 'when condition options return false' 289 | end 290 | 291 | context 'when method returns false' do 292 | let(:klass) do 293 | define_class do 294 | validates_db_uniqueness_of :field, unless: :skip 295 | define_method(:skip) { false } 296 | end 297 | end 298 | 299 | include_examples 'when condition options return true' 300 | end 301 | end 302 | 303 | context 'when unless is a proc' do 304 | context 'when proc has argument' do 305 | context 'when proc returns true' do 306 | let(:klass) do 307 | define_class do 308 | validates_db_uniqueness_of :field, unless: ->(entity) { !entity.nil? } 309 | end 310 | end 311 | 312 | include_examples 'when condition options return false' 313 | end 314 | 315 | context 'when proc returns false' do 316 | let(:klass) do 317 | define_class do 318 | validates_db_uniqueness_of :field, unless: ->(entity) { entity.nil? } 319 | end 320 | end 321 | 322 | include_examples 'when condition options return true' 323 | end 324 | end 325 | 326 | context 'when proc has no argument' do 327 | context 'when proc returns true' do 328 | let(:klass) do 329 | define_class do 330 | validates_db_uniqueness_of :field, unless: -> { !nil? } 331 | end 332 | end 333 | 334 | include_examples 'when condition options return false' 335 | end 336 | 337 | context 'when proc returns false' do 338 | let(:klass) do 339 | define_class do 340 | validates_db_uniqueness_of :field, unless: -> { nil? } 341 | end 342 | end 343 | 344 | include_examples 'when condition options return true' 345 | end 346 | end 347 | end 348 | end 349 | end 350 | 351 | context 'when has not proper validator' do 352 | before do 353 | define_table do |t| 354 | t.string :field 355 | t.index [:field], unique: true 356 | end 357 | end 358 | 359 | let(:klass) { define_class } 360 | let(:attributes) { { field: 0 } } 361 | 362 | it 'raises unique constrain error' do 363 | klass.create(attributes) 364 | expect { klass.create(attributes) }.to raise_error ActiveRecord::RecordNotUnique 365 | end 366 | end 367 | 368 | context 'when wrapped transaction is rolled back' do 369 | before do 370 | define_table do |t| 371 | t.string :field 372 | t.index [:field], unique: true 373 | end 374 | end 375 | 376 | let(:app_uniqueness) { define_class(parent_class) { validates_uniqueness_of :field } } 377 | let(:db_uniqueness) { define_class(parent_class) { validates_db_uniqueness_of :field } } 378 | 379 | it 'does not create rows' do 380 | new = db_uniqueness.new(field: '0') 381 | old = app_uniqueness.new(field: '1') 382 | 383 | begin 384 | ActiveRecord::Base.connection.transaction do 385 | new.save 386 | old.save 387 | raise 'rollback' 388 | end 389 | rescue StandardError 390 | nil 391 | end 392 | expect(parent_class.count).to eq(0) 393 | expect(new.persisted?).to eq(false) 394 | expect(old.persisted?).to eq(false) 395 | end 396 | end 397 | 398 | context 'when parent class has validation' do 399 | before do 400 | define_table do |t| 401 | t.string :field 402 | t.index [:field], unique: true 403 | end 404 | end 405 | 406 | let(:app_uniqueness) { define_class(define_class(parent_class) { validates_uniqueness_of :field }) } 407 | let(:db_uniqueness) { define_class(define_class(parent_class) { validates_db_uniqueness_of :field }) } 408 | 409 | let(:persisted_attrs) { { field: 'persisted' } } 410 | 411 | it_behaves_like 'ActiveRecord::Validation' 412 | end 413 | 414 | context 'when in rescue always' do 415 | before do 416 | define_table do |t| 417 | t.string :field 418 | t.index [:field], unique: true 419 | end 420 | 421 | parent_class.create!(persisted_attrs) 422 | end 423 | 424 | let(:db_uniqueness) { define_class { validates_db_uniqueness_of :field, rescue: :always } } 425 | let(:app_uniqueness) { define_class { validates_uniqueness_of :field } } 426 | 427 | let(:persisted_attrs) { { field: 'persisted' } } 428 | 429 | it 'rescues the error' do 430 | expect { db_uniqueness.new(persisted_attrs).save!(validate: false) } 431 | .to raise_error(ActiveRecord::RecordInvalid) 432 | 433 | expect { app_uniqueness.new(persisted_attrs).save!(validate: false) } 434 | .to raise_error(ActiveRecord::RecordNotUnique) 435 | end 436 | end 437 | 438 | context 'when in enhanced mode' do 439 | before do 440 | define_table do |t| 441 | t.string :field 442 | t.index [:field], unique: true 443 | end 444 | end 445 | 446 | let(:db_uniqueness) { define_class { validates_db_uniqueness_of :field } } 447 | let(:app_uniqueness) { define_class { validates_db_uniqueness_of :field, mode: :enhanced } } 448 | 449 | let(:persisted_attrs) { { field: 'persisted' } } 450 | 451 | it_behaves_like 'ActiveRecord::Validation' 452 | end 453 | 454 | context 'when in standard mode' do 455 | before do 456 | define_table do |t| 457 | t.string :field 458 | t.index [:field], unique: true 459 | end 460 | end 461 | 462 | let(:db_uniqueness) { define_class { validates_db_uniqueness_of :field } } 463 | let(:app_uniqueness) { define_class { validates_db_uniqueness_of :field, mode: :standard } } 464 | 465 | let(:persisted_attrs) { { field: 'persisted' } } 466 | 467 | it_behaves_like 'ActiveRecord::Validation' 468 | 469 | context do 470 | define_negated_matcher :not_change, :change 471 | 472 | before { parent_class.create!(persisted_attrs) } 473 | 474 | it "doesn't rescue from the constraint violation" do 475 | expect_any_instance_of(ActiveRecord::Validations::UniquenessValidator) 476 | .to receive(:scope_relation).twice.and_return(RAILS_5 ? app_uniqueness.none : '1=0') 477 | 478 | expect { app_uniqueness.create(persisted_attrs) } 479 | .to raise_error(ActiveRecord::RecordNotUnique) 480 | .and not_change(parent_class, :count) 481 | 482 | expect { app_uniqueness.create!(persisted_attrs) } 483 | .to raise_error(ActiveRecord::RecordNotUnique) 484 | .and not_change(parent_class, :count) 485 | end 486 | end 487 | end 488 | 489 | context 'when message is provided' do 490 | before do 491 | define_table do |t| 492 | t.string :field 493 | t.index [:field], unique: true 494 | end 495 | end 496 | 497 | let(:db_uniqueness) { define_class { validates_db_uniqueness_of :field, message: 'already exists' } } 498 | let(:app_uniqueness) { define_class { validates_uniqueness_of :field, message: 'already exists' } } 499 | 500 | let(:persisted_attrs) { { field: 'persisted' } } 501 | 502 | it_behaves_like 'ActiveRecord::Validation' 503 | end 504 | 505 | context 'when parent class set validation of flow' do 506 | before do 507 | define_table do |t| 508 | t.string :field 509 | t.index [:field], unique: true 510 | end 511 | 512 | # Add validator to parent class 513 | parent_db_uniqueness.validates_db_uniqueness_of :field 514 | parent_app_uniqueness.validates_uniqueness_of :field 515 | end 516 | 517 | let(:parent_db_uniqueness) { define_class(parent_class) } 518 | let(:parent_app_uniqueness) { define_class(parent_class) } 519 | 520 | let(:db_uniqueness) { define_class(parent_db_uniqueness) } 521 | let(:app_uniqueness) { define_class(parent_app_uniqueness) } 522 | 523 | let(:persisted_attrs) { { field: 'persisted' } } 524 | 525 | it_behaves_like 'ActiveRecord::Validation' 526 | end 527 | 528 | context 'when klass is abstract' do 529 | let(:db_uniqueness) {} 530 | 531 | it 'does not check the index presence' do 532 | expect do 533 | define_class do 534 | self.abstract_class = true 535 | validates_db_uniqueness_of :field 536 | end 537 | end.not_to raise_error 538 | end 539 | end 540 | 541 | context 'without scope' do 542 | context 'without index' do 543 | before { define_table { |t| t.string :field } } 544 | 545 | it 'raises error on boot time' do 546 | expect do 547 | define_class { validates_db_uniqueness_of :field } 548 | end.to raise_index_not_found( 549 | 'No unique index found with columns: ["field"] in table "entities". '\ 550 | 'Available indexes are: [].' 551 | ) 552 | end 553 | end 554 | 555 | context 'with index' do 556 | before do 557 | define_table do |t| 558 | t.string :field 559 | t.index [:field], unique: true 560 | end 561 | end 562 | 563 | let(:db_uniqueness) { define_class { validates_db_uniqueness_of :field } } 564 | let(:app_uniqueness) { define_class { validates_uniqueness_of :field } } 565 | 566 | let(:persisted_attrs) { { field: 'persisted' } } 567 | 568 | it_behaves_like 'ActiveRecord::Validation' 569 | end 570 | end 571 | 572 | context 'with scope' do 573 | context 'without index' do 574 | before do 575 | define_table do |t| 576 | t.string :field_1 577 | t.string :field_2 578 | end 579 | end 580 | 581 | it 'raises error on boot time' do 582 | expect do 583 | define_class do 584 | validates_db_uniqueness_of :field_1, scope: :field_2 585 | end 586 | end.to raise_index_not_found( 587 | 'No unique index found with columns: ["field_1", "field_2"] in table "entities". '\ 588 | 'Available indexes are: [].' 589 | ) 590 | end 591 | end 592 | 593 | context 'with index' do 594 | before do 595 | define_table do |t| 596 | t.string :field_1 597 | t.string :field_2 598 | t.index %i[field_2 field_1], unique: true 599 | end 600 | end 601 | 602 | let(:db_uniqueness) { define_class { validates_db_uniqueness_of :field_1, scope: :field_2 } } 603 | let(:app_uniqueness) { define_class { validates_uniqueness_of :field_1, scope: :field_2 } } 604 | 605 | let(:persisted_attrs) { { field_1: 'persisted', field_2: 'persisted' } } 606 | 607 | it_behaves_like 'ActiveRecord::Validation' 608 | end 609 | end 610 | 611 | context 'with multiple attributes passed' do 612 | context 'without index' do 613 | before do 614 | define_table do |t| 615 | t.string :field_1 616 | t.string :field_2 617 | t.index [:field_1], unique: true 618 | end 619 | end 620 | 621 | it 'raises error with first attribute without index on boot time' do 622 | expect do 623 | define_class do 624 | validates_db_uniqueness_of :field_1 625 | validates_db_uniqueness_of :field_2 626 | end 627 | end.to raise_index_not_found( 628 | 'No unique index found with columns: ["field_2"] in table "entities". '\ 629 | 'Available indexes are: [columns: ["field_1"]].' 630 | ) 631 | end 632 | end 633 | 634 | context 'with indexes' do 635 | before do 636 | define_table do |t| 637 | t.string :field_1 638 | t.string :field_2 639 | t.index [:field_1], unique: true 640 | t.index [:field_2], unique: true 641 | end 642 | end 643 | 644 | let(:db_uniqueness) do 645 | define_class do 646 | validates_db_uniqueness_of :field_1 647 | validates_db_uniqueness_of :field_2 648 | end 649 | end 650 | 651 | let(:app_uniqueness) do 652 | define_class do 653 | validates_uniqueness_of :field_1 654 | validates_uniqueness_of :field_2 655 | end 656 | end 657 | 658 | let(:persisted_attrs) { { field_1: 'persisted', field_2: 'persisted_too' } } 659 | 660 | it_behaves_like 'ActiveRecord::Validation', query_count: 2 661 | end 662 | end 663 | 664 | context 'when defined through validates' do 665 | before do 666 | define_table do |t| 667 | t.string :field_1 668 | t.string :field_2 669 | t.index [:field_1], unique: true 670 | t.index [:field_2], unique: true 671 | end 672 | end 673 | 674 | let(:db_uniqueness) { define_class(parent_class) { validates :field_1, :field_2, db_uniqueness: true } } 675 | let(:app_uniqueness) { define_class(parent_class) { validates :field_1, :field_2, uniqueness: true } } 676 | 677 | let(:persisted_attrs) { { field_1: 'persisted', field_2: 'persisted_too' } } 678 | 679 | it_behaves_like 'ActiveRecord::Validation', query_count: 2 680 | end 681 | end 682 | 683 | shared_examples 'supports condition option' do 684 | context 'when conditions option is provided' do 685 | before do 686 | define_table do |t| 687 | t.integer :field 688 | t.index [:field], unique: true, where: '(field > 1)' 689 | end 690 | end 691 | 692 | context 'when where clause is different' do 693 | it 'raises error' do 694 | expect do 695 | define_class { validates_db_uniqueness_of :field, where: '(field < 1)' } 696 | end.to raise_index_not_found( 697 | 'No unique index found with columns: ["field"] and where: (field < 1) in table "entities". '\ 698 | 'Available indexes are: [columns: ["field"] and where: (field > 1)].' 699 | ) 700 | end 701 | end 702 | 703 | context 'when where clause is the same' do 704 | let(:db_uniqueness) { define_class { validates_db_uniqueness_of :field, where: '(field > 1)' } } 705 | let(:app_uniqueness) { define_class { validates_uniqueness_of :field, conditions: -> { where('(field > 1)') } } } 706 | 707 | context 'when condition should be considered' do 708 | let(:persisted_attrs) { { field: 2 } } 709 | 710 | it_behaves_like 'ActiveRecord::Validation' 711 | end 712 | 713 | context 'when condition should be ignored' do 714 | let(:persisted_attrs) { { field: 0 } } 715 | 716 | describe '#valid?' do 717 | it 'works' do 718 | expect(db_uniqueness.new(persisted_attrs).valid?).to eq(true) 719 | expect(app_uniqueness.new(persisted_attrs).valid?).to eq(true) 720 | end 721 | end 722 | 723 | describe '#create/save/update' do 724 | it 'works' do 725 | expect(db_uniqueness.create(persisted_attrs)).to be_persisted 726 | expect(app_uniqueness.create(persisted_attrs)).to be_persisted 727 | end 728 | end 729 | 730 | describe '#create!/save!/update!' do 731 | it 'works' do 732 | expect { db_uniqueness.create!(persisted_attrs) }.not_to raise_error 733 | expect { app_uniqueness.create!(persisted_attrs) }.not_to raise_error 734 | end 735 | end 736 | end 737 | end 738 | end 739 | end 740 | 741 | shared_examples 'supports index_name option' do 742 | context 'when index_name option is passed' do 743 | before do 744 | define_table do |t| 745 | t.string :field 746 | t.index [:field], unique: true, name: :unique_index 747 | end 748 | end 749 | 750 | context 'when index_name is the same' do 751 | it 'works' do 752 | klass = define_class { validates_db_uniqueness_of :field, index_name: :unique_index } 753 | klass.create!(field: 'field') 754 | expect { klass.create!(field: 'field') }.to raise_error ActiveRecord::RecordInvalid 755 | end 756 | end 757 | 758 | context 'when index_name is different' do 759 | it 'raises an error' do 760 | expect do 761 | define_class { validates_db_uniqueness_of :field, index_name: :missing_index } 762 | end.to raise_index_not_found( 763 | 'No unique index found with name: "missing_index" in table "entities". '\ 764 | 'Available indexes are: ["unique_index"].' 765 | ) 766 | end 767 | end 768 | end 769 | end 770 | 771 | shared_examples 'supports index_name with where option' do 772 | context 'when index has where option' do 773 | before do 774 | define_table do |t| 775 | t.string :field 776 | t.string :another 777 | t.index [:field], unique: true, name: :unique_index, where: '(another IS NOT NULL)' 778 | end 779 | end 780 | 781 | context 'when where option is skipped' do 782 | it 'raises an error due valid? is inconsistent with the index' do 783 | expect do 784 | define_class { validates_db_uniqueness_of :field } 785 | end.to raise_index_not_found( 786 | 'No unique index found with columns: ["field"] in table "entities". '\ 787 | 'Available indexes are: [columns: ["field"] and where: (another IS NOT NULL)].' 788 | ) 789 | end 790 | end 791 | 792 | context 'when where option is provided' do 793 | it 'does not raise an error' do 794 | expect do 795 | define_class { validates_db_uniqueness_of :field, where: '(another IS NOT NULL)' } 796 | end.not_to raise_error 797 | end 798 | end 799 | end 800 | end 801 | 802 | shared_examples 'supports index_name with scope option' do 803 | context 'when index uses many columns' do 804 | before do 805 | define_table do |t| 806 | t.string :field 807 | t.string :another 808 | t.index %i[field another], unique: true, name: :unique_index 809 | end 810 | end 811 | 812 | context 'when scope option is skipped' do 813 | it 'raises an error due valid? is inconsistent with the index' do 814 | expect do 815 | define_class { validates_db_uniqueness_of :field } 816 | end.to raise_index_not_found( 817 | 'No unique index found with columns: ["field"] in table "entities". '\ 818 | 'Available indexes are: [columns: ["field", "another"]].' 819 | ) 820 | end 821 | end 822 | 823 | context 'when scope option is provided' do 824 | it 'does not raise an error' do 825 | expect do 826 | define_class { validates_db_uniqueness_of :field, scope: :another } 827 | end.not_to raise_error 828 | end 829 | end 830 | end 831 | end 832 | 833 | shared_examples 'supports complex indexes' do 834 | next unless RAILS_5 835 | 836 | context 'with index_name option' do 837 | let(:app_uniqueness) { define_class { validates_uniqueness_of :field, case_sensitive: false } } 838 | let(:db_uniqueness) { define_class { validates_db_uniqueness_of :field, index_name: :unique_index, case_sensitive: false } } 839 | 840 | let(:persisted_attrs) { { field: 'field' } } 841 | 842 | before do 843 | define_table do |t| 844 | t.string :field 845 | t.index 'lower(field)', unique: true, name: :unique_index 846 | end 847 | 848 | db_uniqueness.create!(field: 'FIELD') 849 | end 850 | 851 | it 'works' do 852 | expect { db_uniqueness.create!(field: 'field') }.to raise_error ActiveRecord::RecordInvalid 853 | end 854 | 855 | it_behaves_like 'ActiveRecord::Validation', skip_persisted: true 856 | end 857 | 858 | context 'without index_name option' do 859 | before do 860 | define_table do |t| 861 | t.string :field 862 | t.index 'lower(field)', unique: true 863 | end 864 | end 865 | 866 | it 'raises an error' do 867 | expect do 868 | define_class { validates_db_uniqueness_of :field } 869 | end.to raise_index_not_found 870 | end 871 | end 872 | end 873 | 874 | shared_examples 'when index_name is passed only one attribute can be provided' do 875 | it 'throws an error' do 876 | expect do 877 | define_class { validates_db_uniqueness_of :field, :another, index_name: :unique_index } 878 | end.to raise_error ArgumentError, /When index_name is provided validator can have only one attribute./ 879 | end 880 | end 881 | 882 | describe 'postgresql' do 883 | before { define_database(postgresql_configuration) } 884 | 885 | include_examples 'works as expected' 886 | include_examples 'supports condition option' 887 | include_examples 'supports index_name option' 888 | include_examples 'supports complex indexes' 889 | include_examples 'supports index_name with where option' 890 | include_examples 'supports index_name with scope option' 891 | include_examples 'when index_name is passed only one attribute can be provided' 892 | end 893 | 894 | describe 'sqlite3' do 895 | before { define_database(sqlite_configuration) } 896 | 897 | include_examples 'works as expected' 898 | include_examples 'when index_name is passed only one attribute can be provided' 899 | end 900 | 901 | describe 'mysql' do 902 | before { define_database(mysql_configuration) } 903 | 904 | include_examples 'works as expected' 905 | include_examples 'supports index_name option' 906 | include_examples 'supports index_name with scope option' 907 | include_examples 'when index_name is passed only one attribute can be provided' 908 | end 909 | end 910 | --------------------------------------------------------------------------------