סקירה כללית
מהדורת Firestore Enterprise תומכת בצירופים בסגנון יחסי באמצעות שאילתות משנה מקושרות. בניגוד להרבה מסדי נתונים מסוג NoSQL, שבהם בדרך כלל צריך לבטל את הנורמליזציה של הנתונים או לבצע כמה בקשות בצד הלקוח, שאילתות משנה מאפשרות לשלב ולצבור נתונים מאוספים או מאוספי משנה קשורים ישירות בשרת.
שאילתות משנה הן ביטויים שמריצים צינור עיבוד נתונים בתצוגת עץ לכל מסמך שעובר עיבוד על ידי השאילתה החיצונית. כך אפשר להשתמש בתבניות מורכבות לאחזור נתונים, כמו אחזור מסמך לצד הפריטים הקשורים אליו באוסף המשנה, או צירוף נתונים שמקושרים באופן לוגי בין אוספי שורש שונים.
מושגים
בקטע הזה נסביר על המושגים הבסיסיים שקשורים לשימוש בשאילתות משנה כדי לבצע איחודים בפעולות של Pipeline.
שאילתות משנה כביטויים
שאילתת משנה היא לא שלב ברמה העליונה, אלא ביטוי שאפשר להשתמש בו בכל שלב שמקבל ביטויים, כמו select(...), add_fields(...), where(...) או sort(...).
Cloud Firestore תומך בשלושה סוגים של שאילתות משנה:
- שאילתות משנה של מערכים: הפיכת כל קבוצת התוצאות של שאילתת המשנה למערך של מסמכים.
- שאילתות משנה סקלריות: מחשבות ערך יחיד, כמו ספירה, ממוצע או שדה ספציפי ממסמך קשור.
subcollection(...)שאילתות משנה: צירופים פשוטים יותר ליחסי הורה-צאצא של אחד לרבים.
היקף ומשתנים
כשכותבים שאילתת JOIN, שאילתת המשנה המקוננת צריכה לעיתים קרובות להפנות לשדות מהמסמך "החיצוני" (ההורה). כדי לגשר על הפער בין השלבים, משתמשים בשלב let(...) (שנקרא define(...) בחלק מערכות ה-SDK) כדי להגדיר משתנים בשלב ההורה, שאפשר להפנות אליהם בשאילתת המשנה באמצעות הפונקציה variable(...).
תחביר
בקטעים הבאים מופיעה סקירה כללית של התחביר לביצוע איחודים.
השלב let(...)
השלב let(...) (נקרא define(...) בחלק מערכות ה-SDK) הוא שלב ללא סינון, שמעביר באופן מפורש נתונים מההיקף הראשי למשתנה בעל שם, לשימוש בהיקפים הבאים בתצוגת עץ.
שאילתות משנה של מערכים
שאילתת משנה של מערך היא מקרה מיוחד של שאילתת משנה של ביטוי, שיוצרת את כל קבוצת התוצאות של שאילתת המשנה במערך. אם שאילתת המשנה מחזירה אפס שורות, היא מחושבת כמערך ריק. הוא אף פעם לא מחזיר מערך null. שאילתות כאלה שימושיות כשצריך את התוצאות המלאות בתוצאה הסופית, למשל כשמממשים אוסף מקונן או קשור.
כדי להפחית את כמות הנתונים שצריך לאחזר ולהחזיר, וכך להוזיל את עלות השאילתה, אפשר לסנן, למיין ולצבור נתונים בשאילתת המשנה. הסדר של שאילתת המשנה נשמר, כלומר שלב sort(...) בשאילתת המשנה קובע את סדר התוצאות במערך הסופי.
משתמשים ב-wrapper של ה-SDK של toArrayExpression() כדי להמיר שאילתה למערך.
שאילתות משנה סקלריות
שאילתות משנה סקלריות משמשות לעיתים קרובות בשלב select(...) או where(...), כי הן מאפשרות לסנן או להחזיר את התוצאה של שאילתת משנה בלי לממש ישירות את השאילתה המלאה.
שאילתת משנה סקלרית שמניבה אפס תוצאות תעריך את null עצמו, ואילו שאילתת משנה שמעריכה כמה רכיבים תגרום לשגיאת זמן ריצה.
כששאילתת משנה סקלרית מפיקה רק שדה אחד לכל תוצאה, השדה מועלה כדי להיות התוצאה ברמה העליונה של שאילתת המשנה. בדרך כלל זה קורה כששאילתת המשנה מסתיימת ב-select(field("user_name")) או ב-aggregate(countAll().as("total")), והסכימה של שאילתת המשנה היא שדה אחד. אחרת, כששאילתת משנה יכולה להפיק כמה שדות, הם נעטפים במפה.
משתמשים ב-wrapper של ה-SDK של toScalarExpression() כדי להמיר שאילתה לביטוי סקלרי.
subcollection(...) שאילתות משנה
למרות שהוא מוצע כשלב, שלב הקלט subcollection(...) מאפשר לבצע איחודים במודל הנתונים ההיררכי של Cloud Firestore. במודל היררכי, לעיתים קרובות השאילתות צריכות לאחזר מסמך לצד נתונים מאוספי המשנה שלו. אפשר לעשות את זה באמצעות שלב קלט של collection_group(...) ואחריו מסנן על ההפניה הראשית, אבל subcollection(...) מספק תחביר הרבה יותר תמציתי.
מלבד תנאי ההצטרפות המשתמע, הפעולה הזו דומה לשאילתת משנה של מערך, ומחזירה תוצאה ריקה אם אין מסמכים תואמים, גם אם האוסף בתצוגת עץ לא קיים.
זהו בעצם סוכר תחבירי: הוא משתמש באופן אוטומטי ב-__name__ של המסמך בהיקף החיצוני כמפתח ההצטרפות כדי לפתור את הקשר ההיררכי. לכן זו הדרך המועדפת לבצע חיפושים באוספים שמקושרים בקשר הורה-צאצא.
דוגמאות
נתונים לדוגמה
הקוד הבא טוען קבוצה של נתוני בדיקה לשימוש בכל הדוגמאות הבאות.
Node.js
// Load set of cities.
const cities = db.collection("cities");
await cities.doc("SF").set({
name: "San Francisco",
state: "CA",
country: "USA",
});
await cities.doc("LA").set({
name: "Los Angeles",
state: "CA",
country: "USA"
});
await cities.doc("DC").set({
name: "Washington, D.C.",
state: null,
country: "USA"
});
await cities.doc("TOK").set({
name: "Tokyo",
state: null,
country: "Japan"
});
// Load restaurants in various cities.
const sfRestaurants = db.collection("cities/SF/restaurants");
const laRestaurants = db.collection("cities/LA/restaurants");
const dcRestaurants = db.collection("cities/DC/restaurants");
const rest1 = await sfRestaurants.add({
name: "Golden Gate Pizza",
type: "pizza",
owner_id: "Mario Rossi"
});
const rest2 = await sfRestaurants.add({
name: "Bay Area Burger",
type: "burger",
owner_id: "Sarah Jenkins"
});
const rest3 = await sfRestaurants.add({
name: "Sunset Taco",
type: "mexican",
owner_id: "Edward"
});
const rest4 = await laRestaurants.add({
name: "Hollywood Sushi",
type: "sushi",
owner_id: "Ken Kenji"
});
const rest5 = await laRestaurants.add({
name: "Venice Pizza",
type: "pizza",
owner_id: "Luigi Romano"
});
const rest6 = await dcRestaurants.add({
name: "Capitol Tacos",
type: "mexican",
owner_id: "Maria Garcia"
});
const rest7 = await dcRestaurants.add({
name: "Georgetown Coffee",
type: "cafe",
owner_id: "David Kim"
});
// Load collection of reviews.
const reviews = db.collection("reviews");
await reviews.add({ restaurant: rest1, rating: 5, reviewer_id: "Alice" });
await reviews.add({ restaurant: rest1, rating: 4, reviewer_id: "Bob" });
await reviews.add({ restaurant: rest2, rating: 4, reviewer_id: "Charlie" });
await reviews.add({ restaurant: rest3, rating: 5, reviewer_id: "Diana" });
await reviews.add({ restaurant: rest3, rating: 4, reviewer_id: "Edward" });
await reviews.add({ restaurant: rest3, rating: 4, reviewer_id: "Fiona" });
// rest4 has 0 reviews
await reviews.add({ restaurant: rest5, rating: 3, reviewer_id: "George" });
await reviews.add({ restaurant: rest6, rating: 5, reviewer_id: "Hannah" });
await reviews.add({ restaurant: rest6, rating: 4, reviewer_id: "Ian" });
await reviews.add({ restaurant: rest7, rating: 5, reviewer_id: "Julia" });
חיפוש מסמך באוסף אחר
השאילתה הבאה בקבוצת האוספים reviews מבצעת חיפוש בקבוצת האוספים restaurant באמצעות הפניה למפתח ראשי.
Node.js
let results = await 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"))
.execute();
תגובה
{
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 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"))
.execute();
תגובה
{
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 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"))
.execute();
תגובה
{
name: "Golden Gate Pizza",
avg_rating: 4.5
},
{
name: "Venice Pizza",
avg_rating: 3.0
}
Top-N לכל קבוצה (שאילתת משנה עם מגבלה)
השאילתה הבאה מאחזרת את כל המסמכים מקבוצת האוספים restaurants, ומשתמשת בשאילתת משנה מקושרת כדי לאחזר את שתי הביקורות עם הדירוג הגבוה ביותר לכל מסעדה.
כך אפשר לוודא שמערך הביקורות לא יהיה גדול מדי ויחרוג ממגבלת הזיכרון של השאילתה.
Node.js
let results = await 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"))
.execute();
תגובה
{
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 db.pipeline()
.collection("cities")
.addFields(subcollection("restaurants")
.toArrayExpression()
.length()
.as("restaurant_count"))
.execute();
תגובה
{
__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 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))
.execute();
תגובה
{
__name__: cities/SF/restaurants/X9An0HIlx29A9GPuRthS,
name: "Sunset Taco",
type: "mexican",
owner_id: "Edward"
}
Anti-Join (NOT EXISTS)
השאילתה הבאה סורקת את קבוצת האוספים restaurants ומוצאת את כל המסעדות שעדיין אין להן ביקורות.
Node.js
let results = await 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))
.execute();
תגובה
{
__name__: "cities/LA/restaurants/X9An0HIlx29A9GPuRthS",
name: "Hollywood Sushi",
type: "sushi",
owner_id: "Ken Kenji"
}
שאילתת משנה כצירוף
השאילתה הבאה משטחת את הקשר בין כל פיצרייה לבין הביקורות שלה. כשממקמים את שאילתת המשנה בתוך שלב unnest(...), השרת משכפל את מסמך המסעדה החיצוני לכל ביקורת תואמת, ויוצר מסמכים שטוחים ומחוברים (בדומה ל-INNER JOIN ב-SQL).
Node.js
let results = await 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"))
.execute();
תגובה
{
__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 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")
.execute();
תגובה
{
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 שכבות.
- שימוש בזיכרון: המגבלה של 128MiB על נתונים מוחשיים חלה על כל השאילתה, כולל כל המסמכים המצורפים.