【ent】複合主キーで `nil pointer evaluating interface {}.id` が発生した場合の対処法

# TL; DR

- 複合主キーを使用する場合、スキーマの `Edges` メソッドで関連テーブルの指定が必要

https://entgo.io/docs/schema-edges/#user-likes-example

# はじめに

entで複合主キーを持つテーブルのスキーマを作成した際、ソースコード自動生成時に表題のエラーが発生してしまいました。本記事ではエラーの解消法と原因について紹介します。

# エラーの再現

まずはスキーマを作成します。今回は以下のテーブルを考えます。

```sql
-- 講義受講者の成績
CREATE TABLE StudentCourses
(student_id integer NOT NULL.
 course_id integer NOT NULL,
 grade integer NOT NULL
PRIMARY KEY (student_id, course_id));
```

以下コマンドで空のスキーマが生成されます。

```bash
$ go run -mod=mod entgo.io/ent/cmd/ent new StudentCourse
```

フィールド情報を追記すると以下のようになります。

```go
package schema

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

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

func (StudentCourse) Annotations() []schema.Annotation {
	return []schema.Annotation{
		// 複合主キーを指定（デフォルトでは `id` というフィールドが主キーとなる）
		field.ID("student_id", "course_id"),
	}
}

// Fields of the StudentCourse.
func (StudentCourse) Fields() []ent.Field {
	return []ent.Field{
		field.Int("student_id"),
		field.Int("course_id"),
		field.Int("grade"),
	}
}

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

続いてスキーマからソースコードを自動生成しようとすると、表題の `<$.Annotations.Fields.StructTag.id>: nil pointer evaluating interface {}.id` が発生し失敗してしまいます。

```bash
$ go generate ./ent
execute template "model": template: ent.tmpl:33:30: executing "model" at <$.Annotations.Fields.StructTag.id>: nil pointer evaluating interface {}.id
exit status 1
ent/generate.go:3: running "go": exit status 1
```

# 対処法

`Edges` メソッドに、各複合主キーに対応する関連テーブルを指定する（エッジスキーマ）必要があります。

例えば、`student_id` フィールドに `Students` テーブル、`course_id` フィールドに `Courses` テーブルがそれぞれ対応しているとします。

```sql:studentsテーブル
CREATE TABLE Students
(id integer NOT NULL,
 name varchar(255) NOT NULL
PRIMARY KEY (id));
```

```sql:coursesテーブル
CREATE TABLE Courses
(id integer NOT NULL,
 name varchar(255) NOT NULL
PRIMARY KEY (id));
```

`Student`, `Course` スキーマを作成し、`StudentCourse` スキーマから両者への関連を定義します。

```:関連のイメージ
| Students | <--- | StudentCourses | ---> | Courses |
```

スキーマに対して、それぞれの主キーを `Edges` メソッドで関連テーブルへ対応付けます。

```go
// Edges of the StudentCourse.
func (StudentCourse) Edges() []ent.Edge {
	return []ent.Edge{
		edge.To("student", Student.Type). // student_id は Student に対応
			Unique().
			Required().
			Field("student_id"),
		edge.To("course", Course.Type). // course_id は Course に対応
			Unique().
			Required().
			Field("course_id"),
	}
}
```

`Student`, `Course` のスキーマも必要になるので、こちらも生成します。
`Student` から `StudentCourse` を経由して `Course` を取得するような関連を定義します[^student]。

```bash
go run -mod=mod entgo.io/ent/cmd/ent new Student
go run -mod=mod entgo.io/ent/cmd/ent new Course
```

```go
// ...
// Fields of the Student.
func (Student) Fields() []ent.Field {
	return []ent.Field{
		field.Int("id"),
		field.String("name"),
	}
}

// Edges of the Student.
func (Student) Edges() []ent.Edge {
	// 関連を定義: 生徒が履修した講義一覧
	// NOTE: StudentCourseを経由すること！
	return []ent.Edge{
		edge.To("registered_courses", Course.Type).
			Through("student_courses", StudentCourse.Type),
	}
}
```

```go
// ...
// Fields of the Course.
func (Course) Fields() []ent.Field {
	return []ent.Field{
		field.Int("id"),
		field.String("name"),
	}
}

// Edges of the Course.
func (Course) Edges() []ent.Edge {
	// 逆向きの関連をこちらにも指定
	return []ent.Edge{
		edge.From("students", Student.Type).
			Ref("registered_courses").
			Through("student_courses", StudentCourse.Type),
	}
}
```

再びソースコードを自動生成すると、今度はエラーなく生成が成功しました。

```go
$ go generate ./ent
```

エッジスキーマについて詳細は公式ドキュメント以下のページをご覧ください。

https://entgo.io/docs/schema-edges/#user-likes-example

# なぜ関連テーブルが必要か？

entの設計思想として、スキーマが単一の`ID` （デフォルトでは `ID` フィールドに対応）を持つことを原則としているためです。

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

冒頭のエラーはソースコードを自動生成するGo templateの以下の行で発生します。

```:entc/gen/template/ent.tmpl
	{{- if $.HasOneFieldID }}
		// ID of the ent.
		{{- if $.ID.Comment }}
			{{- range $line := split $.ID.Comment "\n" }}
				// {{ $line }}
			{{- end }}
		{{- end }}
		{{- /* 以下の行でエラー発生 */ }}
		ID {{ $.ID.Type }} {{ with $.Annotations.Fields.StructTag.id }}`{{ . }}`{{ else }}`{{ $.ID.StructTag }}`{{ end }}
	{{- end }}
```

https://github.com/ent/ent/blob/89ad7fa39de1a4218c2c6c35db443b05b594b8b6/entc/gen/template/ent.tmpl#L26

複合主キーの場合 `ID` に相当するフィールドがないためnil pointer dereferenceが発生します。

修正後はエッジスキーマを定義することでこの分岐 (`if $.HasOneFieldID`)に入らなくなり、エラーが解消されました。`HasOneFieldID` の定義は以下の通りです。
`edge.From`, `edge.To` の両方が定義されている場合 `false` になります。

https://github.com/ent/ent/blob/0c74e8ee0d7729640ae8600cc37eeb60b919059b/entc/gen/type.go#L295

```go:entc/gen/type.go
// IsEdgeSchema indicates if the type (schema) is used as an edge-schema.
// i.e. is being used by an edge (or its inverse) with edge.Through modifier.
func (t Type) IsEdgeSchema() bool {
	return t.EdgeSchema.To != nil || t.EdgeSchema.From != nil
}

// HasCompositeID indicates if the type has a composite ID field.
func (t Type) HasCompositeID() bool {
	return t.IsEdgeSchema() && len(t.EdgeSchema.ID) > 1
}

// HasOneFieldID indicates if the type has an ID with one field (not composite).
func (t Type) HasOneFieldID() bool {
	return !t.HasCompositeID() && t.ID != nil
}
```

# おわりに
以上、entで複合主キーを扱う際のエラーの対処法でした。entを採用する場合、あまり複合主キーを使わないテーブル設計をしたほうが良いかもしれません。


[^student]: 逆向きに定義 (`Course` から `StudentCourse` を経由して `Student` を取得)してもかまいません。使いたい方をえらんでください。