Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/**
* End-to-end regression test for the Prisma 7 `prisma-client` generator
* provider scenario.
*
* Prisma 7's new `prisma-client` provider deliberately stops exporting
* `Prisma.ModelName` at runtime, which is what `@cedarjs/internal`'s codegen
* previously relied on to populate its GraphQL `mappers` config. Without that
* record, codegen used to emit a `graphql.d.ts` with empty mappers — every
* resolver typed against `unknown`, every relation collapsed to `never` —
* which only surfaced as hundreds of type errors when the project ran
* `cedar type-check` downstream.
*
* This file exercises the actual codegen output (not just the helper in
* isolation) so a future regression in the schema-parsing fallback would
* fail this test rather than silently producing broken types.
*/
import fs from 'node:fs'
import path from 'node:path'

const { mockPrismaClientPath, mockPrismaClientFileUrl } = await vi.hoisted(
async () => {
const p = await import('node:path')
const { pathToFileURL } = await import('node:url')

const mockPrismaClientPath = p.resolve('/mock-prisma-client-path')
const mockPrismaClientFileUrl = pathToFileURL(mockPrismaClientPath)
return { mockPrismaClientPath, mockPrismaClientFileUrl }
},
)

import { afterAll, afterEach, beforeAll, expect, test, vi } from 'vitest'

import type * as ProjectConfig from '@cedarjs/project-config'

import { generateTypeDefGraphQLApi } from '../generate/graphqlCodeGen.js'
import { generateGraphQLSchema } from '../generate/graphqlSchema.js'

const FIXTURE_PATH = path.resolve(
__dirname,
'../../../../__fixtures__/example-todo-main',
)

beforeAll(() => {
process.env.CEDAR_CWD = FIXTURE_PATH
})

afterAll(() => {
delete process.env.CEDAR_CWD
})

afterEach(() => {
vi.restoreAllMocks()
})

vi.mock('@cedarjs/project-config', async (importOriginal) => {
const original = await importOriginal<typeof ProjectConfig>()
const p = await import('node:path')

return {
...original,
resolveGeneratedPrismaClient: () =>
Promise.resolve({ clientPath: mockPrismaClientPath, error: undefined }),
// The `example-todo-main` fixture predates Prisma's `prisma.config.{ts,cjs}`
// style, so `loadPrismaConfig` (called by the real `getSchemaPath`) would
// throw on the missing config file. Short-circuit it to the fixture's
// actual schema path — the rest of the schema-fallback pipeline still
// runs against real `fs` against a real schema file.
getSchemaPath: () =>
Promise.resolve(
p.resolve(
__dirname,
'../../../../__fixtures__/example-todo-main/api/prisma/schema.prisma',
),
),
}
})

// Stub `execa` so that the intermediate `cedar prisma generate` shell-out in
// `getPrismaClient` is a no-op — we don't want this test to actually invoke
// the CLI.
vi.mock('execa', () => {
return {
default: {
sync: vi.fn(),
},
}
})

const mockNow = vi.hoisted(() => new Date().getTime())

// Crucially, the mocked Prisma client *omits* `ModelName`. This is what the
// Prisma 7 `prisma-client` generator produces at runtime and is the exact
// shape that previously caused codegen to emit empty mappers.
vi.mock(mockPrismaClientFileUrl + '?t=' + mockNow, () => {
return {
Prisma: {
// intentionally no `ModelName`
},
}
})

test('codegen recovers ModelName from schema when Prisma client omits it', async () => {
await generateGraphQLSchema()

vi.setSystemTime(mockNow)

let codegenOutput: {
file: fs.PathOrFileDescriptor
data: string | ArrayBufferView
} = { file: '', data: '' }

vi.spyOn(fs, 'writeFileSync').mockImplementation(
(file: fs.PathOrFileDescriptor, data: string | ArrayBufferView) => {
codegenOutput = { file, data }
},
)

const { typeDefFiles, errors } = await generateTypeDefGraphQLApi()
expect(errors).toEqual([])
expect(typeDefFiles).toHaveLength(1)

const data = String(codegenOutput.data)

// The fixture's `schema.prisma` declares a single `Todo` model. With the
// schema-parsing fallback wired in, the codegen output should import it
// from prisma and use it in the resolver mapper aliases — even though the
// mocked Prisma client doesn't expose `ModelName`.
expect(data).toContain("import { Todo as PrismaTodo } from 'src/lib/db'")

// `AllMappedModels` is generated by `printMappedModelsPlugin` and is the
// strongest single signal that mappers came through. An empty mapper map
// produces `MaybeOrArrayOfMaybe<>` here; a working one produces
// `MaybeOrArrayOfMaybe<Todo>`.
expect(data).toContain('type AllMappedModels = MaybeOrArrayOfMaybe<Todo>')

// And the negative assertion — make sure we don't regress back to the
// empty-mapper output.
expect(data).not.toContain('type AllMappedModels = MaybeOrArrayOfMaybe<>')
})
184 changes: 184 additions & 0 deletions packages/internal/src/__tests__/readModelNamesFromSchema.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'

import { afterEach, beforeEach, describe, expect, it, test, vi } from 'vitest'

import type * as ProjectConfig from '@cedarjs/project-config'

// `getPaths` / `getSchemaPath` are the only project-config helpers exercised by
// `readModelNamesFromSchema`. Stub them to point at a per-test temp directory
// so the function under test does real filesystem work against fixtures we
// control, without dragging in the rest of a Cedar project.
const mockGetPaths = vi.hoisted(() =>
vi.fn<() => { api: { prismaConfig: string | null } }>(),
)
const mockGetSchemaPath = vi.hoisted(() =>
vi.fn<(prismaConfig: string | null) => Promise<string | null>>(),
)

vi.mock('@cedarjs/project-config', async (importOriginal) => {
const original = await importOriginal<typeof ProjectConfig>()
return {
...original,
getPaths: mockGetPaths,
getSchemaPath: mockGetSchemaPath,
}
})

// Import after the mocks are registered so `readModelNamesFromSchema` picks
// them up.
const { readModelNamesFromSchema } =
await import('../generate/graphqlCodeGen.js')

let tmpDir: string

beforeEach(() => {
tmpDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'cedar-read-model-names-from-schema-'),
)
mockGetPaths.mockReturnValue({ api: { prismaConfig: null } })
})

afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true })
vi.clearAllMocks()
})

describe('readModelNamesFromSchema', () => {
it('parses model declarations from a single-file schema', async () => {
const schemaPath = path.join(tmpDir, 'schema.prisma')
fs.writeFileSync(
schemaPath,
[
'generator client { provider = "prisma-client" }',
'',
'model User {',
' id Int @id',
'}',
'',
'model Post {',
' id Int @id',
' authorId Int',
'}',
'',
].join('\n'),
)
mockGetSchemaPath.mockResolvedValue(schemaPath)

const result = await readModelNamesFromSchema()

expect(result).toEqual({ User: 'User', Post: 'Post' })
})

it('merges model declarations across multi-file directory schemas', async () => {
const schemaDir = path.join(tmpDir, 'schema')
fs.mkdirSync(schemaDir)
fs.writeFileSync(
path.join(schemaDir, 'user.prisma'),
['model User {', ' id Int @id', '}'].join('\n'),
)
fs.writeFileSync(
path.join(schemaDir, 'post.prisma'),
['model Post {', ' id Int @id', '}'].join('\n'),
)
// Non-prisma file in the same dir should be ignored.
fs.writeFileSync(
path.join(schemaDir, 'notes.txt'),
'model Decoy { id Int @id }',
)
mockGetSchemaPath.mockResolvedValue(schemaDir)

const result = await readModelNamesFromSchema()

expect(result).toEqual({ User: 'User', Post: 'Post' })
})

it('ignores subdirectories whose names end with .prisma', async () => {
// Regression: a previous version filtered by name only and crashed with
// EISDIR when a subdirectory happened to be named e.g. `views.prisma/`.
const schemaDir = path.join(tmpDir, 'schema')
fs.mkdirSync(schemaDir)
fs.mkdirSync(path.join(schemaDir, 'views.prisma'))
fs.writeFileSync(
path.join(schemaDir, 'main.prisma'),
['model User {', ' id Int @id', '}'].join('\n'),
)
mockGetSchemaPath.mockResolvedValue(schemaDir)

const result = await readModelNamesFromSchema()

expect(result).toEqual({ User: 'User' })
})

it('returns null when the schema has no model declarations', async () => {
const schemaPath = path.join(tmpDir, 'schema.prisma')
fs.writeFileSync(
schemaPath,
[
'generator client { provider = "prisma-client" }',
'datasource db { provider = "postgresql" url = env("DATABASE_URL") }',
].join('\n'),
)
mockGetSchemaPath.mockResolvedValue(schemaPath)

const result = await readModelNamesFromSchema()

expect(result).toBeNull()
})

it('returns null when getSchemaPath resolves to null', async () => {
mockGetSchemaPath.mockResolvedValue(null)

const result = await readModelNamesFromSchema()

expect(result).toBeNull()
})

it('returns null when the schema path does not exist', async () => {
mockGetSchemaPath.mockResolvedValue(path.join(tmpDir, 'missing.prisma'))

const result = await readModelNamesFromSchema()

expect(result).toBeNull()
})

it('returns null when getSchemaPath throws', async () => {
mockGetSchemaPath.mockRejectedValue(new Error('boom'))

const result = await readModelNamesFromSchema()

expect(result).toBeNull()
})

test('regex matches `model` lines with surrounding whitespace and ignores comments', async () => {
const schemaPath = path.join(tmpDir, 'schema.prisma')
fs.writeFileSync(
schemaPath,
[
'// model CommentedOut { id Int @id }',
' model Indented {',
' id Int @id',
'}',
'',
'model Tight{',
' id Int @id',
'}',
'',
'model PaddedName {',
' id Int @id',
'}',
].join('\n'),
)
mockGetSchemaPath.mockResolvedValue(schemaPath)

const result = await readModelNamesFromSchema()

expect(result).toEqual({
Indented: 'Indented',
Tight: 'Tight',
PaddedName: 'PaddedName',
})
expect(result).not.toHaveProperty('CommentedOut')
})
})
45 changes: 44 additions & 1 deletion packages/internal/src/generate/graphqlCodeGen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { Kind, type DocumentNode } from 'graphql'
import {
getPaths,
getConfig,
getSchemaPath,
resolveGeneratedPrismaClient,
} from '@cedarjs/project-config'
import { getPackageManager } from '@cedarjs/project-config/packageManager'
Expand Down Expand Up @@ -273,6 +274,40 @@ function getModelName(mod: unknown): Record<string, string> | null {
return null
}

export async function readModelNamesFromSchema(): Promise<Record<
string,
string
> | null> {
try {
const cedarPaths = getPaths()
const schemaPath = await getSchemaPath(cedarPaths.api.prismaConfig)
if (!schemaPath || !fs.existsSync(schemaPath)) {
return null
}

const schemaSource = fs.statSync(schemaPath).isDirectory()
? fs
.readdirSync(schemaPath, { withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.endsWith('.prisma'))
.map((entry) =>
fs.readFileSync(path.join(schemaPath, entry.name), 'utf8'),
)
.join('\n')
: fs.readFileSync(schemaPath, 'utf8')
Comment thread
greptile-apps[bot] marked this conversation as resolved.

const modelRegex = /^\s*model\s+(\w+)\s*\{/gm
const modelNames: Record<string, string> = {}
let match
while ((match = modelRegex.exec(schemaSource)) !== null) {
modelNames[match[1]] = match[1]
}

return Object.keys(modelNames).length > 0 ? modelNames : null
} catch {
return null
}
}

async function getPrismaClient(): Promise<{
ModelName: Record<string, string>
}> {
Expand Down Expand Up @@ -301,7 +336,15 @@ async function getPrismaClient(): Promise<{
return { ModelName: modelName }
}
} catch {
// Fall through to empty ModelName object below.
// Fall through to schema-based fallback below.
}

// Prisma 7's `prisma-client` provider no longer exports `Prisma.ModelName`,
// so neither attempt above can populate it. Fall back to parsing the schema
// file(s) directly — model declarations are stable across all providers.
const schemaModelNames = await readModelNamesFromSchema()
if (schemaModelNames) {
return { ModelName: schemaModelNames }
}

return { ModelName: {} }
Expand Down
Loading