AssociationAccessors
association_accessors is a tool for generating accessors for ActiveRecord
model associations based on columns other than the id
(primary key).
Dependencies
- ruby
'>= 2.3.0'
- activerecord
'>= 4.1.7', '< 6.0'
- rspec
'>= 2.0'
(for the test matcher)
Table of Contents
The Challenge
You have the following tables:
ActiveRecord::Schema.define do
create_table :companies, force: true do |t|
t.string :serial
t.string :name
# ...
end
create_table :users, force: true do |t|
t.string :serial
t.integer :company_id
t.string :name
# ...
end
end
For security reasons it is decided that the frontend must not see the id
s, only the serial
s. So, when you want to change the company of a user, params
will contain something like this:
# <ActionController::Parameters { "serial": "9jco5RMp4K", "user": { company_serial: "MbyDB18lCi" } } permitted: false>
Now, if the User
model has #company_serial=
method, you can simply permit :company_serial
.
Using association_accessors it amounts to this:
class User < ActiveRecord::Base
include AssociationAccessors
belongs_to :company
association_accessor_for :company, with_attribute: :serial
end
Installation
Add this line to your application's Gemfile:
gem 'association_accessors'
And then execute:
$ bundle
Config
It is possible to set a default value for the keyword with_attribute:
, for example in an initializer:
# config/initializers/association_accessors.rb
AssociationAccessors.default_attribute = :serial
Usage
- include the module
AssociationAccessors
. - define the associations
- call
.association_accessor_for
method with the association name and the identifier attribute to generate the accessor methods:- for singular associations it will generate a reader and a writer along the rule
[association_name]_[attribute_name]
- for collection associations it will generate the methods along the rule
[singular_association_name]_[plural_attribute_name]
- for singular associations it will generate a reader and a writer along the rule
These accessors work more or less the same way as the id
accessors generated by default for the associations.
There are a few exceptions and some gotchas, though.
With belongs_to
# AssociationAccessors is included in the ApplicationRecord
class User < ApplicationRecord
belongs_to :company, optional: true # only to demonstrate that it works with the association `nil`
association_accessor_for :company, with_attribute: :serial
end
You have the methods #company_id
and #company_id=
because of the foreign key column. association_accessors
generates the methods #company_serial
and #company_serial=
.
These work in a similar way with the following differences:
#company_id
is a column,#company_serial
is a computed value (theserial
of thecompany
) - where#company_id
can return value other thannil
even without really having acompany
,#company_serial
can only return theserial
of thecompany
(if any)#company_id=
does not break if there is no company with the given id, only#company
will returnnil
- whereas calling#company_serial=
with a not existing serial will raiseActiveRecord::RecordNotFound
user.company # => #<Company id: 10, serial: "HVuPpK">
user.company_id # => 10
user.company_serial # => "HVuPpK"
user.company_id = 100 # there is no company with id=100
user.company # => nil
user.company_id # => 100
user.company_serial # => nil
user.company_serial = 'EeNRM'
# ActiveRecord::RecordNotFound (Couldn't find Company)
With has_one
class User < ApplicationRecord
has_one :address
association_accessor_for :address, with_attribute: :serial
end
There is no #address_id
or #address_id=
method for a has_one
association. The generated methods (#address_serial
, #address_serial=
) behave the following way:
#address_serial
returns theserial
of theaddress
if any#address_serial=
- changes the
user_id
column of theadress
(es) setting it tonull
or to theid
of the user as required - raises
ActiveRecord::RecordNotFound
if the givenserial
does not exist - raises
ActiveRecord::HasOneThroughCantAssociateThroughHasOneOrManyReflection
if association is defined withthrough:
option
- changes the
With has_many
class Company < ApplicationRecord
has_many :users
association_accessor_for :users, with_attribute: :serial
end
The generated methods (#user_serials
and #user_serials=
) will behave exactly the same way the collection_singular_ids
accessor generated by rails
:
#user_ids
will return theid
s of theusers
-#user_serials
will return theserial
s of theusers
#user_ids=
and#user_serials=
will both- update the
company_id
column of the users: setting it tonull
or to theid
of the company as required - raise
ActiveRecord::RecordNotFound
if any of the givenid
s orserial
s is non-existing - raise
ActiveRecord::HasManyThroughCantAssociateThroughHasOneOrManyReflection
if association is defined with:through
option
- update the
With has_and_belongs_to_many
class User < ApplicationRecord
has_and_belongs_to_many :tasks
association_accessor_for :tasks, with_attribute: :serial
end
The generated methods (#task_serials
and #task_serials=
) will behave exactly the same way the collection_singular_ids
accessor generated by rails
:
#task_ids
will return theid
s of thetasks
-#task_serials
will return theserial
s of thetasks
#task_ids=
and#task_serials=
will both- update the join table adding or deleting records as required
- raise
ActiveRecord::RecordNotFound
if any of the givenid
s orserial
s is non-existing
With custom association names
class Node < ApplicationRecord
belongs_to :parent, class_name: 'Node'
association_accessor_for :parent, with_attribute: :serial
has_many :children, class_name: 'Node', foreign_key: :parent_id
association_accessor_for :children, with_attribute: :serial
end
The generated methods (#parent_serial
, #parent_serial=
, #child_serials
and #child_serials=
) work the same way, as they rely on the association, not the column.
With polymorphic associations
class User < ApplicationRecord
has_one :image, as: :imageable
association_accessor_for :image, with_attribute: :serial
end
class Task < ApplicationRecord
has_many :images, as: :imageable
association_accessor_for :images, with_attribute: :serial
end
class Image < ApplicationRecord
belongs_to :imageable, polymorphic: true
end
- The
has_one
andhas_many
parts work just fine, setting theimageable_type
andimageable_id
tonil
or the class name and theid
of the required record - It is not recommended to use
association_accessors
for thebelongs_to
part of polymorphic associations, as the associated class is derived from the actual value of[association_name]_type
column, causing unpredictable behavior and raisingNoMethodError
when it isnil
Test matcher
association_accessors ships a test matcher too: #have_association_accessor_for
. It surely works with rspec.
RSpec.configure do |config|
config.include AssociationAccessors::Test, type: :model
end
RSpec.describe User, type: :model do
it { should have_association_accessor_for(:company).with_attribute(:serial) }
end
So far, it checks if the reader and writer methods are defined on the subject, nothing more.
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/SzNagyMisu/association_accessors.
Development
After checking out the repo, run bin/setup
to install dependencies. Then, run rake spec
to run the tests. You can also run bin/console
for an interactive prompt that will allow you to experiment.
To test against other activerecord
or rspec
versions, check the ./Appraisals
file and choose one:
$ bundle exec appraisal activerecord-5.0 bin/console
$ bundle exec appraisal activerecord-5.0 rspec
To run a full test suite (including multiple ruby, activerecord and rspec versions), run:
$ rake full_test
$ RUBY_VERSIONS=2.5.3,2.6.1 rake full_test
Setting the environment variable RUBY_VERSIONS
overrides the default (2.3.7
, 2.4.1
, 2.5.1
) ruby versions to test against. Note that while the ruby versions must be installed for the test to run, the gems are handled by appraisal.
License
The gem is available as open source under the terms of the MIT License.