SQL и Qt

Типы данных SQL: числа, строки, даты, NULL и его особенности

Введение

Статья 393 кратко упомянула типы данных столбцов — в этой статье разберём их подробнее: основные категории (числа, строки, даты/время), и особенно тонкую, часто неправильно понимаемую концепцию NULL, упомянутую как подводный камень в статье 394.

Концепция

Числовые типы делятся на целочисленные (INTEGER, BIGINT — разной разрядности) и типы с плавающей/фиксированной точкой (DECIMAL/NUMERIC для точных вычислений, важных для денежных сумм; FLOAT/REAL для приближённых вычислений, где небольшая погрешность приемлема). Строковые типы различаются по фиксированной (CHAR(N), дополняется пробелами до точной длины N) или переменной (VARCHAR(N), до N символов без дополнения) длине. NULL представляет отсутствие значения или «неизвестность» — принципиально отличается от нулевого числа, пустой строки, или любого иного конкретного значения, и любая операция с участием NULL (сравнение, арифметика) обычно сама даёт NULL, а не TRUE/FALSE/конкретное число.

Пример кода: Типы данных SQL

-- Различие DECIMAL и FLOAT для денежных расчётов — критично важная деталь
CREATE TABLE accounts (
    id INTEGER PRIMARY KEY,
    balance DECIMAL(12, 2) -- точное представление, без проблем округления — для денег
    -- balance_float FLOAT — НЕ используйте FLOAT для денег, проблема показана ниже
);

-- Проблема FLOAT: классическая иллюстрация неточности двоичного представления десятичных дробей
SELECT 0.1::FLOAT + 0.2::FLOAT; -- может дать 0.30000000000000004, а не точно 0.3 (синтаксис PostgreSQL)
-- NULL — особенности сравнения и обработки
SELECT * FROM customers WHERE email = NULL;     -- НИКОГДА не вернёт строк, даже если email реально NULL
SELECT * FROM customers WHERE email IS NULL;     -- ПРАВИЛЬНЫЙ способ проверки на NULL

-- NULL в арифметике и конкатенации — результат тоже NULL
SELECT 5 + NULL;            -- результат: NULL
SELECT 'привет' || NULL;     -- результат: NULL (конкатенация строк, синтаксис PostgreSQL/стандарт SQL)

-- COALESCE — замена NULL на конкретное значение по умолчанию
SELECT name, COALESCE(email, 'нет email') AS email_display FROM customers;
-- Типы дат и времени — DATE, TIME, TIMESTAMP, и работа с временными зонами
CREATE TABLE events (
    id INTEGER PRIMARY KEY,
    event_date DATE,                          -- только дата, без времени
    event_time TIME,                           -- только время, без даты
    created_at TIMESTAMP,                       -- дата и время без явной временной зоны
    created_at_tz TIMESTAMP WITH TIME ZONE      -- дата и время с учётом временной зоны
);

Пояснения к коду

SELECT 0.1::FLOAT + 0.2::FLOAT иллюстрирует фундаментальную, не специфичную для SQL проблему двоичного представления десятичных дробей с плавающей точкой (та же проблема существует в большинстве языков программирования) — для денежных расчётов, где точность критична, DECIMAL/NUMERIC хранит число в десятичном, а не двоичном представлении внутри, избегая этой неточности за счёт несколько большего объёма хранения и немного более медленных вычислений по сравнению с FLOAT. COALESCE(email, 'нет email') — стандартная, кросс-СУБД функция, возвращающая первое не-NULL значение из переданного списка аргументов, что даёт практичный способ заменить потенциальный NULL на понятное, отображаемое пользователю значение без необходимости отдельной проверки через CASE WHEN ... IS NULL.

Подводные камни

  • Использование FLOAT/REAL для хранения денежных сумм — это классическая, широко известная, но всё ещё часто допускаемая ошибка, способная привести к накоплению мелких, но реальных погрешностей при множестве вычислений (особенно при суммировании большого количества значений), что для финансовых данных совершенно неприемлемо; DECIMAL/NUMERIC с явно указанной точностью — правильный выбор для любых денежных величин без исключений.
  • Сравнение с NULL через =/!= вместо IS NULL/IS NOT NULL, как уже отмечено в статье 394 — это одна из самых частых концептуальных ошибок начинающих работу с SQL, и важно усвоить, что NULL — не значение в обычном смысле, с которым можно сравнивать через обычные операторы равенства, а маркер «неизвестности», требующий специального синтаксиса проверки.
  • Игнорирование различия между TIMESTAMP и TIMESTAMP WITH TIME ZONE при работе с приложением, обслуживающим пользователей из разных часовых зон — хранение времени без явной временной зоны (TIMESTAMP) оставляет интерпретацию хранимого значения неоднозначной (это локальное время сервера? UTC? время какого-то конкретного пользователя?), и для приложений с пользователями из разных зон стоит явно выбрать и придерживаться одной согласованной стратегии — обычно хранение всего в UTC через TIMESTAMP WITH TIME ZONE и преобразование в локальное время пользователя только на уровне отображения в приложении.
  • Использование CHAR(N) вместо VARCHAR(N) без понимания разницы в фактическом хранении — CHAR(N) дополняет каждое значение пробелами до точной длины N независимо от реальной длины сохраняемой строки, что может привести к неожиданным результатам при сравнении строк (значение с дополненными пробелами не равно тому же значению без них при строгом, посимвольном сравнении в некоторых СУБД) и обычно не даёт реальной выгоды по сравнению с VARCHAR для большинства практических случаев, где длина значений может варьироваться.