🌟

entでINNER JOINを行う

2024/01/17に公開

https://github.com/urakawa-jinsei/ent-join

entでのINNER JOINの実装方法がいまいち正確に載っていなかったので、頑張って調べてみました。

ER図

スキーマ定義

ent/schema/uploadedcontent.go
package schema

// Fields of the UploadedContent.

// Edges of the UploadedContent.
func (UploadedContent) Edges() []ent.Edge {
	return []ent.Edge{
		edge.To("contents", Content.Type),
	}
}

ent/schema/content.go
package schema

// Fields of the Content.

// Edges of the Content.
func (Content) Edges() []ent.Edge {
	return []ent.Edge{
		edge.From("uploaded_content", UploadedContent.Type).
			Field("uploaded_content_filename").
			Ref("contents").
			Unique().
			Required(),
		edge.To("content_movie_metadata", ContentMovieMetadata.Type).
			StorageKey(edge.Column("filename")).
			Unique(),
	}
}

ent/schema/contentmoviemetadata.go
package schema

// Fields of the ContentMovieMetadata.

// Edges of the ContentMovieMetadata.
func (ContentMovieMetadata) Edges() []ent.Edge {
	return []ent.Edge{
		edge.From("content", Content.Type).
			Ref("content_movie_metadata").
			Required().
			Unique(),
	}
}

スキーマを定義したら、以下のコマンドでコードを生成します。

$ go generate ./ent

INNER JOINを実行する

The sql/modifier option lets add custom SQL modifiers to the builders and mutate the statements before they are executed.
This option can be added to a project using the --feature sql/modifier flag.

"sql/modifier オプションを使用すると、ビルダーにカスタム SQL 修飾子を追加し、実行前にステートメントを変更することができます。
このオプションは --feature sql/modifier フラグを使用してプロジェクトに追加できます。"

よく分かりませんが、INNER JOINを実行するために必要な関数を使えるようにするために、以下のコマンドを実行します。

$ go run -mod=mod entgo.io/ent/cmd/ent generate --feature sql/modifier ./ent/schema
main.go
package main

import (
	"context"
	"database/sql"
	"log"

	"entgo.io/ent/dialect"
	entsql "entgo.io/ent/dialect/sql"
	"github.com/ent-join/ent"
	"github.com/ent-join/ent/content"
	"github.com/ent-join/ent/uploadedcontent"
	_ "github.com/jackc/pgx/v5/stdlib"
)

func main() {
	Query()
}

// Open new connection
func Open(databaseUrl string) *ent.Client {
	db, err := sql.Open("pgx", databaseUrl)
	if err != nil {
		log.Fatal(err)
	}

	// Create an ent.Driver from `db`.
	drv := entsql.OpenDB(dialect.Postgres, db)
	return ent.NewClient(ent.Driver(drv))
}

func Query() {
	client := Open("postgresql://postgres:postgres@127.0.0.1/postgres")
    defer client.Close()

	// migrate
	ctx := context.Background()
	if err := client.Schema.Create(ctx); err != nil {
		log.Fatal(err)
	}

	// INNER JOIN
	cnt, err := client.Debug().UploadedContent.Query().
		Where(uploadedcontent.ID("sample1.mp4")).
		Modify(func(s *entsql.Selector) {
			t := entsql.Table(uploadedcontent.ContentsTable)
			s.Join(t).
				On(
					s.C(uploadedcontent.FieldID),
					t.C(content.FieldUploadedContentFilename),
				)
		}).
		Count(ctx)
	if err != nil {
		log.Fatal(err)
	}
	log.Println(cnt)
}

以下のように、Debug()メソッドを使用することで、実行されたSQLをログに出力することができます。

client.Debug().UploadedContent.Query()
% go run main.go
2024/01/15 16:37:56 driver.Query: query=SELECT COUNT("uploaded_content"."filename") FROM "uploaded_content" JOIN "content" AS "t1" ON "uploaded_content"."filename" = "t1"."uploaded_content_filename" WHERE "uploaded_content"."filename" = $1 args=[sample1.mp4]
2024/01/15 16:37:56 3

ちなみに、以下のコードでも、同様の結果を得ることができます。

// サブクエリ
count, err := client.Debug().UploadedContent.Query().
		Where(uploadedcontent.ID("sample1.mp4")).
		QueryContents().
		Count(ctx)
	if err != nil {
		log.Fatal(err)
	}
	log.Println(count)

しかしこちらは、サブクエリを使ってJOINしていることがわかります。

% go run main.go
2024/01/15 22:06:54 driver.Query: query=SELECT COUNT(DISTINCT "content"."filename") FROM "content" JOIN (SELECT "uploaded_content"."filename" FROM "uploaded_content" WHERE "uploaded_content"."filename" = $1) AS "t1" ON "content"."uploaded_content_filename" = "t1"."filename" args=[sample1.mp4]
2024/01/15 22:06:54 3

ベンチマークテスト

せっかくなので、ベンチマークでスコアを比較してみましょう。

INNER JOIN

% go test -count 5 -benchmem -bench . 2>&1 | tee old.log
goos: darwin
goarch: arm64
pkg: github.com/ent-join
BenchmarkQuery-8              43          27041099 ns/op          126797 B/op       1928 allocs/op
BenchmarkQuery-8              44          26996943 ns/op          127110 B/op       1928 allocs/op
BenchmarkQuery-8              44          26523704 ns/op          126839 B/op       1927 allocs/op
BenchmarkQuery-8              45          26957560 ns/op          126908 B/op       1927 allocs/op
BenchmarkQuery-8              44          26818100 ns/op          126443 B/op       1926 allocs/op
PASS
ok      github.com/ent-join     6.879s

Sub Query

% go test -count 5 -benchmem -bench . 2>&1 | tee new.log
goos: darwin
goarch: arm64
pkg: github.com/ent-join
BenchmarkQuery-8              45          27307084 ns/op          130155 B/op       1966 allocs/op
BenchmarkQuery-8              45          27357605 ns/op          129911 B/op       1964 allocs/op
BenchmarkQuery-8              45          26745164 ns/op          129761 B/op       1965 allocs/op
BenchmarkQuery-8              42          26974969 ns/op          129763 B/op       1965 allocs/op
BenchmarkQuery-8              48          27060554 ns/op          129865 B/op       1965 allocs/op
PASS
ok      github.com/ent-join     7.040s

比較

2つのベンチマークをbefore/afterとして比較するためにbenchstatをインストールします。

$ go install golang.org/x/perf/cmd/benchstat@latest
% benchstat old.log new.log             
goos: darwin
goarch: arm64
pkg: github.com/ent-join
        │   old.log    │            new.log            │
        │    sec/op    │    sec/op     vs base         │
Query-8   26.96m ± ∞ ¹   27.06m ± ∞ ¹  ~ (p=0.222 n=5)
¹ need >= 6 samples for confidence interval at level 0.95

        │    old.log    │               new.log               │
        │     B/op      │     B/op       vs base              │
Query-8   123.9Ki ± ∞ ¹   126.8Ki ± ∞ ¹  +2.39% (p=0.008 n=5)
¹ need >= 6 samples for confidence interval at level 0.95

        │   old.log    │              new.log               │
        │  allocs/op   │  allocs/op    vs base              │
Query-8   1.927k ± ∞ ¹   1.965k ± ∞ ¹  +1.97% (p=0.008 n=5)
¹ need >= 6 samples for confidence interval at level 0.95

1opあたりの実行時間は+0.1ms、メモリアロケーションサイズが+2.39%、メモリアロケーション回数が+1.97%と、サブクエリを使用する方がほんの少しだけ大きいことがわかりました。
結果的には、どちらを使ってもあまり変わらないということですね。

3つのテーブルを結合する

最後に、3つのテーブルを結合したいと思います。

count, err := client.Debug().UploadedContent.Query().
		Where(uploadedcontent.ID("sample1.mp4")).
		Modify(func(s *entsql.Selector) {
			t := entsql.Table(uploadedcontent.ContentsTable)
			s.Join(t).
				On(
					s.C(uploadedcontent.FieldID),
					t.C(content.FieldUploadedContentFilename),
				)
			u := entsql.Table(content.ContentMovieMetadataTable)
			s.Join(u).
				On(
					t.C(content.FieldID),
					u.C(contentmoviemetadata.FieldID),
				)
		}).
		Count(ctx)
	if err != nil {
		log.Fatal(err)
	}
	log.Println(count)

tcontentテーブルを、ucontent_movie_metadataテーブルを参照しています。

% go run main.go
2024/01/16 14:19:03 driver.Query: query=SELECT COUNT("uploaded_content"."filename") FROM "uploaded_content" JOIN "content" AS "t1" ON "uploaded_content"."filename" = "t1"."uploaded_content_filename" JOIN "content_movie_metadata" AS "t2" ON "t1"."filename" = "t2"."filename" WHERE "uploaded_content"."filename" = $1 args=[sample1.mp4]
2024/01/16 14:19:03 3

参考資料

https://entgo.io/ja/docs/feature-flags/#modify-example-4

https://zenn.dev/ogataka50/articles/f169e51983df41

Discussion