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 > 400true / false / NULLпредикат — это уже boolean, и он трёхзначен
NULL = NULLNULL«неизвестно = неизвестно» — тоже неизвестно, не true
col = NULLникогда не trueотсутствие значения проверяй через IS NULL / IS NOT NULL
count(*)все строкистроки считаются как есть
count(col)строки, где col не NULLNULL пропускается — здесь и теряется Карина

Это рабочий минимум на каждый день; полную трёхзначную логику (NOT IN с NULL, COALESCE, IS DISTINCT FROM) разбираем в 03-06.

Что показывает наш код

Первый запрос — NULL в сравнении, на литералах:

sql
-- 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):

sql
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)-паддинга).

Запуск

sh
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

Вывод:

plaintext
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_idNULL, и 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 = NULLNULL. Отсутствие значения проверяй через IS NULL.
  • count(*) считает строки, count(col) пропускает NULL. LEFT JOIN порождает настоящие NULL — sqlc типизирует их как pgtype.* с полем Valid.

Дальше — юнит 01-03 «дата, время и timestamptz»: почему время всегда хранят в timestamptz, как один и тот же момент выглядит по-разному под SET TIME ZONE, и в чём ловушка timestamp без зоны.

·Модуль 02

Этот урок ещё впереди

Курс изучается по порядку — чтобы открыть этот шаг, сначала завершите предыдущие. Так контекст накапливается без пропусков.

/ вы пытались открыть
Типы данных / text