ruby-on-railsrubyactiverecordmonkeypatchingtheforeman

Inverse :belongs_to for successful :has_many yields nil


I'm developing a plugin to Foreman (v3.5.1) to add support of CNAME records to the network interface model (Nic::Base): ForemanCnames (WIP). I'll first show you the associations that I want, where the red arrows indicate the defunct connection:
ForemanCname model associations

The Problem

While I can get (known) CNAMEs from a (known) NIC, I cannot get the NIC or domain for a given CNAME.

irb(main):001:0> n = Nic::Base.find(7958)
=> <Nic::Managed id: 7958, name: "satellite-test-01.scc.kit.edu", ...>

irb(main):002:0> a = n.host_aliases.first
=> <ForemanCnames::HostAlias id: 5, name: "newer-alias", nics_id: 7958, domains_id: 1>

irb(main):004:0> a.nic
=> nil

irb(main):006:0> a.domain
=> nil

irb(main):007:0> n.domain
=> <Domain id: 1, name: "scc.kit.edu", ...>

Rails Models

This is how HostAlias looks like

module ForemanCnames
  class HostAlias < ActiveRecord::Base
    belongs_to :domain
    belongs_to :nic, :class_name => '::Nic::Base', :inverse_of => :host_aliases

    validates :nics_id, :presence => true
    validates :name, :presence => true, :uniqueness => {:scope => :domains_id}

    def to_s
      name
    end

    def cname
      Nic::Interface.find(nics_id).fqdn
    end
  end
end

The association for Nic::Base is done with a Concern

module ForemanCnames::Concerns::NicExtensions
  extend ActiveSupport::Concern

  included do
    has_many :host_aliases, :foreign_key => :nics_id, :class_name => 'ForemanCnames::HostAlias', :inverse_of => :nic
    accepts_nested_attributes_for :host_aliases, allow_destroy: true
  end
end

The foreman_cnames_host_aliases table was created by database migration.

class CreateHostAliases < ActiveRecord::Migration[4.2]
  def change
    create_table :foreman_cnames_host_aliases do |t|
      # A primary key id column is added by default
      t.string :name, :limit => 255
      t.references :nics, null: false, foreign_key: true
      t.references :domains, null: false, foreign_key: true

      t.timestamps
    end
  end
end

There is no association injected to Domain, since we don't need to find CNAMEs for a given domain.

Analysis

The functionality should be straight forward, as can be seen with HostAlias#cname.

irb(main):001:0> ForemanCnames::HostAlias.first.cname
=> "satellite-test-01.scc.kit.edu"

So HostAlias#nic should be trivial

def nic
  Nic::Base.find(nics_id)
end

I cannot tell what Rails tries, since there is no Ruby or SQL code to inspect.

irb(main):001:0> ForemanCnames::HostAlias.first.method(:nic).source_location
=> ["/usr/share/gems/gems/activerecord-6.1.7/lib/active_record/associations/builder/association.rb", 102]

irb(main):002:0> ForemanCnames::HostAlias.first.nic.to_sql
Traceback (most recent call last):
        2: from lib/tasks/console.rake:5:in `block in <top (required)>'
        1: from (irb):2
NoMethodError (undefined method `to_sql' for nil:NilClass)

irb(main):003:0> puts Nic::Base.find(7958).host_aliases.to_sql
SELECT "foreman_cnames_host_aliases".* FROM "foreman_cnames_host_aliases" WHERE "foreman_cnames_host_aliases"."nics_id" = 7958

:inverse_of

At first I had no :inverse_of option. But as I learned from various sources, it is mandatory when either :class_name and/or :accepts_nested_attributes_for are set:

That said though, whether :inverse_of is present or not does not alter the behavior. It probably would, if NICs were created with CNAMEs in one go (have not tested that).

Association Reflections

I can inspect the associations via the Rails console

irb(main):001:0> Nic::Base.reflect_on_association(:host_aliases)
=> #<ActiveRecord::Reflection::HasManyReflection:0x000055f4135d5090 @name=:host_aliases, @scope=nil, @options={:foreign_key=>:nics_id, :class_name=>"ForemanCnames::HostAlias", :inverse_of=>:nic, :autosave=>true}, @active_record=Nic::Base(id: integer, ...), @klass=ForemanCnames::HostAlias(id: integer, name: string, nics_id: integer, domains_id: integer), @plural_name="host_aliases", @constructable=true, @class_name="ForemanCnames::HostAlias", @inverse_name=:nic, @foreign_key="nics_id", @active_record_primary_key="id", @inverse_of=#<ActiveRecord::Reflection::BelongsToReflection:0x000055f411e906f0 @name=:nic, ...>>

irb(main):002:0> ForemanCnames::HostAlias.reflect_on_association(:nic)
=> #<ActiveRecord::Reflection::BelongsToReflection:0x000055f411e906f0 @name=:nic, @scope=nil, @options={:class_name=>"Nic::Base", :inverse_of=>:host_aliases}, @active_record=ForemanCnames::HostAlias(id: integer, name: string, nics_id: integer, domains_id: integer), @klass=Nic::Base(id: integer, ...), @plural_name="nics", @constructable=true, @foreign_key="nic_id", @class_name="Nic::Base", @inverse_name=:host_aliases, @inverse_of=#<ActiveRecord::Reflection::HasManyReflection:0x000055f4135d5090 @name=:host_aliases ...>>

irb(main):003:0> ForemanCnames::HostAlias.reflect_on_association(:domain)
=> #<ActiveRecord::Reflection::BelongsToReflection:0x000055f411e48170 @name=:domain, @scope=nil, @options={}, @active_record=ForemanCnames::HostAlias(id: integer, name: string, nics_id: integer, domains_id: integer), @klass=nil, @plural_name="domains", @constructable=true>

Looking at those, it seems wrong to me, that the foreign keys for :nic and :domain are not pluralized.

irb(main):001:0> ForemanCnames::HostAlias.reflect_on_association(:nic).association_primary_key
=> "id"

irb(main):002:0> ForemanCnames::HostAlias.reflect_on_association(:nic).foreign_key
=> "nic_id"

irb(main):003:0> ForemanCnames::HostAlias.reflect_on_association(:domain).association_primary_key
=> "id"

irb(main):004:0> ForemanCnames::HostAlias.reflect_on_association(:domain).foreign_key
=> "domain_id"

irb(main):005:0> ForemanCnames::HostAlias
=> ForemanCnames::HostAlias(id: integer, name: string, nics_id: integer, domains_id: integer)

irb(main):006:0> ForemanCnames::HostAlias.reflect_on_association(:nic).table_name
=> "nics"

Presumably, that came from the private method ActiveRecord::Reflection::AssociationReflection#derive_foreign_key? It takes the name of the association and appends "_id" to it, hence, "nic_id" and "domain_id". Though I cannot tell whether that is important.

Sidenotes

At some point I probably want to find CNAMEs for a host. But that should be something similar to has_many :host_aliases, :through => :interfaces on Host::Base. In other words, :through is not the solution to my current problem.


Solution

  • You're right that pluralization matters but you're drawing the wrong conclusions. These are the naming conventions for foreign key columns:

    • Foreign keys - These fields should be named following the pattern singularized_table_name_id (e.g., item_id, order_id). These are the fields that Active Record will look for when you create associations between your models.

    So Foreman is actually using the correct name for it's column. The foreign key column is a reference to one single row on the other table. It's the id of one item not all the items.

    TableDefinition vs SchemaStatement

    Apparently, references during table creation (in block form) works differently. I'll also adopt the alias belongs_to, which then is literally identical to the model's association.

    This is just wrong.

    references is a method of TableDefinition which is just a table based abstraction that lets you modify multiple columns on the table instead of the single column operation methods in SchemaStatement.

    add_reference(:foos, :bar)
    

    Becomes:

    change_table :foos do |t|
      t.references :bar
    end  
    

    Which is way less clunky when you add more columns to the migration.

    They do not produce different results as far as the schema is concerned. This is how they are implemented.

    def references(*args, **options)
      args.each do |ref_name|
        ReferenceDefinition.new(ref_name, **options).add_to(self)
      end
    end
    
    def add_reference(table_name, ref_name, **options)
      ReferenceDefinition.new(ref_name, **options).add(table_name, self)
    end
    

    The actual difference really is that add_to adds the changes to the table definintion and add immediately fires the SQL query to alter the table.

    Inverses

    At first I had no :inverse_of option. But as I learned from various sources, it is mandatory

    Those sources or your interpretation are wrong. Your assocations will work just fine even without an inverse.

    What inverses actually do is create an in-memory link between the records so that you don't incur the performance penality of an unessicary database query when you do foo.bars.first.foo. So inverses are good but your app won't blow up without them.

    Automatic inverses are derived from the name of the assocation so it's wise to specify it if it cannot be derived from the name of the assocation. This isn't actually explicitly linked to the class_name option.

    All together then the right way

    # Acronyms should be ALLCAPS
    # setup an inflection!
    module ForemanCNAMES
      class HostAlias < ActiveRecord::Base
        # Validations are added by default since Rails 5
        belongs_to :domain
        belongs_to :nic, 
          class_name: '::Nic::Base', 
          inverse_of: :host_aliases
        has_one :interface, through: :nic
    
        def to_s
          name
        end
    
        def cname
          interface.fqdn
        end
      end
    end
    
    # Do not use :: when defining classes/modules
    # If you do it right and reopen the module all constants will be resolved
    # from your namespace
    module ForemanCNAMES
      # Don't use module names and excessive nesting that provides no value
      # like Concerns, Modules, Classes
      module NicExtensions
        extend ActiveSupport::Concern
        included do
          has_many :host_aliases 
          accepts_nested_attributes_for :host_aliases, 
            allow_destroy: true
        end
      end
    end