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

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

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

Урок 3 из 25 ~20 мин +50 XP

Поля: типы и валидация

Таблица describe из прошлого урока обещала колонки Optional, Nillable и Default — пришло время расшифровать каждую. Тема урока — ent поля: каталог конструкторов field.*, дефолты через Default и DefaultFunc, разница Optional и Nillable и валидация схемы ent — MaxLen, Positive, Match. Дополним User возрастом, био, ролью и временем создания и закроем домашку: соберём post.go с лимитом на заголовок.

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

Во втором уроке мы остановились на таблице, которую печатает 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")boolemail подтверждён
field.Time("created_at")time.Timeвремя регистрации
field.UUID("token", uuid.UUID{})uuid.UUIDAPI-токен пользователя
field.JSON("prefs", Prefs{})Prefs (ваш тип)настройки интерфейса
field.Enum("role")stringроль: user, moderator, admin
field.Bytes("avatar")[]byteаватарка блобом

Фрагменты Fields(): остальные типы на витрине

GO
import "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 через код не выйдет. Вернём оба атрибута в схему чуть ниже.

Время создания и время правки

GO
field.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 убирает их из сгенерированного представления сознательно.

Каталог встроенных валидаторов#

ВалидаторГде применимЧто проверяет
NotEmptystring, []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 после урока: поля с характером

GO
package 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: домашка второго урока закрыта

GO
package 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)?

Жизненный цикл поля с валидатором

Расставьте шаги от описания поля в схеме до ловли двух разных ошибок.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Восстановите фрагмент схемы

Впишите числовой конструктор, метод списка значений enum и функцию времени.

field.("age"). Positive(), field.Enum("role"). ("user", "moderator", "admin"). Default("user"), field.Time("created_at"). Default(time.)

Карточки: поля, дефолты, валидаторы

Термин

field.*

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

Конструкторы полей из entgo.io/ent/schema/field: String, Text, Int (и другие числовые), Float, Bool, Time, Bytes, UUID, JSON, Enum. Первый аргумент — имя колонки, цепочки методов — ограничения. Для UUID и JSON второй аргумент — образец значения, по которому выводится Go-тип.

1 / 6

Что дальше: 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 · Поля: типы и валидация)

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

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

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

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

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