Drizzle ORMでの初期データ投入戦略案

# はじめに

本記事は [Progaku Advent Calendar 2024](https://qiita.com/advent-calendar/2024/progaku) 19日目の記事になります。

趣味でDrizzle ORMを触っていて、Djangoにある [fixture](https://docs.djangoproject.com/ja/5.1/topics/db/fixtures/) のような仕組みが欲しくなりました。
[seedデータを作る機能](https://orm.drizzle.team/docs/seed-overview)は提供されていますが、私が欲しいのは固定値が投入される機能です。

初回に流す用ファイルは愚直に書くでもよかったのですが、面白そうなのとせっかくアドベントカレンダーのネタになりそうな機能だったので自作してみました。

ただ考慮事項がまったく足りていないので実運用に耐えられなさそうです。あくまで戦略案として参考程度に留めていただきたいです。

# 環境

- [docker: mysql](https://hub.docker.com/_/mysql)
- [docker: phpmyadmin](https://hub.docker.com/_/phpmyadmin)

## docker-compose

http://localhost:8080/ でphpMyAdminに繋がるようにしています。

```yml
version: '3'

services:
  db:
    image: mysql
    container_name: mysql_container
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: password
      MYSQL_DATABASE: ${MYSQL_DATABASE}
      MYSQL_USER: ${MYSQL_USER}
      MYSQL_PASSWORD: ${MYSQL_PASSWORD}
    ports:
      - ${MYSQL_PORT}:${MYSQL_PORT}
    volumes:
      - db_data:/var/lib/mysql

  phpmyadmin:
    image: phpmyadmin/phpmyadmin
    container_name: phpmyadmin_container
    restart: always
    environment:
      PMA_HOST: db
      PMA_USER: ${MYSQL_USER}
      PMA_PASSWORD: ${MYSQL_PASSWORD}
      PMA_ABSOLUTE_URI: http://localhost:8080/
    ports:
      - 8080:80
    depends_on:
      - db

volumes:
  db_data:
```

## env

```
MYSQL_HOST=localhost
MYSQL_DATABASE=my_database
MYSQL_PORT=3306
MYSQL_USER=my_user
MYSQL_PASSWORD=password0000
```

# 戦略案

`fixtures/` 配下に投入したい初期データを下記のようにJSON形式で記載します。
そして、ファイル名はテーブル名と同じ名前にします。

```json
[
  {
    "id": 1,
    "createdAt": "1970-01-01T00:00:00Z",
    "createdBy": 1,
    "name": "JavaScript"
  },
  {
    "id": 2,
    "createdAt": "1970-01-01T00:00:00Z",
    "createdBy": 1,
    "name": "TypeScript"
  },
  {
    "id": 3,
    "createdAt": "1970-01-01T00:00:00Z",
    "createdBy": 1,
    "name": "CoffeeScript"
  }
]
```

fixtures配下にある初期データを、初期データ投入スクリプトから呼び出し、テーブル間のリレーションを考慮した順番で、ファイル名にしているテーブルに初期データを投入します。

## 例

文章に自信がないので具体例を出します。

まずはディレクトリ構造は下記を想定します。

```
src/
├── fixtures/
│   ├── tag.json
│   └── user.json
├── init.ts
└── schema.ts
```

そして、テーブルは下記の通りに定義されているものとします。

```sql
CREATE TABLE `user` (
  `id` INT AUTO_INCREMENT PRIMARY KEY,
  `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `name` VARCHAR(256) NOT NULL,
  `code` VARCHAR(256) NOT NULL UNIQUE KEY `uniq_code`
);

CREATE TABLE `tag` (
  `id` INT AUTO_INCREMENT PRIMARY KEY,
  `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `created_by` INT NOT NULL,
  `name` VARCHAR(256) NOT NULL UNIQUE KEY `uniq_name`,
  FOREIGN KEY (`created_by`) REFERENCES `user`(`id`) ON DELETE CASCADE
);
```

各jsonファイルの中身は下記のようになっています。。

- tag.json

```json
[
  {
    "id": 1,
    "createdAt": "1970-01-01T00:00:00Z",
    "createdBy": 1,
    "name": "JavaScript"
  },
  {
    "id": 2,
    "createdAt": "1970-01-01T00:00:00Z",
    "createdBy": 1,
    "name": "TypeScript"
  },
  {
    "id": 3,
    "createdAt": "1970-01-01T00:00:00Z",
    "createdBy": 1,
    "name": "CoffeeScript"
  }
]
```

- user.json

```json
[
  {
    "id": 1,
    "createdAt": "1970-01-01T09:00:00",
    "name": "管理者",
    "code": "admin"
  }
]
```

本記事で作成するプログラム `init.ts` を流すと、下記のように、依存関係順 `user -> tag` へ `fixtures` 配下に定義されているjsonファイルのレコードがDBに投入されます。 (tagはuserへの外部キーを持っている)

```sql
INSERT INTO user (id, createdAt, name, code)
VALUES
    (1, '1970-01-01T00:00:00Z', '管理者', 'admin');

INSERT INTO tag (id, createdAt, createdBy, name)
VALUES
    (1, '1970-01-01T00:00:00Z', 1, 'JavaScript'),
    (2, '1970-01-01T00:00:00Z', 1, 'TypeScript'),
    (3, '1970-01-01T00:00:00Z', 1, 'CoffeeScript');
```

これができるようになることを目指します！

## 未考慮事項

現在私が見つけている未考慮事項を列挙します。

- 複数schemeへの投入
- テーブルの数が増えた時
- fixtureのレコード数が膨大な場合
- 閉路がある場合
- トポロジカルソートの妥当性

# init

今回はMySQLで行いたいので、

https://orm.drizzle.team/docs/get-started/mysql-new

に従い設定していきます。

## install

```sh
npm i drizzle-orm mysql2 dotenv
npm i -D drizzle-kit tsx
```

また、オブジェクトをイジイジするため[Remeda](https://remedajs.com/)も入れます。

```sh
npm i remeda
```

## ディレクトリ構成

```
.
├── .env
├── README.md
├── docker-compose.yml
├── drizzle.config.ts
├── node_modules/
├── package-lock.json
├── package.json
├── src
│   └── db
│       ├── drizzle
│       ├── fixtures
│       │   ├── article.json
│       │   ├── tag.json
│       │   ├── user.json
│       │   └── user_tag_relation.json
│       ├── init.ts
│       └── schema.ts
└── tsconfig.json
```

## drizzle.config.ts

```ts
import 'dotenv/config';
import { defineConfig } from 'drizzle-kit';

export default defineConfig({
  out: "./src/db/drizzle",
  schema: './src/db/schema.ts',
  dialect: 'mysql',
  dbCredentials: {
    user: process.env.MYSQL_USER || "root",
    password: process.env.MYSQL_PASSWORD || "password",
    host: process.env.MYSQL_HOST || "localhost",
    port: Number(process.env.MYSQL_PORT) || 3306,
    database: process.env.MYSQL_DATABASE || "my_database"
  },
});

```

## schema.ts

テーブルを定義します。考慮外なので[schema](https://orm.drizzle.team/docs/schemas)は作らないです。

```ts
import { boolean, int, mysqlTable, text, timestamp, uniqueIndex, varchar } from 'drizzle-orm/mysql-core';

export const User = mysqlTable('user', {
  id: int('id').autoincrement().primaryKey(),
  createdAt: timestamp('created_at', { mode: 'string' }).notNull().defaultNow(),
  name: varchar('name', { length: 256 }).notNull(),
  code: varchar('code', { length: 256 }).notNull().unique('uniq_code'),
});

export const Tag = mysqlTable('tag', {
  id: int('id').autoincrement().primaryKey(),
  createdAt: timestamp('created_at', { mode: 'string' }).notNull().defaultNow(),
  createdBy: int('created_by')
    .notNull()
    .references(() => User.id, { onDelete: 'cascade' }),
  name: varchar('name', { length: 256 }).notNull().unique('uniq_name'),
  isOfficial: boolean('is_official').notNull().default(false),
});

export const UserTagRelation = mysqlTable(
  'user_tag_relation',
  {
    id: int('id').autoincrement().primaryKey(),
    createdAt: timestamp('created_at', { mode: 'string' }).notNull().defaultNow(),
    userId: int('user_id')
      .notNull()
      .references(() => User.id, { onDelete: 'cascade' }),
    tagId: int('tag_id')
      .notNull()
      .references(() => Tag.id, { onDelete: 'cascade' }),
  },
  (table) => ({
    uniqUserAndTag: uniqueIndex('user_and_tag_uniq').on(table.userId, table.tagId),
  }),
);

export const Article = mysqlTable(
  'article',
  {
    id: int('id').autoincrement().primaryKey(),
    createdAt: timestamp('created_at', { mode: 'string' }).notNull().defaultNow(),
    updatedAt: timestamp('updated_at', { mode: 'string' })
      .notNull()
      .defaultNow()
      .$onUpdate(() => new Date().toISOString()),
    title: varchar('title', { length: 256 }).notNull(),
    articlePath: text('article_path').notNull(),
    userId: int('user_id')
      .notNull()
      .references(() => User.id, { onDelete: 'cascade' }),
  },
  (table) => ({
    uniqUserAndArticleTitle: uniqueIndex('article_title_and_user_uniq').on(table.userId, table.title),
  }),
);

export const ArticleTagRelation = mysqlTable(
  'article_tag_relation',
  {
    id: int('id').autoincrement().primaryKey(),
    createdAt: timestamp('created_at', { mode: 'string' }).notNull().defaultNow(),
    articleId: int('article_id')
      .notNull()
      .references(() => Article.id, { onDelete: 'cascade' }),
    tagId: int('tag_id')
      .notNull()
      .references(() => Tag.id, { onDelete: 'cascade' }),
  },
  (table) => ({
    uniqArticleAndTag: uniqueIndex('article_and_tag_uniq').on(table.articleId, table.tagId),
  }),
);
```

### ER図

ER図にすると下記の通り。

```mermaid
erDiagram
    User ||--o{ Tag : "createdBy"
    User ||--o{ UserTagRelation : "userId"
    Tag ||--o{ UserTagRelation : "tagId"
    User ||--o{ Article : "userId"
    Article ||--o{ ArticleTagRelation : "articleId"
    Tag ||--o{ ArticleTagRelation : "tagId"
```

## マイグレーション

これでMySQLにテーブルが作成されます。

```sh
# container start
docker-compose up -d
# generate migrate file
npx drizzle-kit generate
# migrate
npx drizzle-kit migrate
```

### docker

終わったら下記のコマンドでお掃除できます。

```sh
# container down
docker-compose down
# remove volumes
docker-compose down -v
# remove images
docker images -qa | xargs docker rmi
```

# fixtures

fixtureは下記の通りに定義します。
テーブル名をファイル名として、各カラムに入れたいレコードをオブジェクトで表現します。
ファイル名はDBに合わせているのにレコードのカラムはdrizzleで定義したschemaのプロパティ名になっていて、ブレブレなのが気に食わないのですが、こだわるには面倒だったのでそのままにしてあります。

<details><summary>article.json</summary>

```json
[
  {
    "id": 1,
    "createdAt": "1970-01-01T09:00:00",
    "updatedAt": "1970-01-01T09:00:00",
    "title": "はじめまして",
    "articlePath": "path/to/initial",
    "userId": 1
  }
]
```
</details>

<details><summary>tag.json</summary>

```json
[
  {
    "id": 1,
    "createdAt": "1970-01-01T09:00:00",
    "createdBy": 1,
    "name": "JavaScript",
    "isOfficial": true
  },
  {
    "id": 2,
    "createdAt": "1970-01-01T09:00:00",
    "createdBy": 1,
    "name": "TypeScript",
    "isOfficial": true
  },
  {
    "id": 3,
    "createdAt": "1970-01-01T09:00:00",
    "createdBy": 1,
    "name": "CoffeeScript",
    "isOfficial": true
  },
  {
    "id": 4,
    "createdAt": "1970-01-01T09:00:00",
    "createdBy": 1,
    "name": "React",
    "isOfficial": true
  },
  {
    "id": 5,
    "createdAt": "1970-01-01T09:00:00",
    "createdBy": 1,
    "name": "Angular",
    "isOfficial": true
  },
  {
    "id": 6,
    "createdAt": "1970-01-01T09:00:00",
    "createdBy": 1,
    "name": "Vue",
    "isOfficial": true
  },
  {
    "id": 7,
    "createdAt": "1970-01-01T09:00:00",
    "createdBy": 1,
    "name": "AWS",
    "isOfficial": true
  },
  {
    "id": 8,
    "createdAt": "1970-01-01T09:00:00",
    "createdBy": 1,
    "name": "AWS lambda",
    "isOfficial": true
  },
  {
    "id": 9,
    "createdAt": "1970-01-01T09:00:00",
    "createdBy": 1,
    "name": "EC2",
    "isOfficial": true
  },
  {
    "id": 10,
    "createdAt": "1970-01-01T09:00:00",
    "createdBy": 1,
    "name": "S3",
    "isOfficial": true
  }
]
```
</details>

<details><summary>user.json</summary>

```json
[
  {
    "id": 1,
    "createdAt": "1970-01-01T09:00:00",
    "name": "管理者",
    "code": "admin"
  },
  {
    "id": 2,
    "createdAt": "1970-01-01T09:00:00",
    "name": "フロント",
    "code": "front"
  },
  {
    "id": 3,
    "createdAt": "1970-01-01T09:00:00",
    "name": "AWS",
    "code": "aws"
  }
]
```
</details>

<details><summary>user_tag_relation.json</summary>

```json
[
  {
    "id": 1,
    "createdAt": "1970-01-01T09:00:00",
    "userId": 2,
    "tagId": 1
  },
  {
    "id": 2,
    "createdAt": "1970-01-01T09:00:00",
    "userId": 2,
    "tagId": 2
  },
  {
    "id": 3,
    "createdAt": "1970-01-01T09:00:00",
    "userId": 2,
    "tagId": 3
  },
  {
    "id": 4,
    "createdAt": "1970-01-01T09:00:00",
    "userId": 2,
    "tagId": 4
  },
  {
    "id": 5,
    "createdAt": "1970-01-01T09:00:00",
    "userId": 2,
    "tagId": 5
  },
  {
    "id": 6,
    "createdAt": "1970-01-01T09:00:00",
    "userId": 2,
    "tagId": 6
  },
  {
    "id": 7,
    "createdAt": "1970-01-01T09:00:00",
    "userId": 3,
    "tagId": 7
  },
  {
    "id": 8,
    "createdAt": "1970-01-01T09:00:00",
    "userId": 3,
    "tagId": 8
  },
  {
    "id": 9,
    "createdAt": "1970-01-01T09:00:00",
    "userId": 3,
    "tagId": 9
  },
  {
    "id": 10,
    "createdAt": "1970-01-01T09:00:00",
    "userId": 3,
    "tagId": 10
  }
]
```
</details>

## タイムスタンプが9時間ズレている件について

`JST 1970-01-01 09:00:00` よりも前はUNIX Timestampでは表現できないので、 `1970-01-01 09:00:00` としています。
今回はこれで影響がないし、考慮も面倒だったので、 `1970-01-01 09:00:00` で登録しています。

https://blog.pinkumohikan.com/entry/mysql-timestamp-cannot-insert-before-timestamp0

# 初期データ投入

下記の通りに `init.ts` を作成します。

```ts
import 'dotenv/config';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { drizzle } from 'drizzle-orm/mysql2';
import mysql, { type RowDataPacket } from 'mysql2/promise';
import * as R from 'remeda';

import { Table, getTableName } from 'drizzle-orm';
import * as Schema from './schema';

const QUERY = `SELECT
  TABLE_NAME AS table_name,
  REFERENCED_TABLE_NAME AS reference_table_name
FROM
  information_schema.KEY_COLUMN_USAGE`;

const fixturesDir = path.join(__dirname, 'fixtures');

function topologicalSort(dependencyMap: Map<string, Set<string>>): string[] {
  const sorted: string[] = [];
  const visited: Set<string> = new Set();
  const tempMarked: Set<string> = new Set();

  const visit = (node: string): void => {
    if (tempMarked.has(node)) {
      throw new Error(`${node}で閉路を検出`);
    }

    if (!visited.has(node)) {
      tempMarked.add(node);

      const dependencies = dependencyMap.get(node) || new Set();
      for (const dep of dependencies) {
        visit(dep);
      }

      tempMarked.delete(node);
      visited.add(node);
      sorted.push(node);
    }
  };

  for (const node of dependencyMap.keys()) {
    if (!visited.has(node)) {
      visit(node);
    }
  }

  return sorted;
}

async function main(): Promise<void> {
  const poolConnection = mysql.createPool({
    host: process.env.MYSQL_HOST,
    user: process.env.MYSQL_USER,
    password: process.env.MYSQL_PASSWORD,
    database: process.env.MYSQL_DATABASE,
    port: Number(process.env.MYSQL_PORT),
  });

  const db = drizzle({ client: poolConnection });

  const tables = R.entries(Schema).filter(([_, v]) => v instanceof Table);
  const schemeMap = new Map(tables.map(([_, v]) => [getTableName(v) as string, v]));
  const dependencyMap = new Map<string, Set<string>>(tables.map(([_, v]) => [getTableName(v), new Set<string>()]));

  const [params, _fp] = await poolConnection.execute<RowDataPacket[]>(QUERY);
  const relations = params.filter((res) => res.reference_table_name !== null) as {
    table_name: string;
    reference_table_name: string;
  }[];

  for (const r of relations) {
    const s = dependencyMap.get(r.table_name);
    if (s) {
      s.add(r.reference_table_name);
      dependencyMap.set(r.table_name, s);
    } else {
      throw Error(`schemeに定義していないテーブル: ${r.table_name}`);
    }
  }

  const sorted = topologicalSort(dependencyMap);

  const files = await fs.promises.readdir(fixturesDir);
  const fixtureFileSet = new Set(files.filter((f) => path.extname(f) === '.json').map((f) => path.parse(f).name));

  await db.transaction(async (tx) => {
    for (const tableName of sorted) {
      if (fixtureFileSet.has(tableName)) {
        const filePath = path.join(fixturesDir, `${tableName}.json`);
        const fileContent = await fs.promises.readFile(filePath, 'utf-8');
        const fixture = JSON.parse(fileContent);
        const targetScheme = schemeMap.get(tableName);
        if (!targetScheme) {
          throw Error();
        }

        await tx.insert(targetScheme).values(fixture);
      }
    }
  });

  await poolConnection.end();
}

main().then();
```

`npx tsx src/db/init.ts` で実行すると、各テーブルにレコードが投入されます。

## 解説

じゃああとはコードを読んで理解してくださいは不親切なので説明を入れていきます。

### テーブル名とモデルのマッピング

`getTableName` でテーブル名が取得できるので、 `{テーブル名: モデル}` のMapを作成します。
今回はMySQLでやっているのですが、モデルオブジェクトの `MySqlTableWithColumns` や `PgTableWithColumns` が `Table` を継承していたので、 `instanceof` でモデルのみに絞っています。
schemaの定義は `mysqlSchema` や `pgSchema` と各々で定義されていたので、多分これで弾けます(未検証)

```ts
const tables = R.entries(Schema).filter(([_, v]) => v instanceof Table);
const schemeMap = new Map(tables.map(([_, v]) => [getTableName(v) as string, v]));
```

### 関連テーブルを取得

[INFORMATION_SCHEMA KEY_COLUMN_USAGE](https://dev.mysql.com/doc/refman/8.0/ja/information-schema-key-column-usage-table.html)でテーブル情報と外部キーの参照先テーブルが取れます。
ただ、外部キーのないテーブルも自定義でないテーブルも取れるため、filterで弾いています。
information_schemaからの取得はdrizzleにモデルがなかったのでロークエリで取得しています。
Drizzleのロークエリよりmysql2のロークエリで実行した方が型的に楽だったので、 `poolConnection` で実行しています。

```ts
const QUERY = `SELECT
  TABLE_NAME AS table_name,
  REFERENCED_TABLE_NAME AS reference_table_name
FROM
  information_schema.KEY_COLUMN_USAGE`;
const [params, _fp] = await poolConnection.execute<RowDataPacket[]>(QUERY);
const relations = params.filter((res) => res.reference_table_name !== null) as {
    table_name: string;
    reference_table_name: string;
  }[];
```

### グルーピング

SQLでの取得なので、各テーブルごとにグルーピングされていません。後続処理で楽にするため、グルーピングします。

```ts
for (const r of relations) {
  const s = dependencyMap.get(r.table_name);
  if (s) {
    s.add(r.reference_table_name);
    dependencyMap.set(r.table_name, s);
  } else {
    throw Error(`schemeに定義していないテーブル: ${r.table_name}`);
  }
}
```

実行すると `dependencyMap` は下記のようになります。

```ts
Map(5) {
  'article' => Set(1) { 'user' },
  'article_tag_relation' => Set(2) { 'article', 'tag' },
  'tag' => Set(1) { 'user' },
  'user' => Set(0) {},
  'user_tag_relation' => Set(2) { 'tag', 'user' }
}
```

### 依存関係順にソート

トポロジカルソートで依存関係順に並べ替えます。
トポロジカルソートではありますが、メモ化しながら深さ優先探索をしているだけです。
トポロジカルソート部分を生成AIに詳しく解説してと投げれば具体例を交えて説明してくれるはずなので、ここでの説明は省きます
fixtureを上から順に愚直に投入すると、先にある投入データが後続のテーブルに対して外部キー制約を貼っていた場合、そこでエラーになってしまうためです。

```ts
function topologicalSort(dependencyMap: Map<string, Set<string>>): string[] {
  const sorted: string[] = [];
  const visited: Set<string> = new Set();
  const tempMarked: Set<string> = new Set();

  const visit = (node: string): void => {
    if (tempMarked.has(node)) {
      throw new Error(`${node}で閉路を検出`);
    }

    if (!visited.has(node)) {
      tempMarked.add(node);

      const dependencies = dependencyMap.get(node) || new Set();
      for (const dep of dependencies) {
        visit(dep);
      }

      tempMarked.delete(node);
      visited.add(node);
      sorted.push(node);
    }
  };

  for (const node of dependencyMap.keys()) {
    if (!visited.has(node)) {
      visit(node);
    }
  }

  return sorted;
}

const sorted = topologicalSort(dependencyMap);
```

依存関係順に並べ替えた結果がこちら。

```ts
[
  'user',
  'article',
  'tag',
  'article_tag_relation',
  'user_tag_relation'
]
```

### 依存関係順にレコードを投入

ソートした順序に、fixtures/配下にファイルが存在するか確認し、存在すればファイルの中身を読み込んでbulk_insertしています。
ファイル名がテーブル名にしているのはこのためです。
また、実装をミスしていた場合やJSONファイルの作成でミスしていたときに備えてトランザクションを貼っています。

```ts
const files = await fs.promises.readdir(fixturesDir);
const fixtureFileSet = new Set(files.filter((f) => path.extname(f) === '.json').map((f) => path.parse(f).name));

await db.transaction(async (tx) => {
  for (const tableName of sorted) {
    if (fixtureFileSet.has(tableName)) {
      const filePath = path.join(fixturesDir, `${tableName}.json`);
      const fileContent = await fs.promises.readFile(filePath, 'utf-8');
      const fixture = JSON.parse(fileContent);
      const targetScheme = schemeMap.get(tableName);
      if (!targetScheme) {
        throw Error();
      }

      await tx.insert(targetScheme).values(fixture);
    }
  }
});
```

### close

やっておきます。

```ts
await poolConnection.end();
```

# おわりに

最初、Drizzle ORMのモデル定義から依存関係を取ろうとしていたのですが上手く取れず、抽象的に考えてSQLで取るようにしたら上手くいきました。
[schemaspy](https://schemaspy.org/)を知っていたのがフックになりました。
schemaspyについては私の過去記事[schemaspyでER図を生成](https://qiita.com/masayasviel/items/4a1d03f2e570cc4778e5)をご覧ください。

繰り返しにはなりますが、あくまで本記事は初期データ投入の **"戦略案"** であり、未考慮が多い内容になっています。
参考程度に留めていただきたいです。自作するだけでなく、他ツールも検討ください。

作成してみてやっぱり面白かったです。
以上、[Progaku Advent Calendar 2024](https://qiita.com/advent-calendar/2024/progaku) 19日目でした。