LaunchKit
← All posts
· 13 min read · by The LaunchKit team · 2 views

Polymorphic associations

A Rails polymorphic association is two database columns and one line of Ruby, and most of what you need to know about it is in neither. The mechanism takes a paragraph. The consequences take the rest of this post: one constraint the database refuses to enforce, and one query Active Record refuses to write.

Everything below was run against activerecord 8.1.3.1 and read out of the LaunchKit boilerplate, which carries six polymorphic belongs_to declarations across four tables. The sales site you are reading this on has none, which is the honest reason every example here comes from the product repository.

Two columns, and the migration that writes them

A polymorphic migration is one call to t.references with one option on it, and the option writes a second column:

create_table :notifications do |t|
  t.references :recipient, null: false, polymorphic: true
  t.string :message, null: false
  t.string :url
  t.datetime :read_at

  t.timestamps
end

ReferenceDefinition#columns in activerecord 8.1.3.1 is where the second column comes from, and its body is five lines:

def columns
  result = [[column_name, type, options]]
  if polymorphic
    result.unshift(["#{name}_type", :string, polymorphic_options])
  end
  result
end

unshift, not push. The type column goes first, and the index that references adds by default follows the same order. In the boilerplate's db/schema.rb that lands as:

t.index ["recipient_type", "recipient_id"], name: "index_notifications_on_recipient"

Non-unique, composite, type column leading. The name is worth a second look, because a plain two-column index would have been named index_notifications_on_recipient_type_and_recipient_id. Polymorphic references get a shorter one on purpose:

def polymorphic_index_name(table_name)
  "index_#{table_name}_on_#{name}"
end

That branch only fires for the index references creates itself. The same boilerplate writes t.references :chat, polymorphic: true, null: false, index: false on ruby_llm_usages and adds the index by hand, which is why that one is called index_ruby_llm_usages_on_chat_type_and_chat_id and the notifications one is not. Two indexes on the same shape, two naming conventions, decided by which line of the migration created them.

Leading column order matters for reads. A query filtering on recipient_id alone cannot use that index, because recipient_type is in front of it. Every query Active Record generates for the association supplies both, so this only bites the maintenance queries you write yourself.

The other side of the association

The owning model declares the inverse with as:, naming the association on the other table rather than a foreign key:

has_many :notifications, as: :recipient, dependent: :destroy

ruby_llm does the same thing for its accounting table. acts_as_chat in ruby_llm 2.0.0.rc1 adds three associations to whatever model calls it, and the third is polymorphic:

has_many :ruby_llm_usages,
         -> { chronological },
         as: :chat,
         class_name: 'RubyLLM::ActiveRecord::Usage',
         dependent: :destroy

The Usage model on the far end is the matching half, and it is two lines in the gem:

belongs_to :chat, polymorphic: true
belongs_to :message, polymorphic: true, optional: true

The token accounting behind those declarations is covered in acts_as_chat and the six tables ruby_llm leaves behind, so this post stays on the association mechanics.

One asymmetry to carry into the next few sections: the has_many ... as: side can be joined and the belongs_to polymorphic side cannot. User.joins(:notifications) produces, with no help from you:

INNER JOIN "notifications"
  ON "notifications"."recipient_type" = 'User'
 AND "notifications"."recipient_id" = "users"."id"

Active Record knows the target table here, because has_many names a class. It writes the type condition into the ON clause and the join is exactly as ordinary as any other.

Cannot add a foreign key to a polymorphic relation

That sentence is not a summary, it is the literal string activerecord raises. Add foreign_key: true to the migration line and you never reach the database:

ArgumentError: Cannot add a foreign key to a polymorphic relation

The guard is the last three lines of ReferenceDefinition#initialize:

if polymorphic && foreign_key
  raise ArgumentError, "Cannot add a foreign key to a polymorphic relation"
end

The reason is SQL, not Rails. A REFERENCES clause names one table. recipient_id points at whichever table the string in recipient_type happens to name on that row, and there is no version of a foreign key constraint that resolves its target per row.

You can see the consequence by counting. The boilerplate's db/schema.rb ends with 13 add_foreign_key lines, covering chats, messages, sessions, subscriptions, transactions and the rest. The migration that writes them says why the count stops there:

# ruby_llm_tool_calls and ruby_llm_usages point at their message and chat polymorphically, so
# they get no foreign key here: there is no single table for the constraint to reference.

active_storage_attachments shows both halves in one table. The migration gives it a polymorphic record reference and an ordinary blob one, and exactly one of the two columns is protected:

add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"

The record_id column, which is the entire point of that table, has nothing.

What the missing constraint actually costs

Orphan rows, and no error when you make one. Here is the whole failure, run against activerecord 8.1.3.1 with belongs_to_required_by_default on:

u = User.create!
n = Notification.create!(recipient: u, message: "x")
u.delete           # not destroy: no callbacks, no dependent: :destroy
n.reload.recipient # => nil

The row survives its recipient. n.recipient is nil rather than an exception, so any code doing notification.recipient.email gets a NoMethodError at render time, a long way from the delete that caused it. And the row is now permanently unsaveable:

n.valid?   # => false, ["Recipient must exist"]
n.update!(message: "y")
# ActiveRecord::RecordInvalid: Validation failed: Recipient must exist

dependent: :destroy on the has_many ... as: side is the entire integrity story, and it only runs when something calls destroy. delete, delete_all, a DELETE in a console, a fixture reset and a restored database dump all walk past it. The boilerplate's notifications feature makes the same trade and the notifications post works through it on that one table.

Three things people actually do about it, in descending order of how often they are done:

  1. Nothing, plus dependent: :destroy, plus a periodic sweep like Notification.where(recipient_type: "User").where.not(recipient_id: User.select(:id)). This is the majority answer and it is not unreasonable, because the sweep is cheap on the composite index and the damage from an orphan notification is a missing row on a dropdown.
  2. An exclusive arc: one nullable foreign key column per possible parent, plus a CHECK constraint asserting that exactly one of them is non-null. Every column gets a real constraint, and adding a seventh parent type means a seventh column and a rewritten CHECK. activerecord-exclusive-arc wraps the boilerplate for this, though its last release is 0.3.1 from 2024-01-20, so check the diff against your Rails version before adopting it.
  3. A database trigger that validates the pair on insert and update. Correct, enforced, and invisible to everyone reading the Rails code, which is why it tends to be written once and discovered by whoever debugs the next schema load.

Active Record refuses to join a polymorphic belongs_to

The error has a class of its own. On activerecord 8.1.3.1, every one of these four calls raises the same thing:

The same notifications rows joined from two directions, with opposite results. Joining from the has_many side names one class and one table, so the join compiles and runs; joining from the polymorphic belongs_to side leaves the target table to be decided per row by recipient_type, and Active Record raises EagerLoadPolymorphicError instead of guessing.

Notification.joins(:recipient)
Notification.eager_load(:recipient)
Notification.includes(:recipient).references(:recipient)
Notification.includes(:recipient).where(users: { email: "a@b.c" })
ActiveRecord::EagerLoadPolymorphicError: Cannot eagerly load the polymorphic association :recipient

The fourth one is the one that catches people, because nothing in it mentions eager loading. Naming another table inside where makes Active Record decide it needs a join, and the join it needs is the one it cannot build.

The raise is three lines inside ActiveRecord::Associations::JoinDependency, in the private build that walks the association tree:

if reflection.polymorphic?
  raise EagerLoadPolymorphicError.new(reflection)
end

and the message comes from the error class itself, in associations/errors.rb:

class EagerLoadPolymorphicError < ActiveRecordError
  def initialize(reflection = nil)
    if reflection
      super("Cannot eagerly load the polymorphic association #{reflection.name.inspect}")

The reason is in the documentation for ActiveRecord::Associations, and it is one sentence worth memorising: "The reason is that the parent model's type is a column value so its corresponding table name cannot be put in the FROM/JOIN clauses of that query."

A SQL join names its table when the query is compiled. A polymorphic association picks its table when the row is read. Those two facts cannot both be true in one statement, and Rails would rather raise than generate a LEFT OUTER JOIN against every table in your schema and sort it out afterwards.

Writing the join by hand

Starting from the polymorphic side means writing the ON clause yourself, with the type condition in it. The LaunchKit boilerplate's admin AI dashboard has to do exactly this, because the numbers on the page live in ruby_llm_usages and the dates it groups by live in chats:

# ruby_llm_usages belongs_to :chat polymorphically, so there is no association to join through.
# The chat_type condition is what keeps the join sound if a second model ever acts_as_chat.
CHATS_JOIN = <<~SQL.squish.freeze
  INNER JOIN chats ON chats.id = ruby_llm_usages.chat_id
                  AND ruby_llm_usages.chat_type = 'Chat'
SQL

used twice in the controller, once for the monthly token chart and once for the per-user table:

tokens_by_month = usages.joins(CHATS_JOIN).where(chats: { created_at: since.. })
                        .group(CHAT_MONTH_SQL).sum(Arel.sql(TOKENS_SQL))
tokens = usages.joins(CHATS_JOIN).group("chats.user_id").sum(Arel.sql(TOKENS_SQL))

The AND ruby_llm_usages.chat_type = 'Chat' is not optional and it is not defensive programming. It is the condition Active Record would have written for you in the other direction. Drop it and the join matches any usage row whose chat_id collides with a chats.id, which today is every row in the table and tomorrow, the moment a second class calls acts_as_chat, is silently wrong numbers on a dashboard nobody audits.

Note what the hand written join is not doing: it is not loading Chat objects. It produces a grouped sum, which is the case where the hand written join is genuinely better than the alternative. When you want the objects instead, the next section is the answer.

Why includes still works

includes does not join unless something forces it to. Left alone it falls back to preload, which runs a separate query per distinct type value:

Notification Load  SELECT "notifications".* FROM "notifications"
User Load          SELECT "users".* FROM "users" WHERE "users"."id" = ?
Team Load          SELECT "teams".* FROM "teams" WHERE "teams"."id" = ?

Three queries for a mixed list, not one per row, and the count scales with the number of distinct types rather than the number of notifications. Two types, three queries, whether the list has 20 rows or 20,000. This is the ordinary fix for an N+1 on a polymorphic association, and the general case is N+1 queries in Rails.

What preload cannot do is filter or sort on the parent, because the parent has not been fetched when the first query runs. Notification.includes(:recipient).order("users.email") is the same dead end as the where above. Sorting a mixed polymorphic list by a column on the parent is not a missing feature, it is a question with no SQL answer when the parents are in different tables, and the honest fixes are to denormalise the sort key onto the child table or to query one type at a time.

The type column holds a class name in a string

recipient_type stores Ruby, in a varchar, forever. polymorphic_name decides what goes in it:

def polymorphic_name
  store_full_class_name ? base_class.name : base_class.name.demodulize
end

base_class, so single table inheritance collapses. An AdminUser < User saved as a recipient writes "User" into the column, not "AdminUser", and reading it back gives you a correctly typed AdminUser only because Active Record then consults the STI type column on users. A polymorphic type column pointing at an STI table stores the root of the hierarchy and nothing else.

Rename the class and the rows do not follow. Set one row's type to a constant that no longer resolves and every path to it fails the same way:

n.recipient
# NameError: uninitialized constant Account
Notification.includes(:recipient).to_a
# NameError: uninitialized constant Account

The second one is the dangerous one, because that record was not asked for by name. One stale row in a table poisons the whole preload for every reader of that list. The Rails guide states the obligation without stating the failure: "Since polymorphic associations rely on storing class names in the database, that data must remain synchronized with the class name used by the Ruby code." What that means in practice is that renaming a model with a polymorphic child table is a data migration, and forgetting the UPDATE produces a NameError in production and a green test suite, because your fixtures wrote the new name.

When two types are all there will ever be

Two parent types, and confidence that there will only ever be two, is the case where polymorphic is the wrong answer. Write two nullable foreign key columns and a CHECK constraint. You get real referential integrity on both, joins that Active Record writes for you in both directions, no string column repeated on every row, and the compiler-ish benefit that adding a third type requires a migration somebody has to review rather than a new string value somebody can typo.

The position, stated as a rule: reach for polymorphic when the set of parent types is genuinely open, and especially when it is open to people who are not you. The boilerplate's Notification#recipient is the defensible version of that bet, and the comment above it does not pretend otherwise: "Polymorphic so anything can be notified; today it is always a User." A product sold as a starting point does not know what its buyers will notify. An application you control usually does.

What would flip it: a Notification that stays single-recipient for two years is a belongs_to :user that paid a foreign key, an index column and a join for nothing. If that is your table, the migration from the polymorphic pair to a plain user_id is an afternoon, and it is worth the afternoon, because the constraint you get back is one the database enforces and the one you gave up was never enforced by anything.

What this post does not cover

has_many :through a polymorphic association, which is its own set of restrictions and its own errors. Polymorphic has_one. ruby_llm_batches, which stores a chat_type string next to a chat_ids jsonb array rather than a single id, and so is a polymorphic pointer at a set of rows rather than one: the jsonb side of that is jsonb columns in Rails, not this post. Partitioned or sharded targets. And the argument, which is a real one and older than Rails, that a polymorphic belongs_to is a relational modelling error rather than a Rails feature, and that the exclusive arc is not a workaround but the correct design that Active Record made inconvenient.

#rails #active-record

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.