💨

ent | Quick Introduction

2023/06/16に公開1

ent | Quick Introduction

Quick Introduction

entはGoのためのシンプルで強力なエンティティフレームワークで、大規模なデータモデルを持つアプリケーションの構築と保守を容易にし、以下の原則に忠実です:

  • データベーススキーマをグラフ構造として簡単にモデル化できる。
  • スキーマをプログラム的なGoコードとして定義する。
  • コード生成に基づく静的型付け
  • データベースクエリやグラフトラバーサルを簡単に書くことができる。
  • Goテンプレートを使って拡張やカスタマイズが簡単にできる。

環境構築

go.modを作成します。

bash
go mod init entdemo

スキーマ作成

プロジェクトのルートディレクトリに移動して、下記のコマンドを実行します

bash
go run -mod=mod entgo.io/ent/cmd/ent new User

上記のコマンドで、entdemo/ent/schema/ディレクトリの下にUserのスキーマが生成されます

schema/user.go
package schema

import "entgo.io/ent"

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

// Fields of the User.
func (User) Fields() []ent.Field {
    return nil
}

// Edges of the User.
func (User) Edges() []ent.Edge {
    return nil
}

Userスキーマに2つのフィールドを追加します

schema/user.go
package schema

import (
    "entgo.io/ent"
    "entgo.io/ent/schema/field"
)

// Fields of the User.
func (User) Fields() []ent.Field {
    return []ent.Field{
        field.Int("age").
            Positive(),
        field.String("name").
            Default("unknown"),
    }
}

プロジェクトのルートディレクトリから、以下のようにgo generateを実行します

bash
go generate ./ent

以下のようなファイルが生成されます。

ent
├── client.go
├── config.go
├── context.go
├── ent.go
├── generate.go
├── mutation.go
... truncated
├── schema
│   └── user.go
├── tx.go
**├── user
│   ├── user.go
│   └── where.go
├── user.go
├── user_create.go
├── user_delete.go
├── user_query.go
└── user_update.go**

エンティティ作成

まずは、スキーママイグレーションを実行し、クライアントを作成します

下記の例はPostgreSQLです。

start.go
package main

import (
    "context"
    "log"

    "entdemo/ent"

    _ "github.com/lib/pq"
)

func main() {
    client, err := ent.Open("postgres","host=<host> port=<port> user=<user> dbname=<database> password=<pass>")
    if err != nil {
        log.Fatalf("failed opening connection to postgres: %v", err)
    }
    defer client.Close()
    // Run the auto migration tool.
    if err := client.Schema.Create(context.Background()); err != nil {
        log.Fatalf("failed creating schema resources: %v", err)
    }
}

実行すると、スキーマが作成されます。

postgres
db=# \d
            List of relations
 Schema |     Name     |   Type   | Owner 
--------+--------------+----------+-------
 public | users        | table    | user
 public | users_id_seq | sequence | user
(2 rows)

usersテーブルにユーザーを追加します。

start.go
func CreateUser(ctx context.Context, client *ent.Client) (*ent.User, error) {
	u, err := client.User.
		Create().
		SetAge(30).
		SetName("a8m").
		Save(ctx)
	if err != nil {
		return nil, fmt.Errorf("failed creating user: %w", err)
	}
	log.Println("user was created: ", u)
	return u, nil
}

func main() {
	client, err := ent.Open("postgres", "host=localhost port=5432 user=user dbname=db password=pass")
	if err != nil {
		log.Fatalf("failed opening connection to postgres: %v", err)
	}
	defer client.Close()
	ctx := context.Background()
	CreateUser(ctx, client)
}

実行すると、ユーザーが追加されます。

postgres
db=# select * from users;
 id | age | name 
----+-----+------
  1 |  30 | a8m
(1 row)

クエリを投げる

entは、各エンティティスキーマに対して、その述語、デフォルト値、バリデータ、ストレージ要素に関する追加情報(カラム名、主キーなど)を含むパッケージを生成します。

start.go
func QueryUser(ctx context.Context, client *ent.Client) (*ent.User, error) {
	u, err := client.User.
		Query().
		Where(user.Name("a8m")).
		// `Only` fails if no user found,
		// or more than 1 user returned.
		Only(ctx)
	if err != nil {
		return nil, fmt.Errorf("failed querying user: %w", err)
	}
	log.Println("user returned: ", u)
	return u, nil
}

func main() {
	client, err := ent.Open("postgres", "host=localhost port=5432 user=user dbname=db password=pass")
	if err != nil {
		log.Fatalf("failed opening connection to postgres: %v", err)
	}
	defer client.Close()
	ctx := context.Background()
	QueryUser(ctx, client)
}

↓実行結果

$ go run start.go
2023/06/02 02:24:31 user returned:  User(id=1, age=30, name=a8m)

リレーションを追加する

下記コマンドを実行し、いくつかのフィールドを持つCarGroupという名前の2つの追加エンティティを作成します。

bash
go run -mod=mod entgo.io/ent/cmd/ent new Car Group

そして、残りのフィールドを手動で追加します

schema/car.go
// Fields of the Car.
func (Car) Fields() []ent.Field {
    return []ent.Field{
        field.String("model"),
        field.Time("registered_at"),
    }
}
schema/car.go
// Fields of the Group.
func (Group) Fields() []ent.Field {
    return []ent.Field{
        field.String("name").
            // Regexp validation for group name.
            Match(regexp.MustCompile("[a-zA-Z_]+$")),
    }
}

ユーザーから車へのエッジは、ユーザーは1台以上の車を持つことができるが、車のオーナーは1人だけであることを定義する(一対多の関係)。

Userスキーマに cars エッジを追加し、go generate ./entを実行しましょう:

schema/user.go
// Edges of the User.
func (User) Edges() []ent.Edge {
    return []ent.Edge{
        edge.To("cars", Car.Type),
    }
}

2台の車を作成し、ユーザーに追加します。

start.go
func CreateCars(ctx context.Context, client *ent.Client) (*ent.User, error) {
    // Create a new car with model "Tesla".
    tesla, err := client.Car.
        Create().
        SetModel("Tesla").
        SetRegisteredAt(time.Now()).
        Save(ctx)
    if err != nil {
        return nil, fmt.Errorf("failed creating car: %w", err)
    }
    log.Println("car was created: ", tesla)

    // Create a new car with model "Ford".
    ford, err := client.Car.
        Create().
        SetModel("Ford").
        SetRegisteredAt(time.Now()).
        Save(ctx)
    if err != nil {
        return nil, fmt.Errorf("failed creating car: %w", err)
    }
    log.Println("car was created: ", ford)

    // Create a new user, and add it the 2 cars.
    a8m, err := client.User.
        Create().
        SetAge(30).
        SetName("a8m").
        AddCars(tesla, ford).
        Save(ctx)
    if err != nil {
        return nil, fmt.Errorf("failed creating user: %w", err)
    }
    log.Println("user was created: ", a8m)
    return a8m, nil
}

func main() {
	client, err := ent.Open("postgres", "host=localhost port=5432 user=user dbname=db password=pass")
	if err != nil {
		log.Fatalf("failed opening connection to postgres: %v", err)
	}
	defer client.Close()
	ctx := context.Background()
	// Run the auto migration tool.
	if err := client.Schema.Create(ctx); err != nil {
		log.Fatalf("failed creating schema resources: %v", err)
	}
	CreateCars(ctx, client)
}

carsテーブルとusersテーブルにデータが追加されます。

postgres
db=# select * from cars;
 id | model |         registered_at         | user_cars 
----+-------+-------------------------------+-----------
  1 | Tesla | 2023-06-02 04:35:56.283204+00 |         2
  2 | Ford  | 2023-06-02 04:35:56.315002+00 |         2
(2 rows)

db=# select * from users;
 id | age | name 
----+-----+------
  1 |  30 | a8m
  2 |  30 | a8m
(2 rows)

車のエッジをクエリします。

start.go
func QueryUser(ctx context.Context, client *ent.Client) (*ent.User, error) {
	u, err := client.User.
		Query().
		Where(user.ID(2)).
		// `Only` fails if no user found,
		// or more than 1 user returned.
		Only(ctx)
	if err != nil {
		return nil, fmt.Errorf("failed querying user: %w", err)
	}
	log.Println("user returned: ", u)
	return u, nil
}

func QueryCars(ctx context.Context, a8m *ent.User) error {
	cars, err := a8m.QueryCars().All(ctx)
	if err != nil {
		return fmt.Errorf("failed querying user cars: %w", err)
	}
	log.Println("returned cars:", cars)

	// What about filtering specific cars.
	ford, err := a8m.QueryCars().
		Where(car.Model("Ford")).
		Only(ctx)
	if err != nil {
		return fmt.Errorf("failed querying user cars: %w", err)
	}
	log.Println(ford)
	return nil
}

func main() {
	client, err := ent.Open("postgres", "host=localhost port=5432 user=user dbname=db password=pass")
	if err != nil {
		log.Fatalf("failed opening connection to postgres: %v", err)
	}
	defer client.Close()
	ctx := context.Background()
	// Run the auto migration tool.
	if err := client.Schema.Create(ctx); err != nil {
		log.Fatalf("failed creating schema resources: %v", err)
	}
	u, err := QueryUser(ctx, client)
	if err != nil {
		log.Fatalf("failed creating schema resources: %v", err)
	}
	QueryCars(ctx, u)
}

↓実行結果

bash
$ go run start.go
2023/06/02 05:09:15 user returned:  User(id=2, age=30, name=a8m)
2023/06/02 05:09:15 returned cars: [Car(id=1, model=Tesla, registered_at=Fri Jun  2 04:35:56 2023) Car(id=2, model=Ford, registered_at=Fri Jun  2 04:35:56 2023)]
2023/06/02 05:09:15 Car(id=2, model=Ford, registered_at=Fri Jun  2 04:35:56 2023)

逆向きのエッジを追加する

例えば、Carオブジェクトがあり、その所有者、つまりこの車が属するユーザーを取得したいとします。この場合、edge.From関数で定義される「逆縁」という別のタイプの縁があります。

上の図で新しく作られたエッジは半透明になっていますが、これはデータベースに別のエッジを作らないということを強調するためです。これは、実際のエッジ(関係)への逆参照に過ぎません。

Carスキーマにownerという名前の逆エッジを追加し、Userスキーマのcarsエッジに参照させ、go generate ./entを実行してみまします。

schema/car.go
// Edges of the Car.
func (Car) Edges() []ent.Edge {
    return []ent.Edge{
        // Create an inverse-edge called "owner" of type `User`
        // and reference it to the "cars" edge (in User schema)
        // explicitly using the `Ref` method.
        edge.From("owner", User.Type).
            Ref("cars").
            // setting the edge to unique, ensure
            // that a car can have only one owner.
            Unique(),
    }
}

上記のuser/carsの例に引き続き、逆エッジを問い合わせることにします。

start.go
func QueryUser(ctx context.Context, client *ent.Client) (*ent.User, error) {
	u, err := client.User.
		Query().
		Where(user.ID(2)).
		// `Only` fails if no user found,
		// or more than 1 user returned.
		Only(ctx)
	if err != nil {
		return nil, fmt.Errorf("failed querying user: %w", err)
	}
	log.Println("user returned: ", u)
	return u, nil
}

func QueryCarUsers(ctx context.Context, a8m *ent.User) error {
	cars, err := a8m.QueryCars().All(ctx)
	if err != nil {
		return fmt.Errorf("failed querying user cars: %w", err)
	}
	// Query the inverse edge.
	for _, c := range cars {
		owner, err := c.QueryOwner().Only(ctx)
		if err != nil {
			return fmt.Errorf("failed querying car %q owner: %w", c.Model, err)
		}
		log.Printf("car %q owner: %q\n", c.Model, owner.Name)
	}
	return nil
}

func main() {
	client, err := ent.Open("postgres", "host=localhost port=5432 user=user dbname=db password=pass")
	if err != nil {
		log.Fatalf("failed opening connection to postgres: %v", err)
	}
	defer client.Close()
	ctx := context.Background()
	// Run the auto migration tool.
	if err := client.Schema.Create(ctx); err != nil {
		log.Fatalf("failed creating schema resources: %v", err)
	}
	u, err := QueryUser(ctx, client)
	if err != nil {
		log.Fatalf("failed creating schema resources: %v", err)
	}
	QueryCarUsers(ctx, u)
}
bash
$ go run start.go
2023/06/02 05:56:16 user returned:  User(id=2, age=30, name=a8m)
2023/06/02 05:56:16 car "Tesla" owner: "a8m"
2023/06/02 05:56:16 car "Ford" owner: "a8m"

生成されたSQLスキーマを見る

Entがデータベース用に生成したSQLスキーマを表示するには、Atlasをインストールし、次のコマンドを実行します

Atlasは、ソフトウェアエンジニア、DBA、DevOps実務者がデータベーススキーマを管理するために設計されたオープンソースのツールです。Atlasのユーザーは、Atlas DDL(データ定義言語)またはSQLを使用して目的のデータベーススキーマを記述し、コマンドラインツールを使用してシステムへの移行を計画および適用できます。

Dockerで実行します。

bash
docker pull arigaio/atlas
docker run --rm arigaio/atlas --help

コンテナがホストネットワークまたはローカルディレクトリにアクセスする必要がある場合、--net=hostフラグを使用し、目的のディレクトリをマウントします

bash
docker run --rm --net=host \
  -v "$(pwd)/migrations:/migrations" \
  arigaio/atlas migrate apply \
  --url "postgres://user:pass@:5432/db"

スキーマを調査する

bash
atlas schema inspect \
  -u "ent://ent/schema" \
  --dev-url "sqlite://file?mode=memory&_fk=1" \
  --format '{{ sql . "  " }}'

↓実行結果

bash
-- Create "cars" table
CREATE TABLE `cars` (
  `id` integer NOT NULL PRIMARY KEY AUTOINCREMENT,
  `model` text NOT NULL,
  `registered_at` datetime NOT NULL,
  `user_cars` integer NULL,
  CONSTRAINT `cars_users_cars` FOREIGN KEY (`user_cars`) REFERENCES `users` (`id`) ON DELETE SET NULL
);
-- Create "groups" table
CREATE TABLE `groups` (
  `id` integer NOT NULL PRIMARY KEY AUTOINCREMENT,
  `name` text NOT NULL
);
-- Create "users" table
CREATE TABLE `users` (
  `id` integer NOT NULL PRIMARY KEY AUTOINCREMENT,
  `age` integer NOT NULL,
  `name` text NOT NULL DEFAULT 'unknown'
);

Discussion