├── .github └── workflows │ └── prs.yml ├── .gitignore ├── .simplecov ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── gemfiles ├── Gemfile.base ├── activerecord-5.2 │ ├── Gemfile.base │ ├── Gemfile.mysql2 │ ├── Gemfile.postgresql │ └── Gemfile.sqlite3 ├── activerecord-6.0 │ ├── Gemfile.base │ ├── Gemfile.mysql2 │ ├── Gemfile.postgresql │ └── Gemfile.sqlite3 ├── activerecord-6.1 │ ├── Gemfile.base │ ├── Gemfile.mysql2 │ ├── Gemfile.postgresql │ └── Gemfile.sqlite3 └── activerecord-7.0 │ ├── Gemfile.base │ ├── Gemfile.mysql2 │ ├── Gemfile.postgresql │ └── Gemfile.sqlite3 ├── lib ├── schema_auto_foreign_keys.rb └── schema_auto_foreign_keys │ ├── active_record │ └── connection_adapters │ │ └── sqlite3_adapter.rb │ ├── middleware │ ├── migration.rb │ └── schema.rb │ └── version.rb ├── schema_auto_foreign_keys.gemspec ├── schema_dev.yml └── spec ├── migration_spec.rb ├── schema_spec.rb ├── spec_helper.rb └── support └── matchers ├── automatic_foreign_key_matchers.rb ├── have_index.rb └── reference.rb /.github/workflows/prs.yml: -------------------------------------------------------------------------------- 1 | # This file was auto-generated by the schema_dev tool, based on the data in 2 | # ./schema_dev.yml 3 | # Please do not edit this file; any changes will be overwritten next time 4 | # schema_dev gets run. 5 | --- 6 | name: CI PR Builds 7 | 'on': 8 | push: 9 | branches: 10 | - master 11 | pull_request: 12 | concurrency: 13 | group: ci-${{ github.ref }} 14 | cancel-in-progress: true 15 | jobs: 16 | test: 17 | runs-on: ubuntu-latest 18 | strategy: 19 | fail-fast: false 20 | matrix: 21 | ruby: 22 | - '2.5' 23 | - '2.7' 24 | - '3.0' 25 | - '3.1' 26 | activerecord: 27 | - '5.2' 28 | - '6.0' 29 | - '6.1' 30 | - '7.0' 31 | db: 32 | - mysql2 33 | - sqlite3 34 | - skip 35 | dbversion: 36 | - skip 37 | exclude: 38 | - ruby: '3.0' 39 | activerecord: '5.2' 40 | - ruby: '3.1' 41 | activerecord: '5.2' 42 | - ruby: '2.5' 43 | activerecord: '7.0' 44 | - db: skip 45 | dbversion: skip 46 | include: 47 | - ruby: '2.5' 48 | activerecord: '5.2' 49 | db: postgresql 50 | dbversion: '9.6' 51 | - ruby: '2.5' 52 | activerecord: '6.0' 53 | db: postgresql 54 | dbversion: '9.6' 55 | - ruby: '2.5' 56 | activerecord: '6.1' 57 | db: postgresql 58 | dbversion: '9.6' 59 | - ruby: '2.7' 60 | activerecord: '5.2' 61 | db: postgresql 62 | dbversion: '9.6' 63 | - ruby: '2.7' 64 | activerecord: '6.0' 65 | db: postgresql 66 | dbversion: '9.6' 67 | - ruby: '2.7' 68 | activerecord: '6.1' 69 | db: postgresql 70 | dbversion: '9.6' 71 | - ruby: '2.7' 72 | activerecord: '7.0' 73 | db: postgresql 74 | dbversion: '9.6' 75 | - ruby: '3.0' 76 | activerecord: '6.0' 77 | db: postgresql 78 | dbversion: '9.6' 79 | - ruby: '3.0' 80 | activerecord: '6.1' 81 | db: postgresql 82 | dbversion: '9.6' 83 | - ruby: '3.0' 84 | activerecord: '7.0' 85 | db: postgresql 86 | dbversion: '9.6' 87 | - ruby: '3.1' 88 | activerecord: '6.0' 89 | db: postgresql 90 | dbversion: '9.6' 91 | - ruby: '3.1' 92 | activerecord: '6.1' 93 | db: postgresql 94 | dbversion: '9.6' 95 | - ruby: '3.1' 96 | activerecord: '7.0' 97 | db: postgresql 98 | dbversion: '9.6' 99 | env: 100 | BUNDLE_GEMFILE: "${{ github.workspace }}/gemfiles/activerecord-${{ matrix.activerecord }}/Gemfile.${{ matrix.db }}" 101 | MYSQL_DB_HOST: 127.0.0.1 102 | MYSQL_DB_USER: root 103 | MYSQL_DB_PASS: database 104 | POSTGRESQL_DB_HOST: 127.0.0.1 105 | POSTGRESQL_DB_USER: schema_plus_test 106 | POSTGRESQL_DB_PASS: database 107 | steps: 108 | - uses: actions/checkout@v2 109 | - name: Set up Ruby 110 | uses: ruby/setup-ruby@v1 111 | with: 112 | ruby-version: "${{ matrix.ruby }}" 113 | bundler-cache: true 114 | - name: Run bundle update 115 | run: bundle update 116 | - name: Start Mysql 117 | if: matrix.db == 'mysql2' 118 | run: | 119 | docker run --rm --detach \ 120 | -e MYSQL_ROOT_PASSWORD=$MYSQL_DB_PASS \ 121 | -p 3306:3306 \ 122 | --health-cmd "mysqladmin ping --host=127.0.0.1 --password=$MYSQL_DB_PASS --silent" \ 123 | --health-interval 5s \ 124 | --health-timeout 5s \ 125 | --health-retries 5 \ 126 | --name database mysql:5.6 127 | - name: Start Postgresql 128 | if: matrix.db == 'postgresql' 129 | run: | 130 | docker run --rm --detach \ 131 | -e POSTGRES_USER=$POSTGRESQL_DB_USER \ 132 | -e POSTGRES_PASSWORD=$POSTGRESQL_DB_PASS \ 133 | -p 5432:5432 \ 134 | --health-cmd "pg_isready -q" \ 135 | --health-interval 5s \ 136 | --health-timeout 5s \ 137 | --health-retries 5 \ 138 | --name database postgres:${{ matrix.dbversion }} 139 | - name: Wait for database to start 140 | if: "(matrix.db == 'postgresql' || matrix.db == 'mysql2')" 141 | run: | 142 | COUNT=0 143 | ATTEMPTS=20 144 | until [[ $COUNT -eq $ATTEMPTS ]]; do 145 | [ "$(docker inspect -f {{.State.Health.Status}} database)" == "healthy" ] && break 146 | echo $(( COUNT++ )) > /dev/null 147 | sleep 2 148 | done 149 | - name: Create testing database 150 | if: "(matrix.db == 'postgresql' || matrix.db == 'mysql2')" 151 | run: bundle exec rake create_ci_database 152 | - name: Run tests 153 | run: bundle exec rake spec 154 | - name: Shutdown database 155 | if: always() && (matrix.db == 'postgresql' || matrix.db == 'mysql2') 156 | run: docker stop database 157 | - name: Coveralls Parallel 158 | if: "${{ !env.ACT }}" 159 | uses: coverallsapp/github-action@master 160 | with: 161 | github-token: "${{ secrets.GITHUB_TOKEN }}" 162 | flag-name: run-${{ matrix.ruby }}-${{ matrix.activerecord }}-${{ matrix.db }}-${{ matrix.dbversion }} 163 | parallel: true 164 | finish: 165 | needs: test 166 | runs-on: ubuntu-latest 167 | steps: 168 | - name: Coveralls Finished 169 | if: "${{ !env.ACT }}" 170 | uses: coverallsapp/github-action@master 171 | with: 172 | github-token: "${{ secrets.GITHUB_TOKEN }}" 173 | parallel-finished: true 174 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /coverage 2 | /tmp 3 | /pkg 4 | /Gemfile.local 5 | 6 | *.lock 7 | *.log 8 | *.sqlite3 9 | !gemfiles/**/*.sqlite3 10 | -------------------------------------------------------------------------------- /.simplecov: -------------------------------------------------------------------------------- 1 | SimpleCov.configure do 2 | enable_coverage :branch 3 | add_filter '/spec/' 4 | 5 | add_group 'Binaries', '/bin/' 6 | add_group 'Libraries', '/lib/' 7 | 8 | if ENV['CI'] 9 | require 'simplecov-lcov' 10 | 11 | SimpleCov::Formatter::LcovFormatter.config do |c| 12 | c.report_with_single_file = true 13 | c.single_report_path = 'coverage/lcov.info' 14 | end 15 | 16 | formatter SimpleCov::Formatter::LcovFormatter 17 | end 18 | end 19 | 20 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | 3 | gemspec 4 | 5 | gemfile_local = File.expand_path '../Gemfile.local', __FILE__ 6 | eval File.read(gemfile_local), binding, gemfile_local if File.exist? gemfile_local 7 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 ronen barzel 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Gem Version](https://badge.fury.io/rb/schema_auto_foreign_keys.svg)](http://badge.fury.io/rb/schema_auto_foreign_keys) 2 | [![Build Status](https://github.com/SchemaPlus/schema_auto_foreign_keys/actions/workflows/prs.yml/badge.svg)](https://github.com/SchemaPlus/schema_auto_foreign_keys/actions) 3 | [![Coverage Status](https://coveralls.io/repos/github/SchemaPlus/schema_auto_foreign_keys/badge.svg?branch=master)](https://coveralls.io/github/SchemaPlus/schema_auto_foreign_keys?branch=master) 4 | 5 | # SchemaAutoForeignKeys 6 | 7 | 8 | SchemaAutoForeignKeys is part of the [SchemaPlus](https://github.com/SchemaPlus/) family of Ruby on Rails ActiveRecord extension gems. 9 | 10 | ## Usage 11 | 12 | Many of us think that it should goes without saying that if you define a foreign key *relation* in your database you should also define a foreign key *constraint*. 13 | 14 | Similarly, it should go without saying that if you have a foreign key *constraint* on a column, you should also have an *index* on that column. 15 | 16 | And if you include the `schema_auto_foreign_keys` gem, these will also go without typing! schema_auto_foreign_keys simply turns on some default behavior in your migrations: 17 | 18 | ```ruby 19 | t.integer :user_id # any column named xxxx_id defaults to... 20 | t.integer :user_id, foreign_key: true, index: true 21 | 22 | t.references :user # references defaults to... 23 | t.references :user, foreign_key: true, index: true 24 | 25 | t.belongs_to :user # belongs_to default to... 26 | t.belongs_to :user, foreign_key: true, index: true 27 | ``` 28 | 29 | Note that schema_auto_foreign_keys depends on the [schema_plus_foreign_keys](https://github.com/SchemaPlus/schema_plus_foreign_keys) and [schema_plus_indexes](https://github.com/SchemaPlus/schema_plus_indexes) gems, and so makes available their migration shortcuts. 30 | 31 | There is actually one difference between an auto-created index and specifying `index: true`: if you don't specify anything, schema_auto_foreign_keys will maintain "ownership" of the auto-created index: It will remove the index if the foreign key gets removed; and it will rename the index if the table gets renamed. 32 | 33 | ### Overriding 34 | 35 | If you need specific paramaters other than the default, you can of course specify them: 36 | 37 | ```ruby 38 | t.integer :user_id, index: :unique # "has one" relationship between users and this 39 | model 40 | t.integer :user_id, on_delete: :cascade 41 | ``` 42 | 43 | If you don't want a foreign key constraint (e.g. because "product_id" is a domain-level string rather than a foreign key), or an index just specify falsey: 44 | 45 | ```rugy 46 | t.integer :product_id, foreign_key: false # also implies index: false 47 | t.integer :product_id, references: nil 48 | t.integer :user_id, index: false 49 | ``` 50 | 51 | ## Configuration 52 | 53 | SchemaAutoForeignKeys adds two new entries to SchemaPlus::ForeignKeys' config: 54 | 55 | ```ruby 56 | SchemaPlus::ForeignKeys.setup do |config| 57 | config.auto_create = true # default for schema_auto_foreign_keys 58 | config.auto_index = true # default for schema_auto_foreign_keys 59 | end 60 | ``` 61 | 62 | You can also configure the behavior per-table in a migration: 63 | 64 | ```ruby 65 | create_table :posts, foreign_keys: { auto_create: true, auto_index: true } do |t| 66 | t.integer :author_id 67 | endf 68 | ``` 69 | 70 | 71 | ## Installation 72 | 73 | 74 | 75 | As usual: 76 | 77 | ```ruby 78 | gem "schema_auto_foreign_keys" # in a Gemfile 79 | gem.add_dependency "schema_auto_foreign_keys" # in a .gemspec 80 | ``` 81 | 82 | 83 | 84 | ## Compatibility 85 | 86 | SchemaAutoForeignKeys is tested on: 87 | 88 | 89 | 90 | * ruby **2.5** with activerecord **5.2**, using **mysql2**, **sqlite3** or **postgresql:9.6** 91 | * ruby **2.5** with activerecord **6.0**, using **mysql2**, **sqlite3** or **postgresql:9.6** 92 | * ruby **2.5** with activerecord **6.1**, using **mysql2**, **sqlite3** or **postgresql:9.6** 93 | * ruby **2.7** with activerecord **5.2**, using **mysql2**, **sqlite3** or **postgresql:9.6** 94 | * ruby **2.7** with activerecord **6.0**, using **mysql2**, **sqlite3** or **postgresql:9.6** 95 | * ruby **2.7** with activerecord **6.1**, using **mysql2**, **sqlite3** or **postgresql:9.6** 96 | * ruby **2.7** with activerecord **7.0**, using **mysql2**, **sqlite3** or **postgresql:9.6** 97 | * ruby **3.0** with activerecord **6.0**, using **mysql2**, **sqlite3** or **postgresql:9.6** 98 | * ruby **3.0** with activerecord **6.1**, using **mysql2**, **sqlite3** or **postgresql:9.6** 99 | * ruby **3.0** with activerecord **7.0**, using **mysql2**, **sqlite3** or **postgresql:9.6** 100 | * ruby **3.1** with activerecord **6.0**, using **mysql2**, **sqlite3** or **postgresql:9.6** 101 | * ruby **3.1** with activerecord **6.1**, using **mysql2**, **sqlite3** or **postgresql:9.6** 102 | * ruby **3.1** with activerecord **7.0**, using **mysql2**, **sqlite3** or **postgresql:9.6** 103 | 104 | 105 | 106 | ### Platform-specific Notes: 107 | 108 | MySQL automatically creates indexes for foreign key constraints, so when used with MySQL, schema_auto_foreign_keys doesn't include the auto-index capability. 109 | 110 | SQlite3 doesn't support renaming the auto-index whtn the table name changes. 111 | 112 | 113 | 114 | ## History 115 | 116 | * 1.1.0 - Add AR 6.1 and 7.0, Add Ruby 3.1. remvoe schema_plus_compatiblity dependency 117 | * 1.0.0 - Drop Ruby < 2.5 and Rails < 5.2, add Rails 6.0, and remove many deprecations 118 | * 0.1.3 - AR5 (Rails 5) Support 119 | * 0.1.2 - Missing require 120 | * 0.1.1 - Explicit gem dependencies 121 | * 0.1.0 - Initial release, extracted from schema_plus 2.0.0.pre* 122 | 123 | ## Development & Testing 124 | 125 | Are you interested in contributing to SchemaAutoForeignKeys? Thanks! Please follow 126 | the standard protocol: fork, feature branch, develop, push, and issue pull 127 | request. 128 | 129 | Some things to know about to help you develop and test: 130 | 131 | 132 | 133 | * **schema_dev**: SchemaAutoForeignKeys uses [schema_dev](https://github.com/SchemaPlus/schema_dev) to 134 | facilitate running rspec tests on the matrix of ruby, activerecord, and database 135 | versions that the gem supports, both locally and on 136 | [github actions](https://github.com/SchemaPlus/schema_auto_foreign_keys/actions) 137 | 138 | To to run rspec locally on the full matrix, do: 139 | 140 | $ schema_dev bundle install 141 | $ schema_dev rspec 142 | 143 | You can also run on just one configuration at a time; For info, see `schema_dev --help` or the [schema_dev](https://github.com/SchemaPlus/schema_dev) README. 144 | 145 | The matrix of configurations is specified in `schema_dev.yml` in 146 | the project root. 147 | 148 | 149 | 150 | 151 | 152 | 153 | * **schema_monkey**: SchemaAutoForeignKeys is implemented as a 154 | [schema_monkey](https://github.com/SchemaPlus/schema_monkey) client, 155 | using [schema_monkey](https://github.com/SchemaPlus/schema_monkey)'s 156 | convention-based protocols for extending ActiveRecord and using middleware stacks. 157 | 158 | 159 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler' 2 | Bundler::GemHelper.install_tasks 3 | 4 | require 'schema_dev/tasks' 5 | 6 | task default: :spec 7 | 8 | require 'rspec/core/rake_task' 9 | RSpec::Core::RakeTask.new(:spec) 10 | -------------------------------------------------------------------------------- /gemfiles/Gemfile.base: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | gemspec path: File.expand_path('..', __FILE__) 3 | 4 | File.exist?(gemfile_local = File.expand_path('../Gemfile.local', __FILE__)) and eval File.read(gemfile_local), binding, gemfile_local 5 | -------------------------------------------------------------------------------- /gemfiles/activerecord-5.2/Gemfile.base: -------------------------------------------------------------------------------- 1 | base_gemfile = File.expand_path('../../Gemfile.base', __FILE__) 2 | eval File.read(base_gemfile) 3 | 4 | gem "activerecord", ">= 5.2.0.beta0", "< 5.3" 5 | -------------------------------------------------------------------------------- /gemfiles/activerecord-5.2/Gemfile.mysql2: -------------------------------------------------------------------------------- 1 | base_gemfile = File.expand_path('../Gemfile.base', __FILE__) 2 | eval File.read(base_gemfile), binding, base_gemfile 3 | 4 | platform :ruby do 5 | gem "mysql2" 6 | end 7 | 8 | platform :jruby do 9 | gem 'activerecord-jdbcmysql-adapter' 10 | end 11 | -------------------------------------------------------------------------------- /gemfiles/activerecord-5.2/Gemfile.postgresql: -------------------------------------------------------------------------------- 1 | base_gemfile = File.expand_path('../Gemfile.base', __FILE__) 2 | eval File.read(base_gemfile), binding, base_gemfile 3 | 4 | platform :ruby do 5 | gem "pg" 6 | end 7 | 8 | platform :jruby do 9 | gem 'activerecord-jdbcpostgresql-adapter' 10 | end 11 | -------------------------------------------------------------------------------- /gemfiles/activerecord-5.2/Gemfile.sqlite3: -------------------------------------------------------------------------------- 1 | base_gemfile = File.expand_path('../Gemfile.base', __FILE__) 2 | eval File.read(base_gemfile), binding, base_gemfile 3 | 4 | platform :ruby do 5 | gem "sqlite3" 6 | end 7 | 8 | platform :jruby do 9 | gem 'activerecord-jdbcsqlite3-adapter', '>=1.3.0.beta2' 10 | end 11 | -------------------------------------------------------------------------------- /gemfiles/activerecord-6.0/Gemfile.base: -------------------------------------------------------------------------------- 1 | base_gemfile = File.expand_path('../../Gemfile.base', __FILE__) 2 | eval File.read(base_gemfile) 3 | 4 | gem "activerecord", ">= 6.0", "< 6.1" 5 | -------------------------------------------------------------------------------- /gemfiles/activerecord-6.0/Gemfile.mysql2: -------------------------------------------------------------------------------- 1 | base_gemfile = File.expand_path('../Gemfile.base', __FILE__) 2 | eval File.read(base_gemfile), binding, base_gemfile 3 | 4 | platform :ruby do 5 | gem "mysql2" 6 | end 7 | 8 | platform :jruby do 9 | gem 'activerecord-jdbcmysql-adapter' 10 | end 11 | -------------------------------------------------------------------------------- /gemfiles/activerecord-6.0/Gemfile.postgresql: -------------------------------------------------------------------------------- 1 | base_gemfile = File.expand_path('../Gemfile.base', __FILE__) 2 | eval File.read(base_gemfile), binding, base_gemfile 3 | 4 | platform :ruby do 5 | gem "pg" 6 | end 7 | 8 | platform :jruby do 9 | gem 'activerecord-jdbcpostgresql-adapter' 10 | end 11 | -------------------------------------------------------------------------------- /gemfiles/activerecord-6.0/Gemfile.sqlite3: -------------------------------------------------------------------------------- 1 | base_gemfile = File.expand_path('../Gemfile.base', __FILE__) 2 | eval File.read(base_gemfile), binding, base_gemfile 3 | 4 | platform :ruby do 5 | gem "sqlite3" 6 | end 7 | 8 | platform :jruby do 9 | gem 'activerecord-jdbcsqlite3-adapter', '>=1.3.0.beta2' 10 | end 11 | -------------------------------------------------------------------------------- /gemfiles/activerecord-6.1/Gemfile.base: -------------------------------------------------------------------------------- 1 | base_gemfile = File.expand_path('../../Gemfile.base', __FILE__) 2 | eval File.read(base_gemfile) 3 | 4 | gem "activerecord", ">= 6.1", "< 6.2" 5 | -------------------------------------------------------------------------------- /gemfiles/activerecord-6.1/Gemfile.mysql2: -------------------------------------------------------------------------------- 1 | base_gemfile = File.expand_path('../Gemfile.base', __FILE__) 2 | eval File.read(base_gemfile), binding, base_gemfile 3 | 4 | platform :ruby do 5 | gem "mysql2" 6 | end 7 | 8 | platform :jruby do 9 | gem 'activerecord-jdbcmysql-adapter' 10 | end 11 | -------------------------------------------------------------------------------- /gemfiles/activerecord-6.1/Gemfile.postgresql: -------------------------------------------------------------------------------- 1 | base_gemfile = File.expand_path('../Gemfile.base', __FILE__) 2 | eval File.read(base_gemfile), binding, base_gemfile 3 | 4 | platform :ruby do 5 | gem "pg" 6 | end 7 | 8 | platform :jruby do 9 | gem 'activerecord-jdbcpostgresql-adapter' 10 | end 11 | -------------------------------------------------------------------------------- /gemfiles/activerecord-6.1/Gemfile.sqlite3: -------------------------------------------------------------------------------- 1 | base_gemfile = File.expand_path('../Gemfile.base', __FILE__) 2 | eval File.read(base_gemfile), binding, base_gemfile 3 | 4 | platform :ruby do 5 | gem "sqlite3" 6 | end 7 | 8 | platform :jruby do 9 | gem 'activerecord-jdbcsqlite3-adapter', '>=1.3.0.beta2' 10 | end 11 | -------------------------------------------------------------------------------- /gemfiles/activerecord-7.0/Gemfile.base: -------------------------------------------------------------------------------- 1 | base_gemfile = File.expand_path('../../Gemfile.base', __FILE__) 2 | eval File.read(base_gemfile) 3 | 4 | gem "activerecord", ">= 7.0", "< 7.1" 5 | -------------------------------------------------------------------------------- /gemfiles/activerecord-7.0/Gemfile.mysql2: -------------------------------------------------------------------------------- 1 | base_gemfile = File.expand_path('../Gemfile.base', __FILE__) 2 | eval File.read(base_gemfile), binding, base_gemfile 3 | 4 | platform :ruby do 5 | gem "mysql2" 6 | end 7 | 8 | platform :jruby do 9 | gem 'activerecord-jdbcmysql-adapter' 10 | end 11 | -------------------------------------------------------------------------------- /gemfiles/activerecord-7.0/Gemfile.postgresql: -------------------------------------------------------------------------------- 1 | base_gemfile = File.expand_path('../Gemfile.base', __FILE__) 2 | eval File.read(base_gemfile), binding, base_gemfile 3 | 4 | platform :ruby do 5 | gem "pg" 6 | end 7 | 8 | platform :jruby do 9 | gem 'activerecord-jdbcpostgresql-adapter' 10 | end 11 | -------------------------------------------------------------------------------- /gemfiles/activerecord-7.0/Gemfile.sqlite3: -------------------------------------------------------------------------------- 1 | base_gemfile = File.expand_path('../Gemfile.base', __FILE__) 2 | eval File.read(base_gemfile), binding, base_gemfile 3 | 4 | platform :ruby do 5 | gem "sqlite3" 6 | end 7 | 8 | platform :jruby do 9 | gem 'activerecord-jdbcsqlite3-adapter', '>=1.3.0.beta2' 10 | end 11 | -------------------------------------------------------------------------------- /lib/schema_auto_foreign_keys.rb: -------------------------------------------------------------------------------- 1 | require 'schema_plus/foreign_keys' 2 | require 'schema_plus/indexes' 3 | 4 | require_relative 'schema_auto_foreign_keys/middleware/migration' 5 | require_relative 'schema_auto_foreign_keys/middleware/schema' 6 | 7 | module SchemaAutoForeignKeys 8 | module ActiveRecord 9 | module ConnectionAdapters 10 | autoload :Sqlite3Adapter, 'schema_auto_foreign_keys/active_record/connection_adapters/sqlite3_adapter' 11 | end 12 | end 13 | end 14 | 15 | class SchemaPlus::ForeignKeys::Config 16 | ## 17 | # :attr_accessor: auto_create 18 | # 19 | # Whether to automatically create foreign key constraints for columns 20 | # suffixed with +_id+. Boolean, default is +true+. 21 | has_value :auto_create, klass: :boolean, default: true 22 | 23 | ## 24 | # :attr_accessor: auto_index 25 | # 26 | # Whether to automatically create indexes when creating foreign key constraints for columns. 27 | # Boolean, default is +true+. 28 | has_value :auto_index, klass: :boolean, default: true 29 | end 30 | 31 | SchemaMonkey.register SchemaAutoForeignKeys 32 | -------------------------------------------------------------------------------- /lib/schema_auto_foreign_keys/active_record/connection_adapters/sqlite3_adapter.rb: -------------------------------------------------------------------------------- 1 | module SchemaAutoForeignKeys 2 | module ActiveRecord 3 | module ConnectionAdapters 4 | 5 | # SchemaPlus::ForeignKeys includes an Sqlite3 implementation of the AbstractAdapter 6 | # extensions. 7 | module Sqlite3Adapter 8 | 9 | def copy_table(*args, &block) 10 | fk_override = { auto_create: false, auto_index: false } 11 | save = Hash[fk_override.keys.collect{|key| [key, SchemaPlus::ForeignKeys.config.send(key)]}] 12 | begin 13 | SchemaPlus::ForeignKeys.config.update_attributes(fk_override) 14 | super 15 | ensure 16 | SchemaPlus::ForeignKeys.config.update_attributes(save) 17 | end 18 | end 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/schema_auto_foreign_keys/middleware/migration.rb: -------------------------------------------------------------------------------- 1 | require 'openssl' 2 | 3 | module SchemaAutoForeignKeys 4 | module AutoCreate 5 | # defined below 6 | end 7 | 8 | module Middleware 9 | module Migration 10 | module Column 11 | module PostgreSQL ; include AutoCreate ; end 12 | module SQLite3 ; include AutoCreate ; end 13 | module MySQL 14 | include AutoCreate 15 | def auto_index?(env, config) ; false end 16 | def remove_auto_index?(env) ; false end 17 | end 18 | end 19 | 20 | module RenameTable 21 | def after(env) 22 | newname = env.new_name 23 | oldname = env.table_name 24 | indexes = env.connection.indexes(newname) 25 | env.connection.foreign_keys(newname).each do |fk| 26 | index = indexes.find { |it| it.name == AutoCreate.auto_index_name(oldname, fk.column) } 27 | env.connection.rename_index(newname, index.name, AutoCreate.auto_index_name(newname, index.columns)) if index 28 | end 29 | end 30 | end 31 | end 32 | end 33 | 34 | module AutoCreate 35 | def before(env) 36 | config ||= env.caller.try(:schema_plus_foreign_keys_config) || SchemaPlus::ForeignKeys.config 37 | set_foreign_key(env) if auto_fk?(env, config) 38 | set_auto_index(env) if auto_index?(env, config) 39 | end 40 | 41 | def after(env) 42 | remove_auto_index(env) if env.operation == :change and remove_auto_index?(env) 43 | end 44 | 45 | def auto_fk?(env, config) 46 | return false if env.options.include? :foreign_key 47 | return false unless config.auto_create? 48 | return true if env.type == :reference 49 | return false if env.implements_reference 50 | return true if env.column_name.to_s =~ /_id$/ # later on add a config option for this 51 | end 52 | 53 | def auto_index?(env, config) 54 | return false if env.options.include? :index 55 | return false unless env.options[:foreign_key] 56 | return true if config.auto_index? 57 | end 58 | 59 | def remove_auto_index?(env) 60 | env.options.include? :foreign_key and not env.options[:foreign_key] 61 | end 62 | 63 | def set_foreign_key(env) 64 | env.options[:foreign_key] = true 65 | end 66 | 67 | def set_auto_index(env) 68 | env.options[:index] = { name: auto_index_name(env) } 69 | end 70 | 71 | def remove_auto_index(env) 72 | env.caller.remove_index(env.table_name, name: auto_index_name(env), column: env.column_name, if_exists: true) 73 | end 74 | 75 | def auto_index_name(env) 76 | AutoCreate.auto_index_name(env.table_name, env.column_name) 77 | end 78 | 79 | def self.auto_index_name(from_table, column_name) 80 | name = "fk__#{fixup_schema_name(from_table)}_#{Array.wrap(column_name).join('_and_')}" 81 | name = name.slice(0, 27) + "_" + OpenSSL::Digest::MD5.new.hexdigest(name) if name.length > 60 82 | name 83 | end 84 | 85 | def self.fixup_schema_name(table_name) 86 | # replace . with _ 87 | table_name.to_s.gsub(/[.]/, '_') 88 | end 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /lib/schema_auto_foreign_keys/middleware/schema.rb: -------------------------------------------------------------------------------- 1 | module SchemaAutoForeignKeys 2 | module Middleware 3 | module Schema 4 | module Define 5 | def around(env) 6 | fk_override = { auto_create: false, auto_index: false } 7 | save = Hash[fk_override.keys.collect{|key| [key, SchemaPlus::ForeignKeys.config.send(key)]}] 8 | begin 9 | SchemaPlus::ForeignKeys.config.update_attributes(fk_override) 10 | yield env 11 | ensure 12 | SchemaPlus::ForeignKeys.config.update_attributes(save) 13 | end 14 | end 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/schema_auto_foreign_keys/version.rb: -------------------------------------------------------------------------------- 1 | module SchemaAutoForeignKeys 2 | VERSION = "1.1.0" 3 | end 4 | -------------------------------------------------------------------------------- /schema_auto_foreign_keys.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'schema_auto_foreign_keys/version' 5 | 6 | Gem::Specification.new do |gem| 7 | gem.name = "schema_auto_foreign_keys" 8 | gem.version = SchemaAutoForeignKeys::VERSION 9 | gem.authors = ["ronen barzel"] 10 | gem.email = ["ronen@barzel.org"] 11 | gem.summary = %q{Automatically define foreign key constraints in ActiveRecord} 12 | gem.description = %q{In an ActiveRecord migration, set the default to create a foreign key and index for all columns that define relatoins.} 13 | gem.homepage = "https://github.com/SchemaPlus/schema_auto_foreign_keys" 14 | gem.license = "MIT" 15 | 16 | gem.files = `git ls-files -z`.split("\x0") 17 | gem.executables = gem.files.grep(%r{^bin/}) { |f| File.basename(f) } 18 | gem.test_files = gem.files.grep(%r{^(test|spec|features)/}) 19 | gem.require_paths = ["lib"] 20 | 21 | gem.required_ruby_version = ">= 2.5.0" 22 | 23 | gem.add_dependency "activerecord", ">= 5.2", "< 7.1" 24 | gem.add_dependency "schema_plus_foreign_keys", "~> 1.1.0" 25 | gem.add_dependency "schema_plus_indexes", "~> 1.0.1" 26 | 27 | gem.add_development_dependency "bundler" 28 | gem.add_development_dependency "rake", "~> 13.0" 29 | gem.add_development_dependency "rspec", "~> 3.0" 30 | gem.add_development_dependency "schema_dev", "~> 4.2.0" 31 | end 32 | -------------------------------------------------------------------------------- /schema_dev.yml: -------------------------------------------------------------------------------- 1 | ruby: 2 | - 2.5 3 | - 2.7 4 | - 3.0 5 | - 3.1 6 | activerecord: 7 | - 5.2 8 | - 6.0 9 | - 6.1 10 | - 7.0 11 | db: 12 | - mysql2 13 | - sqlite3 14 | - postgresql 15 | -------------------------------------------------------------------------------- /spec/migration_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require 'spec_helper' 3 | 4 | describe ActiveRecord::Migration do 5 | 6 | before(:each) do 7 | define_schema do 8 | 9 | create_table :users do |t| 10 | t.string :login, index: { unique: true} 11 | end 12 | 13 | create_table :members do |t| 14 | t.string :login 15 | end 16 | 17 | create_table :comments do |t| 18 | t.string :content 19 | t.integer :user 20 | t.bigint :user_id 21 | t.foreign_key :users, column: :user_id 22 | end 23 | 24 | create_table :posts do |t| 25 | t.string :content 26 | end 27 | end 28 | class User < ::ActiveRecord::Base ; end 29 | class Post < ::ActiveRecord::Base ; end 30 | class Comment < ::ActiveRecord::Base ; end 31 | User.reset_column_information 32 | Post.reset_column_information 33 | Comment.reset_column_information 34 | end 35 | 36 | around(:each) do |example| 37 | with_fk_config(auto_create: true, auto_index: true) { example.run } 38 | end 39 | 40 | context "when table is created" do 41 | 42 | before(:each) do 43 | @model = Post 44 | end 45 | 46 | it "creates auto foreign keys" do 47 | create_table(@model) do |t| 48 | t.bigint :user_id 49 | end 50 | expect(@model).to reference(:users, :id).on(:user_id) 51 | end 52 | 53 | it "respects explicit foreign key" do 54 | create_table(@model) do |t| 55 | t.bigint :author_id, foreign_key: { references: :users } 56 | end 57 | expect(@model).to reference(:users, :id).on(:author_id) 58 | expect(@model).to have_index.on(:author_id) 59 | end 60 | 61 | it "suppresses auto foreign key" do 62 | create_table(@model) do |t| 63 | t.bigint :member_id, foreign_key: false 64 | end 65 | expect(@model).not_to reference.on(:member_id) 66 | expect(@model).not_to have_index.on(:member_id) 67 | end 68 | 69 | it "suppresses auto foreign key using shortcut" do 70 | create_table(@model) do |t| 71 | t.bigint :member_id, references: nil 72 | end 73 | expect(@model).not_to reference.on(:member_id) 74 | expect(@model).not_to have_index.on(:member_id) 75 | end 76 | 77 | [:references, :belongs_to].each do |reftype| 78 | 79 | context "when define #{reftype}" do 80 | 81 | before(:each) do 82 | @model = Comment 83 | end 84 | 85 | it "auto creates foreign key" do 86 | create_reference(reftype, :post) 87 | expect(@model).to reference(:posts, :id).on(:post_id) 88 | end 89 | 90 | it "does not create a foreign_key if polymorphic" do 91 | create_reference(reftype, :post, polymorphic: true) 92 | expect(@model).not_to reference(:posts, :id).on(:post_id) 93 | end 94 | 95 | it "does not create a foreign_key with foreign_key: false" do 96 | create_reference(reftype, :post, foreign_key: false) 97 | expect(@model).not_to reference(:posts, :id).on(:post_id) 98 | end 99 | 100 | it "should create an index implicitly" do 101 | create_reference(reftype, :post) 102 | expect(@model).to have_index.on(:post_id) 103 | end 104 | 105 | it "should create exactly one index explicitly (#157)" do 106 | create_reference(reftype, :post, index: true) 107 | expect(@model).to have_index.on(:post_id) 108 | end 109 | 110 | it "should respect :unique (#157)" do 111 | create_reference(reftype, :post, index: :unique) 112 | expect(@model).to have_unique_index.on(:post_id) 113 | end 114 | 115 | it "should create a two-column index if polymophic and index requested" do 116 | create_reference(reftype, :post, polymorphic: true, index: true) 117 | expect(@model).to have_index.on([:post_id, :post_type]) 118 | end 119 | 120 | protected 121 | 122 | def create_reference(reftype, column_name, **kwargs) 123 | create_table(@model) do |t| 124 | t.send reftype, column_name, **kwargs.merge(type: :bigint) 125 | end 126 | end 127 | 128 | end 129 | end 130 | 131 | it "creates auto-index on foreign keys only" do 132 | create_table(@model) do |t| 133 | t.bigint :user_id 134 | t.bigint :application_id, references: nil 135 | t.bigint :state 136 | end 137 | expect(@model).to have_index.on(:user_id) 138 | expect(@model).not_to have_index.on(:application_id) 139 | expect(@model).not_to have_index.on(:state) 140 | end 141 | 142 | it "handles very long index names" do 143 | table = ("ta"*15 + "_id") 144 | column = ("co"*15 + "_id") 145 | expect { 146 | ActiveRecord::Migration.create_table table do |t| 147 | t.bigint column, foreign_key: { references: :members, name: "verylong" } 148 | end 149 | }.not_to raise_error 150 | expect(ActiveRecord::Base.connection.indexes(table).first.columns.first).to eq column 151 | end 152 | 153 | it "overrides foreign key auto_create positively" do 154 | with_fk_config(auto_create: false) do 155 | create_table @model, foreign_keys: { auto_create: true } do |t| 156 | t.bigint :user_id 157 | end 158 | expect(@model).to reference(:users, :id).on(:user_id) 159 | end 160 | end 161 | 162 | it "overrides foreign key auto_create negatively" do 163 | with_fk_config(auto_create: true) do 164 | create_table @model, foreign_keys: { auto_create: false } do |t| 165 | t.bigint :user_id 166 | end 167 | expect(@model).not_to reference.on(:user_id) 168 | end 169 | end 170 | 171 | it "overrides foreign key auto_index positively" do 172 | with_fk_config(auto_index: false) do 173 | create_table @model, foreign_keys: { auto_index: true } do |t| 174 | t.bigint :user_id 175 | end 176 | expect(@model).to have_index.on(:user_id) 177 | end 178 | end 179 | 180 | it "overrides foreign key auto_index negatively", mysql: :skip do 181 | with_fk_config(auto_index: true) do 182 | create_table @model, foreign_keys: { auto_index: false } do |t| 183 | t.bigint :user_id 184 | end 185 | expect(@model).not_to have_index.on(:user_id) 186 | end 187 | end 188 | 189 | it "disables auto-index for a column", mysql: :skip do 190 | with_fk_config(auto_index: true) do 191 | create_table @model do |t| 192 | t.bigint :user_id, index: false 193 | end 194 | expect(@model).not_to have_index.on(:user_id) 195 | end 196 | end 197 | 198 | end 199 | 200 | context "when table is changed", sqlite3: :skip do 201 | before(:each) do 202 | @model = Post 203 | end 204 | [false, true].each do |bulk| 205 | suffix = bulk ? ' with :bulk option' : "" 206 | 207 | it "auto creates a foreign key constraint"+suffix do 208 | # https://github.com/rails/rails/pull/35958 was never backported to Rails 5.2 209 | skip("Not supported on MySQL with Rails 5.2") if bulk && ActiveRecord::VERSION::MAJOR == 5 && ActiveRecord::Base.connection.adapter_name == "Mysql2" 210 | change_table(@model, bulk: bulk) do |t| 211 | t.bigint :user_id 212 | end 213 | expect(@model).to reference(:users, :id).on(:user_id) 214 | end 215 | 216 | context "migrate down" do 217 | it "removes an auto foreign key and index"+suffix do 218 | create_table Comment do |t| 219 | t.bigint :user_id 220 | end 221 | expect(Comment).to reference(:users, :id).on(:user_id) 222 | expect(Comment).to have_index.on(:user_id) 223 | migration = Class.new ::ActiveRecord::Migration::Current do 224 | define_method(:change) { 225 | change_table("comments", bulk: bulk) do |t| 226 | t.bigint :user_id 227 | end 228 | } 229 | end 230 | migration.migrate(:down) 231 | Comment.reset_column_information 232 | expect(Comment).not_to reference(:users, :id).on(:user_id) 233 | expect(Comment).not_to have_index.on(:user_id) 234 | end 235 | end 236 | end 237 | end 238 | 239 | context "when table is renamed", postgresql: :only do 240 | 241 | before(:each) do 242 | @model = Comment 243 | create_table @model do |t| 244 | t.bigint :user_id 245 | t.bigint :xyz, index: true 246 | end 247 | ActiveRecord::Migration.rename_table @model.table_name, :newname 248 | end 249 | 250 | it "should rename fk indexes" do 251 | index = ActiveRecord::Base.connection.indexes(:newname).find { |it| it.columns == ['user_id'] } 252 | expect(index.name).to match(/^fk__newname_/) 253 | end 254 | 255 | end 256 | 257 | context "when column is added", sqlite3: :skip do 258 | 259 | before(:each) do 260 | @model = Comment 261 | end 262 | 263 | it "auto creates foreign key" do 264 | add_column(:post_id, :bigint) do 265 | expect(@model).to reference(:posts, :id).on(:post_id) 266 | end 267 | end 268 | 269 | it "respects explicit foreign key" do 270 | add_column(:author_id, :bigint, foreign_key: { references: :users }) do 271 | expect(@model).to reference(:users, :id).on(:author_id) 272 | end 273 | end 274 | 275 | it "doesn't create foreign key if column doesn't look like foreign key" do 276 | add_column(:views_count, :bigint) do 277 | expect(@model).not_to reference.on(:views_count) 278 | end 279 | end 280 | 281 | it "doesn't create foreign key if declined explicitly" do 282 | add_column(:post_id, :bigint, foreign_key: false) do 283 | expect(@model).not_to reference.on(:post_id) 284 | end 285 | end 286 | 287 | it "shouldn't create foreign key if declined explicitly by shorthand" do 288 | add_column(:post_id, :bigint, references: nil) do 289 | expect(@model).not_to reference.on(:post_id) 290 | end 291 | end 292 | 293 | it "creates auto index" do 294 | add_column(:post_id, :bigint) do 295 | expect(@model).to have_index.on(:post_id) 296 | end 297 | end 298 | 299 | it "does not create auto-index for non-foreign keys" do 300 | add_column(:state, :bigint) do 301 | expect(@model).not_to have_index.on(:state) 302 | end 303 | end 304 | 305 | # MySQL creates an index on foreign key and we can't override that 306 | it "doesn't create auto-index if declined explicitly", mysql: :skip do 307 | add_column(:post_id, :bigint, index: false) do 308 | expect(@model).not_to have_index.on(:post_id) 309 | end 310 | end 311 | 312 | protected 313 | def add_column(column_name, type, **kwargs) 314 | table = @model.table_name 315 | ActiveRecord::Migration.add_column(table, column_name, type, **kwargs) 316 | @model.reset_column_information 317 | yield if block_given? 318 | ActiveRecord::Migration.remove_column(table, column_name) 319 | end 320 | 321 | end 322 | 323 | context "when column is changed" do 324 | 325 | before(:each) do 326 | @model = Comment 327 | end 328 | 329 | context "with foreign keys", sqlite3: :skip do 330 | 331 | context "and initially references to users table" do 332 | 333 | before(:each) do 334 | create_table @model do |t| 335 | t.bigint :user_id 336 | end 337 | end 338 | 339 | it "should have foreign key" do 340 | expect(@model).to reference(:users) 341 | end 342 | 343 | it "should drop foreign key if requested to do so" do 344 | change_column :user_id, :bigint, foreign_key: { references: nil } 345 | expect(@model).not_to reference(:users) 346 | end 347 | 348 | it "should remove auto-created index if foreign key is removed", mysql: :skip do 349 | expect(@model).to have_index.on(:user_id) # sanity check that index was auto-created 350 | change_column :user_id, :bigint, foreign_key: { references: nil } 351 | expect(@model).not_to have_index.on(:user_id) 352 | end 353 | 354 | end 355 | 356 | context "if column defined without foreign key but with index" do 357 | before(:each) do 358 | create_table @model do |t| 359 | t.bigint :user_id, foreign_key: false, index: true 360 | end 361 | end 362 | 363 | it "should create the index" do 364 | expect(@model).to have_index.on(:user_id) 365 | end 366 | 367 | it "adding foreign key should not fail due to attempt to auto-create existing index" do 368 | expect { change_column :user_id, :bigint, foreign_key: true }.to_not raise_error 369 | end 370 | end 371 | end 372 | 373 | context "without foreign keys" do 374 | 375 | it "doesn't auto-add foreign keys" do 376 | create_table @model do |t| 377 | t.bigint :user_id, foreign_key: false 378 | t.string :other_column 379 | end 380 | with_fk_auto_create do 381 | change_column :other_column, :text 382 | end 383 | expect(@model).to_not reference(:users) 384 | end 385 | 386 | end 387 | 388 | protected 389 | def change_column(column_name, type, **kwargs) 390 | table = @model.table_name 391 | ActiveRecord::Migration.change_column(table, column_name, type, **kwargs) 392 | @model.reset_column_information 393 | end 394 | 395 | end 396 | 397 | def create_table(model, opts={}, &block) 398 | ActiveRecord::Migration.create_table model.table_name, **opts.merge(force: true, id: :bigint), &block 399 | model.reset_column_information 400 | end 401 | 402 | def change_table(model, opts={}, &block) 403 | ActiveRecord::Migration.change_table model.table_name, **opts, &block 404 | model.reset_column_information 405 | end 406 | 407 | end 408 | -------------------------------------------------------------------------------- /spec/schema_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe ActiveRecord::Schema do 4 | 5 | let(:connection) { ActiveRecord::Base.connection } 6 | 7 | context "defining with auto_index and auto_create" do 8 | 9 | before(:each) do 10 | with_fk_config(auto_create: true, auto_index: true) do 11 | ActiveRecord::Schema.define do 12 | 13 | create_table :users, force: :cascade do 14 | end 15 | 16 | create_table :colors, force: :cascade do 17 | end 18 | 19 | create_table :shoes, force: :cascade do 20 | end 21 | 22 | create_table :posts, force: true do |t| 23 | t.bigint :user_id, references: :users, index: true 24 | t.bigint :shoe_id, references: :shoes # should not have an index (except mysql) 25 | t.bigint :color_id # should not have a foreign key nor index 26 | end 27 | end 28 | end 29 | end 30 | 31 | it "creates only explicity added indexes" do 32 | expected = SchemaDev::Rspec::Helpers.mysql? ? 2 : 1 33 | expect(connection.tables.collect { |table| connection.indexes(table) }.flatten.size).to eq(expected) 34 | end 35 | 36 | it "should create only explicity added foriegn keys" do 37 | expect(connection.tables.collect { |table| connection.foreign_keys(table) }.flatten.size).to eq(2) 38 | end 39 | 40 | end 41 | 42 | end 43 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'simplecov' 2 | SimpleCov.start unless SimpleCov.running 3 | 4 | $LOAD_PATH.unshift(File.dirname(__FILE__)) 5 | $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) 6 | 7 | require 'rspec' 8 | require 'active_record' 9 | require 'schema_auto_foreign_keys' 10 | require 'schema_dev/rspec' 11 | 12 | SchemaDev::Rspec.setup 13 | 14 | Dir[File.dirname(__FILE__) + "/support/**/*.rb"].each {|f| require f} 15 | 16 | RSpec.configure do |config| 17 | config.include(SchemaPlus::Matchers) 18 | config.warnings = true 19 | config.around(:each) do |example| 20 | with_fk_config(auto_create: true, auto_index: true) do 21 | ActiveRecord::Migration.suppress_messages do 22 | example.run 23 | end 24 | end 25 | end 26 | end 27 | 28 | def with_fk_config(opts={}, &block) 29 | save = opts.keys.map{|key| [key, SchemaPlus::ForeignKeys.config.send(key)]}.to_h 30 | begin 31 | SchemaPlus::ForeignKeys.config.update_attributes(opts) 32 | yield 33 | ensure 34 | SchemaPlus::ForeignKeys.config.update_attributes(save) 35 | end 36 | end 37 | 38 | def with_fk_auto_create(value = true, &block) 39 | with_fk_config(auto_create: value, &block) 40 | end 41 | 42 | def define_schema(&block) 43 | ActiveRecord::Schema.define do 44 | connection.tables.each do |table| 45 | drop_table table, force: :cascade 46 | end 47 | instance_eval &block 48 | end 49 | end 50 | 51 | SimpleCov.command_name "[ruby#{RUBY_VERSION}-activerecord#{::ActiveRecord.version}-#{ActiveRecord::Base.connection.adapter_name}]" 52 | -------------------------------------------------------------------------------- /spec/support/matchers/automatic_foreign_key_matchers.rb: -------------------------------------------------------------------------------- 1 | require 'support/matchers/reference' 2 | require 'support/matchers/have_index' 3 | -------------------------------------------------------------------------------- /spec/support/matchers/have_index.rb: -------------------------------------------------------------------------------- 1 | module SchemaPlus::Matchers 2 | 3 | class HaveIndex 4 | 5 | def initialize(expectation, options = {}) 6 | set_required_columns(expectation) 7 | @unique = options.delete(:unique) 8 | @name = options.delete(:name) 9 | end 10 | 11 | def matches?(model) 12 | @too_many = nil 13 | @model = model 14 | indexes = @model.indexes.select { |index| index.columns.to_set == @required_columns } 15 | if indexes.length > 1 16 | @too_many = indexes.length 17 | return false 18 | end 19 | index = indexes.first 20 | return index && (@unique ? index.unique : true) && (@name ? index.name == @name.to_s : true) 21 | end 22 | 23 | def failure_message(should_not = false) 24 | invert = should_not ? "not to" : "to" 25 | what = "" 26 | what += "unique " if @unique 27 | what += "named '{@name}'" if @name 28 | msg = "Expected #{@model.table_name} #{invert} contain one #{what}index on #{@required_columns.entries.inspect}" 29 | msg += "; found #{@too_many} indexes" if @too_many 30 | msg 31 | end 32 | 33 | def failure_message_when_negated 34 | failure_message(true) 35 | end 36 | 37 | def on(expectation) 38 | set_required_columns(expectation) 39 | self 40 | end 41 | 42 | private 43 | def set_required_columns(expectation) 44 | @required_columns = Array(expectation).collect(&:to_s).to_set 45 | end 46 | 47 | end 48 | 49 | def have_index(*expectation) 50 | options = expectation.extract_options! 51 | HaveIndex.new(expectation, options) 52 | end 53 | 54 | def have_unique_index(*expectation) 55 | options = expectation.extract_options! 56 | options[:unique] = true 57 | HaveIndex.new(expectation, options) 58 | end 59 | 60 | end 61 | -------------------------------------------------------------------------------- /spec/support/matchers/reference.rb: -------------------------------------------------------------------------------- 1 | module SchemaPlus::Matchers 2 | 3 | class Reference 4 | def initialize(expected) 5 | @column = @on_update = @on_delete = @deferrable = @name = @to_table = @primary_key = nil 6 | unless expected.empty? 7 | @to_table, @primary_key = Array(expected).map(&:to_s) 8 | end 9 | end 10 | 11 | def matches?(model) 12 | @model = model 13 | if @to_table 14 | @result = @model.foreign_keys.select do |fk| 15 | fk.to_table == @to_table && 16 | @primary_key.blank? ? true : fk.primary_key == @primary_key 17 | end 18 | else 19 | @result = @model.foreign_keys 20 | end 21 | @result.keep_if {|fk| Array.wrap(fk.column) == @column } if @column 22 | @result.keep_if {|fk| fk.on_update == @on_update } if @on_update 23 | @result.keep_if {|fk| fk.on_delete == @on_delete } if @on_delete 24 | @result.keep_if {|fk| fk.deferrable == @deferrable } if @deferrable 25 | @result.keep_if {|fk| fk.name == @name } if @name 26 | !@result.empty? 27 | end 28 | 29 | def failure_message(should_not = false) 30 | target_column = @column.present? ? "(#{Array.wrap(@column).join(', ')})" : "" 31 | destinantion_column = @to_table ? "#{@to_table}(#{Array.wrap(@primary_key).join(', ')})" : "anything" 32 | invert = should_not ? 'not' : '' 33 | msg = "Expected #{@model.table_name}#{target_column} to #{invert} reference #{destinantion_column}" 34 | with = [] 35 | with << "on_update=#{@on_update.inspect}" if @on_update 36 | with << "on_delete=#{@on_delete.inspect}" if @on_delete 37 | with << "deferrable=#{@deferrable.inspect}" if @deferrable 38 | with << "name=#{@name.inspect}" if @name 39 | msg += " with #{with.join(" and ")}" if with.any? 40 | msg 41 | end 42 | 43 | def failure_message_when_negated 44 | failure_message(true) 45 | end 46 | 47 | def on(*column) 48 | @column = column.collect(&:to_s) 49 | self 50 | end 51 | 52 | def on_update(action) 53 | @on_update = action 54 | self 55 | end 56 | 57 | def deferrable(action) 58 | @deferrable = action 59 | self 60 | end 61 | 62 | def on_delete(action) 63 | @on_delete = action 64 | self 65 | end 66 | 67 | def with_name(action) 68 | @name = action 69 | self 70 | end 71 | 72 | end 73 | 74 | def reference(*expect) 75 | Reference.new(expect) 76 | end 77 | 78 | end 79 | 80 | --------------------------------------------------------------------------------