Realiza uniones con subconsultas

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 la desnormalización de datos o la realización de 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 complejos de recuperación de datos, 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 básicos para usar 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 arreglos: Materializan todo el conjunto de resultados de la subconsulta como un arreglo 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.
  • Subconsultas subcollection(...): 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 los campos del documento "externo" (el elemento superior). Para unir estos alcances, usa la let(...) etapa (denominada define(...) en algunos SDKs) para definir variables en el alcance superior que luego se pueden consultar 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 superior a una variable con nombre para usar en alcances anidados posteriores.

Subconsultas de arreglos

Una subconsulta de arreglos es un caso especial de subconsulta de expresión que materializa todo el conjunto de resultados de la subconsulta en un arreglo. Si la subconsulta muestra cero filas, se evalúa en un arreglo vacío. Nunca muestra un arreglo 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 mostrar para ayudar 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 arreglo final.

Usa el wrapper del SDK toArrayExpression() para convertir una consulta en un arreglo.

Subconsultas escalares

Las subconsultas escalares suelen usarse en una etapa select(...) o where(...), ya que permiten filtrar o mostrar 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 en 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 termina 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.

Subconsultas subcollection(...)

Si bien se ofrece como una etapa, la subcollection(...) etapa de entrada 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 datos de sus propias subcolecciones. Si bien puedes lograr esto con una collection_group(...) etapa de entrada seguida de un filtro en la referencia superior, subcollection(...) proporciona una sintaxis mucho más concisa.

Además de la condición de unión implícita, esto funciona de manera similar a una subconsulta de arreglos, que muestra un resultado vacío si no se encuentran documentos, incluso si la colección anidada no existe.

Es fundamentalmente azúcar sintáctico: usa automáticamente el __name__ de el 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 entre elementos superiores y secundarios.

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 = 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 todas las pizzerías del grupo de colecciones restaurants y usa una subconsulta de arreglos para recuperar y 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" }
  ]
}

Cómo agregar 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
}

Los N primeros 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 mejor calificadas de cada restaurante.

Esto garantiza que el arreglo de opiniones no crezca demasiado y alcance el límite de memoria de la 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")));

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" }
  ]
}

Cómo unir subcolecciones

La siguiente consulta analiza la colección cities y usa la subcollection(...) etapa para unirse de forma implícita a los documentos de una colección anidada para 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
}

Cómo expresar 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 opinan sobre 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"
}

Antiunión (NOT EXISTS)

La siguiente consulta analiza el grupo de colecciones restaurants y encuentra 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 duplica el documento del restaurante externo para cada opinión coincidente, lo que produce 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 en sí misma para encontrar opiniones superiores a la calificación 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 toArrayExpression() subconsultas, ya que materializar una gran cantidad de documentos puede agotar el límite de memoria de la consulta (128 MiB). Para mitigar esto, usa select(...) dentro de la subconsulta para mostrar solo los campos necesarios y aplica where(...) filtros para limitar la cantidad de documentos que se muestran. Considera usar limit(...) si es adecuado para limitar la cantidad de documentos que muestra la subconsulta.
  • Indexación: Asegúrate de que los campos que se usan en la where(...) cláusula de una subconsulta estén indexados. Las uniones de rendimiento se basan en la capacidad de realizar búsquedas de índice en lugar de análisis de tabla completos.

Para obtener más prácticas recomendadas de consultas, consulta nuestra guía sobre la optimización de consultas.

Limitaciones

  • subcollection(...) alcance: La subcollection(...) etapa de entrada solo se admite en 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 anidación: 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.