import { AutoBeDatabase } from "@autobe/interface"; import crypto from "crypto"; import { ArrayUtil } from "../ArrayUtil"; import { MapUtil } from "../MapUtil"; import { StringUtil } from "../StringUtil"; export function writePrismaApplication(props: { dbms: "postgres" | "sqlite"; application: AutoBeDatabase.IApplication; }): Record { for (const file of props.application.files) for (const model of file.models) fillMappingName(model); return { ...Object.fromEntries( props.application.files .filter((file) => file.filename !== "main.prisma") .map((file) => [ file.filename, writeFile({ ...props, file, }), ]), ), "main.prisma": props.dbms === "postgres" ? POSTGRES_MAIN_FILE : SQLITE_MAIN_FILE, }; } function writeFile(props: { dbms: "postgres" | "sqlite"; application: AutoBeDatabase.IApplication; file: AutoBeDatabase.IFile; }): string { return props.file.models .map((model) => writeModel({ ...props, model, }), ) .join("\n\n"); } function writeModel(props: { dbms: "postgres" | "sqlite"; application: AutoBeDatabase.IApplication; file: AutoBeDatabase.IFile; model: AutoBeDatabase.IModel; }): string { return [ writeComment( [ props.model.description, "", ...(props.model.material ? [] : [`@namespace ${props.file.namespace}`]), "@author AutoBE - https://github.com/wrtnlabs/autobe", ].join("\n"), 80, ), `model ${props.model.name} {`, addIndent( ArrayUtil.paddle([writeColumns(props), writeRelations(props)]).join("\n"), ), "}", ].join("\n"); } function fillMappingName(model: AutoBeDatabase.IModel): void { const group: Map = new Map(); for (const ff of model.foreignFields) { MapUtil.take(group, ff.relation.targetModel, () => []).push(ff); if (ff.relation.targetModel == model.name) ff.relation.mappingName = "recursive"; } for (const array of group.values()) if (array.length !== 1) for (const ff of array) ff.relation.mappingName = shortName(`${model.name}_of_${ff.name}`); } /* ----------------------------------------------------------- COLUMNS ----------------------------------------------------------- */ function writeColumns(props: { dbms: "postgres" | "sqlite"; model: AutoBeDatabase.IModel; }): string[] { return [ "//----", "// COLUMNS", "//----", writePrimary({ dbms: props.dbms, model: props.model, field: props.model.primaryField, }), ...props.model.foreignFields .map((x) => [ "", writeField({ dbms: props.dbms, field: x, }), ]) .flat(), ...props.model.plainFields .map((x) => [ "", writeField({ dbms: props.dbms, field: x, }), ]) .flat(), ]; } function writePrimary(props: { dbms: "postgres" | "sqlite"; model: AutoBeDatabase.IModel; field: AutoBeDatabase.IPrimaryField; }): string { const type: string | undefined = props.dbms === "postgres" ? POSTGRES_PHYSICAL_TYPES.uuid : undefined; const pkeyName: string = `${props.model.name}__pkey`; const signature: string = pkeyName.length <= MAX_IDENTIFIER_LENGTH ? "@id" : `@id(map: "${shortName(pkeyName)}")`; return [ writeComment(props.field.description, 78), `${props.field.name} String ${signature}${type ? ` ${type}` : ""}`, ].join("\n"); } function writeField(props: { dbms: "postgres" | "sqlite"; field: AutoBeDatabase.IPlainField; }): string { const logical: string = LOGICAL_TYPES[props.field.type]; const physical: string | undefined = props.dbms === "postgres" ? POSTGRES_PHYSICAL_TYPES[ props.field.type as keyof typeof POSTGRES_PHYSICAL_TYPES ] : undefined; return [ writeComment(props.field.description, 78), [ props.field.name, `${logical}${props.field.nullable ? "?" : ""}`, ...(physical ? [physical] : []), ].join(" "), ].join("\n"); } /* ----------------------------------------------------------- RELATIONS ----------------------------------------------------------- */ function writeRelations(props: { dbms: "postgres" | "sqlite"; application: AutoBeDatabase.IApplication; model: AutoBeDatabase.IModel; }): string[] { interface IHasRelationship { modelName: string; unique: boolean; oppositeName: string; mappingName?: string; } const hasRelationships: IHasRelationship[] = props.application.files .map((otherFile) => otherFile.models.map((otherModel) => otherModel.foreignFields .filter( (otherForeign) => otherForeign.relation.targetModel === props.model.name, ) .map((otherForeign) => ({ modelName: otherModel.name, unique: otherForeign.unique, oppositeName: otherForeign.relation.oppositeName, mappingName: otherForeign.relation.mappingName, })), ), ) .flat(2); const foreignIndexes: AutoBeDatabase.IForeignField[] = props.model.foreignFields.filter((f) => { if (f.unique === true) return props.model.uniqueIndexes.every( (u) => u.fieldNames.length !== 1 || u.fieldNames[0] !== f.name, ); return ( props.model.uniqueIndexes.every((u) => u.fieldNames[0] !== f.name) && props.model.plainIndexes.every((p) => p.fieldNames[0] !== f.name) ); }); const print = (title: string, content: string[]): string[] => { if (content.length === 0) return []; return [ // title "//----", ...title.split("\n").map((l) => `// ${l}`), "//----", // main content ...content, ]; }; return ArrayUtil.paddle([ print( StringUtil.trim` BELONGED RELATIONS, - format: (propertyKey targetModel constraint) `, props.model.foreignFields.map((foreign) => writeConstraint({ dbms: props.dbms, model: props.model, foreign, }), ), ), print( StringUtil.trim` HAS RELATIONS - format: (propertyKey targetModel) `, hasRelationships.map((r) => [ r.oppositeName ?? r.mappingName ?? r.modelName, // for legacy histories `${r.modelName}${r.unique ? "?" : "[]"}`, ...(r.mappingName ? [`@relation("${r.mappingName}")`] : []), ].join(" "), ), ), print("INDEXES", [ ...foreignIndexes.map((field) => writeForeignIndex({ model: props.model, field, }), ), ...props.model.uniqueIndexes.map((unique) => writeUniqueIndex({ model: props.model, unique, }), ), ...props.model.plainIndexes.map((plain) => writePlainIndex({ model: props.model, plain, }), ), ...(props.dbms === "postgres" ? props.model.ginIndexes.map((gin) => writeGinIndex({ model: props.model, gin, }), ) : []), ]), ]); } function writeConstraint(props: { dbms: "postgres" | "sqlite"; model: AutoBeDatabase.IModel; foreign: AutoBeDatabase.IForeignField; }): string { // spellchecker:ignore-next-line const name: string = `${props.model.name}_${props.foreign.name}_rela`; const tooMuchLong: boolean = props.dbms === "postgres" && name.length > MAX_IDENTIFIER_LENGTH; const body: string = [ props.foreign.relation.name, `${props.foreign.relation.targetModel}${props.foreign.nullable ? "?" : ""}`, `@relation(${[ ...(props.foreign.relation.mappingName ? [`"${props.foreign.relation.mappingName}"`] : []), `fields: [${props.foreign.name}]`, `references: [id]`, `onDelete: Cascade`, ...(tooMuchLong ? [`map: "${shortName(name)}"`] : []), ].join(", ")})`, ].join(" "); return tooMuchLong ? StringUtil.trim` // spellchecker: ignore-next-line ${body} ` : body; } function writeForeignIndex(props: { model: AutoBeDatabase.IModel; field: AutoBeDatabase.IForeignField; }): string { const name: string = `${props.model.name}_${props.field.name}_fkey`; const prefix: string = `@@${props.field.unique === true ? "unique" : "index"}([${props.field.name}]`; if (name.length <= MAX_IDENTIFIER_LENGTH) return `${prefix})`; return StringUtil.trim` // spellchecker: ignore-next-line ${prefix}, map: "${shortName(name)}") `; } function writeUniqueIndex(props: { model: AutoBeDatabase.IModel; unique: AutoBeDatabase.IUniqueIndex; }): string { const name: string = `${props.model.name}_${props.unique.fieldNames.join("_")}_key`; const prefix: string = `@@unique([${props.unique.fieldNames.join(", ")}]`; if (name.length <= MAX_IDENTIFIER_LENGTH) return `${prefix})`; return StringUtil.trim` // spellchecker: ignore-next-line ${prefix}, map: "${shortName(name)}") `; } function writePlainIndex(props: { model: AutoBeDatabase.IModel; plain: AutoBeDatabase.IPlainIndex; }): string { const name: string = `${props.model.name}_${props.plain.fieldNames.join("_")}_idx`; const prefix: string = `@@index([${props.plain.fieldNames.join(", ")}]`; if (name.length <= MAX_IDENTIFIER_LENGTH) return `${prefix})`; return StringUtil.trim` // spellchecker: ignore-next-line ${prefix}, map: "${shortName(name)}") `; } function writeGinIndex(props: { model: AutoBeDatabase.IModel; gin: AutoBeDatabase.IGinIndex; }): string { const name: string = `${props.model.name}_${props.gin.fieldName}_idx`; const prefix: string = `@@index([${props.gin.fieldName}(ops: raw("gin_trgm_ops"))], type: Gin`; if (name.length <= MAX_IDENTIFIER_LENGTH) return `${prefix})`; return StringUtil.trim` // spellchecker: ignore-next-line ${prefix}, map: "${shortName(name)}") `; } /* ----------------------------------------------------------- BACKGROUND ----------------------------------------------------------- */ function writeComment(content: string, length: number): string { return content .split("\r\n") .join("\n") .split("\n") .map((line) => line.trim()) .map((line) => { if (line.length <= length - 4) return [line]; const words: string[] = line.split(" "); const result: string[] = []; let currentLine = ""; for (const word of words) { const potentialLine = currentLine ? `${currentLine} ${word}` : word; if (potentialLine.length <= 73) { currentLine = potentialLine; } else { if (currentLine) result.push(currentLine); currentLine = word; } } if (currentLine) result.push(currentLine); return result; }) .flat() .map((str) => `///${str.length ? ` ${str}` : ""}`) .join("\n") .trim(); } function addIndent(content: string): string { return content .split("\r\n") .join("\n") .split("\n") .map((str) => ` ${str}`) .join("\n"); } function shortName(name: string): string { if (name.length <= MAX_IDENTIFIER_LENGTH) return name; const hash: string = crypto .createHash("md5") .update(name) .digest("hex") .substring(0, HASH_TRUNCATION_LENGTH); return `${name.substring(0, MAX_IDENTIFIER_LENGTH - HASH_TRUNCATION_LENGTH - 1)}_${hash}`; } const LOGICAL_TYPES = { // native types boolean: "Boolean", int: "Int", double: "Float", string: "String", // formats datetime: "DateTime", uuid: "String", uri: "String", }; const POSTGRES_PHYSICAL_TYPES = { int: "@db.Integer", double: "@db.DoublePrecision", uuid: "@db.Uuid", datetime: "@db.Timestamptz", uri: "@db.VarChar(80000)", }; const POSTGRES_MAIN_FILE = StringUtil.trim` generator client { provider = "prisma-client" previewFeatures = ["postgresqlExtensions", "views"] output = "../../src/prisma" moduleFormat = "cjs" } datasource db { provider = "postgresql" extensions = [pg_trgm] } generator markdown { provider = "prisma-markdown" output = "../../docs/ERD.md" } `; const SQLITE_MAIN_FILE = StringUtil.trim` generator client { provider = "prisma-client" output = "../../src/prisma" moduleFormat = "cjs" } datasource db { provider = "sqlite" } generator markdown { provider = "prisma-markdown" output = "../../docs/ERD.md" } `; const MAX_IDENTIFIER_LENGTH = 63; const HASH_TRUNCATION_LENGTH = 8;