Rails, ожидающий загрузки, похоже, запрашивает неправильно

#ruby-on-rails #ruby-on-rails-3 #query-optimization #eager-loading

#ruby-on-rails #ruby-on-rails-3 #оптимизация запросов #нетерпеливая загрузка

Вопрос:

Я пытаюсь выполнить быструю загрузку в своем приложении Rails 3. Я сузил его до самого простого примера, и вместо генерации одного запроса, который я ожидаю, он генерирует 4.

Во-первых, вот простая разбивка моих моделей.

 class Profile < ActiveRecord::Base
  belongs_to :gender

  def to_param
    self.name
  end
end

class Gender < ActiveRecord::Base
  has_many :profiles, :dependent => :nullify
end
  

Затем у меня есть действие ProfilesController:: show, где я запрашиваю модель.

 def ProfilesController < ApplicationController
  before_filter :find_profile, :only => [:show]

  def show
  end

  private

    def find_profile
      @profile = Profile.find_by_username(params[:id], :include => :gender)
      raise ActiveRecord::RecordNotFound, "Page not found" unless @profile
    end
end
  

Когда я смотрю на запросы, которые это генерирует, это показывает следующее:

 SELECT `profiles`.* FROM `profiles` WHERE `profiles`.`username` = 'matt' LIMIT 1
SELECT `genders`.* FROM `genders` WHERE (`genders`.`id` = 1)
  

То, что я ожидал увидеть, — это один запрос:

 SELECT `profiles`.*, `genders`.* FROM `profiles` LEFT JOIN `genders` ON `profiles`.gender_id = `genders`.id WHERE `profiles`.`username` = 'matt' LIMIT 1
  

Кто-нибудь знает, что я здесь делаю не так? Все, что я нашел в eager loading, говорит о том, что это должно сработать.

Редактировать: После попытки joins , как рекомендовано sled, я все еще вижу те же результаты.

Код:

 @profile = Profile.joins(:gender).where(:username => params[:id]).limit(1).first
  

Запрос:

 SELECT `profiles`.* FROM `profiles` INNER JOIN `genders` ON `genders`.`id` = `profiles`.`gender_id` WHERE `profiles`.`username` = 'matt' LIMIT 1
  

Опять же, вы можете видеть, что никакие genders данные не извлекаются, и поэтому выполняется второй запрос к genders .

Я даже пытался добавить select , но безрезультатно:

 @profile = Profile.joins(:gender).select('profiles.*, genders.*').where(:username => params[:id]).limit(1).first
  

что правильно привело к:

 SELECT profiles.*, genders.* FROM `profiles` INNER JOIN `genders` ON `genders`.`id` = `profiles`.`gender_id` WHERE `profiles`.`username` = 'matt' LIMIT 1
  

… но он все равно выполнил второй запрос genders позже при доступе к @profile.gender атрибутам.

Правка 2: я также попытался создать область, которая включает в себя оба select и joins , чтобы получить все требуемые поля (аналогично пользовательскому методу соединения слева, продемонстрированному sled). Это выглядит так:

 class Profile < ActiveRecord::Base
  # ...
  ALL_ATTRIBUTES = [:photo, :city, :gender, :relationship_status, :physique, :children,
    :diet, :drink, :smoke, :drug, :education, :income, :job, :politic, :religion, :zodiac]

  scope :with_attributes,
    select((ALL_ATTRIBUTES.collect { |a| "`#{reflect_on_association(a).table_name}`.*" }   ["`#{table_name}`.*"]).join(', ')).
    joins(ALL_ATTRIBUTES.collect { |a|
      assoc = reflect_on_association(a)
      "LEFT JOIN `#{assoc.table_name}` ON `#{table_name}`.#{assoc.primary_key_name} = `#{assoc.table_name}`.#{assoc.active_record_primary_key}"
    }.join(' '))
  # ...
end
  

Это генерирует следующий запрос, который кажется правильным:

 SELECT `photos`.*, `cities`.*, `profile_genders`.*, `profile_relationship_statuses`.*, `profile_physiques`.*, `profile_children`.*, `profile_diets`.*, `profile_drinks`.*, `profile_smokes`.*, `profile_drugs`.*, `profile_educations`.*, `profile_incomes`.*, `profile_jobs`.*, `profile_politics`.*, `profile_religions`.*, `profile_zodiacs`.*, `profiles`.* FROM `profiles` LEFT JOIN `photos` ON `profiles`.photo_id = `photos`.id LEFT JOIN `cities` ON `profiles`.city_id = `cities`.id LEFT JOIN `profile_genders` ON `profiles`.gender_id = `profile_genders`.id LEFT JOIN `profile_relationship_statuses` ON `profiles`.relationship_status_id = `profile_relationship_statuses`.id LEFT JOIN `profile_physiques` ON `profiles`.physique_id = `profile_physiques`.id LEFT JOIN `profile_children` ON `profiles`.children_id = `profile_children`.id LEFT JOIN `profile_diets` ON `profiles`.diet_id = `profile_diets`.id LEFT JOIN `profile_drinks` ON `profiles`.drink_id = `profile_drinks`.id LEFT JOIN `profile_smokes` ON `profiles`.smoke_id = `profile_smokes`.id LEFT JOIN `profile_drugs` ON `profiles`.drug_id = `profile_drugs`.id LEFT JOIN `profile_educations` ON `profiles`.education_id = `profile_educations`.id LEFT JOIN `profile_incomes` ON `profiles`.income_id = `profile_incomes`.id LEFT JOIN `profile_jobs` ON `profiles`.job_id = `profile_jobs`.id LEFT JOIN `profile_politics` ON `profiles`.politic_id = `profile_politics`.id LEFT JOIN `profile_religions` ON `profiles`.religion_id = `profile_religions`.id LEFT JOIN `profile_zodiacs` ON `profiles`.zodiac_id = `profile_zodiacs`.id WHERE `profiles`.`username` = 'matt' LIMIT 1
  

К сожалению, не похоже, что вызовы атрибутов взаимосвязи (например: @profile.gender.name ) используют данные, которые были возвращены в исходном SELECT . Вместо этого я вижу поток запросов, следующих за этим первым:

 Profile::Gender Load (0.2ms)  SELECT `profile_genders`.* FROM `profile_genders` WHERE `profile_genders`.`id` = 1 LIMIT 1
Profile::Gender Load (0.4ms)  SELECT `profile_genders`.* FROM `profile_genders` INNER JOIN `profile_attractions` ON `profile_genders`.id = `profile_attractions`.gender_id WHERE ((`profile_attractions`.profile_id = 2))
City Load (0.4ms)  SELECT `cities`.* FROM `cities` WHERE `cities`.`id` = 1 LIMIT 1
Country Load (0.3ms)  SELECT `countries`.* FROM `countries` WHERE `countries`.`id` = 228 ORDER BY FIELD(code, 'US') DESC, name ASC LIMIT 1
Profile Load (0.4ms)  SELECT `profiles`.* FROM `profiles` WHERE `profiles`.`id` = 2 LIMIT 1
Profile::Language Load (0.4ms)  SELECT `profile_languages`.* FROM `profile_languages` INNER JOIN `profile_profiles_languages` ON `profile_languages`.id = `profile_profiles_languages`.language_id WHERE ((`profile_profiles_languages`.profile_id = 2))
SQL (0.3ms)  SELECT COUNT(*) FROM `profile_ethnicities` INNER JOIN `profile_profiles_ethnicities` ON `profile_ethnicities`.id = `profile_profiles_ethnicities`.ethnicity_id WHERE ((`profile_profiles_ethnicities`.profile_id = 2))
Profile::Religion Load (0.5ms)  SELECT `profile_religions`.* FROM `profile_religions` WHERE `profile_religions`.`id` = 2 LIMIT 1
Profile::Politic Load (0.2ms)  SELECT `profile_politics`.* FROM `profile_politics` WHERE `profile_politics`.`id` = 3 LIMIT 1
  

Ответ №1:

ваш пример хорош, и он будет представлен в двух запросах, потому что именно так реализована быстрая загрузка в rails. Это становится удобным, если у вас много связанных записей. Вы можете прочитать больше об этом здесь

То, что вы, вероятно, хотите, это простое объединение:

 @profile = Profile.joins(:gender).where(:username => params[:id])
  

Редактировать

Если профиль состоит из множества частей, то здесь существует несколько подходов:

Пользовательские соединения слева — возможно, есть плагин, который выполняет эту работу, в противном случае я бы предложил сделать что-то вроде:

 class Profile < ActiveRecord::Base

  # .... code .....

  def self.with_dependencies

    attr_joins    = []
    attr_selects  = []

    attr_selects << "`profiles`.*"
    attr_selects << "`genders`.*"
    attr_selects << "`colors`.*"

    attr_joins << "LEFT JOIN `genders` ON `gender`.`id` = `profiles`.gender_id"
    attr_joins << "LEFT JOIN `colors` ON `colors`.`id` = `profiles`.color_id"

    prep_model  = select(attr_selects.join(','))

    attr_joins.each do |c_join|
      prep_model = prep_model.joins(c_join)
    end

    return prep_model
  end

end
  

Теперь вы могли бы сделать что-то вроде:

 @profile = Profile.with_dependencies.where(:username => params[:id])
  

Другое решение заключается в использовании :include => [:gender, :color] может быть, на несколько запросов больше, но это более чистый «rails way». Если вы столкнетесь с проблемами производительности, возможно, вам захочется переосмыслить свою схему базы данных, но действительно ли у вас такая большая нагрузка?

Мой друг написал приятное небольшое решение для этих простых отношений 1: n (например, для полов), оно называется simple_enum

Комментарии:

1. Однако я полагаю, что Rails объединит их в один запрос, если этого требуют условия поиска (например, @profile = Profile.includes(:gender).where("genders.name = '...'") )

2.Вы правы, но это не рекомендуется Даже несмотря на то, что Active Record позволяет указывать условия для загружаемых с нетерпением ассоциаций точно так же, как объединения, рекомендуемый способ — использовать вместо этого объединения. источник

3. В конечном счете, у меня есть несколько связей, к которым я буду присоединяться profile , и иногда эти ассоциации будут нулевыми. Таким образом, мне нужно будет выполнить левое соединение. Означает ли это, что я должен полностью выписать левое соединение вместе с именем таблицы для каждого соединения, или в Rails 3 есть лучший способ?

4. Спасибо sled. К сожалению, похоже, что все еще создается несколько запросов. Я обновил свой вопрос с помощью генерируемого кода и связанных с ним запросов.

5. хм, да, это важный момент — если вы присоединитесь к чему-либо, rails не будет заполнять это как объект «ассоциации». Rails просто смешивает атрибуты пола с атрибутами профиля. Таким образом, использование объединений в rails более или менее предназначено для уменьшения набора результатов, а не для извлечения «информации» 😉 Поэтому самое простое решение — придерживаться :include => [...] решения и забыть о нескольких запросах. Возможно, вы хотите использовать плагин, подобный simple_enum , о котором я упоминал.

Ответ №2:

После работы с предложениями sled я, наконец, пришел к этому решению. Я уверен, что это можно было бы сделать чище с помощью плагина, но вот что у меня есть на данный момент:

 class Profile < ActiveRecord::Base
  ALL_ATTRIBUTES = [:photo, :city, :gender, :relationship_status, :physique, :children,
    :diet, :drink, :smoke, :drug, :education, :income, :job, :politic, :religion, :zodiac]

  scope :with_attributes,
    includes(ALL_ATTRIBUTES).
    select((ALL_ATTRIBUTES.collect { |a| "`#{reflect_on_association(a).table_name}`.*" }   ["`#{table_name}`.*"]).join(', '))
end
  

Два основных момента:

  • Вызов includes , который передает символы отношений, которые я хочу
  • Вызов select , который гарантирует получение всех столбцов для связанных таблиц. Обратите внимание, что я вызываю reflect_on_association , чтобы мне не приходилось жестко кодировать имена связанных таблиц, позволяя моделям Rails выполнять работу за меня.

Теперь я могу вызвать:

 Profile.with_attributes.where(:username => params[:id]).limit(1).first
  

Собираюсь отметить ответ sled как правильный, поскольку именно его справка (ответы комментарии вместе взятые) привела меня сюда, хотя в конечном итоге я использую именно этот код.