JSONB и границы структуры
Основные поля каталога уже имеют строгий смысл: тема, публичный код, статус и план главы. Но у разных курсов появляются дополнительные свойства: уровень подготовки, набор меток, необязательная подпись иллюстрации. Если таких сведений много и они меняются по редакторской задаче, JSONB позволяет хранить небольшой документ рядом со строкой. При этом гибкость не должна разрушать основной предметный договор.
Рассмотрим PostgreSQL 17.11 и снимок catalog-db-advanced/lesson-23 из архива продолжения. Он сохраняет прежние шесть курсов и двенадцать уроков, дополнительно создавая properties. SQL не запускался. Колонка и исходные документы уже находятся в самостоятельном reset; демонстрационное изменение свойства откатывается.
Документ рядом с полями
В снимке к courses добавлена колонка:
properties jsonb NOT NULL DEFAULT '{}'::jsonb
CONSTRAINT course_properties_object CHECK (jsonb_typeof(properties) = 'object')
Это фрагмент определения, не команда для повторного добавления уже существующего поля. Колонка обязательна и должна содержать JSON-объект. Пустой объект допускается. Ограничение не требует наличие каждого конкретного ключа: это отдельное решение, которое нужно выразить явно, если свойство станет обязательным.
У HTML есть уровень beginner, метки html и css, а также caption со значением JSON null. У JavaScript уровень intermediate и метки javascript, dom. У остальных курсов есть соответствующие учебные метки и уровень; ключ caption отсутствует. Такие данные помогают сравнить отсутствие ключа и специально заданное пустое значение.
SELECT id, slug, properties->>'level' AS level,
properties->'tags' AS tags
FROM catalog.courses
ORDER BY id;
Ожидаются шесть строк. Оператор -> возвращает JSONB-значение, ->> — текстовое представление извлечённого значения. Для массива меток сохраняем JSONB, для подписи уровня читаем текст. Правила операторов находятся в справочнике JSON PostgreSQL.
Тип колонки не превращает произвольный JSON в правильную модель курса. Если клиент положит вместо строки уровня число, верхнеуровневый объект всё ещё пройдёт нашу проверку. Поэтому приложение валидирует договор допустимых свойств, а важные устойчивые инварианты при необходимости закрепляются в колонках и ограничениях. CHECK объекта здесь выбран намеренно узким.
Что оставляем реляционным
Не переносим topic_id внутрь properties только ради удобства одного JSON-ответа. Связь с темой уже поддерживается внешним ключом. Если скрыть её в произвольной структуре без соответствующей модели, база больше не проверит тот же договор автоматически. Аналогично уникальный slug и соответствие статуса времени остаются явными полями.
JSONB подходит дополнительному набору с понятной границей изменения. Он не является оправданием одной огромной колонки «всё приложение». Когда свойство становится основным фильтром, обязательным правилом или связью, разумно пересмотреть его представление. Позднее покажем, как перенести уровень сложности из документа в отдельную колонку без уничтожения курсов.
Также не нужно хранить одновременно одинаковые значения в двух местах без политики согласования. Если создать planned_lessons и копию properties.planned_lessons, какой источник правильный при расхождении? В нашем снимке такой копии нет. Дополнительные свойства не дублируют обязательные количественные поля каталога.
Отсутствие и JSON null
Сравним два случая:
SELECT id, slug,
properties ? 'caption' AS has_caption,
properties->'caption' AS json_caption,
properties->>'caption' AS text_caption
FROM catalog.courses
ORDER BY id;
У HTML has_caption истинно, JSON-извлечение даёт JSON null, а текстовое извлечение — SQL NULL. У остальных ключ отсутствует: проверка существования ложна, оба извлечения дают SQL NULL. Поэтому одного текстового результата недостаточно, чтобы отличить «ключа нет» от «ключ явно обнулён».
Это различие может быть важно в частичном редакторском запросе. Отсутствующий ключ может означать «оставить прежнюю подпись», явный null — «очистить подпись». Если клиент сначала превратит оба состояния в одну пустую строку, такое намерение потеряется. Формат входа должен сохранять различие до применения изменения.
SQL NULL всей колонки тоже отдельное состояние. В нашем договоре оно запрещено NOT NULL. JSON null всей колонки нарушит CHECK объекта. Следовательно, оба неподходящих верхнеуровневых состояния отклоняются, но null внутри допустимого объекта остаётся законным для caption. Нельзя переносить одно слово «пусто» на все уровни структуры.
Изменяем отдельный ключ
Чтобы добавить подпись и сохранить остальные свойства, используем jsonb_set над текущим документом:
BEGIN;
UPDATE catalog.courses
SET properties = jsonb_set(properties, '{caption}', '"Пример сетки"'::jsonb, true),
version = version + 1
WHERE id = 1
RETURNING properties, version;
ROLLBACK;
До отката ожидается новая подпись, прежний уровень и прежние метки. Версия увеличивается, поскольку это редакторское изменение защищаемого представления. После отката исходный caption снова равен JSON null. Полная замена properties маленьким объектом только с caption, напротив, потеряла бы остальные ключи.
Последний аргумент разрешает создание ключа в подходящей структуре. Для вложенных путей нужно отдельно понимать существование родительских объектов; функция не обещает автоматически построить любое произвольное дерево по пожеланию строки пути. Начальный пример использует один верхний ключ, чтобы его результат можно было предсказать по текущему объекту.
Стабильный смысл свойств
Дополнительные свойства удобно менять редакционно, но их имена всё равно становятся частью договора клиентов. Если интерфейс вчера читал level, а завтра импорт записал difficulty внутри того же JSON без перехода, гибкий тип не помог: клиенты получили несовместимые документы. Поэтому нужно согласовывать имя, тип и значение важного свойства, даже когда отдельная SQL-колонка ещё не создана.
Рассмотрим tags: в учебном наборе это массив строк. Наличие допустимого объекта на верхнем уровне не гарантирует такой массив внутри. Документ с tags равным числу всё ещё проходит только показанную верхнюю CHECK-проверку. Читающий клиент должен знать, принимает ли он неизвестную форму, отклоняет её на входе или использует безопасное значение по умолчанию. Это решение не следует оставлять случайному поведению оператора.
Частичная правка через jsonb_set сохраняет остальные поля текущего объекта, тогда как отправка полностью собранной старой копии способна затереть соседнее изменение. Представим одного редактора, который меняет caption, и другого, который добавляет tag. Если оба возвращают старый целый документ, одна правка может исчезнуть. Применение проверки version из предыдущей главы относится и к JSONB-части карточки.
Для нашего caption полезно отдельно выбрать смысл удаления. Отсутствующий ключ может означать «настройка не задана», JSON null — «значение намеренно пустое», пустая строка — ещё одно состояние. Если приложение решит объединить их, это должно быть явным правилом отображения и записи. Сохранение различий на уровне базы позволяет принять это решение осознанно, вместо случайной потери информации при сериализации.
Представление JSONB
JSONB сохраняет разобранное представление документа. Он не обещает исходный порядок ключей, пробелы и буквальную форму присланного текста. Для повторяющихся ключей также действуют правила JSONB, описанные в руководстве JSON-типов. Поэтому не используйте его как архив оригинального подписанного текстового документа без отдельного решения.
Для каталога важны значения и операции над ними. Порядок ключей ответа не является порядком учебных глав: у них уже есть position в отдельной таблице. Массив меток, в свою очередь, не следует превращать в список уроков с произвольными копиями родительских данных. Разные предметные структуры требуют разных правил.
При будущем ручном чтении сравните извлечение caption у первого и второго курса, затем прочитайте обновлённый документ до отката. Ожидаемый результат показывает, какие сведения гибкие, какие остаются строгими и как не потерять остальные свойства при частичном изменении. Теперь JSONB дополняет модель курса, сохраняя ясную границу ответственности между документом, колонками и редакторским клиентом.