├── .github ├── FUNDING.yml └── workflows │ └── tests.yml ├── .gitignore ├── .rubocop.yml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── ar-check.gemspec ├── gemfiles ├── 5_0.gemfile ├── 5_1.gemfile ├── 5_2.gemfile └── 6_0.gemfile ├── lib ├── ar-check.rb └── ar │ ├── check.rb │ └── check │ ├── adapter.rb │ ├── command_recorder.rb │ ├── schema_dumper.rb │ └── version.rb └── test ├── ar └── check_test.rb └── test_helper.rb /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | --- 2 | github: [fnando] 3 | custom: ["https://www.paypal.me/nandovieira/🍕"] 4 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | push: 8 | branches: 9 | - main 10 | schedule: 11 | - cron: "0 10 * * *" 12 | 13 | jobs: 14 | build: 15 | name: Tests with Ruby ${{ matrix.ruby }} with ${{ matrix.gemfile }} 16 | runs-on: "ubuntu-latest" 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | ruby: ["2.7.x", "2.6.x", "2.5.x"] 21 | gemfile: 22 | - gemfiles/6_0.gemfile 23 | - gemfiles/5_2.gemfile 24 | - gemfiles/5_1.gemfile 25 | - gemfiles/5_0.gemfile 26 | 27 | services: 28 | postgres: 29 | image: postgres:11.5 30 | ports: ["5432:5432"] 31 | options: 32 | --health-cmd pg_isready --health-interval 10s --health-timeout 5s 33 | --health-retries 5 34 | 35 | steps: 36 | - uses: actions/checkout@v1 37 | 38 | - uses: actions/cache@v2 39 | with: 40 | path: vendor/bundle 41 | key: > 42 | ${{ runner.os }}-${{ matrix.ruby }}-gems-${{ 43 | hashFiles('**/*.gemspec') }} 44 | restore-keys: > 45 | ${{ runner.os }}-${{ matrix.ruby }}-gems-${{ 46 | hashFiles('**/*.gemspec') }} 47 | 48 | - name: Set up Ruby 49 | uses: actions/setup-ruby@v1 50 | with: 51 | ruby-version: ${{ matrix.ruby }} 52 | 53 | - name: Install PostgreSQL 11 client 54 | run: | 55 | sudo apt-get -yqq install libpq-dev 56 | 57 | - name: Install gem dependencies 58 | env: 59 | BUNDLE_GEMFILE: ${{ matrix.gemfile }} 60 | run: | 61 | gem install bundler 62 | bundle config path vendor/bundle 63 | bundle update --jobs 4 --retry 3 64 | 65 | - name: Run Tests 66 | env: 67 | PGHOST: localhost 68 | PGUSER: postgres 69 | BUNDLE_GEMFILE: ${{ matrix.gemfile }} 70 | run: | 71 | psql -U postgres -c "create database test" 72 | bundle exec rake 73 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | *.lock 11 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | --- 2 | inherit_gem: 3 | rubocop-fnando: .rubocop.yml 4 | 5 | AllCops: 6 | TargetRubyVersion: 2.5 7 | 8 | Exclude: 9 | - vendor/**/* 10 | - gemfiles/vendor/**/* 11 | 12 | Naming/FileName: 13 | Exclude: 14 | - lib/ar-check.rb 15 | 16 | Metrics/LineLength: 17 | Exclude: 18 | - ar-check.gemspec 19 | - test/**/*_test.rb 20 | 21 | Metrics/MethodLength: 22 | Enabled: false 23 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # ar-check 2 | 3 | #### v0.1.0 4 | 5 | - Initial release 6 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | As contributors and maintainers of this project, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities. 4 | 5 | We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, or religion. 6 | 7 | Examples of unacceptable behavior by participants include the use of sexual language or imagery, derogatory comments or personal attacks, trolling, public or private harassment, insults, or other unprofessional conduct. 8 | 9 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. Project maintainers who do not follow the Code of Conduct may be removed from the project team. 10 | 11 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers. 12 | 13 | This Code of Conduct is adapted from the [Contributor Covenant](http://contributor-covenant.org), version 1.0.0, available at [http://contributor-covenant.org/version/1/0/0/](http://contributor-covenant.org/version/1/0/0/) 14 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | # Specify your gem's dependencies in ar-check.gemspec 6 | gemspec 7 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Nando Vieira 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 | # AR::Check 2 | 3 | [![Tests](https://github.com/fnando/ar-check/workflows/Tests/badge.svg)](https://github.com/fnando/ar-check/actions?query=workflow%3ATests) 4 | [![Code Climate](https://codeclimate.com/github/fnando/ar-check/badges/gpa.svg)](https://codeclimate.com/github/fnando/ar-check) 5 | [![Gem](https://img.shields.io/gem/v/ar-check.svg)](https://rubygems.org/gems/ar-check) 6 | [![Gem](https://img.shields.io/gem/dt/ar-check.svg)](https://rubygems.org/gems/ar-check) 7 | 8 | Enable PostgreSQL's CHECK constraints on ActiveRecord migrations. 9 | 10 | ## Installation 11 | 12 | Add this line to your application's Gemfile: 13 | 14 | ```ruby 15 | gem "ar-check" 16 | ``` 17 | 18 | And then execute: 19 | 20 | $ bundle 21 | 22 | Or install it yourself as: 23 | 24 | $ gem install ar-check 25 | 26 | ## Usage 27 | 28 | To create a `CHECK` constraint, just use the method `add_check`. 29 | 30 | ```ruby 31 | create_table :employees do |t| 32 | t.integer :salary, null: false 33 | end 34 | 35 | add_check :employees, :positive_salary, "salary > 0" 36 | ``` 37 | 38 | This will generate a new constraint using the following SQL statement: 39 | 40 | ```sql 41 | ALTER TABLE employees 42 | ADD CONSTRAINT positive_salary_on_things 43 | CHECK (salary > 0) 44 | ``` 45 | 46 | To remove it, just using `remove_check`. 47 | 48 | ```ruby 49 | remove_check :employees, :positive_salary 50 | ``` 51 | 52 | ## Development 53 | 54 | After checking out the repo, run `bin/setup` to install dependencies. Then, run 55 | `rake test` to run the tests. You can also run `bin/console` for an interactive 56 | prompt that will allow you to experiment. 57 | 58 | To install this gem onto your local machine, run `bundle exec rake install`. To 59 | release a new version, update the version number in `version.rb`, and then run 60 | `bundle exec rake release`, which will create a git tag for the version, push 61 | git commits and tags, and push the `.gem` file to 62 | [rubygems.org](https://rubygems.org). 63 | 64 | ## Contributing 65 | 66 | Bug reports and pull requests are welcome on GitHub at 67 | https://github.com/fnando/ar-check. This project is intended to be a safe, 68 | welcoming space for collaboration, and contributors are expected to adhere to 69 | the [Contributor Covenant](contributor-covenant.org) code of conduct. 70 | 71 | ## License 72 | 73 | The gem is available as open source under the terms of the 74 | [MIT License](http://opensource.org/licenses/MIT). 75 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rake/testtask" 5 | require "rubocop/rake_task" 6 | 7 | Rake::TestTask.new(:test) do |t| 8 | t.libs << "test" 9 | t.libs << "lib" 10 | t.test_files = FileList["test/**/*_test.rb"] 11 | t.verbose = false 12 | t.warning = false 13 | end 14 | 15 | RuboCop::RakeTask.new(:rubocop) do |t| 16 | t.options += ["--config", File.join(__dir__, ".rubocop.yml")] 17 | end 18 | 19 | task default: %i[test rubocop] 20 | -------------------------------------------------------------------------------- /ar-check.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "./lib/ar/check/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "ar-check" 7 | spec.version = AR::Check::VERSION 8 | spec.authors = ["Nando Vieira"] 9 | spec.email = ["fnando.vieira@gmail.com"] 10 | spec.required_ruby_version = Gem::Requirement.new(">= 2.5.0") 11 | 12 | spec.summary = "Enable PostgreSQL's CHECK constraints on ActiveRecord migrations" 13 | spec.description = spec.summary 14 | spec.homepage = "https://rubygems.org/gems/ar-check" 15 | spec.license = "MIT" 16 | 17 | spec.files = `git ls-files -z` 18 | .split("\x0") 19 | .reject {|f| f.match(%r{^(test|spec|features)/}) } 20 | spec.bindir = "exe" 21 | spec.executables = spec.files.grep(%r{^exe/}) {|f| File.basename(f) } 22 | spec.require_paths = ["lib"] 23 | 24 | spec.add_dependency "activerecord" 25 | 26 | spec.add_development_dependency "bundler" 27 | spec.add_development_dependency "minitest-utils" 28 | spec.add_development_dependency "pg" 29 | spec.add_development_dependency "pry-meta" 30 | spec.add_development_dependency "rake" 31 | spec.add_development_dependency "rubocop" 32 | spec.add_development_dependency "rubocop-fnando" 33 | spec.add_development_dependency "simplecov" 34 | end 35 | -------------------------------------------------------------------------------- /gemfiles/5_0.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | gemspec path: ".." 5 | 6 | gem "activerecord", "~> 5.0.0" 7 | -------------------------------------------------------------------------------- /gemfiles/5_1.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | gemspec path: ".." 5 | 6 | gem "activerecord", "~> 5.1.0" 7 | -------------------------------------------------------------------------------- /gemfiles/5_2.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | gemspec path: ".." 5 | 6 | gem "activerecord", "~> 5.2.0" 7 | -------------------------------------------------------------------------------- /gemfiles/6_0.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | gemspec path: ".." 5 | 6 | gem "activerecord", "~> 6.0.0" 7 | -------------------------------------------------------------------------------- /lib/ar-check.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "ar/check" 4 | -------------------------------------------------------------------------------- /lib/ar/check.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_support/all" 4 | require "active_record" 5 | require "active_record/connection_adapters/postgresql_adapter" 6 | require "active_record/migration/command_recorder" 7 | require "active_record/schema_dumper" 8 | require "ar/check/version" 9 | 10 | module AR 11 | module Check 12 | require "ar/check/command_recorder" 13 | require "ar/check/adapter" 14 | require "ar/check/schema_dumper" 15 | end 16 | end 17 | 18 | ActiveRecord::Migration::CommandRecorder.include AR::Check::CommandRecorder 19 | ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.include AR::Check::Adapter 20 | ActiveRecord::SchemaDumper.prepend AR::Check::SchemaDumper 21 | -------------------------------------------------------------------------------- /lib/ar/check/adapter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module AR 4 | module Check 5 | module Adapter 6 | def check_constraints(table) 7 | result = select_all <<~SQL 8 | SELECT c.conname AS name, 9 | pg_get_constraintdef(c.oid) AS expression, 10 | t1.relname AS table 11 | FROM pg_constraint c 12 | JOIN pg_class t1 ON c.conrelid = t1.oid 13 | JOIN pg_attribute a1 ON a1.attnum = c.conkey[1] AND a1.attrelid = t1.oid 14 | JOIN pg_namespace t2 ON c.connamespace = t2.oid 15 | WHERE c.contype = 'c' 16 | AND c.convalidated = TRUE 17 | AND t1.relname = '#{table}' 18 | AND t2.nspname = ANY (current_schemas(false)) 19 | SQL 20 | 21 | result.to_a 22 | end 23 | 24 | def add_check(table, constraint_name, expression) 25 | sql = <<-SQL 26 | ALTER TABLE #{table} 27 | ADD CONSTRAINT #{quote_column_name("#{constraint_name}_on_#{table}")} 28 | CHECK (#{expression}) 29 | SQL 30 | execute(sql) 31 | end 32 | 33 | def remove_check(table, constraint_name) 34 | sql = <<-SQL 35 | ALTER TABLE #{table} 36 | DROP CONSTRAINT #{quote_column_name("#{constraint_name}_on_#{table}")} 37 | SQL 38 | execute(sql) 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/ar/check/command_recorder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module AR 4 | module Check 5 | module CommandRecorder 6 | # Usage: 7 | # 8 | # add_check :users, :check_user_age, "age > 18" 9 | # 10 | def add_check(table, constraint_name, expression) 11 | record(__method__, [table, constraint_name, expression]) 12 | end 13 | 14 | # Usage: 15 | # 16 | # remove_check :users, :check_user_age 17 | # 18 | def remove_check(table, constraint_name) 19 | record(__method__, [table, constraint_name]) 20 | end 21 | 22 | def invert_add_check(args) 23 | table, constraint_name, _ = args 24 | [:remove_check, [table, constraint_name]] 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/ar/check/schema_dumper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module AR 4 | module Check 5 | module SchemaDumper 6 | def table(table_name, stream) 7 | super 8 | check_constraints(table_name, stream) 9 | end 10 | 11 | def check_constraints(table, stream) 12 | constraints = @connection.check_constraints(table) 13 | return if constraints.empty? 14 | 15 | constraints.each do |constraint| 16 | expression = constraint["expression"] 17 | .gsub(/^\s*CHECK\s+\(/i, "") 18 | .gsub(/\)$/, "") 19 | 20 | statement = [ 21 | "add_check", 22 | ":#{constraint['table']},", 23 | ":#{constraint['name'].gsub("_on_#{table}", '')},", 24 | expression.inspect 25 | ].join(" ") 26 | 27 | stream.puts " #{statement}" 28 | end 29 | 30 | stream.puts 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/ar/check/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module AR 4 | module Check 5 | VERSION = "0.2.2" 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /test/ar/check_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class CheckTest < Minitest::Test 6 | include TestHelper 7 | 8 | setup do 9 | recreate_table 10 | end 11 | 12 | test "adds constraint" do 13 | with_migration do 14 | def up 15 | add_check :things, :positive_quantity, "quantity >= 0" 16 | end 17 | end.up 18 | 19 | error = assert_raises(ActiveRecord::StatementInvalid) do 20 | Thing.create(quantity: -1) 21 | end 22 | 23 | assert_includes error.message, 24 | %[new row for relation "things" violates check constraint "positive_quantity_on_things"] 25 | end 26 | 27 | test "removes constraint" do 28 | with_migration do 29 | def up 30 | add_check :things, :positive_quantity, "quantity >= 0" 31 | end 32 | 33 | def down 34 | remove_check :things, :positive_quantity 35 | end 36 | 37 | up 38 | down 39 | end 40 | 41 | assert Thing.create(quantity: -1).persisted? 42 | end 43 | 44 | test "dumps schema" do 45 | with_migration do 46 | def up 47 | add_check :things, :slug_format, "slug ~* '^[a-z0-9-]{4,20}$'" 48 | end 49 | end.up 50 | 51 | stream = StringIO.new 52 | ActiveRecord::SchemaDumper.dump(ActiveRecord::Base.connection, stream) 53 | contents = stream.tap(&:rewind).read 54 | 55 | assert_includes contents, %[add_check :things, :slug_format, "((slug)::text ~* '^[a-z0-9-]{4,20}$'::text)"] 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "simplecov" 4 | SimpleCov.start 5 | 6 | require "bundler/setup" 7 | require "ar/check" 8 | require "minitest/utils" 9 | require "minitest/autorun" 10 | 11 | ActiveRecord::Base.establish_connection "postgres:///test" 12 | ActiveRecord::Migration.verbose = false 13 | 14 | class Thing < ActiveRecord::Base 15 | end 16 | 17 | module TestHelper 18 | def recreate_table 19 | ActiveRecord::Schema.define(version: 0) do 20 | begin 21 | drop_table(:things) 22 | rescue StandardError 23 | nil 24 | end 25 | 26 | create_table :things do |t| 27 | t.integer :quantity, default: 0 28 | t.string :slug 29 | end 30 | end 31 | end 32 | 33 | def with_migration(&block) 34 | migration_class = if ActiveRecord::Migration.respond_to?(:[]) 35 | ActiveRecord::Migration[ 36 | ActiveRecord::Migration.current_version 37 | ] 38 | else 39 | ActiveRecord::Migration 40 | end 41 | 42 | Class.new(migration_class, &block).new 43 | end 44 | end 45 | --------------------------------------------------------------------------------