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

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

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

Но как бы не так! На практике оказывается, что этот слой абстракции вся напрочь дырявая, как корыто из сказки о Золотой Рыбке. И что многие базовые возможности использовать нельзя, вроде сравнения дат или работы с массивами. И получаются скоупы с вынужденными 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), будут отрабатывать прекрасно и комбинировать их будет не сложнее, чем приготовить яишницу, правда ведь?

Следующей стадией будет денормализация. Нет, денормализация как бы и была всегда и никуда не исчезала, но забота о ней возлагалась на могучие плечи 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 по самые уши.

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

Приведу его полностью

module ActiveRecord   module Persistence     # https://github.com/rails/rails/blob/v5.2.0/activerecord/lib/active_record/persistence.rb#L729-L741     def _create_record(attribute_names = self.attribute_names)       attribute_names &= self.class.column_names       attributes_values = attributes_with_values_for_create(attribute_names)        an_id, *affected_rows = self.class._insert_record(attributes_values).dup       self.id ||= an_id if self.class.primary_key       Hash[ApplicationRecord.custom_returning_columns(self.class.quoted_table_name, :create).take(affected_rows.size).zip(affected_rows)].each do |column_name, value|         public_send("#{column_name}=", self.class.attribute_types[column_name.to_s].deserialize(value)) if value       end        @new_record = false        yield(self) if block_given?        id     end     private :_create_record      # https://github.com/rails/rails/blob/v5.2.0/activerecord/lib/active_record/persistence.rb#L710-L725     def _update_record(attribute_names = self.attribute_names)       attribute_names &= self.class.column_names       attribute_names = attributes_for_update(attribute_names)        if attribute_names.empty?         affected_rows = []         @_trigger_update_callback = true       else         affected_rows = _update_row(attribute_names)         @_trigger_update_callback = affected_rows.any?       end        Hash[ApplicationRecord.custom_returning_columns(self.class.quoted_table_name, :update).take(affected_rows.size).zip(affected_rows)].each do |column_name, value|         public_send("#{column_name}=", self.class.attribute_types[column_name.to_s].deserialize(value))       end        yield(self) if block_given?        affected_rows.none? ? 0 : 1     end     private :_update_record   end    module ConnectionAdapters     module PostgreSQL       module DatabaseStatements         # https://github.com/rails/rails/blob/v5.2.0/activerecord/lib/active_record/connection_adapters/postgresql/database_statements.rb#L93-L96         def exec_update(sql, name = nil, binds = [])           execute_and_clear(sql_with_returning(sql), name, binds) { |result| Array.wrap(result.values.first) }         end          # https://github.com/rails/rails/blob/v5.2.0/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb#L147-L152         def insert(arel, name = nil, pk = nil, _id_value = nil, sequence_name = nil, binds = [])           sql, binds = to_sql_and_binds(arel, binds)           exec_insert(sql, name, binds, pk, sequence_name).rows.first         end         alias create insert          # https://github.com/rails/rails/blob/v5.2.0/activerecord/lib/active_record/connection_adapters/postgresql/database_statements.rb#L98-L111         def sql_for_insert(sql, pk, id_value, sequence_name, binds) # :nodoc:           table_ref = extract_table_ref_from_insert_sql(sql)           if pk.nil?             # Extract the table from the insert sql. Yuck.             pk = primary_key(table_ref) if table_ref           end            returning_columns = quote_returning_column_names(table_ref, pk, :create)           if returning_columns.any?             sql = "#{sql} RETURNING #{returning_columns.join(', ')}"           end            super         end          # No source in original repo         def quote_returning_column_names(table_ref, pk, action)           returning_columns = []           returning_columns << pk if suppress_composite_primary_key(pk)           returning_columns += ApplicationRecord.custom_returning_columns(table_ref, action)           returning_columns.map { |column| quote_column_name(column) }         end          # No source in original repo         def sql_with_returning(sql)           table_ref = extract_table_ref_from_update_sql(sql)            returning_columns = quote_returning_column_names(table_ref, nil, :update)            return sql if returning_columns.blank?           "#{sql} RETURNING #{returning_columns.join(', ')}"         end          # No source in original repo         def extract_table_ref_from_update_sql(sql)           sql[/update\s("[A-Za-z0-9_."\[\]\s]+"|[A-Za-z0-9_."\[\]]+)\s*set/im]           Regexp.last_match(1)&.strip         end       end     end   end end

Самое главное, что тут происходит обращение к 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 канут в лету и в следующей статье можно будет думать о чем-то более глобальном, вроде удаления висячих пробелов, валидации данных, каскадном удалении данных или параноидальном удалении. Если эта статья понравилась, конечно же.

FavoriteLoadingДобавить в избранное
Posted in Без рубрики

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *