Fazer junções com subconsultas
Visão geral
A edição Enterprise do Firestore oferece suporte a junções de estilo relacional usando subconsultas correlacionadas. Ao contrário de muitos bancos de dados NoSQL que geralmente exigem desnormalização de dados ou a realizaçã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.
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 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 por trás do uso de subconsultas para realizar junções em operações de pipeline.
Subconsultas como expressões
Uma subconsulta não é uma etapa de nível superior. Em vez disso, é uma expressão que pode ser usada em qualquer etapa que aceite expressões, como select(...), add_fields(...), where(...) ou sort(...).
O Firestore é compatível com três tipos de subconsultas:
- Subconsultas de matriz:materializam todo o conjunto de resultados da subconsulta como uma matriz de documentos.
- Subconsultas escalares:são avaliadas como um único valor, como uma contagem, uma média ou um campo específico de um documento relacionado.
subcollection(...)Subconsultas:junções simplificadas para uma relação de um para muitos entre pai e filho.
Escopo e variáveis
Ao escrever uma junção, a subconsulta aninhada geralmente precisa fazer referência a campos do documento "externo" (o pai). Para unir esses escopos, use a etapa let(...) (chamada de define(...) em alguns SDKs) para definir variáveis no escopo principal que podem ser referenciadas na subconsulta usando a função variable(...).
Sintaxe
As seções a seguir oferecem uma visão geral da sintaxe para realizar junções.
A etapa let(...)
A fase let(...) (chamada de define(...) em alguns SDKs) não é de filtragem e traz explicitamente dados do escopo principal para uma variável nomeada, que será usada 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. Ele 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, o que ajuda a diminuir o custo da consulta. A ordem da subconsulta é respeitada, ou seja, uma etapa 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(...) para permitir a filtragem ou resultar no 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 vai resultar em um erro de tempo de execução.
Quando uma subconsulta escalar produz apenas um campo por resultado, o campo é promovido 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 campo único. Caso contrário, quando uma subconsulta pode produzir vários campos, eles são agrupados em um mapa.
Use o wrapper do SDK toScalarExpression() para converter uma consulta em uma expressão escalar.
subcollection(...) Subconsultas
Embora oferecida como uma etapa, a
etapa de entrada subcollection(...) permite
realizar junções no modelo de dados hierárquico do Firestore. Em um modelo hierárquico, as consultas geralmente precisam recuperar um documento com dados das próprias subcoleções. Embora seja possível fazer isso usando uma
etapa de entrada collection_group(...) seguida
por um filtro na referência principal, subcollection(...) oferece uma sintaxe muito mais
concisa.
Além da condição de junção implícita, isso funciona de maneira semelhante a uma subconsulta de matriz, retornando um resultado vazio se nenhum documento for correspondente, mesmo que a coleção aninhada não exista.
É fundamentalmente um açúcar sintático: ele usa automaticamente o __name__ do documento no escopo externo como a chave de junção para resolver a relação hierárquica. Essa é a maneira preferida de fazer pesquisas em coleções vinculadas em uma relação pai-filho.
Exemplos
Dados de exemplo
O exemplo a seguir carrega um conjunto de dados de teste para usar em todos os exemplos a seguir.
Node.js
// Load set of cities.
const cities = db.collection("cities");
await cities.doc("SF").set({
name: "San Francisco",
state: "CA",
country: "USA",
});
await cities.doc("LA").set({
name: "Los Angeles",
state: "CA",
country: "USA"
});
await cities.doc("DC").set({
name: "Washington, D.C.",
state: null,
country: "USA"
});
await cities.doc("TOK").set({
name: "Tokyo",
state: null,
country: "Japan"
});
// Load restaurants in various cities.
const sfRestaurants = db.collection("cities/SF/restaurants");
const laRestaurants = db.collection("cities/LA/restaurants");
const dcRestaurants = db.collection("cities/DC/restaurants");
const rest1 = await sfRestaurants.add({
name: "Golden Gate Pizza",
type: "pizza",
owner_id: "Mario Rossi"
});
const rest2 = await sfRestaurants.add({
name: "Bay Area Burger",
type: "burger",
owner_id: "Sarah Jenkins"
});
const rest3 = await sfRestaurants.add({
name: "Sunset Taco",
type: "mexican",
owner_id: "Edward"
});
const rest4 = await laRestaurants.add({
name: "Hollywood Sushi",
type: "sushi",
owner_id: "Ken Kenji"
});
const rest5 = await laRestaurants.add({
name: "Venice Pizza",
type: "pizza",
owner_id: "Luigi Romano"
});
const rest6 = await dcRestaurants.add({
name: "Capitol Tacos",
type: "mexican",
owner_id: "Maria Garcia"
});
const rest7 = await dcRestaurants.add({
name: "Georgetown Coffee",
type: "cafe",
owner_id: "David Kim"
});
// Load collection of reviews.
const reviews = db.collection("reviews");
await reviews.add({ restaurant: rest1, rating: 5, reviewer_id: "Alice" });
await reviews.add({ restaurant: rest1, rating: 4, reviewer_id: "Bob" });
await reviews.add({ restaurant: rest2, rating: 4, reviewer_id: "Charlie" });
await reviews.add({ restaurant: rest3, rating: 5, reviewer_id: "Diana" });
await reviews.add({ restaurant: rest3, rating: 4, reviewer_id: "Edward" });
await reviews.add({ restaurant: rest3, rating: 4, reviewer_id: "Fiona" });
// rest4 has 0 reviews
await reviews.add({ restaurant: rest5, rating: 3, reviewer_id: "George" });
await reviews.add({ restaurant: rest6, rating: 5, reviewer_id: "Hannah" });
await reviews.add({ restaurant: rest6, rating: 4, reviewer_id: "Ian" });
await reviews.add({ 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 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"))
.execute();
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 lugares que vendem pizza do grupo de
coleção restaurants e usa uma subconsulta de matriz para buscar e incorporar as avaliações associadas
diretamente na resposta.
Node.js
let results = await 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"))
.execute();
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 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"))
.execute();
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 demais e atinja o limite de memória da consulta.
Node.js
let results = await 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"))
.execute();
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 a etapa
subcollection(...) para unir implicitamente
documentos de uma coleção aninhada e encontrar o número de restaurantes por
cidade.
Node.js
let results = await db.pipeline()
.collection("cities")
.addFields(subcollection("restaurants")
.toArrayExpression()
.length()
.as("restaurant_count"))
.execute();
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 junção
A consulta a seguir verifica o grupo de coleções restaurants e realiza uma junção de vários campos com o grupo de coleções reviews para encontrar proprietários avaliando os próprios restaurantes.
Node.js
let results = await 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))
.execute();
Resposta
{
__name__: cities/SF/restaurants/X9An0HIlx29A9GPuRthS,
name: "Sunset Taco",
type: "mexican",
owner_id: "Edward"
}
Anti-Join (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 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))
.execute();
Resposta
{
__name__: "cities/LA/restaurants/X9An0HIlx29A9GPuRthS",
name: "Hollywood Sushi",
type: "sushi",
owner_id: "Ken Kenji"
}
Subconsulta como junção
A consulta a seguir simplifica a relação entre cada pizzaria e as avaliações dela. Ao colocar a subconsulta em uma etapa unnest(...), o servidor duplica o documento externo do restaurante para cada avaliação correspondente, produzindo documentos simples e unidos (semelhante a um INNER JOIN do SQL).
Node.js
let results = await 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"))
.execute();
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 nela mesma para encontrar avaliações maiores que a classificação média.
Node.js
let results = await 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")
.execute();
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 as subconsultastoArrayExpression(), já que a materialização de um grande número de documentos pode esgotar o limite de memória da consulta (128 MiB). Para evitar isso, useselect(...)na subconsulta para retornar apenas os campos necessários e aplique filtroswhere(...)para limitar o número de documentos retornados. Considere usarlimit(...), 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 junções de alto desempenho dependem da capacidade de realizar buscas de índice em vez de verificações completas de tabelas.
Para mais práticas recomendadas de consulta, consulte nosso guia sobre otimização de consultas.
Limitações
- Escopo
subcollection(...):a etapa de entradasubcollection(...)só é compatível com subconsultas, já que exige o contexto de um documento principal para resolver a relação hierárquica e realizar a junção. - Profundidade de aninhamento:as subconsultas podem ser aninhadas em até 20 camadas.
- Uso de memória:o limite de 128 MiB em dados materializados se aplica a toda a consulta, incluindo todos os documentos unidos.