Представим себе, что мы проектируем базу данных для ведения блога. В блоге есть сообщения, и нам хотелось бы сопоставлять им теги.
Традиционный подход состоит в том, что для тегов надо создать отдельную таблицу, например, так:
=> CREATE TABLE posts( post_id integer PRIMARY KEY, message text );
CREATE TABLE
=> CREATE TABLE tags( tag_id integer PRIMARY KEY, name text );
CREATE TABLE
И теперь связываем сообщения и теги отношением многие-ко-многим через еще одну таблицу:
=> CREATE TABLE posts_tags( post_id integer REFERENCES posts(post_id), tag_id integer REFERENCES tags(tag_id) );
CREATE TABLE
Наполним таблицы тестовыми данными:
=> INSERT INTO posts(post_id,message) VALUES (1, 'Перечитывал пейджер, много думал.'), (2, 'Это было уже весной и я отнес елку обратно.');
INSERT 0 2
=> INSERT INTO tags(tag_id,name) VALUES (1, 'былое и думы'), (2, 'технологии'), (3, 'семья');
INSERT 0 3
=> INSERT INTO posts_tags(post_id,tag_id) VALUES (1,1), (1,2), (2,1), (2,3);
INSERT 0 4
Теперь мы можем вывести сообщения и теги:
=> SELECT p.message, t.name FROM posts p JOIN posts_tags pt ON pt.post_id = p.post_id JOIN tags t ON t.tag_id = pt.tag_id ORDER BY p.post_id, t.name;
message | name ---------------------------------------------+-------------- Перечитывал пейджер, много думал. | былое и думы Перечитывал пейджер, много думал. | технологии Это было уже весной и я отнес елку обратно. | былое и думы Это было уже весной и я отнес елку обратно. | семья (4 rows)
Или чуть иначе - возможно удобнее получить массив тегов:
=> SELECT p.message, array_agg(t.name ORDER BY t.name) tags FROM posts p JOIN posts_tags pt ON pt.post_id = p.post_id JOIN tags t ON t.tag_id = pt.tag_id GROUP BY p.post_id ORDER BY p.post_id;
message | tags ---------------------------------------------+----------------------------- Перечитывал пейджер, много думал. | {"былое и думы",технологии} Это было уже весной и я отнес елку обратно. | {"былое и думы",семья} (2 rows)
Можем найти все сообщения с определенным тегом:
=> SELECT p.message FROM posts p JOIN posts_tags pt ON pt.post_id = p.post_id JOIN tags t ON t.tag_id = pt.tag_id WHERE t.name = 'былое и думы' ORDER BY p.post_id;
message --------------------------------------------- Перечитывал пейджер, много думал. Это было уже весной и я отнес елку обратно. (2 rows)
Может потребоваться найти все уникальные теги - это совсем просто:
=> SELECT t.name FROM tags t ORDER BY t.name;
name -------------- былое и думы семья технологии (3 rows)
Теперь попробуем подойти к задаче по-другому. Пусть теги будут представлены текстовым массивом прямо внутри таблицы сообщений.
=> DROP TABLE posts_tags;
DROP TABLE
=> DROP TABLE tags;
DROP TABLE
=> ALTER TABLE posts ADD COLUMN tags text[];
ALTER TABLE
Теперь у нас нет идентификаторов тегов, но они нам не очень и нужны.
=> UPDATE posts SET tags = '{"былое и думы","технологии"}' WHERE post_id = 1;
UPDATE 1
Или другой синтаксис:
=> UPDATE posts SET tags = ARRAY['былое и думы','семья'] WHERE post_id = 2;
UPDATE 1
Вывод всех сообщений упростился:
=> SELECT p.message, p.tags FROM posts p ORDER BY p.post_id;
message | tags ---------------------------------------------+----------------------------- Перечитывал пейджер, много думал. | {"былое и думы",технологии} Это было уже весной и я отнес елку обратно. | {"былое и думы",семья} (2 rows)
Сообщения с определенным тегом тоже легко найти (используем оператор пересечения &&):
=> SELECT p.message FROM posts p WHERE p.tags && '{"былое и думы"}' ORDER BY p.post_id;
message --------------------------------------------- Перечитывал пейджер, много думал. Это было уже весной и я отнес елку обратно. (2 rows)
Эта операция может быть ускорена с помощью GIN-индекса, так что для такого запроса не придется перебирать всю таблицу сообщений.
А вот получить список тегов довольно сложно. Это требует разворачивания всех массивов тегов в большую таблицу (функция unnest) и поиск уникальных значений - тяжелая операция.
=> SELECT unnest(p.tags) AS name FROM posts p;
name -------------- былое и думы технологии былое и думы семья (4 rows)
=> SELECT DISTINCT unnest(p.tags) AS name FROM posts p;
name -------------- семья былое и думы технологии (3 rows)
Тут хорошо видно, что имеет место дублирование данных.
Как видно, оба подхода вполне могут применяться. В простых случаях массивы выглядят проще и работают хорошо. В более сложных сценариях (представьте, что вместе с именем тега мы хотим хранить дату его создания; или требуется проверка ограничений целостности) классический вариант становится более привлекательным.
Приведем некоторые примеры работы с массивами. Объявление переменной и инициализация массива целиком:
=> DO $$ DECLARE a integer[]; BEGIN a := ARRAY[10,20,30]; RAISE NOTICE '%', a; END; $$ LANGUAGE plpgsql;
NOTICE: {10,20,30} DO
Одномерный массив можно заполнять и поэлементно - он автоматически расширяется. Если пропустить какие-то элементы, они получают неопределенные значения.
=> DO $$ DECLARE a integer[3]; -- размер игнорируется BEGIN a[2] := 10; a[3] := 20; a[6] := 30; RAISE NOTICE '%', a; END; $$ LANGUAGE plpgsql;
NOTICE: [2:6]={10,20,NULL,NULL,30} DO
Итерация индексов элементов массива. Второй параметр array_lower и array_upper - номер размерности (единица для одномерных массивов).
=> DO $$ DECLARE a integer[] := ARRAY[10,20,30]; BEGIN FOR i IN array_lower(a,1)..array_upper(a,1) LOOP RAISE NOTICE 'a[%] = %', i, a[i]; END LOOP; END; $$ LANGUAGE plpgsql;
NOTICE: a[1] = 10 NOTICE: a[2] = 20 NOTICE: a[3] = 30 DO
Итерация самих элементов массива.
=> DO $$ DECLARE a integer[] := ARRAY[10,20,30]; x integer; BEGIN FOREACH x IN ARRAY a LOOP RAISE NOTICE '%', x; END LOOP; END; $$ LANGUAGE plpgsql;
NOTICE: 10 NOTICE: 20 NOTICE: 30 DO
Двумерные массивы (здесь мы использовали инициализацию с помощью другой конструкции). Но после инициализации расширить массив уже нельзя.
=> DO $$ DECLARE a integer[][] := '{{10,20,30},{100,200,300}}'; BEGIN RAISE NOTICE '%', a; a[4][4] := 1; END; $$ LANGUAGE plpgsql;
NOTICE: {{10,20,30},{100,200,300}} ERROR: array subscript out of range CONTEXT: PL/pgSQL function inline_code_block line 6 at assignment
Итерация индексов в двумерном массиве. Двойные квадратные скобки в объявлении переменной не обязательны, на самом деле.
=> DO $$ DECLARE a integer[] := ARRAY[ARRAY[10,20,30],ARRAY[100,200,300]]; BEGIN FOR i IN array_lower(a,1)..array_upper(a,1) LOOP -- по строкам FOR j IN array_lower(a,2)..array_upper(a,2) LOOP -- по столбцам RAISE NOTICE 'a[%][%] = %', i, j, a[i][j]; END LOOP; END LOOP; END; $$ LANGUAGE plpgsql;
NOTICE: a[1][1] = 10 NOTICE: a[1][2] = 20 NOTICE: a[1][3] = 30 NOTICE: a[2][1] = 100 NOTICE: a[2][2] = 200 NOTICE: a[2][3] = 300 DO
Итерация элементов двумерного массива.
=> DO $$ DECLARE a integer[] := ARRAY[ARRAY[10,20,30],ARRAY[100,200,300]]; x integer; BEGIN FOREACH x IN ARRAY a LOOP RAISE NOTICE '%', x; END LOOP; END; $$ LANGUAGE plpgsql;
NOTICE: 10 NOTICE: 20 NOTICE: 30 NOTICE: 100 NOTICE: 200 NOTICE: 300 DO
Итерация срезов массивов и обращение к отдельному срезу.
=> DO $$ DECLARE a integer[] := ARRAY[ARRAY[10,20,30],ARRAY[100,200,300]]; x integer[]; BEGIN FOREACH x SLICE 1 IN ARRAY a LOOP -- по строкам RAISE NOTICE 'SLICE 1: %', x; END LOOP; FOREACH x SLICE 2 IN ARRAY a LOOP -- весь массив RAISE NOTICE 'SLICE 2: %', x; END LOOP; RAISE NOTICE '[1:2][2:3] = %', a[1:2][2:3]; END; $$ LANGUAGE plpgsql;
NOTICE: SLICE 1: {10,20,30} NOTICE: SLICE 1: {100,200,300} NOTICE: SLICE 2: {{10,20,30},{100,200,300}} NOTICE: [1:2][2:3] = {{20,30},{200,300}} DO
В теме "SQL. Функции" мы создавали функцию maximum, которая находила максимальное из трех чисел. Обобщим ее на произвольное число аргументов. Для этого объявим один VARIADIC-параметр:
=> CREATE FUNCTION maximum(VARIADIC a integer[]) RETURNS integer AS $$ DECLARE x integer; maxsofar integer; BEGIN FOREACH x IN ARRAY a LOOP IF x IS NOT NULL AND (maxsofar IS NULL OR x > maxsofar) THEN maxsofar := x; END IF; END LOOP; RETURN maxsofar; END; $$ IMMUTABLE LANGUAGE plpgsql;
CREATE FUNCTION
Пробуем:
=> SELECT maximum(12, 65, 47);
maximum --------- 65 (1 row)
=> SELECT maximum(12, 65, 47, null, 87, 24);
maximum --------- 87 (1 row)
=> SELECT maximum(null, null);
maximum --------- (1 row)
Для полноты картины эта функция может быть сделана полиморфной, чтобы принимать любой тип данных (для которого, конечно, должны быть определены операции сравнения).
=> DROP FUNCTION maximum(integer[]);
DROP FUNCTION
=> CREATE FUNCTION maximum(VARIADIC a anyarray, maxsofar OUT anyelement) AS $$ DECLARE x maxsofar%TYPE; BEGIN FOREACH x IN ARRAY a LOOP IF x IS NOT NULL AND (maxsofar IS NULL OR x > maxsofar) THEN maxsofar := x; END IF; END LOOP; END; $$ IMMUTABLE LANGUAGE plpgsql;
CREATE FUNCTION
Проверим:
=> SELECT maximum(12, 65, 47);
maximum --------- 65 (1 row)
=> SELECT maximum(12.1, 65.3, 47.6);
maximum --------- 65.3 (1 row)
Вот теперь у нас получился практически полный аналог функции greatest!
Конец демонстрации.