DEV Community

Collin
Collin

Posted on

Building has_one from has_many Associations

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

And then setup a has_many relationship in our Account model:

class Account < ApplicationRecord
  has_many :addresses
end
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>]
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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> 
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

to:

has_one :billing_address, -> { billing }, class_name: "Address"
has_one :shipping_address, -> { shipping }, class_name: "Address"
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
meg_gutshall profile image
Meg Gutshall

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! πŸ˜‰