Prisma で schema.prisma で書いたコメントをデータベースへも反映する
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
があれば、それを使うように - テーブルのコメントにも対応
最終的なコードは下記の通りです。
/**
* 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