Search code examples
ruby-on-railsactiverecordpolymorphismpolymorphic-associations

Why does a polymorphic association in Rails with source_type set result in the wrong SQL statement?


I have three models: User, Organisation and Role. A user has access to many organizations through a role, and an organization can have many users through their roles. In addition, users can have access to other models through roles, so the Role model has a polymorphic belongs_to association named "access_to", and the roles table have the fields "user_id", "access_to_id" and "access_to_type" to track the associations. This all works great, I can use the organisation.users and user.organisations collections and get the expected records back.

However, it has been decided to rename the Organisation model to "Organization" with US English spelling instead of UK English (this is a made up example, the real problem is similar, but with additional complexity irrelevant to this issue). The application has been running for years, so there are thousands of records in the roles table with "access_to_type" set to "Organisation" (with an "s"). Also, the "Organisation" model has to be kept around for legacy code purposes, while the "Organization" model is used by new code.

To achieve this, "source_type: 'Organisation'" is added to the has_many through: associations on User and the new Organization model, so the complete code looks like this:

class Role < ApplicationRecord
  belongs_to :user
  belongs_to :access_to, polymorphic: true
end

class User < ApplicationRecord
  has_many :roles, autosave: true, foreign_key: "user_id"

  has_many(
    :organizations,
    through: :roles,
    source: :access_to,
    source_type: "Organisation"
  )
end

class Organization < ApplicationRecord
  self.table_name = 'organisations'

  has_many :roles, as: :access_to
  has_many :users, through: :roles, source: :access_to, source_type: "Organisation"
end

class Organisation < ApplicationRecord
  has_many :roles, as: :access_to
  has_many :users, through: :roles
end

Calling "User.first.organizations" still works as expected and returns the expected records with this SQL statement:

SELECT "organisations".* FROM "organisations" 
INNER JOIN "roles" ON "organisations"."id" = "roles"."access_to_id"
WHERE "roles"."user_id" = ? AND "roles"."access_to_type" = ?
LIMIT ?  [["user_id", 1], ["access_to_type", "Organisation"], ["LIMIT", 11]]

And calling "Organisation.first.users" on the legacy model spelled with an "s" work fine, generating the expected SQL:

SELECT "users".* FROM "users" INNER JOIN "roles"
ON "users"."id" = "roles"."user_id"
WHERE "roles"."access_to_id" = ?
AND "roles"."access_to_type" = ?
LIMIT ? 
[["access_to_id", 1],
["access_to_type", "Organisation"],
["LIMIT", 11]]

However, calling "Organization.first.users" does not return any records, and the reason is obvious when looking at the SQL statement Rails generates:

SELECT "organisations".* FROM "organisations"
INNER JOIN "roles" ON "organisations"."id" = "roles"."access_to_id"
WHERE "roles"."access_to_id" = ?
AND "roles"."access_to_type" = ?
AND "roles"."access_to_type" = ?
LIMIT ? 
[["access_to_id", 1],
["access_to_type", "Organization"], 
["access_to_type", "Organisation"],
["LIMIT", 11]]

The SQL statement looks for Role records where access_to_type is both "Organization" (with a "z") and "Organisation" (with an "s"). It seems that setting source_type: "Organisation" adds an additional condition on access_to_type, rather than replacing the default condition where "Organization" is spelled with a "z".

Also it changes the association to look in the "organisations" table instead of the "users" table. I would it expect it to simply change the "access_to_type" condition.

Why does this work in one direction (finding organisations for a user), but not in the other direction (finding users for an organisation)? Is this a bug in Rails (the double condition could indicate that), or is there something I can fix in the association configuration to make it work? How can the source_type mess up so much in one place, and work fine in another?

(Changing the access_to_type values in the database is unfortunately not an option, as there is other code expecting the data to remain unchanged.)

Here is the problem reproduced in a minimal Rails 6.0 app: https://github.com/RSpace/polymorphic-issue


Solution

  • I found a solution that works around the suspected bug: There is an undocumented method called polymorphic_name that ActiveRecord uses to determine what model name to use when doing polymorphic lookups.

    When I change the Organization model to:

    class Organization < ApplicationRecord
      self.table_name = 'organisations'
    
      has_many :roles, as: :access_to
      has_many :users, through: :roles
    
      def self.polymorphic_name
        "Organisation"
      end
    end
    

    then Organization.first.users generates the SQL I want:

    SELECT "users".* FROM "users" INNER JOIN "roles"
    ON "users"."id" = "roles"."user_id"
    WHERE "roles"."access_to_id" = ?
    AND "roles"."access_to_type" = ?
    LIMIT ?  [
    ["access_to_id", 1],
    ["access_to_type", "Organisation"],
    ["LIMIT", 11]]
    

    Commit that fixed my example: https://github.com/RSpace/polymorphic-issue/commit/648de2c4afe54a1e1dff767c7b980bb905e50bad

    I'd still love to hear why the other approach doesn't work though. This workaround seems risky, as I simply discovered this method by digging through the Rails code base, and it's only used internally: https://github.com/rails/rails/search?q=polymorphic_name&unscoped_q=polymorphic_name

    EDIT: I now understand why setting source_type: "Organisation" results in a lookup in the organisations table rather than the users table, as the source_type option controls both model, table and polymorphic name as per the documentation. There is still a bug around getting "access_to_type" set twice, but fixing that won't get my use case working, as source_type is first and foremost for controlling, well, the source type of the association. I will instead pursue to get the polymorphic_name method documented and thus be part of the official ActiveRecord API.