The extent of "options" one could pass to a Rails association macro are somewhat vast and in some cases, at least for me, make one wonder when such "options" would ever be used.
Today, I found myself having to dig into the documentation and google-fu'ing on the fly to try to put into code what a coworker was saying verbally about an approach to building out some model relationships.
To start, let's say that the application we are working on has a model called Account
. Let's also say that for billing and shipping purposes, we also want to add an Address
model and relate the two models such that:
class Account < ApplicationRecord
has_many :addresses
end
Disclaimer For the purpose of experimenting and learning I'm choosing not to debate about pros and cons of the implementation as it relates to the scenario at hand.
Now, what we would really like is for our Account
model to have this set up but without having to make separate models that will most likely have the exact same attributes:
class Account < ApplicationRecord
has_many :addresses
has_one :billing_address
has_one :shipping_address
end
Let's say we decided test our hand (and mind), and proceed. Our first step is to put the parent model relationship in place along with the finer detailed relationships that we desire. In this case I'm referring to :addresses
as the parent model and :billing_address
and :shipping_address
as the "finer detailed" or (perhaps) child relationships.
Now that we (hopefully) are on level ground with what we want, proceeding, we now have an Account
model that looks like so:
class Account < ApplicationRecord
has_many :addresses
has_one :billing_address
has_one :shipping_address
end
The question now is... can we and how do we... make this work?
The short answer is yes, we can make this work. And as we explore how, my hope is that a bit more understanding and curiosity is unlocked for us all.
Let's start making this a reality.
Generate a new rails application however you would normally do so. Then run:
rails g model Account
rails g model Address account:references address_type:integer
We are adding the address_type:integer
piece because we are going to make this an enum column with the two options of :billing
and :shipping
.
Let's migrate rails db:migrate
Now, let's add our enum setup in the Address
model like so:
class Address < ApplicationRecord
belongs_to :account
enum address_type: [:billing, :shipping]
end
And then setup a has_many
relationship in our Account
model:
class Account < ApplicationRecord
has_many :addresses
end
Next let's hop into the rails console bin/rails console
and then run:
account = Account.create!
address = Address.create! address_type: "billing", account: account
address = Address.create! address_type: "shipping", account: account
Great, now let's use the account we created to see all the addresses associated to it:
account.addresses
=>
[#<Address:0x00007fa1ab6d86f8
id: 1,
account_id: 1,
address_type: "billing",
created_at: Fri, 01 Apr 2022 02:11:01.334935000 UTC +00:00,
updated_at: Fri, 01 Apr 2022 02:11:01.334935000 UTC +00:00>,
#<Address:0x00007fa1ab6d3b08
id: 2,
account_id: 1,
address_type: "shipping",
created_at: Fri, 01 Apr 2022 02:11:15.381033000 UTC +00:00,
updated_at: Fri, 01 Apr 2022 02:11:15.381033000 UTC +00:00>]
Ok, this looks promising! Let's try to use our has_one :billing_address
association:
account.billing_address
=>
'rescue in compute_class': Rails couldn't find a valid model for BillingAddress association. Please provide the :class_name option on the association declaration. If :class_name is already provided, make sure it's an ActiveRecord::Base subclass. (NameError)
uninitialized constant Account::BillingAddress (NameError)
Whoops! Rails doesn't seem to be able to find a valid model named BillingAddress
which makes sense because we don't have a BillingAddress
model. We really would like it if Rails could search on the Addresses
table and use information from there to find the billing address record.
As it turns out we can do this by adding a couple of options to the call to has_one :billing_address
and has_one :shipping_address
.
The first change we will make is to pass in the class_name
option which we will then point to the Address
class so that Rails will use that model to perform queries on versus trying to use our non-existent BillingAddress
model. Update both has_one
calls like so:
class Account < ApplicationRecord
has_many :addresses
has_one :billing_address, class_name: "Address"
has_one :shipping_address, class_name: "Address"
end
With this in place let's go back into our Rails console and see what this change will do:
account.billing_address
=>
#<Address:0x00007fa02d699888
id: 1,
account_id: 1,
address_type: "billing",
created_at: Mon, 25 Apr 2022 15:24:48.167699000 UTC +00:00,
updated_at: Mon, 25 Apr 2022 15:24:48.167699000 UTC +00:00>
Look at that! No error this time and we got an address of type "billing" back. Neat! Let's now try our shipping_address
:
account.shipping_address
=>
id: 1,
account_id: 1,
address_type: "billing",
created_at: Mon, 25 Apr 2022 15:24:48.167699000 UTC +00:00,
updated_at: Mon, 25 Apr 2022 15:24:48.167699000 UTC +00:00>
Not as exciting this time around. We get back the same record as when we called account.billing_address
with in both cases seem to just be returning the first record found.
It would stand to reason that we need some why to tell Rails how to differentiate between the two. Thankfully, by leveraging the optional second argument to our has_one call we can pass in a scope and tell Rails how to found the records we want.
Let's update our Account
model as such:
class Account < ApplicationRecord
has_many :addresses
has_one :billing_address, -> { where(address_type: "billing") }, class_name: "Address"
has_one :shipping_address, -> { where(address_type: "shipping") }, class_name: "Address"
end
Now, let's go back into our Rails console one more time and see if this gives us the result we are looking for:
account.billing_address
=>
#<Address:0x00007fb309b8e548
id: 1,
account_id: 1,
address_type: "billing",
created_at: Mon, 25 Apr 2022 15:24:48.167699000 UTC +00:00,
updated_at: Mon, 25 Apr 2022 15:24:48.167699000 UTC +00:00>
account.shipping_address
=>
#<Address:0x00007fb3088d35c0
id: 2,
account_id: 1,
address_type: "shipping",
created_at: Mon, 25 Apr 2022 15:29:22.137482000 UTC +00:00,
updated_at: Mon, 25 Apr 2022 15:29:22.137482000 UTC +00:00>
We did it! We should take a moment to celebrate this win. After a brief celebration, lets make this a little cleaner and a bit safer.
Safety first so let's start there. We want to help enforce the fact that we only want an account to have one billing or shipping address.
Let's add a validation on the Address
model that says that we want to validate the uniqueness of the address_type
attribute and scope it to being unique to an Account
record. Here's the line addition:
class Address < ApplicationRecord
# belongs_to :account
# enum address_type: [:billing, :shipping]
validates :address_type, uniqueness: { scope: :account_id }
end
As an additional note, it would probably be a good idea to also add a unique constraint at the database level with something like:
add_index :addresses, [:address_type, :account_id], unique: true
Now for the clean up. We can drop the where
call in both of the scope:
arguments to the has_one
macro. We can do this because since we are using enums, Rails gives us some new methods that we can leverage to remove the where
calls. Based on the allowed values of the enum field the two scopes that we gain are Address.billing
and Address.shipping
.
With this new knowledge we can update our has_one
scope arguments from:
has_one :billing_address, -> { where(address_type: "billing") }, class_name: "Address"
has_one :shipping_address, -> { where(address_type: "shipping") }, class_name: "Address"
to:
has_one :billing_address, -> { billing }, class_name: "Address"
has_one :shipping_address, -> { shipping }, class_name: "Address"
Pretty slick!
What a really special thing we did here and I hope you found it fun to dig into a few of the options you can pass to your association calls to customize the behavior of them.
Thanks for reading and happy Rubying!
Top comments (1)
Thank you, Collin, for this awesome post! I was wrestling with writing a uniqueness validation for one of the values on an enum attribute when I stumbled on this post. You totally reminded me about creating a scoped association, so now I can write a custom validation method using that! 🎉
I hope you're doing well and happy New Year! Say hi to your grandma for me! 😉