💎

Prisma で schema.prisma で書いたコメントをデータベースへも反映する

2024/07/28に公開

Prisma では、schema.prisma でトリプルスラッシュ(///)でコメントを書くと、抽象構文木(AST)に情報として含まれるようになります。

生成されたクライアントのコードにも、このコメントを含んでくれます。(といっても一部だけのようで、もう少しサポートして欲しい気も..)

これがデータベース側のコメントとしても設定されると、SchemaSpy のような、データベースの内容からドキュメントを作るようなツールでも利用できる情報になって便利なのですが、Prisma本体としては5.17.0時点で対応されていません。

Prisma 側の状況

複数のIssueで要望があがっていますが、下記Issueが一番まとまっています。

2023年11月時点で「実装する可能性は高いが、予定はまだ決まってない」とコメントされており、そこからそのままの状態です。

ただ、Generatorを使った方法がこのIssueの中で提示されており、それが代替策となっているようでした。

Generatorを使った方法を試してみる

代替策として提示されたいたものを試してみました。
Prisma のバージョンは 5.17.0 、データベースは PostgreSQL 16 を使って試しています。

最終的には、上記コードからいくつか変えて期待するものとなりました。

  • @prisma/generator-helper@prisma/internals のインポート方法を変更
    • 関数をそのままimportする形だとうまくいかず、下記のような形に変えてうまくいきました
      - import { EnvValue, GeneratorOptions, generatorHandler } from '@prisma/generator-helper';
      + import GeneratorHelper, { EnvValue, GeneratorOptions } from "@prisma/generator-helper";
      + const { generatorHandler } = GeneratorHelper;
      
  • フィールドの方もdbNameがあれば、それを使うように
  • テーブルのコメントにも対応

最終的なコードは下記の通りです。

prisma/comments-generator.ts
/**
 * Based on the code below.
 * https://github.com/prisma/prisma/issues/8703#issuecomment-1614360386
 */

/**
 * This is a custom generator for Prisma that generates comments for all models fields and
 *  handles creating a migration file for them when comments change.
 *
 * The comments are generated from the documentation field in the Prisma schema (e.g. the /// comments
 *  in the schema file).
 *
 * It works based on a lock file of all comment statements. When it detects that the comments have
 * changed (by comparing the sha256 hash of them), the commited lock file will be updated. In addition,
 * a new migration file will be created with all comments.
 *
 * For our purposes its not a big issue since running the sql statements that add comments
 *  should be cheap anyway.
 *
 * This is a workaround to have https://github.com/prisma/prisma/issues/8703 before it is implemented
 * in Prisma itself.
 */

/* eslint-disable @typescript-eslint/no-explicit-any */

import { createHash } from "crypto";
import debug from "debug";
import { promises as fs } from "fs";

import GeneratorHelper, {
  EnvValue,
  GeneratorOptions,
} from "@prisma/generator-helper";
import Internals from "@prisma/internals";

const { generatorHandler } = GeneratorHelper;
const { getDMMF, parseEnvValue } = Internals;

const debugLog = debug("prisma:generate-comments");

async function generateModelComment(model: any): Promise<string[]> {
  const modelName = model.dbName ?? model.name;

  const commentStatements: string[] = [];

  if (model.documentation) {
    debugLog(`Generating comment for ${modelName}...`);

    const escapedComment = model.documentation?.replace(/'/g, "''") ?? "";

    const commentTemplate = `COMMENT ON TABLE "${modelName}" IS '${escapedComment}';`;
    commentStatements.push(commentTemplate);
  }

  model.fields.forEach((field: any) => {
    if (!field.documentation) {
      return;
    }

    const fieldName = field.dbName ?? field.name;

    debugLog(`Generating comment for ${modelName}.${fieldName}...`);

    const escapedComment = field.documentation?.replace(/'/g, "''") ?? "";

    const commentTemplate = `COMMENT ON COLUMN "${modelName}"."${fieldName}" IS '${escapedComment}';`;
    commentStatements.push(commentTemplate);
  });

  return [`-- Model ${modelName} comments`, "", ...commentStatements, ""];
}

async function fileHash(file: string, allowEmpty = false): Promise<string> {
  try {
    const fileContent = await fs.readFile(file, "utf-8");

    // now use sha256 to hash the content and return it
    return createHash("sha256").update(fileContent).digest("hex");
  } catch (e: any) {
    if (e.code === "ENOENT" && allowEmpty) {
      return "";
    }

    throw e;
  }
}

async function lockChanged(
  lockFile: string,
  tmpLockFile: string,
): Promise<boolean> {
  return (await fileHash(lockFile, true)) !== (await fileHash(tmpLockFile));
}

export async function generate(options: GeneratorOptions) {
  const outputDir = parseEnvValue(options.generator.output as EnvValue);
  await fs.mkdir(outputDir, { recursive: true });

  const prismaClientProvider = options.otherGenerators.find(
    (it) => parseEnvValue(it.provider) === "prisma-client-js",
  );

  const prismaClientDmmf = await getDMMF({
    datamodel: options.datamodel,
    previewFeatures: prismaClientProvider?.previewFeatures,
  });

  const promises: Promise<string[]>[] = [];

  prismaClientDmmf.datamodel.models.forEach((model: any) => {
    debugLog(`Generating comment for ${model.name}...`);
    promises.push(generateModelComment(model));
  });

  const allStatements = await Promise.all(promises);

  const tmpLock = await fs.open(`${outputDir}/.comments-lock.tmp`, "w+");

  await tmpLock.write("-- generator-version: 1.0.0\n\n");

  // concat all promises and separate with new line and two newlines between each model
  const allStatementsString = allStatements
    .map((statements) => statements.join("\n"))
    .join("\n\n");

  await tmpLock.write(allStatementsString);
  await tmpLock.close();

  // compare hashes of tmp lock file and existing lock file
  // if they are the same, do nothing
  // if they are different, write tmp lock file to lock file
  // if lock file does not exist, also write tmp lock file to lock file
  const isChanged = await lockChanged(
    `${outputDir}/.comments-lock`,
    `${outputDir}/.comments-lock.tmp`,
  );

  if (isChanged) {
    await fs.copyFile(
      `${outputDir}/.comments-lock.tmp`,
      `${outputDir}/.comments-lock`,
    );

    // when lockfile changed we generate a new migration file too
    const date = new Date();
    date.setMilliseconds(0);

    const dateStr = date
      .toISOString()
      .replace(/[:\-TZ]/g, "")
      .replace(".000", "");
    const migrationDir = `prisma/migrations/${dateStr}_update_comments`;

    console.log(
      `Lock file changed, creating a new migration at ${migrationDir}...`,
    );

    await fs.mkdir(migrationDir, { recursive: true });

    await fs.copyFile(
      `${outputDir}/.comments-lock`,
      `${migrationDir}/migration.sql`,
    );
  } else {
    console.log(
      "No changes detected, skipping creating a fresh comment migration...",
    );
  }

  // always delete tmp lock file
  await fs.unlink(`${outputDir}/.comments-lock.tmp`);

  console.log("Comment generation completed");
}

generatorHandler({
  onManifest() {
    return {
      defaultOutput: "comments",
      prettyName: "Prisma Database comments Generator",
    };
  },
  onGenerate: generate,
});

schema.prismaには、下記を追加します。

generator comments {
  provider = "tsx prisma/comments-generator.ts"
}

これでprisma generateで生成されるようになります。
なお、Generatorで実施しているので、通常のマイグレーションSQLとは別で生成されます。(現在日時+_update_commentsというフォルダで作られる)

Generatorで実施していること&改善したいところ

このGeneratorでは、ASTの情報からCOMMENT文を作っています。

前回のものをcomments/.comments-lockといったファイルで保持しておき、前回と差分があったら、今回生成したCOMMENT文全部をマイグレーションフォルダに配置といった形になっています。
そのため、差分となる部分だけをCOMMENT文としてくれるのではありません。

COMMENT文は上書きされるので、特に問題無いのですが、、

  • 毎回全コメントとなり、差分がわかりずらい
  • コメントを消した場合に反映されない
    • 消すシチュエーションは少ない気がするので実害無し?

といったところがあります。

時間が取れたら改善したいのと、npmのパッケージにしたいですね、、

Discussion