צעדים ראשונים עם neo4j

02/04/2023

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

1. מהו Neo4j

לפני שנשאל מהו neo4j נרצה לדבר על מהו בסיס נתונים גרפי (ולא, אין שום קשר לתמונות). גרף הוא מבנה נתונים שבנוי מצמתים (Nodes) ומקשתות המחברות אותם (Edges). בסיס נתונים גרפי הוא בסיס נתונים שנותן לנו שפה כדי להגדיר ולתשאל מידע בצורה של גרף. הצרכים שלנו מבסיס נתונים גרפי יהיו:

  1. יכולת ליצור צמתים בגרף.

  2. יכולת לחבר צמתים בקשתות.

  3. יכולת להוסיף מידע בצמתים.

  4. יכולת להוסיף מידע על הקשתות.

  5. יכולת לחפש מידע, כולל חיפוש המבוסס על קשר בין צמתים.

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

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

2. איך מקבלים בסיס נתונים

בשביל להתחיל לעבוד עם neo4j אפשר להריץ אותו אצלנו על המחשב בהתקנה מקומית או דוקר (הכל קוד פתוח), אבל הדרך הכי קלה היא לפתוח בסיס נתונים בענן שלהם שנקרא auro db. הכניסה בקישור:

https://neo4j.com/cloud/platform/aura-graph-database/

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

אחרי שתיצרו את בסיס הנתונים ב Aura תוכלו להיכנס לממשק גרפי לעבודה איתו באמצעות לחיצה על כפתור Open במסך רשימת בסיסי הנתונים. מדביקים את הסיסמה, לוחצים Connect ואתם יכולים להתחיל להריץ שאילתות.

3. יצירת מודל נתונים עבור מנויים לבלוג

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

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

במודל גרפי נוכל להגיד שהצמתים בגרף יהיו:

  1. מנוי - שיחזיק מזהה משתמש, כתובת מייל עדכנית ואולי מידע נוסף שמעניין אותי לגבי המנוי.

  2. קטגוריה - שתחזיק את שם הקטגוריה.

  3. פוסט - שיחזיק את הפרטים לגבי הפוסט כמו הכותרת, התוכן ומתי פורסם.

  4. דיוור - כדי לשלוח מייל למנויים אני ארצה לזכור מה שלחתי למי ואם היו בעיות בשליחה.

מבחינת הקשרים (הקשתות) בין הצמתים נוכל לדבר על:

  1. מנוי יכול להיות "רשום ל" קטגוריה, כדי לקבל למייל פוסטים מקטגוריה מסוימת.

  2. פוסט יכול להיות "שייך ל" קטגוריה.

  3. דיוור "נשלח" למנוי באימייל.

  4. דיוור "כולל" פוסט או אוסף של פוסטים.

הפקודה MERGE ב neo4j יוצרת פריטים חדשים. בואו ניצור כמה פריטים לסכימה שתיארתי. תחילה אני יוצר פוסט עם מאפיין slug (זה השם שמופיע ב url), title עבור שורת הכותרת ו publishedAt עם תאריך ושעת הפירסום:

MERGE (:Post {slug: "first", title: "first post", publishedAt: datetime('2023-01-24T06:00:00')})

ועוד כמה פוסטים בשביל שיהיה מעניין:

MERGE (:Post {slug: "second", title: "second post", publishedAt: datetime('2023-01-25T06:00:00')})

MERGE (:Post {slug: "hello-neo4j", title: "Hello World in Neo4j", publishedAt: datetime('2023-04-02T06:00:00')})

עכשיו אפשר ליצור קטגוריות:

MERGE (:Category {name: "spam"})
MERGE (:Category {name: "neo4j"})

וכמה מנויים:

MERGE (:Reader {email: "foo@demomail.com"})
MERGE (:Reader {email: "bar@demomail.com"})
MERGE (:Reader {email: "buz@demomail.com"})

4. כתיבת שאילתות על צמתים

הפקודה MATCH מספקת דרך בסיסית לקרוא מידע ב neo4j. המידע שלנו בינתיים מאוחסן רק בצמתים והפקודה MATCH יכולה לקבל קריטריון חיפוש ולהחזיר את כל הצמתים שמתאימים לקריטריון. שימו לב שביצירת כל צומת העברנו ל MERGE מילה שמתארת את הצומת - המילה שמתחילה באות גדולה מיד אחרי הנקודותיים. מילה זו נקראת Label והיא נקודת ההתחלה לכל חיפוש. לכן בשביל למצוא את כל קוראי הבלוג אני אכתוב:

MATCH (r:Reader) return r

בתוך ממשק הניהול של בסיס הנתונים אני מקבל את התוצאה בתור גרף ויזואלי, ויכול גם לבחור להציג את התוצאה בתור Table או Raw. התשובה בתור טבלה נראית כך:

{
  identity: 5,
  labels: ["Reader"],
  properties: {
    email: "foo@demomail.com"
  },
  elementId: "4:afd409dc-b534-4d20-bc87-dbccb412845c:5"
}
{
  identity: 6,
  labels: ["Reader"],
  properties: {
    email: "bar@demomail.com"
  },
  elementId: "4:afd409dc-b534-4d20-bc87-dbccb412845c:6"
}
{
  identity: 7,
  labels: ["Reader"],
  properties: {
    email: "buz@demomail.com"
  },
  elementId: "4:afd409dc-b534-4d20-bc87-dbccb412845c:7"
}

אנחנו יכולים לראות שכל צומת במערכת מכיל את ה label שבחרתי (במקרה של "קורא" זו המילה Reader), וכן שמתם לב נכון ש labels הוא מערך ואפשר להגדיר מספר תוויות לצומת. הצומת מכיל גם שדה בשם identity שזה מספר קצר שמזהה אותו וגם elementId שזה כבר מספר יותר מורכב. בגדול מומלץ לא לשמור ערכים אלה כי neo4j ממחזר אותם כשצמתים נמחקים. וכמובן כל המאפיינים שהגדרנו נשמרים בתוך הטבלה properties.

הנה שאילתה דומה עבור פוסטים:

MATCH (p:Post) return p

{
  identity: 2,
  labels: ["Post"],
  properties: {
    publishedAt: 2023-01-24T06:00:00Z,
    title: "first post",
    slug: "first"
  },
  elementId: "4:afd409dc-b534-4d20-bc87-dbccb412845c:2"
}
{
  identity: 3,
  labels: ["Post"],
  properties: {
    publishedAt: 2023-01-25T06:00:00Z,
    title: "second post",
    slug: "second"
  },
  elementId: "4:afd409dc-b534-4d20-bc87-dbccb412845c:3"
}
{
  identity: 4,
  labels: ["Post"],
  properties: {
    publishedAt: 2023-04-02T06:00:00Z,
    title: "Hello World in Neo4j",
    slug: "hello-neo4j"
  },
  elementId: "4:afd409dc-b534-4d20-bc87-dbccb412845c:4"
}

פקודת MATCH יודעת גם לסנן את התוצאות לפי ערכי ה properties. לדוגמה אני יכול לשלוף רק את הקטגוריה שנקראת neo4j:

MATCH (c:Category) WHERE c.name = "neo4j" return c

או את כל הפוסטים שפורסמו באפריל 2023:

MATCH (p:Post) 
WHERE datetime({year: 2023, month: 4, day: 1}) <= p.publishedAt <= datetime({ year: 2023, month: 5, day: 1})
RETURN p

5. הוספת קשרים בין הצמתים

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

הפקודה הבאה משייכת את שני הפוסטים הראשונים לקטגוריית spam:

MATCH (p: Post)
WHERE p.slug IN ["first", "second"]
MATCH (c: Category {name: "spam"})
MERGE (p)-[:BELONGS_TO]->(c)

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

נריץ גם את הפקודה הבאה כדי לשייך את הפוסט האחרון לקטגוריית neo4j:

MATCH (p: Post)
WHERE p.slug = "hello-neo4j"
MATCH (c: Category {name: "neo4j"})
MERGE (p)-[:BELONGS_TO]->(c)

כמו כן קוראים יכולים להירשם לקטגוריות מסוימות בבלוג, אז בואו נשייך קורא אחד לקטגוריית spam, קורא שני לקטגוריית neo4j וקורא שלישי לשתי הקטגוריות גם יחד:

MATCH (foo:Reader {email: "foo@demomail.com"})
MATCH (bar:Reader {email: "bar@demomail.com"})
MATCH (buz:Reader {email: "buz@demomail.com"})
MATCH (spam:Category {name: "spam"})
MATCH (neo4j:Category {name: "neo4j"})

MERGE (foo)-[:SUBSCRIBED_TO]-(spam)
MERGE (bar)-[:SUBSCRIBED_TO]-(neo4j)
MERGE (buz)-[:SUBSCRIBED_TO]-(spam)
MERGE (buz)-[:SUBSCRIBED_TO]-(neo4j)

6. כתיבת שאילתות על יחסים

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

MATCH (foo:Reader {email: "foo@demomail.com"})
MATCH (foo)-[:SUBSCRIBED_TO]-(c:Category)
MATCH (p:Post)-[:BELONGS_TO]-(c)
RETURN p

7. עדכון המודל כדי לאפשר קטגוריות-על

אחד הדברים היפים במודל של neo4j הוא הגמישות של הסכימה. בדוגמה שלנו אפשר לדמיין שנרצה לאפשר למנוי להירשם לכל הקטגוריות, כולל קטגוריות שעדיין לא נוצרו, או לקבוצה דינמית של קטגוריות. למשל אפשר לדמיין שקטגוריות כמו "פיתוח צד-לקוח", "פיתוח node.js" ו"SQL" יהיו שייכות כולן לקטגוריה גדולה יותר בשם "פיתוח Full Stack", ואגב גם זו יכולה להיות שייכת לקטגוריה גדולה יותר כמו "פיתוח", שתהיה שייכת לקטגוריית "כל הפוסטים".

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

ניצור קטגוריה חדשה בשם all:

MERGE (:Category {name: "all"})

ועכשיו נוסיף את שתי הקטגוריות שיש לנו כך שיהיו שייכות ל all:

MATCH (a:Category {name: "all"})
MATCH (c:Category)
WHERE c <> a
MERGE (c)-[:BELONGS_TO]-(a)

וניצור קורא חדש שמנוי לקטגוריית all:

MATCH (a:Category {name: "all"})
MERGE (r:Reader {email: "me@demomail.com"})-[:SUBSCRIBED_TO]-(a)

וכן אפשר ליצור קורא ולחבר אותו לקטגוריה בפקודת MERGE אחת. זה מדליק.

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

MATCH (r:Reader {email: "me@demomail.com"})
MATCH (r)-[:SUBSCRIBED_TO]-(c:Category)
MATCH (p:Post)-[:BELONGS_TO*]-(c)
RETURN p

שימו לב לסימן הכוכבית אחרי המילה BELONGS_TO. הכוכבית הופכת את החיפוש לרקורסיבי ומבקשת מ neo4j להחזיר את כל הפוסטים שיש מהם מסלול שמורכב מכמה חיבורי BELONGS_TO לקטגוריה אליה הוא מנוי.

8. לאן ממשיכים

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

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

ואם הדוגמאות כאן עשו לכם חשק ללמוד יותר על neo4j אני ממליץ על אתר הלימוד הרשמי שלהם בכתובת: https://graphacademy.neo4j.com/