diff --git a/integration/generated/migration.sql b/integration/generated/migration.sql new file mode 100644 index 0000000..50a3170 --- /dev/null +++ b/integration/generated/migration.sql @@ -0,0 +1,335 @@ +-- CreateSchema +CREATE SCHEMA IF NOT EXISTS "public"; + +-- CreateEnum +CREATE TYPE "Role" AS ENUM ('USER', 'ADMIN'); + +-- CreateEnum +CREATE TYPE "Status" AS ENUM ('active', 'inactive'); + +-- CreateTable +CREATE TABLE "ScalarTypes" ( + "id" TEXT NOT NULL, + "str" TEXT NOT NULL, + "int" INTEGER NOT NULL, + "float" DOUBLE PRECISION NOT NULL, + "bool" BOOLEAN NOT NULL, + "dateTime" TIMESTAMP(3) NOT NULL, + "json" JSONB NOT NULL, + "bigInt" BIGINT NOT NULL, + "decimal" DECIMAL(65,30) NOT NULL, + "bytes" BYTEA NOT NULL, + + CONSTRAINT "ScalarTypes_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "OptionalTypes" ( + "id" TEXT NOT NULL, + "str" TEXT, + "int" INTEGER, + "dateTime" TIMESTAMP(3), + "json" JSONB, + "enum" "Status", + + CONSTRAINT "OptionalTypes_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ArrayTypes" ( + "id" TEXT NOT NULL, + "strings" TEXT[], + "ints" INTEGER[], + "bools" BOOLEAN[], + "enums" "Role"[], + "jsonArray" JSONB[], + + CONSTRAINT "ArrayTypes_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "FieldMapping" ( + "id" TEXT NOT NULL, + "first_name" TEXT NOT NULL, + "last_name" TEXT NOT NULL, + + CONSTRAINT "FieldMapping_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "table_mappings" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + + CONSTRAINT "table_mappings_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "combined_mappings" ( + "id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "combined_mappings_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "CompositePK" ( + "tenantId" TEXT NOT NULL, + "recordId" TEXT NOT NULL, + "data" TEXT NOT NULL, + + CONSTRAINT "CompositePK_pkey" PRIMARY KEY ("tenantId","recordId") +); + +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL, + "email" TEXT NOT NULL, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Profile" ( + "id" TEXT NOT NULL, + "bio" TEXT, + "userId" TEXT NOT NULL, + + CONSTRAINT "Profile_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Post" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "authorId" TEXT NOT NULL, + + CONSTRAINT "Post_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Comment" ( + "id" TEXT NOT NULL, + "text" TEXT NOT NULL, + "postId" TEXT NOT NULL, + + CONSTRAINT "Comment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Article" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + + CONSTRAINT "Article_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Tag" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + + CONSTRAINT "Tag_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Worker" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + + CONSTRAINT "Worker_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Skill" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + + CONSTRAINT "Skill_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "WorkerSkill" ( + "id" TEXT NOT NULL, + "proficiency" INTEGER NOT NULL DEFAULT 1, + "workerId" TEXT NOT NULL, + "skillId" TEXT NOT NULL, + + CONSTRAINT "WorkerSkill_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Category" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "parentId" TEXT, + + CONSTRAINT "Category_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "SocialUser" ( + "id" TEXT NOT NULL, + "username" TEXT NOT NULL, + + CONSTRAINT "SocialUser_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Tenant" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + + CONSTRAINT "Tenant_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TenantConfig" ( + "id" TEXT NOT NULL, + "settings" JSONB NOT NULL, + "tenantId" TEXT NOT NULL, + + CONSTRAINT "TenantConfig_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Task" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "creatorId" TEXT NOT NULL, + "assigneeId" TEXT, + + CONSTRAINT "Task_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Member" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + + CONSTRAINT "Member_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "EnumFields" ( + "id" TEXT NOT NULL, + "role" "Role" NOT NULL, + "statuses" "Status"[], + + CONSTRAINT "EnumFields_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "NativeTypes" ( + "id" UUID NOT NULL, + "varchar" VARCHAR(255) NOT NULL, + "text" TEXT NOT NULL, + "smallInt" SMALLINT NOT NULL, + "decimal" DECIMAL(10,2) NOT NULL, + "timestamp" TIMESTAMP(6) NOT NULL, + "jsonb" JSONB NOT NULL, + + CONSTRAINT "NativeTypes_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ExcludedModel" ( + "id" TEXT NOT NULL, + "secret" TEXT NOT NULL, + + CONSTRAINT "ExcludedModel_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "MinimalModel" ( + "id" TEXT NOT NULL, + + CONSTRAINT "MinimalModel_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ReservedWords" ( + "id" TEXT NOT NULL, + "select_field" TEXT NOT NULL, + "from_field" TEXT NOT NULL, + "where_field" TEXT NOT NULL, + + CONSTRAINT "ReservedWords_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "_ArticleToTag" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL, + + CONSTRAINT "_ArticleToTag_AB_pkey" PRIMARY KEY ("A","B") +); + +-- CreateTable +CREATE TABLE "_BlockList" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL, + + CONSTRAINT "_BlockList_AB_pkey" PRIMARY KEY ("A","B") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "Profile_userId_key" ON "Profile"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WorkerSkill_workerId_skillId_key" ON "WorkerSkill"("workerId", "skillId"); + +-- CreateIndex +CREATE UNIQUE INDEX "SocialUser_username_key" ON "SocialUser"("username"); + +-- CreateIndex +CREATE UNIQUE INDEX "TenantConfig_tenantId_key" ON "TenantConfig"("tenantId"); + +-- CreateIndex +CREATE INDEX "_ArticleToTag_B_index" ON "_ArticleToTag"("B"); + +-- CreateIndex +CREATE INDEX "_BlockList_B_index" ON "_BlockList"("B"); + +-- AddForeignKey +ALTER TABLE "Profile" ADD CONSTRAINT "Profile_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Post" ADD CONSTRAINT "Post_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Comment" ADD CONSTRAINT "Comment_postId_fkey" FOREIGN KEY ("postId") REFERENCES "Post"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WorkerSkill" ADD CONSTRAINT "WorkerSkill_workerId_fkey" FOREIGN KEY ("workerId") REFERENCES "Worker"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WorkerSkill" ADD CONSTRAINT "WorkerSkill_skillId_fkey" FOREIGN KEY ("skillId") REFERENCES "Skill"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Category" ADD CONSTRAINT "Category_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "Category"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TenantConfig" ADD CONSTRAINT "TenantConfig_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Task" ADD CONSTRAINT "Task_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "Member"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Task" ADD CONSTRAINT "Task_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "Member"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ArticleToTag" ADD CONSTRAINT "_ArticleToTag_A_fkey" FOREIGN KEY ("A") REFERENCES "Article"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ArticleToTag" ADD CONSTRAINT "_ArticleToTag_B_fkey" FOREIGN KEY ("B") REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_BlockList" ADD CONSTRAINT "_BlockList_A_fkey" FOREIGN KEY ("A") REFERENCES "SocialUser"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_BlockList" ADD CONSTRAINT "_BlockList_B_fkey" FOREIGN KEY ("B") REFERENCES "SocialUser"("id") ON DELETE CASCADE ON UPDATE CASCADE; + diff --git a/integration/generated/zero/schema.ts b/integration/generated/zero/schema.ts index 2f65199..bf84057 100644 --- a/integration/generated/zero/schema.ts +++ b/integration/generated/zero/schema.ts @@ -4,20 +4,100 @@ import { boolean, createBuilder, - createCRUDBuilder, createSchema, + enumeration, + json, number, relationships, string, table, } from '@rocicorp/zero'; +export type Role = 'USER' | 'ADMIN'; + +export type Status = 'active' | 'inactive'; + +export const scalarTypesTable = table('ScalarTypes') + .columns({ + id: string(), + str: string(), + int: number(), + float: number(), + bool: boolean(), + dateTime: number(), + json: json(), + bigInt: number(), + decimal: number(), + bytes: string(), + }) + .primaryKey('id'); + +export const optionalTypesTable = table('OptionalTypes') + .columns({ + id: string(), + str: string().optional(), + int: number().optional(), + dateTime: number().optional(), + json: json().optional(), + enum: enumeration().optional(), + }) + .primaryKey('id'); + +export const arrayTypesTable = table('ArrayTypes') + .columns({ + id: string(), + strings: json(), + ints: json(), + bools: json(), + enums: json(), + jsonArray: json(), + }) + .primaryKey('id'); + +export const fieldMappingTable = table('FieldMapping') + .columns({ + id: string(), + firstName: string().from('first_name'), + lastName: string().from('last_name'), + }) + .primaryKey('id'); + +export const tableMappingTable = table('TableMapping') + .from('table_mappings') + .columns({ + id: string(), + name: string(), + }) + .primaryKey('id'); + +export const combinedMappingTable = table('CombinedMapping') + .from('combined_mappings') + .columns({ + id: string(), + createdAt: number().from('created_at'), + }) + .primaryKey('id'); + +export const compositePkTable = table('CompositePK') + .columns({ + tenantId: string(), + recordId: string(), + data: string(), + }) + .primaryKey('tenantId', 'recordId'); + export const userTable = table('User') .columns({ id: string(), email: string(), - name: string(), - createdAt: number(), + }) + .primaryKey('id'); + +export const profileTable = table('Profile') + .columns({ + id: string(), + bio: string().optional(), + userId: string(), }) .primaryKey('id'); @@ -25,33 +105,437 @@ export const postTable = table('Post') .columns({ id: string(), title: string(), - content: string().optional(), - published: boolean(), authorId: string(), }) .primaryKey('id'); -export const userTableRelationships = relationships(userTable, ({many}) => ({ - posts: many({ +export const commentTable = table('Comment') + .columns({ + id: string(), + text: string(), + postId: string(), + }) + .primaryKey('id'); + +export const articleTable = table('Article') + .columns({ + id: string(), + name: string(), + }) + .primaryKey('id'); + +export const tagTable = table('Tag') + .columns({ + id: string(), + name: string(), + }) + .primaryKey('id'); + +export const workerTable = table('Worker') + .columns({ + id: string(), + name: string(), + }) + .primaryKey('id'); + +export const skillTable = table('Skill') + .columns({ + id: string(), + name: string(), + }) + .primaryKey('id'); + +export const workerSkillTable = table('WorkerSkill') + .columns({ + id: string(), + proficiency: number(), + workerId: string(), + skillId: string(), + }) + .primaryKey('id'); + +export const categoryTable = table('Category') + .columns({ + id: string(), + name: string(), + parentId: string().optional(), + }) + .primaryKey('id'); + +export const socialUserTable = table('SocialUser') + .columns({ + id: string(), + username: string(), + }) + .primaryKey('id'); + +export const tenantTable = table('Tenant') + .columns({ + id: string(), + name: string(), + }) + .primaryKey('id'); + +export const tenantConfigTable = table('TenantConfig') + .columns({ + id: string(), + settings: json(), + tenantId: string(), + }) + .primaryKey('id'); + +export const taskTable = table('Task') + .columns({ + id: string(), + title: string(), + creatorId: string(), + assigneeId: string().optional(), + }) + .primaryKey('id'); + +export const memberTable = table('Member') + .columns({ + id: string(), + name: string(), + }) + .primaryKey('id'); + +export const enumFieldsTable = table('EnumFields') + .columns({ + id: string(), + role: enumeration(), + statuses: json(), + }) + .primaryKey('id'); + +export const nativeTypesTable = table('NativeTypes') + .columns({ + id: string(), + varchar: string(), + text: string(), + smallInt: number(), + decimal: number(), + timestamp: number(), + jsonb: json(), + }) + .primaryKey('id'); + +export const minimalModelTable = table('MinimalModel') + .columns({ + id: string(), + }) + .primaryKey('id'); + +export const reservedWordsTable = table('ReservedWords') + .columns({ + id: string(), + select: string().from('select_field'), + from: string().from('from_field'), + where: string().from('where_field'), + }) + .primaryKey('id'); + +export const _articleToTagTable = table('_ArticleToTag') + .from('_ArticleToTag') + .columns({ + A: string(), + B: string(), + }) + .primaryKey('A', 'B'); + +export const _blockListTable = table('_BlockList') + .from('_BlockList') + .columns({ + A: string(), + B: string(), + }) + .primaryKey('A', 'B'); + +export const userTableRelationships = relationships( + userTable, + ({one, many}) => ({ + profile: one({ + sourceField: ['id'], + destField: ['userId'], + destSchema: profileTable, + }), + posts: many({ + sourceField: ['id'], + destField: ['authorId'], + destSchema: postTable, + }), + }), +); +export const profileTableRelationships = relationships( + profileTable, + ({one}) => ({ + user: one({ + sourceField: ['userId'], + destField: ['id'], + destSchema: userTable, + }), + }), +); +export const postTableRelationships = relationships( + postTable, + ({one, many}) => ({ + author: one({ + sourceField: ['authorId'], + destField: ['id'], + destSchema: userTable, + }), + comments: many({ + sourceField: ['id'], + destField: ['postId'], + destSchema: commentTable, + }), + }), +); +export const commentTableRelationships = relationships( + commentTable, + ({one}) => ({ + post: one({ + sourceField: ['postId'], + destField: ['id'], + destSchema: postTable, + }), + }), +); +export const articleTableRelationships = relationships( + articleTable, + ({many}) => ({ + tags: many( + { + sourceField: ['id'], + destField: ['A'], + destSchema: _articleToTagTable, + }, + { + sourceField: ['B'], + destField: ['id'], + destSchema: tagTable, + }, + ), + }), +); +export const tagTableRelationships = relationships(tagTable, ({many}) => ({ + articles: many( + { + sourceField: ['id'], + destField: ['B'], + destSchema: _articleToTagTable, + }, + { + sourceField: ['A'], + destField: ['id'], + destSchema: articleTable, + }, + ), +})); +export const workerTableRelationships = relationships( + workerTable, + ({many}) => ({ + skills: many({ + sourceField: ['id'], + destField: ['workerId'], + destSchema: workerSkillTable, + }), + }), +); +export const skillTableRelationships = relationships(skillTable, ({many}) => ({ + workers: many({ + sourceField: ['id'], + destField: ['skillId'], + destSchema: workerSkillTable, + }), +})); +export const workerSkillTableRelationships = relationships( + workerSkillTable, + ({one}) => ({ + worker: one({ + sourceField: ['workerId'], + destField: ['id'], + destSchema: workerTable, + }), + skill: one({ + sourceField: ['skillId'], + destField: ['id'], + destSchema: skillTable, + }), + }), +); +export const categoryTableRelationships = relationships( + categoryTable, + ({one, many}) => ({ + parent: one({ + sourceField: ['parentId'], + destField: ['id'], + destSchema: categoryTable, + }), + children: many({ + sourceField: ['id'], + destField: ['parentId'], + destSchema: categoryTable, + }), + }), +); +export const socialUserTableRelationships = relationships( + socialUserTable, + ({many}) => ({ + blocked: many( + { + sourceField: ['id'], + destField: ['A'], + destSchema: _blockListTable, + }, + { + sourceField: ['B'], + destField: ['id'], + destSchema: socialUserTable, + }, + ), + blockedBy: many( + { + sourceField: ['id'], + destField: ['B'], + destSchema: _blockListTable, + }, + { + sourceField: ['A'], + destField: ['id'], + destSchema: socialUserTable, + }, + ), + }), +); +export const tenantTableRelationships = relationships(tenantTable, ({one}) => ({ + config: one({ sourceField: ['id'], - destField: ['authorId'], - destSchema: postTable, + destField: ['tenantId'], + destSchema: tenantConfigTable, }), })); -export const postTableRelationships = relationships(postTable, ({one}) => ({ - author: one({ - sourceField: ['authorId'], +export const tenantConfigTableRelationships = relationships( + tenantConfigTable, + ({one}) => ({ + tenant: one({ + sourceField: ['tenantId'], + destField: ['id'], + destSchema: tenantTable, + }), + }), +); +export const taskTableRelationships = relationships(taskTable, ({one}) => ({ + creator: one({ + sourceField: ['creatorId'], + destField: ['id'], + destSchema: memberTable, + }), + assignee: one({ + sourceField: ['assigneeId'], destField: ['id'], - destSchema: userTable, + destSchema: memberTable, }), })); +export const memberTableRelationships = relationships( + memberTable, + ({many}) => ({ + createdTasks: many({ + sourceField: ['id'], + destField: ['creatorId'], + destSchema: taskTable, + }), + assignedTasks: many({ + sourceField: ['id'], + destField: ['assigneeId'], + destSchema: taskTable, + }), + }), +); +export const _articleToTagTableRelationships = relationships( + _articleToTagTable, + ({one}) => ({ + modelA: one({ + sourceField: ['A'], + destField: ['id'], + destSchema: articleTable, + }), + modelB: one({ + sourceField: ['B'], + destField: ['id'], + destSchema: tagTable, + }), + }), +); +export const _blockListTableRelationships = relationships( + _blockListTable, + ({one}) => ({ + modelA: one({ + sourceField: ['A'], + destField: ['id'], + destSchema: socialUserTable, + }), + modelB: one({ + sourceField: ['B'], + destField: ['id'], + destSchema: socialUserTable, + }), + }), +); /** * The Zero schema object. * This type is auto-generated from your Prisma schema definition. */ export const schema = createSchema({ - tables: [userTable, postTable], - relationships: [userTableRelationships, postTableRelationships], + tables: [ + scalarTypesTable, + optionalTypesTable, + arrayTypesTable, + fieldMappingTable, + tableMappingTable, + combinedMappingTable, + compositePkTable, + userTable, + profileTable, + postTable, + commentTable, + articleTable, + tagTable, + workerTable, + skillTable, + workerSkillTable, + categoryTable, + socialUserTable, + tenantTable, + tenantConfigTable, + taskTable, + memberTable, + enumFieldsTable, + nativeTypesTable, + minimalModelTable, + reservedWordsTable, + _articleToTagTable, + _blockListTable, + ], + relationships: [ + userTableRelationships, + profileTableRelationships, + postTableRelationships, + commentTableRelationships, + articleTableRelationships, + tagTableRelationships, + workerTableRelationships, + skillTableRelationships, + workerSkillTableRelationships, + categoryTableRelationships, + socialUserTableRelationships, + tenantTableRelationships, + tenantConfigTableRelationships, + taskTableRelationships, + memberTableRelationships, + _articleToTagTableRelationships, + _blockListTableRelationships, + ], }); /** @@ -71,11 +555,6 @@ export const zql = createBuilder(schema); * @deprecated Use `zql` instead. */ export const builder = zql; -/** - * Represents the Zero schema CRUD builder. - * This type is auto-generated from your Prisma schema definition. - */ -export const crud = createCRUDBuilder(schema); /** Defines the default types for Zero */ declare module '@rocicorp/zero' { interface DefaultTypes { diff --git a/integration/package.json b/integration/package.json index 90c0c06..5e00a30 100644 --- a/integration/package.json +++ b/integration/package.json @@ -5,7 +5,8 @@ "type": "module", "scripts": { "build": "tsc", - "generate": "prisma generate", + "generate": "prisma generate && pnpm migrate", + "migrate": "pnpm prisma migrate diff --from-empty --to-schema schema.prisma --script > generated/migration.sql", "pretest": "pnpm generate", "test": "pnpm build" }, diff --git a/integration/prisma.config.ts b/integration/prisma.config.ts new file mode 100644 index 0000000..58722c6 --- /dev/null +++ b/integration/prisma.config.ts @@ -0,0 +1,8 @@ +import {defineConfig} from 'prisma/config'; + +export default defineConfig({ + schema: './schema.prisma', + datasource: { + url: 'postgresql://postgres:postgres@localhost:5432/prisma-zero', + }, +}); diff --git a/integration/schema.prisma b/integration/schema.prisma index c28e274..fc05166 100644 --- a/integration/schema.prisma +++ b/integration/schema.prisma @@ -3,24 +3,314 @@ datasource db { } generator zero { - provider = "prisma-zero" - output = "./generated/zero" - prettier = true + provider = "prisma-zero" + output = "./generated/zero" + prettier = true + // camelCase = true + excludeTables = ["ExcludedModel"] } -model User { +// ============================================================================ +// ENUMS +// ============================================================================ + +/// TEST: Basic enum → TypeScript union type +enum Role { + USER + ADMIN +} + +/// TEST: Enum with @map → uses dbName in union values +enum Status { + ACTIVE @map("active") + INACTIVE @map("inactive") +} + +// ============================================================================ +// SCALAR TYPES - Tests all Prisma scalar → Zero type mappings +// ============================================================================ + +/// TEST: All scalar types and their Zero mappings +/// - String → string() +/// - Int/Float/BigInt/Decimal → number() +/// - Boolean → boolean() +/// - DateTime → number() (timestamp) +/// - Json → json() +/// - Bytes → string() (fallback) +model ScalarTypes { + id String @id @default(uuid()) + str String + int Int + float Float + bool Boolean + dateTime DateTime + json Json + bigInt BigInt + decimal Decimal + bytes Bytes +} + +/// TEST: Optional modifier → .optional() on all types +model OptionalTypes { + id String @id @default(uuid()) + str String? + int Int? + dateTime DateTime? + json Json? + enum Status? +} + +/// TEST: Array types → json() since Zero doesn't support native arrays +model ArrayTypes { + id String @id @default(uuid()) + strings String[] + ints Int[] + bools Boolean[] + enums Role[] + jsonArray Json[] +} + +// ============================================================================ +// FIELD & TABLE MAPPING - Tests @map and @@map attributes +// ============================================================================ + +/// TEST: Field @map → .from('db_column_name') +model FieldMapping { + id String @id @default(uuid()) + firstName String @map("first_name") + lastName String @map("last_name") +} + +/// TEST: Table @@map → table().from('db_table_name') +model TableMapping { + id String @id @default(uuid()) + name String + + @@map("table_mappings") +} + +/// TEST: Combined field + table mapping +model CombinedMapping { id String @id @default(uuid()) - email String @unique - name String - createdAt DateTime @default(now()) - posts Post[] + createdAt DateTime @map("created_at") + + @@map("combined_mappings") +} + +// ============================================================================ +// PRIMARY KEYS - Tests @id and @@id +// ============================================================================ + +/// TEST: Composite primary key (@@id) → .primaryKey('field1', 'field2') +model CompositePK { + tenantId String + recordId String + data String + + @@id([tenantId, recordId]) } +// ============================================================================ +// RELATIONSHIPS - One-to-One +// ============================================================================ + +/// TEST: One-to-one relationship (has one side) +model User { + id String @id @default(uuid()) + email String @unique + profile Profile? + posts Post[] +} + +/// TEST: One-to-one relationship (belongs to side with FK) +model Profile { + id String @id @default(uuid()) + bio String? + userId String @unique + user User @relation(fields: [userId], references: [id]) +} + +// ============================================================================ +// RELATIONSHIPS - One-to-Many +// ============================================================================ + +/// TEST: One-to-many (has many side) - uses many() with back-reference FK model Post { - id String @id @default(uuid()) - title String - content String? - published Boolean @default(false) - authorId String - author User @relation(fields: [authorId], references: [id]) + id String @id @default(uuid()) + title String + authorId String + author User @relation(fields: [authorId], references: [id]) + comments Comment[] +} + +/// TEST: One-to-many (belongs to side) +model Comment { + id String @id @default(uuid()) + text String + postId String + post Post @relation(fields: [postId], references: [id]) +} + +// ============================================================================ +// RELATIONSHIPS - Many-to-Many (Implicit) +// ============================================================================ + +/// TEST: Implicit M:N → creates _ArticleToTag join table with A/B columns +model Article { + id String @id @default(uuid()) + name String + tags Tag[] +} + +/// TEST: Implicit M:N (other side) → chained relationship through join table +model Tag { + id String @id @default(uuid()) + name String + articles Article[] +} + +// ============================================================================ +// RELATIONSHIPS - Many-to-Many (Explicit with extra data) +// ============================================================================ + +/// TEST: Explicit M:N source - treated as regular 1:N +model Worker { + id String @id @default(uuid()) + name String + skills WorkerSkill[] +} + +/// TEST: Explicit M:N target - treated as regular 1:N +model Skill { + id String @id @default(uuid()) + name String + workers WorkerSkill[] +} + +/// TEST: Explicit junction table with extra fields (proficiency) +model WorkerSkill { + id String @id @default(uuid()) + proficiency Int @default(1) + workerId String + worker Worker @relation(fields: [workerId], references: [id]) + skillId String + skill Skill @relation(fields: [skillId], references: [id]) + + @@unique([workerId, skillId]) +} + +// ============================================================================ +// RELATIONSHIPS - Self-Referential +// ============================================================================ + +/// TEST: Self-referential 1:N (parent/children tree) +model Category { + id String @id @default(uuid()) + name String + parentId String? + parent Category? @relation("CategoryTree", fields: [parentId], references: [id]) + children Category[] @relation("CategoryTree") +} + +/// TEST: Self-referential implicit M:N → creates _BlockList join table +model SocialUser { + id String @id @default(uuid()) + username String @unique + blocked SocialUser[] @relation("BlockList") + blockedBy SocialUser[] @relation("BlockList") +} + +// ============================================================================ +// RELATIONSHIPS - Composite Foreign Keys +// ============================================================================ + +/// TEST: Parent with composite PK for FK reference +model Tenant { + id String @id @default(uuid()) + name String + config TenantConfig? +} + +/// TEST: Composite FK relationship +model TenantConfig { + id String @id @default(uuid()) + settings Json + tenantId String @unique + tenant Tenant @relation(fields: [tenantId], references: [id]) +} + +// ============================================================================ +// RELATIONSHIPS - Multiple relations to same model +// ============================================================================ + +/// TEST: Model with multiple relations to same target (creator vs assignee) +model Task { + id String @id @default(uuid()) + title String + creatorId String + creator Member @relation("TaskCreator", fields: [creatorId], references: [id]) + assigneeId String? + assignee Member? @relation("TaskAssignee", fields: [assigneeId], references: [id]) +} + +/// TEST: Target of multiple named relations +model Member { + id String @id @default(uuid()) + name String + createdTasks Task[] @relation("TaskCreator") + assignedTasks Task[] @relation("TaskAssignee") +} + +// ============================================================================ +// ENUMS IN FIELDS +// ============================================================================ + +/// TEST: Enum field → enumeration() +/// TEST: Enum array field → json() +model EnumFields { + id String @id @default(uuid()) + role Role + statuses Status[] +} + +// ============================================================================ +// NATIVE DATABASE TYPES - Tests @db.* attributes (mapped to base type) +// ============================================================================ + +/// TEST: Native types → all map to base Zero types +model NativeTypes { + id String @id @default(uuid()) @db.Uuid + varchar String @db.VarChar(255) + text String @db.Text + smallInt Int @db.SmallInt + decimal Decimal @db.Decimal(10, 2) + timestamp DateTime @db.Timestamp(6) + jsonb Json @db.JsonB +} + +// ============================================================================ +// EXCLUDED MODEL - Tests excludeTables config +// ============================================================================ + +/// TEST: Model in excludeTables → should NOT appear in generated schema +model ExcludedModel { + id String @id @default(uuid()) + secret String +} + +// ============================================================================ +// EDGE CASES +// ============================================================================ + +/// TEST: Minimal model (just ID) → basic table generation +model MinimalModel { + id String @id +} + +/// TEST: Reserved SQL keywords as field names with @map +model ReservedWords { + id String @id @default(uuid()) + select String @map("select_field") + from String @map("from_field") + where String @map("where_field") } diff --git a/package.json b/package.json index 735d4bd..d78fa53 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "prisma-zero", - "version": "0.1.0-canary.1", + "version": "0.1.0-canary.2", "description": "Generate Zero schemas from Prisma ORM schemas", "type": "module", "scripts": { @@ -53,7 +53,6 @@ "devDependencies": { "@rocicorp/prettier-config": "^0.4.0", "@rocicorp/zero": "0.24.3000000000", - "@ts-morph/common": "^0.28.1", "@types/node": "^24.10.1", "@types/pg": "^8.15.6", "@types/pluralize": "^0.0.33", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 29085d7..6dc0146 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,9 +21,6 @@ importers: '@rocicorp/zero': specifier: 0.24.3000000000 version: 0.24.3000000000(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0)) - '@ts-morph/common': - specifier: ^0.28.1 - version: 0.28.1 '@types/node': specifier: ^24.10.1 version: 24.10.2 @@ -525,14 +522,6 @@ packages: peerDependencies: hono: ^4 - '@isaacs/balanced-match@4.0.1': - resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} - engines: {node: 20 || >=22} - - '@isaacs/brace-expansion@5.0.0': - resolution: {integrity: sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==} - engines: {node: 20 || >=22} - '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -1364,9 +1353,6 @@ packages: '@standard-schema/spec@1.0.0': resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} - '@ts-morph/common@0.28.1': - resolution: {integrity: sha512-W74iWf7ILp1ZKNYXY5qbddNaml7e9Sedv5lvU1V8lftlitkc9Pq1A+jlH23ltDgWYeZFFEqGCD1Ies9hqu3O+g==} - '@types/aws-lambda@8.10.152': resolution: {integrity: sha512-soT/c2gYBnT5ygwiHPmd9a1bftj462NWVk2tKCc1PYHSIacB2UwbTS2zYG4jzag1mRDuzg/OjtxQjQ2NKRB6Rw==} @@ -2176,10 +2162,6 @@ packages: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} - minimatch@10.1.1: - resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==} - engines: {node: 20 || >=22} - minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} @@ -2296,9 +2278,6 @@ packages: parse-prometheus-text-format@1.1.1: resolution: {integrity: sha512-dBlhYVACjRdSqLMFe4/Q1l/Gd3UmXm8ruvsTi7J6ul3ih45AkzkVpI5XHV4aZ37juGZW5+3dGU5lwk+QLM9XJA==} - path-browserify@1.0.1: - resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} - path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -3242,12 +3221,6 @@ snapshots: dependencies: hono: 4.10.6 - '@isaacs/balanced-match@4.0.1': {} - - '@isaacs/brace-expansion@5.0.0': - dependencies: - '@isaacs/balanced-match': 4.0.1 - '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -4328,12 +4301,6 @@ snapshots: '@standard-schema/spec@1.0.0': {} - '@ts-morph/common@0.28.1': - dependencies: - minimatch: 10.1.1 - path-browserify: 1.0.1 - tinyglobby: 0.2.15 - '@types/aws-lambda@8.10.152': {} '@types/basic-auth@1.1.8': @@ -5212,10 +5179,6 @@ snapshots: mimic-response@3.1.0: {} - minimatch@10.1.1: - dependencies: - '@isaacs/brace-expansion': 5.0.0 - minimist@1.2.8: {} mkdirp-classic@0.5.3: {} @@ -5332,8 +5295,6 @@ snapshots: dependencies: shallow-equal: 1.2.1 - path-browserify@1.0.1: {} - path-key@3.1.1: {} path-parse@1.0.7: {} diff --git a/src/generators/code-generator.ts b/src/generators/code-generator.ts index ab4ede5..629ae38 100644 --- a/src/generators/code-generator.ts +++ b/src/generators/code-generator.ts @@ -13,13 +13,14 @@ function generateImports(schema: TransformedSchema): string { usedImports.add('table'); usedImports.add('createSchema'); usedImports.add('createBuilder'); - usedImports.add('createCRUDBuilder'); // Check which type functions are used in the schema schema.models.forEach(model => { Object.values(model.columns).forEach(mapping => { - // Extract the base type (e.g., "string()" -> "string", "enumeration(...)" -> "enumeration") - const baseType = mapping.type.split('(')[0]; + // Extract the base type (e.g., "string()" -> "string", "enumeration()" -> "enumeration", "json()" -> "json") + // Handle both generic types like "enumeration()" and simple types like "string()" + const match = mapping.type.match(/^([a-z]+)/); + const baseType = match?.[1]; if (baseType) { usedImports.add(baseType); } @@ -203,13 +204,6 @@ function generateSchema(schema: TransformedSchema): string { output += ' */\n'; output += 'export const builder = zql;\n'; - output += '/**\n'; - output += ' * Represents the Zero schema CRUD builder.\n'; - output += - ' * This type is auto-generated from your Prisma schema definition.\n'; - output += ' */\n'; - output += 'export const crud = createCRUDBuilder(schema);\n'; - output += '/** Defines the default types for Zero */\n'; output += 'declare module "@rocicorp/zero" {\n'; output += ' interface DefaultTypes {\n'; diff --git a/src/mappers/schema-mapper.ts b/src/mappers/schema-mapper.ts index e8cd837..b429b3f 100644 --- a/src/mappers/schema-mapper.ts +++ b/src/mappers/schema-mapper.ts @@ -159,7 +159,10 @@ function mapRelationships( } const backReference = targetModel.fields.find( - f => f.relationName === field.relationName && f.type === model.name, + f => + f.relationName === field.relationName && + f.type === model.name && + f.name !== field.name, // Exclude current field for self-referential relations ); if (field.isList) { @@ -179,7 +182,12 @@ function mapRelationships( `Implicit relation ${field.name}: Model ${model.name} or ${targetModel.name} not found.`, ); } - const isModelA = model.name === modelA.name; + const isSelfReferential = model.name === targetModel.name; + const isModelA = isSelfReferential + ? backReference + ? field.name.localeCompare(backReference.name) < 0 + : true + : model.name === modelA.name; // Create a chained relationship through the join table relationships[field.name] = { @@ -263,14 +271,17 @@ function mapModel( throw new Error(`No primary key found for ${model.name}`); } - const tableName = getTableNameFromModel(model); - const camelCasedName = config?.camelCase ? toCamelCase(tableName) : tableName; - - const shouldRemap = config.camelCase && camelCasedName !== tableName; + // Use the Prisma model name (optionally camelCased) for the Zero table name. + // If the Prisma model is mapped to a different DB table (@@map) or camelCase + // changes the casing, capture the DB table name in originalTableName so we + // can emit `.from("")` in the generated schema. + const databaseTableName = getTableNameFromModel(model); + const tableName = getTableName(model.name, config); + const shouldRemap = tableName !== databaseTableName; return { - tableName: shouldRemap ? camelCasedName : tableName, - originalTableName: shouldRemap ? tableName : null, + tableName, + originalTableName: shouldRemap ? databaseTableName : null, modelName: model.name, zeroTableName: getZeroTableName(model.name), columns, @@ -304,12 +315,22 @@ export function transformSchema( if (config.excludeTables?.includes(targetModel.name)) return null; const backReference = targetModel.fields.find( - f => f.relationName === field.relationName && f.type === model.name, + f => + f.relationName === field.relationName && + f.type === model.name && + f.name !== field.name, // Exclude current field for self-referential relations ); if (backReference?.isList) { // Only create the join table once for each relationship - if (model.name.localeCompare(targetModel.name) < 0) { + // For self-referential relations (model === targetModel), use field name comparison + // For different models, use model name comparison + const isSelfReferential = model.name === targetModel.name; + const shouldCreate = isSelfReferential + ? field.name.localeCompare(backReference.name) < 0 + : model.name.localeCompare(targetModel.name) < 0; + + if (shouldCreate) { return createImplicitManyToManyModel( model, targetModel, diff --git a/tests/generator.test.ts b/tests/generator.test.ts index 4b1aa28..2f8b6f7 100644 --- a/tests/generator.test.ts +++ b/tests/generator.test.ts @@ -77,7 +77,6 @@ describe('Generator', () => { import { createBuilder, - createCRUDBuilder, createSchema, number, string, @@ -120,11 +119,6 @@ describe('Generator', () => { * @deprecated Use \`zql\` instead. */ export const builder = zql; - /** - * Represents the Zero schema CRUD builder. - * This type is auto-generated from your Prisma schema definition. - */ - export const crud = createCRUDBuilder(schema); /** Defines the default types for Zero */ declare module "@rocicorp/zero" { interface DefaultTypes { @@ -172,9 +166,8 @@ describe('Generator', () => { import { createBuilder, - createCRUDBuilder, createSchema, - enumeration, + enumeration, string, table, } from "@rocicorp/zero"; @@ -215,11 +208,6 @@ describe('Generator', () => { * @deprecated Use \`zql\` instead. */ export const builder = zql; - /** - * Represents the Zero schema CRUD builder. - * This type is auto-generated from your Prisma schema definition. - */ - export const crud = createCRUDBuilder(schema); /** Defines the default types for Zero */ declare module "@rocicorp/zero" { interface DefaultTypes { @@ -270,7 +258,6 @@ describe('Generator', () => { import { createBuilder, - createCRUDBuilder, createSchema, relationships, string, @@ -338,11 +325,6 @@ describe('Generator', () => { * @deprecated Use \`zql\` instead. */ export const builder = zql; - /** - * Represents the Zero schema CRUD builder. - * This type is auto-generated from your Prisma schema definition. - */ - export const crud = createCRUDBuilder(schema); /** Defines the default types for Zero */ declare module "@rocicorp/zero" { interface DefaultTypes { @@ -419,7 +401,6 @@ describe('Generator', () => { import { createBuilder, - createCRUDBuilder, createSchema, number, relationships, @@ -517,11 +498,6 @@ describe('Generator', () => { * @deprecated Use \`zql\` instead. */ export const builder = zql; - /** - * Represents the Zero schema CRUD builder. - * This type is auto-generated from your Prisma schema definition. - */ - export const crud = createCRUDBuilder(schema); /** Defines the default types for Zero */ declare module "@rocicorp/zero" { interface DefaultTypes { @@ -570,7 +546,6 @@ describe('Generator', () => { import { createBuilder, - createCRUDBuilder, createSchema, number, relationships, @@ -668,11 +643,6 @@ describe('Generator', () => { * @deprecated Use \`zql\` instead. */ export const builder = zql; - /** - * Represents the Zero schema CRUD builder. - * This type is auto-generated from your Prisma schema definition. - */ - export const crud = createCRUDBuilder(schema); /** Defines the default types for Zero */ declare module "@rocicorp/zero" { interface DefaultTypes { @@ -706,11 +676,8 @@ describe('Generator', () => { import { createBuilder, - createCRUDBuilder, createSchema, - json, - json, - json, + json, string, table, } from "@rocicorp/zero"; @@ -752,11 +719,6 @@ describe('Generator', () => { * @deprecated Use \`zql\` instead. */ export const builder = zql; - /** - * Represents the Zero schema CRUD builder. - * This type is auto-generated from your Prisma schema definition. - */ - export const crud = createCRUDBuilder(schema); /** Defines the default types for Zero */ declare module "@rocicorp/zero" { interface DefaultTypes { @@ -796,9 +758,8 @@ describe('Generator', () => { import { createBuilder, - createCRUDBuilder, createSchema, - json, + json, string, table, } from "@rocicorp/zero"; @@ -840,11 +801,6 @@ describe('Generator', () => { * @deprecated Use \`zql\` instead. */ export const builder = zql; - /** - * Represents the Zero schema CRUD builder. - * This type is auto-generated from your Prisma schema definition. - */ - export const crud = createCRUDBuilder(schema); /** Defines the default types for Zero */ declare module "@rocicorp/zero" { interface DefaultTypes { diff --git a/tests/schema-mapper.test.ts b/tests/schema-mapper.test.ts index 8baf9b0..08eb7ed 100644 --- a/tests/schema-mapper.test.ts +++ b/tests/schema-mapper.test.ts @@ -126,6 +126,20 @@ describe('Schema Mapper', () => { expect(result.models[0]?.originalTableName).toBeNull(); }); + it('keeps Prisma model names when @@map is used and camelCase is false', () => { + const model = createModel( + 'TableMapping', + [createField('id', 'String', {isId: true})], + {dbName: 'table_mappings'}, + ); + + const dmmf = createMockDMMF([model]); + const result = transformSchema(dmmf, baseConfig); + + expect(result.models[0]?.tableName).toBe('TableMapping'); + expect(result.models[0]?.originalTableName).toBe('table_mappings'); + }); + it('should remap table names to camel case when camelCase is true', () => { const model = createModel('UserProfile', [ createField('id', 'String', {isId: true}), @@ -142,6 +156,23 @@ describe('Schema Mapper', () => { expect(result.models[0]?.originalTableName).toBe('UserProfile'); }); + it('camelCases the Prisma model name but preserves @@map database name', () => { + const model = createModel( + 'TableMapping', + [createField('id', 'String', {isId: true})], + {dbName: 'table_mappings'}, + ); + + const dmmf = createMockDMMF([model]); + const result = transformSchema(dmmf, { + ...baseConfig, + camelCase: true, + }); + + expect(result.models[0]?.tableName).toBe('tableMapping'); + expect(result.models[0]?.originalTableName).toBe('table_mappings'); + }); + it('should preserve table name if already in camel case', () => { const model = createModel('userProfile', [ createField('id', 'String', {isId: true}), @@ -176,7 +207,7 @@ describe('Schema Mapper', () => { camelCase: true, }); - expect(result.models[0]?.tableName).toBe('userProfile'); + expect(result.models[0]?.tableName).toBe('user'); expect(result.models[0]?.originalTableName).toBe('user_profile'); }); @@ -198,7 +229,7 @@ describe('Schema Mapper', () => { camelCase: true, }); - expect(result.models[0]?.tableName).toBe('userProfileSettings'); + expect(result.models[0]?.tableName).toBe('user'); expect(result.models[0]?.originalTableName).toBe('user_profile_settings'); }); @@ -220,7 +251,7 @@ describe('Schema Mapper', () => { camelCase: true, }); - expect(result.models[0]?.tableName).toBe('_userProfile'); + expect(result.models[0]?.tableName).toBe('userProfile'); expect(result.models[0]?.originalTableName).toBe('_UserProfile'); }); @@ -332,6 +363,48 @@ describe('Schema Mapper', () => { expect(childrenRelationship).toHaveProperty('destSchema'); } }); + + it('maps self-referential implicit many-to-many relationships to distinct join columns', () => { + const socialUserModel = createModel('SocialUser', [ + createField('id', 'String', {isId: true}), + createField('blocked', 'SocialUser', { + isList: true, + relationName: 'BlockList', + kind: 'object', + }), + createField('blockedBy', 'SocialUser', { + isList: true, + relationName: 'BlockList', + kind: 'object', + }), + ]); + + const dmmf = createMockDMMF([socialUserModel]); + const result = transformSchema(dmmf, baseConfig); + + const socialUser = result.models.find(m => m.modelName === 'SocialUser'); + expect(socialUser).toBeDefined(); + if (!socialUser) { + throw new Error('SocialUser model not found'); + } + + const blocked = socialUser.relationships.blocked; + const blockedBy = socialUser.relationships.blockedBy; + + if (!blocked || !blockedBy) { + throw new Error('Expected SocialUser to have both relationship fields'); + } + + if (!('chain' in blocked) || !('chain' in blockedBy)) { + throw new Error('Expected chained many-to-many relationships'); + } + + expect(blocked.chain[0]?.destField).toEqual(['A']); + expect(blocked.chain[1]?.sourceField).toEqual(['B']); + + expect(blockedBy.chain[0]?.destField).toEqual(['B']); + expect(blockedBy.chain[1]?.sourceField).toEqual(['A']); + }); }); it('should correctly map implicit many-to-many relationships with non-string primary keys', () => {