Как логике слоя базы данных не затеряться в 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.
Наконец, хотелось бы предложить полный рецепт решения задачи.
- Создаем папку
sql
в папкеapp
с нужной структурой файлов:
app/
sql/
application.sql
user.sql
product.sql
Файл application.sql
выглядит примерно так:
/*
*= require user.sql
*= require product.sql
*/
- Создаем 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
все существующие функции будут перезаписываться.