Обратный :belongs_to для успешного :has_many дает ноль

Я разрабатываю плагин для Foreman (v3.5.1) для добавления поддержки записей CNAME в модель сетевого интерфейса (Nic::Base) : ForemanCnames (WIP). Сначала я покажу вам нужные мне ассоциации, где красные стрелки указывают на несуществующую связь:

Проблема

Хотя я могу получить (известные) CNAME от (известного) сетевого адаптера, я не могу получить сетевой адаптер или домен для данного 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", ...>

Рельсовые модели

Вот как выглядит HostAlias

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

Ассоциация для Nic::Base осуществляется с помощью 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

Таблица foreman_cnames_host_aliases была создана в результате миграции базы данных.

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

В Domain не добавляется ассоциация, поскольку нам не нужно искать CNAME для данного домена.

Анализ

Функциональность должна быть простой, как это видно по HostAlias#cname.

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

Итак, HostAlias#nic должно быть тривиальным

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

Я не могу сказать, что пытается сделать Rails, поскольку нет кода Ruby или SQL для проверки.

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

Сначала у меня не было :inverse_of варианта. Но, как я узнал из разных источников, это обязательно, когда установлены :class_name и/или :accepts_nested_attributes_for:

Тем не менее, наличие :inverse_of или его отсутствие не меняет поведение. Вероятно, так и было бы, если бы сетевые адаптеры создавались с CNAME за один раз (это не проверялось).

Размышления об ассоциациях

Я могу проверить ассоциации через консоль Rails.

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>

Глядя на это, мне кажется неправильным, что внешние ключи для :nic и :domain не имеют множественного числа.

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"

Предположительно, это произошло из частного метода ActiveRecord::Reflection::AssociationReflection#derive_foreign_key? Он берет имя ассоциации и добавляет к нему «_id», следовательно, «nic_id» и «domain_id». Хотя я не могу сказать, важно ли это.

Примечания

В какой-то момент я, вероятно, захочу найти CNAME для хоста. Но это должно быть что-то похожее на has_many :host_aliases, :through => :interfaces на Host::Base. Другими словами, :through не является решением моей текущей проблемы.

🤔 А знаете ли вы, что...
Ruby on Rails предоставляет множество готовых решений для обработки типичных задач, таких как маршрутизация и работа с базой данных.


1
52
2

Ответы:

Плюрализация – это проблема!

В соответствии с Руководством Ruby on Rails, ссылки используют имена во множественном числе при миграции базы данных, в результате чего создаются столбцы nics_id и domains_id. В ассоциации :belongs_to используется вариант единственного числа и :has_many множественного числа.

Но у Формана есть :belongs_to связь между сетевыми адаптерами и доменами...

irb(main):006:0> Nic::Base.reflect_on_association(:domain).foreign_key
=> "domain_id"

... и ссылку на таблицу базы данных в единственном числе.

Где я ошибся?
@Макс объясняет мои настоящие ошибки в своем ответе.
Судя по всему, ссылки при создании таблицы (в виде блока) работают по-другому. Я также приму псевдоним own_to, который тогда буквально идентичен ассоциации модели.

После обновления всех вхождений "nics_id" и "domains_id"...

irb(main):001:0> n = Nic::Base.find(7958)
=> #<Nic::Managed id: 7958, ...>

irb(main):002:0> a = n.host_aliases.first
=> #<ForemanCnames::HostAlias id: 1, name: "new-alias", nic_id: 7958, domain_id: 1>

irb(main):003:0> a.nic
=> #<Nic::Managed id: 7958, ...>

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

irb(main):005:0> a.nic.fqdn
=> "satellite-test-01.scc.kit.edu"

irb(main):006:0> a.domain.name
=> "scc.kit.edu"

Решено

Вы правы в том, что плюрализация имеет значение, но вы делаете неправильные выводы. Вот соглашения об именах для столбцов внешнего ключа:

  • Внешние ключи. Эти поля должны быть названы в соответствии с шаблоном сингуляризованное_имя_таблицы_id (например, item_id, order_id). Это поля, которые Active Record будет искать при создании связей между моделями.

Итак, Форман на самом деле использует правильное имя для своего столбца. Столбец внешнего ключа — это ссылка на одну строку в другой таблице. Это идентификатор одного элемента, а не всех элементов.

TableDefinition против SchemaStatement

Судя по всему, ссылки при создании таблицы (в виде блока) работают по-другому. Я также возьму псевдоним own_to, который будет буквально идентичен ассоциации модели.

Это просто неправильно.

ссылки — это метод TableDefinition, который представляет собой просто абстракцию на основе таблицы, которая позволяет вам изменить несколько столбцов в таблице вместо методов работы с одним столбцом в SchemaStatement.

add_reference(:foos, :bar)

Становится:

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

Это намного менее неуклюже, если вы добавите больше столбцов в миграцию.

Они не дают разных результатов в отношении схемы. Вот как они реализованы.

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

Фактическая разница заключается в том, что add_to добавляет изменения в определение таблицы и add немедленно запускает SQL-запрос для изменения таблицы.

Инверсии

Сначала у меня не было опции :inverse_of. Но как я узнал из разных источников, это обязательно

Эти источники или ваша интерпретация неверны. Ваши ассоциации будут работать нормально даже без инверсии.

На самом деле инверсии создают связь в памяти между записями, чтобы при этом вы не теряли производительность из-за ненужного запроса к базе данных foo.bars.first.foo. Итак, инверсии — это хорошо, но без них ваше приложение не взорвется.

Автоматические инверсии получаются из имени ассоциации, поэтому разумно указать его, если оно не может быть получено из имени ассоциации. На самом деле это не связано явно с опцией class_name.

Все вместе, тогда правильный путь

# 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