From 6cf86dd4fcec05f3e8531a0cc7342f101a52ee33 Mon Sep 17 00:00:00 2001 From: Victor Petrovykh Date: Tue, 17 Dec 2024 19:50:14 -0500 Subject: [PATCH 1/3] Add the Prisma schema generator Add the `prisma` command to the list of valid @edgedb/generate commands. It requires a `--file` and some way of connecting the client (DSN or whatever other method). The generated Prisma schema will be output as specified by `--file`. --- packages/generate/src/cli.ts | 29 +- packages/generate/src/gel-prisma.ts | 710 ++++++++++++++++++++++++++++ 2 files changed, 735 insertions(+), 4 deletions(-) create mode 100644 packages/generate/src/gel-prisma.ts diff --git a/packages/generate/src/cli.ts b/packages/generate/src/cli.ts index 978ed4fb8..472464d29 100644 --- a/packages/generate/src/cli.ts +++ b/packages/generate/src/cli.ts @@ -25,6 +25,7 @@ import { generateQueryBuilder } from "./edgeql-js"; import { runInterfacesGenerator } from "./interfaces"; import { type Target, exitWithError } from "./genutil"; import { generateQueryFiles } from "./queries"; +import { runGelPrismaGenerator } from "./gel-prisma"; const { readFileUtf8, exists } = systemUtils; @@ -32,6 +33,7 @@ enum Generator { QueryBuilder = "edgeql-js", Queries = "queries", Interfaces = "interfaces", + GelPrisma = "prisma", } const availableGeneratorsHelp = ` @@ -72,6 +74,8 @@ const run = async () => { case Generator.Interfaces: options.target = "ts"; break; + case Generator.GelPrisma: + break; } let projectRoot: string | null = null; @@ -197,7 +201,10 @@ const run = async () => { options.useHttpClient = true; break; case "--target": { - if (generator === Generator.Interfaces) { + if ( + generator === Generator.Interfaces || + generator === Generator.GelPrisma + ) { exitWithError( `--target is not supported for generator "${generator}"`, ); @@ -227,7 +234,10 @@ const run = async () => { options.out = getVal(); break; case "--file": - if (generator === Generator.Interfaces) { + if ( + generator === Generator.Interfaces || + generator === Generator.GelPrisma + ) { options.file = getVal(); } else if (generator === Generator.Queries) { if (args.length > 0 && args[0][0] !== "-") { @@ -297,9 +307,13 @@ const run = async () => { case Generator.Interfaces: console.log(`Generating TS interfaces from schema...`); break; + case Generator.GelPrisma: + console.log(`Generating Prisma schema from database...`); + break; } - if (!options.target) { + // don't need to do any of that for the prisma schema generator + if (!options.target && generator !== Generator.GelPrisma) { if (!projectRoot) { throw new Error( `Failed to detect project root. @@ -418,6 +432,12 @@ Run this command inside an EdgeDB project directory or specify the desired targe schemaDir, }); break; + case Generator.GelPrisma: + await runGelPrismaGenerator({ + options, + client, + }); + break; } } catch (e) { exitWithError((e as Error).message); @@ -439,6 +459,7 @@ COMMANDS: queries Generate typed functions from .edgeql files edgeql-js Generate query builder interfaces Generate TS interfaces for schema types + prisma Generate a Prisma schema for an existing database instance CONNECTION OPTIONS: @@ -467,7 +488,7 @@ OPTIONS: Change the output directory the querybuilder files are generated into (Only valid for 'edgeql-js' generator) --file - Change the output filepath of the 'queries' and 'interfaces' generators + Change the output filepath of the 'queries', 'interfaces', and 'prisma' generators When used with the 'queries' generator, also changes output to single-file mode --force-overwrite Overwrite contents without confirmation diff --git a/packages/generate/src/gel-prisma.ts b/packages/generate/src/gel-prisma.ts new file mode 100644 index 000000000..fdaba39f4 --- /dev/null +++ b/packages/generate/src/gel-prisma.ts @@ -0,0 +1,710 @@ +import type { CommandOptions } from "./commandutil"; +import type { Client } from "edgedb"; +import * as fs from "node:fs"; + +const INTRO_QUERY = ` +with module schema +select ObjectType { + name, + links: { + name, + readonly, + required, + cardinality, + exclusive := exists ( + select .constraints + filter .name = 'std::exclusive' + ), + target: {name}, + + properties: { + name, + readonly, + required, + cardinality, + exclusive := exists ( + select .constraints + filter .name = 'std::exclusive' + ), + target: {name}, + }, + } filter .name != '__type__', + properties: { + name, + readonly, + required, + cardinality, + exclusive := exists ( + select .constraints + filter .name = 'std::exclusive' + ), + target: {name}, + }, + backlinks := >[], +} +filter + not .builtin + and + not .internal + and + not re_test('^(std|cfg|sys|schema)::', .name); +`; + +const MODULE_QUERY = ` +with + module schema, + m := (select \`Module\` filter not .builtin) +select m.name; +`; + +interface JSONField { + name: string; + readonly?: boolean; + required?: boolean; + exclusive?: boolean; + cardinality: string; + target: { name: string }; + has_link_target?: boolean; + has_link_object?: boolean; +} + +interface JSONLink extends JSONField { + fwname?: string; + properties?: JSONField[]; +} + +interface JSONType { + name: string; + links: JSONLink[]; + properties: JSONField[]; + backlinks: JSONLink[]; + backlink_renames?: { [key: string]: string }; +} + +interface LinkTable { + module: string; + name: string; + table: string; + source: string; + target: string; +} + +interface TableObj { + module: string; + name: string; + table: string; + links: JSONField[]; + properties: JSONField[]; +} + +interface ProcessedSpec { + modules: string[]; + object_types: JSONType[]; + link_tables: LinkTable[]; + link_objects: TableObj[]; + prop_objects: TableObj[]; +} + +interface MappedSpec { + link_objects: { [key: string]: TableObj }; + object_types: { [key: string]: JSONType }; +} + +const CLEAN_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/; + +function warn(msg: string) { + // This function exists in case we want to do something more with all the + // warnings. + console.warn(msg); +} + +function getSQLName(name: string): string { + // Just remove the module name + return name.split("::").pop(); +} + +function getMod(fullname: string): string { + return fullname.split("::").slice(0, -1).join("::"); +} + +function getModAndName(fullname: string): string[] { + const mod = fullname.split("::"); + const name = mod.pop(); + return [mod.join("::"), name]; +} + +function validName(name: string): boolean { + // Just remove module separators and check the rest + name = name.replace("::", ""); + if (!CLEAN_NAME.test(name)) { + warn(`Non-alphanumeric names are not supported: ${name}`); + return false; + } + return true; +} + +async function getSchemaJSON(client: Client) { + const types = await client.query(INTRO_QUERY); + const modules = await client.query(MODULE_QUERY); + + return processLinks(types, modules); +} + +function _skipInvalidNames( + spec_list: any[], + recurse_into: string[] | null = null, +): any[] { + const valid = []; + for (const spec of spec_list) { + // skip invalid names + if (validName(spec.name)) { + if (recurse_into) { + for (const fname of recurse_into) { + if (spec[fname]) { + spec[fname] = _skipInvalidNames(spec[fname], recurse_into); + } + } + } + valid.push(spec); + } + } + + return valid; +} + +function processLinks(types: JSONType[], modules: string[]): ProcessedSpec { + // Figure out all the backlinks, link tables, and links with link properties + // that require their own intermediate objects. + const type_map: { [key: string]: JSONType } = {}; + const link_tables: LinkTable[] = []; + const link_objects: TableObj[] = []; + const prop_objects: TableObj[] = []; + + // All the names of types, props and links are valid beyond this point. + types = _skipInvalidNames(types, ["properties", "links"]); + for (const spec of types) { + type_map[spec.name] = spec; + spec.backlink_renames = {}; + } + + for (const spec of types) { + const mod = getMod(spec.name); + const sql_source = getSQLName(spec.name); + + for (const prop of spec.properties) { + const name = prop.name; + const exclusive = prop.exclusive; + const cardinality = prop.cardinality; + const sql_name = getSQLName(name); + + if (cardinality === "Many") { + // Multi property will make its own "link table". But since it + // doesn't link to any other object the link table itself must + // be reflected as an object. + const pobj: TableObj = { + module: mod, + name: `${sql_source}_${sql_name}_prop`, + table: `${sql_source}.${sql_name}`, + links: [ + { + name: "source", + required: true, + cardinality: exclusive ? "One" : "Many", + exclusive: false, + target: { name: spec.name }, + has_link_object: false, + }, + ], + properties: [ + { + name: "target", + required: true, + cardinality: "One", + exclusive: false, + target: prop.target, + has_link_object: false, + }, + ], + }; + prop_objects.push(pobj); + } + } + + for (const link of spec.links) { + if (link.name != "__type__") { + const name = link.name; + const target = link.target.name; + const cardinality = link.cardinality; + const exclusive = link.exclusive; + const sql_name = getSQLName(name); + + const objtype = type_map[target]; + objtype.backlinks.push({ + name: `back_to_${sql_source}`, + fwname: name, + // flip cardinality and exclusivity + cardinality: exclusive ? "One" : "Many", + exclusive: cardinality === "One", + target: { name: spec.name }, + has_link_object: false, + }); + + link.has_link_object = false; + // Any link with properties should become its own intermediate + // object, since ORMs generally don't have a special convenient + // way of exposing this as just a link table. + if (link.properties!.length > 2) { + // more than just 'source' and 'target' properties + const lobj: TableObj = { + module: mod, + name: `${sql_source}_${sql_name}_link`, + table: `${sql_source}.${sql_name}`, + links: [], + properties: [], + }; + for (const prop of link.properties!) { + if (prop.name === "source" || prop.name === "target") { + lobj.links.push(prop); + } else { + lobj.properties.push(prop); + } + } + + link_objects.push(lobj); + link["has_link_object"] = true; + objtype["backlinks"][-1]["has_link_object"] = true; + } else if (cardinality === "Many") { + // Add a link table for One-to-Many and Many-to-Many + link_tables.push({ + module: mod, + name: `${sql_source}_${sql_name}_table`, + table: `${sql_source}.${sql_name}`, + source: spec.name, + target: target, + }); + } + } + } + + // Go over backlinks and resolve any name collisions using the type map. + for (const spec of types) { + // Find collisions in backlink names + const bk: { [key: string]: JSONLink[] } = {}; + + for (const link of spec.backlinks) { + if (link.name.search(/^back_to_/) >= 0) { + if (!bk[link.name]) { + bk[link.name] = []; + } + bk[link.name].push(link); + } + } + + for (const bklinks of Object.values(bk)) { + if (bklinks.length > 1) { + // We have a collision, so each backlink in it must now be disambiguated + for (const link of bklinks) { + const origsrc = getSQLName(link.target.name); + const lname = link.name; + const fwname = link.fwname; + link.name = `$follow_${fwname}_${lname}`; + + // Also update the original source of the link with the special backlink name + const source = type_map[link.target.name]; + source.backlink_renames[fwname] = link.name; + } + } + } + } + } + + return { + modules: modules, + object_types: types, + link_tables: link_tables, + link_objects: link_objects, + prop_objects: prop_objects, + }; +} + +const GEL_SCALAR_MAP: { [key: string]: string } = { + "std::uuid": "String @db.Uuid", + "std::bigint": "Decimal", + "std::bool": "Boolean", + "std::bytes": "Bytes", + "std::decimal": "Decimal", + "std::float32": "Float", + "std::float64": "Float", + "std::int16": "Int", + "std::int32": "Int", + "std::int64": "BigInt", + "std::json": "Json", + "std::str": "String", + "std::datetime": "DateTime", +}; + +const BASE_STUB = `\ +// Automatically generated from Gel schema. + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} +`; + +class ModelClass { + name: string; + table?: string; + props: { [key: string]: string } = {}; + links: { [key: string]: string } = {}; + mlinks: { [key: string]: string } = {}; + backlinks: { [key: string]: string } = {}; + backlink_renames: { [key: string]: string } = {}; + isLinkTable: boolean = false; + + constructor(name: string) { + this.name = name; + } + + getBacklinkName(src_link: string, src_name: string): string { + return this.backlink_renames[src_link] || `back_to_${src_name}`; + } +} + +class ModelGenerator { + INDENT = " "; + outfile: string; + out: string[] = []; + _indentLevel: number = 0; + + constructor(outfile: string) { + this.outfile = outfile; + this._indentLevel = 0; + } + + indent() { + this._indentLevel += 1; + } + + dedent() { + if (this._indentLevel > 0) { + this._indentLevel -= 1; + } + } + + resetIndent() { + this._indentLevel = 0; + } + + write(text: string = "") { + for (const line of text.split("\n")) { + this.out.push(`${this.INDENT.repeat(this._indentLevel)}${line}`); + } + } + + renderOutput() { + fs.writeFileSync(this.outfile, BASE_STUB + this.out.join("\n")); + } + + specToModulesDict(spec: ProcessedSpec) { + const modules: { [key: string]: MappedSpec } = {}; + + for (const mod of spec["modules"].sort()) { + modules[mod] = { link_objects: {}, object_types: {} }; + } + + // convert link tables into full link objects + for (const rec of spec.link_tables) { + const mod = rec.module; + modules[mod].link_objects[rec.table] = { + module: mod, + name: rec.name.replace(/_table$/, "_link"), + table: rec.table, + links: [ + { + name: "source", + required: true, + cardinality: "One", + target: { name: rec.source }, + has_link_object: false, + }, + { + name: "target", + required: true, + cardinality: "One", + target: { name: rec.target }, + has_link_object: false, + }, + ], + properties: [], + }; + } + + for (const rec of spec.link_objects) { + const mod = rec.module; + modules[mod].link_objects[rec.table] = rec; + } + + for (const rec of spec.object_types) { + const [mod, name] = getModAndName(rec.name); + modules[mod].object_types[name] = rec; + } + + return modules["default"]; + } + + buildModels(maps: MappedSpec) { + const modmap: { [key: string]: ModelClass } = {}; + + for (const [name, rec] of Object.entries(maps.object_types)) { + const mod: ModelClass = new ModelClass(name); + mod.table = name; + if (rec.backlink_renames !== undefined) { + mod.backlink_renames = rec.backlink_renames; + } + + // copy backlink information + for (const link of rec.backlinks) { + const code = this.renderBacklinkLink(link); + if (code) { + mod.backlinks[link.name] = code; + } + } + + // process properties as fields + for (const prop of rec.properties) { + const pname = prop.name; + if (pname === "id" || prop.cardinality === "Many") { + continue; + } + + const code = this.renderProp(prop); + if (code) { + mod.props[pname] = code; + } + } + + // process single links as fields + for (const link of rec.links) { + if (link.cardinality === "One") { + const lname = link.name; + const bklink = mod.getBacklinkName(lname, name); + const code = this.renderSingleLink(link, bklink); + if (code) { + mod.links[lname] = code; + // corresponding foreign key field + const opt = link["required"] ? "" : "?"; + mod.props[lname + "_id"] = `String${opt} @db.Uuid`; + } + } + } + + modmap[mod.name] = mod; + } + + for (const [table, rec] of Object.entries(maps.link_objects)) { + const [source, fwname] = table.split("."); + const mod = new ModelClass(`${source}_${fwname}`); + mod.table = table; + mod.isLinkTable = true; + + // Must have source and target + let target: string; + for (const prop of rec.links) { + const [mtgt, tar] = getModAndName(prop.target.name); + if (mtgt !== "default") { + // skip this whole link table + warn( + `Skipping link ${fwname}: link target ${mtgt + "::" + tar} ` + + `is not supported`, + ); + continue; + } + if (prop.name === "target") { + target = tar; + } + } + + mod.props["source_id"] = `String @map("source") @db.Uuid`; + mod.props["target_id"] = `String @map("target") @db.Uuid`; + + // Get source and target models and reconcile them with the link table + const src = modmap[source]; + const tgt = modmap[target!]; + const bkname = src.getBacklinkName(fwname, src.name); + + mod.links["target"] = + `${target!} @relation("${mod.name}", ` + + `fields: [target_id], references: [id], ` + + `onUpdate: NoAction, onDelete: NoAction)`; + mod.links["source"] = + `${source} @relation("${bkname}", ` + + `fields: [source_id], references: [id], ` + + `onUpdate: NoAction, onDelete: NoAction)`; + // Update the source and target models with the corresponding + // ManyToManyField. + src.mlinks[fwname] = `${mod.name}[] @relation("${bkname}")`; + tgt.mlinks[bkname] = `${mod.name}[] @relation("${mod.name}")`; + delete src.links[fwname]; + delete tgt.backlinks[bkname]; + + // process properties if any + for (const prop of rec["properties"]) { + const pname = prop.name; + const code = this.renderProp(prop); + if (code) { + mod.props[pname] = code; + } + } + + modmap[mod.name] = mod; + } + + return modmap; + } + + renderProp(prop: JSONField): string { + const target = prop.target.name; + const type: string | undefined = GEL_SCALAR_MAP[target]; + + if (type === undefined) { + warn(`Scalar type ${target} is not supported`); + return ""; + } + // make props opional and let gel deal with the actual requirements + return type + "?"; + } + + renderSingleLink(link: JSONLink, bklink: string): string { + const opt = link.required ? "" : "?"; + const [mod, target] = getModAndName(link.target.name); + + if (mod !== "default") { + warn( + `Skipping link ${link.name}: link target ${link.target.name} ` + + `is not supported`, + ); + return ""; + } + + return ( + `${target}${opt} @relation("${bklink}", ` + + `fields: [${link.name}_id], references: [id], ` + + `onUpdate: NoAction, onDelete: NoAction)` + ); + } + + renderBacklinkLink(link: JSONLink): string { + const multi = link.cardinality === "One" ? "?" : "[]"; + const [mod, target] = getModAndName(link.target.name); + + if (mod !== "default") { + warn( + `Skipping link ${link.name}: link target ${link.target.name} ` + + `is not supported`, + ); + return ""; + } + + return `${target}${multi} @relation("${link.name}")`; + } + + renderModels(spec: ProcessedSpec) { + const mods = spec.modules; + if (mods[0] != "default" || mods.length > 1) { + const skipped = mods.filter((m) => m != "default").join(", "); + warn( + `Skipping modules ${skipped}: Prisma reflection doesn't support` + + "multiple modules or non-default modules.", + ); + } + + if (spec.prop_objects.length > 0) { + warn( + "Skipping multi properties: Prisma reflection doesn't support multi" + + "properties as they produce models without 'id' field.", + ); + } + + const maps = this.specToModulesDict(spec); + const modmap = this.buildModels(maps); + + for (const mod of Object.values(modmap)) { + this.write(); + this.renderModelClass(mod); + } + + this.renderOutput(); + } + + renderModelClass(mod: ModelClass) { + this.write(`model ${mod.name} {`); + this.indent(); + + if (!mod.isLinkTable) { + this.write( + 'id String @id @default(dbgenerated("uuid_generate_v4()"))' + + " @db.Uuid", + ); + // not actually using this default, but this prevents Prisma from sending null + this.write( + 'gel_type_id String @default(dbgenerated("uuid_generate_v4()"))' + + ' @map("__type__") @db.Uuid', + ); + } + + if (Object.keys(mod.props).length > 0) { + this.write(); + this.write("// properties"); + for (const [name, val] of Object.entries(mod.props)) { + this.write(`${name} ${val}`); + } + } + + if (Object.keys(mod.links).length > 0) { + this.write(); + this.write("// links"); + for (const [name, val] of Object.entries(mod.links)) { + this.write(`${name} ${val}`); + } + } + + if (Object.keys(mod.mlinks).length > 0) { + this.write(); + this.write("// multi-links"); + for (const [name, val] of Object.entries(mod.mlinks)) { + this.write(`${name} ${val}`); + } + } + + if (Object.keys(mod.backlinks).length > 0) { + this.write(); + this.write("// backlinks"); + for (const [name, val] of Object.entries(mod.backlinks)) { + this.write(`${name} ${val}`); + } + } + + this.write(); + if (mod.isLinkTable) { + this.write("@@id([source_id, target_id])"); + } + this.write(`@@map("${mod.table}")`); + + this.dedent(); + this.write(`}`); + } +} + +export async function runGelPrismaGenerator(params: { + options: CommandOptions; + client: Client; +}) { + const spec = await getSchemaJSON(params.client); + const gen = new ModelGenerator(params.options.file!); + gen.renderModels(spec); +} From 208be79e02f16edcf7c10c71c35f83f0ae9a123e Mon Sep 17 00:00:00 2001 From: Victor Petrovykh Date: Wed, 18 Dec 2024 16:19:53 -0500 Subject: [PATCH 2/3] Add tests for the Prisma schema generator. Test the basic CRUD capability of the exposed Prisma models. --- integration-tests/prisma/.gitignore | 1 + .../prisma/dbschema/default.esdl | 23 + .../dbschema/migrations/00001-m1b5kog.edgeql | 21 + integration-tests/prisma/gel.toml | 2 + integration-tests/prisma/globalSetup.ts | 9 + integration-tests/prisma/jest.config.js | 8 + integration-tests/prisma/package.json | 32 + integration-tests/prisma/prisma.test.ts | 629 ++++++++++++++++++ integration-tests/prisma/setupTeardown.ts | 83 +++ integration-tests/prisma/testRunner.ts | 40 ++ integration-tests/prisma/tsconfig.json | 4 + yarn.lock | 52 +- 12 files changed, 903 insertions(+), 1 deletion(-) create mode 100644 integration-tests/prisma/.gitignore create mode 100644 integration-tests/prisma/dbschema/default.esdl create mode 100644 integration-tests/prisma/dbschema/migrations/00001-m1b5kog.edgeql create mode 100644 integration-tests/prisma/gel.toml create mode 100644 integration-tests/prisma/globalSetup.ts create mode 100644 integration-tests/prisma/jest.config.js create mode 100644 integration-tests/prisma/package.json create mode 100644 integration-tests/prisma/prisma.test.ts create mode 100644 integration-tests/prisma/setupTeardown.ts create mode 100644 integration-tests/prisma/testRunner.ts create mode 100644 integration-tests/prisma/tsconfig.json diff --git a/integration-tests/prisma/.gitignore b/integration-tests/prisma/.gitignore new file mode 100644 index 000000000..a731db5ab --- /dev/null +++ b/integration-tests/prisma/.gitignore @@ -0,0 +1 @@ +*.schema diff --git a/integration-tests/prisma/dbschema/default.esdl b/integration-tests/prisma/dbschema/default.esdl new file mode 100644 index 000000000..eb4b769f7 --- /dev/null +++ b/integration-tests/prisma/dbschema/default.esdl @@ -0,0 +1,23 @@ +module default { + type GameSession { + multi link players: User { + constraint exclusive; + } + required property num: int32; + } + + abstract type Named { + required property name: str; + } + + type Post { + required link author: User; + required property body: str; + } + + type User extending Named; + + type UserGroup extending Named { + multi link users: User; + } +} diff --git a/integration-tests/prisma/dbschema/migrations/00001-m1b5kog.edgeql b/integration-tests/prisma/dbschema/migrations/00001-m1b5kog.edgeql new file mode 100644 index 000000000..e4cb1349e --- /dev/null +++ b/integration-tests/prisma/dbschema/migrations/00001-m1b5kog.edgeql @@ -0,0 +1,21 @@ +CREATE MIGRATION m1b5kogggyycxixgy2mcrqu3ntk3hhagdcospowiernk6ddu6op6ia + ONTO initial +{ + CREATE ABSTRACT TYPE default::Named { + CREATE REQUIRED PROPERTY name: std::str; + }; + CREATE TYPE default::User EXTENDING default::Named; + CREATE TYPE default::GameSession { + CREATE MULTI LINK players: default::User { + CREATE CONSTRAINT std::exclusive; + }; + CREATE REQUIRED PROPERTY num: std::int32; + }; + CREATE TYPE default::UserGroup EXTENDING default::Named { + CREATE MULTI LINK users: default::User; + }; + CREATE TYPE default::Post { + CREATE REQUIRED LINK author: default::User; + CREATE REQUIRED PROPERTY body: std::str; + }; +}; diff --git a/integration-tests/prisma/gel.toml b/integration-tests/prisma/gel.toml new file mode 100644 index 000000000..e9ced6501 --- /dev/null +++ b/integration-tests/prisma/gel.toml @@ -0,0 +1,2 @@ +[edgedb] +server-version = "nightly" diff --git a/integration-tests/prisma/globalSetup.ts b/integration-tests/prisma/globalSetup.ts new file mode 100644 index 000000000..4692a7fbb --- /dev/null +++ b/integration-tests/prisma/globalSetup.ts @@ -0,0 +1,9 @@ +import createClient from "edgedb"; + +export default async () => { + const client = createClient(); + + process.env._JEST_EDGEDB_VERSION = await client.queryRequiredSingleJSON( + `select sys::get_version()`, + ); +}; diff --git a/integration-tests/prisma/jest.config.js b/integration-tests/prisma/jest.config.js new file mode 100644 index 000000000..2c22ce297 --- /dev/null +++ b/integration-tests/prisma/jest.config.js @@ -0,0 +1,8 @@ +module.exports = { + preset: "ts-jest", + testEnvironment: "node", + testPathIgnorePatterns: ["./dist", "./esm", "./mts", "./cjs", "./deno"], + globalSetup: "./globalSetup.ts", + transform: {}, + globals: {}, +}; diff --git a/integration-tests/prisma/package.json b/integration-tests/prisma/package.json new file mode 100644 index 000000000..0dd685d7d --- /dev/null +++ b/integration-tests/prisma/package.json @@ -0,0 +1,32 @@ +{ + "private": true, + "name": "@edgedb/integration-prisma", + "version": "0.0.0", + "scripts": { + "typecheck": "echo 'Integration tests - lts, skipping typecheck...'", + "build": "echo 'Integration tests, no build output...'", + "generate": "pwd && npx @edgedb/generate prisma --file prisma.schema && npx prisma generate --schema=prisma.schema", + "test": "yarn test:ts", + "test:ts": "pwd && yarn generate && NODE_OPTIONS=\"--experimental-vm-modules\" jest --testPathIgnorePatterns='(esm/.*|mts/.*|cjs/.*|deno/.*)' --detectOpenHandles --forceExit", + "ci:integration-test": "tsx ./testRunner.ts", + "bench:types": "cd ../.. && tsx integration-tests/lts/bench.ts" + }, + "devDependencies": { + "@arktype/attest": "^0.7.8", + "@edgedb/generate": "*", + "@prisma/client": "^6.1.0", + "@tsconfig/node-lts": "^20.1.3", + "@types/jest": "^29.5.12", + "@types/node": "^20.12.13", + "conditional-type-checks": "^1.0.6", + "jest": "^29.7.0", + "prisma": "^6.1.0", + "superjson": "1.13.3", + "ts-jest": "^29.1.4", + "typescript": "^5.5.2" + }, + "dependencies": { + "edgedb": "*", + "fast-check": "^3.19.0" + } +} diff --git a/integration-tests/prisma/prisma.test.ts b/integration-tests/prisma/prisma.test.ts new file mode 100644 index 000000000..bd0b1f667 --- /dev/null +++ b/integration-tests/prisma/prisma.test.ts @@ -0,0 +1,629 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "child_process"; +import path from "path"; +import { Client } from "edgedb"; +import { execSync } from "child_process"; +import { PrismaClient, Prisma } from "@prisma/client"; + +import { setupTests, teardownTests, testIfVersionGTE } from "./setupTeardown"; + +let client: Client; +let prisma: PrismaClient; + +class Rollback {} + +describe("prisma", () => { + beforeAll(async () => { + const setup = await setupTests(); + ({ client } = setup); + + // the postgres DSN that prisma needs is nearly identical to the EdgeDB + // DSN, so we'll use it as the baseline + const dsn = spawnSync("gel", [ + "instance", + "credentials", + "-I", + "prisma", + "--insecure-dsn", + ]).stdout.toString(); + + prisma = new PrismaClient({ + datasources: { + db: { + url: dsn.replace(/^edgedb:/, "postgresql:"), + }, + }, + }); + }); + + afterAll(async () => { + await teardownTests(client); + }); + + testIfVersionGTE(6)("check read models 01", async () => { + const res = await prisma.user.findMany({ orderBy: { name: "asc" } }); + assert.deepEqual( + res.map((rec) => rec["name"]), + ["Alice", "Billie", "Cameron", "Dana", "Elsa", "Zoe"], + ); + }); + + testIfVersionGTE(6)("check read models 02", async () => { + const res = await prisma.userGroup.findMany({ orderBy: { name: "asc" } }); + assert.deepEqual( + res.map((rec) => rec["name"]), + ["blue", "green", "red"], + ); + }); + + testIfVersionGTE(6)("check read models 03", async () => { + const res = await prisma.gameSession.findMany({ orderBy: { num: "asc" } }); + assert.deepEqual( + res.map((rec) => rec["num"]), + [123, 456], + ); + }); + + testIfVersionGTE(6)("check read models 04", async () => { + const res = await prisma.post.findMany({ orderBy: { body: "asc" } }); + assert.deepEqual( + res.map((rec) => rec["body"]), + ["*magic stuff*", "Hello", "I'm Alice", "I'm Cameron"], + ); + }); + + testIfVersionGTE(6)("check read models 05", async () => { + const res = await prisma.named.findMany({ orderBy: { name: "asc" } }); + assert.deepEqual( + res.map((rec) => rec["name"]), + [ + "Alice", + "Billie", + "Cameron", + "Dana", + "Elsa", + "Zoe", + "blue", + "green", + "red", + ], + ); + }); + + testIfVersionGTE(6)("check read models 06", async () => { + const res = await prisma.post.findMany({ + select: { + body: true, + author: { + select: { + name: true, + }, + }, + }, + orderBy: { body: "asc" }, + }); + assert.deepEqual(res, [ + { body: "*magic stuff*", author: { name: "Elsa" } }, + { body: "Hello", author: { name: "Alice" } }, + { body: "I'm Alice", author: { name: "Alice" } }, + { body: "I'm Cameron", author: { name: "Cameron" } }, + ]); + }); + + testIfVersionGTE(6)("check read models 07", async () => { + const res = await prisma.user.findMany({ + select: { + name: true, + back_to_Post: { + select: { + body: true, + }, + }, + }, + orderBy: { name: "asc" }, + }); + assert.deepEqual(res, [ + { + name: "Alice", + back_to_Post: [{ body: "Hello" }, { body: "I'm Alice" }], + }, + { + name: "Billie", + back_to_Post: [], + }, + { + name: "Cameron", + back_to_Post: [{ body: "I'm Cameron" }], + }, + { + name: "Dana", + back_to_Post: [], + }, + { + name: "Elsa", + back_to_Post: [{ body: "*magic stuff*" }], + }, + { + name: "Zoe", + back_to_Post: [], + }, + ]); + }); + + testIfVersionGTE(6)("check read models 08", async () => { + const res = await prisma.gameSession.findMany({ + select: { + num: true, + players: { + select: { + target: { + select: { + name: true, + }, + }, + }, + }, + }, + orderBy: { num: "asc" }, + }); + assert.deepEqual(res, [ + { + num: 123, + players: [ + { target: { name: "Alice" } }, + { target: { name: "Billie" } }, + ], + }, + { + num: 456, + players: [{ target: { name: "Dana" } }], + }, + ]); + }); + + testIfVersionGTE(6)("check read models 09", async () => { + const res = await prisma.user.findMany({ + select: { + name: true, + back_to_GameSession: { + select: { + source: { + select: { + num: true, + }, + }, + }, + }, + }, + orderBy: { name: "asc" }, + }); + assert.deepEqual(res, [ + { + name: "Alice", + back_to_GameSession: [{ source: { num: 123 } }], + }, + { + name: "Billie", + back_to_GameSession: [{ source: { num: 123 } }], + }, + { + name: "Cameron", + back_to_GameSession: [], + }, + { + name: "Dana", + back_to_GameSession: [{ source: { num: 456 } }], + }, + { + name: "Elsa", + back_to_GameSession: [], + }, + { + name: "Zoe", + back_to_GameSession: [], + }, + ]); + }); + + testIfVersionGTE(6)("check read models 10", async () => { + const res = await prisma.userGroup.findMany({ + select: { + name: true, + users: { + select: { + target: { + select: { + name: true, + }, + }, + }, + }, + }, + orderBy: { name: "asc" }, + }); + assert.deepEqual(res, [ + { + name: "blue", + users: [], + }, + { + name: "green", + users: [{ target: { name: "Alice" } }, { target: { name: "Billie" } }], + }, + { + name: "red", + users: [ + { target: { name: "Alice" } }, + { target: { name: "Billie" } }, + { target: { name: "Cameron" } }, + { target: { name: "Dana" } }, + ], + }, + ]); + }); + + testIfVersionGTE(6)("check read models 11", async () => { + const res = await prisma.user.findMany({ + select: { + name: true, + back_to_UserGroup: { + select: { + source: { + select: { + name: true, + }, + }, + }, + }, + }, + orderBy: { name: "asc" }, + }); + assert.deepEqual(res, [ + { + name: "Alice", + back_to_UserGroup: [ + { source: { name: "red" } }, + { source: { name: "green" } }, + ], + }, + { + name: "Billie", + back_to_UserGroup: [ + { source: { name: "red" } }, + { source: { name: "green" } }, + ], + }, + { + name: "Cameron", + back_to_UserGroup: [{ source: { name: "red" } }], + }, + { + name: "Dana", + back_to_UserGroup: [{ source: { name: "red" } }], + }, + { + name: "Elsa", + back_to_UserGroup: [], + }, + { + name: "Zoe", + back_to_UserGroup: [], + }, + ]); + }); + + testIfVersionGTE(6)("check create models 01", async () => { + try { + await prisma.$transaction(async (tx) => { + await tx.user.create({ + data: { + name: "Yvonne", + }, + }); + + const res = await tx.user.findFirst({ + where: { + name: "Yvonne", + }, + }); + + assert.equal(res!.name, "Yvonne"); + assert.ok(res!.id); + throw new Rollback(); + }); + } catch (err) { + if (!(err instanceof Rollback)) { + throw err; + } + } + }); + + testIfVersionGTE(6)("check create models 02", async () => { + try { + await prisma.$transaction(async (tx) => { + await tx.userGroup.create({ + data: { + name: "cyan", + users: { + create: [ + { target: { create: { name: "Yvonne" } } }, + { target: { create: { name: "Xander" } } }, + ], + }, + }, + }); + + for (const name of ["Yvonne", "Xander"]) { + const res = await tx.user.findFirst({ + where: { + name: name, + }, + include: { + back_to_UserGroup: { + include: { + source: true, + }, + }, + }, + }); + + assert.equal(res!.name, name); + assert.equal(res!.back_to_UserGroup[0].source.name, "cyan"); + assert.ok(res!.id); + } + + throw new Rollback(); + }); + } catch (err) { + if (!(err instanceof Rollback)) { + throw err; + } + } + }); + + testIfVersionGTE(6)("check create models 03", async () => { + try { + await prisma.$transaction(async (tx) => { + // create user and then 2 posts + const user = await tx.user.create({ + data: { + name: "Yvonne", + }, + }); + await tx.post.create({ + data: { + body: "this is a test", + author_id: user.id, + }, + }); + await tx.post.create({ + data: { + body: "also a test", + author_id: user.id, + }, + }); + + const res = await tx.post.findMany({ + where: { + author: { + name: "Yvonne", + }, + }, + select: { + body: true, + author: { + select: { + name: true, + }, + }, + }, + orderBy: { + body: "asc", + }, + }); + + assert.deepEqual(res, [ + { + body: "also a test", + author: { name: "Yvonne" }, + }, + { + body: "this is a test", + author: { name: "Yvonne" }, + }, + ]); + + throw new Rollback(); + }); + } catch (err) { + if (!(err instanceof Rollback)) { + throw err; + } + } + }); + + testIfVersionGTE(6)("check delete models 01", async () => { + try { + await prisma.$transaction(async (tx) => { + const user = await tx.user.findFirst({ + where: { + name: "Zoe", + }, + }); + assert.ok(user?.id); + + // name is not unique so deleteMany is used + await tx.user.deleteMany({ + where: { + name: "Zoe", + }, + }); + + const res = await tx.user.findMany({ + where: { + name: "Zoe", + }, + }); + assert.deepEqual(res, []); + + throw new Rollback(); + }); + } catch (err) { + if (!(err instanceof Rollback)) { + throw err; + } + } + }); + + testIfVersionGTE(6)("check delete models 02", async () => { + try { + await prisma.$transaction(async (tx) => { + const posts = await tx.post.findMany({ + where: { + author: { + name: "Elsa", + }, + }, + }); + assert.equal(posts.length, 1); + assert.ok(posts[0]?.id); + + // name is not unique so deleteMany is used + await tx.post.delete({ + where: { + id: posts[0].id, + }, + }); + + const res = await tx.user.findFirst({ + where: { + name: "Elsa", + }, + select: { + name: true, + back_to_Post: true, + }, + }); + assert.deepEqual(res, { + name: "Elsa", + back_to_Post: [], + }); + + throw new Rollback(); + }); + } catch (err) { + if (!(err instanceof Rollback)) { + throw err; + } + } + }); + + testIfVersionGTE(6)("check delete models 03", async () => { + try { + await prisma.$transaction(async (tx) => { + const red = await tx.userGroup.findFirst({ + where: { + name: "red", + }, + include: { + users: { + include: { + target: true, + }, + }, + }, + }); + assert.deepEqual( + red!.users.map((rec) => rec["target"]["name"]), + ["Alice", "Billie", "Cameron", "Dana"], + ); + + // drop Billie and Cameron from the group + for (const link of red!.users) { + if (link.target.name === "Billie" || link.target.name === "Cameron") { + await tx.userGroup_users.delete({ + where: { + source_id_target_id: { + source_id: link.source_id, + target_id: link.target_id, + }, + }, + }); + } + } + + const res = await tx.userGroup.findFirst({ + where: { + name: "red", + }, + include: { + users: { + include: { + target: true, + }, + }, + }, + }); + assert.deepEqual( + res!.users.map((rec) => rec["target"]["name"]), + ["Alice", "Dana"], + ); + + throw new Rollback(); + }); + } catch (err) { + if (!(err instanceof Rollback)) { + throw err; + } + } + }); + + testIfVersionGTE(6)("check update models 01", async () => { + // as long as we can update any model, it should be fine for all of them + // since in Prisma we reflect all things as models + try { + await prisma.$transaction(async (tx) => { + const user = await tx.user.findFirst({ + where: { + name: "Alice", + }, + }); + const user_id = user!.id; + assert.ok(user_id); + assert.equal(user?.name, "Alice"); + + // name is not unique so deleteMany is used + await tx.user.update({ + where: { + id: user.id, + }, + data: { + name: "Xander", + }, + }); + + let res = await tx.user.findMany({ + where: { + name: "Alice", + }, + }); + assert.deepEqual(res, []); + + res = await tx.user.findMany({ + where: { + name: "Xander", + }, + }); + assert.equal(res.length, 1); + assert.equal(res[0]?.name, "Xander"); + assert.equal(res[0]?.id, user_id); + + throw new Rollback(); + }); + } catch (err) { + if (!(err instanceof Rollback)) { + throw err; + } + } + }); +}); diff --git a/integration-tests/prisma/setupTeardown.ts b/integration-tests/prisma/setupTeardown.ts new file mode 100644 index 000000000..6e00671a2 --- /dev/null +++ b/integration-tests/prisma/setupTeardown.ts @@ -0,0 +1,83 @@ +import * as tc from "conditional-type-checks"; +import { type Client, createClient } from "edgedb"; + +export { tc }; + +type depromisify = T extends Promise ? U : T; +export type TestData = depromisify>["data"]; + +export async function setupTests() { + const client = createClient(); + await cleanupData(client); + + await client.execute(` + insert User {name := 'Alice'}; + insert User {name := 'Billie'}; + insert User {name := 'Cameron'}; + insert User {name := 'Dana'}; + insert User {name := 'Elsa'}; + insert User {name := 'Zoe'}; + + insert UserGroup { + name := 'red', + users := (select User filter .name not in {'Elsa', 'Zoe'}), + }; + insert UserGroup { + name := 'green', + users := (select User filter .name in {'Alice', 'Billie'}), + }; + insert UserGroup { + name := 'blue', + }; + + insert GameSession { + num := 123, + players := (select User filter .name in {'Alice', 'Billie'}), + }; + insert GameSession { + num := 456, + players := (select User filter .name in {'Dana'}), + }; + + insert Post { + author := assert_single((select User filter .name = 'Alice')), + body := 'Hello', + }; + insert Post { + author := assert_single((select User filter .name = 'Alice')), + body := "I'm Alice", + }; + insert Post { + author := assert_single((select User filter .name = 'Cameron')), + body := "I'm Cameron", + }; + insert Post { + author := assert_single((select User filter .name = 'Elsa')), + body := '*magic stuff*', + }; + `); + + return { + // no data is needed + data: {}, + client, + }; +} + +async function cleanupData(client: Client) { + await client.execute("delete Object"); +} + +export async function teardownTests(client: Client) { + await cleanupData(client); + + await client.close(); +} + +export const versionGTE = (majorVer: number) => { + const version = JSON.parse(process.env._JEST_EDGEDB_VERSION!); + return version.major >= majorVer; +}; + +export const testIfVersionGTE = (majorVer: number) => + versionGTE(majorVer) ? test : test.skip; diff --git a/integration-tests/prisma/testRunner.ts b/integration-tests/prisma/testRunner.ts new file mode 100644 index 000000000..72711f2b2 --- /dev/null +++ b/integration-tests/prisma/testRunner.ts @@ -0,0 +1,40 @@ +import createClient from "edgedb"; + +import { + shutdown, + applyMigrations, + generateStatusFileName, + getServerCommand, + getWSLPath, + startServer, + runCommand, + configToEnv, +} from "../../packages/driver/test/testUtil"; + +(async function main() { + console.log("\nStarting EdgeDB test cluster..."); + + const statusFile = generateStatusFileName("node"); + console.log("Node status file:", statusFile); + + const { args } = getServerCommand(getWSLPath(statusFile)); + + const { proc, config } = await startServer(args, statusFile); + + console.log(`EdgeDB test cluster is up [port: ${config.port}]...`); + + const managementConn = await createClient(config).ensureConnected(); + + try { + await applyMigrations(config); + console.log(`\nRunning tests...`); + await runCommand("yarn", ["test:ts"], configToEnv(config)); + } catch (err) { + console.error(err); + process.exit(1); + } finally { + console.log("Shutting down EdgeDB test cluster..."); + await shutdown(proc, managementConn); + console.log("EdgeDB test cluster is down..."); + } +})(); diff --git a/integration-tests/prisma/tsconfig.json b/integration-tests/prisma/tsconfig.json new file mode 100644 index 000000000..1a9da497e --- /dev/null +++ b/integration-tests/prisma/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "@tsconfig/node-lts/tsconfig.json", + "include": ["./**/*.ts"] +} diff --git a/yarn.lock b/yarn.lock index 4aac93ac8..027c00693 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1394,6 +1394,47 @@ resolved "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.25.tgz" integrity sha512-j7P6Rgr3mmtdkeDGTe0E/aYyWEWVtc5yFXtHCRHs28/jptDEWfaVOc5T7cblqy1XKPPfCxJc/8DwQ5YgLOZOVQ== +"@prisma/client@^6.1.0": + version "6.1.0" + resolved "https://registry.yarnpkg.com/@prisma/client/-/client-6.1.0.tgz#179d3b70586e7be522f6f1f0a82cca01396f719a" + integrity sha512-AbQYc5+EJKm1Ydfq3KxwcGiy7wIbm4/QbjCKWWoNROtvy7d6a3gmAGkKjK0iUCzh+rHV8xDhD5Cge8ke/kiy5Q== + +"@prisma/debug@6.1.0": + version "6.1.0" + resolved "https://registry.yarnpkg.com/@prisma/debug/-/debug-6.1.0.tgz#a27a1d144f72a3bc95061ecb0255e7554d9d59ec" + integrity sha512-0himsvcM4DGBTtvXkd2Tggv6sl2JyUYLzEGXXleFY+7Kp6rZeSS3hiTW9mwtUlXrwYbJP6pwlVNB7jYElrjWUg== + +"@prisma/engines-version@6.1.0-21.11f085a2012c0f4778414c8db2651556ee0ef959": + version "6.1.0-21.11f085a2012c0f4778414c8db2651556ee0ef959" + resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-6.1.0-21.11f085a2012c0f4778414c8db2651556ee0ef959.tgz#0b21ebf57362ffe35d0760c39855f90bbfa0f2fd" + integrity sha512-PdJqmYM2Fd8K0weOOtQThWylwjsDlTig+8Pcg47/jszMuLL9iLIaygC3cjWJLda69siRW4STlCTMSgOjZzvKPQ== + +"@prisma/engines@6.1.0": + version "6.1.0" + resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-6.1.0.tgz#2195244a8ce33839a8131e4465624e21d1f8d042" + integrity sha512-GnYJbCiep3Vyr1P/415ReYrgJUjP79fBNc1wCo7NP6Eia0CzL2Ot9vK7Infczv3oK7JLrCcawOSAxFxNFsAERQ== + dependencies: + "@prisma/debug" "6.1.0" + "@prisma/engines-version" "6.1.0-21.11f085a2012c0f4778414c8db2651556ee0ef959" + "@prisma/fetch-engine" "6.1.0" + "@prisma/get-platform" "6.1.0" + +"@prisma/fetch-engine@6.1.0": + version "6.1.0" + resolved "https://registry.yarnpkg.com/@prisma/fetch-engine/-/fetch-engine-6.1.0.tgz#2a5174787bf57c9b1d5d400bb923e0dc6a73a794" + integrity sha512-asdFi7TvPlEZ8CzSZ/+Du5wZ27q6OJbRSXh+S8ISZguu+S9KtS/gP7NeXceZyb1Jv1SM1S5YfiCv+STDsG6rrg== + dependencies: + "@prisma/debug" "6.1.0" + "@prisma/engines-version" "6.1.0-21.11f085a2012c0f4778414c8db2651556ee0ef959" + "@prisma/get-platform" "6.1.0" + +"@prisma/get-platform@6.1.0": + version "6.1.0" + resolved "https://registry.yarnpkg.com/@prisma/get-platform/-/get-platform-6.1.0.tgz#d4394a24ef91af6675a92382ed40e6e6e07eeb13" + integrity sha512-ia8bNjboBoHkmKGGaWtqtlgQOhCi7+f85aOkPJKgNwWvYrT6l78KgojLekE8zMhVk0R9lWcifV0Pf8l3/15V0Q== + dependencies: + "@prisma/debug" "6.1.0" + "@remix-run/router@1.16.1": version "1.16.1" resolved "https://registry.npmjs.org/@remix-run/router/-/router-1.16.1.tgz" @@ -3251,7 +3292,7 @@ fs.realpath@^1.0.0: resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz" integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== -fsevents@^2.3.2, fsevents@~2.3.2, fsevents@~2.3.3: +fsevents@2.3.3, fsevents@^2.3.2, fsevents@~2.3.2, fsevents@~2.3.3: version "2.3.3" resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz" integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== @@ -4752,6 +4793,15 @@ pretty-format@^29.0.0, pretty-format@^29.7.0: ansi-styles "^5.0.0" react-is "^18.0.0" +prisma@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/prisma/-/prisma-6.1.0.tgz#738f657fdd5ab8e6775f385db81bf7e61c70fbaf" + integrity sha512-aFI3Yi+ApUxkwCJJwyQSwpyzUX7YX3ihzuHNHOyv4GJg3X5tQsmRaJEnZ+ZyfHpMtnyahhmXVfbTZ+lS8ZtfKw== + dependencies: + "@prisma/engines" "6.1.0" + optionalDependencies: + fsevents "2.3.3" + prompts@^2.0.1: version "2.4.2" resolved "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz" From f9e05573da6c81ddc873bb38e32e79a100fe970e Mon Sep 17 00:00:00 2001 From: Victor Petrovykh Date: Mon, 3 Feb 2025 07:36:57 -0500 Subject: [PATCH 3/3] Update how we generate Prisma models. Shorten backlink names to `bk__` (cannot start with underscore, so needs `bk` prefix). Recognize and reflect array types. Sort fields by name. --- .../prisma/dbschema/default.esdl | 7 + .../dbschema/migrations/00002-m1l3ekc.edgeql | 10 ++ integration-tests/prisma/prisma.test.ts | 131 ++++++++++++++---- integration-tests/prisma/setupTeardown.ts | 6 + packages/generate/src/gel-prisma.ts | 103 ++++++++------ 5 files changed, 186 insertions(+), 71 deletions(-) create mode 100644 integration-tests/prisma/dbschema/migrations/00002-m1l3ekc.edgeql diff --git a/integration-tests/prisma/dbschema/default.esdl b/integration-tests/prisma/dbschema/default.esdl index eb4b769f7..4d0683e47 100644 --- a/integration-tests/prisma/dbschema/default.esdl +++ b/integration-tests/prisma/dbschema/default.esdl @@ -20,4 +20,11 @@ module default { type UserGroup extending Named { multi link users: User; } + + type AssortedScalars { + required name: str; + vals: array; + ts: datetime; + bstr: bytes; + } } diff --git a/integration-tests/prisma/dbschema/migrations/00002-m1l3ekc.edgeql b/integration-tests/prisma/dbschema/migrations/00002-m1l3ekc.edgeql new file mode 100644 index 000000000..8cd6dd3ea --- /dev/null +++ b/integration-tests/prisma/dbschema/migrations/00002-m1l3ekc.edgeql @@ -0,0 +1,10 @@ +CREATE MIGRATION m1l3ekcdknbfrm2zn5gucofoh7pyt5uxnbeoze7kkmlfp7b7nr3ira + ONTO m1b5kogggyycxixgy2mcrqu3ntk3hhagdcospowiernk6ddu6op6ia +{ + CREATE TYPE default::AssortedScalars { + CREATE PROPERTY bstr: std::bytes; + CREATE REQUIRED PROPERTY name: std::str; + CREATE PROPERTY ts: std::datetime; + CREATE PROPERTY vals: array; + }; +}; diff --git a/integration-tests/prisma/prisma.test.ts b/integration-tests/prisma/prisma.test.ts index bd0b1f667..996bd196a 100644 --- a/integration-tests/prisma/prisma.test.ts +++ b/integration-tests/prisma/prisma.test.ts @@ -114,7 +114,7 @@ describe("prisma", () => { const res = await prisma.user.findMany({ select: { name: true, - back_to_Post: { + bk_author_Post: { select: { body: true, }, @@ -125,27 +125,27 @@ describe("prisma", () => { assert.deepEqual(res, [ { name: "Alice", - back_to_Post: [{ body: "Hello" }, { body: "I'm Alice" }], + bk_author_Post: [{ body: "Hello" }, { body: "I'm Alice" }], }, { name: "Billie", - back_to_Post: [], + bk_author_Post: [], }, { name: "Cameron", - back_to_Post: [{ body: "I'm Cameron" }], + bk_author_Post: [{ body: "I'm Cameron" }], }, { name: "Dana", - back_to_Post: [], + bk_author_Post: [], }, { name: "Elsa", - back_to_Post: [{ body: "*magic stuff*" }], + bk_author_Post: [{ body: "*magic stuff*" }], }, { name: "Zoe", - back_to_Post: [], + bk_author_Post: [], }, ]); }); @@ -185,7 +185,7 @@ describe("prisma", () => { const res = await prisma.user.findMany({ select: { name: true, - back_to_GameSession: { + bk_players_GameSession: { select: { source: { select: { @@ -200,27 +200,27 @@ describe("prisma", () => { assert.deepEqual(res, [ { name: "Alice", - back_to_GameSession: [{ source: { num: 123 } }], + bk_players_GameSession: [{ source: { num: 123 } }], }, { name: "Billie", - back_to_GameSession: [{ source: { num: 123 } }], + bk_players_GameSession: [{ source: { num: 123 } }], }, { name: "Cameron", - back_to_GameSession: [], + bk_players_GameSession: [], }, { name: "Dana", - back_to_GameSession: [{ source: { num: 456 } }], + bk_players_GameSession: [{ source: { num: 456 } }], }, { name: "Elsa", - back_to_GameSession: [], + bk_players_GameSession: [], }, { name: "Zoe", - back_to_GameSession: [], + bk_players_GameSession: [], }, ]); }); @@ -266,7 +266,7 @@ describe("prisma", () => { const res = await prisma.user.findMany({ select: { name: true, - back_to_UserGroup: { + bk_users_UserGroup: { select: { source: { select: { @@ -281,33 +281,52 @@ describe("prisma", () => { assert.deepEqual(res, [ { name: "Alice", - back_to_UserGroup: [ + bk_users_UserGroup: [ { source: { name: "red" } }, { source: { name: "green" } }, ], }, { name: "Billie", - back_to_UserGroup: [ + bk_users_UserGroup: [ { source: { name: "red" } }, { source: { name: "green" } }, ], }, { name: "Cameron", - back_to_UserGroup: [{ source: { name: "red" } }], + bk_users_UserGroup: [{ source: { name: "red" } }], }, { name: "Dana", - back_to_UserGroup: [{ source: { name: "red" } }], + bk_users_UserGroup: [{ source: { name: "red" } }], }, { name: "Elsa", - back_to_UserGroup: [], + bk_users_UserGroup: [], }, { name: "Zoe", - back_to_UserGroup: [], + bk_users_UserGroup: [], + }, + ]); + }); + + testIfVersionGTE(6)("check read models 12", async () => { + const res = await prisma.assortedScalars.findMany({ + select: { + name: true, + vals: true, + bstr: true, + ts: true, + }, + }); + assert.deepEqual(res, [ + { + name: "hello world", + vals: ["brown", "fox"], + bstr: new Uint8Array([119, 111, 114, 100, 0, 11]), + ts: new Date("2025-01-26T20:13:45Z"), }, ]); }); @@ -359,7 +378,7 @@ describe("prisma", () => { name: name, }, include: { - back_to_UserGroup: { + bk_users_UserGroup: { include: { source: true, }, @@ -368,7 +387,7 @@ describe("prisma", () => { }); assert.equal(res!.name, name); - assert.equal(res!.back_to_UserGroup[0].source.name, "cyan"); + assert.equal(res!.bk_users_UserGroup[0].source.name, "cyan"); assert.ok(res!.id); } @@ -501,12 +520,12 @@ describe("prisma", () => { }, select: { name: true, - back_to_Post: true, + bk_author_Post: true, }, }); assert.deepEqual(res, { name: "Elsa", - back_to_Post: [], + bk_author_Post: [], }); throw new Rollback(); @@ -626,4 +645,66 @@ describe("prisma", () => { } } }); + + testIfVersionGTE(6)("check update models 02", async () => { + try { + await prisma.$transaction(async (tx) => { + const scal = await tx.assortedScalars.findFirst({ + where: { + name: "hello world", + }, + }); + + const scal_id = scal!.id; + assert.ok(scal_id); + assert.equal(scal?.name, "hello world"); + + // name is not unique so deleteMany is used + await tx.assortedScalars.update({ + where: { + id: scal.id, + }, + data: { + name: "New Name", + vals: scal.vals.concat("jumped"), + bstr: new Uint8Array([1, 115, 117, 99, 99, 101, 115, 115, 2]), + ts: new Date("2025-01-20T20:13:45Z"), + }, + }); + + const nope = await tx.assortedScalars.findMany({ + where: { + name: "hello world", + }, + }); + assert.deepEqual(nope, []); + + const res = await tx.assortedScalars.findMany({ + select: { + name: true, + vals: true, + bstr: true, + ts: true, + }, + where: { + name: "New Name", + }, + }); + assert.deepEqual(res, [ + { + name: "New Name", + vals: ["brown", "fox", "jumped"], + bstr: new Uint8Array([1, 115, 117, 99, 99, 101, 115, 115, 2]), + ts: new Date("2025-01-20T20:13:45Z"), + }, + ]); + + throw new Rollback(); + }); + } catch (err) { + if (!(err instanceof Rollback)) { + throw err; + } + } + }); }); diff --git a/integration-tests/prisma/setupTeardown.ts b/integration-tests/prisma/setupTeardown.ts index 6e00671a2..63c42f472 100644 --- a/integration-tests/prisma/setupTeardown.ts +++ b/integration-tests/prisma/setupTeardown.ts @@ -55,6 +55,12 @@ export async function setupTests() { author := assert_single((select User filter .name = 'Elsa')), body := '*magic stuff*', }; + insert AssortedScalars { + name:= 'hello world', + vals := ['brown', 'fox'], + bstr := b'word\x00\x0b', + ts:='2025-01-26T20:13:45+00:00', + }; `); return { diff --git a/packages/generate/src/gel-prisma.ts b/packages/generate/src/gel-prisma.ts index fdaba39f4..84ec21f7d 100644 --- a/packages/generate/src/gel-prisma.ts +++ b/packages/generate/src/gel-prisma.ts @@ -78,7 +78,6 @@ interface JSONType { links: JSONLink[]; properties: JSONField[]; backlinks: JSONLink[]; - backlink_renames?: { [key: string]: string }; } interface LinkTable { @@ -111,6 +110,8 @@ interface MappedSpec { } const CLEAN_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/; +const ARRAY_RE = /^array<(.+)>$/; +const NAME_RE = /^(\w+?)(\d*)$/; function warn(msg: string) { // This function exists in case we want to do something more with all the @@ -184,7 +185,6 @@ function processLinks(types: JSONType[], modules: string[]): ProcessedSpec { types = _skipInvalidNames(types, ["properties", "links"]); for (const spec of types) { type_map[spec.name] = spec; - spec.backlink_renames = {}; } for (const spec of types) { @@ -240,7 +240,7 @@ function processLinks(types: JSONType[], modules: string[]): ProcessedSpec { const objtype = type_map[target]; objtype.backlinks.push({ - name: `back_to_${sql_source}`, + name: `bk_${name}_${sql_source}`, fwname: name, // flip cardinality and exclusivity cardinality: exclusive ? "One" : "Many", @@ -285,37 +285,6 @@ function processLinks(types: JSONType[], modules: string[]): ProcessedSpec { } } } - - // Go over backlinks and resolve any name collisions using the type map. - for (const spec of types) { - // Find collisions in backlink names - const bk: { [key: string]: JSONLink[] } = {}; - - for (const link of spec.backlinks) { - if (link.name.search(/^back_to_/) >= 0) { - if (!bk[link.name]) { - bk[link.name] = []; - } - bk[link.name].push(link); - } - } - - for (const bklinks of Object.values(bk)) { - if (bklinks.length > 1) { - // We have a collision, so each backlink in it must now be disambiguated - for (const link of bklinks) { - const origsrc = getSQLName(link.target.name); - const lname = link.name; - const fwname = link.fwname; - link.name = `$follow_${fwname}_${lname}`; - - // Also update the original source of the link with the special backlink name - const source = type_map[link.target.name]; - source.backlink_renames[fwname] = link.name; - } - } - } - } } return { @@ -345,6 +314,7 @@ const GEL_SCALAR_MAP: { [key: string]: string } = { const BASE_STUB = `\ // Automatically generated from Gel schema. +// Do not edit directly as re-generating this file will overwrite any changes. generator client { provider = "prisma-client-js" @@ -356,6 +326,22 @@ datasource db { } `; +function field_name_sort(a: unknown[], b: unknown[]) { + const a_match = NAME_RE.exec(a[0]); + const b_match = NAME_RE.exec(b[0]); + + const a_val = [a_match![1], Number(a_match![2] || -1)]; + const b_val = [b_match![1], Number(b_match![2] || -1)]; + + if (a_val < b_val) { + return -1; + } else if (a_val > b_val) { + return 1; + } else { + return 0; + } +} + class ModelClass { name: string; table?: string; @@ -363,7 +349,6 @@ class ModelClass { links: { [key: string]: string } = {}; mlinks: { [key: string]: string } = {}; backlinks: { [key: string]: string } = {}; - backlink_renames: { [key: string]: string } = {}; isLinkTable: boolean = false; constructor(name: string) { @@ -371,7 +356,7 @@ class ModelClass { } getBacklinkName(src_link: string, src_name: string): string { - return this.backlink_renames[src_link] || `back_to_${src_name}`; + return `bk_${src_link}_${src_name}`; } } @@ -463,9 +448,6 @@ class ModelGenerator { for (const [name, rec] of Object.entries(maps.object_types)) { const mod: ModelClass = new ModelClass(name); mod.table = name; - if (rec.backlink_renames !== undefined) { - mod.backlink_renames = rec.backlink_renames; - } // copy backlink information for (const link of rec.backlinks) { @@ -568,7 +550,14 @@ class ModelGenerator { } renderProp(prop: JSONField): string { - const target = prop.target.name; + let target = prop.target.name; + let is_array = false; + const match = ARRAY_RE.exec(target); + if (match) { + is_array = true; + target = match[1]; + } + const type: string | undefined = GEL_SCALAR_MAP[target]; if (type === undefined) { @@ -576,7 +565,11 @@ class ModelGenerator { return ""; } // make props opional and let gel deal with the actual requirements - return type + "?"; + if (is_array) { + return type + "[]"; + } else { + return type + "?"; + } } renderSingleLink(link: JSONLink, bklink: string): string { @@ -632,8 +625,18 @@ class ModelGenerator { const maps = this.specToModulesDict(spec); const modmap = this.buildModels(maps); + const values = Object.values(modmap); + values.sort((a, b) => { + if (a.name < b.name) { + return -1; + } else if (a.name == b.name) { + return 0; + } else { + return 1; + } + }); - for (const mod of Object.values(modmap)) { + for (const mod of values) { this.write(); this.renderModelClass(mod); } @@ -660,7 +663,9 @@ class ModelGenerator { if (Object.keys(mod.props).length > 0) { this.write(); this.write("// properties"); - for (const [name, val] of Object.entries(mod.props)) { + const props = Object.entries(mod.props); + props.sort(field_name_sort); + for (const [name, val] of props) { this.write(`${name} ${val}`); } } @@ -668,7 +673,9 @@ class ModelGenerator { if (Object.keys(mod.links).length > 0) { this.write(); this.write("// links"); - for (const [name, val] of Object.entries(mod.links)) { + const links = Object.entries(mod.links); + links.sort(field_name_sort); + for (const [name, val] of links) { this.write(`${name} ${val}`); } } @@ -676,7 +683,9 @@ class ModelGenerator { if (Object.keys(mod.mlinks).length > 0) { this.write(); this.write("// multi-links"); - for (const [name, val] of Object.entries(mod.mlinks)) { + const mlinks = Object.entries(mod.mlinks); + mlinks.sort(field_name_sort); + for (const [name, val] of mlinks) { this.write(`${name} ${val}`); } } @@ -684,7 +693,9 @@ class ModelGenerator { if (Object.keys(mod.backlinks).length > 0) { this.write(); this.write("// backlinks"); - for (const [name, val] of Object.entries(mod.backlinks)) { + const backlinks = Object.entries(mod.backlinks); + backlinks.sort(field_name_sort); + for (const [name, val] of backlinks) { this.write(`${name} ${val}`); } }