要約
- もともと
email
は string + NotEmpty + Validate(A案)にしていたため、空文字が弾かれて「未設定」を表現できなかった -
B案として、Ent スキーマを
Nillable().Optional()
に変更してnil = 未設定
を表現 - 保存・更新時は
SetNillableEmail(*string)
を使い、LINEのみ登録→後でメール追加のフローを自然に実現できるようになった - バリデーション(
Validate
/NotEmpty
)は 値がセットされた時のみ評価されるので、未設定時(nil
)には実行されない
背景と課題(A案の限界)
当初は以下のようなスキーマだった:
field.String("email").
Unique().
NotEmpty().
Validate(func(email string) error {
emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
if !emailRegex.MatchString(email) {
return errors.New("invalid email format")
}
return nil
})
-
NotEmpty()
と正規表現Validate
により、空文字が禁止 - つまり未設定を空文字で表すことができず、LINEだけで初回登録というユースケースに合わない
解決策(B案):nullable にして「未設定 = nil」を表現
Ent スキーマの変更
field.String("email").
Unique().
Nillable(). // ← これで DB 列が NULL 許可に
Optional(). // ← これで「セットしない」を選べる
Validate(func(email string) error {
// 値がセットされた時だけ評価される(nil のときは呼ばれない)
emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
if !emailRegex.MatchString(email) {
return errors.New("invalid email format")
}
return nil
})
メモ:
NotEmpty()
は正規表現と役割が重複するので外すのがシンプル(残しても「セット時のみ」評価)。
コード生成を更新
ent generate ./ent/schema
# or
go generate ./...
生成後、UserCreate
/ UserUpdateOne
に SetNillableEmail(*string)
が生える(SetEmail(string)
も共存)。
保存・更新の書き方(重要)
Create
// u.Email は *string(nil なら未設定)
eu, err := tx.User.
Create().
SetNillableEmail(u.Email). // ← ここがキモ。nil をそのまま渡せる
SetName(u.Name).
SetNillablePassword(u.Password) // password も Nillable なら同様
Save(ctx)
Update
_, err := tx.User.
UpdateOneID(u.ID).
SetNillableEmail(u.Email). // nil を渡せば NULL(未設定)に戻せる
SetNillablePassword(u.Password).
Save(ctx)
生成 API を確認:
SetNillableXxx(*T)
があるかどうか。無ければスキーマ or 生成の更新漏れ。
なぜポインタ化(nullable)で null
を渡せるのか(論理の要点)
-
Go の
string
は値型:常に何らかの値(空文字を含む)を持つため、未設定を表現できない -
*string
(ポインタ)にするとnil
を持てる:不在(未設定)を値として表現可能 -
Ent の
Nillable().Optional()
は以下を意味する:- DB 列が NULL 許可になる
- mutator(Create/Update ビルダー)に値をセットしなければ、そのフィールドは「未設定」扱い
-
この時、
NotEmpty()
やValidate(func(string) error)
は 値がセットされた時だけ評価される(nil
ならそもそも呼ばれない)
結果として:
-
初回登録(LINEのみ):
email = nil
のまま保存できる -
あとからメール追加:
email = &"foo@example.com"
を渡す時だけ正規表現チェックが効く
補足:UNIQUE と NULL
多くの RDBMS(PostgreSQL / MySQL)は UNIQUE 列に複数の NULL を許容する。
つまり「メールが設定されているレコード間のみユニーク」を、自然に満たす。
ドメイン/DTO の型方針
-
Domain:
Email *string
(未設定 =nil
) -
DTO(JSON):
Email *string
(null
をそのまま返す)- フロント(TypeScript)は
string | null
で扱える → 表示はemail ?? '未設定'
- フロント(TypeScript)は
// domain.User
type User struct {
ID int
Email *string
Name string
IsAdmin bool
IsRoot bool
IsTest bool
HasPassword bool
HasLine bool
CreatedAt time.Time
UpdatedAt time.Time
}
// DTO
type UserDetail struct {
ID int `json:"id"`
Email *string `json:"email"` // null 許容
Name string `json:"name"`
IsAdmin bool `json:"isAdmin"`
IsRoot bool `json:"isRoot"`
IsTest bool `json:"isTest"`
IsLine bool `json:"isLine"`
IsSettedPassword bool `json:"isSettedPassword"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
マイグレーションの注意点
- 既存列が
NOT NULL
の場合は NULL 許可に変更が必要 - もし既存データに空文字があるなら、先に NULL へ置換しておくとよい
- アプリで
""
を「未設定」として使っていた箇所は、nil
ベースに置き換える
クエリとバリデーションの実務 Tips
-
「メール未設定のユーザーを検索」:
email IS NULL
条件(Ent ならuser.EmailIsNil()
) -
メール or LINE どちらか必須など複合ルールは、
- 最初は Usecase で実装すると簡単
- 厳密にやるなら Ent Hook(mutation を参照)で禁止するのも可
// Hook 例(イメージ):Create/Update で email=nil & LINE連携なし → エラー
func UserBusinessRuleHook() ent.Hook { /* ... */ }
よくあるハマりどころ(チェックリスト)
- スキーマを直したのに
SetNillableEmail
が見えない → 生成し直し(ent generate
)。古い生成物を参照していないか確認 -
SetEmail(*string)
を呼んでしまう →SetNillableEmail(*string)
を使う(関数名に注意) - Unique × 空文字ハック → やめる。NULL 運用で自然に解ける
- DTO だけ
string
にしてしまい""
が飛ぶ → DTO も*string
にするとフロントでの扱いが楽
まとめ
-
LINEだけで初回登録 → 後でメール追加を実現するには、
email
を nullable にするのが正攻法 - Ent の
Nillable().Optional()
と、生成されるSetNillableEmail(*string)
を使えば、
nil = 未設定 を自然に表現でき、セット時だけ バリデーションが働く - Domain/DTO も
*string
に揃えれば、API でnull
を返せるため、フロント実装も素直になる
設計の一貫性・拡張性・実装の簡潔さ、すべての観点で nullable 設計がベスト。
既存が A案(string 固定)でも、B案(nullable)への移行は最小限の変更で済み、面倒なハック(空文字運用)も不要になります。