0

Using Rails 7

I am using a Single Table Inheritance (STI) to store some very simple associations. The source object uses has_many associations with the STI models. Following some advice in question https://stackoverflow.com/a/45681641/1014251 I am using a polymorphic relationship in the join model. This works really well with one annoyance:

When creating a join models the source type is being taken from the root STI class rather than the actual source.

The models:

class Guidance < ApplicationRecord
  has_many :guidance_details
  has_many :themes, through: :guidance_details, source: :detailable, source_type: "Theme"
end

class Detail < ApplicationRecord
  has_many :guidance_details, as: :detailable
  has_many :guidances, through: :guidance_details
end

class GuidanceDetail < ApplicationRecord
  belongs_to :detailable, polymorphic: true
  belongs_to :guidance
end

class Theme < Detail
end

The problem

If I create a new GuidanceDetail and do not specify the detailable_source the system inserts "Detail" rather than "Theme".:

guidance_detail = GuidanceDetail.create(guidance: Guidance.first, detailable: Theme.first)
guidance_detail.detailable_type => "Detail"

The detailable type should be "Theme".

To fix this currently I am having to specify the detailable_type each time I create a new GuidanceDetail.

A fix that doesn't work

I have tried specifying the has_many association of the child object directly, but get the same result:

class Theme < Detail
  has_many :guidance_details, as: :detailable
end

Alternative creation method doesn't work

theme = Theme.first
guidance = Guidance.first

guidance.themes << theme

Outputs:

GuidanceDetail Create (1.3ms)  INSERT INTO "guidance_details" ("detailable_id", "guidance_id", "position", "created_at", "updated_at", "detailable_type") VALUES ($1, $2, $3, $4, $5, $6) RETURNING "id"  [["detailable_id", 11], ["guidance_id", 1], ["position", nil], ["created_at", "2024-11-14 10:24:19.785623"], ["updated_at", "2024-11-14 10:24:19.785623"], ["detailable_type", "Detail"]]

As you see: "detailable_type" is "Detail".

3 Answers 3

1

From my understanding, in order to store the class name "Theme", it appears Detail needs to be a abstract_class.

ActiveRecord::Associations::BelongsToPolymorphicAssociation sets the foreign_type to the polymorphic_name

polymorphic_name is defined as:

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

and base_class

def set_base_class # :nodoc:
  @base_class = if self == Base
    self
  else
    unless self < Base
      raise ActiveRecordError, "#{name} doesn't belong in a hierarchy descending from ActiveRecord"
    end

    if superclass == Base || superclass.abstract_class?
      self
    else
      superclass.base_class
    end
  end
end

So you can see that base_class.name will only use the actual class name if the class is the base class or the superclass is an abstract class. At least this is how I tracked it around.

Sign up to request clarification or add additional context in comments.

3 Comments

Thank you - that's the solution - Setting the Detail class as an abstract class (with abstract_class = true fixed the problem!!.
GuidanceDetail is a join model that allows a many to many relationship between Theme (and the other Detail classes) and Guidance. That's why it DOES NOT inherit from Detail.
Thanks @engieersmnky - I've reworded that text so that I hope it is a little less confusing.
1

You most likely don't actually need / want a polymorphic assocation here.

Polymorphic assocations solve the problem where an assocation points to not just multiple classes but also multiple tables. Like lets say we have:

class Wheel < ApplicationRecord
  belongs_to :vehicle, polymorphic: true
end

class Car < ApplicationRecord
  has_many :wheels, as: :vehicle
end

class Airplane < ApplicationRecord
  has_many :wheels, as: :vehicle
end

We need both a wheels.vehicle_id and wheels.vechicle_type column since wheels.vehicle_id could point to any table and the combination of the two makes a composite foreign key of sorts.

If we change this so that Car and Airplane use single table inheritance to inherit from Vehicle we no longer need a polymorphic assocation because we have a single table:

class Wheel < ApplicationRecord
  belongs_to :vehicle
end

class Vehicle < ApplicationRecord
  self.abstract_class = true
  has_many :wheels
end

class Car < Vehicle
  # ...
end

class Airplane < Vehicle
  # ...
end

Here wheel doesn't actually care if vehicle is a Car or Airplane. Just that the vehicle assocation points to the vehicles table and that it can be resolved by joining on wheels.vehicle_id = vehicles.id.

When the record is loaded Rails will read the vehicles.type column and give you the right subtype.

This is one really strong argument for why you would use Single Table Inheritance as it lets you have real foreign key constraints.

Comments

0

Override detailable_type with a callback in GuidanceDetail

To avoid having to specify detailable_type each time, you can create a callback that automatically assigns the correct STI subclass name based on the type of the associated detailable object.

In GuidanceDetail, add a before_save callback to set detailable_type if it’s not already defined:

class GuidanceDetail < ApplicationRecord
  belongs_to :detailable, polymorphic: true
  belongs_to :guidance

  before_save :set_detailable_type

  private

  def set_detailable_type
    self.detailable_type = detailable.class.base_class.name
  end
end

This ensures detailable_type is set to the actual subclass name (Theme) when creating a new GuidanceDetail.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.