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

שורה תחתונה: אתרו קודם עומק מוכר, ואז הוסיפו פרוסות ומסננים במקום לפנות ל-$..; נתיב בודד במסנן רק בודק קיום, ולכן בודקים ערך בוליאני עם @.stock == true.
כל אחד יכול לכתוב
$.store.book[0].title. מה שבאמת שובר את JSONPath הוא שילוב של$..עם פרוסות ומספר תנאים, ואז קבלת יותר מדי תוצאות, מעט מדי תוצאות, או סדר שלא ציפיתם לו.
1. מסמך לדוגמה
{
"store": {
"book": [
{ "title": "JSON: The Definitive Guide", "price": 8.95, "stock": true, "tags": ["json", "spec"] },
{ "title": "A History of Data Formats", "price": 12.99, "stock": false, "tags": ["history"] },
{ "title": "How Parsers Work", "price": 8.99, "stock": true, "tags": ["parser", "json"] },
{ "title": "Streaming JSON in Practice", "price": 9.5, "stock": true, "tags": [] }
],
"bicycle": { "color": "red", "price": 19.95 }
},
"updatedAt": "2026-10-31"
}
כל ביטוי בהמשך משתמש במסמך הזה כקלט. זכרו שתי הנחות: התוצאה היא קבוצת צמתים, והסדר שלה הולך לפי סדר המסמך ולא לפי סדר כתיבת התנאים. בתוך מסנן, @ מתייחס רק לצומת המועמד הנוכחי.
2. ירידה רקורסיבית $..: נוחה אבל יקרה
.. פירושו "חפש רקורסיבית בכל עומק מהמיקום הנוכחי". RFC 9535 מגדיר זאת כקטע צאצא, ואפשר לשלב אותו עם שמות, תווים כלליים, אינדקסים, פרוסות ומסננים:
$..price // every member named price at any depth
$..book[?(@.price < 10)] // any book at any depth, then filter
$..[?(@.stock)] // any node at any depth where stock exists
שלושה דברים להבין:
$..bookמוצא כל צומת בשםbook, בלי קשר לעומק שלו;- המעבר הוא depth-first, וסדר הפלט תלוי באופן שבו המימוש מבקר ילדים;
..לא יכול להשתמש באינדקס, ולכן העלות שלו גדלה עם מספר הצמתים הכולל.
כתוצאה מכך, $..price ו-$.store.book[*].price עשויים להיבדל רק במחיר האופניים בפלט, אבל להיבדל בסדר גודל בעלות. כשאתם יודעים את העומק, תמיד כתבו את הנתיב המפורש. אם רק רוצים להשוות את גבולות שתי שפות הנתיב, ראו JSONPath מול JSON Pointer.
3. פרוסות מערך ותווים כלליים: שלוש מלכודות
תחביר הפרוסה הוא [start:end:step], כולל את ההתחלה ובלעדי הסוף, עם מספרים שליליים שנספרים מהזנב:
| ביטוי | משמעות | תוצאה (עם ארבעה ספרים) |
|---|---|---|
[0:2] |
שני הספרים הראשונים | ספרים 1 ו-2 |
[1:] |
מהספר השני ועד הסוף | ספרים 2, 3 ו-4 |
[:2] |
שני הספרים הראשונים | ספרים 1 ו-2 |
[-2:] |
שני הספרים האחרונים | ספרים 3 ו-4 |
[::2] |
כל ספר שני | ספרים 1 ו-3 |
[::-1] |
סדר הפוך | ספרים 4, 3, 2 ו-1 |
שלוש מלכודות:
- צעד אפס אינו חוקי. RFC 9535 דורש צעד שאינו אפס, בעוד שחלק מהמימושים מחזירים בשקט קבוצה ריקה;
- צעדים שליליים לא נתמכים בכל מקום. מימוש Goessner המקורי תומך בהם, אבל מימושים מינימליים מסוימים מקבלים רק צעדים חיוביים;
- סינון אחרי פרוסה לא מרחיב את ההיקף.
[0:2][?(@.stock)]רק מסנן את שני הספרים שהפרוסה הפיקה; הוא לא חוזר למערך כולו.
התו הכללי [*] יכול להשתלב בפרוסות, אבל [*] מפיק "כל צמתי הבן". אם הבנים הם אובייקטים, הוספת .title תיתן את כל הכותרות. אם בן הוא עצמו מערך, .title לא ישטח בשקיפות רמה נוספת. כתבו [*] שוב כשצריך להרחיב; אל תצפו לשיטוח מרומז.
4. מסננים מרובי תנאים: השוואות, לוגיקה וקיום
מסנן [?(...)] יכול להכיל ארבעה סוגי מבנים: השוואות, אופרטורים לוגיים, בדיקות קיום ופונקציות. שאילתה מרובת תנאים נפוצה נראית כך:
$..book[?(@.price < 10 && @.stock)]
פירושה "מחיר מתחת ל-10 וגם יש מלאי". שימו לב לכמה פרטים סמנטיים:
@.stockהיא בדיקת קיום, לא "הערך הוא true". אם השדה קיים אבל ערכוfalse, התנאי עדיין מתקיים. כדי לבדוק ערך בוליאני חייבים לכתוב@.stock == true;&&נקשר חזק יותר מ-||. הוסיפו סוגריים כשמערבבים ביניהם במקום לסמוך על הזיכרון;- שני צדדי השוואה חייבים להיות מאותו סוג. מספרים ומחרוזות לא מומרים בשקיפות;
@.price < "10"הוא false או שגיאה במימושים קפדניים; - שדה חסר תמיד הופך השוואה ל-false.
@.missing < 10לא מעלה שגיאה; הוא פשוט לא מתאים לשום דבר.
זה המקום הקל ביותר להיתקל בו כשכותבים מסננים כאילו היו JavaScript: if (obj.stock) בודק אמתיות ב-JavaScript, בעוד ש-JSONPath בודק קיום. לתחביר התקן המלא ולפונקציות, חזרו ל-שפת השאילתות JSONPath בפועל.
5. ביטויי סקריפט: יותר כוח, פחות ניוד
מימוש Goessner המקורי ו-jsonpath-plus מתירים השוואות בין צמתים כמו [?(@.price < $.max)], ואפילו סקריפטים שרירותיים. RFC 9535 מתיר במפורש רק גישה לצומת הנוכחי @ ואוסר $ בתוך מסנן.
// Works in older implementations; not allowed by RFC 9535
$..book[?(@.price < $.expensive)]
// Standard approach: pass the threshold from the caller
query(document, '$..book[?(@.price < threshold)]', { threshold: 10 })
ביטויי סקריפט יוצרים שלוש בעיות:
- הניוד צונח לאפס. מעבר ספרייה פירושו כתיבה מחדש של כל ביטוי;
- גבול האבטחה נעלם. כשהביטויים מגיעים ממשתמשים, הערכת סקריפט מוסרת את הרסן על הרצת קוד;
- אופטימיזציה סטטית הופכת בלתי אפשרית. המימוש יכול רק לפרש צומת אחר צומת, מה שאיטי וקשה למטמון.
אם באמת צריך השוואה בין צמתים, העבירו את הסף כפרמטר שאילתה וסננו שוב בקוד האפליקציה. זה בדרך כלל יציב יותר מתלות בתחביר הרחבה.
6. צורות תוצאה: חושבים שמקבלים ערכים, אבל מקבלים צמתים
אותו ביטוי יכול להחזיר לפחות שלוש צורות בספריות שונות:
["JSON: The Definitive Guide", "How Parsers Work", "Streaming JSON in Practice"] // value array
[{ "path": "$['store']['book'][0]['title']", "value": "..." }] // path + value
[{ "pointer": "/store/book/0/title", "value": "..." }] // JSON Pointer + value
זה משפיע ישירות על שלושה דברים: איך הפרונט קורא תוצאות, איך מכפילים פחותים בתוצאות, ואיך תוצאות שאילתה הופכות לפעולות כתיבה. ה-nodelist שמוגדר ב-RFC 9535 הוא קבוצה של הפניות לצמתים, אבל מימושים ב-JavaScript מחזירים בדרך כלל רק ערכים כי הפניות לא עוברות גבולות תהליך.
עצה מעשית:
- תמיד קראו ערך יחיד דרך
[0]אוvalue(); אל תניחו שהתוצאה היא סקלאר; - כשצריך לכתוב בחזרה, בחרו מימוש שמחזיר JSON Pointers או בנו את ה-pointer בעצמכם;
- תוצאה ריקה ותוצאה של
[null]הן שונות; בדקו אורך ולא אמתיות.
7. הבדלי ספריות: הריצו את חבילת התאימות לפני הגירה
| יכולת | Goessner המקורי | jsonpath-plus | RFC 9535 קפדני |
|---|---|---|---|
סוגריים במסנן ?() |
חובה | תואם | אופציונלי |
הפניה לשורש $.x |
נתמך | נתמך | אסור |
| ביטויי סקריפט | נתמך | נתמך | אסור |
| אינדקסים וצעדים שליליים | נתמך | נתמך | נתמך |
| החזרת נתיבים | ניתן להגדרה | ניתן להגדרה | לא מוגדר |
פונקציות כמו length() |
אין | הרחבות | פונקציות תקן |
לפני הגירה, אמתו לפחות ארבעה דברים: האם הסוגריים חובה, האם $ יכול להופיע בתוך מסנן, איך מתנהגים צעדים שליליים, ומבנה התוצאה. חבילת מבחני התאימות הרשמית מכסה את רוב הבדלי התחביר ומונעת כשלים שקטים כמו "שלוש שורות נעלמו אחרי החלפת ספרייה".
8. ביצועים ורשימת בדיקה לשימוש לרעה
הביצועים מסתכמים במשפט אחד: JSONPath הוא מעבר מלא, ו-.. הוא המעבר המלא היקר ביותר.
- במסמכים עם יותר מ-100,000 צמתים, צמצמו קודם את ההיקף עם נתיב מפורש, ואז סננו את תת-הקבוצה;
- קמפלו ביטוי פעם אחת והשתמשו בו שוב; אל תפרסרו מחדש את המחרוזת בתוך לולואה;
- עברו ל-
jqאו לקוד עבור אגרגציה (סכימה, קיבוץ, מיון); JSONPath רק בוחר; - שימו את התנאים מוקדם ככל האפשר:
$.store.book[?(@.price < 10)]סורק הרבה פחות צמתים לא רלוונטיים מ-$..[?(@.price < 10)].
רשימת בדיקה לשימוש לרעה:
- [ ] התייחסות לבדיקת קיום כאל בדיקת אמת בוליאנית;
- [ ] שימוש ב-
$..במקום עומק מוכר; - [ ] תלות בביטויי סקריפט להשוואה בין צמתים;
- [ ] הנחה שהתוצאה היא תמיד מערך של ערכים;
- [ ] מתן אפשרות לביטויים בשליטת משתמש בלי מגבלות אורך וקינון.
שאלות נפוצות
למה @.stock עדיין מתאים כשהשדה הוא false?
כי נתיב בודד בתוך מסנן פירושו "השדה קיים", לא "הערך הוא true". כדי לבדוק את הבוליאני, השוו במפורש עם @.stock == true. זהו ההבדל הסמנטי הגדול ביותר בין JSONPath לאמתיות של JavaScript.
האם סדר הירידה הרקורסיבית יציב?
לא. RFC 9535 מגדיר סמנטיקת קבוצת צמתים, וסדר המעבר תלוי באופן שבו המימוש מבקר ילדים. כשצריך סדר קבוע, אתרו את המערך וקראו לפי אינדקס, או מיינו את התוצאות בקוד האפליקציה.
אפשר לשלב פרוסות ומסננים?
כן, אבל זכרו את ההיקף. $.store.book[0:2][?(@.stock)] מסנן רק את שני הספרים שהפרוסה הפיקה. כדי לסנן קודם ולפרוס אחר כך, כתבו $.store.book[?(@.stock)][0:2]; סדר אחר נותן תוצאה אחרת.
למה jsonpath-plus תומך ב-$.expensive כשהתקן אוסר זאת?
כי מסננים מוגדרים רק במונחים של הצומת הנוכחי @. הפניה לשורש הופכת ביטוי לתלוי בהקשר המסמך כולו, מה שקשה לנתח סטטית ולהעביר בין שפות. העבירו את הסף כפרמטר במקום להטמיע אותו בביטוי.
איך מונעים מירידה רקורסיבית לסרוק צמתים לא רלוונטיים?
כתבו את הנתיב המפורש בכל פעם שאתם יודעים את העומק, צמצמו קודם את ההיקף, וסננו רק את תת-הקבוצה במקום להשתמש ב-$.. במקום רמה מוכרת. שימו את התנאים מוקדם ככל האפשר: $.store.book[?(@.price < 10)] סורק הרבה פחות צמתים לא רלוונטיים מ-$..[?(@.price < 10)]. במסמכים עם יותר מ-100,000 צמתים, עשו זאת קודם.