No more has_one, please

Есть два из трёх популярных видов ассоциаций: has_one и has_many. Все знаю как они устроены: в первом случае идентификатор связанной сущности является атрибутом модели (а следовательно – столбцом в БД). Во втором случае, создаётся транзитивная таблица связи со столбцами entityA_id и entityB_id.

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

Проблема начинается с постановки задачи: допустим, заказчик говорит: хочу, чтобы в модели была связь вида “Юзер проживает в таком-то Городе”. Вы создаёте модель user и модель city, пишете user has_one city, в БД создаётся столбец user.city_id. Из коробки вырастает API, в интерфейсе можно вывести наименование города пользователя как user.city.name, и все довольны.

Пока.

Пока вы не осознаёте, что в случае переезда юзера из одного города в другой – вы теряете информацию о прошлом городе. А заказчик неожиданно удивляется, и просит впредь её хранить. И ещё фиксировать даты переезда.

И начинаются костыли. Добавляется столбец user.start_living_in_the_city, аудит внешними инструментами и прочий мрак. А заказчик приходит снова и говорит: а вычислите мне длительность проживания юзера в Москве. И начинается ещё больший мрак и п****ц.

Как надо было делать:

Объект user существует сам по себе, и город city существует сам по себе. То, что юзер проживает в городе, само по себе является business-valuable информацией, но не должно затрагивать ни тех, ни других.

Надо было сделать:

– has_many с обоих сторон
– таблицу связи cities_users
– третью модель (назовём её citizen) на таблице cities_users

И в целях обратной совместимости определить бизнес-метод модели user, возвращающий текущий город “как раньше”:

def user.city
self.citizens.last.city
end

Как работает этот метод? self.citizens не “вытаскивает” и не перебирает все сущности связи, как можно было ошибочно предположить, а возвращает виртуальный proxy-объект по коллекции. Когда на нём вызывается метод last, система штатно формирует SQL-запрос, “достающий” из БД только одну последнюю запись. А дальше от неё берётся прямая реляция на city.

Что получилось в итоге:

– визуально система работает как раньше, в интерфейсе по-прежнему можно писать user.city.name;
– поскольку любая модель из-коробки имеет атрибут created_at, у вас штатно фиксируется дата “начала жизни юзера в городе”;
– к модели citizen также из-коробки вырастает штатное REST-API (CRUD), то есть со всеми фактами “въезда юзера в город” можно работать элементарными CRUD-операциями из интерфейса;
– длительность жизни в городе вычисляется как разница между въездом в указанный город и въездом в следующий;
– можно аналогично определить user.city_of_birth, который вернёт вам город рождения;
– при желании из citizen-а можно ссылаться на любые дополнительные сущности, такие как “Район”, если чувак перемещается в пределах города.

А теперь поменяйте в этой модели “Юзера” на “Товар”, “Город” на “Цена”, а “Citizen” на “Period”, и вы сможете на той же системе определить “Цена на водку с 1 до 12 января будет 100 рублей”.

Развиваем мысль про has_many through:

В описанной модели у нас зафиксирован факт о том, что юзер “относится” к городу (as “проживает”) с какого-то по какое-то время. Однако представим, что данный факт может быть просто создан, а может быть “завизирован” (reviewed) кем-то из официальных лиц. Конечно, мы хотим знать, кто и когда это сделал. Делаем:

Сам ревьюер (официальное лицо) у нас уже есть – это обычный юзер, назовём его reviewer (отнаследуем модель от user).

Исходная модель – имеющийся у нас факт Citizen.

Делаем между ними третью модель Review, которая фиксирует следующее: факт въезда юзера “U” в город “C” был завизирован пользователем “X” такого-то числа (напоминаю про атрибут created_at у любой модели).

Проницательный читатель догадается, что из модели Review наружу торчит стандартное REST API, естественно.

Получаем возможность определять методы вроде:

citizen.reviewer – кто заревьюил данный переезд;
reviewer.citizens – переезды, которые были завизированы данным ревьюером;
reviewer.citizens.count – сколько переездов завизировал этот чувак (типа рейтинг);
reviews с фильтром по времени – какие переезды вообще были завизированы за период;
reviewer.citizens с фильтром по времени – переезды каких пользователей были завизированы за период;
reviewer.citizens.last.user – конкретный чувак, который переехал и этот переезд был завизирован последним, причём этим ревьюером;
user.citizens.last.reviewer – кто заревьюил мой последний переезд.

И так далее. А теперь поменяйте “юзеров” на “товары”, “города” на “склады”, “ситизенов” и “ревью” – на “накладные” и “акты”, и получите упрощённый прототип логистической систему учёта грузоперевозок.

Как можно заметить, я просто повторил паттерн User-Citizen-City как Citizen-Review-Reviewer.

Кто виноват?

Как ни парадоксально, но в выдуманной ситуации из заголовка статьи – большая часть вины лежит на заказчике. Или на человеке, которому платят деньги за надпись “Менеджер проекта” в служебном расписании. Почему? Потому что, заметьте, в моём повествовании задача “хранить информацию о переездах” возникла после реализации первой версии продукта. А если она является бизнес-значимой, то неплохо было бы озвучить её изначально.

Виноват ли разработчик, который не подумал за заказчика и не провёл дополнительную работу сразу? На этот вопрос, дорогие друзья, пусть каждый ответит себе сам. Могу сказать, что диалог в стиле “- а что ты сразу не сказал? – а что ты сам не подумал?” – означает лишь то, что стороны до сих пор не поделили зоны своей ответственности.

Когда этот подход в целом неэффективен:

– когда требования к быстродействию – критичны (has_one даст заведомо меньше SQL-запросов);
– когда атрибут (foreign_id), указывающий на удалённую сущность, по бизнес-специфике лишь однократно создаётся и не меняется с течением времени;
– когда вы делаете простой сайт-визитку.

Выводы:

Разберитесь, чем именно вы занимаетесь – делаете сайт или работаете с данными. Если вы работаете с данными, то для вас критически важно понимать, что любая информация есть данные, а значит – любая информация должна ложиться на модель. И во-вторых, любое изменение данных – есть информация, а значит – каждое business-valuable (значимое для бизнеса) изменение должно быть оформлено и функционировать как классическая модель.

С точки зрения внешнего API, вы упрощаете жизнь себе и frontend-разработчику, потому что вместо кастомных PUT-запросов на изменение атрибутов – он всегда имеет дело лишь с созданием или удалением сущностей.