Используем SQL в Rails

Автор: admin от 1-09-2018, 21:55, посмотрело: 101

Во-первых, статья не о том, как я люблю рельсы, во-вторых, статья не о том, как я их ненавижу. К ним можно относиться совершенно по-разному и лучше они станут только если их менять. И хуже они могут стать только если их начать менять. Ну, в общем, я вас предупредил, а вы меня поняли.



Одна из основных концепций ActiveRecord состоит в том, что база данных достаточно утилитарна и может быть изменена. Ну, вы такие сидите, пишете свои модельки с использованием MySQL и вдруг где-то прочитали, что можно вот так вот взять и заменить MySQL на MongoDB. Хорошо, не так радикально, но, скажем, на PostgreSQL у вас могут быть причины заменить MySQL. Или наоборот, ничего не имею против MySQL. Тут ActiveRecord утверждает, что сделать вам это проще простого, мол скоупы, before/after фильтры и ассоциации достаточно абстрактны, чтобы не переживать за формирование запросов к базе данных и заботится о логике приложения. Что вместо WHERE is_archived = TRUE вы с радостью напишете where(is_archived: true) и ActiveRecord сделает все за вас. Все примеры будут преведены для PostgreSQL, а не для MySQL, так что пользователи MySQL вынуждены будут изобретать свой собственный велосипед.



Используем SQL в Rails

Но как бы не так! На практике оказывается, что этот слой абстракции вся напрочь дырявая, как корыто из сказки о Золотой Рыбке. И что многие базовые возможности использовать нельзя, вроде сравнения дат или работы с массивами. И получаются скоупы с вынужденными where("#{quoted_table_name}.finished_at >= ?", Date.current) или where("#{quoted_table_name}.other_ids <@ ARRAY[?]", ids). На что ActiveRecord дает вполне осознанный и логичный ответ: не используйте это. Вместо массивов используйте habtm-связь, а если надо сравнивать даты, живите с этим. Да, и не дай бог вам пропустить quoted_table_name в таком скоупе — первый же includes или joins расставит все на свои места. Проще везде и всегда писать, чтобы руку не сбивать.



Ну и, конечно же, как только вы решитесь на вот такое вот вмешивание в работу ActiveRecord, назад пути уже не будет. Не то, чтобы шансов, даже призрачной надежы на безболезненый переход на другую базу данных не будет. Значительно проще будет распечатать и сжечь этот исходный код. И само собой, никакой другой причины не использовать экстра-возможности базы данных в вашем приложении нет. Используйте на здоровье и других заставляйте!



И когда окажется, что ваши скоупы в папке models состоят более чем наполовину из вот таких вот использований экстра-возможностей, то станет совершенно очевидно, что ActiveRecord — всего лишь удобная оболочка для интеграции одного куска кода с табличками с другим куском кода. И скоупы, вроде where(is_archived: true).joins(:sprint).merge(Sprint.archived), будут отрабатывать прекрасно и комбинировать их будет не сложнее, чем приготовить яишницу, правда ведь?



Используем SQL в Rails

Следующей стадией будет денормализация. Нет, денормализация как бы и была всегда и никуда не исчезала, но забота о ней возлагалась на могучие плечи Rails и ActiveRecord, а вы знаете, что эти два парня не отличались расторопностью и аскетичностью в требованиях к ресурсам. Допустим, counter_cache: true — это первый шаг к денормализации, ведь COUNT(*) AS sprints_count вам сделать ActiveRecord так вот просто не позволит (вы же не хотите менять select(), правда ведь?). И еще counter_cache не далек от совершенства и в некоторых случаях может произойти рассинхронизация реального количества от закешированного. Некритично, конечно, но неприятно. И это только первый кандидат для того, чтобы осесть в базе данных и не загружать и без того загруженную голову руби-машины. Всего-то пара триггеров и готово! Во-первых при удалении и добавлении новой записи в табличку А нужно пересчитать количество записей в табличке Б и все, да? Ну и при редактировании, конечно же, если foreign_key поменялся, ведь запрос UPDATE B SET a_id = $1 WHERE id = $2 сломает counter_cache и для старого А и для нового.



  CREATE OR REPLACE FUNCTION update_#{parent_table}_#{child_table}_counter_on_insert() RETURNS TRIGGER AS $$
  BEGIN
    UPDATE #{parent_table} SET #{counter_column} = COALESCE((SELECT COUNT(id) FROM #{child_table} GROUP BY #{foreign_column} HAVING #{foreign_column} = NEW.#{foreign_column}), 0) WHERE (#{parent_table}.id = NEW.#{foreign_column});
    RETURN NULL;
  END;
  $$ LANGUAGE plpgsql;


Следующая стезя работ базы данных окажется связанной с датой-временем. И для начала давайте просто поля created_at и updated_at будем обслуживать в базе, благо, это сильно проще. Сначала поставим дефолты:



  change_column_default :table_name, :created_at,  { 'CURRENT_TIMESTAMP' }
  change_column_default :table_name, :updated_at,  { 'CURRENT_TIMESTAMP' }


А чтобы сразу сделать это везде, можно цикл организовать по всем табличкам, где есть эти поля. Кроме таблиц schema_migrations и ar_internal_metadata, конечно же:



  (tables - %w(schema_migrations ar_internal_metadata)).each { ... }


Все, теперь значение по-умолчанию для этих таблиц будет ровно таким, как нам нужно. И теперь самое время позаботиться о том, чтобы рельсы не трогали эти поля. Делается это двумя болтами в нужных местах. И да, есть опция в настройке фреймворка, которая за это отвечает:



Rails.application.config.active_record.record_timestamps = false


Итак, следующим шагом будет обновление поля updated_at в момент обновления записи. Это просто:



  CREATE OR REPLACE FUNCTION touch_for_#{table_name}_on_update() RETURNS TRIGGER AS $$
  BEGIN
    SELECT CURRENT_TIMESTAMP INTO NEW.updated_at;
    RETURN NEW;
  END;
  $$ LANGUAGE plpgsql;


Теперь нужно полностью избавится от touch: true в моделях. Эта штука очень сильно похожа на мишень в тире — тоже полностью дырявая. И даже не буду объяснять почему, ведь вы и так все эти случаи знаете. Это не сильно сложнее, всего-лишь нужно обновлять updated_at не только себе:



  CREATE OR REPLACE FUNCTION touch_for_#{table_name}_on_update() RETURNS TRIGGER AS $$
  BEGIN
    UPDATE foreign_table_name SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.foreign_column_name;
    SELECT CURRENT_TIMESTAMP INTO NEW.updated_at;
    RETURN NEW;
  END;
  $$ LANGUAGE plpgsql;


Конечно, цепочка вызовов таких вот триггеров будет делать лишнее действие, но вот в постгресе вменяемого механизма вызвать триггеры не меняя саму запись нет. Можно попробовать сделать SET title = title, но это выходит ничем не лучше, чем SET updated_at = CURRENT_TIMESTAMP.



Точно такой же триггер будет и на вставку, только обновлять updated_at не обязательно:



  CREATE OR REPLACE FUNCTION touch_for_#{table_name}_on_insert() RETURNS TRIGGER AS $$
  BEGIN
    UPDATE foreign_table_name SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.foreign_column_name;
    RETURN NEW;
  END;
  $$ LANGUAGE plpgsql;


Конечно, можно бы попытаться написать это одной функцией, добавив проверку на текущее событие прям внутрь триггера, на подобии IF TG_OP = 'UPDATE' THEN, но предпочтительнее делать все триггеры как можно проще, чтобы уменьшить вероятность ошибки.



Вам, возможно, захочется как-то автоматизировать генерацию таких триггеров, и тогда вам наверняка нужно будет найти все foreign-связи между текущей таблицей и остальными. Вот с этим запросом это вы легко сделаете:



  SELECT ccu.table_name AS foreign_table_name, kcu.column_name AS column_name
  FROM information_schema.table_constraints AS tc
    JOIN information_schema.key_column_usage AS kcu
    ON tc.constraint_name = kcu.constraint_name
    JOIN information_schema.constraint_column_usage AS ccu
    ON ccu.constraint_name = tc.constraint_name
  WHERE constraint_type = 'FOREIGN KEY' AND tc.table_name = '#{table_name}'
  ORDER BY ccu.table_name;


Еще один очень полезный совет. Называйте триггеры однообразно, чтобы иметь возможность убедиться в наличии или отсутствии необходимого одним запросом, например этот запрос найдет все touch-insert триггеры:



  SELECT routine_name AS name
  FROM information_schema.routines
  WHERE
    routine_name LIKE 'touch_for_%_on_insert' AND
    routine_type ='FUNCTION' AND
    specific_schema='public';


И на последок осталось самое страшное. Дело в том, что рельсы не рассчитаны на хоть сколько-нибудь умную базу данных и совершенно плевать хотели на то, что в базе может поменяться хоть что-то, кроме поля id, и то, только при вставке. Поэтому нет вменяемого механизма добавить RETURNING updated_at к update-запросам никакого нет, нужно будет нырнуть во внутренности Rails по самые уши.



Используем SQL в Rails

Манкипатч получился не такой уж, чтобы сильно аккуратный, но в первую очередь ставилась цель как можно меньше повреждать текущую работу фреймворка.





Самое главное, что тут происходит обращение к ApplicationRecord.custom_returning_columns, чтобы узнать какие же колонки, кроме id нас интерисуют. И выглядит этот метод как-то так:



  class  self
    def custom_returning_columns(table_ref, action)
      return [] if ['"schema_migrations"', '"ar_internal_metadata"'].include?(table_ref)

      res = []
      res  :created_at if action == :create
      res  :updated_at

      res += case table_ref
             when '"user_applications"'
               [:api_token]
             when '"users"'
               [:session_salt, :password_changed_at]
             # ...
             else
               []
             end

      res
    end
  end




Вместо выводов можно сказать, что, больная голова Rails стала чуть менее больная. Такие рутинные процессы, как counter_cache и touch канут в лету и в следующей статье можно будет думать о чем-то более глобальном, вроде удаления висячих пробелов, валидации данных, каскадном удалении данных или параноидальном удалении. Если эта статья понравилась, конечно же.



Источник: Хабр / Интересные публикации

Категория: Веб-разработка

Уважаемый посетитель, Вы зашли на сайт как незарегистрированный пользователь.
Мы рекомендуем Вам зарегистрироваться либо войти на сайт под своим именем.

Добавление комментария

Имя:*
E-Mail:
Комментарий:
Полужирный Наклонный текст Подчеркнутый текст Зачеркнутый текст | Выравнивание по левому краю По центру Выравнивание по правому краю | Вставка смайликов Выбор цвета | Скрытый текст Вставка цитаты Преобразовать выбранный текст из транслитерации в кириллицу Вставка спойлера
Введите два слова, показанных на изображении: *