فرض کنید یک وب سایت حرفهای خبری یا علمی-پژوهشی داریم که قابلیت دریافت نظرات کاربران را در مورد هر مطلب مندرج در سایت یا نظرات داده شده در مورد آن مطالب را دارا میباشد. یعنی هر کاربر علاوه بر توانایی اظهار نظر در مورد یک خبر یا مطلب باید بتواند پاسخ نظرات کاربران دیگر را نیز بدهد. اولین راه حلی که برای طراحی این مطلب در پایگاه داده به ذهن ما میرسد، ایجاد یک زنجیره با استفاده از کد sql زیر میباشد:
CREATE TABLE Comments (
comment_idSERIAL PRIMARY KEY,
parent_idBIGINT UNSIGNED,
comment TEXT NOT NULL,
FOREIGN KEY (parent_id) REFERENCES Comments(comment_id)
);
البته همان طور که پیداست بازیابی زنجیرهای از پاسخها در یک پرسوجوی sql کار سختی است. این نخها معمولا عمق نامحدودی دارند و برای به دست آوردن تمام نخهای یک زنجیره باید پرسوجوهای زیادی را اجرا نمود.
ایدهی دیگر میتواند بازیابی تمام نظرها و ذخیرهی آنها در حافظهی برنامه به صورت درخت باشد. ولی این روش برای ذخیره هزاران نظری که ممکن است در سایت ثبت شود و علاوه بر آن مقالات جدیدی که اضافه میشوند، تقریبا غیرعملی است.
1.2 هدف: ذخیره و ایجاد پرسوجو در سلسلهمراتب
وجود سلسله مراتب بین دادهها امری عادی محسوب میگردد. در ساختار دادهای درختی هر ورودی یک گره محسوب میگردد. یک گره ممکن است تعدادی فرزند و یک پدر داشته باشد. گره اول پدر ندارد، ریشه و گره فرزند که فرزند ندارد، برگ و گرهای دیگر، گرههای غیربرگ نامیده میشوند.
مثالهایی که از ساختار درختی دادهها وجود دارد شامل موارد زیر است:
Organizational chart: در این ساختار برای مثال در ارتباط بین کارمندان و مدیر، هر کارمند یک مدیر دارد که نشاندهندهی پدر یک کارمند در ساختار درختی است. هر مدیر هم یک کارمند محسوب میگردد.
Threaded discussion: در این ساختار برای مثال در سیستم نظردهی و پاسخها، ممکن است زنجیرهای از نظرات در پاسخ به نظرات دیگر استفاده گردد. در درخت، فرزندان یک گرهی نظر، پاسخهای آن گره هستند.
در این فصل ما از مثال ساختار دوم برای نشان دادن Antipattern و راه حل آن بهره میگیریم.
2.2 Antipattern : همیشه مبتنی بر یکی از والدین
راه حل ابتدایی و ناکارآمد
اضافه نمودن ستون parent_id . این ستون، به ستون نظر در همان جدول ارجاع داده میشود و شما میتوانید برای اجرای این رابطه از قید کلید خارجی استفاده نمایید. پرسوجویی که برای ساخت مثالی که ما در این بحث از آن استفاده میکنیم در ادامه آمده است:
CREATE TABLE Comments ( comment_idSERIAL PRIMARY KEY,
parent_idBIGINT UNSIGNED,
bug_idBIGINT UNSIGNED NOT NULL,
author BIGINT UNSIGNED NOT NULL,
comment_dateDATETIME NOT NULL,
comment TEXT NOT NULL,
FOREIGN KEY (parent_id)REFERENCES Comments(comment_id),
FOREIGN KEY (bug_id) REFERENCES Bugs(bug_id),
FOREIGN KEY(author) REFERENCES Accounts(account_id)
);
مثالی از پرسوجوی فوق را میتوانید در زیر ببینید:
لیست مجاورت :
لیست مجاورت خود میتواند به عنوان یک antipattern در نظر گرفته شود. البته این مطلب از آنجایی نشأت میگیرد که این روش توسط بسیاری از توسعهدهندگان مورد استفاده قرار میگیرد ولی نتوانسته است به عنوان راه حل برای معمولترین وظیفهی خود، یعنی ایجاد پرسوجو بر روی کلیه فرزندان، باشد.
• با استفاده از پرسوجوی زیر میتوان فرزند بلافاصلهی یک "نظر" را برگرداند:
SELECT c1.*, c2.*
FROM Comments c1 LEFT OUTER JOIN Comments c2
ON c2.parent_id = c1.comment_id;
ضعف پرسوجوی فوق این است که فقط میتواند دو سطح از درخت را برای شما برگرداند. در حالیکه خاصیت درخت این است که شما را قادر میسازد بدون هیچ گونه محدودیتی فرزندان و نوههای متعدد (سطوح بیشمار) برای درخت خود تعریف کنید. بنابراین به ازای هر سطح اضافه باید یک join به پرسجوی خود اضافه نمایید. برای مثال اگر پرسوجوی زیر میتواند درختی با چهار سطح برای شما برگرداند ولی نه بیش از آن:
SELECT c1.*, c2.*, c3.*, c4.*
FROM Comments c1 -- 1st level
LEFT OUTER JOIN Comments c2
ON c2.parent_id = c1.comment_id -- 2nd level
LEFT OUTER JOIN Comments c3
ON c3.parent_id = c2.comment_id -- 3rd level
LEFT OUTER JOIN Comments c4
ON c4.parent_id = c3.comment_id; -- 4th level
این پرسوجو به این دلیل که با اضافه شدن ستونهای دیگر، نوهها را سطوح عمیقتری برمیگرداند، پرسوجوی مناسبی نیست. در واقع استفاده از توابع تجمیعی ، مانند COUNT() مشکل میشود.
راه دیگر برای به دست آوردن ساختار یک زیردرخت از لیست مجاورت برای یک برنامه، این است که سطرهای مورد نظر خود را از مجموعه بازیابی نموده و سلسهمراتب مورد نظر را در حافظه بازیابی نماییم و از آن به عنوان درخت استفاده نماییم:
SELECT * FROM Comments WHERE bug_id = 1234;
نگهداری کردن یک درخت با استفاده از لیست مجاورت
البته برخی از عملکردها با لیست مجاورت به خوبی انجام میگیرد. برای مثال اضافه نمودن یک گره (نظر)، مکانیابی مجدد برای یک گره یا یک زیردرخت .
INSERT INTO Comments (bug_id, parent_id, author, comment)
VALUES (1234, 7, 'Kukla' , 'Thanks!' );
بازیابی
دوباره مکان یک نود یا یک زیردرخت نیز آسان است: UPDATE Comments SET parent_id = 3 WHERE comment_id = 6;
با این حال حذف یک گره از یک درخت در این روش پیچیده است. اگر بخواهیم یک زیردرخت را حذف کنید باید چندین پرسوجو برای پیدا کردن تمام نوهها بنویسیم و سپس حذف نوهها را از پایینترین سطح شروع کرده و تا جایی که قید کلید خارجی برقرار شود ادامه دهیم. البته میتوان از کلید خارجی با تنظیم ON DELETE CASCADE استفاده کرد تا این کارها به طور خودکار انجام گیرد.
حال اگر بخواهیم یک نود غیر برگ را حذف کرده یا فرزندان آن را در درخت جابجا کنیم، ابتدا باید parent_id فرزندان آن نود را تغییر داده و سپس نود مورد نظر را حذف میکنیم:
SELECT parent_id FROM Comments WHERE comment_id = 6; -- returns 4
UPDATE Comments SET parent_id = 4 WHERE parent_id = 6;
DELETE FROM Comments WHERE comment_id = 6;
3.2 موارد تشخیص این Antipattern:
سؤالات زیر نشان میدهند که Naive Trees antipattern مورد استفاده قرار گرفته است:
- چه تعداد سطح برای پشتیبانی در درخت نیاز خواهیم داشت؟
- من همیشه از کار با کدی که ساختار دادهی درختی را مدیریت میکند، میترسم
- من باید اسکریپتی را به طور دورهای اجرا نمایم تا سطرهای یتیم موجود در درخت را حذف کند.
4.2 مواردی که استفاده از این Antipattern مجاز است:
قدرت لیست مجاورت در بازیابی پدر یا فرزند مستقیم یک نود میباشد. قرار دادن یک سطر هم در لیست مجاورت کار سادهای است. اگر این عملیات، تمام آن چیزی است که برای انجام کارتان مورد نیاز شما است، بنابراین استفاده از لیست مجاورت میتواند مناسب باشد.
برخی از برندهای RDBMS از افزونههایی پشتیبانی میکنند که قابلیت ذخیرهی سلسله مراتب را در لیست مجاورت ممکن میسازد. مثلا SQL-99، پرسوجوی بازگشتی را تعریف میکند که مثال آن در ادامه آمده است:
WITH CommentTree (comment_id, bug_id, parent_id, author, comment, depth)
AS (
SELECT *, 0 AS depth FROM Comments
WHERE parent_id IS NULL
UNION ALL
SELECT c.*, ct.depth+1 AS depth FROM CommentTreect
JOIN Comments c ON (ct.comment_id = c.parent_id)
)
SELECT * FROM CommentTree WHERE bug_id = 1234;
Microsoft SQL Server 2005، Oracle 11g، IBM DB2 و PostgreSQL 8.4 نیز از پرسوجوی بازگشتی پشتیبانی میکنند.Oracle 9i و 10g از عبارت WITH استفاده میکنند، ولی نه برای پرسوجوهای بازگشتی. در عوض میتوانید از پرسوجوی زیر برای ایجاد پرسوجوی بازگشتی استفاده نمایید:
SELECT * FROM Comments
START WITH comment_id = 9876
CONNECT BY PRIOR parent_id = comment_id;
5.2 راه حل: استفاده از مدلهای درختی دیگر
جایگزینهای دیگری برای ذخیرهسازی دادههای سلسله مراتبی وجود دارد. البته برخی از این راه حلها ممکن است در لحظهی اول پیچیدتر از لیست مجاورت به نظر آیند، ولی برخی از عملیات درخت که در لیست مجاورت بسیار سخت یا ناکارآمد است، را آسانتر میکنند.
شمارش مسیر :
مشکل پرهزینه بودن بازیابی نیاکان یک گره که در روش لیست مجاورت وجود داشت در روش شمارش مسیر به این ترتیب حل شده است: اضافه نمودن یک صفت به هر گره که رشتهای از نیکان آن صفت در آن ذخیره شده است.
در جدول Comments به جای استفاده از parent_id، یک ستون به نام path که توع آن varchar است تعریف شده است. رشتهای که در این ستون تعریف شده است، ترتیبی از فرزندان این سطر از بالا به پایین درخت است. مانند مسیری که در سیستم عامل UNIX، برای نشان دادن مسیر در سیستم فایل استفاده شده است. شما میتوانید از / به عنوان کاراکتر جداکننده استفاده نمایید. دقت کنید برای درست کار کردن پرسوجوها حتما در آخر مسیر هم این کاراکتر را قرار دهید. پرسوجوی تشکیل چنین درختی به شکل زیر است:
CREATE TABLE Comments ( comment_id SERIAL PRIMARY KEY,
path VARCHAR(1000),
bug_id BIGINT UNSIGNED NOT NULL,
author BIGINT UNSIGNED NOT NULL,
comment_date DATETIME NOT NULL,
comment TEXT NOT NULL,
FOREIGN KEY (bug_id) REFERENCES Bugs(bug_id),
FOREIGN KEY (author) REFERENCES Accounts(account_id)
در این روش، هر گره مسیری دارد که شماره خود آن گره هم در آنتهای آن مسیر قرار دارد. این به دلیل درست جواب دادن پرسوجوهای ایجاد شده است.
میتوان نیاکان را با مقایسهی مسیر سطر کنونی با مسیر سطر دیگر به دست آورد. برای مثال برای یافتن نیاکان گره (نظر) شمارهی 7 که مسیر آن 1/4/6/7/ میباشد، میتوان چنین نوشت:
SELECT * FROM Comments AS c
WHERE '1/4/6/7/' LIKE c.path || '%' ;
این پرسوجو الگوهایی را مییابد که از مسیرهای 1/4/6/%، 1/4/% و 1/% نشأت میگیرد.
همچنین فرزندان (نوههای) یک گره، مثلا گرهی 4 را که مسیرش 1/4/ است را میتوان با پرسوجوی زیر یافت:
SELECT * FROM Comments AS c
WHERE c.path LIKE '1/4/' || '%' ;
الگوی 1/4/% با مسیرهای 1/4/5/، 1/4/6/ و 1/4/6/7/ تطابق مییابد.
همچنین میتوان پرسوجوهای دیگری را نیز در این مسیر به سادگی انجام داد؛ مانند محاسبهی مجموع هزینهی گرهها در یک زیردرخت یا شمارش تعداد گرهها.
اضافه نمودن یک گره هم مانند ساختن خود مدل است. میتوان یک گرهی غیر برگ را بدون نیاز به اصلاح هیچ سطری اضافه نمود. برای این کار مسیر را را از گرهی پدر کپی کرده و در انتها شمارهی خود گره را به آن اضافه میکنیم.
از مشکلات این روش میتوان به عدم توانایی پایگاه دادهها در تحمیل این نکته که مسیر یک گره درست ایجاد شده است و یا تضمین وجود گرهای در مسیری خاص، اشاره نمود. همچنین نگهداری رشتهی مسیر یک گره مبتنی بر کد برنامه است و اعتبارسنجی آن کاری هزینهبر است. این رشته اندازهای محدود دارد و درختهایی با عمق نامحدود را پشتیبانی نمیکند.
مجموعههای تودرتو :
مجموعههای تودرتو، اطلاعات را با هر گرهای که مربوط به مجموعهای از نوههایش است، به جای این که تنها مربوط به یک فرزند بلافصلش باشد، ذخیره میکنند.
این اطلاعات میتوانند به وسیلهی هر گرهای که در درخت با دو شمارهی nsleft و nsright ذخیره شده، نمایش داده شوند:
CREATE TABLE Comments ( comment_id SERIAL PRIMARY KEY,
nsleft INTEGER NOT NULL,
nsright INTEGER NOT NULL,
bug_id BIGINT UNSIGNED NOT NULL,
author BIGINT UNSIGNED NOT NULL,
comment_date DATETIME NOT NULL,
comment TEXT NOT NULL,
FOREIGN KEY (bug_id) REFERENCES Bugs (bug_id),
FOREIGN KEY (author) REFERENCES Accounts(account_id)
);
شمارهی سمت چپ یک گره از تمام شمارههای سمت چپ فرزندان آن گره کوچکتر و شمارهی سمت راست آن گره از تمام شمارههای سمت راست آن گره بزرگتر است. این شمارهها هیچ ارتباطی به comment_id مربوط به آن گره ندارند.
یک راه حل ساده برای تخصیص این شمارهها به گرهها این است که از سمت چپ یک گره آغاز میکنیم و اولین شماره را اختصاص میدهیم و به همین به گرهای سمت چپ فرزندان میآییم و شمارهها را به صورت افزایشی به سمت چپ آنها نیز اختصاص میدهیم. سپس در ادامه به سمت راست آخرین نود رفته و از آن جا به سمت بالا میآییم و به همین ترتیب به صورت بازگشتی تخصیص شمارهها را ادامه میدهیم.
با اختصتص شمارهها به هر گره، میتوان از آنها برای یافتن نیاکان و فرزندان آن گره بهره جست. برای مثال برای بازیابی گرهی 4 و فرزندان (نوههای) آن باید دنبال گرههایی باشیم که شمارههای آن گرهها بین nsleft و nsright گرهی شماره4 باشد:
SELECT c2.* FROM Comments AS c1
JOIN Comments as c2
ON c2.nsleft BETWEEN c1.nsleft AND c1.nsright
WHERE c1.comment_id = 4;
همچنین میتوان گرهی شمارهی 6 و نیاکان آن را با دنبال نمودن گرههایی به دست آورد که شمارههای آنها در محدودهی شمارهی گرهی 6 باشد:
SELECT c2.*
FROM Comments AS c1
JOIN Comment AS c2
ON c1.nsleft BETWEEN c2.nsleft AND c2.nsright
WHERE c1.comment_id = 6;
یک مزیت مهم روش مجموعهای تودرتو، این است که هنگامی که یک گره را حذف میکنیم، نوههای آن به طور مستقیم به عنوان فرزندان پدر گرهی حذف شده تلقی میشوند.
برخی از پرسوجوهایی که در روش لیست مجاورت ساده بودند، مانند بازیابی فرزند یا پدر بلافصل، در روش مجموعههای تودرتو پیچیدهتر میباشند. برای مثال برای یافتن پدر بلافصل گرهی شمارهی 6 باید چنین نوشت:
SELECT parent.* FROM Comment AS c
JOIN Comment AS parent
ON c.nsleft BETWEEN parent.nsleft AND parent.nsright
LEFT OUTER JOIN Comment AS in_between
ON c.nsleft BETWEEN in_between.nsleft AND in_between.nsright
AND in_between.nsleft BETWEEN parent.nsleft AND parent.nsright
WHERE c.comment_id = 6
AND in_between.comment_id IS NULL;
دستکاری درخت، اضافه، حذف و جابجا نمودن گرهها در آن درروش مجموعههای تودرتو از مدلهای دیگر پیچیدهتر است. هنگامی که یک گرهی جدید را اضافه میکنیم، باید تمام مقادیر چپ و راست بزرگتر از مقدار سمت چپ گرهی جدید را مجددا محاسبه کنیم؛ که این شامل برادر سمت راست گرهی جدید، نیاکان آن و برادر سمت راست نیاکان آن میباشد. همچنین اگر گرهی جدید به عنوان گرهی غیربرگ اضافه شده باشد، شامل فرزندان آن هم میشود. برای مثال اگر بخواهیم گرهی جدیدی به گرهی 5 اضافه نماییم، باید چنین بنویسیم:
-- make space for NS values 8 and 9
UPDATE Comment
SET nsleft = CASE WHEN nsleft >= 8 THEN nsleft+2 ELSE nsleft END,
nsright = nsright+2
WHERE nsright >= 7;
-- create new child of comment #5, occupying NS values 8 and 9
INSERT INTO Comment (nsleft, nsright, author, comment)
VALUES (8, 9, 'Fran' , 'Me too!' );
تنها مزیت این روش نسبت به روشهای قبلی سادهتر و سریعتر شدن ایجاد پرسوجوها برای پیدا کردن فرزندان یا پدران یک درخت است. اگر هدف استفاده از درخت شامل اضافه نمودن متعدد گرهها است، مجموعههای تودرتو انتخاب خوبی نیست.
Closure Table
راه حل closure table روشی دیگر برای ذخیرهی سلسهمراتبی است. این روش علاوه بر ارتباطات مستقیم پدر- فرزندی، تمام مسیرهای موجود در درخت را ذخیره میکند.
این روش علاوه بر داشتن یک جدول نظرها، یک جدول دیگر به نام TreePaths با دو ستون دارد که هر کدام از این ستونها یک کلید خارجی به جدولComment هستند:
CREATE TABLE Comments ( comment_id SERIAL PRIMARY KEY,
bug_id BIGINT UNSIGNED NOT NULL,
author BIGINT UNSIGNED NOT NULL,
comment_date DATETIME NOT NULL,
comment TEXT NOT NULL,
FOREIGN KEY (bug_id) REFERENCES Bugs(bug_id),
FOREIGN KEY (author) REFERENCES Accounts(account_id)
);
CREATE TABLE TreePaths (
ancestor BIGINT UNSIGNED NOT NULL,
descendant BIGINT UNSIGNED NOT NULL,
PRIMARY KEY(ancestor, descendant),
FOREIGN KEY (ancestor) REFERENCES Comments(comment_id),
FOREIGN KEY (descendant) REFERENCES Comments(comment_id)
);
به جای استفاده از جدول Comments برای ذخیرهی اطلاعات مربوط به یک درخت از جدول TreePath استفاده میکنیم. به ازای هر یک جفت گره در این درخت یک سطر در جدول ذخیره میشود که ارتباط پدر فرزندی را نمایش میدهد و الزاما نباید این دو پدر فرزند بلافصل باشد. همچنین یک سطر هم به ازای ارتباط هر گره با خودش به جدول اضافه میگردد.
پرسوجوهای بازیابی نیاکان و فرزندان (گرهها) از طریق جدول TreePaths سادهتر از روش مجموعههای تودرتو است. مثلا برای بازیابی فرزندان (نوههای) گرهی شمارهی 4، سطرهایی که نیاکان آنها 4 است را به دست میآوریم:
SELECT c.* FROM Comments AS c
JOIN TreePaths AS t ON c.comment_id = t.descendant
WHERE t.ancestor = 4;
برای به دست آوردن نیاکان گرهی شمارهی 6، سطرهایی از جول TreePaths را به دست میآوریم که فرزندان آنها 6 باشد:
SELECT c.*
FROM Comments AS c
JOIN TreePaths AS t ON c.comment_id = t.ancestor
WHERE t.descendant = 6;
برای اضافه کردن گرهی جدید، برای مثال به عنوان فرزند گرهی شمارهی 5، ابتدا سطری که به خود آن گره برمیگردد را اضافه میکنیم، سپس یک کپی از سطوری که در جدول TreePaths، به عنوان فرزندان (نوههای) گرهی شماره5 هستند (که شامل سطری که به خود گرهی 5 به عنوان فرزند اشاره میکند) به جدول اضافه نموده و فیلد descendant آن را با شمارهی گرهی جدید جایگزین میکنیم:
INSERT INTO TreePaths (ancestor, descendant) SELECT t.ancestor, 8
FROM TreePaths AS t
WHERE t.descendant = 5
UNION ALL
SELECT 8, 8;
در این جا میتوان به اهمیت ارجاع یک گره به خودش به عنوان پدر (یا فرزند) پی برد.
برای حذف یک گره، مثلا گرهی شمارهی 7، تمام سطوری که فیلد descendant آنها در جدول TreePaths برابر با 7 است حذف میکنیم:
DELETE FROM TreePaths WHERE descendant = 7;
برای حذف یک زیردرخت کامل، برای مثال گرهی شمارهی 4 و فرزندان (نوههای) آن، تمام سطوری که در جدول TreePaths دارای فیلد descendant با مقدار 4 هستند، حذف میکنیم. علاوه بر این باید نودهایی که به عنوان descendant به فیلد descendant گرهی 4، ارجاع داده میشوند نیز باید حذف گردد:
برای جابجایی یک زیردرخت از مکانی به مکان دیگری در درخت، سطرهایی که ancestor گرهی بالایی زیردرخت را برمیگردانند و فرزندان آن گره را حذف میکنیم. برای مثال برای جابجایی گرهی شمارهی 6 به عنوان فرزند گرهی شمارهی 4 و قرار دادن آن به عنوان فرزند گرهی شمارهی 3، این چنین عمل میکنیم. فقط باید حواسمان جمع باشد سطری که گرهی شمارهی 6 به خودش ارجاع داده است را حذف نکنیم:
لازم به ذکر است در اینجا ستون سوم (Query Child) به معنای پرسوجوهایی است که با فرزندان کار میکند و ستون چهارم (Query Tree) به معنای پرسوجوهایی است که با کل درخت کار میکنند، میباشد.