作りたい。

factoryboyをご存知ですか?

factory_boy — Factory Boy stable documentation

pythonでテスト実装する際、ダミーデータの実装に役に立つライブラリなのですが、これがまー使いやすい。

ので、prismaでシード作るときにも同じような感じで使いたいなーと思い、実現方法を考えてみました。

factory-botを使ってみる

調べてみると、factory-botというのがありました。

Git

これを使ってみたいと思います。

とりあえずディレクトリを作って初期化します。

mkdir prisma-factory-bot
cd prisma-factory-bot
npm init

次に、typescriptの実行基盤とお目当てのfactory-botをインストールします。

npm i -D typescript ts-node @types/node
npm i factory-bot

これで準備できました。

最も簡易的な形の実装は以下のとおりです。

import { factory } from "factory-bot";

class User {
    username: string;
    score: number;

    constructor(attrs: { username: string, score: number }) {
        this.username = attrs.username;
        this.score = attrs.score;
    }
}

factory.define('user', User, {
    username: 'Bob',
    score: 50,
});

(async () => {
    const user = await factory.build<User>('user');
    console.log(user);
})();

とりあえず動く形になりました。

実行すると、生成されたUserが表示されます。

User { username: 'Bob', score: 50 }

bulidMany関数を使うと、複数のモデルをいっぺんに作ることができます。

    const users = await factory.buildMany<User>("user", 5);
    console.log(users);

いい感じに動きますね〜

Prisma側にスキーマを定義する

ではprismaをインストールして初期化します。

npm i -D prisma
npx prisma init --datasource-provider sqlite # 今回はSQLiteを使用します

次にスキーマを初期化しておきましょう。

ガイドのスキーマをまるっとコピペします。

model Post {
  id        Int     @id @default(autoincrement())
  title     String
  content   String?
  published Boolean @default(false)
  author    User?   @relation(fields: [authorId], references: [id])
  authorId  Int?
}

model User {
  id    Int     @id @default(autoincrement())
  email String  @unique
  name  String?
  posts Post[]
}

マイグレーションしておきます。

npx prisma migrate dev --name init

これで準備が整いました。

prismaとfactory-botを組み合わせる

思ったよりfactory-botの実装に柔軟性があったので、さっとprismaと組み合わせてseed.tsを作ります。

その前に一度使えそうなジェネレーターをインストールしておきます。

schema.prismaのモデルからクラスを生成するジェネレータです。

GitHub - kimjbstar/prisma-class-generator: Class generator from Prisma schema.

npm i prisma-class-generator

schema.prismaにジェネレータを追加しておきます。

generator prismaClassGenerator {
  provider = "prisma-class-generator"
  dryRun = false
}

これで準備ができたので、一度生成します。

npx prisma generate

生成したクラスを利用して、factory-botに組み込んでみます。

import { PrismaModel } from './src/_gen/prisma-class';
import { factory } from "factory-bot";

factory.define('user', PrismaModel.User, {
	email: factory.seq('User.email', (n) => `user${n}@test.com`),
	name: "string",
	posts: []
});

(async () => {
    const user = await factory.build<PrismaModel.User>('user');
    console.log(user);
    const users = await factory.buildMany<PrismaModel.User>("user", 5);
    console.log(users);
})();

実行します。

% npx ts-node index.ts
/home/username/Project/study/prisma-factory-bot/node_modules/ts-node/src/index.ts:859
    return new TSError(diagnosticText, diagnosticCodes, diagnostics);
           ^
TSError: ⨯ Unable to compile TypeScript:
src/_gen/prisma-class/post.ts(2,50): error TS2307: Cannot find module '@nestjs/swagger' or its corresponding type declarations.
src/_gen/prisma-class/post.ts(6,2): error TS2564: Property 'id' has no initializer and is not definitely assigned in the constructor.
src/_gen/prisma-class/post.ts(9,2): error TS2564: Property 'title' has no initializer and is not definitely assigned in the constructor.
src/_gen/prisma-class/post.ts(15,2): error TS2564: Property 'published' has no initializer and is not definitely assigned in the constructor.

    at createTSError (/home/username/Project/study/prisma-factory-bot/node_modules/ts-node/src/index.ts:859:12)
    at reportTSError (/home/username/Project/study/prisma-factory-bot/node_modules/ts-node/src/index.ts:863:19)
    at getOutput (/home/username/Project/study/prisma-factory-bot/node_modules/ts-node/src/index.ts:1077:36)
    at Object.compile (/home/username/Project/study/prisma-factory-bot/node_modules/ts-node/src/index.ts:1433:41)
    at Module.m._compile (/home/username/Project/study/prisma-factory-bot/node_modules/ts-node/src/index.ts:1617:30)
    at Module._extensions..js (node:internal/modules/cjs/loader:1310:10)
    at Object.require.extensions.<computed> [as .ts] (/home/username/Project/study/prisma-factory-bot/node_modules/ts-node/src/index.ts:1621:12)
    at Module.load (node:internal/modules/cjs/loader:1119:32)
    at Function.Module._load (node:internal/modules/cjs/loader:960:12)
    at Module.require (node:internal/modules/cjs/loader:1143:19) {
  diagnosticCodes: [ 2307, 2564, 2564, 2564 ]

エラーになってしまいました。

Swagger関連の実装があるのが邪魔なので、設定を修正して再生成します。

generator prismaClassGenerator {
  provider = "prisma-class-generator"
  dryRun = false
  useSwagger = false # これを追加
}
npx prisma generate

再実行しました。

npx ts-node index.ts
/home/username/Project/study/prisma-factory-bot/node_modules/ts-node/src/index.ts:859
    return new TSError(diagnosticText, diagnosticCodes, diagnostics);
           ^
TSError: ⨯ Unable to compile TypeScript:
src/_gen/prisma-class/post.ts(4,2): error TS2564: Property 'id' has no initializer and is not definitely assigned in the constructor.
src/_gen/prisma-class/post.ts(6,2): error TS2564: Property 'title' has no initializer and is not definitely assigned in the constructor.
src/_gen/prisma-class/post.ts(10,2): error TS2564: Property 'published' has no initializer and is not definitely assigned in the constructor.

    at createTSError (/home/username/Project/study/prisma-factory-bot/node_modules/ts-node/src/index.ts:859:12)
    at reportTSError (/home/username/Project/study/prisma-factory-bot/node_modules/ts-node/src/index.ts:863:19)
    at getOutput (/home/username/Project/study/prisma-factory-bot/node_modules/ts-node/src/index.ts:1077:36)
    at Object.compile (/home/username/Project/study/prisma-factory-bot/node_modules/ts-node/src/index.ts:1433:41)
    at Module.m._compile (/home/username/Project/study/prisma-factory-bot/node_modules/ts-node/src/index.ts:1617:30)
    at Module._extensions..js (node:internal/modules/cjs/loader:1310:10)
    at Object.require.extensions.<computed> [as .ts] (/home/username/Project/study/prisma-factory-bot/node_modules/ts-node/src/index.ts:1621:12)
    at Module.load (node:internal/modules/cjs/loader:1119:32)
    at Function.Module._load (node:internal/modules/cjs/loader:960:12)
    at Module.require (node:internal/modules/cjs/loader:1143:19) {
  diagnosticCodes: [ 2564, 2564, 2564 ]

一つエラーが減りました。

ただ他にもエラーが出ています。

これに関してもいろいろ調べたんですが、オプションを追加することで対処可能でした。

generator prismaClassGenerator {
  provider = "prisma-class-generator"
  dryRun = false
  useSwagger = false
  useNonNullableAssertions = true # これを追加
}

ただ、追加してもprisma-class-generatorがコンストラクタを生成してくれないため、作成されたモデルは空になってしまいます。

こんな場合に使えるObjectAdapterというのがあるので、これを使いましょう。

import { PrismaModel } from './src/_gen/prisma-class';
import { factory } from "factory-bot";

const FactoryBot = require('factory-bot');

const adapter = new FactoryBot.ObjectAdapter();

factory.setAdapter(adapter);

factory.define('user', PrismaModel.User, {
    id: factory.seq('User.id', (n) => n),
	email: factory.seq('User.email', (n) => `user${n}@test.com`),
	name: "string",
	posts: [
        {
            id: factory.seq('Post.id.sub', (n) => n),
            title: "string",
            published: true,
        },
    ]
});

factory.define('post', PrismaModel.Post, {
    id: factory.seq('Post.id', (n) => n),
    title: "string",
    published: true,
});

(async () => {
    const posts = await factory.buildMany<PrismaModel.Post>("post", 5);
    console.log(posts);
    const users = await factory.buildMany<PrismaModel.User>("user", 5);
    console.log(users);
    console.log(users[0].posts)
})();

これを実行すると、オブジェクトが生成されていることが確認できます。

% npx ts-node index.ts
[
  Post { id: 1, title: 'string', published: true },
  Post { id: 2, title: 'string', published: true },
  Post { id: 3, title: 'string', published: true },
  Post { id: 4, title: 'string', published: true },
  Post { id: 5, title: 'string', published: true }
]
[
  User {
    posts: [ [Object] ],
    id: 1,
    email: 'user1@test.com',
    name: 'string'
  },
  User {
    posts: [ [Object] ],
    id: 2,
    email: 'user2@test.com',
    name: 'string'
  },
  User {
    posts: [ [Object] ],
    id: 3,
    email: 'user3@test.com',
    name: 'string'
  },
  User {
    posts: [ [Object] ],
    id: 4,
    email: 'user4@test.com',
    name: 'string'
  },
  User {
    posts: [ [Object] ],
    id: 5,
    email: 'user5@test.com',
    name: 'string'
  }
]
[ { id: 1, title: 'string', published: true } ]

少しいい感じになりましたね。

ではここから更にPrismaでデータ生成もできるようにしていきましょう。

import { PrismaClient } from '@prisma/client';
import { PrismaModel } from './src/_gen/prisma-class';
import { factory } from "factory-bot";

const FactoryBot = require('factory-bot');

class PrismaAdapter extends FactoryBot.ObjectAdapter {
    prisma = new PrismaClient();

    async save<T extends { new (...args: any[]): any }>(model: any, modelClass: T) {
        const className = modelClass.name;
        const result = await (this.prisma as any)[className].create({data: {...model}});
        return result;
    }

    async destroy<T extends { new (...args: any[]): any }>(model: any, modelClass: T) {
        console.log("destroy")
        const className = modelClass.name;
        await (this.prisma as any)[className].deleteMany();
        return model;
    }
}

const adapter = new PrismaAdapter();

factory.setAdapter(adapter);

factory.define('user', PrismaModel.User, {
    id: factory.seq('User.id', (n) => n),
	email: factory.seq('User.email', (n) => `user${n}@test.com`),
	name: "string",
    posts: {
        create: {
            title: "string",
            published: true,
        },
    },
});

factory.define('post', PrismaModel.Post, {
    id: factory.seq('Post.id', (n) => n),
    title: "string",
    published: true,
});

(async () => {
    await adapter.prisma.user.deleteMany();
    await adapter.prisma.post.deleteMany();

    const posts = await factory.createMany<PrismaModel.Post>("post", 5);
    console.log(posts);
    const users = await factory.createMany<PrismaModel.User>("user", 5);
    console.log(users);
})();

adapterという便利な機能があるので、これでPrisma用のAdapterを実装しました。

また、Userの作成はPrismaのリレーション作成機能があるのでそれを使っています。

これをシードとして実行します。

% npx prisma db seed 
Environment variables loaded from .env
Running seed command `ts-node prisma/seed.ts` ...
[
  {
    id: 1,
    title: 'string',
    content: null,
    published: true,
    authorId: null
  },
  {
    id: 2,
    title: 'string',
    content: null,
    published: true,
    authorId: null
  },
  {
    id: 3,
    title: 'string',
    content: null,
    published: true,
    authorId: null
  },
  {
    id: 4,
    title: 'string',
    content: null,
    published: true,
    authorId: null
  },
  {
    id: 5,
    title: 'string',
    content: null,
    published: true,
    authorId: null
  }
]
[
  { id: 1, email: 'user1@test.com', name: 'string' },
  { id: 2, email: 'user2@test.com', name: 'string' },
  { id: 3, email: 'user3@test.com', name: 'string' },
  { id: 4, email: 'user4@test.com', name: 'string' },
  { id: 5, email: 'user5@test.com', name: 'string' }
]

🌱  The seed command has been executed.

データが作成されました!

まとめ

今回はfactory-botで便利なシードを実装しました。

factoryboyにはSubFactoryというのがあって、これを使うと自動で作成までしてくれたりするんですが、そのへんも実装できたら嬉しいですね。。

(Factory実装も独自の実装にしてあげればできそう)

2023-08-24 追記

SubFactory的な実装ありました。

assocというらしいです。

import { PrismaClient, Prisma } from '@prisma/client';
import { PrismaModel } from './src/_gen/prisma-class';
import { factory } from "factory-bot";

const FactoryBot = require('factory-bot');

class FunctionAdapter extends FactoryBot.ObjectAdapter {
    async build(func: (args: any) => any, props: any) {
        return props;
    }

    async save(model: any, func: (args: any) => any) {
        return await func(model);
    }
}

const objectAdapter = new FactoryBot.ObjectAdapter();
const funcAdapter = new FunctionAdapter();

factory.setAdapter(funcAdapter);

const prisma = new PrismaClient();

factory.define("user", prisma.user.create, {
    data: {
        id: factory.seq('User.id', (n) => n),
        email: factory.seq('User.email', (n) => `user${n}@test.com`),
        name: "string",
        posts: {
            create: factory.assocAttrs("post", "data")
        },
    }
});

factory.define('post', prisma.post.create, {
    data: {
        id: factory.seq('Post.id', (n) => n),
        title: "string",
        published: true,
    }
});

(async () => {
    await prisma.user.deleteMany();
    await prisma.post.deleteMany();

    const user = await factory.create("user");
    console.log(user);
    const users = await factory.createMany("user", 5);
    console.log(users);
    const posts = await factory.createMany<PrismaModel.Post>("post", 5);
    console.log(posts);
})();

assocを使用するのと、factory.defineで直接prisma.user.create関数を使用するようにしました。

assocを使用すると、作成されたモデルを元にして実行し、assocAttrsを使用するとfactory.defineに定義したattrsを参照するようになります。

今回の場合、作成したモデルをそのまま使うとエラーになってしまったためassocAttrsを使用するようにしました。

なかなかいいライブラリだなぁこれ…。