简体   繁体   中英

Polymorphic Association On UUID and Integer Fields

Given tables with integer and uuid primary keys what is the best way to integrate a polymorphic join ( has_many )? For example:

class Interest < ActiveRecord::Base
  # id is an integer
  has_many :likes, as: :likeable
end

class Post < ActiveRecord::Base
  # id is a UUID
  has_many :likes, as: :likeable
end

class User < ActiveRecord::Base
  has_many :likes
  has_many :posts, through: :likes, source: :likeable, source_type: "Post"
  has_many :interests, through: :likes, source: :likeable, source_type: "Interest"
end

class Like < ActiveRecord::Base
  # likeable_id and likeable_type are strings
  belongs_to :likeable, polymorphic: true
  belongs_to :user
end

Many queries work:

interest.likes
post.likes
user.likes

However:

user.interests

Gives:

PG::UndefinedFunction: ERROR: operator does not exist: integer = character varying LINE 1: ...interests" INNER JOIN "likes" ON "interests"."id" = "likes".... ^ HINT: No operator matches the given name and argument type(s). You might need to add explicit type casts. : SELECT "interests".* FROM "interests" INNER JOIN "likes" ON "interests"."id" = "likes"."likeable_id" WHERE "likes"."user_id" = $1 AND "likes"."likeable_type" = $2

What's the best way to include ensure the proper casting happens?

This is an old question, but here's my recommendation.

This is more of an architecture problem. Don't combine UUID ids and integer ids, it get's messy real fast. If you can, migrate the integer IDs to UUID or revert the uuids to integer ids.

My experience has been that the best solution is probably to make use of the rather nice Friendly ID gem: https://github.com/norman/friendly_id

In the off case this is broken in the future, it is basically just a slug generation/managemnet tool, the slug would use this kind of route path: posts/this-is-a-potential-slug instead of posts/1 , but nothing prevents you from using posts/<UUID here> or posts/<alphanumeric string here> .

Typically if you are using UUIDs it's because you don't want to show the sequential integers. Friendly ID works well to avoid that issue.

There's no means to specify the necessary cast using Rails. Instead, add a generated column with the cast, and declare an extra belongs_to association to use it. For example, with this in a migration:

add_column :interests, :_id_s, 'TEXT GENERATED ALWAYS AS (id::text) STORED'
add_index :interests, :_id_s

and this in your models:

class Like
  belongs_to :_likeable_cast, polymorphic: true, primary_key: :_id_s, foreign_key: :likeable_id, foreign_type: :likeable_type

class User
  has_many :interests, through: :likes, source: :_likeable_cast, source_type: "Interest"

then user.interests joins through the alternative association, ie using the generated column with the cast.

I suggest using a column type of text rather than varchar for the likeable_id column, to avoid unnecessary conversions during the join and ensure the index is used.

Can you describe your likes table? I suppose that it contains

  • user_id as integer,
  • likeable_id as integer,
  • likeable_type as integer
  • any third-part fields

So, technically you can not create the same polymorphic association with uuid string and id as integer in scope of two fields likeable_id and likeable_type .

As solution - you can simply add id as primary key to posts table instead of uuid . In case if you maybe do not want to show id of post in URL, or for another security reasons - you can still use uuid as before.

You might be able to define your own method to retrieve likes in your Interest model.

def likes
  Like.where("likeable_type = ? AND likeable_id = ?::text", self.class.name, id)
end

The problem with this solution is that you're not defining the association, so something like 'has_many through' won't work, you'd have to define those methods/queries yourself as well.

Have you considered something like playing around with typecasting the foreign- or primary-key in the association macro? Eg has_many :likes, foreign_key: "id::UUID" or something similar.

Tested on Rails 6.1.4

Having a likeable_id as string works well and rails takes care of the casting of IDs.

Here is an example of my code

Migration for adding polymorphic "owner" to timeline_event model

 class AddOwnerToTimelineEvent < ActiveRecord::Migration[6.1]
  def change
    add_column :timeline_events, :owner_type, :string, null: true
    add_column :timeline_events, :owner_id, :string, null: true
  end
end

Polymorphic model

class TimelineEvent < ApplicationRecord
  belongs_to :owner, polymorphic: true
end

Now we have 2 owner, Contact which has id as Bigint and Company which has id as uuid, you could see in the SQL that rails has already casted them to strings

 contact.timeline_events
  TimelineEvent Load (5.8ms)  SELECT "timeline_events"."id",  "timeline_events"."at_time", 
                                     "timeline_events"."created_at", "timeline_events"."updated_at", 
                                     "timeline_events"."owner_type", "timeline_events"."owner_id" FROM 
                                     "timeline_events" WHERE "timeline_events"."owner_id" = $1 AND 
                                     "timeline_events"."owner_type" = $2  [["owner_id", "1"], 
                                     ["owner_type", "Contact"]]


company.timeline_events
  TimelineEvent Load (1.3ms)  SELECT "timeline_events"."id",  "timeline_events"."action", 
                                     "timeline_events"."at_time", "timeline_events"."created_at", 
                                     "timeline_events"."updated_at", "timeline_events"."owner_type", 
                                     "timeline_events"."owner_id" FROM "timeline_events" WHERE 
                                     "timeline_events"."owner_id" = $1 AND "timeline_events"."owner_type" = 
                                     $2  [["owner_id", "0b967b7c-8b15-4560-adac-17a6970a4274"], 
                                     ["owner_type", "Company"]]

There is a caveat though when you are loading timeline_events for a particular owner type and rails cannot do the type casting for you have to do the casting yourself. for eg loading timelines where owner is a Company

TimelineEvent.where(
  "(owner_type = 'Company' AND uuid(owner_id) in (:companies))",
  companies: Company.select(:id)
)

I'm not good with ActiveRecord, and this is definitely not the answer you're looking for, but if you need a temporary *ugly workaround till you can find a solution, you could override the getter :

class User
  def interests
    self.likes.select{|like| like.likeable._type == 'Interest'}.map(&:likeable)
  end
end

*Very ugly cause it will load all the user likes and then sort them

EDIT I found this interesting article :

self.likes.inject([]) do |result, like|
  result << like.likeable if like.likeable._type = 'Interest'
  result
end

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM