null_string_refactoring
Рефакторинг: sql.Null* → указатели
Цель
Убрать sql.NullString, sql.NullInt64, sql.NullTime из бизнес-логики. Nullable поля становятся указателями (*string, *int64, *time.Time).
Изменения в sqlc.yaml
emit_pointers_for_null_types: true
Добавлено в ОБА блока (read и write queries).
Результат
- Удаляется слой конверсии между БД и бизнес-логикой
- gqlgen работает с указателями напрямую без кастомных резолверов
- Код становится идиоматичнее —
*Tэто стандартный Go-способ представления optional значений - Меньше бойлерплейта и импортов
database/sqlвне репозиториев
Паттерны замены
Проверка на null
// Было
if obj.UserID.Valid {
doSomething(obj.UserID.Int64)
}
// Стало
if obj.UserID != nil {
doSomething(*obj.UserID)
}
Создание nullable значения
// Было
params := db.SomeParams{
UserID: sql.NullInt64{Int64: userID, Valid: true},
Email: sql.NullString{String: email, Valid: true},
}
// Стало
params := db.SomeParams{
UserID: &userID,
Email: &email,
}
// Или через хелпер (для литералов)
params := db.SomeParams{
UserID: ptr.To(int64(123)),
Email: ptr.To("test@example.com"),
}
Хелпер ptr.To
// internal/ptr/ptr.go
package ptr
func To[T any](v T) *T {
return &v
}
resolveOnePtr для GraphQL резолверов
// internal/graph/helpers.go
func resolveOnePtr[T any, K any](
ctx context.Context,
id *K,
fetch func(context.Context, K) (T, error),
) (*T, error) {
if id == nil {
return nil, nil
}
return resolveOne(ctx, *id, fetch)
}
// Использование - было
func (r *resolver) User(ctx context.Context, obj *db.Purchase) (*db.User, error) {
if obj.UserID == nil {
return nil, nil
}
return resolveOne[db.User](ctx, *obj.UserID, r.env(ctx).UserByID)
}
// Использование - стало
func (r *resolver) User(ctx context.Context, obj *db.Purchase) (*db.User, error) {
return resolveOnePtr[db.User](ctx, obj.UserID, r.env(ctx).UserByID)
}
Что нужно исправить
Файлы с ошибками компиляции (после go build ./...)
internal/case/admin/completetelegramaccountauth/resolve.go
internal/case/admin/creategittoken/resolve.go
internal/case/admin/createhtmlinjection/resolve.go
internal/case/admin/createoffer/resolve.go
internal/case/admin/createrelease/resolve.go
internal/case/admin/deleteboostycredentials/resolve.go
internal/case/admin/deletepatreoncredentials/resolve.go
internal/case/admin/disableapikey/resolve.go
internal/case/admin/disablegittoken/resolve.go
internal/case/admin/revokeusersubgraphaccess/resolve.go
internal/case/admin/signouttelegramaccount/resolve.go
internal/case/admin/updatehtmlinjection/resolve.go
internal/case/admin/updatenotegraphpositions/resolve.go
internal/case/admin/updateoffer/resolve.go
internal/case/admin/updatesubgraph/resolve.go
internal/case/admin/updatetelegramaccount/resolve.go
internal/case/admin/updatetgbot/resolve.go
internal/case/admin/updateuser/resolve.go
internal/case/admin/updateusersubgraphaccess/resolve.go
internal/case/backjob/sendtelegramaccountmessage/resolve.go
internal/case/backjob/sendtelegrammessage/resolve.go
internal/case/createemailwaitlistrequest/resolve.go
internal/case/cronjob/refreshtelegramaccounts/resolve.go
internal/case/hidenotes/resolve.go
internal/case/processnotionwebook/resolve.go
internal/case/processnowpaymentsipn/resolve.go
internal/case/refreshboostydata/resolve.go
internal/case/refreshboostytoken/resolve.go
internal/case/refreshpatreondata/resolve.go
internal/case/sendtelegramaccountpublishpost/resolve.go
internal/case/sendtelegrampublishpost/resolve.go
internal/cronjobs/jobs.go
internal/graph/schema.resolvers.go (несколько мест)
Тестовые файлы (после go test ./...)
Все тесты, которые создают структуры с sql.Null* полями.
Уже исправлено
internal/patreonjobs/jobs.gointernal/boostyjobs/jobs.gointernal/case/getboostyuser/resolve.gointernal/case/signinbypurchasetoken/resolve.gointernal/case/cronjob/removeexpiredtgchatmembers/resolve.gointernal/case/processpatreonwebhook/resolve.gointernal/case/createpaymentlink/resolve.gointernal/case/signinbyemail/resolve.gointernal/case/getpatreonuser/resolve.gointernal/case/handletgupdate/resolve.goиaccess.gointernal/graph/schema.resolvers.go(частично)internal/graph/helpers.go(добавлен resolveOnePtr)
Функции db.ToNullable*
Эти функции (db.ToNullableInt64, db.ToNullableTime, etc.) больше не нужны в большинстве случаев — просто передавайте указатель напрямую.
// Было
params.CreatedAtGte = db.ToNullableTime(filter.CreatedAt.Gte)
// Стало
params.CreatedAtGte = filter.CreatedAt.Gte // уже *time.Time
Подозрительные места для проверки
Общие паттерны
- Места где nullable поле используется в логе — nil pointer dereference
- Сравнения типа
obj.Field.Int64 == someValue— теперь нужно*obj.Field == someValueс проверкой на nil - Функции nullableString, nullableBool в updatetelegramaccount/updatetgbot — нужно переписать
Конкретные файлы
internal/case/cronjob/removeexpiredtgchatmembers/resolve.go:134
Логирование user.TgUserID.Int64 когда TgUserID может быть nil. Исправлено на pointer, но нужно проверить что логика не сломалась — особенно в случае когда user есть, но TgUserID == nil.
internal/patreonjobs/jobs.go:72 и internal/boostyjobs/jobs.go:60
Логирование cred.SyncedAt.Time — после исправления на pointer передаётся lastSync переменная. Проверить что логгер корректно обрабатывает zero time.
internal/graph/schema.resolvers.go:1827
if !data.X.Valid || !data.Y.Valid {
Это проверка координат для note graph positions. Нужно заменить на:
if data.X == nil || data.Y == nil {
И ниже data.X.Float64 на *data.X.
internal/graph/schema.resolvers.go:2131
if !obj.BannedBy.Valid {
Проверка забаненного пользователя. Заменить на obj.BannedBy == nil.
internal/case/admin/updatetelegramaccount/resolve.go
Функции nullableString, nullableBoolToInt64 возвращают sql.NullString и sql.NullInt64. Нужно переписать:
// Было
func nullableString(s *string) sql.NullString {
if s == nil {
return sql.NullString{}
}
return sql.NullString{String: *s, Valid: true}
}
// Стало — функция больше не нужна, просто используй s напрямую
params.DisplayName = input.DisplayName // уже *string
internal/case/admin/updatetgbot/resolve.go
Аналогично — функции nullableString, nullableBool нужно убрать и передавать указатели напрямую.
internal/case/refreshpatreondata/resolve.go:217
currentTierID := sql.NullInt64{}
// ...
currentTierID = sql.NullInt64{Int64: tier.ID, Valid: true}
Нужно заменить на:
var currentTierID *int64
// ...
currentTierID = &tier.ID
internal/case/refreshboostydata/resolve.go:101
Аналогичная ситуация с sql.NullInt64 — заменить на *int64.
internal/cronjobs/jobs.go:208, 261-262
Создание sql.NullString для error message и report data. Заменить на указатели:
// Было
ErrorMessage: sql.NullString{String: errMsg, Valid: true}
// Стало
ErrorMessage: &errMsg
Тесты
Все тестовые файлы используют старый синтаксис. Основные:
internal/case/admin/createhtmlinjection/resolve_test.gointernal/case/admin/createoffer/resolve_test.gointernal/case/admin/createrelease/resolve_test.gointernal/case/admin/createuser/resolve_test.gointernal/case/admin/deleteboostycredentials/resolve_test.gointernal/case/admin/deletepatreoncredentials/resolve_test.gointernal/case/admin/disableapikey/resolve_test.gointernal/case/admin/resettelegrampublishnote/resolve_test.gointernal/case/admin/restoreboostycredentials/resolve_test.go
В тестах паттерн замены тот же:
// Было
want := db.SomeStruct{
UserID: sql.NullInt64{Int64: 123, Valid: true},
}
// Стало
want := db.SomeStruct{
UserID: ptr.To(int64(123)),
}