Массивы и диапазоны

Предпосылки: SELECT и фильтрация, агрегация (array_agg), индексы (CREATE INDEX, GIN, GiST).

JSONB | Полнотекстовый поиск

Товару нужны теги: «electronics», «portable», «sale». Классический подход — junction table: product_tags(product_id, tag_id), отдельная таблица tags(id, name). Это три таблицы и два JOIN для получения тегов товара. Для простых атомарных меток без атрибутов и без двусторонних запросов («какие товары у тега?» не нужно) массив TEXT[] проще — одна таблица, один столбец, без JOIN.

ARRAY — массив значений

Любой тип данных PostgreSQL может быть массивом:

CREATE TABLE products (
    id   BIGSERIAL PRIMARY KEY,
    name TEXT NOT NULL,
    tags TEXT[] NOT NULL DEFAULT '{}'
);
 
INSERT INTO products (name, tags) VALUES
('Ноутбук', ARRAY['electronics', 'portable']),
('Книга',   '{books, education}'),
('Монитор', ARRAY['electronics']);

Две формы литерала: ARRAY[...] и '{...}'.

Доступ к элементам

Индексация с 1 (не с 0):

SELECT name, tags[1] AS first_tag FROM products;
 name    | first_tag
---------+------------
 Ноутбук | electronics
 Книга   | books
 Монитор | electronics

Операторы

@> — содержит:

SELECT name FROM products WHERE tags @> ARRAY['portable'];
 name
---------
 Ноутбук

&& — пересечение (overlap):

SELECT name FROM products WHERE tags && ARRAY['books', 'portable'];

Строки, где tags содержит хотя бы один из указанных элементов.

ANY — элемент равен значению:

SELECT name FROM products WHERE 'electronics' = ANY(tags);

unnest — разворачивание массива

unnest(array) превращает массив в набор строк:

SELECT name, unnest(tags) AS tag
FROM products;
 name    | tag
---------+------------
 Ноутбук | electronics
 Ноутбук | portable
 Книга   | books
 Книга   | education
 Монитор | electronics

Каждый элемент массива — отдельная строка. Обратная операция — array_agg().

Массив vs таблица связи

Массив подходит, когда: элементы — атомарные метки без собственных атрибутов (теги, роли, категории), количество элементов невелико (десятки, не тысячи), нет необходимости в FK (массив не может ссылаться на другую таблицу), и запросы идут от владельца к элементам («теги товара»), а не наоборот.

Junction table лучше, когда: у связи есть атрибуты (дата добавления тега, приоритет), нужны двусторонние запросы («все товары с тегом X» через индекс на tag_id), или элементы — сущности со своими данными (пользователи, категории с описаниями).

UPDATE массива перезаписывает всё значение — как и JSONB. Добавление одного тега к массиву из 100 элементов записывает в WAL все 100. Для часто изменяемых коллекций junction table эффективнее.

Индексирование массивов

GIN индекс для операторов @>, &&:

CREATE INDEX products_tags_idx ON products USING gin (tags);

Range types — диапазоны

Range type хранит диапазон значений: от и до, с указанием включения/исключения границ.

Встроенные типы:

ТипЭлементы
int4rangeinteger
int8rangebigint
numrangenumeric
tsrangetimestamp
tstzrangetimestamptz
daterangedate
CREATE TABLE bookings (
    id      BIGSERIAL PRIMARY KEY,
    room_id BIGINT NOT NULL,
    during  TSRANGE NOT NULL
);
 
INSERT INTO bookings (room_id, during) VALUES
(1, '[2024-03-15 09:00, 2024-03-15 11:00)'),
(1, '[2024-03-15 11:00, 2024-03-15 13:00)'),
(2, '[2024-03-15 09:00, 2024-03-15 12:00)');

[ — включая границу, ) — исключая границу. [09:00, 11:00) — от 9:00 (включительно) до 11:00 (не включая).

Операторы диапазонов

&& — пересечение (overlap):

SELECT * FROM bookings
WHERE during && '[2024-03-15 10:00, 2024-03-15 12:00)';

Строки, диапазон которых пересекается с указанным.

@> — содержит значение:

SELECT * FROM bookings WHERE during @> '2024-03-15 10:30'::timestamp;

<@ — содержится в диапазоне. -|- — смежность (adjacent).

EXCLUDE constraint — защита от пересечений

Два пользователя одновременно бронируют комнату 1 на пересекающееся время. Без constraint оба INSERT проходят — в базе два конфликтующих бронирования. Проверка на уровне приложения (SELECT ... WHERE during && ... перед INSERT) не спасает: между SELECT и INSERT другая транзакция может вставить свою запись (race condition).

EXCLUDE constraint решает проблему на уровне базы:

ALTER TABLE bookings
ADD CONSTRAINT bookings_no_overlap
EXCLUDE USING gist (room_id WITH =, during WITH &&);

Для room_id WITH = необходимо расширение btree_gist — подробнее в EXCLUSION.

room_id WITH = — ограничение на одну и ту же комнату. during WITH && — диапазоны не должны пересекаться. GiST индекс обеспечивает эффективную проверку — PostgreSQL не сканирует все бронирования, а проверяет только потенциальные конфликты через дерево.

Для нескольких ресурсов (комната + оборудование) — дополнительные столбцы в EXCLUDE:

EXCLUDE USING gist (
    room_id      WITH =,
    equipment_id WITH =,
    during       WITH &&
);

Подробнее — в ограничениях.

Индексирование диапазонов

GiST индекс для операторов &&, @>, <@:

CREATE INDEX bookings_during_idx ON bookings USING gist (during);

Sources


JSONB | Полнотекстовый поиск