Chapter 04

Mutation:修改与写入

掌握 GraphQL 数据变更操作——input 类型、错误处理与文件上传

Mutation 基础

Schema 定义

input CreatePostInput {
  title: String!
  content: String!
  tags: [String!]
  published: Boolean = false
}

input UpdatePostInput {
  title: String
  content: String
  tags: [String!]
  published: Boolean
}

type Mutation {
  createPost(input: CreatePostInput!): Post!
  updatePost(id: ID!, input: UpdatePostInput!): Post!
  deletePost(id: ID!): DeletePostResult!
  publishPost(id: ID!): Post!
}

客户端调用

mutation CreatePost($input: CreatePostInput!) {
  createPost(input: $input) {
    id
    title
    published
    createdAt
    author { name }
  }
}

# Variables:
# {
#   "input": {
#     "title": "Hello GraphQL",
#     "content": "...",
#     "tags": ["graphql", "api"]
#   }
# }

Resolver 实现

const resolvers = {
  Mutation: {
    createPost: async (
      _: unknown,
      { input }: { input: CreatePostInput },
      { db, user }: Context
    ) => {
      // 验证用户已登录
      if (!user) throw new AuthenticationError('Must be logged in');

      // 创建帖子
      const post = await db.posts.create({
        data: {
          ...input,
          authorId: user.id,
        },
      });
      return post;
    },

    deletePost: async (_, { id }, { db, user }) => {
      const post = await db.posts.findUnique({ where: { id } });
      if (!post) throw new UserInputError('Post not found');
      if (post.authorId !== user.id) throw new ForbiddenError('Not your post');

      await db.posts.delete({ where: { id } });
      return { success: true, id };
    },
  },
};

错误处理模式

方案一:GraphQL errors 数组(Apollo 默认)

{
  "data": { "createPost": null },
  "errors": [
    {
      "message": "Title cannot be empty",
      "locations": [{ "line": 2, "column": 3 }],
      "path": ["createPost"],
      "extensions": {
        "code": "BAD_USER_INPUT",
        "field": "title"
      }
    }
  ]
}

方案二:Union 错误类型(推荐)

type ValidationError {
  field: String!
  message: String!
}

type CreatePostSuccess {
  post: Post!
}

type CreatePostError {
  message: String!
  validationErrors: [ValidationError!]
}

# Mutation 返回 Union 类型
union CreatePostResult = CreatePostSuccess | CreatePostError

type Mutation {
  createPost(input: CreatePostInput!): CreatePostResult!
}

# 客户端查询
mutation {
  createPost(input: { title: "", content: "..." }) {
    __typename
    ... on CreatePostSuccess {
      post { id title }
    }
    ... on CreatePostError {
      message
      validationErrors { field message }
    }
  }
}

文件上传(multipart)

scalar Upload

type Mutation {
  uploadAvatar(file: Upload!, userId: ID!): User!
  uploadMultiple(files: [Upload!]!): [File!]!
}
import { GraphQLUpload, FileUpload } from 'graphql-upload';

const resolvers = {
  Upload: GraphQLUpload,
  Mutation: {
    uploadAvatar: async (_, { file, userId }, { db, storage }) => {
      const { createReadStream, filename, mimetype }: FileUpload = await file;

      // 上传到对象存储(S3/OSS)
      const url = await storage.upload({
        stream: createReadStream(),
        key: `avatars/${userId}/${filename}`,
        contentType: mimetype,
      });

      return db.users.update({
        where: { id: userId },
        data: { avatarUrl: url },
      });
    },
  },
};
Mutation 中的串行执行

GraphQL 查询中多个字段并行执行,但 Mutation 中多个操作保证串行执行,按从上到下的顺序依次运行。这对于依赖前一个操作结果的场景非常重要。

input 类型设计最佳实践

为何使用 input 类型而非直接参数
将多个参数包装为 input 类型有三个优势:1) 可扩展性——新增字段不改变 Mutation 签名;2) 客户端可以将 input 对象声明为变量复用;3) 类型文档清晰,与普通 Object 类型区分(input 类型不能有 Resolver,只能包含标量/enum/其他 input 类型)。
Create vs Update input 分离
创建时必填字段(如 title!)在更新时应为可选(title: String),所以通常 CreateXxxInput 和 UpdateXxxInput 是两个不同类型。不要复用同一个 input 类型同时用于创建和更新。
返回值设计
Mutation 应返回被修改的对象(而非 Boolean),让客户端缓存能自动更新相关字段。deleteXxx 可返回被删除对象的 ID 或 { success: Boolean!, deletedId: ID! } 结构,便于客户端从缓存中移除。

两种错误处理方案对比

errors 数组方案(Apollo 默认)
Resolver 抛出 GraphQLError,错误出现在响应顶层 errors 数组中,data.createPost 为 null。优点:简单;缺点:客户端需要额外处理 errors 数组,难以在 TypeScript 中类型安全地访问错误详情。
Union 错误类型方案(推荐)
Mutation 返回 CreatePostResult = CreatePostSuccess | CreatePostError Union 类型,错误作为数据字段返回(data.createPost.__typename === 'CreatePostError')。优点:类型安全、可携带结构化验证错误、客户端用 inline fragment 处理;缺点:Schema 更复杂。

事务性 Mutation

// Prisma 事务:多个写操作原子性执行
const resolvers = {
  Mutation: {
    transferCredits: async (_, { fromId, toId, amount }, { db }) => {
      // $transaction 确保两个操作要么全成功,要么全失败
      const [from, to] = await db.$transaction([
        // 扣减发送方积分
        db.users.update({
          where: { id: fromId },
          data: { credits: { decrement: amount } },
        }),
        // 增加接收方积分
        db.users.update({
          where: { id: toId },
          data: { credits: { increment: amount } },
        }),
      ]);

      // 检查发送方余额是否足够(事务后验证)
      if (from.credits < 0) {
        throw new GraphQLError('Insufficient credits', {
          extensions: { code: 'BAD_USER_INPUT' }
        });
      }

      return { from, to, amount };
    },
  },
};

本章小结

本章核心要点
  • input 类型设计:Create 和 Update 用不同 input 类型(必填 vs 可选);Mutation 应返回被修改的对象而非 Boolean,便于客户端缓存自动更新。
  • 两种错误处理:throws GraphQLError(简单,errors 数组)vs Union 类型(类型安全,推荐);生产代码推荐 Union 方案,让错误成为可预期的数据而非意外。
  • Mutation 串行执行:同一请求中多个 Mutation 字段按顺序串行执行(不同于 Query 的并行),这是 GraphQL 规范保证的行为。
  • 文件上传:使用 graphql-upload 包定义 Upload scalar,通过 multipart/form-data 协议传输;Resolver 中解构 createReadStream/filename/mimetype 处理上传流。
  • 事务处理:多个写操作需要原子性时,用 Prisma $transaction 或原生 DB 事务包裹;事务完成后再抛出业务错误(如余额不足),确保数据一致性。
  • 安全验证顺序:Mutation Resolver 中应先检查认证(requireAuth)→ 检查授权(requireRole/isOwner)→ 验证输入(zod/manual)→ 执行业务逻辑,不要省略任何一步。