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 ids, only the serials. 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

  1. include the module AssociationAccessors.
  2. define the associations
  3. 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]

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 (the serial of the company) - where #company_id can return value other than nil even without really having a company, #company_serial can only return the serial of the company (if any)
  • #company_id= does not break if there is no company with the given id, only #company will return nil - whereas calling #company_serial= with a not existing serial will raise ActiveRecord::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 the serial of the address if any
  • #address_serial=
    • changes the user_id column of the adress(es) setting it to null or to the id of the user as required
    • raises ActiveRecord::RecordNotFound if the given serial does not exist
    • raises ActiveRecord::HasOneThroughCantAssociateThroughHasOneOrManyReflection if association is defined with through: option

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 the ids of the users - #user_serials will return the serials of the users
  • #user_ids= and #user_serials= will both
    • update the company_id column of the users: setting it to null or to the id of the company as required
    • raise ActiveRecord::RecordNotFound if any of the given ids or serials is non-existing
    • raise ActiveRecord::HasManyThroughCantAssociateThroughHasOneOrManyReflection if association is defined with :through option

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 the ids of the tasks - #task_serials will return the serials of the tasks
  • #task_ids= and #task_serials= will both
    • update the join table adding or deleting records as required
    • raise ActiveRecord::RecordNotFound if any of the given ids or serials 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 and has_many parts work just fine, setting the imageable_type and imageable_id to nil or the class name and the id of the required record
  • It is not recommended to use association_accessors for the belongs_to part of polymorphic associations, as the associated class is derived from the actual value of [association_name]_type column, causing unpredictable behavior and raising NoMethodError when it is nil

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.