Chapter 12

ent でスキーマを設計する

Spiegel
Spiegel
2021.09.27に更新

環境が整ったところでスキーマ定義について検証しておこう。確かこんなコードだったよね。

ent/schema/user.go
package schema

import (
    "time"

    "entgo.io/ent"
    "entgo.io/ent/schema/edge"
    "entgo.io/ent/schema/field"
)

// User holds the schema definition for the User entity.
type User struct {
    ent.Schema
}

// Fields of the User.
func (User) Fields() []ent.Field {
    return []ent.Field{
        field.String("username").
            MaxLen(63).
            NotEmpty().
            Unique(),
        field.Time("created_at").
            Default(time.Now),
        field.Time("updated_at").
            Default(time.Now),
    }
}

// Edges of the User.
func (User) Edges() []ent.Edge {
    return []ent.Edge{
        edge.To("owned", BinaryFile.Type),
    }
}
ent/schema/binaryfile.go
package schema

import (
    "time"

    "entgo.io/ent"
    "entgo.io/ent/schema/edge"
    "entgo.io/ent/schema/field"
)

// BinaryFile holds the schema definition for the BinaryFile entity.
type BinaryFile struct {
    ent.Schema
}

// Fields of the BinaryFile.
func (BinaryFile) Fields() []ent.Field {
    return []ent.Field{
        field.String("filename").
            NotEmpty().
            Unique(),
        field.Bytes("body").
            Optional().
            Nillable(),
        field.Time("created_at").
            Default(time.Now),
        field.Time("updated_at").
            Default(time.Now),
    }
}

// Edges of the BinaryFile.
func (BinaryFile) Edges() []ent.Edge {
    return []ent.Edge{
        edge.From("owner", User.Type).
            Unique().
            Required().
            Ref("owned"),
    }
}

そもそもエッヂってなに?

知ってる方には言わずもながなだが,そもそもエッヂってなに? って話から。

このように,データのグラフ構造を表すときの最小単位がノード(節)とエッヂ(枝)である。

上のコードに当てはめるなら

という関係を記述しているわけだ。具体的には From 側ノード User では To 側ノードに対して owned エッヂを定義し To 側ノード BinaryFile では From 側ノードに対して owner エッヂを定義している。

ちなみに owner / owned といった Edges() 定義の名称は Fields() 定義の名称(idusername など)と同じにしてはいけないようで,同じ名前を使いまわすと entc generate コマンドでエラーになったり自動生成したコードがコンパイルエラーになったりした(最初は意味が分からなくてねぇ...)。

さらに To 側ノード BinaryFile の Edges() メソッドを見ると

ent/schema/binaryfile.go
// Edges of the BinaryFile.
func (BinaryFile) Edges() []ent.Edge {
    return []ent.Edge{
        edge.From("owner", User.Type).
            Unique().
            Required().
            Ref("owned"),
    }
}

となっている。

Unique() オプションは owner エッヂの相手(From)ノードは唯一のレコードのみ紐づくことを意味する。一方, From 側ノード User で定義する owned エッヂには Unique() オプションはないため User (From) と BinaryFile (To) との関係(多重度)は O2M (one-to-many) であることが分かる。このように Unique() オプションを使って O2O, O2M/M2O, M2M といった多重度を表現できる。

更に Ref() オプションを使ってエッヂ間の関係を記述でき,これによって To 側ノードに foreign key が設定される。なお Ref() オプションは To 側ノードが From 側ノードへのエッヂ定義としてのみ書けるようだ。

スキーマ定義を確認するには以下のコマンドを叩くとよい。

$ go run -mod=mod entgo.io/ent/cmd/ent describe ./ent/schema
BinaryFile:
    +------------+-----------+--------+----------+----------+---------+---------------+-----------+-----------------------------+------------+
    |   Field    |   Type    | Unique | Optional | Nillable | Default | UpdateDefault | Immutable |          StructTag          | Validators |
    +------------+-----------+--------+----------+----------+---------+---------------+-----------+-----------------------------+------------+
    | id         | int       | false  | false    | false    | false   | false         | false     | json:"id,omitempty"         |          0 |
    | filename   | string    | true   | false    | false    | false   | false         | false     | json:"filename,omitempty"   |          1 |
    | body       | []byte    | false  | true     | true     | false   | false         | false     | json:"body,omitempty"       |          0 |
    | created_at | time.Time | false  | false    | false    | true    | false         | false     | json:"created_at,omitempty" |          0 |
    | updated_at | time.Time | false  | false    | false    | true    | false         | false     | json:"updated_at,omitempty" |          0 |
    +------------+-----------+--------+----------+----------+---------+---------------+-----------+-----------------------------+------------+
    +-------+------+---------+---------+----------+--------+----------+
    | Edge  | Type | Inverse | BackRef | Relation | Unique | Optional |
    +-------+------+---------+---------+----------+--------+----------+
    | owner | User | true    | owned   | M2O      | true   | true     |
    +-------+------+---------+---------+----------+--------+----------+

User:
    +------------+-----------+--------+----------+----------+---------+---------------+-----------+-----------------------------+------------+
    |   Field    |   Type    | Unique | Optional | Nillable | Default | UpdateDefault | Immutable |          StructTag          | Validators |
    +------------+-----------+--------+----------+----------+---------+---------------+-----------+-----------------------------+------------+
    | id         | int       | false  | false    | false    | false   | false         | false     | json:"id,omitempty"         |          0 |
    | username   | string    | true   | false    | false    | false   | false         | false     | json:"username,omitempty"   |          2 |
    | created_at | time.Time | false  | false    | false    | true    | false         | false     | json:"created_at,omitempty" |          0 |
    | updated_at | time.Time | false  | false    | false    | true    | false         | false     | json:"updated_at,omitempty" |          0 |
    +------------+-----------+--------+----------+----------+---------+---------------+-----------+-----------------------------+------------+
    +-------+------------+---------+---------+----------+--------+----------+
    | Edge  |    Type    | Inverse | BackRef | Relation | Unique | Optional |
    +-------+------------+---------+---------+----------+--------+----------+
    | owned | BinaryFile | false   |         | O2M      | false  | true     |
    +-------+------------+---------+---------+----------+--------+----------+

スキーマ定義から DDL を生成する

ent ではスキーマ定義から DDL を生成することができる。この機能を使って意図通りのテーブル構成になるか確認しておこう。

sample2.go
func Run() exitcode.ExitCode {
    // get ent context
    entCtx, err := dbconn.NewEnt()
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        return exitcode.Abnormal
    }
    defer entCtx.Close()

    // output DDL
    if err := entCtx.GetClient().Schema.WriteTo(context.TODO(), os.Stdout); err != nil {
        entCtx.GetLogger().Error().Interface("error", errs.Wrap(err)).Send()
        return exitcode.Abnormal
    }

    return exitcode.Normal
}

これを実行すると

$ go run sample2.go
BEGIN;
CREATE TABLE IF NOT EXISTS "binary_files"("id" bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL, "filename" varchar UNIQUE NOT NULL, "body" bytea NULL, "created_at" timestamp with time zone NOT NULL, "updated_at" timestamp with time zone NOT NULL, "user_owned" bigint NULL, PRIMARY KEY("id"));
CREATE TABLE IF NOT EXISTS "users"("id" bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL, "username" varchar UNIQUE NOT NULL, "created_at" timestamp with time zone NOT NULL, "updated_at" timestamp with time zone NOT NULL, PRIMARY KEY("id"));
ALTER TABLE "binary_files" ADD CONSTRAINT "binary_files_users_owned" FOREIGN KEY("user_owned") REFERENCES "users"("id") ON DELETE SET NULL;
COMMIT;

と出力される。分かりにくいな(笑) 適当に整形するか[1]

BEGIN
;
CREATE TABLE IF NOT EXISTS "binary_files"(
  "id" bigint GENERATED BY
    DEFAULT AS IDENTITY NOT NULL
    ,"filename" varchar UNIQUE NOT NULL
    ,"body" bytea NULL
    ,"created_at" timestamp with time zone NOT NULL
    ,"updated_at" timestamp with time zone NOT NULL
    ,"user_owned" bigint NULL
    ,PRIMARY KEY("id")
)
;
CREATE TABLE IF NOT EXISTS "users"(
  "id" bigint GENERATED BY
    DEFAULT AS IDENTITY NOT NULL
    ,"username" varchar UNIQUE NOT NULL
    ,"created_at" timestamp with time zone NOT NULL
    ,"updated_at" timestamp with time zone NOT NULL
    ,PRIMARY KEY("id")
)
;
ALTER TABLE "binary_files" ADD CONSTRAINT "binary_files_users_owned" FOREIGN KEY(
  "user_owned"
) REFERENCES "users"(
  "id"
)
ON  DELETE
SET
  NULL
;
COMMIT
;

テーブル名を変更したい

GORM もそうだったが,やっぱテーブル名って複数形なんだな。これが普通って認識でいいのかな。

テーブル名を変更するには Annotations() メソッドを使うらしい。

// Annotations of the User.
func (User) Annotations() []schema.Annotation {
    return []schema.Annotation{
        entsql.Annotation{Table: "Foo"},
    }
}

まぁ,今回はしないけどね。

Validator は DDL に影響を与えない?

users.username フィールドには組み込み validator を使って MaxLen(63) と定義していたのだが, DDL を見ても varchr(63) とかにはしてくれないらしい。 DDL 上の型を明示的に変更したいなら SchemaType() を使って

// Fields of the User.
func (User) Fields() []ent.Field {
    return []ent.Field{
        field.String("username").
            SchemaType(map[string]string{
                dialect.Postgres: "varchar(63)",
            }).
            NotEmpty().
            Unique(),
        ...
    }
}

とすればいいようだ。

Primary Key の名前と型を変更したい

Primary Key としては暗黙的に int 型の id フィールドが定義され,それがそのままカラム名になっている。これを変更するには id フィールドを上書き再定義すればいいらしい。以下は id フィールドを varchr(20)user_id カラムに変更した状態。

// Fields of the User.
func (User) Fields() []ent.Field {
    return []ent.Field{
        field.String("id").
            StorageKey("user_id").
            StructTag(`json:"user_id"`).
            SchemaType(map[string]string{
                dialect.Postgres: "varchar(20)",
            }),
        ...
    }
}

これも今回はしない。

外部参照カラムは NOT NULL にできない?

Edges() メソッド定義で生成される外部参照カラム(今回なら binary_files.user_owned)は NOT NULL にできないっぽい。 Required() オプションは DDL に対しては効いてない感じ。なお,外部参照カラムを foreign key にしたくない場合は DDL 生成処理の中で WithForeignKeys() 関数を使って

// output DDL
if err := entCtx.GetClient().Schema.WriteTo(context.TODO(), os.Stdout, , migrate.WithForeignKeys(false)); err != nil {
    entCtx.GetLogger().Error().Interface("error", errs.Wrap(err)).Send()
    return exitcode.Abnormal
}

とすればいいらしい。後述の Create() メソッドでも同様にできる。今回はしない。

外部参照カラム名を変えたいが...

今回の構成では変えられない。他所様のブログ等を見るに From/To を入れ替えて(つまり M2O にして)

という片方向の関連にすれば From ノード側の Edges() メソッドで Field() オプションを使い Fields() メソッドで定義されたフィールド名に紐づけすれば,紐づけされたフィールドは foreign key にできるらしい。片方向では面白くないし(何故か両方向にできなかった),今回はそこまで名前に思い入れがあるわけではないので弄らないことにする。

再び DDL を生成する

ここまでのスキーマ定義は以下の通り

ent/schema/user.go
package schema

import (
    "time"

    "entgo.io/ent"
    "entgo.io/ent/dialect"
    "entgo.io/ent/schema/edge"
    "entgo.io/ent/schema/field"
)

// User holds the schema definition for the User entity.
type User struct {
    ent.Schema
}

// Fields of the User.
func (User) Fields() []ent.Field {
    return []ent.Field{
        field.String("username").
            SchemaType(map[string]string{
                dialect.Postgres: "varchar(63)",
            }).
            NotEmpty().
            Unique(),
        field.Time("created_at").
            Default(time.Now),
        field.Time("updated_at").
            Default(time.Now),
    }
}

// Edges of the User.
func (User) Edges() []ent.Edge {
    return []ent.Edge{
        edge.To("owned", BinaryFile.Type),
    }
}
ent/schema/binaryfile.go
package schema

import (
    "time"

    "entgo.io/ent"
    "entgo.io/ent/schema/edge"
    "entgo.io/ent/schema/field"
)

// BinaryFile holds the schema definition for the BinaryFile entity.
type BinaryFile struct {
    ent.Schema
}

// Fields of the BinaryFile.
func (BinaryFile) Fields() []ent.Field {
    return []ent.Field{
        field.String("filename").
            NotEmpty().
            Unique(),
        field.Bytes("body").
            Optional().
            Nillable(),
        field.Time("created_at").
            Default(time.Now),
        field.Time("updated_at").
            Default(time.Now),
    }
}

// Edges of the BinaryFile.
func (BinaryFile) Edges() []ent.Edge {
    return []ent.Edge{
        edge.From("owner", User.Type).
            Unique().
            Required().
            Ref("owned"),
    }
}

この状態で再び DDL を生成してみる。実行結果を整形したものだけ挙げておこう。

BEGIN
;
CREATE TABLE IF NOT EXISTS "binary_files"(
   "id" bigint GENERATED BY
     DEFAULT AS IDENTITY NOT NULL
    ,"filename" varchar UNIQUE NOT NULL
    ,"body" bytea NULL
    ,"created_at" timestamp with time zone NOT NULL
    ,"updated_at" timestamp with time zone NOT NULL
    ,"user_owned" bigint NULL
    ,PRIMARY KEY("id")
)
;
CREATE TABLE IF NOT EXISTS "users"(
   "id" bigint GENERATED BY
     DEFAULT AS IDENTITY NOT NULL
    ,"username" varchar(63) UNIQUE NOT NULL
    ,"created_at" timestamp with time zone NOT NULL
    ,"updated_at" timestamp with time zone NOT NULL
    ,PRIMARY KEY("id")
)
;
ALTER TABLE "binary_files" ADD CONSTRAINT "binary_files_users_owned" FOREIGN KEY(
  "user_owned"
) REFERENCES "users"(
  "id"
)
ON  DELETE
SET
  NULL
;
COMMIT
;

まぁ,こんなもんかな。

テーブルの作成

では,いよいよテーブルを作成しよう。さきほどの sample2.go を少しいじって

sample3.go
// create tables
if err := entCtx.GetClient().Schema.Create(context.TODO()); err != nil {
    entCtx.GetLogger().Error().Interface("error", errs.Wrap(err)).Send()
    return exitcode.Abnormal
}

とする。実行結果は以下の通り。

$ go run sample\sample3.go
0:00AM INF Dialing PostgreSQL server host=hostname module=pgx
0:00AM INF Exec args=[] commandTag=QkVHSU4= module=pgx pid=2629 sql=begin
0:00AM INF Query args=[] module=pgx pid=2629 rowCount=1 sql="SHOW server_version_num"
0:00AM INF Query args=["binary_files"] module=pgx pid=2629 rowCount=1 sql="SELECT COUNT(*) FROM \"information_schema\".\"tables\" WHERE \"table_schema\" = CURRENT_SCHEMA() AND \"table_name\" = $1"
0:00AM INF Exec args=[] commandTag=Q1JFQVRFIFRBQkxF module=pgx pid=2629 sql="CREATE TABLE IF NOT EXISTS \"binary_files\"(\"id\" bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL, \"filename\" varchar UNIQUE NOT NULL, \"body\" bytea NULL, \"created_at\" timestamp with time zone NOT NULL, \"updated_at\" timestamp with time zone NOT NULL, \"user_owned\" bigint NULL, PRIMARY KEY(\"id\"))"
0:00AM INF Query args=["users"] module=pgx pid=2629 rowCount=1 sql="SELECT COUNT(*) FROM \"information_schema\".\"tables\" WHERE \"table_schema\" = CURRENT_SCHEMA() AND \"table_name\" = $1"
0:00AM INF Exec args=[] commandTag=Q1JFQVRFIFRBQkxF module=pgx pid=2629 sql="CREATE TABLE IF NOT EXISTS \"users\"(\"id\" bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL, \"username\" varchar(63) UNIQUE NOT NULL, \"created_at\" timestamp with time zone NOT NULL, \"updated_at\" timestamp with time zone NOT NULL, PRIMARY KEY(\"id\"))"
0:00AM INF Query args=["FOREIGN KEY","binary_files_users_owned"] module=pgx pid=2629 rowCount=1 sql="SELECT COUNT(*) FROM \"information_schema\".\"table_constraints\" WHERE \"table_schema\" = CURRENT_SCHEMA() AND \"constraint_type\" = $1 AND \"constraint_name\" = $2"
0:00AM INF Exec args=[] commandTag=QUxURVIgVEFCTEU= module=pgx pid=2629 sql="ALTER TABLE \"binary_files\" ADD CONSTRAINT \"binary_files_users_owned\" FOREIGN KEY(\"user_owned\") REFERENCES \"users\"(\"id\") ON DELETE SET NULL"
0:00AM INF Exec args=[] commandTag=Q09NTUlU module=pgx pid=2629 sql=commit
0:00AM INF closed connection module=pgx pid=2629

ちゃんと作成されているようである。うんうん。

複数の Primary Key は設定できない(今のところ)

どうも primary key を複数定義することは出来ないようだ。この辺は以下の issue で議論されているようだが,まだ実装には至ってない。

https://github.com/ent/ent/issues/400
https://github.com/ent/ent/issues/1949

私はこの時点で ent の採用を泣く泣く見送った。よくできてると思うんだけどなぁ。

ent はテーブルの Drop はできない?

ひょっとして ent ってテーブルの Drop 機能はないのかな。カラムやインデックスは Drop できるみたいだけど。特に開発初期は頻繁にテーブルを作ったり潰したりするので割と必須の機能だと思うんだけど,最近はそういうやり方はしない? まぁ,最悪は SQL 文を投げればいいんだけど。でもそのためだけに素の sql.DB に触れるようにするのは何か違う気がするし...

脚注
  1. 以前からネット上の SQL 文整形サービスは「SQLフォーマッターFor WEB」のお世話になっている。今回久しぶりに利用したよ。ありがとう。いいサービスです。 ↩︎