Skip to content

Adjacency list model

2009 May 19
by Richard Knop

Managing hierarchical data in relational databases (such as MySQL) is a troublesome task because relational databases were not designed to store hierarchical data. MySQL tables are just flat files with no representation of a parent-child relationship. There are two methods that are usually used to store hierarchical data in MySQL:

In this post I will show you how to use the adjacency list model and how to implement it in PHP. Look at the picture to get a better idea about what the adjacency model looks like:

Adjacency list model

As you can see the hierarchy is created by parent-child relationships. A typical example would be categories in WordPress where every category can have zero or one parent.

How is this achieved in the database? The table just need to have an additional column with the id of the parent. In addition, this column should allow NULL values. For instance:

CREATE TABLE categories (
id INT NOT NULL AUTO_INCREMENT,
name VARCHAR(255) NOT NULL,
parent_id INT NULL DEFAULT NULL,
FOREIGN KEY (parent_id) REFERENCES categories(id) ON UPDATE CASCADE,
INDEX (parent_id),
PRIMARY KEY (id)
) ENGINE = INNODB;

Let’s populate the table with some content we can work with:

INSERT INTO categories (id, name, parent_id) VALUES
(NULL, 'Relational databases', NULL),
(NULL, 'SQLite', 1),
(NULL, 'MySQL', 1),
(NULL, 'PostgreSQL', 1),
(NULL, 'PDO', 3),
(NULL, 'Transactions', 3);

Adding, editing or removing categories from this table is easy. The query to select the full tree is more complex though:

SELECT t0.id AS lvl0_id, t0.name AS lvl0_name,
t1.id AS lvl1_id, t1.name as lvl1_name,
t2.id AS lvl2_id, t2.name as lvl2_name,
t3.id AS lvl3_id, t3.name as lvl3_name,
t4.id AS lvl4_id, t4.name as lvl4_name
FROM categories AS t0
LEFT JOIN categories AS t1 ON t1.parent_id = t0.id
LEFT JOIN categories AS t2 ON t2.parent_id = t1.id
LEFT JOIN categories AS t3 ON t3.parent_id = t2.id
LEFT JOIN categories AS t4 ON t4.parent_id = t3.id
ORDER BY t0.id, t1.id, t2.id, t3.id, t4.id;

PHP implementation

Now the intriguing part. You will usually want to display the tree in a nested unordered XHTML list. There are many ways to do it, here is my approach:

$user = 'root';
  1. $pass = '';
  2. $conn = new PDO('mysql:host=localhost;dbname=test', $user, $pass);
  3.  
  4. $sql ='SELECT t0.id AS lvl0_id, t0.name AS lvl0_name,
  5. t1.id AS lvl1_id, t1.name as lvl1_name,
  6. t2.id AS lvl2_id, t2.name as lvl2_name,
  7. t3.id AS lvl3_id, t3.name as lvl3_name,
  8. t4.id AS lvl4_id, t4.name as lvl4_name
  9. FROM categories AS t0
  10. LEFT JOIN categories AS t1 ON t1.parent_id = t0.id
  11. LEFT JOIN categories AS t2 ON t2.parent_id = t1.id
  12. LEFT JOIN categories AS t3 ON t3.parent_id = t2.id
  13. LEFT JOIN categories AS t4 ON t4.parent_id = t3.id
  14. ORDER BY t0.id, t1.id, t2.id, t3.id, t4.id';
  15. $stmt = $conn->query($sql);
  16.  
  17. $categories = array();
  18. $pool = array();
  19. while ($row = $stmt->fetchObject()) {
  20.     if (in_array($row->lvl0_id, $pool) === false && isset($row->lvl0_name)) {
  21.         $c = array('id' => $row->lvl0_id,
  22.                    'name' => $row->lvl0_name,
  23.                    'level' => 0);
  24.         $categories[] = $c;
  25.     }
  26.     if (in_array($row->lvl1_id, $pool) === false && isset($row->lvl1_name)) {
  27.         $c = array('id' => $row->lvl1_id,
  28.                    'name' => $row->lvl1_name,
  29.                    'level' => 1);
  30.         $categories[] = $c;
  31.     }
  32.     if (in_array($row->lvl2_id, $pool) === false && isset($row->lvl2_name)) {
  33.         $c = array('id' => $row->lvl2_id,
  34.                    'name' => $row->lvl2_name,
  35.                    'level' => 2);
  36.         $categories[] = $c;
  37.     }
  38.     if (in_array($row->lvl3_id, $pool) === false && isset($row->lvl3_name)) {
  39.         $c = array('id' => $row->lvl3_id,
  40.                    'name' => $row->lvl3_name,
  41.                    'level' => 3);
  42.         $categories[] = $c;
  43.     }
  44.     if (in_array($row->lvl4_id, $pool) === false && isset($row->lvl4_name)) {
  45.         $c = array('id' => $row->lvl4_id,
  46.                    'name' => $row->lvl4_name,
  47.                    'level' => 4);
  48.         $categories[] = $c;
  49.     }
  50.     $pool[] = $row->lvl0_id;
  51.     $pool[] = $row->lvl1_id;
  52.     $pool[] = $row->lvl2_id;
  53.     $pool[] = $row->lvl3_id;
  54.     $pool[] = $row->lvl4_id;
  55. }
  56.  
  57. echo '<ul>', "\n";
  58. $count = count($categories);
  59. if ($count == 1) {
  60.     echo '<li>', $categories[0]['name'], '</li>', "\n";
  61. } else {
  62.     $i = 0;
  63.     while (isset($categories[$i])) {
  64.         echo '<li>', $categories[$i]['name'];
  65.         if ($i < $count - 1) {
  66.             if ($categories[$i + 1]['level'] > $categories[$i]['level'])
  67.             {
  68.                 echo '<ul>', "\n";
  69.             }
  70.             else {
  71.                 echo '</li>', "\n";
  72.             }
  73.             if ($categories[$i + 1]['level'] < $categories[$i]['level']) {
  74.                 echo str_repeat('</ul></li>' . "\n",
  75.                                 $categories[$i]['level'] - $categories[$i + 1]['level']);
  76.             }
  77.         } else {
  78.             echo '<li>', "\n";
  79.             echo str_repeat('</ul></li>' . "\n", $categories[$i]['level']);
  80.         }
  81.     $i++;
  82.     }
  83. }
  84. echo '</ul>', "\n";

Disadvantages

  • limited hierarchy levels (not a big problem as you can easily modify the SQL query for any amount of levels)
  • complex and long-winded PHP implementation

Advantages

  • fast (you need only a single SQL query – much much faster than the usual recursion method)
  • very simple hierarchy modifications (you need to update only a single table row each time – not the whole table as in the nested set model)
14 Responses leave one →
  1. ficuscr permalink
    August 26, 2009

    Recursion? Recursion?

  2. August 26, 2009

    Recursion is simply wrong for this kind of thing. It is a major performance issue and should be avoided at all costs (unless I misunderstood you and you meant something else than selecting the whole tree with recursion database queries).

  3. December 12, 2009

    For me, creating the ultimate adjacency model PHP/MySQL solution is one of the holy grail projects I will tackle before I die. The SQL you gave above doesn’t work for me. I get zero rows. I completely disagree with Richard Knop. Using a recursive function is the ideal way to handle an arbitrarily deep nested set. You ought to be able to make a single query and write a single recursive function that returns a nested array, but I’ve tried many times and it’s a lot harder than it seems.

    Here is some alternate SQL that I’ve used in the past but is nowhere near as elegant as the solution I really want to create. It uses MySQL’s WHERE … IN ( ) and sub-select/subquery syntax to find categories in the top level, then level 1, … one SQL query per level is required. Each deeper level requires an additional layer in the sub-select, but it’s pretty easy to follow, and each subquery is based on the previous level.

    # LEVEL 0 #################################

    SELECT
    t0.id as id0,
    t0.name as name0,
    FROM category t0
    WHERE
    t0.parent_id IS NULL
    ORDER BY
    t0.ord

    # LEVEL 1 #################################

    SELECT
    t1.id as id1,
    t1.parent_id as parent1,
    t1.name as name1,
    FROM caegory t1
    WHERE
    t1.parent_id IN
    (
    SELECT
    t0.id as id0
    FROM
    category t0
    WHERE
    t0.parent_pid IS NULL
    )
    ORDER BY
    t1.ord

    # LEVEL 2 #################################

    SELECT
    t2.id as id2 ,
    t2.parent_id as parent2 ,
    t2.name as name2
    FROM
    category AS t2
    WHERE
    t2.parent_id
    IN (
    SELECT t1.id as id1
    FROM category t1
    WHERE t1.parent_id IN
    (
    SELECT t0.id as id0
    FROM category t0
    WHERE t0.parent_id IS NULL
    )
    )

  4. December 12, 2009

    @Geoff: I just tested it in phpmyadmin and it works fine here. Are you sure your MySQL installation allows InnoDB tables? Because I used the InnoDB format in the CREATE TABLE query. Change that to MyISAM and it should work the same.

  5. December 12, 2009

    I’m using a MyISAM table and I don’t have a foreign key explicitly defined as such; just

    CREATE TABLE category (
    id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
    category VARCHAR(100) NOT NULL,
    parent_id INT UNSIGNED NOT NULL ,
    INDEX( parent_id )
    )

    but InnoDB & Foreign Key constraints are not required for adjacency SQL?
    Don’t you only get rows that have all 4 levels deep?

  6. December 12, 2009

    OK, I just tried it with MyISAM:

    CREATE TABLE categories (
    id INT NOT NULL AUTO_INCREMENT,
    name VARCHAR(255) NOT NULL,
    parent_id INT NULL DEFAULT NULL,
    INDEX (parent_id),
    PRIMARY KEY (id)
    ) ENGINE = MYISAM;

    Then:

    INSERT INTO categories (id, name, parent_id) VALUES
    (NULL, ‘Relational databases’, NULL),
    (NULL, ‘SQLite’, 1),
    (NULL, ‘MySQL’, 1),
    (NULL, ‘PostgreSQL’, 1),
    (NULL, ‘PDO’, 3),
    (NULL, ‘Transactions’, 3);

    Then:

    SELECT t0.id AS lvl0_id, t0.name AS lvl0_name,
    t1.id AS lvl1_id, t1.name as lvl1_name,
    t2.id AS lvl2_id, t2.name as lvl2_name,
    t3.id AS lvl3_id, t3.name as lvl3_name,
    t4.id AS lvl4_id, t4.name as lvl4_name
    FROM categories AS t0
    LEFT JOIN categories AS t1 ON t1.parent_id = t0.id
    LEFT JOIN categories AS t2 ON t2.parent_id = t1.id
    LEFT JOIN categories AS t3 ON t3.parent_id = t2.id
    LEFT JOIN categories AS t4 ON t4.parent_id = t3.id
    ORDER BY t0.id, t1.id, t2.id, t3.id, t4.id;

    And it works as expected.

    Yes, the point of this approach is that it has limited number of levels, so it can only go as deep as the number of levels defined in the SELECT query (I defined 4 levels in this example but you can define 10 or more if you need). But it doesn’t use a recursion which means it is much faster and overall more elegant solution.

    If you have a hierarchy with 10+ levels I would advise against using the adjacency list model. In that case use the nested set model.

  7. December 12, 2009

    I’ve always thought that the easy way out was just doing SELECT * FROM category and passing the result to a PHP class that handles sorting out how everything is nested, having functions to getNestedArray( ), getDepth( $id ) and getParent( $id ) but the SQL purist in me wants to figure out a way to let the DB do as much as it can before PHP takes over.

    What I’m looking for is SQL that renders something like this:

    1 | Top 1 | Null | Null
    2 | Top 1 | Sub 1 | Null
    3 | Top 1 | Sub 1 | Third 1
    4 | Top 1 | Sub 2 | Null
    5 | Top 2 | Null | Null
    6 | Top 2 | Sub 3 | Null

    … just like you would logically think of how a menu would be constructed.

    Great blog, by the way! Lots of really useful stuff here. Keep going.

  8. December 12, 2009

    You’re right, your SQL does work! Sorry about that – In my CMS I have an extra `status` field that I was checking in each table to force only ‘active’ categories to appear. Even though all my categories are active, the addition of a WHERE clause was throwing it all off. I think if I allow NULL on that column it may work again. Although, the total number of rows, and the order of the rows in the output aren’t what I was hoping for, either. Sigh.

    For what it’s worth, I think @ficuscr was after a PHP function that uses recursion to return a nested structure from a SELECT * scenario like I described in my previous comment. Basically a findChildren( $cat ) method that calls itself to ouput something like:

    $cats = array (
    ‘id’ => 1 ,
    ‘name’ => ‘Top 1′,
    ‘children’ => array (
    ‘id’ => 2,
    ‘name’ => ‘Sub 1′,
    ‘children’ => array(
    ‘id’ => 3
    ‘name’ => ‘Third 1′,
    ‘children => array()
    )
    )
    )

    If you have that, then you can use recursive function to output a nested ul li as well. The benefit to using recursive functions is that they’d work to any arbitrary depth.

  9. December 12, 2009

    Yeah that’s an alternative. You can just fetch all rows from the table and do all computing in PHP. I believe there are robust PHP classes floating around the Web with just that purpose.

    But when I wrote this I was working on a simple CMS application where the client would hardly ever create more than 1 level of hierarchy (so 4 levels were more than enough). And many applications are like that – with just one or two levels of hierarchy.

    If you need unlimited levels then, of course, either use the nested set model or the adjacency list model the way you are suggesting.

    Thanks for comments :)

  10. December 27, 2009

    Perfect!! Thank you

Trackbacks and Pingbacks

  1. Nested set model | Richard Knop's Blog
  2. Back online | Zend Framework Blog Written By Richard Knop
  3. Anonymous
  4. Merge sort PHP implementation | Zend Framework Blog

Leave a Reply

Note: You can use basic XHTML in your comments. Your email address will never be published.

Subscribe to this comment feed via RSS