Last active
March 19, 2025 03:02
-
-
Save saiashirwad/d305425c038bd8c5e81d53b2fd4b4dd2 to your computer and use it in GitHub Desktop.
A prisma -> effect schema codegen script for a very opinionated setup using the parserator library
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// this is incredibly hacky, but it works great for me | |
// for this to work, i have a file called json-types.ts that contains a bunch of exported interfaces, one for each of | |
// the tables i have that have any JSON fields. json-types.ts should only have Effect Schema imports, like so: | |
// import { Schema1 } from '../../somewhere' | |
// import ... | |
// % | |
// ^ that is essential lol. that tells the script that the imports section is over | |
// export interface SomeTable { | |
// something: Schema1, | |
// ... (you only need the fields that are JSON typed, everything else can be whatever) | |
// } | |
import { | |
Parser, | |
alphabet, | |
char, | |
digit, | |
many0, | |
many1, | |
optional, | |
or, | |
sepBy, | |
skipSpaces, | |
skipUntil, | |
string, | |
takeUntil, | |
} from "parserator"; | |
export const peekAhead = (n: number) => | |
new Parser((s) => { | |
return Parser.succeed(s.remaining.slice(0, n), s); | |
}); | |
const whitespace = many0(or(char(" "), char("\n"), char("\t"))).withName( | |
"whitespace", | |
); | |
const word = many1(or(alphabet, digit, char("_"))).map((chars) => | |
chars.join(""), | |
); | |
const skipLine = skipUntil(char("\n")); | |
const takeLine = takeUntil(char("\n")); | |
const basicPrismaTypes = [ | |
"String", | |
"Int", | |
"Float", | |
"Boolean", | |
"DateTime", | |
"Json", | |
]; | |
namespace JsonTypes { | |
type FieldsMap = Map< | |
string, | |
{ | |
type: string; | |
following?: string; | |
}[] | |
>; | |
export type InterfacesMap = Map<string, FieldsMap>; | |
export const parser = Parser.gen(function* () { | |
yield* whitespace; | |
const imports = yield* takeUntil(char("%")); | |
yield* skipLine.then(whitespace); | |
const interfaces = yield* many0(parseInterface); | |
const interfacesMap: InterfacesMap = new Map(); | |
for (const iface of interfaces) { | |
const fields = iface.fields; | |
const fieldsMap: FieldsMap = new Map(); | |
for (const field of fields) { | |
fieldsMap.set(field.name, field.types); | |
} | |
interfacesMap.set(iface.name, fieldsMap); | |
} | |
return { imports, interfaces: interfacesMap }; | |
}); | |
const parseFieldTypes = sepBy( | |
char("|"), | |
whitespace | |
.then( | |
word.zip(optional(string("[]"))).map(([typeName, following]) => ({ | |
type: typeName, | |
following, | |
})), | |
) | |
.thenDiscard(whitespace), | |
); | |
const parseInterface = Parser.gen(function* () { | |
const name = yield* whitespace | |
.then(string("export interface")) | |
.then(whitespace) | |
.then(word) | |
.thenDiscard(whitespace); | |
yield* char("{"); | |
const fields = []; | |
while (true) { | |
yield* whitespace; | |
const next = yield* peekAhead(1); | |
if (next === "}") { | |
yield* char("}"); | |
break; | |
} | |
if (next === ";") { | |
yield* skipUntil(char("\n")); | |
continue; | |
} | |
const fieldName = yield* word; | |
yield* skipSpaces.then(char(":")).thenDiscard(skipSpaces); | |
const types = yield* parseFieldTypes; | |
fields.push({ name: fieldName, types }); | |
} | |
return { | |
type: "interface" as const, | |
name, | |
fields, | |
}; | |
}); | |
} | |
namespace Prisma { | |
const fieldParser = Parser.gen(function* () { | |
yield* whitespace; | |
const name = yield* word; | |
const type = yield* whitespace.then( | |
Parser.gen(function* () { | |
return { | |
type: yield* word, | |
following: yield* optional(or(string("[]"), char("?"))), | |
}; | |
}), | |
); | |
yield* skipLine; | |
return { | |
name, | |
...type, | |
}; | |
}); | |
const parseModel = (name: string, interfaces: JsonTypes.InterfacesMap) => | |
Parser.gen(function* () { | |
const fields = []; | |
while (true) { | |
yield* whitespace; | |
const next = yield* optional(peekAhead(1)); | |
if (!next) { | |
break; | |
} | |
if (next === "@") { | |
yield* skipUntil(char("\n")); | |
} else if (next === "}") { | |
yield* char("}"); | |
break; | |
} else { | |
const field = yield* fieldParser.thenDiscard(whitespace); | |
if (basicPrismaTypes.includes(field.type)) { | |
fields.push(field); | |
} | |
} | |
} | |
return { | |
type: "model" as const, | |
name, | |
fields, | |
}; | |
}).map(({ fields, name }) => { | |
return `export class ${name} extends Schema.Class<${name}>("${name}")({ | |
${fields | |
.map((field) => { | |
const fieldTypeMap: Record<string, string> = { | |
String: "Schema.String", | |
Int: "Schema.Number", | |
Float: "Schema.Number", | |
Boolean: "Schema.Boolean", | |
DateTime: "Schema.Date", | |
}; | |
let fieldType = fieldTypeMap[field.type]; | |
if (field.type === "Json") { | |
const fields = interfaces.get(name); | |
if (fields) { | |
const fieldTypes = fields.get(field.name)?.map((type) => { | |
let t = ""; | |
if (type.type === "any") { | |
t = "Schema.Any"; | |
} else if (type.type === "null") { | |
t = "Schema.Null"; | |
} else if (type.type === "string") { | |
t = "Schema.String"; | |
} else { | |
t = type.type; | |
} | |
if (type.following === "[]") { | |
t = `Schema.Array(${t})`; | |
} | |
return t; | |
}); | |
if (fieldTypes?.length) { | |
fieldType = `Schema.Union(${fieldTypes.join(", ")})`; | |
} else { | |
fieldType = "Schema.Any"; | |
} | |
} else { | |
fieldType = "Schema.Any"; | |
} | |
} | |
if (field.following === "[]") { | |
fieldType = `Schema.Array(${fieldType})`; | |
} else if (field.following === "?") { | |
fieldType = `${fieldType}.pipe(Schema.optional)`; | |
} | |
return `${field.name}: ${fieldType}`; | |
}) | |
.join(",\n")} | |
}) {}`; | |
}); | |
const parseEnum = (name: string) => | |
Parser.gen(function* () { | |
const variants = []; | |
while (true) { | |
yield* whitespace; | |
const next = yield* optional(peekAhead(1)); | |
if (!next) { | |
break; | |
} | |
if (next === "@") { | |
yield* skipLine; | |
} else if (next === "}") { | |
yield* char("}"); | |
break; | |
} else { | |
const variant = yield* word; | |
variants.push(variant); | |
yield* skipLine; | |
} | |
} | |
return { | |
type: "enum" as const, | |
name, | |
variants, | |
}; | |
}).map(({ name, variants }) => { | |
return ` | |
export const ${name} = Schema.Union(${variants.map((variant) => `Schema.Literal("${variant}")`).join(", ")}); | |
export type ${name} = Schema.Schema.Type<typeof ${name}>; | |
`; | |
}); | |
export const parser = Parser.gen(function* () { | |
const result = yield* many0( | |
Parser.gen(function* () { | |
yield* whitespace; | |
const blockName = yield* word; | |
yield* skipSpaces; | |
const name = yield* word; | |
yield* whitespace.then(char("{").thenDiscard(whitespace)); | |
if (blockName === "model") { | |
return yield* parseModel(name, jsonTypesResult.interfaces); | |
} | |
if (blockName === "enum") { | |
return yield* parseEnum(name); | |
} | |
yield* skipUntil(char("}")); | |
}), | |
); | |
return result.filter((x) => x !== undefined); | |
}); | |
} | |
const jsonTypesFile = await Bun.file("src/db/json-types.ts").text(); | |
const jsonTypesResult = JsonTypes.parser.parseOrThrow(jsonTypesFile); | |
const schemaPrisma = await Bun.file("prisma/schema.prisma").text(); | |
const result = ` | |
import { Schema } from "effect"; | |
${jsonTypesResult.imports} | |
${Prisma.parser.parseOrThrow(schemaPrisma).join("\n\n")}`; | |
await Bun.write("src/db/schema.ts", result); | |
await Bun.$`bun biome format --write src/db/schema.ts`; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { | |
Parser, | |
alphabet, | |
char, | |
digit, | |
many0, | |
many1, | |
optional, | |
or, | |
skipSpaces, | |
skipUntil, | |
string, | |
} from "parserator"; | |
export const peekAhead = (n: number) => | |
new Parser((s) => { | |
return Parser.succeed(s.remaining.slice(0, n), s); | |
}); | |
const whitespace = many0(or(char(" "), char("\n"), char("\t"))).withName( | |
"whitespace", | |
); | |
const word = many1(or(alphabet, digit, char("_"))).map((chars) => | |
chars.join(""), | |
); | |
const basicPrismaTypes = [ | |
"String", | |
"Int", | |
"Float", | |
"Boolean", | |
"DateTime", | |
"Json", | |
]; | |
const fieldParser = Parser.gen(function* () { | |
yield* whitespace; | |
const name = yield* word; | |
const type = yield* whitespace.then( | |
Parser.gen(function* () { | |
return { | |
type: yield* word, | |
following: yield* optional(or(string("[]"), char("?"))), | |
}; | |
}), | |
); | |
yield* skipUntil(char("\n")); | |
return { | |
name, | |
...type, | |
}; | |
}); | |
const parseModel = (name: string) => | |
Parser.gen(function* () { | |
const fields = []; | |
while (true) { | |
yield* whitespace; | |
const next = yield* optional(peekAhead(1)); | |
if (!next) { | |
break; | |
} | |
if (next === "@") { | |
yield* skipUntil(char("\n")); | |
} else if (next === "}") { | |
yield* char("}"); | |
break; | |
} else { | |
const field = yield* fieldParser.thenDiscard(whitespace); | |
if (basicPrismaTypes.includes(field.type)) { | |
fields.push(field); | |
} | |
} | |
} | |
return { | |
type: "model" as const, | |
name, | |
fields, | |
}; | |
}).map(({ fields, name }) => { | |
return `export class ${name} extends Schema.Class<${name}>("${name}")({ | |
${fields | |
.map((field) => { | |
const fieldTypeMap: Record<string, string> = { | |
String: "Schema.String", | |
Int: "Schema.Number", | |
Float: "Schema.Number", | |
Boolean: "Schema.Boolean", | |
DateTime: "Schema.Date", | |
}; | |
let fieldType = fieldTypeMap[field.type]; | |
if (field.type === "Json") { | |
// TODO: inject json types here | |
fieldType = "Schema.Any"; | |
} | |
if (field.following === "[]") { | |
fieldType = `Schema.Array(${fieldType})`; | |
} else if (field.following === "?") { | |
fieldType = `${fieldType}.pipe(Schema.optional)`; | |
} | |
return `${field.name}: ${fieldType}`; | |
}) | |
.join(",\n")} | |
}) {}`; | |
}); | |
const parseEnum = (name: string) => | |
Parser.gen(function* () { | |
const variants = []; | |
while (true) { | |
yield* whitespace; | |
const next = yield* optional(peekAhead(1)); | |
if (!next) { | |
break; | |
} | |
if (next === "@") { | |
yield* skipUntil(char("\n")); | |
} else if (next === "}") { | |
yield* char("}"); | |
break; | |
} else { | |
const variant = yield* word; | |
variants.push(variant); | |
yield* skipUntil(char("\n")); | |
} | |
} | |
return { | |
type: "enum" as const, | |
name, | |
variants, | |
}; | |
}).map(({ name, variants }) => { | |
return ` | |
export const ${name} = Schema.Union(${variants.map((variant) => `Schema.Literal("${variant}")`).join(", ")}); | |
export type ${name} = Schema.Schema.Type<typeof ${name}>; | |
`; | |
}); | |
const schemaParser = Parser.gen(function* () { | |
const result = yield* many0( | |
Parser.gen(function* () { | |
yield* whitespace; | |
const blockName = yield* word; | |
yield* skipSpaces; | |
const name = yield* word; | |
yield* whitespace.then(char("{").thenDiscard(whitespace)); | |
if (blockName === "model") { | |
return yield* parseModel(name); | |
} | |
if (blockName === "enum") { | |
return yield* parseEnum(name); | |
} | |
yield* skipUntil(char("}")); | |
}), | |
); | |
return result.filter((x) => x !== undefined); | |
}); | |
const schemaPrisma = await Bun.file("prisma/schema.prisma").text(); | |
let result = schemaParser.parseOrThrow(schemaPrisma).join("\n\n"); | |
result = `import { Schema } from "effect"; | |
${result}`; | |
await Bun.write("src/db/schema.ts", result); | |
await Bun.$`bun biome format --write src/db/schema.ts`; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment