• בלוג
  • הצעה ל DSL לשאילתות על גרף בסקאלה וגרמלין

הצעה ל DSL לשאילתות על גרף בסקאלה וגרמלין

23/10/2023

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

1. הבעיה עם גרמלין

באותו מדריך מלפני כמה שבועות הראיתי קטעי קוד שנראו כך:

g.addV("post")
  .property("slug", slug)
  .property("title", title)
  .property("publishedAt", LocalDateTime.parse(publishedAt, formatter))
  .next()

המשתנה g מייצג חיבור לגרף ופונקציות כמו addV מבצעות פעולות על הגרף (פונקציית addV מוסיפה צומת). בשביל לגשת לאותו פוסט שיצרתי אני כותב שאילתה שזה משהו מסוג "חיפוש בגרף":

g.V().has("post", "slug", "first")

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

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

g
.V()
.has("post", "slug", "first")
.out("belongs_to")

ושאילתה כזו תחזיר את כל הפוסטים ששייכים לאותה קטגוריה כמו first:

g
.V()
.has("post", "slug", "first")
.out("belongs_to")
.in("belongs_to")

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

2. פיתוח שאילתות לשימוש חוזר באמצעות עטיפת מסלולים בגרף

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

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

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

trait CustomTraversal[S, E] {
  val value: GraphTraversal[S, E]
}

בהינתן אוביקט מסוג זה, אני רוצה להמיר אותו בצורה אוטומטית לשדה ה value שלו, כדי שאפשר יהיה להשתמש בו כאילו היה לנו ביד GraphTraversal ולכן אני מגדיר המרה אוטומטית:

implicit def toValue[S, E](t: CustomTraversal[S, E]): GraphTraversal[S, E] = t.value

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

case class TPost[S](value: GraphTraversal[S, Vertex]) extends CustomTraversal[S, Vertex]

או ורטקס שמייצג קטגוריה:

case class TCategory[S](value: GraphTraversal[S, Vertex]) extends CustomTraversal[S, Vertex]

ואז אפשר להרחיב את הגרף כדי שיוכל להחזיר פוסט או קטגוריה:

extension (g: GraphTraversalSource)
  def Post(slug: String): TPost[Vertex] =
    TPost(value = g.V().has("post", "slug", slug))

  def Category(name: String): TCategory[Vertex]=
    TCategory(value = g.V().has("category", "name", name))

וזה כבר עובד, כלומר אני יכול לכתוב:

g.Post("first").next()

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

case class TPost[S](value: GraphTraversal[S, Vertex]) extends CustomTraversal[S, Vertex] {
  def categories(): TCategory[S]=
    TCategory(this.out("belongs_to").hasLabel("category"))
}

case class TCategory[S](value: GraphTraversal[S, Vertex]) extends CustomTraversal[S, Vertex] {
  def posts(): TPost[S]=
    TPost(this.in("belongs_to").hasLabel("post"))
}

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

אחרי מימוש התשתית אני יכול לכתוב שאילתות כמו:

g.Post("first").categories.toList

כדי לקבל את כל הקטגוריות של הפוסט first, או אפילו:

g.Post("first").categories.posts.toList

כדי לקבל את כל הפוסטים ששייכים לאותן קטגוריות כמו הפוסט first.