Пошук уроків, статей та іншого контенту
Поєднаєте спеціалізовані типи, функції, процедури, тригери та пошук у цілісному прикладному рішенні.
У цьому прикладі побудуємо невелику систему обліку заявок служби підтримки. Вона поєднає:
ENUM для статусів і пріоритетів;
JSONB для додаткових властивостей заявки;
масиви для тегів;
діапазонний тип tstzrange для запланованого часу виконання;
автоматично обчислюване поле tsvector для повнотекстового пошуку;
функції для пошуку;
процедуру для зміни стану заявки;
тригери для оновлення часу зміни та аудиту;
індекси GIN і GiST для прискорення пошуку.
Заявка матиме такі властивості:
заголовок і опис;
статус: відкрита, у роботі, вирішена або скасована;
пріоритет;
набір тегів;
довільні метадані у форматі JSON;
часовий діапазон, протягом якого заявку потрібно опрацювати;
поле повнотекстового пошуку.
ENUM зручно використовувати для невеликої фіксованої множини значень:
CREATE TYPE ticket_status AS ENUM (
'open',
'in_progress',
'resolved',
'cancelled'
);
CREATE TYPE ticket_priority AS ENUM (
'low',
'normal',
'high',
'urgent'
);На відміну від звичайного text, такі поля не дозволять випадково зберегти значення на кшталт 'resloved'.
Для тегів використаємо масив:
tags text[] NOT NULL DEFAULT '{}'Для метаданих — jsonb:
metadata jsonb NOT NULL DEFAULT '{}'::jsonbjsonb зручний, коли набір додаткових властивостей може відрізнятися для різних заявок. Наприклад:
{
"browser": "Firefox",
"os": "Linux",
"customer_id": 42
}Для часового вікна використаємо tstzrange. Він зберігає діапазон моментів часу разом із часовою зоною:
tstzrange('[2026-09-01 09:00:00+00,2026-09-01 17:00:00+00)')Позначення [ означає включену нижню межу, а ) — виключену верхню.
PostgreSQL може зберігати не лише початковий текст, а й підготовлене представлення для пошуку — tsvector.
У ньому текст розбивається на лексеми, нормалізується та індексується. Пошуковий запит представляється типом tsquery.
Для заявки збережемо обчислюване поле:
search_vector tsvector GENERATED ALWAYS AS (
to_tsvector(
'simple'::regconfig,
coalesce(title, '') || ' ' ||
coalesce(description, '') || ' ' ||
coalesce(array_to_string(tags, ' '), '')
)
) STOREDGENERATED ALWAYS AS ... STORED означає, що PostgreSQL автоматично перераховує значення під час вставки або зміни рядка.
Для англійського тексту можна використовувати конфігурацію english, а для українського — відповідну конфігурацію, доступну в конкретній інсталяції PostgreSQL. Конфігурація simple не виконує мовного стемінгу, але добре підходить для демонстрації та пошуку точних лексем.
Наведений скрипт можна виконати в PostgreSQL 12 або новішій версії.
CREATE EXTENSION IF NOT EXISTS pg_trgm;
DROP SCHEMA IF EXISTS support_demo CASCADE;
CREATE SCHEMA support_demo;
SET search_path TO support_demo;
CREATE TYPE ticket_status AS ENUM (
'open',
'in_progress',
'resolved',
'cancelled'
);
CREATE TYPE ticket_priority AS ENUM (
'low',
'normal',
'high',
'urgent'
);
CREATE TABLE customers (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL,
email text NOT NULL UNIQUE,
created_at timestamptz NOT NULL DEFAULT clock_timestamp()
);
CREATE TABLE tickets (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id bigint NOT NULL
REFERENCES customers(id),
title text NOT NULL
CHECK (length(trim(title)) >= 5),
description text NOT NULL,
status ticket_status NOT NULL DEFAULT 'open',
priority ticket_priority NOT NULL DEFAULT 'normal',
tags text[] NOT NULL DEFAULT '{}'
CHECK (array_position(tags, '') IS NULL),
metadata jsonb NOT NULL DEFAULT '{}'::jsonb
CHECK (jsonb_typeof(metadata) = 'object'),
due_window tstzrange NOT NULL DEFAULT tstzrange(
clock_timestamp(),
clock_timestamp() + interval '24 hours',
'[)'
)
CHECK (NOT isempty(due_window)),
created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
updated_at timestamptz NOT NULL DEFAULT clock_timestamp(),
search_vector tsvector GENERATED ALWAYS AS (
to_tsvector(
'simple'::regconfig,
coalesce(title, '') || ' ' ||
coalesce(description, '') || ' ' ||
coalesce(array_to_string(tags, ' '), '')
)
) STORED
);
CREATE TABLE ticket_events (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
ticket_id bigint NOT NULL
REFERENCES tickets(id)
ON DELETE CASCADE,
event_type text NOT NULL,
old_status ticket_status,
new_status ticket_status,
details jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT clock_timestamp()
);
CREATE INDEX tickets_search_vector_idx
ON tickets USING GIN (search_vector);
CREATE INDEX tickets_title_trgm_idx
ON tickets USING GIN (title gin_trgm_ops);
CREATE INDEX tickets_metadata_idx
ON tickets USING GIN (metadata);
CREATE INDEX tickets_tags_idx
ON tickets USING GIN (tags);
CREATE INDEX tickets_due_window_idx
ON tickets USING GIST (due_window);
CREATE INDEX ticket_events_ticket_id_idx
ON ticket_events (ticket_id);Тригери дають змогу централізувати правила, які мають виконуватися автоматично під час зміни даних.
У нашому прикладі потрібні два правила:
updated_at має оновлюватися під час кожної зміни заявки.
Зміна статусу має записуватися до журналу подій.
Функція тригера завжди повертає тип trigger.
CREATE OR REPLACE FUNCTION set_ticket_updated_at()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
NEW.updated_at := clock_timestamp();
RETURN NEW;
END;
$$;
CREATE TRIGGER tickets_set_updated_at
BEFORE INSERT OR UPDATE ON tickets
FOR EACH ROW
EXECUTE FUNCTION set_ticket_updated_at();Для BEFORE-тригера можна змінити NEW і повернути його. Саме так ми встановлюємо нове значення updated_at.
Тепер створимо журнал переходів між статусами.
CREATE OR REPLACE FUNCTION audit_ticket_status_change()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF OLD.status IS DISTINCT FROM NEW.status THEN
INSERT INTO ticket_events (
ticket_id,
event_type,
old_status,
new_status,
details
)
VALUES (
NEW.id,
'status_changed',
OLD.status,
NEW.status,
jsonb_build_object(
'changed_at', clock_timestamp()
)
);
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER tickets_audit_status_change
AFTER UPDATE OF status ON tickets
FOR EACH ROW
EXECUTE FUNCTION audit_ticket_status_change();Оператор IS DISTINCT FROM безпечніший за <>, коли в порівнянні можуть бути NULL. Він завжди повертає логічний результат.
Тригер є AFTER, тому запис до журналу відбувається після зміни заявки. Якщо операція завершиться помилкою або транзакцію буде скасовано, запис аудиту також не залишиться.
INSERT INTO customers (name, email)
VALUES
('Олена Коваль', 'olena@example.com'),
('Андрій Мельник', 'andrii@example.com'),
('Ірина Бондар', 'iryna@example.com');
INSERT INTO tickets (
customer_id,
title,
description,
priority,
tags,
metadata,
due_window
)
VALUES
(
1,
'Не працює вхід до облікового запису',
'Користувач отримує помилку під час входу після зміни пароля.',
'high',
ARRAY['login', 'authentication', 'password'],
jsonb_build_object(
'browser', 'Firefox',
'os', 'Linux',
'attempts', 3
),
tstzrange(
clock_timestamp(),
clock_timestamp() + interval '4 hours',
'[)'
)
),
(
2,
'Повільно завантажується сторінка замовлення',
'Сторінка замовлення відкривається приблизно 15 секунд.',
'normal',
ARRAY['performance', 'orders'],
jsonb_build_object(
'browser', 'Chrome',
'os', 'Windows',
'response_time_ms', 15000
),
tstzrange(
clock_timestamp() + interval '2 hours',
clock_timestamp() + interval '2 days',
'[)'
)
),
(
3,
'Потрібно додати нового користувача',
'Створіть користувачу доступ до внутрішньої панелі.',
'low',
ARRAY['users', 'administration'],
jsonb_build_object(
'department', 'sales',
'role', 'manager'
),
tstzrange(
clock_timestamp() + interval '1 day',
clock_timestamp() + interval '3 days',
'[)'
)
);Тепер можна перевірити, що поле пошуку було сформоване автоматично:
SELECT
id,
title,
search_vector
FROM tickets;Функція інкапсулює пошукову логіку та повертає узгоджений набір результатів.
Вона підтримує:
повнотекстовий пошук у заголовку, описі та тегах;
пошук приблизного збігу в заголовку за допомогою pg_trgm;
необов’язковий фільтр за статусом;
сортування за релевантністю.
CREATE OR REPLACE FUNCTION search_tickets(
p_query text,
p_status ticket_status DEFAULT NULL
)
RETURNS TABLE (
ticket_id bigint,
title text,
status ticket_status,
priority ticket_priority,
score real
)
LANGUAGE plpgsql
STABLE
AS $$
DECLARE
v_query tsquery;
BEGIN
IF p_query IS NULL OR length(trim(p_query)) = 0 THEN
RAISE EXCEPTION 'Пошуковий запит не може бути порожнім';
END IF;
v_query := plainto_tsquery(
'simple'::regconfig,
trim(p_query)
);
RETURN QUERY
SELECT
t.id,
t.title,
t.status,
t.priority,
(
ts_rank(t.search_vector, v_query)
+ similarity(t.title, trim(p_query))
)::real AS score
FROM tickets AS t
WHERE
(p_status IS NULL OR t.status = p_status)
AND (
t.search_vector @@ v_query
OR t.title % trim(p_query)
)
ORDER BY score DESC, t.created_at DESC;
END;
$$;Позначення STABLE повідомляє PostgreSQL, що функція не змінює дані та повертає однаковий результат для однакових даних у межах одного оператора.
Виклик функції:
SELECT *
FROM search_tickets('вхід пароль');Пошук лише серед заявок, які ще перебувають у роботі:
SELECT *
FROM search_tickets('сторінка', 'in_progress');Повнотекстовий пошук використовує індекс tickets_search_vector_idx, якщо планувальник вважає це вигідним. Для приблизного порівняння заголовків може використовуватися індекс tickets_title_trgm_idx.
Перевірити план виконання можна за допомогою:
EXPLAIN (ANALYZE, BUFFERS)
SELECT *
FROM search_tickets('вхід пароль');Для маленької таблиці PostgreSQL може свідомо вибрати послідовне сканування. Це нормально: індекс не завжди швидший за читання всієї маленької таблиці.
Функції зазвичай використовують для обчислення та повернення результатів. Процедури зручно застосовувати для команд, які змінюють дані та реалізують бізнес-операцію.
Створимо процедуру завершення заявки. Вона:
перевіряє, що текст результату не порожній;
блокує потрібний рядок на час операції;
перевіряє поточний статус;
змінює статус на resolved;
додає результат до metadata.
CREATE OR REPLACE PROCEDURE resolve_ticket(
p_ticket_id bigint,
p_resolution text
)
LANGUAGE plpgsql
AS $$
DECLARE
v_current_status ticket_status;
BEGIN
IF p_resolution IS NULL OR length(trim(p_resolution)) = 0 THEN
RAISE EXCEPTION 'Опис вирішення не може бути порожнім';
END IF;
SELECT status
INTO v_current_status
FROM tickets
WHERE id = p_ticket_id
FOR UPDATE;
IF NOT FOUND THEN
RAISE EXCEPTION 'Заявку з id % не знайдено', p_ticket_id;
END IF;
IF v_current_status IN ('resolved', 'cancelled') THEN
RAISE EXCEPTION
'Заявку з id % не можна завершити зі статусу %',
p_ticket_id,
v_current_status;
END IF;
UPDATE tickets
SET
status = 'resolved',
metadata = metadata || jsonb_build_object(
'resolution', trim(p_resolution),
'resolved_at', clock_timestamp()
)
WHERE id = p_ticket_id;
END;
$$;Викликаємо процедуру:
CALL resolve_ticket(
1,
'Пароль було скинуто, користувач підтвердив успішний вхід.'
);Після UPDATE автоматично спрацюють обидва тригери:
updated_at отримає новий час;
у ticket_events з’явиться перехід зі старого статусу до resolved.
Перевірка результату:
SELECT
id,
status,
metadata,
updated_at
FROM tickets
WHERE id = 1;
SELECT
ticket_id,
event_type,
old_status,
new_status,
details,
created_at
FROM ticket_events
WHERE ticket_id = 1
ORDER BY created_at;Спеціалізовані типи корисні не лише під час запису, а й у фільтрах.
Оператор @> перевіряє, чи містить JSON-документ вказаний фрагмент:
SELECT
id,
title,
metadata
FROM tickets
WHERE metadata @> '{"os": "Linux"}'::jsonb;Індекс tickets_metadata_idx створено саме для подібних операцій.
Отримати конкретне значення можна оператором ->>:
SELECT
id,
title,
metadata->>'browser' AS browser
FROM tickets;Оператор @> для масивів перевіряє, чи містить масив задані елементи:
SELECT
id,
title,
tags
FROM tickets
WHERE tags @> ARRAY['performance'];Оператор && перевіряє, чи перетинаються два діапазони:
SELECT
id,
title,
due_window
FROM tickets
WHERE due_window && tstzrange(
clock_timestamp(),
clock_timestamp() + interval '8 hours',
'[)'
);Індекс tickets_due_window_idx використовує метод GiST, придатний для операцій над діапазонами.
Процедуру можна викликати в транзакції разом з іншими операціями:
BEGIN;
CALL resolve_ticket(
2,
'Оптимізовано запит до таблиці замовлень.'
);
INSERT INTO ticket_events (
ticket_id,
event_type,
details
)
VALUES (
2,
'resolution_notified',
jsonb_build_object(
'channel', 'email',
'sent_at', clock_timestamp()
)
);
COMMIT;Якщо будь-яка операція завершиться помилкою, транзакцію можна скасувати:
BEGIN;
CALL resolve_ticket(
3,
'Доступ створено та перевірено.'
);
-- Якщо наступна операція помилкова, усі зміни можна скасувати.
-- ROLLBACK;Тригери та процедура працюють у тому самому транзакційному контексті. Тому зміна заявки, оновлення updated_at і запис аудиту є атомарними.
Один із можливих сценаріїв:
-- 1. Пошук заявки.
SELECT *
FROM search_tickets('пароль');
-- 2. Переведення заявки у статус виконаної.
CALL resolve_ticket(
1,
'Користувачу надіслано інструкцію зі скидання пароля.'
);
-- 3. Перевірка автоматичного аудиту.
SELECT
ticket_id,
old_status,
new_status,
created_at
FROM ticket_events
WHERE ticket_id = 1
ORDER BY created_at;
-- 4. Пошук завершених заявок.
SELECT *
FROM search_tickets('пароль', 'resolved');
-- 5. Пошук за додатковим атрибутом.
SELECT id, title
FROM tickets
WHERE metadata @> '{"browser": "Firefox"}'::jsonb;
-- 6. Пошук заявок за тегом.
SELECT id, title
FROM tickets
WHERE tags @> ARRAY['login'];text замість ENUM без потребиЯкщо набір значень обмежений і стабільний, ENUM захищає від помилок у написанні. Якщо значення часто змінюються або адміністратор має додавати їх без зміни схеми, доречнішою може бути окрема таблиця-довідник.
JSONBjsonb приймає дуже різні структури. Якщо очікується саме JSON-об’єкт, варто додати перевірку:
CHECK (jsonb_typeof(metadata) = 'object')Інакше замість об’єкта можна випадково зберегти масив або просте значення.
ILIKE у великій таблиціЗапит на кшталт:
WHERE title ILIKE '%пароль%'може вимагати повного сканування таблиці. Для повнотекстового пошуку використовуйте tsvector і tsquery, а для приблизного пошуку коротких текстів — pg_trgm та відповідний індекс.
для tsvector потрібен GIN;
для масивів і багатьох операцій над jsonb також використовується GIN;
для діапазонів зручно використовувати GiST;
для триграм — GIN або GiST з операторним класом gin_trgm_ops чи gist_trgm_ops.
Якщо зміна статусу виконується через процедуру, а аудит уже реалізовано тригером, не потрібно додатково вставляти такий самий запис у процедуру. Інакше одна зміна може з’явитися в журналі двічі.
У процедурі використано:
SELECT ...
FOR UPDATE;Це блокує рядок до завершення транзакції та не дозволяє двом паралельним операціям одночасно змінювати стан тієї самої заявки без узгодження.
У прикладному рішенні PostgreSQL різні можливості доповнюють одна одну:
ENUM обмежує допустимі статуси та пріоритети;
JSONB зберігає гнучкі додаткові дані;
масиви зберігають теги;
tstzrange моделює часові вікна;
tsvector і tsquery реалізують повнотекстовий пошук;
GIN, GiST і триграмні індекси прискорюють спеціалізовані запити;
функція приховує та повторно використовує пошукову логіку;
процедура реалізує атомарну бізнес-операцію;
тригери автоматично оновлюють технічні поля та ведуть аудит;
транзакції гарантують узгодженість усіх пов’язаних змін.