סקירה כללית
מהדורת Enterprise של Firestore תומכת בצירופים בסגנון יחסי באמצעות שאילתות משנה מתואמות. בניגוד למסדי נתונים רבים מסוג NoSQL, שלרוב נדרש בהם ביטול הנרמול של הנתונים או ביצוע של כמה בקשות בצד הלקוח, שאילתות משנה מאפשרות לכם לשלב ולצבור נתונים מאוספים קשורים או מאוספי משנה קשורים ישירות בשרת.
שאילתות משנה הן ביטויים שמריצים צינור עיבוד נתונים מוטמע לכל מסמך שעובר עיבוד על ידי השאילתה החיצונית. כך אפשר לאחזר נתונים מורכבים, כמו אחזור מסמך לצד פריטים קשורים בקולקציית משנה או צירוף נתונים שמקושרים באופן לוגי בין קולקציות שורש שונות.
מושגים
בקטע הזה נסביר את המושגים הבסיסיים שקשורים לשימוש בשאילתות משנה כדי לבצע צירופים בפעולות של צינורות.
שאילתות משנה כביטויים
שאילתת משנה היא לא שלב ברמה העליונה, אלא ביטוי שאפשר להשתמש בו בכל שלב שמקבל ביטויים, כמו select(...), add_fields(...), where(...) או sort(...).
Cloud Firestore תומך בשלושה סוגים של שאילתות משנה:
- Array Subqueries: Materialize the entire result set of the subquery as an array of documents.
- Scalar Subqueries: Evaluate to a single value, such as a count, an average, or a specific field from a related document.
subcollection(...)שאילתות משנה: הצטרפות פשוטה לקשר של הורה-צאצא מסוג אחד לרבים.
היקף ומשתנים
כשכותבים שאילתת צירוף, לעיתים קרובות צריך להפנות בשאילתת המשנה המקוננת לשדות מהמסמך 'החיצוני' (המסמך הראשי). כדי לגשר בין ההיקפים האלה, משתמשים בשלב let(...) (שנקרא define(...) בחלק מה-SDK) כדי להגדיר משתנים בהיקף האב שאפשר להפנות אליהם בשאילתת המשנה באמצעות הפונקציה variable(...).
תחביר
בקטעים הבאים מוסבר על התחביר של ביצוע צירופים.
השלב של let(...)
השלב let(...) (שנקרא define(...) בחלק מה-SDK) הוא שלב ללא סינון, שמעביר באופן מפורש נתונים מההיקף של רכיב ההורה למשתנה בעל שם, לשימוש בהיקפים מקוננים עוקבים.
Array Subqueries
שאילתת משנה של מערך היא מקרה מיוחד של שאילתת משנה של ביטוי, שיוצרת את כל קבוצת התוצאות של שאילתת המשנה כמערך. אם שאילתת המשנה מחזירה אפס שורות, היא מוערכת כמערך ריק. היא אף פעם לא מחזירה מערך 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 (Subquery with Limit)
השאילתה הבאה מאחזרת את כל המסמכים מקבוצת האוסף restaurants, ומשתמשת בשאילתת משנה מתואמת כדי לאחזר את 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(...) כדי לבצע הצטרפות מרומזת למסמכים מאוסף מקונן, כדי למצוא את מספר המסעדות בכל עיר.
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(...), השרת משכפל את מסמך המסעדה החיצוני לכל ביקורת תואמת, ויוצר מסמכים שטוחים ומצורפים (בדומה ל-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")));
תשובה
{
__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(), כי יצירת מספר גדול של מסמכים עלולה לגרום לחריגה ממגבלת הזיכרון של השאילתה (128 MiB). כדי לצמצם את הבעיה, משתמשים ב-select(...)בשאילתת המשנה כדי להחזיר רק את השדות הנדרשים, ומחילים מסננים שלwhere(...)כדי להגביל את מספר המסמכים שמוחזרים. כדאי להשתמש ב-limit(...)אם זה מתאים כדי להגביל את מספר המסמכים שמוחזרים על ידי שאילתת המשנה. - יצירת אינדקס: מוודאים שהשדות שמשמשים בסעיף
where(...)של שאילתת משנה מתווספים לאינדקס. הצטרפות יעילה מסתמכת על היכולת לבצע חיפושי אינדקס ולא סריקות מלאות של טבלאות.
לשיטות מומלצות נוספות בנושא שאילתות, אפשר לעיין במדריך שלנו לאופטימיזציה של שאילתות.
מגבלות
- היקף
subcollection(...): שלב הקלטsubcollection(...)נתמך רק בשאילתות משנה, כי הוא דורש את ההקשר של מסמך הורה כדי לפתור את היחס ההיררכי ולבצע את הצירוף. - עומק הקינון: אפשר לקנן שאילתות משנה עד 20 שכבות.
- שימוש בזיכרון: המגבלה של 128MiB על נתונים מגובשים חלה על כל השאילתה, כולל כל המסמכים המצורפים.