🎎

GolangでORMを使うなら ent. がおすすめ!

2024/01/04に公開

1. はじめに

「Active Recordみたいな使用感のGolang製のORMが無いかなー?」と思って色々なORMを触っている中で、おすすめのORM「ent.」見つけたので備忘録+布教も兼ねて本記事を残そうと思います。

--- 2024/01/07 追記 ---
本記事で割愛していたVisualize the Schemaに関する記事を投稿しました。
スキーマの可視化に興味のある方は覗いてみてください。

2. ent.って何?

Metaが開発しているGolang製のORMです。
近年、githubのスターも伸びており、公式のドキュメントも充実しているので安心感があります。
また、RailsやDjangoでの開発経験がある方には、以下の特徴があるため非常に馴染みやすいと思っています。

  • 定義したschemaからテーブルが作れる
  • Atlasを利用することで、マイグレーションのバージョンを管理できる
  • CRUD APIによる操作が可能

3. 開発環境

以下に、筆者の開発環境と成果物のリポジトリを記載します。

  • golang 1.21.4
  • docker 4.16.2
  • postgresql 16.1
  • ent. v0.12.5

リポジトリはこちらになります。
https://github.com/KaiTakabe0301/entdemo

4. ent.のQuick Introduction

公式にQuick Introductionがありますが、日本語訳が無く、分かりにくい箇所もあるので、以下にテーブル定義等と合わせて使用方法を記載します。

4-1 開発環境の準備

Quick Introductionを実行するためのディレクトリenddemogo.modを作成します。

mkdir entdemo
cd entdemo
go mod init entdemo

4-2 データベースの準備

スキーマからテーブルを作成するためのDBを準備します。

4-2-1. docker-composeの作成

DBはdocker-composeで準備するので、以下のようにdocker-compose.ymlを作成します。

touch docker-compose.yml
docker-compose.yml
version: "3.8"

services:
  postgres:
    image: postgres:16.1-alpine
    container_name: postgres
    environment:
      POSTGRES_DB: entdemo
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: password
    ports:
      - "5432:5432"
    volumes:
      - ./data/db_data/volume:/var/lib/postgresql/data

準備ができたら、コンテナを起動させます。

docker-compose up -d

4-3. はじめてのスキーマ作成

4-3-1. 雛形の作成

entdemoのrootディレクトリで、以下のコマンドを実行することで、スキーマの雛形が作成できます。

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

この時、ent/が作成されent/schema/の中に、以下の内容でuser.goというファイルが生成されます。

ent/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
}

4-3-2. フィールドの追加

では、Useragenameというフィールドを以下のように追加しましょう。

ent/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"),
	}
}

4-3-3. アセットの作成

以下のコマンドで、スキーマからアセットの作成を行います。

go generate ./ent

このコマンドを実行することで、以下のアセットがent/に自動生成されます。

ent
├── client.go
├── ent.go
├── enttest
│   └── enttest.go
├── hook
│   └── hook.go
├── migrate
│   ├── migrate.go
│   └── schema.go
├── mutation.go
├── predicate
│   └── predicate.go
├── runtime
│   └── runtime.go
├── runtime.go
├── tx.go
├── user
│   ├── user.go
│   └── where.go
├── user.go
├── user_create.go
├── user_delete.go
├── user_query.go
└── user_update.go

作成されたアセットは、以下の用途で利用されます。

  • tx.goclinet.go
    • Client と Tx オブジェクトはグラフとのやり取りに使用されます。
  • user_create.gouser_delete.gouser_query.gouser_update.go
    • 各スキーマ型の CRUD ビルダー。詳細はCRUDを参照して下さい。
  • user/user.gouser/where.gouser.go
    • 各スキーマタイプのエンティティオブジェクト (Go struct)
  • predicate/predicate.go
    • ビルダーとの相互作用に使用される定数と述語を含むパッケージ。
  • migrate/migrate.gomigrate/schema.go
    • SQLの migrate パッケージ。 詳細はMigrationを参照してください。
  • hook/hook.go
    • mutationミドルウェアを追加するためのhook パッケージ。 詳細はHooksを参照してください。
  • enttest/enttest.go
    • ユニットテストで利用できるパッケージ。詳細は Testing を参照して下さい。
  • mutation.go
    • スキーマへのCRUD操作が記載されている。

あとは、自動生成されたパッケージに必要なモジュールを取得するために、以下のコマンドを実行して準備は完了です!

go mod tidy

4-4. 初めてのエンティティ作成

4-4-1. マイグレーション

それでは、Clientを作成してマイグレーションを行いましょう。
entdemoのルートディレクトリにstart.goを作成して、以下の内容を記述して下さい。

entdemo/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)
    }
}

作成したら、以下のコマンドを実行して不足しているモジュールを取得して下さい。

go mod tidy

最後に、以下のコマンドでstart.goを実行してマイグレーションを完了させましょう。

go run start.go

4-4-2. 作成されたテーブルの確認

では、実際にマイグレーションが完了しているか確認しましょう。
今回はコンテナ上にpostgresqlを立ち上げているので、以下のように確認することができます。

# postgresqlに接続
docker exec -it postgres psql -U postgres entdemo
psql (16.1)
Type "help" for help.

# テーブル一覧
entdemo=# \dt;
         List of relations
 Schema | Name  | Type  |  Owner
--------+-------+-------+----------
 public | users | table | postgres
(1 row)

# レコードの確認
entdemo=# select * from users;
 id | age | name
----+-----+------
(0 rows)

# テーブル構造の確認
entdemo=# \d users;
                                 Table "public.users"
 Column |       Type        | Collation | Nullable |             Default
--------+-------------------+-----------+----------+----------------------------------
 id     | bigint            |           | not null | generated by default as identity
 age    | bigint            |           | not null |
 name   | character varying |           | not null | 'unknown'::character varying
Indexes:
    "users_pkey" PRIMARY KEY, btree (id)

4-4-3. Userエンティティの作成

マイグレーションが完了したら、実際にUserエンティティを作成してみましょう。
以下のCreateUserを、start.goで実行することでUserエンティティが作成されます。

entdemo/start.go
package main

import (
    "context"
    "log"

    "entdemo/ent"

    _ "github.com/lib/pq"
)

func main() {
	client, err := ent.Open("postgres", "host=localhost port=5432 user=postgres dbname=entdemo password=password sslmode=disable")
    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)
    }
    
    CreateUser(context.Background(), client)
}

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
}

go run start.goをターミナルで実行して、以下のメッセージが表示されればOKです。

2024/01/02 16:35:10 user was created:  User(id=1, age=30, name=a8m)

初回なので、念のためにレコードも確認してみましょう。
ターミナルで、以下のようにusersテーブルのレコードを検索してみると、しっかりとレコードが登録されていることが確認できます。

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

4-5. エンティティの問い合わせ

entは、各エンティティのスキーマに対して、SQLの述語、デフォルト値、バリデーション、ストレージ要素に対する追加情報を自動生成してくれます。
それらを利用して、以下のような処理を記述することで、エンティティの問い合わせが可能です。

entdemo/start.go
package main

import (
	"context"
	"fmt"
	"log"

	"entdemo/ent"
	"entdemo/ent/user"

	_ "github.com/lib/pq"
)

func main() {
	client, err := ent.Open("postgres", "host=localhost port=5432 user=postgres dbname=entdemo password=password sslmode=disable")
    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)
    }

    QueryUser(context.Background(), client)
}

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
}

go run start.goをターミナルで実行して、以下のメッセージが表示されればOKです。

2024/01/02 16:45:13 user returned:  User(id=1, age=30, name=a8m)

4-6. Edge(リレーション)の追加

entでは、Edgeと呼ばれる機能を利用してリレーションシップを設定することができます。
ここでは、CarエンティティとGroupエンティティを作成して、Userエンティティを参照するEdgeを設定してみましょう。

4-6-1. CarエンティティとGroupエンティティの作成

以下のコマンドを実行して、エンティティを追加します。

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

追加したら、以下のようにFieldを定義して下さい。

entdemo/ent/schema/car.go
// Fields of the Car.
func (Car) Fields() []ent.Field {
	return []ent.Field{
		field.String(("model")),
		field.Time("registered_at"),
	}
}
entdemo/ent/schema/group.go
// Fields of the Car.
// Fields of the Group.
func (Group) Fields() []ent.Field {
	return []ent.Field{
		field.String("name").
		// 正規表現を使って、nameの値を制限する
		Match(regexp.MustCompile("[a-zA-Z_]+$")),
	}
}

4-6-2. UserエンティティからCarエンティティへのEdgeを追加

フィールドの定義ができたら、Edgeを設定してみましょう!
ここでは、ユーザは複数台の車を所有することができ、車にはただ一人のオーナが存在する設定でリレーションを設定します。
要するに、以下のような1対多のリレーションシップです。

Edgeを設定する場合は、以下のようにUserスキーマのEdge()関数に記述をします。

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

次にgo generate ./entを実行して、関連するファイルを生成して下さい。
あとは、go run start.goを実行してマイグレーションを行うだけです。

4-6-2-1. テーブル定義におけるEdgeの確認

念のために以下のコマンドを実行して、作成されたテーブルとテーブル定義も確認しておきましょう。
まずは、テーブルからです。
ちゃんと、carsgroupsの名前でテーブルが作成されています。

entdemo=# \dt;
         List of relations
 Schema |  Name  | Type  |  Owner
--------+--------+-------+----------
 public | cars   | table | postgres
 public | groups | table | postgres
 public | users  | table | postgres
(3 rows)

次に、テーブル定義を確認しましょう。
usersテーブルでは、末尾にReferenced by: ~が追加されており、carsテーブルから外部キー制約が設定されていることがわかります。

entdemo=# \d users;
                                 Table "public.users"
 Column |       Type        | Collation | Nullable |             Default
--------+-------------------+-----------+----------+----------------------------------
 id     | bigint            |           | not null | generated by default as identity
 age    | bigint            |           | not null |
 name   | character varying |           | not null | 'unknown'::character varying
Indexes:
    "users_pkey" PRIMARY KEY, btree (id)
Referenced by:
    TABLE "cars" CONSTRAINT "cars_users_cars" FOREIGN KEY (user_cars) REFERENCES users(id) ON DELETE SET NULL

carsテーブルの定義は以下のように設定されており、usersへの外部キー制約を確認できます。

entdemo=# \d cars;
                                        Table "public.cars"
    Column     |           Type           | Collation | Nullable |             Default
---------------+--------------------------+-----------+----------+----------------------------------
 id            | bigint                   |           | not null | generated by default as identity
 model         | character varying        |           | not null |
 registered_at | timestamp with time zone |           | not null |
 user_cars     | bigint                   |           |          |
Indexes:
    "cars_pkey" PRIMARY KEY, btree (id)
Foreign-key constraints:
    "cars_users_cars" FOREIGN KEY (user_cars) REFERENCES users(id) ON DELETE SET NULL

4-6-3. UserエンティティへCarエンティティを登録する

これで、やっとUserエンティティに対してCarエンティティを登録することができます。
以下のようにstart.goを記述して、go run start.goを実行して下さい。

entdemo/start.go
package main

import (
	"context"
	"fmt"
	"log"
	"time"

	"entdemo/ent"

	_ "github.com/lib/pq"
)

func main() {
	client, err := ent.Open("postgres", "host=localhost port=5432 user=postgres dbname=entdemo password=password sslmode=disable")
    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)
    }

    CreateCars(context.Background(), client)
}


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
}

正常に登録ができると、以下のように出力されます。

2024/01/02 18:08:17 car was created:  Car(id=1, model=Tesla, registered_at=Tue Jan  2 18:08:16 2024)
2024/01/02 18:08:17 car was created:  Car(id=2, model=Ford, registered_at=Tue Jan  2 18:08:17 2024)
2024/01/02 18:08:17 user was created:  User(id=2, age=30, name=a8m)

また、userscarsのレコードは以下のように登録されています。

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

entdemo=# select * from cars;
 id | model |         registered_at         | user_cars
----+-------+-------------------------------+-----------
  1 | Tesla | 2024-01-02 09:08:16.99925+00  |         2
  2 | Ford  | 2024-01-02 09:08:17.010137+00 |         2
(2 rows)

4-6-4. UserエンティティからCarエンティティを参照する

もちろん、Userエンティティから、それに紐づいているCarエンティティを参照することが可能です。
参照方法は非常に簡単で、以下のようにUserエンティティから述語に関連するメソッドを実行するだけです。

entdemo/start.go
package main

import (
	"context"
	"fmt"
	"log"

	"entdemo/ent"
	"entdemo/ent/car"
	"entdemo/ent/user"

	_ "github.com/lib/pq"
)

func main() {
    client, err := ent.Open("postgres", "host=localhost port=5432 user=postgres dbname=entdemo password=password sslmode=disable")
    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)
    }

    a8m, err := client.User.Query().Where(user.NameEQ("a8m")).All(context.Background())
    if err != nil {
        log.Fatalf("failed querying user: %v", err)
    }

    // 初回に追加したユーザ
    QueryCars(context.Background(), a8m[0])
    // CreateCarsで追加されたユーザ
    QueryCars(context.Background(), a8m[1])
}


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
}

正常に検索が完了すると、以下のように出力されます。

2024/01/02 18:40:22 returned cars: []
2024/01/02 18:40:22 returned cars: [Car(id=1, model=Tesla, registered_at=Tue Jan  2 09:08:16 2024) Car(id=2, model=Ford, registered_at=Tue Jan  2 09:08:17 2024)]
2024/01/02 18:40:22 Car(id=2, model=Ford, registered_at=Tue Jan  2 09:08:17 2024)

4-7. CarエンティティからUserエンティティを参照する

Carエンティティの所有者を参照したい場合は多々あるかと思いますが、今のままのスキーマ定義ではそれを実現することは叶いません。
それを行うにはedge.From関数を用いてinverse edgeと呼ばれるEdgeを設定する必要があります(下図参照)

上図で新たに作成されたownerのエッジは色が薄くなっているのは、「データベースに別のエッジを作成しない」ことを強調するためです。これは実際のエッジ(リレーション)への後方参照に過ぎません。

では、以下のようにCarスキーマにownerという名前でinverse edgeを設定し、go generate ./entを実行しましょう。

entdemo/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(),
    }
}

あとは、以下のようにCarエンティティからUserエンティティを参照する関数を実装するだけです。

entdemo/start.go
package main

import (
	"context"
	"fmt"
	"log"

	"entdemo/ent"
	"entdemo/ent/user"

	_ "github.com/lib/pq"
)

func main() {
	client, err := ent.Open("postgres", "host=localhost port=5432 user=postgres dbname=entdemo password=password sslmode=disable")
    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)
    }

    a8m, err := client.User.Query().Where(user.NameEQ("a8m")).All(context.Background())
    if err != nil {
        log.Fatalf("failed querying user: %v", err)
    }

    QueryCarUsers(context.Background(), a8m[1])
}


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
}

正常に動作すると、以下のように出力されます。

2024/01/02 19:34:25 car "Tesla" owner: "a8m"
2024/01/02 19:34:25 car "Ford" owner: "a8m"

念のために、テーブル定義も確認しておきましょう。
以下のように、テーブル定義を確認しても新たに外部キーなどが設定されていないことが確認できます。

entdemo=# \d users;
                                 Table "public.users"
 Column |       Type        | Collation | Nullable |             Default
--------+-------------------+-----------+----------+----------------------------------
 id     | bigint            |           | not null | generated by default as identity
 age    | bigint            |           | not null |
 name   | character varying |           | not null | 'unknown'::character varying
Indexes:
    "users_pkey" PRIMARY KEY, btree (id)
Referenced by:
    TABLE "cars" CONSTRAINT "cars_users_cars" FOREIGN KEY (user_cars) REFERENCES users(id) ON DELETE SET NULL

entdemo=# \d cars;
                                        Table "public.cars"
    Column     |           Type           | Collation | Nullable |             Default
---------------+--------------------------+-----------+----------+----------------------------------
 id            | bigint                   |           | not null | generated by default as identity
 model         | character varying        |           | not null |
 registered_at | timestamp with time zone |           | not null |
 user_cars     | bigint                   |           |          |
Indexes:
    "cars_pkey" PRIMARY KEY, btree (id)
Foreign-key constraints:
    "cars_users_cars" FOREIGN KEY (user_cars) REFERENCES users(id) ON DELETE SET NULL

4-8. 多対多のEdgeを追加する

ここでは、複数のユーザが、複数のグループに属している関係を表現するために、多対多のEdgeを追加していきます。

上図のような関係を構築するには、下記のコードのようにGroupスキーマからは通常のEdgeを設定し、Userスキーマからはinverse edgeを設定します。そうすることで多対多を表現可能です。

entdemo/ent/schema/group.go
// Edges of the Group.
func (Group) Edges() []ent.Edge {
   return []ent.Edge{
       edge.To("users", User.Type),
   }
}
entdemo/ent/schema/user.go
// Edges of the User.
func (User) Edges() []ent.Edge {
   return []ent.Edge{
       edge.To("cars", Car.Type),
       // Create an inverse-edge called "groups" of type `Group`
       // and reference it to the "users" edge (in Group schema)
       // explicitly using the `Ref` method.
       edge.From("groups", Group.Type).
           Ref("users"),
   }
}

あとは、いつも通りにスキーマを変更したらgo generate ./entを実行して、対応するコードを自動生成して下さい。

4-8-1. 多対多の中間テーブルを確認

先ほどUserGroupに多対多のリレーションを設定しましたが、当然中間テーブルが構築されているはずです。
念のために、以下のようにターミナル上で確認してみましょう。

entdemo=# \dt;
            List of relations
 Schema |    Name     | Type  |  Owner
--------+-------------+-------+----------
 public | cars        | table | postgres
 public | group_users | table | postgres
 public | groups      | table | postgres
 public | users       | table | postgres
(4 rows)

entdemo=# \d group_users;
             Table "public.group_users"
  Column  |  Type  | Collation | Nullable | Default
----------+--------+-----------+----------+---------
 group_id | bigint |           | not null |
 user_id  | bigint |           | not null |
Indexes:
    "group_users_pkey" PRIMARY KEY, btree (group_id, user_id)
Foreign-key constraints:
    "group_users_group_id" FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
    "group_users_user_id" FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE

中間テーブルgroup_usersが生成されており、テーブル定義もusersgroupsへの外部キーとなるgroup_iduser_idが定義されていることが確認できました。

また、groupsusersのテーブル定義も以下のように、group_usersテーブルへの参照が加えられています。

entdemo=# \d groups;
                                Table "public.groups"
 Column |       Type        | Collation | Nullable |             Default
--------+-------------------+-----------+----------+----------------------------------
 id     | bigint            |           | not null | generated by default as identity
 name   | character varying |           | not null |
Indexes:
    "groups_pkey" PRIMARY KEY, btree (id)
Referenced by:
    TABLE "group_users" CONSTRAINT "group_users_group_id" FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE

entdemo=# \d users;
                                 Table "public.users"
 Column |       Type        | Collation | Nullable |             Default
--------+-------------------+-----------+----------+----------------------------------
 id     | bigint            |           | not null | generated by default as identity
 age    | bigint            |           | not null |
 name   | character varying |           | not null | 'unknown'::character varying
Indexes:
    "users_pkey" PRIMARY KEY, btree (id)
Referenced by:
    TABLE "cars" CONSTRAINT "cars_users_cars" FOREIGN KEY (user_cars) REFERENCES users(id) ON DELETE SET NULL
    TABLE "group_users" CONSTRAINT "group_users_user_id" FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE

4-9. グラフ構造の探索

最後に、下図のような関係性を持ったグラフを作成し、それらに対してクエリを実行してみましょう。

4-9-1. データの作成

まずは、以下のコードを実行して、上図と同じ関係性を持ったグラフを作成します。

entdemo/start.go
package main

import (
	"context"
	"log"
	"time"

	"entdemo/ent"

	_ "github.com/lib/pq"
)

func main() {
	client, err := ent.Open("postgres", "host=localhost port=5432 user=postgres dbname=entdemo password=password sslmode=disable")
    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)
    }

    if err := CreateGraph(context.Background(), client); err != nil {
        log.Fatalf("failed creating graph: %v", err)
    }
}


func CreateGraph(ctx context.Context, client *ent.Client) error {
    // First, create the users.
    a8m, err := client.User.
        Create().
        SetAge(30).
        SetName("Ariel").
        Save(ctx)
    if err != nil {
        return err
    }
    neta, err := client.User.
        Create().
        SetAge(28).
        SetName("Neta").
        Save(ctx)
    if err != nil {
        return err
    }
    // Then, create the cars, and attach them to the users created above.
    err = client.Car.
        Create().
        SetModel("Tesla").
        SetRegisteredAt(time.Now()).
        // Attach this car to Ariel.
        SetOwner(a8m).
        Exec(ctx)
    if err != nil {
        return err
    }
    err = client.Car.
        Create().
        SetModel("Mazda").
        SetRegisteredAt(time.Now()).
        // Attach this car to Ariel.
        SetOwner(a8m).
        Exec(ctx)
    if err != nil {
        return err
    }
    err = client.Car.
        Create().
        SetModel("Ford").
        SetRegisteredAt(time.Now()).
        // Attach this car to Neta.
        SetOwner(neta).
        Exec(ctx)
    if err != nil {
        return err
    }
    // Create the groups, and add their users in the creation.
    err = client.Group.
        Create().
        SetName("GitLab").
        AddUsers(neta, a8m).
        Exec(ctx)
    if err != nil {
        return err
    }
    err = client.Group.
        Create().
        SetName("GitHub").
        AddUsers(a8m).
        Exec(ctx)
    if err != nil {
        return err
    }
    log.Println("The graph was created successfully")
    return nil
}

4-9-1. Githubに属するユーザの所有車を検索する

以下のように、WhereメソッドからGroupによる絞り込みが行えます。

entdemo/start.go
package main

import (
	"context"
	"fmt"
	"log"

	"entdemo/ent"
	"entdemo/ent/group"

	_ "github.com/lib/pq"
)

func main() {
	client, err := ent.Open("postgres", "host=localhost port=5432 user=postgres dbname=entdemo password=password sslmode=disable")
    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)
    }

    QueryGithub(context.Background(), client)

}

func QueryGithub(ctx context.Context, client *ent.Client) error {
    cars, err := client.Group.
        Query().
        Where(group.Name("GitHub")). // (Group(Name=GitHub),)
        QueryUsers().                // (User(Name=Ariel, Age=30),)
        QueryCars().                 // (Car(Model=Tesla, RegisteredAt=<Time>), Car(Model=Mazda, RegisteredAt=<Time>),)
        All(ctx)
    if err != nil {
        return fmt.Errorf("failed getting cars: %w", err)
    }
    log.Println("cars returned:", cars)
    // Output: (Car(Model=Tesla, RegisteredAt=<Time>), Car(Model=Mazda, RegisteredAt=<Time>),)
    return nil
}

上記のコードが正常に動作すると、以下のように出力されます。

2024/01/04 19:31:42 cars returned: [Car(id=6, model=Tesla, registered_at=Thu Jan  4 10:27:21 2024) Car(id=7, model=Mazda, registered_at=Thu Jan  4 10:27:21 2024)]

4-9-2. 検索の始点をArielにした場合

4-9-1項のコードを少し修正して、以下のようにしてCarを検索することも可能です。

entdemo/start.go
package main

import (
	"context"
	"fmt"
	"log"

	"entdemo/ent"
	"entdemo/ent/car"
	"entdemo/ent/user"

	_ "github.com/lib/pq"
)

func main() {
	client, err := ent.Open("postgres", "host=localhost port=5432 user=postgres dbname=entdemo password=password sslmode=disable")
    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)
    }

    QueryArielCars(context.Background(), client)

}

func QueryArielCars(ctx context.Context, client *ent.Client) error {
    // Get "Ariel" from previous steps.
    a8m := client.User.
        Query().
        Where(
            user.HasCars(),
            user.Name("Ariel"),
        ).
        OnlyX(ctx)
    cars, err := a8m.                       // Get the groups, that a8m is connected to:
            QueryGroups().                  // (Group(Name=GitHub), Group(Name=GitLab),)
            QueryUsers().                   // (User(Name=Ariel, Age=30), User(Name=Neta, Age=28),)
            QueryCars().                    // (Car(Model=Tesla, RegisteredAt=<Time>), Car(Model=Mazda, RegisteredAt=<Time>),)
            Where(                          //
                car.Not(                    //  Get Neta and Ariel cars, but filter out
                    car.Model("Mazda"),     //  those who named "Mazda"
                ),                          //
            ).                              //
            All(ctx)
    if err != nil {
        return fmt.Errorf("failed getting cars: %w", err)
    }
    log.Println("cars returned:", cars)
    // Output: (Car(Model=Tesla, RegisteredAt=<Time>), Car(Model=Ford, RegisteredAt=<Time>),)
    return nil
}

上記のコードの実行結果は、以下の通りです。

2024/01/04 19:46:29 cars returned: [Car(id=6, model=Tesla, registered_at=Thu Jan  4 10:27:21 2024) Car(id=8, model=Ford, registered_at=Thu Jan  4 10:27:21 2024)]

4-9-3. ユーザが1人以上所属しているグループの検索

最後に、ユーザが1人以上所属しているグループを検索してみましょう。
以下のコードを実行することで、簡単に検索することが可能です。

entdemo/start.go
package main

import (
	"context"
	"fmt"
	"log"

	"entdemo/ent"
	"entdemo/ent/group"

	_ "github.com/lib/pq"
)

func main() {
	client, err := ent.Open("postgres", "host=localhost port=5432 user=postgres dbname=entdemo password=password sslmode=disable")
    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)
    }

    QueryGroupWithUsers(context.Background(), client)

}

func QueryGroupWithUsers(ctx context.Context, client *ent.Client) error {
    groups, err := client.Group.
        Query().
        Where(group.HasUsers()).
        All(ctx)
    if err != nil {
        return fmt.Errorf("failed getting groups: %w", err)
    }
    log.Println("groups returned:", groups)
    // Output: (Group(Name=GitHub), Group(Name=GitLab),)
    return nil
}

上記のコードの実行結果は、以下の通りです。

2024/01/04 19:57:54 groups returned: [Group(id=3, name=GitLab) Group(id=4, name=GitHub)]

まとめ

Quick Introductionに気持ちばかりの加筆をした記事になってしまいましたが、私の周囲ではent.の知名度があまり高くないので、とりあえず日本語記事が増えればいいやぐらいの気持ちで書きました。
この記事では、Atlasを用いたVersioned MigrationsやVisualize the Schemaを省いてしまいましたが、別記事でこれらの使い方も書こうと思います。(個人的には、Visualize the Schemaはめちゃくちゃ便利だと思ってます)

Discussion