graphql-fabbrica/DesignDoc/インターフェイス
2023/8/11 mizdra.icon とりあえずこうなっていて欲しいというインターフェイスを思い描いてみる。
code:schema.graphql
type Book implements Node {
id: ID!
title: String!
author: Author!
}
type Author implements Node {
id: ID!
name: String!
}
type User implements Node {
id: ID!
name: String!
email: String
avatar: Image!
}
type Image implements Node {
id: ID!
src: String!
width: Int!
height: Int!
}
type BookConnection {
pageInfo: PageInfo!
totalCount: Int!
}
type BookEdge {
node: Book
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
interface Node {
id: ID!
}
code:codegen.ts
import type { CodegenConfig } from '@graphql-codegen/cli';
const config: CodegenConfig = {
schema: './schema.graphql',
ignoreNoDocuments: true,
generates: {
'__generated__/graphql-codegen/types.ts': {
config: {
scalars: {
DateTime: 'string',
},
},
},
'__generated__/graphql-fabbrica.ts': {
config: {
typesFile: './types.ts',
addTypename: true,
enumsAsTypes: true,
scalars: {
DateTime: 'string',
},
},
},
},
};
// eslint-disable-next-line import/no-default-export
export default config;
code:ts
import {
defineBookFactory,
defineAuthorFactory,
defineUserFactory,
defineImageFactory,
defineBookConnectionFactory,
defineBookEdgeFactory,
definePageInfoFactory,
} from './__generated__/graphql-fabbrica';
import I_DUMMY_AVATAR from './assets/dummy-avatar.png';
import I_SPACER from './assets/spacer.gif';
import { faker } from '@faker-js/faker';
const BookFactory = defineBookFactory({
defaultFields: {
id: ({ seq }) => Book-${seq},
title: ({ seq }) => ゆゆ式 ${seq}巻,
author: undefined,
},
});
const AuthorFactory = defineAuthorFactory({
defaultFields: {
id: ({ seq }) => Author-${seq},
name: ({ seq }) => ${seq}上小又,
books: undefined,
},
});
const UserFactory = defineAuthorFactory({
defaultFields: {
id: ({ seq }) => User-${seq},
name: faker.person.fullName(),
avator: async () => ImageFactory.use("avatar").build(),
},
});
const ImageFactory = defineImageFactory({
defaultFields: {
id: ({ seq }) => Image-${seq},
src: I_SPACER.src,
width: I_SPACER.width,
height: I_SPACER.height,
},
traits: {
avatar: {
fields: {
src: I_DUMMY_AVATAR.src,
width: I_DUMMY_AVATAR.width,
height: I_DUMMY_AVATAR.height,
},
},
},
});
const BookConnectionFactory = defineBookConnectionFactory({
transientFields: {
count: 3,
totalCount: 4,
},
defaultFields: {
totalCount: async ({ get }) => get('totalCount'),
edges: async ({ get }) => BookEdgeFactory.buildList(await get('count')),
pageInfo: async ({ get }) => PageInfoFactory.build({
hasNextPage: true,
hasPrevPage: false,
startCursor: async ({ get }) => (await get('edges'))?.at(0)?.cursor ?? null,
endCursor: async ({ get }) => (await get('edges'))?.at(-1)?.cursor ?? null,
},
},
});
const book0 = await BookFactory.build();
const author0 = await AuthorFactory.build();
expect(book0).toStrictEqual({
__typename: 'Book',
id: 'Book-0',
title: 'ゆゆ式 0巻',
author: undefined,
});
expect(author0).toStrictEqual({
__typename: 'Author',
id: 'Author-0',
name: '0上小又',
books: undefined,
});
const book1 = await BookFactory.build({
title: 'ゆゆ式 X巻',
author: author0,
});
expect(book1).toStrictEqual({
__typename: 'Book',
id: 'Book-1',
title: 'ゆゆ式 X巻',
author: {
__typename: 'Author',
id: 'author-0',
name: '0上小又',
books: undefined,
},
});
const books = await BookFactory.buildList(3); // book-2, book-3, book-4
const book2 = {
author: await AuthorFactory.build({
books,
}),
};
expect(book2).toStrictEqual({
__typename: 'Book',
id: 'Book-2',
title: 'ゆゆ式 2巻',
author: {
__typename: 'Author',
id: 'author-1',
name: '1上小又',
books: [
{ __typename: 'Book', id: 'book-2', title: 'ゆゆ式 2巻', author: undefined },
{ __typename: 'Book', id: 'book-3', title: 'ゆゆ式 3巻', author: undefined },
{ __typename: 'Book', id: 'book-4', title: 'ゆゆ式 4巻', author: undefined },
],
},
});
const user0 = UserFactory.build();
expect(user0).toStrictEqual({
__typename: 'User',
id: 'User-0',
name: 'Bob',
email: 'foo@example.com',
avator: {
id: 'image-0',
src: '/_next/image?url=%2Fdummy-avatar.png',
width: 128,
heidht: 128,
},
});
/icons/---.icon
mizdra.icon 以下、フロー情報。
TODO & 検討事項
x .build() は promies を返すようにするべき? defaultData で async function をサポートするのだったら、promise 返す必要がありそう
x 関連している type の mock data もデフォルトで再帰的に作成するべき? つまりこうするべきか
code:ts
const BookFactory = defineBookFactory({
defaultData: async ({ seq }) => ({
id: Book-${seq},
title: ゆゆ式 ${seq}巻,
author: await AuthorFactory.build(),
}),
});
const AuthorFactory = defineAuthorFactory({
defaultData: async ({ seq }) => ({
id: Author-${seq},
name: ${seq}上小又,
books: await BookFactory.buildList(1),
}),
});
const book1 = await BookFactory.build();
expect(book1).toStrictEqual({
__typename: 'Book',
id: 'Book-0',
title: 'ゆゆ式 0巻',
author: {
__typename: 'Author',
id: 'Author-0',
name: '0上小又',
books: undefined,
},
});
FactoryBot の挙動を踏襲するなら、デフォルトでは再帰的に作成するべきではない
association があったら作成する、とするべき
一方 graphql-codegen-typescript-mock-data は再帰的に作成する
GraphQL の mock data を作るという用途だとどっちが嬉しいのか
これめっちゃ悩む
仮に再帰的に作成することにした場合、どうなるか考えてみる
多分以下のようになることをユーザーは期待する?
code:ts
const book = await BookFactory.build();
expect(book.id).toBe('Book-1');
expect(book.author.books0.id).toBe('Book-1'); expect(book.author.books[0].id).toBe('book-1');にはできない
再帰的に作成してもそんなに嬉しさないんじゃないかなー
一旦再帰的に作成しないことにしてみる
そもそもデフォルト値あったほうが良い?
必ず defaultDataで field ごとのデフォルト値を都度指定する方式ではダメ?
field を追加する度にどのデフォルト値が相応しいかを考える必要があって面倒かも?
これめっちゃ悩む
enum のデフォルト値どうするのとか、nullable のデフォルト値どうするのとか、custom scalar のデフォルト値どうするのとか、array のデフォルト値どうするのかとか、色々検討事項がある
まあ一旦デフォルト値無しで実装して、あとからやる気が出たらデフォルト値を実装すれば良いのでは
それで
素直にcodegen.ts側でどの TypeScript 型に convert するか指定する形にする
どうやって...?
こんな感じ...?
code:ts
const book1 = await BookFactory.build({
author: await AuthorFactory.build(),
);
これbook1.author.books[0] === book1で循環しているから、JSON.stringify(book1)で実行時エラーになるのでは?
扱いにくそう
もしくは.ref()みたいなものを導入する...?
code:ts
const book1 = await BookFactory.build({
id: 'Book-a',
author: await AuthorFactory.build({
}),
);
これを実現するには、@mizdra/graphql-fabbricaが.build()したものを覚えておかないといけない
ところでawait BookFactory.build({ id: 'book-a', ... })よりもawait BookFactory.ref({ id: 'book-a' })のほうが早く評価されるのだから、await BookFactory.ref({ id: 'book-a' })する時点ではまだid === 'book-a'な Node は作成できていないのでは...?
こうか...?
code:ts
const books = await BookFactory.buildList(3);
const book1 = {
author: await AuthorFactory.build({
books,
}),
};
良いけど書くのにコツが必要でムズい
まあでもこういうものな気がするなー
一旦これで
どうやって...?
シンプルに defaultData 内で工夫してもらったら良い?
code:ts
const UserFactory = defineAuthorFactory({
defaultData: async ({ seq }) => ({
id: User-${seq},
name: faker.person.fullName(),
avator: ImageFactory.buildLazy(), // like association
}),
});
const ImageFactory = defineImageFactory({
defaultData: async ({ seq }) => ({
id: Image-${seq},
src: I_DUMMY_AVATAR.src,
width: I_DUMMY_AVATAR.width,
height: I_DUMMY_AVATAR.height,
}),
});
const user0 = UserFactory.build();
expect(user0).toStrictEqual({
__typename: 'User',
id: 'User-0',
name: 'Bob',
email: 'foo@example.com',
avator: {
id: 'Image-0',
src: '/_next/image?url=%2Fdummy-avatar.png',
width: 128,
heidht: 128,
},
});
UserFactory.build({ avator: undefined })と書いたときにImageFactory.build()の呼び出しがされないよう、ImageFactory.buildLazy()で build を遅延させているのがポイント
冷静になって考えてみたら、ただ関数を渡せるようにするだけで良さそう
code:ts
const UserFactory = defineAuthorFactory({
defaultData: async ({ seq }) => ({
id: User-${seq},
name: faker.person.fullName(),
avator: async () => ImageFactory.build(), // like association
}),
});
これで
スナップショットテストや、ビジュアルリグレッションテストのために mock data を作成しているケースでは、出力を固定するために XXXFactory.build()で得られる値を固定したくなるはず
ようは決定性がある出力を得たい
一方で、様々な問題を検知するためにも、faker.js でランダムに field 値を変えたい状況もある
どっちのユースケースもサポートしたい
faker.js の seed を固定してください、ということにする?
一旦それで
_ Connection を簡単に作れる API を用意する buildConnection()とか?
totalCount とか Connection に拡張 field 持たせているケースも扱えると良い
もしかして transient attributes を導入すれば、特別な API 用意する必要ないのでは
code:ts
type BookConnectionTransientData = {
count?: number, // default: 3
hasNextPage: boolean, // default: true
hasPrevPage: boolean, // default: false
totalCount?: number, // default: count + 1
};
const BookConnectionFactory = defineBookConnectionFactory<BookConnectionTransientAttributes>({
defaultData: async ({ seq, transientData }) => {
const count = transientData.count ?? 3;
const totalCount = transientData.totalCount ?? count + 1;
const edges = await BookEdgeFactory.buildList(count);
return {
totalCount,
edges,
pageInfo: async () => PageInfoFactory.build({
hasNextPage: true,
hasPrevPage: false,
startCursor: edges.at(0)?.cursor ?? null,
endCursor: edges.at(-1)?.cursor ?? null,
},
};
}),
});
より丁寧にやるなら、BookEdgeFactory.buildListの呼び出しを遅延させるべき?
code:ts
const BookConnectionFactory = defineBookConnectionFactory<BookConnectionTransientAttributes>({
defaultData: async ({ seq, transientData }) => {
const count = transientData.count ?? 3;
const totalCount = transientData.totalCount ?? count + 1;
return {
totalCount,
edges: async () => BookEdgeFactory.buildList(count),
pageInfo: async ({ get }) => PageInfoFactory.build({
hasNextPage: true,
hasPrevPage: false,
startCursor: (await get('edges'))?.at(0)?.cursor ?? null,
endCursor: (await get('edges'))?.at(-1)?.cursor ?? null,
},
};
}),
});
分かってないとこう書けないので、ムズい気がする? そうでもない?
今気づいたけど、fieldAの値をもとにfieldBの値を変えたい、というニーズがあるのだな
うーんこれ悩ましいな
というかそもそも transient attributes をdefaultDataで受け取れるようにするインターフェイスで良いのか?
FactoryBot 的には attributes や before/after callback で受け取れることになってる
prisma-fabbrica にはまだ実装されていないし、実装方針も決まっていないように見える
after callback 相当のonAfterCreateは実装予定らしい
まあいいんじゃないか
Transient Attributes + Dependent Attributes を導入して、それで Connection を組み立てる、という方向で良さそう
x transient attributes のデフォルト値が設定できるように 素朴にtransientsプロパティを生やしたら良さそう
code:ts
const BookConnectionFactory = defineBookConnectionFactory<BookConnectionTransientAttributes>({
transients: {
count: 3,
totalCount: 4,
},
defaultData: async ({ seq, transientData }) => {
return {
totalCount: transientData.totalCount,
edges: async () => BookEdgeFactory.buildList(transientData.count),
pageInfo: async ({ get }) => PageInfoFactory.build({
hasNextPage: true,
hasPrevPage: false,
startCursor: (await get('edges'))?.at(0)?.cursor ?? null,
endCursor: (await get('edges'))?.at(-1)?.cursor ?? null,
},
};
}),
});
まあ一旦これで
x もしかしてdefaultDataは関数である必要ないのでは? こうじゃなくて
code:ts
const BookConnectionFactory = defineBookConnectionFactory<BookConnectionTransientAttributes>({
transients: {
count: 3,
totalCount: 4,
},
defaultData: async ({ seq, transientData }) => {
return {
totalCount: transientData.totalCount,
edges: async () => BookEdgeFactory.buildList(transientData.count),
pageInfo: async ({ get }) => PageInfoFactory.build({
hasNextPage: true,
hasPrevPage: false,
startCursor: (await get('edges'))?.at(0)?.cursor ?? null,
endCursor: (await get('edges'))?.at(-1)?.cursor ?? null,
},
};
}),
});
こうで良いのでは
code:ts
const DEFAULT_COUNT = 3;
const BookConnectionFactory = defineBookConnectionFactory<BookConnectionTransientAttributes>({
transients: {
count: 3,
totalCount: 4,
},
defaultData: {
totalCount: ({ transientData }) => transientData.totalCount,
edges: async ({ transientData }) => BookEdgeFactory.buildList(transientData.count),
pageInfo: async ({ get }) => PageInfoFactory.build({
hasNextPage: true,
hasPrevPage: false,
startCursor: (await get('edges'))?.at(0)?.cursor ?? null,
endCursor: (await get('edges'))?.at(-1)?.cursor ?? null,
},
},
});
実際 FactoryBot とかはそういうインターフェイスになってる
まあどうかなー
自然とBookEdgeFactory.buildListを遅延呼び出しするコードになるのは魅力的だけど
その分自由度はなくなる
FactoryBot がそうなっているんだったら、それに倣っておけば大きな問題は起きないんじゃないか
一旦 FactoryBot に倣ってみる
こういう感じでどうか
code:ts
const BookFactory = defineBookFactory({
defaultFields: {
id: ({ seq }) => Book-${seq},
title: ({ seq }) => ゆゆ式 ${seq}巻,
author: () => undefined,
},
});
const ImageFactory = defineImageFactory({
defaultFields: {
id: ({ seq }) => Image-${seq},
src: () => I_SPACER.src,
width: () => I_SPACER.width,
height: () => I_SPACER.height,
},
traits: {
avatar: {
fields: {
src: () => I_DUMMY_AVATAR.src,
width: () => I_DUMMY_AVATAR.width,
height: () => I_DUMMY_AVATAR.height,
},
},
},
});
const BookConnectionFactory = defineBookConnectionFactory({
transientFields: {
count: 3,
totalCount: 4,
},
defaultFields: {
totalCount: ({ transientData }) => transientData.totalCount,
edges: async ({ transientData }) => BookEdgeFactory.buildList(transientData.count),
pageInfo: async ({ get }) => PageInfoFactory.build({
hasNextPage: true,
hasPrevPage: false,
startCursor: (await get('edges'))?.at(0)?.cursor ?? null,
endCursor: (await get('edges'))?.at(-1)?.cursor ?? null,
},
},
});
以下ポイント
デフォルトの field をセットするのはdefaultFields
prisma-fabbrica のdefaultData相当のやつ
graphql-fabbrica は prisma 関係ないので、素直にdefaultFieldsと呼ぶことに
traits.<trait-name>ではなくtraits.<trait-name>.fieldsにする
将来的にfields以外を拡張できるようにしてる
2023/8/14 mizdra.icon ざっとインターフェイスは定まったので、次は実装計画を立てる。