Feature Proposal: `alias_association`

TLDR

There is a high chance of Rails breaking alias_attribute behaviour when its used to alias an association instead of an attribute. This is an opportunity to share reasons to why Rails may need to provide an alternative such as alias_association

Context

In Rails, we currently have the alias_attribute method, which provides a way to alias an attribute. This is a powerful feature that allows developers to refer to an attribute by a different name, providing flexibility in how applications design models and interact with data.

However, there’s currently no built-in way to alias an association. This has led some applications to use alias_attribute as a workaround to alias associations, which is not its intended use. This approach is not officially supported by Rails and could potentially break in future Rails updates with no deprecation cycle.

This lack of a built-in method to alias an association can lead to unnecessary code duplication and can make our models more difficult to understand and maintain.

Blockers

  • :warning: This proposal currently lacks a strong use-case example. Without a solid case it is going to be hard to justify adding complexity that comes with the feature.
  • Even with a strong need the implementation will need to ensure that it doesn’t hurt performance for applications that won’t use the feature

Proposal

I propose that we add a new method to Rails: alias_association. This method would work similarly to alias_attribute, but it would be used to alias an association instead of an attribute.

Here’s an example of how it might be used:

class Post < ApplicationRecord
  belongs_to :author
  alias_association :creator, :author
end

In this example, :creator is an alias for the :author association. This means that we can use post.creator to refer to the post’s author, just like we would use post.author. Along with aliased accessor we will be able to query by the aliased association: Post.where(creator: Author.first).to_a

Acceptance Criteria

  • The alias_association method should create an alias for an association.

  • The alias should work exactly like the original association. This means that we should be able to call all the same methods on the alias as we can on the original association.

  • The alias_association method should raise an error if the original association does not exist.

  • The alias_association method should not interfere with the original association. This means that we should still be able to use the original association even after creating an alias for it.

    Fundamentals

    The alias_association feature should at least provide the same capabilities as alias_attribute when used against an association:

    • Provides getter and setter for the association
    • Allows objects to be queried by the aliased association name in .where()

    Nice to have:

    • We need to make sure that the original foreign key is being set when association is assigned using an aliased setter
    • Overall alias_association should also define an alias on the original foreign key attribute

Acceptance Criteria tests

This is a script to be used to verify the implementation of the feature against acceptance criteria. The list will be extended with more examples and eventually may be used as a set of tests in the Rails test suite.

# typed: true
# frozen_string_literal: true

require "bundler/inline"

gemfile(true) do
  source "https://rubygems.org"

  git_source(:github) { |repo| "https://github.com/#{repo}.git" }

  gem "rails", github: "rails/rails", branch: "main"
  gem "sqlite3"
end

require "active_record"
require "minitest/autorun"
require "logger"

ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
ActiveRecord::Base.logger = Logger.new(STDOUT)

ActiveRecord::Schema.define do
  create_table :posts, force: true do |t|
    t.string(:title)
    t.text(:body)
    t.integer(:author_id)
  end

  create_table :authors, force: true do |t|
    t.string(:name)
  end
end

class Post < ActiveRecord::Base
  belongs_to :author
  # Uncomment to see some tests pass to show which use-cases are supported by `alias_attribute`
  alias_attribute :creator, :author
  # alias_association :creator, :author
end

class Author < ActiveRecord::Base
  has_many :posts

  # Uncomment to see some tests pass to show which use-cases are supported by `alias_attribute`
  alias_attribute :publications, :posts
  # alias_association :publication, :posts
end

bob = Author.create(name: "Bob")
alice = Author.create(name: "Alice")

bob.posts.create(title: "Bob's first post", body: "Bob says hello")
bob.posts.create(title: "Bob's second post", body: "Bob says hey")
alice.posts.create(title: "Alice's first post", body: "Alice says hello")
alice.posts.create(title: "Alice's second post", body: "Alice says hey")

class AliasAssociation < Minitest::Test
  def setup
    @bob = Author.find_by(name: "Bob")
    @bobs_first_post = Post.find_by(title: "Bob's first post")
    @bobs_second_post = Post.find_by(title: "Bob's second post")
    @alice = Author.find_by(name: "Alice")
    @alices_first_post = Post.find_by(title: "Alice's first post")
  end

  def test_belongs_to_getter
    assert_equal(@bob, @bobs_first_post.author)
    assert_equal(@bobs_first_post.author, @bobs_first_post.creator)
  end

  def test_belongs_to_setter
    @bobs_first_post.creator = @alice

    assert_equal(@alice, @bobs_first_post.author)
    assert_equal(@alice, @bobs_first_post.creator)
  end

  def test_belongs_to_where
    bobs_posts = @bob.posts.to_a
    refute_empty(bobs_posts)
    assert_equal(bobs_posts, Post.where(creator: @bob).to_a)
  end

  def test_has_many_getter
    assert_equal([@bobs_first_post, @bobs_second_post], @bob.posts.to_a)
    assert_equal(@bob.posts.to_a, @bob.publications.to_a)
  end

  def has_many_setter
    post = Post.create(title: "Completely new post")
    @bob.publications = [post]
    @bob.save

    bobs_posts = @bob.posts.to_a
    assert_equal([post], @bob.posts)
    assert_equal([post], @bob.publications)
  end

  def has_many_shovel
    post = Post.create(title: "Additional post")
    @bob.publications << post

    assert_includes(@bob.posts.to_a, post)
    assert_includes(@bob.publications.to_a, post)
  end

  def has_many_where
    bobs_posts = @bob.posts.to_a
    refute_empty(bobs_posts)
    assert_equal(bobs_posts, Post.where(creator: @bob).to_a)
  end

  # TODO:
  # Add tests for aliased `creator_id` foreign key
end

Request for Feedback

To ensure that alias_association meets the needs of the Rails community, we’re interested in hearing about your potential use-cases for this feature. If you’ve ever found yourself wishing for a way to alias an association, or if you’ve used alias_attribute or other workarounds to achieve this, we want to hear from you.

Please share:

  • Scenarios where you would use alias_association
  • How you’re currently handling these scenarios without alias_association
  • Any other thoughts or ideas you have about this feature

Your input will help us shape alias_association into a feature that’s useful and intuitive for all Rails developers. Thank you in advance for your feedback!

2 Likes

@nikita we recently added code to our application as a workaround for this issue. I have also started some code to add to Rails to introduce the alias_association method. I have it working, however, I believe it should also have safeguards to make sure it can’t be applied to cases where, for example, alias_attribute should instead be used. I am just not sure how to go about that, and at what point those safeguards should be triggered (for example, the alias_attribute deprecation warnings are generated in activerecord/lib/active_record/attribute_methods.rb). I can share the code if/when that makes sense. I didn’t want to create a Rails PR just yet, because I believe it’s incomplete without these additional considerations.

Here’s a simplified example of how we were using alias_attribute before the deprecation:

class Ticket < ApplicationRecord
  belongs_to :ticketable, polymorphic: true
  alias_attribute :invoice, :ticketable
end

To deal with this in the meantime, we have deleted the alias_attribute and added these methods to the classes. However, we had to modify four classes that were affected by this and we’d prefer to see this handled with something like an alias_association method:

  def invoice
    ticketable
  end

  def invoice=(invoice)
    self.ticketable = invoice
  end

In terms of the Rails code, I started by adding the method to activesupport/lib/active_support/core_ext/module/aliasing.rb, and crafted new tests to validate the usage.

Do you have any input or feedback on this?

Absolutely fair expectation!

I think since associations only exist in Active Record framework all logic related to aliasing an association will have to be placed under /activerecord

So indeed getter & setter are two essential expectations on the feature but ultimately I believe we would want more: For example it should be possible to preload objects like Model.includes(:aliased_association).to_a and accessing the original_association on loaded objects should not perform any new queries. Also where() should work for both where(aliased_association: [obj1, obj2]) and where(original_association: [obj1, obj2]). Ultimately even foreign key names should be aliased as well so if we have belongs_to :order; alias_association :subject, :order model should potentially alias order_id attribute to subject_id The feature doesn’t have to support everything from the beginning as it can always evolve but it’s important to keep it in mind as it’s hard to tell what was the exact reason for an application to alias an association using alias_attribute apart from defining a getter/setter

Thank you for the input. I’ll work on this new direction and post an update…

Hi, just curious, is there any update here? I was planning to build my own version when I came across this. But wondering if you already have a version you’d be willing to share? All the stuff that @nikita said is important to me as well! :slight_smile:

So the initial PR is here Introduce `alias_association` feature by nvasilevski · Pull Request #49801 · rails/rails · GitHub

It may not cover all of the use-cases because I wanted to make it basic enough to get merged and once we have the API merged it should be much easier to justify any extension for the feature

1 Like

Just looked it over, that’s perfect. Excited to have it out!

Thanks for your work on that! I hadn’t had a chance to circle back around to it.