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 a menudo requieren 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 que procesa la consulta externa. Esto permite patrones complejos de recuperación de datos, como recuperar un documento junto con los elementos de su subcolección relacionada o unir datos vinculados lógicamente en colecciones raíz dispares.
Conceptos
En esta sección, se presentan los conceptos básicos para usar subconsultas y realizar uniones en las operaciones de canalización.
Subconsultas como expresiones
Una subconsulta no es una etapa de nivel superior, sino 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 array: Materializan todo el conjunto de resultados de la subconsulta como un array de documentos.
- Subconsultas escalares: Se evalúan en un solo valor, como un recuento, un promedio o un campo específico de un documento relacionado.
subcollection(...)Subconsultas: Uniones simplificadas para una relación principal-secundaria de uno a varios.
Alcance y variables
Cuando se escribe una unión, la subconsulta anidada a menudo necesita hacer referencia a campos del documento "externo" (el elemento superior). Para conectar estos alcances, usa la etapa let(...) (a la que se hace referencia como define(...) en algunos SDKs) para definir variables en el alcance principal a las que se puede hacer referencia 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 SDKs) es una etapa sin filtrado que incorpora de forma explícita datos del alcance principal a una variable con nombre para su uso en alcances anidados posteriores.
Subconsultas de arreglos
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 datos 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 suelen usarse en una etapa select(...) o where(...) como filtro de permiso o para generar 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 solo un 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 finaliza con un select(field("user_name")) o aggregate(countAll().as("total")) en el que el esquema de la subconsulta es solo un campo. De lo contrario, cuando una subconsulta puede producir varios campos, se incluyen en un mapa.
Usa el wrapper del SDK toScalarExpression() para convertir una consulta en una expresión escalar.
subcollection(...) Subconsultas
Si bien se ofrece como una etapa, la etapa de entrada subcollection(...) permite realizar uniones en el modelo de datos jerárquicos de Cloud Firestore. En un modelo jerárquico, las consultas a menudo necesitan 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 principal, subcollection(...) proporciona una sintaxis mucho más concisa.
Además de la condición de unión implícita, esta función actúa de manera similar a una subconsulta de array, ya que devuelve un resultado vacío si no se encuentran documentos coincidentes, incluso si no existe la colección anidada.
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. Por lo tanto, es la forma preferida de realizar búsquedas en colecciones vinculadas en una relación principal-secundaria.
Ejemplos
Datos de ejemplo
El siguiente código carga un conjunto de datos de prueba para usar en todos los ejemplos posteriores.
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" });
Cómo buscar 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 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")));
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" }
}
Cómo combinar varias colecciones
La siguiente consulta recupera todos los lugares de pizza del grupo de recopilación restaurants y usa una subconsulta de array para recuperar y, luego, incorporar sus opiniones asociadas directamente en la respuesta.
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")));
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 en 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 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")));
Respuesta
{
name: "Golden Gate Pizza",
avg_rating: 4.5
},
{
name: "Venice Pizza",
avg_rating: 3.0
}
Top-N 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 búsqueda.
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")));
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" }
]
}
Unir subcolecciones
La siguiente consulta analiza la colección cities y usa la etapa subcollection(...) para unir de forma implícita los documentos de una colección anidada y encontrar la cantidad de restaurantes por ciudad.
Node.js
let results = await execute(db.pipeline()
.collection("cities")
.addFields(subcollection("restaurants")
.toArrayExpression()
.length()
.as("restaurant_count")));
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 colección restaurants y realiza una unión de varios campos con el grupo de colección reviews para encontrar a los propietarios que revisan sus propios 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)));
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 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)));
Respuesta
{
__name__: "cities/LA/restaurants/X9An0HIlx29A9GPuRthS",
name: "Hollywood Sushi",
type: "sushi",
owner_id: "Ken Kenji"
}
Subconsulta como unión
La siguiente consulta aplana la relación entre cada pizzería y sus opiniones. Si colocas la subconsulta dentro de una etapa unnest(...), el servidor duplicará el documento externo del restaurante para cada revisión coincidente, lo que producirá documentos unidos y planos (similares a un INNER JOIN de 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")));
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 sobre sí misma para encontrar opiniones con una calificación superior a la promedio.
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");
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 filtros dewhere(...)para limitar la cantidad de documentos que se devuelven. Considera usarlimit(...)si es adecuado 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 eficientes se basan en la capacidad de realizar búsquedas de índices en lugar de análisis completos de la tabla.
Para conocer más prácticas recomendadas sobre las consultas, consulta nuestra guía sobre la optimización de consultas.
Limitaciones
- Alcance de
subcollection(...): La etapa de entradasubcollection(...)solo se admite dentro de las subconsultas, ya que requiere el contexto de un documento principal para resolver la relación jerárquica y realizar la unión. - Profundidad de anidamiento: Las subconsultas se pueden anidar hasta 20 niveles 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.