Fazer junções com subconsultas

Visão geral

A edição Enterprise do Firestore oferece suporte a mesclas de estilo relacional por meio de subconsultas correlacionadas. Ao contrário de muitos bancos de dados NoSQL que geralmente exigem a desnormalização de dados ou a execução de várias solicitações do lado do cliente, as subconsultas permitem combinar e agregar dados de coleções ou subcoleções relacionadas diretamente no servidor.

As subconsultas são expressões que executam um pipeline aninhado para cada documento processado pela consulta externa. Isso permite padrões complexos de recuperação de dados, como buscar um documento junto com os itens da subcoleção relacionada ou unir dados logicamente vinculados em coleções raiz diferentes.

Conceitos

Esta seção apresenta os conceitos básicos do uso de subconsultas para realizar mesclas em operações de pipeline.

Subconsultas como expressões

Uma subconsulta não é um estágio de nível superior. Em vez disso, é uma expressão que pode ser usada em qualquer estágio que aceite expressões, como select(...), add_fields(...), where(...), ou sort(...).

Cloud Firestore oferece suporte a três tipos de subconsultas:

  • Subconsultas de matriz:materializam todo o conjunto de resultados da subconsulta como uma matriz de documentos.
  • Subconsultas escalares:avaliam um único valor, como uma contagem, uma média ou um campo específico de um documento relacionado.
  • Subconsultas subcollection(...):mesclas simplificadas para uma relação pai-filho um para muitos.

Escopo e variáveis

Ao escrever uma mescla, a subconsulta aninhada geralmente precisa fazer referência a campos do documento "externo" (o pai). Para unir esses escopos, use o let(...) estágio (referido como define(...) em alguns SDKs) para definir variáveis no escopo pai que podem ser referenciadas na subconsulta usando a variable(...) função.

Sintaxe

As seções a seguir oferecem uma visão geral da sintaxe para realizar mesclas.

O estágio let(...)

O estágio let(...) (referido como define(...) em alguns SDKs) é um estágio não filtrado que traz explicitamente dados do escopo pai para uma variável nomeada para uso em escopos aninhados subsequentes.

Subconsultas de matriz

Uma subconsulta de matriz é um caso especial de subconsulta de expressão que materializa todo o conjunto de resultados da subconsulta em uma matriz. Se a subconsulta não retornar nenhuma linha, ela será avaliada como uma matriz vazia. Ela nunca retorna uma matriz null. Essas consultas são úteis quando os resultados completos são necessários no resultado final, como ao materializar uma coleção aninhada ou correlacionada.

As consultas podem filtrar, classificar e agregar na subconsulta para reduzir a quantidade de dados que precisam ser buscados e retornados, ajudando a reduzir o custo da consulta. A ordem da subconsulta é respeitada, o que significa que um estágio sort(...) na subconsulta controla a ordem dos resultados na matriz final.

Use o wrapper do SDK toArrayExpression() para converter uma consulta em uma matriz.

Subconsultas escalares

As subconsultas escalares são frequentemente usadas em um estágio select(...) ou where(...), pois permitem filtrar ou resultar o resultado de uma subconsulta sem materializar a consulta completa diretamente.

Uma subconsulta escalar que produz zero resultados será avaliada como null, enquanto uma subconsulta que é avaliada como vários elementos resultará em um erro de execução.

Quando uma subconsulta escalar produz apenas um único campo por resultado, o campo é elevado para ser o resultado de nível superior da subconsulta. Isso é mais comum quando a subconsulta termina com um select(field("user_name")) ou aggregate(countAll().as("total")) em que o esquema da subconsulta é apenas um único campo. Caso contrário, quando uma subconsulta pode produzir vários campos, eles são encapsulados em um mapa.

Use o wrapper do SDK toScalarExpression() para converter uma consulta em uma expressão escalar.

Subconsultas subcollection(...)

Embora oferecido como um estágio, o subcollection(...) estágio de entrada permite realizar mesclas no modelo de dados hierárquico do Cloud Firestore. Em um modelo hierárquico, as consultas geralmente precisam recuperar um documento junto com dados das próprias subcoleções. Embora seja possível fazer isso usando um collection_group(...) estágio de entrada seguido por um filtro na referência pai, subcollection(...) oferece uma sintaxe muito mais concisa.

Além da condição de mescla implícita, isso funciona de maneira semelhante a uma subconsulta de matriz, retornando um resultado vazio se nenhum documento for correspondido, mesmo que a coleção aninhada não exista.

É fundamentalmente açúcar sintático: ele usa automaticamente o __name__ de o documento no escopo externo como a chave de mescla para resolver a relação hierárquica. Isso a torna a maneira preferida de realizar pesquisas em coleções vinculadas em uma relação pai-filho.

Exemplos

Dados de exemplo

O código a seguir carrega um conjunto de dados de teste para uso em todos os exemplos a seguir.

Node.js

// Load set of cities.
const cities = collection(db, "cities");

await setDoc(doc(cities, "SF"), {
  name: "San Francisco",
  state: "CA",
  country: "USA",
});
await setDoc(doc(cities, "LA"), {
  name: "Los Angeles",
  state: "CA",
  country: "USA"
});
await setDoc(doc(cities, "DC"), {
  name: "Washington, D.C.",
  state: null,
  country: "USA"
});
await setDoc(doc(cities, "TOK"), {
  name: "Tokyo",
  state: null,
  country: "Japan"
});

// Load restaurants in various cities.
const sfRestaurants = collection(db, "cities", "SF", "restaurants");
const laRestaurants = collection(db, "cities", "LA", "restaurants");
const dcRestaurants = collection(db, "cities", "DC", "restaurants");

const rest1 = await addDoc(sfRestaurants, {
  name: "Golden Gate Pizza",
  type: "pizza",
  owner_id: "Mario Rossi"
});
const rest2 = await addDoc(sfRestaurants, {
  name: "Bay Area Burger",
  type: "burger",
  owner_id: "Sarah Jenkins"
});
const rest3 = await addDoc(sfRestaurants, {
  name: "Sunset Taco",
  type: "mexican",
  owner_id: "Edward"
});

const rest4 = await addDoc(laRestaurants, {
  name: "Hollywood Sushi",
  type: "sushi",
  owner_id: "Ken Kenji"
});
const rest5 = await addDoc(laRestaurants, {
  name: "Venice Pizza",
  type: "pizza",
  owner_id: "Luigi Romano"
});

const rest6 = await addDoc(dcRestaurants, {
  name: "Capitol Tacos",
  type: "mexican",
  owner_id: "Maria Garcia"
});
const rest7 = await addDoc(dcRestaurants, {
  name: "Georgetown Coffee",
  type: "cafe",
  owner_id: "David Kim"
});

// Load collection of reviews.
const reviews = collection(db, "reviews");

await addDoc(reviews, { restaurant: rest1, rating: 5, reviewer_id "Alice" });
await addDoc(reviews, { restaurant: rest1, rating: 4, reviewer_id "Bob" });
await addDoc(reviews, { restaurant: rest2, rating: 4, reviewer_id "Charlie" });
await addDoc(reviews, { restaurant: rest3, rating: 5, reviewer_id "Diana" });
await addDoc(reviews, { restaurant: rest3, rating: 4, reviewer_id "Edward" });
await addDoc(reviews, { restaurant: rest3, rating: 4, reviewer_id "Fiona" });
// rest4 has 0 reviews
await addDoc(reviews, { restaurant: rest5, rating: 3, reviewer_id "George" });
await addDoc(reviews, { restaurant: rest6, rating: 5, reviewer_id "Hannah" });
await addDoc(reviews, { restaurant: rest6, rating: 4, reviewer_id "Ian" });
await addDoc(reviews, { restaurant: rest7, rating: 5, reviewer_id "Julia" });

Pesquisar um documento em outra coleção

A consulta a seguir no grupo de coleções reviews realiza uma pesquisa no grupo de coleções restaurant usando uma referência de chave primária.

Node.js

let results = await execute(db.pipeline()
  .collectionGroup("reviews")
  .define(field("restaurant").as("restaurant_name"))
  .addFields(db.pipeline()
    .collectionGroup("restaurant")
    .where(field("__name__").equal(variable("restaurant_name")))
    .select("name", "type")
    .toScalarExpression()
    .as("restaurant")));

Resposta

{
  rating: 5,
  reviewer_id "Alice",
  restaurant: { name: "Golden Gate Pizza", type: "pizza" }
},
{
  rating: 4,
  reviewer_id "Bob",
  restaurant: { name: "Golden Gate Pizza", type: "pizza" }
},
{
  rating: 4,
  reviewer_id "Charlie",
  restaurant: { name: "Bay Area Burger", type: "burger" }
},
{
  rating: 5,
  reviewer_id "Diana",
  restaurant: { name: "Sunset Taco", type: "mexican" }
},
{
  rating: 4,
  reviewer_id "Edward",
  restaurant: { name: "Sunset Taco", type: "mexican" }
},
{
  rating: 4,
  reviewer_id "Fiona",
  restaurant: { name: "Sunset Taco", type: "mexican" }
},
{
  rating: 3,
  reviewer_id "George",
  restaurant: { name: "Venice Pizza", type: "pizza" }
},
{
  rating: 5,
  reviewer_id "Hannah",
  restaurant: { name: "Capitol Tacos", type: "mexican" }
},
{
  rating: 4,
  reviewer_id "Ian",
  restaurant: { name: "Capitol Tacos", type: "mexican" }
},
{
  rating: 5,
  reviewer_id "Julia",
  restaurant: { name: "Georgetown Coffee", type: "cafe" }
}

Combinar várias coleções

A consulta a seguir busca todos os locais de pizza do grupo de coleções restaurants e usa uma subconsulta de matriz para buscar e incorporar as avaliações associadas diretamente na resposta.

Node.js

let results = await execute(db.pipeline()
  .collectionGroup("restaurants")
  .where(field("type").equal("pizza"))
  .define(field("__name__").as("restaurant_name"))
  .select(
    field("name"),
    db.pipeline()
      .collectionGroup("reviews")
      .where(field("restaurant").equal(variable("restaurant_name")))
      .select("rating", "reviewer_id")
      .toArrayExpression()
      .as("reviews")));

Resposta

{
  name: "Golden Gate Pizza",
  reviews: [
    { rating: 5, reviewer_id "Alice" },
    { rating: 4, reviewer_id "Bob" }
  ]
},
{
  name: "Venice Pizza",
  type: "pizza",
  owner_id: "Luigi Romano",
  reviews: [
    { rating: 3, reviewer_id "George" }
  ]
}

Agregar em várias coleções

A consulta a seguir no grupo de coleções restaurants usa uma subconsulta correlacionada para receber a classificação média de cada restaurante do grupo de coleções reviews.

Node.js

let results = await execute(db.pipeline()
  .collectionGroup("restaurants")
  .where(field("type").equal("pizza"))
  .define(field("__name__").as("restaurant_name"))
  .select(
    field("name"),
    db.pipeline()
      .collectionGroup("reviews")
      .where(field("restaurant").equal(variable("restaurant_name")))
      .aggregate(average("rating").as("avg_rating"))
      .toScalarExpression()
      .as("avg_rating")));

Resposta

{
  name: "Golden Gate Pizza",
  avg_rating: 4.5
},
{
  name: "Venice Pizza",
  avg_rating: 3.0
}

Top-N por grupo (subconsulta com limite)

A consulta a seguir busca todos os documentos do grupo de coleções restaurants e usa uma subconsulta correlacionada para buscar as duas avaliações mais bem classificadas de cada restaurante.

Isso garante que a matriz de avaliações não cresça muito e atinja o limite de memória da consulta.

Node.js

let results = await execute(db.pipeline()
  .collectionGroup("restaurants")
  .define(field("__name__").as("restaurant_name"))
  .select(
    field("name"),
    db.pipeline()
      .collectionGroup("reviews")
      .where(field("restaurant").equal(variable("restaurant_name")))
      .sort(field("rating").descending())
      .limit(2)
      .select("rating", "reviewer_id")
      .toArrayExpression()
      .as("top_reviews")));

Resposta

{
  name: "Golden Gate Pizza",
  top_reviews: [
    { rating: 5, reviewer_id "Alice" },
    { rating: 4, reviewer_id "Bob" }
  ]
},
{
  name: "Bay Area Burger",
  top_reviews: [
    { rating: 4, reviewer_id "Charlie" }
  ]
},
{
  name: "Sunset Taco",
  top_reviews: [
    { rating: 5, reviewer_id "Diana" },
    { rating: 4, reviewer_id "Edward" }
  ]
},
{
  name: "Hollywood Sushi",
  top_reviews: []
},
{
  name: "Venice Pizza",
  top_reviews: [
    { rating: 3, reviewer_id "George" }
  ]
},
{
  name: "Capitol Tacos",
  top_reviews: [
    { rating: 5, reviewer_id "Hannah" },
    { rating: 4, reviewer_id "Ian" }
  ]
},
{
  name: "Georgetown Coffee",
  top_reviews: [
    { rating: 5, reviewer_id "Julia" }
  ]
}

Mesclar subcoleções

A consulta a seguir verifica a coleção cities e usa o subcollection(...) estágio para mesclar implicitamente documentos de uma coleção aninhada para encontrar o número de restaurantes por cidade.

Node.js

let results = await execute(db.pipeline()
  .collection("cities")
  .addFields(subcollection("restaurants")
    .toArrayExpression()
    .length()
    .as("restaurant_count")));

Resposta

{
  __name__: cities/SF,
  name: "San Francisco",
  state: "CA",
  country: "USA",
  restaurant_count: 3
},
{
  __name__: cities/LA,
  name: "Los Angeles",
  state: "CA",
  country: "USA",
  restaurant_count: 2
},
{
  __name__: cities/DC,
  name: "Washington, D.C.",
  state: null,
  country: "USA",
  restaurant_count: 2
},
{
  __name__: cities/TOK,
  name: "Tokyo",
  state: null,
  country: "Japan",
  restaurant_count: 0
}

Expressar várias condições de mescla

A consulta a seguir verifica o grupo de coleções restaurants e realiza uma mescla de vários campos com o grupo de coleções reviews para encontrar proprietários que avaliam os próprios restaurantes.

Node.js

let results = await execute(db.pipeline()
  .collectionGroup("restaurants")
  .define(field("owner_id"), field("__name__"))
  .where(db.pipeline()
    .collectionGroup("reviews")
    .where(field("restaurant").equal(variable("__name__")))
    .where(field("author").equal(variable("owner_id")))
    .aggregate(count().as("c"))
    .toScalarExpression()
    .greaterThan(0)));

Resposta

{
  __name__: cities/SF/restaurants/X9An0HIlx29A9GPuRthS,
  name: "Sunset Taco",
  type: "mexican",
  owner_id: "Edward"
}

Anti-mescla (NOT EXISTS)

A consulta a seguir verifica o grupo de coleções restaurants e encontra todos os restaurantes que ainda não têm avaliações.

Node.js

let results = await execute(db.pipeline()
  .collectionGroup("restaurants")
  .define(field("__name__").as("restaurant_name"))
  .where(db.pipeline()
    .collectionGroup("reviews")
    .where(field("restaurant").equal(variable("restaurant_name")))
    .aggregate(count().as("review_count"))
    .toScalarExpression()
    .equal(0)));

Resposta

{
  __name__: "cities/LA/restaurants/X9An0HIlx29A9GPuRthS",
  name: "Hollywood Sushi",
  type: "sushi",
  owner_id: "Ken Kenji"
}

Subconsulta como mescla

A consulta a seguir simplifica a relação entre cada pizzaria e as avaliações. Ao colocar a subconsulta dentro de um estágio unnest(...), o servidor duplica o documento do restaurante externo para cada avaliação correspondente, produzindo documentos simples e mesclados (semelhante a um INNER JOIN do SQL).

Node.js

let results = await execute(db.pipeline()
  .collectionGroup("restaurants")
  .where(field("type").equal("pizza"))
  .define(field("__name__").as("restaurant_name"))
  .unnest(
    db.pipeline()
      .collectionGroup("reviews")
      .where(field("restaurant").equal(variable("restaurant_name")))
      .select("rating", "reviewer_id")
      .toArrayExpression()
      .as("review")));

Resposta

{
  __name__: "cities/SF/restaurants/xU4pu8nFpnJDPZOwcSPP",
  name: "Golden Gate Pizza",
  type: "pizza",
  owner_id: "Mario Rossi"
  review: { rating: 5, reviewer_id "Alice" }
},
{
  __name__: "cities/SF/restaurants/xU4pu8nFpnJDPZOwcSPP",
  name: "Golden Gate Pizza",
  type: "pizza",
  owner_id: "Mario Rossi",
  review: { rating: 4, reviewer_id "Bob" }
},
{
  __name__: "cities/LA/restaurants/6CYntvNgbYzgaW652Gq1",
  name: "Venice Pizza",
  type: "pizza",
  owner_id: "Luigi Romano",
  review: { rating: 3, reviewer_id "George" }
}

Subconsulta não correlacionada como filtro

A consulta a seguir na coleção reviews realiza filtros usando uma subconsulta não correlacionada para encontrar avaliações maiores que a classificação média.

Node.js

let results = await execute(db.pipeline()
  .collection("reviews")
  // Average review rating is 4.3
  .where(field("rating").greaterThan(db.pipeline()
    .collection("reviews")
    .aggregate(average("rating").as("avg"))
    .toScalarExpression())))
  .select("rating", "reviewer_id");

Resposta

{
  rating: 5,
  reviewer_id "Alice"
},
{
  rating: 5,
  reviewer_id "Diana"
},
{
  rating: 5,
  reviewer_id "Hannah"
},
{
  rating: 5,
  reviewer_id "Julia"
}

Práticas recomendadas

  • Gerenciar a memória com toArrayExpression(): tenha cuidado com toArrayExpression() subconsultas, já que a materialização de um grande número de documentos pode esgotar o limite de memória da consulta (128 MiB). Para atenuar isso, use select(...) na subconsulta para retornar apenas os campos necessários e aplique where(...) filtros para limitar o número de documentos retornados. Considere usar limit(...) se apropriado para limitar o número de documentos retornados pela subconsulta.
  • Indexação: verifique se os campos usados na cláusula where(...) de uma subconsulta estão indexados. As mesclas de desempenho dependem da capacidade de realizar pesquisas de índice em vez de verificações de tabela completas.

Para mais práticas recomendadas de consulta, consulte nosso guia sobre otimização de consultas.

Limitações

  • subcollection(...) escopo: O subcollection(...) estágio de entrada só é compatível com subconsultas, já que exige o contexto de um documento pai para resolver a relação hierárquica e realizar a mescla.
  • Profundidade de aninhamento:as subconsultas podem ser aninhadas em até 20 camadas de profundidade.
  • Uso de memória:o limite de 128 MiB em dados materializados se aplica a toda a consulta, incluindo todos os documentos mesclados.