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 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 subconsultas toArrayExpression(), ya que materializar una gran cantidad de documentos puede agotar el límite de memoria de la consulta (128 MiB). Para mitigar este problema, usa select(...) dentro de la subconsulta para devolver solo los campos necesarios y aplica filtros where(...) para limitar la cantidad de documentos que se devuelven. Considera usar limit(...) 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 entrada subcollection(...) 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.