ביצוע צירופים באמצעות שאילתות משנה

סקירה כללית

מהדורת Enterprise של Firestore תומכת בצירופים בסגנון יחסי באמצעות שאילתות משנה מתואמות. בניגוד למסדי נתונים רבים מסוג NoSQL, שבהם לרוב צריך לבצע דה-נורמליזציה של נתונים או לבצע כמה בקשות בצד הלקוח, שאילתות משנה מאפשרות לכם לשלב ולצבור נתונים מאוספים קשורים או מאוספי משנה קשורים ישירות בשרת.

שאילתות משנה הן ביטויים שמריצים צינור עיבוד נתונים מוטמע לכל מסמך שעובר עיבוד על ידי השאילתה החיצונית. כך אפשר לאחזר דפוסי נתונים מורכבים, כמו אחזור מסמך לצד פריטים קשורים של אוסף משנה, או צירוף נתונים שמקושרים באופן לוגי בין אוספי שורש שונים.

מושגים

בקטע הזה נסביר את המושגים הבסיסיים שקשורים לשימוש בשאילתות משנה כדי לבצע צירופים בפעולות של צינורות.

תת-שאילתות כביטויים

שאילתת משנה היא לא שלב ברמה העליונה, אלא ביטוי שאפשר להשתמש בו בכל שלב שמקבל ביטויים, כמו select(...),‏ add_fields(...),‏ where(...) או sort(...).

‫Cloud Firestore תומך בשלושה סוגים של שאילתות משנה:

  • שאילתות משנה של מערכים: המערכת יוצרת את כל קבוצת התוצאות של שאילתת המשנה כמערך של מסמכים.
  • שאילתות משנה סקלריות: מחזירות ערך יחיד, כמו ספירה, ממוצע או שדה ספציפי ממסמך קשור.
  • subcollection(...) שאילתות משנה: הצטרפות פשוטה ליחס הורה-ילד מסוג אחד לרבים.

היקף ומשתנים

כשכותבים שאילתת צירוף, לעיתים קרובות צריך להפנות בשאילתת המשנה המקוננת לשדות מהמסמך 'החיצוני' (המסמך הראשי). כדי לגשר בין ההיקפים האלה, משתמשים בשלב let(...) (שנקרא define(...) בחלק מה-SDK) כדי להגדיר משתנים בהיקף האב שאפשר להפנות אליהם אחר כך בשאילתת המשנה באמצעות הפונקציה variable(...).

תחביר

בקטעים הבאים מופיע סקירה כללית של התחביר לביצוע צירופים.

שלב let(...)

השלב let(...) (שנקרא define(...) בחלק מה-SDK) הוא שלב ללא סינון, שבו הנתונים מההיקף ההורה מועברים באופן מפורש למשתנה בעל שם לשימוש בהיקפים מקוננים עוקבים.

שאילתות משנה של מערכים

שאילתת משנה של מערך היא מקרה מיוחד של שאילתת משנה של ביטוי, שיוצרת את כל קבוצת התוצאות של שאילתת המשנה כמערך. אם שאילתת המשנה מחזירה אפס שורות, היא מחזירה מערך ריק. היא אף פעם לא מחזירה מערך null. שאילתות כאלה שימושיות כשנדרשות התוצאות המלאות בתוצאה הסופית, למשל כשמממשים אוסף מקונן או מתואם.

שאילתות יכולות לסנן, למיין ולצבור נתונים בשאילתת המשנה כדי להפחית גם את כמות הנתונים שצריך לאחזר ולהחזיר, וכך להקטין את העלות של השאילתה. הסדר של שאילתת המשנה נשמר, כלומר שלב sort(...) בשאילתת המשנה קובע את סדר התוצאות במערך הסופי.

משתמשים ב-wrapper של toArrayExpression() SDK כדי להמיר שאילתה למערך.

שאילתות משנה סקלריות

לעתים קרובות משתמשים בשאילתות משנה סקלריות בשלב select(...) או where(...) כדי לאפשר סינון או כדי להציג את התוצאה של שאילתת משנה בלי להציג ישירות את השאילתה המלאה.

שאילתת משנה סקלרית שמניבה אפס תוצאות תניב את הערך null עצמו, ואילו שאילתת משנה שמניבה כמה רכיבים תגרום לשגיאת זמן ריצה.

כששאילתת משנה סקלרית מפיקה רק שדה אחד לכל תוצאה, השדה מועלה להיות התוצאה ברמה העליונה של שאילתת המשנה. המצב הזה קורה בדרך כלל כשהשאילתה המשנית מסתיימת ב-select(field("user_name")) או ב-aggregate(countAll().as("total")), והסכימה של השאילתה המשנית היא רק שדה אחד. אחרת, כששאילתת משנה יכולה להפיק כמה שדות, הם נכללים במפה.

משתמשים ב-wrapper של toScalarExpression() SDK כדי להמיר שאילתה לביטוי סקלרי.

subcollection(...) שאילתות משנה

למרות ששלב הקלט subcollection(...) מוצע כשלב, הוא מאפשר לבצע הצטרפויות במודל הנתונים ההיררכי של Cloud Firestore. במודל היררכי, לעיתים קרובות השאילתות צריכות לאחזר מסמך לצד נתונים מקולקציות המשנה שלו. אפשר להשיג את זה באמצעות שלב קלט של collection_group(...) ואחריו מסנן בהפניה לאב, אבל subcollection(...) מספק תחביר תמציתי הרבה יותר.

מלבד תנאי הצירוף המרומז, הפעולה הזו דומה לשאילתת משנה של מערך, ומחזירה תוצאה ריקה אם לא נמצאו מסמכים תואמים, גם אם האוסף המקונן לא קיים.

היא בעצם קיצור דרך תחבירי: היא משתמשת אוטומטית ב-__name__ של המסמך בהיקף החיצוני כמפתח לצירוף כדי לפתור את היחס ההיררכי. לכן זו הדרך המומלצת לבצע חיפושים באוספים שמקושרים בקשר הורה-צאצא.

דוגמאות

נתונים לדוגמה

הקוד הבא טוען קבוצה של נתוני בדיקה לשימוש בכל הדוגמאות הבאות.

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

חיפוש מסמך באוסף אחר

השאילתה הבאה בקבוצת האוספים reviews מבצעת חיפוש בקבוצת האוספים restaurant באמצעות הפניה למפתח ראשי.

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

תשובה

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

שילוב של כמה אוספים

השאילתה הבאה מאחזרת את כל המקומות שמוכרים פיצה מקבוצת האוסף restaurants, ומשתמשת בשאילתת משנה של מערך כדי לאחזר את הביקורות שקשורות אליהם ולהטמיע אותן ישירות בתשובה.

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

תשובה

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

צבירה של נתונים מכמה אוספים

השאילתה הבאה בקבוצת הפריטים restaurants משתמשת בשאילתת משנה מתואמת כדי לקבל את הדירוג הממוצע של כל מסעדה מקבוצת הפריטים 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")));

תשובה

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

‫Top-N Per Group (שאילתת משנה עם Limit)

השאילתה הבאה מאחזרת את כל המסמכים מהקולקציה restaurantsgroup, ומשתמשת בשאילתת משנה מתואמת כדי לאחזר את 2 הביקורות עם הדירוג הכי גבוה לכל מסעדה.

כך מוודאים שמערך הביקורות לא גדל יותר מדי ולא חורג ממגבלת הזיכרון של השאילתה.

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

תשובה

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

הצטרפות לאוספי משנה

השאילתה הבאה סורקת את האוסף cities ומשתמשת בשלב subcollection(...) כדי לבצע איחוד (join) באופן מרומז בין מסמכים מאוסף מקונן, כדי למצוא את מספר המסעדות בכל עיר.

Node.js

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

תשובה

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

הגדרת כמה תנאי הצטרפות

השאילתה הבאה סורקת את קבוצת האוספים restaurants ומבצעת צירוף של כמה שדות עם קבוצת האוספים reviews כדי למצוא בעלים שבודקים את המסעדות שלהם.

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

תשובה

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

Anti-Join (NOT EXISTS)

השאילתה הבאה סורקת את קבוצת האוסף restaurants ומוצאת את כל המסעדות שעדיין לא קיבלו ביקורות.

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

תשובה

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

שאילתת משנה כצירוף

השאילתה הבאה משטחת את הקשר בין כל פיצרייה לבין הביקורות שלה. אם מציבים את שאילתת המשנה בשלב unnest(...), השרת משכפל את מסמך המסעדה החיצוני לכל ביקורת תואמת, ומפיק מסמכים שטוחים ומצורפים (בדומה ל-SQL INNER JOIN).

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

תשובה

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

שאילתת משנה לא קשורה כמסנן

השאילתה הבאה באוסף reviews מבצעת סינון באמצעות שאילתת משנה לא קשורה בעצמה, כדי למצוא ביקורות עם דירוג גבוה מהממוצע.

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

תשובה

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

שיטות מומלצות

  • ניהול הזיכרון באמצעות toArrayExpression(): צריך להיזהר עם שאילתות משנה של toArrayExpression(), כי יצירת מספר גדול של מסמכים עלולה לגרום לחריגה ממגבלת הזיכרון של השאילתה (128MiB). כדי לצמצם את הבעיה, צריך להשתמש ב-select(...) בשאילתת המשנה כדי להחזיר רק את השדות הנדרשים, ולהחיל מסנני where(...) כדי להגביל את מספר המסמכים שמוחזרים. כדאי להשתמש ב-limit(...) אם מתאים להגביל את מספר המסמכים שמוחזרים על ידי שאילתת המשנה.
  • יצירת אינדקס: מוודאים שהשדות שמשמשים בפסוקית where(...) של שאילתת משנה נמצאים באינדקס. הצטרפות יעילה מסתמכת על היכולת לבצע חיפושי אינדקס במקום סריקות מלאות של טבלאות.

טיפים נוספים לשיפור השאילתות מפורטים במדריך לאופטימיזציה של שאילתות.

מגבלות

  • היקף subcollection(...): שלב הקלט subcollection(...) נתמך רק בשאילתות משנה, כי הוא דורש את ההקשר של מסמך הורה כדי לפתור את היחס ההיררכי ולבצע את הצירוף.
  • עומק הקינון: אפשר לקנן שאילתות משנה עד 20 שכבות עומק.
  • שימוש בזיכרון: המגבלה של 128MB על נתונים מגובשים חלה על כל השאילתה, כולל כל המסמכים המצורפים.