Sử dụng Adjacency List Model (ALM) để quản lý dữ liệu phân cấp trong MySQL
Trong bài viết này, chúng ta sẽ học cách sử dụng Adjacency List Model (ALM) để quản lý dữ liệu phân cấp.
Giới thiệu về Adjacency List Model (ALM)
Như chúng ta biết thì dữ liệu phân cấp ở mọi nơi. Nó có thể là blog categories, phân cấp các sản phẩm hoặc là cấu trúc của tổ chức.
Có nhiều cách để quản lý dữ liệu phân cấp trong MySQL nhưng có lẽ ALM là giải pháp đơn giản nhất. Chính vì sự đơn giản mà ALM là lựa chọn phổ biến bởi hầu hết các developers và các nhà quản trị cơ sở dữ liệu.
Trong ALM, mỗi node có 1 pointer trỏ tới chính parent của nó. Top node sẽ không có parent. Để rõ hơn hãy xem các loại sản phẩm điện tử ở hình sau:

Có một vài thuật ngữ bạn nên làm quen trước khi thực sự làm việc với ALM:
- Electronicslà top node hoặc root node.
- Các node Laptops, Cameras & photo, Phones & Accessorieslà node con của nodeElectronics.
- Các node là lá sẽ không có node child như Laptops, PC, Android, iOS, … trong khi các node không phải là node lá thì sẽ có ít nhất 1 node child.
- Children và grandchildren của 1 node được gọi là descendants (con cháu). Và parents, grandparents, etc., của 1 node được gọi là ancestors (tổ tiên).
Dựa theo sơ đồ trên, chúng ta có thể tạo 1 bảng category với 3 columns id, title và parent_id:
CREATE TABLE category (
  id int(10) unsigned NOT NULL AUTO_INCREMENT,
  title varchar(255) NOT NULL,
  parent_id int(10) unsigned DEFAULT NULL,
  PRIMARY KEY (id),
  FOREIGN KEY (parent_id) REFERENCES category (id) 
    ON DELETE CASCADE ON UPDATE CASCADE
);
Mỗi row sẽ là 1 node xác định bởi cột id. Cột parent_id được gán làm foreign key của chính bảng category luôn. Nó hoạt động như 1 con trỏ trỏ tới cột id.
Inserting data
Root node sẽ không có parent nên là giá trị cột parent_id sẽ là NULL. Các node khác phải có ít nhất 1 parent.
Thêm mới root node:
INSERT INTO category(title,parent_id) 
VALUES('Electronics',NULL);
Để thêm mới một non-root node, chúng ta chỉ cần gán parent_id chính là giá trị id của node cha. Ví dụ, parent_id của các node Laptop & PC, Cameras & Photos và Phone & Accessories sẽ là 1:
INSERT INTO category(title,parent_id) 
VALUES('Laptops & PC',1);
 
INSERT INTO category(title,parent_id) 
VALUES('Laptops',2);
INSERT INTO category(title,parent_id) 
VALUES('PC',2);
 
INSERT INTO category(title,parent_id) 
VALUES('Cameras & photo',1);
INSERT INTO category(title,parent_id) 
VALUES('Camera',5);
 
INSERT INTO category(title,parent_id) 
VALUES('Phones & Accessories',1);
INSERT INTO category(title,parent_id) 
VALUES('Smartphones',7);
 
INSERT INTO category(title,parent_id) 
VALUES('Android',8);
INSERT INTO category(title,parent_id) 
VALUES('iOS',8);
INSERT INTO category(title,parent_id) 
VALUES('Other Smartphones',8);
 
INSERT INTO category(title,parent_id) 
VALUES('Batteries',7);
INSERT INTO category(title,parent_id) 
VALUES('Headsets',7);
INSERT INTO category(title,parent_id) 
VALUES('Screen Protectors',7);
Tìm root node
Vì root node không có parent nên có parent_id là NULL.
SELECT
    id, title
FROM
    category
WHERE
    parent_id IS NULL;

Tìm node con
Truy vấn sau sẽ lấy các children của root node:
SELECT
    id, title
FROM
    category
WHERE
    parent_id = 1;

Tìm các node lá
Các node lá là các node không có children.
SELECT
    c1.id, c1.title
FROM
    category c1
        LEFT JOIN
    category c2 ON c2.parent_id = c1.id
WHERE
    c2.id IS NULL;

Truy vấn toàn bộ category
Chúng ta sẽ sử dụng recursive common table expression (CTE) để lấy toàn bộ dữ liệu category.
Lưu ý CTE chỉ được sử dụng với phiên bản MySQL >= 8.0
WITH RECURSIVE category_path (id, title, path) AS
(
  SELECT id, title, title as path
    FROM category
    WHERE parent_id IS NULL
  UNION ALL
  SELECT c.id, c.title, CONCAT(cp.path, ' > ', c.title)
    FROM category_path AS cp JOIN category AS c
      ON cp.id = c.parent_id
)
SELECT * FROM category_path
ORDER BY path;

Truy vấn sub-category
Giả sử chúng ta muốn lấy dữ liệu của category Phone & Accessories với id = 7.
WITH RECURSIVE category_path (id, title, path) AS
(
  SELECT id, title, title as path
    FROM category
    WHERE parent_id = 7
  UNION ALL
  SELECT c.id, c.title, CONCAT(cp.path, ' > ', c.title)
    FROM category_path AS cp JOIN category AS c
      ON cp.id = c.parent_id
)
SELECT * FROM category_path
ORDER BY path;

Truy vấn theo nhánh cây trong cây category
Giả sử chúng ta muốn truy vấn từ dưới lên trên theo nhánh iOS tới Electronics, chúng ta sẽ dùng truy vấn sau:
WITH RECURSIVE category_path (id, title, parent_id) AS
(
  SELECT id, title, parent_id
    FROM category
    WHERE id = 10 -- child node
  UNION ALL
  SELECT c.id, c.title, c.parent_id
    FROM category_path AS cp JOIN category AS c
      ON cp.parent_id = c.id
)
SELECT * FROM category_path;

Tính toán level của mỗi node
Giả sử level của root node là 0, mỗi node phía dưới sẽ bằng chính level của node cha cộng thêm 1.
WITH RECURSIVE category_path (id, title, lvl) AS
(
  SELECT id, title, 0 lvl
    FROM category
    WHERE parent_id IS NULL
  UNION ALL
  SELECT c.id, c.title,cp.lvl + 1
    FROM category_path AS cp JOIN category AS c
      ON cp.id = c.parent_id
)
SELECT * FROM category_path
ORDER BY lvl;

Xóa node và các các node con cháu
Để xóa node và tất cả các node con cháu thì chúng ta chỉ cần xóa node đó và tất cả các node con cháu sẽ tự động bị xóa bởi DELETE CASCADE do ràng buộc foreign key.
Ví dụ, xóa node Laptops & PC và các node children ( Laptops, PC), chúng ta sử dụng truy vấn:
DELETE FROM category 
WHERE
    id = 2;
Xóa node và đẩy con cháu lên
Để xóa node không phải là node lá và đấy con cháu của nó lên chúng ta thực hiện 2 bước:
- Đầu tiên cập nhật parent_idcủa children của node bằng vớiidcủa node parent mới.
- Sau đó, xóa node.
Ví dụ, xóa node Smartphones và đẩy node con như là  Android, iOS, Other Smartphones lên:
Đầu tiên, cập nhật parent_id cho tất cả các children của Smartphones:
UPDATE category 
SET 
    parent_id = 7 -- Phones & Accessories
WHERE
    parent_id = 5; -- Smartphones
Thứ 2, xóa node Smartphones:
DELETE FROM category 
WHERE
    id = 8;
Chúng ta nên gộp 2 truy vấn vào thành 1 transaction:
BEGIN;
 
UPDATE category 
SET 
    parent_id = 7 
WHERE 
    parent_id = 5;
 
DELETE FROM category 
WHERE 
    id = 8;
 
COMMIT;
Di chuyển 1 sub-category
Rất đơn giản, chỉ cần cập nhật giá trị parent_id của top node của sub-category là xong. Ví dụ, di chuyển Cameras & photo sang làm children của Phone and Accessories, chúng ta sử dụng lệnh sau:
UPDATE category 
SET 
    parent_id = 7
WHERE
    id = 5;
Trong bài viết này, chúng ta đã học cách sử dụng adjacency list model (ALM) để quản lý dữ liệu phân cấp.
Hãy like, share & comment để cùng nhau học hỏi nhé!