Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 4 из 25 ~16 мин +50 XP

Optional и Nillable поля

Тема урока — ent optional nillable: полная NULL-семантика ent, которую мы отложили в конце прошлого урока, где bio получил пару Optional().Nillable(). Разбираемся, чем nullable поле отличается от нулевого значения Go, что генерируется рядом с SetBio — SetNillableBio и ClearBio, — как искать записи без значения и как выбрать одну из трёх стратегий пустоты для микроблога.

Содержание урока · 10 разделов

В третьем уроке мы объявили 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"" (пустая строка)«не рассказал о себе» неотличимо от «рассказал пустоту»
int0пропущенный возраст выглядит как настоящий ноль
boolfalse«не подтвердил» и «не спрашивали» сливаются в одно
time.Time0001-01-01 00:00:00 UTCнулевая дата пробивается в отчёты и API-ответы
[]bytenilпочти безобидно: len 0 и «нет значения» ведут себя одинаково
uuid.UUID00000000-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)

Чтение — по типу поля. Сгенерированный код сканирует колонку через 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). Какой фильтр правильный?

Соберите обработчик профиля

Впишите сеттер с указателем, метод очистки и предикат пустоты.

// input.Bio — *string из JSON-запроса (null -> nil) updated, err := client.User.UpdateOneID(id). (input.Bio). // прислали новое — записать; nil — не трогать Save(ctx) // пользователь стёр био в интерфейсе — записываем NULL: err = client.User.UpdateOneID(id). (). Exec(ctx) // и считаем всех, кто био так и не завёл: n, err := client.User.Query(). Where(user.()). Count(ctx)

Жизненный цикл 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 + Nillablefield.String("bio").Optional().Nillable()*stringSetBio / SetNillableBio / ClearBio
website: пусто и «нет» — одно и то жеOptionalfield.String("website").Optional()stringSetWebsite / SetNillableWebsite / ClearWebsite
role: пустой роли не бываетDefaultfield.Enum("role").Default("user")Role (строка)SetRole
created_at: время ставит entDefault + Immutablefield.Time("created_at").Default(time.Now).Immutable()time.TimeSetCreatedAt — только при создании
handle: уникальный ник или отсутствиеOptional + Uniquefield.String("handle").Optional().Unique()stringSetHandle / ClearHandle
token: API-ключ, которого может не бытьOptional + Nillablefield.UUID("token", uuid.UUID{}).Optional().Nillable()*uuid.UUIDSetToken / SetNillableToken / ClearToken
published_at: пост ещё не вышелOptional + Nillablefield.Time("published_at").Optional().Nillable()*time.TimeSetPublishedAt / SetNillablePublishedAt / ClearPublishedAt
edits: счётчик правок постаOptionalfield.Int("edits").Optional()intSetEdits / AddEdits / ClearEdits
age: обязателен и положителенобязательное полеfield.Int("age").Positive()intSetAge

Проверка: что не сгенерируется у 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

Термин

Optional vs Nillable

нажмите, чтобы перевернуть
Определение

Optional: колонка nullable, поле можно не задавать при создании, тип в структуре остаётся T — NULL читается как нулевое значение. Nillable: меняет тип на *T — NULL читается как nil. Пара Optional().Nillable() даёт честную пустоту: nil означает ровно «в базе NULL».

1 / 6

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +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 заплатили? Свой ответ сверьте с таблицей решений из урока.

Личные заметкиПусто
Хранятся только в вашем браузере (ent · Optional и Nillable поля)

Вопросы и ответы по уроку

Чем 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-kit25 уроков~14 чпродвинутый25 уроков уже доступны

go-kit: микросервисный тулкит

Курс go-kit: endpoint, транспорт, логирование, метрики, трейсинг, circuit breaker, service discovery. 25 уроков с итоговым микросервисом.

  • Понимать архитектуру endpoint → transport → service
  • Оборачивать сервисы middleware: логи, метрики, трейсинг
  • Подключать HTTP и gRPC транспорты
  • Ставить rate limiting и circuit breaker