December 3, 2022

Как устроен 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

> select json_agg(t) from t;

[{"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. В следующей части (когда-нибудь) расскажу, как я объединял товары друг с другом.