Содержание урока · 10 разделов
- NULL в SQL и нулевое значение Go: фундаментальный разрыв
- Нулевые значения Go: что читается вместо NULL
- Стратегия 1: ent optional nillable — указатель вместо обёртки
- Сгенерированные мутации: SetBio, SetNillableBio и ClearBio
- Поиск NULL: предикаты BioIsNil и BioNotNil
- Nillable ent без Optional: честное представление
- Как выбрать стратегию: таблица решений
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
В третьем уроке мы объявили bio как MaxLen(280).Optional().Nillable() и ушли со сцены на самом интересном месте: что ent делает с NULL. Сегодня разбираем пару Optional и Nillable до дна. Тема урока, ent optional nillable, закрывает три вопроса: где в Go прячется нулевое значение и почему оно конфликтует с NULL из SQL, какие методы SetBio, SetNillableBio и ClearBio генерирует ent и как находить записи без значения. Стратегий пустоты всего три, и микроблог даст каждой работу: bio останется указателем, для сайта пользователя заведём поле без Nillable, а роль уже живёт с Default. Имена методов, которые встретятся ниже, никто не набирает руками — их выдаёт go generate ./ent после правки схемы, как договорились ещё во втором уроке.
NULL в SQL и нулевое значение Go: фундаментальный разрыв#
NULL в SQL — это не «пусто», а «значения нет и не было»: биография не заполнена, срок не поставлен, ник ещё не выбран. У Go позиция другая: каждый тип имеет нулевое значение, которое нельзя отключить — 0 для чисел, false для bool, пустая строка, 1 января нулевого года для time.Time. Две картины мира сталкиваются в момент чтения строки из базы: драйвер database/sql достаёт колонку через обёртку sql.NullString с флагом Valid, а сгенерированный код ent переводит результат на язык структуры. Правило перевода короткое: у Nillable-полей NULL становится nil, у обычных — нулевым значением типа. Ту же задачу мы видели в уроке про sqlc типы: там nullable колонки превращаются в sql.NullString и pgtype с тем же флагом Valid — ent выбрал третий путь, указатели. Так себя вели и классические ORM: в вводном уроке GORM нулевые значения и «нет данных» путались в одном поле точно так же. Разница между «в базе NULL» и «в базе пустая строка» переживает это путешествие только при одной из стратегий — в этом весь сюжет урока.
Нулевые значения Go: что читается вместо NULL#
| Тип поля | Нулевое значение | Чем грозит путаница с NULL |
|---|---|---|
| string | "" (пустая строка) | «не рассказал о себе» неотличимо от «рассказал пустоту» |
| int | 0 | пропущенный возраст выглядит как настоящий ноль |
| bool | false | «не подтвердил» и «не спрашивали» сливаются в одно |
| time.Time | 0001-01-01 00:00:00 UTC | нулевая дата пробивается в отчёты и API-ответы |
| []byte | nil | почти безобидно: len 0 и «нет значения» ведут себя одинаково |
| uuid.UUID | 00000000-0000-0000-0000-000000000000 | нулевой токен может уйти наружу как настоящий |
Стратегия 1: ent optional nillable — указатель вместо обёртки#
Первая стратегия — та, по которой построено bio: field.String("bio").Optional().Nillable(). Optional снимает обязательность: SetBio можно не вызывать, Save не вернёт missing required field, а колонка становится nullable. Nillable меняет тип в сгенерированной структуре на *string: ent читает NULL и кладёт в Bio nil, читает значение — кладёт указатель на строку. Проверка u.Bio == nil — честный вопрос «в базе есть что-нибудь?», и ответ больше не подменяется пустой строкой.
Когда указатели эргономичны: для строк, времени и любых типов, где «пустота» — отдельное состояние со своим смыслом. Био, дата публикации, дата блокировки, токен — все они бывают «не заданы», и каждое из этих состояний нужно различать. Когда указатели избыточны: для счётчиков и bool, где нулевое значение редко несёт отдельный смысл, а разыменование *u.Bio захламляет код. Цена стратегии не нулевая и на границе API: nil-указатель требует решения, что отдавать клиенту — null, пустую строку или вовсе убрать поле из ответа. Этот выбор разберём на блоке про JSON.
bio, website, role: три стратегии в схеме
GO// Стратегия 1: Optional + Nillable — «нет био» видно в коде как nil
field.String("bio").
MaxLen(280). // лимит из прошлого урока
Optional(). // SetBio можно не вызывать, колонка NULL-able
Nillable(), // в структуре Bio *string
// Стратегия 2: только Optional — пусто и «нет значения» значат одно
field.String("website").
Optional(), // колонка NULL-able, в структуре Website string
// Стратегия 3: Default вместо Optional — «пусто» запрещено как класс
field.Enum("role").
Values("user", "moderator", "admin").
Default("user"), // колонка NOT NULL, роль есть всегдаТри поля микроблога — три ответа на вопрос «что делать, когда значения нет». bio различает пустоту и отсутствие; website объединяет их: пустой сайт и «нет сайта» — одно состояние; role не допускает отсутствия — вместо пустоты ent подставит user. В таблице describe из первого урока это видно сразу: у bio Optional true, Nillable true, Default false; у website только Optional true; у role Default true при Optional false. Колонки bio и website в базе одинаково NULL-able — различие стратегий живёт не в SQL, а в Go-коде, и следующий блок это покажет.
Что окажется в user.go: три версии поля
GO// Стратегия 2: только Optional
type User struct {
Website string `json:"website,omitempty"` // NULL читается как ""
}
// Стратегия 1: Optional + Nillable
type User struct {
Bio *string `json:"bio,omitempty"` // NULL читается как nil
}
// Стратегия 3: Default вместо Optional
type User struct {
Role Role `json:"role,omitempty"` // NULL не встречается: колонка NOT NULL
}Разница живёт не в базе — колонки website и bio одинаково nullable — а в типе Go. Website string: NULL из базы превращается в пустую строку, и «пользователь без сайта» неотличим от «пользователя с пустым сайтом» — мы сами решили, что это одно и то же. Bio *string: nil означает ровно «в базе NULL». Role — именованный строковый тип с константами RoleUser, RoleModerator, RoleAdmin из прошлого урока; колонка NOT NULL, и вопроса «а что если NULL» не существует. Обратите внимание на json-теги: ent по умолчанию пишет всем полям omitempty, поэтому пустая строка и nil-указатель просто исчезнут из JSON-ответа; если API-контракт требует явный null, тег переопределяется через StructTag — приём покажем ниже.
Проверка: чтение NULL без Nillable
bio объявлен только Optional(), без Nillable: в структуре Bio string. В базе у пользователя колонка bio = NULL. Что окажется в u.Bio после client.User.Get(ctx, id)?
Сгенерированные мутации: SetBio, SetNillableBio и ClearBio#
Схема готова — смотрим, что ent сгенерировал для Optional-поля bio. Три рабочих метода. SetBio("...") записывает строку. SetNillableBio(p) принимает string и устроен внутри честно: if v != nil { SetBio(v) } — nil молча пропускается; формулировка документации ent так и звучит: «sets the field if the given value is not nil». ClearBio() помечает поле на очистку — Save отправит в базу NULL. Рядом есть ResetBio, но он не про базу: метод отменяет незавершённое изменение внутри мутации, значение в таблице он не трогает.
Теперь распределение по билдерам. У create-билдера ClearBio нет — при создании «не вызвал Set» уже означает NULL, чистить нечего. У update-билдеров — UserUpdate и UserUpdateOne — ClearBio есть и работает. Заметьте: SetNillableBio генерируется для любого Optional-поля, даже без Nillable — у website он тоже будет, с типом *string. А вот обязательным полям, включая role с Default, ни SetNillable, ни Clear не положены: колонка NOT NULL, там NULL не бывает.
Паттерн SetNillableBio(input) в обработчике
GO// input.Bio — *string из JSON-запроса профиля:
// клиент прислал null — в поле nil; прислал текст — указатель.
// создание: NULL, если био не прислали
client.User.Create().
SetName("Анна").
SetEmail("anna@example.com").
SetNillableBio(input.Bio). // nil — поле останется NULL
Save(ctx)
// обновление: заменить био, только если прислали новое
client.User.UpdateOneID(id).
SetNillableBio(input.Bio). // nil — старое значение не трогаем
Save(ctx)
// обновление: пользователь нажал «стереть био» — пишем NULL
client.User.UpdateOneID(id).
ClearBio(). // не SetNillableBio(nil) — тот лишь no-op
Save(ctx)
// чтение с проверкой на пустоту
if u.Bio != nil {
fmt.Println("био:", *u.Bio)
}Классический обработчик профиля. input.Bio — указатель string из JSON: клиент прислал null — распаковка положит в поле nil, прислал текст — указатель на строку. SetNillableBio(input.Bio) покрывает оба случая одной строкой: nil тихо пропускается, значение записывается. При создании незаданное Optional-поле и так уйдёт в базу NULL; при обновлении no-op означает «не трогать старое значение». Третья мутация — намеренная очистка: ClearBio — единственный способ записать NULL в существующую строку. Чтение — с nil-проверкой: разыменование u.Bio только после if u.Bio != nil, иначе паника.
Что напечатает обновление с nil
Прочитайте код и выберите, что он выведет:
// bio в схеме: field.String("bio").Optional().Nillable()
// у пользователя id=42 bio = "пишу о Go"
client.User.UpdateOneID(42).
SetNillableBio(nil).
Exec(ctx)
u, _ := client.User.Get(ctx, 42)
fmt.Println(*u.Bio)Частый баг: «пользователь стёр био» обрабатывают как SetBio(""). В колонке окажется пустая строка, а не NULL: BioIsNil() такого пользователя не найдёт, а при Optional-без-Nillable чтении вы вообще не отличите стёртое от никогда не заполнявшегося. Правило: если в интерфейсе есть действие «удалить значение» — это ClearBio(), и только он. И наоборот: SetNillableBio(nil) при обновлении не очистит поле — это no-op, а не очистка.
Чтение — по типу поля. Сгенерированный код сканирует колонку через sql.NullString из database/sql и присваивает полю структуры значение только при Valid=true. Nillable-поле при NULL остаётся nil; обычное — нулевым значением: в Website окажется "". Отсюда два идиома чтения. Для bio — разыменование после проверки: if u.Bio != nil { fmt.Println(*u.Bio) }, иначе паника. Для website проверок нет по договорённости: пустая строка и «нет сайта» значат одно и то же, мы так спроектировали вторую стратегию.
Последний штрих — JSON. Дефолтный тег ent json:"...,omitempty" выбрасывает из ответа и пустую строку, и nil-указатель: API не покажет разницу между null и "". Если контракт требует явный null, переопределите тег: field.String("bio").StructTag(json:"bio") — теперь nil придёт клиенту как null, а значение — как строка.
Поиск NULL: предикаты BioIsNil и BioNotNil#
Фильтр «найти пустые» упирается в особенность SQL: сравнение bio = NULL никогда не бывает истинным — неизвестность тянется и в равенство, и в неравенство, поэтому строка с NULL не пройдёт ни bio = NULL, ни bio <> NULL. У SQL есть отдельные операторы IS NULL и IS NOT NULL, а ent генерирует для каждого Optional-поля пару предикатов: user.BioIsNil() и user.BioNotNil(). В сгенерированном where.go за ними стоят sql.FieldIsNull и sql.FieldNotNull — прямой перевод IS NULL на язык билдера. Работают предикаты на уровне колонки, так что не важно, какая из двух стратегий у поля: BioIsNil найдёт и nil-указатель, и пустую строку, которой дублировали NULL. Цепочка client.User.Query().Where(user.BioIsNil()).Count(ctx) вернёт всех, кто био не заполнял. Обязательным полям предикаты не генерируются: у name NULL не бывает, и NameIsNil просто не существует.
Проверка: фильтр пустых био
Нужно найти всех пользователей, у которых био не заполнено (bio: Optional+Nillable, некоторые стёрли его через ClearBio). Какой фильтр правильный?
Соберите обработчик профиля
Впишите сеттер с указателем, метод очистки и предикат пустоты.
Жизненный цикл nullable-поля
Расставьте шаги от заполненного био до записи NULL и фильтра по пустоте.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Optional хорошо дружит с Unique: пара field.String("handle").Optional().Unique() описывает ник, который либо уникален, либо отсутствует — пользователь ещё не придумал себе @handle. База такую комбинацию любит: в уникальном индексе PostgreSQL, SQLite и MySQL (InnoDB) разрешено сколько угодно NULL — они не конфликтуют, потому что NULL не равен ничему, даже другому NULL. Ограничение сработает только на двух users с одинаковым непустым ником. В прошлом уроке была обратная история: Default уникальным полям запрещён — автозначение мгновенно столкнулось бы само с собой на второй записи. Optional+Unique — легальная пара «уникален, если есть», и это стоит держать в голове, проектируя необязательные идентификаторы.
Nillable ent без Optional: честное представление#
Nillable умеет жить и без Optional — на обязательных полях. Сценарий: поле заполняется дефолтом, но при частичных выборках (о них — в будущих уроках про запросы) его может не быть в прочитанной строке. Тогда обычный time.Time честно покажет нулевую дату 0001-01-01T00:00:00Z — она же пробьётся в json.Marshal: в примере из документации ent пустая Task сериализуется как {"created_at":"0001-01-01T00:00:00Z"}. У Nillable-поля в той же ситуации nil: в коде «значения нет» видно, а с дефолтным тегом omitempty поле просто не попадёт в JSON. В прошлом уроке эта способность упоминалась одной строкой — теперь причина ясна: Nillable не про nullable колонку, а про честное представление «значения может не быть» в самом Go.
Как выбрать стратегию: таблица решений#
| Ситуация в микроблоге | Стратегия | Схема | Тип в Go | Мутации |
|---|---|---|---|---|
| bio: «не рассказал» и «стёр» — разные состояния | Optional + Nillable | field.String("bio").Optional().Nillable() | *string | SetBio / SetNillableBio / ClearBio |
| website: пусто и «нет» — одно и то же | Optional | field.String("website").Optional() | string | SetWebsite / SetNillableWebsite / ClearWebsite |
| role: пустой роли не бывает | Default | field.Enum("role").Default("user") | Role (строка) | SetRole |
| created_at: время ставит ent | Default + Immutable | field.Time("created_at").Default(time.Now).Immutable() | time.Time | SetCreatedAt — только при создании |
| handle: уникальный ник или отсутствие | Optional + Unique | field.String("handle").Optional().Unique() | string | SetHandle / ClearHandle |
| token: API-ключ, которого может не быть | Optional + Nillable | field.UUID("token", uuid.UUID{}).Optional().Nillable() | *uuid.UUID | SetToken / SetNillableToken / ClearToken |
| published_at: пост ещё не вышел | Optional + Nillable | field.Time("published_at").Optional().Nillable() | *time.Time | SetPublishedAt / SetNillablePublishedAt / ClearPublishedAt |
| edits: счётчик правок поста | Optional | field.Int("edits").Optional() | int | SetEdits / AddEdits / ClearEdits |
| age: обязателен и положителен | обязательное поле | field.Int("age").Positive() | int | SetAge |
Новые nullable-поля по умолчанию делайте Optional().Nillable(): nil-проверка стоит дешевле, чем отладка истории, где «не рассказал о себе» и «стёр био» неотличимы от пустой строки. Optional без Nillable оставляйте полям, у которых нулевое значение честно совпадает со смыслом «не указано»: сайт, счётчик, логин. А если пусто не должно быть никогда — Default, как у role. Указатели захламляют код? Иногда. Но это чище, чем второй, тайный смысл у пустой строки.
Проверка: что не сгенерируется у role
role объявлен как field.Enum("role").Default("user") — без Optional. Какого метода не будет ни у одного билдера role?
Пустота разобрана, и схема User больше не полумера: шесть полей, каждое со своей стратегией — bio с указателем, website с нулевым значением, role с дефолтом, created_at с Immutable. Следующие два урока — рёбра: в пятом свяжем User и Post через O2M и M2O, в шестом добавим комментарии и many2many, и таблицы заговорят друг с другом. Тема пустоты при этом не закрывается, а переезжает: у будущих сущностей появятся свои Optional-поля вроде edited_at, и стратегии этого урока пригодятся там напрямую. Полный план из 25 уроков лежит на странице курса ent, а домашка ниже закрепляет все три стратегии на живой схеме.
Карточки: NULL-семантика ent
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- NULL в SQL — «значения нет»; у Go вместо этого нулевые значения: "", 0, false, нулевое время. ent переводит NULL в nil только у Nillable-полей, у остальных — в нулевое значение типа.
- Три стратегии пустоты: Optional+Nillable даёт *T и nil-проверку; Optional без Nillable — T, где нулевое значение совпадает со смыслом; Default вместо Optional — колонка NOT NULL, пустоты не бывает.
- Для каждого Optional-поля ent генерирует SetBio, SetNillableBio (принимает *string, no-op при nil) и ResetBio; ClearBio живёт только в update-билдерах — при создании «не задал» уже означает NULL.
- Обязательным полям, включая поля с Default, SetNillable и Clear не генерируются: колонка NOT NULL, чистить нечего.
- SetBio("") записывает пустую строку, а не NULL: предикат BioIsNil его не найдёт. Очистка существующего значения — только ClearBio().
- Поиск пустых записей — предикаты IsNil и NotNil (IS NULL и IS NOT NULL): сравнение с NULL через = и <> в SQL всегда даёт UNKNOWN.
- Unique сочетается с Optional: несколько NULL в уникальном индексе не конфликтуют; сочетание Unique с Default запрещено.
Домашнее задание
Добавьте в user.go поле website: field.String("website").Optional() — без Nillable — и запустите go generate ./ent. Создайте пользователя без сайта и без био: распечатайте u.Website и u.Bio и объясните себе разницу между "" и nil. Затем сравните два «стирания»: SetWebsite("") и ClearWebsite() — и проверьте через user.WebsiteIsNil(), кого из двух нашёл фильтр. Вопрос на подумать: почему для website мы не стали платить указателем, а для bio заплатили? Свой ответ сверьте с таблицей решений из урока.
Личные заметкиПусто
Вопросы и ответы по уроку
Чем Nillable лучше sql.NullString?
Это не «лучше и хуже», а разные школы. database/sql оборачивает значение в структуру с флагом Valid — так работает sqlc и сырой sql.Rows. ent генерирует указатели *T: nil проверяется обычным сравнением, а не .Valid, и значение естественно сериализуется в JSON. Внутри ent всё равно сканирует через sql.Null* — но наружу отдаёт указатели.
SetNillableBio(nil) очистит поле при обновлении?
Нет. SetNillable-метод при nil — no-op: сгенерированный код не вызывает SetBio, и мутация уходит без изменения bio. Чтобы записать NULL в существующую строку, нужен ClearBio(). При создании разницы нет: незаданное Optional-поле и так станет NULL.
Что выбрать для чисел и bool?
Если 0 и false могут быть настоящими значениями, а «не указано» надо различать — Optional().Nillable() с *int или *bool. Если нулевое значение и «нет данных» для вас одно и то же — хватит Optional без Nillable: меньше разыменований. Для полей с осмысленным значением по умолчанию — Default, как у role в микроблоге.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
go-kit: микросервисный тулкит
Курс go-kit: endpoint, транспорт, логирование, метрики, трейсинг, circuit breaker, service discovery. 25 уроков с итоговым микросервисом.
- Понимать архитектуру endpoint → transport → service
- Оборачивать сервисы middleware: логи, метрики, трейсинг
- Подключать HTTP и gRPC транспорты
- Ставить rate limiting и circuit breaker