为什么GraphQL查询返回null?

37

我有一个graphql/apollo-server/graphql-yoga端点,它公开了从数据库(或REST端点或其他服务)返回的数据。

我知道我的数据源正在返回正确的数据--如果我在解析器内记录数据源调用的结果,我可以看到返回的数据。但是,我的GraphQL字段总是返回null。

如果我使字段为非null,则会在响应的errors数组中看到以下错误:

Cannot return null for non-nullable field

为什么GraphQL没有返回数据呢?


1
注意:此问题旨在作为类似问题的参考问题和潜在的重复目标。这就是为什么问题很广泛,省略了任何特定的代码或模式细节。有关详细信息,请参见此元帖 - Daniel Rearden
1
我认为你应该将标题改为“对于非空字段,不能返回 null”的问题似乎仍然不容易被发现,甚至包括 “[graphql] Cannot return null for non-nullable field” 和“为什么返回了 null 呢?” - xadm
6个回答

89

你的字段或几个字段返回null的原因通常有两个:1)在解析器内部以错误的形式返回数据;2)未正确使用Promises。

注意:如果您看到以下错误:

Cannot return null for non-nullable field

根本问题是您的字段返回null。 您仍然可以按照下面概述的步骤尝试解决此错误。

以下示例将引用此简单模式:

type Query {
  post(id: ID): Post
  posts: [Post]
}

type Post {
  id: ID
  title: String
  body: String
}

返回错误的数据格式

我们的schema和请求查询一起定义了我们在端点响应中返回的"data"对象的“形状”。 形状指对象具有的属性以及这些属性值是标量值,其他对象还是对象或标量的数组。

就像模式定义总响应的形状一样,单个字段的类型也定义了该字段值的形状。 我们在解析器中返回的数据形状必须与此预期的形状相匹配。 当不匹配时,我们经常会在响应中得到意外的null。

然而,在我们深入具体示例之前,重要的是要理解GraphQL如何解析字段。

了解默认解析器行为

虽然您确实可以为模式中的每个字段编写解析器,但通常不必这样做,因为当您未提供解析器时,GraphQL.js将使用默认解析器。

从高层次上讲,默认解析器的工作原理很简单:它查看字段解析到的值,如果该值是JavaScript对象,则查找该对象上具有相同名称的属性名的属性。 如果找到该属性,则解析为该属性的值。否则,将解析为null。

假设在我们的post字段的解析器中,我们返回值{ title: 'My First Post', bod: 'Hello World!' }。 如果我们没有为Post类型的任何字段编写解析器,我们仍然可以请求post

query {
  post {
    id
    title
    body
  }
}

我们的回应将是:

{
  "data": {
    "post" {
      "id": null,
      "title": "My First Post",
      "body": null,
    }
  }
}
title 字段被解析了,即使我们没有为它提供解析器,因为默认的解析器已经做了重活——它看到了父字段(在本例中为post)解析到的对象上有一个名为title 的属性,所以它只是解析了那个属性的值。 id字段解析为null,因为我们在post解析器中返回的对象没有id属性。由于拼写错误,body字段也解析为null——我们有一个名为bod而不是body的属性!

专业提示:如果bod不是拼写错误而是API或数据库实际返回的内容,我们可以始终为body字段编写解析器以匹配我们的模式。例如:(parent) => parent.bod

需要记住的一件重要事情是,在JavaScript中,几乎所有东西都是对象。因此,如果post字段解析为字符串或数字,则Post类型的每个字段的默认解析器仍会尝试在父对象上找到适当命名的属性,但不可避免地失败并返回null。如果字段具有对象类型,但在其解析器中返回其他类型(如字符串或数组),则您将不会看到有关类型不匹配的任何错误,但该字段的子字段将不可避免地解析为null。

常见场景#1:包装响应

如果我们正在编写post查询的解析器,我们可能会从一些其他端点获取代码,像这样:

function post (root, args) {
  // axios
  return axios.get(`http://SOME_URL/posts/${args.id}`)
    .then(res => res.data);

  // fetch
  return fetch(`http://SOME_URL/posts/${args.id}`)
    .then(res => res.json());

  // request-promise-native
  return request({
    uri: `http://SOME_URL/posts/${args.id}`,
    json: true
  });
}

post字段的类型为Post,因此我们的解析器应该返回一个具有像idtitlebody这样的属性的对象。如果这是我们的API返回的内容,那么我们就准备好了。然而,响应通常实际上是一个包含其他元数据的对象。因此,我们从端点实际上得到的对象可能看起来像这样:

{
  "status": 200,
  "result": {
    "id": 1,
    "title": "My First Post",
    "body": "Hello world!"
  },
}
在这种情况下,我们不能仅仅返回响应并期望默认解析器能够正确工作, 因为我们返回的对象没有我们需要的id、title和body属性。我们的解析器需要做类似以下的操作:
function post (root, args) {
  // axios
  return axios.get(`http://SOME_URL/posts/${args.id}`)
    .then(res => res.data.result);

  // fetch
  return fetch(`http://SOME_URL/posts/${args.id}`)
    .then(res => res.json())
    .then(data => data.result);

  // request-promise-native
  return request({
    uri: `http://SOME_URL/posts/${args.id}`,
    json: true
  })
    .then(res => res.result);
}

注意:上面的示例从另一个端点提取数据;然而,当直接使用数据库驱动程序(而不是使用ORM)时,这种包装的响应也非常常见!例如,如果您使用node-postgres,您将获得一个Result对象,该对象包括rowsfieldsrowCountcommand等属性。您需要从此响应中提取适当的数据,然后将其返回到解析器中。

常见情况 #2:数组而不是对象

如果我们从数据库中获取帖子,我们的解析器可能会像这样:

function post(root, args, context) {
  return context.Post.find({ where: { id: args.id } })
}

这里的Post是我们通过上下文注入的某个模型。如果我们使用的是sequelize,我们可能会调用findAllmongoosetypeorm则有find方法。这些方法的共同之处在于,虽然它们允许我们指定WHERE条件,但它们返回的Promises仍然解析成数组而不是单个对象。虽然您的数据库中可能只有一个特定ID的帖子,但在调用这些方法时它仍然被包装在一个数组中。由于数组仍然是一个对象,因此GraphQL不会将post字段解析为null。但它 将所有子字段解析为null,因为它找不到数组上的适当命名的属性。

您可以很容易地通过只获取数组中的第一个项目并将其返回给您的解析器来解决这种情况:

function post(root, args, context) {
  return context.Post.find({ where: { id: args.id } })
    .then(posts => posts[0])
}

如果你从另一个API中获取数据,这通常是唯一的选项。另一方面,如果你使用ORM,则通常有不同的方法可用(比如findOne),它将明确地仅返回数据库中的单个行(或null,如果不存在)。

function post(root, args, context) {
  return context.Post.findOne({ where: { id: args.id } })
}
关于INSERTUPDATE调用的特别说明:我们经常期望插入或更新行或模型实例的方法返回已插入或已更新的行。通常他们确实会这样做,但有些方法不会。例如,sequelizeupsert方法解析为一个布尔值,或者一个元组(如果将returning选项设置为true,则包含已插入的记录和一个布尔值)。mongoosefindOneAndUpdate解析为一个带有value属性的对象,该属性包含修改后的行。在返回解析结果之前,请查阅你所使用的ORM文档并适当地解析结果。

常见场景#3:对象而不是数组

在我们的模式中,posts字段的类型是Post列表,这意味着它的解析器需要返回一个对象数组(或解析为该数组的Promise)。我们可以像这样获取帖子:

function posts (root, args) {
  return fetch('http://SOME_URL/posts')
    .then(res => res.json())
}

然而,我们的API实际返回的响应可能是一个包装文章数组的对象:

{
  "count": 10,
  "next": "http://SOME_URL/posts/?page=2",
  "previous": null,
  "results": [
    {
      "id": 1,
      "title": "My First Post",
      "body" "Hello World!"
    },
    ...
  ]
}

我们不能在解析器中返回该对象,因为GraphQL需要一个数组。如果我们这样做,该字段将解析为null,并且我们将在响应中看到一个错误,如下所示:

期望迭代对象,但未在Query.posts字段中找到。

与上面两种情况不同,在这种情况下,GraphQL能够显式检查我们在解析器中返回的值的类型,如果它不是像数组一样的可迭代对象,则会引发异常。

就像我们在第一个场景中讨论的那样,为了修复此错误,我们必须将响应转换为适当的格式,例如:

function posts (root, args) {
  return fetch('http://SOME_URL/posts')
    .then(res => res.json())
    .then(data => data.results)
}

不正确地使用 Promises

GraphQL.js 在内部使用 Promise API。因此,一个解析器可以返回一些值(比如 { id: 1, title: 'Hello!' }),或者返回一个将会被resolve为该值的 Promise。对于具有 List 类型的字段,您还可以返回一个 Promise 数组。如果 Promise 被拒绝,该字段将返回 null,并且相应的错误将被添加到响应中的 errors 数组中。如果一个字段具有对象类型,则 Promise 解析为的值将作为该字段的父值传递到任何子字段的解析器。

Promise 是一个“表示异步操作的最终完成(或失败)及其结果值的对象。” 接下来的几种情况概述了在解析器中处理 Promises 时遇到的一些常见问题。然而,如果您不熟悉 Promises 和较新的 async/await 语法,强烈建议您花些时间学习基础知识。

注意:下面的示例都是针对一个 getPost 函数。这个函数的实现细节并不重要--它只是一个返回 Promise 的函数,这个 Promise 将解析为一个帖子对象。

常见情况#4:未返回值

post 字段的工作解析器可能是这样的:

function post(root, args) {
  return getPost(args.id)
}

getPosts 返回一个 Promise,我们正在返回该 Promise。无论该 Promise 解决为什么值,它都将成为我们字段解决为的值。看起来很好!

但是如果我们这样做会发生什么:

function post(root, args) {
  getPost(args.id)
}
我们仍在创建一个将解析为帖子的Promise。但是,我们没有返回Promise,因此GraphQL不知道它,并且不会等待它的解决。在JavaScript中,如果函数没有显式的return语句,则会隐式返回undefined。因此,我们的函数创建了一个Promise,然后立即返回undefined,导致GraphQL返回null。
如果getPost返回的Promise被拒绝,我们也不会在响应中看到任何错误--因为我们没有返回Promise,底层代码不关心它是解决还是拒绝。实际上,如果Promise被拒绝,您会在服务器控制台上看到UnhandledPromiseRejectionWarning错误。
修复这个问题很简单--只需要添加return即可。
常见情况#5:未正确链接Promise 您决定记录对getPost的调用结果,因此您将解析器更改为类似以下内容的样子:
function post(root, args) {
  return getPost(args.id)
    .then(post => {
      console.log(post)
    })
}

运行查询时,查询结果会在控制台中记录,但GraphQL会将字段解析为空值。为什么?

当我们在Promise上调用then时,实际上是获取Promise解析的值并返回一个新的Promise。你可以把它想象成类似于Array.map,只不过是针对Promises的。 then可以返回一个值或另一个Promise。无论哪种情况,在then内部返回的内容都会被“链接”到原始Promise上。通过使用多个then,可以像这样将多个Promise链接在一起。链中的每个Promise按顺序解决,并且最终值有效地作为原始Promise的值得到解决。

在我们上面的示例中,我们在then内部没有返回任何内容,因此Promise解析为undefined,GraphQL将其转换为null。要解决这个问题,我们必须返回帖子:

function post(root, args) {
  return getPost(args.id)
    .then(post => {
      console.log(post)
      return post // <----
    })
}

如果你需要在resolver中解决多个Promise,你需要使用then正确地链式调用它们并返回正确的值。例如,如果我们需要在调用getPost之前先调用另外两个异步函数(getFoogetBar),我们可以这样做:

function post(root, args) {
  return getFoo()
    .then(foo => {
      // Do something with foo
      return getBar() // return next Promise in the chain
    })
    .then(bar => {
      // Do something with bar
      return getPost(args.id) // return next Promise in the chain
    })

专业提示:如果您在正确链接Promises方面遇到困难,您可能会发现使用async/await语法更加清晰易用。

常见情况#6

在Promises出现之前,处理异步代码的标准方式是使用回调函数,或者在异步工作完成后调用的函数。例如,我们可以这样调用mongoosefindOne方法:

function post(root, args) {
  return Post.findOne({ where: { id: args.id } }, function (err, post) {
    return post
  })

这里的问题是双重的。首先,在回调函数内返回的值没有被用于任何事情(即它没有以任何方式传递给底层代码)。其次,当我们使用回调函数时,Post.findOne 不会返回一个 Promise;它只返回 undefined。在这个例子中,我们的回调函数将被调用,如果我们记录 post 的值,我们将看到从数据库返回的任何内容。但是,因为我们没有使用 Promise,GraphQL 不会等待这个回调函数完成——它获取返回值(undefined)并使用它。

大多数流行的库,包括 mongoose,都支持 Promises。那些不支持的库通常有补充的“包装”库添加此功能。在处理 GraphQL 解析器时,应避免使用使用回调的方法,而是使用返回 Promise 的方法。

专业提示:支持回调和 Promises 的库通常会过载其函数,以便如果未提供回调,则函数将返回 Promise。请查阅该库的文档以了解详情。

如果您绝对必须使用回调函数,也可以将回调函数包装在 Promise 中:

function post(root, args) {
  return new Promise((resolve, reject) => {
    Post.findOne({ where: { id: args.id } }, function (err, post) {
      if (err) {
        reject(err)
      } else {
        resolve(post)
      }
    })
  })

1
哇...是的,如果你是Facebook,GraphQL可以解决一个问题,但对于我们其他人来说,我们必须阅读这样的响应才能理解它最基本的概念。也许归根结底,问题在于增加了不清晰的附加值的复杂性。例如GraphQL。 - LMS5400

4

我在Nest.js上也遇到了同样的问题。

如果你想解决这个问题,可以在@Query装饰器中添加{nullable: true}选项。

这是一个例子。

@Resolver(of => Team)
export class TeamResolver {
  constructor(
    private readonly teamService: TeamService,
    private readonly memberService: MemberService,
  ) {}

  @Query(returns => Team, { name: 'team', nullable: true })
  @UseGuards(GqlAuthGuard)
  async get(@Args('id') id: string) {
    return this.teamService.findOne(id);
  }
}

然后,你可以返回空对象来查询。

输入图像描述


1
我在这里发布了这个答案,因为此URL(https://dev59.com/97bna4cB1Zd3GeqPdZdd)上的一个问题被标记为重复问题。 - Atsuhiro Teshima
我正在使用 Nest JS,忘记注册模块。 - CTS_AE

2

我来自Flutter。

我找不到任何与Flutter相关的解决方案,所以每次搜索都会带我来到这里,让我在这里添加一下。

确切的错误是:

执行同步查询到AppSync失败:[GraphQLResponse.Error{message='Cannot return null for non-nullable type: 'AWSTimestamp' within parent

因此,在我的模式(在AppSync控制台上)中,我有:

type TypeName {
   id: ID!
   ...
   _version: Int!
   _deleted: Boolean
   _lastChangedAt: AWSTimestamp!
   createdAt: AWSDateTime!
   updatedAt: AWSDateTime!
 }

我从字段_lastChangedAt收到了错误,因为AWSTimestamp不能为空。

我所要做的就是从该字段中删除空值检查(!),问题就解决了。

现在,我不知道这对长远的影响,但如果有必要,我会更新这个答案。

编辑:我发现这样做的影响是,我所做的任何更改,amplify.push都会被撤消。只需返回您的appsync控制台并在测试时再次更改即可。因此,这不是一个可持续的解决方案,但我在网上听到的传言表明,很快就会对amplify flutter进行改进。


0
如果以上方法都没有帮助,而且您有一个全局拦截器,将所有响应包装在一个"data"字段中,那么您必须禁用它以便GraphQL解析器不会转换为null。
这是我在我的情况下对拦截器所做的操作:
  intercept(
    context: ExecutionContext,
    next: CallHandler,
  ): Observable<Response<T>> {
    if (context['contextType'] === 'graphql') return next.handle();

    return next
      .handle()
      .pipe(map(data => {
        return {
          data: isObject(data) ? this.transformResponse(data) : data
        };
      }));
  }

0

如果有人使用过apollo-server-express并且得到了空值,请注意。

// This will return values, as you expect.

const typeDefs = require('./schema');
const resolvers = require('./resolver');

const server = new ApolloServer({typeDefs,resolvers});


// This will return null, since ApolloServer constructor is not using correct properties.

const withDifferentVarNameSchema = require('./schema');
const withDifferentVarNameResolver= require('./resolver');

const server = new ApolloServer({withDifferentVarNameSchema,withDifferentVarNameResolver});


注意:创建Apollo Server实例时,只需要传递typeDefs和resolvers变量名。

1
GraphQl 只接受 typeDefsresolvers 作为键名,因此 withDifferentVarNameSchemawithDifferentVarNameResolver 将无法使用。 - darKnight
@darKnight,我试图表达与您的评论相同的意思。我认为您错过了相应代码示例的文本,请注意,现在已将相同的文本附加到代码示例中。 - Abhishek Kumar
或者,只需使用TypeScript。 - OneCricketeer

0

@Thomas Hennes对我来说完全正确

尽管我们没有为标题字段提供解析器,但默认解析器已经处理好了--它看到父字段(在这种情况下是post)解析到的对象中有一个名为“标题”的属性,因此它只是解析该属性的值。 id字段解析为null,因为我们在post解析器中返回的对象没有id属性。 由于拼写错误,body字段也解析为null--我们有一个称为bod而不是body的属性!

专业提示:如果bod并不是打字错误,而是API或数据库实际返回的内容,则我们始终可以编写解析器以匹配我们的模式。例如:(parent)=> parent.bod

要记住的一件重要事情是,在JavaScript中,几乎每个东西都是一个对象。因此,如果post字段解析为字符串或数字,则Post类型上每个字段的默认解析器仍将尝试在父对象上查找适当命名的属性,最终失败并返回null。如果字段具有对象类型,但您在其解析器中返回除对象之外的其他内容(如字符串或数组),则不会出现任何有关类型不匹配的错误,但该字段的子字段最终将解析为null。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接