Descripción general
La edición Enterprise de Firestore admite uniones de estilo relacional a través de subconsultas correlacionadas. A diferencia de muchas bases de datos NoSQL que suelen requerir desnormalizar datos o realizar varias solicitudes del cliente, las subconsultas te permiten combinar y agregar datos de colecciones o subcolecciones relacionadas directamente en el servidor.
Las subconsultas son expresiones que ejecutan una canalización anidada para cada documento procesado por la consulta externa. Esto permite patrones de recuperación de datos complejos, como recuperar un documento junto con sus elementos de subcolección relacionados o unir datos vinculados de forma lógica en colecciones raíz dispares.
Conceptos
En esta sección, se presentan los conceptos principales detrás del uso de subconsultas para realizar uniones en operaciones de canalización.
Subconsultas como expresiones
Una subconsulta no es una etapa de nivel superior; en cambio, es una expresión que
se puede usar en cualquier etapa que acepte expresiones, como
select(...),
add_fields(...),
where(...) o sort(...).
Cloud Firestore admite tres tipos de subconsultas:
- Subconsultas de arrays: Materializan todo el conjunto de resultados de la subconsulta como un array de documentos.
- Subconsultas escalares: Se evalúan como un solo valor, como un recuento, un promedio o un campo específico de un documento relacionado.
- Subconsultas de
subcollection(...): Son uniones simplificadas para una relación de uno a varios entre elementos superiores y secundarios.
Alcance y variables
Cuando se escribe una unión, la subconsulta anidada suele necesitar hacer referencia a campos
del documento "externo" (el superior). Para conectar estos alcances, usa la etapa
let(...) (denominada define(...) en algunos
SDK) para definir variables en el alcance superior que, luego, se pueden referenciar en la
subconsulta con la función variable(...).
Sintaxis
En las siguientes secciones, se proporciona una descripción general de la sintaxis para realizar uniones.
La etapa let(...)
La etapa let(...) (denominada define(...) en algunos SDK) es una etapa sin filtrado que lleva de forma explícita los datos del alcance superior a una variable con nombre para usarla en alcances anidados posteriores.
Subconsultas de arrays
Una subconsulta de ARRAY es un caso especial de subconsulta de expresión que materializa todo el conjunto de resultados de la subconsulta en un array. Si la subconsulta devuelve cero
filas, se evalúa como un array vacío. Nunca devuelve un array null. Estas
consultas son útiles cuando se requieren los resultados completos en el resultado final, como
cuando se materializa una colección anidada o correlacionada.
Las consultas pueden filtrar, ordenar y agregar en la subconsulta para reducir también la cantidad
de datos que se deben recuperar y devolver, lo que ayuda a reducir el costo de la
consulta. Se respeta el orden de la subconsulta, lo que significa que una etapa sort(...)
en la subconsulta controla el orden de los resultados en el array final.
Usa el wrapper del SDK de toArrayExpression() para convertir una consulta en un array.
Subconsultas escalares
Las subconsultas escalares se suelen usar en una etapa select(...) o
where(...), ya que permiten filtrar o producir el
resultado de una subconsulta sin materializar la consulta completa directamente.
Una subconsulta escalar que produce cero resultados se evaluará como null,
mientras que una subconsulta que se evalúa como varios elementos generará un error de tiempo de ejecución.
Cuando una subconsulta escalar produce un solo campo por resultado, el campo se
eleva para ser el resultado de nivel superior de la subconsulta. Esto se observa con mayor frecuencia cuando la subconsulta termina con un select(field("user_name")) o aggregate(countAll().as("total")) en el que el esquema de la subconsulta es solo un campo único. De lo contrario, cuando una subconsulta puede producir varios campos, estos se
unifican en un mapa.
Usa el wrapper del SDK de toScalarExpression() para convertir una consulta en una expresión
escalar.
Subconsultas de subcollection(...)
Si bien se ofrece como una etapa, la etapa de entrada subcollection(...) permite realizar uniones en el modelo de datos jerárquico de Cloud Firestore. En un
modelo jerárquico, las consultas suelen necesitar recuperar un documento junto con los datos
de sus propias subcolecciones. Si bien puedes lograr esto con una etapa de entrada collection_group(...) seguida de un filtro en la referencia superior, subcollection(...) proporciona una sintaxis mucho más concisa.
Aparte de la condición de unión implícita, esto actúa de manera similar a una subconsulta de array, ya que devuelve un resultado vacío si no hay documentos que coincidan, incluso si la colección anidada no existe.
Es, fundamentalmente, azúcar sintáctico: usa automáticamente el __name__ del
documento en el alcance externo como la clave de unión para resolver la relación
jerárquica. Esto la convierte en la forma preferida de realizar búsquedas en colecciones vinculadas en una relación superior-secundario.
Ejemplos
Datos de ejemplo
A continuación, se carga un conjunto de datos de prueba para usar en todos los ejemplos siguientes.
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" });
Busca un documento en otra colección
La siguiente consulta en el grupo de colecciones reviews realiza una búsqueda en
el grupo de colecciones restaurant con una referencia de clave primaria.
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();
Respuesta
{
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" }
}
Combina varias colecciones
La siguiente consulta recupera todas las pizzerías del grupo de colecciones restaurants
y usa una subconsulta de array para recuperar e incorporar las opiniones asociadas
directamente en la respuesta.
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();
Respuesta
{
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" }
]
}
Agrega datos de varias colecciones
La siguiente consulta en el grupo de colecciones restaurants usa una subconsulta correlacionada
para obtener la calificación promedio de cada restaurante del grupo de colecciones
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();
Respuesta
{
name: "Golden Gate Pizza",
avg_rating: 4.5
},
{
name: "Venice Pizza",
avg_rating: 3.0
}
Los N primeros resultados por grupo (subconsulta con límite)
La siguiente consulta recupera todos los documentos del grupo de colecciones
restaurants y usa una subconsulta correlacionada para recuperar las 2 opiniones con la calificación más alta
de cada restaurante.
Esto garantiza que el array de opiniones no crezca demasiado y alcance el límite de memoria de la 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();
Respuesta
{
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" }
]
}
Une subcolecciones
La siguiente consulta analiza la colección cities y usa la
etapa subcollection(...) para unirse de forma implícita
a los documentos de una colección anidada y encontrar la cantidad de restaurantes por
ciudad.
Node.js
let results = await db.pipeline()
.collection("cities")
.addFields(subcollection("restaurants")
.toArrayExpression()
.length()
.as("restaurant_count"))
.execute();
Respuesta
{
__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
}
Expresa varias condiciones de unión
La siguiente consulta analiza el grupo de colecciones restaurants y realiza una
unión de varios campos con el grupo de colecciones reviews para encontrar propietarios que opinen
sobre sus propios 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();
Respuesta
{
__name__: cities/SF/restaurants/X9An0HIlx29A9GPuRthS,
name: "Sunset Taco",
type: "mexican",
owner_id: "Edward"
}
Anti-Join (NOT EXISTS)
La siguiente consulta analiza el grupo de colecciones restaurants y busca todos
los restaurantes que aún no tienen opiniones.
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();
Respuesta
{
__name__: "cities/LA/restaurants/X9An0HIlx29A9GPuRthS",
name: "Hollywood Sushi",
type: "sushi",
owner_id: "Ken Kenji"
}
Subconsulta como unión
La siguiente consulta reduce la relación entre cada pizzería y sus
opiniones. Cuando se coloca la subconsulta dentro de una etapa
unnest(...), el servidor duplica el documento externo
del restaurante para cada opinión coincidente, lo que produce documentos planos y unidos
(similares a un INNER JOIN de 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();
Respuesta
{
__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 no correlacionada como filtro
La siguiente consulta en la colección reviews realiza filtros con una
subconsulta no correlacionada en sí misma para encontrar opiniones superiores a la calificación promedio.
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();
Respuesta
{
rating: 5,
reviewer_id "Alice"
},
{
rating: 5,
reviewer_id "Diana"
},
{
rating: 5,
reviewer_id "Hannah"
},
{
rating: 5,
reviewer_id "Julia"
}
Prácticas recomendadas
- Administra la memoria con
toArrayExpression(): Ten cuidado con las subconsultastoArrayExpression(), ya que materializar una gran cantidad de documentos puede agotar el límite de memoria de la consulta (128 MiB). Para mitigar este problema, usaselect(...)dentro de la subconsulta para devolver solo los campos necesarios y aplica filtroswhere(...)para limitar la cantidad de documentos que se devuelven. Considera usarlimit(...)si corresponde para limitar la cantidad de documentos que devuelve la subconsulta. - Indexación: Asegúrate de que los campos que se usan en la cláusula
where(...)de una subconsulta estén indexados. Las uniones de alto rendimiento dependen de la capacidad de realizar búsquedas de índices en lugar de análisis completos de tablas.
Para obtener más prácticas recomendadas sobre las consultas, consulta nuestra guía sobre la optimización de consultas.
Limitaciones
- Permiso
subcollection(...): La etapa de entradasubcollection(...)solo se admite dentro de las subconsultas, ya que requiere el contexto de un documento superior para resolver la relación jerárquica y realizar la unión. - Profundidad de anidamiento: Las subconsultas se pueden anidar hasta 20 capas de profundidad.
- Uso de memoria: El límite de 128 MiB en los datos materializados se aplica a toda la consulta, incluidos todos los documentos unidos.