Как логике слоя базы данных не затеряться в Rails приложении

Очень часто в приложениях общающихся с реляционной базой данных возникает необходимость обновления некоторых данных при изменении других. Примерами таких задач могут служить всякого рода счетчики, обновление статистики, синхронизация данных в денормализованной базе данных. В Ruby on Rails стандартными инструментами для этого являются callbacks в ActiveRecord моделях либо observers. Иногда имеет смысл перенести такого рода логику в базу данных. Классическая причина - оптимизация, уменьшение нагрузки на приложение.

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

В Rails приложениях для управления структурой базы данных есть стандартный инструмент - миграции (ActiveRecord::Migration), которые кроме предоставления DSL для управления структурой, также позволяют выполнять сырой SQL. А это значит, что есть способ добавлять/изменять/удалять необходимые триггеры, хранимые процедуры etc. Такой подход имеет свои минусы:

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

Дабы исправить приведенные недостатки, в папке app можно завести папку sql для sql-файлов с триггерами и хранимыми процедурами, сгруппированными так как удобнее разработчикам (согласитесь, иметь несколько файлов с логически сгруппированными кусками кода намного удобнее, чем вывалить все свои мысли в один файл).

app/
  assets/
  controllers/
  models/
  sql/              ←  наша папка для sql-файлов
  views/

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

task :import_sql do
  # Логика импорта sql в базу данных
end

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

namespace :db do
  task :migrate do
    # Логика импорта sql в базу данных
  end
end

При таком подходе можно не менять свои деплой скрипты (выполнение миграций при деплое - очень частое явление). А также у новых разработчиков не возникнут вопросы, почему у них приложение ведет себя не так как у других, ведь rake db:migrate скорее всего при развертывании на локальной машине он запустит.

Реализация импорта sql в базу данных в лоб выглядит примерно так:

Dir[Rails.root + "/app/sql/*.sql"].each do |file|
  sql = File.read(file)  
  ActiveRecord::Base.connection.execute(sql)
end

Но есть более оптимальное решение - смержить все sql-файлы и залить одним запросом. К счастью необходимые инструменты есть и широко используются Rails сообществом. Можно поступить с sql-файлами точно так же, как это делается с ассетами, то есть смержить используя Sprockets.

Наконец, хотелось бы предложить полный рецепт решения задачи.

  1. Создаем папку sql в папке app с нужной структурой файлов:
app/
  sql/
    application.sql
    user.sql
    product.sql

Файл application.sql выглядит примерно так:

/*
  *= require user.sql
  *= require product.sql
*/
  1. Создаем rake задачу со следующим содержимым:
namespace :db do
  task :migrate do
    environment = Sprockets::Environment.new do |env|
                              env.register_mime_type('text/sql', '.sql')
                              env.register_processor('text/sql', Sprockets::DirectiveProcessor)
                              env.append_path 'app/sql'
                            end
     ActiveRecord::Base.connection.execute environment['application.sql'].to_s
  end
end

Вот собственно и весь рецепт.

Ложка дегтя. При таком подходе перед каждым объявлением новой функции, хранимой процедуры либо создания триггера должен быть свой DROP (FUNCTION/TRIGGER) iF EXIST и/или CREATE OR REPLACE, так как здесь нет никакого версионирования и механизму миграций непонятно, вносились ли изменения в файлы, новые они или давно импортированные в базу данных. Следовательно при каждом rake db:migrate все существующие функции будут перезаписываться.

Связаться