Во-первых, статья не о том, как я люблю рельсы, во-вторых, статья не о том, как я их ненавижу. К ним можно относиться совершенно по-разному и лучше они станут только если их менять. И хуже они могут стать только если их начать менять. Ну, в общем, я вас предупредил, а вы меня поняли.
Одна из основных концепций 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
канут в лету и в следующей статье можно будет думать о чем-то более глобальном, вроде удаления висячих пробелов, валидации данных, каскадном удалении данных или параноидальном удалении. Если эта статья понравилась, конечно же.
printercu
Arel решает часть проблем, которые вы решаете с помощью quoted_table_name, избавляя при этом от необходимости писать sql:
На самом деле, на arel можно написать почти любое выражение sql, но иногда это может оказаться слишком громоздко. И документация оставляет желать лучшего.
Конечно, разные базы данных имеют разный функционал и даже свое надмножество SQL. При этом есть много проектов, которые действительно поддерживают разные СУБД. Например, Redmine. При разработке не публичных приложений будет странно, если команда собирается менять СУБД, при этом использует специфичный функционал. ActiveRecord тут вообще не причем.
По остальным моментам я упустил мысль уже на таймстэмпах — из кода непонятно, какие задачи решает приведенный код. Почему обновлять (updated|created)_at в базе "сильно проще"? touch тоже имеет свой контракт для разработчика, если следовать ему, то все будет работать.