קטנה על שאילתות ב Datomic

18/02/2023

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

1. שמירת עובדות במקום שורות

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

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

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

  1. קילו עגבניות עולה 12 ש"ח.

  2. לחמניה עולה 3 ש"ח.

  3. קילו תפוחים ירוקים עולה 14 ש"ח

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

[
  {:db/ident :product/name
   :db/cardinality :db.cardinality/one
   :db/unique :db.unique/identity
   :db/valueType :db.type/string}
  {:db/ident :product/price
   :db/cardinality :db.cardinality/one
   :db/valueType :db.type/long}
]

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

פקודת הכנסה לבסיס נתונים של העובדות שכתבתי מקודם יכולה להיראות בקלוז'ר בערך כך:

[[:db/add "e1", :product/name "1kg tomatoes"]
 [:db/add "e1", :product/price 12]
 [:db/add "e2", :product/name "bun"]
 [:db/add "e2", :product/price 3]
 [:db/add "e3", :product/name "1kg green apples"]
 [:db/add "e3", :product/price 14]]

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

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

2. שאילתות

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

[:find ?name
 :where [_ :product/name ?name]]

בתרגום לעברית: כל מה שמתחיל בסימן שאלה הוא משתנה, וכל בלוק where מקבל שלשה שמורכבת מ"ישות", Attribute וערך. השלשה שבדוגמה אומרת שלא משנה לי מה הישות, אבל אני רוצה שיהיה ל Attribute בשם :product/name ואת הערך שלו אני שומר במשתנה name, שגם חוזר מהשאילתה.

3. דוגמה לסכימה אמיתית

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

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

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

(def schema
  [
   {:db/ident :post/published-at
    :db/cardinality :db.cardinality/one
    :db/valueType :db.type/instant}
   {:db/ident :post/slug
    :db/cardinality :db.cardinality/one
    :db/valueType :db.type/string
    :db/unique :db.unique/identity}
   {:db/ident :post/title
    :db/cardinality :db.cardinality/one
    :db/valueType :db.type/string}
   {:db/ident :post/content
    :db/cardinality :db.cardinality/one
    :db/valueType :db.type/string}
   {:db/ident :post/categories
    :db/cardinality :db.cardinality/many
    :db/valueType :db.type/ref}

   {:db/ident :category/javascript}
   {:db/ident :category/clojure}
   {:db/ident :category/rust}

   {:db/ident :subscriber/email
    :db/cardinality :db.cardinality/one
    :db/valueType :db.type/string
    :db/unique :db.unique/identity}
   {:db/ident :subscriber/categories
    :db/cardinality :db.cardinality/many
    :db/valueType :db.type/ref}
   {:db/ident :subscriber/received
    :db/cardinality :db.cardinality/many
    :db/valueType :db.type/ref}
   ])

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

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

(defn date [ddMMyyyy]
  (.parse (java.text.SimpleDateFormat. "ddMMyyyy") ddMMyyyy))

(def posts [
                 {:post/title "first post"
                  :post/slug "first"
                  :post/published-at (date "01012023")
                  :post/categories [:category/clojure]}
                 {:post/title "second post"
                  :post/slug "second"
                  :post/published-at (date "02012023")
                  :post/categories [:category/clojure]}
                 {:post/title "rust"
                  :post/slug "rust"
                  :post/published-at (date "01012023")
                  :post/categories [:category/rust]}])

ורשימת מנויים ראשונית יכולה להיראות כך:

(def subscribers [
                  {:subscriber/email "clojure@demomail.com"
                   :subscriber/categories [:category/clojure]}
                  {:subscriber/email "rust@demomail.com"
                   :subscriber/categories [:category/rust]}
                  {:subscriber/email "all@demomail.com"
                   :subscriber/categories [:category/rust :category/clojure]}])

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

@(d/transact conn schema)
@(d/transact conn posts)
@(d/transact conn subscribers)

אגב התחילית d נמצאת שם בגלל הדרך בה ייבאתי את datomic לתוכנית שהיא השורות:

(ns ynonp.blogmail
  (:require [datomic.api :as d]))

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

(def db-uri "datomic:mem://blog")
(d/create-database db-uri)
(def conn (d/connect db-uri))

כולן יצטרכו להופיע בתוכנית לפני פקודת ה d/transact.

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

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

(d/q '[:find ?title :where [_ :post/title ?title]] (d/db conn))

;; returns: #{["second post"] ["rust"] ["first post"]}

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

(d/q '[:find ?title ?slug :where
       [?p :post/title ?title]
       [?p :post/slug ?slug]] (d/db conn))

;; returns #{["rust" "rust"] ["second post" "second"] ["first post" "first"]}

ופה כדאי לשים לב לשימוש במשתנה ?p - בעצם ביקשתי מדטומיק להחזיר מידע על ישויות לפי שתי עובדות על הישויות, גם שלישות יש :post/title וגם שיש לה :post/slug. בגלל ששני התנאים מתיחסים לאותו משתנה ?p קיבלתי רק ישויות שמחזיקות עובדות משני הסוגים. אם הייתי משתמש שם ב _ במקום, הייתי מקבל משהו שדומה ל Outer Join ב SQL, כלומר את כל ה title-ים של כל הישויות ואחרי זה את כל ה slug-ים של כל הישויות, כלומר את הרשימה:

(d/q '[:find ?title ?slug :where
       [_ :post/title ?title]
       [_ :post/slug ?slug]] (d/db conn))

;; returns
;; #{["rust" "first"] ["rust" "rust"] ["first post" "second"] ["second post" "rust"] ["first post" "rust"] ["second post" "first"] ["second post" "second"] ["rust" "second"] ["first post" "first"]}

שאילתה יותר מעניינת תחזיר את כל הפוסטים שמעניינים משתמש מסוים:

(d/q '[:find ?title :where
       [?p :post/title ?title]
       [?p :post/categories ?c]
       [?s :subscriber/email "all@demomail.com"]
       [?s :subscriber/categories ?c]] (d/db conn))

;; returns #{["second post"] ["rust"] ["first post"]}

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

בשביל לסמן שמנוי מסוים קיבל פוסט מסוים אני משתמש בטרנזאקציה הבאה:

@(d/transact conn [
                   {:subscriber/email "clojure@demomail.com"
                    :subscriber/received {:db/id [:post/slug "first"]}}])

ובגלל ה Cardinality של המאפיין received דטומיק יודע להוסיף את הפוסט שביקשתי לרשימה שם במקום להחליף ערך בודד.

עכשיו אפשר להמשיך ולשאול איזה פוסטים משתמש מסוים כבר קיבל:

(d/q '[:find ?title :where
       [?p :post/title ?title]
       [?s :subscriber/email "clojure@demomail.com"]
       [?s :subscriber/received ?p]] (d/db conn))

;; #{["first post"]}

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

(d/q '[:find ?post-slug
       :where
       [?subscriber :subscriber/email "clojure@demomail.com"]
       [?subscriber :subscriber/categories ?cat]
       [?post :post/categories ?cat]
       (not-join [?subscriber ?post]
                 [?subscriber :subscriber/received ?post])
       [?post :post/slug ?post-slug]] (d/db conn))

;; returns #{["second"]}

שפת השאילתות datalog מתגלה כשפה מאוד ידידותית וקריאה, במיוחד בהשוואה ל SQL. וכן זו שפה חדשה וצריך ללמוד אותה, אבל אני מקווה שכבר מהדוגמאות הקטנות כאן אפשר לראות את הכח שלה.

4. מה הלאה

המקום הכי טוב ללמוד בו עוד על Datomic הוא התיעוד שלהם, שקצת קצר בדוגמאות אבל כולל אינסוף הסברים מקיפים: https://docs.datomic.com/on-prem/getting-started/brief-overview.html.

אם אתם בעניין הרצאות ביוטיוב אז כאן ריק היקי מסביר על דטומיק: https://www.youtube.com/watch?v=9TYfcyvSpEQ

וכאן יש הרצאה יותר טכנית על איך עובדים איתו ואיך מנהלים מידע: https://www.youtube.com/watch?v=yWdfhQ4_Yfw

ואחרונה על שפת השאילתות datalog: https://www.youtube.com/watch?v=bAilFQdaiHk