# JOIN 关联查询

Typesense 支持基于相关列将一个或多个集合中的文档进行 JOIN 关联查询。

注意: 目前尚不支持通过 alter 操作为现有集合添加引用字段。

# 一对一关系

创建集合时,您可以通过 reference 属性创建一个字段,将文档连接到另一个集合中的字段。

例如,我们可以使用 authors 集合的 id 字段作为引用,将 books 集合连接到 authors 集合:

{
  "name":  "books",
  "fields": [
    {"name": "title", "type": "string"},
    {"name": "author_id", "type": "string", "reference": "authors.id"}
  ]
}

当我们搜索 books 集合时,可以通过 include_fieldsauthors 集合获取作者字段。

curl "http://localhost:8108/multi_search" -X POST \
        -H "X-TYPESENSE-API-KEY: ${TYPESENSE_API_KEY}" \
        -d '{
          "searches": [
            {
              "collection": "books",
              "include_fields": "$authors(first_name,last_name)",
              "q": "famous"
            }
          ]
        }'

通过 $authors(first_name,last_name) 请求 first_namelast_name 时,响应会包含一个带有相应作者信息的 authors 对象:

{
  "document": {
    "id": "0",
    "title": "Famous Five",
    "author_id": "0",
    "authors": {
      "first_name": "Enid",
      "last_name": "Blyton"
    }
  }
}

要包含集合中的所有字段,应使用星号 *

{
  "collection": "books",
  "include_fields": "$authors(*)",
  "q": "famous"
}

假设我们想查询 authors 集合并获取匹配查询的所有作者的相关书籍。由于 books 集合具有引用字段而我们正在搜索 authors 集合,不能简单地指定 include_fields: $books(*) 来连接相关文档。为此,我们需要像这样指定 filter_by 子句来连接集合:

{
  "collection": "authors",
  "q": "query",
  "filter_by": "$books(id: *)",
  "include_fields": "$books(*)",
}

id:* 是一个特殊过滤器,它匹配特定集合的所有文档,因此在连接中使用时,它允许您列出特定作者的所有相关书籍。

int32int64string 类型的字段可用于"一对一"引用情况,即一个文档恰好关联一个引用文档。int32[]int64[]string[] 类型的字段可用于多引用情况,即一个文档关联另一个集合中的零个或多个文档。

# 一对多关系

# 简单示例

让我们从一个简单示例开始,假设我们有一个 orders 集合,需要跟踪每位客户的不同订单,即每位客户可能拥有多个不同的订单。

customers 集合的 schema 如下所示:

{
  "name":  "customers",
  "fields": [
    {"name": "forename", "type": "string"},
    {"name": "surname", "type": "string"},
    {"name": "email", "type": "string"}
  ]
}

接着我们创建一个 orders 集合来存储订单详情,并通过引用关联到 customers 集合中对应的用户:

{
  "name":  "orders",
  "fields": [
    {"name": "total_price", "type": "float"},
    {"name": "initial_date", "type": "int64"},
    {"name": "accepted_date", "type": "int64", "optional": true},
    {"name": "completed_date", "type": "int64", "optional": true},
    {"name": "customer_id", "type": "string", "reference": "customers.id"}
  ] 
}

现在我们可以搜索 customers 集合,并通过 filter_by 参数从 orders 集合中筛选特定客户的订单:

{
    "q":"*",
    "collection":"customers",
    "filter_by":"$orders(customer_id:=customer_a)"
}

也可以根据其他字段进行筛选,例如查询总价低于 100 的订单对应的客户:

{
    "q": "*",
    "collection": "customers",
    "filter_by": "$orders(total_price:<100)"
}

默认情况下,上述查询会包含被引用集合 orders 的所有字段。如果只需要包含被引用集合中的 total_price 字段,可以这样操作:

{
  "include_fields": "$orders(total_price)" 
}

# 专业示例

为了展示一个更专业的应用场景,假设我们有一个 products 产品集合,需要为不同客户提供个性化定价,即每个产品对每位客户都有不同的价格。此时,联合查询功能就能派上用场。

products 集合的 schema 结构如下:

{
  "name":  "products",
  "fields": [
    {"name": "product_id", "type": "string"},
    {"name": "product_name", "type": "string"},
    {"name": "product_description", "type": "string"}
  ]
}

我们再创建一个 customer_product_prices 集合,用于存储每位客户的定制价格,同时通过引用关联到产品集合中的对应文档:

{
  "name":  "customer_product_prices",
  "fields": [
    {"name": "customer_id", "type": "string"},
    {"name": "custom_price", "type": "float"},
    {"name": "product_id", "type": "string", "reference": "products.product_id"}
  ] 
}

现在我们可以搜索 products 集合,并通过 filter_by 参数从 customer_product_prices 集合中筛选特定客户的价格:

{
    "q":"*",
    "collection":"products",
    "filter_by":"$customer_product_prices(customer_id:=customer_a)"
}

如果需要查询价格低于 100 的产品?同样可以轻松实现:

{
    "q": "*",
    "collection": "products",
    "filter_by": "$customer_product_prices(customer_id:=customer_a && custom_price:<100)"
}

简单示例类似,您也可以仅包含引用集合中的 custom_price 字段:

{
  "include_fields": "$customer_product_prices(custom_price)" 
}

# 多对多关系

考虑一个文档集合,我们希望为用户提供访问权限,使得一个文档可以被多个用户访问,同时一个用户可以访问多个文档。

要实现这一点,我们可以创建三个集合:documents(文档)、users(用户)和 user_doc_access(用户文档访问权限),其模式如下:


{
  "name":  "documents",
  "fields": [
    {"name": "id", "type": "string"},
    {"name": "title", "type": "string"}
    {"name": "content", "type": "string"}
  ]
}

{
  "name":  "users",
  "fields": [
    {"name": "id", "type": "string"},
    {"name": "username", "type": "string"}
  ]
}

{
  "name":  "user_doc_access",
  "fields": [
    {"name": "user_id", "type": "string", "reference": "users.id"},
    {"name": "document_id", "type": "string", "reference": "documents.id"},
  ]
}

要获取用户 user_a 可以访问的所有文档,可以这样查询:

{
    "q": "*",
    "collection": "documents",
    "filter_by": "$user_doc_access(user_id:=user_a)"
}

要获取可以访问特定文档的用户ID:

{
    "q": "*",
    "collection": "documents",
    "query_by": "title",
    "filter_by": "$user_doc_access(id: *)",
    "include_fields": "$users(id) as user_identifier"
}

当引用字段是数组类型(string[], int32[], int64[])时,即使某个文档只与一个文档有关联,我们也会始终以数组形式返回关联文档。由于 user_doc_access 集合使用的是单一引用字段类型,只有当它与多个文档有关联时,才会以数组形式返回关联文档。可以在 include_fields 中指定 strategy: nest_array 来强制 Typesense 始终以数组形式返回关联文档,无论关联数量多少。详情请参阅此处的"强制嵌套数组关联字段"部分。

# 按关联集合字段排序

遵循 $JoinedCollectionName( ... ) 的命名约定,我们可以通过以下方式对关联集合中的字段进行 sort_by 排序:

{
  "sort_by": "$JoinedCollectionName( 字段名:desc )"
}

同样地,要使用关联集合的字段指定 _eval 操作:

{
  "sort_by": "$JoinedCollectionName( _eval(字段名:foo):desc )"
}

# 合并/嵌套关联字段

默认情况下,当我们关联一个集合时,该集合的字段会作为嵌套文档返回。

例如,当我们像上面那样将 books 集合与 authors 集合关联时,注意 authors 集合的字段是如何作为对象出现在响应文档中的:

{
  "document": {
    "id": "0",
    "title": "Famous Five",
    "author_id": "0",
    "authors": {
      "first_name": "Enid",
      "last_name": "Blyton"
    }
  }
}

我们可以通过使用 merge 策略,将 authors 集合的字段与 books 文档的字段合并:

{
  "collection": "books",
  "include_fields": "$authors(*, strategy: merge)",
  "q": "famous"
}

默认行为是 strategy: nest(嵌套策略)。

# 强制嵌套字段使用数组格式

在一对多关联查询中,您可能希望关联集合的字段始终以对象数组的形式呈现,即使只有一个匹配项。

例如,给定以下作者和书籍数据:

{"id": "0", ",first_name": "Enid", "last_name": "Blyton"}
{"id": "1", ",first_name": "JK", "last_name": "Rowling"}
{"title": "Famous Five", "author_id": "0"}
{"title": "Secret Seven", "author_id": "0"}
{"title": "Harry Potter", "author_id": "1"}

当我们查询 authors 集合并关联 books 集合时,如下所示:

{
  "collection": "authors",
  "q": "*",
  "filter_by": "$books(id:*)",
  "include_fields": "$books(*)"
}

根据每个作者匹配的书籍数量(1本或多本),books 字段可能呈现为嵌套对象或嵌套对象数组:

[
  {
    "document": {
      "id": "1",
      "first_name": "JK",
      "last_name": "Rowling",
      "books": {
        "author_id": "1",
        "id": "2",
        "title": "Harry Potter"
      }
    }
  },
  {
    "document": {
      "id": "0",
      "first_name": "Enid",
      "last_name": "Blyton",
      "books": [
        {
          "author_id": "0",
          "id": "0",
          "title": "Famous Five"
        },
        {
          "author_id": "0",
          "id": "1",
          "title": "Secret Seven"
        }
      ]
    }
  }
]

若要始终使关联的 books 集合字段以对象数组形式返回,可以使用 nest_array 字段合并策略:

{
  "collection": "authors",
  "q": "*",
  "filter_by": "$books(id:*)",
  "include_fields": "$books(*, strategy: nest_array)"
}

这将始终为 books 集合的字段返回对象数组:

[
  {
    "document": {
      "id": "1",
      "first_name": "JK",
      "last_name": "Rowling",
      "books": [
        {
          "author_id": "1",
          "id": "2",
          "title": "Harry Potter"
        }
      ]
    }
  },
  {
    "document": {
      "id": "0",
      "first_name": "Enid",
      "last_name": "Blyton",
      "books": [
        {
          "author_id": "0",
          "id": "0",
          "title": "Famous Five"
        },
        {
          "author_id": "0",
          "id": "1",
          "title": "Secret Seven"
        }
      ]
    }
  }
]

# 异步引用

在创建引用字段时,可以使用 async_reference 选项。这样即使被引用的文档尚不存在,也能成功索引文档。

{
  "name": "orders",
  "fields": [
    {"name": "product_id", "type": "string", "reference": "products.product_id", "optional": true, "async_reference": true}
  ]
}

通过设置 async_reference: true,文档将被索引且不会返回以下错误:

在集合 '...' 中未找到具有 '...' 的引用文档。

当被引用的文档后续被索引时,引用关系会自动解析。这在需要按照不同于依赖关系的顺序索引文档的场景中特别有用。

# 对象内的引用

假设 orders 集合中有一个名为 orderobject 类型字段。我们可以让订单引用 products 集合中的产品,如下所示:

{
  "name": "orders",
  "fields": [
    {"name": "order", "type": "object"},
    {"name": "order.product_id", "type": "string", "reference": "products.id"}
  ]
}

或者,如果我们有一组 order 对象数组,每个订单对象都包含一个引用,那么引用字段的类型也必须是数组。

{
  "name": "orders",
  "fields": [
    {"name": "orders", "type": "object[]"},
    {"name": "orders.product_id", "type": "string[]", "reference": "products.id"}
  ]
}

WARNING

对于嵌套对象内的引用,不支持 async_reference 参数。这些引用必须在索引时解析。

# 在 Join 操作中使用别名

您可以在引用字段定义中使用集合别名。在下面的示例中,products 可以是一个别名:

{"name": "product_id", "type": "string", "reference": "products.product_id"}

但需要注意的是,集合文档存储的是被引用集合文档的内部 ID。这些内部 ID 本质上是顺序分配的,根据文档被索引的顺序进行分配。

因此,必须将被引用的集合视为一个整体来处理,也就是说,如果您打算通过别名更新来切换任何一个集合,您必须同时重新索引所有相关集合。这将确保参与 join 操作的所有集合中的内部 ID 保持同步。

# 左连接 (Left Join)

默认情况下,Typesense 执行的是内连接(inner join)。要执行左连接,可以指定:

{
  "filter_by": "id:* || $join_collection_name( <join_condition> )" 
}

id:* 会匹配被搜索集合的所有文档。因此,如果存在引用,结果将包含被引用的文档;否则将原样返回文档。

# 嵌套关联 (Nested Joins)

Typesense 支持在涉及多级数据检索和过滤的查询中使用嵌套关联。假设我们正在管理多个零售商的库存,其中某个产品可能有多种变体。数据可以这样建模:

{
  "name": "products",
  "fields": [
    { "name": "name", "type": "string" }
  ]
}
{
  "name": "product_variants",
  "fields": [
    { "name": "title", "type": "string" },
    { "name": "price", "type": "float" },
    { "name": "product_id", "type": "string", "reference": "products.id" }
  ]
}
{
  "name": "retailers",
  "fields": [
    { "name": "title", "type": "string" },
    { "name": "location", "type": "geopoint" }
  ]
}
{
  "name": "inventory",
  "fields": [
    { "name": "qty", "type": "int32" },
    { "name": "retailer_id", "type": "string", "reference": "retailers.id" },
    { "name": "product_variant_id", "type": "string", "reference": "product_variants.id" }
  ]
}

嵌套关联语法遵循 $JoinedCollectionName( $NestedJoinedCollectionName(...)) 的约定。要搜索产品名称并获取每个零售商的所有库存,可以发送以下查询:

{
  "collection": "products",
  "q": "shampoo",
  "query_by": "name",
  "filter_by": "$product_variants( $inventory( $retailers(id:*)))",
  "include_fields": "$product_variants(price, $inventory(qty, $retailers(title)))"
}

如果我们想搜索特定地理半径内的产品,可以发送以下查询:

{
  "collection": "products",
  "q": "shampoo",
  "query_by": "name",
  "filter_by": "$product_variants( $inventory( $retailers(location:(48.87538726829884, 2.296113163780903,1 km))))",
  "include_fields": "$product_variants(price, $inventory(qty, $retailers(title)))"
}

如果我们还想按价格进行过滤,可以发送以下查询:

{
  "collection": "products",
  "q": "shampoo",
  "query_by": "name",
  "filter_by": "$product_variants(price:<100 && $inventory( $retailers(location:(48.87538726829884, 2.296113163780903,1 km))))",
  "include_fields": "$product_variants(price, $inventory(qty, $retailers(title)))"
}

# 按嵌套关联集合的字段排序

遵循 $JoinedCollectionName( $NestedJoinedCollectionName(...)) 的命名约定,我们可以通过以下方式指定对嵌套关联集合中的字段进行排序:

{
  "sort_by": "$JoinedCollectionName( $NestedJoinedCollectionName(field_name:desc))"
}