Eseguire join con sottoquery

Panoramica

La versione Firestore Enterprise supporta i join di tipo relazionale tramite subquery correlate. A differenza di molti database NoSQL che spesso richiedono la denormalizzazione dei dati o l'esecuzione di più richieste lato client, le subquery consentono di combinare e aggregare i dati di raccolte o sottoraccolte correlate direttamente sul server.

Le subquery sono espressioni che eseguono una pipeline nidificata per ogni documento elaborato dalla query esterna. Ciò consente pattern di recupero dei dati complessi, come il recupero di un documento insieme agli elementi della sottoraccolta correlata o l'unione di dati collegati logicamente tra raccolte principali diverse.

Concetti

Questa sezione introduce i concetti fondamentali alla base dell'utilizzo delle sottoquery per l'esecuzione di join nelle operazioni della pipeline.

Subquery come espressioni

Una sottoquery non è una fase di primo livello, ma un'espressione che può essere utilizzata in qualsiasi fase che accetta espressioni, ad esempio select(...), add_fields(...), where(...) o sort(...).

Cloud Firestore supporta tre tipi di subquery:

  • Subquery di array: materializzano l'intero set di risultati della subquery come un array di documenti.
  • Sottoquery scalari: restituiscono un singolo valore, ad esempio un conteggio, una media o un campo specifico di un documento correlato.
  • Subquery subcollection(...): join semplificati per una relazione principale-secondaria one-to-many.

Ambito e variabili

Quando si scrive un join, la sottoquery nidificata spesso deve fare riferimento ai campi del documento "esterno" (il documento principale). Per collegare questi ambiti, utilizza la fase let(...) (indicata come define(...) in alcuni SDK) per definire le variabili nell'ambito principale, a cui è possibile fare riferimento nella sottoquery utilizzando la funzione variable(...).

Sintassi

Le sezioni seguenti forniscono una panoramica della sintassi per l'esecuzione dei join.

La fase let(...)

La fase let(...) (denominata define(...) in alcuni SDK) è una fase di non filtro che trasferisce esplicitamente i dati dall'ambito principale a una variabile denominata da utilizzare negli ambiti nidificati successivi.

Subquery di array

Una subquery Array è un caso speciale di subquery di espressione che materializza l'intero set di risultati della subquery in un array. Se la sottoquery restituisce zero righe, restituisce un array vuoto. Non restituisce mai un array null. Queste query sono utili quando sono richiesti i risultati completi nel risultato finale, ad esempio quando si materializza una raccolta nidificata o correlata.

Le query possono filtrare, ordinare e aggregare nella sottoquery per ridurre anche la quantità di dati da recuperare e restituire, contribuendo a ridurre il costo della query. L'ordine della sottoquery viene rispettato, il che significa che una fase sort(...) nella sottoquery controlla l'ordine dei risultati nell'array finale.

Utilizza il wrapper dell'SDK toArrayExpression() per convertire una query in un array.

Subquery scalari

Le sottoquery scalari vengono spesso utilizzate in una fase select(...) o where(...) perché consentono di filtrare o restituire il risultato di una sottoquery senza materializzare direttamente la query completa.

Una sottoquery scalare che non produce risultati restituirà null, mentre una sottoquery che restituisce più elementi genererà un errore di runtime.

Quando una sottoquery scalare produce un solo campo per risultato, il campo viene elevato per diventare il risultato di primo livello della sottoquery. Questo accade più comunemente quando la sottoquery termina con un select(field("user_name")) o aggregate(countAll().as("total")) in cui lo schema della sottoquery è un singolo campo. In caso contrario, quando una sottoquery può produrre più campi, questi vengono aggregati in una mappa.

Utilizza il wrapper dell'SDK toScalarExpression() per convertire una query in un'espressione scalare.

subcollection(...) subquery

Sebbene sia offerta come fase, la fase di input subcollection(...) consente di eseguire join sul modello di dati gerarchico di Cloud Firestore. In un modello gerarchico, le query spesso devono recuperare un documento insieme ai dati delle sue sottoraccolte. Sebbene sia possibile ottenere questo risultato utilizzando una fase di input collection_group(...) seguita da un filtro sul riferimento principale, subcollection(...) fornisce una sintassi molto più concisa.

A parte la condizione di join implicita, questa operazione agisce in modo simile a una sottoquery di array, restituendo un risultato vuoto se non vengono trovati documenti corrispondenti, anche se la raccolta nidificata non esiste.

Si tratta fondamentalmente di syntactic sugar: utilizza automaticamente il campo __name__ del documento nell'ambito esterno come chiave di join per risolvere la relazione gerarchica. Questo lo rende il modo preferito per eseguire ricerche tra le raccolte collegate in una relazione di tipo principale-secondario.

Esempi

Dati di esempio

Il comando seguente carica un insieme di dati di test da utilizzare in tutti gli esempi successivi.

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" });

Cerca un documento in un'altra raccolta

La seguente query sul gruppo di raccolte reviews esegue una ricerca nel gruppo di raccolte restaurant utilizzando un riferimento alla chiave 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")));

Risposta

{
  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 più raccolte

La seguente query recupera tutte le pizzerie dal gruppo di raccolte restaurants e utilizza una sottoquery di array per recuperare e incorporare le recensioni associate direttamente nella risposta.

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")));

Risposta

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

Aggrega i dati di più raccolte

La seguente query sul gruppo di raccolte restaurants utilizza una sottoquery correlata per ottenere la valutazione media di ogni ristorante dal gruppo di raccolte 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")));

Risposta

{
  name: "Golden Gate Pizza",
  avg_rating: 4.5
},
{
  name: "Venice Pizza",
  avg_rating: 3.0
}

Top-N per gruppo (subquery con limite)

La seguente query recupera tutti i documenti dal gruppo di raccolte restaurants e utilizza una sottoquery correlata per recuperare le due recensioni con la valutazione più alta per ogni ristorante.

In questo modo, l'array di recensioni non diventa troppo grande e non raggiunge il limite di memoria della query.

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")));

Risposta

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

Unire le sottoraccolte

La seguente query analizza la raccolta cities e utilizza la fase subcollection(...) per unire in modo implicito i documenti di una raccolta nidificata per trovare il numero di ristoranti per città.

Node.js

let results = await execute(db.pipeline()
  .collection("cities")
  .addFields(subcollection("restaurants")
    .toArrayExpression()
    .length()
    .as("restaurant_count")));

Risposta

{
  __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
}

Esprimi più condizioni di join

La seguente query analizza il gruppo di raccolte restaurants ed esegue un join su più campi con il gruppo di raccolte reviews per trovare i proprietari che recensiscono i propri ristoranti.

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)));

Risposta

{
  __name__: cities/SF/restaurants/X9An0HIlx29A9GPuRthS,
  name: "Sunset Taco",
  type: "mexican",
  owner_id: "Edward"
}

Anti-join (NOT EXISTS)

La seguente query analizza il gruppo di raccolte restaurants e trova tutti i ristoranti che non hanno ancora recensioni.

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)));

Risposta

{
  __name__: "cities/LA/restaurants/X9An0HIlx29A9GPuRthS",
  name: "Hollywood Sushi",
  type: "sushi",
  owner_id: "Ken Kenji"
}

Subquery come join

La seguente query appiattisce la relazione tra ogni pizzeria e le relative recensioni. Inserendo la sottoquery all'interno di una fase unnest(...), il server duplica il documento del ristorante esterno per ogni recensione corrispondente, producendo documenti uniti e piatti (simili a un INNER JOIN 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")));

Risposta

{
  __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" }
}

Subquery non correlata come filtro

La seguente query sulla raccolta reviews esegue filtri utilizzando una sottoquery non correlata su se stessa per trovare recensioni superiori alla valutazione media.

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");

Risposta

{
  rating: 5,
  reviewer_id "Alice"
},
{
  rating: 5,
  reviewer_id "Diana"
},
{
  rating: 5,
  reviewer_id "Hannah"
},
{
  rating: 5,
  reviewer_id "Julia"
}

Best practice

  • Gestisci la memoria con toArrayExpression():fai attenzione alle subquery toArrayExpression(), poiché la materializzazione di un numero elevato di documenti può esaurire il limite di memoria della query (128 MiB). Per mitigare questo problema, utilizza select(...) all'interno della sottoquery per restituire solo i campi necessari e applica i filtri where(...) per limitare il numero di documenti restituiti. Valuta la possibilità di utilizzare limit(...), se opportuno, per limitare il numero di documenti restituiti dalla sottoquery.
  • Indicizzazione: assicurati che i campi utilizzati nella clausola where(...) di una sottoquery siano indicizzati. I join ad alte prestazioni si basano sulla capacità di eseguire ricerche nell'indice anziché scansioni complete della tabella.

Per altre best practice sulle query, consulta la nostra guida sull'ottimizzazione delle query.

Limitazioni

  • Ambito subcollection(...): la fase di input subcollection(...) è supportata solo all'interno delle subquery, in quanto richiede il contesto di un documento principale per risolvere la relazione gerarchica ed eseguire l'unione.
  • Profondità di nidificazione: le sottoquery possono essere nidificate fino a 20 livelli di profondità.
  • Memoria utilizzata: il limite di 128 MiB per i dati materializzati si applica all'intera query, inclusi tutti i documenti uniti.