Xavier Mol
Xavier Mol

Reputation: 132

Association not found for subclass of Ruby on Rails STI base class

I'm implementing a plugin to Foreman v3.3.0 that should add some additional parameters to the Subnet model.

# app/models/subnet_extensions.rb
module SubnetExtensions
  extend ActiveSupport::Concern

  included do
    has_one :subnet_bgp_config
  end
end

# lib/engine.rb
class Engine < Rails::Engine
  config.to_prepare
    Subnet.include SubnetExtensions
  end
end

Subnets in Foreman are implemented with single table inheritance (STI) and types Subnet::Ipv6 and Subnet::Ipv4. My issue for the plugin comes up whenever the page for any subnet is rendered:

Association named ‘subnet_bgp_config’ was not found on Subnet::Ipv4; perhaps you misspelled it?

In the Rails console, I can verify that the association simply is not present for the subclasses:

# foreman-rake console (Rails 6.1.7)
irb(main):001:0> Subnet.reflect_on_all_associations.map(&:name).include? :subnet_bgp_config
=> true
irb(main):002:0> Subnet::Ipv4.reflect_on_all_associations.map(&:name).include? :subnet_bgp_config
=> false

I've validated, that this approach works just fine, when the has_one association is written into the Foreman source code. So somehow the method of adding the plugin to Foreman breaks the inheritance.

Even including SubnetExtensions in the console does not work (nor does it raise an error):

irb(main):001:0> Subnet::Ipv4.include ForemanSubnetsWithBGPConfig::SubnetExtensions
=> Subnet::Ipv4(id: integer, network: string, mask: string, priority: integer, name: text, vlanid: integer, created_at: datetime, updated_at: datetime, dhcp_id: integer, tftp_id: integer, gateway: string, dns_primary: string, dns_secondary: string, from: string, to: string, dns_id: integer, boot_mode: string, ipam: string, discovery_id: integer, type: string, description: text, mtu: integer, template_id: integer, httpboot_id: integer, netdb_id: integer, nic_delay: integer, externalipam_id: integer, externalipam_group: text, bmc_id: integer)
irb(main):002:0> Subnet::Ipv4.reflect_on_all_associations.map(&:name).include? :subnet_bgp_config
=> false

Can somebody give hints on what went wrong or pointers what to inspect/try next?

Upvotes: 0

Views: 44

Answers (2)

Xavier Mol
Xavier Mol

Reputation: 132

Asking for help on the Foreman developer forum more or less confirms that the issue lies with the load order specific to Foreman. At least the current development branch, which has switched to Rails-7 and Zeitwerk autoloader does not show the same symptoms.

Upvotes: 0

San
San

Reputation: 463

It seems that the issue is due to the way Rails handles associations and inheritance in Single Table Inheritance (STI) with concerns added at runtime. Specifically, when using ActiveSupport::Concern to inject a new association into an STI base class (Subnet), the association may not automatically propagate to the subclasses (Subnet::Ipv4 and Subnet::Ipv6). This can happen because concerns included after the subclasses have loaded might not register the new associations correctly.

Here are some approaches to resolve the issue:

Solution 1: Use prepend in SubnetExtensions

Instead of include, use prepend when extending the Subnet class with the plugin. This can help ensure that the association is added before Rails processes the STI subclasses.

module SubnetExtensions
  extend ActiveSupport::Concern

  included do
    has_one :subnet_bgp_config
  end
end

# Prepend to make sure it applies before subclasses are loaded
Subnet.prepend SubnetExtensions

Solution 2: Define Associations Directly on Subclasses in the Plugin

If prepend doesn’t resolve the issue, you can explicitly add the association to each subclass within the plugin, ensuring that Rails recognizes the association for each subclass:

module SubnetExtensions
  extend ActiveSupport::Concern

  included do
    has_one :subnet_bgp_config
  end
end

Subnet.include SubnetExtensions
Subnet::Ipv4.include SubnetExtensions
Subnet::Ipv6.include SubnetExtensions

This explicitly adds has_one :subnet_bgp_config to both Subnet::Ipv4 and Subnet::Ipv6, addressing the issue for each subclass directly.

Solution 3: Use Rails.application.config.to_prepare

If SubnetExtensions needs to be loaded at runtime (for example, if the plugin is loaded after Rails has initialized), then try adding it in a Rails initializer with Rails.application.config.to_prepare. This ensures that the plugin is reloaded on each request in development, allowing the association to be applied correctly.

In an initializer file, add:

# config/initializers/subnet_extensions.rb
Rails.application.config.to_prepare do
  Subnet.include SubnetExtensions
  Subnet::Ipv4.include SubnetExtensions
  Subnet::Ipv6.include SubnetExtensions
end

This approach is particularly useful in development environments, where Rails classes may be reloaded, and the plugin code needs to be re-included each time.

Solution 4: Use after_initialize Callback

If none of the above methods work, you might consider using an after_initialize callback in SubnetExtensions to ensure the association is defined after Foreman and Rails have fully loaded.

In your plugin’s main file or an initializer:

Rails.application.config.after_initialize do
  Subnet.include SubnetExtensions
end

This callback ensures that SubnetExtensions is loaded and applied after Rails initializes the classes. However, this is generally a last-resort approach, as it depends on the initialization order.

Summary

Try each solution in order, as prepend and adding include on each subclass are generally cleaner and should be sufficient. If you still encounter issues, Rails.application.config.to_prepare and after_initialize can help ensure your plugin initializes correctly in Foreman’s context.

Upvotes: 0

Related Questions