├── .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 | [](https://circleci.com/gh/toptal/database_validations/tree/master)
4 | [](https://badge.fury.io/rb/database_validations)
5 | [](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 | 
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 | 
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 | 
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 |
--------------------------------------------------------------------------------