5

我试图提高我对 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
      }
    }
  }
}

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

  • data.attributes.amenities.camping > 5
  • data.attributes.amenities.camping > 5 和 data.attributes.amenities.hiking > 6
  • data.attributes.amenities.camping < 6 AND data.attributes.amenities.culture > 6 AND 远足 > 5 AND ...

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

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

查询:budget >= 6 AND camping >=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"))
  )
)

这种方法有以下缺点:

  • 它不像预期的那样工作,例如,如果第一个(文化)属性在这个范围内,但第二个(远足)不是,那么我仍然会得到一个返回值
  • 由于我需要为每个结果遵循参考,它会导致大量读取。

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

提前致谢

4

4 回答 4

14

谢谢你的问题。Ben 已经写了一个完整的例子来展示你可以做什么,我会根据他的建议来进一步澄清。

FaunaDB 的 FQL 非常强大,这意味着有多种方法可以做到这一点,但是有了这样的能力,学习曲线就会很短,所以我很乐意提供帮助 :)。回答这个问题需要一段时间的原因是,如此详尽的答案实际上值得一篇完整的博客文章。好吧,我从来没有在 Stack Overflow 上写过博客文章,一切都是第一次!

有三种方法可以执行“复合范围查询”,但有一种方法对您的用例来说性能最好,我们会看到第一种方法实际上并不完全是您所需要的。剧透,我们在这里描述的第三个选项是你需要的。

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

我会将它保存在一个集合中以使其更简单,并在此处使用 Fauna Query Language 的 JavaScript 风格。有充分的理由在第二个集合中分离数据,但这与您的第二个地图/获取问题有关(请参阅此答案的结尾)

创建集合

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

在这种情况下,我们可以将所有值都放入其中,以便它们全部返回,从而避免 Get。例如,假设“露营”是我们最重要的过滤器。

    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]))

哪个应该返回两个元素(第三个有 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')))
      )
    )

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


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

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

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

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

当我们查看交叉点的文档示例时,我们会看到以下示例:

    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相交,因此包括露营/游泳值和参考值。这意味着我们必须删除该值,因为我们只需要引用。在分页之前执行此操作的方法是使用连接,基本上我们将与另一个索引连接

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')) 的结果,并通过加入一个只返回 ref 的索引来删除该值。现在让我们把它结合起来,做一个 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'))
      ))
    ))

结果是两个值,并且都在同一个页面中!

请注意,您可以通过使用本机语言(在本例中为 JS)轻松扩展编写FQL,使其看起来更好(注意我没有测试这段代码)

    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]))
    ))

最后一个扩展,它只返回索引,所以你需要映射 get。如果你真的想通过......当然有一种方法可以解决这个问题。只需使用另一个索引:)

    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'] }
      ]
    }) 

现在您有了范围查询,无需地图/获取即可获得所有内容:

  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 中的索引是视图,所以我们称它们为 indiviews。这是一个权衡,本质上你是在用计算交换存储。通过创建具有多个值的视图,您可以非常快速地访问数据的某个子集。但还有另一个权衡,那就是灵活性。你不能只需添加元素,因为这需要您重写整个索引。在这种情况下,如果您有很多数据(是的,这很常见),您将必须创建一个新索引并等待它构建,并确保您执行的查询(查看映射过滤器中的 lambda 参数)匹配你的新索引。之后您始终可以删除其他索引。仅使用 Map/Get 会更加灵活,数据库中的所有内容都是一种权衡,FaunaDB 为您提供了两种选择:)。我建议从您的数据模型固定并且您在应用程序中看到要优化的特定部分的那一刻起就使用这种方法。

避免 MapGet

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

  • 1 个索引页 === 1 次阅读
  • 1 次获得 === 1 次阅读

最后的笔记

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

于 2020-05-06T14:23:19.690 回答
6

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

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

第一站:我们的系列。

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 岁以下远足和游泳(任何分数)。这是事情发生转折的地方。我们想要模拟连接,这在动物群中意味着相交集。我们遇到的问题是,到目前为止,我们一直在使用一个返回分数和位置参考的索引。为了让交叉点起作用,我们只需要参考。是时候耍花招了:

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 的 place refs(空数组排序在任何内容之前,因此用作下限的占位符)。

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 来整理很多,练习留给读者。涉及的条件or可以以几乎相同的方式使用联合进行管理。

于 2020-05-03T21:08:12.443 回答
1

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

    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
      };
    });

因此,在查询构建器中,您可以通过您想要的索引过滤Let部分中变量中的每个嵌套文档。

于 2022-02-10T04:05:34.777 回答
-1

这是过滤的另一种变体,在SQL中如下所示:

SELECT * FROM clients WHERE pay > 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中的某种过滤,条件 if 返回 true,因此它将出现在响应的结果中。例子:

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

于 2022-02-13T20:57:22.483 回答