Содержание урока · 12 разделов
- Типы ent field: каталог конструкторов
- field.* → тип в Go и пример в микроблоге
- Default: значение, которое ставится само
- Optional и Nillable ent: два уровня пустоты
- Валидация схемы ent: встроенные валидаторы
- Каталог встроенных валидаторов
- Enum: роль из фиксированного списка
- Честно про валидацию: что возвращает Save
- Что дальше: NULL-семантика и рёбра
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Во втором уроке мы остановились на таблице, которую печатает ent describe: у полей name и email там стояли колонки Optional, Nillable, Default — все с false, и мы пообещали расшифровать их по ходу курса. Час пришёл. Сегодня дополняем схему User под микроблог: возраст с проверкой на положительность, био, роль из фиксированного списка, время регистрации с дефолтом — и заодно закроем домашку со вторым файлом схемы. Разбираем ent поля по порядку: сначала каталог типов, потом правила дефолтов, затем валидаторы. Путь у всех конструкторов один: конструктор задаёт тип, цепочки методов после него — ограничения и поведение. Правило прошлого урока остаётся в силе: всё, что мы напишем, живёт в ent/schema, а Set-методы, валидаторы и новые типы сгенерирует go generate ./ent — руками их никто не набирает.
Типы ent field: каталог конструкторов#
Все конструкторы полей живут в пакете entgo.io/ent/schema/field — мы импортировали его ещё во втором уроке. Синтаксис единый: field.String("name") — первый аргумент становится именем колонки, методы после него — атрибуты поля. Числовые поля самые разветвлённые: field.Int даёт в структуре int, рядом есть Int8, Int16, Int32, Int64 и беззнаковые варианты, field.Float — это float64 (есть и Float32). Поддерживаются все числовые типы Go, так что узких мест здесь почти не бывает. Строки делятся на field.String под короткие значения и field.Text под длинные тексты — body поста просится именно туда. Дальше по списку: field.Bool, field.Time со структурой time.Time, field.Bytes для сырых байтов ([]byte).
Три конструктора устроены иначе: они принимают не имя типа, а пример значения. field.UUID("token", uuid.UUID{}) выводит Go-тип из переданного образца, field.JSON("prefs", Prefs{}) — из вашего собственного типа, field.Enum("role") вообще работает со списком допустимых строк. Тип поля определяет тип в сгенерированной структуре и тип колонки в базе; правила второй трансформации — тема урока про миграции, пока следим за Go-стороной.
И имя поля превращается в имя колонки как есть — мы писали created_at с подчёркиванием именно поэтому. Если колонка должна называться иначе, есть StorageKey: field.String("name").StorageKey("user_name") оставит в коде поле Name, а в базе появится колонка user_name. Развязка нужна редко — обычно против легаси-схем, которые нельзя переименовывать, но знать про неё стоит до того, как колонки разъедутся по проекту.
field.* → тип в Go и пример в микроблоге#
| Конструктор | Тип в Go | Пример в микроблоге |
|---|---|---|
| field.String("name") | string | имя пользователя |
| field.Text("body") | string | текст поста — длинный |
| field.Int("age") | int | возраст |
| field.Float("rating") | float64 | средняя оценка поста |
| field.Bool("verified") | bool | email подтверждён |
| field.Time("created_at") | time.Time | время регистрации |
| field.UUID("token", uuid.UUID{}) | uuid.UUID | API-токен пользователя |
| field.JSON("prefs", Prefs{}) | Prefs (ваш тип) | настройки интерфейса |
| field.Enum("role") | string | роль: user, moderator, admin |
| field.Bytes("avatar") | []byte | аватарка блобом |
Фрагменты Fields(): остальные типы на витрине
GOimport "github.com/google/uuid"
// Тип для JSON-поля: обычная Go-структура рядом со схемой.
type Prefs struct {
Theme string `json:"theme"` // тема интерфейса
Newsletter bool `json:"newsletter"` // согласие на рассылку
}
// ...и фрагменты Fields() разных сущностей микроблога:
field.Float("rating").
Positive(), // средняя оценка поста: строго больше нуля
field.Bool("verified").
Default(false), // email подтверждён?
field.UUID("token", uuid.UUID{}).
Default(uuid.New), // API-токен: новый при каждом создании
field.JSON("prefs", Prefs{}).
Optional(), // настройки интерфейса целикомЧитаем сверху вниз. Prefs — обычная Go-структура с json-тегами: ent сериализует её значение в JSON и положит в текстовую/JSON-колонку, а из кода вы работаете с обычной структурой через SetPrefs(Prefs{...}). rating — поле для будущего рейтинга постов: float64 с проверкой на положительность. У verified дефолт false — необязательное значение с честным значением по умолчанию. token: второй аргумент field.UUID — образец значения, по нему ent выводит тип uuid.UUID из пакета github.com/google/uuid; Default(uuid.New) — снова функция: новый токен на каждое создание. prefs собран в JSON целиком — флаги настроек удобно хранить одним полем, пока их меньше десятка.
Default: значение, которое ставится само#
Поля с Default не требуют Set-вызова: при создании ent сам подставит значение. Для констант это выглядит скучно и надёжно: field.Bool("verified").Default(false), field.Enum("role") с Default("user"). Интересное начинается со времени. У time-полей Default принимает функцию: field.Time("created_at").Default(time.Now) — time.Now без скобок, вы передаёте саму функцию, а ent вызывает её в момент создания каждой записи. Классическая ошибка — time.Now() со скобками: выражение вычислится один раз на старте, и все записи за жизнь процесса получат одно и то же время. Ошибка коварная: код собирается, тесты на свежем клиенте проходят, а через неделю в базе обнаруживается поле created_at, застывшее в моменте.
Для любых типов с генерацией значения есть DefaultFunc — туда передают свою функцию-генератор, например cuid.New. Поле с Default остаётся обязательным по смыслу: записи без значения не будет, просто заполняет его ent, а не вы. Ограничение: уникальным полям Default запрещён — авторы ent сознательно не смешивают автозначения с уникальностью.
Рядом живут два атрибута из таблицы describe. UpdateDefault — функция, применяемая при каждом обновлении: поле updated_at описывают парой Default(time.Now).UpdateDefault(time.Now). Immutable — поле, которое задаётся только при создании: для update-билдеров сеттер просто не генерируется, поменять created_at через код не выйдет. Вернём оба атрибута в схему чуть ниже.
Время создания и время правки
GOfield.Time("created_at").
Default(time.Now). // функция вызывается при каждом создании
Immutable(), // после создания поле не меняется
field.Time("updated_at").
Default(time.Now).
UpdateDefault(time.Now), // обновляется при каждом UpdateПара штампов, которые вы будете копировать из проекта в проект. created_at пишется один раз: Default(time.Now) ставит время при создании, Immutable() запрещает его менять — сеттера в update-билдере не появится. updated_at живёт активнее: UpdateDefault(time.Now) подставляет свежее время при каждом обновлении сущности, так что поле честно отражает последнюю правку. Никаких хуков и ручных присваиваний — всё это ent делает в сгенерированном коде до похода в базу. В нашу схему User мы возьмём только created_at: updated_at пригодится, когда посты начнут редактироваться.
Проверка: когда срабатывает Default(time.Now)
В схеме написано field.Time("created_at").Default(time.Now). Когда вычисляется время для конкретной записи?
Optional и Nillable ent: два уровня пустоты#
Поля ent обязательны по умолчанию: не вызвали SetAge — Save вернёт ошибку вида ent: missing required field "User.age". ent не подставляет нулевые значения молча, и это спасает от половины тихих багов. Метод Optional() переводит поле в другой режим: его можно не задавать при создании, а колонка в базе становится NULL-able. Биография пользователя — классический кандидат: есть она не у всех.
Но у Optional есть особенность, о которой редко думают заранее: в сгенерированной структуре поле остаётся обычным string. Пропустили SetBio — в Bio пустая строка, неотличимая от намеренно пустой биографии «я ничего о себе не рассказываю». Нулевое значение Go и отсутствие значения в базе слиплись в одно — знакомая болезнь, если вы привыкли к GORM: в его вводном уроке мы видели, как нулевые значения и «нет данных» путаются в одном поле структуры.
Разводит их Nillable(). Для Optional-поля типа T он меняет тип в структуре на T: ent читает NULL из базы и кладёт в поле nil. Получилась классическая пара Optional().Nillable(): SetBio можно не вызывать, а в коде Bio — string, где nil значит «в базе NULL». Nillable работает и на обязательных полях — чтобы не ловить нулевые значения при частичных выборках, — но в прикладных схемах почти всегда встречается в этой паре. Предикаты для проверки на NULL и остальная NULL-семантика — тема четвёртого урока, здесь берём минимум, который нужен для схемы.
Что напечатает Bio без SetBio
Прочитайте код и выберите, что он выведет:
// bio в схеме: field.String("bio").Optional().Nillable()
u, err := client.User.
Create().
SetName("Анна").
SetEmail("anna@example.com").
SetAge(19).
Save(ctx)
fmt.Println(u.Bio == nil)Валидация схемы ent: встроенные валидаторы#
Валидатор — метод на билдере поля, который добавляет проверку значения. NotEmpty и Unique вы уже видели в первом уроке — они появились в схеме User в первые минуты знакомства с ent. Теперь соберём каталог целиком. Для строк: MinLen и MaxLen — границы длины, Match — совпадение с регулярным выражением, NotEmpty — запрет пустой строки, который внутри устроен просто как MinLen(1) (пробел, кстати, пройдёт). Для чисел: Min и Max с включёнными границами, Range(i, j) для отрезка, Positive, Negative и NonNegative как готовые комбинации — Positive устроен как Min(1), поэтому ноль не пройдёт, и это надо помнить, описывая «необязательный» возраст. Валидаторов на одном поле может быть несколько — ent применит их все по очереди.
У MaxLen есть скрытая вторая работа: она же подсказывает генератору размер строковой колонки. А если встроенных проверок мало, любой билдер принимает Validate(fn) — вашу функцию, возвращающую nil или ошибку.
Рядом с валидаторами по смыслу живёт Sensitive(). Это не проверка, а пометка: поле вроде пароля не печатается в логах и отладочных выводах ent и пропускается при кодировании в JSON. struct-тегов у sensitive-полей не бывает — ent убирает их из сгенерированного представления сознательно.
Каталог встроенных валидаторов#
| Валидатор | Где применим | Что проверяет |
|---|---|---|
| NotEmpty | string, []byte | не пустая строка; по сути MinLen(1) — одиночный пробел пройдёт |
| MinLen(i), MaxLen(i) | string, []byte | длина в байтах, границы включены; для счёта букв есть MinRuneLen и MaxRuneLen |
| Match(re) | string | значение совпадает с regexp — формат email, телефон, префикс |
| Min(i), Max(i) | числовые | не меньше i и не больше i — обе границы включены |
| Range(i, j) | числовые | значение в отрезке [i, j] |
| Positive | числовые | не меньше 1 (это Min(1)): ноль и минус не пройдут |
| Negative, NonNegative | числовые | не больше -1; не меньше нуля |
| Validate(fn) | любые поля | ваша функция func(T) error: nil — проверка пройдена |
| Unique | любые поля | не валидатор, а уникальный индекс в базе; с Default не совмещается |
User после урока: поля с характером
GOpackage schema
import (
"time"
"entgo.io/ent"
"entgo.io/ent/schema/field"
)
// User — пользователь микроблога: пишет посты и комментарии.
type User struct {
ent.Schema
}
// Fields — колонки таблицы users.
func (User) Fields() []ent.Field {
return []ent.Field{
field.String("name").
NotEmpty(), // из урока 02: безымянных не даём
field.String("email").
Unique(),
field.Int("age").
Positive(), // возраст: строго больше нуля
field.String("bio").
MaxLen(280). // лимит профиля
Optional(). // SetBio можно не вызывать
Nillable(), // в структуре Bio *string
field.Enum("role").
Values("user", "moderator", "admin").
Default("user"),
field.Time("created_at").
Default(time.Now).
Immutable(), // ставится один раз при создании
}
}
// Edges — связи появятся в уроках 05 и 06.
func (User) Edges() []ent.Edge {
return nil
}Схема выросла с двух полей до шести, и каждое добавлено по сценарию урока. age: Positive отвергает ноль и минус — поле обязательное, SetAge нужен при каждом создании. bio: MaxLen(280) плюс пара Optional и Nillable — колонка NULL-able, в структуре Bio *string. role: enum из трёх значений с дефолтом user. created_at: время ставит сам ent при создании, Immutable не даёт его переписать. name и email не изменились — они уже были собраны во втором уроке. Порядок методов в цепочке свободный: Optional().Nillable() и Nillable().Optional() дают одно и то же. После правки не забудьте go generate ./ent — только тогда появятся SetAge, SetBio и SetRole.
Проверка: что даёт Nillable
field.String("bio").Optional().Nillable() — как поле Bio выглядит в сгенерированной структуре ent/user.go?
Enum: роль из фиксированного списка#
field.Enum("role").Values("user", "moderator", "admin") — Values задаёт полный список допустимых значений, и это список навсегда: добавить четвёртую роль значит отредактировать схему и перегенерировать код, других путей нет. Генератор отработает щедро: в пакете ent появится тип Role — строка с репутацией — и константы RoleUser, RoleModerator, RoleAdmin, так что в прикладном коде роли живут именованными значениями, а не сырыми строками.
Честно про границу защиты. Компилятор от произвольной строки не спасёт: SetRole("boss") соберётся, потому что нетипизированная строковая константа присваивается любому строковому типу. Но дальше в дело вступает сгенерированный валидатор: Save сверит значение со списком Values и вернёт ошибку вида «invalid enum value» ещё до базы. Свой контур защиты у enum замкнут, и это приятная новость.
Default("user") заполняет роль, если её не задали: новые пользователи по умолчанию обычные, а модератора назначает отдельная операция обновления. По умолчанию в PostgreSQL и SQLite enum — обычная строковая колонка, в MySQL колонка становится нативным ENUM; если хочется нативных enum-типов и в PostgreSQL, у ent есть отдельный сценарий миграций — вернёмся к нему в уроке про миграции.
post.go: домашка второго урока закрыта
GOpackage schema
import (
"entgo.io/ent"
"entgo.io/ent/schema/field"
)
// Post — пост микроблога.
type Post struct {
ent.Schema
}
// Fields — колонки таблицы posts.
func (Post) Fields() []ent.Field {
return []ent.Field{
field.String("title").
NotEmpty(). // пост без заголовка не создаём
MaxLen(140), // лимит как у старого твита
field.Text("body"),
}
}
// Edges — связи появятся в уроках 05 и 06.
func (Post) Edges() []ent.Edge {
return nil
}Каркас post.go создал ent init ещё во втором уроке, домашка просила заполнить его полями title и body — вот заполненная версия. title: NotEmpty() не даёт анонимных постов, MaxLen(140) ставит верхнюю границу и заодно подсказывает генератору размер колонки. body собран на field.Text: длинный текст без лимита длины — если ограничение понадобится, MaxLen сработает и здесь. Оба поля обязательные: пост без заголовка или тела не пройдёт Save. Ребро на автора намеренно не трогаем — edges займут пятый и шестой уроки, и тогда title и body получат соседа author.
Честно про валидацию: что возвращает Save#
Самое важное про валидацию — где она выполняется. Валидаторы живут в сгенерированном коде: Save сначала прогоняет все проверки и только потом идёт в базу. Провал валидатора выглядит так: Save(ctx) возвращает не nil-ошибку, а значение типа ValidationError из пакета blog/ent, и текст у неё конкретный: ent: validator failed for field "User.age": value out of range. Обязательное поле, которое забыли задать, — тоже ValidationError, но с другим текстом: ent: missing required field "User.age". Обе ошибки типизированы, различаются через errors.As, у ValidationError есть поле Name с именем провинившегося поля — удобно логировать.
Уникальность живёт отдельно: Unique() создаёт уникальный индекс, и проверяет его сама база. Дубликат email рождает ошибку от СУБД, которую ent заворачивает в ConstraintError — она прилетает после похода в базу, а не до. Это не причуда, а честное распределение ролей: валидатор не увидит параллельную вставку из другого процесса, индекс увидит.
Теперь границы честности. Валидаторы исполняются в клиенте ent — всё, что обходит клиент, мимо проверок: рукописный INSERT, второй сервис на той же базе, скрипт миграции данных. Поэтому правила, от которых зависит целостность, дублируют ограничениями базы: NOT NULL, уникальные индексы, CHECK. Валидаторы — это ранний отказ в вашем коде с понятным текстом ошибки, а не бронированная дверь в данные.
Проверка: ноль против Positive
В схеме field.Int("age").Positive(). Что вернёт client.User.Create().SetAge(0).Save(ctx)?
Внутри MaxLen стоит len(v), а len в Go — это байты. Кириллица занимает два байта на букву, поэтому MaxLen(280) у bio пропустит примерно 140 русских заголовков-букв — вдвое меньше, чем подсказывает интуиция. Если лимит должен считаться в буквах, есть парные MinRuneLen и MaxRuneLen — они считают руны. На латинице разницы нет: для email и ников MaxLen честен.
Жизненный цикл поля с валидатором
Расставьте шаги от описания поля в схеме до ловли двух разных ошибок.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Восстановите фрагмент схемы
Впишите числовой конструктор, метод списка значений enum и функцию времени.
Карточки: поля, дефолты, валидаторы
Что дальше: NULL-семантика и рёбра#
Схема User повзрослела, пост получил свои поля — фундамент для связей готов. Четвёртый урок полностью посвящит паре Optional и Nillable: предикаты для проверки на NULL, поведение Set-методов и то, как NULL ведёт себя в чтении и загрузках. Пятый и шестой — рёбра: наконец свяжем User и Post, добавим комментарии, и поля author_id появятся в таблице posts сами. Напоминание из второго урока остаётся в силе: после каждой правки схемы — go generate ./ent, иначе новые Set-методы не скомпилируются. Полный план из 25 уроков лежит на странице курса ent, а домашка ниже — лучший способ закрепить сегодня описанные поля.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Поля ent строятся конструкторами field.*: String, Text, Int, Float, Bool, Time, Bytes, UUID, JSON, Enum; тип в Go определяется конструктором или образцом значения.
- Default подставляет значение при создании: Default(time.Now) — функция без скобок, вызывается на каждый Save; UpdateDefault работает при обновлениях; уникальным полям дефолт запрещён.
- Optional разрешает не задавать поле при создании, колонка становится NULL-able; без него Save вернёт missing required field.
- Nillable меняет поле в структуре на *T: NULL читается как nil; пара Optional().Nillable() разводит «нет данных» и нулевое значение.
- Валидаторы (NotEmpty, MaxLen, MinLen, Match, Min, Max, Positive, Range, Validate) выполняются в сгенерированном коде на Save, до похода в базу; ошибка — ValidationError.
- Уникальность проверяет база: нарушение индекса приходит уже от СУБД в виде ConstraintError.
- MaxLen считает байты: 280 байт кириллицы — примерно 140 букв; для счёта по буквам есть MaxRuneLen и MinRuneLen.
Домашнее задание
Доведите свою схему микроблога до версии урока: в user.go добавьте age с Positive, bio с MaxLen(280).Optional().Nillable(), role с Values и Default("user"), created_at с Default(time.Now).Immutable(); в post.go соберите title (NotEmpty + MaxLen(140)) и body на field.Text. Запустите go generate ./ent, затем в main.go попробуйте создать пользователя с SetAge(0) и прочитайте текст ошибки ValidationError. Повторите создание с SetAge(19), не задавая bio, напечатайте u.Bio и убедитесь, что это nil. Вопрос на подумать: у email нет ни одного валидатора — что всё-таки защищает его от дубликатов и в какой момент это срабатывает?
Личные заметкиПусто
Вопросы и ответы по уроку
Валидаторы ent защищают саму базу от мусора?
Нет. Валидаторы исполняются в сгенерированном клиенте на этапе Save: любой обход ent — рукописный INSERT, другой сервис, скрипт заливки данных — пройдёт мимо проверок. Целостность на уровне базы обеспечивают ограничения СУБД: NOT NULL, уникальные индексы, CHECK. Валидаторы дают ранний отказ с понятным текстом в вашем коде.
Чем Default отличается от Optional?
Default подставляет значение при создании, если вы не вызвали Set: записи без значения не будет, но и Set-вызов не нужен. Optional делает поле необязательным: значения может не быть вовсе, колонка — NULL-able. Поле с Default остаётся заполненным, поле с Optional может быть пустым — это разные контракты.
Можно ли добавить свой валидатор с собственной ошибкой?
Да. Любой билдер поля принимает Validate(fn), где fn — функция func(T) error: верните nil, если значение годится, или свою ошибку иначе. Свой валидатор работает вместе со встроенными — ent применит их все при Save и вернёт ValidationError с вашим текстом.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
go-kit: микросервисный тулкит
Курс go-kit: endpoint, транспорт, логирование, метрики, трейсинг, circuit breaker, service discovery. 25 уроков с итоговым микросервисом.
- Понимать архитектуру endpoint → transport → service
- Оборачивать сервисы middleware: логи, метрики, трейсинг
- Подключать HTTP и gRPC транспорты
- Ставить rate limiting и circuit breaker