├── .github └── workflows │ └── prs.yml ├── .gitignore ├── .simplecov ├── Gemfile ├── MIT-LICENSE ├── README.md ├── Rakefile ├── gemfiles ├── Gemfile.base ├── activerecord-5.2 │ ├── Gemfile.base │ ├── Gemfile.mysql2 │ ├── Gemfile.postgresql │ └── Gemfile.sqlite3 ├── activerecord-6.0 │ ├── Gemfile.base │ ├── Gemfile.mysql2 │ ├── Gemfile.postgresql │ └── Gemfile.sqlite3 ├── activerecord-6.1 │ ├── Gemfile.base │ ├── Gemfile.mysql2 │ ├── Gemfile.postgresql │ └── Gemfile.sqlite3 └── activerecord-7.0 │ ├── Gemfile.base │ ├── Gemfile.mysql2 │ ├── Gemfile.postgresql │ └── Gemfile.sqlite3 ├── init.rb ├── lib ├── schema_associations.rb └── schema_associations │ ├── active_record │ └── associations.rb │ └── version.rb ├── schema_associations.gemspec ├── schema_dev.yml └── spec ├── association_spec.rb └── spec_helper.rb /.github/workflows/prs.yml: -------------------------------------------------------------------------------- 1 | # This file was auto-generated by the schema_dev tool, based on the data in 2 | # ./schema_dev.yml 3 | # Please do not edit this file; any changes will be overwritten next time 4 | # schema_dev gets run. 5 | --- 6 | name: CI PR Builds 7 | 'on': 8 | push: 9 | branches: 10 | - master 11 | pull_request: 12 | concurrency: 13 | group: ci-${{ github.ref }} 14 | cancel-in-progress: true 15 | jobs: 16 | test: 17 | runs-on: ubuntu-latest 18 | strategy: 19 | fail-fast: false 20 | matrix: 21 | ruby: 22 | - '2.5' 23 | - '2.7' 24 | - '3.0' 25 | - '3.1' 26 | activerecord: 27 | - '5.2' 28 | - '6.0' 29 | - '6.1' 30 | - '7.0' 31 | db: 32 | - mysql2 33 | - sqlite3 34 | - skip 35 | dbversion: 36 | - skip 37 | exclude: 38 | - ruby: '3.0' 39 | activerecord: '5.2' 40 | - ruby: '3.1' 41 | activerecord: '5.2' 42 | - ruby: '2.5' 43 | activerecord: '7.0' 44 | - db: skip 45 | dbversion: skip 46 | include: 47 | - ruby: '2.5' 48 | activerecord: '5.2' 49 | db: postgresql 50 | dbversion: '9.6' 51 | - ruby: '2.5' 52 | activerecord: '6.0' 53 | db: postgresql 54 | dbversion: '9.6' 55 | - ruby: '2.5' 56 | activerecord: '6.1' 57 | db: postgresql 58 | dbversion: '9.6' 59 | - ruby: '2.7' 60 | activerecord: '5.2' 61 | db: postgresql 62 | dbversion: '9.6' 63 | - ruby: '2.7' 64 | activerecord: '6.0' 65 | db: postgresql 66 | dbversion: '9.6' 67 | - ruby: '2.7' 68 | activerecord: '6.1' 69 | db: postgresql 70 | dbversion: '9.6' 71 | - ruby: '2.7' 72 | activerecord: '7.0' 73 | db: postgresql 74 | dbversion: '9.6' 75 | - ruby: '3.0' 76 | activerecord: '6.0' 77 | db: postgresql 78 | dbversion: '9.6' 79 | - ruby: '3.0' 80 | activerecord: '6.1' 81 | db: postgresql 82 | dbversion: '9.6' 83 | - ruby: '3.0' 84 | activerecord: '7.0' 85 | db: postgresql 86 | dbversion: '9.6' 87 | - ruby: '3.1' 88 | activerecord: '6.0' 89 | db: postgresql 90 | dbversion: '9.6' 91 | - ruby: '3.1' 92 | activerecord: '6.1' 93 | db: postgresql 94 | dbversion: '9.6' 95 | - ruby: '3.1' 96 | activerecord: '7.0' 97 | db: postgresql 98 | dbversion: '9.6' 99 | env: 100 | BUNDLE_GEMFILE: "${{ github.workspace }}/gemfiles/activerecord-${{ matrix.activerecord }}/Gemfile.${{ matrix.db }}" 101 | MYSQL_DB_HOST: 127.0.0.1 102 | MYSQL_DB_USER: root 103 | MYSQL_DB_PASS: database 104 | POSTGRESQL_DB_HOST: 127.0.0.1 105 | POSTGRESQL_DB_USER: schema_plus_test 106 | POSTGRESQL_DB_PASS: database 107 | steps: 108 | - uses: actions/checkout@v2 109 | - name: Set up Ruby 110 | uses: ruby/setup-ruby@v1 111 | with: 112 | ruby-version: "${{ matrix.ruby }}" 113 | bundler-cache: true 114 | - name: Run bundle update 115 | run: bundle update 116 | - name: Start Mysql 117 | if: matrix.db == 'mysql2' 118 | run: | 119 | docker run --rm --detach \ 120 | -e MYSQL_ROOT_PASSWORD=$MYSQL_DB_PASS \ 121 | -p 3306:3306 \ 122 | --health-cmd "mysqladmin ping --host=127.0.0.1 --password=$MYSQL_DB_PASS --silent" \ 123 | --health-interval 5s \ 124 | --health-timeout 5s \ 125 | --health-retries 5 \ 126 | --name database mysql:5.6 127 | - name: Start Postgresql 128 | if: matrix.db == 'postgresql' 129 | run: | 130 | docker run --rm --detach \ 131 | -e POSTGRES_USER=$POSTGRESQL_DB_USER \ 132 | -e POSTGRES_PASSWORD=$POSTGRESQL_DB_PASS \ 133 | -p 5432:5432 \ 134 | --health-cmd "pg_isready -q" \ 135 | --health-interval 5s \ 136 | --health-timeout 5s \ 137 | --health-retries 5 \ 138 | --name database postgres:${{ matrix.dbversion }} 139 | - name: Wait for database to start 140 | if: "(matrix.db == 'postgresql' || matrix.db == 'mysql2')" 141 | run: | 142 | COUNT=0 143 | ATTEMPTS=20 144 | until [[ $COUNT -eq $ATTEMPTS ]]; do 145 | [ "$(docker inspect -f {{.State.Health.Status}} database)" == "healthy" ] && break 146 | echo $(( COUNT++ )) > /dev/null 147 | sleep 2 148 | done 149 | - name: Create testing database 150 | if: "(matrix.db == 'postgresql' || matrix.db == 'mysql2')" 151 | run: bundle exec rake create_ci_database 152 | - name: Run tests 153 | run: bundle exec rake spec 154 | - name: Shutdown database 155 | if: always() && (matrix.db == 'postgresql' || matrix.db == 'mysql2') 156 | run: docker stop database 157 | - name: Coveralls Parallel 158 | if: "${{ !env.ACT }}" 159 | uses: coverallsapp/github-action@master 160 | with: 161 | github-token: "${{ secrets.GITHUB_TOKEN }}" 162 | flag-name: run-${{ matrix.ruby }}-${{ matrix.activerecord }}-${{ matrix.db }}-${{ matrix.dbversion }} 163 | parallel: true 164 | finish: 165 | needs: test 166 | runs-on: ubuntu-latest 167 | steps: 168 | - name: Coveralls Finished 169 | if: "${{ !env.ACT }}" 170 | uses: coverallsapp/github-action@master 171 | with: 172 | github-token: "${{ secrets.GITHUB_TOKEN }}" 173 | parallel-finished: true 174 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## MAC OS 2 | .DS_Store 3 | 4 | ## TEXTMATE 5 | *.tmproj 6 | tmtags 7 | 8 | ## EMACS 9 | *~ 10 | \#* 11 | .\#* 12 | 13 | ## VIM 14 | .*.sw? 15 | 16 | ## PROJECT::GENERAL 17 | coverage 18 | rdoc 19 | pkg 20 | 21 | ## PROJECT::SPECIFIC 22 | .rvmrc 23 | *.log 24 | tmp/ 25 | Gemfile.lock 26 | gemfiles/*.lock 27 | gemfiles/**/*.lock 28 | /.idea 29 | -------------------------------------------------------------------------------- /.simplecov: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | SimpleCov.configure do 4 | enable_coverage :branch 5 | add_filter '/spec/' 6 | 7 | add_group 'Binaries', '/bin/' 8 | add_group 'Libraries', '/lib/' 9 | 10 | if ENV['CI'] 11 | require 'simplecov-lcov' 12 | 13 | SimpleCov::Formatter::LcovFormatter.config do |c| 14 | c.report_with_single_file = true 15 | c.single_report_path = 'coverage/lcov.info' 16 | end 17 | 18 | formatter SimpleCov::Formatter::LcovFormatter 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "http://rubygems.org" 4 | gemspec 5 | 6 | gemfile_local = File.expand_path '../Gemfile.local', __FILE__ 7 | eval File.read(gemfile_local), binding, gemfile_local if File.exist? gemfile_local 8 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2006 RedHill Consulting, Pty. Ltd. 2 | Copyright (c) 2009 Michal Lomnicki & Ronen Barzel 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining 5 | a copy of this software and associated documentation files (the 6 | "Software"), to deal in the Software without restriction, including 7 | without limitation the rights to use, copy, modify, merge, publish, 8 | distribute, sublicense, and/or sell copies of the Software, and to 9 | permit persons to whom the Software is furnished to do so, subject to 10 | the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | Except as contained in this notice, the name(s) of the above copyright 16 | holders shall not be used in advertising or otherwise to promote the sale, 17 | use or other dealings in this Software without prior written authorization. 18 | 19 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 20 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 21 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 22 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 23 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 24 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 25 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SchemaAssociations 2 | 3 | SchemaAssociations is an ActiveRecord extension that keeps your model class 4 | definitions simpler and more DRY, by automatically defining associations based 5 | on the database schema. 6 | 7 | [![Gem Version](https://badge.fury.io/rb/schema_associations.svg)](http://badge.fury.io/rb/schema_associations) 8 | [![Build Status](https://github.com/SchemaPlus/schema_associations/actions/workflows/pr.yml/badge.svg)](http://github.com/SchemaPlus/schema_associations/actions) 9 | [![Coverage Status](https://coveralls.io/github/SchemaPlus/schema_associations/badge.svg)](https://coveralls.io/github/SchemaPlus/schema_associations) 10 | 11 | 12 | ## Overview 13 | 14 | One of the great things about Rails (ActiveRecord, in particular) is that it 15 | inspects the database and automatically defines accessors for all your 16 | columns, keeping your model class definitions simple and DRY. That's great 17 | for simple data columns, but where it falls down is when your table contains 18 | references to other tables: then the "accessors" you need are the associations 19 | defined using `belongs_to`, `has_one`, `has_many`, and 20 | `has_and_belongs_to_many` -- and you need to put them into your model class 21 | definitions by hand. In fact, for every relation, you need to define two 22 | associations each listing its inverse, such as 23 | 24 | ```ruby 25 | class Post < ActiveRecord::Base 26 | has_many :comments, inverse_of: :post 27 | end 28 | 29 | class Comment < ActiveRecord::Base 30 | belongs_to :post, inverse_of: :comments 31 | end 32 | ``` 33 | 34 | ....which isn't so DRY. 35 | 36 | Enter the SchemaAssociations gem. It extends ActiveRecord to automatically define the appropriate associations based on foreign key constraints in the database. 37 | 38 | SchemaAssociations works particularly well with the 39 | [schema_auto_foreign_keys](http://github.com/SchemaPlus/schema_auto_foreign_keys) gem which automatically 40 | defines foreign key constraints. So the common case is simple -- if you have this in your migration: 41 | 42 | ```ruby 43 | create_table :posts do |t| 44 | end 45 | 46 | create_table :comments do |t| 47 | t.integer post_id 48 | end 49 | ``` 50 | 51 | Then all you need for your models is: 52 | 53 | ```ruby 54 | class Post < ActiveRecord::Base 55 | end 56 | 57 | class Comment < ActiveRecord::Base 58 | end 59 | ``` 60 | 61 | and SchemaAssociations defines the appropriate associations under the hood. 62 | 63 | ### What if I want something special? 64 | 65 | You're always free to define associations yourself, if for example you want to 66 | pass special options. SchemaAssociations won't clobber any existing 67 | definitions. 68 | 69 | You can also control the behavior with various options, via a global initializer and/or per-model. See the [Configuration section](#configuration) for the available options. 70 | 71 | ### This seems cool, but I'm worried about too much automagic 72 | 73 | You can globally turn off automatic creation in 74 | `config/initializers/schema_associations.rb`: 75 | 76 | ```ruby 77 | SchemaAssociations.setup do |config| 78 | config.auto_create = false 79 | end 80 | ``` 81 | 82 | Then in any model where you want automatic associations, just do 83 | 84 | ```ruby 85 | class Post < ActiveRecord::Base 86 | schema_associations 87 | end 88 | ``` 89 | 90 | You can also pass options as described in [Configuration](#configuration) 91 | 92 | ## Full Details 93 | 94 | ### The basics 95 | 96 | The common cases work entirely as you'd expect. For a one-to-many 97 | relationship using standard naming conventions: 98 | 99 | ```ruby 100 | # 101 | # migration: 102 | # 103 | create_table :comments do |t| 104 | t.integer post_id 105 | end 106 | 107 | # 108 | # schema_associations defines: 109 | # 110 | class Post < ActiveRecord::Base 111 | has_many :comments 112 | end 113 | 114 | class Comment < ActiveReocrd::Base 115 | belongs_to :post 116 | end 117 | ``` 118 | 119 | For a one-to-one relationship: 120 | 121 | ```ruby 122 | # 123 | # migration: 124 | # 125 | create_table :comments do |t| 126 | t.integer post_id, index: :unique # (using the :index option provided by schema_plus ) 127 | end 128 | 129 | # 130 | # schema_associations defines: 131 | # 132 | class Post < ActiveRecord::Base 133 | has_one :comment 134 | end 135 | 136 | class Comment < ActiveReocrd::Base 137 | belongs_to :post 138 | end 139 | ``` 140 | 141 | And for many-to-many relationships: 142 | 143 | ```ruby 144 | # 145 | # migration: 146 | # 147 | create_table :groups_members do |t| 148 | integer :group_id 149 | integer :member_id 150 | end 151 | 152 | # 153 | # schema_associations defines: 154 | # 155 | class Group < ActiveReocrd::Base 156 | has_and_belongs_to_many :members 157 | end 158 | 159 | class Member < ActiveRecord::Base 160 | has_and_belongs_to_many :groups 161 | end 162 | ``` 163 | 164 | ### Unusual names, multiple references 165 | 166 | Sometimes you want or need to deviate from the simple naming conventions. In 167 | this case, the `belongs_to` relationship name is taken from the name of the 168 | foreign key column, and the `has_many` or `has_one` is named by the 169 | referencing table, suffixed with "as" the relationship name. An example 170 | should make this clear... 171 | 172 | Suppose your company hires interns, and each intern is assigned a manager and 173 | a mentor, who are regular employees. 174 | 175 | ```ruby 176 | create_table :interns do |t| 177 | t.integer :manager_id, references: :employees 178 | t.integer :mentor_id, references: :employees 179 | end 180 | ``` 181 | 182 | SchemaAssociations defines a `belongs_to` association for each reference, 183 | named according to the column: 184 | 185 | ```ruby 186 | class Intern < ActiveRecord::Base 187 | belongs_to :manager, class_name: "Employee", foreign_key: "manager_id" 188 | belongs_to :mentor, class_name: "Employee", foreign_key: "mentor_id" 189 | end 190 | ``` 191 | 192 | And the corresponding `has_many` association each gets a suffix to indicate 193 | which one relation it refers to: 194 | 195 | ```ruby 196 | class Employee < ActiveRecord::Base 197 | has_many :interns_as_manager, class_name: "Intern", foreign_key: "manager_id" 198 | has_many :interns_as_mentor, class_name: "Intern", foreign_key: "mentor_id" 199 | end 200 | ``` 201 | 202 | ### Special case for trees 203 | 204 | If your forward relation is named "parent", SchemaAssociations names the 205 | reverse relation "child" or "children". That is, if you have: 206 | 207 | ```ruby 208 | create_table :nodes do |t| 209 | t.integer :parent_id # schema_plus assumes it's a reference to this table 210 | end 211 | ``` 212 | 213 | Then SchemaAssociations will define 214 | 215 | ```ruby 216 | class Node < ActiveRecord::Base 217 | belongs_to :parent, class_name: "Node", foreign_key: "parent_id" 218 | has_many :children, class_name: "Node", foreign_key: "parent_id" 219 | end 220 | ``` 221 | 222 | ### Concise names 223 | 224 | For modularity in your tables and classes, you might use a common prefix for 225 | related objects. For example, you may have widgets each of which has a color, and each widget might have one frob that has a top color and a bottom color--all from the same set of colors. 226 | 227 | ```ruby 228 | create_table :widget_colors do |t| 229 | end 230 | 231 | create_table :widgets do |t| 232 | t.integer :widget_color_id 233 | end 234 | 235 | create_table :widget_frobs do |t| 236 | t.integer :widget_id, index: :unique 237 | t.integer :top_widget_color_id, references: :widget_colors 238 | t.integer :bottom_widget_color_id, references: :widget_colors 239 | end 240 | ``` 241 | 242 | Using the full name for the associations would make your code verbose and not 243 | quite DRY: 244 | 245 | ```ruby 246 | @widget.widget_color 247 | @widget.widget_frob.top_widget_color 248 | ``` 249 | 250 | Instead, by default, SchemaAssociations uses concise names: shared leading 251 | words are removed from the association name. So instead of the above, your 252 | code looks like: 253 | 254 | ```ruby 255 | @widget.color 256 | @widget.frob.top_color 257 | ``` 258 | 259 | i.e. these associations would be defined: 260 | 261 | ```ruby 262 | class WidgetColor < ActiveRecord::Base 263 | has_many :widgets, class_name: "Widget", foreign_key: "widget_color_id" 264 | has_many :frobs_as_top, class_name: "WidgetFrob", foreign_key: "top_widget_color_id" 265 | has_many :frobs_as_bottom, class_name: "WidgetFrob", foreign_key: "bottom_widget_color_id" 266 | end 267 | 268 | class Widget < ActiveRecord::Base 269 | belongs_to :color, class_name: "WidgetColor", foreign_key: "widget_color_id" 270 | has_one :frob, class_name: "WidgetFrob", foreign_key: "widget_frob_id" 271 | end 272 | 273 | class WidgetFrob < ActiveRecord::Base 274 | belongs_to :top_color, class_name: "WidgetColor", foreign_key: "top_widget_color_id" 275 | belongs_to :bottom_color, class_name: "WidgetColor", foreign_key: "bottom_widget_color_id" 276 | belongs_to :widget, class_name: "Widget", foreign_key: "widget_id" 277 | end 278 | ``` 279 | 280 | If you like the formality of using full names for the asociations, you can 281 | turn off concise names globally or per-model, see [Configuration](#configuration). 282 | 283 | ### Ordering `has_many` using `position` 284 | 285 | If the target of a `has_many` association has a column named `position`, 286 | SchemaAssociations will specify `order: :position` for the association. 287 | That is, 288 | 289 | ```ruby 290 | create_table :comments do |t| 291 | t.integer post_id 292 | t.integer position 293 | end 294 | ``` 295 | 296 | leads to 297 | 298 | ```ruby 299 | class Post < ActiveRecord::Base 300 | has_many :comments, order: :position 301 | end 302 | ``` 303 | 304 | ## Table names, model class names, and modules 305 | 306 | SchemaAssociations determines the model class name from the table name using the same convention (and helpers) that ActiveRecord uses. But sometimes you might be doing things differently. For example, in an engine you might have a prefix that goes in front of all table names, and the models might all be namespaced in a module. 307 | 308 | To that end, SchemaAssociations lets you configure mappings from a table name prefix to a model class name prefix to use instead. For example, suppose your database had tables: 309 | 310 | ```ruby 311 | hpy_campers 312 | hpy_go_lucky 313 | ``` 314 | 315 | The default model class names would be 316 | 317 | ```ruby 318 | HpyCampers 319 | HpyGoLucky 320 | ``` 321 | 322 | But if instead you wanted 323 | 324 | ```ruby 325 | Happy::Campers 326 | Happy::GoLucky 327 | ``` 328 | 329 | you would define the mapping in the [configuration](#configuration): 330 | 331 | ```ruby 332 | SchemaPlus.setup do |config| 333 | config.table_prefix_map["hpy_"] = "Happy::" 334 | end 335 | ``` 336 | 337 | Tables names that don't start with `hpy_` will continue to use the default determination. 338 | 339 | You can set up multiple mappings. E.g. if you're using several engines they can each set up the mapping for their own modules. 340 | 341 | You can set up a mapping from or to the empty string, in order to unconditionally add or remove prefixes from all model class names. 342 | 343 | 344 | ## How do I know what it did? 345 | 346 | If you're curious (or dubious) about what associations SchemaAssociations 347 | defines, you can check the log file. For every assocation that 348 | SchemaAssociations defines, it generates a debug entry such as 349 | 350 | [schema_associations] Post.has_many :comments, :class_name "Comment", :foreign_key "comment_id" 351 | 352 | which shows the exact method definition call. 353 | 354 | 355 | SchemaAssociations defines the associations lazily, only creating them when 356 | they're first needed. So you may need to search through the log file to find 357 | them all (and some may not be defined at all if they were never needed for the 358 | use cases that you logged). 359 | 360 | ## Configuration 361 | 362 | You can configure options globally in an initializer such as `config/initializers/schema_associations.rb`, e.g. 363 | 364 | ```ruby 365 | SchemaAssociations.setup do |config| 366 | config.concise_names = false 367 | end 368 | ``` 369 | 370 | and/or override the options per-model, e.g.: 371 | 372 | ```ruby 373 | class MyModel < ActiveRecord::Base 374 | schema_associations.config concise_names: false 375 | end 376 | ``` 377 | 378 | Here's the full list of options, with their default values: 379 | 380 | ```ruby 381 | SchemaAssociations.setup do |config| 382 | 383 | # Enable/disable SchemaAssociations' automatic behavior 384 | config.auto_create = true 385 | 386 | # Whether to use concise naming (strip out common prefixes from class names) 387 | config.concise_names = true 388 | 389 | # List of association names to exclude from automatic creation. 390 | # Value is a single name, an array of names, or nil. 391 | config.except = nil 392 | 393 | # List of association names to include in automatic creation. 394 | # Value is a single name, and array of names, or nil. 395 | config.only = nil 396 | 397 | # List of association types to exclude from automatic creation. 398 | # Value is one or an array of :belongs_to, :has_many, :has_one, and/or 399 | # :has_and_belongs_to_many, or nil. 400 | config.except_type = nil 401 | 402 | # List of association types to include in automatic creation. 403 | # Value is one or an array of :belongs_to, :has_many, :has_one, and/or 404 | # :has_and_belongs_to_many, or nil. 405 | config.only_type = nil 406 | 407 | # Hash whose keys are possible matches at the start of table names, and 408 | # whose corresponding values are the prefix to use in front of class 409 | # names. 410 | config.table_prefix_map = {} 411 | end 412 | ``` 413 | 414 | 415 | ## Compatibility 416 | 417 | SchemaAssociations is tested on all combinations of: 418 | 419 | 420 | 421 | * ruby **2.5** with activerecord **5.2**, using **mysql2**, **postgresql:9.6** or **sqlite3** 422 | * ruby **2.5** with activerecord **6.0**, using **mysql2**, **postgresql:9.6** or **sqlite3** 423 | * ruby **2.5** with activerecord **6.1**, using **mysql2**, **postgresql:9.6** or **sqlite3** 424 | * ruby **2.7** with activerecord **5.2**, using **mysql2**, **postgresql:9.6** or **sqlite3** 425 | * ruby **2.7** with activerecord **6.0**, using **mysql2**, **postgresql:9.6** or **sqlite3** 426 | * ruby **2.7** with activerecord **6.1**, using **mysql2**, **postgresql:9.6** or **sqlite3** 427 | * ruby **2.7** with activerecord **7.0**, using **mysql2**, **postgresql:9.6** or **sqlite3** 428 | * ruby **3.0** with activerecord **6.0**, using **mysql2**, **postgresql:9.6** or **sqlite3** 429 | * ruby **3.0** with activerecord **6.1**, using **mysql2**, **postgresql:9.6** or **sqlite3** 430 | * ruby **3.0** with activerecord **7.0**, using **mysql2**, **postgresql:9.6** or **sqlite3** 431 | * ruby **3.1** with activerecord **6.0**, using **mysql2**, **postgresql:9.6** or **sqlite3** 432 | * ruby **3.1** with activerecord **6.1**, using **mysql2**, **postgresql:9.6** or **sqlite3** 433 | * ruby **3.1** with activerecord **7.0**, using **mysql2**, **postgresql:9.6** or **sqlite3** 434 | 435 | 436 | 437 | Notes: 438 | 439 | * As for version 1.3.0, rails < 5.2 and ruby < 2.5 are no longer supported 440 | * As of version 1.2.3, rails < 4.1 and ruby < 2.1 are no longer supported 441 | * As of version 1.2.0, ruby 1.9.2 is no longer supported. 442 | * As of version 1.0.0, ruby 1.8.7 and rails < 3.2 are no longer supported. 443 | 444 | ## Installation 445 | 446 | Install from http://rubygems.org via 447 | 448 | $ gem install "schema_associations" 449 | 450 | or in a Gemfile 451 | 452 | gem "schema_associations" 453 | 454 | ## Release notes: 455 | 456 | ### 1.4.0 457 | 458 | * Add AR 6.1 and 7.0 459 | * Add Ruby 3.1 460 | * drop schema_plus_compatibiltiy dependency (indirect through schema_plus_foreign_keys update) 461 | 462 | ### 1.3.0 463 | 464 | * add AR 6.0 465 | * add Ruby 3.0 466 | * drop AR < 5.2 467 | * drop Ruby < 2.5 468 | 469 | ### 1.2.7 470 | 471 | * add in auto deferring of has_* :through associations manually defined on the model so they work in AR 5.1+ 472 | 473 | ### 1.2.6 474 | 475 | * Support for AR5 (Rails 5). 476 | 477 | ### 1.2.5 478 | 479 | * Use schema_monkey rather than Railties. 480 | 481 | ### 1.2.4 482 | 483 | * Bug fix: Don't fail trying to do associations for abstract classes (mysql2 only). [#11, #12] Thanks to [@dmeranda](https://github.com/dmeranda) 484 | 485 | ### 1.2.3 486 | 487 | * Use schema_plus_foreign_keys rather than all of schema_plus, to eliminate unneeded dependancies. That limits us to AR >= 4.1 and ruby >= 2.1 488 | * Fix deprecations 489 | * Logging is now at `debug` level rather than `info` level 490 | 491 | ### 1.2.2 492 | 493 | * Bug fix (Rails workaround) for STI: propagate associations to subclasses, since Rails might not, depending on the load order. 494 | 495 | ### 1.2.1 496 | 497 | * Works with Rails 4.1 498 | * Test against MRI ruby 2.1.2 499 | 500 | ### 1.2.0 501 | 502 | * Works with Rails 4, thanks to [@tovodeverett](https://github.com/tovodeverett) 503 | * Test against MRI ruby 2.0.0; no longer test against 1.9.2 504 | 505 | ### 1.1.0 506 | 507 | * New feature: `config.table_prefix_map` 508 | 509 | ### 1.0.1 510 | 511 | * Bug fix: use singular :inverse_of for :belongs_to of a :has_one 512 | 513 | 514 | ### 1.0.0 515 | 516 | * Use :inverse_of in generated associations 517 | 518 | * Drop support for ruby 1.8.7 and rails < 3.2 519 | 520 | 521 | ## History 522 | 523 | * SchemaAssociations is derived from the "Red Hill On Rails" plugin 524 | foreign_key_associations originally created by harukizaemon 525 | (https://github.com/harukizaemon) 526 | 527 | * SchemaAssociations was created in 2011 by Michal Lomnicki and Ronen Barzel 528 | 529 | 530 | ## License 531 | 532 | This gem is released under the MIT license. 533 | 534 | ## Development & Testing 535 | 536 | Are you interested in contributing to SchemaPlus::Views? Thanks! Please follow the standard protocol: fork, feature branch, develop, push, and issue pull request. 537 | 538 | Some things to know about to help you develop and test: 539 | 540 | 541 | 542 | * **schema_dev**: SchemaAssociations uses [schema_dev](https://github.com/SchemaPlus/schema_dev) to 543 | facilitate running rspec tests on the matrix of ruby, activerecord, and database 544 | versions that the gem supports, both locally and on 545 | [github actions](https://github.com/SchemaPlus/schema_associations/actions) 546 | 547 | To to run rspec locally on the full matrix, do: 548 | 549 | $ schema_dev bundle install 550 | $ schema_dev rspec 551 | 552 | 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. 553 | 554 | The matrix of configurations is specified in `schema_dev.yml` in 555 | the project root. 556 | 557 | 558 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler' 4 | Bundler::GemHelper.install_tasks 5 | 6 | require 'schema_dev/tasks' 7 | 8 | task :default => :spec 9 | 10 | require 'rspec/core/rake_task' 11 | RSpec::Core::RakeTask.new(:spec) 12 | -------------------------------------------------------------------------------- /gemfiles/Gemfile.base: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | gemspec path: File.expand_path('..', __FILE__) 3 | 4 | File.exist?(gemfile_local = File.expand_path('../Gemfile.local', __FILE__)) and eval File.read(gemfile_local), binding, gemfile_local 5 | -------------------------------------------------------------------------------- /gemfiles/activerecord-5.2/Gemfile.base: -------------------------------------------------------------------------------- 1 | base_gemfile = File.expand_path('../../Gemfile.base', __FILE__) 2 | eval File.read(base_gemfile) 3 | 4 | gem "activerecord", ">= 5.2.0.beta0", "< 5.3" 5 | -------------------------------------------------------------------------------- /gemfiles/activerecord-5.2/Gemfile.mysql2: -------------------------------------------------------------------------------- 1 | base_gemfile = File.expand_path('../Gemfile.base', __FILE__) 2 | eval File.read(base_gemfile), binding, base_gemfile 3 | 4 | platform :ruby do 5 | gem "mysql2" 6 | end 7 | 8 | platform :jruby do 9 | gem 'activerecord-jdbcmysql-adapter' 10 | end 11 | -------------------------------------------------------------------------------- /gemfiles/activerecord-5.2/Gemfile.postgresql: -------------------------------------------------------------------------------- 1 | base_gemfile = File.expand_path('../Gemfile.base', __FILE__) 2 | eval File.read(base_gemfile), binding, base_gemfile 3 | 4 | platform :ruby do 5 | gem "pg" 6 | end 7 | 8 | platform :jruby do 9 | gem 'activerecord-jdbcpostgresql-adapter' 10 | end 11 | -------------------------------------------------------------------------------- /gemfiles/activerecord-5.2/Gemfile.sqlite3: -------------------------------------------------------------------------------- 1 | base_gemfile = File.expand_path('../Gemfile.base', __FILE__) 2 | eval File.read(base_gemfile), binding, base_gemfile 3 | 4 | platform :ruby do 5 | gem "sqlite3" 6 | end 7 | 8 | platform :jruby do 9 | gem 'activerecord-jdbcsqlite3-adapter', '>=1.3.0.beta2' 10 | end 11 | -------------------------------------------------------------------------------- /gemfiles/activerecord-6.0/Gemfile.base: -------------------------------------------------------------------------------- 1 | base_gemfile = File.expand_path('../../Gemfile.base', __FILE__) 2 | eval File.read(base_gemfile) 3 | 4 | gem "activerecord", ">= 6.0", "< 6.1" 5 | -------------------------------------------------------------------------------- /gemfiles/activerecord-6.0/Gemfile.mysql2: -------------------------------------------------------------------------------- 1 | base_gemfile = File.expand_path('../Gemfile.base', __FILE__) 2 | eval File.read(base_gemfile), binding, base_gemfile 3 | 4 | platform :ruby do 5 | gem "mysql2" 6 | end 7 | 8 | platform :jruby do 9 | gem 'activerecord-jdbcmysql-adapter' 10 | end 11 | -------------------------------------------------------------------------------- /gemfiles/activerecord-6.0/Gemfile.postgresql: -------------------------------------------------------------------------------- 1 | base_gemfile = File.expand_path('../Gemfile.base', __FILE__) 2 | eval File.read(base_gemfile), binding, base_gemfile 3 | 4 | platform :ruby do 5 | gem "pg" 6 | end 7 | 8 | platform :jruby do 9 | gem 'activerecord-jdbcpostgresql-adapter' 10 | end 11 | -------------------------------------------------------------------------------- /gemfiles/activerecord-6.0/Gemfile.sqlite3: -------------------------------------------------------------------------------- 1 | base_gemfile = File.expand_path('../Gemfile.base', __FILE__) 2 | eval File.read(base_gemfile), binding, base_gemfile 3 | 4 | platform :ruby do 5 | gem "sqlite3" 6 | end 7 | 8 | platform :jruby do 9 | gem 'activerecord-jdbcsqlite3-adapter', '>=1.3.0.beta2' 10 | end 11 | -------------------------------------------------------------------------------- /gemfiles/activerecord-6.1/Gemfile.base: -------------------------------------------------------------------------------- 1 | base_gemfile = File.expand_path('../../Gemfile.base', __FILE__) 2 | eval File.read(base_gemfile) 3 | 4 | gem "activerecord", ">= 6.1", "< 6.2" 5 | -------------------------------------------------------------------------------- /gemfiles/activerecord-6.1/Gemfile.mysql2: -------------------------------------------------------------------------------- 1 | base_gemfile = File.expand_path('../Gemfile.base', __FILE__) 2 | eval File.read(base_gemfile), binding, base_gemfile 3 | 4 | platform :ruby do 5 | gem "mysql2" 6 | end 7 | 8 | platform :jruby do 9 | gem 'activerecord-jdbcmysql-adapter' 10 | end 11 | -------------------------------------------------------------------------------- /gemfiles/activerecord-6.1/Gemfile.postgresql: -------------------------------------------------------------------------------- 1 | base_gemfile = File.expand_path('../Gemfile.base', __FILE__) 2 | eval File.read(base_gemfile), binding, base_gemfile 3 | 4 | platform :ruby do 5 | gem "pg" 6 | end 7 | 8 | platform :jruby do 9 | gem 'activerecord-jdbcpostgresql-adapter' 10 | end 11 | -------------------------------------------------------------------------------- /gemfiles/activerecord-6.1/Gemfile.sqlite3: -------------------------------------------------------------------------------- 1 | base_gemfile = File.expand_path('../Gemfile.base', __FILE__) 2 | eval File.read(base_gemfile), binding, base_gemfile 3 | 4 | platform :ruby do 5 | gem "sqlite3" 6 | end 7 | 8 | platform :jruby do 9 | gem 'activerecord-jdbcsqlite3-adapter', '>=1.3.0.beta2' 10 | end 11 | -------------------------------------------------------------------------------- /gemfiles/activerecord-7.0/Gemfile.base: -------------------------------------------------------------------------------- 1 | base_gemfile = File.expand_path('../../Gemfile.base', __FILE__) 2 | eval File.read(base_gemfile) 3 | 4 | gem "activerecord", ">= 7.0", "< 7.1" 5 | -------------------------------------------------------------------------------- /gemfiles/activerecord-7.0/Gemfile.mysql2: -------------------------------------------------------------------------------- 1 | base_gemfile = File.expand_path('../Gemfile.base', __FILE__) 2 | eval File.read(base_gemfile), binding, base_gemfile 3 | 4 | platform :ruby do 5 | gem "mysql2" 6 | end 7 | 8 | platform :jruby do 9 | gem 'activerecord-jdbcmysql-adapter' 10 | end 11 | -------------------------------------------------------------------------------- /gemfiles/activerecord-7.0/Gemfile.postgresql: -------------------------------------------------------------------------------- 1 | base_gemfile = File.expand_path('../Gemfile.base', __FILE__) 2 | eval File.read(base_gemfile), binding, base_gemfile 3 | 4 | platform :ruby do 5 | gem "pg" 6 | end 7 | 8 | platform :jruby do 9 | gem 'activerecord-jdbcpostgresql-adapter' 10 | end 11 | -------------------------------------------------------------------------------- /gemfiles/activerecord-7.0/Gemfile.sqlite3: -------------------------------------------------------------------------------- 1 | base_gemfile = File.expand_path('../Gemfile.base', __FILE__) 2 | eval File.read(base_gemfile), binding, base_gemfile 3 | 4 | platform :ruby do 5 | gem "sqlite3" 6 | end 7 | 8 | platform :jruby do 9 | gem 'activerecord-jdbcsqlite3-adapter', '>=1.3.0.beta2' 10 | end 11 | -------------------------------------------------------------------------------- /init.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'schema_associations' unless defined?(SchemaAssociations) 4 | -------------------------------------------------------------------------------- /lib/schema_associations.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'schema_plus_foreign_keys' 4 | require 'valuable' 5 | 6 | require 'schema_associations/version' 7 | require 'schema_associations/active_record/associations' 8 | 9 | module SchemaAssociations 10 | 11 | # The configuation options for SchemaAssociations. Set them globally in 12 | # +config/initializers/schema_associations.rb+, e.g.: 13 | # 14 | # SchemaAssociations.setup do |config| 15 | # config.concise_names = false 16 | # end 17 | # 18 | # or override them per-model, e.g.: 19 | # 20 | # class MyModel < ActiveRecord::Base 21 | # schema_associations :concise_names => false 22 | # end 23 | # 24 | class Config < Valuable 25 | 26 | ## 27 | # :attr_accessor: auto_create 28 | # 29 | # Whether to automatically create associations based on foreign keys. 30 | # Boolean, default is +true+. 31 | has_value :auto_create, :klass => :boolean, :default => true 32 | 33 | ## 34 | # :attr_accessor: concise_names 35 | # 36 | # Whether to use concise naming (strip out common prefixes from class names). 37 | # Boolean, default is +true+. 38 | has_value :concise_names, :klass => :boolean, :default => true 39 | 40 | ## 41 | # :attr_accessor: except 42 | # 43 | # List of association names to exclude from automatic creation. 44 | # Value is a single name, an array of names, or +nil+. Default is +nil+. 45 | has_value :except, :default => nil 46 | 47 | ## 48 | # :attr_accessor: only 49 | # 50 | # List of association names to include in automatic creation. 51 | # Value is a single name, and array of names, or +nil+. Default is +nil+. 52 | has_value :only, :default => nil 53 | 54 | ## 55 | # :attr_accessor: except_type 56 | # 57 | # List of association types to exclude from automatic creation. 58 | # Value is one or an array of +:belongs_to+, +:has_many+, +:has_one+, and/or 59 | # +:has_and_belongs_to_many+, or +nil+. Default is +nil+. 60 | has_value :except_type, :default => nil 61 | 62 | ## 63 | # :attr_accessor: only_type 64 | # 65 | # List of association types to include from automatic creation. 66 | # Value is one or an array of +:belongs_to+, +:has_many+, +:has_one+, and/or 67 | # +:has_and_belongs_to_many+, or +nil+. Default is +nil+. 68 | has_value :only_type, :default => nil 69 | 70 | ## 71 | # :attr_accessor: table_prefix_map 72 | # 73 | # Hash whose keys are possible matches at the start of table names, and 74 | # whose corresponding values are the prefix to use in front of class 75 | # names. 76 | has_value :table_prefix_map, :default => {} 77 | 78 | def dup # :nodoc: 79 | self.class.new(Hash[attributes.collect{ |key, val| [key, Valuable === val ? val.class.new(val.attributes) : val] }]) 80 | end 81 | 82 | def update_attributes(opts)#:nodoc: 83 | opts = opts.dup 84 | super(opts) 85 | self 86 | end 87 | 88 | def merge(opts)#:nodoc: 89 | dup.update_attributes(opts) 90 | end 91 | 92 | end 93 | 94 | # Returns the global configuration, i.e., the singleton instance of Config 95 | def self.config 96 | @config ||= Config.new 97 | end 98 | 99 | # Initialization block is passed a global Config instance that can be 100 | # used to configure SchemaAssociations behavior. E.g., if you want to 101 | # disable automation creation associations put the following in 102 | # config/initializers/schema_associations.rb : 103 | # 104 | # SchemaAssociations.setup do |config| 105 | # config.auto_create = false 106 | # end 107 | # 108 | def self.setup # :yields: config 109 | yield config 110 | end 111 | 112 | end 113 | 114 | SchemaMonkey.register SchemaAssociations 115 | -------------------------------------------------------------------------------- /lib/schema_associations/active_record/associations.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'ostruct' 4 | 5 | module SchemaAssociations 6 | module ActiveRecord 7 | 8 | module Relation 9 | 10 | def initialize(klass, *args, **kwargs) 11 | klass.send :_load_schema_associations_associations unless klass.nil? 12 | super 13 | end 14 | end 15 | 16 | module Base 17 | 18 | module ClassMethods 19 | 20 | def reflections(*args) 21 | _load_schema_associations_associations 22 | super 23 | end 24 | 25 | def reflect_on_association(*args) 26 | _load_schema_associations_associations 27 | super 28 | end 29 | 30 | # introduced in rails 4.1 31 | def _reflect_on_association(*args) 32 | _load_schema_associations_associations 33 | super 34 | end 35 | 36 | def reflect_on_all_associations(*args) 37 | _load_schema_associations_associations 38 | super 39 | end 40 | 41 | def define_attribute_methods(*args) 42 | super 43 | _load_schema_associations_associations 44 | end 45 | 46 | # Per-model override of Config options. Use via, e.g. 47 | # class MyModel < ActiveRecord::Base 48 | # schema_associations :auto_create => false 49 | # end 50 | # 51 | # If :auto_create is not specified, it is implicitly 52 | # specified as true. This allows the "non-invasive" style of using 53 | # SchemaAssociations in which you set the global Config to 54 | # auto_create = false, then in any model that you want auto 55 | # associations you simply do: 56 | # 57 | # class MyModel < ActiveRecord::Base 58 | # schema_associations 59 | # end 60 | # 61 | # Of course other options can be passed, such as 62 | # 63 | # class MyModel < ActiveRecord::Base 64 | # schema_associations :concise_names => false, :except_type => :has_and_belongs_to_many 65 | # end 66 | # 67 | def schema_associations(opts={}) 68 | @schema_associations_config = SchemaAssociations.config.merge({:auto_create => true}.merge(opts)) 69 | end 70 | 71 | def schema_associations_config # :nodoc: 72 | @schema_associations_config ||= SchemaAssociations.config.dup 73 | end 74 | 75 | %i[has_many has_one].each do |m| 76 | define_method(m) do |name, *args, **options| 77 | if @schema_associations_associations_loaded 78 | super name, *args, **options 79 | else 80 | @schema_associations_deferred_associations ||= [] 81 | @schema_associations_deferred_associations.push({macro: m, name: name, args: args, options: options}) 82 | end 83 | end 84 | end 85 | 86 | private 87 | 88 | def _load_schema_associations_associations 89 | return if @schema_associations_associations_loaded 90 | return if abstract_class? 91 | return unless schema_associations_config.auto_create? 92 | 93 | @schema_associations_associations_loaded = :loading 94 | 95 | reverse_foreign_keys.each do | foreign_key | 96 | if foreign_key.from_table =~ /^#{table_name}_(.*)$/ || foreign_key.from_table =~ /^(.*)_#{table_name}$/ 97 | other_table = $1 98 | if other_table == other_table.pluralize and connection.columns(foreign_key.from_table).any?{|col| col.name == "#{other_table.singularize}_id"} 99 | _define_association(:has_and_belongs_to_many, foreign_key, other_table) 100 | else 101 | _define_association(:has_one_or_many, foreign_key) 102 | end 103 | else 104 | _define_association(:has_one_or_many, foreign_key) 105 | end 106 | end 107 | 108 | foreign_keys.each do | foreign_key | 109 | _define_association(:belongs_to, foreign_key) 110 | end 111 | 112 | (@schema_associations_deferred_associations || []).each do |a| 113 | argstr = a[:args].inspect[1...-1] + ' # deferred association' 114 | _create_association(a[:macro], a[:name], argstr, *a[:args], **a[:options]) 115 | end 116 | if instance_variable_defined? :@schema_associations_deferred_associations 117 | remove_instance_variable :@schema_associations_deferred_associations 118 | end 119 | 120 | @schema_associations_associations_loaded = true 121 | end 122 | 123 | def _define_association(macro, fk, referencing_table_name = nil) 124 | column_names = Array.wrap(fk.column) 125 | return unless column_names.size == 1 126 | 127 | referencing_table_name ||= fk.from_table 128 | column_name = column_names.first 129 | 130 | references_name = fk.to_table.singularize 131 | referencing_name = referencing_table_name.singularize 132 | 133 | referencing_class_name = _get_class_name(referencing_name) 134 | references_class_name = _get_class_name(references_name) 135 | 136 | names = _determine_association_names(column_name.sub(/_id$/, ''), referencing_name, references_name) 137 | 138 | argstr = "" 139 | 140 | 141 | case macro 142 | when :has_and_belongs_to_many 143 | name = names[:has_many] 144 | opts = {:class_name => referencing_class_name, :join_table => fk.from_table, :foreign_key => column_name} 145 | when :belongs_to 146 | name = names[:belongs_to] 147 | opts = {:class_name => references_class_name, :foreign_key => column_name} 148 | if connection.indexes(referencing_table_name).any?{|index| index.unique && index.columns == [column_name]} 149 | opts[:inverse_of] = names[:has_one] 150 | else 151 | opts[:inverse_of] = names[:has_many] 152 | end 153 | 154 | when :has_one_or_many 155 | opts = {:class_name => referencing_class_name, :foreign_key => column_name, :inverse_of => names[:belongs_to]} 156 | # use connection.indexes and connection.colums rather than class 157 | # methods of the referencing class because using the class 158 | # methods would require getting the class -- which might trigger 159 | # an autoload which could start some recursion making things much 160 | # harder to debug. 161 | if connection.indexes(referencing_table_name).any?{|index| index.unique && index.columns == [column_name]} 162 | macro = :has_one 163 | name = names[:has_one] 164 | else 165 | macro = :has_many 166 | name = names[:has_many] 167 | if connection.columns(referencing_table_name).any?{ |col| col.name == 'position' } 168 | scope_block = lambda { order :position } 169 | argstr += "-> { order :position }, " 170 | end 171 | end 172 | end 173 | argstr += opts.inspect[1...-1] 174 | if (_filter_association(macro, name) && !_method_exists?(name)) 175 | _create_association(macro, name, argstr, scope_block, **opts.dup) 176 | end 177 | end 178 | 179 | def _create_association(macro, name, argstr, *args, **options) 180 | logger.debug "[schema_associations] #{self.name || self.from_table.classify}.#{macro} #{name.inspect}, #{argstr}" 181 | send macro, name, *args, **options 182 | case 183 | when respond_to?(:subclasses) then subclasses 184 | end.each do |subclass| 185 | subclass.send :_create_association, macro, name, argstr, *args, **options 186 | end 187 | end 188 | 189 | def _determine_association_names(reference_name, referencing_name, references_name) 190 | 191 | references_concise = _concise_name(references_name, referencing_name) 192 | referencing_concise = _concise_name(referencing_name, references_name) 193 | 194 | if _use_concise_name? 195 | references = references_concise 196 | referencing = referencing_concise 197 | else 198 | references = references_name 199 | referencing = referencing_name 200 | end 201 | 202 | case reference_name 203 | when 'parent' 204 | belongs_to = 'parent' 205 | has_one = 'child' 206 | has_many = 'children' 207 | 208 | when references_name 209 | belongs_to = references 210 | has_one = referencing 211 | has_many = referencing.pluralize 212 | 213 | when /(.*)_#{references_name}$/, /(.*)_#{references_concise}$/ 214 | label = $1 215 | belongs_to = "#{label}_#{references}" 216 | has_one = "#{referencing}_as_#{label}" 217 | has_many = "#{referencing.pluralize}_as_#{label}" 218 | 219 | when /^#{references_name}_(.*)$/, /^#{references_concise}_(.*)$/ 220 | label = $1 221 | belongs_to = "#{references}_#{label}" 222 | has_one = "#{referencing}_as_#{label}" 223 | has_many = "#{referencing.pluralize}_as_#{label}" 224 | 225 | else 226 | belongs_to = reference_name 227 | has_one = "#{referencing}_as_#{reference_name}" 228 | has_many = "#{referencing.pluralize}_as_#{reference_name}" 229 | end 230 | 231 | { :belongs_to => belongs_to.to_sym, :has_one => has_one.to_sym, :has_many => has_many.to_sym } 232 | end 233 | 234 | def _concise_name(string, other) 235 | case 236 | when string =~ /^#{other}_(.*)$/ then $1 237 | when string =~ /(.*)_#{other}$/ then $1 238 | when leader = _common_leader(string,other) then string[leader.length, string.length-leader.length] 239 | else string 240 | end 241 | end 242 | 243 | def _common_leader(string, other) 244 | leader = nil 245 | other.split('_').each do |part| 246 | test = "#{leader}#{part}_" 247 | break unless string.start_with? test 248 | leader = test 249 | end 250 | return leader 251 | end 252 | 253 | def _use_concise_name? 254 | schema_associations_config.concise_names? 255 | end 256 | 257 | def _filter_association(macro, name) 258 | config = schema_associations_config 259 | return false if config.only and not Array.wrap(config.only).include?(name) 260 | return false if config.except and Array.wrap(config.except).include?(name) 261 | return false if config.only_type and not Array.wrap(config.only_type).include?(macro) 262 | return false if config.except_type and Array.wrap(config.except_type).include?(macro) 263 | return true 264 | end 265 | 266 | def _get_class_name(name) 267 | name = name.dup 268 | found = schema_associations_config.table_prefix_map.find { |table_prefix, class_prefix| 269 | name.sub! %r[\A#{table_prefix}], '' 270 | } 271 | name = name.classify 272 | name = found.last + name if found 273 | name 274 | end 275 | 276 | def _method_exists?(name) 277 | method_defined?(name) || private_method_defined?(name) 278 | end 279 | 280 | end 281 | 282 | end 283 | end 284 | end 285 | -------------------------------------------------------------------------------- /lib/schema_associations/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SchemaAssociations 4 | VERSION = "1.4.0" 5 | end 6 | -------------------------------------------------------------------------------- /schema_associations.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $:.push File.expand_path("../lib", __FILE__) 4 | require "schema_associations/version" 5 | 6 | Gem::Specification.new do |gem| 7 | gem.name = "schema_associations" 8 | gem.version = SchemaAssociations::VERSION 9 | gem.platform = Gem::Platform::RUBY 10 | gem.authors = ["Ronen Barzel", "Michał Łomnicki"] 11 | gem.email = ["ronen@barzel.org", "michal.lomnicki@gmail.com"] 12 | gem.homepage = "https://github.com/SchemaPlus/schema_associations" 13 | gem.summary = "ActiveRecord extension that automatically (DRY) creates associations based on the schema" 14 | gem.description = "SchemaAssociations extends ActiveRecord to automatically create associations by inspecting the database schema. This is more more DRY than the standard behavior, for which in addition to specifying the foreign key in the migration, you must also specify complementary associations in two model files (e.g. a :belongs_to and a :has_many)." 15 | 16 | gem.files = `git ls-files`.split("\n") 17 | gem.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 18 | gem.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } 19 | gem.require_paths = ["lib"] 20 | 21 | gem.required_ruby_version = '>= 2.5' 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 'valuable' 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 | - postgresql 14 | - sqlite3 15 | -------------------------------------------------------------------------------- /spec/association_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require File.expand_path(File.dirname(__FILE__) + '/spec_helper') 4 | 5 | describe ActiveRecord::Base do 6 | def stub_model(name, base = ActiveRecord::Base, &block) 7 | klass = Class.new(base) 8 | 9 | if block_given? 10 | klass.instance_eval(&block) 11 | end 12 | 13 | stub_const(name, klass) 14 | end 15 | 16 | context "in basic case" do 17 | before(:each) do 18 | create_tables( 19 | "posts", {}, {}, 20 | "comments", {}, { post_id: {foreign_key: true} } 21 | ) 22 | stub_model('Post') 23 | stub_model('Comment') 24 | end 25 | 26 | it "should create belongs_to association when reflecting on it" do 27 | reflection = Comment.reflect_on_association(:post) 28 | expect(reflection).not_to be_nil 29 | expect(reflection.macro).to eq(:belongs_to) 30 | expect(reflection.options[:class_name]).to eq("Post") 31 | expect(reflection.options[:foreign_key]).to eq("post_id") 32 | expect(reflection.options[:inverse_of]).to eq(:comments) 33 | end 34 | 35 | it "should create association when reflecting on all associations" do 36 | reflection = Comment.reflect_on_all_associations.first 37 | expect(reflection).not_to be_nil 38 | expect(reflection.macro).to eq(:belongs_to) 39 | expect(reflection.options[:class_name]).to eq("Post") 40 | expect(reflection.options[:foreign_key]).to eq("post_id") 41 | expect(reflection.options[:inverse_of]).to eq(:comments) 42 | end 43 | 44 | it "should create association when accepts_nested_attributes_for is called" do 45 | expect { 46 | Post.class_eval { accepts_nested_attributes_for :comments } 47 | }.to_not raise_error 48 | end 49 | 50 | it "should create association when accessing it" do 51 | post = Post.create 52 | comment = Comment.create(post_id: post.id) 53 | expect(comment.post.id).to eq(post.id) 54 | end 55 | 56 | it "should create association when creating record" do 57 | post = Post.create 58 | comment = Comment.create(post: post) 59 | expect(comment.reload.post.id).to eq(post.id) 60 | end 61 | 62 | it "should create has_many association" do 63 | reflection = Post.reflect_on_association(:comments) 64 | expect(reflection).not_to be_nil 65 | expect(reflection.macro).to eq(:has_many) 66 | expect(reflection.options[:class_name]).to eq("Comment") 67 | expect(reflection.options[:foreign_key]).to eq("post_id") 68 | expect(reflection.options[:inverse_of]).to eq(:post) 69 | end 70 | it "shouldn't raise an exception when model is instantiated" do 71 | expect { Post.new }.to_not raise_error 72 | end 73 | end 74 | 75 | context "with multiple associations of all types" do 76 | before(:each) do 77 | create_tables( 78 | "owners", {}, {}, 79 | "colors", {}, {}, 80 | "widgets", {}, { 81 | owner_id: { foreign_key: true }, 82 | }, 83 | "parts", {}, { widget_id: { foreign_key: true } }, 84 | "manifests", {}, { widget_id: { foreign_key: true, index: {unique: true}} }, 85 | "colors_widgets", {id: false}, { widget_id: { foreign_key: true}, color_id: { foreign_key: true} } 86 | ) 87 | end 88 | 89 | def check_reflections(hash) 90 | hash.each do |key, val| 91 | reflection = Widget.reflect_on_association(key) 92 | case val 93 | when true then expect(reflection).not_to be_nil 94 | else expect(reflection).to be_nil 95 | end 96 | end 97 | end 98 | 99 | it "should default as expected" do 100 | stub_model('Widget') 101 | check_reflections(owner: true, colors: true, parts: true, manifest: true) 102 | end 103 | 104 | it "should respect :only" do 105 | stub_model('Widget') do 106 | schema_associations only: :owner 107 | end 108 | check_reflections(owner: true, colors: false, parts: false, manifest: false) 109 | end 110 | 111 | it "should respect :except" do 112 | stub_model('Widget') do 113 | schema_associations except: :owner 114 | end 115 | check_reflections(owner: false, colors: true, parts: true, manifest: true) 116 | end 117 | 118 | it "should respect :only_type :belongs_to" do 119 | stub_model('Widget') do 120 | schema_associations only_type: :belongs_to 121 | end 122 | check_reflections(owner: true, colors: false, parts: false, manifest: false) 123 | end 124 | 125 | it "should respect :except_type :belongs_to" do 126 | stub_model('Widget') do 127 | schema_associations except_type: :belongs_to 128 | end 129 | check_reflections(owner: false, colors: true, parts: true, manifest: true) 130 | end 131 | 132 | it "should respect :only_type :has_many" do 133 | stub_model('Widget') do 134 | schema_associations only_type: :has_many 135 | end 136 | check_reflections(owner: false, colors: false, parts: true, manifest: false) 137 | end 138 | 139 | it "should respect :except_type :has_many" do 140 | stub_model('Widget') do 141 | schema_associations except_type: :has_many 142 | end 143 | check_reflections(owner: true, colors: true, parts: false, manifest: true) 144 | end 145 | 146 | it "should respect :only_type :has_one" do 147 | stub_model('Widget') do 148 | schema_associations only_type: :has_one 149 | end 150 | check_reflections(owner: false, colors: false, parts: false, manifest: true) 151 | end 152 | 153 | it "should respect :except_type :has_one" do 154 | stub_model('Widget') do 155 | schema_associations except_type: :has_one 156 | end 157 | check_reflections(owner: true, colors: true, parts: true, manifest: false) 158 | end 159 | 160 | it "should respect :only_type :has_and_belongs_to_many" do 161 | stub_model('Widget') do 162 | schema_associations only_type: :has_and_belongs_to_many 163 | end 164 | check_reflections(owner: false, colors: true, parts: false, manifest: false) 165 | end 166 | 167 | it "should respect :except_type :has_and_belongs_to_many" do 168 | stub_model('Widget') do 169 | schema_associations except_type: :has_and_belongs_to_many 170 | end 171 | check_reflections(owner: true, colors: false, parts: true, manifest: true) 172 | end 173 | 174 | end 175 | 176 | context "overrides" do 177 | it "should override auto_create negatively" do 178 | with_associations_auto_create(true) do 179 | create_tables( 180 | "posts", {}, {}, 181 | "comments", {}, { post_id: {foreign_key: true} } 182 | ) 183 | stub_model('Post') do 184 | schema_associations auto_create: false 185 | end 186 | stub_model('Comment') 187 | expect(Post.reflect_on_association(:comments)).to be_nil 188 | expect(Comment.reflect_on_association(:post)).not_to be_nil 189 | end 190 | end 191 | 192 | 193 | it "should override auto_create positively explicitly" do 194 | with_associations_auto_create(false) do 195 | create_tables( 196 | "posts", {}, {}, 197 | "comments", {}, { post_id: {foreign_key: true} } 198 | ) 199 | stub_model('Post') do 200 | schema_associations auto_create: true 201 | end 202 | stub_model('Comment') 203 | expect(Post.reflect_on_association(:comments)).not_to be_nil 204 | expect(Comment.reflect_on_association(:post)).to be_nil 205 | end 206 | end 207 | 208 | it "should override auto_create positively implicitly" do 209 | with_associations_auto_create(false) do 210 | create_tables( 211 | "posts", {}, {}, 212 | "comments", {}, { post_id: {foreign_key: true} } 213 | ) 214 | stub_model('Post') do 215 | schema_associations 216 | end 217 | stub_model('Comment') 218 | expect(Post.reflect_on_association(:comments)).not_to be_nil 219 | expect(Comment.reflect_on_association(:post)).to be_nil 220 | end 221 | end 222 | end 223 | 224 | 225 | context "with unique index" do 226 | before(:each) do 227 | create_tables( 228 | "posts", {}, {}, 229 | "comments", {}, { post_id: {foreign_key: true, index: { unique: true} } } 230 | ) 231 | stub_model('Post') 232 | stub_model('Comment') 233 | end 234 | it "should create has_one association" do 235 | reflection = Post.reflect_on_association(:comment) 236 | expect(reflection).not_to be_nil 237 | expect(reflection.macro).to eq(:has_one) 238 | expect(reflection.options[:class_name]).to eq("Comment") 239 | expect(reflection.options[:foreign_key]).to eq("post_id") 240 | expect(reflection.options[:inverse_of]).to eq(:post) 241 | end 242 | it "should create belongs_to association with singular inverse" do 243 | reflection = Comment.reflect_on_association(:post) 244 | expect(reflection).not_to be_nil 245 | expect(reflection.macro).to eq(:belongs_to) 246 | expect(reflection.options[:class_name]).to eq("Post") 247 | expect(reflection.options[:foreign_key]).to eq("post_id") 248 | expect(reflection.options[:inverse_of]).to eq(:comment) 249 | end 250 | end 251 | 252 | context "with prefixed column names" do 253 | before(:each) do 254 | create_tables( 255 | "posts", {}, {}, 256 | "comments", {}, { subject_post_id: { foreign_key: { references: "posts" }} } 257 | ) 258 | stub_model('Post') 259 | stub_model('Comment') 260 | end 261 | it "should name belongs_to according to column" do 262 | reflection = Comment.reflect_on_association(:subject_post) 263 | expect(reflection).not_to be_nil 264 | expect(reflection.macro).to eq(:belongs_to) 265 | expect(reflection.options[:class_name]).to eq("Post") 266 | expect(reflection.options[:foreign_key]).to eq("subject_post_id") 267 | expect(reflection.options[:inverse_of]).to eq(:comments_as_subject) 268 | end 269 | 270 | it "should name has_many using 'as column'" do 271 | reflection = Post.reflect_on_association(:comments_as_subject) 272 | expect(reflection).not_to be_nil 273 | expect(reflection.macro).to eq(:has_many) 274 | expect(reflection.options[:class_name]).to eq("Comment") 275 | expect(reflection.options[:foreign_key]).to eq("subject_post_id") 276 | expect(reflection.options[:inverse_of]).to eq(:subject_post) 277 | end 278 | end 279 | 280 | context "with suffixed column names" do 281 | before(:each) do 282 | create_tables( 283 | "posts", {}, {}, 284 | "comments", {}, { post_cited: { foreign_key: {references: "posts" }} } 285 | ) 286 | stub_model('Post') 287 | stub_model('Comment') 288 | end 289 | it "should name belongs_to according to column" do 290 | reflection = Comment.reflect_on_association(:post_cited) 291 | expect(reflection).not_to be_nil 292 | expect(reflection.macro).to eq(:belongs_to) 293 | expect(reflection.options[:class_name]).to eq("Post") 294 | expect(reflection.options[:foreign_key]).to eq("post_cited") 295 | expect(reflection.options[:inverse_of]).to eq(:comments_as_cited) 296 | end 297 | 298 | it "should name has_many using 'as column'" do 299 | reflection = Post.reflect_on_association(:comments_as_cited) 300 | expect(reflection).not_to be_nil 301 | expect(reflection.macro).to eq(:has_many) 302 | expect(reflection.options[:class_name]).to eq("Comment") 303 | expect(reflection.options[:foreign_key]).to eq("post_cited") 304 | expect(reflection.options[:inverse_of]).to eq(:post_cited) 305 | end 306 | end 307 | 308 | context "with arbitrary column names" do 309 | before(:each) do 310 | create_tables( 311 | "posts", {}, {}, 312 | "comments", {}, { subject: {foreign_key: { references: "posts" }} } 313 | ) 314 | stub_model('Post') 315 | stub_model('Comment') 316 | end 317 | it "should name belongs_to according to column" do 318 | reflection = Comment.reflect_on_association(:subject) 319 | expect(reflection).not_to be_nil 320 | expect(reflection.macro).to eq(:belongs_to) 321 | expect(reflection.options[:class_name]).to eq("Post") 322 | expect(reflection.options[:foreign_key]).to eq("subject") 323 | expect(reflection.options[:inverse_of]).to eq(:comments_as_subject) 324 | end 325 | 326 | it "should name has_many using 'as column'" do 327 | reflection = Post.reflect_on_association(:comments_as_subject) 328 | expect(reflection).not_to be_nil 329 | expect(reflection.macro).to eq(:has_many) 330 | expect(reflection.options[:class_name]).to eq("Comment") 331 | expect(reflection.options[:foreign_key]).to eq("subject") 332 | expect(reflection.options[:inverse_of]).to eq(:subject) 333 | end 334 | end 335 | 336 | it "maps table prefix" do 337 | with_associations_config(table_prefix_map: { "wooga_" => "Happy"} ) do 338 | create_tables( 339 | "wooga_posts", {}, {}, 340 | "wooga_comments", {}, { wooga_post_id: { foreign_key: true} } 341 | ) 342 | stub_model('HappyPost') do 343 | self.table_name = 'wooga_posts' 344 | end 345 | stub_model('HappyComment') do 346 | self.table_name = 'wooga_comments' 347 | end 348 | 349 | # Kernel.warn HappyPost.reflect_on_all_associations.inspect 350 | expect(HappyComment.reflect_on_association(:post).class_name).to eq("HappyPost") 351 | expect(HappyPost.reflect_on_association(:comments).class_name).to eq("HappyComment") 352 | end 353 | end 354 | 355 | context "without position" do 356 | before(:each) do 357 | create_tables( 358 | "posts", {}, {}, 359 | "comments", {}, { post_id: { foreign_key: true} } 360 | ) 361 | stub_model('Post') 362 | stub_model('Comment') 363 | end 364 | it "should create unordered has_many association" do 365 | reflection = Post.reflect_on_association(:comments) 366 | expect(reflection).not_to be_nil 367 | expect(reflection.macro).to eq(:has_many) 368 | expect(reflection.options[:class_name]).to eq("Comment") 369 | expect(reflection.options[:foreign_key]).to eq("post_id") 370 | expect(reflection.options[:inverse_of]).to eq(:post) 371 | expect(reflection.scope).to be_nil 372 | end 373 | end 374 | 375 | context "with position" do 376 | before(:each) do 377 | create_tables( 378 | "posts", {}, {}, 379 | "comments", {}, { post_id: {foreign_key: true}, position: {} } 380 | ) 381 | stub_model('Post') 382 | stub_model('Comment') 383 | end 384 | it "should create ordered has_many association" do 385 | reflection = Post.reflect_on_association(:comments) 386 | expect(reflection).not_to be_nil 387 | expect(reflection.macro).to eq(:has_many) 388 | expect(reflection.options[:class_name]).to eq("Comment") 389 | expect(reflection.options[:foreign_key]).to eq("post_id") 390 | expect(reflection.options[:inverse_of]).to eq(:post) 391 | expect(reflection.scope).not_to be_nil 392 | scope_tester = Object.new 393 | expect(scope_tester).to receive(:order).with(:position) 394 | scope_tester.instance_exec(&reflection.scope) 395 | end 396 | end 397 | 398 | context "with scope that doesn't use include" do 399 | before(:each) do 400 | create_tables( 401 | "posts", {}, {}, 402 | "comments", {}, { post_id: {}, position: {} } 403 | ) 404 | stub_model('Post') 405 | stub_model('Comment') do 406 | scope :simple_scope, lambda { order(:id) } 407 | end 408 | end 409 | it "should create viable scope" do 410 | relation = Comment.simple_scope 411 | expect { relation.to_a }.to_not raise_error 412 | end 413 | end 414 | 415 | context "with scope that uses include" do 416 | before(:each) do 417 | create_tables( 418 | "posts", {}, {}, 419 | "comments", {}, { post_id: {}, position: {} } 420 | ) 421 | stub_model('Post') 422 | stub_model('Comment') do 423 | scope :simple_scope, lambda { order(:id).includes(:post) } 424 | end 425 | end 426 | it "should create viable scope" do 427 | relation = Comment.simple_scope 428 | expect { relation.to_a }.to_not raise_error 429 | end 430 | end 431 | 432 | context "regarding parent-child relationships" do 433 | 434 | let (:migration) {ActiveRecord::Migration} 435 | 436 | before(:each) do 437 | create_tables( 438 | "nodes", {}, { parent_id: { foreign_key: true} } 439 | ) 440 | end 441 | 442 | it "should use children as the inverse of parent" do 443 | stub_model('Node') 444 | reflection = Node.reflect_on_association(:children) 445 | expect(reflection).not_to be_nil 446 | end 447 | 448 | it "should use child as the singular inverse of parent" do 449 | migration.suppress_messages do 450 | migration.add_index(:nodes, :parent_id, unique: true) 451 | end 452 | stub_model('Node') 453 | reflection = Node.reflect_on_association(:child) 454 | expect(reflection).not_to be_nil 455 | end 456 | end 457 | 458 | 459 | context "regarding concise names" do 460 | 461 | def prefix_one 462 | create_tables( 463 | "posts", {}, {}, 464 | "post_comments", {}, { post_id: { foreign_key: true} } 465 | ) 466 | stub_model('Post') 467 | stub_model('PostComment') 468 | end 469 | 470 | def suffix_one 471 | create_tables( 472 | "posts", {}, {}, 473 | "comment_posts", {}, { post_id: { foreign_key: true} } 474 | ) 475 | stub_model('Post') 476 | stub_model('PostComment') 477 | end 478 | 479 | def prefix_both 480 | create_tables( 481 | "blog_page_posts", {}, {}, 482 | "blog_page_comments", {}, { blog_page_post_id: { foreign_key: true} } 483 | ) 484 | stub_model('BlogPagePost') 485 | stub_model('BlogPageComment') 486 | end 487 | 488 | it "should use concise association name for one prefix" do 489 | with_associations_config(auto_create: true, concise_names: true) do 490 | prefix_one 491 | reflection = Post.reflect_on_association(:comments) 492 | expect(reflection).not_to be_nil 493 | expect(reflection.macro).to eq(:has_many) 494 | expect(reflection.options[:class_name]).to eq("PostComment") 495 | expect(reflection.options[:foreign_key]).to eq("post_id") 496 | expect(reflection.options[:inverse_of]).to eq(:post) 497 | end 498 | end 499 | 500 | it "should use concise association name for one suffix" do 501 | with_associations_config(auto_create: true, concise_names: true) do 502 | suffix_one 503 | reflection = Post.reflect_on_association(:comments) 504 | expect(reflection).not_to be_nil 505 | expect(reflection.macro).to eq(:has_many) 506 | expect(reflection.options[:class_name]).to eq("CommentPost") 507 | expect(reflection.options[:foreign_key]).to eq("post_id") 508 | expect(reflection.options[:inverse_of]).to eq(:post) 509 | end 510 | end 511 | 512 | it "should use concise association name for shared prefixes" do 513 | with_associations_config(auto_create: true, concise_names: true) do 514 | prefix_both 515 | reflection = BlogPagePost.reflect_on_association(:comments) 516 | expect(reflection).not_to be_nil 517 | expect(reflection.macro).to eq(:has_many) 518 | expect(reflection.options[:class_name]).to eq("BlogPageComment") 519 | expect(reflection.options[:foreign_key]).to eq("blog_page_post_id") 520 | expect(reflection.options[:inverse_of]).to eq(:post) 521 | end 522 | end 523 | 524 | it "should use full names and not concise names when so configured" do 525 | with_associations_config(auto_create: true, concise_names: false) do 526 | prefix_one 527 | reflection = Post.reflect_on_association(:post_comments) 528 | expect(reflection).not_to be_nil 529 | expect(reflection.macro).to eq(:has_many) 530 | expect(reflection.options[:class_name]).to eq("PostComment") 531 | expect(reflection.options[:foreign_key]).to eq("post_id") 532 | expect(reflection.options[:inverse_of]).to eq(:post) 533 | reflection = Post.reflect_on_association(:comments) 534 | expect(reflection).to be_nil 535 | end 536 | end 537 | 538 | it "should use concise names and not full names when so configured" do 539 | with_associations_config(auto_create: true, concise_names: true) do 540 | prefix_one 541 | reflection = Post.reflect_on_association(:comments) 542 | expect(reflection).not_to be_nil 543 | expect(reflection.macro).to eq(:has_many) 544 | expect(reflection.options[:class_name]).to eq("PostComment") 545 | expect(reflection.options[:foreign_key]).to eq("post_id") 546 | expect(reflection.options[:inverse_of]).to eq(:post) 547 | reflection = Post.reflect_on_association(:post_comments) 548 | expect(reflection).to be_nil 549 | end 550 | end 551 | 552 | 553 | end 554 | 555 | context "with joins table" do 556 | before(:each) do 557 | create_tables( 558 | "posts", {}, {}, 559 | "tags", {}, {}, 560 | "posts_tags", {id: false}, { post_id: { foreign_key: true}, tag_id: { foreign_key: true}} 561 | ) 562 | stub_model('Post') 563 | stub_model('Tag') 564 | end 565 | it "should create has_and_belongs_to_many association" do 566 | reflection = Post.reflect_on_association(:tags) 567 | expect(reflection).not_to be_nil 568 | expect(reflection.macro).to eq(:has_and_belongs_to_many) 569 | expect(reflection.options[:class_name]).to eq("Tag") 570 | expect(reflection.options[:join_table]).to eq("posts_tags") 571 | end 572 | end 573 | 574 | context 'defining has_many through associations' do 575 | before(:each) do 576 | create_tables( 577 | "users", {}, {}, 578 | "posts", {}, { user_id: { foreign_key: true}}, 579 | "comments", {}, { post_id: { foreign_key: true}}, 580 | ) 581 | stub_model('Post') 582 | stub_model('Comment') 583 | stub_model('User') do 584 | has_many :comments, through: :posts 585 | end 586 | end 587 | 588 | it 'should not error when accessing the through association' do 589 | reflection = User.reflect_on_association(:posts) 590 | expect(reflection).not_to be_nil 591 | 592 | reflection = User.reflect_on_association(:comments) 593 | expect(reflection).not_to be_nil 594 | expect(reflection.macro).to eq(:has_many) 595 | expect(reflection.options[:through]).to eq(:posts) 596 | 597 | expect { User.new.comments }.to_not raise_error 598 | end 599 | end 600 | 601 | context "regarding existing methods" do 602 | before(:each) do 603 | create_tables( 604 | "types", {}, {}, 605 | "posts", {}, {type_id: { foreign_key: true}} 606 | ) 607 | end 608 | it "should define association normally if no existing method is defined" do 609 | stub_model('Type') 610 | expect(Type.reflect_on_association(:posts)).not_to be_nil # sanity check for this context 611 | end 612 | it "should not define association over existing public method" do 613 | stub_model('Type') do 614 | define_method(:posts) do 615 | :existing 616 | end 617 | end 618 | expect(Type.reflect_on_association(:posts)).to be_nil 619 | end 620 | it "should not define association over existing private method" do 621 | stub_model('Type') do 622 | define_method(:posts) do 623 | :existing 624 | end 625 | private :posts 626 | end 627 | expect(Type.reflect_on_association(:posts)).to be_nil 628 | end 629 | it "should define association :type over (deprecated) kernel method" do 630 | stub_model('Post') 631 | expect(Post.reflect_on_association(:type)).not_to be_nil 632 | end 633 | it "should not define association :type over model method" do 634 | stub_model('Post') do 635 | define_method(:type) do 636 | :existing 637 | end 638 | end 639 | expect(Post.reflect_on_association(:type)).to be_nil 640 | end 641 | end 642 | 643 | context "regarding STI" do 644 | before(:each) do 645 | create_tables( 646 | "posts", {}, {}, 647 | "comments", {}, { post_id: { foreign_key: true}, type: {coltype: :string} }, 648 | "citers", {}, {}, 649 | "citations", {}, { comment_id: { foreign_key: true}, citer_id: { foreign_key: true}} 650 | ) 651 | stub_model('Post') 652 | stub_model('Comment') 653 | stub_model('Citation') 654 | stub_model('SubComment', Comment) 655 | stub_model('OwnComment', Comment) do 656 | has_one :citer, through: :citations 657 | end 658 | end 659 | 660 | it "defines association for subclass" do 661 | expect(SubComment.reflect_on_association(:post)).not_to be_nil 662 | end 663 | 664 | it "defines association for subclass that has its own associations" do 665 | expect(OwnComment.reflect_on_association(:post)).not_to be_nil 666 | end 667 | end 668 | 669 | 670 | context "with abstract base classes" do 671 | before(:each) do 672 | create_tables( 673 | "posts", {}, {} 674 | ) 675 | stub_model('PostBase') do 676 | self.abstract_class = true 677 | end 678 | stub_model('Post', PostBase) 679 | end 680 | 681 | it "should skip abstract classes" do 682 | expect { PostBase.table_name }.to_not raise_error 683 | expect( PostBase.table_name ).to be_nil 684 | expect( !! PostBase.table_exists? ).to eq(false) 685 | end 686 | 687 | it "should work with classes derived from abstract classes" do 688 | expect( Post.table_name ).to eq("posts") 689 | expect( !! Post.table_exists? ).to eq(true) 690 | end 691 | end 692 | 693 | if defined? ::ActiveRecord::Relation 694 | 695 | context "regarding relations" do 696 | before(:each) do 697 | create_tables( 698 | "posts", {}, {}, 699 | "comments", {}, { post_id: { foreign_key: true} } 700 | ) 701 | stub_model('Post') 702 | stub_model('Comment') 703 | end 704 | 705 | it "should define associations before needed by relation" do 706 | expect { Post.joins(:comments).to_a }.to_not raise_error 707 | end 708 | end 709 | end 710 | 711 | protected 712 | 713 | def with_associations_auto_create(value, &block) 714 | with_associations_config(auto_create: value, &block) 715 | end 716 | 717 | def with_associations_config(opts, &block) 718 | save = Hash[opts.keys.collect{|key| [key, SchemaAssociations.config.send(key)]}] 719 | begin 720 | SchemaAssociations.setup do |config| 721 | config.update_attributes(opts) 722 | end 723 | yield 724 | ensure 725 | SchemaAssociations.config.update_attributes(save) 726 | end 727 | end 728 | 729 | def create_tables(*table_defs) 730 | ActiveRecord::Migration.suppress_messages do 731 | ActiveRecord::Base.connection.tables.sort.each do |table| 732 | ActiveRecord::Migration.drop_table table, force: :cascade 733 | end 734 | table_defs.each_slice(3) do |table_name, opts, columns_with_options| 735 | ActiveRecord::Migration.create_table table_name, **opts do |t| 736 | columns_with_options.each_pair do |column, options| 737 | coltype = options.delete(:coltype) || :bigint 738 | t.send coltype, column, **options 739 | end 740 | end 741 | end 742 | end 743 | end 744 | 745 | end 746 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'simplecov' 4 | SimpleCov.start unless SimpleCov.running 5 | 6 | $LOAD_PATH.unshift(File.dirname(__FILE__)) 7 | $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) 8 | 9 | require 'rspec' 10 | require 'active_record' 11 | require 'schema_associations' 12 | require 'logger' 13 | require 'schema_dev/rspec' 14 | 15 | SchemaDev::Rspec::setup 16 | 17 | Dir[File.dirname(__FILE__) + "/support/**/*.rb"].each { |f| require f } 18 | 19 | SimpleCov.command_name "[Ruby #{RUBY_VERSION} - ActiveRecord #{::ActiveRecord::VERSION::STRING}]" 20 | --------------------------------------------------------------------------------