Skip to content
Merged
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
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@
"@prisma/config": "workspace:*",
"@prisma/dev": "0.20.0",
"@prisma/engines": "workspace:*",
"@prisma/studio-core": "0.16.3",
"@prisma/studio-core": "0.21.1",
"mysql2": "3.15.3",
"postgres": "3.4.7"
},
Expand Down
75 changes: 70 additions & 5 deletions packages/cli/src/Studio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { serve } from '@hono/node-server'
import type { PrismaConfigInternal } from '@prisma/config'
import { arg, type Command, format, HelpError, isError } from '@prisma/internals'
import type { Executor, SequenceExecutor } from '@prisma/studio-core/data'
import { serializeError, type StudioBFFRequest } from '@prisma/studio-core/data/bff'
import { type SerializedError, serializeError, type StudioBFFRequest } from '@prisma/studio-core/data/bff'
import { createMySQL2Executor } from '@prisma/studio-core/data/mysql2'
import { createNodeSQLiteExecutor } from '@prisma/studio-core/data/node-sqlite'
import { createPostgresJSExecutor } from '@prisma/studio-core/data/postgresjs'
Expand Down Expand Up @@ -347,27 +347,27 @@ ${bold('Examples')}
const [error, results] = await executor.execute(request.query)

if (error) {
return ctx.json([serializeError(error)])
return ctx.json([serializeBffError(error)])
}

return ctx.json([null, results])
}

if (procedure === 'sequence') {
if (!('executeSequence' in executor)) {
return ctx.json([[serializeError(new Error('Executor does not support sequences'))]])
return ctx.json([[serializeBffError(new Error('Executor does not support sequences'))]])
}

const [[error0, result0], maybeResult1] = await (executor as SequenceExecutor).executeSequence(request.sequence)

if (error0) {
return ctx.json([[serializeError(error0)]])
return ctx.json([[serializeBffError(error0)]])
}

const [error1, result1] = maybeResult1 || []

if (error1) {
return ctx.json([[null, result0], [serializeError(error1)]])
return ctx.json([[null, result0], [serializeBffError(error1)]])
}

return ctx.json([
Expand All @@ -376,6 +376,23 @@ ${bold('Examples')}
])
}

if (procedure === 'sql-lint') {
if (!executor.lintSql) {
return ctx.json([serializeBffError(new Error('Executor does not support SQL lint'))])
}

const [error, result] = await executor.lintSql({
schemaVersion: request.schemaVersion,
sql: request.sql,
})

if (error) {
return ctx.json([serializeBffError(error)])
}

return ctx.json([null, result])
}

procedure satisfies undefined

return ctx.text('Unknown procedure', { status: 500 })
Expand Down Expand Up @@ -441,6 +458,54 @@ function getUrlBasePath(url: string | undefined, configPath: string | null): str
return url ? process.cwd() : configPath ? dirname(configPath) : process.cwd()
}

function serializeBffError(error: unknown): SerializedError {
return getSerializedBffError(error) ?? serializeError(error)
}

function getSerializedBffError(error: unknown): SerializedError | null {
if (isSerializedError(error)) {
return error
}

if (!isRecord(error)) {
return null
}

const nestedError = error.error

if (isSerializedError(nestedError)) {
return nestedError
}

const rpcSerializedError = error['@@error']

if (isSerializedError(rpcSerializedError)) {
return rpcSerializedError
}

return null
}

function isSerializedError(error: unknown): error is SerializedError {
if (!isRecord(error)) {
return false
}

if (typeof error.name !== 'string' || typeof error.message !== 'string') {
return false
}

if (error.errors === undefined) {
return true
}

return Array.isArray(error.errors) && error.errors.every(isSerializedError)
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}

function isAccelerateProtocol(protocol: string): boolean {
return protocol === 'prisma' || protocol === 'prisma+postgres'
}
Expand Down
217 changes: 212 additions & 5 deletions packages/cli/src/__tests__/Studio.vitest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,23 @@ import { defaultTestConfig } from '@prisma/config'
import { beforeEach, describe, expect, test, vi } from 'vitest'

const createPoolMock = vi.fn(() => ({ end: vi.fn() }))
const serveMock = vi.fn(() => ({ close: vi.fn() }))
const createPostgresJSExecutorMock = vi.fn(() => ({
execute: vi.fn(),
}))
const serializeErrorMock = vi.fn((error: unknown) => {
if (error instanceof Error) {
return {
message: error.message,
name: error.name,
}
}

return {
message: JSON.stringify(error),
name: 'UnknownError',
}
})

vi.mock('mysql2/promise', () => {
return {
Expand All @@ -11,7 +28,7 @@ vi.mock('mysql2/promise', () => {

vi.mock('@hono/node-server', () => {
return {
serve: vi.fn(() => ({ close: vi.fn() })),
serve: serveMock,
}
})

Expand All @@ -25,7 +42,7 @@ vi.mock('@prisma/studio-core/data/mysql2', () => {

vi.mock('@prisma/studio-core/data/bff', () => {
return {
serializeError: vi.fn(() => ({ message: 'mock-error' })),
serializeError: serializeErrorMock,
}
})

Expand All @@ -39,16 +56,17 @@ vi.mock('@prisma/studio-core/data/node-sqlite', () => {

vi.mock('@prisma/studio-core/data/postgresjs', () => {
return {
createPostgresJSExecutor: vi.fn(() => ({
execute: vi.fn(),
})),
createPostgresJSExecutor: createPostgresJSExecutorMock,
}
})

describe('Studio MySQL URL compatibility', () => {
beforeEach(() => {
vi.resetModules()
createPoolMock.mockClear()
createPostgresJSExecutorMock.mockClear()
serveMock.mockClear()
serializeErrorMock.mockClear()
})

test('converts sslaccept=strict to mysql2 ssl JSON', async () => {
Expand Down Expand Up @@ -120,3 +138,192 @@ describe('Studio MySQL URL compatibility', () => {
expect(passedUrl.searchParams.get('ssl')).toBe('{"rejectUnauthorized":false}')
})
})

describe('Studio BFF', () => {
beforeEach(() => {
vi.resetModules()
createPoolMock.mockClear()
createPostgresJSExecutorMock.mockClear()
serveMock.mockClear()
serializeErrorMock.mockClear()
})

test('routes sql-lint requests to executor.lintSql', async () => {
const lintSqlMock = vi.fn(() =>
Promise.resolve([
null,
{
diagnostics: [{ from: 0, message: 'lint-ok', severity: 'info', to: 1 }],
schemaVersion: 'v1',
},
]),
)

await startStudioBff({
execute: vi.fn(),
lintSql: lintSqlMock,
})

const response = await getBffResponse({
procedure: 'sql-lint',
schemaVersion: 'v1',
sql: 'select 1',
})

expect(lintSqlMock).toHaveBeenCalledWith({
schemaVersion: 'v1',
sql: 'select 1',
})
expect(await response.json()).toEqual([
null,
{
diagnostics: [{ from: 0, message: 'lint-ok', severity: 'info', to: 1 }],
schemaVersion: 'v1',
},
])
})

test('unwraps RPC-serialized sql-lint errors', async () => {
await startStudioBff({
execute: vi.fn(),
lintSql: vi.fn(() =>
Promise.resolve([
{
'@@error': {
message: 'relation "missing_table" does not exist',
name: 'PostgresError',
},
},
]),
),
})

const response = await getBffResponse({
procedure: 'sql-lint',
schemaVersion: 'v1',
sql: 'select * from missing_table',
})

expect(serializeErrorMock).not.toHaveBeenCalled()
expect(await response.json()).toEqual([
{
message: 'relation "missing_table" does not exist',
name: 'PostgresError',
},
])
})

test('passes through top-level serialized sql-lint errors', async () => {
await startStudioBff({
execute: vi.fn(),
lintSql: vi.fn(() =>
Promise.resolve([
{
message: 'syntax error at or near "from"',
name: 'PostgresError',
},
]),
),
})

const response = await getBffResponse({
procedure: 'sql-lint',
schemaVersion: 'v1',
sql: 'select from',
})

expect(serializeErrorMock).not.toHaveBeenCalled()
expect(await response.json()).toEqual([
{
message: 'syntax error at or near "from"',
name: 'PostgresError',
},
])
})

test('unwraps nested serialized sql-lint errors', async () => {
await startStudioBff({
execute: vi.fn(),
lintSql: vi.fn(() =>
Promise.resolve([
{
error: {
message: 'relation "users" does not exist',
name: 'PostgresError',
},
},
]),
),
})

const response = await getBffResponse({
procedure: 'sql-lint',
schemaVersion: 'v1',
sql: 'select * from users',
})

expect(serializeErrorMock).not.toHaveBeenCalled()
expect(await response.json()).toEqual([
{
message: 'relation "users" does not exist',
name: 'PostgresError',
},
])
})

test('falls back to serializeError for unknown sql-lint error shapes', async () => {
await startStudioBff({
execute: vi.fn(),
lintSql: vi.fn(() =>
Promise.resolve([
{
message: 'missing name field',
} as never,
]),
),
})

const response = await getBffResponse({
procedure: 'sql-lint',
schemaVersion: 'v1',
sql: 'select 1',
})

expect(serializeErrorMock).toHaveBeenCalledTimes(1)
expect(await response.json()).toEqual([
{
message: '{"message":"missing name field"}',
name: 'UnknownError',
},
])
})
})

async function getBffResponse(body: unknown): Promise<Response> {
const fetchHandler = serveMock.mock.calls.at(-1)?.[0]?.fetch as ((request: Request) => Promise<Response>) | undefined

if (!fetchHandler) {
throw new Error('Studio server fetch handler was not registered')
}

return fetchHandler(
new Request('http://localhost:5555/bff', {
body: JSON.stringify(body),
headers: {
'content-type': 'application/json',
},
method: 'POST',
}),
)
}

async function startStudioBff(executor: { execute: ReturnType<typeof vi.fn>; lintSql?: ReturnType<typeof vi.fn> }) {
createPostgresJSExecutorMock.mockReturnValueOnce(executor)

const { Studio } = await import('../Studio')

await Studio.new().parse(
['--browser', 'none', '--port', '5555', '--url', 'postgresql://user:password@localhost:5432/db'],
defaultTestConfig(),
)
}
2 changes: 1 addition & 1 deletion packages/client-generator-js/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
"@prisma/client-common": "workspace:*",
"@prisma/debug": "workspace:*",
"@prisma/dmmf": "workspace:*",
"@prisma/engines-version": "7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919",
"@prisma/engines-version": "7.5.0-13.0f1690a1b5dcd01b5341a4f411f07767f1f76fc2",
"@prisma/fetch-engine": "workspace:*",
"@prisma/generator": "workspace:*",
"@prisma/get-platform": "workspace:*",
Expand Down
Loading
Loading