メインコンテンツへスキップする

Automatic Migration

entのマイグレーションサポートでは、データベースのスキーマを、プロジェクトのルート下にあるent/migrate/schema.goで定義されたスキーマ・オブジェクトと一致させておくためのオプションが用意されています。

自動マイグレーション

アプリケーションの初期時に自動マイグレーションを実行します。

if err := client.Schema.Create(ctx); err != nil {
log.Fatalf("failed creating schema resources: %v", err)
}

Create は、 ent プロジェクトに必要なすべてのデータベースリソースを作成します。 デフォルトでは、Create"append-only"モードで動作します。つまり、新しいテーブルやインデックスの作成、テーブルへのカラムの追加、カラムの型の拡張のみを行います。 たとえば、intbigint に変更できます。

カラムやインデックスの削除はどのように行うのでしょうか?

リソースの削除

WithDropIndexWithDropColumn はテーブルのカラムとインデックスを削除するための2つのオプションです。

package main

import (
"context"
"log"

"<project>/ent"
"<project>/ent/migrate"
)

func main() {
client, err := ent.Open("mysql", "root:pass@tcp(localhost:3306)/test")
if err != nil {
log.Fatalf("failed connecting to mysql: %v", err)
}
defer client.Close()
ctx := context.Background()
// マイグレーションの実行
err = client.Schema.Create(
ctx,
migrate.WithDropIndex(true),
migrate.WithDropColumn(true),
)
if err != nil {
log.Fatalf("failed creating schema resources: %v", err)
}
}

デバッグモードでマイグレーションを実行(すべての SQL クエリを出力) するには、以下を実行します。

err := client.Debug().Schema.Create(
ctx,
migrate.WithDropIndex(true),
migrate.WithDropColumn(true),
)
if err != nil {
log.Fatalf("failed creating schema resources: %v", err)
}

ユニバーサルID

デフォルトでは、SQLの主キーはテーブルごとに1から始まります。これは、異なるタイプの複数のエンティティが同じIDを共有できることを意味します。 これは、ノードIDがUUIDになるAWS Neptuneとは異なっています。

GraphQLでは、オブジェクトIDが一意であることが要求されるため、この方法はうまく機能しません。

プロジェクトの Universal-ID サポートを有効にするには、マイグレーション時に WithGlobalUniqueID オプションを渡します。

Versioned-migration users should follow the documentation when using WithGlobalUniqueID on MySQL 5.*. :::
package main

import (
"context"
"log"

"<project>/ent"
"<project>/ent/migrate"
)

func main() {
client, err := ent.Open("mysql", "root:pass@tcp(localhost:3306)/test")
if err != nil {
log.Fatalf("failed connecting to mysql: %v", err)
}
defer client.Close()
ctx := context.Background()
// マイグレーションの実行
if err := client.Schema.Create(ctx, migrate.WithGlobalUniqueID(true)); err != nil {
log.Fatalf("failed creating schema resources: %v", err)
}
}

How does it work? ent migration allocates a 1<<32 range for the IDs of each entity (table), and store this information in a table named ent_types. For example, type A will have the range of [1,4294967296) for its IDs, and type B will have the range of [4294967296,8589934592), etc.

Note that if this option is enabled, the maximum number of possible tables is 65535.

オフラインモード

With Atlas becoming the default migration engine soon, offline migration will be replaced by versioned migrations.

Offline mode allows you to write the schema changes to an io.Writer before executing them on the database. It's useful for verifying the SQL commands before they're executed on the database, or to get an SQL script to run manually.

Print changes

package main

import (
"context"
"log"
"os"

"<project>/ent"
"<project>/ent/migrate"
)

func main() {
client, err := ent.Open("mysql", "root:pass@tcp(localhost:3306)/test")
if err != nil {
log.Fatalf("failed connecting to mysql: %v", err)
}
defer client.Close()
ctx := context.Background()
// マイグレーションによる変更を標準出力にダンプ
if err := client.Schema.WriteTo(ctx, os.Stdout); err != nil {
log.Fatalf("failed printing schema changes: %v", err)
}
}

Write changes to a file

package main

import (
"context"
"log"
"os"

"<project>/ent"
"<project>/ent/migrate"
)

func main() {
client, err := ent.Open("mysql", "root:pass@tcp(localhost:3306)/test")
if err != nil {
log.Fatalf("failed connecting to mysql: %v", err)
}
defer client.Close()
ctx := context.Background()
// マイグレーションによる変更をSQLスクリプトとしてダンプ
f, err := os.Create("migrate.sql")
if err != nil {
log.Fatalf("create migrate file: %v", err)
}
defer f.Close()
if err := client.Schema.WriteTo(ctx, f); err != nil {
log.Fatalf("failed printing schema changes: %v", err)
}
}

外部キー

By default, ent uses foreign-keys when defining relationships (edges) to enforce correctness and consistency on the database side.

However, ent also provide an option to disable this functionality using the WithForeignKeys option. You should note that setting this option to false, will tell the migration to not create foreign-keys in the schema DDL and the edges validation and clearing must be handled manually by the developer.

We expect to provide a set of hooks for implementing the foreign-key constraints in the application level in the near future.

package main

import (
"context"
"log"

"<project>/ent"
"<project>/ent/migrate"
)

func main() {
client, err := ent.Open("mysql", "root:pass@tcp(localhost:3306)/test")
if err != nil {
log.Fatalf("failed connecting to mysql: %v", err)
}
defer client.Close()
ctx := context.Background()
// マイグレーションの実行
err = client.Schema.Create(
ctx,
migrate.WithForeignKeys(false), // Disable foreign keys.
)
if err != nil {
log.Fatalf("failed creating schema resources: %v", err)
}
}

マイグレーションフック

The framework provides an option to add hooks (middlewares) to the migration phase. This option is ideal for modifying or filtering the tables that the migration is working on, or for creating custom resources in the database.

package main

import (
"context"
"log"

"<project>/ent"
"<project>/ent/migrate"

"entgo.io/ent/dialect/sql/schema"
)

func main() {
client, err := ent.Open("mysql", "root:pass@tcp(localhost:3306)/test")
if err != nil {
log.Fatalf("failed connecting to mysql: %v", err)
}
defer client.Close()
ctx := context.Background()
// マイグレーションの実行
err = client.Schema.Create(
ctx,
schema.WithHooks(func(next schema.Creator) schema.Creator {
return schema.CreateFunc(func(ctx context.Context, tables ...*schema.Table) error {
// ここでカスタムコードを実行
return next.Create(ctx, tables...)
})
}),
)
if err != nil {
log.Fatalf("failed creating schema resources: %v", err)
}
}

Atlas との統合

Starting with v0.10, Ent supports running migration with Atlas, which is a more robust migration framework that covers many features that are not supported by current Ent migrate package. In order to execute a migration with the Atlas engine, use the WithAtlas(true) option.

package main

import (
"context"
"log"

"<project>/ent"
"<project>/ent/migrate"

"entgo.io/ent/dialect/sql/schema"
)

func main() {
client, err := ent.Open("mysql", "root:pass@tcp(localhost:3306)/test")
if err != nil {
log.Fatalf("failed connecting to mysql: %v", err)
}
defer client.Close()
ctx := context.Background()
// マイグレーションの実行
err = client.Schema.Create(ctx, schema.WithAtlas(true))
if err != nil {
log.Fatalf("failed creating schema resources: %v", err)
}
}

In addition to the standard options (e.g. WithDropColumn, WithGlobalUniqueID), the Atlas integration provides additional options for hooking into schema migration steps.

atlas-migration-process

Atlas Diff and Apply Hooks

Here are two examples that show how to hook into the Atlas Diff and Apply steps.

package main

import (
"context"
"log"

"<project>/ent"
"<project>/ent/migrate"

"ariga.io/atlas/sql/migrate"
atlas "ariga.io/atlas/sql/schema"
"entgo.io/ent/dialect"
"entgo.io/ent/dialect/sql/schema"
)

func main() {
client, err := ent.Open("mysql", "root:pass@tcp(localhost:3306)/test")
if err != nil {
log.Fatalf("failed connecting to mysql: %v", err)
}
defer client.Close()
ctx := context.Background()
// Run migration.
err := client.Schema.Create(
ctx,
// Hook into Atlas Diff process.
schema.WithDiffHook(func(next schema.Differ) schema.Differ {
return schema.DiffFunc(func(current, desired *atlas.Schema) ([]atlas.Change, error) {
// Before calculating changes.
changes, err := next.Diff(current, desired)
if err != nil {
return nil, err
}
// After diff, you can filter
// changes or return new ones.
return changes, nil
})
}),
// Hook into Atlas Apply process.
schema.WithApplyHook(func(next schema.Applier) schema.Applier {
return schema.ApplyFunc(func(ctx context.Context, conn dialect.ExecQuerier, plan *migrate.Plan) error {
// Example to hook into the apply process, or implement
// a custom applier. For example, write to a file.
//
// for _, c := range plan.Changes {
// fmt.Printf("%s: %s", c.Comment, c.Cmd)
// if err := conn.Exec(ctx, c.Cmd, c.Args, nil); err != nil {
// return err
// }
// }
//
return next.Apply(ctx, conn, plan)
})
}),
)
if err != nil {
log.Fatalf("failed creating schema resources: %v", err)
}
}

Diff Hook Example

In case a field was renamed in the ent/schema, Ent won't detect this change as renaming and will propose DropColumn and AddColumn changes in the diff stage. One way to get over this is to use the StorageKey option on the field and keep the old column name in the database table. However, using Atlas Diff hooks allow replacing the DropColumn and AddColumn changes with a RenameColumn change.

func main() {
client, err := ent.Open("mysql", "root:pass@tcp(localhost:3306)/test")
if err != nil {
log.Fatalf("failed connecting to mysql: %v", err)
}
defer client.Close()
// ...
if err := client.Schema.Create(ctx, schema.WithDiffHook(renameColumnHook)); err != nil {
log.Fatalf("failed creating schema resources: %v", err)
}
}

func renameColumnHook(next schema.Differ) schema.Differ {
return schema.DiffFunc(func(current, desired *atlas.Schema) ([]atlas.Change, error) {
changes, err := next.Diff(current, desired)
if err != nil {
return nil, err
}
for _, c := range changes {
m, ok := c.(*atlas.ModifyTable)
// Skip if the change is not a ModifyTable,
// or if the table is not the "users" table.
if !ok || m.T.Name != user.Table {
continue
}
changes := atlas.Changes(m.Changes)
switch i, j := changes.IndexDropColumn("old_name"), changes.IndexAddColumn("new_name"); {
case i != -1 && j != -1:
// Append a new renaming change.
changes = append(changes, &atlas.RenameColumn{
From: changes[i].(*atlas.DropColumn).C,
To: changes[j].(*atlas.AddColumn).C,
})
// Remove the drop and add changes.
changes.RemoveIndex(i, j)
m.Changes = changes
case i != -1 || j != -1:
return nil, errors.New("old_name and new_name must be present or absent")
}
}
return changes, nil
})
}

Apply Hook Example

The Apply hook allows accessing and mutating the migration plan and its raw changes (SQL statements), but in addition to that it is also useful for executing custom SQL statements before or after the plan is applied. For example, changing a nullable column to non-nullable without a default value is not allowed by default. However, we can work around this using an Apply hook that UPDATEs all rows that contain NULL value in this column:

func main() {
client, err := ent.Open("mysql", "root:pass@tcp(localhost:3306)/test")
if err != nil {
log.Fatalf("failed connecting to mysql: %v", err)
}
defer client.Close()
// ...
if err := client.Schema.Create(ctx, schema.WithApplyHook(fillNulls)); err != nil {
log.Fatalf("failed creating schema resources: %v", err)
}
}

func fillNulls(next schema.Applier) schema.Applier {
return schema.ApplyFunc(func(ctx context.Context, conn dialect.ExecQuerier, plan *migrate.Plan) error {
// There are three ways to UPDATE the NULL values to "Unknown" in this stage.
// Append a custom migrate.Change to the plan, execute an SQL statement directly
// on the dialect.ExecQuerier, or use the ent.Client used by the project.

// Execute a custom SQL statement.
query, args := sql.Dialect(dialect.MySQL).
Update(user.Table).
Set(user.FieldDropOptional, "Unknown").
Where(sql.IsNull(user.FieldDropOptional)).
Query()
if err := conn.Exec(ctx, query, args, nil); err != nil {
return err
}

// Append a custom statement to migrate.Plan.
//
// plan.Changes = append([]*migrate.Change{
// {
// Cmd: fmt.Sprintf("UPDATE users SET %[1]s = '%[2]s' WHERE %[1]s IS NULL", user.FieldDropOptional, "Unknown"),
// },
// }, plan.Changes...)

// Use the ent.Client used by the project.
//
// drv := sql.NewDriver(dialect.MySQL, sql.Conn{ExecQuerier: conn.(*sql.Tx)})
// if err := ent.NewClient(ent.Driver(drv)).
// User.
// Update().
// SetDropOptional("Unknown").
// Where(/* Add predicate to filter only rows with NULL values */).
// Exec(ctx); err != nil {
// return fmt.Errorf("fix default values to uppercase: %w", err)
// }

return next.Apply(ctx, conn, plan)
})
}