Active Record associations without foreign keys

28 Feb 2018

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:

class Order < ApplicationRecord
  has_one :custom_logo,
    ->(order) {
      unscope(:where).where(
        origin_value: order.origin,
        client_id:    order.client_id)
    }
end

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:

>> Order.all.eager_load(:custom_logo)
Traceback (most recent call last):
ArgumentError (The association scope 'custom_logo' is instance dependent (the scope block takes an argument). Preloading instance dependent scopes is not supported.)

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:

>> Order.first.custom_logo = CustomLogo.last
Traceback (most recent call last):
        1: from (irb):4
ActiveModel::MissingAttributeError (can't write unknown attribute `order_id`)

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:

def find_target
  # ... some code that's interesting but not relevant ...
  sc = reflection.association_scope_cache(conn, owner) do
    StatementCache.create(conn) { |params|
      as = AssociationScope.create { params.bind }
      target_scope.merge(as.scope(self, conn)).limit(1)
    }
end

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:

$ be rails runner "CustomLogo.destroy_all ; \
Order.first.create_custom_logo ; \
Order.first.create_custom_logo ; \
Order.first.create_custom_logo rescue nil ; \
puts CustomLogo.count"
1

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:

$ be rails runner "CustomLogo.destroy_all ; \
Order.first.create_custom_logo(origin_value: 'twitter', client_id: 42) rescue nil; \
Order.first.create_custom_logo(origin_value: 'twitter', client_id: 42) rescue nil; \
Order.first.create_custom_logo(origin_value: 'twitter', client_id: 42) rescue nil; \
puts CustomLogo.count"
3

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.