如何在faunadb中通过多个条件进行查询?

How to query by multiple conditions in faunadb?

我努力加深对 FaunaDB 的理解。

我有一个包含以下记录的集合:

{
  "ref": Ref(Collection("regions"), "261442015390073344"),
  "ts": 1587576285055000,
  "data": {
    "name": "italy",
    "attributes": {
      "amenities": {
        "camping": 1,
        "swimming": 7,
        "hiking": 3,
        "culture": 7,
        "nightlife": 10,
        "budget": 6
      }
    }
  }
}

我想通过不同的属性以灵活的方式查询,例如:

我创建了一个包含所有属性的索引,但我不知道如何在包含多个术语的索引中进行更大的等于过滤。

我的后备方案是为每个属性创建一个索引,并使用交集来获取我要检查的所有子查询中的记录,但这感觉有些不对:

查询:预算 >= 6 AND 露营 >=8 将是:

Index:
{
  name: "all_regions_by_all_attributes",
  unique: false,
  serialized: true,
  source: "regions",
  terms: [],
  values: [
    {
      field: ["data", "attributes", "amenities", "culture"]
    },
    {
      field: ["data", "attributes", "amenities", "hiking"]
    },
    {
      field: ["data", "attributes", "amenities", "swimming"]
    },
    {
      field: ["data", "attributes", "amenities", "budget"]
    },
    {
      field: ["data", "attributes", "amenities", "nightlife"]
    },
    {
      field: ["data", "attributes", "amenities", "camping"]
    },
    {
      field: ["ref"]
    }
  ]
}

查询:

Map(
  Paginate(
    Intersection(
      Range(Match(Index("all_regions_by_all_attributes")), [0, 0, 0, 6, 0, 8], [10, 10, 10, 10, 10, 10]),
    )

  ),
  Lambda(
    ["culture", "hiking", "swimming", "budget", "nightlife", "camping", "ref"],
    Get(Var("ref"))
  )
)

这种方法有以下缺点:

是否可以将所有值存储在这种包含所有数据的索引中?我知道我可以向索引添加更多值并访问它们。但这意味着一旦我们向实体添加更多字段,我就必须创建一个新索引。但也许这是普遍现象。

提前致谢

我认为有几个误解让您误入歧途。最重要的一个:Match(Index($x)) 生成一个集合引用,它是一个有序的元组集合。元组对应于索引的值部分中存在的字段数组。默认情况下,这只是一个 one-tuple 包含对 collection select 中由索引编辑的文档的引用。 Range 对集合引用进行操作,并且 对用于 select 返回的集合引用的术语一无所知。那么我们如何编写查询呢?

从第一原则开始。让我们想象一下,我们刚刚在内存中有了这些东西。如果我们有一组按属性排序的 (attribute, scores),score 然后只取 attribute == $attribute 的那些会让我们接近,然后按 score > $score 过滤会得到我们想要的。假设我们将属性值对建模为文档,这恰好对应于以属性作为术语的分数范围查询。我们还可以将指针嵌入回该位置,以便我们也可以在同一查询中检索它。废话少说,开始吧:

第一站:我们的collections.

jnr> CreateCollection({name: "place_attribute"})
{
  ref: Collection("place_attribute"),
  ts: 1588528443250000,
  history_days: 30,
  name: 'place_attribute'
}
jnr> CreateCollection({name: "place"})
{
  ref: Collection("place"),
  ts: 1588528453350000,
  history_days: 30,
  name: 'place'
}

接下来是一些数据。我们将选择几个地方并赋予它们一些属性。

jnr> Create(Collection("place"), {data: {"name": "mullion"}})
jnr> Create(Collection("place"), {data: {"name": "church cove"}})
jnr> Create(Collection("place_attribute"), {data: {"attribute": "swimming", "score": 3, "place": Ref(Collection("place"), 264525084639625739)}})
jnr> Create(Collection("place_attribute"), {data: {"attribute": "hiking", "score": 1, "place": Ref(Collection("place"), 264525084639625739)}}) 
jnr> Create(Collection("place_attribute"), {data: {"attribute": "hiking", "score": 7, "place": Ref(Collection("place"), 264525091487875586)}})

现在进入更有趣的部分。索引.

jnr> CreateIndex({name: "attr_score", source: Collection("place_attribute"), terms:[{"field":["data", "attribute"]}], values:[{"field": ["data", "score"]}, {"field": ["data", "place"]}]})
{
  ref: Index("attr_score"),
  ts: 1588529816460000,
  active: true,
  serialized: true,
  name: 'attr_score',
  source: Collection("place_attribute"),
  terms: [ { field: [ 'data', 'attribute' ] } ],
  values: [ { field: [ 'data', 'score' ] }, { field: [ 'data', 'place' ] } ],
  partitions: 1
}

好的。一个简单的查询。谁有远足?

jnr> Paginate(Match(Index("attr_score"), "hiking"))
{
  data: [
    [ 1, Ref(Collection("place"), "264525084639625730") ],
    [ 7, Ref(Collection("place"), "264525091487875600") ]
  ]
}

没有太多的想象力,可以偷偷调用一个 Get 调用来拉出这个地方。

如果只有 5 分以上的徒步旅行呢?我们有一组有序的元组,所以只需提供第一个组件(分数)就足以让我们得到我们想要的东西。

jnr> Paginate(Range(Match(Index("attr_score"), "hiking"), [5], null))
{ data: [ [ 7, Ref(Collection("place"), "264525091487875600") ] ] }

复合条件呢? 5 岁以下徒步旅行和游泳(任何分数)。这是事情有点转折的地方。我们想要建立联合模型,在动物群中这意味着相交集。我们遇到的问题是,到目前为止,我们一直在使用 returns 分数和位置参考的索引。为了使交集起作用,我们只需要引用。花招时间:

jnr> Get(Index("doc_by_doc"))
{
  ref: Index("doc_by_doc"),
  ts: 1588530936380000,
  active: true,
  serialized: true,
  name: 'doc_by_doc',
  source: Collection("place"),
  terms: [ { field: [ 'ref' ] } ],
  partitions: 1
}

你问这样的索引有什么意义?好吧,我们可以使用它从任何索引中删除我们喜欢的任何数据,并通过连接只留下引用。这为我们提供了远足分数小于 5 的地点参考(空数组排序在任何东西之前,因此用作下限的占位符)。

jnr> Paginate(Join(Range(Match(Index("attr_score"), "hiking"), [], [5]), Lambda(["s", "p"], Match(Index("doc_by_doc"), Var("p")))))
{ data: [ Ref(Collection("place"), "264525084639625739") ] }

所以最后是抵抗部分:所有带有 swimming and (hiking < 5) 的地方:

jnr> Let({
...   hiking: Join(Range(Match(Index("attr_score"), "hiking"), [], [5]), Lambda(["s", "p"], Match(Index("doc_by_doc"), Var("p")))),
...   swimming: Join(Match(Index("attr_score"), "swimming"), Lambda(["s", "p"], Match(Index("doc_by_doc"), Var("p"))))
... },
... Map(Paginate(Intersection(Var("hiking"), Var("swimming"))), Lambda("ref", Get(Var("ref"))))
... )
{
  data: [
    {
      ref: Ref(Collection("place"), "264525084639625739"),
      ts: 1588529629270000,
      data: { name: 'mullion' }
    }
  ]
}

多田。这可以通过几个 udf 来整理很多,练习留给 reader。涉及 or 的条件可以用大致相同的方式使用 union 进行管理。

感谢您的提问。 Ben 已经编写了一个完整的示例来展示您可以做什么,我将根据他的建议并尝试进一步阐明。

FaunaDB 的 FQL 非常强大,这意味着有多种方法可以做到这一点,但这种强大的功能带来了一个小的学习曲线,所以我很乐意提供帮助:)。之所以花了一段时间来回答这个问题,是因为如此详尽的答案实际上值得一个完整的博客post。好吧,我从来没有在 Stack Overflow 上写过博客 post,凡事都有第一!

有三种方法可以做到 'compound range-like queries' 但有一种方法对您的用例来说性能最高,我们将看到第一种方法实际上并不完全是你所需要的。剧透,我们在这里描述的第三个选项就是你需要的。

准备 - 让我们像 Ben 一样输入一些数据

我将把它放在一个集合中以使其更简单,并且我在这里使用 JavaScript 动物群查询语言的风格。尽管这与您的第二个 map/get 问题相关(请参阅此答案的末尾)

,但有充分的理由在第二个集合中分离数据

创建集合

 CreateCollection({ name: 'place' })

输入一些数据

    Do(
      Select(
        ['ref'],
        Create(Collection('place'), {
          data: {
            name: 'mullion',
            focus: 'team-building',
            camping: 1,
            swimming: 7,
            hiking: 3,
            culture: 7,
            nightlife: 10,
            budget: 6
          }
        })
      ),
      Select(
        ['ref'],
        Create(Collection('place'), {
          data: {
            name: 'church covet',
            focus: 'private',
            camping: 1,
            swimming: 7,
            hiking: 9,
            culture: 7,
            nightlife: 10,
            budget: 6
          }
        })
      ),
      Select(
        ['ref'],
        Create(Collection('place'), {
          data: {
            name: 'the great outdoors',
            focus: 'private',
            camping: 5,
            swimming: 3,
            hiking: 2,
            culture: 1,
            nightlife: 9,
            budget: 3
          }
        })
      )
    )

选项 1:具有多个值的复合索引

我们可以在索引中放入与值一样多的术语,并使用 MatchRange 来查询它们。然而!如果您使用多个值,范围可能会给您带来与您预期不同的东西。 Range 准确地告诉您索引的作用,索引按词法对值进行排序。如果我们查看文档中 Range 的示例,我们会看到一个示例,我们可以在该示例上扩展多个值。

假设我们有一个包含两个值的索引,我们写:

    Range(Match(Index('people_by_age_first')), [80, 'Leslie'], [92, 'Marvin'])

那么结果就是你在左边看到的,而不是你在右边看到的。这是一个非常可扩展的行为,并且在没有基础索引开销的情况下公开了原始功能,但这并不是您正在寻找的!

那么让我们转向另一个解决方案!

选项 2:首先是范围,然后是过滤器

另一个非常灵活的解决方案是先使用 Range,然后再使用 Filter。然而,如果您使用过滤器过滤掉很多内容,那么这不是一个好主意,因为您的页面将变得更加空白。想象一下,您在 'Range' 之后的页面中有 10 个项目并使用过滤器,那么您最终将得到包含 2、5、4 个元素的页面,具体取决于被过滤掉的内容。这是一个好主意,但是如果其中一个属性具有如此高的基数,它将过滤掉大部分实体。例如。假设所有内容都带有时间戳,您希望首先获得一个日期范围,然后继续过滤只会消除一小部分结果集的内容。我相信在您的情况下,所有这些值都相当相等,因此第三种解决方案(见下方)将是最适合您的。

在这种情况下,我们可以将所有值都放入,以便它们 所有 得到 returned,从而避免 Get。例如,假设 'camping' 是我们最重要的过滤器。

    CreateIndex({
      name: 'all_camping_first',
      source: Collection('place'),
      values: [
        { field: ['data', 'camping'] },
        // and the rest will not be used for filter
        // but we want to return them to avoid Map/Get
        { field: ['data', 'swimming'] },
        { field: ['data', 'hiking'] },
        { field: ['data', 'culture'] },
        { field: ['data', 'nightlife'] },
        { field: ['data', 'budget'] },
        { field: ['data', 'name'] },
        { field: ['data', 'focus'] },
      ]
    })

您现在可以编写一个仅根据露营值获取范围的查询:

    Paginate(Range(Match('all_camping_first'), [1], [3]))

应该return两个元素(第三个有camping === 5) 现在假设我们要过滤这些并且我们将页面设置得较小以避免不必要的工作

    Filter(
      Paginate(Range(Match('all_camping_first'), [1], [3]), { size: 2 }),
      Lambda(
        ['camping', 'swimming', 'hiking', 'culture', 'nightlife', 'budget', 'name', 'focus'],
        And(GTE(Var('hiking'), 0), GTE(7, Var('hiking')))
      )
    )

由于我想清楚地说明每种方法的优点和缺点,让我们通过添加另一个具有与我们的查询匹配的属性的过滤器来确切地说明过滤器的工作原理。

    Create(Collection('place'), {
      data: {
        name: 'the safari',
        focus: 'team-building',
        camping: 1,
        swimming: 9,
        hiking: 2,
        culture: 4,
        nightlife: 3,
        budget: 10
      }
    })

运行同样的查询:

    Filter(
      Paginate(Range(Match('all_camping_first'), [1], [3]), { size: 2 }),
      Lambda(
        ['camping', 'swimming', 'hiking', 'culture', 'nightlife', 'budget', 'name', 'focus'],
        And(GTE(Var('hiking'), 0), GTE(7, Var('hiking')))
      )
    )

现在 return 仍然只有一个值 ,但为您提供了指向下一页的 'after' 光标 。您可能会想:“嗯?我的页面大小是 2?”。那是因为 Filter 在 分页之后起作用,并且您的页面最初有两​​个实体,其中一个被过滤掉了。所以你留下了一个值为 1 的页面和一个指向下一页的指针。


{
  "after": [
    ... 
  ],
  "data": [
    [
      1,
      7,
      3,
      7,
      10,
      6,
      "mullion",
      "team-building"
    ]
  ]

您也可以选择直接在 SetRef 上过滤,然后才分页。在这种情况下,您的页面大小将包含所需的大小。但是,请记住,这是对从 Range 返回的元素数量的 O(n) 操作。 Range 使用索引,但从您使用 Filter 的那一刻起,它就会遍历每个元素。

选项 3:一个值的索引 + 交集!

这是适合您的用例的最佳解决方案,但它需要更多的理解和中间索引。

当我们查看 intersection 的文档示例时,我们看到了这个示例:

    Paginate(
       Intersection(
          Match(q.Index('spells_by_element'), 'fire'),
          Match(q.Index('spells_by_element'), 'water'),
       )
    ) 

这是可行的,因为它是相同索引的两倍,这意味着 **结果是相似的值 **(在本例中为引用)。 假设我们添加了一些索引。

    CreateIndex({
      name: 'by_camping',
      source: Collection('place'),
      values: [
        { field: ['data', 'camping']}, {field:  ['ref']}
      ]
    })

    CreateIndex({
      name: 'by_swimming',
      source: Collection('place'),
      values: [
        { field: ['data', 'swimming']}, {field:  ['ref']} 
      ]
    })

    CreateIndex({
      name: 'by_hiking',
      source: Collection('place'),
      values: [
        { field: ['data', 'hiking']}, {field:  ['ref']} 
      ]
    })

我们现在可以在它们上相交但它不会给我们正确的结果。例如...让我们称之为:

    Paginate(
      Intersection(
        Range(Match(Index("by_camping")), [3], []),
        Range(Match(Index("by_swimming")), [3], [])
      )
    )

结果为空。虽然我们有一个游泳 3 和露营 5。 这正是问题所在。如果游泳和露营的价值相同,我们就会得到一个结果。因此,重要的是要注意 Intersection 与 values 相交,因此它包括 camping/swimming 值和引用。这意味着我们必须删除该值,因为我们只需要引用。 before 分页的方法是使用连接,本质上我们将连接另一个索引,该索引将只是.. return ref(未指定值默认值仅参考)

CreateIndex({
  name: 'ref_by_ref',
  source: Collection('place'),
  terms: [{field:  ['ref']}]
})

此连接如下所示

    Paginate(Join(
      Range(Match(Index('by_camping')), [4], [9]),
      Lambda(['value', 'ref'], Match(Index('ref_by_ref'), Var('ref'))
    )))

这里我们只是获取了 Match(Index('by_camping')) 的结果,并通过加入一个仅 return 引用的索引来删除该值。现在让我们结合起来,做一个 AND 类型的范围查询 ;)

    Paginate(Intersection(
      Join(
        Range(Match(Index('by_camping')), [1], [3]),
        Lambda(['value', 'ref'], Match(Index('ref_by_ref'), Var('ref'))
      )),
      Join(
        Range(Match(Index('by_hiking')), [0], [7]),
        Lambda(['value', 'ref'], Match(Index('ref_by_ref'), Var('ref'))
      ))
    ))

结果是两个值,而且都在同一页!

请注意,您可以轻松地 extendcompose FQL,只需使用本地语言(在本例中为 JS)即可实现此外观好多了(注意我没有测试这段代码)

    const DropAllButRef = function(RangeMatch) {
      return Join(
        RangeMatch,
        Lambda(['value', 'ref'], Match(Index('ref_by_ref'), Var('ref'))
      ))
    }
    
    Paginate(Intersection(
      DropAllButRef (Range(Match(Index('by_camping')), [1], [3])),
      DropAllButRef (Range(Match(Index('by_hiking')), [0], [7]))
    ))

最后一个扩展,这只有 return 个索引,因此您需要映射获取。如果你真的想通过.. 只使用另一个索引,当然有办法解决这个问题:)

    const index = CreateIndex({
      name: 'all_values_by_ref',
      source: Collection('place'),
      values: [
        { field: ['data', 'camping'] },
        { field: ['data', 'swimming'] },
        { field: ['data', 'hiking'] },
        { field: ['data', 'culture'] },
        { field: ['data', 'nightlife'] },
        { field: ['data', 'budget'] },
        { field: ['data', 'name'] },
        { field: ['data', 'focus'] }
      ],
      terms: [
        { field: ['ref'] }
      ]
    }) 

现在您有了范围查询,将得到没有 map/get 的所有内容:

  Paginate(
    Intersection(
      Join(
        Range(Match(Index('by_camping')), [1], [3]),
        Lambda(['value', 'ref'], Match(Index('all_values_by_ref'), Var('ref'))
      )),
      Join(
        Range(Match(Index('by_hiking')), [0], [7]),
        Lambda(['value', 'ref'], Match(Index('all_values_by_ref'), Var('ref'))
      ))
    )
  )

使用这种连接方法,您甚至可以对不同的集合进行范围索引,只要您在相交之前将它们连接到相同的引用即可!很酷吧?

我可以在索引中存储更多的值吗?

是的,FaunaDB 中的索引是视图,所以我们称它们为独立视图。这是一种权衡,本质上你是在用计算换取存储。通过制作具有多个值的视图,您可以非常快速地访问数据的特定子集。但是还有另一个权衡,那就是灵活性。您可以 而不是 直接添加元素,因为那样需要您重写整个索引。在这种情况下,如果您有很多数据(是的,这很常见),您将不得不创建一个新索引并等待它构建,并确保您所做的查询(查看地图过滤器中的 lambda 参数)匹配你的新索引。之后您可以随时删除其他索引。仅使用 Map/Get 会更加灵活,数据库中的所有内容都是一种权衡,而 FaunaDB 为您提供了两种选择:)。我建议从您的数据模型被修复并且您在您的应用程序中看到您想要优化的特定部分时开始使用这种方法。

避免 MapGet

关于 Map/Get 的第二个问题需要一些解释。如果您想使用 Join 更有效地获取实际的 places,那么将您要搜索的值与地点分开(就像 Ben 所做的那样)是个好主意。这将不需要 Map Get,因此您的读取成本要低得多,但请注意 Join 是一个遍历(它会将当前引用替换为它加入的目标引用)所以如果您同时需要值和实际位置查询结束时一个对象中的数据比您需要 Map/Get。从这个角度来看,索引在读取方面便宜得离谱,你可以用它们走得很远,但对于某些操作来说,Map/Get 是没有办法的,Get 仍然只有 1 次读取。鉴于您每天免费获得 100 000 个,这仍然不贵 :)。您也可以使页面相对较小(分页中的大小参数)以确保您不会进行不必要的获取,除非您的用户或应用程序需要更多页面。 对于阅读这篇文章但还不知道这一点的人:

  • 1 个索引页 === 1 次阅读
  • 1 得到 === 1 读取

最后的笔记

我们可以而且将来会让这更容易。但是,请注意,您正在使用可扩展的分布式数据库,而且通常这些事情在其他解决方案中甚至是不可能的,或者效率非常低。 FaunaDB 为您提供了非常强大的结构和对索引如何工作的原始访问,并为您提供了许多选择。它不会试图在幕后为您变得聪明,因为如果我们弄错了,这可能会导致非常低效的查询(这在可扩展的现收现付系统中会很糟糕)。

使用多个条件进行查询的简单方法我认为使用文档进行查询差异,在我的解决方案中它是这样的:

    const response = await client.query(
      q.Let(
        {
          activeUsers: q.Difference(
            q.Match(q.Index("allUsers")),
            q.Match(q.Index("usersByStatus"), "ARCHIVE")
          ),
          paginatedDocuments: q.Map(
            q.Paginate(q.Var("activeUsers"), {
              size,
              before: reqBefore,
              after: reqAfter
            }),
            q.Lambda("x", q.Get(q.Var("x")))
          ),
          total: q.Count(q.Var("activeUsers"))
        },
        {
          documents: q.Var("paginatedDocuments"),
          total: q.Var("total")
        }
      )
    );
    
    const {
      documents: {
        data: dbData = [],
        before: dbBefore = [],
        after: dbAfter = []
      } = {},
      total = 0
    } = response || {};

    const respBefore = dbBefore[0]?.value?.id || null;
    const respAfter = dbAfter[0]?.value?.id || null;

    const data = await dbData.map((userData) => {
      const {
        ref: { id = null } = {},
        data: { firstName = "", lastName = "" }
      } = userData;

      return {
        id,
        firstName,
        lastName
      };
    });

因此,在查询生成器中,您可以根据需要的 index 过滤 Let 部分变量中的每个嵌套文档。

这是过滤的另一个变体,在SQL中看起来像:

SELECT * FROM clients WHERE salary > 2000 AND age > 30;

动物群查询:

const response = await client.query(
  q.Let(
    {
      allClients: q.Match(q.Index("allClients")),

      filteredClients: q.Filter(
        q.Var("allClients"),
        q.Lambda(
          "client",
          q.And(
            q.GT(q.Select(["data", "salary"], q.Get(q.Var("client"))), 2000),
            q.GT(q.Select(["data", "age"], q.Get(q.Var("client"))), 30)
          )
        )
      ),

      paginatedDocuments: q.Map(
        q.Paginate(q.Var("filteredClients")),
        q.Lambda("x", q.Get(q.Var("x")))
      ),
      total: q.Count(q.Var("filteredClients"))
    },

    {
      documents: q.Var("paginatedDocuments"),
      total: q.Var("total")
    }
  )
);

这是 在 javascript 中的某种过滤,其中条件如果 returns 为真,那么它将出现在响应的结果中。示例:

const filteredClients = allClients.filter((client) => {
  const { salary, age } = client;
  
  return ( salary > 2000 ) && (age > 30)
})