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


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


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




К сожалению, не доступен сервер mySQL