Suppose you've got a legacy database, or some other non-Railsy schema situation, and you want to define an association but the tables don't have the standard Active Record foreign key columns. You can accomplish this, to a certain extent, with an association scope that defines a new where
relation. But there are some hitches.
For example, you might have an Order that has_one
CustomLogo, but rather than a foreign key it relies on matching values for several other fields: origin
and client_id
. Here's how you could manage that:
I wish I had thought of this technique, but credit goes to Dwight on Stack Overflow. It's a neat way to handle this situation, but it does have a few pitfalls. One issue is that since the scope uses attributes from a specific object instance, we can't eagerly load all the logos for a collection of orders:
The same thing happens with an includes
relation. Active Record will raise this exception regardless of whether the association scope actually uses the argument. That's a pretty reasonable design choice since the alternative is to attempt to do a data flow analysis inside the scope's Proc
.
You'll also run into problems when using some of the methods generated by a has_one
declaration. If you attempt to assign a record via the association accessor you'll get an exception around the missing foreign key:
Incidentally, to me the unscope
call looks like a complicated part of this scope. But all it does is replace any existing where
clause objects in the relation with nil. And it returns the relation, so we can then define a new where
clause like we do in this example. Notice that this is different than ActiveRecord::Relation#rewhere
, which lets you redefine the where
clause for a particular attribute.
Even more incidentally, how does the association scope only return one record even though the where
clause doesn't constrain the resulting relation? This is a more general has_one
question, and the answer is in ActiveRecord::Associations::SingularAssociation#find_target
:
So limit(1)
explains that.
Finally, there's some interesting behavior around creation helper methods. Using the association helper with no arguments to create a record results in only one CustomLogo
record being created, no matter how many invocations:
This makes sense because Active Record is using the equality Arel nodes in the association scope to create the record. So if the first Order
has an origin_value
of "facebook" and a client_id
of 42, so will the one associated CustomLogo
. The rescue nil
is there because when we call create_custom_logo
after the first record is in place, ActiveRecord::Associations::HasOneAssociation#replace
calls ActiveRecord::Associations::Association#set_owner_attributes
with an order_id
value in an attempt to set a foreign key. That raises the same MissingAttributeError
exception that we saw earlier, so I'm rescuing that to prevent the script from exiting early.
The interesting thing is what happens if you pass arguments to the creation helper; calling that repeatedly will create a bunch of records:
This goes through a different code path. All record creations, even the first one, call set_owner_attributes
and thus we need to rescue the MissingAttributeError
every time.
Pulling back a little on this, if origin_value
and client_id
form effectively a composite unique key, a good way to prevent odd data would be to add a partial uniqueness constraint on those two columns.
To sum up, you can define associations without foreign keys - but don't be surprised when some things don't work the way they normally do.