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:
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", ...>
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.
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:
:invers_of
option on Rails model associationsThat 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).
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.
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.
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.
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.
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.
# 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