🔥

[Facebook/ent]entc.goでのGraphQLファイル生成をテーブルごとに分割する

2023/12/03に公開

ent とは

Facebook 社製の Go のエンティティフレームワーク
https://entgo.io/ja/

GraphQL インテグレーションもサポートしており、

  1. ent スキーマを書く
  2. ent のコード生成
  3. GraphQL ファイルの生成
  4. gqlgen による resolver 等の生成

ここまで一括して generate コマンドで行ってくれます。
便利 ❣️

概要

今回は ent による GraphQL ファイル生成の話です。
ent による自動生成はファイル名の指定は出来ますが、複数ファイルに分割して生成することはできません。

例えば、
item,user,cartのようなディレクトリがあったとして、
ファイル名の指定をent.graphqlにすると、

ent.graphql はこんな感じ
ent.graphql
"""
Define a Relay Cursor type:
https://relay.dev/graphql/connections.htm#sec-Cursor
"""
scalar Cursor
"""
An object with an ID.
Follows the [Relay Global Object Identification Specification](https://relay.dev/graphql/objectidentification.htm)
"""
interface Node @goModel(model: "github.com/hive-collective/hive-collective-renew/server/ent.Noder") {
	"""The id of the object."""
	id: ID!
}
"""Possible directions in which to order a list of items when provided an `orderBy` argument."""
enum OrderDirection {
	"""Specifies an ascending order for a given `orderBy` argument."""
	ASC
	"""Specifies a descending order for a given `orderBy` argument."""
	DESC
}
"""
Information about pagination in a connection.
https://relay.dev/graphql/connections.htm#sec-undefined.PageInfo
"""
type PageInfo {
	"""When paginating forwards, are there more items?"""
	hasNextPage: Boolean!
	"""When paginating backwards, are there more items?"""
	hasPreviousPage: Boolean!
	"""When paginating backwards, the cursor to continue."""
	startCursor: Cursor
	"""When paginating forwards, the cursor to continue."""
	endCursor: Cursor
}
type Query {
	"""Fetches an object given its ID."""
	node(
		"""ID of the object."""
		id: ID!
	): Node
	"""Lookup nodes by a list of IDs."""
	nodes(
		"""The list of node IDs."""
		ids: [ID!]!
	): [Node]!
    users(
		"""Returns the elements in the list that come after the specified cursor."""
		after: Cursor

		"""Returns the first _n_ elements from the list."""
		first: Int

		"""Returns the elements in the list that come before the specified cursor."""
		before: Cursor

		"""Returns the last _n_ elements from the list."""
		last: Int

		"""Ordering options for Users returned from the connection."""
		orderBy: [UserOrder!]

		"""Filtering options for Users returned from the connection."""
		where: UserWhereInput
	): UserConnection!
}
"""
CreateUserInput is used for create User object.
Input was generated by ent.
"""
input CreateUserInput {
    inputフィールドが色々書いてある
}
"""
UpdateUserInput is used for update User object.
Input was generated by ent.
"""
input UpdateUserInput {
    updateフィールドが色々書いてある
}
type User implements Node {
    フィールド一覧が色々書いてある
}
"""A connection to a list of items."""
type UserConnection {
	"""A list of edges."""
	edges: [UserEdge]
	"""Information to aid in pagination."""
	pageInfo: PageInfo!
	"""Identifies the total count of items in the connection."""
	totalCount: Int!
}
"""An edge in a connection."""
type UserEdge {
	"""The item at the end of the edge."""
	node: User
	"""A cursor for use in pagination."""
	cursor: Cursor!
}
"""Ordering options for User connections"""
input UserOrder {
	"""The ordering direction."""
	direction: OrderDirection! = ASC
	"""The field by which to order Users."""
	field: UserOrderField!
}
"""Properties by which User connections can be ordered."""
enum UserOrderField {
    長い
}
"""
UserWhereInput is used for filtering User objects.
Input was generated by ent.
"""
input UserWhereInput {
    めっちゃ長い
}

このあとitem,cartの定義が続く

超長い。
3テーブルだけで 1000 行超えそう。
(Relay を採用しているので、Node とか Connection が増えているのもある)

ちゃんとアプリとして機能させようとすると、もちろん3テーブルじゃ済まないわけで…。
自動生成しか入らないから、自分でファイルを編集することは無いとはいえ、全く見ないことは無いと思います。
バックエンドエンジニアは ent スキーマを見れば OK!となるかもしれないですが、GraphQL ファイルに関してはフロントエンドエンジニアとの共通認識も必要な部分では?
というわけで、探しやすいようにテーブルごとに分割したいな!と試みた結果を共有したい 🙋🏻‍♀️

目指す状態

.
└── graphql
    └── schema
        ├── schema.graphql
        ├── gen_schema.graphql
        ├── user
        │   ├── gen_user.graphql
        │   └──user.graphql
        ├── item
        │   ├── gen_user.graphql
        │   └──user.graphql
        └── cart
            ├── gen_user.graphql
            └──user.graphql

こんな感じを目指します。
Mutation や directive は自分で書く必要があるので

  • ent 自動生成はgen_xxxx.graphql
  • 自作はxxxx.graphql

とファイル名で判別できるようにします。

gen_schema.graphqlはテーブルに依存しないものが入ります。
NodeとかScalarとか。

実装していく

事前準備

resolver の生成まで出来るようなコードが整っている前提になります。
ent の graphql イントロダクションの内容が終わっている状態。

分割処理をどこに差し込んでいくのか

entc.goを見ていきます。

チュートリアルに載っているコードは以下です。

entc.go
//go:build ignore

package main

import (
    "log"

    "entgo.io/ent/entc"
    "entgo.io/ent/entc/gen"
    "entgo.io/contrib/entgql"
)

func main() {
    ex, err := entgql.NewExtension(
        entgql.WithSchemaGenerator(),
        entgql.WithSchemaPath("ent.graphql"), // 生成するファイル名の指定
        entgql.WithConfigPath("gqlgen.yml"), // gqlgenの設定
    )
    if err != nil {
        log.Fatalf("creating entgql extension: %v", err)
    }
    opts := []entc.Option{
        entc.Extensions(ex),
    }
    if err := entc.Generate("./ent/schema", &gen.Config{}, opts...); err != nil { // 参照するentスキーマディレクトリの指定
        log.Fatalf("running ent codegen: %v", err)
    }
}

entgql.NewExtensionが graphql ファイル生成部分になっているので、ここを見ていけば良さそうです。
NewExtension にどんなメソッドが用意されているのか見ていくと、entgql.WithOutputWriter()が用意されています。

func WithOutputWriter ¶
added in v0.3.5
func WithOutputWriter(w func(\*ast.Schema) error) ExtensionOption
WithOutputWriter sets the function to write the generated schema.
https://pkg.go.dev/entgo.io/contrib@v0.4.5/entgql#WithOutputWriter

ここで生成時に関数を差し込めるようです。
これを使っていきます。
今回は関数名はcustomOutputFileWriter()とします。

entc.go
ex, err := entgql.NewExtension(
    entgql.WithSchemaGenerator(),
    entgql.WithSchemaPath("ent.graphql"), // 生成するファイル名の指定
    entgql.WithConfigPath("gqlgen.yml"), // gqlgenの設定
+   entgql.WithOutputWriter(customOutputFileWriter) // 生成時の関数の指定
)

情報整理

ast.Schema の中身を確認する

ast.Schemaには何が入っているのか確認します。

ast/Schema
type Schema struct {
	Query        *Definition
	Mutation     *Definition
	Subscription *Definition

	Types      map[string]*Definition
	Directives map[string]*DirectiveDefinition

	PossibleTypes map[string][]*Definition
	Implements    map[string][]*Definition

	Description string

	Comment *CommentGroup
}

https://pkg.go.dev/github.com/vektah/gqlparser/v2/ast#Schema

なるほど。わからん。
となったので、実際に entgql がどのような ast.Schema を扱っているのか見てみます。

だいぶ省略した json
{
  "Query": null,
  "Mutation": null,
  "Subscription": null,
  "Types": {
    "Node": {
      "Kind": "INTERFACE",
      "Description": "An object with an ID.\nFollows the [Relay Global Object Identification Specification](https://relay.dev/graphql/objectidentification.htm)",
      "Name": "Node",
      "Directives": [
        {
          "Name": "goModel",
          "Arguments": [
            {
              "Name": "model",
              "Value": {
                "Raw": "github.com/hive-collective/hive-collective-renew/server/ent.Noder",
                "Children": null,
                "Kind": 3,
                "Position": null,
                "Definition": null,
                "VariableDefinition": null,
                "ExpectedType": null
              },
              "Position": null
            }
          ],
          "Position": null,
          "ParentDefinition": null,
          "Definition": null,
          "Location": "OBJECT"
        }
      ],
      "Interfaces": null,
      "Fields": [
        {
          "Description": "The id of the object.",
          "Name": "id",
          "Arguments": null,
          "DefaultValue": null,
          "Type": {
            "NamedType": "ID",
            "Elem": null,
            "NonNull": true,
            "Position": null
          },
          "Directives": null,
          "Position": null
        }
      ],
      "Types": null,
      "EnumValues": null,
      "Position": null,
      "BuiltIn": false
    },
    "User": {
      "Kind": "OBJECT",
      "Description": "",
      "Name": "User",
      "Directives": [],
      "Interfaces": ["Node"],
      "Fields": [
        {
          "Description": "",
          "Name": "id",
          "Arguments": null,
          "DefaultValue": null,
          "Type": {
            "NamedType": "ID",
            "Elem": null,
            "NonNull": true,
            "Position": null
          },
          "Directives": [],
          "Position": null
        },
        {
          "Description": "",
          "Name": "createdAt",
          "Arguments": null,
          "DefaultValue": null,
          "Type": {
            "NamedType": "Time",
            "Elem": null,
            "NonNull": true,
            "Position": null
          },
          "Directives": [],
          "Position": null
        },
        {
          "Description": "",
          "Name": "updatedAt",
          "Arguments": null,
          "DefaultValue": null,
          "Type": {
            "NamedType": "Time",
            "Elem": null,
            "NonNull": true,
            "Position": null
          },
          "Directives": [],
          "Position": null
        },
        {
          "Description": "名前",
          "Name": "name",
          "Arguments": null,
          "DefaultValue": null,
          "Type": {
            "NamedType": "String",
            "Elem": null,
            "NonNull": true,
            "Position": null
          },
          "Directives": [],
          "Position": null
        }
        // Fieldが続く...
      ],
      "Types": null,
      "EnumValues": null,
      "Position": null,
      "BuiltIn": false
    }
    // typeで定義されるものが続く
  },
  "Directives": {
    "goField": {
      "Description": "",
      "Name": "goField",
      "Arguments": [
        {
          "Description": "",
          "Name": "forceResolver",
          "DefaultValue": null,
          "Type": {
            "NamedType": "Boolean",
            "Elem": null,
            "NonNull": false,
            "Position": null
          },
          "Directives": null,
          "Position": null
        },
        {
          "Description": "",
          "Name": "name",
          "DefaultValue": null,
          "Type": {
            "NamedType": "String",
            "Elem": null,
            "NonNull": false,
            "Position": null
          },
          "Directives": null,
          "Position": null
        }
      ],
      "Locations": ["FIELD_DEFINITION", "INPUT_FIELD_DEFINITION"],
      "IsRepeatable": false,
      "Position": {
        "Start": 0,
        "End": 0,
        "Line": 0,
        "Column": 0,
        "Src": { "Name": "", "Input": "", "BuiltIn": false }
      }
    },
    "goModel": {
      "Description": "",
      "Name": "goModel",
      "Arguments": [
        {
          "Description": "",
          "Name": "model",
          "DefaultValue": null,
          "Type": {
            "NamedType": "String",
            "Elem": null,
            "NonNull": false,
            "Position": null
          },
          "Directives": null,
          "Position": null
        },
        {
          "Description": "",
          "Name": "models",
          "DefaultValue": null,
          "Type": {
            "NamedType": "",
            "Elem": {
              "NamedType": "String",
              "Elem": null,
              "NonNull": true,
              "Position": null
            },
            "NonNull": false,
            "Position": null
          },
          "Directives": null,
          "Position": null
        }
      ],
      "Locations": [
        "OBJECT",
        "INPUT_OBJECT",
        "SCALAR",
        "ENUM",
        "INTERFACE",
        "UNION"
      ],
      "IsRepeatable": false,
      "Position": {
        "Start": 0,
        "End": 0,
        "Line": 0,
        "Column": 0,
        "Src": { "Name": "", "Input": "", "BuiltIn": false }
      }
    }
  },
  "PossibleTypes": null,
  "Implements": null,
  "Description": ""
}
  • ast.Schema の型定義の中で値が入るのはTypesDirectivesのみ
  • DirectivesにはgoFieldが入るだけなので、カスタマイズからは無視しで大丈夫そう

ということが分かりました。
Typesにだけ注目していけば良さそうです。

Types

Types に何が入っているのかまとめると以下の形

_key / Name Kind interfaces
Cursor SCALAR
Node INTERFACE
OrderDirection ENUM
PageInfo OBJECT
Query OBJECT
Time SCALAR
〇〇(Type 名) OBJECT Node
〇〇 Connection OBJECT
〇〇 Edge OBJECT
〇〇 Order INPUT_OBJECT
〇〇 OrderField ENUM
〇〇[定義した Enum] ENUM
〇〇 WhereInput INPUT_OBJECT
Create 〇〇 Input INPUT_OBJECT
Update 〇〇 Input INPUT_OBJECT

Types の情報を整理

分割のために Types をより細かく整理していきます。

  • テーブルに依存しないもの(共通で使うもの): gen_schema.graphql
  • テーブル依存するもの: gen_xxx(Type名).graphql

とします。

gen_schema.graphqlに置くもの

_key / Name Kind interfaces 備考
Cursor SCALAR
Node INTERFACE
OrderDirection ENUM
PageInfo OBJECT
Query OBJECT field 下で分割が必要
Time SCALAR

Queryの中のFieldsには各クエリが入っています。
node,nodes,users,items,carts
なので、node,nodes以外のクエリは各gen_xxx(テーブル名).graphqlに書くように分岐が必要そうです。
Query以外はTypes.Name一致の条件分岐で分割できそうですね。

gen_xxx(テーブル名).graphqlに置くもの

_key / Name Kind interfaces 備考
〇〇(Type 名) OBJECT Node
〇〇 Connection OBJECT
〇〇 Edge OBJECT
〇〇 Order INPUT_OBJECT
〇〇 OrderField ENUM
〇〇[定義した Enum] ENUM ここだけ動的
〇〇 WhereInput INPUT_OBJECT
Create 〇〇 Input INPUT_OBJECT
Update 〇〇 Input INPUT_OBJECT

出力ファイル名となる Type 名に関しては、interfaces == "Node"となる Name を取得すれば OK そうです。
基本的には Name が Type 名ごとに確定していますが、
kind == "Enum"〇〇 OrderField〇〇[定義した Enum]となっており、
〇〇[定義した Enum]の方は各 ent スキーマでどんな Enum フィールドを定義したかによって動的な Name になってきます。

Query.Fields の分割について考える

先ほど述べたQuery.Fieldsの分割を考えます。

node,nodes -> gen_schema.graphql
users -> gen_user.graphql
items -> gen_item.graphql
carts -> gen_cart.graphql

このように分割したいので、Query.Fieldsの中を詳しくみていきます。

Description Name Arguments DefaultValue Derective Position Type
Fetches an object given its ID. node [引数の配列] null null null {"NamedType": "Node","Elem": null, "NonNull": false, "Position": null },
Lookup nodes by a list of IDs" nodes [引数の配列] null null null {"NamedType": "","Elem": {"NamedType": "Node","Elem": null, "NonNull": false, "Position": null }, "NonNull": true, "Position": null },
〇〇 s (ex.users) [引数の配列] null [] null {"NamedType": "UserConnection","Elem": null,"NonNull": true,"Position": null},

まとめると上記のように分類できました。

node,nodes以外はNameの部分を単数形に変換することで、各ファイルに分割できそうです。

〇〇[定義した Enum]について考える

usersテーブルでtypeというカラム名を定義した場合、出力されるNameUserTypeとなります。
これだと大文字の場所で分割すれば良さそうに見えますが、テーブル名やカラム名を_で繋いでいた場合は異なります。
cart_itemsというテーブルにcode_typeというようなカラム名を定義していた場合、出力されるNameCartItemCodeTypeとなります。
こうなると、どのファイルに置くべき Enum なのかの分割が難しくなってきます。

Enum に何か使える情報がないかみてみます。

"UserType": {
      "Kind": "ENUM",
      "Description": "UserType is enum for the field type",
      "Name": "UserType",
      "Directives": [
        {
          "Name": "goModel",
          "Arguments": [
            {
              "Name": "model",
              "Value": {
                "Raw": "github.com/xxxx/user.Type",
                "Children": null,
                "Kind": 3,
                "Position": null,
                "Definition": null,
                "VariableDefinition": null,
                "ExpectedType": null
              },
              "Position": null
            }
          ],
          "Position": null,
          "ParentDefinition": null,
          "Definition": null,
          "Location": "OBJECT"
        }
      ],
      "Interfaces": null,
      "Fields": null,
      "Types": null,
      "EnumValues": [
        {
          "Description": "",
          "Name": "Admin",
          "Directives": null,
          "Position": null
        },
        // 省略
      ],
      "Position": null,
      "BuiltIn": false
    },

な、無い…。
ギリギリ"Raw": "github.com/xxxx/user.Type",の部分が使える…?
いや強引すぎ…、でもそれしか…。
というわけで、あまり使いたくないですが、Rawの文字列の部分を使っていきます。(ものすごく嫌)

customOutputFileWriter を実装する

とりあえず関数を定義する

WithOutputWriterは以下のようになっています。

entgql/extension.go
func WithOutputWriter(w func(*ast.Schema) error) ExtensionOption {
	return func(ex *Extension) error {
		ex.outputWriter = w
		return nil
	}
}

ast.Schemaのポインタを引数にとり、エラーを返す関数を実装していきます。

entc.go
func customOutputFileWriter(schema *ast.Schema) error {
    // ここで分割処理を書きたい
	return nil
}

名前が決まっているものに関して対応表を作る

先程述べた Query と動的な Enum 以外に関して、Schema.Types.Nameとファイル名の対応表を作ります。

Schema.Types.Name ファイル名
Cursor gen_schema.graphql
Node gen_schema.graphql
OrderDirection gen_schema.graphql
PageInfo gen_schema.graphql
Time gen_schema.graphql
User gen_schema.graphql
UserConnection gen_user.graphql
UserEdge gen_user.graphql
UserOrder gen_user.graphql
UserOrderField gen_user.graphql
UserWhereInput gen_user.graphql
CreateUserInput gen_user.graphql
UpdateUserInput gen_user.graphql
... ...
(Item や Cart も同様) gen_xxx.graphql

Schema.Types.Nameを Key にして、ファイル名の動的な部分を値とする Map を作成します。

ファイル名の動的な部分は
gen_schema.graphql -> "schema"
gen_xxx.graphql -> Schema.Type.KindObjectSchema.Type.InterfacesNodeになっているSchema.Type.Name
という形で設定できます。

entc.go
func createFileNameMap(schema *ast.Schema) map[string]string {
	fileNameMap := map[string]string{
		"Node":           "Schema",
		"OrderDirection": "Schema",
		"PageInfo":       "Schema",
	}
	for _, currentType := range schema.Types {
        // SCALARは複数種類があるので条件分岐する
        if currentType.Kind == ast.Scalar {
            fileNameMap[currentType.Name] = "Schema"
        }
        // schema以外のファイルに配置するもの
		if currentType.Kind == ast.Object && slices.Contains(currentType.Interfaces, "Node") {
			fileNameMap[currentType.Name] = currentType.Name
			fileNameMap[currentType.Name+"Connection"] = currentType.Name
			fileNameMap[currentType.Name+"Edge"] = currentType.Name
			fileNameMap[currentType.Name+"Order"] = currentType.Name
			fileNameMap[currentType.Name+"OrderField"] = currentType.Name
			fileNameMap[currentType.Name+"WhereInput"] = currentType.Name
			fileNameMap["Create"+currentType.Name+"Input"] = currentType.Name
			fileNameMap["Update"+currentType.Name+"Input"] = currentType.Name
		}
	}
	return fileNameMap
}

Schema.Types を分割する

Schema.Typesを分割してカスタマイズしたcustomSchemTypes map[string][]*ast.Definitionを作成していきます。

まずは先程の対応 Map の値部分を(SchemaUser部分)を Key にして分割していきます。

entc.go
func genCustomSchemaTypes(schema *ast.Schema, fileNameMap map[string]string) (map[string][]*ast.Definition, error) {
    customSchemaTypes := map[string][]*ast.Definition{}

    for _, currentType := range schema.Types {
        // fileNameMapで定義されているNameの場合、その値をKeyとしてcustomSchemaTypesにappendする
        fileName, isExist := fileNameMap[currentType.Name]
        if isExist {
            customSchemaTypes[fileName] = append(customSchemaTypes[fileName], currentType)
            continue
        }

        // どの処理パスにもマッチしなかった場合、エラーを返す
        return nil, fmt.Errorf("Typeに一致する処理パスがありません: " + currentType.Name)
    }
    return customSchemaTypes, nil
}

次にカスタム Enum 部分です。
カスタム Enum は Kind が Enum で Directives の長さが 1 以上になっています。
raw が"github.com/xxx/user.Type"のようになっているので、.の部分で分割した最後の要素が ENUM 名となります。
Schema.Types.Name == "UserType"だとすると、UserTypeから Enum 名を除外すると、ファイル名の Key となります。(User)

entc.go
	for _, currentType := range schema.Types {
        // fileNameMapで定義されているNameの場合、その値をKeyとしてcustomSchemaTypesにappendする
        fileName, isExist := fileNameMap[currentType.Name]
        if isExist {
            customSchemaTypes[fileName] = append(customSchemaTypes[fileName], currentType)
            continue
        }

+       if currentType.Kind == ast.Enum && len(currentType.Directives) > 0 {
+           // "github.com/xxx/user.Type"などの最後のENUM名のみを取得する
+           raw := currentType.Directives[0].Arguments[0].Value.Raw
+           lastDotIndex := strings.LastIndex(raw, ".")
+           trimmedEnum := raw[lastDotIndex+1:]
+           //  "Name": "UserType"などから"Type"を抜くと出力用のkeyとなる
+           trimmedKey := strings.TrimSuffix(currentType.Name, trimmedEnum)
+
+           customSchemaTypes[trimmedKey] = append(customSchemaTypes[trimmedKey], currentType)
+           continue
+       }

        // どの処理パスにもマッチしなかった場合、エラーを返す
        return nil, fmt.Errorf("Typeに一致する処理パスがありません: " + currentType.Name)
	}

最後に Query を分割していきます。
まず gen_schema.grapgql に関しては、node と nodes が入るので、複数の Field が入ります。

gen_schema.graphql
type Query {
	"""Fetches an object given its ID."""
	node(
		"""ID of the object."""
		id: ID!
	): Node
	"""Lookup nodes by a list of IDs."""
	nodes(
		"""The list of node IDs."""
		ids: [ID!]!
	): [Node]!
}

フィールドの slice を作り、Query の Fields に代入。
それをcustomSchemaType["Schema"]に追加します。

entc.go
func genCustomSchemaTypes(schema *ast.Schema, fileNameMap map[string]string) (map[string][]*ast.Definition, error) {
    for _, currentType := range schema.Types {
        // ...

        if currentType.Kind == ast.Enum && len(currentType.Directives) > 0 {
        // ...
        }

+       if currentType.Name == "Query" {
+           schemaFields := []*ast.FieldDefinition{}
+           for _, field := range currentType.Fields {
+               // fieldのKeyがnode,nodesの場合はschemaFieldに追加する
+               if field.Name == "node" || field.Name == "nodes" {
+                   schemaFields = append(rootFields, field)
+                   continue
+               }
+           }
+
+           schemaType := *currentType
+           schemaType.Fields = schemaFields
+           customSchemaTypes["Schema"] = append(customSchemaTypes["Schema"], &schemaType)
+
+           continue
+       }

        // どの処理パスにもマッチしなかった場合、エラーを返す
        return nil, fmt.Errorf("Typeに一致する処理パスがありません: " + currentType.Name)
    }
}

次にusersなど、各ファイルに分割する方です。
同じ type は複数定義できないので、各カスタムファイルに関しては extend してあげる必要があります。
ここに関しては後々の処理で行うので、ひとまず分割時は単一の Field を各ファイルの Query に設定します。

user/gen_user.graphql
extend type Query {
	users(
		"""Returns the elements in the list that come after the specified cursor."""
		after: Cursor

		"""Returns the first _n_ elements from the list."""
		first: Int

		"""Returns the elements in the list that come before the specified cursor."""
		before: Cursor

		"""Returns the last _n_ elements from the list."""
		last: Int

		"""Ordering options for Users returned from the connection."""
		orderBy: [UserOrder!]

		"""Filtering options for Users returned from the connection."""
		where: UserWhereInput
	): UserConnection!
}

field.Nameusersのように単数形になっていますが、各ファイルに配置するための customSchemaType はUserという Key になっています。
フィールド名を単数形 + アッパーケースにする必要があります。

entc.go
func genCustomSchemaTypes(schema *ast.Schema, fileNameMap map[string]string) (map[string][]*ast.Definition, error) {

+   pluralize := pluralize.NewClient()

    for _, currentType := range schema.Types {
        // ...

        if currentType.Kind == ast.Enum && len(currentType.Directives) > 0 {
        // ...
        }

       if currentType.Name == "Query" {
           schemaFields := []*ast.FieldDefinition{}
           for _, field := range currentType.Fields {
               // fieldのKeyがnode,nodesの場合はschemaFieldに追加する
               if field.Name == "node" || field.Name == "nodes" {
                   schemaFields = append(rootFields, field)
                   continue
               }
+               // その他のフィールドはextendしたいので、customFieldsに追加する
+               trimFieldName := pluralize.Singular(field.Name)
+               trimFieldNameRunes := []rune(trimFieldName)
+               // user -> User に変換
+               upperCaseName := strings.ToUpper(string(trimFieldNameRunes[0])) + string(trimFieldNameRunes[1:])
+               customType := *currentType
+               customType.Fields = []*ast.FieldDefinition{field}
+               customSchemaTypes[upperCaseName] = append(customSchemaTypes[upperCaseName], &customType)
+           }

           schemaType := *currentType
           schemaType.Fields = schemaFields
           customSchemaTypes["Schema"] = append(customSchemaTypes["Schema"], &schemaType)

           continue
       }

        // どの処理パスにもマッチしなかった場合、エラーを返す
        return nil, fmt.Errorf("Typeに一致する処理パスがありません: " + currentType.Name)
    }
}

これで各ファイルと Type が対応するようになりました。
customOutputFileWriter に追加します。

entc.go
func customOutputFileWriter(schema *ast.Schema) error {
+   fileNameMap := createFileNameMap(schema)

+   customOutputTypes, err := genCustomSchemaTypes(schema, fileNameMap)
+   if err != nil {
+       return err
+   }

    // 各ファイルを生成する

    return nil
}

ファイル生成を実装する

次に各ファイルを生成します。
gen_schema.graphqlの場合は schema ディレクトリの直下に。
gen_user.graphql等の場合は各ディレクトリを作成した中に生成します。

実際に書き込むスキーマに関しては別関数で処理を行うため、一部はまだコメントアウトしておきます。

entc.go
func writeCustomSchema(schema *ast.Schema, customOutputTypes map[string][]*ast.Definition) error {
    for key, currentType := range customOutputTypes {
        var customDir string
        var outputFilename string
        var formattedSchema string

        if key == "Schema" {
            customDir = "schema"
            outputFilename = customDir + "/gen_" + strings.ToLower(key) + ".graphql"
            // 実際に書き込むスキーマは別関数で処理する
            // formattedSchema = createAndFormatSchema(currentType, schema)
        } else {
            customDir = "schema/" + strings.ToLower(key)
            outputFilename = customDir + "/gen_" + strings.ToLower(key) + ".graphql"
            // 実際に書き込むスキーマは別関数で処理する
            // formattedSchema = createAndFormatSchema(currentType, nil)
		}

        // ディレクトリが存在しない場合は作成する
        if _, err := os.Stat(customDir); os.IsNotExist(err) {
            err := os.MkdirAll(customDir, os.ModePerm)
            if err != nil {
                return err
            }
        }

        // err := os.WriteFile(outputFileName, []byte(formattedSchema), os.ModePerm)
        // if err != nil {
        //     return err
        // }
    }

    return nil
}

スキーマを成形する

書き込むスキーマを成形していきます。

行うことは以下です。

  • gen_schema.graphqlにはTypes以外に元々定義されているものも追加する
  • 各カスタムファイルでは Query を extend する

まずは1つ目の部分からやっていきます。
gen_schema.graphqlの場合はベースのスキーマを渡すことで分岐していきます。

entc.go
func createAndFormatSchema(types []*ast.Definition, baseSchema *ast.Schema) string {
    var sb strings.Builder
    f := formatter.NewFormatter(&sb)
    newSchema := &ast.Schema{
        Types: map[string]*ast.Definition{},
    }

    for _, typeDef := range types {
        newSchema.Types[typeDef.Name] = typeDef
    }

    if baseSchema != nil {
        newSchema.Query = baseSchema.Query
        newSchema.Mutation = baseSchema.Mutation
        newSchema.Subscription = baseSchema.Subscription
        newSchema.Directives = baseSchema.Directives
        newSchema.PossibleTypes = baseSchema.PossibleTypes
        newSchema.Implements = baseSchema.Implements
        newSchema.Description = baseSchema.Description
	}

    f.FormatSchema(newSchema)

    return schemaStr
}

type Queryextend type Queryに置換します。
条件分岐は baseSchema が渡されているかどうかで分岐させます。

entc.go
func createAndFormatSchema(types []*ast.Definition, baseSchema *ast.Schema) string {
    // ...

    f.FormatSchema(newSchema)

+   var schemaStr string
+
+   if baseSchema != nil {
+       schemaStr = sb.String()
+   } else {
+       // Queryをextendさせる
+       schemaStr = strings.Replace(sb.String(), "type Query {", "extend type Query {", 1)
+   }

    return schemaStr
}

最後に writeCustomSchema のコメントアウトを外します。

entc.go
func writeCustomSchema(schema *ast.Schema, customOutputTypes map[string][]*ast.Definition) error {
    for key, currentType := range customOutputTypes {
        var customDir string
        var outputFilename string
        var formattedSchema string

        if key == "Schema" {
            customDir = "schema"
            outputFilename = customDir + "/gen_" + strings.ToLower(key) + ".graphql"
-           // 実際に書き込むスキーマは別関数で処理する
-           // formattedSchema = createAndFormatSchema(currentType, schema)
+           formattedSchema = createAndFormatSchema(currentType, schema)
        } else {
            customDir = "schema/" + strings.ToLower(key)
            outputFilename = customDir + "/gen_" + strings.ToLower(key) + ".graphql"
-           // 実際に書き込むスキーマは別関数で処理する
-           // formattedSchema = createAndFormatSchema(currentType, nil)
+           formattedSchema = createAndFormatSchema(currentType, nil)
		}

        // ディレクトリが存在しない場合は作成する
        if _, err := os.Stat(customDir); os.IsNotExist(err) {
            err := os.MkdirAll(customDir, os.ModePerm)
            if err != nil {
                return err
            }
        }

-       // err := os.WriteFile(outputFileName, []byte(formattedSchema), os.ModePerm)
-       // if err != nil {
-       //     return err
-       // }
+       err := os.WriteFile(outputFileName, []byte(formattedSchema), os.ModePerm)
+           if err != nil {
+               return err
+           }
    }

    return nil
}

Time スカラが何故か生成されない(たぶんバグ)

これで実装終わりかと思いきや、何故か 1/2 の確率で Time スカラが生成されずエラーになります。
そもそもの*ast.Schemaを出力してみると、entgql.WithOutputWriter()に渡ってくる時点で Time スカラが生成されてなさそう。
仕方ないので Scalar に関しても分岐していきます。

entc.go
func createFileNameMap(schema *ast.Schema) map[string]string {
	fileNameMap := map[string]string{
		"Node":           "Schema",
		"OrderDirection": "Schema",
		"PageInfo":       "Schema",
	}
	for _, currentType := range schema.Types {
-       // SCALARは複数種類があるので条件分岐する
-       if currentType.Kind == ast.Scalar {
-           fileNameMap[currentType.Name] = "Schema"
-       }
        // schema以外のファイルに配置するもの
		if currentType.Kind == ast.Object && slices.Contains(currentType.Interfaces, "Node") {
			fileNameMap[currentType.Name] = currentType.Name
			fileNameMap[currentType.Name+"Connection"] = currentType.Name
			fileNameMap[currentType.Name+"Edge"] = currentType.Name
			fileNameMap[currentType.Name+"Order"] = currentType.Name
			fileNameMap[currentType.Name+"OrderField"] = currentType.Name
			fileNameMap[currentType.Name+"WhereInput"] = currentType.Name
			fileNameMap["Create"+currentType.Name+"Input"] = currentType.Name
			fileNameMap["Update"+currentType.Name+"Input"] = currentType.Name
		}
	}
	return fileNameMap
}

func genCustomSchemaTypes(schema *ast.Schema, fileNameMap map[string]string) (map[string][]*ast.Definition, error) {
   for _, currentType := range schema.Types {
       // ...

       if currentType.Kind == ast.Enum && len(currentType.Directives) > 0 {
       // ...
       }

+       // Timeだけ1/2の確率で生成されないため、手動定義するために除外する
+       if currentType.Name == "Time" {
+           continue
+       }
+
+       if currentType.Kind == ast.Scalar {
+           customSchemaTypes["Schema"] = append(customSchemaTypes["Schema"], currentType)
+           continue
+       }

       if currentType.Name == "Query" {
        // ...
       }

       // どの処理パスにもマッチしなかった場合、エラーを返す
       return nil, fmt.Errorf("Typeに一致する処理パスがありません: " + currentType.Name)
   }
}

終わりに

というわけで、かなり強引な方法ですがテーブルごとに分割することが出来ました。
ent 公式で分割するかどうかのフラグが追加されて欲しい。
強引すぎて記事にしていいのか悩ましいところですが、

  • もしかしたら誰かの役に立つかも
  • いい方法誰かが教えてくれるかも

ということを信じて記事にしました。
強引じゃない方法を考えることが来年の課題かな…。

Discussion