[Facebook/ent]entc.goでのGraphQLファイル生成をテーブルごとに分割する
ent とは
Facebook 社製の Go のエンティティフレームワーク
https://entgo.io/ja/
GraphQL インテグレーションもサポートしており、
- ent スキーマを書く
- ent のコード生成
- GraphQL ファイルの生成
- gqlgen による resolver 等の生成
ここまで一括して generate コマンドで行ってくれます。
便利 ❣️
概要
今回は ent による GraphQL ファイル生成の話です。
ent による自動生成はファイル名の指定は出来ますが、複数ファイルに分割して生成することはできません。
例えば、
item
,user
,cart
のようなディレクトリがあったとして、
ファイル名の指定を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
を見ていきます。
チュートリアルに載っているコードは以下です。
//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()
とします。
ex, err := entgql.NewExtension(
entgql.WithSchemaGenerator(),
entgql.WithSchemaPath("ent.graphql"), // 生成するファイル名の指定
entgql.WithConfigPath("gqlgen.yml"), // gqlgenの設定
+ entgql.WithOutputWriter(customOutputFileWriter) // 生成時の関数の指定
)
情報整理
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
}
なるほど。わからん。
となったので、実際に 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 の型定義の中で値が入るのは
Types
とDirectives
のみ -
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
というカラム名を定義した場合、出力されるName
はUserType
となります。
これだと大文字の場所で分割すれば良さそうに見えますが、テーブル名やカラム名を_
で繋いでいた場合は異なります。
cart_items
というテーブルにcode_type
というようなカラム名を定義していた場合、出力されるName
はCartItemCodeType
となります。
こうなると、どのファイルに置くべき 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
は以下のようになっています。
func WithOutputWriter(w func(*ast.Schema) error) ExtensionOption {
return func(ex *Extension) error {
ex.outputWriter = w
return nil
}
}
ast.Schema
のポインタを引数にとり、エラーを返す関数を実装していきます。
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.Kind
がObject
でSchema.Type.Interfaces
がNode
になっているSchema.Type.Name
という形で設定できます。
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 の値部分を(Schema
やUser
部分)を Key にして分割していきます。
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
)
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 が入ります。
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"]
に追加します。
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 に設定します。
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.Name
はusers
のように単数形になっていますが、各ファイルに配置するための customSchemaType はUser
という Key になっています。
フィールド名を単数形 + アッパーケースにする必要があります。
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 に追加します。
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
等の場合は各ディレクトリを作成した中に生成します。
実際に書き込むスキーマに関しては別関数で処理を行うため、一部はまだコメントアウトしておきます。
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
の場合はベースのスキーマを渡すことで分岐していきます。
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 Query
をextend type Query
に置換します。
条件分岐は baseSchema が渡されているかどうかで分岐させます。
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 のコメントアウトを外します。
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 に関しても分岐していきます。
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