Eseguire join con sottoquery

Panoramica

La versione Enterprise di Firestore supporta i join in stile relazionale tramite sottoquery 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 da 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, ad esempio il recupero di un documento insieme agli elementi della sottoraccolta correlata o l'unione di dati collegati logicamente tra raccolte root disparate.

Concetti

Questa sezione introduce i concetti di base relativi all'utilizzo delle sottoquery per eseguire i 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:

  • Sottoquery array: materializzano l'intero set di risultati della sottoquery 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.
  • Sottoquery subcollection(...): join semplificati per una relazione padre-figlio uno-a-molti.

Ambito e variabili

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

La fase let(...)

La fase let(...) (denominata define(...) in alcuni SDK) è una fase di non filtraggio che porta esplicitamente i dati dall'ambito principale a una variabile denominata per l'utilizzo negli ambiti nidificati successivi.

Sottoquery array

Una sottoquery array è un caso speciale di sottoquery di espressione che materializza l'intero set 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 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 SDK toArrayExpression() per convertire una query in un array.

Sottoquery scalari

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

Una sottoquery scalare che produce zero risultati verrà valutata come 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 essere il risultato di primo livello della sottoquery. Questo si verifica 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 racchiusi in una mappa.

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

Sottoquery subcollection(...)

Sebbene offerta come fase, la subcollection(...) fase di input 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. Sebbene sia possibile ottenere questo risultato utilizzando una collection_group(...) fase di input seguita da un filtro sul riferimento principale, subcollection(...) fornisce una sintassi molto più concisa.

Oltre alla condizione di join implicita, questa fase si comporta in modo simile a una sottoquery 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__ di documento nell'ambito esterno come chiave di join per risolvere la relazione gerarchica. Questo è il modo preferito per eseguire ricerche nelle raccolte collegate in una relazione padre-figlio.

Esempi

Dati di esempio

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

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

Ricerca di 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" }
}

Combinazione di più raccolte

La seguente query recupera tutti i locali che vendono pizza dal gruppo di raccolte restaurants e utilizza una sottoquery 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" }
  ]
}

Aggregazione su 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 (sottoquery 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" }
  ]
}

Join delle sottoraccolte

La seguente query analizza la raccolta cities e utilizza la fase subcollection(...) per eseguire implicitamente il join dei 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
}

Espressione di più condizioni di join

La seguente query analizza il gruppo di raccolte restaurants ed esegue un join multi-campo 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"
}

Sottoquery come join

La seguente query appiattisce la relazione tra ogni locale che vende pizza 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" }
}

Sottoquery non correlata come filtro

La seguente query sulla raccolta reviews esegue filtri utilizzando una sottoquery non correlata su se stessa per trovare le 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

  • Gestione della memoria con toArrayExpression(): fai attenzione alle toArrayExpression() sottoquery, poiché la materializzazione di un numero elevato di documenti può esaurire il limite di memoria della query (128 MiB). Per risolvere 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. Se appropriato, valuta la possibilità di utilizzare limit(...) 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 indici anziché scansioni complete delle tabelle.

Per ulteriori best practice relative alle query, consulta la nostra guida sull'ottimizzazione delle query.

Limitazioni

  • subcollection(...) ambito: 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 di profondità.
  • Utilizzo della memoria: il limite di 128 MiB per i dati materializzati si applica all'intera query, inclusi tutti i documenti uniti.