`has_one` relationships are missing methods

I know May is over, but this one just bit me again and felt relevant to share.

On a number of occasions, I’ve run into issues where an expectation of interface consistency has failed me around has_one associations. As an example, here’s a [limited] chart of the various capabilities available with various association types, and the generated methods that provide those capabilities.

+----------------------+----------------+----------------+----------------+---------------+-------------------------+-----------------+-----------------+
|                      | belongs_to     | belongs_to     | has_one        | has_one       | has_and_belongs_to_many | has_many        | has_many        |
|                      |                | :polymorphic   |                | :through      |                         |                 | :through        |
+----------------------+----------------+----------------+----------------+---------------+-------------------------+-----------------+-----------------+
| Fetch Record(s)      | record         | record         | record         | record        | records                 | records         | records         |
+----------------------+----------------+----------------+----------------+---------------+-------------------------+-----------------+-----------------+
| Replace Record(s)    | record=        | record=        | record=        | record=       | records=                | records=        | records=        |
+----------------------+----------------+----------------+----------------+---------------+-------------------------+-----------------+-----------------+
| Fetch Record ID(s)   | record_id      | record_id      |                |               | record_ids              | record_ids      | record_ids      |
|                      | [attribute]    | [attribute]    |                |               |                         |                 |                 |
+----------------------+----------------+----------------+----------------+---------------+-------------------------+-----------------+-----------------+
| Replace Record ID(s) | record_id=     | record_id=     |                |               | record_ids=             | record_ids=     | record_ids=     |
|                      | [attribute]    | [attribute]    |                |               |                         |                 |                 |
+----------------------+----------------+----------------+----------------+---------------+-------------------------+-----------------+-----------------+
| Reload Record(s)     | reload_record  | reload_record  | reload_record  | reload_record | records.reload          | records.reload  | records.reload  |
+----------------------+----------------+----------------+----------------+---------------+-------------------------+-----------------+-----------------+
| `build`              | build_record   |                | build_record   |               | records.build           | records.build   | records.build   |
+----------------------+----------------+----------------+----------------+---------------+-------------------------+-----------------+-----------------+
| `create`             | create_record  |                | create_record  |               | records.create          | records.create  | records.create  |
+----------------------+----------------+----------------+----------------+---------------+-------------------------+-----------------+-----------------+
| `create!`            | create_record! |                | create_record! |               | records.create!         | records.create! | records.create! |
+----------------------+----------------+----------------+----------------+---------------+-------------------------+-----------------+-----------------+

(Adapted in part from the documentation here. Apologies for the scroll width.)

belongs_to :polymorphic has a few gaps in its profile, but the reason for these is relatively straightforward to explain — they all have to do with creation of models for a polymorphic relation. One could envision such methods existing with a mandatory type parameter, which might provide an acceptable avenue for rebuilding parity, but an exception doesn’t seem entirely unreasonable either.

has_one associations live in the strange space between has_many relationships (which don’t “own” any of their own data) and belongs_to relationships (which are singular). As such, they tend to blend capabilities from the two sets. Like belongs_to, they don’t actually create accessors for the related ID. This leads to a strange imbalance, since belongs_to relationships effectively do have ID accessors, since the FK column (conventionally, <association>_id) has methods created for it.

A naïve implementation is fairly trivial:

def record_id=(id)
  self.record = id.present? ? Record.find(id) : nil
end

def record_id
  self.record && self.record.id
end

In our application, we’ve found the *_id and *_id= methods very useful for updating associations through our model-agnostic form objects, allowing us to avoid having to over-query association data, serialize that data to an ID on read, and explicitly fetch the record on write. Having said that, our application doesn’t have the same concerns as many web applications — there might be a catch that legitimately prevents these methods from being globally appropriate, even if they work most of the time.

The final missing methods are the record construction methods on has_one :through relationships. These are somewhat trickier, since the FK lives on the passed argument, but both lazily and eagerly created associated records are handled properly by has_many and has_many :through, so I have to believe this problem is not insurmountable either. Indeed, a naïve implementation of create_record could be as simple as:

def create_record(attributes = {})
  self.record = Record.create(attributes)
end

It’s worth stating explicitly that the documentation around association methods doesn’t claim that these has_one methods do exist, but it also doesn’t state that (or why) they don’t. The WTF here is that there’s a reasonable expectation that they do exist, which makes the documentation seem incomplete and increases frustration when calls to those methods fail.