Eseguire join con sottoquery

Panoramica

La versione Enterprise di Firestore supporta i join in stile 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 sottoquery consentono di combinare e aggregare i dati di raccolte o sottoraccolte correlate direttamente sul server.

Le sottoquery 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 in raccolte radice disparate.

Concetti

Questa sezione introduce i concetti di base per l'utilizzo delle sottoquery per eseguire join nelle operazioni della pipeline.

Sottoquery 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 sottoquery:

  • Subquery di array:materializza l'intero insieme di risultati della subquery come array di documenti.
  • Subquery scalari:restituiscono un singolo valore, ad esempio un conteggio, una media o un campo specifico di un documento correlato.
  • subcollection(...) Subquery: join semplificati per una relazione principale-secondaria uno-a-molti.

Ambito e variabili

Quando scrivi un'unione, la sottoquery nidificata spesso deve fare riferimento ai campi del documento "esterno" (il documento principale). Per collegare questi ambiti, utilizza la fase let(...) (denominata 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 eseguire i join.

La fase let(...)

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

Sottoquery di array

Una sottoquery di array è un caso speciale di sottoquery di espressione che materializza l'intero insieme di risultati della sottoquery in un array. Se la sottoquery restituisce zero righe, viene valutata come un array vuoto. Non restituisce mai un array null. Queste query sono utili quando i risultati completi sono necessari 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 che devono essere recuperati e restituiti per contribuire 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.

Sottoquery scalari

Le sottoquery scalari vengono spesso utilizzate in una fase select(...) o where(...) per consentire il filtraggio o per ottenere il risultato di una sottoquery senza materializzare direttamente l'intera query.

Una sottoquery scalare che produce zero risultati verrà valutata come null stessa, 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 si verifica più comunemente quando la sottoquery termina con select(field("user_name")) o aggregate(countAll().as("total")), dove lo schema della sottoquery è un singolo campo. Altrimenti, quando una sottoquery può produrre più campi, questi vengono racchiusi in una mappa.

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

subcollection(...) Sottoquery

Sebbene 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 relative sottoraccolte. Anche se puoi 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 query secondaria si comporta in modo simile a una query secondaria di array, restituendo un risultato vuoto se non vengono trovati documenti corrispondenti, anche se la raccolta nidificata non esiste.

Si tratta fondamentalmente di zucchero sintattico: utilizza automaticamente __name__ del documento nell'ambito esterno come chiave di join per risolvere la relazione gerarchica. Ciò la rende il modo preferito per eseguire ricerche nelle raccolte collegate in una relazione principale-secondario.

Esempi

Dati di esempio

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

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

Combinare più raccolte

La seguente query recupera tutte le pizzerie dal gruppo di raccolta 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" }
  ]
}

Aggregare 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 (query secondaria 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 supera 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 implicitamente 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
}

Esprimere più condizioni di unione

La seguente query analizza il gruppo di raccolta restaurants ed esegue un'unione multicampo con il gruppo di raccolta 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 esegue la scansione del 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 as Join

La seguente query appiattisce la relazione tra ogni pizzeria e le relative recensioni. Se inserisci 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 subquery non correlata su se stessa per trovare recensioni con una valutazione superiore alla 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 query secondarie toArrayExpression(), poiché la materializzazione di un numero elevato di documenti può esaurire il limite di memoria della query (128 MiB). Per risolvere il problema, utilizza select(...) all'interno della sottoquery per restituire solo i campi necessari e applica filtri where(...) per limitare il numero di documenti restituiti. Valuta la possibilità di utilizzare limit(...), se appropriato, 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 efficienti si basano sulla possibilità di eseguire ricerche di indice anziché scansioni complete delle tabelle.

Per ulteriori best practice per le query, consulta la nostra guida sull'ottimizzazione delle query.

Limitazioni

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