├── .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 | [](http://badge.fury.io/rb/schema_associations)
8 | [](http://github.com/SchemaPlus/schema_associations/actions)
9 | [](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 |
--------------------------------------------------------------------------------