Skip to content

Commit

Permalink
feat(typed-sql): Support enum names that are not valid JS identifiers (
Browse files Browse the repository at this point in the history
…#25262)

For enum types, we are using DB names for types and values.
Problem: enum can be mapped to arbitrary name in the database that would
not necessary be a valid JS identifier. In that case, Prisma 5.19.0
generates syntactically invalid TS declaration.

This PR fixes this. Rather than introduce sanitization function (and
risk naming conflicts when sanitizer produces identical name for two
different originals), we are continuing to rely on DB names, we just use
them differently depending on their validity as an identifier.

For valid identifiers, we continue to use namespaced name:
`$DbEnum.MyEnum`.

For invalid ones, we access them as a string literal property:
`$DbEnums["MyEnum"]`.

Fix #25163
  • Loading branch information
SevInf authored Sep 24, 2024
1 parent ce11a90 commit 100c926
Show file tree
Hide file tree
Showing 8 changed files with 131 additions and 11 deletions.
41 changes: 33 additions & 8 deletions packages/client/src/generation/typedSql/buildDbEnums.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { DMMF, SqlQueryOutput } from '@prisma/generator-helper'
import { isValidJsIdentifier } from '@prisma/internals'

import * as ts from '../ts-builders'

Expand All @@ -23,25 +24,49 @@ export class DbEnumsList {
return Boolean(this.enums.find((dbEnum) => dbEnum.name === name))
}

*[Symbol.iterator]() {
*validJsIdentifiers() {
for (const dbEnum of this.enums) {
yield dbEnum
if (isValidJsIdentifier(dbEnum.name)) {
yield dbEnum
}
}
}

*invalidJsIdentifiers() {
for (const dbEnum of this.enums) {
if (!isValidJsIdentifier(dbEnum.name)) {
yield dbEnum
}
}
}
}

export function buildDbEnums(list: DbEnumsList) {
const file = ts.file()
for (const dbEntry of list) {
file.add(buildDbEnum(dbEntry))
}
file.add(buildInvalidIdentifierEnums(list))
file.add(buildValidIdentifierEnums(list))

return ts.stringify(file)
}

function buildDbEnum(dbEnum: DbEnum) {
const type = ts.unionType(dbEnum.values.map(ts.stringLiteral))
return ts.moduleExport(ts.typeDeclaration(dbEnum.name, type))
function buildValidIdentifierEnums(list: DbEnumsList) {
const namespace = ts.namespace('$DbEnums')
for (const dbEnum of list.validJsIdentifiers()) {
namespace.add(ts.typeDeclaration(dbEnum.name, enumToUnion(dbEnum)))
}
return ts.moduleExport(namespace)
}

function buildInvalidIdentifierEnums(list: DbEnumsList) {
const iface = ts.interfaceDeclaration('$DbEnums')
for (const dbEnum of list.invalidJsIdentifiers()) {
iface.add(ts.property(dbEnum.name, enumToUnion(dbEnum)))
}
return ts.moduleExport(iface)
}

function enumToUnion(dbEnum: DbEnum) {
return ts.unionType(dbEnum.values.map(ts.stringLiteral))
}

export function queryUsesEnums(query: SqlQueryOutput, enums: DbEnumsList): boolean {
Expand Down
2 changes: 1 addition & 1 deletion packages/client/src/generation/typedSql/buildIndex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { DbEnumsList } from './buildDbEnums'
export function buildIndexTs(queries: SqlQueryOutput[], enums: DbEnumsList) {
const file = ts.file()
if (!enums.isEmpty()) {
file.add(ts.moduleExportFrom('./$DbEnums').asNamespace('$DbEnums'))
file.add(ts.moduleExportFrom('./$DbEnums').named('$DbEnums'))
}
for (const query of queries) {
file.add(ts.moduleExportFrom(`./${query.name}`))
Expand Down
2 changes: 1 addition & 1 deletion packages/client/src/generation/typedSql/buildTypedQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export function buildTypedQueryTs({ query, runtimeBase, runtimeName, enums }: Bu

file.addImport(ts.moduleImport(`${runtimeBase}/${runtimeName}`).asNamespace('$runtime'))
if (queryUsesEnums(query, enums)) {
file.addImport(ts.moduleImport('./$DbEnums').asNamespace('$DbEnums'))
file.addImport(ts.moduleImport('./$DbEnums').named('$DbEnums'))
}

const doc = ts.docComment(query.documentation ?? undefined)
Expand Down
10 changes: 9 additions & 1 deletion packages/client/src/generation/typedSql/mapTypes.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { QueryIntrospectionBuiltinType, QueryIntrospectionType } from '@prisma/generator-helper'
import { isValidJsIdentifier } from '@prisma/internals'

import * as ts from '../ts-builders'
import { DbEnumsList } from './buildDbEnums'
Expand Down Expand Up @@ -107,7 +108,7 @@ function getMappingConfig(

if (!config) {
if (enums.hasEnum(introspectionType)) {
const type = ts.namedType(`$DbEnums.${introspectionType}`)
const type = getEnumType(introspectionType)

return { in: type, out: type }
}
Expand All @@ -121,3 +122,10 @@ function getMappingConfig(

return config
}

function getEnumType(name: string) {
if (isValidJsIdentifier(name)) {
return ts.namedType(`$DbEnums.${name}`)
}
return ts.namedType('$DbEnums').subKey(name)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { defineMatrix } from '../../_utils/defineMatrix'
import { Providers } from '../../_utils/providers'

export default defineMatrix(() => [[{ provider: Providers.POSTGRESQL }, { provider: Providers.COCKROACHDB }]])
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { idForProvider } from '../../../_utils/idForProvider'
import testMatrix from '../_matrix'

export default testMatrix.setupSchema(({ provider }) => {
return /* Prisma */ `
generator client {
provider = "prisma-client-js"
previewFeatures = ["typedSql"]
}
datasource db {
provider = "${provider}"
url = env("DATABASE_URI_${provider}")
}
model User {
id ${idForProvider(provider)}
role UserRole
favoriteAnimal Animal
}
enum UserRole {
ADMIN
USER
@@map("user-role")
}
enum Animal {
CAT
DOG
STEVE
}
`
})
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
SELECT "role", "favoriteAnimal" FROM "User"
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { expectTypeOf } from 'expect-type'

import testMatrix from './_matrix'
// @ts-ignore
import type { PrismaClient } from './node_modules/@prisma/client'
// @ts-ignore
import * as Sql from './node_modules/@prisma/client/sql'

declare let prisma: PrismaClient
declare let sql: typeof Sql

testMatrix.setupTestSuite(
() => {
beforeAll(async () => {
await prisma.user.create({
data: {
role: 'ADMIN',
favoriteAnimal: 'STEVE',
},
})
})

test('returns enums that are mapped to invalid JS identifier correctly', async () => {
const result = await prisma.$queryRawTyped(sql.getUser())
expect(result).toMatchInlineSnapshot(`
[
{
"favoriteAnimal": "STEVE",
"role": "ADMIN",
},
]
`)

expectTypeOf(result[0].favoriteAnimal).toEqualTypeOf<'CAT' | 'DOG' | 'STEVE'>()
expectTypeOf(result[0].favoriteAnimal).toEqualTypeOf<Sql.$DbEnums.Animal>()

expectTypeOf(result[0].role).toEqualTypeOf<'ADMIN' | 'USER'>()
expectTypeOf(result[0].role).toEqualTypeOf<Sql.$DbEnums['user-role']>()
})
},
{
optOut: {
from: ['sqlite', 'mysql', 'mongodb', 'sqlserver'],
reason: 'Test need both enums and typed-sql support',
},
},
)

0 comments on commit 100c926

Please sign in to comment.