Skip to content
GQLoom

Prisma

Prisma ORM凭借其直观的数据模型、自动迁移、类型安全和自动完成功能,使开发人员在使用数据库时获得了全新的体验。

@gqloom/prisma 提供了 GQLoom 与 Prisma 的集成:

  • 从 Prisma Schema 生成丝线
  • 使用解析器工厂从 Prisma 快速生成 CRUD 操作。

安装

请参考 Prisma 的入门指南安装 Prisma 与对应的数据库驱动。

sh
npm i graphql @gqloom/core @gqloom/prisma
sh
pnpm add graphql @gqloom/core @gqloom/prisma
sh
yarn add graphql @gqloom/core @gqloom/prisma
sh
bun add graphql @gqloom/core @gqloom/prisma
sh
deno add npm:graphql npm:prisma npm:@gqloom/core npm:@gqloom/prisma

配置

prisma/schema.prisma 文件中定义你的 Prisma Schema:

prisma
generator client {
  provider = "prisma-client-js"
}

generator gqloom { 
  provider = "prisma-gqloom"
} 

datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

model User {
  id    Int     @id @default(autoincrement())
  email String  @unique
  name  String?
  posts Post[]
}

model Post {
  id        Int     @id @default(autoincrement())
  title     String
  content   String?
  published Boolean @default(false)
  author    User    @relation(fields: [authorId], references: [id])
  authorId  Int
}

generator 参数

generator 接受以下参数:

参数说明默认值
gqloomPathGQLoom 包的路径。@gqloom/prisma
clientOutputPrisma 客户端的路径。node_modules/@prisma/client
output生成的文件所在的文件夹路径。node_modules/@gqloom/prisma/generated
commonjsFileCommonJS 文件的文件名。使用空字符串 "" 将跳过 CommonJS 文件的生成。index.cjs
moduleFileES 模块文件的文件名。使用空字符串 "" 将跳过 ES 模块文件的生成。index.js
typesFilesTypeScript 声明文件的文件名。使用 [] 将跳过 TypeScript 声明文件的生成。["index.d.ts"]

生成丝线

sh
npx prisma generate

使用丝线

在生成丝线后,我们可以在 resolver 中使用,同时我们使用 useSelectedFields 以确保只选择 GraphQL 查询所需要的字段:

ts
import { resolver, query, field, weave } from '@gqloom/core'
import { asyncContextProvider } from '@gqloom/core/context'
import { useSelectedFields } from "@gqloom/prisma/context"
import { ValibotWeaver } from '@gqloom/valibot'
import { Post, User } from '@gqloom/prisma/generated'
import * as v from 'valibot'
import { PrismaClient } from '@prisma/client'

const db = new PrismaClient({})

const userResolver = resolver.of(User, {
  user: query(User.nullable(), {
    input: { id: v.number() },
    resolve: ({ id }) => {
      return db.user.findUnique({
        select: useSelectedFields(User),
        where: { id },
      })
    },
  }),

  posts: field(Post.list(), async (user) => {
    const posts = await db.user
      .findUnique({ where: { id: user.id } })
      .posts({ select: useSelectedFields(Post) })
    return posts ?? []
  }),
})

const postResolver = resolver.of(Post, {
  author: field(User.nullable())
    .derivedFrom("authorId")
    .resolve((post) => {
      if (!post.authorId) return null
      return db.user.findUnique({ where: { id: post.authorId } })
    }),
})

export const schema = weave(asyncContextProvider, ValibotWeaver, userResolver, postResolver)

如上面的代码所示,我们可以直接在 resolver 里使用 Prisma 生成的类型。在这里我们定义了两个解析器:userResolverpostResolver

userResolver 中,我们使用 User 作为 resolver.of 的父类型,并定义了两个字段:

  • user 查询:返回类型是 User.nullable(),表示可能返回单个用户或 null。它接受一个 id 参数,并使用 Prisma 的 findUnique 方法查询数据库。
  • posts 字段:返回类型是 Post.list(),表示返回该用户的所有文章列表。它通过 Prisma 的关系查询来获取用户的文章。

postResolver 中,我们使用 Post 作为父类型,定义了一个字段:

  • author 字段:返回类型是 User,表示返回文章的作者。它通过 Prisma 的关系查询来获取文章的作者信息。

所有查询都使用了 useSelectedFields() 函数来确保只选择 GraphQL 查询中请求的字段,这有助于优化数据库查询性能。此函数需要启用上下文。对于无法使用 useSelectedFields() 函数的运行时,我们也可以使用 getSelectedFields() 函数来获取当前查询需要选取的列。

派生字段

为模型添加派生字段非常简单,但需要注意使用 field().derivedFrom() 方法声明所依赖的列,以便 useSelectedFields 方法能正确地选取这些列:

ts
export const postResolver = resolver.of(Post, {
  abstract: field(v.string())
    .derivedFrom("title", "content")
    .resolve((post) => {
      return `${post.title} ${post.content?.slice(0, 60)}...`
    }),
})

隐藏字段

@gqloom/prisma 默认将暴露所有字段。如果你希望隐藏某些字段,你可以使用 field.hidden

ts
const postResolver = resolver.of(Post, {
  author: field(User, async (post) => {
    const author = await db.post.findUnique({ where: { id: post.id } }).author()
    return author!
  }),

  authorId: field.hidden, 
})

在上面的代码中,我们隐藏了 authorId 字段,这意味着它将不会出现在生成的 GraphQL Schema 中。

模型配置

通过生成的丝线的 .config() 方法,你可以为特定的 Prisma 模型定制输出字段、输入行为以及元数据。

输出字段配置

你可以使用 fields 选项来定制模型生成的 GraphQL 对象类型(Object Type)。这允许你覆盖字段的类型、添加描述或隐藏特定字段。

ts
import { User } from '@gqloom/prisma/generated'
import { weave, SYMBOLS } from '@gqloom/core'
import { GraphQLID } from 'graphql'

const userConfig = User.config({
  description: "系统用户信息", // 为 GraphQL 类型添加描述
  fields: {
    // 覆盖字段描述
    email: { description: "用户的唯一电子邮箱" },
    // 覆盖字段类型,支持 GraphQL 类型或丝线
    id: { type: GraphQLID },
    // 隐藏字段,使其不出现在查询结果中
    password: SYMBOLS.FIELD_HIDDEN,
  },
})

export const schema = weave(userConfig, userResolver, postResolver)

输入字段行为

你可以使用 input 选项来控制字段在各种输入类型(如 CreateInputUpdateInputWhereInput)中的行为。你可以根据不同的「操作」来决定字段是否可见,或者覆盖其输入类型。

支持的操作类型包括:

  • create: 用于创建操作的输入(如 UserCreateInput)。
  • update: 用于更新操作的输入(如 UserUpdateInput)。
  • filters: 用于过滤操作的输入(如 UserWhereInput)。
ts
import { User } from '@gqloom/prisma/generated'
import { weave } from '@gqloom/core'
import * as v from 'valibot'

const userConfig = User.config({
  input: {
    // 在创建时隐藏 email 字段
    email: { create: false },
    // 在更新时将 name 字段设为必填(通过丝线覆盖)
    name: { update: v.string() },
    // 默认隐藏所有字段的过滤功能
    "*": { filters: false },
    // 仅显式开启 id 的过滤功能
    id: { filters: true },
  },
})

export const schema = weave(userConfig, userResolver, postResolver)

优先级

对于输入类型,input 选项中定义的行为优先级最高,它会覆盖 fields 中的配置以及全局的预设。

解析器工厂

@gqloom/prisma 提供了 PrismaResolverFactory 来帮助你创建解析器工厂。
使用解析器工厂,你可以快速定义常用的查询、变更和字段,解析器工厂还预置了常见操作输入的输入类型,使用解析器工厂可以大大减少样板代码,这在快速开发时非常有用。

ts
import { Post, User } from '@gqloom/prisma/generated'
import { PrismaResolverFactory } from '@gqloom/prisma'

import { PrismaClient } from '@prisma/client'

const db = new PrismaClient({})

const userResolverFactory = new PrismaResolverFactory(User, db)
const postResolverFactory = new PrismaResolverFactory(Post, db)

在上面的代码中,我们创建了 UserPost 模型的解析器工厂。PrismaResolverFactory 接受两个参数,第一个是作为丝线的模型,第二个是 PrismaClient 实例。

关系字段

解析器工厂提供了 relationField 方法来定义关系字段:

ts
const userResolver = resolver.of(User, {
  user: query(User.nullable(), {
    input: { id: v.number() },
    resolve: ({ id }) => {
      return db.user.findUnique({ where: { id } })
    },
  }),

  posts: field(Post.list(), async (user) => { 
    const posts = await db.user.findUnique({ where: { id: user.id } }).posts() 
    return posts ?? [] 
  }), 
  posts: userResolverFactory.relationField('posts'), 
})

const postResolver = resolver.of(Post, {
  author: field(User, async (post) => { 
    const author = await db.post.findUnique({ where: { id: post.id } }).author() 
    return author!
  }), 
  author: postResolverFactory.relationField('author'), 

  authorId: field.hidden,
})

在上面的代码中,我们使用 userResolverFactory.relationField('posts')postResolverFactory.relationField('author') 来定义关系字段。 relationField 方法接受一个字符串参数,表示关系字段的名称。

查询

解析器工厂预置了常用的查询:

  • countQuery
  • findFirstQuery
  • findManyQuery
  • findUniqueQuery

你可以直接使用它们:

ts
const userResolver = resolver.of(User, {
  user: query(User.nullable(), { 
    input: { id: v.number() }, 
    resolve: ({ id }) => { 
      return db.user.findUnique({ where: { id } }) 
    }, 
  }), 
  user: userResolverFactory.findUniqueQuery(), 

  posts: userResolverFactory.relationField('posts'),
})

在上面的代码中,我们使用 userResolverFactory.findUniqueQuery() 来定义 user 查询。解析器工厂将自动创建输入类型和解析函数。

变更

解析器工厂预置了常用的变更:

  • createMutation
  • createManyMutation
  • deleteMutation
  • deleteManyMutation
  • updateMutation
  • updateManyMutation
  • upsertMutation

你可以直接使用它们:

ts
const postResolver = resolver.of(Post, {
  createPost: postResolverFactory.createMutation(), 

  author: postResolverFactory.relationField('author'),

  authorId: field.hidden,
})

在上面的代码中,我们使用 postResolverFactory.createMutation() 来定义 createPost 变更。工厂将自动创建输入类型和解析函数。

自定义输入

解析器工厂预置的查询和变更支持自定义输入,你可以通过 input 选项来定义输入类型:

ts
import * as v from "valibot"

const userResolver = resolver.of(User, {
  user: userResolverFactory.findUniqueQuery().input(
    v.pipe( 
      v.object({ id: v.number() }), 
      v.transform(({ id }) => ({ where: { id } })) 
    ) 
  ),

  posts: userResolverFactory.relationField("posts"),
})

在上面的代码中,我们使用 valibot 来定义输入类型, v.object({ id: v.number() }) 定义了输入对象的类型,v.transform(({ id }) => ({ where: { id } })) 将输入参数转换为 Prisma 的查询参数。

添加中间件

解析器工厂预置的查询、变更和字段支持添加中间件,你可以通过 middlewares 选项来定义中间件:

ts
const postResolver = resolver.of(Post, {
  createPost: postResolverFactory.createMutation().use(async (next) => {
    const user = await useAuthedUser() 
    if (user == null) throw new GraphQLError("Please login first") 
    return next() 
  }), 

  author: postResolverFactory.relationField("author"),

  authorId: field.hidden,
})

在上面的代码中,我们使用 middlewares 选项来定义中间件,async (next) => { ... } 定义了一个中间件,useAuthedUser() 是一个自定义的函数,用于获取当前登录的用户,如果用户未登录,则抛出一个错误,否则调用 next() 继续执行。

完整解析器

你可以从解析器工厂中直接创建一个完整解析器:

ts
// Readonly Resolver
const userQueriesResolver = userResolverFactory.queriesResolver()

// Full Resolver
const userResolver = userResolverFactory.resolver()

有两个用于创建解析器的方法:

  • usersResolverFactory.queriesResolver(): 创建一个只包含查询、关系字段的解析器。
  • usersResolverFactory.resolver(): 创建一个包含所有查询、变更和关系字段的解析器。

自定义类型映射

为了适应更多的 Prisma 类型,我们可以拓展 GQLoom 为其添加更多的类型映射。

首先我们使用 PrismaWeaver.config 来定义类型映射的配置。这里我们导入来自 graphql-scalarsGraphQLDateTimeGraphQLJSON,当遇到 DateTimeJson 类型时,我们将其映射到对应的 GraphQL 标量。

ts
import { 
GraphQLDateTime
,
GraphQLJSON
} from 'graphql-scalars'
import {
PrismaWeaver
} from '@gqloom/prisma'
export const
prismaWeaverConfig
=
PrismaWeaver
.
config
({
/** * 将 @id 字段发射为 GraphQL ID 类型(仅影响输出类型)。 * 默认为 true。如果设为 false,则使用底层标量(如 Int 或 String)。 */
emitIdAsIDType
: false,
presetGraphQLType
: (
type
) => {
switch (
type
) {
case 'DateTime': return
GraphQLDateTime
case 'Json': return
GraphQLJSON
} }, })

在编织 GraphQL Schema 时传入配置到 weave 函数中:

ts
import { weave } from "@gqloom/core"

export const schema = weave(prismaWeaverConfig, userResolver, postResolver)

默认类型映射

下表列出了 GQLoom 中 Prisma 类型与 GraphQL 类型之间的默认映射关系:

Prisma 类型GraphQL 类型
Int @idGraphQLID
String @idGraphQLID
BigIntGraphQLInt
IntGraphQLInt
DecimalGraphQLFloat
FloatGraphQLFloat
BooleanGraphQLBoolean
DateTimeGraphQLString
StringGraphQLString