Как устроен Price Monitor. Работа с данными
В этой статье я расскажу, как устроено хранение данных в Price Monitor, как я научился запрашивать сложные структуры одним запросом в базу и как я сделал так, чтобы с этим было удобно работать в коде.
В Price Monitor я использую PostgreSQL, реляционную СУБД. Данные в ней хранятся в виде таблиц.
У меня есть таблица для наименований Product
, таблицы для товаров каждого магазина GlobusProduct/LentaProduct/...
, таблицы для связей товаров и наименований ProductGlobusLink/ProductLentaLink/...
, таблица для штрих-кодов ProductEan
, материализованные view для актуальных цен GlobusProductLastPrice/LentaProductLastPrice/...
, несколько обычных view для удобства (например, ProductWithLinks
) и много чего ещё. И из этого зоопарка таблиц нужно уметь доставать нужные данные.
Результатом SELECT-запросов тоже является таблица. Это значит, что результат будет представлен в виде набора столбцов и набора строк, без каких-либо вложенных полей. Но часто хочется уметь вытаскивать одним SQL-запросом какую-нибудь сложную структуру данных (например, всю информацию о товаре и массив изменений цен во времени). На первый взгляд кажется, что это невозможно, но я нашёл интересное решение.
В PostgreSQL есть неплохой набор инструментов для работы с JSON. В ячейках таблицы можно хранить произвольные JSON-структуры, обращаться к вложенным полям с помощью операторов ->
и ->>
и даже индексировать их. А ещё там есть функция json_agg
, которая позволяет преобразовать любую таблицу в json массив объектов.
a | b ------- 1 | 2 3 | 4
[{"a": 1, "b": 2}, {"a": 3, "b": 4}]
Если эту функцию использовать в подзапросе, то в результате получается ровно такое поведение, какое мне хотелось. Если нужно достать одиночное значение, то подзапрос будет выглядеть вот так (SELECT json_agg(t) -> 0 FROM (подзапрос) t)
, если нужен весь массив целиком, то так (SELECT COALESCE(json_agg(t), '[]'::json) as j FROM (подзапрос) t)
. coalesce нужен, чтобы возвращался пустой массив, если в подзапросе ничего не нашлось.
Теперь плавно перейдём к коду. Весь бэкенд Price Monitor написан на Typescript. Для работы с базой из кода я использую библиотеку kysely. Это небольшой, но мощный query builder со строгой типизацией запросов. Сначала нужно описать схему базы данных в виде типов, а потом можно строить запросы вот в таком виде:
И если допустить ошибку в запросе, она тут же её подсветит:
И, что самое главное, она позволяет выносить куски логики в отдельные функции, не теряя при этом типизации. И не только всякие простые куски кода, чтобы не копипастить их несколько раз, но и целые слои абстракции. Например, я написал утилиты для работы с json, о которых рассказал выше:
Выглядит как магия. Полностью кастомный кусок SQL, который идеально интегрируется в запросы и сохраняет при этом типизацию!
Я использую эти функции во многих местах, но, в частности, в функции для поиска товара:
eans тут достаются в виде массива, а товары из разных магазинов — в виде одиночных объектов. Второе необязательно, но очень упрощает дальнейшую обработку.
Для выборки товаров по условию у меня тоже есть отдельные функции: по id, по ean и по категории (но она не влезла в кадр)
И теперь, чтобы получить товар по id, можно просто написать...
Kysely поддерживает не только вот такие махинации с запросами, но и стриминг результатов, транзакции и много чего ещё. Очень доволен этой библиотекой.
Спасибо, что прочитали! Надеюсь, стало понятнее, как устроен Price Monitor. В следующей части (когда-нибудь) расскажу, как я объединял товары друг с другом.