使用 GROQ 在终端查询 JSON 文档

Avatar of Magnus Holm
Magnus Holm 发布

DigitalOcean 为您旅程的每个阶段提供云产品。 立即开始使用 200 美元的免费额度!

如今 JSON 文档无处不在,但它们很少以您想要的方式进行结构化。 它们通常包含 过多的数据,字段名称很奇怪,或者将数据放在不必要的嵌套对象中。 图关系对象查询 (GROQ) 是一种查询语言(类似于 SQL,但有所不同),旨在直接作用于 JSON 文档。 它基本上允许您编写查询,您可以快速过滤这些查询,然后重新格式化 JSON 文档以将其转换为最方便的形式。

GROQ 由 Sanity.io(它用作主要的查询语言)开发。 它是 开源的,并且它为我们在 JavaScript 和任何 JSON 源上的 命令行 中使用它提供了内置方法。 我们将一起将 GROQ 添加到终端工具集中,这将在您需要在项目中处理一些 JSON 数据时为您节省时间。

让我们安装 GROQ

像大多数事情一样,我们需要安装 GROQ CLI 工具,并且可以使用终端中的 npm(或 Yarn)来做到这一点。

$ npm install -g groq-cli

为了使用它,我们需要有一个 JSON 文件可用。 我们将使用 curl 下载一个待办事项数据的示例数据集。

$ curl -o todos.json https://jsonplaceholder.typicode.com/todos

让我们快速查看数据中的一个示例项目。

{
  "userId": 1,
  "id": 1,
  "title": "delectus aut autem",
  "completed": false
},

非常简单。 我们有一个用户 ID、一个待办事项 ID、一个待办事项标题和一个布尔值,用于指定待办事项是否已完成。

现在让我们运行一个基本的 GROQ 查询:查找所有已完成的待办事项,但仅返回待办事项标题和用户 ID。 可以复制/粘贴此行,因为我们稍后将逐步介绍它的含义。

$ cat todos.json | groq '*[completed == true]{title, userId}' --pretty

groq 命令行工具接受 标准输入 上的 JSON 文档。 这与“执行一项操作并在文本流上工作”的 Unix 哲学非常吻合。 为了从文件读取 JSON,我们将使用cat命令。 还要注意,groq 默认情况下会输出单行上的最小 JSON,但通过传递--pretty,我们可以获得缩进良好且语法高亮的格式。

要存储结果,我们可以使用>将其管道传输到新文件。

$ cat todos.json | groq '*[completed == true]{title, userId}' > result.json

查询本身由三个部分组成。

  • * 指的是数据集(即 JSON 文件中的数据)。
  • [completed == true] 是一个过滤器,用于删除标记为未完成的项目。
  • {title, userId} 是一个投影,它使查询仅返回"title""userId"属性。

让我们热身做一些练习

您可能从未想过需要通过锻炼才能完成这篇文章! 好消息是,在深入了解更多细节之前,我们只需要用一些内容来锻炼思维,尝试使用 GROQ。

  1. 如果您删除[completed == true]和/或{title, userId}会发生什么?
  2. 如何更改查询以查找 ID 为 2 的用户的全部待办事项?
  3. 如何更改查询以查找 ID 为 2 的用户的未完成待办事项?
  4. 如果原始查询示例中的过滤器与投影交换位置会发生什么?
  5. 如何编写一个(带管道)的单一命令来下载 JSON 并使用 GROQ 处理它?

我们将把答案放在文章的末尾供您参考。

查询诺贝尔奖获得者

待办事项数据对于热身来说很好,但老实说:查看使用拉丁语作为占位符内容的列表并没有多大意义。 然而,诺贝尔奖有一个 所有过去获奖者的数据集 可供公众使用。

这是一个示例返回值。

{
  "laureates": [
    {
      "id": "1",
      "firstname": "Wilhelm Conrad",
      "surname": "Röntgen",
      "born": "1845-03-27",
      "died": "1923-02-10",
      "bornCountry": "Prussia (now Germany)",
      "bornCountryCode": "DE",
      "bornCity": "Lennep (now Remscheid)",
      "diedCountry": "Germany",
      "diedCountryCode": "DE",
      "diedCity": "Munich",
      "gender": "male",
      "prizes": [...],
    },
    // ...
  ]
}

啊! 这更有意思! 让我们下载数据集并查找所有挪威获奖者的名字。 在这里,我们将使用--output标志 curl 将数据保存到文件。

$ curl --output laureate.json http://api.nobelprize.org/v1/laureate.json
$ cat laureate.json | groq '*.laureates[bornCountryCode == "NO"]{firstname}' --pretty

您得到了什么结果? 我收到了 12 位挪威诺贝尔奖获得者。 不错!

请注意,此查询与我们编写的第一个查询略有不同。 我们在这个查询中多了一个.laureates。 当我们在待办事项数据集中使用*时,它表示整个 JSON 文档,该文档包含在待办事项数据集顶层的一个数组中。 另一方面,获奖者文件在顶层使用一个对象,其中获奖者列表存储在"laureates"属性中。

要访问特定项目,我们可以使用过滤器[0]并仅返回第一个名字。 那应该告诉我们第一个获得诺贝尔奖的挪威人是哪位。

$ cat laureate.json | groq '*.laureates[bornCountryCode == "NO"]{firstname}[0]' --pretty

// Returned object
{
  "firstname": "Ivar"
}

更多练习!

如果我们不稍微玩一下这个新数据集来了解查询是如何工作的,那我们就失职了。

  1. 编写一个查询以查找您所在国家/地区的所有诺贝尔奖获得者。
  2. 编写一个查询以返回最后一位挪威获奖者。 提示:-1指的是最后一项。
  3. 如果您尝试直接在根对象上进行过滤会发生什么? *[bornCountryCode == "NO"]
  4. *.laureates\[bornCountryCode == "NO"\][0]*.laureates\[0\][bornCountryCode == "NO"]有什么区别?

与上次一样,答案将在本文末尾给出。

使用过滤器

现在我们知道总共有 12 位挪威诺贝尔奖获得者,其中有多少人出生于 1950 年以后? 使用 GROQ 找出这一点毫无问题。

$ cat laureate.json | groq '*.laureates[bornCountryCode == "NO" && born >= "1950-01-01"]{firstname}' --pretty

// Sample return
[
  {
    "firstname": "May-Britt"
  },
  {
    "firstname": "Edvard I."
  }
]

实际上,GROQ 有一套丰富的运算符,我们可以在过滤器中使用它们。 我们可以使用等于 (==)、不等于 (!=)、大于 (>)、大于或等于 (>=)、小于 (<) 和小于或等于 (<=) 来比较数字和字符串。 此外,比较可以与 AND (&&)、OR (||) 和 NOT (!) 组合使用。 in运算符甚至可以检查多种情况(例如bornCountryCode in ["NO", "SE", "DK"])。 defined函数允许我们查看字段是否存在(例如defined(diedCountry))。

更多练习!

您知道该怎么做:尝试玩一下过滤器,看看它们如何在数据集中工作。 答案当然在最后。

  1. 编写一个返回在世获奖者的查询。
  2. 过滤器[bornCountryCode == "NO"][born >= "1950-01-01"][bornCountryCode == "NO" && born >= "1950-01-01"]之间有区别吗?
  3. 你能找到 1973 年获得奖项的所有获奖者吗?

使用投影

诺贝尔奖数据集将每个获奖者的名字和姓氏分开,但如果我们想将它们组合到一个字段中怎么办? GROQ 中的投影可以做到这一点!

*.laureates[bornCountryCode == "NO" && born >= "1950-01-01"]{
  "name": firstname + " " + surname, 
  born,
  "prizeCount": count(prizes),
}

运行此查询告诉我们 May-Britt MoserEdvard Moser 获得了一个奖项(实际上是同一个奖项)。

[
  {
    "name": "May-Britt Moser",
    "born": "1963-01-04",
    "prizeCount": 1
  },
  {
    "name": "Edvard I. Moser",
    "born": "1962-04-27",
    "prizeCount": 1
  }
]

这里发生了什么?好吧,当我们在 GROQ 中编写投影时,我们实际上编写的是一个 JSON 对象。以前,我们有简单的投影(例如 {firstname}),但这是编写 {"firstname": firstname} 的一种简写方式。通过使用扩展的对象语法,我们可以重命名键并转换值。

GROQ 有一套丰富的运算符和函数用于转换数据,包括字符串连接、算术运算符(+-*/%**)、数组计数(count(prizes))和舍入数字(round(num, <amount of decimals>)。

练习

希望此时您对这些内容有了很好的了解,但这里有一些更多的方法来练习使用投影。

  1. 查找所有获得两个或更多奖项的获奖者。
  2. 查找女性获得的奖项数量。
  3. 格式化一个 fullname 键,该键将结果中的 lastnamefirstname 组合在一起。

一次做更多事情

观看此视频

$ cat laureate.json | groq --pretty '
{
  "count": count(*.laureates),
  "norwegians": *.laureates[bornCountryCode == "NO"]{firstname}, 
}
'

结果

{
  "count": 928,
  "norwegians": [
    {
      "firstname": "Ivar"
    },
    {
      "firstname": "Lars"
    },
    …
  ]
}

抓住重点了吗?GROQ 查询不必以 * 开头。在此查询中,我们正在创建一个 JSON 对象,其中值来自单独的查询结果。这为我们使用 GROQ 可以生成的内容提供了很大的灵活性。也许您希望将未完成待办事项的总数与最近五个待办事项的列表一起显示。或者,也许您希望将待办事项分成两个单独的列表:一个用于已完成的,一个用于未完成的。或者,也许您需要将所有内容包装在对象中,因为这是另一个工具/库/框架所期望的。无论哪种情况,GROQ 都能满足您的需求。

让我们再做最后一个练习。您可以投影一个对象,其中 laureates 包含一个数组,该数组包含每个获奖者所获得奖项总数的四舍五入后的百分比,并返回获奖者的名字?然后,尝试输出发放的总数。

总结

在开始充分利用 GROQ 之前,您无需学习太多内容。如果您已完成练习,那么您正走在成为 GROQ 大师的正确道路上。当然,本简介并未涉及 GROQ 的所有不同功能和方面,因此请随时浏览 规范GitHub 上的项目本身。如果您对使用 GROQ 进行数据整理有任何疑问,请随时联系 Sanity.io 团队。


练习答案

练习 1
问题 1

如果删除 [completed == true],您将获得所有待办事项,而不仅仅是已完成的待办事项。如果删除 {title, userId},您将获得所有属性。

问题 2
*[userId == 2]
问题 3
*[userId == 2 && completed == false] or *[userId == 2 && !completed]
问题 4

如果更改过滤器和投影的顺序,则将首先执行投影,然后应用过滤器。这意味着您正在过滤仅包含 titleuserId 的待办事项列表,并且 completed == true 永远不可能为真。

问题 5
curl https://jsonplaceholder.typicode.com/todos | groq '*[completed == true]{title, userId}' > result.json
练习 2
问题 1
*.laureates[bornCountryCode == "INSERT-YOUR-COUNTRY-HERE"]
问题 2
*.laureates\[bornCountryCode == "NO"\][-1]
问题 3

*[bornCountryCode == "NO"] 将尝试过滤对象。这是没有意义的,因此您将得到 null 作为答案。

问题 4

*.laureates\[0\][bornCountryCode == "NO"] 的效果并非您想象的那样。这将首先找到第一个获奖者(恰好是威廉·康拉德),然后尝试“过滤”对象。这是没有意义的,因此答案是 null

练习 3
问题 1
*.laureates[died == "0000-00-00"]
问题 2

过滤器 \[bornCountryCode == "NO"\][born >= "1950-01-01"][bornCountryCode == "NO" && born >= "1950-01-01"] 之间没有区别。第一个过滤器分两“轮”进行过滤,但最终结果相同。

问题 3
*.laureates["1973" in prizes[].year]
练习 4
问题 1
*.laureates[count(prizes) >= 2]
问题 2
count(*.laureates[gender == "female"])
问题 3
*.laureates{"fullname": surname + ", " + firstname}
练习 5
*.laureates{"laureates": {firstname, "percentage": round(count(prizes) / count(*.laureates[].prizes), 3) * 100}, "total": count(*.laureates[].prizes)}