Active Record Composite Primary Key Support In Rails 7.1.2

Rails’ ActiveRecord models can now be configured with composite primary keys, making it easier to work with complex data models. This feature was introduced in Rails 7.1.2 and allows you to define a primary key that consists of multiple columns.

ActiveRecord has always been a powerful tool for working with databases, but it has traditionally been limited to using a single column as the primary key. Back in November of 2022, Rails added query_constraints to allow models to be queried by a combination of keys for basic functions such as find, update and reload. This was a step in the right direction but the primary key would still be a single column, which is not a complete solution for all cases.

Before

Let’s consider a data model that contains a basket and a cart. A basket has many carts and a cart belongs to a basket. Each basket is identified by both its id and it’s manufacturer_id. It might look like this,

# frozen_string_literal: true

ActiveRecord::Schema.define do
  create_table :baskets, id: :integer, force: true do |t|
    t.integer :manufacturer_id
  end

  create_table :carts, id: :integer, force: true do |t|
    t.integer :basket_id
    t.integer :manufacturer_id
  end
end

class Basket < ActiveRecord::Base
  query_constraints :id, :manufacturer_id
end

class Cart < ActiveRecord::Base
  belongs_to :basket, query_constraints: [:id, :manufacturer_id]
end

class BugTest < Minitest::Test
  def test_association_stuff
    basket = Basket.create!(manufacturer_id: 123)

    cart = Cart.new
    cart.basket = basket
    cart.save

    assert_equal basket, Cart.take.basket
    assert_equal 123, basket.id
  end
end

Here we use the query_constraints method to define how a Basket can be queried. Similarly, we use the query_constraints option in the belongs_to association to define how a Cart can be queried.

However a basket can not be identified by just its id alone, it also needs the manufacturer_id.

This is where composite primary keys come in. Unfortunately the current implementation does not support composite primary keys.

When basket.id is called, it returns the id value of the basket, which is not a complete representation of the primary key. This can be confusing and misleading when working with complex data models.

In a real world scenario, id might not carry any value by itself and it’s the combination of id and manufacturer_id that defines a basket.

After

Now thanks to this PR, a primary_key can be an array. This cleans up associations and queries making it far easier to both understand and convey relationships.

Let’s look at this example with composite primary keys.

# frozen_string_literal: true

ActiveRecord::Schema.define do
  create_table :baskets, id: :integer, force: true do |t|
    t.integer :manufacturer_id
  end

  create_table :carts, id: :integer, force: true do |t|
    t.integer :basket_id
    t.integer :manufacturer_id
  end
end

class Basket < ActiveRecord::Base
  self.primary_key = [:id, :manufacturer_id]

  alias_attribute :id_value, :id

  has_many :carts, query_constraints: [:basket_id, :manufacturer_id]
end

class Cart < ActiveRecord::Base
  belongs_to :basket
end

class BugTest < Minitest::Test
  def test_association_stuff
    basket = Basket.create!(manufacturer_id: 123)

    cart = Cart.new
    cart.basket = basket
    cart.save

    assert_equal basket, Cart.take.basket
    assert_equal [basket.id_value, 123], basket.id
  end
end

It is important to understand that this is only an ActiveRecord level change. The underlying database still considers the primary key to be a single column, the id which is an auto-incrementing integer.

With the self.primary_key = [:id, :manufacturer_id] option it’s obvious that a basket is identified by both its id and manufacturer_id. This makes it easier to understand the model and its relationships.

Now when basket.id is called, it returns an array of the primary key values, instead of the underlying database id value. This is a more accurate representation of our primary key in this example.

This change also makes it easier to define associations, the query_constraints option is no longer needed in the belongs_to association. The has_many association in Basket now uses the composite primary key to define the relationship.

Need help on your Ruby on Rails or React project?

Join Our Newsletter