text
В Brew решили построить простой отчёт: «сколько у каждого клиента заказов». Запрос написали быстро, а число клиентов в отчёте оказалось меньше, чем в таблице customers. Карина, которая зарегистрировалась, но ещё ничего не купила, просто исчезла из результата. Не баг джойна — её «съела» неаккуратная работа с NULL: у неё нет заказов, значит order_id — это NULL, а NULL ведёт себя не так, как кажется.
Этот юнит — про три типа, которые выглядят скучно, но именно на них спотыкаются приложения: text (и почему не char(n)), boolean (с его трёхзначной логикой) и NULL. Главное про NULL — это не «пусто» и не «ноль», а «неизвестно». Полный разбор семантики NULL ждёт в 03-06; здесь — тизер, чтобы ловушка не застала врасплох.
text, а не char(n) или varchar(n)
В Postgres строковый тип по умолчанию — text, без ограничения длины. varchar(n) — это text с проверкой длины (и почти никогда не нужен: ограничивай длину CHECK-ом, если надо). А вот char(n) — отдельная ловушка: он дополняет строку пробелами до фиксированной длины. Из-за этого 'abc'::char(5) на самом деле хранит 'abc ', и при сравнении хвостовые пробелы «съедаются»: 'abc'::char(5) = 'abc '::char(5) → true. У text пробелы значимы: 'abc' = 'abc ' → false. Поэтому в курсе (и в схеме Brew) мы держим text — предсказуемое сравнение по байтам.
boolean: true, false и… NULL
boolean кажется двузначным, но в SQL он трёхзначный: true, false и NULL (неизвестно). Логические выражения легко получаются прямо из предикатов: base_price > 400 — это уже boolean, и sqlc типизирует такую колонку как Go bool. Но как только в выражение попадает NULL, результат может стать NULL — и об этом следующий раздел.
NULL — это «неизвестно», а не «пусто»
Ключевая интуиция: NULL означает «значение неизвестно». Поэтому сравнение с NULL через = даёт не false, а NULL: NULL = NULL — это «неизвестно = неизвестно» → тоже NULL. Из этого следуют две вещи:
WHERE col = NULLникогда не сработает (условие никогда неtrue) — для проверки на отсутствие значения естьIS NULL/IS NOT NULL.- Агрегаты пропускают
NULL:count(*)считает все строки, аcount(col)— только строки, гдеcolнеNULL. Та самая Карина теряется, если считатьcount(order_id)вместоcount(*).
NULL в данных появляется естественно — например, из LEFT JOIN: у клиента без заказов колонки из правой таблицы будут NULL. И это правильный, типобезопасный способ выразить «значения нет»: sqlc видит, что колонка из LEFT JOIN nullable, и типизирует её как pgtype.Int8 (с полем Valid), а не как голый int64.
boolean и NULL: шпаргалка
| Выражение | Результат | Что запомнить |
|---|---|---|
base_price > 400 | true / false / NULL | предикат — это уже boolean, и он трёхзначен |
NULL = NULL | NULL | «неизвестно = неизвестно» — тоже неизвестно, не true |
col = NULL | никогда не true | отсутствие значения проверяй через IS NULL / IS NOT NULL |
count(*) | все строки | строки считаются как есть |
count(col) | строки, где col не NULL | NULL пропускается — здесь и теряется Карина |
Это рабочий минимум на каждый день; полную трёхзначную логику (NOT IN с NULL, COALESCE, IS DISTINCT FROM) разбираем в 03-06.
Что показывает наш код
Первый запрос — NULL в сравнении, на литералах:
-- name: NullComparison :one
SELECT
((NULL = NULL) IS NOT TRUE)::boolean AS eq_null_is_not_true,
((NULL = NULL) IS NULL)::boolean AS eq_null_is_unknown;Оба — true: NULL = NULL это не TRUE и вообще NULL. Дальше — настоящий NULL из LEFT JOIN и контраст count(*) vs count(col):
SELECT c.id AS customer_id, c.name, o.id AS order_id -- CustomersWithOrders :many
FROM customers c LEFT JOIN orders o ON o.customer_id = c.id::text
ORDER BY c.id, o.id;
SELECT count(*) AS rows_total, count(o.id) AS rows_with_order -- CountStarVsCol :one
FROM customers c LEFT JOIN orders o ON o.customer_id = c.id::text;В Go order_id — это pgtype.Int8; печатаем NULL, если !Valid. Последние два запроса — boolean из предиката (base_price > 400) и поведение text/char(n) ('abc' = 'abc ' против char(5)-паддинга).
Запуск
docker compose up -d
make lecture L=01-data-types/01-02-text-boolean-and-null-teaser T=db-reset
make lecture L=01-data-types/01-02-text-boolean-and-null-teaserВывод:
1) (NULL = NULL) — это не TRUE и не FALSE, а NULL («неизвестно»):
(NULL = NULL) IS NOT TRUE = true; IS NULL = true
→ отсутствие значения проверяем через IS NULL, не через = NULL.
2) LEFT JOIN customers ↔ orders — order_id у клиента без заказов = NULL:
CUSTOMER_ID ИМЯ ORDER_ID
1 Алиса Иванова 1
1 Алиса Иванова 3
2 Борис Петров 2
3 Карина Сидорова NULL
3) count(*) = 4 (все строки), count(o.id) = 3 (без NULL-заказа Карины)
4) boolean из выражения base_price > 400 (в Go это bool):
ID НАЗВАНИЕ IS_PREMIUM
1 Эспрессо false
2 Капучино true
3 Латте true
4 Колд брю true
5 Зелёный чай false
5) text сравнивает по байтам, char(n) дополняет пробелами:
'abc' = 'abc ' → false (text: пробел значим)
'abc'::char(5) = 'abc ' → true (char(n): паддинг съел пробелы)Вот и Карина: в LEFT JOIN её order_id — NULL, и count(o.id) (=3) её не считает, а count(*) (=4) — считает. Отчёт «сколько заказов на клиента» должен показать её с нулём, а не потерять, — и теперь видно, почему наивный count это делал.
Заборчик
Это только тизер. За остальным — в 03-06; здесь держим три правила:
- Полная семантика
NULLждёт.NOT INсNULL(классическая дыра, возвращающая пустоту),COALESCE/NULLIF/IS DISTINCT FROM, трёхзначная логика вWHERE/CHECK/уникальных индексах — всё это в 03-06. - Отсутствие значения — только
IS NULL/IS NOT NULL. Никогда= NULL: такое условие не станетtrue. char(n)в проде ты почти не встретишь по делу. Если видишь его в чужой схеме — это чаще историческая ошибка, чем намеренный выбор.
Что забрать с собой
- Держи
text.char(n)молча дополняет пробелами и портит сравнение;varchar(n)— этоtextс проверкой длины, обычно лишней. booleanтрёхзначен:true,false,NULL. Предикат вродеbase_price > 400— это ужеbool.NULL— «неизвестно», не «пусто».NULL = NULL→NULL. Отсутствие значения проверяй черезIS NULL.count(*)считает строки,count(col)пропускаетNULL.LEFT JOINпорождает настоящиеNULL— sqlc типизирует их какpgtype.*с полемValid.
Дальше — юнит 01-03 «дата, время и timestamptz»: почему время всегда хранят в timestamptz, как один и тот же момент выглядит по-разному под SET TIME ZONE, и в чём ловушка timestamp без зоны.