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

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

ניקח את הדוגמה הרגילה. ל-Vehicle יש id, owner ו-registration. Car מוסיף doors; Truck מוסיף payload_kg.

ירושה בטבלה יחידה

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

CREATE TABLE vehicle (
    id            SERIAL PRIMARY KEY,
    kind          TEXT NOT NULL CHECK (kind IN ('car', 'truck')),
    owner         TEXT NOT NULL,
    registration  TEXT NOT NULL UNIQUE,
    doors         INTEGER,
    payload_kg    INTEGER
);

השאילתות טריוויאליות. כל כלי רכב הוא שורה אחת בטבלה אחת; "כל כלי הרכב בבעלות X" היא סריקה אחת ו"כל המשאיות" היא סינון על kind. טעינה פולימורפית אינה זקוקה לשום צירוף.

האילוצים חלשים. doors חייב להיות NULL למשאית ו-NOT NULL למכונית, והסכמה אינה יכולה לומר זאת בלי אילוץ בדיקה לכל עמודה של תת-מחלקה:

CHECK ((kind = 'car') = (doors IS NOT NULL)),
CHECK ((kind = 'truck') = (payload_kg IS NOT NULL))

זה עובד לשתי תת-מחלקות. לעשרים, עם עשר עמודות לכל אחת, הטבלה היא בעיקר NULL ואילוצי הבדיקה הם חומה של טקסט. המקום המבוזבז הוא לעיתים רחוקות הבעיה; אובדן הסכמה כתיעוד הוא הבעיה.

האינדקסים משותפים. אינדקס על payload_kg מאנדקס גם את ה-NULL של כל מכונית. אינדקסים חלקיים (WHERE kind = 'truck') מתקנים זאת במקום שבו בסיס הנתונים תומך בהם.

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

ירושה בטבלה למחלקה

טבלה אחת לכל מחלקה, כשטבלאות תת-המחלקות מחזיקות רק את העמודות שלהן ומפתח זר לשורת האב:

CREATE TABLE vehicle (
    id            SERIAL PRIMARY KEY,
    owner         TEXT NOT NULL,
    registration  TEXT NOT NULL UNIQUE
);

CREATE TABLE car (
    id     INTEGER PRIMARY KEY REFERENCES vehicle(id) ON DELETE CASCADE,
    doors  INTEGER NOT NULL
);

CREATE TABLE truck (
    id          INTEGER PRIMARY KEY REFERENCES vehicle(id) ON DELETE CASCADE,
    payload_kg  INTEGER NOT NULL
);

האילוצים מדויקים. כל עמודה היא NOT NULL היכן שהיא צריכה להיות, הסכמה נקראת כמו דיאגרמת המחלקות, ומפתח זר אל vehicle(id) פירושו "כל כלי רכב" בעוד אחד אל car(id) פירושו "מכונית". זו התשובה המנורמלת וזו שאיש בסיסי נתונים יצייר על הלוח.

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

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

ירושה בטבלה קונקרטית

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

CREATE TABLE car (
    id            SERIAL PRIMARY KEY,
    owner         TEXT NOT NULL,
    registration  TEXT NOT NULL UNIQUE,
    doors         INTEGER NOT NULL
);

CREATE TABLE truck (
    id            SERIAL PRIMARY KEY,
    owner         TEXT NOT NULL,
    registration  TEXT NOT NULL UNIQUE,
    payload_kg    INTEGER NOT NULL
);

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

האב נעלם. אין טבלת vehicle, ולכן אין לטבלה אחרת אל מה להפנות כשהיא מתכוונת ל"כל כלי רכב". ה-UNIQUE על registration הוא לכל טבלה, ולכן מכונית ומשאית יכולות לחלוק אחד, ותיקון זה דורש טריגר או טבלה חיצונית. "כל כלי הרכב" הוא UNION ALL על כל טבלת תת-מחלקה, והוספת תת-מחלקה פירושה שינוי כל שאילתה כזאת. שני רצפי ה-SERIAL גם מחלקים מזהים חופפים, ולכן id לבדו אינו מזהה עוד כלי רכב; אתם זקוקים ל-(kind, id), שלממפה יהיו דעות עליו.

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

הגרסה המובנית של PostgreSQL

ב-PostgreSQL ירושת טבלאות מובנית:

CREATE TABLE vehicle (
    id            SERIAL PRIMARY KEY,
    owner         TEXT NOT NULL,
    registration  TEXT NOT NULL UNIQUE
);

CREATE TABLE car (doors INTEGER NOT NULL) INHERITS (vehicle);
CREATE TABLE truck (payload_kg INTEGER NOT NULL) INHERITS (vehicle);

SELECT * FROM vehicle מחזיר גם את השורות של car ו-truck, עם עמודות האב, ו-SELECT * FROM ONLY vehicle מחזיר רק שורות שהוכנסו ישירות לאב. האחסון הוא של טבלה קונקרטית (כל ילד מחזיק את שורותיו המלאות); השאילתה היא של טבלה יחידה (האב רואה הכול). זה נראה כמו הטוב משני העולמות.

המלכוד הוא מה שאינו נורש. אילוצי ייחודיות, מפתחות ראשיים ומפתחות זרים חלים לכל טבלה בנפרד. ה-UNIQUE (registration) על vehicle אינו מונע ממכונית וממשאית לחלוק רישוי, ומפתח זר REFERENCES vehicle(id) מטבלה אחרת לא יקבל מזהה של מכונית, מפני ששורת המכונית אינה באינדקס של vehicle. אילוצי בדיקה ו-NOT NULL כן נורשים; אינדקסים לא, ויש ליצור אותם על כל ילד. זה מתועד וזו הסיבה שהתכונה משמשת הרבה יותר לחלוקה למחיצות מאשר למידול.

מה הממפה יעשה לכם

כל ממפה אובייקטים-יחסים תומך בשתי האסטרטגיות הראשונות ורובם תומכים בשלישית; Hibernate קורא להן SINGLE_TABLE, JOINED ו-TABLE_PER_CLASS. שתי אזהרות מהניסיון.

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

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

הכלל שאני משתמש בו

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